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

- 新增外部 IMAP 账号的加密存储、同步任务与远端直连能力。
- Web 端个人邮箱页和邮件列表页支持查看、测试、同步和切换外部邮箱。
- 补充相关配置项、环境变量说明和使用文档。
This commit is contained in:
LanQin_
2026-06-25 17:09:46 +08:00
parent 2558aa96ed
commit 11734bf119
15 changed files with 1890 additions and 135 deletions
+4
View File
@@ -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_MAILDIR_ROOT` | Maildir 根目录 | `/var/mail/vhosts` |
| `LANQIN_CATCH_ALL_ENABLED` | 未注册收件地址是否进入全部邮件 | `false` | | `LANQIN_CATCH_ALL_ENABLED` | 未注册收件地址是否进入全部邮件 | `false` |
| `LANQIN_USER_MAILBOX_APPLY_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 → 投递到目标地址。 2. **发件**Webmail 调用 API → API 构造 MIME → SMTP 提交给 Postfix 或外部 SMTP → 投递到目标地址。
3. **本地投递**:开发环境中,系统内邮箱互发可直接写入对方 Inbox;未配置 `LANQIN_SMTP_HOST` 时不会真正投递外部收件人。 3. **本地投递**:开发环境中,系统内邮箱互发可直接写入对方 Inbox;未配置 `LANQIN_SMTP_HOST` 时不会真正投递外部收件人。
4. **第三方客户端**:可通过 SMTP 465/587、IMAP 993、POP3 995 连接;生产环境请配置匹配 `LANQIN_PUBLIC_HOSTNAME` 的证书。 4. **第三方客户端**:可通过 SMTP 465/587、IMAP 993、POP3 995 连接;生产环境请配置匹配 `LANQIN_PUBLIC_HOSTNAME` 的证书。
5. **外部邮箱接入**:个人邮箱管理可添加外部 IMAP 账号。本地存储模式会同步入库;远端直连模式每次读取远端,不写入本地邮件表。
## 开发与验证 ## 开发与验证
+2
View File
@@ -13,6 +13,8 @@ require (
require ( require (
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // 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-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/emersion/go-smtp v0.24.0 // indirect github.com/emersion/go-smtp v0.24.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
+35
View File
@@ -2,6 +2,10 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 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= 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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= 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 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= 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 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 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 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= 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/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 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= 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 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= 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 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+60
View File
@@ -29,6 +29,7 @@ type App struct {
policy *HTMLPolicy policy *HTMLPolicy
workerCancel context.CancelFunc workerCancel context.CancelFunc
maildirHealth *maildirSyncHealthTracker maildirHealth *maildirSyncHealthTracker
externalIMAP externalIMAPClientFactory
} }
func New(cfg Config, logger *slog.Logger) (*App, error) { 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) db.SetMaxOpenConns(1)
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker()} 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 { if err := a.configureSQLite(context.Background()); err != nil {
db.Close() db.Close()
return nil, err return nil, err
@@ -76,6 +78,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
go a.maildirWorker(workerCtx) go a.maildirWorker(workerCtx)
} }
go a.sendQueueWorker(workerCtx) go a.sendQueueWorker(workerCtx)
go a.externalIMAPWorker(workerCtx)
go a.smtpEventsCleanupWorker(workerCtx) go a.smtpEventsCleanupWorker(workerCtx)
return a, nil return a, nil
} }
@@ -344,6 +347,63 @@ func (a *App) migrate(ctx context.Context) error {
created_at TEXT NOT NULL created_at TEXT NOT NULL
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_pop3_events_user_created ON pop3_events(user_id, created_at)`, `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 ( `CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
+126
View File
@@ -48,6 +48,11 @@ func newTestApp(t *testing.T) *App {
PublicBaseURL: "http://localhost:5173", PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true, 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))) a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil { if err != nil {
t.Fatal(err) 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) { func TestParseMailAuthenticationResults(t *testing.T) {
header := textproto.MIMEHeader{} 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") header.Add("Authentication-Results", "mx.example.test; spf=pass smtp.mailfrom=sender.example; dkim=fail (bad signature) header.d=sender.example; dmarc=none")
+72 -66
View File
@@ -8,77 +8,83 @@ import (
) )
type Config struct { type Config struct {
Addr string Addr string
DBPath string DBPath string
DataDir string DataDir string
CookieName string CookieName string
SessionTTLHours int SessionTTLHours int
AdminEmail string AdminEmail string
AdminPassword string AdminPassword string
PublicHostname string PublicHostname string
PublicBaseURL string PublicBaseURL string
SMTPHost string SMTPHost string
SMTPPort string SMTPPort string
SMTPUsername string SMTPUsername string
SMTPPassword string SMTPPassword string
SMTPRequireTLS bool SMTPRequireTLS bool
SubmissionAddr string SubmissionAddr string
SubmissionTLSAddr string SubmissionTLSAddr string
SubmissionMaxMessageMB int SubmissionMaxMessageMB int
TLSCertFile string TLSCertFile string
TLSKeyFile string TLSKeyFile string
MaildirRoot string MaildirRoot string
MaildirScanSeconds int MaildirScanSeconds int
AllowInsecureHTTP bool AllowInsecureHTTP bool
OpenRegistration bool OpenRegistration bool
TwoFactorEnabled bool TwoFactorEnabled bool
TurnstileEnabled bool TurnstileEnabled bool
TurnstileSiteKey string TurnstileSiteKey string
TurnstileSecretKey string TurnstileSecretKey string
CatchAllEnabled bool CatchAllEnabled bool
MailAutoRefresh bool MailAutoRefresh bool
MailRefreshSeconds int MailRefreshSeconds int
UserMailboxApplyEnabled bool UserMailboxApplyEnabled bool
UserMailboxDomainIDs string UserMailboxDomainIDs string
ReservedMailboxPrefixes string ReservedMailboxPrefixes string
ExternalIMAPSecretKey string
ExternalIMAPSyncSeconds int
ExternalIMAPAllowPrivateHosts bool
} }
func LoadConfig() Config { func LoadConfig() Config {
dataDir := getenv("LANQIN_DATA_DIR", "./data") dataDir := getenv("LANQIN_DATA_DIR", "./data")
return Config{ return Config{
Addr: getenv("LANQIN_ADDR", ":8080"), Addr: getenv("LANQIN_ADDR", ":8080"),
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")), DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
DataDir: dataDir, DataDir: dataDir,
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"), CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7), SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")), AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""), AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"), PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"), PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
SMTPHost: getenv("LANQIN_SMTP_HOST", ""), SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"), SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""), SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""), SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false), SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""), SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""), SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35), SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""), TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""), TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""), MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30), MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true), AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false), OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false), TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false), TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""), TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""), TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false), CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true), MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30), MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false), UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""), 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"), ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
ExternalIMAPSecretKey: getenv("LANQIN_EXTERNAL_IMAP_SECRET_KEY", ""),
ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300),
ExternalIMAPAllowPrivateHosts: getenvBool("LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS", false),
} }
} }
File diff suppressed because it is too large Load Diff
+12
View File
@@ -59,6 +59,12 @@ func (a *App) Router() http.Handler {
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender) r.With(a.requireAuth, a.requirePermission(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(PermissionMailStats)).Get("/me/stats", a.handleMailStats)
r.With(a.requireAuth, a.requirePermission(PermissionMailOrganize)).Post("/me/cleanup", a.handleMailCleanup) 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.With(a.requireAuth).Get("/events", a.handleEvents)
r.Group(func(r chi.Router) { 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/messages", a.handleMailMessages)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages) 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/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(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", a.handleSendQueue)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit)
+70 -31
View File
@@ -77,37 +77,38 @@ type MailLabel struct {
} }
type MailMessage struct { type MailMessage struct {
ID string `json:"id"` ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"` MailboxID string `json:"mailboxId,omitempty"`
MailboxAddress string `json:"mailboxAddress,omitempty"` MailboxAddress string `json:"mailboxAddress,omitempty"`
OwnerEmail string `json:"ownerEmail,omitempty"` OwnerEmail string `json:"ownerEmail,omitempty"`
RecipientAddr string `json:"recipientAddress,omitempty"` RecipientAddr string `json:"recipientAddress,omitempty"`
FolderID string `json:"folderId"` FolderID string `json:"folderId"`
Folder string `json:"folder"` Folder string `json:"folder"`
MessageUID string `json:"messageUid"` MessageUID string `json:"messageUid"`
IMAPUID int64 `json:"imapUid"` IMAPUID int64 `json:"imapUid"`
IMAPModSeq int64 `json:"imapModseq"` IMAPModSeq int64 `json:"imapModseq"`
MessageID string `json:"messageId"` MessageID string `json:"messageId"`
Subject string `json:"subject"` Subject string `json:"subject"`
From string `json:"from"` From string `json:"from"`
FromName string `json:"fromName,omitempty"` FromName string `json:"fromName,omitempty"`
To []string `json:"to"` To []string `json:"to"`
CC []string `json:"cc"` CC []string `json:"cc"`
BCC []string `json:"bcc,omitempty"` BCC []string `json:"bcc,omitempty"`
SentAt time.Time `json:"sentAt"` SentAt time.Time `json:"sentAt"`
ReceivedAt time.Time `json:"receivedAt"` ReceivedAt time.Time `json:"receivedAt"`
Snippet string `json:"snippet"` Snippet string `json:"snippet"`
BodyText string `json:"bodyText,omitempty"` BodyText string `json:"bodyText,omitempty"`
BodyHTML string `json:"bodyHtml,omitempty"` BodyHTML string `json:"bodyHtml,omitempty"`
IsRead bool `json:"isRead"` IsRead bool `json:"isRead"`
IsStarred bool `json:"isStarred"` IsStarred bool `json:"isStarred"`
HasAttachments bool `json:"hasAttachments"` HasAttachments bool `json:"hasAttachments"`
SizeBytes int64 `json:"sizeBytes"` SizeBytes int64 `json:"sizeBytes"`
Labels []MailLabel `json:"labels,omitempty"` Labels []MailLabel `json:"labels,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"` Attachments []Attachment `json:"attachments,omitempty"`
Authentication MailAuthentication `json:"authentication"` Authentication MailAuthentication `json:"authentication"`
SendQueueID string `json:"sendQueueId,omitempty"` SendQueueID string `json:"sendQueueId,omitempty"`
SendQueueStatus string `json:"sendQueueStatus,omitempty"` SendQueueStatus string `json:"sendQueueStatus,omitempty"`
ExternalAccountID string `json:"externalAccountId,omitempty"`
} }
type MailAuthentication struct { type MailAuthentication struct {
@@ -227,6 +228,44 @@ type MailStatsFolderCount struct {
Bytes int64 `json:"bytes"` 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 { type SendQueueEntry struct {
ID string `json:"id"` ID string `json:"id"`
MailboxID string `json:"mailboxId"` MailboxID string `json:"mailboxId"`
+7
View File
@@ -64,6 +64,7 @@ export type MailMessage = {
labels?: MailLabel[] labels?: MailLabel[]
sendQueueId?: string sendQueueId?: string
sendQueueStatus?: SendQueueStatus sendQueueStatus?: SendQueueStatus
externalAccountId?: string
} }
export type DNSRecord = { type: string; name: string; value: string; ttl: number } export type DNSRecord = { type: string; name: string; value: string; ttl: number }
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> } export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
@@ -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 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 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 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 MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] } export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number } export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number }
+15 -1
View File
@@ -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" export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000 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) }), 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<MailboxApplyOptions>("/api/me/mailbox-apply-options"), mailboxApplyOptions: () => request<MailboxApplyOptions>("/api/me/mailbox-apply-options"),
applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request<Mailbox>("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }), applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request<Mailbox>("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }),
externalImapAccounts: (mailboxId?: string) => request<ListResponse<ExternalImapAccount>>(`/api/me/external-imap-accounts${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
createExternalImapAccount: (payload: ExternalImapAccountPayload) => request<ExternalImapAccount>("/api/me/external-imap-accounts", { method: "POST", body: JSON.stringify(payload) }),
updateExternalImapAccount: (id: string, payload: ExternalImapAccountPayload) => request<ExternalImapAccount>(`/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<ExternalImapSyncRun>(`/api/me/external-imap-accounts/${id}/sync`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
adminOverview: () => request<AdminOverview>("/api/admin/overview"), adminOverview: () => request<AdminOverview>("/api/admin/overview"),
users: () => request<ListResponse<AdminUser>>("/api/admin/users"), users: () => request<ListResponse<AdminUser>>("/api/admin/users"),
permissionGroups: () => request<ListResponse<PermissionGroup> & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"), permissionGroups: () => request<ListResponse<PermissionGroup> & { 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`), dnsRecords: (domainId: string) => request<{ items: DNSRecord[] }>(`/api/admin/domains/${domainId}/dns-records`),
checkDns: (domainId: string) => request<DNSCheckResult>(`/api/admin/domains/${domainId}/check-dns`, { method: "POST" }), checkDns: (domainId: string) => request<DNSCheckResult>(`/api/admin/domains/${domainId}/check-dns`, { method: "POST" }),
myMailboxes: () => request<ListResponse<Mailbox>>("/api/mail/mailboxes"), myMailboxes: () => request<ListResponse<Mailbox>>("/api/mail/mailboxes"),
externalMailAccounts: () => request<ListResponse<ExternalImapAccount>>("/api/mail/external-accounts"),
externalFolders: (id: string) => request<ListResponse<ExternalImapFolder>>(`/api/mail/external-accounts/${id}/folders`),
externalMessages: (id: string, folder: string, cursor = "") => {
const params = new URLSearchParams({ folder, cursor })
return request<ListResponse<MailMessage>>(`/api/mail/external-accounts/${id}/messages?${params.toString()}`)
},
externalMessage: (id: string, remoteId: string) => request<MailMessage>(`/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<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
createFolder: (payload: { mailboxId?: string; name: string }) => { createFolder: (payload: { mailboxId?: string; name: string }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : "" const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
+129 -32
View File
@@ -13,7 +13,7 @@ import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import type { ImperativePanelHandle } from "react-resizable-panels" 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 { 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 { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme" import { applyTheme, getInitialTheme } from "@/lib/theme"
import { useDisplayMode } from "@/lib/display-mode" import { useDisplayMode } from "@/lib/display-mode"
@@ -61,7 +61,7 @@ const folderLabels: Record<string, string> = {
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 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 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 MailListResponse = { items?: MailMessage[]; nextCursor?: string }
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void } type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
type MailNotificationState = { latestId: string; latestReceivedAt: string } type MailNotificationState = { latestId: string; latestReceivedAt: string }
@@ -98,6 +98,8 @@ export function MailPage() {
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false) const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false)
const [mailFilter, setMailFilter] = React.useState<MailFilter>("all") const [mailFilter, setMailFilter] = React.useState<MailFilter>("all")
const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "") 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 [darkMode, setDarkMode] = React.useState(getInitialTheme)
const [displayMode] = useDisplayMode() const [displayMode] = useDisplayMode()
const isMobile = useIsMobile() const isMobile = useIsMobile()
@@ -138,6 +140,9 @@ export function MailPage() {
const canManageSignatures = hasPermission(user, "mail.signatures.manage") const canManageSignatures = hasPermission(user, "mail.signatures.manage")
const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail }) 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 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 selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId])
const activeMailboxId = selectedMailbox?.id || "" const activeMailboxId = selectedMailbox?.id || ""
@@ -174,7 +179,14 @@ export function MailPage() {
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && (mailView !== "label" || !!selectedLabelId), 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<MailMessage>) { function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current) qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current)
qc.setQueriesData({ queryKey: ["messages"] }, (current: InfiniteData<MailListResponse> | undefined) => { qc.setQueriesData({ queryKey: ["messages"] }, (current: InfiniteData<MailListResponse> | undefined) => {
@@ -187,6 +199,16 @@ export function MailPage() {
})), })),
} }
}) })
qc.setQueriesData({ queryKey: ["external-messages"] }, (current: InfiniteData<MailListResponse> | 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({ const star = useMutation({
mutationFn: ({ id, starred }: { id: string; starred: boolean }) => api.star(id, starred), 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 }), 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({ const addLabel = useMutation({
mutationFn: ({ id, label }: { id: string; label: MailLabel }) => api.addLabel(id, { name: label.name, color: label.color }), mutationFn: ({ id, label }: { id: string; label: MailLabel }) => api.addLabel(id, { name: label.name, color: label.color }),
onMutate: async ({ id, label }) => { onMutate: async ({ id, label }) => {
@@ -521,7 +553,7 @@ export function MailPage() {
}, [mailRefreshInterval, publicSettings.data?.mailAutoRefresh, qc]) }, [mailRefreshInterval, publicSettings.data?.mailAutoRefresh, qc])
const selected = detail.data 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) => { const visibleMessages = allMessages.filter((message) => {
if (mailFilter === "unread") return !message.isRead if (mailFilter === "unread") return !message.isRead
if (mailFilter === "starred") return message.isStarred 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 sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length
const visibleSendQueueItems = sendQueueItems const visibleSendQueueItems = sendQueueItems
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue) 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 labelItems = labels.data?.items || []
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId) const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
const viewTitle = mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder 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, folder, allMessages.length) const emptyMessage = getEmptyMessage(mailView, mailView === "external" ? externalFolder : folder, allMessages.length)
const visibleMessageIds = visibleMessages.map((message) => message.id) const visibleMessageIds = visibleMessages.map((message) => message.id)
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length
const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected
const hasMoreMessages = !!messages.hasNextPage const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : !!messages.hasNextPage
const canLoadMore = !!messages.hasNextPage && !messages.isFetchingNextPage const canLoadMore = mailView === "external" ? !!externalMessages.hasNextPage && !externalMessages.isFetchingNextPage : !!messages.hasNextPage && !messages.isFetchingNextPage
function toggleCompactSelectAll(checked: boolean) { function toggleCompactSelectAll(checked: boolean) {
setCompactSelectedIds(checked ? visibleMessageIds : []) setCompactSelectedIds(checked ? visibleMessageIds : [])
} }
@@ -560,6 +594,9 @@ export function MailPage() {
async function refreshMailData() { async function refreshMailData() {
await Promise.all([ await Promise.all([
qc.invalidateQueries({ queryKey: ["messages"] }), 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: ["folders"] }),
qc.invalidateQueries({ queryKey: ["mail-stats"] }), qc.invalidateQueries({ queryKey: ["mail-stats"] }),
qc.invalidateQueries({ queryKey: ["labels"] }), qc.invalidateQueries({ queryKey: ["labels"] }),
@@ -659,6 +696,7 @@ export function MailPage() {
} }
function switchMailbox(mailboxId: string) { function switchMailbox(mailboxId: string) {
setSelectedMailboxId(mailboxId) setSelectedMailboxId(mailboxId)
setSelectedExternalAccountId("")
setFolder("Inbox") setFolder("Inbox")
setMailView("folder") setMailView("folder")
setSelectedLabelId("") setSelectedLabelId("")
@@ -667,6 +705,7 @@ export function MailPage() {
setMobileSidebarOpen(false) setMobileSidebarOpen(false)
} }
function openFolder(nextFolder: string) { function openFolder(nextFolder: string) {
setSelectedExternalAccountId("")
setFolder(nextFolder) setFolder(nextFolder)
setMailView("folder") setMailView("folder")
setSelectedLabelId("") setSelectedLabelId("")
@@ -675,6 +714,7 @@ export function MailPage() {
setMobileSidebarOpen(false) setMobileSidebarOpen(false)
} }
function openStarred() { function openStarred() {
setSelectedExternalAccountId("")
setMailView("starred") setMailView("starred")
setSelectedLabelId("") setSelectedLabelId("")
setSelectedId(null) setSelectedId(null)
@@ -682,6 +722,7 @@ export function MailPage() {
setMobileSidebarOpen(false) setMobileSidebarOpen(false)
} }
function openScheduled() { function openScheduled() {
setSelectedExternalAccountId("")
setMailView("scheduled") setMailView("scheduled")
setSelectedLabelId("") setSelectedLabelId("")
setSelectedId(null) setSelectedId(null)
@@ -689,6 +730,7 @@ export function MailPage() {
setMobileSidebarOpen(false) setMobileSidebarOpen(false)
} }
function openMessageContextMenu(event: React.MouseEvent, message: MailMessage) { function openMessageContextMenu(event: React.MouseEvent, message: MailMessage) {
if (mailView === "external") return
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (message.folder !== "Drafts") setSelectedId(message.id) if (message.folder !== "Drafts") setSelectedId(message.id)
@@ -711,6 +753,15 @@ export function MailPage() {
else if (item.type === "sendQueue") openSendQueue() else if (item.type === "sendQueue") openSendQueue()
else openFolder(item.folderName) 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) { function reorderCustomFolder(draggedId: string, target: FolderDropTarget) {
if (!canOrganizeMail || reorderFolders.isPending) return if (!canOrganizeMail || reorderFolders.isPending) return
const foldersByID = new Map((folders.data?.items || []).map((item) => [item.id, item])) const foldersByID = new Map((folders.data?.items || []).map((item) => [item.id, item]))
@@ -837,6 +888,7 @@ export function MailPage() {
confirmDeleteMessage(message) confirmDeleteMessage(message)
} }
function openSendQueue() { function openSendQueue() {
setSelectedExternalAccountId("")
setMailView("sendQueue") setMailView("sendQueue")
setSelectedLabelId("") setSelectedLabelId("")
setSelectedId(null) setSelectedId(null)
@@ -848,6 +900,7 @@ export function MailPage() {
setSendQueueAuditId(message.sendQueueId) setSendQueueAuditId(message.sendQueueId)
} }
function openLabel(labelId: string) { function openLabel(labelId: string) {
setSelectedExternalAccountId("")
setSelectedLabelId(labelId) setSelectedLabelId(labelId)
setMailView("label") setMailView("label")
setSelectedId(null) setSelectedId(null)
@@ -860,13 +913,14 @@ export function MailPage() {
return return
} }
const message = allMessages.find((item) => item.id === messageId) const message = allMessages.find((item) => item.id === messageId)
if (message?.folder === "Drafts") { if (mailView !== "external" && message?.folder === "Drafts") {
void openDraft(message) void openDraft(message)
return return
} }
setSelectedId(messageId) setSelectedId(messageId)
if (message && !message.isRead && canOrganizeMail) { 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() { async function refreshMail() {
@@ -977,6 +1031,41 @@ export function MailPage() {
{folders.isLoading && <FolderSkeleton />} {folders.isLoading && <FolderSkeleton />}
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
{externalAccountItems.length > 0 && <SidebarGroup>
{!sidebarCollapsed && <SidebarGroupLabel></SidebarGroupLabel>}
<SidebarGroupContent>
<SidebarMenu>
{externalAccountItems.map((account) => (
<React.Fragment key={account.id}>
<SidebarMenuItem>
<SidebarMenuButton
isActive={mailView === "external" && selectedExternalAccountId === account.id}
className={cn(sidebarCollapsed && "justify-center px-0")}
onClick={() => openExternalFolder(account, "INBOX")}
>
<Mail className="h-4 w-4" />
{!sidebarCollapsed && <span>{account.name}</span>}
{!sidebarCollapsed && <Badge variant="outline" className="ml-auto">{account.storageMode === "local" ? "同步" : "直连"}</Badge>}
</SidebarMenuButton>
</SidebarMenuItem>
{!sidebarCollapsed && mailView === "external" && selectedExternalAccountId === account.id && externalFolderItems.map((item) => (
<SidebarMenuItem key={`${account.id}-${item.name}`}>
<SidebarMenuButton
isActive={externalFolder === item.name}
className="pl-8"
onClick={() => openExternalFolder(account, item.name)}
>
{folderIcons[item.role.toLowerCase()] || <Inbox className="h-4 w-4" />}
<span>{folderLabels[item.role] || folderLabels[item.name] || item.name}</span>
{item.unreadCount > 0 && <Badge variant="secondary" className="ml-auto">{item.unreadCount}</Badge>}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</React.Fragment>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>}
{(canReadMail || canManageLabels) && <SidebarGroup> {(canReadMail || canManageLabels) && <SidebarGroup>
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<div className="flex items-center justify-between px-2 py-1.5"> <div className="flex items-center justify-between px-2 py-1.5">
@@ -1115,10 +1204,10 @@ export function MailPage() {
selectedIds={compactSelectedIds} selectedIds={compactSelectedIds}
allSelected={compactAllSelected} allSelected={compactAllSelected}
someSelected={compactSomeSelected} someSelected={compactSomeSelected}
loading={messages.isLoading} loading={mailView === "external" ? externalMessages.isLoading : messages.isLoading}
hasMore={hasMoreMessages} hasMore={hasMoreMessages}
loadingMore={messages.isFetchingNextPage} loadingMore={mailView === "external" ? externalMessages.isFetchingNextPage : messages.isFetchingNextPage}
onLoadMore={() => messages.fetchNextPage()} onLoadMore={() => mailView === "external" ? externalMessages.fetchNextPage() : messages.fetchNextPage()}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
selectedId={selectedId} selectedId={selectedId}
selected={selected} selected={selected}
@@ -1130,21 +1219,21 @@ export function MailPage() {
onToggleSelected={toggleCompactSelect} onToggleSelected={toggleCompactSelect}
scheduledDraftIds={scheduledDraftIds} scheduledDraftIds={scheduledDraftIds}
onCloseReader={() => setSelectedId(null)} 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} onReply={openReply}
onForward={openForward} onForward={openForward}
onSendTimeline={openMessageSendTimeline} onSendTimeline={openMessageSendTimeline}
onArchive={(message) => move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" })} onArchive={(message) => { if (mailView !== "external") move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" }) }}
onDelete={confirmDeleteMessage} onDelete={(message) => { if (mailView !== "external") confirmDeleteMessage(message) }}
onToggleRead={(message) => markRead.mutate({ id: message.id, read: !message.isRead })} 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 })} onAddLabel={(message, label) => addLabel.mutate({ id: message.id, label })}
onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })} onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })}
bulkPending={bulkPending} bulkPending={bulkPending}
onBulkAction={runBulkAction} onBulkAction={runBulkAction}
onContextMenu={openMessageContextMenu} onContextMenu={openMessageContextMenu}
canSend={canSendMail} canSend={canSendMail}
canOrganize={canOrganizeMail} canOrganize={canOrganizeMail && mailView !== "external"}
canManageLabels={canManageLabels} canManageLabels={canManageLabels && mailView !== "external"}
canDownloadAttachments={canDownloadAttachments} canDownloadAttachments={canDownloadAttachments}
/> />
) : ( ) : (
@@ -1162,13 +1251,13 @@ export function MailPage() {
{selectedCountOnPage > 0 && canOrganizeMail && <BulkActionMenu pending={bulkPending} onAction={runBulkAction} />} {selectedCountOnPage > 0 && canOrganizeMail && <BulkActionMenu pending={bulkPending} onAction={runBulkAction} />}
</div> </div>
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
{messages.isLoading && <MessageSkeleton />} {(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && <MessageSkeleton />}
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} canOrganize={canOrganizeMail} />)} {visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => 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 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>} {!(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && visibleMessages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
{!messages.isLoading && hasMoreMessages && ( {!(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && hasMoreMessages && (
<div className="border-b p-4 text-center"> <div className="border-b p-4 text-center">
<Button variant="outline" size="sm" disabled={!canLoadMore} onClick={() => messages.fetchNextPage()}> <Button variant="outline" size="sm" disabled={!canLoadMore} onClick={() => mailView === "external" ? externalMessages.fetchNextPage() : messages.fetchNextPage()}>
{messages.isFetchingNextPage ? "加载中..." : "加载更多"} {(mailView === "external" ? externalMessages.isFetchingNextPage : messages.isFetchingNextPage) ? "加载中..." : "加载更多"}
</Button> </Button>
</div> </div>
)} )}
@@ -1188,13 +1277,13 @@ export function MailPage() {
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
{canSendMail && <Button variant="outline" size="sm" onClick={() => openReply(selected)}><Reply className="h-4 w-4" /></Button>} {canSendMail && <Button variant="outline" size="sm" onClick={() => openReply(selected)}><Reply className="h-4 w-4" /></Button>}
{canSendMail && <Button variant="outline" size="sm" onClick={() => openForward(selected)}><Forward className="h-4 w-4" /></Button>} {canSendMail && <Button variant="outline" size="sm" onClick={() => openForward(selected)}><Forward className="h-4 w-4" /></Button>}
{selected.sendQueueId && <Button variant="outline" size="sm" onClick={() => openMessageSendTimeline(selected)}><History className="h-4 w-4" />线</Button>} {mailView !== "external" && selected.sendQueueId && <Button variant="outline" size="sm" onClick={() => openMessageSendTimeline(selected)}><History className="h-4 w-4" />线</Button>}
{canOrganizeMail && (selected.folder === "Archive" ? ( {mailView !== "external" && canOrganizeMail && (selected.folder === "Archive" ? (
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Inbox" })}></Button> <Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Inbox" })}></Button>
) : ( ) : (
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}></Button> <Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}></Button>
))} ))}
{canOrganizeMail && <Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}></Button>} {mailView !== "external" && canOrganizeMail && <Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}></Button>}
</div> </div>
</div> </div>
<MessageMetaPanel <MessageMetaPanel
@@ -1205,7 +1294,7 @@ export function MailPage() {
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<div className="p-6"> <div className="p-6">
<MailHtmlFrame message={selected} /> <MailHtmlFrame message={selected} />
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium"></div><div className="space-y-2">{selected.attachments.map((a) => canDownloadAttachments ? <a className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-accent" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a> : <div className="flex items-center justify-between rounded-md border p-3 text-sm text-muted-foreground" key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span>{formatBytes(a.sizeBytes)}</span></div>)}</div></div>} {selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium"></div><div className="space-y-2">{selected.attachments.map((a) => canDownloadAttachments ? <a className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-accent" href={attachmentHref(selected, a.id)} key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a> : <div className="flex items-center justify-between rounded-md border p-3 text-sm text-muted-foreground" key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span>{formatBytes(a.sizeBytes)}</span></div>)}</div></div>}
</div> </div>
</ScrollArea> </ScrollArea>
</div>} </div>}
@@ -1237,7 +1326,7 @@ export function MailPage() {
{canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>} {canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>}
<div className="relative basis-full"> <div className="relative basis-full">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" /> <Input value={query} onChange={(e) => setQuery(e.target.value)} disabled={mailView === "external"} placeholder={mailView === "external" ? "远端直连暂不支持搜索" : mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
</div> </div>
</header> </header>
)} )}
@@ -1261,7 +1350,7 @@ export function MailPage() {
{autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"} {autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"}
</div> </div>
)} )}
{mailView !== "scheduled" && mailView !== "sendQueue" && ( {mailView !== "scheduled" && mailView !== "sendQueue" && mailView !== "external" && (
<> <>
{canOrganizeMail && <Button variant="outline" size="sm" disabled={!activeMailboxId || markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" /></Button>} {canOrganizeMail && <Button variant="outline" size="sm" disabled={!activeMailboxId || markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" /></Button>}
<DropdownMenu> <DropdownMenu>
@@ -1281,7 +1370,7 @@ export function MailPage() {
</div> </div>
<div className="relative w-full max-w-md"> <div className="relative w-full max-w-md">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" /> <Input value={query} onChange={(e) => setQuery(e.target.value)} disabled={mailView === "external"} placeholder={mailView === "external" ? "远端直连暂不支持搜索" : mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" />
</div> </div>
</header> </header>
{contentView} {contentView}
@@ -1449,6 +1538,7 @@ function FolderSkeleton() { return <div className="space-y-2 p-2"><Skeleton clas
function MessageSkeleton() { return <div className="space-y-0">{Array.from({ length: 6 }).map((_, i) => <div className="space-y-2 border-b p-4" key={i}><Skeleton className="h-4 w-1/2" /><Skeleton className="h-4 w-4/5" /><Skeleton className="h-3 w-full" /></div>)}</div> } function MessageSkeleton() { return <div className="space-y-0">{Array.from({ length: 6 }).map((_, i) => <div className="space-y-2 border-b p-4" key={i}><Skeleton className="h-4 w-1/2" /><Skeleton className="h-4 w-4/5" /><Skeleton className="h-3 w-full" /></div>)}</div> }
function getEmptyMessage(mailView: MailView, folder: string, total: number) { function getEmptyMessage(mailView: MailView, folder: string, total: number) {
if (mailView === "external") return total === 0 ? "远端文件夹没有邮件" : "当前筛选条件下没有远端邮件"
if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件" if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件"
if (mailView === "sendQueue") return total === 0 ? "发送队列为空" : "当前搜索没有匹配的发送任务" if (mailView === "sendQueue") return total === 0 ? "发送队列为空" : "当前搜索没有匹配的发送任务"
if (total > 0) return "当前筛选条件下没有邮件" 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 }[] = [ const sendQueueStatusOptions: { value: SendQueueStatus | "all"; label: string }[] = [
{ value: "all", label: "全部状态" }, { value: "all", label: "全部状态" },
{ value: "queued", label: "排队中" }, { value: "queued", label: "排队中" },
@@ -2296,7 +2393,7 @@ function CompactMessageDetail({
</div> </div>
<div className="py-6 sm:py-8"> <div className="py-6 sm:py-8">
<MailHtmlFrame message={selected} /> <MailHtmlFrame message={selected} />
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium"></div><div className="space-y-2">{selected.attachments.map((a) => canDownloadAttachments ? <a className="flex flex-col gap-1 rounded-md border p-3 text-sm hover:bg-accent sm:flex-row sm:items-center sm:justify-between" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex min-w-0 items-center gap-2"><Paperclip className="h-4 w-4 shrink-0" /><span className="truncate">{a.filename}</span></span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a> : <div className="flex flex-col gap-1 rounded-md border p-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between" key={a.id}><span className="flex min-w-0 items-center gap-2"><Paperclip className="h-4 w-4 shrink-0" /><span className="truncate">{a.filename}</span></span><span>{formatBytes(a.sizeBytes)}</span></div>)}</div></div>} {selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium"></div><div className="space-y-2">{selected.attachments.map((a) => canDownloadAttachments ? <a className="flex flex-col gap-1 rounded-md border p-3 text-sm hover:bg-accent sm:flex-row sm:items-center sm:justify-between" href={attachmentHref(selected, a.id)} key={a.id}><span className="flex min-w-0 items-center gap-2"><Paperclip className="h-4 w-4 shrink-0" /><span className="truncate">{a.filename}</span></span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a> : <div className="flex flex-col gap-1 rounded-md border p-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between" key={a.id}><span className="flex min-w-0 items-center gap-2"><Paperclip className="h-4 w-4 shrink-0" /><span className="truncate">{a.filename}</span></span><span>{formatBytes(a.sizeBytes)}</span></div>)}</div></div>}
</div> </div>
</div> </div>
</ScrollArea> </ScrollArea>
+187 -5
View File
@@ -2,9 +2,9 @@ import * as React from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
import { useNavigate, useSearchParams } from "react-router-dom" 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 { 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 { cn, formatBytes } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme" import { applyTheme, getInitialTheme } from "@/lib/theme"
import { DisplayMode, useDisplayMode } from "@/lib/display-mode" 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 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 selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
const activeMailboxId = selectedMailbox?.id || "" 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 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 }) 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 }), 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(() => { React.useEffect(() => {
if (!mailboxes.isSuccess) return if (!mailboxes.isSuccess) return
@@ -291,7 +317,7 @@ export function ProfilePage() {
</div> </div>
) )
function renderTab() { function renderTab() {
if (tab === "mailboxes") return <MailboxManagement mailboxes={canAccessMail ? mailboxes.data?.items || [] : []} applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} /> if (tab === "mailboxes") return <MailboxManagement mailboxes={canAccessMail ? mailboxes.data?.items || [] : []} applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} externalAccounts={externalImapAccounts.data?.items || []} externalPending={createExternalImap.isPending || updateExternalImap.isPending || deleteExternalImap.isPending || testExternalImap.isPending || syncExternalImap.isPending} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { 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 <ClientSettingsSection mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} hostname={publicSettings.data?.publicHostname} onSelectMailbox={setMailboxId} onCopy={copy} /> if (tab === "clients") return <ClientSettingsSection mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} hostname={publicSettings.data?.publicHostname} onSelectMailbox={setMailboxId} onCopy={copy} />
if (tab === "signatures") return <SignaturesSection items={signatures.data?.items || []} mailboxes={mailboxes.data?.items || []} loading={signatures.isLoading} pending={createSignature.isPending || updateSignature.isPending || setDefaultSignature.isPending || deleteSignature.isPending} onCreate={(form) => createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} /> if (tab === "signatures") return <SignaturesSection items={signatures.data?.items || []} mailboxes={mailboxes.data?.items || []} loading={signatures.isLoading} pending={createSignature.isPending || updateSignature.isPending || setDefaultSignature.isPending || deleteSignature.isPending} onCreate={(form) => createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} />
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} /> if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => 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<void> }) { 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<void>
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 canApply = !!applyOptions?.enabled && (applyOptions.domains || []).length > 0
const selectedMailbox = mailboxes.find((item) => item.id === selectedMailboxId)
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<div className="flex justify-end"> <div className="flex justify-end">
{canApply && <ApplyMailboxDialog options={applyOptions} pending={applyPending} onApply={onApply} />} {canApply && <ApplyMailboxDialog options={applyOptions} pending={applyPending} onApply={onApply} />}
</div> </div>
@@ -496,6 +555,41 @@ function MailboxManagement({ mailboxes, applyOptions, applyPending, selectedMail
{mailboxes.map((m) => <Card key={m.id} className={cn(selectedMailboxId === m.id && "border-primary")}><CardHeader><div className="flex items-start justify-between gap-3"><div className="min-w-0"><CardTitle className="truncate text-base">{m.address}</CardTitle></div>{selectedMailboxId === m.id && <Badge></Badge>}</div></CardHeader><CardContent className="flex flex-wrap gap-2"><Button variant="outline" size="sm" onClick={() => onSelect(m.id)}></Button><Button variant="outline" size="sm" onClick={() => onCopy(m.address)}><Copy className="h-4 w-4" /></Button><Button size="sm" onClick={() => onOpen(m.id)}></Button></CardContent></Card>)} {mailboxes.map((m) => <Card key={m.id} className={cn(selectedMailboxId === m.id && "border-primary")}><CardHeader><div className="flex items-start justify-between gap-3"><div className="min-w-0"><CardTitle className="truncate text-base">{m.address}</CardTitle></div>{selectedMailboxId === m.id && <Badge></Badge>}</div></CardHeader><CardContent className="flex flex-wrap gap-2"><Button variant="outline" size="sm" onClick={() => onSelect(m.id)}></Button><Button variant="outline" size="sm" onClick={() => onCopy(m.address)}><Copy className="h-4 w-4" /></Button><Button size="sm" onClick={() => onOpen(m.id)}></Button></CardContent></Card>)}
{mailboxes.length === 0 && <EmptyState text={canApply ? "暂无邮箱账号,点击申请邮箱创建" : "暂无邮箱账号"} />} {mailboxes.length === 0 && <EmptyState text={canApply ? "暂无邮箱账号,点击申请邮箱创建" : "暂无邮箱账号"} />}
</div> </div>
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle> IMAP </CardTitle>
<div className="mt-1 text-sm text-muted-foreground"></div>
</div>
<ExternalImapDialog mailboxId={selectedMailboxId} disabled={!selectedMailbox} pending={externalPending} onSubmit={onCreateExternal} />
</div>
</CardHeader>
<CardContent className="space-y-3">
{!selectedMailbox && <EmptyState text="请先选择一个本地邮箱" />}
{selectedMailbox && externalAccounts.length === 0 && <EmptyState text="暂无外部 IMAP 账号" />}
{selectedMailbox && externalAccounts.map((account) => (
<div key={account.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate font-medium">{account.name}</div>
<Badge variant={account.enabled ? "secondary" : "outline"}>{account.enabled ? "已启用" : "已停用"}</Badge>
<Badge variant="outline">{account.storageMode === "local" ? "本地存储" : "远端直连"}</Badge>
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{account.username} · {account.host}:{account.port} · {account.tlsMode.toUpperCase()}</div>
<div className="mt-1 text-xs text-muted-foreground">{externalStatusLabel(account.lastStatus)}{account.lastSyncAt ? ` · 最近同步 ${formatDateTime(account.lastSyncAt)}` : ""}{account.lastError ? ` · ${account.lastError}` : ""}</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onTestExternal(account.id)}><Link2 className="h-4 w-4" /></Button>
{account.storageMode === "local" && <Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onSyncExternal(account.id)}><RefreshCcw className="h-4 w-4" /></Button>}
<ExternalImapDialog account={account} mailboxId={selectedMailboxId} pending={externalPending} onSubmit={(payload) => onUpdateExternal(account.id, payload)} />
<Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onUpdateExternal(account.id, { ...externalPayloadFromAccount(account), enabled: !account.enabled })}>{account.enabled ? "停用" : "启用"}</Button>
<Button type="button" variant="destructive" size="sm" disabled={externalPending} onClick={() => onDeleteExternal(account.id)}></Button>
</div>
</div>
))}
</CardContent>
</Card>
</div> </div>
) )
} }
@@ -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<ExternalImapTlsMode>(account?.tlsMode || "tls")
const [storageMode, setStorageMode] = React.useState<ExternalImapStorageMode>(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<HTMLFormElement>) {
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 (
<Dialog open={open} onOpenChange={setOpen}>
<Button type="button" variant={account ? "outline" : "default"} size={account ? "sm" : "default"} disabled={disabled} onClick={() => setOpen(true)}>
{account ? "编辑" : <><Plus className="h-4 w-4" /></>}
</Button>
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-xl">
<DialogHeader><DialogTitle>{account ? "编辑外部 IMAP" : "添加外部 IMAP"}</DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={submit}>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="显示名称"><Input name="name" defaultValue={account?.name || ""} placeholder="Gmail / 工作邮箱" /></Field>
<Field label="用户名"><Input name="username" defaultValue={account?.username || ""} required placeholder="name@example.com" /></Field>
<Field label="服务器"><Input name="host" defaultValue={account?.host || ""} required placeholder="imap.example.com" /></Field>
<Field label="端口"><Input name="port" type="number" min={1} max={65535} defaultValue={account?.port || (tlsMode === "tls" ? 993 : 143)} /></Field>
<Field label="加密方式">
<Select value={tlsMode} onValueChange={(value) => setTlsMode(value as ExternalImapTlsMode)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="tls">SSL/TLS</SelectItem><SelectItem value="starttls">STARTTLS</SelectItem><SelectItem value="plain"></SelectItem></SelectContent>
</Select>
</Field>
<Field label="存储模式">
<Select value={storageMode} onValueChange={(value) => setStorageMode(value as ExternalImapStorageMode)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="local"></SelectItem><SelectItem value="remote"></SelectItem></SelectContent>
</Select>
</Field>
</div>
<Field label={account ? "密码(留空则不修改)" : "密码"}><PasswordInput name="password" required={!account} placeholder={account ? "不修改请留空" : "外部邮箱密码或授权码"} /></Field>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-center gap-2 rounded-lg border p-3 text-sm"><Checkbox checked={syncReadState} onCheckedChange={(checked) => setSyncReadState(checked === true)} /></label>
<label className="flex items-center gap-2 rounded-lg border p-3 text-sm"><Checkbox checked={enabled} onCheckedChange={(checked) => setEnabled(checked === true)} /></label>
</div>
<DialogFooter className="gap-2 [&>button]:w-full sm:[&>button]:w-auto">
<Button type="button" variant="outline" onClick={() => setOpen(false)}></Button>
<Button disabled={pending || !mailboxId}>{pending ? "保存中..." : "保存"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
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<string, string>)[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 }) { 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 selected = mailboxes.find((item) => item.id === selectedMailboxId) || mailboxes[0]
const server = clientServerHost(hostname, selected?.address) const server = clientServerHost(hostname, selected?.address)
+9
View File
@@ -132,6 +132,15 @@ LANQIN_MAIL_AUTO_REFRESH=true
# 自动刷新间隔,单位:秒。 # 自动刷新间隔,单位:秒。
LANQIN_MAIL_REFRESH_SECONDS=30 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
# ========================= # =========================
# 系统 # 系统
# ========================= # =========================
+1
View File
@@ -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 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。 - 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` 去重。 - 第三方客户端可通过 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` 中显式授权的地址。 - send-as v1 支持本人邮箱、启用的别名转发 source 指向本人邮箱,或数据库表 `send_as_grants` 中显式授权的地址。
## 邮件客户端 TLS 证书 ## 邮件客户端 TLS 证书