feat: prepare v1.2.19 release
This commit is contained in:
@@ -705,6 +705,9 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateTelegramNotifications(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateDefaultMailLabels(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -722,6 +725,48 @@ func (a *App) migrateTelegramNotifications(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateDefaultMailLabels(ctx context.Context) error {
|
||||
const marker = "defaultMailLabelsInitialized"
|
||||
var initialized int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM system_settings WHERE key=?`, marker).Scan(&initialized); err != nil {
|
||||
return err
|
||||
}
|
||||
if initialized > 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM mailboxes ORDER BY id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var mailboxIDs []string
|
||||
for rows.Next() {
|
||||
var mailboxID string
|
||||
if err := rows.Scan(&mailboxID); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
mailboxIDs = append(mailboxIDs, mailboxID)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, mailboxID := range mailboxIDs {
|
||||
if err := insertDefaultMailLabels(ctx, tx, mailboxID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, marker, "true", now); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *App) initializeTelegramNotificationDefaults(ctx context.Context) error {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
var mailboxSettingExists int
|
||||
@@ -1769,6 +1814,30 @@ func defaultFolderDefs() []struct{ name, role string } {
|
||||
}
|
||||
}
|
||||
|
||||
type defaultMailLabel struct {
|
||||
name string
|
||||
color string
|
||||
}
|
||||
|
||||
func defaultMailLabelDefs() []defaultMailLabel {
|
||||
return []defaultMailLabel{
|
||||
{name: "个人", color: "#10b981"},
|
||||
{name: "家人", color: "#ec4899"},
|
||||
{name: "朋友", color: "#06b6d4"},
|
||||
{name: "工作", color: "#3b82f6"},
|
||||
{name: "重要", color: "#f59e0b"},
|
||||
}
|
||||
}
|
||||
|
||||
func insertDefaultMailLabels(ctx context.Context, tx *sql.Tx, mailboxID, now string) error {
|
||||
for _, label := range defaultMailLabelDefs() {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO mail_labels(id,mailbox_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)`, newID("lbl"), mailboxID, label.name, label.color, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, displayName, password string, quotaMB int, status string) (string, error) {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -1826,6 +1895,9 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := insertDefaultMailLabels(ctx, tx, id, now); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -549,16 +549,26 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
|
||||
var labels struct {
|
||||
Items []MailLabel `json:"items"`
|
||||
}
|
||||
if code := bob.do("GET", "/api/mail/labels?mailboxId="+mb2.ID, nil, &labels); code != http.StatusOK || len(labels.Items) != 1 || labels.Items[0].MessageCount != 1 {
|
||||
if code := bob.do("GET", "/api/mail/labels?mailboxId="+mb2.ID, nil, &labels); code != http.StatusOK || len(labels.Items) != len(defaultMailLabelDefs()) {
|
||||
t.Fatalf("labels code=%d items=%+v", code, labels.Items)
|
||||
}
|
||||
var importantLabel MailLabel
|
||||
for _, label := range labels.Items {
|
||||
if label.Name == "重要" {
|
||||
importantLabel = label
|
||||
break
|
||||
}
|
||||
}
|
||||
if importantLabel.ID == "" || importantLabel.MessageCount != 1 {
|
||||
t.Fatalf("important label missing or count is wrong: %+v", labels.Items)
|
||||
}
|
||||
var labeled struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := bob.do("GET", "/api/mail/messages?mailboxId="+mb2.ID+"&labelId="+labels.Items[0].ID, nil, &labeled); code != http.StatusOK || len(labeled.Items) != 1 || labeled.Items[0].ID != detail.ID {
|
||||
if code := bob.do("GET", "/api/mail/messages?mailboxId="+mb2.ID+"&labelId="+importantLabel.ID, nil, &labeled); code != http.StatusOK || len(labeled.Items) != 1 || labeled.Items[0].ID != detail.ID {
|
||||
t.Fatalf("labeled messages code=%d items=%+v", code, labeled.Items)
|
||||
}
|
||||
if code := bob.do("DELETE", "/api/mail/messages/"+detail.ID+"/labels/"+labels.Items[0].ID, nil, &labelUpdate); code != http.StatusOK || len(labelUpdate.Labels) != 0 {
|
||||
if code := bob.do("DELETE", "/api/mail/messages/"+detail.ID+"/labels/"+importantLabel.ID, nil, &labelUpdate); code != http.StatusOK || len(labelUpdate.Labels) != 0 {
|
||||
t.Fatalf("remove label code=%d labels=%+v", code, labelUpdate.Labels)
|
||||
}
|
||||
var starred struct {
|
||||
@@ -2020,6 +2030,16 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
|
||||
insertMessage("msg_multi_primary_read", primary.ID, primaryInboxID, "primary read", 1)
|
||||
insertMessage("msg_multi_primary_archived", primary.ID, primaryArchiveID, "primary archived unread", 0)
|
||||
insertMessage("msg_multi_secondary_unread", secondary.ID, secondaryInboxID, "secondary unread", 0)
|
||||
var primaryImportantID, secondaryImportantID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id FROM mail_labels WHERE mailbox_id=? AND name='重要'`, primary.ID).Scan(&primaryImportantID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id FROM mail_labels WHERE mailbox_id=? AND name='重要'`, secondary.ID).Scan(&secondaryImportantID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO message_labels(message_id,label_id,created_at) VALUES(?,?,?),(?,?,?)`, "msg_multi_primary_unread_1", primaryImportantID, now, "msg_multi_secondary_unread", secondaryImportantID, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
userClient := &testClient{t: t, server: ts}
|
||||
if code := userClient.do("POST", "/api/auth/login", map[string]string{"email": primary.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
@@ -2031,6 +2051,28 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
|
||||
if code := userClient.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 2 {
|
||||
t.Fatalf("my mailboxes code=%d items=%d", code, len(mine.Items))
|
||||
}
|
||||
var allLabels struct {
|
||||
Items []MailLabel `json:"items"`
|
||||
}
|
||||
if code := userClient.do("GET", "/api/mail/labels?mailboxId=all", nil, &allLabels); code != http.StatusOK || len(allLabels.Items) != len(defaultMailLabelDefs()) {
|
||||
t.Fatalf("all labels code=%d items=%+v", code, allLabels.Items)
|
||||
}
|
||||
var allImportant MailLabel
|
||||
for _, label := range allLabels.Items {
|
||||
if label.Name == "重要" {
|
||||
allImportant = label
|
||||
break
|
||||
}
|
||||
}
|
||||
if allImportant.ID == "" || allImportant.MailboxID != "" || allImportant.MessageCount != 2 {
|
||||
t.Fatalf("aggregated important label=%+v", allImportant)
|
||||
}
|
||||
var importantMessages struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := userClient.do("GET", "/api/mail/messages?mailboxId=all&labelId="+url.QueryEscape(allImportant.ID), nil, &importantMessages); code != http.StatusOK || len(importantMessages.Items) != 2 {
|
||||
t.Fatalf("all important messages code=%d items=%+v", code, importantMessages.Items)
|
||||
}
|
||||
unreadByAddress := map[string]int{}
|
||||
for _, item := range mine.Items {
|
||||
unreadByAddress[item.Address] = item.UnreadCount
|
||||
@@ -4760,6 +4802,72 @@ func TestDNSRecords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultMailLabelsBackfillOrderAndDeletion(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
var mailboxID string
|
||||
if err := a.db.QueryRow(`SELECT id FROM mailboxes WHERE address='admin@lanqin.local'`).Scan(&mailboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.Exec(`DELETE FROM system_settings WHERE key='defaultMailLabelsInitialized'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.Exec(`DELETE FROM mail_labels WHERE mailbox_id=?`, mailboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.migrateDefaultMailLabels(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels, err := a.labelsForMailbox(context.Background(), mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defaults := defaultMailLabelDefs()
|
||||
if len(labels) != len(defaults) {
|
||||
t.Fatalf("labels=%+v", labels)
|
||||
}
|
||||
for index, expected := range defaults {
|
||||
if labels[index].Name != expected.name || labels[index].Color != expected.color {
|
||||
t.Fatalf("label %d=%+v want name=%q color=%q", index, labels[index], expected.name, expected.color)
|
||||
}
|
||||
}
|
||||
if _, err := a.db.Exec(`DELETE FROM mail_labels WHERE id=?`, labels[1].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.migrateDefaultMailLabels(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels, err = a.labelsForMailbox(context.Background(), mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != len(defaults)-1 {
|
||||
t.Fatalf("deleted default label was restored: %+v", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDKIMRecordRequiresMatchingPublicKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
records []string
|
||||
key string
|
||||
ok bool
|
||||
message string
|
||||
}{
|
||||
{name: "matching", records: []string{"v=DKIM1; k=rsa; p=ABC123"}, key: "ABC123", ok: true, message: "DKIM 公钥匹配"},
|
||||
{name: "split whitespace", records: []string{"v=DKIM1; k=rsa; p=ABC 123\n456"}, key: "ABC123456", ok: true, message: "DKIM 公钥匹配"},
|
||||
{name: "wrong key", records: []string{"v=DKIM1; k=rsa; p=WRONG"}, key: "EXPECTED", ok: false, message: "DKIM 公钥与后台生成的记录不一致"},
|
||||
{name: "unrelated TXT", records: []string{"google-site-verification=token"}, key: "EXPECTED", ok: false, message: "未找到 DKIM 记录"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
status := checkDKIMRecord(tt.records, tt.key)
|
||||
if status.OK != tt.ok || status.Message != tt.message {
|
||||
t.Fatalf("status=%+v", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
|
||||
@@ -70,7 +70,7 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
|
||||
|
||||
dkimName := d.DKIMSelector + "._domainkey." + d.Name
|
||||
dkimTXT, _ := resolver.LookupTXT(ctx, dkimName)
|
||||
checks["dkim"] = txtContains(dkimTXT, "v=DKIM1", "DKIM 记录存在", "未找到 DKIM 记录")
|
||||
checks["dkim"] = checkDKIMRecord(dkimTXT, d.DKIMPublicKey)
|
||||
|
||||
dmarcTXT, _ := resolver.LookupTXT(ctx, "_dmarc."+d.Name)
|
||||
checks["dmarc"] = txtContains(dmarcTXT, "v=DMARC1", "DMARC 记录存在", "未找到 DMARC 记录")
|
||||
@@ -85,6 +85,42 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
|
||||
return DNSCheckResult{Domain: d.Name, Status: status, Checks: checks}
|
||||
}
|
||||
|
||||
func checkDKIMRecord(records []string, expectedPublicKey string) DNSCheckStatus {
|
||||
found := append([]string{}, records...)
|
||||
expectedPublicKey = compactDKIMPublicKey(expectedPublicKey)
|
||||
dkimFound := false
|
||||
for _, record := range records {
|
||||
tags := map[string]string{}
|
||||
for _, part := range strings.Split(record, ";") {
|
||||
key, value, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tags[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value)
|
||||
}
|
||||
if !strings.EqualFold(tags["v"], "DKIM1") {
|
||||
continue
|
||||
}
|
||||
dkimFound = true
|
||||
if expectedPublicKey != "" && compactDKIMPublicKey(tags["p"]) == expectedPublicKey {
|
||||
return DNSCheckStatus{OK: true, Message: "DKIM 公钥匹配", Found: found}
|
||||
}
|
||||
}
|
||||
if dkimFound {
|
||||
return DNSCheckStatus{OK: false, Message: "DKIM 公钥与后台生成的记录不一致", Found: found}
|
||||
}
|
||||
return DNSCheckStatus{OK: false, Message: "未找到 DKIM 记录", Found: found}
|
||||
}
|
||||
|
||||
func compactDKIMPublicKey(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == ' ' || r == '\t' || r == '\r' || r == '\n' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
}
|
||||
|
||||
func txtContains(records []string, needle, okMsg, failMsg string) DNSCheckStatus {
|
||||
found := append([]string{}, records...)
|
||||
for _, item := range records {
|
||||
|
||||
@@ -546,11 +546,12 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
|
||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||
user := currentUser(r)
|
||||
if labelID := strings.TrimSpace(r.URL.Query().Get("labelId")); labelID != "" {
|
||||
if !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
|
||||
labelName, ok := a.labelNameForUser(r.Context(), labelID, user.ID)
|
||||
if !ok {
|
||||
respondError(w, http.StatusNotFound, "label not found")
|
||||
return
|
||||
}
|
||||
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)`, []any{user.ID, labelID})
|
||||
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id=m.id AND lower(l.name)=lower(?))`, []any{user.ID, labelName})
|
||||
return
|
||||
}
|
||||
folder := r.URL.Query().Get("folder")
|
||||
@@ -2605,7 +2606,7 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab
|
||||
FROM mail_labels l LEFT JOIN message_labels ml ON ml.label_id=l.id
|
||||
WHERE l.mailbox_id=?
|
||||
GROUP BY l.id,l.mailbox_id,l.name,l.color
|
||||
ORDER BY lower(l.name)`, mailboxID)
|
||||
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2622,13 +2623,13 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab
|
||||
}
|
||||
|
||||
func (a *App) labelsForUser(ctx context.Context, userID string) ([]MailLabel, error) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color,COUNT(ml.message_id)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT MIN(l.id),'',MIN(l.name),MIN(l.color),COUNT(ml.message_id)
|
||||
FROM mail_labels l
|
||||
JOIN mailboxes mb ON mb.id=l.mailbox_id
|
||||
LEFT JOIN message_labels ml ON ml.label_id=l.id
|
||||
WHERE mb.user_id=? AND mb.status='active'
|
||||
GROUP BY l.id,l.mailbox_id,l.name,l.color
|
||||
ORDER BY lower(l.name)`, userID)
|
||||
GROUP BY lower(l.name)
|
||||
ORDER BY `+mailLabelNameOrderSQL("MIN(l.name)")+`, lower(MIN(l.name))`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2648,7 +2649,7 @@ func (a *App) labelsForMessage(ctx context.Context, messageID string) ([]MailLab
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color
|
||||
FROM mail_labels l JOIN message_labels ml ON ml.label_id=l.id
|
||||
WHERE ml.message_id=?
|
||||
ORDER BY lower(l.name)`, messageID)
|
||||
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2679,7 +2680,7 @@ func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) e
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT ml.message_id,l.id,l.mailbox_id,l.name,l.color
|
||||
FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id
|
||||
WHERE ml.message_id IN (`+strings.Join(ids, ",")+`)
|
||||
ORDER BY lower(l.name)`, args...)
|
||||
ORDER BY `+mailLabelOrderSQL("l")+`, lower(l.name)`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2697,6 +2698,14 @@ func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) e
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func mailLabelOrderSQL(alias string) string {
|
||||
return mailLabelNameOrderSQL(alias + `.name`)
|
||||
}
|
||||
|
||||
func mailLabelNameOrderSQL(expression string) string {
|
||||
return `CASE ` + expression + ` WHEN '个人' THEN 10 WHEN '家人' THEN 20 WHEN '朋友' THEN 30 WHEN '工作' THEN 40 WHEN '重要' THEN 50 ELSE 100 END`
|
||||
}
|
||||
|
||||
func (a *App) ensureLabel(ctx context.Context, mailboxID, name, color string) (MailLabel, error) {
|
||||
name = normalizeLabelName(name)
|
||||
if name == "" {
|
||||
@@ -2740,6 +2749,14 @@ func (a *App) labelBelongsToUser(ctx context.Context, labelID, userID string) bo
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (a *App) labelNameForUser(ctx context.Context, labelID, userID string) (string, bool) {
|
||||
var name string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT l.name FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id WHERE l.id=? AND mb.user_id=? AND mb.status='active'`, labelID, userID).Scan(&name); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
|
||||
func normalizeLabelName(name string) string {
|
||||
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
|
||||
if len([]rune(name)) > 32 {
|
||||
|
||||
@@ -122,8 +122,17 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
|
||||
if labelID == "" || !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)")
|
||||
args = append(args, labelID)
|
||||
if isAllMailboxID(mailboxID) {
|
||||
labelName, ok := a.labelNameForUser(r.Context(), labelID, user.ID)
|
||||
if !ok {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id=m.id AND lower(l.name)=lower(?))")
|
||||
args = append(args, labelName)
|
||||
} else {
|
||||
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)")
|
||||
args = append(args, labelID)
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("unsupported mail view")
|
||||
}
|
||||
|
||||
@@ -473,6 +473,7 @@ func sanitizeTelegramAttachmentName(value string) string {
|
||||
var (
|
||||
telegramOTPKeywordRe = regexp.MustCompile(`(?i)(验证码|校验码|动态码|登录码|安全码|一次性密码|otp|verification[ -]?code|security[ -]?code|login[ -]?code|passcode|one[ -]?time[ -]?(?:password|code))`)
|
||||
telegramOTPCandidateRe = regexp.MustCompile(`(?i)[a-z0-9]{4,10}`)
|
||||
telegramEmailRe = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
|
||||
telegramURLRe = regexp.MustCompile(`(?i)https?://[^\s<>"']+`)
|
||||
)
|
||||
|
||||
@@ -489,7 +490,11 @@ func detectTelegramOTP(subject, body string) string {
|
||||
}
|
||||
scores := map[string]candidateScore{}
|
||||
subjectEnd := len(strings.TrimSpace(subject))
|
||||
excludedRanges := append(telegramEmailRe.FindAllStringIndex(text, -1), telegramURLRe.FindAllStringIndex(text, -1)...)
|
||||
for _, match := range telegramOTPCandidateRe.FindAllStringIndex(text, -1) {
|
||||
if telegramRangeOverlaps(match, excludedRanges) {
|
||||
continue
|
||||
}
|
||||
if match[0] > 0 && isTelegramOTPAlphaNumeric(rune(text[match[0]-1])) {
|
||||
continue
|
||||
}
|
||||
@@ -561,6 +566,15 @@ func detectTelegramOTP(subject, body string) string {
|
||||
return items[0].value
|
||||
}
|
||||
|
||||
func telegramRangeOverlaps(candidate []int, ranges [][]int) bool {
|
||||
for _, item := range ranges {
|
||||
if len(item) == 2 && candidate[0] < item[1] && candidate[1] > item[0] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isTelegramOTPNonCode(value string) bool {
|
||||
if len(value) == 4 {
|
||||
if year, err := strconv.Atoi(value); err == nil && year >= 1900 && year <= 2099 {
|
||||
|
||||
@@ -223,6 +223,19 @@ func TestTelegramOTPDetectionAndMessageBudget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramIQiyiOTPDetection(t *testing.T) {
|
||||
subject := "825534 是您的动态安全验证码"
|
||||
body := "哈喽 iqiyi02@newszxcn.com 您正在进行爱奇艺账号的安全验证,以下是您的动态验证码:825534 如果这不是您的邮件,请忽略此邮件,请勿回复 手机·电视 其他 APP 在 LG, Samsung 等应用商店搜索 iQiyi 即可获得 Copyright © 2021 iQiyi All Rights Reserved"
|
||||
otp := detectTelegramOTP(subject, body)
|
||||
if otp != "825534" {
|
||||
t.Fatalf("iQiyi OTP not detected: %q", otp)
|
||||
}
|
||||
message := formatTelegramMailMessage(telegramMailPayload{Subject: subject, From: "no_reply_intl@iq.com", Recipient: "iqiyi02@newszxcn.com", ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), Body: body, OTP: otp})
|
||||
if !strings.Contains(message.HTML, "<code>825534</code>") || telegramCopyMarkup(message.OTP) == nil {
|
||||
t.Fatalf("iQiyi OTP section or copy button missing: %+v", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramForwardedGateOTPAndLinks(t *testing.T) {
|
||||
body := `---------- Forwarded message ---------
|
||||
Date: 2026年8月6日周四 17:59
|
||||
|
||||
@@ -2171,18 +2171,32 @@ function dnsDescription(record: DNSRecord): string {
|
||||
}
|
||||
|
||||
function DNSRecordRow({ record }: { record: DNSRecord }) {
|
||||
const { toast } = useToast(); const text = `${record.type} ${record.name} ${record.value}`
|
||||
const { toast } = useToast()
|
||||
const desc = dnsDescription(record)
|
||||
async function copyField(label: string, value: string) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast({ title: `${label}已复制` })
|
||||
}
|
||||
return <div className="rounded-lg border bg-card p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="mb-2 flex items-center">
|
||||
<Badge variant="outline" className="font-mono">{record.type}</Badge>
|
||||
<Button size="sm" variant="ghost" className="h-7 gap-1 text-xs" onClick={() => { navigator.clipboard.writeText(text); toast({ title: "已复制" }) }}><Copy className="h-3.5 w-3.5" />复制</Button>
|
||||
</div>
|
||||
{desc && <p className="mb-2 text-xs text-muted-foreground">{desc}</p>}
|
||||
<div className="break-all font-mono text-xs text-muted-foreground">
|
||||
<div><span className="text-foreground">Name:</span> {record.name}</div>
|
||||
<div><span className="text-foreground">Value:</span> {record.value}</div>
|
||||
<div><span className="text-foreground">TTL:</span> {record.ttl}s</div>
|
||||
<div className="space-y-1 font-mono text-xs text-muted-foreground">
|
||||
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)_1.75rem] items-start gap-2">
|
||||
<span className="pt-1 text-foreground">主机记录</span>
|
||||
<code className="break-all pt-1 font-mono">{record.name}</code>
|
||||
<Button type="button" size="icon" variant="ghost" className="h-7 w-7" aria-label="复制主机记录" title="复制主机记录" onClick={() => copyField("主机记录", record.name)}><Copy className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)_1.75rem] items-start gap-2">
|
||||
<span className="pt-1 text-foreground">记录值</span>
|
||||
<code className="break-all pt-1 font-mono">{record.value}</code>
|
||||
<Button type="button" size="icon" variant="ghost" className="h-7 w-7" aria-label="复制记录值" title="复制记录值" onClick={() => copyField("记录值", record.value)}><Copy className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-2">
|
||||
<span className="text-foreground">TTL</span>
|
||||
<code className="font-mono">{record.ttl} 秒</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+55
-30
@@ -208,7 +208,13 @@ export function MailPage() {
|
||||
}, [mailboxList.data?.items, selectedMailboxId])
|
||||
const isAllMailboxSelected = selectedMailboxId === "all"
|
||||
const activeMailboxId = selectedMailboxId === "all" ? "all" : selectedMailbox?.id || ""
|
||||
const selectedComposeMailbox = selectedMailbox || (isAllMailboxSelected ? mailboxList.data?.items?.[0] : undefined)
|
||||
const composeMailboxes = React.useMemo(() => (mailboxList.data?.items || []).filter((item) => item.status === "active"), [mailboxList.data?.items])
|
||||
const accountMailbox = React.useMemo(() => {
|
||||
const loginEmail = user?.email.trim().toLowerCase()
|
||||
if (!loginEmail) return undefined
|
||||
return composeMailboxes.find((item) => item.address.trim().toLowerCase() === loginEmail)
|
||||
}, [composeMailboxes, user?.email])
|
||||
const selectedComposeMailbox = selectedMailbox?.status === "active" ? selectedMailbox : (isAllMailboxSelected ? accountMailbox || composeMailboxes[0] : undefined)
|
||||
const hasMailboxes = (mailboxList.data?.items.length || 0) > 0
|
||||
const canManageFolders = canOrganizeMail && hasMailboxes
|
||||
const showMailboxCopy = !!selectedMailbox && !isAllMailboxSelected
|
||||
@@ -797,11 +803,11 @@ export function MailPage() {
|
||||
}
|
||||
function openReply(message: MailMessage) {
|
||||
if (!canSendMail) return
|
||||
openCompose({ key: `reply-${message.id}-${Date.now()}`, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) })
|
||||
openCompose({ key: `reply-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) })
|
||||
}
|
||||
function openForward(message: MailMessage) {
|
||||
if (!canSendMail) return
|
||||
openCompose({ key: `forward-${message.id}-${Date.now()}`, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) })
|
||||
openCompose({ key: `forward-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) })
|
||||
}
|
||||
async function openDraft(message: MailMessage) {
|
||||
if (!canManageDrafts) return
|
||||
@@ -1549,7 +1555,7 @@ export function MailPage() {
|
||||
) : compactMailLayout ? (
|
||||
<CompactMailView
|
||||
title={viewTitle}
|
||||
icon={mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: generateLabelColor(selectedLabel.name).backgroundColor }} />{selectedLabel.name}</Badge> : undefined}
|
||||
icon={mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(selectedLabel) }} />{selectedLabel.name}</Badge> : undefined}
|
||||
messages={visibleMessages}
|
||||
total={allMessages.length}
|
||||
selectedIds={compactSelectedIds}
|
||||
@@ -1739,7 +1745,7 @@ export function MailPage() {
|
||||
<div className="h-svh">{sidebarContent}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<div className="min-w-0 flex-1 text-sm font-semibold">{mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: generateLabelColor(selectedLabel.name).backgroundColor }} />{selectedLabel.name}</Badge> : viewTitle}</div>
|
||||
<div className="min-w-0 flex-1 text-sm font-semibold">{mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(selectedLabel) }} />{selectedLabel.name}</Badge> : viewTitle}</div>
|
||||
{mailTransferTools}
|
||||
{canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedComposeMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>}
|
||||
<div className="relative basis-full">
|
||||
@@ -1762,7 +1768,7 @@ export function MailPage() {
|
||||
)}
|
||||
</SidebarProvider>
|
||||
|
||||
<ComposeDialog mailbox={selectedComposeMailbox} open={composeOpen} draft={composeDraft} limits={user?.limits} canSend={canSendMail} canManageDrafts={canManageDrafts} canSchedule={canScheduleMail} canManageSignatures={canManageSignatures} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }); qc.invalidateQueries({ queryKey: ["send-queue"] }) }} />
|
||||
<ComposeDialog mailboxes={composeMailboxes} mailbox={selectedComposeMailbox} open={composeOpen} draft={composeDraft} limits={user?.limits} canSend={canSendMail} canManageDrafts={canManageDrafts} canSchedule={canScheduleMail} canManageSignatures={canManageSignatures} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }); qc.invalidateQueries({ queryKey: ["send-queue"] }) }} />
|
||||
<Input ref={mailImportInputRef} type="file" accept=".eml,.mbox,message/rfc822,application/mbox" multiple className="hidden" onChange={importSelectedMailFiles} />
|
||||
<SendQueueAuditDialog
|
||||
open={!!sendQueueAuditId}
|
||||
@@ -2625,11 +2631,10 @@ function MessageContextMenu({ state, labels, folders, canSend, canOrganize, canM
|
||||
<div className="max-h-44 overflow-y-auto">
|
||||
{labels.map((label) => {
|
||||
const active = (message.labels || []).some((item) => item.id === label.id)
|
||||
const colors = generateLabelColor(label.name)
|
||||
return (
|
||||
<Button key={label.id} type="button" variant="ghost" className={itemClass} disabled={labelPending} onClick={() => toggleLabel(label)}>
|
||||
<Check className={cn("h-4 w-4", active ? "opacity-100" : "opacity-0")} />
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: colors.backgroundColor }} />
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(label) }} />
|
||||
<span className="min-w-0 flex-1 truncate text-left">{active ? `移除 ${label.name}` : label.name}</span>
|
||||
</Button>
|
||||
)
|
||||
@@ -3445,10 +3450,9 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
|
||||
<MessageMetaRow label="标签">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{labels.map((label) => {
|
||||
const colors = generateLabelColor(label.name)
|
||||
return (
|
||||
<Badge key={label.id} variant="outline" className="label-badge group/badge gap-1.5 rounded-md font-normal">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: colors.backgroundColor }} />
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(label) }} />
|
||||
<span>{label.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -3476,7 +3480,6 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
|
||||
{availableLabels.length === 0 && <DropdownMenuItem disabled>请先在侧栏新建标签</DropdownMenuItem>}
|
||||
{availableLabels.map((label) => {
|
||||
const active = labels.some((l) => l.id === label.id)
|
||||
const colors = generateLabelColor(label.name)
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={label.id}
|
||||
@@ -3486,7 +3489,7 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
|
||||
active ? onRemoveLabel(label.id) : onAddLabel(label)
|
||||
}}
|
||||
>
|
||||
<span className="mr-2 h-2 w-2 rounded-full" style={{ backgroundColor: colors.backgroundColor }} />
|
||||
<span className="mr-2 h-2 w-2 rounded-full" style={{ backgroundColor: labelDotColor(label) }} />
|
||||
<span>{label.name}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
@@ -3618,10 +3621,9 @@ function MessageRow({
|
||||
}
|
||||
|
||||
function MailLabelBadge({ label }: { label: MailLabel }) {
|
||||
const colors = generateLabelColor(label.name)
|
||||
return (
|
||||
<Badge variant="outline" className="shrink-0 gap-1.5 rounded-md font-normal">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(label) || colors.backgroundColor }} />
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(label) }} />
|
||||
{label.name}
|
||||
</Badge>
|
||||
)
|
||||
@@ -3631,9 +3633,12 @@ function labelDotColor(label: MailLabel) {
|
||||
return label.color?.trim() || generateLabelColor(label.name).backgroundColor
|
||||
}
|
||||
|
||||
function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts, canSchedule, canManageSignatures, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; limits?: PermissionLimits; canSend: boolean; canManageDrafts: boolean; canSchedule: boolean; canManageSignatures: boolean; onOpenChange: (v: boolean) => void; onSent: () => void }) {
|
||||
function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canManageDrafts, canSchedule, canManageSignatures, onOpenChange, onSent }: { mailboxes: Mailbox[]; mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; limits?: PermissionLimits; canSend: boolean; canManageDrafts: boolean; canSchedule: boolean; canManageSignatures: boolean; onOpenChange: (v: boolean) => void; onSent: () => void }) {
|
||||
const { toast } = useToast()
|
||||
const qc = useQueryClient()
|
||||
const initialMailboxId = mailboxes.find((item) => item.id === draft?.mailboxId)?.id || mailboxes.find((item) => item.id === mailbox?.id)?.id || mailboxes[0]?.id || ""
|
||||
const [senderMailboxId, setSenderMailboxId] = React.useState(initialMailboxId)
|
||||
const senderMailbox = React.useMemo(() => mailboxes.find((item) => item.id === senderMailboxId) || mailboxes[0], [mailboxes, senderMailboxId])
|
||||
const [files, setFiles] = React.useState<File[]>([])
|
||||
const [draftAttachments, setDraftAttachments] = React.useState<SendPayload["attachments"]>([])
|
||||
const [attachmentsTouched, setAttachmentsTouched] = React.useState(false)
|
||||
@@ -3648,14 +3653,15 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
|
||||
const [sendIntent, setSendIntent] = React.useState<ComposeSendIntent | null>(null)
|
||||
const sendStartedRef = React.useRef(false)
|
||||
const lastSavedPayloadRef = React.useRef("")
|
||||
const initializedSessionRef = React.useRef("")
|
||||
const [showCc, setShowCc] = React.useState(Boolean(draft?.cc))
|
||||
const [showBcc, setShowBcc] = React.useState(Boolean(draft?.bcc))
|
||||
const [sendSeparately, setSendSeparately] = React.useState(false)
|
||||
const defaultSignature = useQuery({ queryKey: ["signature", "default", mailbox?.id], queryFn: () => api.defaultSignature(mailbox?.id), enabled: open && !!mailbox?.id && canManageSignatures })
|
||||
const defaultSignature = useQuery({ queryKey: ["signature", "default", senderMailbox?.id], queryFn: () => api.defaultSignature(senderMailbox?.id), enabled: open && !!senderMailbox?.id && canManageSignatures })
|
||||
const signatureText = defaultSignature.data?.signature?.content || ""
|
||||
const composerText = draft?.html || (draft?.text !== undefined ? draft.text : signatureText ? `\n\n-- \n${signatureText}` : "")
|
||||
const [body, setBody] = React.useState<ComposerValue>(() => draft?.html !== undefined ? htmlComposerValue(draft.html) : plainTextComposerValue(composerText))
|
||||
const activeMailboxId = draft?.mailboxId || mailbox?.id || ""
|
||||
const activeMailboxId = senderMailbox?.id || ""
|
||||
const maxAttachmentBytes = attachmentLimitBytes(limits)
|
||||
const maxAttachmentText = maxAttachmentBytes > 0 ? formatBytes(maxAttachmentBytes) : "不限"
|
||||
const composePayload = React.useMemo<DraftPayload>(() => ({
|
||||
@@ -3707,13 +3713,20 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
if (!open) {
|
||||
initializedSessionRef.current = ""
|
||||
return
|
||||
}
|
||||
const sessionKey = draft?.key || draft?.id || "new"
|
||||
if (initializedSessionRef.current === sessionKey) return
|
||||
initializedSessionRef.current = sessionKey
|
||||
sendStartedRef.current = false
|
||||
const nextMailboxId = mailboxes.find((item) => item.id === draft?.mailboxId)?.id || mailboxes.find((item) => item.id === mailbox?.id)?.id || mailboxes[0]?.id || ""
|
||||
const nextShowCc = Boolean(draft?.cc)
|
||||
const nextShowBcc = Boolean(draft?.bcc)
|
||||
const nextBody = draft?.html !== undefined ? htmlComposerValue(draft.html) : plainTextComposerValue(composerText)
|
||||
const nextBody = draft?.html !== undefined ? htmlComposerValue(draft.html) : plainTextComposerValue(draft?.text || "")
|
||||
lastSavedPayloadRef.current = JSON.stringify({
|
||||
mailboxId: draft?.mailboxId || mailbox?.id || "",
|
||||
mailboxId: nextMailboxId,
|
||||
to: splitEmails(draft?.to || ""),
|
||||
cc: nextShowCc ? splitEmails(draft?.cc || "") : [],
|
||||
bcc: nextShowBcc ? splitEmails(draft?.bcc || "") : [],
|
||||
@@ -3722,6 +3735,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
|
||||
html: nextBody.html || plainTextToHtml(nextBody.text),
|
||||
draftId: draft?.id || "",
|
||||
})
|
||||
setSenderMailboxId(nextMailboxId)
|
||||
setDraftId(draft?.id || "")
|
||||
setToValue(draft?.to || "")
|
||||
setCcValue(draft?.cc || "")
|
||||
@@ -3736,7 +3750,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
|
||||
setFiles(draft?.files || [])
|
||||
setDraftAttachments([])
|
||||
setAttachmentsTouched(false)
|
||||
}, [open, draft?.key, draft?.id, draft?.mailboxId, draft?.to, draft?.cc, draft?.bcc, draft?.subject, draft?.html, draft?.files, mailbox?.id, composerText])
|
||||
}, [open, draft?.key, draft?.id, draft?.mailboxId, draft?.to, draft?.cc, draft?.bcc, draft?.subject, draft?.text, draft?.html, draft?.files, mailbox?.id, mailboxes])
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -3814,7 +3828,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
|
||||
|
||||
async function prepareSend() {
|
||||
if (!canSend) return
|
||||
if (!mailbox) return
|
||||
if (!senderMailbox) return
|
||||
if (!attachmentsWithinLimit()) return
|
||||
const attachments = await Promise.all(files.map(fileToAttachment))
|
||||
const to = splitEmails(toValue)
|
||||
@@ -3822,7 +3836,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
|
||||
const bcc = showBcc ? splitEmails(bccValue) : []
|
||||
const text = body.text
|
||||
const html = body.html || plainTextToHtml(text)
|
||||
const payload: SendPayload = { mailboxId: mailbox.id, to, cc, bcc, subject: subjectValue, text, html, attachments }
|
||||
const payload: SendPayload = { mailboxId: senderMailbox.id, to, cc, bcc, subject: subjectValue, text, html, attachments }
|
||||
const separateRecipients = Array.from(new Set([...to, ...cc, ...bcc]))
|
||||
const payloads = sendSeparately && separateRecipients.length > 0
|
||||
? separateRecipients.map((recipient): SendPayload => ({ ...payload, to: [recipient], cc: [], bcc: [] }))
|
||||
@@ -3841,7 +3855,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
|
||||
|
||||
async function submit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (!mailbox) {
|
||||
if (!senderMailbox) {
|
||||
toast({ title: "请选择发件邮箱" })
|
||||
return
|
||||
}
|
||||
@@ -3849,14 +3863,14 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
|
||||
}
|
||||
async function scheduleAt(sendAt: string) {
|
||||
if (!canSchedule) return
|
||||
if (!mailbox) {
|
||||
if (!senderMailbox) {
|
||||
toast({ title: "请选择发件邮箱" })
|
||||
return
|
||||
}
|
||||
if (!attachmentsWithinLimit()) return
|
||||
const attachments = await Promise.all(files.map(fileToAttachment))
|
||||
const payload: SendPayload & { draftId?: string; sendAt: string } = {
|
||||
mailboxId: mailbox.id,
|
||||
mailboxId: senderMailbox.id,
|
||||
to: splitEmails(toValue),
|
||||
cc: showCc ? splitEmails(ccValue) : [],
|
||||
bcc: showBcc ? splitEmails(bccValue) : [],
|
||||
@@ -3882,7 +3896,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex h-svh w-screen max-w-none overflow-hidden p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(96vw,82rem)]"
|
||||
className="flex h-svh w-screen max-w-none overflow-hidden p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(92vw,72rem)]"
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
@@ -3897,7 +3911,18 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
|
||||
</DialogHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<ComposeField label="发件邮箱">
|
||||
<Input value={mailbox?.address || "未选择"} readOnly className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
|
||||
{mailboxes.length > 1 ? (
|
||||
<Select value={senderMailbox?.id || ""} onValueChange={setSenderMailboxId}>
|
||||
<SelectTrigger aria-label="发件邮箱" className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus:ring-0">
|
||||
<SelectValue placeholder="选择发件邮箱" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-w-[calc(100vw-2rem)]">
|
||||
{mailboxes.map((item) => <SelectItem key={item.id} value={item.id}>{item.address}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input value={senderMailbox?.address || "未选择"} readOnly className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
|
||||
)}
|
||||
</ComposeField>
|
||||
<ComposeField
|
||||
label="收件人"
|
||||
@@ -3940,8 +3965,8 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
|
||||
</div>
|
||||
<DialogFooter className="grid grid-cols-3 gap-2 border-t bg-background px-4 py-3 sm:flex sm:flex-row sm:justify-end sm:px-6 sm:py-4">
|
||||
<Button type="button" variant="outline" className="min-h-10 px-3" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
{canSchedule && <Button type="button" variant="outline" className="min-h-10 px-3" disabled={send.isPending || scheduleSend.isPending || !mailbox} onClick={() => setScheduleDialogOpen(true)}><Calendar className="h-4 w-4" />定时</Button>}
|
||||
{canSend && <Button className="min-h-10 px-4" disabled={send.isPending || !mailbox}><Send className="h-4 w-4" />{send.isPending ? "发送中..." : "发送"}</Button>}
|
||||
{canSchedule && <Button type="button" variant="outline" className="min-h-10 px-3" disabled={send.isPending || scheduleSend.isPending || !senderMailbox} onClick={() => setScheduleDialogOpen(true)}><Calendar className="h-4 w-4" />定时</Button>}
|
||||
{canSend && <Button className="min-h-10 px-4" disabled={send.isPending || !senderMailbox}><Send className="h-4 w-4" />{send.isPending ? "发送中..." : "发送"}</Button>}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
<ScheduleSendDialog open={scheduleDialogOpen} pending={scheduleSend.isPending} onOpenChange={setScheduleDialogOpen} onConfirm={scheduleAt} />
|
||||
|
||||
Reference in New Issue
Block a user