chore(deploy): 将 DKIM 组件切换为 Rspamd

- 用 Rspamd 替换 OpenDKIM 及其相关镜像、编排和启动脚本。
- 调整 Postfix、单容器镜像与 Docker 配置,改为对接 Rspamd milter。
- 增加 DKIM 私钥同步脚本与 Rspamd 本地配置,支持从 SQLite 周期导出签名密钥。
- 更新 API 启动与测试逻辑,兼容新的默认管理员邮箱与邮箱加载方式。
This commit is contained in:
LanQin
2026-06-15 23:41:21 +08:00
parent 2a5c4fcc0c
commit f8ff2a814b
25 changed files with 234 additions and 137 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ coverage
tmp tmp
data data
maildata maildata
dkim-keys rspamd-dkim
logs logs
*.log *.log
+4 -4
View File
@@ -94,10 +94,10 @@ jobs:
suffix: -dovecot suffix: -dovecot
context: ./deploy/dovecot context: ./deploy/dovecot
file: ./deploy/dovecot/Dockerfile file: ./deploy/dovecot/Dockerfile
- name: opendkim - name: rspamd
suffix: -opendkim suffix: -rspamd
context: ./deploy/opendkim context: ./deploy/rspamd
file: ./deploy/opendkim/Dockerfile file: ./deploy/rspamd/Dockerfile
steps: steps:
- name: Checkout - name: Checkout
+3 -2
View File
@@ -1,6 +1,6 @@
# LanQin Email # LanQin Email
LanQin Email 是一个自建邮箱 Webmail MVPReact/Vite + shadcn 风格组件前端,Go + SQLite 后端,默认使用单容器集成 Postfix、Dovecot、OpenDKIM LanQin Email 是一个自建邮箱 Webmail MVPReact/Vite + shadcn 风格组件前端,Go + SQLite 后端,默认使用单容器集成 Postfix、Dovecot、Rspamd
## 快速开发 ## 快速开发
@@ -54,7 +54,7 @@ docker compose pull
docker compose up -d docker compose up -d
``` ```
这个容器内部集成:API、Web、Nginx、Postfix、Dovecot、OpenDKIM 这个容器内部集成:API、Web、Nginx、Postfix、Dovecot、Rspamd
如果 GHCR 镜像是私有的,先登录: 如果 GHCR 镜像是私有的,先登录:
@@ -94,3 +94,4 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
- 收件同步:Postfix/Dovecot 收到的 Maildir 邮件会由 API 的 Maildir worker 同步到 SQLite 后展示在 Webmail。 - 收件同步:Postfix/Dovecot 收到的 Maildir 邮件会由 API 的 Maildir worker 同步到 SQLite 后展示在 Webmail。
- Maildir worker 通过 `LANQIN_MAILDIR_ROOT``LANQIN_MAILDIR_SCAN_SECONDS` 控制,默认服务器路径为 `/var/mail/vhosts` - Maildir worker 通过 `LANQIN_MAILDIR_ROOT``LANQIN_MAILDIR_SCAN_SECONDS` 控制,默认服务器路径为 `/var/mail/vhosts`
+27 -15
View File
@@ -294,9 +294,32 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateMailRulesBuilder(ctx); err != nil { if err := a.migrateMailRulesBuilder(ctx); err != nil {
return err return err
} }
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
return err
}
return nil return nil
} }
func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
adminEmail := normalizeEmail(a.cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil
}
now := time.Now().UTC().Format(time.RFC3339Nano)
_, err := a.db.ExecContext(ctx, `
UPDATE mailboxes
SET status='disabled', updated_at=?
WHERE address=?
AND display_name='LanQin Admin'
AND EXISTS (
SELECT 1 FROM users
WHERE users.id=mailboxes.user_id
AND users.email=?
AND users.role='admin'
)`, now, adminEmail, adminEmail)
return err
}
func (a *App) migrateMailRulesBuilder(ctx context.Context) error { func (a *App) migrateMailRulesBuilder(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(mail_rules)`) rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(mail_rules)`)
if err != nil { if err != nil {
@@ -533,32 +556,21 @@ func (a *App) seed(ctx context.Context) error {
return nil return nil
} }
domainName := strings.Split(a.cfg.AdminEmail, "@")[1]
domainID, err := a.createDomainTx(ctx, nil, domainName)
if err != nil {
return err
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(a.cfg.AdminPassword), bcrypt.DefaultCost) passwordHash, err := bcrypt.GenerateFromPassword([]byte(a.cfg.AdminPassword), bcrypt.DefaultCost)
if err != nil { if err != nil {
return err return err
} }
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr") userID := newID("usr")
adminEmail := normalizeEmail(a.cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return errors.New("invalid admin email")
}
_, err = a.db.ExecContext(ctx, `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at) _, err = a.db.ExecContext(ctx, `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, userID, a.cfg.AdminEmail, "LanQin Admin", "admin", string(passwordHash), 0, now, now) VALUES(?,?,?,?,?,?,?,?)`, userID, a.cfg.AdminEmail, "LanQin Admin", "admin", string(passwordHash), 0, now, now)
if err != nil { if err != nil {
return err return err
} }
local := strings.Split(a.cfg.AdminEmail, "@")[0]
mailboxID, err := a.createMailbox(ctx, userID, domainID, local, "LanQin Admin", a.cfg.AdminPassword, 2048, "active")
if err != nil {
return err
}
if err := a.seedWelcomeMessage(ctx, mailboxID); err != nil {
return err
}
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", a.cfg.AdminEmail) a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", a.cfg.AdminEmail)
return nil return nil
} }
+47 -31
View File
@@ -148,6 +148,28 @@ func (c *testClient) do(method, path string, body any, out any) int {
return resp.StatusCode return resp.StatusCode
} }
func createTestDomain(t *testing.T, admin *testClient, name string) Domain {
t.Helper()
var domain Domain
if code := admin.do("POST", "/api/admin/domains", map[string]string{"name": name}, &domain); code != http.StatusCreated {
t.Fatalf("create domain %s code=%d domain=%+v", name, code, domain)
}
return domain
}
func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, displayName, password string, extra map[string]any) Mailbox {
t.Helper()
payload := map[string]any{"domainId": domainID, "localPart": localPart, "displayName": displayName, "password": password}
for key, value := range extra {
payload[key] = value
}
var mailbox Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", payload, &mailbox); code != http.StatusCreated {
t.Fatalf("create mailbox %s code=%d mailbox=%+v", localPart, code, mailbox)
}
return mailbox
}
func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) { func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
@@ -159,22 +181,10 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
t.Fatalf("login code=%d body=%v", code, login) t.Fatalf("login code=%d body=%v", code, login)
} }
var domains struct { domainID := createTestDomain(t, admin, "lanqin.local").ID
Items []Domain `json:"items"`
}
if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 {
t.Fatalf("domains code=%d items=%d", code, len(domains.Items))
}
domainID := domains.Items[0].ID
var mb1 Mailbox mb1 := createTestMailbox(t, admin, domainID, "alice", "Alice", "Password123!", nil)
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{"domainId": domainID, "localPart": "alice", "displayName": "Alice", "password": "Password123!"}, &mb1); code != http.StatusCreated { mb2 := createTestMailbox(t, admin, domainID, "bob", "Bob", "Password123!", nil)
t.Fatalf("create alice code=%d mailbox=%+v", code, mb1)
}
var mb2 Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{"domainId": domainID, "localPart": "bob", "displayName": "Bob", "password": "Password123!"}, &mb2); code != http.StatusCreated {
t.Fatalf("create bob code=%d mailbox=%+v", code, mb2)
}
var alias Alias var alias Alias
if code := admin.do("POST", "/api/admin/aliases", map[string]any{"domainId": domainID, "source": "sales", "destination": mb1.Address}, &alias); code != http.StatusCreated { if code := admin.do("POST", "/api/admin/aliases", map[string]any{"domainId": domainID, "source": "sales", "destination": mb1.Address}, &alias); code != http.StatusCreated {
@@ -269,22 +279,11 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
t.Fatalf("login code=%d body=%v", code, login) t.Fatalf("login code=%d body=%v", code, login)
} }
var domains struct { domainID := createTestDomain(t, admin, "lanqin.local").ID
Items []Domain `json:"items"` createTestMailbox(t, admin, domainID, "admin", "Admin", "ChangeMe123!", map[string]any{"ownerEmail": "admin@lanqin.local", "role": "admin"})
}
if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 {
t.Fatalf("domains code=%d items=%d", code, len(domains.Items))
}
domainID := domains.Items[0].ID
var primary Mailbox primary := createTestMailbox(t, admin, domainID, "multi", "Multi", "Password123!", nil)
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{"domainId": domainID, "localPart": "multi", "displayName": "Multi", "password": "Password123!"}, &primary); code != http.StatusCreated { secondary := createTestMailbox(t, admin, domainID, "multi-work", "Multi Work", "Password456!", map[string]any{"ownerEmail": primary.Address})
t.Fatalf("create primary code=%d mailbox=%+v", code, primary)
}
var secondary Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{"domainId": domainID, "localPart": "multi-work", "displayName": "Multi Work", "password": "Password456!", "ownerEmail": primary.Address}, &secondary); code != http.StatusCreated {
t.Fatalf("create secondary code=%d mailbox=%+v", code, secondary)
}
if primary.UserID != secondary.UserID { if primary.UserID != secondary.UserID {
t.Fatalf("mailboxes were not bound to one user: primary=%s secondary=%s", primary.UserID, secondary.UserID) t.Fatalf("mailboxes were not bound to one user: primary=%s secondary=%s", primary.UserID, secondary.UserID)
} }
@@ -331,6 +330,8 @@ func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) {
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login) t.Fatalf("login code=%d body=%v", code, login)
} }
domainID := createTestDomain(t, admin, "lanqin.local").ID
createTestMailbox(t, admin, domainID, "admin", "Admin", "ChangeMe123!", map[string]any{"ownerEmail": "admin@lanqin.local", "role": "admin"})
payload := map[string]any{ payload := map[string]any{
"to": []string{"ghost@lanqin.local"}, "to": []string{"ghost@lanqin.local"},
@@ -530,7 +531,11 @@ func TestUserTwoFactorSetupAndLogin(t *testing.T) {
func TestDNSRecords(t *testing.T) { func TestDNSRecords(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
d, err := a.domainByID(context.Background(), mustDefaultDomainID(t, a)) domainID, err := a.createDomainTx(context.Background(), nil, "lanqin.local")
if err != nil {
t.Fatal(err)
}
d, err := a.domainByID(context.Background(), domainID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -548,6 +553,17 @@ func TestMaildirSyncImportsRFC822(t *testing.T) {
ctx := context.Background() ctx := context.Background()
root := t.TempDir() root := t.TempDir()
a.cfg.MaildirRoot = root a.cfg.MaildirRoot = root
domainID, err := a.createDomainTx(ctx, nil, "lanqin.local")
if err != nil {
t.Fatal(err)
}
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
if err != nil {
t.Fatal(err)
}
if _, err := a.createMailbox(ctx, adminUser.ID, domainID, "admin", "Admin", "ChangeMe123!", 1024, "active"); err != nil {
t.Fatal(err)
}
mailboxes, err := a.maildirMailboxes(ctx) mailboxes, err := a.maildirMailboxes(ctx)
if err != nil { if err != nil {
+5 -2
View File
@@ -46,8 +46,11 @@ type storedMessage struct {
func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) { func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
user := currentUser(r) user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
FROM mailboxes WHERE user_id=? AND status='active' ORDER BY address`, user.ID) FROM mailboxes mb
JOIN domains d ON d.id=mb.domain_id
WHERE mb.user_id=? AND mb.status='active' AND d.status='active'
ORDER BY mb.address`, user.ID)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailboxes") respondError(w, http.StatusInternalServerError, "failed to load mailboxes")
return return
+4 -1
View File
@@ -14,7 +14,7 @@ LANQIN_API_IMAGE=ghcr.io/lanqin996/lanqin-email-api:latest
LANQIN_WEB_IMAGE=ghcr.io/lanqin996/lanqin-email-web:latest LANQIN_WEB_IMAGE=ghcr.io/lanqin996/lanqin-email-web:latest
LANQIN_POSTFIX_IMAGE=ghcr.io/lanqin996/lanqin-email-postfix:latest LANQIN_POSTFIX_IMAGE=ghcr.io/lanqin996/lanqin-email-postfix:latest
LANQIN_DOVECOT_IMAGE=ghcr.io/lanqin996/lanqin-email-dovecot:latest LANQIN_DOVECOT_IMAGE=ghcr.io/lanqin996/lanqin-email-dovecot:latest
LANQIN_OPENDKIM_IMAGE=ghcr.io/lanqin996/lanqin-email-opendkim:latest LANQIN_RSPAMD_IMAGE=ghcr.io/lanqin996/lanqin-email-rspamd:latest
# ========================= # =========================
# 对外访问地址 # 对外访问地址
@@ -96,6 +96,9 @@ LANQIN_MAILDIR_ROOT=/var/mail/vhosts
# 扫描 Maildir 的间隔,单位:秒。 # 扫描 Maildir 的间隔,单位:秒。
LANQIN_MAILDIR_SCAN_SECONDS=30 LANQIN_MAILDIR_SCAN_SECONDS=30
# Rspamd 从 SQLite 导出 DKIM 私钥到签名目录的间隔,单位:秒。
LANQIN_RSPAMD_DKIM_SYNC_SECONDS=60
# 无人收件开关。false:未注册邮箱默认拒收;true:未注册收件地址进入全部邮件。 # 无人收件开关。false:未注册邮箱默认拒收;true:未注册收件地址进入全部邮件。
LANQIN_CATCH_ALL_ENABLED=false LANQIN_CATCH_ALL_ENABLED=false
+6 -4
View File
@@ -34,7 +34,7 @@ lanqin-email
- Nginx - Nginx
- Postfix - Postfix
- Dovecot - Dovecot
- OpenDKIM - Rspamd
常用命令: 常用命令:
@@ -60,7 +60,7 @@ ghcr.io/lanqin996/lanqin-email-api:latest
ghcr.io/lanqin996/lanqin-email-web:latest ghcr.io/lanqin996/lanqin-email-web:latest
ghcr.io/lanqin996/lanqin-email-postfix:latest ghcr.io/lanqin996/lanqin-email-postfix:latest
ghcr.io/lanqin996/lanqin-email-dovecot:latest ghcr.io/lanqin996/lanqin-email-dovecot:latest
ghcr.io/lanqin996/lanqin-email-opendkim:latest ghcr.io/lanqin996/lanqin-email-rspamd:latest
``` ```
如果拉取时报: 如果拉取时报:
@@ -92,7 +92,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
## 可选:多容器调试部署 ## 可选:多容器调试部署
如果需要分别查看 Postfix / Dovecot / OpenDKIM 日志,可以使用 stack 编排。 如果需要分别查看 Postfix / Dovecot / Rspamd 日志,可以使用 stack 编排。
拉取镜像版: 拉取镜像版:
@@ -123,7 +123,8 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
- Postfix 读取 `/data/lanqin.db` 中的 `domains``mailboxes``aliases` - Postfix 读取 `/data/lanqin.db` 中的 `domains``mailboxes``aliases`
- Dovecot 读取同一个 SQLite 数据库进行邮箱认证,并使用 `/var/mail/vhosts` 作为 Maildir 根目录。 - Dovecot 读取同一个 SQLite 数据库进行邮箱认证,并使用 `/var/mail/vhosts` 作为 Maildir 根目录。
- OpenDKIM 启动时从 SQLite 导出域名 DKIM 私钥到容器内 `/etc/opendkim/keys` - Rspamd 通过 milter 接入 Postfix,负责 DKIM 签名和垃圾邮件标记
- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`
- Go API 是 Webmail 和管理后台唯一入口;浏览器不直接连接 SMTP/IMAP。 - Go API 是 Webmail 和管理后台唯一入口;浏览器不直接连接 SMTP/IMAP。
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。 - Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。
@@ -132,3 +133,4 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
- 建议在服务器或边缘网关配置 HTTPS。 - 建议在服务器或边缘网关配置 HTTPS。
- 云厂商通常默认封禁 25 端口,需要单独申请解封。 - 云厂商通常默认封禁 25 端口,需要单独申请解封。
- SQLite 适合 V1 单机部署;多节点部署前迁移到 PostgreSQL,并把 Postfix/Dovecot maps 改为 PostgreSQL。 - SQLite 适合 V1 单机部署;多节点部署前迁移到 PostgreSQL,并把 Postfix/Dovecot maps 改为 PostgreSQL。
+4 -4
View File
@@ -27,7 +27,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
ca-certificates tzdata sqlite3 supervisor nginx \ ca-certificates tzdata sqlite3 supervisor nginx \
postfix postfix-sqlite \ postfix postfix-sqlite \
dovecot-core dovecot-imapd dovecot-lmtpd dovecot-sqlite ssl-cert \ dovecot-core dovecot-imapd dovecot-lmtpd dovecot-sqlite ssl-cert \
opendkim opendkim-tools rspamd
COPY --from=api-build /out/lanqin-api /usr/local/bin/lanqin-api COPY --from=api-build /out/lanqin-api /usr/local/bin/lanqin-api
COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html
@@ -41,10 +41,10 @@ COPY deploy/postfix/master.cf /etc/postfix/master.cf
COPY deploy/postfix/sqlite-*.cf /etc/postfix/ COPY deploy/postfix/sqlite-*.cf /etc/postfix/
COPY deploy/dovecot/dovecot.conf /etc/dovecot/dovecot.conf COPY deploy/dovecot/dovecot.conf /etc/dovecot/dovecot.conf
COPY deploy/dovecot/dovecot-sql.conf.ext /etc/dovecot/dovecot-sql.conf.ext COPY deploy/dovecot/dovecot-sql.conf.ext /etc/dovecot/dovecot-sql.conf.ext
COPY deploy/opendkim/opendkim.conf /etc/opendkim.conf COPY deploy/rspamd/local.d/ /etc/rspamd/local.d/
COPY deploy/opendkim/TrustedHosts /etc/opendkim/TrustedHosts COPY deploy/rspamd/sync-dkim.sh /usr/local/bin/lanqin-rspamd-sync-dkim
RUN chmod +x /entrypoint.sh && mkdir -p /run/nginx /var/mail/vhosts /data /etc/opendkim/keys RUN chmod +x /entrypoint.sh /usr/local/bin/lanqin-rspamd-sync-dkim && mkdir -p /run/nginx /run/rspamd /var/mail/vhosts /data /var/lib/rspamd/dkim
EXPOSE 80 443 25 587 993 EXPOSE 80 443 25 587 993
CMD ["/entrypoint.sh"] CMD ["/entrypoint.sh"]
+11 -19
View File
@@ -13,17 +13,23 @@ export LANQIN_DATA_DIR LANQIN_DB_PATH LANQIN_ADDR LANQIN_SMTP_HOST LANQIN_SMTP_P
addgroup --system --gid 5000 vmail 2>/dev/null || true addgroup --system --gid 5000 vmail 2>/dev/null || true
adduser --system --uid 5000 --gid 5000 --home /var/mail/vhosts --no-create-home vmail 2>/dev/null || true adduser --system --uid 5000 --gid 5000 --home /var/mail/vhosts --no-create-home vmail 2>/dev/null || true
mkdir -p /data /var/mail/vhosts /etc/opendkim/keys /var/spool/postfix /var/run/dovecot mkdir -p /data /var/mail/vhosts /var/lib/rspamd/dkim /run/rspamd /var/spool/postfix /var/run/dovecot
chown -R 5000:5000 /var/mail/vhosts chown -R 5000:5000 /var/mail/vhosts
if id _rspamd >/dev/null 2>&1; then
chown -R _rspamd:_rspamd /run/rspamd /var/lib/rspamd 2>/dev/null || true
elif id rspamd >/dev/null 2>&1; then
chown -R rspamd:rspamd /run/rspamd /var/lib/rspamd 2>/dev/null || true
fi
postconf -e "myhostname = ${LANQIN_PUBLIC_HOSTNAME}" postconf -e "myhostname = ${LANQIN_PUBLIC_HOSTNAME}"
postconf -e "myorigin = ${LANQIN_PUBLIC_HOSTNAME}" postconf -e "myorigin = ${LANQIN_PUBLIC_HOSTNAME}"
postconf -e "virtual_transport = lmtp:inet:127.0.0.1:24" postconf -e "virtual_transport = lmtp:inet:127.0.0.1:24"
postconf -e "smtpd_sasl_path = inet:127.0.0.1:12345" postconf -e "smtpd_sasl_path = inet:127.0.0.1:12345"
postconf -e "smtpd_milters = inet:127.0.0.1:8891" postconf -e "milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen}"
postconf -e "non_smtpd_milters = inet:127.0.0.1:8891" postconf -e "smtpd_milters = inet:127.0.0.1:11332"
postconf -e "non_smtpd_milters = inet:127.0.0.1:11332"
# OpenDKIM keys are generated after API seed/migrations create the SQLite DB. # Rspamd DKIM keys are exported after API seed/migrations create the SQLite DB.
/usr/local/bin/lanqin-api >/tmp/lanqin-api-bootstrap.log 2>&1 & /usr/local/bin/lanqin-api >/tmp/lanqin-api-bootstrap.log 2>&1 &
bootstrap_pid=$! bootstrap_pid=$!
for i in $(seq 1 60); do for i in $(seq 1 60); do
@@ -39,21 +45,7 @@ done
kill "$bootstrap_pid" 2>/dev/null || true kill "$bootstrap_pid" 2>/dev/null || true
wait "$bootstrap_pid" 2>/dev/null || true wait "$bootstrap_pid" 2>/dev/null || true
: > /etc/opendkim/KeyTable /usr/local/bin/lanqin-rspamd-sync-dkim --once || true
: > /etc/opendkim/SigningTable
if [ -f "$LANQIN_DB_PATH" ]; then
sqlite3 -separator '|' "$LANQIN_DB_PATH" "SELECT name, dkim_selector, dkim_private_key FROM domains WHERE status='active';" | while IFS='|' read -r domain selector private_key; do
[ -n "$domain" ] || continue
dir="/etc/opendkim/keys/$domain"
mkdir -p "$dir"
keyfile="$dir/$selector.private"
printf '%s' "$private_key" | base64 -d > "$keyfile"
chmod 600 "$keyfile"
echo "$selector._domainkey.$domain $domain:$selector:$keyfile" >> /etc/opendkim/KeyTable
echo "*@${domain} ${selector}._domainkey.${domain}" >> /etc/opendkim/SigningTable
done
fi
chown -R opendkim:opendkim /etc/opendkim
postfix check postfix check
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/lanqin.conf exec /usr/bin/supervisord -c /etc/supervisor/conf.d/lanqin.conf
+12 -2
View File
@@ -24,8 +24,8 @@ stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
priority=20 priority=20
[program:opendkim] [program:rspamd-dkim-sync]
command=/usr/sbin/opendkim -f -x /etc/opendkim.conf command=/usr/local/bin/lanqin-rspamd-sync-dkim
autostart=true autostart=true
autorestart=true autorestart=true
stdout_logfile=/dev/fd/1 stdout_logfile=/dev/fd/1
@@ -34,6 +34,16 @@ stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
priority=30 priority=30
[program:rspamd]
command=/usr/bin/rspamd -f
autostart=true
autorestart=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
priority=35
[program:postfix] [program:postfix]
command=/usr/sbin/postfix start-fg command=/usr/sbin/postfix start-fg
autostart=true autostart=true
+2 -2
View File
@@ -17,6 +17,6 @@ services:
build: build:
context: ./dovecot context: ./dovecot
opendkim: rspamd:
build: build:
context: ./opendkim context: ./rspamd
+5 -5
View File
@@ -37,7 +37,7 @@ services:
- "587:587" - "587:587"
depends_on: depends_on:
- dovecot - dovecot
- opendkim - rspamd
restart: unless-stopped restart: unless-stopped
dovecot: dovecot:
@@ -50,15 +50,15 @@ services:
- "993:993" - "993:993"
restart: unless-stopped restart: unless-stopped
opendkim: rspamd:
image: ${LANQIN_OPENDKIM_IMAGE:-ghcr.io/lanqin996/lanqin-email-opendkim:latest} image: ${LANQIN_RSPAMD_IMAGE:-ghcr.io/lanqin996/lanqin-email-rspamd:latest}
env_file: .env env_file: .env
volumes: volumes:
- lanqin-data:/data:ro - lanqin-data:/data:ro
- dkim-keys:/etc/opendkim/keys - rspamd-dkim:/var/lib/rspamd/dkim
restart: unless-stopped restart: unless-stopped
volumes: volumes:
lanqin-data: lanqin-data:
maildata: maildata:
dkim-keys: rspamd-dkim:
+2 -2
View File
@@ -11,10 +11,10 @@ services:
volumes: volumes:
- lanqin-data:/data - lanqin-data:/data
- maildata:/var/mail/vhosts - maildata:/var/mail/vhosts
- dkim-keys:/etc/opendkim/keys - rspamd-dkim:/var/lib/rspamd/dkim
restart: unless-stopped restart: unless-stopped
volumes: volumes:
lanqin-data: lanqin-data:
maildata: maildata:
dkim-keys: rspamd-dkim:
-19
View File
@@ -1,19 +0,0 @@
#!/bin/sh
set -eu
mkdir -p /etc/opendkim/keys
: > /etc/opendkim/KeyTable
: > /etc/opendkim/SigningTable
if [ -f /data/lanqin.db ]; then
sqlite3 -separator '|' /data/lanqin.db "SELECT name, dkim_selector, dkim_private_key FROM domains WHERE status='active';" | while IFS='|' read -r domain selector private_key; do
[ -n "$domain" ] || continue
dir="/etc/opendkim/keys/$domain"
mkdir -p "$dir"
keyfile="$dir/$selector.private"
printf '%s' "$private_key" | base64 -d > "$keyfile"
chmod 600 "$keyfile"
echo "$selector._domainkey.$domain $domain:$selector:$keyfile" >> /etc/opendkim/KeyTable
echo "*@${domain} ${selector}._domainkey.${domain}" >> /etc/opendkim/SigningTable
done
fi
chown -R opendkim:opendkim /etc/opendkim
exec opendkim -f -x /etc/opendkim.conf
-12
View File
@@ -1,12 +0,0 @@
Syslog yes
UMask 002
Mode sv
Canonicalization relaxed/simple
SubDomains no
OversignHeaders From
Socket inet:8891@0.0.0.0
UserID opendkim:opendkim
KeyTable /etc/opendkim/KeyTable
SigningTable refile:/etc/opendkim/SigningTable
ExternalIgnoreList /etc/opendkim/TrustedHosts
InternalHosts /etc/opendkim/TrustedHosts
+3 -2
View File
@@ -3,7 +3,8 @@ set -eu
: "${LANQIN_PUBLIC_HOSTNAME:=mail.example.com}" : "${LANQIN_PUBLIC_HOSTNAME:=mail.example.com}"
postconf -e "myhostname = ${LANQIN_PUBLIC_HOSTNAME}" postconf -e "myhostname = ${LANQIN_PUBLIC_HOSTNAME}"
postconf -e "myorigin = ${LANQIN_PUBLIC_HOSTNAME}" postconf -e "myorigin = ${LANQIN_PUBLIC_HOSTNAME}"
postconf -e "smtpd_milters = inet:opendkim:8891" postconf -e "milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen}"
postconf -e "non_smtpd_milters = inet:opendkim:8891" postconf -e "smtpd_milters = inet:rspamd:11332"
postconf -e "non_smtpd_milters = inet:rspamd:11332"
postfix check postfix check
exec postfix start-fg exec postfix start-fg
+4 -3
View File
@@ -23,8 +23,9 @@ smtpd_sasl_auth_enable = yes
smtpd_tls_security_level = may smtpd_tls_security_level = may
smtp_tls_security_level = may smtp_tls_security_level = may
# DKIM milter. # Rspamd milter: DKIM signing + spam headers.
milter_protocol = 6 milter_protocol = 6
milter_default_action = accept milter_default_action = accept
smtpd_milters = inet:opendkim:8891 milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen}
non_smtpd_milters = inet:opendkim:8891 smtpd_milters = inet:rspamd:11332
non_smtpd_milters = inet:rspamd:11332
@@ -5,10 +5,10 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \ --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean && \ rm -f /etc/apt/apt.conf.d/docker-clean && \
apt-get update && apt-get install -y --no-install-recommends opendkim opendkim-tools sqlite3 ca-certificates apt-get update && apt-get install -y --no-install-recommends rspamd sqlite3 ca-certificates
COPY opendkim.conf /etc/opendkim.conf COPY local.d/ /etc/rspamd/local.d/
COPY TrustedHosts /etc/opendkim/TrustedHosts COPY sync-dkim.sh /usr/local/bin/lanqin-rspamd-sync-dkim
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh /usr/local/bin/lanqin-rspamd-sync-dkim && mkdir -p /run/rspamd /var/lib/rspamd/dkim
EXPOSE 8891 EXPOSE 11332 11334
CMD ["/entrypoint.sh"] CMD ["/entrypoint.sh"]
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
mkdir -p /run/rspamd /var/lib/rspamd/dkim
if id _rspamd >/dev/null 2>&1; then
chown -R _rspamd:_rspamd /run/rspamd /var/lib/rspamd 2>/dev/null || true
elif id rspamd >/dev/null 2>&1; then
chown -R rspamd:rspamd /run/rspamd /var/lib/rspamd 2>/dev/null || true
fi
/usr/local/bin/lanqin-rspamd-sync-dkim --once || true
/usr/local/bin/lanqin-rspamd-sync-dkim &
exec rspamd -f
+4
View File
@@ -0,0 +1,4 @@
# V1 先把 Rspamd 作为 DKIM 签名和标记组件,不直接拒信,避免误杀。
reject = 999;
add_header = 6;
greylist = 999;
+19
View File
@@ -0,0 +1,19 @@
enabled = true;
# LanQin API 固定生成 selector=lanqin。路径支持 $domain / $selector 模板。
path = "/var/lib/rspamd/dkim/$domain.$selector.key";
selector = "lanqin";
try_fallback = true;
use_domain = "header";
use_esld = false;
use_redis = false;
# Go API / Postfix 在容器内提交的邮件需要签名。
sign_authenticated = true;
sign_local = true;
sign_networks = "/etc/rspamd/local.d/sign_networks.map";
allow_username_mismatch = true;
allow_hdrfrom_mismatch_local = true;
allow_hdrfrom_mismatch_sign_networks = true;
allow_hdrfrom_multiple = true;
@@ -1,5 +1,4 @@
127.0.0.1 127.0.0.0/8
localhost
10.0.0.0/8 10.0.0.0/8
172.16.0.0/12 172.16.0.0/12
192.168.0.0/16 192.168.0.0/16
+8
View File
@@ -0,0 +1,8 @@
bind_socket = "0.0.0.0:11332";
milter = yes;
timeout = 120s;
upstream "local" {
default = yes;
self_scan = yes;
}
+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh
set -eu
: "${LANQIN_DB_PATH:=/data/lanqin.db}"
: "${LANQIN_RSPAMD_DKIM_DIR:=/var/lib/rspamd/dkim}"
: "${LANQIN_RSPAMD_DKIM_SYNC_SECONDS:=60}"
chown_dkim_dir() {
if id _rspamd >/dev/null 2>&1; then
chown -R _rspamd:_rspamd "$LANQIN_RSPAMD_DKIM_DIR" 2>/dev/null || true
elif id rspamd >/dev/null 2>&1; then
chown -R rspamd:rspamd "$LANQIN_RSPAMD_DKIM_DIR" 2>/dev/null || true
fi
}
sync_keys() {
mkdir -p "$LANQIN_RSPAMD_DKIM_DIR"
if [ ! -f "$LANQIN_DB_PATH" ]; then
chown_dkim_dir
return 0
fi
sqlite3 -separator '|' "$LANQIN_DB_PATH" "SELECT name, dkim_selector, dkim_private_key FROM domains WHERE status='active';" 2>/dev/null | while IFS='|' read -r domain selector private_key; do
[ -n "$domain" ] || continue
[ -n "$selector" ] || selector="lanqin"
keyfile="$LANQIN_RSPAMD_DKIM_DIR/${domain}.${selector}.key"
tmpfile="${keyfile}.tmp"
printf '%s' "$private_key" | base64 -d > "$tmpfile"
chmod 0640 "$tmpfile"
mv "$tmpfile" "$keyfile"
done
chown_dkim_dir
}
if [ "${1:-}" = "--once" ]; then
sync_keys
exit 0
fi
while true; do
sync_keys || true
sleep "$LANQIN_RSPAMD_DKIM_SYNC_SECONDS"
done