feat: support multiple forwarding targets
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
verifiedTargets := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
verified, err := a.forwardingEmailVerified(ctx, userID, target)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
return nil, "", "", err
|
||||
}
|
||||
if !verified {
|
||||
return "", userID, mailboxAddress, nil
|
||||
if verified {
|
||||
verifiedTargets = append(verifiedTargets, target)
|
||||
}
|
||||
return target, userID, mailboxAddress, nil
|
||||
}
|
||||
return dedupeEmails(verifiedTargets), userID, mailboxAddress, nil
|
||||
}
|
||||
|
||||
func (a *App) forwardingRawMessage(ctx context.Context, messageID string) ([]byte, error) {
|
||||
|
||||
@@ -30,11 +30,13 @@ type ForwardingVerifiedEmail struct {
|
||||
type MailboxForwardingRule struct {
|
||||
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
|
||||
}
|
||||
@@ -214,21 +211,23 @@ func (a *App) handleUpdateAccountForwarding(w http.ResponseWriter, r *http.Reque
|
||||
user := currentUser(r)
|
||||
var req struct {
|
||||
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
|
||||
@@ -257,27 +256,29 @@ func (a *App) handleUpdateMailboxForwarding(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
var req struct {
|
||||
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,11 +344,16 @@ 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
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user