diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 260587e..adf3c45 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -227,6 +227,26 @@ func (a *App) migrate(ctx context.Context) error { updated_at TEXT NOT NULL, UNIQUE(domain_id, local_part) )`, + `CREATE TABLE IF NOT EXISTS forwarding_verified_emails ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + email TEXT NOT NULL, + verified INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, email) + )`, + `CREATE INDEX IF NOT EXISTS idx_forwarding_verified_emails_user ON forwarding_verified_emails(user_id, email)`, + `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 '', + 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 '', + updated_at TEXT NOT NULL + )`, `CREATE TABLE IF NOT EXISTS aliases ( id TEXT PRIMARY KEY, domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE, diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 98d4c07..f2e5c01 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -1642,6 +1642,106 @@ func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) { } } +func TestInboundForwardingSettingsAndDelivery(t *testing.T) { + a := newTestApp(t) + stopTestWorkers(a) + host, port, received := startCapturingSMTP(t, 2) + a.cfg.SMTPHost = host + a.cfg.SMTPPort = port + ts := httptest.NewServer(a.Router()) + defer ts.Close() + admin := &testClient{t: t, server: ts} + + var login map[string]any + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { + t.Fatalf("login code=%d body=%v", code, login) + } + _, mb := defaultAdminUserAndMailbox(t, a) + var settings ForwardingSettings + if code := admin.do("POST", "/api/me/forwarding/verified-emails", map[string]string{"email": "account-forward@example.test"}, &settings); code != http.StatusCreated { + t.Fatalf("add account forwarding target code=%d settings=%+v", code, settings) + } + 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" { + t.Fatalf("save account forwarding code=%d settings=%+v", code, settings) + } + + ctx := context.Background() + inboxID, err := a.ensureFolder(ctx, mb.ID, "Inbox") + if err != nil { + t.Fatal(err) + } + insertInbound := func(messageID, subject string, raw []byte) string { + t.Helper() + now := a.now().UTC() + id, err := a.insertMessage(ctx, storedMessage{ + MailboxID: mb.ID, + FolderID: inboxID, + MessageUID: newID("uid"), + MessageID: messageID, + Subject: subject, + From: "sender@example.test", + To: []string{mb.Address}, + SentAt: now, + ReceivedAt: now, + Snippet: "body", + BodyText: "body", + IsRead: false, + RecipientAddr: mb.Address, + }, nil) + if err != nil { + t.Fatal(err) + } + a.processInboundForwarding(ctx, id, mb.ID, raw) + return id + } + + raw := []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: account forward\r\nMessage-ID: \r\n\r\nbody") + firstID := insertInbound("", "account forward", raw) + var recipientsJSON string + 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") { + t.Fatalf("account forwarding recipients=%s", recipientsJSON) + } + if err := a.processDueSendQueue(ctx); err != nil { + t.Fatal(err) + } + select { + case body := <-received: + if !strings.Contains(body, forwardingHeaderName+": mail.example.test") || !strings.Contains(body, "X-LanQin-Forwarded-For: admin@lanqin.local") || strings.Contains(body, "\r\n\r\n\r\nbody") { + t.Fatalf("unexpected forwarded body: %q", body) + } + case <-time.After(2 * time.Second): + t.Fatal("account forwarding mail was not relayed") + } + + if code := admin.do("POST", "/api/me/forwarding/verified-emails", map[string]string{"email": "mailbox-forward@example.test"}, &settings); code != http.StatusCreated { + t.Fatalf("add mailbox forwarding target code=%d settings=%+v", code, settings) + } + if code := admin.do("POST", "/api/me/mailboxes/"+mb.ID+"/forwarding", map[string]string{"targetEmail": "mailbox-forward@example.test"}, &settings); code != http.StatusOK { + t.Fatalf("save mailbox forwarding code=%d settings=%+v", code, settings) + } + raw = []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: mailbox forward\r\nMessage-ID: \r\n\r\nbody") + secondID := insertInbound("", "mailbox forward", raw) + 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") { + t.Fatalf("mailbox forwarding should override account target, recipients=%s", recipientsJSON) + } + + loopRaw := []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: loop\r\n" + forwardingHeaderName + ": mail.example.test\r\nMessage-ID: \r\n\r\nbody") + insertInbound("", "loop", loopRaw) + var queueCount int + if err := a.db.QueryRow(`SELECT COUNT(1) FROM send_queue WHERE source=?`, sendSourceForwarding).Scan(&queueCount); err != nil { + t.Fatal(err) + } + if queueCount != 2 { + t.Fatalf("forwarding queue count=%d, want 2", queueCount) + } +} + func TestMailSendRejectsUnauthorizedFrom(t *testing.T) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) diff --git a/apps/api/internal/app/forwarding_delivery.go b/apps/api/internal/app/forwarding_delivery.go new file mode 100644 index 0000000..0b87062 --- /dev/null +++ b/apps/api/internal/app/forwarding_delivery.go @@ -0,0 +1,153 @@ +package app + +import ( + "bytes" + "context" + "fmt" + "os" + "strings" +) + +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) + if err != nil { + a.log.Warn("failed to load forwarding target", "message", messageID, "mailbox", mailboxID, "error", err) + return + } + if target == "" || userID == "" || mailboxAddress == "" { + return + } + if normalizeEmail(target) == normalizeEmail(mailboxAddress) { + return + } + if len(raw) == 0 { + raw, err = a.forwardingRawMessage(ctx, messageID) + if err != nil { + a.log.Warn("failed to load raw message for forwarding", "message", messageID, "error", err) + return + } + } + if hasForwardingHeader(raw) { + a.log.Warn("skip forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID) + return + } + forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname) + var rfcMessageID string + _ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID) + if strings.TrimSpace(rfcMessageID) == "" { + rfcMessageID = messageID + } + queueID, err := a.enqueueSend(ctx, sendQueueInput{ + UserID: userID, + MailboxID: mailboxID, + SentMessageID: messageID, + MessageID: rfcMessageID, + Source: sendSourceForwarding, + MailFrom: mailboxAddress, + HeaderFrom: mailboxAddress, + Recipients: []string{target}, + 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) + return + } + if queueID == "" { + a.log.Warn("forwarding target configured but SMTP sending is not configured", "message", messageID, "mailbox", mailboxID, "target", target) + } +} + +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,'') + 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) + if err != nil { + return "", "", "", err + } + target := normalizeEmail(mailboxTarget) + if target == "" { + target = normalizeEmail(accountTarget) + } + if target == "" { + return "", userID, mailboxAddress, nil + } + verified, err := a.forwardingEmailVerified(ctx, userID, target) + if err != nil { + return "", "", "", err + } + if !verified { + return "", userID, mailboxAddress, nil + } + return target, userID, mailboxAddress, nil +} + +func (a *App) forwardingRawMessage(ctx context.Context, messageID string) ([]byte, error) { + msg, err := a.storedMessageByID(ctx, messageID) + if err != nil { + return nil, err + } + if strings.TrimSpace(msg.RawPath) != "" { + if ok, err := a.pathIsUnderMaildirRoot(msg.RawPath); err == nil && ok { + if raw, err := os.ReadFile(msg.RawPath); err == nil { + return raw, nil + } + } + } + attachments, err := a.attachmentInputsForMessage(ctx, messageID) + if err != nil { + return nil, err + } + return BuildMIME(MIMEMessage{ + From: msg.From, + FromName: msg.FromName, + To: msg.To, + CC: msg.CC, + BCC: msg.BCC, + Subject: msg.Subject, + Text: msg.BodyText, + HTML: msg.BodyHTML, + MessageID: msg.MessageID, + Date: messageDate(msg), + Attachments: attachments, + }) +} + +func hasForwardingHeader(raw []byte) bool { + header := raw + if idx := bytes.Index(raw, []byte("\r\n\r\n")); idx >= 0 { + header = raw[:idx] + } else if idx := bytes.Index(raw, []byte("\n\n")); idx >= 0 { + header = raw[:idx] + } + return strings.Contains(strings.ToLower(string(header)), strings.ToLower(forwardingHeaderName)+":") +} + +func addForwardingHeaders(raw []byte, mailboxAddress, hostname string) []byte { + hostname = strings.TrimSpace(hostname) + if hostname == "" { + hostname = "lanqin.local" + } + header := fmt.Sprintf("%s: %s\r\nX-LanQin-Forwarded-For: %s\r\n", forwardingHeaderName, hostname, normalizeEmail(mailboxAddress)) + if idx := bytes.Index(raw, []byte("\r\n\r\n")); idx >= 0 { + out := make([]byte, 0, len(raw)+len(header)) + out = append(out, raw[:idx]...) + out = append(out, []byte("\r\n"+header)...) + out = append(out, raw[idx+2:]...) + return out + } + if idx := bytes.Index(raw, []byte("\n\n")); idx >= 0 { + lfHeader := strings.ReplaceAll(header, "\r\n", "\n") + out := make([]byte, 0, len(raw)+len(lfHeader)) + out = append(out, raw[:idx]...) + out = append(out, []byte("\n"+lfHeader)...) + out = append(out, raw[idx+1:]...) + return out + } + return append([]byte(header+"\r\n"), raw...) +} diff --git a/apps/api/internal/app/forwarding_handlers.go b/apps/api/internal/app/forwarding_handlers.go new file mode 100644 index 0000000..bc58005 --- /dev/null +++ b/apps/api/internal/app/forwarding_handlers.go @@ -0,0 +1,296 @@ +package app + +import ( + "context" + "database/sql" + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +type ForwardingVerifiedEmail struct { + ID string `json:"id"` + Email string `json:"email"` + Verified bool `json:"verified"` + CreatedAt time.Time `json:"createdAt"` +} + +type MailboxForwardingRule struct { + MailboxID string `json:"mailboxId"` + TargetEmail string `json:"targetEmail"` +} + +type ForwardingSettings struct { + VerifiedEmails []ForwardingVerifiedEmail `json:"verifiedEmails"` + AccountTargetEmail string `json:"accountTargetEmail"` + MailboxRules []MailboxForwardingRule `json:"mailboxRules"` +} + +func (a *App) handleForwardingSettings(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + settings, err := a.forwardingSettings(r.Context(), user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load forwarding settings") + return + } + respondJSON(w, http.StatusOK, settings) +} + +func (a *App) handleAddForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + var req struct { + Email string `json:"email"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + email := normalizeEmail(req.Email) + if email == "" || !strings.Contains(email, "@") { + badRequest(w, errors.New("邮箱地址无效")) + return + } + if owns, err := a.userOwnsMailboxAddress(r.Context(), user.ID, email); err != nil { + respondError(w, http.StatusInternalServerError, "failed to check mailbox") + return + } else if owns { + badRequest(w, errors.New("不能把当前账号邮箱作为转发验证邮箱")) + return + } + now := a.now().UTC().Format(time.RFC3339Nano) + id := newID("fwd") + _, err := a.db.ExecContext(r.Context(), `INSERT INTO forwarding_verified_emails(id,user_id,email,verified,created_at,updated_at) + VALUES(?,?,?,?,?,?) + ON CONFLICT(user_id,email) DO UPDATE SET verified=1,updated_at=excluded.updated_at`, + id, user.ID, email, 1, now, now) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to save verified email") + return + } + settings, err := a.forwardingSettings(r.Context(), user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load forwarding settings") + return + } + respondJSON(w, http.StatusCreated, settings) +} + +func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + respondError(w, http.StatusNotFound, "verified email not found") + return + } + var email string + if err := a.db.QueryRowContext(r.Context(), `SELECT email FROM forwarding_verified_emails WHERE id=? AND user_id=?`, id, user.ID).Scan(&email); err != nil { + respondError(w, http.StatusNotFound, "verified email not found") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to start transaction") + return + } + defer tx.Rollback() + now := a.now().UTC().Format(time.RFC3339Nano) + if _, err := tx.ExecContext(r.Context(), `DELETE FROM forwarding_verified_emails WHERE id=? AND user_id=?`, id, user.ID); err != nil { + 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 { + respondError(w, http.StatusInternalServerError, "failed to update mailbox forwarding") + return + } + if err := tx.Commit(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to save forwarding settings") + return + } + settings, err := a.forwardingSettings(r.Context(), user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load forwarding settings") + return + } + respondJSON(w, http.StatusOK, settings) +} + +func (a *App) handleUpdateAccountForwarding(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + var req struct { + TargetEmail string `json:"targetEmail"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + target, err := a.cleanForwardingTarget(r.Context(), user.ID, req.TargetEmail) + if err != nil { + badRequest(w, err) + return + } + 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) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to save account forwarding") + return + } + settings, err := a.forwardingSettings(r.Context(), user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load forwarding settings") + return + } + respondJSON(w, http.StatusOK, settings) +} + +func (a *App) handleUpdateMailboxForwarding(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + mailboxID := strings.TrimSpace(chi.URLParam(r, "id")) + if mailboxID == "" { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + if ok, err := a.userOwnsMailboxID(r.Context(), user.ID, mailboxID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to check mailbox") + return + } else if !ok { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + var req struct { + TargetEmail string `json:"targetEmail"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + target, err := a.cleanForwardingTarget(r.Context(), user.ID, req.TargetEmail) + if err != nil { + badRequest(w, err) + return + } + if target == "" { + 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 { + respondError(w, http.StatusInternalServerError, "failed to save mailbox forwarding") + return + } + } + settings, err := a.forwardingSettings(r.Context(), user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load forwarding settings") + return + } + respondJSON(w, http.StatusOK, settings) +} + +func (a *App) forwardingSettings(ctx context.Context, userID string) (ForwardingSettings, error) { + settings := ForwardingSettings{ + VerifiedEmails: []ForwardingVerifiedEmail{}, + MailboxRules: []MailboxForwardingRule{}, + } + rows, err := a.db.QueryContext(ctx, `SELECT id,email,verified,created_at FROM forwarding_verified_emails WHERE user_id=? ORDER BY created_at DESC,email`, userID) + if err != nil { + return settings, err + } + defer rows.Close() + for rows.Next() { + var item ForwardingVerifiedEmail + var verified int + var created string + if err := rows.Scan(&item.ID, &item.Email, &verified, &created); err != nil { + return settings, err + } + item.Verified = intBool(verified) + item.CreatedAt = parseTime(created) + settings.VerifiedEmails = append(settings.VerifiedEmails, item) + } + 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) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return settings, err + } + rows, err = a.db.QueryContext(ctx, `SELECT mfs.mailbox_id,mfs.target_email + FROM mailbox_forwarding_settings mfs + JOIN mailboxes mb ON mb.id=mfs.mailbox_id + WHERE mb.user_id=? AND mfs.target_email<>'' + ORDER BY mb.address`, userID) + if err != nil { + return settings, err + } + defer rows.Close() + for rows.Next() { + var item MailboxForwardingRule + if err := rows.Scan(&item.MailboxID, &item.TargetEmail); err != nil { + return settings, err + } + settings.MailboxRules = append(settings.MailboxRules, item) + } + return settings, rows.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) + if err != nil { + return false, err + } + return count > 0, 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) + if err != nil { + return false, err + } + return count > 0, nil +} + +func (a *App) userOwnsMailboxAddress(ctx context.Context, userID, address string) (bool, error) { + var count int + err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mailboxes WHERE user_id=? AND address=? AND status='active'`, userID, normalizeEmail(address)).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index bc8dd9b..ba9d4e1 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -1007,6 +1007,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil { _ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments) a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject) + a.processInboundForwarding(ctx, inboxMsgID, rcptMailbox.ID, mimeBytes) } } diff --git a/apps/api/internal/app/maildir_sync.go b/apps/api/internal/app/maildir_sync.go index c0c4540..0ef292b 100644 --- a/apps/api/internal/app/maildir_sync.go +++ b/apps/api/internal/app/maildir_sync.go @@ -371,6 +371,7 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai id, err := a.insertMessage(ctx, msg, attachments) if err == nil && strings.EqualFold(folder.Name, "Inbox") { a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject) + a.processInboundForwarding(ctx, id, mb.ID, raw) } return err == nil, err } diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index 680bd39..bdbeee9 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -44,6 +44,11 @@ func (a *App) Router() http.Handler { r.With(a.requireAuth).Delete("/me/api-tokens/{id}", a.handleDeleteAPIToken) r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions) r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Post("/me/mailboxes/apply", a.handleApplyMailbox) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Get("/me/forwarding", a.handleForwardingSettings) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/forwarding/verified-emails", a.handleAddForwardingVerifiedEmail) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Delete("/me/forwarding/verified-emails/{id}", a.handleDeleteForwardingVerifiedEmail) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/forwarding/account", a.handleUpdateAccountForwarding) + r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/mailboxes/{id}/forwarding", a.handleUpdateMailboxForwarding) r.With(a.requireAuth).Post("/me/2fa/setup", a.handleTwoFactorSetup) r.With(a.requireAuth).Post("/me/2fa/enable", a.handleTwoFactorEnable) r.With(a.requireAuth).Post("/me/2fa/disable", a.handleTwoFactorDisable) diff --git a/apps/api/internal/app/send_queue.go b/apps/api/internal/app/send_queue.go index 722d109..c05070d 100644 --- a/apps/api/internal/app/send_queue.go +++ b/apps/api/internal/app/send_queue.go @@ -28,6 +28,7 @@ const ( sendSourceWebmail = "webmail" sendSourceSubmission = "submission" sendSourceOpenAPI = "open_api" + sendSourceForwarding = "forwarding" sendQueueStaleAfter = 15 * time.Minute sendQueueConcurrency = 4 diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index ea79935..8d05d3a 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -125,6 +125,9 @@ export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; enabled: boolean; createdAt: string; appliedExistingCount?: number } export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string } export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; attachmentBytes: number; storageBytes: number; quotaBytes: number; quotaUsedPct: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] } +export type ForwardingVerifiedEmail = { id: string; email: string; verified: boolean; createdAt: string } +export type MailboxForwardingRule = { mailboxId: string; targetEmail: string } +export type ForwardingSettings = { verifiedEmails: ForwardingVerifiedEmail[]; accountTargetEmail: string; mailboxRules: MailboxForwardingRule[] } export type ExternalImapStorageMode = "local" | "remote" export type ExternalImapTlsMode = "tls" | "starttls" | "plain" export type ExternalImapAuthMode = "password" | "oauth2" diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 63014bc..9894e4b 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types" +import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types" export * from "./api-types" const REQUEST_TIMEOUT_MS = 15_000 @@ -103,6 +103,11 @@ export const api = { cleanupMail: (payload: { mailboxId: string; target: "empty-trash" | "empty-spam" | "archive-read-inbox" }) => request<{ ok: boolean; affected: number }>("/api/me/cleanup", { method: "POST", body: JSON.stringify(payload) }), mailboxApplyOptions: () => request("/api/me/mailbox-apply-options"), applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }), + forwardingSettings: () => request("/api/me/forwarding"), + addForwardingVerifiedEmail: (email: string) => request("/api/me/forwarding/verified-emails", { method: "POST", body: JSON.stringify({ email }) }), + deleteForwardingVerifiedEmail: (id: string) => request(`/api/me/forwarding/verified-emails/${id}`, { method: "DELETE" }), + updateAccountForwarding: (targetEmail: string) => request("/api/me/forwarding/account", { method: "POST", body: JSON.stringify({ targetEmail }) }), + updateMailboxForwarding: (mailboxId: string, targetEmail: string) => request(`/api/me/mailboxes/${mailboxId}/forwarding`, { method: "POST", body: JSON.stringify({ targetEmail }) }), externalImapAccounts: (mailboxId?: string) => request>(`/api/me/external-imap-accounts${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), createExternalImapAccount: (payload: ExternalImapAccountPayload) => request("/api/me/external-imap-accounts", { method: "POST", body: JSON.stringify(payload) }), updateExternalImapAccount: (id: string, payload: ExternalImapAccountPayload) => request(`/api/me/external-imap-accounts/${id}`, { method: "POST", body: JSON.stringify(payload) }), @@ -239,4 +244,3 @@ export const api = { move: (id: string, folder: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}/move`, { method: "POST", body: JSON.stringify({ folder }) }), delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }), } - diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index 475d8c8..9958969 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useNavigate, useSearchParams } from "react-router-dom" import { ArrowLeft, BarChart3, Ban, Clock3, Code2, Contact, Copy, Image, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, MessageSquare, Moon, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Repeat2, Search, SendHorizontal, Settings, Share2, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react" import { QRCodeSVG } from "qrcode.react" -import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" +import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, ForwardingSettings, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" import { cn, formatBytes } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { DisplayMode, useDisplayMode } from "@/lib/display-mode" @@ -1365,6 +1365,8 @@ function MailboxManagement({ onSyncExternal: (id: string) => void onSyncExternalFolder: (id: string, folder: string) => void }) { + const qc = useQueryClient() + const { toast } = useToast() const canApply = !!applyOptions?.enabled && (applyOptions.domains || []).length > 0 const [domainId, setDomainId] = React.useState(() => applyOptions?.domains?.[0]?.id || "") const [localPart, setLocalPart] = React.useState("") @@ -1374,29 +1376,82 @@ function MailboxManagement({ const [noteDraft, setNoteDraft] = React.useState("") const [forwardingMailbox, setForwardingMailbox] = React.useState(null) const [forwardDraft, setForwardDraft] = React.useState("none") - const [mailboxForwards, setMailboxForwards] = React.useState>(() => readLocalRecord("lanqin:seek-mailbox-forward-targets")) - const [accountForwardTarget, setAccountForwardTarget] = React.useState(() => readLocalString("lanqin:account-forward-target") || "none") + const [accountForwardTarget, setAccountForwardTarget] = React.useState("none") const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false) const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("") - const [verifiedEmails, setVerifiedEmails] = React.useState(() => readLocalStringList("lanqin:seek-verified-forward-emails")) const [logs, setLogs] = React.useState(() => readLocalLogs("lanqin:seek-mailbox-action-logs")) const [pendingConfirm, setPendingConfirm] = React.useState(null) + const forwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: mailboxes.length > 0 }) + const verifiedEmailItems = forwarding.data?.verifiedEmails || [] + const verifiedEmails = React.useMemo(() => verifiedEmailItems.map((item) => item.email), [verifiedEmailItems]) + const mailboxForwards = React.useMemo>(() => { + const next: Record = {} + for (const rule of forwarding.data?.mailboxRules || []) { + if (rule.targetEmail) next[rule.mailboxId] = rule.targetEmail + } + return next + }, [forwarding.data?.mailboxRules]) const normalizedMailboxSearch = mailboxSearch.trim().toLowerCase() const domainOptions = applyOptions?.domains || [] const selectedDomain = domainOptions.find((domain) => domain.id === domainId) || domainOptions[0] const filteredMailboxes = normalizedMailboxSearch ? mailboxes.filter((mailbox) => `${mailbox.address} ${notes[mailbox.id] || ""}`.toLowerCase().includes(normalizedMailboxSearch)) : mailboxes + const setForwardingCache = React.useCallback((settings: ForwardingSettings) => { + qc.setQueryData(["forwarding-settings"], settings) + }, [qc]) + const addVerifiedEmail = useMutation({ + mutationFn: api.addForwardingVerifiedEmail, + onSuccess: (settings, email) => { + setForwardingCache(settings) + addLog("添加验证邮箱", email) + setVerifiedEmailDraft("") + toast({ title: "验证邮箱已添加" }) + }, + onError: (error) => toast({ title: "添加失败", description: error.message }), + }) + const deleteVerifiedEmail = useMutation({ + mutationFn: ({ id }: { id: string; email: string }) => api.deleteForwardingVerifiedEmail(id), + onSuccess: (settings, item) => { + setForwardingCache(settings) + addLog("移除验证邮箱", item.email) + toast({ title: "验证邮箱已移除" }) + }, + onError: (error) => toast({ title: "移除失败", description: error.message }), + }) + const saveAccountForwarding = useMutation({ + mutationFn: api.updateAccountForwarding, + onSuccess: (settings, target) => { + setForwardingCache(settings) + addLog("保存账号转发", target || "不转发") + toast({ title: "账号级转发已保存" }) + }, + onError: (error) => toast({ title: "保存失败", description: error.message }), + }) + const saveMailboxForwarding = useMutation({ + mutationFn: ({ mailboxId, targetEmail }: { mailboxId: string; targetEmail: string }) => api.updateMailboxForwarding(mailboxId, targetEmail), + onSuccess: (settings, payload) => { + setForwardingCache(settings) + const mailbox = mailboxes.find((item) => item.id === payload.mailboxId) + addLog("保存邮箱转发", mailbox?.address || payload.mailboxId) + setForwardingMailbox(null) + setForwardDraft("none") + toast({ title: "邮箱转发已保存" }) + }, + onError: (error) => toast({ title: "保存失败", description: error.message }), + }) + const forwardingBusy = forwarding.isLoading || addVerifiedEmail.isPending || deleteVerifiedEmail.isPending || saveAccountForwarding.isPending || saveMailboxForwarding.isPending React.useEffect(() => { if (!domainOptions.length) return setDomainId((current) => domainOptions.some((domain) => domain.id === current) ? current : domainOptions[0].id) }, [domainOptions]) - React.useEffect(() => { writeLocalString("lanqin:account-forward-target", accountForwardTarget) }, [accountForwardTarget]) + React.useEffect(() => { + setAccountForwardTarget(forwarding.data?.accountTargetEmail || "none") + }, [forwarding.data?.accountTargetEmail]) + React.useEffect(() => { writeLocalRecord("lanqin:seek-mailbox-notes", notes) }, [notes]) - React.useEffect(() => { writeLocalRecord("lanqin:seek-mailbox-forward-targets", mailboxForwards) }, [mailboxForwards]) - React.useEffect(() => { writeLocalStringList("lanqin:seek-verified-forward-emails", verifiedEmails) }, [verifiedEmails]) React.useEffect(() => { writeLocalLogs("lanqin:seek-mailbox-action-logs", logs) }, [logs]) function addLog(action: string, target: string) { @@ -1431,33 +1486,20 @@ function MailboxManagement({ function saveMailboxForward() { if (!forwardingMailbox) return - setMailboxForwards((items) => ({ ...items, [forwardingMailbox.id]: forwardDraft })) - addLog("保存转发", forwardingMailbox.address) - setForwardingMailbox(null) - setForwardDraft("none") + saveMailboxForwarding.mutate({ mailboxId: forwardingMailbox.id, targetEmail: forwardDraft === "none" ? "" : forwardDraft }) } function submitVerifiedEmail(event: React.FormEvent) { event.preventDefault() const value = verifiedEmailDraft.trim() if (!value || verifiedEmails.includes(value)) return - setVerifiedEmails((items) => [value, ...items]) - addLog("添加验证邮箱", value) - setVerifiedEmailDraft("") + addVerifiedEmail.mutate(value) } - function removeVerifiedEmail(value: string) { - setVerifiedEmails((items) => items.filter((item) => item !== value)) - setMailboxForwards((items) => { - const next = { ...items } - for (const [id, target] of Object.entries(next)) { - if (target === value) next[id] = "none" - } - return next - }) - if (accountForwardTarget === value) setAccountForwardTarget("none") - if (forwardDraft === value) setForwardDraft("none") - addLog("移除验证邮箱", value) + function removeVerifiedEmail(id: string, email: string) { + if (accountForwardTarget === email) setAccountForwardTarget("none") + if (forwardDraft === email) setForwardDraft("none") + deleteVerifiedEmail.mutate({ id, email }) } function confirmLocalAction(action: string, mailbox: Mailbox, destructive = false) { @@ -1513,6 +1555,7 @@ function MailboxManagement({ {filteredMailboxes.map((mailbox) => { const note = notes[mailbox.id]?.trim() const forwardTarget = mailboxForwards[mailbox.id] + const accountForwardTargetActive = !forwardTarget && accountForwardTarget !== "none" return (
@@ -1528,6 +1571,7 @@ function MailboxManagement({ 创建于 {formatDateTime(mailbox.createdAt)} {note && 备注:{note}} {forwardTarget && forwardTarget !== "none" && 转发:{forwardTarget}} + {accountForwardTargetActive && 转发:使用账号级 {accountForwardTarget}}
@@ -1553,11 +1597,14 @@ function MailboxManagement({
账号级转发
对所有邮箱生效,邮箱单独设置优先级更高
- - + +
{verifiedEmails.length === 0 &&

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

} @@ -1598,16 +1645,19 @@ function MailboxManagement({
{forwardingMailbox?.address}
- + {verifiedEmails.length === 0 &&

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

}
- - + + @@ -1616,16 +1666,19 @@ function MailboxManagement({ 管理验证邮箱
- setVerifiedEmailDraft(event.target.value)} className="h-[37px] flex-1" placeholder="输入邮箱地址" /> - + setVerifiedEmailDraft(event.target.value)} className="h-[37px] flex-1" placeholder="输入邮箱地址" disabled={forwardingBusy} /> +
- {verifiedEmails.map((email) => ( -
- {email} - + +
))} {verifiedEmails.length === 0 &&
暂无验证邮箱
}