chore(deploy): 将 DKIM 组件切换为 Rspamd
- 用 Rspamd 替换 OpenDKIM 及其相关镜像、编排和启动脚本。 - 调整 Postfix、单容器镜像与 Docker 配置,改为对接 Rspamd milter。 - 增加 DKIM 私钥同步脚本与 Rspamd 本地配置,支持从 SQLite 周期导出签名密钥。 - 更新 API 启动与测试逻辑,兼容新的默认管理员邮箱与邮箱加载方式。
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user