feat(mail): 支持外部 IMAP 账号接入。

- 新增外部 IMAP 账号的加密存储、同步任务与远端直连能力。
- Web 端个人邮箱页和邮件列表页支持查看、测试、同步和切换外部邮箱。
- 补充相关配置项、环境变量说明和使用文档。
This commit is contained in:
LanQin_
2026-06-25 17:09:46 +08:00
parent 2558aa96ed
commit 11734bf119
15 changed files with 1890 additions and 135 deletions
+2
View File
@@ -13,6 +13,8 @@ require (
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/emersion/go-smtp v0.24.0 // indirect
github.com/google/uuid v1.6.0 // indirect
+35
View File
@@ -2,6 +2,10 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
@@ -26,21 +30,52 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+60
View File
@@ -29,6 +29,7 @@ type App struct {
policy *HTMLPolicy
workerCancel context.CancelFunc
maildirHealth *maildirSyncHealthTracker
externalIMAP externalIMAPClientFactory
}
func New(cfg Config, logger *slog.Logger) (*App, error) {
@@ -49,6 +50,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
db.SetMaxOpenConns(1)
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker()}
a.externalIMAP = a
if err := a.configureSQLite(context.Background()); err != nil {
db.Close()
return nil, err
@@ -76,6 +78,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
go a.maildirWorker(workerCtx)
}
go a.sendQueueWorker(workerCtx)
go a.externalIMAPWorker(workerCtx)
go a.smtpEventsCleanupWorker(workerCtx)
return a, nil
}
@@ -344,6 +347,63 @@ func (a *App) migrate(ctx context.Context) error {
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_pop3_events_user_created ON pop3_events(user_id, created_at)`,
`CREATE TABLE IF NOT EXISTS external_imap_accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL,
tls_mode TEXT NOT NULL CHECK(tls_mode IN ('tls','starttls','plain')),
username TEXT NOT NULL,
password_ciphertext TEXT NOT NULL,
storage_mode TEXT NOT NULL DEFAULT 'local' CHECK(storage_mode IN ('local','remote')),
sync_read_state INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 1,
last_sync_at TEXT,
last_status TEXT NOT NULL DEFAULT 'idle',
last_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_external_imap_accounts_user_mailbox ON external_imap_accounts(user_id, mailbox_id)`,
`CREATE INDEX IF NOT EXISTS idx_external_imap_accounts_enabled ON external_imap_accounts(enabled, updated_at)`,
`CREATE TABLE IF NOT EXISTS external_imap_folder_states (
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
remote_folder TEXT NOT NULL,
local_folder_id TEXT NOT NULL DEFAULT '',
uid_validity INTEGER NOT NULL DEFAULT 0,
last_uid INTEGER NOT NULL DEFAULT 0,
last_sync_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(account_id, remote_folder)
)`,
`CREATE TABLE IF NOT EXISTS external_imap_messages (
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
remote_folder TEXT NOT NULL,
uid_validity INTEGER NOT NULL,
uid INTEGER NOT NULL,
message_id TEXT NOT NULL DEFAULT '',
local_message_id TEXT NOT NULL DEFAULT '',
is_read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(account_id, remote_folder, uid_validity, uid)
)`,
`CREATE INDEX IF NOT EXISTS idx_external_imap_messages_local ON external_imap_messages(local_message_id) WHERE local_message_id <> ''`,
`CREATE TABLE IF NOT EXISTS external_imap_sync_runs (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
status TEXT NOT NULL,
imported INTEGER NOT NULL DEFAULT 0,
skipped INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
started_at TEXT NOT NULL,
finished_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_external_imap_sync_runs_account_started ON external_imap_sync_runs(account_id, started_at DESC)`,
`CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY,
+126
View File
@@ -48,6 +48,11 @@ func newTestApp(t *testing.T) *App {
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
}
return newTestAppWithConfig(t, cfg)
}
func newTestAppWithConfig(t *testing.T, cfg Config) *App {
t.Helper()
a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
@@ -424,6 +429,127 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
}
}
func TestExternalIMAPAccountEncryptsPasswordAndDoesNotReturnSecret(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminEmail: "admin@lanqin.local",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
ExternalIMAPSecretKey: "test-secret",
ExternalIMAPAllowPrivateHosts: true,
})
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
t.Fatalf("login code=%d", code)
}
_, mb := defaultAdminUserAndMailbox(t, a)
var created ExternalIMAPAccount
payload := map[string]any{"mailboxId": mb.ID, "name": "Gmail", "host": "imap.gmail.com", "port": 993, "tlsMode": "tls", "username": "user@gmail.com", "password": "app-password", "storageMode": "remote", "syncReadState": true, "enabled": true}
if code := admin.do("POST", "/api/me/external-imap-accounts", payload, &created); code != http.StatusCreated {
t.Fatalf("create external imap code=%d account=%+v", code, created)
}
raw, err := json.Marshal(created)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "app-password") {
t.Fatalf("external account response leaked password: %s", string(raw))
}
var ciphertext string
if err := a.db.QueryRow(`SELECT password_ciphertext FROM external_imap_accounts WHERE id=?`, created.ID).Scan(&ciphertext); err != nil {
t.Fatal(err)
}
if ciphertext == "" || ciphertext == "app-password" {
t.Fatalf("password was not encrypted: %q", ciphertext)
}
plain, err := a.decryptExternalIMAPPassword(ciphertext)
if err != nil || plain != "app-password" {
t.Fatalf("decrypt password=%q err=%v", plain, err)
}
var list struct {
Items []map[string]any `json:"items"`
}
if code := admin.do("GET", "/api/me/external-imap-accounts?mailboxId="+mb.ID, nil, &list); code != http.StatusOK || len(list.Items) != 1 {
t.Fatalf("list external imap code=%d items=%+v", code, list.Items)
}
if _, ok := list.Items[0]["password"]; ok {
t.Fatalf("list response exposed password field: %+v", list.Items[0])
}
}
func TestExternalIMAPRejectsPrivateHostsByDefault(t *testing.T) {
a := newTestApp(t)
a.cfg.ExternalIMAPSecretKey = "test-secret"
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
t.Fatalf("login code=%d", code)
}
_, mb := defaultAdminUserAndMailbox(t, a)
var out map[string]any
payload := map[string]any{"mailboxId": mb.ID, "name": "Local", "host": "127.0.0.1", "port": 143, "tlsMode": "plain", "username": "local", "password": "secret", "storageMode": "remote"}
if code := admin.do("POST", "/api/me/external-imap-accounts", payload, &out); code != http.StatusBadRequest {
t.Fatalf("private host should be rejected code=%d body=%v", code, out)
}
}
func TestExternalIMAPAccountOwnershipIsolation(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminEmail: "admin@lanqin.local",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
ExternalIMAPSecretKey: "test-secret",
ExternalIMAPAllowPrivateHosts: true,
})
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
t.Fatalf("login admin code=%d", code)
}
domainID := mustDefaultDomainID(t, a)
owner := createTestMailbox(t, admin, domainID, "ximap-owner", "Owner", "Password123!", nil)
other := createTestMailbox(t, admin, domainID, "ximap-other", "Other", "Password123!", nil)
ownerClient := &testClient{t: t, server: ts}
if code := ownerClient.do("POST", "/api/auth/login", map[string]string{"email": owner.Address, "password": "Password123!"}, nil); code != http.StatusOK {
t.Fatalf("login owner code=%d", code)
}
otherClient := &testClient{t: t, server: ts}
if code := otherClient.do("POST", "/api/auth/login", map[string]string{"email": other.Address, "password": "Password123!"}, nil); code != http.StatusOK {
t.Fatalf("login other code=%d", code)
}
var created ExternalIMAPAccount
payload := map[string]any{"mailboxId": owner.ID, "name": "Owner external", "host": "imap.example.com", "port": 993, "tlsMode": "tls", "username": "owner@example.com", "password": "secret", "storageMode": "remote"}
if code := ownerClient.do("POST", "/api/me/external-imap-accounts", payload, &created); code != http.StatusCreated {
t.Fatalf("create code=%d account=%+v", code, created)
}
var denied map[string]any
if code := otherClient.do("POST", "/api/me/external-imap-accounts/"+created.ID, map[string]any{"mailboxId": other.ID, "name": "steal", "host": "imap.example.com", "port": 993, "tlsMode": "tls", "username": "other@example.com", "storageMode": "remote"}, &denied); code != http.StatusNotFound {
t.Fatalf("cross-user update should be hidden code=%d body=%v", code, denied)
}
if code := otherClient.do("DELETE", "/api/me/external-imap-accounts/"+created.ID, nil, &denied); code != http.StatusNotFound {
t.Fatalf("cross-user delete should be hidden code=%d body=%v", code, denied)
}
}
func TestParseMailAuthenticationResults(t *testing.T) {
header := textproto.MIMEHeader{}
header.Add("Authentication-Results", "mx.example.test; spf=pass smtp.mailfrom=sender.example; dkim=fail (bad signature) header.d=sender.example; dmarc=none")
+72 -66
View File
@@ -8,77 +8,83 @@ import (
)
type Config struct {
Addr string
DBPath string
DataDir string
CookieName string
SessionTTLHours int
AdminEmail string
AdminPassword string
PublicHostname string
PublicBaseURL string
SMTPHost string
SMTPPort string
SMTPUsername string
SMTPPassword string
SMTPRequireTLS bool
SubmissionAddr string
SubmissionTLSAddr string
SubmissionMaxMessageMB int
TLSCertFile string
TLSKeyFile string
MaildirRoot string
MaildirScanSeconds int
AllowInsecureHTTP bool
OpenRegistration bool
TwoFactorEnabled bool
TurnstileEnabled bool
TurnstileSiteKey string
TurnstileSecretKey string
CatchAllEnabled bool
MailAutoRefresh bool
MailRefreshSeconds int
UserMailboxApplyEnabled bool
UserMailboxDomainIDs string
ReservedMailboxPrefixes string
Addr string
DBPath string
DataDir string
CookieName string
SessionTTLHours int
AdminEmail string
AdminPassword string
PublicHostname string
PublicBaseURL string
SMTPHost string
SMTPPort string
SMTPUsername string
SMTPPassword string
SMTPRequireTLS bool
SubmissionAddr string
SubmissionTLSAddr string
SubmissionMaxMessageMB int
TLSCertFile string
TLSKeyFile string
MaildirRoot string
MaildirScanSeconds int
AllowInsecureHTTP bool
OpenRegistration bool
TwoFactorEnabled bool
TurnstileEnabled bool
TurnstileSiteKey string
TurnstileSecretKey string
CatchAllEnabled bool
MailAutoRefresh bool
MailRefreshSeconds int
UserMailboxApplyEnabled bool
UserMailboxDomainIDs string
ReservedMailboxPrefixes string
ExternalIMAPSecretKey string
ExternalIMAPSyncSeconds int
ExternalIMAPAllowPrivateHosts bool
}
func LoadConfig() Config {
dataDir := getenv("LANQIN_DATA_DIR", "./data")
return Config{
Addr: getenv("LANQIN_ADDR", ":8080"),
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
DataDir: dataDir,
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""),
ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
Addr: getenv("LANQIN_ADDR", ":8080"),
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
DataDir: dataDir,
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""),
ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
ExternalIMAPSecretKey: getenv("LANQIN_EXTERNAL_IMAP_SECRET_KEY", ""),
ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300),
ExternalIMAPAllowPrivateHosts: getenvBool("LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS", false),
}
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -59,6 +59,12 @@ func (a *App) Router() http.Handler {
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
r.With(a.requireAuth, a.requirePermission(PermissionMailStats)).Get("/me/stats", a.handleMailStats)
r.With(a.requireAuth, a.requirePermission(PermissionMailOrganize)).Post("/me/cleanup", a.handleMailCleanup)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Get("/me/external-imap-accounts", a.handleListExternalIMAPAccounts)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts", a.handleCreateExternalIMAPAccount)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}", a.handleUpdateExternalIMAPAccount)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Delete("/me/external-imap-accounts/{id}", a.handleDeleteExternalIMAPAccount)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}/test", a.handleTestExternalIMAPAccount)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}/sync", a.handleSyncExternalIMAPAccount)
r.With(a.requireAuth).Get("/events", a.handleEvents)
r.Group(func(r chi.Router) {
@@ -74,6 +80,12 @@ func (a *App) Router() http.Handler {
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages", a.handleMailMessages)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts", a.handleMailExternalAccounts)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts/{id}/folders", a.handleExternalIMAPFolders)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts/{id}/messages", a.handleExternalIMAPMessages)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts/{id}/messages/{remoteId}", a.handleExternalIMAPMessage)
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/external-accounts/{id}/attachments/{remoteId}/{partId}", a.handleExternalIMAPAttachment)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/external-accounts/{id}/messages/{remoteId}/mark-read", a.handleExternalIMAPMarkRead)
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue", a.handleSendQueue)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit)
+70 -31
View File
@@ -77,37 +77,38 @@ type MailLabel struct {
}
type MailMessage struct {
ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"`
MailboxAddress string `json:"mailboxAddress,omitempty"`
OwnerEmail string `json:"ownerEmail,omitempty"`
RecipientAddr string `json:"recipientAddress,omitempty"`
FolderID string `json:"folderId"`
Folder string `json:"folder"`
MessageUID string `json:"messageUid"`
IMAPUID int64 `json:"imapUid"`
IMAPModSeq int64 `json:"imapModseq"`
MessageID string `json:"messageId"`
Subject string `json:"subject"`
From string `json:"from"`
FromName string `json:"fromName,omitempty"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc,omitempty"`
SentAt time.Time `json:"sentAt"`
ReceivedAt time.Time `json:"receivedAt"`
Snippet string `json:"snippet"`
BodyText string `json:"bodyText,omitempty"`
BodyHTML string `json:"bodyHtml,omitempty"`
IsRead bool `json:"isRead"`
IsStarred bool `json:"isStarred"`
HasAttachments bool `json:"hasAttachments"`
SizeBytes int64 `json:"sizeBytes"`
Labels []MailLabel `json:"labels,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
Authentication MailAuthentication `json:"authentication"`
SendQueueID string `json:"sendQueueId,omitempty"`
SendQueueStatus string `json:"sendQueueStatus,omitempty"`
ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"`
MailboxAddress string `json:"mailboxAddress,omitempty"`
OwnerEmail string `json:"ownerEmail,omitempty"`
RecipientAddr string `json:"recipientAddress,omitempty"`
FolderID string `json:"folderId"`
Folder string `json:"folder"`
MessageUID string `json:"messageUid"`
IMAPUID int64 `json:"imapUid"`
IMAPModSeq int64 `json:"imapModseq"`
MessageID string `json:"messageId"`
Subject string `json:"subject"`
From string `json:"from"`
FromName string `json:"fromName,omitempty"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc,omitempty"`
SentAt time.Time `json:"sentAt"`
ReceivedAt time.Time `json:"receivedAt"`
Snippet string `json:"snippet"`
BodyText string `json:"bodyText,omitempty"`
BodyHTML string `json:"bodyHtml,omitempty"`
IsRead bool `json:"isRead"`
IsStarred bool `json:"isStarred"`
HasAttachments bool `json:"hasAttachments"`
SizeBytes int64 `json:"sizeBytes"`
Labels []MailLabel `json:"labels,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
Authentication MailAuthentication `json:"authentication"`
SendQueueID string `json:"sendQueueId,omitempty"`
SendQueueStatus string `json:"sendQueueStatus,omitempty"`
ExternalAccountID string `json:"externalAccountId,omitempty"`
}
type MailAuthentication struct {
@@ -227,6 +228,44 @@ type MailStatsFolderCount struct {
Bytes int64 `json:"bytes"`
}
type ExternalIMAPAccount struct {
ID string `json:"id"`
UserID string `json:"userId,omitempty"`
MailboxID string `json:"mailboxId"`
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
TLSMode string `json:"tlsMode"`
Username string `json:"username"`
StorageMode string `json:"storageMode"`
SyncReadState bool `json:"syncReadState"`
Enabled bool `json:"enabled"`
LastSyncAt *time.Time `json:"lastSyncAt,omitempty"`
LastStatus string `json:"lastStatus"`
LastError string `json:"lastError,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ExternalIMAPFolder struct {
Name string `json:"name"`
Role string `json:"role"`
UnreadCount int `json:"unreadCount"`
TotalCount int `json:"totalCount"`
}
type ExternalIMAPSyncRun struct {
ID string `json:"id"`
AccountID string `json:"accountId"`
Status string `json:"status"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
}
type SendQueueEntry struct {
ID string `json:"id"`
MailboxID string `json:"mailboxId"`