feat: require verification for forwarding targets
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user