feat: require verification for forwarding targets

This commit is contained in:
zxyszx
2026-08-02 09:08:20 +08:00
parent 7f05a70f60
commit 9f330fdf57
8 changed files with 541 additions and 54 deletions
+33 -1
View File
@@ -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 {
+123 -5
View File
@@ -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)
}
+248 -22
View File
@@ -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 := `<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#111827;line-height:1.6;padding:32px 24px">
<div style="max-width:640px;margin:0 auto">
<h1 style="font-size:28px;line-height:1.25;margin:0 0 28px;font-weight:700">邮箱转发验证</h1>
<p style="font-size:17px;margin:0 0 28px">您正在将此邮箱添加为邮件转发目标地址请点击下方按钮完成验证</p>
<p style="text-align:center;margin:0 0 34px"><a href="` + htmlEscape(link) + `" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;border-radius:8px;padding:14px 38px;font-size:18px;font-weight:700">确认验证</a></p>
<p style="font-size:15px;color:#6b7280;margin:0 0 12px">如果按钮无法点击请复制以下链接到浏览器</p>
<p style="font-size:15px;color:#6b7280;word-break:break-all;margin:0 0 28px">` + htmlEscape(link) + `</p>
<p style="font-size:15px;color:#9ca3af;margin:0">此链接 24 小时内有效如果您没有发起此操作请忽略此邮件</p>
</div></div>`
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, `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head><body style="margin:0;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif"><main style="min-height:100vh;display:grid;place-items:center;padding:24px"><section style="width:min(100%%,520px);background:white;border:1px solid #e2e8f0;border-radius:14px;padding:34px 30px;box-shadow:0 18px 45px rgba(15,23,42,.08)"><h1 style="margin:0 0 14px;font-size:28px">%s</h1><p style="margin:0 0 10px;font-size:17px;color:#475569">%s</p><p style="margin:0 0 26px;font-size:15px;color:#64748b">%s</p><a href="/" style="display:inline-block;border-radius:8px;background:%s;color:white;text-decoration:none;padding:12px 18px;font-weight:700">返回邮箱</a></section></main></body></html>`,
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
}
+2
View File
@@ -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)
+5 -4
View File
@@ -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
+11 -1
View File
@@ -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"
+1
View File
@@ -105,6 +105,7 @@ export const api = {
applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request<Mailbox>("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }),
forwardingSettings: () => request<ForwardingSettings>("/api/me/forwarding"),
addForwardingVerifiedEmail: (email: string) => request<ForwardingSettings>("/api/me/forwarding/verified-emails", { method: "POST", body: JSON.stringify({ email }) }),
resendForwardingVerifiedEmail: (id: string) => request<ForwardingSettings>(`/api/me/forwarding/verified-emails/${id}/resend`, { method: "POST" }),
deleteForwardingVerifiedEmail: (id: string) => request<ForwardingSettings>(`/api/me/forwarding/verified-emails/${id}`, { method: "DELETE" }),
updateAccountForwarding: (targetEmail: string) => request<ForwardingSettings>("/api/me/forwarding/account", { method: "POST", body: JSON.stringify({ targetEmail }) }),
updateMailboxForwarding: (mailboxId: string, targetEmail: string) => request<ForwardingSettings>(`/api/me/mailboxes/${mailboxId}/forwarding`, { method: "POST", body: JSON.stringify({ targetEmail }) }),
+118 -21
View File
@@ -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<PendingConfirm | null>(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<Record<string, string>>(() => {
const next: Record<string, string> = {}
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<HTMLFormElement>) {
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({
<h2 className="text-lg font-semibold leading-7"></h2>
<Button type="button" variant="outline" size="sm" onClick={() => setVerifiedDialogOpen(true)}></Button>
</div>
<div className="rounded-lg bg-background">
<div className="rounded-xl bg-muted/20 px-5 py-5">
<div className="mb-3 text-sm font-medium"></div>
<div className="mb-4 text-sm text-muted-foreground"></div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_72px]">
@@ -1607,6 +1628,19 @@ function MailboxManagement({
<Button type="button" className="h-[37px]" disabled={forwardingBusy} onClick={() => saveAccountForwarding.mutate(accountForwardTarget === "none" ? "" : accountForwardTarget)}>{saveAccountForwarding.isPending ? "保存中" : "保存"}</Button>
</div>
</div>
{verifiedEmailItems.length > 0 && (
<div className="mt-5 flex flex-wrap gap-2">
{verifiedEmailItems.map((item) => {
const tone = forwardingEmailTone(item)
return (
<span key={item.id} className={cn("inline-flex max-w-full items-center gap-2 rounded-full px-3 py-1 text-sm", tone.chipClass)}>
<span className={cn("size-2 shrink-0 rounded-full", tone.dotClass)} />
<span className="min-w-0 truncate">{item.email} · {tone.shortLabel}</span>
</span>
)
})}
</div>
)}
{verifiedEmails.length === 0 && <p className="mt-4 text-sm text-muted-foreground"></p>}
<p className="mt-3 text-sm text-muted-foreground"></p>
</section>
@@ -1663,28 +1697,44 @@ function MailboxManagement({
</Dialog>
<Dialog open={verifiedDialogOpen} onOpenChange={setVerifiedDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<form className="flex gap-2" onSubmit={submitVerifiedEmail}>
<Input type="email" value={verifiedEmailDraft} onChange={(event) => setVerifiedEmailDraft(event.target.value)} className="h-[37px] flex-1" placeholder="输入邮箱地址" disabled={forwardingBusy} />
<Button className="h-[37px] px-4" disabled={forwardingBusy || !verifiedEmailDraft.trim()}>{addVerifiedEmail.isPending ? "添加中" : "添加"}</Button>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[640px]">
<DialogHeader className="px-8 pt-8">
<DialogTitle className="text-2xl leading-8"></DialogTitle>
</DialogHeader>
<div className="px-8 pt-5 text-[17px] leading-8 text-muted-foreground">
</div>
<form className="grid gap-3 px-8 pt-6 sm:grid-cols-[minmax(0,1fr)_96px]" onSubmit={submitVerifiedEmail}>
<Input type="email" value={verifiedEmailDraft} onChange={(event) => setVerifiedEmailDraft(event.target.value)} className="h-12 text-base shadow-none" placeholder="输入邮箱地址" disabled={forwardingBusy} />
<Button className="h-12 px-0 text-base" disabled={forwardingBusy || !verifiedEmailDraft.trim()}>{addVerifiedEmail.isPending ? "添加中" : "添加"}</Button>
</form>
<div className="space-y-2">
<div className="mx-8 mt-6 max-h-[360px] overflow-y-auto rounded-lg border">
{verifiedEmailItems.map((item) => (
<div key={item.id} className="flex h-10 items-center justify-between gap-3 rounded-md border px-3 text-sm">
<span className="min-w-0 truncate">{item.email}</span>
<div className="flex shrink-0 items-center gap-2">
<Badge variant="secondary" className="h-5 rounded-md px-1.5 text-[10px]">{item.verified ? "已验证" : "待验证"}</Badge>
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground hover:text-destructive" disabled={forwardingBusy} onClick={() => removeVerifiedEmail(item.id, item.email)} aria-label={`移除 ${item.email}`}>
<X className="h-4 w-4" />
<div key={item.id} className="grid min-h-[82px] gap-3 border-b px-4 py-4 last:border-b-0 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="min-w-0">
<div className="truncate text-lg font-semibold leading-6">{item.email}</div>
<div className="mt-1 text-sm text-muted-foreground">{item.verified ? `已验证 - ${formatDateTime(item.verifiedAt || item.createdAt)}` : "待验证"}</div>
{!item.verified && (
<div className={cn("mt-1 text-sm leading-5", forwardingEmailTone(item).detailClass)}>
{forwardingEmailStatusText(item)}
</div>
)}
</div>
<div className="flex shrink-0 items-center justify-end gap-2">
<span className={cn("size-2.5 rounded-full", forwardingEmailTone(item).dotClass)} />
{!item.verified && (
<Button type="button" variant="outline" className="h-10 px-4" disabled={forwardingBusy} onClick={() => resendVerification(item)}></Button>
)}
<Button type="button" variant="outline" className="h-10 px-4 text-destructive hover:text-destructive" disabled={forwardingBusy} onClick={() => removeVerifiedEmail(item.id, item.email)} aria-label={`移除 ${item.email}`}>
</Button>
</div>
</div>
))}
{verifiedEmails.length === 0 && <div className="py-6 text-center text-sm text-muted-foreground"></div>}
{verifiedEmailItems.length === 0 && <div className="py-10 text-center text-sm text-muted-foreground"></div>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setVerifiedDialogOpen(false)}></Button>
<DialogFooter className="border-t px-8 py-6">
<Button type="button" variant="outline" className="h-12 px-8 text-base" onClick={() => setVerifiedDialogOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -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")