From 25cbd58e1f311d4abd0d39ad7a72004f53b60f53 Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:33:25 +0800 Subject: [PATCH] feat: support multiple forwarding targets --- apps/api/internal/app/app.go | 56 +++++ apps/api/internal/app/app_test.go | 12 +- apps/api/internal/app/forwarding_delivery.go | 58 +++--- apps/api/internal/app/forwarding_handlers.go | 202 ++++++++++++++----- apps/web/src/lib/api-types.ts | 4 +- apps/web/src/lib/api.ts | 4 +- apps/web/src/pages/profile.tsx | 127 ++++++++---- 7 files changed, 342 insertions(+), 121 deletions(-) diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 3bb6dec..47d7517 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -249,11 +249,13 @@ func (a *App) migrate(ctx context.Context) error { `CREATE TABLE IF NOT EXISTS account_forwarding_settings ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, target_email TEXT NOT NULL DEFAULT '', + target_emails TEXT NOT NULL DEFAULT '[]', updated_at TEXT NOT NULL )`, `CREATE TABLE IF NOT EXISTS mailbox_forwarding_settings ( mailbox_id TEXT PRIMARY KEY REFERENCES mailboxes(id) ON DELETE CASCADE, target_email TEXT NOT NULL DEFAULT '', + target_emails TEXT NOT NULL DEFAULT '[]', updated_at TEXT NOT NULL )`, `CREATE TABLE IF NOT EXISTS aliases ( @@ -635,6 +637,9 @@ func (a *App) migrate(ctx context.Context) error { if err := a.migrateForwardingVerification(ctx); err != nil { return err } + if err := a.migrateForwardingTargets(ctx); err != nil { + return err + } if err := a.migrateAPITokenScopes(ctx); err != nil { return err } @@ -666,6 +671,57 @@ func (a *App) migrateForwardingVerification(ctx context.Context) error { return err } +func (a *App) migrateForwardingTargets(ctx context.Context) error { + if err := a.ensureTableColumn(ctx, "account_forwarding_settings", "target_emails", `ALTER TABLE account_forwarding_settings ADD COLUMN target_emails TEXT NOT NULL DEFAULT '[]'`); err != nil { + return err + } + if err := a.ensureTableColumn(ctx, "mailbox_forwarding_settings", "target_emails", `ALTER TABLE mailbox_forwarding_settings ADD COLUMN target_emails TEXT NOT NULL DEFAULT '[]'`); err != nil { + return err + } + if err := a.backfillForwardingTargets(ctx, "account_forwarding_settings", "user_id"); err != nil { + return err + } + return a.backfillForwardingTargets(ctx, "mailbox_forwarding_settings", "mailbox_id") +} + +func (a *App) backfillForwardingTargets(ctx context.Context, table, keyColumn string) error { + rows, err := a.db.QueryContext(ctx, fmt.Sprintf(`SELECT %s,target_email,target_emails FROM %s`, keyColumn, table)) + if err != nil { + return err + } + defer rows.Close() + type row struct { + key string + targetEmail string + targetsJSON string + } + var updates []row + for rows.Next() { + var item row + if err := rows.Scan(&item.key, &item.targetEmail, &item.targetsJSON); err != nil { + return err + } + targets := forwardingTargetsFromStored(item.targetEmail, item.targetsJSON) + if len(targets) == 0 || len(jsonDecodeSlice(item.targetsJSON)) > 0 { + continue + } + updates = append(updates, item) + } + if err := rows.Err(); err != nil { + return err + } + if err := rows.Close(); err != nil { + return err + } + for _, item := range updates { + targets := forwardingTargetsFromStored(item.targetEmail, item.targetsJSON) + if _, err := a.db.ExecContext(ctx, fmt.Sprintf(`UPDATE %s SET target_emails=? WHERE %s=?`, table, keyColumn), jsonEncode(targets), item.key); err != nil { + return err + } + } + return nil +} + func (a *App) migrateAPITokenScopes(ctx context.Context) error { rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(api_tokens)`) if err != nil { diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index db15d07..27a2a44 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -1773,7 +1773,7 @@ func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) { func TestInboundForwardingSettingsAndDelivery(t *testing.T) { a := newTestApp(t) stopTestWorkers(a) - host, port, received := startCapturingSMTP(t, 4) + host, port, received := startCapturingSMTP(t, 8) a.cfg.SMTPHost = host a.cfg.SMTPPort = port ts := httptest.NewServer(a.Router()) @@ -1848,7 +1848,8 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) { if code := admin.do("GET", "/api/verify-email?token="+url.QueryEscape(token), nil, nil); code != http.StatusOK { t.Fatalf("reopen account verification link code=%d", code) } - if code := admin.do("POST", "/api/me/forwarding/account", map[string]string{"targetEmail": "account-forward@example.test"}, &settings); code != http.StatusOK || settings.AccountTargetEmail != "account-forward@example.test" { + verifyTarget("account-forward-two@example.test") + if code := admin.do("POST", "/api/me/forwarding/account", map[string]any{"targetEmails": []string{"account-forward@example.test", "account-forward-two@example.test"}}, &settings); code != http.StatusOK || settings.AccountTargetEmail != "account-forward@example.test" || len(settings.AccountTargetEmails) != 2 { t.Fatalf("save account forwarding code=%d settings=%+v", code, settings) } @@ -1887,7 +1888,7 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) { if err := a.db.QueryRow(`SELECT recipients_json FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceForwarding, firstID).Scan(&recipientsJSON); err != nil { t.Fatal(err) } - if !strings.Contains(recipientsJSON, "account-forward@example.test") { + if !strings.Contains(recipientsJSON, "account-forward@example.test") || !strings.Contains(recipientsJSON, "account-forward-two@example.test") { t.Fatalf("account forwarding recipients=%s", recipientsJSON) } if err := a.processDueSendQueue(ctx); err != nil { @@ -1903,7 +1904,8 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) { } verifyTarget("mailbox-forward@example.test") - if code := admin.do("POST", "/api/me/mailboxes/"+mb.ID+"/forwarding", map[string]string{"targetEmail": "mailbox-forward@example.test"}, &settings); code != http.StatusOK { + verifyTarget("mailbox-forward-two@example.test") + if code := admin.do("POST", "/api/me/mailboxes/"+mb.ID+"/forwarding", map[string]any{"targetEmails": []string{"mailbox-forward@example.test", "mailbox-forward-two@example.test"}}, &settings); code != http.StatusOK { t.Fatalf("save mailbox forwarding code=%d settings=%+v", code, settings) } raw = []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: mailbox forward\r\nMessage-ID: \r\n\r\nbody") @@ -1911,7 +1913,7 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) { if err := a.db.QueryRow(`SELECT recipients_json FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceForwarding, secondID).Scan(&recipientsJSON); err != nil { t.Fatal(err) } - if !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || strings.Contains(recipientsJSON, "account-forward@example.test") { + if !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") || strings.Contains(recipientsJSON, "account-forward@example.test") || strings.Contains(recipientsJSON, "account-forward-two@example.test") { t.Fatalf("mailbox forwarding should override account target, recipients=%s", recipientsJSON) } diff --git a/apps/api/internal/app/forwarding_delivery.go b/apps/api/internal/app/forwarding_delivery.go index 0b87062..2a2c4b7 100644 --- a/apps/api/internal/app/forwarding_delivery.go +++ b/apps/api/internal/app/forwarding_delivery.go @@ -11,15 +11,24 @@ import ( const forwardingHeaderName = "X-LanQin-Forwarded-By" func (a *App) processInboundForwarding(ctx context.Context, messageID, mailboxID string, raw []byte) { - target, userID, mailboxAddress, err := a.inboundForwardingTarget(ctx, mailboxID) + targets, userID, mailboxAddress, err := a.inboundForwardingTargets(ctx, mailboxID) if err != nil { a.log.Warn("failed to load forwarding target", "message", messageID, "mailbox", mailboxID, "error", err) return } - if target == "" || userID == "" || mailboxAddress == "" { + if len(targets) == 0 || userID == "" || mailboxAddress == "" { return } - if normalizeEmail(target) == normalizeEmail(mailboxAddress) { + self := normalizeEmail(mailboxAddress) + filteredTargets := make([]string, 0, len(targets)) + for _, target := range targets { + if normalizeEmail(target) == self { + continue + } + filteredTargets = append(filteredTargets, target) + } + targets = dedupeEmails(filteredTargets) + if len(targets) == 0 { return } if len(raw) == 0 { @@ -47,44 +56,47 @@ func (a *App) processInboundForwarding(ctx context.Context, messageID, mailboxID Source: sendSourceForwarding, MailFrom: mailboxAddress, HeaderFrom: mailboxAddress, - Recipients: []string{target}, + Recipients: targets, MIMEBytes: forwarded, Now: a.now().UTC(), }) if err != nil { - a.log.Warn("failed to enqueue inbound forwarding", "message", messageID, "mailbox", mailboxID, "target", target, "error", err) + a.log.Warn("failed to enqueue inbound forwarding", "message", messageID, "mailbox", mailboxID, "targets", strings.Join(targets, ","), "error", err) return } if queueID == "" { - a.log.Warn("forwarding target configured but SMTP sending is not configured", "message", messageID, "mailbox", mailboxID, "target", target) + a.log.Warn("forwarding target configured but SMTP sending is not configured", "message", messageID, "mailbox", mailboxID, "targets", strings.Join(targets, ",")) } } -func (a *App) inboundForwardingTarget(ctx context.Context, mailboxID string) (targetEmail, userID, mailboxAddress string, err error) { - var mailboxTarget, accountTarget string - err = a.db.QueryRowContext(ctx, `SELECT mb.user_id,mb.address,COALESCE(mfs.target_email,''),COALESCE(afs.target_email,'') +func (a *App) inboundForwardingTargets(ctx context.Context, mailboxID string) (targetEmails []string, userID, mailboxAddress string, err error) { + var mailboxTarget, mailboxTargetsJSON, accountTarget, accountTargetsJSON string + err = a.db.QueryRowContext(ctx, `SELECT mb.user_id,mb.address,COALESCE(mfs.target_email,''),COALESCE(mfs.target_emails,'[]'),COALESCE(afs.target_email,''),COALESCE(afs.target_emails,'[]') FROM mailboxes mb LEFT JOIN mailbox_forwarding_settings mfs ON mfs.mailbox_id=mb.id LEFT JOIN account_forwarding_settings afs ON afs.user_id=mb.user_id - WHERE mb.id=? AND mb.status='active'`, mailboxID).Scan(&userID, &mailboxAddress, &mailboxTarget, &accountTarget) + WHERE mb.id=? AND mb.status='active'`, mailboxID).Scan(&userID, &mailboxAddress, &mailboxTarget, &mailboxTargetsJSON, &accountTarget, &accountTargetsJSON) if err != nil { - return "", "", "", err + return nil, "", "", err } - target := normalizeEmail(mailboxTarget) - if target == "" { - target = normalizeEmail(accountTarget) + targets := forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON) + if len(targets) == 0 { + targets = forwardingTargetsFromStored(accountTarget, accountTargetsJSON) } - if target == "" { - return "", userID, mailboxAddress, nil + if len(targets) == 0 { + return nil, userID, mailboxAddress, nil } - verified, err := a.forwardingEmailVerified(ctx, userID, target) - if err != nil { - return "", "", "", err + verifiedTargets := make([]string, 0, len(targets)) + for _, target := range targets { + verified, err := a.forwardingEmailVerified(ctx, userID, target) + if err != nil { + return nil, "", "", err + } + if verified { + verifiedTargets = append(verifiedTargets, target) + } } - if !verified { - return "", userID, mailboxAddress, nil - } - return target, userID, mailboxAddress, nil + return dedupeEmails(verifiedTargets), userID, mailboxAddress, nil } func (a *App) forwardingRawMessage(ctx context.Context, messageID string) ([]byte, error) { diff --git a/apps/api/internal/app/forwarding_handlers.go b/apps/api/internal/app/forwarding_handlers.go index e760219..9a62b54 100644 --- a/apps/api/internal/app/forwarding_handlers.go +++ b/apps/api/internal/app/forwarding_handlers.go @@ -28,13 +28,15 @@ type ForwardingVerifiedEmail struct { } type MailboxForwardingRule struct { - MailboxID string `json:"mailboxId"` - TargetEmail string `json:"targetEmail"` + MailboxID string `json:"mailboxId"` + TargetEmail string `json:"targetEmail"` + TargetEmails []string `json:"targetEmails"` } type ForwardingSettings struct { VerifiedEmails []ForwardingVerifiedEmail `json:"verifiedEmails"` AccountTargetEmail string `json:"accountTargetEmail"` + AccountTargetEmails []string `json:"accountTargetEmails"` MailboxRules []MailboxForwardingRule `json:"mailboxRules"` } @@ -189,12 +191,7 @@ func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http respondError(w, http.StatusInternalServerError, "failed to delete verified email") return } - if _, err := tx.ExecContext(r.Context(), `UPDATE account_forwarding_settings SET target_email='',updated_at=? WHERE user_id=? AND target_email=?`, now, user.ID, email); err != nil { - respondError(w, http.StatusInternalServerError, "failed to update account forwarding") - return - } - if _, err := tx.ExecContext(r.Context(), `DELETE FROM mailbox_forwarding_settings - WHERE target_email=? AND mailbox_id IN (SELECT id FROM mailboxes WHERE user_id=?)`, email, user.ID); err != nil { + if err := a.removeForwardingTargetFromSettings(r.Context(), tx, user.ID, email, now); err != nil { respondError(w, http.StatusInternalServerError, "failed to update mailbox forwarding") return } @@ -213,22 +210,24 @@ func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http func (a *App) handleUpdateAccountForwarding(w http.ResponseWriter, r *http.Request) { user := currentUser(r) var req struct { - TargetEmail string `json:"targetEmail"` + TargetEmail string `json:"targetEmail"` + TargetEmails []string `json:"targetEmails"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) return } - target, err := a.cleanForwardingTarget(r.Context(), user.ID, req.TargetEmail) + targets, err := a.cleanForwardingTargets(r.Context(), user.ID, forwardingTargetsFromRequest(req.TargetEmail, req.TargetEmails)) if err != nil { badRequest(w, err) return } + target := firstForwardingTarget(targets) now := a.now().UTC().Format(time.RFC3339Nano) - _, err = a.db.ExecContext(r.Context(), `INSERT INTO account_forwarding_settings(user_id,target_email,updated_at) - VALUES(?,?,?) - ON CONFLICT(user_id) DO UPDATE SET target_email=excluded.target_email,updated_at=excluded.updated_at`, - user.ID, target, now) + _, err = a.db.ExecContext(r.Context(), `INSERT INTO account_forwarding_settings(user_id,target_email,target_emails,updated_at) + VALUES(?,?,?,?) + ON CONFLICT(user_id) DO UPDATE SET target_email=excluded.target_email,target_emails=excluded.target_emails,updated_at=excluded.updated_at`, + user.ID, target, jsonEncode(targets), now) if err != nil { respondError(w, http.StatusInternalServerError, "failed to save account forwarding") return @@ -256,28 +255,30 @@ func (a *App) handleUpdateMailboxForwarding(w http.ResponseWriter, r *http.Reque return } var req struct { - TargetEmail string `json:"targetEmail"` + TargetEmail string `json:"targetEmail"` + TargetEmails []string `json:"targetEmails"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) return } - target, err := a.cleanForwardingTarget(r.Context(), user.ID, req.TargetEmail) + targets, err := a.cleanForwardingTargets(r.Context(), user.ID, forwardingTargetsFromRequest(req.TargetEmail, req.TargetEmails)) if err != nil { badRequest(w, err) return } - if target == "" { + target := firstForwardingTarget(targets) + if len(targets) == 0 { if _, err := a.db.ExecContext(r.Context(), `DELETE FROM mailbox_forwarding_settings WHERE mailbox_id=?`, mailboxID); err != nil { respondError(w, http.StatusInternalServerError, "failed to save mailbox forwarding") return } } else { now := a.now().UTC().Format(time.RFC3339Nano) - if _, err := a.db.ExecContext(r.Context(), `INSERT INTO mailbox_forwarding_settings(mailbox_id,target_email,updated_at) - VALUES(?,?,?) - ON CONFLICT(mailbox_id) DO UPDATE SET target_email=excluded.target_email,updated_at=excluded.updated_at`, - mailboxID, target, now); err != nil { + if _, err := a.db.ExecContext(r.Context(), `INSERT INTO mailbox_forwarding_settings(mailbox_id,target_email,target_emails,updated_at) + VALUES(?,?,?,?) + ON CONFLICT(mailbox_id) DO UPDATE SET target_email=excluded.target_email,target_emails=excluded.target_emails,updated_at=excluded.updated_at`, + mailboxID, target, jsonEncode(targets), now); err != nil { respondError(w, http.StatusInternalServerError, "failed to save mailbox forwarding") return } @@ -325,14 +326,17 @@ func (a *App) forwardingSettings(ctx context.Context, userID string) (Forwarding if err := rows.Err(); err != nil { return settings, err } - err = a.db.QueryRowContext(ctx, `SELECT target_email FROM account_forwarding_settings WHERE user_id=?`, userID).Scan(&settings.AccountTargetEmail) + var accountTarget, accountTargetsJSON string + err = a.db.QueryRowContext(ctx, `SELECT target_email,target_emails FROM account_forwarding_settings WHERE user_id=?`, userID).Scan(&accountTarget, &accountTargetsJSON) if err != nil && !errors.Is(err, sql.ErrNoRows) { return settings, err } - rows, err = a.db.QueryContext(ctx, `SELECT mfs.mailbox_id,mfs.target_email + settings.AccountTargetEmails = forwardingTargetsFromStored(accountTarget, accountTargetsJSON) + settings.AccountTargetEmail = firstForwardingTarget(settings.AccountTargetEmails) + rows, err = a.db.QueryContext(ctx, `SELECT mfs.mailbox_id,mfs.target_email,mfs.target_emails FROM mailbox_forwarding_settings mfs JOIN mailboxes mb ON mb.id=mfs.mailbox_id - WHERE mb.user_id=? AND mfs.target_email<>'' + WHERE mb.user_id=? AND (mfs.target_email<>'' OR mfs.target_emails<>'[]') ORDER BY mb.address`, userID) if err != nil { return settings, err @@ -340,10 +344,15 @@ func (a *App) forwardingSettings(ctx context.Context, userID string) (Forwarding defer rows.Close() for rows.Next() { var item MailboxForwardingRule - if err := rows.Scan(&item.MailboxID, &item.TargetEmail); err != nil { + var target, targetsJSON string + if err := rows.Scan(&item.MailboxID, &target, &targetsJSON); err != nil { return settings, err } - settings.MailboxRules = append(settings.MailboxRules, item) + item.TargetEmails = forwardingTargetsFromStored(target, targetsJSON) + item.TargetEmail = firstForwardingTarget(item.TargetEmails) + if len(item.TargetEmails) > 0 { + settings.MailboxRules = append(settings.MailboxRules, item) + } } return settings, rows.Err() } @@ -475,25 +484,6 @@ func (a *App) primaryMailboxForUser(ctx context.Context, userID string) (Mailbox return mb, err } -func (a *App) cleanForwardingTarget(ctx context.Context, userID, value string) (string, error) { - value = strings.TrimSpace(value) - if value == "" || strings.EqualFold(value, "none") { - return "", nil - } - target := normalizeEmail(value) - if target == "" || !strings.Contains(target, "@") { - return "", errors.New("转发邮箱无效") - } - ok, err := a.forwardingEmailVerified(ctx, userID, target) - if err != nil { - return "", err - } - if !ok { - return "", errors.New("请先完成邮箱验证") - } - return target, nil -} - func (a *App) forwardingEmailVerified(ctx context.Context, userID, email string) (bool, error) { var count int err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM forwarding_verified_emails WHERE user_id=? AND email=? AND verified=1`, userID, normalizeEmail(email)).Scan(&count) @@ -503,6 +493,128 @@ func (a *App) forwardingEmailVerified(ctx context.Context, userID, email string) return count > 0, nil } +func forwardingTargetsFromRequest(targetEmail string, targetEmails []string) []string { + if len(targetEmails) > 0 { + return targetEmails + } + if strings.TrimSpace(targetEmail) == "" { + return nil + } + return []string{targetEmail} +} + +func (a *App) cleanForwardingTargets(ctx context.Context, userID string, values []string) ([]string, error) { + targets := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || strings.EqualFold(value, "none") { + continue + } + target := normalizeEmail(value) + if target == "" || !strings.Contains(target, "@") { + return nil, errors.New("转发邮箱无效") + } + if seen[target] { + continue + } + ok, err := a.forwardingEmailVerified(ctx, userID, target) + if err != nil { + return nil, err + } + if !ok { + return nil, errors.New("请先完成邮箱验证") + } + seen[target] = true + targets = append(targets, target) + } + return targets, nil +} + +func forwardingTargetsFromStored(targetEmail, targetsJSON string) []string { + targets := dedupeEmails(jsonDecodeSlice(targetsJSON)) + if len(targets) > 0 { + return targets + } + target := normalizeEmail(targetEmail) + if target == "" { + return nil + } + return []string{target} +} + +func firstForwardingTarget(targets []string) string { + if len(targets) == 0 { + return "" + } + return targets[0] +} + +func removeForwardingTarget(targets []string, email string) []string { + email = normalizeEmail(email) + next := make([]string, 0, len(targets)) + for _, target := range targets { + if normalizeEmail(target) == email { + continue + } + next = append(next, normalizeEmail(target)) + } + return dedupeEmails(next) +} + +func (a *App) removeForwardingTargetFromSettings(ctx context.Context, tx *sql.Tx, userID, email, now string) error { + var accountTarget, accountTargetsJSON string + if err := tx.QueryRowContext(ctx, `SELECT target_email,target_emails FROM account_forwarding_settings WHERE user_id=?`, userID).Scan(&accountTarget, &accountTargetsJSON); err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } else if err == nil { + targets := removeForwardingTarget(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), email) + if _, err := tx.ExecContext(ctx, `UPDATE account_forwarding_settings SET target_email=?,target_emails=?,updated_at=? WHERE user_id=?`, firstForwardingTarget(targets), jsonEncode(targets), now, userID); err != nil { + return err + } + } + + rows, err := tx.QueryContext(ctx, `SELECT mfs.mailbox_id,mfs.target_email,mfs.target_emails + FROM mailbox_forwarding_settings mfs + JOIN mailboxes mb ON mb.id=mfs.mailbox_id + WHERE mb.user_id=?`, userID) + if err != nil { + return err + } + type mailboxRow struct { + id string + target string + targetsJSON string + } + var items []mailboxRow + for rows.Next() { + var item mailboxRow + if err := rows.Scan(&item.id, &item.target, &item.targetsJSON); err != nil { + rows.Close() + return err + } + items = append(items, item) + } + if err := rows.Close(); err != nil { + return err + } + if err := rows.Err(); err != nil { + return err + } + for _, item := range items { + targets := removeForwardingTarget(forwardingTargetsFromStored(item.target, item.targetsJSON), email) + if len(targets) == 0 { + if _, err := tx.ExecContext(ctx, `DELETE FROM mailbox_forwarding_settings WHERE mailbox_id=?`, item.id); err != nil { + return err + } + continue + } + if _, err := tx.ExecContext(ctx, `UPDATE mailbox_forwarding_settings SET target_email=?,target_emails=?,updated_at=? WHERE mailbox_id=?`, firstForwardingTarget(targets), jsonEncode(targets), now, item.id); err != nil { + return err + } + } + return nil +} + func (a *App) userOwnsMailboxID(ctx context.Context, userID, mailboxID string) (bool, error) { var count int err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mailboxes WHERE id=? AND user_id=? AND status='active'`, mailboxID, userID).Scan(&count) diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 9118e8d..ef9cd1c 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -136,8 +136,8 @@ export type ForwardingVerifiedEmail = { deliveryStatus?: SendQueueStatus | "verified" deliveryError?: string } -export type MailboxForwardingRule = { mailboxId: string; targetEmail: string } -export type ForwardingSettings = { verifiedEmails: ForwardingVerifiedEmail[]; accountTargetEmail: string; mailboxRules: MailboxForwardingRule[] } +export type MailboxForwardingRule = { mailboxId: string; targetEmail: string; targetEmails?: string[] } +export type ForwardingSettings = { verifiedEmails: ForwardingVerifiedEmail[]; accountTargetEmail: string; accountTargetEmails?: string[]; mailboxRules: MailboxForwardingRule[] } export type ExternalImapStorageMode = "local" | "remote" export type ExternalImapTlsMode = "tls" | "starttls" | "plain" export type ExternalImapAuthMode = "password" | "oauth2" diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 51c4ad3..5667bfd 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -107,8 +107,8 @@ export const api = { addForwardingVerifiedEmail: (email: string) => request("/api/me/forwarding/verified-emails", { method: "POST", body: JSON.stringify({ email }) }), resendForwardingVerifiedEmail: (id: string) => request(`/api/me/forwarding/verified-emails/${id}/resend`, { method: "POST" }), deleteForwardingVerifiedEmail: (id: string) => request(`/api/me/forwarding/verified-emails/${id}`, { method: "DELETE" }), - updateAccountForwarding: (targetEmail: string) => request("/api/me/forwarding/account", { method: "POST", body: JSON.stringify({ targetEmail }) }), - updateMailboxForwarding: (mailboxId: string, targetEmail: string) => request(`/api/me/mailboxes/${mailboxId}/forwarding`, { method: "POST", body: JSON.stringify({ targetEmail }) }), + updateAccountForwarding: (targetEmails: string[] | string) => request("/api/me/forwarding/account", { method: "POST", body: JSON.stringify(Array.isArray(targetEmails) ? { targetEmails } : { targetEmail: targetEmails }) }), + updateMailboxForwarding: (mailboxId: string, targetEmails: string[] | string) => request(`/api/me/mailboxes/${mailboxId}/forwarding`, { method: "POST", body: JSON.stringify(Array.isArray(targetEmails) ? { targetEmails } : { targetEmail: targetEmails }) }), externalImapAccounts: (mailboxId?: string) => request>(`/api/me/external-imap-accounts${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), createExternalImapAccount: (payload: ExternalImapAccountPayload) => request("/api/me/external-imap-accounts", { method: "POST", body: JSON.stringify(payload) }), updateExternalImapAccount: (id: string, payload: ExternalImapAccountPayload) => request(`/api/me/external-imap-accounts/${id}`, { method: "POST", body: JSON.stringify(payload) }), diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index fae2d40..c7e08c5 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -1374,8 +1374,8 @@ function MailboxManagement({ const [editingNote, setEditingNote] = React.useState(null) const [noteDraft, setNoteDraft] = React.useState("") const [forwardingMailbox, setForwardingMailbox] = React.useState(null) - const [forwardDraft, setForwardDraft] = React.useState("none") - const [accountForwardTarget, setAccountForwardTarget] = React.useState("none") + const [forwardDraft, setForwardDraft] = React.useState([]) + const [accountForwardTargets, setAccountForwardTargets] = React.useState([]) const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false) const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("") const [logs, setLogs] = React.useState(() => readLocalLogs("lanqin:seek-mailbox-action-logs")) @@ -1383,10 +1383,11 @@ function MailboxManagement({ const forwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: mailboxes.length > 0 }) const verifiedEmailItems = forwarding.data?.verifiedEmails || [] const verifiedEmails = React.useMemo(() => verifiedEmailItems.filter((item) => item.verified).map((item) => item.email), [verifiedEmailItems]) - const mailboxForwards = React.useMemo>(() => { - const next: Record = {} + const mailboxForwards = React.useMemo>(() => { + const next: Record = {} for (const rule of forwarding.data?.mailboxRules || []) { - if (rule.targetEmail) next[rule.mailboxId] = rule.targetEmail + const targets = forwardingTargetsFromRule(rule) + if (targets.length > 0) next[rule.mailboxId] = targets } return next }, [forwarding.data?.mailboxRules]) @@ -1437,21 +1438,21 @@ function MailboxManagement({ }) const saveAccountForwarding = useMutation({ mutationFn: api.updateAccountForwarding, - onSuccess: (settings, target) => { + onSuccess: (settings, targets) => { setForwardingCache(settings) - addLog("保存账号转发", target || "不转发") + addLog("保存账号转发", forwardingTargetsLabel(Array.isArray(targets) ? targets : [targets].filter(Boolean))) toast({ title: "账号级转发已保存" }) }, onError: (error) => toast({ title: "保存失败", description: error.message }), }) const saveMailboxForwarding = useMutation({ - mutationFn: ({ mailboxId, targetEmail }: { mailboxId: string; targetEmail: string }) => api.updateMailboxForwarding(mailboxId, targetEmail), + mutationFn: ({ mailboxId, targetEmails }: { mailboxId: string; targetEmails: string[] }) => api.updateMailboxForwarding(mailboxId, targetEmails), onSuccess: (settings, payload) => { setForwardingCache(settings) const mailbox = mailboxes.find((item) => item.id === payload.mailboxId) - addLog("保存邮箱转发", mailbox?.address || payload.mailboxId) + addLog("保存邮箱转发", `${mailbox?.address || payload.mailboxId} -> ${forwardingTargetsLabel(payload.targetEmails)}`) setForwardingMailbox(null) - setForwardDraft("none") + setForwardDraft([]) toast({ title: "邮箱转发已保存" }) }, onError: (error) => toast({ title: "保存失败", description: error.message }), @@ -1464,8 +1465,8 @@ function MailboxManagement({ }, [domainOptions]) React.useEffect(() => { - setAccountForwardTarget(forwarding.data?.accountTargetEmail || "none") - }, [forwarding.data?.accountTargetEmail]) + setAccountForwardTargets(forwardingTargetsFromSettings(forwarding.data)) + }, [forwarding.data?.accountTargetEmail, forwarding.data?.accountTargetEmails]) React.useEffect(() => { writeLocalRecord("lanqin:seek-mailbox-notes", notes) }, [notes]) React.useEffect(() => { writeLocalLogs("lanqin:seek-mailbox-action-logs", logs) }, [logs]) @@ -1497,12 +1498,12 @@ function MailboxManagement({ function openMailboxForward(mailbox: Mailbox) { setForwardingMailbox(mailbox) - setForwardDraft(mailboxForwards[mailbox.id] || "none") + setForwardDraft(mailboxForwards[mailbox.id] || []) } function saveMailboxForward() { if (!forwardingMailbox) return - saveMailboxForwarding.mutate({ mailboxId: forwardingMailbox.id, targetEmail: forwardDraft === "none" ? "" : forwardDraft }) + saveMailboxForwarding.mutate({ mailboxId: forwardingMailbox.id, targetEmails: forwardDraft }) } function submitVerifiedEmail(event: React.FormEvent) { @@ -1513,8 +1514,8 @@ function MailboxManagement({ } function removeVerifiedEmail(id: string, email: string) { - if (accountForwardTarget === email) setAccountForwardTarget("none") - if (forwardDraft === email) setForwardDraft("none") + setAccountForwardTargets((items) => items.filter((item) => item !== email)) + setForwardDraft((items) => items.filter((item) => item !== email)) deleteVerifiedEmail.mutate({ id, email }) } @@ -1559,7 +1560,7 @@ function MailboxManagement({

- {canApply ? "提示:短邮箱或单词邮箱需要支付星辰费用。每周可免费创建 35 个邮箱。" : "提示:当前账号暂不可创建新邮箱。"} + {canApply ? "提示:邮箱数量受账号配额限制,管理员可在后台为单个账号调整可创建数量。" : "提示:当前账号暂不可创建新邮箱。"}

@@ -1574,8 +1575,10 @@ function MailboxManagement({
{filteredMailboxes.map((mailbox) => { const note = notes[mailbox.id]?.trim() - const forwardTarget = mailboxForwards[mailbox.id] - const accountForwardTargetActive = !forwardTarget && accountForwardTarget !== "none" + const forwardTargets = mailboxForwards[mailbox.id] || [] + const accountForwardTargetActive = forwardTargets.length === 0 && accountForwardTargets.length > 0 + const effectiveForwardTargets = forwardTargets.length > 0 ? forwardTargets : accountForwardTargetActive ? accountForwardTargets : [] + const forwardingActive = effectiveForwardTargets.length > 0 return (
@@ -1584,20 +1587,20 @@ function MailboxManagement({
- + {selectedMailboxId === mailbox.id && 当前}
创建于 {formatDateTime(mailbox.createdAt)} {note && 备注:{note}} - {forwardTarget && forwardTarget !== "none" && 转发:{forwardTarget}} - {accountForwardTargetActive && 转发:使用账号级 {accountForwardTarget}} + {forwardTargets.length > 0 && 转发:{forwardTargets.join("、")}} + {accountForwardTargetActive && 转发:使用账号级 {accountForwardTargets.join("、")}}
- +
@@ -1615,16 +1618,10 @@ function MailboxManagement({
账号级转发
-
对所有邮箱生效,邮箱单独设置优先级更高
-
- - +
对所有邮箱生效,可同时转发到多个已验证邮箱;邮箱单独设置优先级更高
+
+ +
{verifiedEmailItems.length > 0 && ( @@ -1641,7 +1638,7 @@ function MailboxManagement({
)} {verifiedEmails.length === 0 &&

暂未添加验证邮箱,请先点击「管理验证邮箱」添加。

} -

提示:每个邮箱可单独设置转发(点击邮箱列表中的「转发」按钮),单独设置会覆盖账号级配置。

+

提示:每个邮箱可单独设置多个转发目标(点击邮箱列表中的「转发」按钮),单独设置会覆盖账号级配置。

@@ -1678,13 +1675,7 @@ function MailboxManagement({
{forwardingMailbox?.address}
- + {verifiedEmails.length === 0 &&

暂未添加验证邮箱,请先点击「管理验证邮箱」添加。

}
@@ -1965,6 +1956,54 @@ function formatDateTime(value: string) { return date.toLocaleString() } +function forwardingTargetsFromRule(rule: { targetEmail?: string; targetEmails?: string[] }) { + const targets = rule.targetEmails?.length ? rule.targetEmails : rule.targetEmail ? [rule.targetEmail] : [] + return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean))) +} + +function forwardingTargetsFromSettings(settings?: ForwardingSettings) { + const targets = settings?.accountTargetEmails?.length ? settings.accountTargetEmails : settings?.accountTargetEmail ? [settings.accountTargetEmail] : [] + return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean))) +} + +function forwardingTargetsLabel(targets: string[]) { + const clean = Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean))) + return clean.length ? clean.join("、") : "不转发" +} + +function ForwardingTargetPicker({ emails, selected, onChange, disabled }: { emails: string[]; selected: string[]; onChange: (targets: string[]) => void; disabled?: boolean }) { + const selectedSet = React.useMemo(() => new Set(selected), [selected]) + function toggle(email: string, checked: boolean) { + if (disabled) return + const next = checked ? Array.from(new Set([...selected, email])) : selected.filter((item) => item !== email) + onChange(next) + } + if (emails.length === 0) { + return
暂无已验证邮箱
+ } + return ( +
+ {emails.map((email) => { + const checked = selectedSet.has(email) + return ( + + ) + })} + {selected.length === 0 &&
未选择目标,保存后不转发。
} +
+ ) +} + function forwardingEmailTone(item: ForwardingVerifiedEmail) { if (item.verified) { return { @@ -1992,9 +2031,9 @@ function forwardingEmailTone(item: ForwardingVerifiedEmail) { } return { shortLabel: "待验证", - dotClass: "bg-blue-500", - chipClass: "bg-blue-100 text-blue-800", - detailClass: "text-blue-700", + dotClass: "bg-muted-foreground", + chipClass: "bg-muted text-foreground", + detailClass: "text-foreground", } }