diff --git a/README.md b/README.md index 34d69d6..c758d23 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,9 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build | `LANQIN_MAILDIR_ROOT` | Maildir 根目录 | `/var/mail/vhosts` | | `LANQIN_CATCH_ALL_ENABLED` | 未注册收件地址是否进入全部邮件 | `false` | | `LANQIN_USER_MAILBOX_APPLY_ENABLED` | 是否允许用户自助申请邮箱 | `false` | +| `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | 外部 IMAP 密码加密密钥,启用接入前必须设置 | 随机长字符串 | +| `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | 外部 IMAP 本地存储模式同步间隔 | `300` | +| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | 是否允许外部 IMAP 连接内网/localhost 主机 | `false` | ## 架构 @@ -179,6 +182,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build 2. **发件**:Webmail 调用 API → API 构造 MIME → SMTP 提交给 Postfix 或外部 SMTP → 投递到目标地址。 3. **本地投递**:开发环境中,系统内邮箱互发可直接写入对方 Inbox;未配置 `LANQIN_SMTP_HOST` 时不会真正投递外部收件人。 4. **第三方客户端**:可通过 SMTP 465/587、IMAP 993、POP3 995 连接;生产环境请配置匹配 `LANQIN_PUBLIC_HOSTNAME` 的证书。 +5. **外部邮箱接入**:个人邮箱管理可添加外部 IMAP 账号。本地存储模式会同步入库;远端直连模式每次读取远端,不写入本地邮件表。 ## 开发与验证 diff --git a/apps/api/go.mod b/apps/api/go.mod index f153bf9..f74d7c0 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -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 diff --git a/apps/api/go.sum b/apps/api/go.sum index ff88e17..62a956a 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -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= diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index cabde5e..ce94504 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -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, diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 7287dd9..e7206ed 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -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") diff --git a/apps/api/internal/app/config.go b/apps/api/internal/app/config.go index 47f84d1..f08c373 100644 --- a/apps/api/internal/app/config.go +++ b/apps/api/internal/app/config.go @@ -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), } } diff --git a/apps/api/internal/app/external_imap.go b/apps/api/internal/app/external_imap.go new file mode 100644 index 0000000..9152c5e --- /dev/null +++ b/apps/api/internal/app/external_imap.go @@ -0,0 +1,1161 @@ +package app + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "net" + "net/http" + "strconv" + "strings" + "time" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" + "github.com/go-chi/chi/v5" +) + +const ( + externalIMAPStorageLocal = "local" + externalIMAPStorageRemote = "remote" + externalIMAPTLS = "tls" + externalIMAPStartTLS = "starttls" + externalIMAPPlain = "plain" + externalIMAPMaxFetch = 30 +) + +type externalIMAPClientFactory interface { + openExternalIMAPClient(ctx context.Context, account externalIMAPAccountRecord) (externalIMAPClient, error) +} + +type externalIMAPClient interface { + Close() error + ListFolders(ctx context.Context) ([]externalIMAPRemoteFolder, error) + FetchSummaries(ctx context.Context, folder string, cursor uint32, limit int) ([]externalIMAPRemoteMessage, string, error) + FetchNew(ctx context.Context, folder string, afterUID uint32, limit int) ([]externalIMAPRemoteMessage, error) + FetchRaw(ctx context.Context, folder string, uid uint32) ([]byte, externalIMAPRemoteMessage, error) + SetRead(ctx context.Context, folder string, uid uint32, read bool) error +} + +type externalIMAPAccountRecord struct { + ExternalIMAPAccount + PasswordCiphertext string +} + +type externalIMAPRemoteFolder struct { + Name string + Role string + UnreadCount int + TotalCount int + UIDValidity uint32 +} + +type externalIMAPRemoteMessage struct { + UID uint32 + UIDValidity uint32 + Folder string + MessageID string + Subject string + From string + FromName string + To []string + CC []string + SentAt time.Time + ReceivedAt time.Time + Snippet string + IsRead bool + SizeBytes int64 + Raw []byte +} + +type externalIMAPPayload struct { + MailboxID string `json:"mailboxId"` + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + TLSMode string `json:"tlsMode"` + Username string `json:"username"` + Password string `json:"password"` + StorageMode string `json:"storageMode"` + SyncReadState *bool `json:"syncReadState"` + Enabled *bool `json:"enabled"` +} + +func (a *App) externalIMAPWorker(ctx context.Context) { + interval := time.Duration(a.cfg.ExternalIMAPSyncSeconds) * time.Second + if interval <= 0 { + interval = 5 * time.Minute + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + a.syncDueExternalIMAPAccounts(ctx) + } + } +} + +func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) { + rows, err := a.db.QueryContext(ctx, `SELECT id FROM external_imap_accounts WHERE enabled=1 AND storage_mode=? ORDER BY COALESCE(last_sync_at, created_at) ASC LIMIT 10`, externalIMAPStorageLocal) + if err != nil { + a.log.Warn("failed to list external imap accounts", "error", err) + return + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + continue + } + runCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + _, _ = a.syncExternalIMAPAccount(runCtx, id) + cancel() + } +} + +func (a *App) handleListExternalIMAPAccounts(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")) + args := []any{user.ID} + where := "user_id=?" + if mailboxID != "" { + if _, err := a.mailboxForUserByID(r.Context(), user.ID, mailboxID); err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + where += " AND mailbox_id=?" + args = append(args, mailboxID) + } + rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE `+where+` ORDER BY created_at DESC`, args...) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load external accounts") + return + } + defer rows.Close() + items := []ExternalIMAPAccount{} + for rows.Next() { + item, err := scanExternalIMAPAccount(rows) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to scan external accounts") + return + } + items = append(items, item.ExternalIMAPAccount) + } + respondJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (a *App) handleCreateExternalIMAPAccount(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + var req externalIMAPPayload + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + mb, err := a.mailboxForUserByID(r.Context(), user.ID, strings.TrimSpace(req.MailboxID)) + if err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + req.MailboxID = mb.ID + if strings.TrimSpace(req.Password) == "" { + badRequest(w, errors.New("password is required")) + return + } + normalized, err := a.normalizeExternalIMAPPayload(r.Context(), req, true) + if err != nil { + badRequest(w, err) + return + } + ciphertext, err := a.encryptExternalIMAPPassword(normalized.Password) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + now := a.now().UTC().Format(time.RFC3339Nano) + id := newID("ximap") + if _, err := a.db.ExecContext(r.Context(), `INSERT INTO external_imap_accounts(id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,storage_mode,sync_read_state,enabled,last_status,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, user.ID, normalized.MailboxID, normalized.Name, normalized.Host, normalized.Port, normalized.TLSMode, normalized.Username, ciphertext, normalized.StorageMode, boolInt(*normalized.SyncReadState), boolInt(*normalized.Enabled), "idle", now, now); err != nil { + respondError(w, http.StatusInternalServerError, "failed to create external account") + return + } + account, err := a.externalIMAPAccountForUser(r.Context(), user.ID, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load external account") + return + } + respondJSON(w, http.StatusCreated, account.ExternalIMAPAccount) +} + +func (a *App) handleUpdateExternalIMAPAccount(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + id := chi.URLParam(r, "id") + current, err := a.externalIMAPAccountForUser(r.Context(), user.ID, id) + if err != nil { + respondError(w, http.StatusNotFound, "external account not found") + return + } + var req externalIMAPPayload + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + if strings.TrimSpace(req.MailboxID) == "" { + req.MailboxID = current.MailboxID + } + if _, err := a.mailboxForUserByID(r.Context(), user.ID, strings.TrimSpace(req.MailboxID)); err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + normalized, err := a.normalizeExternalIMAPPayload(r.Context(), req, false) + if err != nil { + badRequest(w, err) + return + } + ciphertext := current.PasswordCiphertext + if strings.TrimSpace(normalized.Password) != "" { + ciphertext, err = a.encryptExternalIMAPPassword(normalized.Password) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + } + now := a.now().UTC().Format(time.RFC3339Nano) + _, err = a.db.ExecContext(r.Context(), `UPDATE external_imap_accounts SET mailbox_id=?,name=?,host=?,port=?,tls_mode=?,username=?,password_ciphertext=?,storage_mode=?,sync_read_state=?,enabled=?,updated_at=? WHERE id=? AND user_id=?`, + normalized.MailboxID, normalized.Name, normalized.Host, normalized.Port, normalized.TLSMode, normalized.Username, ciphertext, normalized.StorageMode, boolInt(*normalized.SyncReadState), boolInt(*normalized.Enabled), now, id, user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update external account") + return + } + account, err := a.externalIMAPAccountForUser(r.Context(), user.ID, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load external account") + return + } + respondJSON(w, http.StatusOK, account.ExternalIMAPAccount) +} + +func (a *App) handleDeleteExternalIMAPAccount(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + res, err := a.db.ExecContext(r.Context(), `DELETE FROM external_imap_accounts WHERE id=? AND user_id=?`, chi.URLParam(r, "id"), user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete external account") + return + } + if n, _ := res.RowsAffected(); n == 0 { + respondError(w, http.StatusNotFound, "external account not found") + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (a *App) handleTestExternalIMAPAccount(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + account, err := a.externalIMAPAccountForUser(r.Context(), user.ID, chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusNotFound, "external account not found") + return + } + client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account) + if err != nil { + a.updateExternalIMAPStatus(r.Context(), account.ID, "error", err.Error()) + respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) + return + } + defer client.Close() + folders, err := client.ListFolders(r.Context()) + if err != nil { + a.updateExternalIMAPStatus(r.Context(), account.ID, "error", err.Error()) + respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) + return + } + a.updateExternalIMAPStatus(r.Context(), account.ID, "ok", "") + respondJSON(w, http.StatusOK, map[string]any{"ok": true, "folders": len(folders)}) +} + +func (a *App) handleSyncExternalIMAPAccount(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + account, err := a.externalIMAPAccountForUser(r.Context(), user.ID, chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusNotFound, "external account not found") + return + } + if account.StorageMode != externalIMAPStorageLocal { + badRequest(w, errors.New("remote storage accounts do not sync into local mailbox")) + return + } + run, err := a.syncExternalIMAPAccount(r.Context(), account.ID) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusOK, run) +} + +func (a *App) handleMailExternalAccounts(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE user_id=? AND enabled=1 ORDER BY name`, user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load external accounts") + return + } + defer rows.Close() + items := []ExternalIMAPAccount{} + for rows.Next() { + item, err := scanExternalIMAPAccount(rows) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to scan external accounts") + return + } + items = append(items, item.ExternalIMAPAccount) + } + respondJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (a *App) handleExternalIMAPFolders(w http.ResponseWriter, r *http.Request) { + account, ok := a.externalIMAPAccountForMailRequest(w, r) + if !ok { + return + } + client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account) + if err != nil { + respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) + return + } + defer client.Close() + folders, err := client.ListFolders(r.Context()) + if err != nil { + respondError(w, http.StatusBadRequest, "failed to list folders") + return + } + items := make([]ExternalIMAPFolder, 0, len(folders)) + for _, folder := range folders { + items = append(items, ExternalIMAPFolder{Name: folder.Name, Role: folder.Role, UnreadCount: folder.UnreadCount, TotalCount: folder.TotalCount}) + } + respondJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (a *App) handleExternalIMAPMessages(w http.ResponseWriter, r *http.Request) { + account, ok := a.externalIMAPAccountForMailRequest(w, r) + if !ok { + return + } + folder := strings.TrimSpace(r.URL.Query().Get("folder")) + if folder == "" { + folder = "INBOX" + } + cursor, _ := strconv.ParseUint(strings.TrimSpace(r.URL.Query().Get("cursor")), 10, 32) + client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account) + if err != nil { + respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) + return + } + defer client.Close() + remote, next, err := client.FetchSummaries(r.Context(), folder, uint32(cursor), externalIMAPMaxFetch) + if err != nil { + respondError(w, http.StatusBadRequest, "failed to load remote messages") + return + } + items := make([]MailMessage, 0, len(remote)) + for _, msg := range remote { + items = append(items, externalRemoteMessageToMailMessage(account, msg, false)) + } + respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next}) +} + +func (a *App) handleExternalIMAPMessage(w http.ResponseWriter, r *http.Request) { + account, ok := a.externalIMAPAccountForMailRequest(w, r) + if !ok { + return + } + folder, uid, ok := decodeExternalRemoteID(w, chi.URLParam(r, "remoteId")) + if !ok { + return + } + client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account) + if err != nil { + respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) + return + } + defer client.Close() + raw, remote, err := client.FetchRaw(r.Context(), folder, uid) + if err != nil { + respondError(w, http.StatusBadRequest, "failed to load remote message") + return + } + stored, attachments, err := a.parseMaildirMessage(raw, account.Username) + msg := externalRemoteMessageToMailMessage(account, remote, true) + if err == nil { + msg.BodyText = stored.BodyText + msg.BodyHTML = stored.BodyHTML + msg.Snippet = stored.Snippet + msg.Attachments = []Attachment{{ID: "raw", MessageID: msg.ID, Filename: safeExternalEMLFilename(msg.Subject), ContentType: "message/rfc822", SizeBytes: int64(len(raw)), CreatedAt: a.now().UTC()}} + if len(attachments) > 0 { + msg.HasAttachments = true + } + } + respondJSON(w, http.StatusOK, msg) +} + +func (a *App) handleExternalIMAPAttachment(w http.ResponseWriter, r *http.Request) { + account, ok := a.externalIMAPAccountForMailRequest(w, r) + if !ok { + return + } + if chi.URLParam(r, "partId") != "raw" { + respondError(w, http.StatusNotFound, "attachment not found") + return + } + folder, uid, ok := decodeExternalRemoteID(w, chi.URLParam(r, "remoteId")) + if !ok { + return + } + client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account) + if err != nil { + respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) + return + } + defer client.Close() + raw, remote, err := client.FetchRaw(r.Context(), folder, uid) + if err != nil { + respondError(w, http.StatusBadRequest, "failed to load remote message") + return + } + w.Header().Set("Content-Type", "message/rfc822") + w.Header().Set("Content-Disposition", `attachment; filename="`+safeExternalEMLFilename(remote.Subject)+`"`) + w.Header().Set("Content-Length", strconv.Itoa(len(raw))) + _, _ = w.Write(raw) +} + +func (a *App) handleExternalIMAPMarkRead(w http.ResponseWriter, r *http.Request) { + account, ok := a.externalIMAPAccountForMailRequest(w, r) + if !ok { + return + } + if !account.SyncReadState { + badRequest(w, errors.New("read state sync is disabled")) + return + } + folder, uid, ok := decodeExternalRemoteID(w, chi.URLParam(r, "remoteId")) + if !ok { + return + } + var req struct { + Read *bool `json:"read"` + } + _ = decodeJSON(r, &req) + read := true + if req.Read != nil { + read = *req.Read + } + client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account) + if err != nil { + respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) + return + } + defer client.Close() + if err := client.SetRead(r.Context(), folder, uid, read); err != nil { + respondError(w, http.StatusBadRequest, "failed to update remote message") + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true, "read": read}) +} + +func (a *App) normalizeExternalIMAPPayload(ctx context.Context, req externalIMAPPayload, create bool) (externalIMAPPayload, error) { + req.Name = strings.Join(strings.Fields(req.Name), " ") + if req.Name == "" { + req.Name = req.Username + } + if req.Name == "" { + req.Name = req.Host + } + if len([]rune(req.Name)) > 80 { + return req, errors.New("name is too long") + } + req.Host = strings.ToLower(strings.TrimSpace(req.Host)) + if req.Host == "" { + return req, errors.New("host is required") + } + if err := a.validateExternalIMAPHost(ctx, req.Host); err != nil { + return req, err + } + req.TLSMode = strings.ToLower(strings.TrimSpace(req.TLSMode)) + if req.TLSMode == "" { + req.TLSMode = externalIMAPTLS + } + if req.TLSMode != externalIMAPTLS && req.TLSMode != externalIMAPStartTLS && req.TLSMode != externalIMAPPlain { + return req, errors.New("invalid TLS mode") + } + if req.Port <= 0 { + if req.TLSMode == externalIMAPTLS { + req.Port = 993 + } else { + req.Port = 143 + } + } + if req.Port <= 0 || req.Port > 65535 { + return req, errors.New("invalid port") + } + req.Username = strings.TrimSpace(req.Username) + if req.Username == "" { + return req, errors.New("username is required") + } + req.StorageMode = strings.ToLower(strings.TrimSpace(req.StorageMode)) + if req.StorageMode == "" { + req.StorageMode = externalIMAPStorageLocal + } + if req.StorageMode != externalIMAPStorageLocal && req.StorageMode != externalIMAPStorageRemote { + return req, errors.New("invalid storage mode") + } + if req.SyncReadState == nil { + v := true + req.SyncReadState = &v + } + if req.Enabled == nil { + v := true + req.Enabled = &v + } + return req, nil +} + +func (a *App) validateExternalIMAPHost(ctx context.Context, host string) error { + if a.cfg.ExternalIMAPAllowPrivateHosts { + return nil + } + if strings.EqualFold(host, "localhost") { + return errors.New("localhost is not allowed") + } + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil { + if ip := net.ParseIP(host); ip != nil { + ips = []net.IP{ip} + } else { + return fmt.Errorf("failed to resolve host: %w", err) + } + } + for _, ip := range ips { + if !isPublicExternalIMAPIP(ip) { + return errors.New("private or local IMAP hosts are not allowed") + } + } + return nil +} + +func isPublicExternalIMAPIP(ip net.IP) bool { + if ip == nil { + return false + } + return !(ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified()) +} + +func (a *App) encryptExternalIMAPPassword(password string) (string, error) { + key, err := a.externalIMAPKey() + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + out := append(nonce, gcm.Seal(nil, nonce, []byte(password), nil)...) + return base64.StdEncoding.EncodeToString(out), nil +} + +func (a *App) decryptExternalIMAPPassword(ciphertext string) (string, error) { + key, err := a.externalIMAPKey() + if err != nil { + return "", err + } + raw, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(raw) < gcm.NonceSize() { + return "", errors.New("invalid encrypted password") + } + nonce, data := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] + plain, err := gcm.Open(nil, nonce, data, nil) + if err != nil { + return "", err + } + return string(plain), nil +} + +func (a *App) externalIMAPKey() ([]byte, error) { + secret := strings.TrimSpace(a.cfg.ExternalIMAPSecretKey) + if secret == "" { + return nil, errors.New("LANQIN_EXTERNAL_IMAP_SECRET_KEY is required") + } + sum := sha256.Sum256([]byte(secret)) + return sum[:], nil +} + +func (a *App) externalIMAPAccountForUser(ctx context.Context, userID, id string) (externalIMAPAccountRecord, error) { + row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE id=? AND user_id=?`, id, userID) + return scanExternalIMAPAccount(row) +} + +func (a *App) externalIMAPAccountForMailRequest(w http.ResponseWriter, r *http.Request) (externalIMAPAccountRecord, bool) { + user := currentUser(r) + account, err := a.externalIMAPAccountForUser(r.Context(), user.ID, chi.URLParam(r, "id")) + if err != nil || !account.Enabled { + respondError(w, http.StatusNotFound, "external account not found") + return externalIMAPAccountRecord{}, false + } + return account, true +} + +type externalIMAPScanner interface { + Scan(dest ...any) error +} + +func scanExternalIMAPAccount(row externalIMAPScanner) (externalIMAPAccountRecord, error) { + var item externalIMAPAccountRecord + var syncRead, enabled int + var lastSync sql.NullString + var created, updated string + err := row.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.Name, &item.Host, &item.Port, &item.TLSMode, &item.Username, &item.PasswordCiphertext, &item.StorageMode, &syncRead, &enabled, &lastSync, &item.LastStatus, &item.LastError, &created, &updated) + if err != nil { + return item, err + } + item.SyncReadState = syncRead != 0 + item.Enabled = enabled != 0 + if lastSync.Valid && strings.TrimSpace(lastSync.String) != "" { + t := parseTime(lastSync.String) + item.LastSyncAt = &t + } + item.CreatedAt = parseTime(created) + item.UpdatedAt = parseTime(updated) + return item, nil +} + +func (a *App) updateExternalIMAPStatus(ctx context.Context, accountID, status, errText string) { + _, _ = a.db.ExecContext(ctx, `UPDATE external_imap_accounts SET last_status=?,last_error=?,updated_at=? WHERE id=?`, status, trimExternalIMAPError(errText), a.now().UTC().Format(time.RFC3339Nano), accountID) +} + +func (a *App) syncExternalIMAPAccount(ctx context.Context, accountID string) (ExternalIMAPSyncRun, error) { + account, err := a.externalIMAPAccountByID(ctx, accountID) + if err != nil { + return ExternalIMAPSyncRun{}, err + } + if account.StorageMode != externalIMAPStorageLocal { + return ExternalIMAPSyncRun{}, errors.New("account is not configured for local storage") + } + run := ExternalIMAPSyncRun{ID: newID("ximrun"), AccountID: account.ID, Status: "running", StartedAt: a.now().UTC()} + _, _ = a.db.ExecContext(ctx, `INSERT INTO external_imap_sync_runs(id,account_id,status,started_at) VALUES(?,?,?,?)`, run.ID, run.AccountID, run.Status, run.StartedAt.Format(time.RFC3339Nano)) + client, err := a.externalIMAP.openExternalIMAPClient(ctx, account) + if err != nil { + return a.finishExternalIMAPRun(ctx, run, "failed", err) + } + defer client.Close() + folders, err := client.ListFolders(ctx) + if err != nil { + return a.finishExternalIMAPRun(ctx, run, "failed", err) + } + for _, folder := range folders { + imported, skipped, failed, err := a.syncExternalIMAPFolder(ctx, account, client, folder) + run.Imported += imported + run.Skipped += skipped + run.Failed += failed + if err != nil { + run.Failed++ + a.log.Warn("external imap folder sync failed", "account", account.ID, "folder", folder.Name, "error", err) + } + } + status := "ok" + if run.Failed > 0 { + status = "partial" + } + return a.finishExternalIMAPRun(ctx, run, status, nil) +} + +func (a *App) externalIMAPAccountByID(ctx context.Context, id string) (externalIMAPAccountRecord, error) { + row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,host,port,tls_mode,username,password_ciphertext,storage_mode,sync_read_state,enabled,last_sync_at,last_status,last_error,created_at,updated_at FROM external_imap_accounts WHERE id=? AND enabled=1`, id) + return scanExternalIMAPAccount(row) +} + +func (a *App) syncExternalIMAPFolder(ctx context.Context, account externalIMAPAccountRecord, client externalIMAPClient, folder externalIMAPRemoteFolder) (int, int, int, error) { + localFolderName := normalizeExternalIMAPFolderName(folder.Name) + localFolderID, err := a.ensureFolder(ctx, account.MailboxID, localFolderName) + if err != nil { + return 0, 0, 0, err + } + state := a.loadExternalIMAPFolderState(ctx, account.ID, folder.Name) + remote, err := client.FetchNew(ctx, folder.Name, state.LastUID, 100) + if err != nil { + return 0, 0, 0, err + } + imported, skipped, failed := 0, 0, 0 + maxUID := state.LastUID + now := a.now().UTC().Format(time.RFC3339Nano) + for _, item := range remote { + if item.UID > maxUID { + maxUID = item.UID + } + if a.externalIMAPRemoteMessageExists(ctx, account.ID, folder.Name, item.UIDValidity, item.UID) { + skipped++ + continue + } + raw, item, err := client.FetchRaw(ctx, folder.Name, item.UID) + if err != nil { + failed++ + continue + } + stored, attachments, err := a.parseMaildirMessage(raw, account.Username) + if err != nil { + failed++ + continue + } + stored.MailboxID = account.MailboxID + stored.FolderID = localFolderID + stored.RecipientAddr = account.Username + stored.IsRead = item.IsRead + msgID, err := a.insertExternalIMAPMessageOnce(ctx, account, folder.Name, item, stored, attachments) + if err != nil { + failed++ + continue + } + if msgID != "" { + if err := a.writeStoredMessageToMaildir(ctx, msgID, stored, attachments); err != nil { + a.log.Warn("failed to write external imap message to maildir", "message", msgID, "error", err) + } + imported++ + } else { + skipped++ + } + } + _, _ = a.db.ExecContext(ctx, `INSERT INTO external_imap_folder_states(account_id,remote_folder,local_folder_id,uid_validity,last_uid,last_sync_at,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(account_id,remote_folder) DO UPDATE SET local_folder_id=excluded.local_folder_id,uid_validity=excluded.uid_validity,last_uid=MAX(last_uid,excluded.last_uid),last_sync_at=excluded.last_sync_at,updated_at=excluded.updated_at`, + account.ID, folder.Name, localFolderID, folder.UIDValidity, maxUID, now, now, now) + return imported, skipped, failed, nil +} + +type externalIMAPFolderState struct { + LastUID uint32 +} + +func (a *App) loadExternalIMAPFolderState(ctx context.Context, accountID, folder string) externalIMAPFolderState { + var state externalIMAPFolderState + _ = a.db.QueryRowContext(ctx, `SELECT last_uid FROM external_imap_folder_states WHERE account_id=? AND remote_folder=?`, accountID, folder).Scan(&state.LastUID) + return state +} + +func (a *App) externalIMAPRemoteMessageExists(ctx context.Context, accountID, folder string, uidValidity, uid uint32) bool { + var exists int + _ = a.db.QueryRowContext(ctx, `SELECT 1 FROM external_imap_messages WHERE account_id=? AND remote_folder=? AND uid_validity=? AND uid=?`, accountID, folder, uidValidity, uid).Scan(&exists) + return exists == 1 +} + +func (a *App) insertExternalIMAPMessageOnce(ctx context.Context, account externalIMAPAccountRecord, folder string, item externalIMAPRemoteMessage, msg storedMessage, attachments []AttachmentInput) (string, error) { + now := a.now().UTC().Format(time.RFC3339Nano) + tx, err := a.db.BeginTx(ctx, nil) + if err != nil { + return "", err + } + defer tx.Rollback() + res, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO external_imap_messages(account_id,remote_folder,uid_validity,uid,message_id,is_read,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, account.ID, folder, item.UIDValidity, item.UID, msg.MessageID, boolInt(item.IsRead), now, now) + if err != nil { + return "", err + } + if n, _ := res.RowsAffected(); n == 0 { + return "", tx.Commit() + } + if strings.TrimSpace(msg.MessageID) != "" { + var existing string + if err := tx.QueryRowContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? LIMIT 1`, msg.MailboxID, msg.FolderID, msg.MessageID).Scan(&existing); err == nil { + _, _ = tx.ExecContext(ctx, `UPDATE external_imap_messages SET local_message_id=?,updated_at=? WHERE account_id=? AND remote_folder=? AND uid_validity=? AND uid=?`, existing, now, account.ID, folder, item.UIDValidity, item.UID) + return "", tx.Commit() + } + } + id, err := a.insertMessageWithDB(ctx, tx, msg, attachments) + if err != nil { + return "", err + } + _, err = tx.ExecContext(ctx, `UPDATE external_imap_messages SET local_message_id=?,updated_at=? WHERE account_id=? AND remote_folder=? AND uid_validity=? AND uid=?`, id, now, account.ID, folder, item.UIDValidity, item.UID) + if err != nil { + return "", err + } + return id, tx.Commit() +} + +func (a *App) finishExternalIMAPRun(ctx context.Context, run ExternalIMAPSyncRun, status string, err error) (ExternalIMAPSyncRun, error) { + run.Status = status + if err != nil { + run.Error = trimExternalIMAPError(err.Error()) + } + finished := a.now().UTC() + run.FinishedAt = &finished + _, _ = a.db.ExecContext(ctx, `UPDATE external_imap_sync_runs SET status=?,imported=?,skipped=?,failed=?,error=?,finished_at=? WHERE id=?`, + run.Status, run.Imported, run.Skipped, run.Failed, run.Error, finished.Format(time.RFC3339Nano), run.ID) + lastStatus := status + if status == "failed" { + lastStatus = "error" + } + _, _ = a.db.ExecContext(ctx, `UPDATE external_imap_accounts SET last_sync_at=?,last_status=?,last_error=?,updated_at=? WHERE id=?`, + finished.Format(time.RFC3339Nano), lastStatus, run.Error, finished.Format(time.RFC3339Nano), run.AccountID) + return run, err +} + +func trimExternalIMAPError(value string) string { + value = strings.TrimSpace(value) + if len(value) > 500 { + return value[:500] + } + return value +} + +func normalizeExternalIMAPFolderName(name string) string { + switch strings.ToLower(strings.TrimSpace(name)) { + case "inbox": + return "Inbox" + case "sent", "sent items", "sent messages": + return "Sent" + case "drafts", "draft": + return "Drafts" + case "archive", "archives": + return "Archive" + case "spam", "junk", "junk email": + return "Spam" + case "trash", "deleted", "deleted items": + return "Trash" + default: + folder, err := normalizeCustomFolderName(name) + if err != nil { + return "Imported" + } + return folder + } +} + +func encodeExternalRemoteID(folder string, uid uint32) string { + return base64.RawURLEncoding.EncodeToString([]byte(folder + "\x00" + strconv.FormatUint(uint64(uid), 10))) +} + +func decodeExternalRemoteID(w http.ResponseWriter, raw string) (string, uint32, bool) { + data, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid remote id") + return "", 0, false + } + parts := strings.SplitN(string(data), "\x00", 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" { + respondError(w, http.StatusBadRequest, "invalid remote id") + return "", 0, false + } + uid, err := strconv.ParseUint(parts[1], 10, 32) + if err != nil || uid == 0 { + respondError(w, http.StatusBadRequest, "invalid remote id") + return "", 0, false + } + return parts[0], uint32(uid), true +} + +func externalRemoteMessageToMailMessage(account externalIMAPAccountRecord, msg externalIMAPRemoteMessage, includeBody bool) MailMessage { + id := encodeExternalRemoteID(msg.Folder, msg.UID) + messageID := msg.MessageID + if messageID != "" && !strings.HasPrefix(messageID, "<") { + messageID = "<" + messageID + ">" + } + return MailMessage{ + ID: id, + MailboxID: account.MailboxID, + MailboxAddress: account.Name, + FolderID: msg.Folder, + Folder: msg.Folder, + MessageUID: id, + IMAPUID: int64(msg.UID), + MessageID: messageID, + Subject: msg.Subject, + From: msg.From, + FromName: msg.FromName, + To: msg.To, + CC: msg.CC, + SentAt: msg.SentAt, + ReceivedAt: msg.ReceivedAt, + Snippet: msg.Snippet, + IsRead: msg.IsRead, + SizeBytes: msg.SizeBytes, + HasAttachments: includeBody, + ExternalAccountID: account.ID, + } +} + +func safeExternalEMLFilename(subject string) string { + name := strings.TrimSpace(subject) + if name == "" { + name = "message" + } + name = strings.Map(func(r rune) rune { + if r < 32 || strings.ContainsRune(`\/:*?"<>|`, r) { + return '-' + } + return r + }, name) + if len([]rune(name)) > 80 { + name = string([]rune(name)[:80]) + } + return name + ".eml" +} + +func (a *App) openExternalIMAPClient(ctx context.Context, account externalIMAPAccountRecord) (externalIMAPClient, error) { + if err := a.validateExternalIMAPHost(ctx, account.Host); err != nil { + return nil, err + } + password, err := a.decryptExternalIMAPPassword(account.PasswordCiphertext) + if err != nil { + return nil, err + } + addr := net.JoinHostPort(account.Host, strconv.Itoa(account.Port)) + options := &imapclient.Options{ + Dialer: &net.Dialer{Timeout: 10 * time.Second}, + TLSConfig: &tls.Config{ServerName: account.Host, MinVersion: tls.VersionTLS12}, + } + var c *imapclient.Client + switch account.TLSMode { + case externalIMAPTLS: + c, err = imapclient.DialTLS(addr, options) + case externalIMAPStartTLS: + c, err = imapclient.DialStartTLS(addr, options) + default: + c, err = imapclient.DialInsecure(addr, options) + } + if err != nil { + return nil, err + } + if err := c.Login(account.Username, password).Wait(); err != nil { + c.Close() + return nil, err + } + return &goExternalIMAPClient{client: c}, nil +} + +type goExternalIMAPClient struct { + client *imapclient.Client +} + +func (c *goExternalIMAPClient) Close() error { + if c.client == nil { + return nil + } + _ = c.client.Logout().Wait() + return c.client.Close() +} + +func (c *goExternalIMAPClient) ListFolders(ctx context.Context) ([]externalIMAPRemoteFolder, error) { + list, err := c.client.List("", "*", &imap.ListOptions{ReturnStatus: &imap.StatusOptions{NumMessages: true, NumUnseen: true}}).Collect() + if err != nil { + return nil, err + } + folders := []externalIMAPRemoteFolder{} + for _, item := range list { + if strings.TrimSpace(item.Mailbox) == "" || mailboxHasNoSelect(item.Attrs) { + continue + } + f := externalIMAPRemoteFolder{Name: item.Mailbox, Role: normalizeExternalIMAPFolderName(item.Mailbox)} + if item.Status != nil { + if item.Status.NumMessages != nil { + f.TotalCount = int(*item.Status.NumMessages) + } + if item.Status.NumUnseen != nil { + f.UnreadCount = int(*item.Status.NumUnseen) + } + } + if strings.EqualFold(item.Mailbox, "INBOX") { + f.Name = "INBOX" + } + folders = append(folders, f) + } + if len(folders) == 0 { + folders = append(folders, externalIMAPRemoteFolder{Name: "INBOX", Role: "Inbox"}) + } + return folders, nil +} + +func mailboxHasNoSelect(attrs []imap.MailboxAttr) bool { + for _, attr := range attrs { + if strings.EqualFold(string(attr), `\Noselect`) { + return true + } + } + return false +} + +func (c *goExternalIMAPClient) FetchSummaries(ctx context.Context, folder string, cursor uint32, limit int) ([]externalIMAPRemoteMessage, string, error) { + selected, err := c.client.Select(folder, nil).Wait() + if err != nil { + return nil, "", err + } + if selected.NumMessages == 0 { + return nil, "", nil + } + if limit <= 0 || limit > 100 { + limit = externalIMAPMaxFetch + } + start := selected.NumMessages + if cursor > 0 { + start = cursor + } + if start == 0 { + return nil, "", nil + } + stop := uint32(1) + if start > uint32(limit) { + stop = start - uint32(limit) + 1 + } + var set imap.SeqSet + set.AddRange(stop, start) + bodySection := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierHeader, Peek: true} + messages, err := c.client.Fetch(set, &imap.FetchOptions{UID: true, Flags: true, Envelope: true, InternalDate: true, RFC822Size: true, BodySection: []*imap.FetchItemBodySection{bodySection}}).Collect() + if err != nil { + return nil, "", err + } + out := []externalIMAPRemoteMessage{} + for i := len(messages) - 1; i >= 0 && len(out) < limit; i-- { + out = append(out, fetchBufferToExternalMessage(folder, selected.UIDValidity, messages[i], nil)) + } + next := "" + if stop > 1 { + next = strconv.FormatUint(uint64(stop-1), 10) + } + return out, next, nil +} + +func (c *goExternalIMAPClient) FetchNew(ctx context.Context, folder string, afterUID uint32, limit int) ([]externalIMAPRemoteMessage, error) { + selected, err := c.client.Select(folder, nil).Wait() + if err != nil { + return nil, err + } + if selected.NumMessages == 0 || selected.UIDNext <= imap.UID(afterUID+1) { + return nil, nil + } + if limit <= 0 || limit > 100 { + limit = 100 + } + var set imap.UIDSet + set.AddRange(imap.UID(afterUID+1), selected.UIDNext-1) + bodySection := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierHeader, Peek: true} + messages, err := c.client.Fetch(set, &imap.FetchOptions{UID: true, Flags: true, Envelope: true, InternalDate: true, RFC822Size: true, BodySection: []*imap.FetchItemBodySection{bodySection}}).Collect() + if err != nil { + return nil, err + } + out := []externalIMAPRemoteMessage{} + for i := 0; i < len(messages) && len(out) < limit; i++ { + out = append(out, fetchBufferToExternalMessage(folder, selected.UIDValidity, messages[i], nil)) + } + return out, nil +} + +func (c *goExternalIMAPClient) FetchRaw(ctx context.Context, folder string, uid uint32) ([]byte, externalIMAPRemoteMessage, error) { + selected, err := c.client.Select(folder, nil).Wait() + if err != nil { + return nil, externalIMAPRemoteMessage{}, err + } + bodySection := &imap.FetchItemBodySection{Peek: true} + messages, err := c.client.Fetch(imap.UIDSetNum(imap.UID(uid)), &imap.FetchOptions{UID: true, Flags: true, Envelope: true, InternalDate: true, RFC822Size: true, BodySection: []*imap.FetchItemBodySection{bodySection}}).Collect() + if err != nil { + return nil, externalIMAPRemoteMessage{}, err + } + if len(messages) == 0 { + return nil, externalIMAPRemoteMessage{}, sql.ErrNoRows + } + raw := messages[0].FindBodySection(bodySection) + return raw, fetchBufferToExternalMessage(folder, selected.UIDValidity, messages[0], raw), nil +} + +func (c *goExternalIMAPClient) SetRead(ctx context.Context, folder string, uid uint32, read bool) error { + if _, err := c.client.Select(folder, nil).Wait(); err != nil { + return err + } + op := imap.StoreFlagsDel + if read { + op = imap.StoreFlagsAdd + } + return c.client.Store(imap.UIDSetNum(imap.UID(uid)), &imap.StoreFlags{Op: op, Flags: []imap.Flag{imap.FlagSeen}, Silent: true}, nil).Close() +} + +func fetchBufferToExternalMessage(folder string, uidValidity uint32, msg *imapclient.FetchMessageBuffer, raw []byte) externalIMAPRemoteMessage { + out := externalIMAPRemoteMessage{Folder: folder, UIDValidity: uidValidity, UID: uint32(msg.UID), ReceivedAt: time.Now().UTC(), Raw: raw} + if msg.Envelope != nil { + out.MessageID = msg.Envelope.MessageID + out.Subject = msg.Envelope.Subject + out.SentAt = msg.Envelope.Date + out.From, out.FromName = firstIMAPAddress(msg.Envelope.From) + out.To = imapAddresses(msg.Envelope.To) + out.CC = imapAddresses(msg.Envelope.Cc) + } + if out.SentAt.IsZero() { + out.SentAt = out.ReceivedAt + } + if !msg.InternalDate.IsZero() { + out.ReceivedAt = msg.InternalDate + } + out.SizeBytes = msg.RFC822Size + for _, flag := range msg.Flags { + if flag == imap.FlagSeen { + out.IsRead = true + break + } + } + if len(raw) > 0 { + if stored, _, err := parseExternalIMAPRawForSnippet(raw); err == nil { + if out.Subject == "" { + out.Subject = stored.Subject + } + if out.MessageID == "" { + out.MessageID = strings.Trim(stored.MessageID, "<>") + } + out.Snippet = stored.Snippet + } + } + return out +} + +func parseExternalIMAPRawForSnippet(raw []byte) (storedMessage, []AttachmentInput, error) { + tmp := &App{now: time.Now, policy: NewHTMLPolicy()} + return tmp.parseMaildirMessage(raw, "") +} + +func firstIMAPAddress(addrs []imap.Address) (string, string) { + for _, addr := range addrs { + if email := addr.Addr(); email != "" { + return normalizeEmail(email), addr.Name + } + } + return "", "" +} + +func imapAddresses(addrs []imap.Address) []string { + out := []string{} + for _, addr := range addrs { + if email := addr.Addr(); email != "" { + out = append(out, normalizeEmail(email)) + } + } + return out +} diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index 3208826..95ccb86 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -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) diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index 70fb3fa..615d637 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -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"` diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index a6629fb..552d9a1 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -64,6 +64,7 @@ export type MailMessage = { labels?: MailLabel[] sendQueueId?: string sendQueueStatus?: SendQueueStatus + externalAccountId?: string } export type DNSRecord = { type: string; name: string; value: string; ttl: number } export type DNSCheckResult = { domain: string; status: string; checks: Record } @@ -122,6 +123,12 @@ export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; enabled: boolean; createdAt: string; appliedExistingCount?: number } export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string } export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; attachmentBytes: number; storageBytes: number; quotaBytes: number; quotaUsedPct: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] } +export type ExternalImapStorageMode = "local" | "remote" +export type ExternalImapTlsMode = "tls" | "starttls" | "plain" +export type ExternalImapAccount = { id: string; mailboxId: string; name: string; host: string; port: number; tlsMode: ExternalImapTlsMode; username: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean; lastSyncAt?: string; lastStatus: string; lastError?: string; createdAt: string; updatedAt: string } +export type ExternalImapAccountPayload = { mailboxId: string; name: string; host: string; port: number; tlsMode: ExternalImapTlsMode; username: string; password?: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean } +export type ExternalImapFolder = { name: string; role: string; unreadCount: number; totalCount: number } +export type ExternalImapSyncRun = { id: string; accountId: string; status: string; imported: number; skipped: number; failed: number; error?: string; startedAt: string; finishedAt?: string } export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string } export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] } export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 556510c..4508a37 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types" +import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types" export * from "./api-types" const REQUEST_TIMEOUT_MS = 15_000 @@ -61,6 +61,12 @@ export const api = { cleanupMail: (payload: { mailboxId: string; target: "empty-trash" | "empty-spam" | "archive-read-inbox" }) => request<{ ok: boolean; affected: number }>("/api/me/cleanup", { method: "POST", body: JSON.stringify(payload) }), mailboxApplyOptions: () => request("/api/me/mailbox-apply-options"), applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }), + externalImapAccounts: (mailboxId?: string) => request>(`/api/me/external-imap-accounts${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), + createExternalImapAccount: (payload: ExternalImapAccountPayload) => request("/api/me/external-imap-accounts", { method: "POST", body: JSON.stringify(payload) }), + updateExternalImapAccount: (id: string, payload: ExternalImapAccountPayload) => request(`/api/me/external-imap-accounts/${id}`, { method: "POST", body: JSON.stringify(payload) }), + deleteExternalImapAccount: (id: string) => request<{ ok: boolean }>(`/api/me/external-imap-accounts/${id}`, { method: "DELETE" }), + testExternalImapAccount: (id: string) => request<{ ok: boolean; folders: number }>(`/api/me/external-imap-accounts/${id}/test`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), + syncExternalImapAccount: (id: string) => request(`/api/me/external-imap-accounts/${id}/sync`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), adminOverview: () => request("/api/admin/overview"), users: () => request>("/api/admin/users"), permissionGroups: () => request & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"), @@ -115,6 +121,14 @@ export const api = { dnsRecords: (domainId: string) => request<{ items: DNSRecord[] }>(`/api/admin/domains/${domainId}/dns-records`), checkDns: (domainId: string) => request(`/api/admin/domains/${domainId}/check-dns`, { method: "POST" }), myMailboxes: () => request>("/api/mail/mailboxes"), + externalMailAccounts: () => request>("/api/mail/external-accounts"), + externalFolders: (id: string) => request>(`/api/mail/external-accounts/${id}/folders`), + externalMessages: (id: string, folder: string, cursor = "") => { + const params = new URLSearchParams({ folder, cursor }) + return request>(`/api/mail/external-accounts/${id}/messages?${params.toString()}`) + }, + externalMessage: (id: string, remoteId: string) => request(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`), + markExternalRead: (id: string, remoteId: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }), folders: (mailboxId?: string) => request>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), createFolder: (payload: { mailboxId?: string; name: string }) => { const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : "" diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 6863927..267cc6f 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -13,7 +13,7 @@ import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap import { useNavigate } from "react-router-dom" import type { ImperativePanelHandle } from "react-resizable-panels" import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react" -import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api" +import { api, ExternalImapAccount, ExternalImapFolder, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api" import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { useDisplayMode } from "@/lib/display-mode" @@ -61,7 +61,7 @@ const folderLabels: Record = { type ComposeDraft = { key: string; id?: string; mailboxId?: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string; html?: string; files?: File[]; isDraft?: boolean } type MailFilter = "all" | "unread" | "starred" | "attachments" -type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue" +type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue" | "external" type MailListResponse = { items?: MailMessage[]; nextCursor?: string } type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void } type MailNotificationState = { latestId: string; latestReceivedAt: string } @@ -98,6 +98,8 @@ export function MailPage() { const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false) const [mailFilter, setMailFilter] = React.useState("all") const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "") + const [selectedExternalAccountId, setSelectedExternalAccountId] = React.useState("") + const [externalFolder, setExternalFolder] = React.useState("INBOX") const [darkMode, setDarkMode] = React.useState(getInitialTheme) const [displayMode] = useDisplayMode() const isMobile = useIsMobile() @@ -138,6 +140,9 @@ export function MailPage() { const canManageSignatures = hasPermission(user, "mail.signatures.manage") const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail }) + const externalMailAccounts = useQuery({ queryKey: ["mail-external-accounts"], queryFn: api.externalMailAccounts, enabled: canAccessMail && canReadMail }) + const selectedExternalAccount = React.useMemo(() => externalMailAccounts.data?.items.find((item) => item.id === selectedExternalAccountId), [externalMailAccounts.data?.items, selectedExternalAccountId]) + const externalFolders = useQuery({ queryKey: ["mail-external-folders", selectedExternalAccountId], queryFn: () => api.externalFolders(selectedExternalAccountId), enabled: !!selectedExternalAccountId && canReadMail }) const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId]) const activeMailboxId = selectedMailbox?.id || "" @@ -174,7 +179,14 @@ export function MailPage() { getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && (mailView !== "label" || !!selectedLabelId), }) - const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail }) + const externalMessages = useInfiniteQuery({ + queryKey: ["external-messages", selectedExternalAccountId, externalFolder], + queryFn: ({ pageParam }) => api.externalMessages(selectedExternalAccountId, externalFolder, typeof pageParam === "string" ? pageParam : ""), + initialPageParam: "", + getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, + enabled: !!selectedExternalAccountId && canReadMail && mailView === "external", + }) + const detail = useQuery({ queryKey: ["message", selectedId, mailView, selectedExternalAccountId], queryFn: () => mailView === "external" ? api.externalMessage(selectedExternalAccountId, selectedId!) : api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail && (mailView !== "external" || !!selectedExternalAccountId) }) function updateCachedMessage(id: string, patch: Partial) { qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current) qc.setQueriesData({ queryKey: ["messages"] }, (current: InfiniteData | undefined) => { @@ -187,6 +199,16 @@ export function MailPage() { })), } }) + qc.setQueriesData({ queryKey: ["external-messages"] }, (current: InfiniteData | undefined) => { + if (!current?.pages) return current + return { + ...current, + pages: current.pages.map((page) => ({ + ...page, + items: (page.items || []).map((message) => message.id === id ? { ...message, ...patch } : message), + })), + } + }) } const star = useMutation({ mutationFn: ({ id, starred }: { id: string; starred: boolean }) => api.star(id, starred), @@ -210,6 +232,16 @@ export function MailPage() { }, onError: (error) => toast({ title: "操作失败", description: error.message }), }) + const markExternalRead = useMutation({ + mutationFn: ({ id, remoteId, read }: { id: string; remoteId: string; read: boolean }) => api.markExternalRead(id, remoteId, read), + onMutate: ({ remoteId, read }) => updateCachedMessage(remoteId, { isRead: read }), + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: ["external-messages"] }) + await qc.invalidateQueries({ queryKey: ["message"] }) + await qc.invalidateQueries({ queryKey: ["mail-external-folders"] }) + }, + onError: (error) => toast({ title: "操作失败", description: error.message }), + }) const addLabel = useMutation({ mutationFn: ({ id, label }: { id: string; label: MailLabel }) => api.addLabel(id, { name: label.name, color: label.color }), onMutate: async ({ id, label }) => { @@ -521,7 +553,7 @@ export function MailPage() { }, [mailRefreshInterval, publicSettings.data?.mailAutoRefresh, qc]) const selected = detail.data - const allMessages = messages.data?.pages.flatMap((page) => page.items || []) || [] + const allMessages = (mailView === "external" ? externalMessages.data?.pages : messages.data?.pages)?.flatMap((page) => page.items || []) || [] const visibleMessages = allMessages.filter((message) => { if (mailFilter === "unread") return !message.isRead if (mailFilter === "starred") return message.isStarred @@ -541,16 +573,18 @@ export function MailPage() { const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length const visibleSendQueueItems = sendQueueItems const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue) + const externalAccountItems = externalMailAccounts.data?.items || [] + const externalFolderItems = externalFolders.data?.items || [] const labelItems = labels.data?.items || [] const selectedLabel = labelItems.find((item) => item.id === selectedLabelId) - const viewTitle = mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder - const emptyMessage = getEmptyMessage(mailView, folder, allMessages.length) + const viewTitle = mailView === "external" ? `${selectedExternalAccount?.name || "外部邮箱"} · ${folderLabels[externalFolder] || externalFolder}` : mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder + const emptyMessage = getEmptyMessage(mailView, mailView === "external" ? externalFolder : folder, allMessages.length) const visibleMessageIds = visibleMessages.map((message) => message.id) const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected - const hasMoreMessages = !!messages.hasNextPage - const canLoadMore = !!messages.hasNextPage && !messages.isFetchingNextPage + const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : !!messages.hasNextPage + const canLoadMore = mailView === "external" ? !!externalMessages.hasNextPage && !externalMessages.isFetchingNextPage : !!messages.hasNextPage && !messages.isFetchingNextPage function toggleCompactSelectAll(checked: boolean) { setCompactSelectedIds(checked ? visibleMessageIds : []) } @@ -560,6 +594,9 @@ export function MailPage() { async function refreshMailData() { await Promise.all([ qc.invalidateQueries({ queryKey: ["messages"] }), + qc.invalidateQueries({ queryKey: ["external-messages"] }), + qc.invalidateQueries({ queryKey: ["mail-external-folders"] }), + qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }), qc.invalidateQueries({ queryKey: ["folders"] }), qc.invalidateQueries({ queryKey: ["mail-stats"] }), qc.invalidateQueries({ queryKey: ["labels"] }), @@ -659,6 +696,7 @@ export function MailPage() { } function switchMailbox(mailboxId: string) { setSelectedMailboxId(mailboxId) + setSelectedExternalAccountId("") setFolder("Inbox") setMailView("folder") setSelectedLabelId("") @@ -667,6 +705,7 @@ export function MailPage() { setMobileSidebarOpen(false) } function openFolder(nextFolder: string) { + setSelectedExternalAccountId("") setFolder(nextFolder) setMailView("folder") setSelectedLabelId("") @@ -675,6 +714,7 @@ export function MailPage() { setMobileSidebarOpen(false) } function openStarred() { + setSelectedExternalAccountId("") setMailView("starred") setSelectedLabelId("") setSelectedId(null) @@ -682,6 +722,7 @@ export function MailPage() { setMobileSidebarOpen(false) } function openScheduled() { + setSelectedExternalAccountId("") setMailView("scheduled") setSelectedLabelId("") setSelectedId(null) @@ -689,6 +730,7 @@ export function MailPage() { setMobileSidebarOpen(false) } function openMessageContextMenu(event: React.MouseEvent, message: MailMessage) { + if (mailView === "external") return event.preventDefault() event.stopPropagation() if (message.folder !== "Drafts") setSelectedId(message.id) @@ -711,6 +753,15 @@ export function MailPage() { else if (item.type === "sendQueue") openSendQueue() else openFolder(item.folderName) } + function openExternalFolder(account: ExternalImapAccount, folderName = "INBOX") { + setSelectedExternalAccountId(account.id) + setExternalFolder(folderName) + setMailView("external") + setSelectedLabelId("") + setSelectedId(null) + setMailFilter("all") + setMobileSidebarOpen(false) + } function reorderCustomFolder(draggedId: string, target: FolderDropTarget) { if (!canOrganizeMail || reorderFolders.isPending) return const foldersByID = new Map((folders.data?.items || []).map((item) => [item.id, item])) @@ -837,6 +888,7 @@ export function MailPage() { confirmDeleteMessage(message) } function openSendQueue() { + setSelectedExternalAccountId("") setMailView("sendQueue") setSelectedLabelId("") setSelectedId(null) @@ -848,6 +900,7 @@ export function MailPage() { setSendQueueAuditId(message.sendQueueId) } function openLabel(labelId: string) { + setSelectedExternalAccountId("") setSelectedLabelId(labelId) setMailView("label") setSelectedId(null) @@ -860,13 +913,14 @@ export function MailPage() { return } const message = allMessages.find((item) => item.id === messageId) - if (message?.folder === "Drafts") { + if (mailView !== "external" && message?.folder === "Drafts") { void openDraft(message) return } setSelectedId(messageId) if (message && !message.isRead && canOrganizeMail) { - markRead.mutate({ id: message.id, read: true }) + if (mailView === "external" && selectedExternalAccountId) markExternalRead.mutate({ id: selectedExternalAccountId, remoteId: message.id, read: true }) + else markRead.mutate({ id: message.id, read: true }) } } async function refreshMail() { @@ -977,6 +1031,41 @@ export function MailPage() { {folders.isLoading && } + {externalAccountItems.length > 0 && + {!sidebarCollapsed && 外部邮箱} + + + {externalAccountItems.map((account) => ( + + + openExternalFolder(account, "INBOX")} + > + + {!sidebarCollapsed && {account.name}} + {!sidebarCollapsed && {account.storageMode === "local" ? "同步" : "直连"}} + + + {!sidebarCollapsed && mailView === "external" && selectedExternalAccountId === account.id && externalFolderItems.map((item) => ( + + openExternalFolder(account, item.name)} + > + {folderIcons[item.role.toLowerCase()] || } + {folderLabels[item.role] || folderLabels[item.name] || item.name} + {item.unreadCount > 0 && {item.unreadCount}} + + + ))} + + ))} + + + } {(canReadMail || canManageLabels) && {!sidebarCollapsed && (
@@ -1115,10 +1204,10 @@ export function MailPage() { selectedIds={compactSelectedIds} allSelected={compactAllSelected} someSelected={compactSomeSelected} - loading={messages.isLoading} + loading={mailView === "external" ? externalMessages.isLoading : messages.isLoading} hasMore={hasMoreMessages} - loadingMore={messages.isFetchingNextPage} - onLoadMore={() => messages.fetchNextPage()} + loadingMore={mailView === "external" ? externalMessages.isFetchingNextPage : messages.isFetchingNextPage} + onLoadMore={() => mailView === "external" ? externalMessages.fetchNextPage() : messages.fetchNextPage()} emptyMessage={emptyMessage} selectedId={selectedId} selected={selected} @@ -1130,21 +1219,21 @@ export function MailPage() { onToggleSelected={toggleCompactSelect} scheduledDraftIds={scheduledDraftIds} onCloseReader={() => setSelectedId(null)} - onStar={(message) => star.mutate({ id: message.id, starred: !message.isStarred })} + onStar={(message) => { if (mailView !== "external") star.mutate({ id: message.id, starred: !message.isStarred }) }} onReply={openReply} onForward={openForward} onSendTimeline={openMessageSendTimeline} - onArchive={(message) => move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" })} - onDelete={confirmDeleteMessage} - onToggleRead={(message) => markRead.mutate({ id: message.id, read: !message.isRead })} + onArchive={(message) => { if (mailView !== "external") move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" }) }} + onDelete={(message) => { if (mailView !== "external") confirmDeleteMessage(message) }} + onToggleRead={(message) => mailView === "external" && selectedExternalAccountId ? markExternalRead.mutate({ id: selectedExternalAccountId, remoteId: message.id, read: !message.isRead }) : markRead.mutate({ id: message.id, read: !message.isRead })} onAddLabel={(message, label) => addLabel.mutate({ id: message.id, label })} onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })} bulkPending={bulkPending} onBulkAction={runBulkAction} onContextMenu={openMessageContextMenu} canSend={canSendMail} - canOrganize={canOrganizeMail} - canManageLabels={canManageLabels} + canOrganize={canOrganizeMail && mailView !== "external"} + canManageLabels={canManageLabels && mailView !== "external"} canDownloadAttachments={canDownloadAttachments} /> ) : ( @@ -1162,13 +1251,13 @@ export function MailPage() { {selectedCountOnPage > 0 && canOrganizeMail && }
- {messages.isLoading && } + {(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && } {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} canOrganize={canOrganizeMail} />)} - {!messages.isLoading && visibleMessages.length === 0 &&
{emptyMessage}
} - {!messages.isLoading && hasMoreMessages && ( + {!(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && visibleMessages.length === 0 &&
{emptyMessage}
} + {!(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && hasMoreMessages && (
-
)} @@ -1188,13 +1277,13 @@ export function MailPage() {
{canSendMail && } {canSendMail && } - {selected.sendQueueId && } - {canOrganizeMail && (selected.folder === "Archive" ? ( + {mailView !== "external" && selected.sendQueueId && } + {mailView !== "external" && canOrganizeMail && (selected.folder === "Archive" ? ( ) : ( ))} - {canOrganizeMail && } + {mailView !== "external" && canOrganizeMail && }
- {selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => canDownloadAttachments ? {a.filename}{formatBytes(a.sizeBytes)} :
{a.filename}{formatBytes(a.sizeBytes)}
)}
} + {selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => canDownloadAttachments ? {a.filename}{formatBytes(a.sizeBytes)} :
{a.filename}{formatBytes(a.sizeBytes)}
)}
}
} @@ -1237,7 +1326,7 @@ export function MailPage() { {canSendMail && }
- setQuery(e.target.value)} placeholder={mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" /> + setQuery(e.target.value)} disabled={mailView === "external"} placeholder={mailView === "external" ? "远端直连暂不支持搜索" : mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
)} @@ -1261,7 +1350,7 @@ export function MailPage() { {autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"} )} - {mailView !== "scheduled" && mailView !== "sendQueue" && ( + {mailView !== "scheduled" && mailView !== "sendQueue" && mailView !== "external" && ( <> {canOrganizeMail && } @@ -1281,7 +1370,7 @@ export function MailPage() {
- setQuery(e.target.value)} placeholder={mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" /> + setQuery(e.target.value)} disabled={mailView === "external"} placeholder={mailView === "external" ? "远端直连暂不支持搜索" : mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" />
{contentView} @@ -1449,6 +1538,7 @@ function FolderSkeleton() { return
{Array.from({ length: 6 }).map((_, i) =>
)}
} function getEmptyMessage(mailView: MailView, folder: string, total: number) { + if (mailView === "external") return total === 0 ? "远端文件夹没有邮件" : "当前筛选条件下没有远端邮件" if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件" if (mailView === "sendQueue") return total === 0 ? "发送队列为空" : "当前搜索没有匹配的发送任务" if (total > 0) return "当前筛选条件下没有邮件" @@ -1569,6 +1659,13 @@ function ScheduledStatusBadge({ status }: { status: ScheduledSend["status"] }) { ) } +function attachmentHref(message: MailMessage, attachmentId: string) { + if (message.externalAccountId) { + return `/api/mail/external-accounts/${encodeURIComponent(message.externalAccountId)}/attachments/${encodeURIComponent(message.id)}/${encodeURIComponent(attachmentId)}` + } + return `/api/mail/attachments/${attachmentId}` +} + const sendQueueStatusOptions: { value: SendQueueStatus | "all"; label: string }[] = [ { value: "all", label: "全部状态" }, { value: "queued", label: "排队中" }, @@ -2296,7 +2393,7 @@ function CompactMessageDetail({
- {selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => canDownloadAttachments ? {a.filename}{formatBytes(a.sizeBytes)} :
{a.filename}{formatBytes(a.sizeBytes)}
)}
} + {selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => canDownloadAttachments ? {a.filename}{formatBytes(a.sizeBytes)} :
{a.filename}{formatBytes(a.sizeBytes)}
)}
}
diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index b045ae5..c474472 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -2,9 +2,9 @@ import * as React from "react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import type { ImperativePanelHandle } from "react-resizable-panels" import { useNavigate, useSearchParams } from "react-router-dom" -import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react" +import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react" import { QRCodeSVG } from "qrcode.react" -import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" +import { api, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapStorageMode, ExternalImapTlsMode, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" import { cn, formatBytes } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { DisplayMode, useDisplayMode } from "@/lib/display-mode" @@ -101,6 +101,7 @@ export function ProfilePage() { const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked }) const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId]) const activeMailboxId = selectedMailbox?.id || "" + const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail }) const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && canManageRules && (canReadMail || canManageLabels) }) const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats }) @@ -198,6 +199,31 @@ export function ProfilePage() { }, onError: (error) => toast({ title: "申请失败", description: error.message }), }) + const createExternalImap = useMutation({ + mutationFn: api.createExternalImapAccount, + onSuccess: () => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }); toast({ title: "外部 IMAP 已保存" }) }, + onError: (error) => toast({ title: "保存失败", description: error.message }), + }) + const updateExternalImap = useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: ExternalImapAccountPayload }) => api.updateExternalImapAccount(id, payload), + onSuccess: () => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }); toast({ title: "外部 IMAP 已更新" }) }, + onError: (error) => toast({ title: "更新失败", description: error.message }), + }) + const deleteExternalImap = useMutation({ + mutationFn: api.deleteExternalImapAccount, + onSuccess: () => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }); toast({ title: "外部 IMAP 已删除" }) }, + onError: (error) => toast({ title: "删除失败", description: error.message }), + }) + const testExternalImap = useMutation({ + mutationFn: api.testExternalImapAccount, + onSuccess: (res) => toast({ title: `连接成功,发现 ${res.folders} 个文件夹` }), + onError: (error) => toast({ title: "连接失败", description: error.message }), + }) + const syncExternalImap = useMutation({ + mutationFn: api.syncExternalImapAccount, + onSuccess: (run) => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `同步完成:导入 ${run.imported},跳过 ${run.skipped}` }) }, + onError: (error) => toast({ title: "同步失败", description: error.message }), + }) React.useEffect(() => { if (!mailboxes.isSuccess) return @@ -291,7 +317,7 @@ export function ProfilePage() { ) function renderTab() { - if (tab === "mailboxes") return { if (!canAccessMail) return; setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} /> + if (tab === "mailboxes") return { if (!canAccessMail) return; setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} onCreateExternal={(payload) => createExternalImap.mutate(payload)} onUpdateExternal={(id, payload) => updateExternalImap.mutate({ id, payload })} onDeleteExternal={(id) => deleteExternalImap.mutate(id)} onTestExternal={(id) => testExternalImap.mutate(id)} onSyncExternal={(id) => syncExternalImap.mutate(id)} /> if (tab === "clients") return if (tab === "signatures") return createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} /> if (tab === "contacts") return createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} /> @@ -485,10 +511,43 @@ function LimitBadge({ label, value, unit }: { label: string; value?: number; uni ) } -function MailboxManagement({ mailboxes, applyOptions, applyPending, selectedMailboxId, onSelect, onCopy, onOpen, onApply }: { mailboxes: Mailbox[]; applyOptions?: MailboxApplyOptions; applyPending: boolean; selectedMailboxId: string; onSelect: (id: string) => void; onCopy: (text: string) => void; onOpen: (id: string) => void; onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise }) { +function MailboxManagement({ + mailboxes, + applyOptions, + applyPending, + selectedMailboxId, + externalAccounts, + externalPending, + onSelect, + onCopy, + onOpen, + onApply, + onCreateExternal, + onUpdateExternal, + onDeleteExternal, + onTestExternal, + onSyncExternal, +}: { + mailboxes: Mailbox[] + applyOptions?: MailboxApplyOptions + applyPending: boolean + selectedMailboxId: string + externalAccounts: ExternalImapAccount[] + externalPending: boolean + onSelect: (id: string) => void + onCopy: (text: string) => void + onOpen: (id: string) => void + onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise + onCreateExternal: (payload: ExternalImapAccountPayload) => void + onUpdateExternal: (id: string, payload: ExternalImapAccountPayload) => void + onDeleteExternal: (id: string) => void + onTestExternal: (id: string) => void + onSyncExternal: (id: string) => void +}) { const canApply = !!applyOptions?.enabled && (applyOptions.domains || []).length > 0 + const selectedMailbox = mailboxes.find((item) => item.id === selectedMailboxId) return ( -
+
{canApply && }
@@ -496,6 +555,41 @@ function MailboxManagement({ mailboxes, applyOptions, applyPending, selectedMail {mailboxes.map((m) =>
{m.address}
{selectedMailboxId === m.id && 当前}
)} {mailboxes.length === 0 && }
+ + +
+
+ 外部 IMAP 接入 +
接入其他邮箱,可选择同步到本地,或每次打开时直接从远端读取。
+
+ +
+
+ + {!selectedMailbox && } + {selectedMailbox && externalAccounts.length === 0 && } + {selectedMailbox && externalAccounts.map((account) => ( +
+
+
+
{account.name}
+ {account.enabled ? "已启用" : "已停用"} + {account.storageMode === "local" ? "本地存储" : "远端直连"} +
+
{account.username} · {account.host}:{account.port} · {account.tlsMode.toUpperCase()}
+
状态:{externalStatusLabel(account.lastStatus)}{account.lastSyncAt ? ` · 最近同步 ${formatDateTime(account.lastSyncAt)}` : ""}{account.lastError ? ` · ${account.lastError}` : ""}
+
+
+ + {account.storageMode === "local" && } + onUpdateExternal(account.id, payload)} /> + + +
+
+ ))} +
+
) } @@ -546,6 +640,94 @@ function ApplyMailboxDialog({ options, pending, onApply }: { options: MailboxApp ) } +function ExternalImapDialog({ account, mailboxId, disabled, pending, onSubmit }: { account?: ExternalImapAccount; mailboxId: string; disabled?: boolean; pending: boolean; onSubmit: (payload: ExternalImapAccountPayload) => void }) { + const [open, setOpen] = React.useState(false) + const [tlsMode, setTlsMode] = React.useState(account?.tlsMode || "tls") + const [storageMode, setStorageMode] = React.useState(account?.storageMode || "local") + const [syncReadState, setSyncReadState] = React.useState(account?.syncReadState ?? true) + const [enabled, setEnabled] = React.useState(account?.enabled ?? true) + React.useEffect(() => { + if (!open) return + setTlsMode(account?.tlsMode || "tls") + setStorageMode(account?.storageMode || "local") + setSyncReadState(account?.syncReadState ?? true) + setEnabled(account?.enabled ?? true) + }, [account, open]) + + function submit(event: React.FormEvent) { + event.preventDefault() + const form = new FormData(event.currentTarget) + const payload: ExternalImapAccountPayload = { + mailboxId, + name: String(form.get("name") || ""), + host: String(form.get("host") || ""), + port: Number(form.get("port") || (tlsMode === "tls" ? 993 : 143)), + tlsMode, + username: String(form.get("username") || ""), + password: String(form.get("password") || ""), + storageMode, + syncReadState, + enabled, + } + onSubmit(payload) + if (!pending) setOpen(false) + } + + return ( + + + + {account ? "编辑外部 IMAP" : "添加外部 IMAP"} +
+
+ + + + + + + + + + +
+ +
+ + +
+ + + + +
+
+
+ ) +} + +function externalPayloadFromAccount(account: ExternalImapAccount): ExternalImapAccountPayload { + return { mailboxId: account.mailboxId, name: account.name, host: account.host, port: account.port, tlsMode: account.tlsMode, username: account.username, password: "", storageMode: account.storageMode, syncReadState: account.syncReadState, enabled: account.enabled } +} + +function externalStatusLabel(status: string) { + return ({ idle: "未同步", ok: "正常", partial: "部分成功", error: "错误", running: "同步中" } as Record)[status] || status || "未知" +} + +function formatDateTime(value: string) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString() +} + function ClientSettingsSection({ mailboxes, selectedMailboxId, hostname, onSelectMailbox, onCopy }: { mailboxes: Mailbox[]; selectedMailboxId: string; hostname?: string; onSelectMailbox: (id: string) => void; onCopy: (text: string) => void }) { const selected = mailboxes.find((item) => item.id === selectedMailboxId) || mailboxes[0] const server = clientServerHost(hostname, selected?.address) diff --git a/deploy/.env.example b/deploy/.env.example index 5ca83e2..49d4bbb 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -132,6 +132,15 @@ LANQIN_MAIL_AUTO_REFRESH=true # 自动刷新间隔,单位:秒。 LANQIN_MAIL_REFRESH_SECONDS=30 +# 外部 IMAP 密码加密密钥。启用外部 IMAP 接入前必须设置为足够长的随机字符串。 +LANQIN_EXTERNAL_IMAP_SECRET_KEY= + +# 外部 IMAP 本地存储模式的后台同步间隔,单位:秒。 +LANQIN_EXTERNAL_IMAP_SYNC_SECONDS=300 + +# 是否允许用户配置 localhost / 内网 / link-local IMAP 主机。默认 false,避免 SSRF 风险。 +LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS=false + # ========================= # 系统 # ========================= diff --git a/deploy/README.md b/deploy/README.md index 9597cc1..db3f459 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -129,6 +129,7 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up - Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。 - Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。 - 第三方客户端可通过 LanQin API 提供的 SMTP `465/587` 发信;Webmail/API 和第三方客户端的“已发送”都由 API 写入,外发投递进入发送队列并由 API worker relay/retry,客户端后续 IMAP APPEND 到 Sent 会按 `Message-ID` 去重。 +- 用户可在个人邮箱管理中接入外部 IMAP 账号;本地存储模式会同步到 LanQin,远端直连模式每次从远端读取。启用前必须配置 `LANQIN_EXTERNAL_IMAP_SECRET_KEY`,默认不允许连接 localhost / 内网 / link-local IMAP 主机。 - send-as v1 支持本人邮箱、启用的别名转发 source 指向本人邮箱,或数据库表 `send_as_grants` 中显式授权的地址。 ## 邮件客户端 TLS 证书