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