Initial commit: WpywMail 自建邮件系统:.NET 8 原生 SMTP/IMAP 服务端(DKIM 签名、SPF/DKIM/DMARC 入站校验、SQLite 存储、完整账号体系)、Node 服务端、WinUI 3 客户端与 Web 前端

This commit is contained in:
WpyQwq
2026-09-19 11:19:40 +08:00
commit b8814a7615
84 changed files with 21197 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MAIL_DOMAIN=wpyw.site
MAIL_HOSTNAME=mail.wpyw.site
MAIL_USER=[email protected]
MAIL_PASSWORD=replace-with-a-long-password
WEB_PORT=8787
SMTP_PORT=25
SUBMISSION_PORT=587
MAIL_DATA_DIR=H:\\MailData
CLIENT_ORIGIN=https://webmail.wpyw.site
# Optional outbound SMTP relay. If omitted, the server delivers directly to recipient MX hosts.
# SMTP_RELAY_HOST=smtp.example.com
# SMTP_RELAY_PORT=587
# SMTP_RELAY_USER=username
# SMTP_RELAY_PASSWORD=password
# SMTP_RELAY_SECURE=false
# SMTP_DIRECT_TLS_REJECT_UNAUTHORIZED=true
# Optional TLS for SMTP STARTTLS. Use a certificate for mail.wpyw.site.
# SMTP_TLS_KEY=H:\\MailData\\certs\\privkey.pem
# SMTP_TLS_CERT=H:\\MailData\\certs\\fullchain.pem
+92
View File
@@ -0,0 +1,92 @@
# wpyw.mail server
这是独立的邮箱服务端,不包含任何 Webmail 客户端代码。
当前服务端提供:
- SMTP 25:接收发往 `@wpyw.site` 的邮件
- SMTP Submission 587:登录认证后发信
- REST API:供未来独立客户端使用
- 本地 JSON 邮件存储和附件落盘
- 可选外部 SMTP 中继
## 启动
在 `server/` 目录执行:
```powershell
npm install
Copy-Item .env.example .env
notepad .env
npm start
```
必须设置 `MAIL_PASSWORD`,服务端不会使用默认密码启动。
默认监听:
```text
REST API 8787
SMTP 25
SMTP Submission 587
```
测试时可以临时改成非特权端口:
```powershell
$env:WEB_PORT='8787'
$env:SMTP_PORT='2525'
$env:SUBMISSION_PORT='2587'
npm start
```
## API
登录:
```http
POST /api/login
Content-Type: application/json
{"email":"admin@wpyw.site","password":"你的密码"}
```
之后把返回的 token 放入请求头:
```http
Authorization: Bearer <token>
```
主要接口:
```text
GET /api/health
GET /api/config
GET /api/me
GET /api/messages?folder=inbox
GET /api/messages/:id
POST /api/send
POST /api/logout
```
发信请求示例:
```json
{
"to": "[email protected]",
"subject": "测试邮件",
"text": "邮件正文"
}
```
## Cloudflare 和端口
`mail.wpyw.site` 应保持 DNS only,MX 指向 `mail.wpyw.site`。网站或未来的 Webmail 客户端可以继续通过 Cloudflare Tunnel,但 SMTP 25/587 直接连接服务器公网 IP。
## 当前边界
这是第一版服务端:已经具备收信、发信和客户端 API,但还没有实现 IMAP/POP3、多用户、DKIM 签名、DMARC 报告、反垃圾、配额和管理后台。未来客户端应通过 REST API 或后续增加的 IMAP 服务访问邮箱。
不要把它配置成 Open Relay。正式公网使用前,应配置 `mail.wpyw.site` 的 TLS 证书、PTR 反向解析、SPF、DKIM、DMARC,并优先考虑 SMTP 中继以提高投递率。
当前没有监听 993/995,因此暂时不要把它当作 Outlook/手机的 IMAP/POP3 服务器使用;第一版客户端应通过 REST API 连接。
+1
View File
@@ -0,0 +1 @@
+102
View File
@@ -0,0 +1,102 @@
import 'dotenv/config'
import crypto from 'node:crypto'
import express from 'express'
import { initStore, listMessages, getMessage, markRead, mailboxStats, saveMessage } from './store.mjs'
import { mailConfig, sendMail, startMailServers } from './mail.mjs'
const app = express()
const port = Number(process.env.WEB_PORT || 8787)
const sessions = new Map()
const account = (process.env.MAIL_USER || `admin@${mailConfig.domain}`).toLowerCase()
const password = process.env.MAIL_PASSWORD
if (!password) {
throw new Error('MAIL_PASSWORD is required. Copy server/.env.example to server/.env and set it before starting.')
}
app.use(express.json({ limit: '2mb' }))
app.use((req, res, next) => {
const allowedOrigin = process.env.CLIENT_ORIGIN || '*'
res.setHeader('Access-Control-Allow-Origin', allowedOrigin)
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, OPTIONS')
if (req.method === 'OPTIONS') return res.sendStatus(204)
next()
})
function auth(req, res, next) {
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '')
if (!token || !sessions.has(token)) return res.status(401).json({ error: '登录已失效' })
req.user = sessions.get(token)
next()
}
app.get('/api/health', (_req, res) => {
res.json({ ok: true, service: 'wpyw.mail', hostname: mailConfig.hostname, domain: mailConfig.domain })
})
app.get('/api/config', auth, (_req, res) => {
res.json({
domain: mailConfig.domain,
hostname: mailConfig.hostname,
account,
protocols: {
smtp: Number(process.env.SMTP_PORT || 25),
submission: Number(process.env.SUBMISSION_PORT || 587),
api: port,
},
})
})
app.post('/api/login', (req, res) => {
const email = String(req.body?.email || '').toLowerCase().trim()
const pass = String(req.body?.password || '')
if (email !== account || pass !== password) return res.status(401).json({ error: '邮箱或密码不正确' })
const token = crypto.randomBytes(32).toString('hex')
sessions.set(token, { email: account, createdAt: Date.now() })
res.json({ token, user: { email: account, domain: mailConfig.domain } })
})
app.post('/api/logout', auth, (req, res) => {
const token = req.headers.authorization.replace(/^Bearer\s+/i, '')
sessions.delete(token)
res.json({ ok: true })
})
app.get('/api/me', auth, async (req, res) => {
res.json({ user: req.user, stats: await mailboxStats() })
})
app.get('/api/messages', auth, async (req, res) => {
const folder = ['inbox', 'sent', 'drafts', 'archive'].includes(req.query.folder) ? req.query.folder : 'inbox'
res.json({ messages: await listMessages(folder, String(req.query.q || '')) })
})
app.get('/api/messages/:id', auth, async (req, res) => {
const message = await getMessage(req.params.id)
if (!message) return res.status(404).json({ error: '邮件不存在' })
await markRead(req.params.id)
res.json({ message: { ...message, unread: false } })
})
app.post('/api/send', auth, async (req, res) => {
const { to, subject, text, html } = req.body || {}
if (!to || !subject || !text) return res.status(400).json({ error: '收件人、主题和正文不能为空' })
try {
const result = await sendMail({ to, subject, text, html })
await saveMessage({ folder: 'sent', from: account, to, subject, text, html, unread: false })
res.json({ ok: true, result })
} catch (error) {
console.error('[send]', error)
res.status(502).json({ error: `发信失败:${error.message}` })
}
})
app.use('/api', (_req, res) => res.status(404).json({ error: 'API endpoint not found' }))
await initStore()
app.listen(port, '0.0.0.0', () => console.log(`[web] wpyw.mail listening on ${port}`))
startMailServers()
process.on('SIGINT', () => process.exit(0))
process.on('SIGTERM', () => process.exit(0))
+197
View File
@@ -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 }
+1306
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "wpyw-mail-server",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"start": "node index.mjs",
"dev": "node --watch index.mjs"
},
"dependencies": {
"better-sqlite3": "^13.0.3",
"dotenv": "^16.4.7",
"express": "^5.1.0",
"express-rate-limit": "^8.7.0",
"helmet": "^8.3.0",
"mailparser": "^3.7.2",
"nodemailer": "^10.0.1",
"smtp-server": "^3.15.0"
}
}
+143
View File
@@ -0,0 +1,143 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
const serverDir = path.dirname(fileURLToPath(import.meta.url))
const dataDir = process.env.MAIL_DATA_DIR || path.join(serverDir, 'data')
const messagesFile = path.join(dataDir, 'messages.json')
let writeQueue = Promise.resolve()
async function ensureStore() {
await fs.mkdir(dataDir, { recursive: true })
try {
await fs.access(messagesFile)
} catch {
await fs.writeFile(messagesFile, '[]', 'utf8')
}
}
async function readMessages() {
await ensureStore()
const raw = await fs.readFile(messagesFile, 'utf8')
try {
return JSON.parse(raw)
} catch {
return []
}
}
function queueWrite(messages) {
writeQueue = writeQueue.then(async () => {
const tmp = `${messagesFile}.${process.pid}.tmp`
await fs.writeFile(tmp, JSON.stringify(messages, null, 2), 'utf8')
await fs.rename(tmp, messagesFile)
})
return writeQueue
}
export async function initStore() {
await ensureStore()
if (process.env.SEED_DEMO === 'true') {
const messages = await readMessages()
if (!messages.length) {
const now = Date.now()
await queueWrite([
makeMessage({
folder: 'inbox',
from: 'Cloudflare <[email protected]>',
to: process.env.MAIL_USER || '[email protected]',
subject: '你的 wpyw.site 邮件服务已准备就绪',
text: '这是本地演示邮件。正式使用时,来自公网的 SMTP 邮件会自动进入这里。',
date: new Date(now - 1000 * 60 * 12).toISOString(),
unread: true,
}),
makeMessage({
folder: 'inbox',
from: '系统管理员 <[email protected]>',
to: process.env.MAIL_USER || '[email protected]',
subject: '欢迎使用 wpyw.mail',
text: '你可以从左侧开始管理收件箱,或点击右上角写信。',
date: new Date(now - 1000 * 60 * 60 * 4).toISOString(),
unread: false,
}),
makeMessage({
folder: 'sent',
from: process.env.MAIL_USER || '[email protected]',
to: '[email protected]',
subject: '测试发信',
text: 'SMTP 提交链路测试。',
date: new Date(now - 1000 * 60 * 60 * 22).toISOString(),
unread: false,
}),
])
}
}
}
export function makeMessage(input) {
const text = input.text || ''
return {
id: input.id || crypto.randomUUID(),
folder: input.folder || 'inbox',
from: input.from || '',
to: input.to || '',
subject: input.subject || '(无主题)',
text,
html: input.html || '',
preview: input.preview || text.replace(/\s+/g, ' ').trim().slice(0, 140),
date: input.date || new Date().toISOString(),
unread: input.unread ?? true,
attachments: input.attachments || [],
messageId: input.messageId || '',
}
}
export async function listMessages(folder = 'inbox', query = '') {
const messages = await readMessages()
const normalized = query.trim().toLowerCase()
return messages
.filter((message) => message.folder === folder)
.filter((message) => {
if (!normalized) return true
return [message.from, message.to, message.subject, message.text]
.join(' ')
.toLowerCase()
.includes(normalized)
})
.sort((a, b) => new Date(b.date) - new Date(a.date))
.map(({ text, html, ...summary }) => summary)
}
export async function getMessage(id) {
const messages = await readMessages()
return messages.find((message) => message.id === id) || null
}
export async function saveMessage(input) {
const messages = await readMessages()
const message = makeMessage(input)
messages.push(message)
await queueWrite(messages)
return message
}
export async function markRead(id) {
const messages = await readMessages()
const index = messages.findIndex((message) => message.id === id)
if (index < 0) return null
messages[index].unread = false
await queueWrite(messages)
return messages[index]
}
export async function mailboxStats() {
const messages = await readMessages()
return {
inbox: messages.filter((message) => message.folder === 'inbox').length,
unread: messages.filter((message) => message.folder === 'inbox' && message.unread).length,
sent: messages.filter((message) => message.folder === 'sent').length,
drafts: messages.filter((message) => message.folder === 'drafts').length,
}
}