feat(mail): 支持外部 IMAP 账号接入。
- 新增外部 IMAP 账号的加密存储、同步任务与远端直连能力。 - Web 端个人邮箱页和邮件列表页支持查看、测试、同步和切换外部邮箱。 - 补充相关配置项、环境变量说明和使用文档。
This commit is contained in:
@@ -13,6 +13,8 @@ require (
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/emersion/go-smtp v0.24.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
||||
@@ -2,6 +2,10 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||
@@ -26,21 +30,52 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
||||
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
|
||||
@@ -29,6 +29,7 @@ type App struct {
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
maildirHealth *maildirSyncHealthTracker
|
||||
externalIMAP externalIMAPClientFactory
|
||||
}
|
||||
|
||||
func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
@@ -49,6 +50,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker()}
|
||||
a.externalIMAP = a
|
||||
if err := a.configureSQLite(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
@@ -76,6 +78,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
go a.maildirWorker(workerCtx)
|
||||
}
|
||||
go a.sendQueueWorker(workerCtx)
|
||||
go a.externalIMAPWorker(workerCtx)
|
||||
go a.smtpEventsCleanupWorker(workerCtx)
|
||||
return a, nil
|
||||
}
|
||||
@@ -344,6 +347,63 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_pop3_events_user_created ON pop3_events(user_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS external_imap_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
tls_mode TEXT NOT NULL CHECK(tls_mode IN ('tls','starttls','plain')),
|
||||
username TEXT NOT NULL,
|
||||
password_ciphertext TEXT NOT NULL,
|
||||
storage_mode TEXT NOT NULL DEFAULT 'local' CHECK(storage_mode IN ('local','remote')),
|
||||
sync_read_state INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_sync_at TEXT,
|
||||
last_status TEXT NOT NULL DEFAULT 'idle',
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_external_imap_accounts_user_mailbox ON external_imap_accounts(user_id, mailbox_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_external_imap_accounts_enabled ON external_imap_accounts(enabled, updated_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS external_imap_folder_states (
|
||||
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
|
||||
remote_folder TEXT NOT NULL,
|
||||
local_folder_id TEXT NOT NULL DEFAULT '',
|
||||
uid_validity INTEGER NOT NULL DEFAULT 0,
|
||||
last_uid INTEGER NOT NULL DEFAULT 0,
|
||||
last_sync_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(account_id, remote_folder)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS external_imap_messages (
|
||||
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
|
||||
remote_folder TEXT NOT NULL,
|
||||
uid_validity INTEGER NOT NULL,
|
||||
uid INTEGER NOT NULL,
|
||||
message_id TEXT NOT NULL DEFAULT '',
|
||||
local_message_id TEXT NOT NULL DEFAULT '',
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(account_id, remote_folder, uid_validity, uid)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_external_imap_messages_local ON external_imap_messages(local_message_id) WHERE local_message_id <> ''`,
|
||||
`CREATE TABLE IF NOT EXISTS external_imap_sync_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL,
|
||||
imported INTEGER NOT NULL DEFAULT 0,
|
||||
skipped INTEGER NOT NULL DEFAULT 0,
|
||||
failed INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_external_imap_sync_runs_account_started ON external_imap_sync_runs(account_id, started_at DESC)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -48,6 +48,11 @@ func newTestApp(t *testing.T) *App {
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
}
|
||||
return newTestAppWithConfig(t, cfg)
|
||||
}
|
||||
|
||||
func newTestAppWithConfig(t *testing.T, cfg Config) *App {
|
||||
t.Helper()
|
||||
a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -424,6 +429,127 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalIMAPAccountEncryptsPasswordAndDoesNotReturnSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminEmail: "admin@lanqin.local",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
ExternalIMAPSecretKey: "test-secret",
|
||||
ExternalIMAPAllowPrivateHosts: true,
|
||||
})
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d", code)
|
||||
}
|
||||
_, mb := defaultAdminUserAndMailbox(t, a)
|
||||
var created ExternalIMAPAccount
|
||||
payload := map[string]any{"mailboxId": mb.ID, "name": "Gmail", "host": "imap.gmail.com", "port": 993, "tlsMode": "tls", "username": "user@gmail.com", "password": "app-password", "storageMode": "remote", "syncReadState": true, "enabled": true}
|
||||
if code := admin.do("POST", "/api/me/external-imap-accounts", payload, &created); code != http.StatusCreated {
|
||||
t.Fatalf("create external imap code=%d account=%+v", code, created)
|
||||
}
|
||||
raw, err := json.Marshal(created)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "app-password") {
|
||||
t.Fatalf("external account response leaked password: %s", string(raw))
|
||||
}
|
||||
var ciphertext string
|
||||
if err := a.db.QueryRow(`SELECT password_ciphertext FROM external_imap_accounts WHERE id=?`, created.ID).Scan(&ciphertext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ciphertext == "" || ciphertext == "app-password" {
|
||||
t.Fatalf("password was not encrypted: %q", ciphertext)
|
||||
}
|
||||
plain, err := a.decryptExternalIMAPPassword(ciphertext)
|
||||
if err != nil || plain != "app-password" {
|
||||
t.Fatalf("decrypt password=%q err=%v", plain, err)
|
||||
}
|
||||
var list struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/me/external-imap-accounts?mailboxId="+mb.ID, nil, &list); code != http.StatusOK || len(list.Items) != 1 {
|
||||
t.Fatalf("list external imap code=%d items=%+v", code, list.Items)
|
||||
}
|
||||
if _, ok := list.Items[0]["password"]; ok {
|
||||
t.Fatalf("list response exposed password field: %+v", list.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalIMAPRejectsPrivateHostsByDefault(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
a.cfg.ExternalIMAPSecretKey = "test-secret"
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d", code)
|
||||
}
|
||||
_, mb := defaultAdminUserAndMailbox(t, a)
|
||||
var out map[string]any
|
||||
payload := map[string]any{"mailboxId": mb.ID, "name": "Local", "host": "127.0.0.1", "port": 143, "tlsMode": "plain", "username": "local", "password": "secret", "storageMode": "remote"}
|
||||
if code := admin.do("POST", "/api/me/external-imap-accounts", payload, &out); code != http.StatusBadRequest {
|
||||
t.Fatalf("private host should be rejected code=%d body=%v", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalIMAPAccountOwnershipIsolation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminEmail: "admin@lanqin.local",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
ExternalIMAPSecretKey: "test-secret",
|
||||
ExternalIMAPAllowPrivateHosts: true,
|
||||
})
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("login admin code=%d", code)
|
||||
}
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
owner := createTestMailbox(t, admin, domainID, "ximap-owner", "Owner", "Password123!", nil)
|
||||
other := createTestMailbox(t, admin, domainID, "ximap-other", "Other", "Password123!", nil)
|
||||
ownerClient := &testClient{t: t, server: ts}
|
||||
if code := ownerClient.do("POST", "/api/auth/login", map[string]string{"email": owner.Address, "password": "Password123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("login owner code=%d", code)
|
||||
}
|
||||
otherClient := &testClient{t: t, server: ts}
|
||||
if code := otherClient.do("POST", "/api/auth/login", map[string]string{"email": other.Address, "password": "Password123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("login other code=%d", code)
|
||||
}
|
||||
var created ExternalIMAPAccount
|
||||
payload := map[string]any{"mailboxId": owner.ID, "name": "Owner external", "host": "imap.example.com", "port": 993, "tlsMode": "tls", "username": "owner@example.com", "password": "secret", "storageMode": "remote"}
|
||||
if code := ownerClient.do("POST", "/api/me/external-imap-accounts", payload, &created); code != http.StatusCreated {
|
||||
t.Fatalf("create code=%d account=%+v", code, created)
|
||||
}
|
||||
var denied map[string]any
|
||||
if code := otherClient.do("POST", "/api/me/external-imap-accounts/"+created.ID, map[string]any{"mailboxId": other.ID, "name": "steal", "host": "imap.example.com", "port": 993, "tlsMode": "tls", "username": "other@example.com", "storageMode": "remote"}, &denied); code != http.StatusNotFound {
|
||||
t.Fatalf("cross-user update should be hidden code=%d body=%v", code, denied)
|
||||
}
|
||||
if code := otherClient.do("DELETE", "/api/me/external-imap-accounts/"+created.ID, nil, &denied); code != http.StatusNotFound {
|
||||
t.Fatalf("cross-user delete should be hidden code=%d body=%v", code, denied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMailAuthenticationResults(t *testing.T) {
|
||||
header := textproto.MIMEHeader{}
|
||||
header.Add("Authentication-Results", "mx.example.test; spf=pass smtp.mailfrom=sender.example; dkim=fail (bad signature) header.d=sender.example; dmarc=none")
|
||||
|
||||
@@ -8,77 +8,83 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
DBPath string
|
||||
DataDir string
|
||||
CookieName string
|
||||
SessionTTLHours int
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
PublicHostname string
|
||||
PublicBaseURL string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPRequireTLS bool
|
||||
SubmissionAddr string
|
||||
SubmissionTLSAddr string
|
||||
SubmissionMaxMessageMB int
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
MaildirRoot string
|
||||
MaildirScanSeconds int
|
||||
AllowInsecureHTTP bool
|
||||
OpenRegistration bool
|
||||
TwoFactorEnabled bool
|
||||
TurnstileEnabled bool
|
||||
TurnstileSiteKey string
|
||||
TurnstileSecretKey string
|
||||
CatchAllEnabled bool
|
||||
MailAutoRefresh bool
|
||||
MailRefreshSeconds int
|
||||
UserMailboxApplyEnabled bool
|
||||
UserMailboxDomainIDs string
|
||||
ReservedMailboxPrefixes string
|
||||
Addr string
|
||||
DBPath string
|
||||
DataDir string
|
||||
CookieName string
|
||||
SessionTTLHours int
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
PublicHostname string
|
||||
PublicBaseURL string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPRequireTLS bool
|
||||
SubmissionAddr string
|
||||
SubmissionTLSAddr string
|
||||
SubmissionMaxMessageMB int
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
MaildirRoot string
|
||||
MaildirScanSeconds int
|
||||
AllowInsecureHTTP bool
|
||||
OpenRegistration bool
|
||||
TwoFactorEnabled bool
|
||||
TurnstileEnabled bool
|
||||
TurnstileSiteKey string
|
||||
TurnstileSecretKey string
|
||||
CatchAllEnabled bool
|
||||
MailAutoRefresh bool
|
||||
MailRefreshSeconds int
|
||||
UserMailboxApplyEnabled bool
|
||||
UserMailboxDomainIDs string
|
||||
ReservedMailboxPrefixes string
|
||||
ExternalIMAPSecretKey string
|
||||
ExternalIMAPSyncSeconds int
|
||||
ExternalIMAPAllowPrivateHosts bool
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
dataDir := getenv("LANQIN_DATA_DIR", "./data")
|
||||
return Config{
|
||||
Addr: getenv("LANQIN_ADDR", ":8080"),
|
||||
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
|
||||
DataDir: dataDir,
|
||||
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
||||
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
|
||||
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
|
||||
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
|
||||
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
|
||||
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
|
||||
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
|
||||
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
|
||||
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
|
||||
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
|
||||
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
|
||||
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
|
||||
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
|
||||
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
|
||||
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
|
||||
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
|
||||
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
|
||||
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
|
||||
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
|
||||
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
|
||||
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
|
||||
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
|
||||
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
|
||||
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
|
||||
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
|
||||
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
|
||||
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
|
||||
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""),
|
||||
ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
|
||||
Addr: getenv("LANQIN_ADDR", ":8080"),
|
||||
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
|
||||
DataDir: dataDir,
|
||||
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
||||
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
|
||||
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
|
||||
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
|
||||
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
|
||||
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
|
||||
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
|
||||
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
|
||||
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
|
||||
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
|
||||
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
|
||||
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
|
||||
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
|
||||
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
|
||||
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
|
||||
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
|
||||
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
|
||||
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
|
||||
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
|
||||
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
|
||||
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
|
||||
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
|
||||
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
|
||||
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
|
||||
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
|
||||
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
|
||||
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
|
||||
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""),
|
||||
ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
|
||||
ExternalIMAPSecretKey: getenv("LANQIN_EXTERNAL_IMAP_SECRET_KEY", ""),
|
||||
ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300),
|
||||
ExternalIMAPAllowPrivateHosts: getenvBool("LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS", false),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,12 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailStats)).Get("/me/stats", a.handleMailStats)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailOrganize)).Post("/me/cleanup", a.handleMailCleanup)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Get("/me/external-imap-accounts", a.handleListExternalIMAPAccounts)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts", a.handleCreateExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}", a.handleUpdateExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Delete("/me/external-imap-accounts/{id}", a.handleDeleteExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}/test", a.handleTestExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}/sync", a.handleSyncExternalIMAPAccount)
|
||||
r.With(a.requireAuth).Get("/events", a.handleEvents)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -74,6 +80,12 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages", a.handleMailMessages)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts", a.handleMailExternalAccounts)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts/{id}/folders", a.handleExternalIMAPFolders)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts/{id}/messages", a.handleExternalIMAPMessages)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts/{id}/messages/{remoteId}", a.handleExternalIMAPMessage)
|
||||
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/external-accounts/{id}/attachments/{remoteId}/{partId}", a.handleExternalIMAPAttachment)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/external-accounts/{id}/messages/{remoteId}/mark-read", a.handleExternalIMAPMarkRead)
|
||||
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue", a.handleSendQueue)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit)
|
||||
|
||||
@@ -77,37 +77,38 @@ type MailLabel struct {
|
||||
}
|
||||
|
||||
type MailMessage struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||
FolderID string `json:"folderId"`
|
||||
Folder string `json:"folder"`
|
||||
MessageUID string `json:"messageUid"`
|
||||
IMAPUID int64 `json:"imapUid"`
|
||||
IMAPModSeq int64 `json:"imapModseq"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Snippet string `json:"snippet"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||
IsRead bool `json:"isRead"`
|
||||
IsStarred bool `json:"isStarred"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Labels []MailLabel `json:"labels,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
Authentication MailAuthentication `json:"authentication"`
|
||||
SendQueueID string `json:"sendQueueId,omitempty"`
|
||||
SendQueueStatus string `json:"sendQueueStatus,omitempty"`
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||
FolderID string `json:"folderId"`
|
||||
Folder string `json:"folder"`
|
||||
MessageUID string `json:"messageUid"`
|
||||
IMAPUID int64 `json:"imapUid"`
|
||||
IMAPModSeq int64 `json:"imapModseq"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Snippet string `json:"snippet"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||
IsRead bool `json:"isRead"`
|
||||
IsStarred bool `json:"isStarred"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Labels []MailLabel `json:"labels,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
Authentication MailAuthentication `json:"authentication"`
|
||||
SendQueueID string `json:"sendQueueId,omitempty"`
|
||||
SendQueueStatus string `json:"sendQueueStatus,omitempty"`
|
||||
ExternalAccountID string `json:"externalAccountId,omitempty"`
|
||||
}
|
||||
|
||||
type MailAuthentication struct {
|
||||
@@ -227,6 +228,44 @@ type MailStatsFolderCount struct {
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
type ExternalIMAPAccount struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
TLSMode string `json:"tlsMode"`
|
||||
Username string `json:"username"`
|
||||
StorageMode string `json:"storageMode"`
|
||||
SyncReadState bool `json:"syncReadState"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastSyncAt *time.Time `json:"lastSyncAt,omitempty"`
|
||||
LastStatus string `json:"lastStatus"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ExternalIMAPFolder struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
}
|
||||
|
||||
type ExternalIMAPSyncRun struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"accountId"`
|
||||
Status string `json:"status"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
}
|
||||
|
||||
type SendQueueEntry struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
|
||||
@@ -64,6 +64,7 @@ export type MailMessage = {
|
||||
labels?: MailLabel[]
|
||||
sendQueueId?: string
|
||||
sendQueueStatus?: SendQueueStatus
|
||||
externalAccountId?: string
|
||||
}
|
||||
export type DNSRecord = { type: string; name: string; value: string; ttl: number }
|
||||
export type DNSCheckResult = { domain: string; status: string; checks: Record<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 BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
|
||||
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; attachmentBytes: number; storageBytes: number; quotaBytes: number; quotaUsedPct: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
|
||||
export type ExternalImapStorageMode = "local" | "remote"
|
||||
export type ExternalImapTlsMode = "tls" | "starttls" | "plain"
|
||||
export type ExternalImapAccount = { id: string; mailboxId: string; name: string; host: string; port: number; tlsMode: ExternalImapTlsMode; username: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean; lastSyncAt?: string; lastStatus: string; lastError?: string; createdAt: string; updatedAt: string }
|
||||
export type ExternalImapAccountPayload = { mailboxId: string; name: string; host: string; port: number; tlsMode: ExternalImapTlsMode; username: string; password?: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean }
|
||||
export type ExternalImapFolder = { name: string; role: string; unreadCount: number; totalCount: number }
|
||||
export type ExternalImapSyncRun = { id: string; accountId: string; status: string; imported: number; skipped: number; failed: number; error?: string; startedAt: string; finishedAt?: string }
|
||||
export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
|
||||
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
|
||||
export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number }
|
||||
|
||||
+15
-1
@@ -1,4 +1,4 @@
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
|
||||
export * from "./api-types"
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
@@ -61,6 +61,12 @@ export const api = {
|
||||
cleanupMail: (payload: { mailboxId: string; target: "empty-trash" | "empty-spam" | "archive-read-inbox" }) => request<{ ok: boolean; affected: number }>("/api/me/cleanup", { method: "POST", body: JSON.stringify(payload) }),
|
||||
mailboxApplyOptions: () => request<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) }),
|
||||
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"),
|
||||
users: () => request<ListResponse<AdminUser>>("/api/admin/users"),
|
||||
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`),
|
||||
checkDns: (domainId: string) => request<DNSCheckResult>(`/api/admin/domains/${domainId}/check-dns`, { method: "POST" }),
|
||||
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)}` : ""}`),
|
||||
createFolder: (payload: { mailboxId?: string; name: string }) => {
|
||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||
|
||||
+129
-32
@@ -13,7 +13,7 @@ import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react"
|
||||
import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
|
||||
import { api, ExternalImapAccount, ExternalImapFolder, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { useDisplayMode } from "@/lib/display-mode"
|
||||
@@ -61,7 +61,7 @@ const folderLabels: Record<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 MailFilter = "all" | "unread" | "starred" | "attachments"
|
||||
type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue"
|
||||
type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue" | "external"
|
||||
type MailListResponse = { items?: MailMessage[]; nextCursor?: string }
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
type MailNotificationState = { latestId: string; latestReceivedAt: string }
|
||||
@@ -98,6 +98,8 @@ export function MailPage() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false)
|
||||
const [mailFilter, setMailFilter] = React.useState<MailFilter>("all")
|
||||
const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "")
|
||||
const [selectedExternalAccountId, setSelectedExternalAccountId] = React.useState("")
|
||||
const [externalFolder, setExternalFolder] = React.useState("INBOX")
|
||||
const [darkMode, setDarkMode] = React.useState(getInitialTheme)
|
||||
const [displayMode] = useDisplayMode()
|
||||
const isMobile = useIsMobile()
|
||||
@@ -138,6 +140,9 @@ export function MailPage() {
|
||||
const canManageSignatures = hasPermission(user, "mail.signatures.manage")
|
||||
|
||||
const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail })
|
||||
const externalMailAccounts = useQuery({ queryKey: ["mail-external-accounts"], queryFn: api.externalMailAccounts, enabled: canAccessMail && canReadMail })
|
||||
const selectedExternalAccount = React.useMemo(() => externalMailAccounts.data?.items.find((item) => item.id === selectedExternalAccountId), [externalMailAccounts.data?.items, selectedExternalAccountId])
|
||||
const externalFolders = useQuery({ queryKey: ["mail-external-folders", selectedExternalAccountId], queryFn: () => api.externalFolders(selectedExternalAccountId), enabled: !!selectedExternalAccountId && canReadMail })
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||
const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId])
|
||||
const activeMailboxId = selectedMailbox?.id || ""
|
||||
@@ -174,7 +179,14 @@ export function MailPage() {
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
||||
enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && (mailView !== "label" || !!selectedLabelId),
|
||||
})
|
||||
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail })
|
||||
const externalMessages = useInfiniteQuery({
|
||||
queryKey: ["external-messages", selectedExternalAccountId, externalFolder],
|
||||
queryFn: ({ pageParam }) => api.externalMessages(selectedExternalAccountId, externalFolder, typeof pageParam === "string" ? pageParam : ""),
|
||||
initialPageParam: "",
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
||||
enabled: !!selectedExternalAccountId && canReadMail && mailView === "external",
|
||||
})
|
||||
const detail = useQuery({ queryKey: ["message", selectedId, mailView, selectedExternalAccountId], queryFn: () => mailView === "external" ? api.externalMessage(selectedExternalAccountId, selectedId!) : api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail && (mailView !== "external" || !!selectedExternalAccountId) })
|
||||
function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
|
||||
qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current)
|
||||
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({
|
||||
mutationFn: ({ id, starred }: { id: string; starred: boolean }) => api.star(id, starred),
|
||||
@@ -210,6 +232,16 @@ export function MailPage() {
|
||||
},
|
||||
onError: (error) => toast({ title: "操作失败", description: error.message }),
|
||||
})
|
||||
const markExternalRead = useMutation({
|
||||
mutationFn: ({ id, remoteId, read }: { id: string; remoteId: string; read: boolean }) => api.markExternalRead(id, remoteId, read),
|
||||
onMutate: ({ remoteId, read }) => updateCachedMessage(remoteId, { isRead: read }),
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ["external-messages"] })
|
||||
await qc.invalidateQueries({ queryKey: ["message"] })
|
||||
await qc.invalidateQueries({ queryKey: ["mail-external-folders"] })
|
||||
},
|
||||
onError: (error) => toast({ title: "操作失败", description: error.message }),
|
||||
})
|
||||
const addLabel = useMutation({
|
||||
mutationFn: ({ id, label }: { id: string; label: MailLabel }) => api.addLabel(id, { name: label.name, color: label.color }),
|
||||
onMutate: async ({ id, label }) => {
|
||||
@@ -521,7 +553,7 @@ export function MailPage() {
|
||||
}, [mailRefreshInterval, publicSettings.data?.mailAutoRefresh, qc])
|
||||
|
||||
const selected = detail.data
|
||||
const allMessages = messages.data?.pages.flatMap((page) => page.items || []) || []
|
||||
const allMessages = (mailView === "external" ? externalMessages.data?.pages : messages.data?.pages)?.flatMap((page) => page.items || []) || []
|
||||
const visibleMessages = allMessages.filter((message) => {
|
||||
if (mailFilter === "unread") return !message.isRead
|
||||
if (mailFilter === "starred") return message.isStarred
|
||||
@@ -541,16 +573,18 @@ export function MailPage() {
|
||||
const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length
|
||||
const visibleSendQueueItems = sendQueueItems
|
||||
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue)
|
||||
const externalAccountItems = externalMailAccounts.data?.items || []
|
||||
const externalFolderItems = externalFolders.data?.items || []
|
||||
const labelItems = labels.data?.items || []
|
||||
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
|
||||
const viewTitle = mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
|
||||
const emptyMessage = getEmptyMessage(mailView, folder, allMessages.length)
|
||||
const viewTitle = mailView === "external" ? `${selectedExternalAccount?.name || "外部邮箱"} · ${folderLabels[externalFolder] || externalFolder}` : mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
|
||||
const emptyMessage = getEmptyMessage(mailView, mailView === "external" ? externalFolder : folder, allMessages.length)
|
||||
const visibleMessageIds = visibleMessages.map((message) => message.id)
|
||||
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
|
||||
const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length
|
||||
const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected
|
||||
const hasMoreMessages = !!messages.hasNextPage
|
||||
const canLoadMore = !!messages.hasNextPage && !messages.isFetchingNextPage
|
||||
const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : !!messages.hasNextPage
|
||||
const canLoadMore = mailView === "external" ? !!externalMessages.hasNextPage && !externalMessages.isFetchingNextPage : !!messages.hasNextPage && !messages.isFetchingNextPage
|
||||
function toggleCompactSelectAll(checked: boolean) {
|
||||
setCompactSelectedIds(checked ? visibleMessageIds : [])
|
||||
}
|
||||
@@ -560,6 +594,9 @@ export function MailPage() {
|
||||
async function refreshMailData() {
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ["messages"] }),
|
||||
qc.invalidateQueries({ queryKey: ["external-messages"] }),
|
||||
qc.invalidateQueries({ queryKey: ["mail-external-folders"] }),
|
||||
qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }),
|
||||
qc.invalidateQueries({ queryKey: ["folders"] }),
|
||||
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
||||
qc.invalidateQueries({ queryKey: ["labels"] }),
|
||||
@@ -659,6 +696,7 @@ export function MailPage() {
|
||||
}
|
||||
function switchMailbox(mailboxId: string) {
|
||||
setSelectedMailboxId(mailboxId)
|
||||
setSelectedExternalAccountId("")
|
||||
setFolder("Inbox")
|
||||
setMailView("folder")
|
||||
setSelectedLabelId("")
|
||||
@@ -667,6 +705,7 @@ export function MailPage() {
|
||||
setMobileSidebarOpen(false)
|
||||
}
|
||||
function openFolder(nextFolder: string) {
|
||||
setSelectedExternalAccountId("")
|
||||
setFolder(nextFolder)
|
||||
setMailView("folder")
|
||||
setSelectedLabelId("")
|
||||
@@ -675,6 +714,7 @@ export function MailPage() {
|
||||
setMobileSidebarOpen(false)
|
||||
}
|
||||
function openStarred() {
|
||||
setSelectedExternalAccountId("")
|
||||
setMailView("starred")
|
||||
setSelectedLabelId("")
|
||||
setSelectedId(null)
|
||||
@@ -682,6 +722,7 @@ export function MailPage() {
|
||||
setMobileSidebarOpen(false)
|
||||
}
|
||||
function openScheduled() {
|
||||
setSelectedExternalAccountId("")
|
||||
setMailView("scheduled")
|
||||
setSelectedLabelId("")
|
||||
setSelectedId(null)
|
||||
@@ -689,6 +730,7 @@ export function MailPage() {
|
||||
setMobileSidebarOpen(false)
|
||||
}
|
||||
function openMessageContextMenu(event: React.MouseEvent, message: MailMessage) {
|
||||
if (mailView === "external") return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (message.folder !== "Drafts") setSelectedId(message.id)
|
||||
@@ -711,6 +753,15 @@ export function MailPage() {
|
||||
else if (item.type === "sendQueue") openSendQueue()
|
||||
else openFolder(item.folderName)
|
||||
}
|
||||
function openExternalFolder(account: ExternalImapAccount, folderName = "INBOX") {
|
||||
setSelectedExternalAccountId(account.id)
|
||||
setExternalFolder(folderName)
|
||||
setMailView("external")
|
||||
setSelectedLabelId("")
|
||||
setSelectedId(null)
|
||||
setMailFilter("all")
|
||||
setMobileSidebarOpen(false)
|
||||
}
|
||||
function reorderCustomFolder(draggedId: string, target: FolderDropTarget) {
|
||||
if (!canOrganizeMail || reorderFolders.isPending) return
|
||||
const foldersByID = new Map((folders.data?.items || []).map((item) => [item.id, item]))
|
||||
@@ -837,6 +888,7 @@ export function MailPage() {
|
||||
confirmDeleteMessage(message)
|
||||
}
|
||||
function openSendQueue() {
|
||||
setSelectedExternalAccountId("")
|
||||
setMailView("sendQueue")
|
||||
setSelectedLabelId("")
|
||||
setSelectedId(null)
|
||||
@@ -848,6 +900,7 @@ export function MailPage() {
|
||||
setSendQueueAuditId(message.sendQueueId)
|
||||
}
|
||||
function openLabel(labelId: string) {
|
||||
setSelectedExternalAccountId("")
|
||||
setSelectedLabelId(labelId)
|
||||
setMailView("label")
|
||||
setSelectedId(null)
|
||||
@@ -860,13 +913,14 @@ export function MailPage() {
|
||||
return
|
||||
}
|
||||
const message = allMessages.find((item) => item.id === messageId)
|
||||
if (message?.folder === "Drafts") {
|
||||
if (mailView !== "external" && message?.folder === "Drafts") {
|
||||
void openDraft(message)
|
||||
return
|
||||
}
|
||||
setSelectedId(messageId)
|
||||
if (message && !message.isRead && canOrganizeMail) {
|
||||
markRead.mutate({ id: message.id, read: true })
|
||||
if (mailView === "external" && selectedExternalAccountId) markExternalRead.mutate({ id: selectedExternalAccountId, remoteId: message.id, read: true })
|
||||
else markRead.mutate({ id: message.id, read: true })
|
||||
}
|
||||
}
|
||||
async function refreshMail() {
|
||||
@@ -977,6 +1031,41 @@ export function MailPage() {
|
||||
{folders.isLoading && <FolderSkeleton />}
|
||||
</SidebarGroupContent>
|
||||
</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>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center justify-between px-2 py-1.5">
|
||||
@@ -1115,10 +1204,10 @@ export function MailPage() {
|
||||
selectedIds={compactSelectedIds}
|
||||
allSelected={compactAllSelected}
|
||||
someSelected={compactSomeSelected}
|
||||
loading={messages.isLoading}
|
||||
loading={mailView === "external" ? externalMessages.isLoading : messages.isLoading}
|
||||
hasMore={hasMoreMessages}
|
||||
loadingMore={messages.isFetchingNextPage}
|
||||
onLoadMore={() => messages.fetchNextPage()}
|
||||
loadingMore={mailView === "external" ? externalMessages.isFetchingNextPage : messages.isFetchingNextPage}
|
||||
onLoadMore={() => mailView === "external" ? externalMessages.fetchNextPage() : messages.fetchNextPage()}
|
||||
emptyMessage={emptyMessage}
|
||||
selectedId={selectedId}
|
||||
selected={selected}
|
||||
@@ -1130,21 +1219,21 @@ export function MailPage() {
|
||||
onToggleSelected={toggleCompactSelect}
|
||||
scheduledDraftIds={scheduledDraftIds}
|
||||
onCloseReader={() => setSelectedId(null)}
|
||||
onStar={(message) => star.mutate({ id: message.id, starred: !message.isStarred })}
|
||||
onStar={(message) => { if (mailView !== "external") star.mutate({ id: message.id, starred: !message.isStarred }) }}
|
||||
onReply={openReply}
|
||||
onForward={openForward}
|
||||
onSendTimeline={openMessageSendTimeline}
|
||||
onArchive={(message) => move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" })}
|
||||
onDelete={confirmDeleteMessage}
|
||||
onToggleRead={(message) => markRead.mutate({ id: message.id, read: !message.isRead })}
|
||||
onArchive={(message) => { if (mailView !== "external") move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" }) }}
|
||||
onDelete={(message) => { if (mailView !== "external") confirmDeleteMessage(message) }}
|
||||
onToggleRead={(message) => mailView === "external" && selectedExternalAccountId ? markExternalRead.mutate({ id: selectedExternalAccountId, remoteId: message.id, read: !message.isRead }) : markRead.mutate({ id: message.id, read: !message.isRead })}
|
||||
onAddLabel={(message, label) => addLabel.mutate({ id: message.id, label })}
|
||||
onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })}
|
||||
bulkPending={bulkPending}
|
||||
onBulkAction={runBulkAction}
|
||||
onContextMenu={openMessageContextMenu}
|
||||
canSend={canSendMail}
|
||||
canOrganize={canOrganizeMail}
|
||||
canManageLabels={canManageLabels}
|
||||
canOrganize={canOrganizeMail && mailView !== "external"}
|
||||
canManageLabels={canManageLabels && mailView !== "external"}
|
||||
canDownloadAttachments={canDownloadAttachments}
|
||||
/>
|
||||
) : (
|
||||
@@ -1162,13 +1251,13 @@ export function MailPage() {
|
||||
{selectedCountOnPage > 0 && canOrganizeMail && <BulkActionMenu pending={bulkPending} onAction={runBulkAction} />}
|
||||
</div>
|
||||
<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} />)}
|
||||
{!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) && visibleMessages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
|
||||
{!(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && hasMoreMessages && (
|
||||
<div className="border-b p-4 text-center">
|
||||
<Button variant="outline" size="sm" disabled={!canLoadMore} onClick={() => messages.fetchNextPage()}>
|
||||
{messages.isFetchingNextPage ? "加载中..." : "加载更多"}
|
||||
<Button variant="outline" size="sm" disabled={!canLoadMore} onClick={() => mailView === "external" ? externalMessages.fetchNextPage() : messages.fetchNextPage()}>
|
||||
{(mailView === "external" ? externalMessages.isFetchingNextPage : messages.isFetchingNextPage) ? "加载中..." : "加载更多"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1188,13 +1277,13 @@ export function MailPage() {
|
||||
<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={() => 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>}
|
||||
{canOrganizeMail && (selected.folder === "Archive" ? (
|
||||
{mailView !== "external" && selected.sendQueueId && <Button variant="outline" size="sm" onClick={() => openMessageSendTimeline(selected)}><History className="h-4 w-4" />投递时间线</Button>}
|
||||
{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: "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>
|
||||
<MessageMetaPanel
|
||||
@@ -1205,7 +1294,7 @@ export function MailPage() {
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-6">
|
||||
<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>
|
||||
</ScrollArea>
|
||||
</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>}
|
||||
<div className="relative basis-full">
|
||||
<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>
|
||||
</header>
|
||||
)}
|
||||
@@ -1261,7 +1350,7 @@ export function MailPage() {
|
||||
{autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"}
|
||||
</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>}
|
||||
<DropdownMenu>
|
||||
@@ -1281,7 +1370,7 @@ export function MailPage() {
|
||||
</div>
|
||||
<div className="relative w-full max-w-md">
|
||||
<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>
|
||||
</header>
|
||||
{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 getEmptyMessage(mailView: MailView, folder: string, total: number) {
|
||||
if (mailView === "external") return total === 0 ? "远端文件夹没有邮件" : "当前筛选条件下没有远端邮件"
|
||||
if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件"
|
||||
if (mailView === "sendQueue") return total === 0 ? "发送队列为空" : "当前搜索没有匹配的发送任务"
|
||||
if (total > 0) return "当前筛选条件下没有邮件"
|
||||
@@ -1569,6 +1659,13 @@ function ScheduledStatusBadge({ status }: { status: ScheduledSend["status"] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function attachmentHref(message: MailMessage, attachmentId: string) {
|
||||
if (message.externalAccountId) {
|
||||
return `/api/mail/external-accounts/${encodeURIComponent(message.externalAccountId)}/attachments/${encodeURIComponent(message.id)}/${encodeURIComponent(attachmentId)}`
|
||||
}
|
||||
return `/api/mail/attachments/${attachmentId}`
|
||||
}
|
||||
|
||||
const sendQueueStatusOptions: { value: SendQueueStatus | "all"; label: string }[] = [
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "queued", label: "排队中" },
|
||||
@@ -2296,7 +2393,7 @@ function CompactMessageDetail({
|
||||
</div>
|
||||
<div className="py-6 sm:py-8">
|
||||
<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>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -2,9 +2,9 @@ import * as React from "react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
|
||||
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
|
||||
import { QRCodeSVG } from "qrcode.react"
|
||||
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
|
||||
import { api, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapStorageMode, ExternalImapTlsMode, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
|
||||
import { cn, formatBytes } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
|
||||
@@ -101,6 +101,7 @@ export function ProfilePage() {
|
||||
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked })
|
||||
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
|
||||
const activeMailboxId = selectedMailbox?.id || ""
|
||||
const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail })
|
||||
const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && canManageRules && (canReadMail || canManageLabels) })
|
||||
const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats })
|
||||
|
||||
@@ -198,6 +199,31 @@ export function ProfilePage() {
|
||||
},
|
||||
onError: (error) => toast({ title: "申请失败", description: error.message }),
|
||||
})
|
||||
const createExternalImap = useMutation({
|
||||
mutationFn: api.createExternalImapAccount,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }); toast({ title: "外部 IMAP 已保存" }) },
|
||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||
})
|
||||
const updateExternalImap = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: ExternalImapAccountPayload }) => api.updateExternalImapAccount(id, payload),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }); toast({ title: "外部 IMAP 已更新" }) },
|
||||
onError: (error) => toast({ title: "更新失败", description: error.message }),
|
||||
})
|
||||
const deleteExternalImap = useMutation({
|
||||
mutationFn: api.deleteExternalImapAccount,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }); toast({ title: "外部 IMAP 已删除" }) },
|
||||
onError: (error) => toast({ title: "删除失败", description: error.message }),
|
||||
})
|
||||
const testExternalImap = useMutation({
|
||||
mutationFn: api.testExternalImapAccount,
|
||||
onSuccess: (res) => toast({ title: `连接成功,发现 ${res.folders} 个文件夹` }),
|
||||
onError: (error) => toast({ title: "连接失败", description: error.message }),
|
||||
})
|
||||
const syncExternalImap = useMutation({
|
||||
mutationFn: api.syncExternalImapAccount,
|
||||
onSuccess: (run) => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `同步完成:导入 ${run.imported},跳过 ${run.skipped}` }) },
|
||||
onError: (error) => toast({ title: "同步失败", description: error.message }),
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mailboxes.isSuccess) return
|
||||
@@ -291,7 +317,7 @@ export function ProfilePage() {
|
||||
</div>
|
||||
)
|
||||
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 === "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} />
|
||||
@@ -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 selectedMailbox = mailboxes.find((item) => item.id === selectedMailboxId)
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
{canApply && <ApplyMailboxDialog options={applyOptions} pending={applyPending} onApply={onApply} />}
|
||||
</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.length === 0 && <EmptyState text={canApply ? "暂无邮箱账号,点击申请邮箱创建" : "暂无邮箱账号"} />}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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 }) {
|
||||
const selected = mailboxes.find((item) => item.id === selectedMailboxId) || mailboxes[0]
|
||||
const server = clientServerHost(hostname, selected?.address)
|
||||
|
||||
Reference in New Issue
Block a user