%s
%s
%s
返回邮箱From 9f330fdf574902177461db2cdeda90d352f8fde0 Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:08:20 +0800 Subject: [PATCH] feat: require verification for forwarding targets --- apps/api/internal/app/app.go | 34 ++- apps/api/internal/app/app_test.go | 128 ++++++++- apps/api/internal/app/forwarding_handlers.go | 270 +++++++++++++++++-- apps/api/internal/app/router_auth.go | 2 + apps/api/internal/app/send_queue.go | 9 +- apps/web/src/lib/api-types.ts | 12 +- apps/web/src/lib/api.ts | 1 + apps/web/src/pages/profile.tsx | 139 ++++++++-- 8 files changed, 541 insertions(+), 54 deletions(-) diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index adf3c45..3246318 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -231,7 +231,14 @@ func (a *App) migrate(ctx context.Context) error { 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, + verified INTEGER NOT NULL DEFAULT 0, + verified_at TEXT, + verification_token_hash TEXT NOT NULL DEFAULT '', + verification_sent_at TEXT, + verification_expires_at TEXT, + delivery_queue_id TEXT NOT NULL DEFAULT '', + delivery_status TEXT NOT NULL DEFAULT '', + delivery_error TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(user_id, email) @@ -617,6 +624,9 @@ func (a *App) migrate(ctx context.Context) error { if err := a.migrateExternalIMAP(ctx); err != nil { return err } + if err := a.migrateForwardingVerification(ctx); err != nil { + return err + } if err := a.migrateAPITokenScopes(ctx); err != nil { return err } @@ -626,6 +636,28 @@ func (a *App) migrate(ctx context.Context) error { return nil } +func (a *App) migrateForwardingVerification(ctx context.Context) error { + columns := []struct { + name string + sql string + }{ + {"verified_at", `ALTER TABLE forwarding_verified_emails ADD COLUMN verified_at TEXT`}, + {"verification_token_hash", `ALTER TABLE forwarding_verified_emails ADD COLUMN verification_token_hash TEXT NOT NULL DEFAULT ''`}, + {"verification_sent_at", `ALTER TABLE forwarding_verified_emails ADD COLUMN verification_sent_at TEXT`}, + {"verification_expires_at", `ALTER TABLE forwarding_verified_emails ADD COLUMN verification_expires_at TEXT`}, + {"delivery_queue_id", `ALTER TABLE forwarding_verified_emails ADD COLUMN delivery_queue_id TEXT NOT NULL DEFAULT ''`}, + {"delivery_status", `ALTER TABLE forwarding_verified_emails ADD COLUMN delivery_status TEXT NOT NULL DEFAULT ''`}, + {"delivery_error", `ALTER TABLE forwarding_verified_emails ADD COLUMN delivery_error TEXT NOT NULL DEFAULT ''`}, + } + for _, column := range columns { + if err := a.ensureTableColumn(ctx, "forwarding_verified_emails", column.name, column.sql); err != nil { + return err + } + } + _, err := a.db.ExecContext(ctx, `UPDATE forwarding_verified_emails SET verified_at=created_at WHERE verified=1 AND (verified_at IS NULL OR verified_at='')`) + return err +} + func (a *App) migrateAPITokenScopes(ctx context.Context) error { rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(api_tokens)`) if err != nil { diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index f2e5c01..21b877b 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -21,9 +21,12 @@ import ( "io" "log/slog" "math/big" + "mime" + "mime/multipart" "net" "net/http" "net/http/httptest" + netmail "net/mail" "net/textproto" "net/url" "os" @@ -219,6 +222,71 @@ func handleFakeSMTPConn(conn net.Conn, received chan<- string) { } } +type testMIMEHeader interface { + Get(string) string +} + +func extractForwardingVerificationToken(t *testing.T, raw string) string { + t.Helper() + msg, err := netmail.ReadMessage(strings.NewReader(raw)) + if err != nil { + t.Fatalf("read verification message: %v", err) + } + body := extractMIMETextForTest(t, msg.Header, msg.Body) + marker := "/api/verify-email?token=" + idx := strings.Index(body, marker) + if idx < 0 { + t.Fatalf("verification link not found in body: %q", body) + } + token := body[idx+len(marker):] + if end := strings.IndexAny(token, "\"'<>\r\n\t "); end >= 0 { + token = token[:end] + } + token, _ = url.QueryUnescape(token) + if token == "" { + t.Fatalf("verification token empty in body: %q", body) + } + return token +} + +func extractMIMETextForTest(t *testing.T, header testMIMEHeader, body io.Reader) string { + t.Helper() + contentType := header.Get("Content-Type") + mediaType, params, _ := mime.ParseMediaType(contentType) + if strings.HasPrefix(strings.ToLower(mediaType), "multipart/") { + boundary := params["boundary"] + if boundary == "" { + t.Fatalf("multipart message missing boundary: %s", contentType) + } + mr := multipart.NewReader(body, boundary) + var out strings.Builder + for { + part, err := mr.NextPart() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("read mime part: %v", err) + } + out.WriteString(extractMIMETextForTest(t, part.Header, part)) + out.WriteString("\n") + } + return out.String() + } + data, err := io.ReadAll(body) + if err != nil { + t.Fatalf("read mime body: %v", err) + } + if strings.EqualFold(strings.TrimSpace(header.Get("Content-Transfer-Encoding")), "base64") { + decoded, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(string(data)), "")) + if err != nil { + t.Fatalf("decode mime base64: %v", err) + } + data = decoded + } + return string(data) +} + type testClient struct { t *testing.T server *httptest.Server @@ -1645,7 +1713,7 @@ func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) { func TestInboundForwardingSettingsAndDelivery(t *testing.T) { a := newTestApp(t) stopTestWorkers(a) - host, port, received := startCapturingSMTP(t, 2) + host, port, received := startCapturingSMTP(t, 4) a.cfg.SMTPHost = host a.cfg.SMTPPort = port ts := httptest.NewServer(a.Router()) @@ -1657,15 +1725,67 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) { t.Fatalf("login code=%d body=%v", code, login) } _, mb := defaultAdminUserAndMailbox(t, a) + ctx := context.Background() + verifyTarget := func(email string) { + t.Helper() + var settings ForwardingSettings + if code := admin.do("POST", "/api/me/forwarding/verified-emails", map[string]string{"email": email}, &settings); code != http.StatusCreated { + t.Fatalf("add forwarding target %s code=%d settings=%+v", email, code, settings) + } + if len(settings.VerifiedEmails) == 0 || settings.VerifiedEmails[0].Verified { + t.Fatalf("target should start pending: %+v", settings.VerifiedEmails) + } + if err := a.processDueSendQueue(ctx); err != nil { + t.Fatal(err) + } + var verificationBody string + select { + case verificationBody = <-received: + case <-time.After(2 * time.Second): + t.Fatal("verification email was not relayed") + } + token := extractForwardingVerificationToken(t, verificationBody) + if code := admin.do("GET", "/api/verify-email?token="+url.QueryEscape(token), nil, nil); code != http.StatusOK { + t.Fatalf("verify email code=%d", code) + } + if code := admin.do("GET", "/api/me/forwarding", nil, &settings); code != http.StatusOK { + t.Fatalf("reload forwarding settings code=%d", code) + } + found := false + for _, item := range settings.VerifiedEmails { + if item.Email == email { + found = item.Verified + } + } + if !found { + t.Fatalf("target %s was not marked verified: %+v", email, settings.VerifiedEmails) + } + } + 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.StatusBadRequest { + t.Fatalf("unverified account forwarding should be rejected, code=%d settings=%+v", code, settings) + } + if err := a.processDueSendQueue(ctx); err != nil { + t.Fatal(err) + } + var verificationBody string + select { + case verificationBody = <-received: + case <-time.After(2 * time.Second): + t.Fatal("account verification email was not relayed") + } + token := extractForwardingVerificationToken(t, verificationBody) + if code := admin.do("GET", "/api/verify-email?token="+url.QueryEscape(token), nil, nil); code != http.StatusOK { + t.Fatalf("verify account target 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" { 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) @@ -1716,9 +1836,7 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) { 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) - } + 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 { t.Fatalf("save mailbox forwarding code=%d settings=%+v", code, settings) } diff --git a/apps/api/internal/app/forwarding_handlers.go b/apps/api/internal/app/forwarding_handlers.go index bc58005..129eca5 100644 --- a/apps/api/internal/app/forwarding_handlers.go +++ b/apps/api/internal/app/forwarding_handlers.go @@ -4,18 +4,27 @@ import ( "context" "database/sql" "errors" + "fmt" "net/http" + "net/url" "strings" "time" "github.com/go-chi/chi/v5" ) +const forwardingVerificationTTL = 24 * time.Hour + type ForwardingVerifiedEmail struct { - ID string `json:"id"` - Email string `json:"email"` - Verified bool `json:"verified"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"id"` + Email string `json:"email"` + Verified bool `json:"verified"` + CreatedAt time.Time `json:"createdAt"` + VerifiedAt *time.Time `json:"verifiedAt,omitempty"` + VerificationSentAt *time.Time `json:"verificationSentAt,omitempty"` + VerificationExpiresAt *time.Time `json:"verificationExpiresAt,omitempty"` + DeliveryStatus string `json:"deliveryStatus,omitempty"` + DeliveryError string `json:"deliveryError,omitempty"` } type MailboxForwardingRule struct { @@ -48,25 +57,28 @@ func (a *App) handleAddForwardingVerifiedEmail(w http.ResponseWriter, r *http.Re badRequest(w, err) return } - email := normalizeEmail(req.Email) - if email == "" || !strings.Contains(email, "@") { - badRequest(w, errors.New("邮箱地址无效")) + email, ok := a.cleanForwardingVerificationEmail(w, r, user.ID, req.Email) + if !ok { 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("不能把当前账号邮箱作为转发验证邮箱")) + id, verified, err := a.forwardingVerifiedEmailState(r.Context(), user.ID, email) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + respondError(w, http.StatusInternalServerError, "failed to load verified email") 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 { + if verified { + 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) + return + } + if id == "" { + id = newID("fwd") + } + if err := a.issueForwardingVerification(r.Context(), user.ID, id, email, errors.Is(err, sql.ErrNoRows)); err != nil { respondError(w, http.StatusInternalServerError, "failed to save verified email") return } @@ -78,6 +90,82 @@ func (a *App) handleAddForwardingVerifiedEmail(w http.ResponseWriter, r *http.Re respondJSON(w, http.StatusCreated, settings) } +func (a *App) handleResendForwardingVerifiedEmail(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 + var verified int + err := a.db.QueryRowContext(r.Context(), `SELECT email,verified FROM forwarding_verified_emails WHERE id=? AND user_id=?`, id, user.ID).Scan(&email, &verified) + if errors.Is(err, sql.ErrNoRows) { + respondError(w, http.StatusNotFound, "verified email not found") + return + } + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load verified email") + return + } + if intBool(verified) { + 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) + return + } + if err := a.issueForwardingVerification(r.Context(), user.ID, id, email, false); err != nil { + respondError(w, http.StatusInternalServerError, "failed to resend verification 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.StatusOK, settings) +} + +func (a *App) handleVerifyForwardingEmail(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(r.URL.Query().Get("token")) + if token == "" { + a.renderForwardingVerificationPage(w, http.StatusBadRequest, false, "", "验证链接无效") + return + } + var id, email string + var verified int + var expiresRaw sql.NullString + err := a.db.QueryRowContext(r.Context(), `SELECT id,email,verified,verification_expires_at FROM forwarding_verified_emails WHERE verification_token_hash=?`, hashToken(token)).Scan(&id, &email, &verified, &expiresRaw) + if errors.Is(err, sql.ErrNoRows) { + a.renderForwardingVerificationPage(w, http.StatusBadRequest, false, "", "验证链接无效或已使用") + return + } + if err != nil { + a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, "", "验证失败,请稍后重试") + return + } + if intBool(verified) { + a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "该邮箱已经验证完成") + return + } + if expiresRaw.Valid && expiresRaw.String != "" && parseTime(expiresRaw.String).Before(a.now().UTC()) { + a.renderForwardingVerificationPage(w, http.StatusBadRequest, false, email, "验证链接已过期,请回到设置页重新发送") + return + } + now := a.now().UTC().Format(time.RFC3339Nano) + _, err = a.db.ExecContext(r.Context(), `UPDATE forwarding_verified_emails + SET verified=1,verified_at=?,verification_token_hash='',delivery_status='verified',delivery_error='',updated_at=? + WHERE id=?`, now, now, id) + if err != nil { + a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, email, "验证失败,请稍后重试") + return + } + a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "验证完成,可以回到设置页选择此转发目标") +} + func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) { user := currentUser(r) id := strings.TrimSpace(chi.URLParam(r, "id")) @@ -207,7 +295,14 @@ func (a *App) forwardingSettings(ctx context.Context, userID string) (Forwarding 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) + rows, err := a.db.QueryContext(ctx, `SELECT fve.id,fve.email,fve.verified,fve.created_at, + fve.verified_at,fve.verification_sent_at,fve.verification_expires_at, + COALESCE(NULLIF(sq.status,''), fve.delivery_status), + COALESCE(NULLIF(sq.last_error,''), fve.delivery_error) + FROM forwarding_verified_emails fve + LEFT JOIN send_queue sq ON sq.id=fve.delivery_queue_id + WHERE fve.user_id=? + ORDER BY fve.created_at DESC,fve.email`, userID) if err != nil { return settings, err } @@ -216,11 +311,15 @@ func (a *App) forwardingSettings(ctx context.Context, userID string) (Forwarding var item ForwardingVerifiedEmail var verified int var created string - if err := rows.Scan(&item.ID, &item.Email, &verified, &created); err != nil { + var verifiedAt, sentAt, expiresAt sql.NullString + if err := rows.Scan(&item.ID, &item.Email, &verified, &created, &verifiedAt, &sentAt, &expiresAt, &item.DeliveryStatus, &item.DeliveryError); err != nil { return settings, err } item.Verified = intBool(verified) item.CreatedAt = parseTime(created) + item.VerifiedAt = nullableTime(verifiedAt) + item.VerificationSentAt = nullableTime(sentAt) + item.VerificationExpiresAt = nullableTime(expiresAt) settings.VerifiedEmails = append(settings.VerifiedEmails, item) } if err := rows.Err(); err != nil { @@ -249,6 +348,133 @@ func (a *App) forwardingSettings(ctx context.Context, userID string) (Forwarding return settings, rows.Err() } +func (a *App) issueForwardingVerification(ctx context.Context, userID, id, email string, insert bool) error { + now := a.now().UTC() + expires := now.Add(forwardingVerificationTTL) + token := randomToken() + nowRaw := now.Format(time.RFC3339Nano) + expiresRaw := expires.Format(time.RFC3339Nano) + if insert { + if _, err := a.db.ExecContext(ctx, `INSERT INTO forwarding_verified_emails(id,user_id,email,verified,verified_at,verification_token_hash,verification_sent_at,verification_expires_at,delivery_queue_id,delivery_status,delivery_error,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, + id, userID, email, 0, nil, hashToken(token), nowRaw, expiresRaw, "", sendQueueStatusQueued, "", nowRaw, nowRaw); err != nil { + return err + } + } else { + if _, err := a.db.ExecContext(ctx, `UPDATE forwarding_verified_emails + SET verified=0,verified_at=NULL,verification_token_hash=?,verification_sent_at=?,verification_expires_at=?,delivery_queue_id='',delivery_status=?,delivery_error='',updated_at=? + WHERE id=? AND user_id=?`, + hashToken(token), nowRaw, expiresRaw, sendQueueStatusQueued, nowRaw, id, userID); err != nil { + return err + } + } + queueID, err := a.sendForwardingVerificationEmail(ctx, userID, email, token, now) + if err != nil { + _, _ = a.db.ExecContext(ctx, `UPDATE forwarding_verified_emails SET delivery_status=?,delivery_error=?,updated_at=? WHERE id=? AND user_id=?`, sendQueueStatusFailed, err.Error(), nowRaw, id, userID) + return nil + } + if queueID != "" { + _, _ = a.db.ExecContext(ctx, `UPDATE forwarding_verified_emails SET delivery_queue_id=?,delivery_status=?,delivery_error='',updated_at=? WHERE id=? AND user_id=?`, queueID, sendQueueStatusQueued, nowRaw, id, userID) + } + return nil +} + +func (a *App) sendForwardingVerificationEmail(ctx context.Context, userID, targetEmail, token string, now time.Time) (string, error) { + if strings.TrimSpace(a.cfg.SMTPHost) == "" { + return "", errors.New("SMTP 未配置,无法发送验证邮件") + } + mb, err := a.primaryMailboxForUser(ctx, userID) + if err != nil { + return "", err + } + fromDomain := domainPart(mb.Address) + from := "noreply@" + fromDomain + link := a.forwardingVerificationURL(token) + text := "邮箱转发验证\n\n您正在将此邮箱添加为邮件转发目标地址。请打开以下链接完成验证:\n" + link + "\n\n此链接 24 小时内有效。如果您没有发起此操作,请忽略此邮件。" + html := `
您正在将此邮箱添加为邮件转发目标地址。请点击下方按钮完成验证:
+ +如果按钮无法点击,请复制以下链接到浏览器:
+` + htmlEscape(link) + `
+此链接 24 小时内有效。如果您没有发起此操作,请忽略此邮件。
+%s
%s
返回邮箱暂未添加验证邮箱,请先点击「管理验证邮箱」添加。
}提示:每个邮箱可单独设置转发(点击邮箱列表中的「转发」按钮),单独设置会覆盖账号级配置。
@@ -1663,28 +1697,44 @@ function MailboxManagement({