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 (
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 {
+7 -5
View File
@@ -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: <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 {
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)
}
+35 -23
View File
@@ -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) {
+157 -45
View File
@@ -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)
+2 -2
View File
@@ -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"
+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 }) }),
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" }),
updateAccountForwarding: (targetEmail: string) => request<ForwardingSettings>("/api/me/forwarding/account", { method: "POST", body: JSON.stringify({ targetEmail }) }),
updateMailboxForwarding: (mailboxId: string, targetEmail: string) => request<ForwardingSettings>(`/api/me/mailboxes/${mailboxId}/forwarding`, { 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, 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)}` : ""}`),
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) }),
+83 -44
View File
@@ -1374,8 +1374,8 @@ function MailboxManagement({
const [editingNote, setEditingNote] = React.useState<Mailbox | null>(null)
const [noteDraft, setNoteDraft] = React.useState("")
const [forwardingMailbox, setForwardingMailbox] = React.useState<Mailbox | null>(null)
const [forwardDraft, setForwardDraft] = React.useState("none")
const [accountForwardTarget, setAccountForwardTarget] = React.useState("none")
const [forwardDraft, setForwardDraft] = React.useState<string[]>([])
const [accountForwardTargets, setAccountForwardTargets] = React.useState<string[]>([])
const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false)
const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("")
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 verifiedEmailItems = forwarding.data?.verifiedEmails || []
const verifiedEmails = React.useMemo(() => verifiedEmailItems.filter((item) => item.verified).map((item) => item.email), [verifiedEmailItems])
const mailboxForwards = React.useMemo<Record<string, string>>(() => {
const next: Record<string, string> = {}
const mailboxForwards = React.useMemo<Record<string, string[]>>(() => {
const next: Record<string, string[]> = {}
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<HTMLFormElement>) {
@@ -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({
<Button className="h-[42px] px-0" disabled={!canApply || applyPending || !selectedDomain || !localPart.trim()}>{applyPending ? "创建中" : "创建"}</Button>
</form>
<p className="mt-4 text-sm text-muted-foreground">
{canApply ? "提示:邮箱或单词邮箱需要支付星辰费用。每周可免费创建 35 个邮箱。" : "提示:当前账号暂不可创建新邮箱。"}
{canApply ? "提示:邮箱数量受账号配额限制,管理员可在后台为单个账号调整可创建数量。" : "提示:当前账号暂不可创建新邮箱。"}
</p>
</section>
@@ -1574,8 +1575,10 @@ function MailboxManagement({
<div className="divide-y">
{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 (
<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">
@@ -1584,20 +1587,20 @@ function MailboxManagement({
</div>
<div className="min-w-0">
<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>}
</div>
<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>
{note && <span className="max-w-full truncate">{note}</span>}
{forwardTarget && forwardTarget !== "none" && <span className="max-w-full truncate">{forwardTarget}</span>}
{accountForwardTargetActive && <span className="max-w-full truncate">使 {accountForwardTarget}</span>}
{forwardTargets.length > 0 && <span className="max-w-full truncate font-semibold text-foreground">{forwardTargets.join("、")}</span>}
{accountForwardTargetActive && <span className="max-w-full truncate font-semibold text-foreground">使 {accountForwardTargets.join("、")}</span>}
</div>
</div>
</div>
<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={() => { 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-[54px] px-0 text-destructive hover:text-destructive" onClick={() => confirmLocalAction("释放邮箱", mailbox, true)}></Button>
</div>
@@ -1615,16 +1618,10 @@ function MailboxManagement({
</div>
<div className="rounded-xl bg-muted/20 px-5 py-5">
<div className="mb-3 text-sm font-medium"></div>
<div className="mb-4 text-sm text-muted-foreground"></div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_72px]">
<Select value={accountForwardTarget} onValueChange={setAccountForwardTarget} 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>
<Button type="button" className="h-[37px]" disabled={forwardingBusy} onClick={() => saveAccountForwarding.mutate(accountForwardTarget === "none" ? "" : accountForwardTarget)}>{saveAccountForwarding.isPending ? "保存中" : "保存"}</Button>
<div className="mb-4 text-sm text-muted-foreground"></div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_72px] md:items-start">
<ForwardingTargetPicker emails={verifiedEmails} selected={accountForwardTargets} onChange={setAccountForwardTargets} disabled={forwardingBusy} />
<Button type="button" className="h-[37px]" disabled={forwardingBusy} onClick={() => saveAccountForwarding.mutate(accountForwardTargets)}>{saveAccountForwarding.isPending ? "保存中" : "保存"}</Button>
</div>
</div>
{verifiedEmailItems.length > 0 && (
@@ -1641,7 +1638,7 @@ function MailboxManagement({
</div>
)}
{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 className="rounded-lg border bg-card">
@@ -1678,13 +1675,7 @@ function MailboxManagement({
<div className="space-y-4">
<div className="truncate text-sm text-muted-foreground">{forwardingMailbox?.address}</div>
<Field label="转发到">
<Select value={forwardDraft} onValueChange={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>
<ForwardingTargetPicker emails={verifiedEmails} selected={forwardDraft} onChange={setForwardDraft} disabled={forwardingBusy} />
</Field>
{verifiedEmails.length === 0 && <p className="text-sm text-muted-foreground"></p>}
</div>
@@ -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 <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) {
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",
}
}