Initial commit: WpywMail 自建邮件系统:.NET 8 原生 SMTP/IMAP 服务端(DKIM 签名、SPF/DKIM/DMARC 入站校验、SQLite 存储、完整账号体系)、Node 服务端、WinUI 3 客户端与 Web 前端
This commit is contained in:
+197
@@ -0,0 +1,197 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user