198 lines
7.3 KiB
JavaScript
198 lines
7.3 KiB
JavaScript
import fs from 'node:fs'
|
|
import dns from 'node:dns/promises'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { simpleParser } from 'mailparser'
|
|
import nodemailer from 'nodemailer'
|
|
import { SMTPServer } from 'smtp-server'
|
|
import { saveMessage } from './store.mjs'
|
|
|
|
const domain = (process.env.MAIL_DOMAIN || 'wpyw.site').toLowerCase()
|
|
const account = (process.env.MAIL_USER || `admin@${domain}`).toLowerCase()
|
|
const password = process.env.MAIL_PASSWORD
|
|
const hostname = process.env.MAIL_HOSTNAME || `mail.${domain}`
|
|
const serverDir = path.dirname(fileURLToPath(import.meta.url))
|
|
const dataDir = process.env.MAIL_DATA_DIR || path.join(serverDir, 'data')
|
|
|
|
if (!password) {
|
|
throw new Error('MAIL_PASSWORD is required. Copy server/.env.example to server/.env and set it before starting.')
|
|
}
|
|
|
|
function addressOf(value) {
|
|
if (!value) return ''
|
|
if (typeof value === 'string') return value.toLowerCase()
|
|
if (Array.isArray(value)) return addressOf(value[0])
|
|
if (value.value?.[0]?.address) return value.value[0].address.toLowerCase()
|
|
if (value.address) return value.address.toLowerCase()
|
|
return ''
|
|
}
|
|
|
|
function addressList(value) {
|
|
if (!value) return []
|
|
if (typeof value === 'string') return [value]
|
|
if (Array.isArray(value)) return value.flatMap(addressList)
|
|
if (value.value) return value.value.map((item) => item.address || item.name).filter(Boolean)
|
|
return value.address ? [value.address] : []
|
|
}
|
|
|
|
function isLocalAddress(address) {
|
|
return address.toLowerCase().endsWith(`@${domain}`)
|
|
}
|
|
|
|
async function storeIncoming(parsed, envelopeRecipients = []) {
|
|
const attachments = []
|
|
for (const attachment of parsed.attachments || []) {
|
|
const filename = `${Date.now()}-${attachment.filename || 'attachment.bin'}`.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
const attachmentDir = path.join(dataDir, 'attachments')
|
|
await fs.promises.mkdir(attachmentDir, { recursive: true })
|
|
await fs.promises.writeFile(path.join(attachmentDir, filename), attachment.content)
|
|
attachments.push({ filename: attachment.filename || filename, storedAs: filename, contentType: attachment.contentType })
|
|
}
|
|
|
|
const to = envelopeRecipients.length ? envelopeRecipients : addressList(parsed.to)
|
|
await saveMessage({
|
|
folder: 'inbox',
|
|
from: parsed.from?.text || addressOf(parsed.from),
|
|
to: to.join(', '),
|
|
subject: parsed.subject || '(无主题)',
|
|
text: parsed.text || '',
|
|
html: typeof parsed.html === 'string' ? parsed.html : '',
|
|
date: parsed.date?.toISOString() || new Date().toISOString(),
|
|
unread: true,
|
|
attachments,
|
|
messageId: parsed.messageId || '',
|
|
})
|
|
}
|
|
|
|
async function parseAndRoute(stream, session, submission) {
|
|
const parsed = await simpleParser(stream)
|
|
const envelopeRecipients = session.envelope.rcptTo.map((item) => item.address)
|
|
|
|
if (!submission) {
|
|
await storeIncoming(parsed, envelopeRecipients)
|
|
return
|
|
}
|
|
|
|
const recipients = envelopeRecipients.length ? envelopeRecipients : addressList(parsed.to)
|
|
if (!recipients.length) throw new Error('No recipients in submitted message')
|
|
await sendMail({
|
|
to: recipients,
|
|
subject: parsed.subject || '(无主题)',
|
|
text: parsed.text || '',
|
|
html: typeof parsed.html === 'string' ? parsed.html : undefined,
|
|
})
|
|
await saveMessage({
|
|
folder: 'sent',
|
|
from: account,
|
|
to: recipients.join(', '),
|
|
subject: parsed.subject || '(无主题)',
|
|
text: parsed.text || '',
|
|
html: typeof parsed.html === 'string' ? parsed.html : '',
|
|
date: parsed.date?.toISOString() || new Date().toISOString(),
|
|
unread: false,
|
|
messageId: parsed.messageId || '',
|
|
})
|
|
}
|
|
|
|
function tlsOptions() {
|
|
const keyPath = process.env.SMTP_TLS_KEY
|
|
const certPath = process.env.SMTP_TLS_CERT
|
|
if (!keyPath || !certPath || !fs.existsSync(keyPath) || !fs.existsSync(certPath)) return {}
|
|
return { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) }
|
|
}
|
|
|
|
function makeSmtpServer({ submission = false } = {}) {
|
|
const options = tlsOptions()
|
|
return new SMTPServer({
|
|
name: hostname,
|
|
secure: false,
|
|
...options,
|
|
authOptional: !submission,
|
|
allowInsecureAuth: false,
|
|
onAuth(auth, _session, callback) {
|
|
if (auth.username?.toLowerCase() === account && auth.password === password) {
|
|
return callback(null, { user: account })
|
|
}
|
|
const error = new Error('Invalid username or password')
|
|
error.responseCode = 535
|
|
return callback(error)
|
|
},
|
|
onMailFrom(address, _session, callback) {
|
|
if (submission && address.address.toLowerCase() !== account) {
|
|
const error = new Error('Sender address must match the authenticated mailbox')
|
|
error.responseCode = 553
|
|
return callback(error)
|
|
}
|
|
callback()
|
|
},
|
|
onRcptTo(address, _session, callback) {
|
|
const recipient = address.address.toLowerCase()
|
|
if (!submission && !isLocalAddress(recipient)) {
|
|
const error = new Error('Relay denied')
|
|
error.responseCode = 550
|
|
return callback(error)
|
|
}
|
|
callback()
|
|
},
|
|
onData(stream, session, callback) {
|
|
parseAndRoute(stream, session, submission)
|
|
.then(() => callback())
|
|
.catch((error) => callback(error))
|
|
},
|
|
})
|
|
}
|
|
|
|
export function startMailServers() {
|
|
const smtpPort = Number(process.env.SMTP_PORT || 25)
|
|
const submissionPort = Number(process.env.SUBMISSION_PORT || 587)
|
|
const inbound = makeSmtpServer({ submission: false })
|
|
const submission = makeSmtpServer({ submission: true })
|
|
|
|
inbound.listen(smtpPort, '0.0.0.0', () => console.log(`[smtp] inbound listening on ${smtpPort}`))
|
|
submission.listen(submissionPort, '0.0.0.0', () => console.log(`[smtp] submission listening on ${submissionPort}`))
|
|
inbound.on('error', (error) => console.error('[smtp] inbound error', error.message))
|
|
submission.on('error', (error) => console.error('[smtp] submission error', error.message))
|
|
return { inbound, submission }
|
|
}
|
|
|
|
async function directTransport(recipient) {
|
|
const recipientDomain = recipient.split('@').pop()
|
|
const mxRecords = await dns.resolveMx(recipientDomain)
|
|
if (!mxRecords.length) throw new Error(`No MX record found for ${recipientDomain}`)
|
|
mxRecords.sort((a, b) => a.priority - b.priority)
|
|
return nodemailer.createTransport({
|
|
host: mxRecords[0].exchange,
|
|
port: 25,
|
|
secure: false,
|
|
name: hostname,
|
|
tls: {
|
|
rejectUnauthorized: process.env.SMTP_DIRECT_TLS_REJECT_UNAUTHORIZED !== 'false',
|
|
},
|
|
})
|
|
}
|
|
|
|
function relayTransport() {
|
|
const host = process.env.SMTP_RELAY_HOST
|
|
if (!host) return null
|
|
return nodemailer.createTransport({
|
|
host,
|
|
port: Number(process.env.SMTP_RELAY_PORT || 587),
|
|
secure: process.env.SMTP_RELAY_SECURE === 'true',
|
|
auth: process.env.SMTP_RELAY_USER
|
|
? { user: process.env.SMTP_RELAY_USER, pass: process.env.SMTP_RELAY_PASSWORD }
|
|
: undefined,
|
|
})
|
|
}
|
|
|
|
export async function sendMail({ to, subject, text, html }) {
|
|
const sender = account
|
|
const recipients = Array.isArray(to) ? to : String(to).split(',').map((item) => item.trim()).filter(Boolean)
|
|
if (!recipients.length) throw new Error('Recipient is required')
|
|
const transport = relayTransport() || await directTransport(recipients[0])
|
|
const info = await transport.sendMail({ from: sender, to: recipients.join(', '), subject, text, html: html || undefined })
|
|
transport.close?.()
|
|
return { messageId: info.messageId, accepted: info.accepted }
|
|
}
|
|
|
|
export const mailConfig = { domain, account, hostname }
|