chore(deploy): 将 DKIM 组件切换为 Rspamd
- 用 Rspamd 替换 OpenDKIM 及其相关镜像、编排和启动脚本。 - 调整 Postfix、单容器镜像与 Docker 配置,改为对接 Rspamd milter。 - 增加 DKIM 私钥同步脚本与 Rspamd 本地配置,支持从 SQLite 周期导出签名密钥。 - 更新 API 启动与测试逻辑,兼容新的默认管理员邮箱与邮箱加载方式。
This commit is contained in:
+1
-1
@@ -22,7 +22,7 @@ coverage
|
|||||||
tmp
|
tmp
|
||||||
data
|
data
|
||||||
maildata
|
maildata
|
||||||
dkim-keys
|
rspamd-dkim
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# LanQin Email
|
# LanQin Email
|
||||||
|
|
||||||
LanQin Email 是一个自建邮箱 Webmail MVP:React/Vite + shadcn 风格组件前端,Go + SQLite 后端,默认使用单容器集成 Postfix、Dovecot、OpenDKIM。
|
LanQin Email 是一个自建邮箱 Webmail MVP:React/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`。
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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。
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./dovecot
|
context: ./dovecot
|
||||||
|
|
||||||
opendkim:
|
rspamd:
|
||||||
build:
|
build:
|
||||||
context: ./opendkim
|
context: ./rspamd
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# V1 先把 Rspamd 作为 DKIM 签名和标记组件,不直接拒信,避免误杀。
|
||||||
|
reject = 999;
|
||||||
|
add_header = 6;
|
||||||
|
greylist = 999;
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
bind_socket = "0.0.0.0:11332";
|
||||||
|
milter = yes;
|
||||||
|
timeout = 120s;
|
||||||
|
|
||||||
|
upstream "local" {
|
||||||
|
default = yes;
|
||||||
|
self_scan = yes;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user