面向小团队的自托管邮件服务器:一份二进制同时提供 Exchange ActiveSync、IMAPS、SMTPS、 收信 API 和一套新的管理控制台。默认 SQLite,也可以直接跑在 PostgreSQL 上。
- EAS 同步:
/Microsoft-Server-ActiveSync(iOS 邮件、Outlook、Windows 邮件等) - 标准邮件协议:IMAPS 993、SMTPS 465(按域名证书,SNI 选择)
- 发信:Cloudflare Email Sending(
smtp.mx.cloudflare.net:465,隐式 TLS) - 收信:Cloudflare Email Routing → Worker →
POST /api/inbound - 管理控制台:
/admin/,Token 登录,管理账号、地址、域名证书、发件队列、设备、日历订阅 - 只读日历订阅:
/calendar/<uuid>.ics,任何日历 App 都能订阅
flowchart LR
client["EAS 客户端"] -->|"HTTPS + Basic Auth"| eas["lifemail<br/>ActiveSync"]
mailclient["IMAP / SMTP 客户端"] -->|"993 / 465<br/>SNI 证书"| mail["lifemail<br/>IMAPS / SMTPS"]
eas --> db[(SQLite / PostgreSQL)]
mail --> db
eas -->|"SMTP 465"| cf["Cloudflare Email Sending"]
mail -->|"SMTP 465"| cf
inbound["外部邮件"] --> routing["Cloudflare Email Routing"]
routing --> worker["Email Worker"]
worker -->|"POST 原始 MIME"| api["lifemail<br/>/api/inbound"]
api --> db
admin["浏览器"] -->|"/admin/"| ui["嵌入式控制台"]
ui -->|"/api/admin/*"| api2["lifemail<br/>Admin API"]
api2 --> db
只有接口分面,控制台静态文件不参与划分(/admin/ 是 SvelteKit 产物,
任何域名都可以直接取):
| 面 | 路径 |
|---|---|
| 用户 / 邮件协议 | /Microsoft-Server-ActiveSync、/Autodiscover/*、/api/inbound、/api/me/*、/calendar/*、/healthz、/readyz |
| 管理接口 | /api/admin/*(含 /api/admin/metrics) |
| 静态文件 | /admin/* |
因此可以用两个域名指向同一个后端:公开域名拒绝 /api/admin/*,
管理域名放行并在入口处启用客户端证书校验(见下文 Caddy 示例)。
详细分层、表结构与同步模型见 docs/architecture.md; EAS 真机行为与踩坑记录见 docs/eas-notes.md。
# 1. 构建(前端产物在 web/build,二进制运行时读取;需要 Node 22+ 与 pnpm)
make build
# 2. 初始化数据库(serve 也会自动迁移,这一步可以省略)
./lifemail db migrate
# 3. 创建账号(不指定 -password 会随机生成并打印一次)
./lifemail user add -name alice -address alice@example.com -display "Alice"
# 4. 配置
cp .env.example .env
# 至少设置 LIFEMAIL_INBOUND_TOKEN / LIFEMAIL_ADMIN_TOKEN
# 域名、证书、SMTP 在控制台 /admin/ 里配置
# 5. 启动
set -a; . ./.env; set +a
./lifemail serve默认监听 :8080,数据库为 $LIFEMAIL_DB_DSN(默认 lifemail.db)。
必须放在 HTTPS 后面:EAS 使用 HTTP Basic Auth,凭据只能通过 TLS 传输。
# 公开域名:邮件协议与用户接口。管理接口在这里直接拒绝。
mail.example.com {
@adminApi path /api/admin/*
respond @adminApi 404
reverse_proxy 127.0.0.1:8080
}
# 管理域名:客户端证书校验在这一层完成,Go 进程只校验 Admin Token。
admin.example.com {
tls {
client_auth {
mode require_and_verify
trusted_ca_cert_file /etc/caddy/admin-ca.pem
}
}
reverse_proxy 127.0.0.1:8080
}两个域名走同一个后端端口;除了上面两块之外不需要额外配置。
控制台静态文件两个域名都能取,但公开域名上 /api/admin/* 是 404,
所以请从管理域名打开 /admin/。
没有公网 IP 时可以用 Cloudflare Tunnel 暴露服务。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
LIFEMAIL_ADDR |
:8080 |
HTTP 监听地址 |
LIFEMAIL_DB_DSN |
lifemail.db |
SQLite 文件路径;postgres:// 开头时连接 PostgreSQL |
LIFEMAIL_AUTO_MIGRATE |
1 |
启动时应用迁移;设为 0 时由 db migrate 单独执行 |
LIFEMAIL_INBOUND_TOKEN |
空 | 收信 API 的 Bearer token;为空时拒绝所有收信请求 |
LIFEMAIL_ADMIN_TOKEN |
空 | /api/admin/* 与控制台的 token;为空时管理接口关闭 |
LIFEMAIL_ATTACHMENT_DIR |
attachments |
MIME 原文与附件正文目录,可指向 rclone 挂载点 |
LIFEMAIL_PUBLIC_BASE_URL |
空 | 打印日历订阅绝对地址用,例如 https://mail.example.com |
LIFEMAIL_EAS_DUMP |
空 | 设置后把 WBXML 请求体落盘,便于离线排查 |
LIFEMAIL_LOG_LEVEL |
info |
debug / info / warn / error |
邮件域、TLS 证书与 SMTP 参数存在数据库里,不进环境变量,见下文。
schema 由版本化迁移定义(internal/store/migrations/NNNN_name.<dialect>.sql),
随二进制嵌入,启动时自动应用;也可以显式执行:
./lifemail db status # 已应用 / 待应用
./lifemail db migrate # 应用- 迁移只前向:已应用的版本不会重跑,不支持降级(回滚 = 恢复备份)
- 每个文件记录 checksum;已发布的迁移文件只读,修改会导致启动失败,正确做法是新增一个版本
- 破坏性变更分三步:新增列/双写 → 回填 → 下一版删除旧列
- 本项目不迁移重写前的旧数据库(PocketBase / v0.3.x);从 v1 起,schema 仍然必须通过迁移演进
- 仓库根目录的 AGENTS.md 记录了这些规则,供自动化助手遵守
切换 PostgreSQL 只需换 DSN:
LIFEMAIL_DB_DSN='postgres://lifemail:secret@db:5432/lifemail?sslmode=disable' ./lifemail serve两种方言的迁移文件保持字段一一对应。
打开管理域名下的 /admin/(例如 https://admin.example.com/admin/),
输入 LIFEMAIL_ADMIN_TOKEN。控制台与 /api/admin/* 同源,Cookie 只在这个域名生效;
公开域名上这些路径返回 404。
| 页面 | 能做什么 |
|---|---|
| 概览 | 账号 / 邮件 / 设备 / 队列计数,发信与域名状态,迁移版本 |
| 用户 | 新建账号、改显示名、重置密码(只显示一次)、删除、管理别名与主地址 |
| 地址与别名 | 把额外地址投递到已有账号 |
| 域名与证书 | 收信域列表 + IMAPS/SMTPS 证书路径,保存时校验文件与密钥匹配 |
| 发件队列 | 5 秒自动刷新、按状态过滤、单条重试/放弃 |
| 设备 | 已配对的 ActiveSync 客户端,远程擦除 / 移除,清理变更记录 |
| 日历订阅 | 每个日历的公开开关与订阅链接 |
| 设置 | SMTP 主机、端口、Token、兜底 From;发送测试邮件;按域名的兜底转发 |
| 诊断 | 版本、数据库方言、迁移记录、服务端点、排查提示 |
界面支持浅色/深色主题、移动端抽屉导航;所有写操作都有确认弹窗与结果提示。
| 项目 | 值 |
|---|---|
| 服务器 | https://mail.example.com/Microsoft-Server-ActiveSync |
| 用户名 | 账号名(alice)或该账号的任一地址(alice@example.com) |
| 密码 | lifemail user add 输出的密码 |
| 自动发现 | https://mail.example.com/autodiscover/autodiscover.xml |
账号不绑定域名:一个账号可以拥有多个地址(主地址 + 别名),
其中 is_primary 的那条是默认发件地址。
- 在 Cloudflare 控制台为发件域名开启 Email Sending
- 创建 API Token,权限选 Email Sending: Edit
- 控制台「设置」里填入 Token(
smtp.username保持api_token) - 在「域名与证书」里加上发件域名
- 保存后用同一页的「发送测试邮件」验证(
POST /api/admin/smtp/test,同步发送并直接返回错误)
Cloudflare 的硬限制:只支持 465 隐式 TLS;MAIL FROM 必须属于已 onboard 的域名;
单封邮件最大 5 MiB,单次会话最多 50 个收件人。
外部地址投递失败时写入 outbox,后台按 1/2/4/8/16/32 分钟退避,最多 20 次(约 12 小时),
之后标记失败并保留记录;控制台可以直接重试或放弃。
cd deploy
npx wrangler secret put LIFEMAIL_INBOUND_TOKEN # 与 LIFEMAIL_INBOUND_TOKEN 一致
npx wrangler deploy- 修改
deploy/wrangler.toml中的LIFEMAIL_INBOUND_URL - 在 Cloudflare 控制台把目标域名路由到该 Worker(catch-all 即可)
- Worker 会附带信封收件人(
X-Envelope-To),把原始 MIME POST 到 API
API 行为:
POST /api/inbound,Authorization: Bearer <token>或X-Lifemail-Token- 支持原始 MIME,也支持 JSON
{"to":["a@x"],"raw":"<base64>"} - 收件人解析:主地址/别名 → plus 地址(
alice+tag@example.com) - 同一
Message-ID重复投递自动去重;命中本地账号的收件人返回delivered - 未知收件人按「设置 → 兜底转发」的域名规则同步转发到外部地址,返回
forwarded - 没有任何兜底规则的未知收件人返回
550(Worker 调用setReject永久退信); 转发失败返回502、存储失败返回500,Worker 抛错让 Cloudflare 重试
兜底转发只在收信请求内完成,不写入本地邮箱:目标地址收到的是原始 MIME, 信封发件人使用被兜底的地址(都在已配置域名内)。
data/lifemail.db
attachments/raw/01/1-<hash>.eml ← 整封 MIME
attachments/att/01/1-<hash>.pdf ← 附件正文
attachments/out/01/1-<hash>.eml ← 排队中的邮件
数据库只保存可搜索字段和相对路径;目录丢失等同于邮件内容丢失,
备份时必须把数据库与 LIFEMAIL_ATTACHMENT_DIR 一起备份。
镜像只包含预编译的静态二进制和前端构建产物,不包含 Go 工具链与 Node:
make build # 先构建前端到 web/build,再交叉编译二进制
make image # docker build -t shynome/lifemail:$(git describe --tags --always)
docker run -d --name lifemail -p 8080:8080 \
--env-file .env \
-v "$PWD/data:/data" \
"shynome/lifemail:$(git describe --tags --always)"前端不嵌入二进制:镜像里 COPY web/build /srv/lifemail/web,并以
LIFEMAIL_WEB_DIR=/srv/lifemail/web 运行。只想换 UI 时,把新的 web/build
挂载覆盖这个目录即可,不用重做镜像(需与二进制的构建版本匹配)。
ARM 服务器:make build GOARCH=arm64 image。只想重新编译后端:make gobuild(不动前端)。
镜像自带 HEALTHCHECK,每 30 秒请求 /healthz。
go test ./... # EAS / IMAP / SMTP / 收信 / 管理接口 / 迁移
make verify # 收尾跑一次:gofmt + vet + svelte-check + 全量测试
make check # 只做静态检查(gofmt + vet + svelte-check)
cd web
pnpm install
pnpm run build # 输出到 web/build,二进制运行时按 LIFEMAIL_WEB_DIR 读取vite dev 会把 /api 反代到 Go 服务,所以改前端不用反复 pnpm run build:
./lifemail serve # 终端一:后端,默认 :8080
cd web && pnpm run dev # 终端二:http://localhost:5173/
# 后端不在默认地址时指定目标
LIFEMAIL_DEV_API=http://127.0.0.1:18080 pnpm run dev- 浏览器打开
http://localhost:5173/app/,登录一次(Cookie 落在 5173),之后源文件保存即热更新。 pnpm run preview同样代理/api,用来验证pnpm run build的产物。- 代理保留原始
Host,这样后端的同源(CSRF)校验能通过;若要跨机器访问,用--host并把LIFEMAIL_DEV_API指向可达的后端地址。 - 严格 CSP 只在
vite build时注入(构建产物自带),dev 下不注入,否则会挡掉 vite 的 worker 与 HMR。 - 跑过
pnpm run check(内部会svelte-kit sync)后 dev server 可能返回 404,重启pnpm run dev即可。
版本号由 make build 注入:git describe --tags --always --dirty。
- 不支持 Tasks、Notes、S/MIME、收件箱规则、会话视图、远程擦除的实际下发
- 服务器不展开重复事件(展开由客户端完成);忙闲只统计已存实例
Search只匹配主题/发件人/正文,不支持自定义属性与附件内容搜索- EAS 不提供日历分享 UI,对外分享只能通过只读
.ics订阅 - 收件只走 Cloudflare Worker API,不提供 SMTP 入站
- 联系人头像不下发(base64 内嵌会让全量同步流量翻数倍)
- EAS / IMAP / SMTP / 后台登录共用一套失败限流:10 分钟 5 次失败锁定 5 分钟
- 客户端 IP 取自
X-Forwarded-For;服务默认部署在 CDN / 反向代理之后 - 后台会话为
HttpOnly + SameSite=StrictCookie,写操作校验Origin - 访问日志包含 method/path/status/duration/ip/request_id/ua;
X-Request-Id会回写到响应头