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