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
+27 -15
View File
@@ -294,9 +294,32 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateMailRulesBuilder(ctx); err != nil {
return err
}
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
return err
}
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 {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(mail_rules)`)
if err != nil {
@@ -533,32 +556,21 @@ func (a *App) seed(ctx context.Context) error {
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)
if err != nil {
return err
}
now := a.now().UTC().Format(time.RFC3339Nano)
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)
VALUES(?,?,?,?,?,?,?,?)`, userID, a.cfg.AdminEmail, "LanQin Admin", "admin", string(passwordHash), 0, now, now)
if err != nil {
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)
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
}
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) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
@@ -159,22 +181,10 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
t.Fatalf("login code=%d body=%v", code, login)
}
var domains struct {
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
domainID := createTestDomain(t, admin, "lanqin.local").ID
var mb1 Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{"domainId": domainID, "localPart": "alice", "displayName": "Alice", "password": "Password123!"}, &mb1); code != http.StatusCreated {
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)
}
mb1 := createTestMailbox(t, admin, domainID, "alice", "Alice", "Password123!", nil)
mb2 := createTestMailbox(t, admin, domainID, "bob", "Bob", "Password123!", nil)
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 {
@@ -269,22 +279,11 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
t.Fatalf("login code=%d body=%v", code, login)
}
var domains struct {
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
domainID := createTestDomain(t, admin, "lanqin.local").ID
createTestMailbox(t, admin, domainID, "admin", "Admin", "ChangeMe123!", map[string]any{"ownerEmail": "admin@lanqin.local", "role": "admin"})
var primary Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{"domainId": domainID, "localPart": "multi", "displayName": "Multi", "password": "Password123!"}, &primary); code != http.StatusCreated {
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)
}
primary := createTestMailbox(t, admin, domainID, "multi", "Multi", "Password123!", nil)
secondary := createTestMailbox(t, admin, domainID, "multi-work", "Multi Work", "Password456!", map[string]any{"ownerEmail": primary.Address})
if 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 {
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{
"to": []string{"ghost@lanqin.local"},
@@ -530,7 +531,11 @@ func TestUserTwoFactorSetupAndLogin(t *testing.T) {
func TestDNSRecords(t *testing.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 {
t.Fatal(err)
}
@@ -548,6 +553,17 @@ func TestMaildirSyncImportsRFC822(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
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)
if err != nil {
+5 -2
View File
@@ -46,8 +46,11 @@ type storedMessage struct {
func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
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
FROM mailboxes WHERE user_id=? AND status='active' ORDER BY address`, user.ID)
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 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 {
respondError(w, http.StatusInternalServerError, "failed to load mailboxes")
return