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 小时内有效。如果您没有发起此操作,请忽略此邮件。

+
` + messageID := fmt.Sprintf("<%s@%s>", newID("fwdverify"), fromDomain) + mimeBytes, err := BuildMIME(MIMEMessage{From: from, FromName: "noreply", To: []string{targetEmail}, Subject: "邮箱转发验证", Text: text, HTML: html, MessageID: messageID, Date: now}) + if err != nil { + return "", err + } + return a.enqueueSend(ctx, sendQueueInput{ + UserID: userID, + MailboxID: mb.ID, + MessageID: messageID, + Source: sendSourceForwardingVerification, + MailFrom: from, + HeaderFrom: from, + Recipients: []string{targetEmail}, + MIMEBytes: mimeBytes, + Now: now, + }) +} + +func (a *App) forwardingVerificationURL(token string) string { + base := strings.TrimRight(strings.TrimSpace(a.cfg.PublicBaseURL), "/") + if base == "" { + base = "https://" + strings.Trim(strings.TrimSpace(a.cfg.PublicHostname), "/") + } + return base + "/api/verify-email?token=" + url.QueryEscape(token) +} + +func (a *App) renderForwardingVerificationPage(w http.ResponseWriter, status int, ok bool, email, message string) { + title := "邮箱转发验证" + heading := "验证失败" + color := "#dc2626" + if ok { + heading = "验证完成" + color = "#2563eb" + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + _, _ = fmt.Fprintf(w, `%s

%s

%s

%s

返回邮箱
`, + title, heading, htmlEscape(message), htmlEscape(email), color) +} + +func (a *App) cleanForwardingVerificationEmail(w http.ResponseWriter, r *http.Request, userID, value string) (string, bool) { + email := normalizeEmail(value) + if email == "" || !strings.Contains(email, "@") { + badRequest(w, errors.New("邮箱地址无效")) + return "", false + } + if owns, err := a.userOwnsMailboxAddress(r.Context(), userID, email); err != nil { + respondError(w, http.StatusInternalServerError, "failed to check mailbox") + return "", false + } else if owns { + badRequest(w, errors.New("不能把当前账号邮箱作为转发验证邮箱")) + return "", false + } + return email, true +} + +func (a *App) forwardingVerifiedEmailState(ctx context.Context, userID, email string) (id string, verified bool, err error) { + var verifiedInt int + err = a.db.QueryRowContext(ctx, `SELECT id,verified FROM forwarding_verified_emails WHERE user_id=? AND email=?`, userID, normalizeEmail(email)).Scan(&id, &verifiedInt) + return id, intBool(verifiedInt), err +} + +func (a *App) primaryMailboxForUser(ctx context.Context, userID string) (Mailbox, error) { + var mb Mailbox + var created string + err := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at + FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id LIMIT 1`, userID). + Scan(&mb.ID, &mb.UserID, &mb.DomainID, &mb.LocalPart, &mb.Address, &mb.DisplayName, &mb.QuotaMB, &mb.Status, &created) + if errors.Is(err, sql.ErrNoRows) { + return mb, errors.New("当前账号没有可用于发送验证邮件的邮箱") + } + mb.CreatedAt = parseTime(created) + 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") { @@ -263,7 +489,7 @@ func (a *App) cleanForwardingTarget(ctx context.Context, userID, value string) ( return "", err } if !ok { - return "", errors.New("请先添加验证邮箱") + return "", errors.New("请先完成邮箱验证") } return target, nil } diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index bdbeee9..c826ac1 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -32,6 +32,7 @@ func (a *App) Router() http.Handler { r.Route("/api", func(r chi.Router) { r.Get("/public/settings", a.handlePublicSettings) + r.Get("/verify-email", a.handleVerifyForwardingEmail) r.Post("/auth/register", a.handleRegister) r.Post("/auth/login", a.handleLogin) r.Post("/auth/logout", a.handleLogout) @@ -46,6 +47,7 @@ func (a *App) Router() http.Handler { 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)).Post("/me/forwarding/verified-emails/{id}/resend", a.handleResendForwardingVerifiedEmail) 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) diff --git a/apps/api/internal/app/send_queue.go b/apps/api/internal/app/send_queue.go index c05070d..122a40c 100644 --- a/apps/api/internal/app/send_queue.go +++ b/apps/api/internal/app/send_queue.go @@ -25,10 +25,11 @@ const ( sendAuditRetry = "retry" sendAuditCanceled = "canceled" - sendSourceWebmail = "webmail" - sendSourceSubmission = "submission" - sendSourceOpenAPI = "open_api" - sendSourceForwarding = "forwarding" + sendSourceWebmail = "webmail" + sendSourceSubmission = "submission" + sendSourceOpenAPI = "open_api" + sendSourceForwarding = "forwarding" + sendSourceForwardingVerification = "forwarding_verification" 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 8d05d3a..95a265c 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -125,7 +125,17 @@ 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 ForwardingVerifiedEmail = { + id: string + email: string + verified: boolean + createdAt: string + verifiedAt?: string + verificationSentAt?: string + verificationExpiresAt?: string + deliveryStatus?: SendQueueStatus | "verified" + deliveryError?: string +} export type MailboxForwardingRule = { mailboxId: string; targetEmail: string } export type ForwardingSettings = { verifiedEmails: ForwardingVerifiedEmail[]; accountTargetEmail: string; mailboxRules: MailboxForwardingRule[] } export type ExternalImapStorageMode = "local" | "remote" diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 9894e4b..336ee6a 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -105,6 +105,7 @@ export const api = { 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 }) }), + resendForwardingVerifiedEmail: (id: string) => request(`/api/me/forwarding/verified-emails/${id}/resend`, { method: "POST" }), 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 }) }), diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index 9958969..826ca1f 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, ForwardingSettings, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" +import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, ForwardingSettings, ForwardingVerifiedEmail, 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" @@ -1383,7 +1383,7 @@ function MailboxManagement({ 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 verifiedEmails = React.useMemo(() => verifiedEmailItems.filter((item) => item.verified).map((item) => item.email), [verifiedEmailItems]) const mailboxForwards = React.useMemo>(() => { const next: Record = {} for (const rule of forwarding.data?.mailboxRules || []) { @@ -1406,10 +1406,27 @@ function MailboxManagement({ setForwardingCache(settings) addLog("添加验证邮箱", email) setVerifiedEmailDraft("") - toast({ title: "验证邮箱已添加" }) + const item = settings.verifiedEmails.find((entry) => entry.email.toLowerCase() === email.trim().toLowerCase()) + toast({ + title: item?.deliveryStatus === "failed" ? "验证邮箱已添加,邮件发送失败" : "验证邮件已发送", + description: item?.deliveryStatus === "failed" ? item.deliveryError || "请稍后重发验证邮件" : "请前往目标邮箱点击确认验证", + }) }, onError: (error) => toast({ title: "添加失败", description: error.message }), }) + const resendVerifiedEmail = useMutation({ + mutationFn: ({ id }: { id: string; email: string }) => api.resendForwardingVerifiedEmail(id), + onSuccess: (settings, item) => { + setForwardingCache(settings) + addLog("重发验证邮件", item.email) + const next = settings.verifiedEmails.find((entry) => entry.id === item.id) + toast({ + title: next?.deliveryStatus === "failed" ? "重发失败" : "验证邮件已重发", + description: next?.deliveryStatus === "failed" ? next.deliveryError || "请稍后再试" : "请前往目标邮箱点击确认验证", + }) + }, + onError: (error) => toast({ title: "重发失败", description: error.message }), + }) const deleteVerifiedEmail = useMutation({ mutationFn: ({ id }: { id: string; email: string }) => api.deleteForwardingVerifiedEmail(id), onSuccess: (settings, item) => { @@ -1440,7 +1457,7 @@ function MailboxManagement({ }, onError: (error) => toast({ title: "保存失败", description: error.message }), }) - const forwardingBusy = forwarding.isLoading || addVerifiedEmail.isPending || deleteVerifiedEmail.isPending || saveAccountForwarding.isPending || saveMailboxForwarding.isPending + const forwardingBusy = forwarding.isLoading || addVerifiedEmail.isPending || resendVerifiedEmail.isPending || deleteVerifiedEmail.isPending || saveAccountForwarding.isPending || saveMailboxForwarding.isPending React.useEffect(() => { if (!domainOptions.length) return @@ -1492,7 +1509,7 @@ function MailboxManagement({ function submitVerifiedEmail(event: React.FormEvent) { event.preventDefault() const value = verifiedEmailDraft.trim() - if (!value || verifiedEmails.includes(value)) return + if (!value) return addVerifiedEmail.mutate(value) } @@ -1502,6 +1519,10 @@ function MailboxManagement({ deleteVerifiedEmail.mutate({ id, email }) } + function resendVerification(item: ForwardingVerifiedEmail) { + resendVerifiedEmail.mutate({ id: item.id, email: item.email }) + } + function confirmLocalAction(action: string, mailbox: Mailbox, destructive = false) { setPendingConfirm({ title: `${action}?`, @@ -1593,7 +1614,7 @@ function MailboxManagement({

邮件转发

-
+
账号级转发
对所有邮箱生效,邮箱单独设置优先级更高
@@ -1607,6 +1628,19 @@ function MailboxManagement({
+ {verifiedEmailItems.length > 0 && ( +
+ {verifiedEmailItems.map((item) => { + const tone = forwardingEmailTone(item) + return ( + + + {item.email} · {tone.shortLabel} + + ) + })} +
+ )} {verifiedEmails.length === 0 &&

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

}

提示:每个邮箱可单独设置转发(点击邮箱列表中的「转发」按钮),单独设置会覆盖账号级配置。

@@ -1663,28 +1697,44 @@ function MailboxManagement({ - - 管理验证邮箱 -
- setVerifiedEmailDraft(event.target.value)} className="h-[37px] flex-1" placeholder="输入邮箱地址" disabled={forwardingBusy} /> - + + + 验证邮箱管理 + +
+ 添加并验证外部邮箱地址后,才能用作转发目标。这里只展示投递状态摘要,不展示验证邮件内容。 +
+ + setVerifiedEmailDraft(event.target.value)} className="h-12 text-base shadow-none" placeholder="输入邮箱地址" disabled={forwardingBusy} /> + -
+
{verifiedEmailItems.map((item) => ( -
- {item.email} -
- {item.verified ? "已验证" : "待验证"} - + )} +
))} - {verifiedEmails.length === 0 &&
暂无验证邮箱
} + {verifiedEmailItems.length === 0 &&
暂无验证邮箱
}
- - + +
@@ -1916,6 +1966,53 @@ function formatDateTime(value: string) { return date.toLocaleString() } +function forwardingEmailTone(item: ForwardingVerifiedEmail) { + if (item.verified) { + return { + shortLabel: "已验证", + dotClass: "bg-emerald-500", + chipClass: "bg-emerald-100 text-emerald-800", + detailClass: "text-emerald-700", + } + } + if (item.deliveryStatus === "failed") { + return { + shortLabel: "发送失败", + dotClass: "bg-destructive", + chipClass: "bg-destructive/10 text-destructive", + detailClass: "text-destructive", + } + } + if (item.deliveryStatus === "delivered") { + return { + shortLabel: "待验证", + dotClass: "bg-amber-500", + chipClass: "bg-amber-100 text-amber-800", + detailClass: "text-amber-700", + } + } + return { + shortLabel: "待验证", + dotClass: "bg-blue-500", + chipClass: "bg-blue-100 text-blue-800", + detailClass: "text-blue-700", + } +} + +function forwardingEmailStatusText(item: ForwardingVerifiedEmail) { + const time = item.verificationSentAt ? ` · 最近尝试 ${formatDateTime(item.verificationSentAt)}` : "" + if (item.deliveryStatus === "failed") { + return `验证邮件发送失败${item.deliveryError ? `:${item.deliveryError}` : ""}${time}` + } + if (item.deliveryStatus === "delivered") { + return `验证邮件已发送,请前往目标邮箱完成验证${time}` + } + if (item.deliveryStatus === "sending") { + return `验证邮件发送中${time}` + } + return `验证邮件排队发送中${time}` +} + function dateInputValue(date: Date) { const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, "0")