feat: support multiple forwarding targets

This commit is contained in:
zxyszx
2026-08-02 18:33:25 +08:00
parent b8632feb86
commit 25cbd58e1f
7 changed files with 342 additions and 121 deletions
+56
View File
@@ -249,11 +249,13 @@ func (a *App) migrate(ctx context.Context) error {
`CREATE TABLE IF NOT EXISTS account_forwarding_settings ( `CREATE TABLE IF NOT EXISTS account_forwarding_settings (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
target_email TEXT NOT NULL DEFAULT '', target_email TEXT NOT NULL DEFAULT '',
target_emails TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
)`, )`,
`CREATE TABLE IF NOT EXISTS mailbox_forwarding_settings ( `CREATE TABLE IF NOT EXISTS mailbox_forwarding_settings (
mailbox_id TEXT PRIMARY KEY REFERENCES mailboxes(id) ON DELETE CASCADE, mailbox_id TEXT PRIMARY KEY REFERENCES mailboxes(id) ON DELETE CASCADE,
target_email TEXT NOT NULL DEFAULT '', target_email TEXT NOT NULL DEFAULT '',
target_emails TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
)`, )`,
`CREATE TABLE IF NOT EXISTS aliases ( `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 { if err := a.migrateForwardingVerification(ctx); err != nil {
return err return err
} }
if err := a.migrateForwardingTargets(ctx); err != nil {
return err
}
if err := a.migrateAPITokenScopes(ctx); err != nil { if err := a.migrateAPITokenScopes(ctx); err != nil {
return err return err
} }
@@ -666,6 +671,57 @@ func (a *App) migrateForwardingVerification(ctx context.Context) error {
return err 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 { func (a *App) migrateAPITokenScopes(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(api_tokens)`) rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(api_tokens)`)
if err != nil { if err != nil {
+7 -5
View File
@@ -1773,7 +1773,7 @@ func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) {
func TestInboundForwardingSettingsAndDelivery(t *testing.T) { func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
stopTestWorkers(a) stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 4) host, port, received := startCapturingSMTP(t, 8)
a.cfg.SMTPHost = host a.cfg.SMTPHost = host
a.cfg.SMTPPort = port a.cfg.SMTPPort = port
ts := httptest.NewServer(a.Router()) 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 { 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) 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) 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 { 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) 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) t.Fatalf("account forwarding recipients=%s", recipientsJSON)
} }
if err := a.processDueSendQueue(ctx); err != nil { if err := a.processDueSendQueue(ctx); err != nil {
@@ -1903,7 +1904,8 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
} }
verifyTarget("mailbox-forward@example.test") 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) 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: <mailbox-forward@example.test>\r\n\r\nbody") raw = []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: mailbox forward\r\nMessage-ID: <mailbox-forward@example.test>\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 { 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) 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) t.Fatalf("mailbox forwarding should override account target, recipients=%s", recipientsJSON)
} }
+35 -23
View File
@@ -11,15 +11,24 @@ import (
const forwardingHeaderName = "X-LanQin-Forwarded-By" const forwardingHeaderName = "X-LanQin-Forwarded-By"
func (a *App) processInboundForwarding(ctx context.Context, messageID, mailboxID string, raw []byte) { 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 { if err != nil {
a.log.Warn("failed to load forwarding target", "message", messageID, "mailbox", mailboxID, "error", err) a.log.Warn("failed to load forwarding target", "message", messageID, "mailbox", mailboxID, "error", err)
return return
} }
if target == "" || userID == "" || mailboxAddress == "" { if len(targets) == 0 || userID == "" || mailboxAddress == "" {
return 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 return
} }
if len(raw) == 0 { if len(raw) == 0 {
@@ -47,44 +56,47 @@ func (a *App) processInboundForwarding(ctx context.Context, messageID, mailboxID
Source: sendSourceForwarding, Source: sendSourceForwarding,
MailFrom: mailboxAddress, MailFrom: mailboxAddress,
HeaderFrom: mailboxAddress, HeaderFrom: mailboxAddress,
Recipients: []string{target}, Recipients: targets,
MIMEBytes: forwarded, MIMEBytes: forwarded,
Now: a.now().UTC(), Now: a.now().UTC(),
}) })
if err != nil { 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 return
} }
if queueID == "" { 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) { func (a *App) inboundForwardingTargets(ctx context.Context, mailboxID string) (targetEmails []string, userID, mailboxAddress string, err error) {
var mailboxTarget, accountTarget string var mailboxTarget, mailboxTargetsJSON, accountTarget, accountTargetsJSON string
err = a.db.QueryRowContext(ctx, `SELECT mb.user_id,mb.address,COALESCE(mfs.target_email,''),COALESCE(afs.target_email,'') 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 FROM mailboxes mb
LEFT JOIN mailbox_forwarding_settings mfs ON mfs.mailbox_id=mb.id 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 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 { if err != nil {
return "", "", "", err return nil, "", "", err
} }
target := normalizeEmail(mailboxTarget) targets := forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)
if target == "" { if len(targets) == 0 {
target = normalizeEmail(accountTarget) targets = forwardingTargetsFromStored(accountTarget, accountTargetsJSON)
} }
if target == "" { if len(targets) == 0 {
return "", userID, mailboxAddress, nil return nil, userID, mailboxAddress, nil
} }
verified, err := a.forwardingEmailVerified(ctx, userID, target) verifiedTargets := make([]string, 0, len(targets))
if err != nil { for _, target := range targets {
return "", "", "", err verified, err := a.forwardingEmailVerified(ctx, userID, target)
if err != nil {
return nil, "", "", err
}
if verified {
verifiedTargets = append(verifiedTargets, target)
}
} }
if !verified { return dedupeEmails(verifiedTargets), userID, mailboxAddress, nil
return "", userID, mailboxAddress, nil
}
return target, userID, mailboxAddress, nil
} }
func (a *App) forwardingRawMessage(ctx context.Context, messageID string) ([]byte, error) { func (a *App) forwardingRawMessage(ctx context.Context, messageID string) ([]byte, error) {
+157 -45
View File
@@ -28,13 +28,15 @@ type ForwardingVerifiedEmail struct {
} }
type MailboxForwardingRule struct { type MailboxForwardingRule struct {
MailboxID string `json:"mailboxId"` MailboxID string `json:"mailboxId"`
TargetEmail string `json:"targetEmail"` TargetEmail string `json:"targetEmail"`
TargetEmails []string `json:"targetEmails"`
} }
type ForwardingSettings struct { type ForwardingSettings struct {
VerifiedEmails []ForwardingVerifiedEmail `json:"verifiedEmails"` VerifiedEmails []ForwardingVerifiedEmail `json:"verifiedEmails"`
AccountTargetEmail string `json:"accountTargetEmail"` AccountTargetEmail string `json:"accountTargetEmail"`
AccountTargetEmails []string `json:"accountTargetEmails"`
MailboxRules []MailboxForwardingRule `json:"mailboxRules"` 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") respondError(w, http.StatusInternalServerError, "failed to delete verified email")
return 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 { if err := a.removeForwardingTargetFromSettings(r.Context(), tx, user.ID, email, now); 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 {
respondError(w, http.StatusInternalServerError, "failed to update mailbox forwarding") respondError(w, http.StatusInternalServerError, "failed to update mailbox forwarding")
return return
} }
@@ -213,22 +210,24 @@ func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http
func (a *App) handleUpdateAccountForwarding(w http.ResponseWriter, r *http.Request) { func (a *App) handleUpdateAccountForwarding(w http.ResponseWriter, r *http.Request) {
user := currentUser(r) user := currentUser(r)
var req struct { var req struct {
TargetEmail string `json:"targetEmail"` TargetEmail string `json:"targetEmail"`
TargetEmails []string `json:"targetEmails"`
} }
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
badRequest(w, err) badRequest(w, err)
return 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 { if err != nil {
badRequest(w, err) badRequest(w, err)
return return
} }
target := firstForwardingTarget(targets)
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
_, err = a.db.ExecContext(r.Context(), `INSERT INTO account_forwarding_settings(user_id,target_email,updated_at) _, err = a.db.ExecContext(r.Context(), `INSERT INTO account_forwarding_settings(user_id,target_email,target_emails,updated_at)
VALUES(?,?,?) VALUES(?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET target_email=excluded.target_email,updated_at=excluded.updated_at`, 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, now) user.ID, target, jsonEncode(targets), now)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to save account forwarding") respondError(w, http.StatusInternalServerError, "failed to save account forwarding")
return return
@@ -256,28 +255,30 @@ func (a *App) handleUpdateMailboxForwarding(w http.ResponseWriter, r *http.Reque
return return
} }
var req struct { var req struct {
TargetEmail string `json:"targetEmail"` TargetEmail string `json:"targetEmail"`
TargetEmails []string `json:"targetEmails"`
} }
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
badRequest(w, err) badRequest(w, err)
return 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 { if err != nil {
badRequest(w, err) badRequest(w, err)
return 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 { 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") respondError(w, http.StatusInternalServerError, "failed to save mailbox forwarding")
return return
} }
} else { } else {
now := a.now().UTC().Format(time.RFC3339Nano) 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) if _, err := a.db.ExecContext(r.Context(), `INSERT INTO mailbox_forwarding_settings(mailbox_id,target_email,target_emails,updated_at)
VALUES(?,?,?) VALUES(?,?,?,?)
ON CONFLICT(mailbox_id) DO UPDATE SET target_email=excluded.target_email,updated_at=excluded.updated_at`, ON CONFLICT(mailbox_id) DO UPDATE SET target_email=excluded.target_email,target_emails=excluded.target_emails,updated_at=excluded.updated_at`,
mailboxID, target, now); err != nil { mailboxID, target, jsonEncode(targets), now); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save mailbox forwarding") respondError(w, http.StatusInternalServerError, "failed to save mailbox forwarding")
return return
} }
@@ -325,14 +326,17 @@ func (a *App) forwardingSettings(ctx context.Context, userID string) (Forwarding
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return settings, err 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) { if err != nil && !errors.Is(err, sql.ErrNoRows) {
return settings, err 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 FROM mailbox_forwarding_settings mfs
JOIN mailboxes mb ON mb.id=mfs.mailbox_id 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) ORDER BY mb.address`, userID)
if err != nil { if err != nil {
return settings, err return settings, err
@@ -340,10 +344,15 @@ func (a *App) forwardingSettings(ctx context.Context, userID string) (Forwarding
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var item MailboxForwardingRule 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 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() return settings, rows.Err()
} }
@@ -475,25 +484,6 @@ func (a *App) primaryMailboxForUser(ctx context.Context, userID string) (Mailbox
return mb, err 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) { func (a *App) forwardingEmailVerified(ctx context.Context, userID, email string) (bool, error) {
var count int 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) 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 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) { func (a *App) userOwnsMailboxID(ctx context.Context, userID, mailboxID string) (bool, error) {
var count int 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) err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mailboxes WHERE id=? AND user_id=? AND status='active'`, mailboxID, userID).Scan(&count)
+2 -2
View File
@@ -136,8 +136,8 @@ export type ForwardingVerifiedEmail = {
deliveryStatus?: SendQueueStatus | "verified" deliveryStatus?: SendQueueStatus | "verified"
deliveryError?: string deliveryError?: string
} }
export type MailboxForwardingRule = { mailboxId: string; targetEmail: string } export type MailboxForwardingRule = { mailboxId: string; targetEmail: string; targetEmails?: string[] }
export type ForwardingSettings = { verifiedEmails: ForwardingVerifiedEmail[]; accountTargetEmail: string; mailboxRules: MailboxForwardingRule[] } export type ForwardingSettings = { verifiedEmails: ForwardingVerifiedEmail[]; accountTargetEmail: string; accountTargetEmails?: string[]; mailboxRules: MailboxForwardingRule[] }
export type ExternalImapStorageMode = "local" | "remote" export type ExternalImapStorageMode = "local" | "remote"
export type ExternalImapTlsMode = "tls" | "starttls" | "plain" export type ExternalImapTlsMode = "tls" | "starttls" | "plain"
export type ExternalImapAuthMode = "password" | "oauth2" export type ExternalImapAuthMode = "password" | "oauth2"
+2 -2
View File
@@ -107,8 +107,8 @@ export const api = {
addForwardingVerifiedEmail: (email: string) => request<ForwardingSettings>("/api/me/forwarding/verified-emails", { method: "POST", body: JSON.stringify({ email }) }), addForwardingVerifiedEmail: (email: string) => request<ForwardingSettings>("/api/me/forwarding/verified-emails", { method: "POST", body: JSON.stringify({ email }) }),
resendForwardingVerifiedEmail: (id: string) => request<ForwardingSettings>(`/api/me/forwarding/verified-emails/${id}/resend`, { method: "POST" }), resendForwardingVerifiedEmail: (id: string) => request<ForwardingSettings>(`/api/me/forwarding/verified-emails/${id}/resend`, { method: "POST" }),
deleteForwardingVerifiedEmail: (id: string) => request<ForwardingSettings>(`/api/me/forwarding/verified-emails/${id}`, { method: "DELETE" }), deleteForwardingVerifiedEmail: (id: string) => request<ForwardingSettings>(`/api/me/forwarding/verified-emails/${id}`, { method: "DELETE" }),
updateAccountForwarding: (targetEmail: string) => request<ForwardingSettings>("/api/me/forwarding/account", { method: "POST", body: JSON.stringify({ targetEmail }) }), updateAccountForwarding: (targetEmails: string[] | string) => request<ForwardingSettings>("/api/me/forwarding/account", { method: "POST", body: JSON.stringify(Array.isArray(targetEmails) ? { targetEmails } : { targetEmail: targetEmails }) }),
updateMailboxForwarding: (mailboxId: string, targetEmail: string) => request<ForwardingSettings>(`/api/me/mailboxes/${mailboxId}/forwarding`, { method: "POST", body: JSON.stringify({ targetEmail }) }), updateMailboxForwarding: (mailboxId: string, targetEmails: string[] | string) => request<ForwardingSettings>(`/api/me/mailboxes/${mailboxId}/forwarding`, { method: "POST", body: JSON.stringify(Array.isArray(targetEmails) ? { targetEmails } : { targetEmail: targetEmails }) }),
externalImapAccounts: (mailboxId?: string) => request<ListResponse<ExternalImapAccount>>(`/api/me/external-imap-accounts${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), 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) }), 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) }), updateExternalImapAccount: (id: string, payload: ExternalImapAccountPayload) => request<ExternalImapAccount>(`/api/me/external-imap-accounts/${id}`, { method: "POST", body: JSON.stringify(payload) }),
+83 -44
View File
@@ -1374,8 +1374,8 @@ function MailboxManagement({
const [editingNote, setEditingNote] = React.useState<Mailbox | null>(null) const [editingNote, setEditingNote] = React.useState<Mailbox | null>(null)
const [noteDraft, setNoteDraft] = React.useState("") const [noteDraft, setNoteDraft] = React.useState("")
const [forwardingMailbox, setForwardingMailbox] = React.useState<Mailbox | null>(null) const [forwardingMailbox, setForwardingMailbox] = React.useState<Mailbox | null>(null)
const [forwardDraft, setForwardDraft] = React.useState("none") const [forwardDraft, setForwardDraft] = React.useState<string[]>([])
const [accountForwardTarget, setAccountForwardTarget] = React.useState("none") const [accountForwardTargets, setAccountForwardTargets] = React.useState<string[]>([])
const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false) const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false)
const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("") const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("")
const [logs, setLogs] = React.useState<MailboxActionLog[]>(() => readLocalLogs("lanqin:seek-mailbox-action-logs")) const [logs, setLogs] = React.useState<MailboxActionLog[]>(() => 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 forwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: mailboxes.length > 0 })
const verifiedEmailItems = forwarding.data?.verifiedEmails || [] const verifiedEmailItems = forwarding.data?.verifiedEmails || []
const verifiedEmails = React.useMemo(() => verifiedEmailItems.filter((item) => item.verified).map((item) => item.email), [verifiedEmailItems]) const verifiedEmails = React.useMemo(() => verifiedEmailItems.filter((item) => item.verified).map((item) => item.email), [verifiedEmailItems])
const mailboxForwards = React.useMemo<Record<string, string>>(() => { const mailboxForwards = React.useMemo<Record<string, string[]>>(() => {
const next: Record<string, string> = {} const next: Record<string, string[]> = {}
for (const rule of forwarding.data?.mailboxRules || []) { 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 return next
}, [forwarding.data?.mailboxRules]) }, [forwarding.data?.mailboxRules])
@@ -1437,21 +1438,21 @@ function MailboxManagement({
}) })
const saveAccountForwarding = useMutation({ const saveAccountForwarding = useMutation({
mutationFn: api.updateAccountForwarding, mutationFn: api.updateAccountForwarding,
onSuccess: (settings, target) => { onSuccess: (settings, targets) => {
setForwardingCache(settings) setForwardingCache(settings)
addLog("保存账号转发", target || "不转发") addLog("保存账号转发", forwardingTargetsLabel(Array.isArray(targets) ? targets : [targets].filter(Boolean)))
toast({ title: "账号级转发已保存" }) toast({ title: "账号级转发已保存" })
}, },
onError: (error) => toast({ title: "保存失败", description: error.message }), onError: (error) => toast({ title: "保存失败", description: error.message }),
}) })
const saveMailboxForwarding = useMutation({ 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) => { onSuccess: (settings, payload) => {
setForwardingCache(settings) setForwardingCache(settings)
const mailbox = mailboxes.find((item) => item.id === payload.mailboxId) const mailbox = mailboxes.find((item) => item.id === payload.mailboxId)
addLog("保存邮箱转发", mailbox?.address || payload.mailboxId) addLog("保存邮箱转发", `${mailbox?.address || payload.mailboxId} -> ${forwardingTargetsLabel(payload.targetEmails)}`)
setForwardingMailbox(null) setForwardingMailbox(null)
setForwardDraft("none") setForwardDraft([])
toast({ title: "邮箱转发已保存" }) toast({ title: "邮箱转发已保存" })
}, },
onError: (error) => toast({ title: "保存失败", description: error.message }), onError: (error) => toast({ title: "保存失败", description: error.message }),
@@ -1464,8 +1465,8 @@ function MailboxManagement({
}, [domainOptions]) }, [domainOptions])
React.useEffect(() => { React.useEffect(() => {
setAccountForwardTarget(forwarding.data?.accountTargetEmail || "none") setAccountForwardTargets(forwardingTargetsFromSettings(forwarding.data))
}, [forwarding.data?.accountTargetEmail]) }, [forwarding.data?.accountTargetEmail, forwarding.data?.accountTargetEmails])
React.useEffect(() => { writeLocalRecord("lanqin:seek-mailbox-notes", notes) }, [notes]) React.useEffect(() => { writeLocalRecord("lanqin:seek-mailbox-notes", notes) }, [notes])
React.useEffect(() => { writeLocalLogs("lanqin:seek-mailbox-action-logs", logs) }, [logs]) React.useEffect(() => { writeLocalLogs("lanqin:seek-mailbox-action-logs", logs) }, [logs])
@@ -1497,12 +1498,12 @@ function MailboxManagement({
function openMailboxForward(mailbox: Mailbox) { function openMailboxForward(mailbox: Mailbox) {
setForwardingMailbox(mailbox) setForwardingMailbox(mailbox)
setForwardDraft(mailboxForwards[mailbox.id] || "none") setForwardDraft(mailboxForwards[mailbox.id] || [])
} }
function saveMailboxForward() { function saveMailboxForward() {
if (!forwardingMailbox) return if (!forwardingMailbox) return
saveMailboxForwarding.mutate({ mailboxId: forwardingMailbox.id, targetEmail: forwardDraft === "none" ? "" : forwardDraft }) saveMailboxForwarding.mutate({ mailboxId: forwardingMailbox.id, targetEmails: forwardDraft })
} }
function submitVerifiedEmail(event: React.FormEvent<HTMLFormElement>) { function submitVerifiedEmail(event: React.FormEvent<HTMLFormElement>) {
@@ -1513,8 +1514,8 @@ function MailboxManagement({
} }
function removeVerifiedEmail(id: string, email: string) { function removeVerifiedEmail(id: string, email: string) {
if (accountForwardTarget === email) setAccountForwardTarget("none") setAccountForwardTargets((items) => items.filter((item) => item !== email))
if (forwardDraft === email) setForwardDraft("none") setForwardDraft((items) => items.filter((item) => item !== email))
deleteVerifiedEmail.mutate({ id, email }) deleteVerifiedEmail.mutate({ id, email })
} }
@@ -1559,7 +1560,7 @@ function MailboxManagement({
<Button className="h-[42px] px-0" disabled={!canApply || applyPending || !selectedDomain || !localPart.trim()}>{applyPending ? "创建中" : "创建"}</Button> <Button className="h-[42px] px-0" disabled={!canApply || applyPending || !selectedDomain || !localPart.trim()}>{applyPending ? "创建中" : "创建"}</Button>
</form> </form>
<p className="mt-4 text-sm text-muted-foreground"> <p className="mt-4 text-sm text-muted-foreground">
{canApply ? "提示:邮箱或单词邮箱需要支付星辰费用。每周可免费创建 35 个邮箱。" : "提示:当前账号暂不可创建新邮箱。"} {canApply ? "提示:邮箱数量受账号配额限制,管理员可在后台为单个账号调整可创建数量。" : "提示:当前账号暂不可创建新邮箱。"}
</p> </p>
</section> </section>
@@ -1574,8 +1575,10 @@ function MailboxManagement({
<div className="divide-y"> <div className="divide-y">
{filteredMailboxes.map((mailbox) => { {filteredMailboxes.map((mailbox) => {
const note = notes[mailbox.id]?.trim() const note = notes[mailbox.id]?.trim()
const forwardTarget = mailboxForwards[mailbox.id] const forwardTargets = mailboxForwards[mailbox.id] || []
const accountForwardTargetActive = !forwardTarget && accountForwardTarget !== "none" const accountForwardTargetActive = forwardTargets.length === 0 && accountForwardTargets.length > 0
const effectiveForwardTargets = forwardTargets.length > 0 ? forwardTargets : accountForwardTargetActive ? accountForwardTargets : []
const forwardingActive = effectiveForwardTargets.length > 0
return ( return (
<div key={mailbox.id} className={cn("grid gap-3 px-6 py-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center", selectedMailboxId === mailbox.id && "bg-muted/50")}> <div key={mailbox.id} className={cn("grid gap-3 px-6 py-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center", selectedMailboxId === mailbox.id && "bg-muted/50")}>
<div className="flex min-w-0 items-center gap-4"> <div className="flex min-w-0 items-center gap-4">
@@ -1584,20 +1587,20 @@ function MailboxManagement({
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<button type="button" className="min-w-0 truncate text-left text-sm font-semibold hover:underline" onClick={() => { onSelect(mailbox.id); onOpen(mailbox.id) }}>{mailbox.address}</button> <Button type="button" variant="ghost" size="sm" className="h-auto min-w-0 max-w-full truncate justify-start p-0 text-left text-sm font-semibold hover:bg-transparent hover:underline" onClick={() => { onSelect(mailbox.id); onOpen(mailbox.id) }}>{mailbox.address}</Button>
{selectedMailboxId === mailbox.id && <Badge variant="secondary" className="h-5 rounded-md px-1.5 text-[10px]"></Badge>} {selectedMailboxId === mailbox.id && <Badge variant="secondary" className="h-5 rounded-md px-1.5 text-[10px]"></Badge>}
</div> </div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"> <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span> {formatDateTime(mailbox.createdAt)}</span> <span> {formatDateTime(mailbox.createdAt)}</span>
{note && <span className="max-w-full truncate">{note}</span>} {note && <span className="max-w-full truncate">{note}</span>}
{forwardTarget && forwardTarget !== "none" && <span className="max-w-full truncate">{forwardTarget}</span>} {forwardTargets.length > 0 && <span className="max-w-full truncate font-semibold text-foreground">{forwardTargets.join("、")}</span>}
{accountForwardTargetActive && <span className="max-w-full truncate">使 {accountForwardTarget}</span>} {accountForwardTargetActive && <span className="max-w-full truncate font-semibold text-foreground">使 {accountForwardTargets.join("、")}</span>}
</div> </div>
</div> </div>
</div> </div>
<div className="flex shrink-0 flex-wrap gap-2"> <div className="flex shrink-0 flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" className="h-[30px] w-[72px] gap-1 px-0" onClick={() => openMailboxNote(mailbox)}><PencilLine className="h-3.5 w-3.5" /></Button> <Button type="button" variant="outline" size="sm" className="h-[30px] w-[72px] gap-1 px-0" onClick={() => openMailboxNote(mailbox)}><PencilLine className="h-3.5 w-3.5" /></Button>
<Button type="button" variant="outline" size="sm" className="h-[30px] w-[72px] gap-1 px-0" onClick={() => { onSelect(mailbox.id); openMailboxForward(mailbox) }}><SendHorizontal className="h-3.5 w-3.5" /></Button> <Button type="button" variant="outline" size="sm" className={cn("h-[30px] w-[72px] gap-1 px-0", forwardingActive && "border-foreground/20 bg-foreground text-background hover:bg-foreground/90 hover:text-background")} onClick={() => { onSelect(mailbox.id); openMailboxForward(mailbox) }}><SendHorizontal className="h-3.5 w-3.5" />{forwardingActive ? "转发中" : "转发"}</Button>
<Button type="button" variant="outline" size="sm" className="h-[30px] w-[82px] px-0" onClick={() => confirmLocalAction("暂停收信", mailbox)}></Button> <Button type="button" variant="outline" size="sm" className="h-[30px] w-[82px] px-0" onClick={() => confirmLocalAction("暂停收信", mailbox)}></Button>
<Button type="button" variant="outline" size="sm" className="h-[30px] w-[54px] px-0 text-destructive hover:text-destructive" onClick={() => confirmLocalAction("释放邮箱", mailbox, true)}></Button> <Button type="button" variant="outline" size="sm" className="h-[30px] w-[54px] px-0 text-destructive hover:text-destructive" onClick={() => confirmLocalAction("释放邮箱", mailbox, true)}></Button>
</div> </div>
@@ -1615,16 +1618,10 @@ function MailboxManagement({
</div> </div>
<div className="rounded-xl bg-muted/20 px-5 py-5"> <div className="rounded-xl bg-muted/20 px-5 py-5">
<div className="mb-3 text-sm font-medium"></div> <div className="mb-3 text-sm font-medium"></div>
<div className="mb-4 text-sm text-muted-foreground"></div> <div className="mb-4 text-sm text-muted-foreground"></div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_72px]"> <div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_72px] md:items-start">
<Select value={accountForwardTarget} onValueChange={setAccountForwardTarget} disabled={forwardingBusy}> <ForwardingTargetPicker emails={verifiedEmails} selected={accountForwardTargets} onChange={setAccountForwardTargets} disabled={forwardingBusy} />
<SelectTrigger className="h-[37px] shadow-none"><SelectValue /></SelectTrigger> <Button type="button" className="h-[37px]" disabled={forwardingBusy} onClick={() => saveAccountForwarding.mutate(accountForwardTargets)}>{saveAccountForwarding.isPending ? "保存中" : "保存"}</Button>
<SelectContent>
<SelectItem value="none"></SelectItem>
{verifiedEmails.map((email) => <SelectItem key={email} value={email}>{email}</SelectItem>)}
</SelectContent>
</Select>
<Button type="button" className="h-[37px]" disabled={forwardingBusy} onClick={() => saveAccountForwarding.mutate(accountForwardTarget === "none" ? "" : accountForwardTarget)}>{saveAccountForwarding.isPending ? "保存中" : "保存"}</Button>
</div> </div>
</div> </div>
{verifiedEmailItems.length > 0 && ( {verifiedEmailItems.length > 0 && (
@@ -1641,7 +1638,7 @@ function MailboxManagement({
</div> </div>
)} )}
{verifiedEmails.length === 0 && <p className="mt-4 text-sm text-muted-foreground"></p>} {verifiedEmails.length === 0 && <p className="mt-4 text-sm text-muted-foreground"></p>}
<p className="mt-3 text-sm text-muted-foreground"></p> <p className="mt-3 text-sm text-muted-foreground"></p>
</section> </section>
<section className="rounded-lg border bg-card"> <section className="rounded-lg border bg-card">
@@ -1678,13 +1675,7 @@ function MailboxManagement({
<div className="space-y-4"> <div className="space-y-4">
<div className="truncate text-sm text-muted-foreground">{forwardingMailbox?.address}</div> <div className="truncate text-sm text-muted-foreground">{forwardingMailbox?.address}</div>
<Field label="转发到"> <Field label="转发到">
<Select value={forwardDraft} onValueChange={setForwardDraft} disabled={forwardingBusy}> <ForwardingTargetPicker emails={verifiedEmails} selected={forwardDraft} onChange={setForwardDraft} disabled={forwardingBusy} />
<SelectTrigger className="h-[37px] shadow-none"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{verifiedEmails.map((email) => <SelectItem key={email} value={email}>{email}</SelectItem>)}
</SelectContent>
</Select>
</Field> </Field>
{verifiedEmails.length === 0 && <p className="text-sm text-muted-foreground"></p>} {verifiedEmails.length === 0 && <p className="text-sm text-muted-foreground"></p>}
</div> </div>
@@ -1965,6 +1956,54 @@ function formatDateTime(value: string) {
return date.toLocaleString() 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 <div className="rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground"></div>
}
return (
<div className="grid gap-2 sm:grid-cols-2">
{emails.map((email) => {
const checked = selectedSet.has(email)
return (
<label
key={email}
className={cn(
"flex min-h-[37px] cursor-pointer items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm transition-colors",
checked && "border-foreground/20 bg-muted font-semibold text-foreground",
disabled && "cursor-not-allowed opacity-60",
)}
>
<Checkbox checked={checked} disabled={disabled} onCheckedChange={(value) => toggle(email, value === true)} />
<span className="min-w-0 truncate">{email}</span>
</label>
)
})}
{selected.length === 0 && <div className="self-center text-sm text-muted-foreground sm:col-span-2"></div>}
</div>
)
}
function forwardingEmailTone(item: ForwardingVerifiedEmail) { function forwardingEmailTone(item: ForwardingVerifiedEmail) {
if (item.verified) { if (item.verified) {
return { return {
@@ -1992,9 +2031,9 @@ function forwardingEmailTone(item: ForwardingVerifiedEmail) {
} }
return { return {
shortLabel: "待验证", shortLabel: "待验证",
dotClass: "bg-blue-500", dotClass: "bg-muted-foreground",
chipClass: "bg-blue-100 text-blue-800", chipClass: "bg-muted text-foreground",
detailClass: "text-blue-700", detailClass: "text-foreground",
} }
} }