Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a572bf13a | |||
| 2b846ac671 | |||
| b3669f189e | |||
| d28ed4adcc | |||
| b36adfdac2 | |||
| 632a8a4896 | |||
| f8d058f7e4 | |||
| 1788d49a59 |
@@ -715,7 +715,7 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rows.Close()
|
||||
for _, messageID := range messageIDs {
|
||||
a.deleteMessageFiles(r.Context(), messageID)
|
||||
a.deleteMessage(r.Context(), messageID)
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||
if err != nil {
|
||||
@@ -786,7 +786,7 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
args = append(args, limit+1, offset)
|
||||
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
FROM messages m
|
||||
LEFT JOIN folders f ON f.id=m.folder_id
|
||||
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id
|
||||
@@ -833,6 +833,116 @@ func (a *App) handleAdminMessage(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, msg)
|
||||
}
|
||||
|
||||
func (a *App) handleAdminSendAudit(w http.ResponseWriter, r *http.Request) {
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
messageID := strings.TrimSpace(r.URL.Query().Get("messageId"))
|
||||
event := strings.TrimSpace(r.URL.Query().Get("event"))
|
||||
from, err := adminAuditTimeParam(r.URL.Query().Get("from"), false)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
to, err := adminAuditTimeParam(r.URL.Query().Get("to"), true)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := 50
|
||||
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if mailboxID != "" && mailboxID != "all" {
|
||||
where = append(where, "sae.mailbox_id=?")
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
if messageID != "" {
|
||||
where = append(where, "(sq.message_id=? OR m.message_id=? OR sae.sent_message_id=?)")
|
||||
args = append(args, messageID, messageID, messageID)
|
||||
}
|
||||
if event != "" && event != "all" {
|
||||
if !isSendAuditEvent(event) {
|
||||
badRequest(w, errors.New("invalid event"))
|
||||
return
|
||||
}
|
||||
where = append(where, "sae.event=?")
|
||||
args = append(args, event)
|
||||
}
|
||||
if from != "" {
|
||||
where = append(where, "sae.created_at>=?")
|
||||
args = append(args, from)
|
||||
}
|
||||
if to != "" {
|
||||
where = append(where, "sae.created_at<=?")
|
||||
args = append(args, to)
|
||||
}
|
||||
args = append(args, limit+1, offset)
|
||||
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT sae.id,sae.queue_id,sae.mailbox_id,COALESCE(mb.address,''),sae.sent_message_id,COALESCE(sq.message_id,m.message_id,''),sae.source,sae.event,sae.status,sae.mail_from,sae.header_from,sae.recipients_json,sae.error,sae.created_at
|
||||
FROM send_audit_events sae
|
||||
LEFT JOIN mailboxes mb ON mb.id=sae.mailbox_id
|
||||
LEFT JOIN send_queue sq ON sq.id=sae.queue_id
|
||||
LEFT JOIN messages m ON m.id=sae.sent_message_id
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY sae.created_at DESC, sae.id DESC LIMIT ? OFFSET ?`, args...)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send audit")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []SendAuditEvent{}
|
||||
for rows.Next() {
|
||||
var item SendAuditEvent
|
||||
var recipientsJSON, createdAt string
|
||||
if err := rows.Scan(&item.ID, &item.QueueID, &item.MailboxID, &item.MailboxAddress, &item.SentMessageID, &item.MessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan send audit")
|
||||
return
|
||||
}
|
||||
item.Recipients = jsonDecodeSlice(recipientsJSON)
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send audit")
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
next = strconv.Itoa(offset + limit)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func adminAuditTimeParam(value string, endOfDay bool) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return t.UTC().Format(time.RFC3339Nano), nil
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", value); err == nil {
|
||||
if endOfDay {
|
||||
t = t.Add(24*time.Hour - time.Nanosecond)
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339Nano), nil
|
||||
}
|
||||
return "", errors.New("invalid time filter")
|
||||
}
|
||||
|
||||
func isSendAuditEvent(event string) bool {
|
||||
switch event {
|
||||
case sendAuditAccepted, sendAuditQueued, sendAuditRetry, sendAuditDelivered, sendAuditFailed, sendAuditCanceled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
DomainID string `json:"domainId"`
|
||||
@@ -1039,6 +1149,6 @@ func (a *App) ensureFolder(ctx context.Context, mailboxID, folder string) (strin
|
||||
}
|
||||
role := strings.ToLower(folder)
|
||||
id = newID("fld")
|
||||
_, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,created_at) VALUES(?,?,?,?,?)`, id, mailboxID, folder, role, a.now().UTC().Format(time.RFC3339Nano))
|
||||
_, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?)`, id, mailboxID, folder, role, a.newUIDValidity(), 1, 1, a.now().UTC().Format(time.RFC3339Nano))
|
||||
return id, err
|
||||
}
|
||||
|
||||
@@ -22,12 +22,13 @@ import (
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg Config
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
cfg Config
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
maildirHealth *maildirSyncHealthTracker
|
||||
}
|
||||
|
||||
func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
@@ -47,7 +48,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy()}
|
||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker()}
|
||||
if err := a.configureSQLite(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
@@ -201,6 +202,9 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
uid_validity INTEGER NOT NULL DEFAULT 0,
|
||||
uid_next INTEGER NOT NULL DEFAULT 1,
|
||||
highest_modseq INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(mailbox_id, name)
|
||||
)`,
|
||||
@@ -226,7 +230,14 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
is_starred INTEGER NOT NULL DEFAULT 0,
|
||||
has_attachments INTEGER NOT NULL DEFAULT 0,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
auth_results TEXT NOT NULL DEFAULT '',
|
||||
auth_spf TEXT NOT NULL DEFAULT 'unknown',
|
||||
auth_dkim TEXT NOT NULL DEFAULT 'unknown',
|
||||
auth_dmarc TEXT NOT NULL DEFAULT 'unknown',
|
||||
received_spf TEXT NOT NULL DEFAULT '',
|
||||
raw_path TEXT NOT NULL DEFAULT '',
|
||||
imap_uid INTEGER NOT NULL DEFAULT 0,
|
||||
imap_modseq INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
@@ -413,6 +424,9 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateMessagesFromName(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateMessageAuthentication(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateUsersForTwoFactor(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -428,12 +442,56 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateSendQueueMessageID(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateIMAPMetadata(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateMessageAuthentication(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
columns := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull int
|
||||
var dflt any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
columns[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
alter := []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{"auth_results", `ALTER TABLE messages ADD COLUMN auth_results TEXT NOT NULL DEFAULT ''`},
|
||||
{"auth_spf", `ALTER TABLE messages ADD COLUMN auth_spf TEXT NOT NULL DEFAULT 'unknown'`},
|
||||
{"auth_dkim", `ALTER TABLE messages ADD COLUMN auth_dkim TEXT NOT NULL DEFAULT 'unknown'`},
|
||||
{"auth_dmarc", `ALTER TABLE messages ADD COLUMN auth_dmarc TEXT NOT NULL DEFAULT 'unknown'`},
|
||||
{"received_spf", `ALTER TABLE messages ADD COLUMN received_spf TEXT NOT NULL DEFAULT ''`},
|
||||
}
|
||||
for _, item := range alter {
|
||||
if !columns[item.name] {
|
||||
if _, err := a.db.ExecContext(ctx, item.sql); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateSendQueueMessageID(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(send_queue)`)
|
||||
if err != nil {
|
||||
@@ -582,7 +640,7 @@ func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
for _, messageID := range messageIDs {
|
||||
a.deleteMessageFiles(ctx, messageID)
|
||||
a.deleteMessage(ctx, messageID)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil {
|
||||
return err
|
||||
@@ -1029,7 +1087,7 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
|
||||
return "", err
|
||||
}
|
||||
for _, f := range defaultFolderDefs() {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,created_at) VALUES(?,?,?,?,?)`, newID("fld"), id, f.name, f.role, now)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?)`, newID("fld"), id, f.name, f.role, a.newUIDValidity(), 1, 1, now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
type imapMetadata struct {
|
||||
UID int64
|
||||
ModSeq int64
|
||||
}
|
||||
|
||||
func (a *App) migrateIMAPMetadata(ctx context.Context) error {
|
||||
if err := a.ensureTableColumn(ctx, "folders", "uid_validity", `ALTER TABLE folders ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTableColumn(ctx, "folders", "uid_next", `ALTER TABLE folders ADD COLUMN uid_next INTEGER NOT NULL DEFAULT 1`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTableColumn(ctx, "folders", "highest_modseq", `ALTER TABLE folders ADD COLUMN highest_modseq INTEGER NOT NULL DEFAULT 1`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTableColumn(ctx, "messages", "imap_uid", `ALTER TABLE messages ADD COLUMN imap_uid INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTableColumn(ctx, "messages", "imap_modseq", `ALTER TABLE messages ADD COLUMN imap_modseq INTEGER NOT NULL DEFAULT 1`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET uid_validity=? WHERE uid_validity=0`, a.newUIDValidity()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET uid_next=1 WHERE uid_next<1`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET highest_modseq=1 WHERE highest_modseq<1`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.backfillIMAPUIDs(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_folder_imap_uid ON messages(folder_id, imap_uid) WHERE folder_id IS NOT NULL AND imap_uid > 0`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notNull int
|
||||
var dflt any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == column {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, alterSQL)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) backfillIMAPUIDs(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM folders ORDER BY created_at,id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var folderIDs []string
|
||||
for rows.Next() {
|
||||
var folderID string
|
||||
if err := rows.Scan(&folderID); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
folderIDs = append(folderIDs, folderID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, folderID := range folderIDs {
|
||||
if err := a.backfillFolderIMAPUIDs(ctx, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) backfillFolderIMAPUIDs(ctx context.Context, folderID string) error {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE folder_id=? AND imap_uid=0 ORDER BY created_at,id`, folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var messageIDs []string
|
||||
for rows.Next() {
|
||||
var messageID string
|
||||
if err := rows.Scan(&messageID); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
messageIDs = append(messageIDs, messageID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, messageID := range messageIDs {
|
||||
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET imap_uid=?,imap_modseq=? WHERE id=?`, meta.UID, meta.ModSeq, messageID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var maxUID, maxModSeq int64
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(imap_uid),0),COALESCE(MAX(imap_modseq),1) FROM messages WHERE folder_id=?`, folderID).Scan(&maxUID, &maxModSeq); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE folders SET uid_next=MAX(uid_next,?),highest_modseq=MAX(highest_modseq,?) WHERE id=?`, maxUID+1, maxModSeq, folderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) newUIDValidity() int64 {
|
||||
value := a.now().UTC().Unix()
|
||||
if value <= 0 {
|
||||
return time.Now().UTC().Unix()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (a *App) nextIMAPMetadata(ctx context.Context, db dbExecutor, folderID string) (imapMetadata, error) {
|
||||
if folderID == "" {
|
||||
return imapMetadata{}, nil
|
||||
}
|
||||
rowDB, ok := db.(dbQueryer)
|
||||
if !ok {
|
||||
return imapMetadata{}, nil
|
||||
}
|
||||
var nextUID, highestModSeq int64
|
||||
err := rowDB.QueryRowContext(ctx, `SELECT uid_next,highest_modseq FROM folders WHERE id=?`, folderID).Scan(&nextUID, &highestModSeq)
|
||||
if err != nil {
|
||||
return imapMetadata{}, err
|
||||
}
|
||||
if nextUID < 1 {
|
||||
nextUID = 1
|
||||
}
|
||||
nextModSeq := highestModSeq + 1
|
||||
if nextModSeq < 1 {
|
||||
nextModSeq = 1
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE folders SET uid_next=?,highest_modseq=MAX(highest_modseq,?) WHERE id=?`, nextUID+1, nextModSeq, folderID); err != nil {
|
||||
return imapMetadata{}, err
|
||||
}
|
||||
return imapMetadata{UID: nextUID, ModSeq: nextModSeq}, nil
|
||||
}
|
||||
|
||||
func (a *App) bumpFolderModSeq(ctx context.Context, folderID string) (int64, error) {
|
||||
return a.bumpFolderModSeqWithDB(ctx, a.db, folderID)
|
||||
}
|
||||
|
||||
func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderID string) (int64, error) {
|
||||
if folderID == "" {
|
||||
return 0, nil
|
||||
}
|
||||
rowDB, ok := db.(dbQueryer)
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
var current int64
|
||||
if err := rowDB.QueryRowContext(ctx, `SELECT highest_modseq FROM folders WHERE id=?`, folderID).Scan(¤t); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
next := current + 1
|
||||
if next < 1 || next == math.MaxInt64 {
|
||||
next = current
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE folders SET highest_modseq=MAX(highest_modseq,?) WHERE id=?`, next, folderID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (a *App) touchMessageIMAPModSeq(ctx context.Context, messageID string) error {
|
||||
var folderID sql.NullString
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !folderID.Valid || folderID.String == "" {
|
||||
return nil
|
||||
}
|
||||
modSeq, err := a.bumpFolderModSeq(ctx, folderID.String)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if modSeq == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) updateMessageModSeq(ctx context.Context, messageID string, folderID string) (int64, error) {
|
||||
if folderID == "" {
|
||||
var dbFolderID sql.NullString
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&dbFolderID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !dbFolderID.Valid || dbFolderID.String == "" {
|
||||
return 0, nil
|
||||
}
|
||||
folderID = dbFolderID.String
|
||||
}
|
||||
modSeq, err := a.bumpFolderModSeq(ctx, folderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if modSeq == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
|
||||
return modSeq, err
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -28,25 +29,26 @@ type AttachmentInput struct {
|
||||
}
|
||||
|
||||
type storedMessage struct {
|
||||
MailboxID string
|
||||
FolderID string
|
||||
RecipientAddr string
|
||||
MessageUID string
|
||||
MessageID string
|
||||
Subject string
|
||||
From string
|
||||
FromName string
|
||||
To []string
|
||||
CC []string
|
||||
BCC []string
|
||||
SentAt time.Time
|
||||
ReceivedAt time.Time
|
||||
Snippet string
|
||||
BodyText string
|
||||
BodyHTML string
|
||||
IsRead bool
|
||||
IsStarred bool
|
||||
RawPath string
|
||||
MailboxID string
|
||||
FolderID string
|
||||
RecipientAddr string
|
||||
MessageUID string
|
||||
MessageID string
|
||||
Subject string
|
||||
From string
|
||||
FromName string
|
||||
To []string
|
||||
CC []string
|
||||
BCC []string
|
||||
SentAt time.Time
|
||||
ReceivedAt time.Time
|
||||
Snippet string
|
||||
BodyText string
|
||||
BodyHTML string
|
||||
IsRead bool
|
||||
IsStarred bool
|
||||
RawPath string
|
||||
Authentication MailAuthentication
|
||||
}
|
||||
|
||||
func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -84,7 +86,8 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,
|
||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||
COUNT(m.id) AS total
|
||||
COUNT(m.id) AS total,
|
||||
f.uid_validity,f.uid_next,f.highest_modseq
|
||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role
|
||||
ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END, f.name`, mb.ID)
|
||||
@@ -96,7 +99,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
items := []MailFolder{}
|
||||
for rows.Next() {
|
||||
var f MailFolder
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount); err != nil {
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||
return
|
||||
}
|
||||
@@ -154,7 +157,7 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe
|
||||
args = append(args, like, like, like, like, like)
|
||||
}
|
||||
args = append(args, limit+1, offset)
|
||||
query := `SELECT m.id,m.mailbox_id,m.folder_id,COALESCE(f.name,''),m.message_uid,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
query := `SELECT m.id,m.mailbox_id,m.folder_id,COALESCE(f.name,''),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE ` + where + ` ORDER BY m.received_at DESC LIMIT ? OFFSET ?`
|
||||
rows, err := a.db.QueryContext(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
@@ -328,7 +331,15 @@ func (a *App) handleMailMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("markRead") != "0" && !msg.IsRead && userHasPermission(currentUser(r), PermissionMailOrganize) {
|
||||
_, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
|
||||
read := true
|
||||
if err := a.updateMessageMaildirFlags(r.Context(), msg.ID, &read, nil); err != nil {
|
||||
a.log.Warn("failed to update maildir read flag", "message_id", msg.ID, "error", err)
|
||||
}
|
||||
if modSeq, err := a.updateMessageModSeq(r.Context(), msg.ID, msg.FolderID); err == nil && modSeq > 0 {
|
||||
_, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, imap_modseq=?, updated_at=? WHERE id=?`, modSeq, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
|
||||
} else {
|
||||
_, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
|
||||
}
|
||||
msg.IsRead = true
|
||||
}
|
||||
respondJSON(w, http.StatusOK, msg)
|
||||
@@ -420,6 +431,10 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errMailboxQuotaExceeded) {
|
||||
respondError(w, http.StatusInsufficientStorage, err.Error())
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -431,6 +446,7 @@ var errInvalidMIME = errors.New("invalid mime message")
|
||||
var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit")
|
||||
var errSMTPRateLimited = errors.New("smtp send rate limit exceeded")
|
||||
var errSenderNotAuthorized = errors.New("sender address is not authorized")
|
||||
var errMailboxQuotaExceeded = errors.New("mailbox quota exceeded")
|
||||
|
||||
func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
|
||||
if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil {
|
||||
@@ -480,6 +496,10 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store sent message: %w", err)
|
||||
}
|
||||
if err := a.writeRawMessageToMaildir(ctx, sentID, mimeBytes, false); err != nil {
|
||||
a.deleteMessage(ctx, sentID)
|
||||
return nil, fmt.Errorf("failed to store sent message in maildir: %w", err)
|
||||
}
|
||||
a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients})
|
||||
if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: messageID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients, MIMEBytes: mimeBytes, Now: now}); err != nil {
|
||||
a.deleteMessage(ctx, sentID)
|
||||
@@ -503,7 +523,9 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
|
||||
copyMsg.RecipientAddr = normalizeEmail(rcpt)
|
||||
copyMsg.MessageUID = newID("uid")
|
||||
copyMsg.IsRead = false
|
||||
_, _ = a.insertMessage(ctx, copyMsg, req.Attachments)
|
||||
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if rcptMailbox.Status != "active" {
|
||||
@@ -514,7 +536,9 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
|
||||
copyMsg.RecipientAddr = normalizeEmail(rcpt)
|
||||
copyMsg.MessageUID = newID("uid")
|
||||
copyMsg.IsRead = false
|
||||
_, _ = a.insertMessage(ctx, copyMsg, req.Attachments)
|
||||
if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -528,6 +552,7 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
|
||||
copyMsg.MessageUID = newID("uid")
|
||||
copyMsg.IsRead = false
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -767,6 +792,11 @@ func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save draft")
|
||||
return
|
||||
}
|
||||
if err := a.writeStoredMessageToMaildir(r.Context(), draftID, stored, attachments); err != nil {
|
||||
a.deleteMessage(r.Context(), draftID)
|
||||
respondError(w, http.StatusInternalServerError, "failed to save draft")
|
||||
return
|
||||
}
|
||||
msg, _ := a.messageByID(r.Context(), draftID, true)
|
||||
respondJSON(w, http.StatusCreated, msg)
|
||||
return
|
||||
@@ -796,8 +826,13 @@ func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(size_bytes),0) FROM attachments WHERE message_id=?`, draftID).Scan(&attachmentBytes)
|
||||
size += attachmentBytes
|
||||
}
|
||||
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET subject=?,to_addrs=?,cc_addrs=?,bcc_addrs=?,sent_at=?,received_at=?,snippet=?,body_text=?,body_html=?,is_read=1,has_attachments=?,size_bytes=?,updated_at=? WHERE id=?`,
|
||||
subject, jsonEncode(compose.To), jsonEncode(compose.CC), jsonEncode(compose.BCC), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), snippetFrom(compose.Text, compose.HTML), compose.Text, compose.HTML, boolInt(hasAttachments), size, now.Format(time.RFC3339Nano), draftID)
|
||||
modSeq, err := a.updateMessageModSeq(r.Context(), draftID, existing.FolderID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update draft")
|
||||
return
|
||||
}
|
||||
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET subject=?,to_addrs=?,cc_addrs=?,bcc_addrs=?,sent_at=?,received_at=?,snippet=?,body_text=?,body_html=?,is_read=1,has_attachments=?,size_bytes=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`,
|
||||
subject, jsonEncode(compose.To), jsonEncode(compose.CC), jsonEncode(compose.BCC), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), snippetFrom(compose.Text, compose.HTML), compose.Text, compose.HTML, boolInt(hasAttachments), size, modSeq, modSeq, now.Format(time.RFC3339Nano), draftID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update draft")
|
||||
return
|
||||
@@ -810,6 +845,10 @@ func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := a.rewriteMessageMaildir(r.Context(), draftID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update draft")
|
||||
return
|
||||
}
|
||||
msg, _ := a.messageByID(r.Context(), draftID, true)
|
||||
respondJSON(w, http.StatusOK, msg)
|
||||
}
|
||||
@@ -820,11 +859,13 @@ func (a *App) handleDeleteDraft(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "draft not found")
|
||||
return
|
||||
}
|
||||
a.deleteMessageMaildirFile(r.Context(), msg.ID)
|
||||
a.deleteMessageFiles(r.Context(), msg.ID)
|
||||
if _, err := a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete draft")
|
||||
return
|
||||
}
|
||||
_, _ = a.bumpFolderModSeq(r.Context(), msg.FolderID)
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
@@ -873,6 +914,214 @@ func (a *App) handleScheduledSends(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||
cursor, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
|
||||
if cursor < 0 {
|
||||
cursor = 0
|
||||
}
|
||||
limit := 30
|
||||
args := []any{user.ID, mb.ID}
|
||||
where := `mb.user_id=? AND sq.mailbox_id=?`
|
||||
if status != "" {
|
||||
if !validSendQueueStatus(status) {
|
||||
badRequest(w, errors.New("invalid send queue status"))
|
||||
return
|
||||
}
|
||||
where += ` AND sq.status=?`
|
||||
args = append(args, status)
|
||||
}
|
||||
args = append(args, limit+1, cursor)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
|
||||
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id WHERE `+where+` ORDER BY sq.created_at DESC, sq.id DESC LIMIT ? OFFSET ?`, args...)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send queue")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []SendQueueEntry{}
|
||||
for rows.Next() {
|
||||
item, err := scanSendQueueEntry(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan send queue")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send queue")
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
next = strconv.Itoa(cursor + limit)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func (a *App) handleSendQueueAudit(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if !a.sendQueueBelongsToUser(r.Context(), id, user.ID) {
|
||||
respondError(w, http.StatusNotFound, "send queue item not found")
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,queue_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at
|
||||
FROM send_audit_events WHERE queue_id=? ORDER BY created_at ASC, id ASC`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send audit")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []SendAuditEvent{}
|
||||
for rows.Next() {
|
||||
var item SendAuditEvent
|
||||
var recipientsJSON, createdAt string
|
||||
if err := rows.Scan(&item.ID, &item.QueueID, &item.MailboxID, &item.SentMessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan send audit")
|
||||
return
|
||||
}
|
||||
item.Recipients = jsonDecodeSlice(recipientsJSON)
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send audit")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) handleRetrySendQueue(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
item, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "send queue item not found")
|
||||
return
|
||||
}
|
||||
if item.Status != sendQueueStatusFailed {
|
||||
badRequest(w, errors.New("send queue item is not failed"))
|
||||
return
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=? AND EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=send_queue.mailbox_id AND mb.user_id=?)`,
|
||||
sendQueueStatusQueued, now, now, id, sendQueueStatusFailed, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to retry send queue item")
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
respondError(w, http.StatusNotFound, "send queue item not found")
|
||||
return
|
||||
}
|
||||
a.deleteSendQueueDeliveredMarker(id)
|
||||
a.recordSendAudit(r.Context(), sendAuditRetry, sendQueueStatusQueued, sendAuditInput{
|
||||
QueueID: item.ID,
|
||||
UserID: user.ID,
|
||||
MailboxID: item.MailboxID,
|
||||
SentMessageID: item.SentMessageID,
|
||||
Source: item.Source,
|
||||
MailFrom: item.MailFrom,
|
||||
HeaderFrom: item.HeaderFrom,
|
||||
Recipients: item.Recipients,
|
||||
})
|
||||
updated, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send queue item")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (a *App) handleCancelSendQueue(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
item, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "send queue item not found")
|
||||
return
|
||||
}
|
||||
if item.Status != sendQueueStatusQueued && item.Status != sendQueueStatusFailed {
|
||||
badRequest(w, errors.New("send queue item cannot be canceled"))
|
||||
return
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,last_error='',updated_at=? WHERE id=? AND status IN (?,?) AND EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=send_queue.mailbox_id AND mb.user_id=?)`,
|
||||
sendQueueStatusCanceled, now, id, sendQueueStatusQueued, sendQueueStatusFailed, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to cancel send queue item")
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
respondError(w, http.StatusNotFound, "send queue item not found")
|
||||
return
|
||||
}
|
||||
a.deleteSendQueueDeliveredMarker(id)
|
||||
a.recordSendAudit(r.Context(), sendAuditCanceled, sendQueueStatusCanceled, sendAuditInput{
|
||||
QueueID: item.ID,
|
||||
UserID: user.ID,
|
||||
MailboxID: item.MailboxID,
|
||||
SentMessageID: item.SentMessageID,
|
||||
Source: item.Source,
|
||||
MailFrom: item.MailFrom,
|
||||
HeaderFrom: item.HeaderFrom,
|
||||
Recipients: item.Recipients,
|
||||
})
|
||||
updated, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send queue item")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
type sendQueueEntryScanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanSendQueueEntry(row sendQueueEntryScanner) (SendQueueEntry, error) {
|
||||
var item SendQueueEntry
|
||||
var recipientsJSON, nextAttemptAt, createdAt, updatedAt string
|
||||
var deliveredAt sql.NullString
|
||||
err := row.Scan(&item.ID, &item.MailboxID, &item.SentMessageID, &item.MessageID, &item.Subject, &item.Source, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Status, &item.AttemptCount, &item.MaxAttempts, &nextAttemptAt, &item.LastError, &createdAt, &updatedAt, &deliveredAt)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
item.Recipients = jsonDecodeSlice(recipientsJSON)
|
||||
item.NextAttemptAt = parseTime(nextAttemptAt)
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
item.UpdatedAt = parseTime(updatedAt)
|
||||
item.DeliveredAt = nullableTime(deliveredAt)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (a *App) loadSendQueueEntryForUser(ctx context.Context, id, userID string) (SendQueueEntry, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
|
||||
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id WHERE sq.id=? AND mb.user_id=?`, id, userID)
|
||||
return scanSendQueueEntry(row)
|
||||
}
|
||||
|
||||
func (a *App) sendQueueBelongsToUser(ctx context.Context, id, userID string) bool {
|
||||
var count int
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id WHERE sq.id=? AND mb.user_id=?`, id, userID).Scan(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func validSendQueueStatus(status string) bool {
|
||||
switch status {
|
||||
case sendQueueStatusQueued, sendQueueStatusSending, sendQueueStatusDelivered, sendQueueStatusFailed, sendQueueStatusCanceled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleScheduleSend(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
MailboxID string `json:"mailboxId"`
|
||||
@@ -1081,8 +1330,7 @@ func (a *App) processScheduledSend(ctx context.Context, id, mailboxID, draftID,
|
||||
return
|
||||
}
|
||||
if draftID != "" {
|
||||
a.deleteMessageFiles(ctx, draftID)
|
||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, draftID)
|
||||
a.deleteMessage(ctx, draftID)
|
||||
}
|
||||
sentAt := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE scheduled_sends SET status='sent',sent_at=?,updated_at=?,error='' WHERE id=?`, sentAt, sentAt, id); err != nil {
|
||||
@@ -1120,7 +1368,16 @@ func (a *App) handleMarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Read != nil {
|
||||
read = *req.Read
|
||||
}
|
||||
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=?, updated_at=? WHERE id=?`, boolInt(read), a.now().UTC().Format(time.RFC3339Nano), msg.ID)
|
||||
if err := a.updateMessageMaildirFlags(r.Context(), msg.ID, &read, nil); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update message")
|
||||
return
|
||||
}
|
||||
modSeq, err := a.updateMessageModSeq(r.Context(), msg.ID, msg.FolderID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update message")
|
||||
return
|
||||
}
|
||||
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=?, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, boolInt(read), modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update message")
|
||||
return
|
||||
@@ -1142,7 +1399,16 @@ func (a *App) handleStar(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Starred != nil {
|
||||
starred = *req.Starred
|
||||
}
|
||||
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_starred=?, updated_at=? WHERE id=?`, boolInt(starred), a.now().UTC().Format(time.RFC3339Nano), msg.ID)
|
||||
if err := a.updateMessageMaildirFlags(r.Context(), msg.ID, nil, &starred); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update message")
|
||||
return
|
||||
}
|
||||
modSeq, err := a.updateMessageModSeq(r.Context(), msg.ID, msg.FolderID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update message")
|
||||
return
|
||||
}
|
||||
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_starred=?, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, boolInt(starred), modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update message")
|
||||
return
|
||||
@@ -1168,8 +1434,7 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folder")
|
||||
return
|
||||
}
|
||||
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
|
||||
if err != nil {
|
||||
if err := a.moveMessageMaildir(r.Context(), msg.ID, folderID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to move message")
|
||||
return
|
||||
}
|
||||
@@ -1183,14 +1448,20 @@ func (a *App) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(msg.Folder, "Trash") {
|
||||
a.deleteMessageMaildirFile(r.Context(), msg.ID)
|
||||
a.deleteMessageFiles(r.Context(), msg.ID)
|
||||
_, err = a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID)
|
||||
if err == nil {
|
||||
_, _ = a.bumpFolderModSeq(r.Context(), msg.FolderID)
|
||||
}
|
||||
} else {
|
||||
trashID, e := a.ensureFolder(r.Context(), msg.MailboxID, "Trash")
|
||||
if e != nil {
|
||||
err = e
|
||||
} else {
|
||||
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, trashID, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
|
||||
if e := a.moveMessageMaildir(r.Context(), msg.ID, trashID); e != nil {
|
||||
err = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
@@ -1316,7 +1587,7 @@ func (a *App) loadMessageForRequest(r *http.Request, id string, includeBody bool
|
||||
}
|
||||
|
||||
func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*MailMessage, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
row := a.db.QueryRowContext(ctx, `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes,COALESCE(m.auth_results,''),COALESCE(m.auth_spf,'unknown'),COALESCE(m.auth_dkim,'unknown'),COALESCE(m.auth_dmarc,'unknown'),COALESCE(m.received_spf,'')
|
||||
FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id)
|
||||
msg, err := scanMessageFull(row, includeBody)
|
||||
if err != nil {
|
||||
@@ -1359,6 +1630,9 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
|
||||
size += int64(len(decoded))
|
||||
}
|
||||
}
|
||||
if err := a.ensureMailboxQuotaAvailable(ctx, db, msg.MailboxID, size); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var mailboxID, folderID any
|
||||
if strings.TrimSpace(msg.MailboxID) != "" {
|
||||
mailboxID = msg.MailboxID
|
||||
@@ -1366,9 +1640,18 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
|
||||
if strings.TrimSpace(msg.FolderID) != "" {
|
||||
folderID = msg.FolderID
|
||||
}
|
||||
imapUID, imapModSeq := int64(0), int64(1)
|
||||
if strings.TrimSpace(msg.FolderID) != "" {
|
||||
meta, err := a.nextIMAPMetadata(ctx, db, msg.FolderID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
imapUID, imapModSeq = meta.UID, meta.ModSeq
|
||||
}
|
||||
recipientAddr := normalizeEmail(msg.RecipientAddr)
|
||||
_, err := db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, mailboxID, folderID, recipientAddr, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, msg.FromName, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, msg.RawPath, now, now)
|
||||
auth := normalizeMailAuthentication(msg.Authentication)
|
||||
_, err := db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,auth_results,auth_spf,auth_dkim,auth_dmarc,received_spf,raw_path,imap_uid,imap_modseq,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, mailboxID, folderID, recipientAddr, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, msg.FromName, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, auth.AuthenticationResults, auth.SPF, auth.DKIM, auth.DMARC, auth.ReceivedSPF, msg.RawPath, imapUID, imapModSeq, now, now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1381,6 +1664,33 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (a *App) ensureMailboxQuotaAvailable(ctx context.Context, db dbExecutor, mailboxID string, addBytes int64) error {
|
||||
mailboxID = strings.TrimSpace(mailboxID)
|
||||
if mailboxID == "" || addBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
rowDB, ok := db.(dbQueryer)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var quotaMB int64
|
||||
if err := rowDB.QueryRowContext(ctx, `SELECT quota_mb FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan("aMB); err != nil {
|
||||
return err
|
||||
}
|
||||
if quotaMB <= 0 {
|
||||
return nil
|
||||
}
|
||||
var used int64
|
||||
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_id=?`, mailboxID).Scan(&used); err != nil {
|
||||
return err
|
||||
}
|
||||
quotaBytes := quotaMB * 1024 * 1024
|
||||
if used+addBytes > quotaBytes {
|
||||
return fmt.Errorf("%w: used %d bytes, adding %d bytes exceeds %d bytes", errMailboxQuotaExceeded, used, addBytes, quotaBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) storeAttachment(ctx context.Context, messageID string, input AttachmentInput) error {
|
||||
return a.storeAttachmentWithDB(ctx, a.db, messageID, input)
|
||||
}
|
||||
@@ -1578,8 +1888,14 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
|
||||
}
|
||||
|
||||
func (a *App) deleteMessage(ctx context.Context, messageID string) {
|
||||
var folderID sql.NullString
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID)
|
||||
a.deleteMessageMaildirFile(ctx, messageID)
|
||||
a.deleteMessageFiles(ctx, messageID)
|
||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, messageID)
|
||||
if folderID.Valid && folderID.String != "" {
|
||||
_, _ = a.bumpFolderModSeq(ctx, folderID.String)
|
||||
}
|
||||
}
|
||||
|
||||
type messageSummaryScanner interface{ Scan(dest ...any) error }
|
||||
@@ -1588,7 +1904,7 @@ func scanAdminMessageSummary(row messageSummaryScanner) (MailMessage, error) {
|
||||
var msg MailMessage
|
||||
var toJSON, ccJSON, bccJSON, sent, received string
|
||||
var read, starred, hasAtt int
|
||||
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.MailboxAddress, &msg.OwnerEmail, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes)
|
||||
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.MailboxAddress, &msg.OwnerEmail, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes)
|
||||
if err != nil {
|
||||
return msg, err
|
||||
}
|
||||
@@ -1602,7 +1918,7 @@ func scanMessageSummary(row messageSummaryScanner) (MailMessage, error) {
|
||||
var msg MailMessage
|
||||
var toJSON, ccJSON, bccJSON, sent, received string
|
||||
var read, starred, hasAtt int
|
||||
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes)
|
||||
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes)
|
||||
if err != nil {
|
||||
return msg, err
|
||||
}
|
||||
@@ -1615,17 +1931,109 @@ func scanMessageSummary(row messageSummaryScanner) (MailMessage, error) {
|
||||
func scanMessageFull(row messageSummaryScanner, includeBody bool) (MailMessage, error) {
|
||||
var msg MailMessage
|
||||
var toJSON, ccJSON, bccJSON, sent, received string
|
||||
var auth MailAuthentication
|
||||
var read, starred, hasAtt int
|
||||
var bodyText, bodyHTML string
|
||||
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes)
|
||||
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes, &auth.AuthenticationResults, &auth.SPF, &auth.DKIM, &auth.DMARC, &auth.ReceivedSPF)
|
||||
if err != nil {
|
||||
return msg, err
|
||||
}
|
||||
msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON)
|
||||
msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received)
|
||||
msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt)
|
||||
msg.Authentication = normalizeMailAuthentication(auth)
|
||||
if includeBody {
|
||||
msg.BodyText, msg.BodyHTML = bodyText, bodyHTML
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func parseMailAuthentication(header textproto.MIMEHeader) MailAuthentication {
|
||||
authResults := strings.Join(header.Values("Authentication-Results"), "\n")
|
||||
receivedSPF := strings.Join(header.Values("Received-SPF"), "\n")
|
||||
auth := MailAuthentication{
|
||||
AuthenticationResults: strings.TrimSpace(authResults),
|
||||
ReceivedSPF: strings.TrimSpace(receivedSPF),
|
||||
SPF: "unknown",
|
||||
DKIM: "unknown",
|
||||
DMARC: "unknown",
|
||||
}
|
||||
for _, value := range header.Values("Authentication-Results") {
|
||||
for _, field := range strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ';' || r == '\r' || r == '\n'
|
||||
}) {
|
||||
key, result, ok := authMethodResult(field)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "spf":
|
||||
auth.SPF = result
|
||||
case "dkim":
|
||||
auth.DKIM = result
|
||||
case "dmarc":
|
||||
auth.DMARC = result
|
||||
}
|
||||
}
|
||||
}
|
||||
if auth.SPF == "unknown" {
|
||||
for _, value := range header.Values("Received-SPF") {
|
||||
if result := firstAuthStatus(value); result != "unknown" {
|
||||
auth.SPF = result
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalizeMailAuthentication(auth)
|
||||
}
|
||||
|
||||
func authMethodResult(value string) (string, string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.SplitN(value, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
method := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
if method != "spf" && method != "dkim" && method != "dmarc" {
|
||||
return "", "", false
|
||||
}
|
||||
return method, firstAuthStatus(parts[1]), true
|
||||
}
|
||||
|
||||
func firstAuthStatus(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "unknown"
|
||||
}
|
||||
value = strings.ToLower(strings.Fields(value)[0])
|
||||
if idx := strings.IndexAny(value, "();,"); idx >= 0 {
|
||||
value = value[:idx]
|
||||
}
|
||||
switch value {
|
||||
case "pass", "fail", "softfail", "neutral", "temperror", "permerror", "none":
|
||||
return value
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMailAuthentication(auth MailAuthentication) MailAuthentication {
|
||||
auth.AuthenticationResults = strings.TrimSpace(auth.AuthenticationResults)
|
||||
auth.ReceivedSPF = strings.TrimSpace(auth.ReceivedSPF)
|
||||
auth.SPF = normalizeAuthStatus(auth.SPF)
|
||||
auth.DKIM = normalizeAuthStatus(auth.DKIM)
|
||||
auth.DMARC = normalizeAuthStatus(auth.DMARC)
|
||||
return auth
|
||||
}
|
||||
|
||||
func normalizeAuthStatus(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "pass", "fail", "softfail", "neutral", "temperror", "permerror", "none":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxMaildirRecentErrors = 10
|
||||
|
||||
type maildirSyncCounts struct {
|
||||
FilesScanned int `json:"filesScanned"`
|
||||
Imported int `json:"imported"`
|
||||
Backfilled int `json:"backfilled"`
|
||||
Cleaned int `json:"cleaned"`
|
||||
FileErrors int `json:"fileErrors"`
|
||||
fileErrorDetails []string `json:"-"`
|
||||
}
|
||||
|
||||
func (c maildirSyncCounts) total() int {
|
||||
return c.Imported + c.Backfilled + c.Cleaned
|
||||
}
|
||||
|
||||
type maildirSyncRun struct {
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Counts maildirSyncCounts `json:"counts"`
|
||||
}
|
||||
|
||||
type maildirSyncHealthResponse struct {
|
||||
Configured bool `json:"configured"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Root string `json:"root"`
|
||||
ScanSeconds int `json:"scanSeconds"`
|
||||
WorkerStarted bool `json:"workerStarted"`
|
||||
Running bool `json:"running"`
|
||||
LastRun *maildirSyncRun `json:"lastRun,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
NextRunAt *time.Time `json:"nextRunAt,omitempty"`
|
||||
RecentErrors []string `json:"recentErrors"`
|
||||
Summary maildirSyncCounts `json:"summary"`
|
||||
}
|
||||
|
||||
type maildirSyncHealthTracker struct {
|
||||
mu sync.Mutex
|
||||
workerStarted bool
|
||||
running bool
|
||||
current *maildirSyncRun
|
||||
lastRun *maildirSyncRun
|
||||
lastError string
|
||||
nextRunAt *time.Time
|
||||
recentErrors []string
|
||||
summary maildirSyncCounts
|
||||
}
|
||||
|
||||
func newMaildirSyncHealthTracker() *maildirSyncHealthTracker {
|
||||
return &maildirSyncHealthTracker{}
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markWorkerStarted(nextRunAt *time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.workerStarted = true
|
||||
h.nextRunAt = cloneTimePtr(nextRunAt)
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markWorkerStopped() {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.workerStarted = false
|
||||
h.nextRunAt = nil
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markRunStarted(startedAt time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
run := &maildirSyncRun{StartedAt: startedAt.UTC(), Status: "running"}
|
||||
h.running = true
|
||||
h.current = run
|
||||
h.lastRun = cloneMaildirSyncRun(run)
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markRunFinished(finishedAt time.Time, counts maildirSyncCounts, err error, nextRunAt *time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
run := h.current
|
||||
if run == nil {
|
||||
run = &maildirSyncRun{StartedAt: finishedAt.UTC()}
|
||||
}
|
||||
finished := finishedAt.UTC()
|
||||
run.FinishedAt = &finished
|
||||
run.DurationMs = finished.Sub(run.StartedAt).Milliseconds()
|
||||
run.Counts = counts
|
||||
run.Status = "success"
|
||||
run.Error = ""
|
||||
if err != nil {
|
||||
run.Status = "error"
|
||||
run.Error = err.Error()
|
||||
h.lastError = run.Error
|
||||
h.pushRecentError(run.Error)
|
||||
} else if counts.FileErrors > 0 {
|
||||
run.Status = "partial"
|
||||
if len(counts.fileErrorDetails) > 0 {
|
||||
run.Error = counts.fileErrorDetails[0]
|
||||
h.lastError = run.Error
|
||||
}
|
||||
for _, detail := range counts.fileErrorDetails {
|
||||
h.pushRecentError(detail)
|
||||
}
|
||||
} else {
|
||||
h.lastError = ""
|
||||
}
|
||||
h.summary.FilesScanned += counts.FilesScanned
|
||||
h.summary.Imported += counts.Imported
|
||||
h.summary.Backfilled += counts.Backfilled
|
||||
h.summary.Cleaned += counts.Cleaned
|
||||
h.summary.FileErrors += counts.FileErrors
|
||||
h.running = false
|
||||
h.current = nil
|
||||
h.lastRun = cloneMaildirSyncRun(run)
|
||||
h.nextRunAt = cloneTimePtr(nextRunAt)
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) snapshot(cfg Config) maildirSyncHealthResponse {
|
||||
root := strings.TrimSpace(cfg.MaildirRoot)
|
||||
scanSeconds := cfg.MaildirScanSeconds
|
||||
if scanSeconds <= 0 {
|
||||
scanSeconds = 30
|
||||
}
|
||||
out := maildirSyncHealthResponse{
|
||||
Configured: root != "",
|
||||
Enabled: root != "",
|
||||
Root: root,
|
||||
ScanSeconds: scanSeconds,
|
||||
}
|
||||
if h == nil {
|
||||
return out
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out.WorkerStarted = h.workerStarted
|
||||
out.Running = h.running
|
||||
out.LastRun = cloneMaildirSyncRun(h.lastRun)
|
||||
out.LastError = h.lastError
|
||||
out.NextRunAt = cloneTimePtr(h.nextRunAt)
|
||||
out.RecentErrors = append([]string(nil), h.recentErrors...)
|
||||
out.Summary = h.summary
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) pushRecentError(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
h.recentErrors = append([]string{value}, h.recentErrors...)
|
||||
if len(h.recentErrors) > maxMaildirRecentErrors {
|
||||
h.recentErrors = h.recentErrors[:maxMaildirRecentErrors]
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMaildirSyncRun(in *maildirSyncRun) *maildirSyncRun {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
out.FinishedAt = cloneTimePtr(in.FinishedAt)
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneTimePtr(in *time.Time) *time.Time {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := in.UTC()
|
||||
return &out
|
||||
}
|
||||
|
||||
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.cfg))
|
||||
}
|
||||
@@ -49,54 +49,79 @@ func (a *App) maildirWorker(ctx context.Context) {
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
nextRunAt := a.now().UTC()
|
||||
a.maildirHealth.markWorkerStarted(&nextRunAt)
|
||||
a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
|
||||
if n, err := a.syncMaildirOnce(ctx); err != nil {
|
||||
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
|
||||
a.log.Warn("initial maildir sync failed", "error", err)
|
||||
} else if n > 0 {
|
||||
a.log.Info("initial maildir sync imported messages", "count", n)
|
||||
} else if n := counts.total(); n > 0 {
|
||||
a.log.Info("initial maildir sync processed messages", "count", n)
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.maildirHealth.markWorkerStopped()
|
||||
a.log.Info("maildir sync worker stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
n, err := a.syncMaildirOnce(ctx)
|
||||
counts, err := a.syncMaildirOnceTracked(ctx, interval)
|
||||
if err != nil {
|
||||
a.log.Warn("maildir sync failed", "error", err)
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
a.log.Info("maildir sync imported messages", "count", n)
|
||||
if n := counts.total(); n > 0 {
|
||||
a.log.Info("maildir sync processed messages", "count", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnceTracked(ctx context.Context, interval time.Duration) (maildirSyncCounts, error) {
|
||||
startedAt := a.now().UTC()
|
||||
a.maildirHealth.markRunStarted(startedAt)
|
||||
counts, err := a.syncMaildirOnceDetailed(ctx)
|
||||
finishedAt := a.now().UTC()
|
||||
var nextRunAt *time.Time
|
||||
if interval > 0 && err == nil {
|
||||
next := finishedAt.Add(interval)
|
||||
nextRunAt = &next
|
||||
}
|
||||
a.maildirHealth.markRunFinished(finishedAt, counts, err, nextRunAt)
|
||||
return counts, err
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
counts, err := a.syncMaildirOnceDetailed(ctx)
|
||||
return counts.total(), err
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
|
||||
root := strings.TrimSpace(a.cfg.MaildirRoot)
|
||||
if root == "" {
|
||||
return 0, nil
|
||||
return maildirSyncCounts{}, nil
|
||||
}
|
||||
mailboxes, err := a.maildirMailboxes(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return maildirSyncCounts{}, err
|
||||
}
|
||||
imported := 0
|
||||
counts := maildirSyncCounts{}
|
||||
for _, mb := range mailboxes {
|
||||
if mb.Unregistered {
|
||||
count, err := a.syncUnregisteredMaildir(ctx, mb)
|
||||
mbCounts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
|
||||
counts.FilesScanned += mbCounts.FilesScanned
|
||||
counts.Imported += mbCounts.Imported
|
||||
counts.FileErrors += mbCounts.FileErrors
|
||||
counts.fileErrorDetails = append(counts.fileErrorDetails, mbCounts.fileErrorDetails...)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
imported += count
|
||||
continue
|
||||
}
|
||||
folders, err := a.maildirFolders(ctx, mb.ID)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
base := filepath.Join(root, mb.Domain, mb.LocalPart, "Maildir")
|
||||
for _, folder := range folders {
|
||||
@@ -104,7 +129,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
for _, sub := range []string{"new", "cur"} {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return imported, ctx.Err()
|
||||
return counts, ctx.Err()
|
||||
default:
|
||||
}
|
||||
dir := filepath.Join(folderBase, sub)
|
||||
@@ -113,26 +138,39 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
counts.FilesScanned++
|
||||
ok, err := a.syncMaildirFile(ctx, mb, folder, path)
|
||||
if err != nil {
|
||||
counts.FileErrors++
|
||||
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
|
||||
a.log.Warn("maildir file import failed", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
imported++
|
||||
counts.Imported++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return imported, nil
|
||||
backfilled, err := a.backfillSQLiteMessagesToMaildir(ctx)
|
||||
if err != nil {
|
||||
return counts, err
|
||||
}
|
||||
counts.Backfilled += backfilled
|
||||
cleaned, err := a.cleanupMissingMaildirMessages(ctx)
|
||||
if err != nil {
|
||||
return counts, err
|
||||
}
|
||||
counts.Cleaned += cleaned
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
@@ -179,12 +217,17 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
|
||||
counts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
|
||||
return counts.Imported, err
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
|
||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
imported := 0
|
||||
counts := maildirSyncCounts{}
|
||||
for _, sub := range []string{"new", "cur"} {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return imported, ctx.Err()
|
||||
return counts, ctx.Err()
|
||||
default:
|
||||
}
|
||||
dir := filepath.Join(base, sub)
|
||||
@@ -193,24 +236,27 @@ func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (i
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
counts.FilesScanned++
|
||||
ok, err := a.syncUnregisteredMaildirFile(ctx, mb, path)
|
||||
if err != nil {
|
||||
counts.FileErrors++
|
||||
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
|
||||
a.log.Warn("unregistered maildir file import failed", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
imported++
|
||||
counts.Imported++
|
||||
}
|
||||
}
|
||||
}
|
||||
return imported, nil
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox, path string) (bool, error) {
|
||||
@@ -248,6 +294,7 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
|
||||
if exists, err := a.unregisteredMaildirMessageExists(ctx, path, msg.MessageID, msg.RecipientAddr); err != nil {
|
||||
return false, err
|
||||
} else if exists {
|
||||
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
|
||||
return false, nil
|
||||
}
|
||||
_, err = a.insertMessage(ctx, msg, attachments)
|
||||
@@ -291,7 +338,7 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
||||
}
|
||||
msg.MailboxID = mb.ID
|
||||
msg.FolderID = folder.ID
|
||||
msg.IsRead = !strings.EqualFold(folder.Name, "Inbox")
|
||||
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
|
||||
msg.RawPath = path
|
||||
if msg.MessageUID == "" {
|
||||
msg.MessageUID = newID("uid")
|
||||
@@ -311,6 +358,14 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
||||
if exists, err := a.maildirMessageExists(ctx, mb.ID, folder.ID, path, msg.MessageID); err != nil {
|
||||
return false, err
|
||||
} else if exists {
|
||||
if _, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if handled, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
|
||||
return false, err
|
||||
} else if handled {
|
||||
return false, nil
|
||||
}
|
||||
id, err := a.insertMessage(ctx, msg, attachments)
|
||||
@@ -338,6 +393,210 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (a *App) attachMaildirRawPathToExisting(ctx context.Context, mailboxID, folderID, rawPath, messageID string) {
|
||||
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path=''`,
|
||||
rawPath, a.now().UTC().Format(time.RFC3339Nano), mailboxID, folderID, messageID); err != nil {
|
||||
a.log.Warn("failed to attach maildir raw path to existing message", "path", rawPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
var samePathID, oldFolderID string
|
||||
var oldRead, oldStarred int
|
||||
var oldModSeq int64
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(folder_id,''),is_read,is_starred,imap_modseq FROM messages WHERE mailbox_id=? AND raw_path=?`, mailboxID, rawPath).Scan(&samePathID, &oldFolderID, &oldRead, &oldStarred, &oldModSeq)
|
||||
if err == nil {
|
||||
if oldFolderID != folderID {
|
||||
if oldFolderID != "" {
|
||||
if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`,
|
||||
folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, samePathID)
|
||||
return err == nil, err
|
||||
}
|
||||
modSeq := oldModSeq
|
||||
if oldRead != boolInt(read) || oldStarred != boolInt(starred) {
|
||||
modSeq, err = a.bumpFolderModSeq(ctx, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,is_read=?,is_starred=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`,
|
||||
rawPath, boolInt(read), boolInt(starred), modSeq, modSeq, now, samePathID)
|
||||
return err == nil, err
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(messageID) == "" {
|
||||
return false, nil
|
||||
}
|
||||
type candidate struct {
|
||||
ID string
|
||||
RawPath string
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE mailbox_id=? AND message_id=? AND message_id <> '' ORDER BY CASE WHEN folder_id=? THEN 0 ELSE 1 END, created_at`, mailboxID, messageID, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var chosen candidate
|
||||
for rows.Next() {
|
||||
var c candidate
|
||||
if err := rows.Scan(&c.ID, &c.RawPath); err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
if c.RawPath == "" || c.RawPath == rawPath {
|
||||
chosen = c
|
||||
break
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(c.RawPath)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
if ok {
|
||||
if _, err := os.Stat(c.RawPath); errors.Is(err, os.ErrNotExist) {
|
||||
chosen = c
|
||||
break
|
||||
} else if err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if chosen.ID == "" {
|
||||
a.removeDuplicateMaildirMessage(ctx, rawPath, mailboxID, folderID, messageID)
|
||||
return false, nil
|
||||
}
|
||||
var previousFolderID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(folder_id,'') FROM messages WHERE id=?`, chosen.ID).Scan(&previousFolderID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if previousFolderID != "" && previousFolderID != folderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, previousFolderID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, chosen.ID)
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailboxID, folderID, messageID string) {
|
||||
var existing string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path<>'' LIMIT 1`, mailboxID, folderID, messageID).Scan(&existing)
|
||||
if err != nil || existing == "" || existing == rawPath {
|
||||
return
|
||||
}
|
||||
a.removeMaildirPath(ctx, rawPath)
|
||||
}
|
||||
|
||||
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND raw_path<>'' AND updated_at<?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
type item struct {
|
||||
ID string
|
||||
RawPath string
|
||||
}
|
||||
var missing []item
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.ID, &it.RawPath); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(it.RawPath)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(it.RawPath); errors.Is(err, os.ErrNotExist) {
|
||||
missing = append(missing, it)
|
||||
} else if err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, it := range missing {
|
||||
a.deleteMessageFiles(ctx, it.ID)
|
||||
var folderID sql.NullString
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, it.ID).Scan(&folderID)
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, it.ID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if folderID.Valid && folderID.String != "" {
|
||||
if _, err := a.bumpFolderModSeq(ctx, folderID.String); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(missing), nil
|
||||
}
|
||||
|
||||
func maildirFlagsFromPath(path, folderName string) (bool, bool) {
|
||||
base := filepath.Base(path)
|
||||
flags := ""
|
||||
hasFlags := false
|
||||
for _, sep := range []string{maildirFlagSeparator(), ":2,", "!2,"} {
|
||||
if idx := strings.LastIndex(base, sep); idx >= 0 {
|
||||
flags = base[idx+len(sep):]
|
||||
hasFlags = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasFlags {
|
||||
return strings.ContainsRune(flags, 'S'), strings.ContainsRune(flags, 'F')
|
||||
}
|
||||
return !strings.EqualFold(folderName, "Inbox"), false
|
||||
}
|
||||
|
||||
func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, rawPath, messageID, recipient string) {
|
||||
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id IS NULL AND recipient_addr=? AND message_id=? AND message_id <> '' AND raw_path=''`,
|
||||
rawPath, a.now().UTC().Format(time.RFC3339Nano), recipient, messageID); err != nil {
|
||||
a.log.Warn("failed to attach unregistered maildir raw path to existing message", "path", rawPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
|
||||
domain = normalizeDomain(domain)
|
||||
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
|
||||
@@ -382,19 +641,20 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
||||
receivedAt = sentAt
|
||||
}
|
||||
return storedMessage{
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
||||
Subject: subject,
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
To: to,
|
||||
CC: cc,
|
||||
SentAt: sentAt,
|
||||
ReceivedAt: receivedAt,
|
||||
Snippet: snippetFrom(bodyText, bodyHTML),
|
||||
BodyText: bodyText,
|
||||
BodyHTML: bodyHTML,
|
||||
IsRead: false,
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
||||
Subject: subject,
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
To: to,
|
||||
CC: cc,
|
||||
SentAt: sentAt,
|
||||
ReceivedAt: receivedAt,
|
||||
Snippet: snippetFrom(bodyText, bodyHTML),
|
||||
BodyText: bodyText,
|
||||
BodyHTML: bodyHTML,
|
||||
IsRead: false,
|
||||
Authentication: parseMailAuthentication(textproto.MIMEHeader(m.Header)),
|
||||
}, parsed.Attachments, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
|
||||
return nil
|
||||
}
|
||||
raw, err := 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,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildir(ctx, messageID, raw, false)
|
||||
}
|
||||
|
||||
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
msg, err := a.storedMessageByID(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := 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,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildir(ctx, messageID, raw, true)
|
||||
}
|
||||
|
||||
func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, raw []byte, replace bool) error {
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildirFolder(ctx, messageID, state.FolderID, raw, replace, false)
|
||||
}
|
||||
|
||||
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if folderID != "" {
|
||||
oldFolderID := state.FolderID
|
||||
state.FolderID = folderID
|
||||
if updateFolder && oldFolderID != "" && oldFolderID != state.FolderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if state.MailboxID == "" || state.FolderID == "" {
|
||||
return nil
|
||||
}
|
||||
if !replace && state.RawPath != "" {
|
||||
if ok, err := a.pathIsUnderMaildirRoot(state.RawPath); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
if _, err := os.Stat(state.RawPath); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folderName, err := a.folderNameByID(ctx, state.FolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
folderBase := maildirFolderPath(base, folderName)
|
||||
subdir := "cur"
|
||||
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
|
||||
subdir = "new"
|
||||
}
|
||||
if err := ensureMaildirFolderDirs(folderBase); err != nil {
|
||||
return err
|
||||
}
|
||||
filename := maildirFilename(messageID, state.MessageID)
|
||||
tmpPath := filepath.Join(folderBase, "tmp", filename)
|
||||
finalPath := filepath.Join(folderBase, subdir, filename)
|
||||
finalPath = maildirPathWithFlags(finalPath, state.IsRead, state.IsStarred)
|
||||
if err := os.WriteFile(tmpPath, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, finalPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if replace || state.RawPath != "" {
|
||||
a.removeMaildirPath(ctx, state.RawPath)
|
||||
}
|
||||
if updateFolder {
|
||||
if state.IMAPUID > 0 && folderID == "" {
|
||||
modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, state.FolderID, finalPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
} else {
|
||||
meta, metaErr := a.nextIMAPMetadata(ctx, a.db, state.FolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, state.FolderID, finalPath, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
}
|
||||
} else {
|
||||
modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, finalPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
state, stateErr := a.maildirMessageState(ctx, messageID)
|
||||
if stateErr != nil {
|
||||
return stateErr
|
||||
}
|
||||
if state.FolderID != "" && state.FolderID != targetFolderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
meta, metaErr := a.nextIMAPMetadata(ctx, a.db, targetFolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
return err
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.MailboxID == "" {
|
||||
return nil
|
||||
}
|
||||
state.FolderID = targetFolderID
|
||||
if state.RawPath == "" {
|
||||
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
|
||||
}
|
||||
if _, err := os.Stat(state.RawPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folderName, err := a.folderNameByID(ctx, targetFolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
folderBase := maildirFolderPath(base, folderName)
|
||||
if err := ensureMaildirFolderDirs(folderBase); err != nil {
|
||||
return err
|
||||
}
|
||||
subdir := "cur"
|
||||
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
|
||||
subdir = "new"
|
||||
}
|
||||
targetPath := filepath.Join(folderBase, subdir, filepath.Base(state.RawPath))
|
||||
if filepath.Clean(targetPath) != filepath.Clean(state.RawPath) {
|
||||
if err := os.Rename(state.RawPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if state.FolderID != "" && state.FolderID != targetFolderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
meta, err := a.nextIMAPMetadata(ctx, a.db, targetFolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, targetPath, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) writeMessageToNewMaildirFolder(ctx context.Context, messageID, folderID string) error {
|
||||
msg, err := a.storedMessageByID(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg.FolderID = folderID
|
||||
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := 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,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildirFolder(ctx, messageID, folderID, raw, true, true)
|
||||
}
|
||||
|
||||
func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
|
||||
var rawPath string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE id=?`, messageID).Scan(&rawPath); err != nil {
|
||||
return
|
||||
}
|
||||
a.removeMaildirPath(ctx, rawPath)
|
||||
}
|
||||
|
||||
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.RawPath == "" {
|
||||
return nil
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(state.RawPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
currentRead := state.IsRead
|
||||
currentStarred := state.IsStarred
|
||||
if read != nil {
|
||||
currentRead = *read
|
||||
}
|
||||
if starred != nil {
|
||||
currentStarred = *starred
|
||||
}
|
||||
targetPath := maildirPathWithFlags(state.RawPath, currentRead, currentStarred)
|
||||
if filepath.Clean(targetPath) == filepath.Clean(state.RawPath) {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(state.RawPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
modSeq, err := a.bumpFolderModSeq(ctx, state.FolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, targetPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
|
||||
rawPath = strings.TrimSpace(rawPath)
|
||||
if rawPath == "" {
|
||||
return
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(rawPath)
|
||||
if err != nil || !ok {
|
||||
if err != nil {
|
||||
a.log.Warn("failed to validate maildir path", "path", rawPath, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := os.Remove(rawPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
a.log.Warn("failed to remove maildir message", "path", rawPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
rows.Close()
|
||||
return 0, ctx.Err()
|
||||
default:
|
||||
}
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, id := range ids {
|
||||
if err := a.rewriteMessageMaildir(ctx, id); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
type maildirMessageState struct {
|
||||
MailboxID string
|
||||
FolderID string
|
||||
MessageID string
|
||||
RawPath string
|
||||
IsRead bool
|
||||
IsStarred bool
|
||||
IMAPUID int64
|
||||
IMAPModSeq int64
|
||||
}
|
||||
|
||||
func (a *App) maildirMessageState(ctx context.Context, id string) (maildirMessageState, error) {
|
||||
var state maildirMessageState
|
||||
var mailboxID, folderID sql.NullString
|
||||
var read, starred int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT mailbox_id,folder_id,message_id,raw_path,is_read,is_starred,imap_uid,imap_modseq FROM messages WHERE id=?`, id).Scan(&mailboxID, &folderID, &state.MessageID, &state.RawPath, &read, &starred, &state.IMAPUID, &state.IMAPModSeq)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
state.MailboxID = mailboxID.String
|
||||
state.FolderID = folderID.String
|
||||
state.IsRead = intBool(read)
|
||||
state.IsStarred = intBool(starred)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (a *App) storedMessageByID(ctx context.Context, id string) (storedMessage, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT COALESCE(mailbox_id,''),COALESCE(folder_id,''),recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,raw_path FROM messages WHERE id=?`, id)
|
||||
var msg storedMessage
|
||||
var toJSON, ccJSON, bccJSON, sent, received string
|
||||
var read, starred int
|
||||
err := row.Scan(&msg.MailboxID, &msg.FolderID, &msg.RecipientAddr, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &msg.BodyText, &msg.BodyHTML, &read, &starred, &msg.RawPath)
|
||||
if err != nil {
|
||||
return msg, err
|
||||
}
|
||||
msg.To = jsonDecodeSlice(toJSON)
|
||||
msg.CC = jsonDecodeSlice(ccJSON)
|
||||
msg.BCC = jsonDecodeSlice(bccJSON)
|
||||
msg.SentAt = parseTime(sent)
|
||||
msg.ReceivedAt = parseTime(received)
|
||||
msg.IsRead = intBool(read)
|
||||
msg.IsStarred = intBool(starred)
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (a *App) attachmentInputsForMessage(ctx context.Context, messageID string) ([]AttachmentInput, error) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type,storage_path FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AttachmentInput
|
||||
for rows.Next() {
|
||||
var filename, contentType, storagePath string
|
||||
if err := rows.Scan(&filename, &contentType, &storagePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, AttachmentInput{Filename: filename, ContentType: contentType, ContentBase64: base64.StdEncoding.EncodeToString(data)})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) maildirMailboxByID(ctx context.Context, mailboxID string) (maildirMailbox, error) {
|
||||
var mb maildirMailbox
|
||||
err := a.db.QueryRowContext(ctx, `SELECT m.id,m.address,m.local_part,d.name FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE m.id=?`, mailboxID).Scan(&mb.ID, &mb.Address, &mb.LocalPart, &mb.Domain)
|
||||
return mb, err
|
||||
}
|
||||
|
||||
func (a *App) folderNameByID(ctx context.Context, folderID string) (string, error) {
|
||||
var name string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT name FROM folders WHERE id=?`, folderID).Scan(&name)
|
||||
return name, err
|
||||
}
|
||||
|
||||
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
|
||||
root := strings.TrimSpace(a.cfg.MaildirRoot)
|
||||
if root == "" || strings.TrimSpace(path) == "" {
|
||||
return false, nil
|
||||
}
|
||||
rootAbs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
pathAbs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rel, err := filepath.Rel(rootAbs, pathAbs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..", nil
|
||||
}
|
||||
|
||||
func ensureMaildirFolderDirs(folderBase string) error {
|
||||
for _, sub := range []string{"tmp", "new", "cur"} {
|
||||
if err := os.MkdirAll(filepath.Join(folderBase, sub), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maildirFilename(messageID, headerMessageID string) string {
|
||||
base := strings.TrimSpace(headerMessageID)
|
||||
if base == "" {
|
||||
base = messageID
|
||||
}
|
||||
return fmt.Sprintf("%d.%s.%s", time.Now().UnixNano(), safeMaildirName(messageID), safeMaildirName(base))
|
||||
}
|
||||
|
||||
func safeMaildirName(value string) string {
|
||||
value = strings.Trim(value, "<>")
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '.', r == '_', r == '-', r == '@':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "._-")
|
||||
if out == "" {
|
||||
out = "message"
|
||||
}
|
||||
if len(out) > 120 {
|
||||
out = out[:120]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func messageDate(msg storedMessage) time.Time {
|
||||
if !msg.SentAt.IsZero() {
|
||||
return msg.SentAt
|
||||
}
|
||||
if !msg.ReceivedAt.IsZero() {
|
||||
return msg.ReceivedAt
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func maildirPathWithFlags(path string, read, starred bool) string {
|
||||
dir := filepath.Dir(path)
|
||||
name := filepath.Base(path)
|
||||
if read || starred {
|
||||
dir = filepath.Join(filepath.Dir(dir), "cur")
|
||||
} else if filepath.Base(dir) == "cur" {
|
||||
dir = filepath.Join(filepath.Dir(dir), "new")
|
||||
}
|
||||
base := name
|
||||
sep := maildirFlagSeparator()
|
||||
existingFlags := ""
|
||||
if idx := strings.LastIndex(base, sep); idx >= 0 {
|
||||
existingFlags = base[idx+len(sep):]
|
||||
base = base[:idx]
|
||||
}
|
||||
flags := preserveMaildirFlags(existingFlags, "SF")
|
||||
if read {
|
||||
flags = appendMaildirFlag(flags, 'S')
|
||||
}
|
||||
if starred {
|
||||
flags = appendMaildirFlag(flags, 'F')
|
||||
}
|
||||
if flags != "" {
|
||||
base += sep + flags
|
||||
}
|
||||
return filepath.Join(dir, base)
|
||||
}
|
||||
|
||||
func preserveMaildirFlags(flags, managed string) string {
|
||||
var b strings.Builder
|
||||
for _, flag := range flags {
|
||||
if strings.ContainsRune(managed, flag) || strings.ContainsRune(b.String(), flag) {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(flag)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func appendMaildirFlag(flags string, flag rune) string {
|
||||
if strings.ContainsRune(flags, flag) {
|
||||
return flags
|
||||
}
|
||||
return flags + string(flag)
|
||||
}
|
||||
|
||||
func maildirFlagSeparator() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "!2,"
|
||||
}
|
||||
return ":2,"
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -466,14 +467,12 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
matchMode := strings.TrimSpace(req.MatchMode)
|
||||
if matchMode == "" {
|
||||
matchMode = "all"
|
||||
}
|
||||
if matchMode != "all" && matchMode != "any" {
|
||||
rawMatchMode := strings.ToLower(strings.TrimSpace(req.MatchMode))
|
||||
if rawMatchMode != "" && rawMatchMode != "all" && rawMatchMode != "and" && rawMatchMode != "any" && rawMatchMode != "or" {
|
||||
badRequest(w, errors.New("invalid match mode"))
|
||||
return
|
||||
}
|
||||
matchMode := normalizeRuleMatchMode(rawMatchMode)
|
||||
conditions := normalizeRuleConditions(req.Conditions, req.FromContains, req.SubjectContains)
|
||||
if len(conditions) == 0 {
|
||||
badRequest(w, errors.New("rule condition is required"))
|
||||
@@ -639,10 +638,21 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load stats")
|
||||
return
|
||||
}
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount); err != nil {
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id),COALESCE(SUM(a.size_bytes),0) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount, &stats.AttachmentBytes); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load attachment stats")
|
||||
return
|
||||
}
|
||||
if mailboxID != "" {
|
||||
var quotaMB int64
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan("aMB); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
||||
return
|
||||
}
|
||||
stats.QuotaBytes = quotaMB * 1024 * 1024
|
||||
if stats.QuotaBytes > 0 {
|
||||
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
|
||||
}
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
|
||||
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
|
||||
WHERE `+where+` GROUP BY f.id,f.name,f.role ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END`, args...)
|
||||
@@ -723,11 +733,14 @@ func (a *App) deleteMessagesInFolder(ctx context.Context, mailboxID, folder stri
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
a.deleteMessageFiles(ctx, id)
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
a.deleteMessage(ctx, id)
|
||||
}
|
||||
return int64(len(ids)), nil
|
||||
}
|
||||
@@ -741,13 +754,31 @@ func (a *App) archiveReadInbox(ctx context.Context, mailboxID string) (int64, er
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
res, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE mailbox_id=? AND folder_id=? AND is_read=1`,
|
||||
archiveID, a.now().UTC().Format(time.RFC3339Nano), mailboxID, inboxID)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND is_read=1`, mailboxID, inboxID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := a.moveMessageMaildir(ctx, id, archiveID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return int64(len(ids)), nil
|
||||
}
|
||||
|
||||
func scanContact(row messageSummaryScanner) (Contact, error) {
|
||||
@@ -832,9 +863,7 @@ func scanRule(row messageSummaryScanner) (MailRule, error) {
|
||||
if err == nil {
|
||||
item.Conditions = decodeRuleConditions(conditionsJSON, item.FromContains, item.SubjectContains)
|
||||
item.Actions = decodeRuleActions(actionsJSON, item.Action)
|
||||
if item.MatchMode == "" {
|
||||
item.MatchMode = "all"
|
||||
}
|
||||
item.MatchMode = normalizeRuleMatchMode(item.MatchMode)
|
||||
}
|
||||
item.ApplyToExisting = intBool(applyToExisting)
|
||||
item.StopProcessing = intBool(stopProcessing)
|
||||
@@ -856,13 +885,8 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT user_id FROM mailboxes WHERE id=?`, mailboxID).Scan(&userID); err != nil {
|
||||
return
|
||||
}
|
||||
from = normalizeEmail(from)
|
||||
var blocked int
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?`, userID, mailboxID, from).Scan(&blocked)
|
||||
if blocked > 0 {
|
||||
if spamID, err := a.ensureFolder(ctx, mailboxID, "Spam"); err == nil {
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, spamID, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
}
|
||||
if a.senderBlocked(ctx, userID, mailboxID, from) {
|
||||
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at`, userID, mailboxID)
|
||||
@@ -878,26 +902,94 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
||||
}
|
||||
rows.Close()
|
||||
msg := ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,subject,snippet,body_text FROM messages WHERE id=?`, messageID).Scan(&msg.From, &msg.To, &msg.Subject, &msg.Snippet, &msg.BodyText)
|
||||
msg, ok := a.ruleMessageByID(ctx, messageID)
|
||||
if !ok {
|
||||
msg = ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if !ruleMatches(rule, msg) {
|
||||
continue
|
||||
}
|
||||
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
|
||||
if rule.StopProcessing {
|
||||
return
|
||||
break
|
||||
}
|
||||
}
|
||||
if a.senderBlocked(ctx, userID, mailboxID, from) {
|
||||
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) senderBlocked(ctx context.Context, userID, mailboxID, from string) bool {
|
||||
from = normalizeEmail(from)
|
||||
if from == "" {
|
||||
return false
|
||||
}
|
||||
var blocked int
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?`, userID, mailboxID, from).Scan(&blocked)
|
||||
return blocked > 0
|
||||
}
|
||||
|
||||
func (a *App) moveBlockedMessageToSpam(ctx context.Context, messageID, mailboxID string) {
|
||||
spamID, err := a.ensureFolder(ctx, mailboxID, "Spam")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = a.moveMessageMaildir(ctx, messageID, spamID)
|
||||
}
|
||||
|
||||
type ruleMessage struct {
|
||||
ID string
|
||||
MailboxID string
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
Snippet string
|
||||
BodyText string
|
||||
ID string
|
||||
MailboxID string
|
||||
From string
|
||||
To string
|
||||
CC string
|
||||
Subject string
|
||||
Snippet string
|
||||
BodyText string
|
||||
AttachmentNames string
|
||||
SizeBytes int64
|
||||
ReceivedAt time.Time
|
||||
}
|
||||
|
||||
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
|
||||
var msg ruleMessage
|
||||
var toAddrs, ccAddrs, receivedAt string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
||||
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
|
||||
if err != nil {
|
||||
return ruleMessage{}, false
|
||||
}
|
||||
msg.To = ruleAddressText(toAddrs)
|
||||
msg.CC = ruleAddressText(ccAddrs)
|
||||
msg.ReceivedAt = parseTime(receivedAt)
|
||||
msg.AttachmentNames = a.ruleAttachmentNames(ctx, messageID)
|
||||
return msg, true
|
||||
}
|
||||
|
||||
func ruleAddressText(raw string) string {
|
||||
var items []string
|
||||
if strings.TrimSpace(raw) != "" && json.Unmarshal([]byte(raw), &items) == nil {
|
||||
return strings.Join(items, " ")
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer rows.Close()
|
||||
parts := []string{}
|
||||
for rows.Next() {
|
||||
var filename, contentType string
|
||||
if err := rows.Scan(&filename, &contentType); err != nil {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
parts = append(parts, filename, contentType)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubject string) []MailRuleCondition {
|
||||
@@ -911,26 +1003,56 @@ func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubjec
|
||||
}
|
||||
out := []MailRuleCondition{}
|
||||
for _, item := range items {
|
||||
field := strings.TrimSpace(item.Field)
|
||||
operator := strings.TrimSpace(item.Operator)
|
||||
value := strings.TrimSpace(item.Value)
|
||||
if value == "" {
|
||||
continue
|
||||
if normalized, ok := normalizeRuleCondition(item); ok {
|
||||
out = append(out, normalized)
|
||||
}
|
||||
if field != "from" && field != "to" && field != "subject" && field != "body" {
|
||||
continue
|
||||
}
|
||||
if operator == "" {
|
||||
operator = "contains"
|
||||
}
|
||||
if operator != "contains" && operator != "not-contains" && operator != "equals" && operator != "not-equals" && operator != "starts-with" && operator != "ends-with" {
|
||||
continue
|
||||
}
|
||||
out = append(out, MailRuleCondition{Field: field, Operator: operator, Value: value})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
|
||||
matchMode := normalizeRuleMatchMode(item.MatchMode)
|
||||
if len(item.Conditions) > 0 {
|
||||
children := normalizeRuleConditions(item.Conditions, "", "")
|
||||
if len(children) == 0 {
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
return MailRuleCondition{MatchMode: matchMode, Conditions: children}, true
|
||||
}
|
||||
field := strings.ToLower(strings.TrimSpace(item.Field))
|
||||
operator := strings.ToLower(strings.TrimSpace(item.Operator))
|
||||
value := strings.TrimSpace(item.Value)
|
||||
if value == "" {
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
switch field {
|
||||
case "from", "to", "cc", "subject", "body", "attachment", "size", "date":
|
||||
default:
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
if operator == "" {
|
||||
operator = "contains"
|
||||
}
|
||||
switch operator {
|
||||
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
|
||||
case "gt", "gte", "lt", "lte", "before", "after", "on":
|
||||
default:
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
|
||||
}
|
||||
|
||||
func normalizeRuleMatchMode(matchMode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(matchMode)) {
|
||||
case "any", "or":
|
||||
return "any"
|
||||
case "all", "and":
|
||||
return "all"
|
||||
default:
|
||||
return "all"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRuleAction {
|
||||
if len(items) == 0 && strings.TrimSpace(legacyAction) != "" {
|
||||
items = append(items, MailRuleAction{Type: strings.TrimSpace(legacyAction)})
|
||||
@@ -987,10 +1109,11 @@ func ruleMatches(rule MailRule, msg ruleMessage) bool {
|
||||
if len(conditions) == 0 {
|
||||
return false
|
||||
}
|
||||
matchMode := rule.MatchMode
|
||||
if matchMode == "" {
|
||||
matchMode = "all"
|
||||
}
|
||||
matchMode := normalizeRuleMatchMode(rule.MatchMode)
|
||||
return ruleConditionsMatch(conditions, matchMode, msg)
|
||||
}
|
||||
|
||||
func ruleConditionsMatch(conditions []MailRuleCondition, matchMode string, msg ruleMessage) bool {
|
||||
matched := 0
|
||||
for _, condition := range conditions {
|
||||
if ruleConditionMatches(condition, msg) {
|
||||
@@ -1006,12 +1129,17 @@ func ruleMatches(rule MailRule, msg ruleMessage) bool {
|
||||
}
|
||||
|
||||
func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
|
||||
if len(condition.Conditions) > 0 {
|
||||
return ruleConditionsMatch(condition.Conditions, normalizeRuleMatchMode(condition.MatchMode), msg)
|
||||
}
|
||||
var source string
|
||||
switch condition.Field {
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Field)) {
|
||||
case "from":
|
||||
source = msg.From
|
||||
case "to":
|
||||
source = msg.To
|
||||
case "cc":
|
||||
source = msg.CC
|
||||
case "subject":
|
||||
source = msg.Subject
|
||||
case "body":
|
||||
@@ -1019,12 +1147,18 @@ func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
|
||||
if source == "" {
|
||||
source = msg.Snippet
|
||||
}
|
||||
case "attachment":
|
||||
source = msg.AttachmentNames
|
||||
case "size":
|
||||
return ruleNumericConditionMatches(condition, msg.SizeBytes)
|
||||
case "date":
|
||||
return ruleDateConditionMatches(condition, msg.ReceivedAt)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
source = strings.ToLower(source)
|
||||
value := strings.ToLower(condition.Value)
|
||||
switch condition.Operator {
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
|
||||
case "contains":
|
||||
return strings.Contains(source, value)
|
||||
case "not-contains":
|
||||
@@ -1042,35 +1176,139 @@ func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func ruleNumericConditionMatches(condition MailRuleCondition, source int64) bool {
|
||||
value, ok := parseRuleSizeValue(condition.Value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
|
||||
case "gt":
|
||||
return source > value
|
||||
case "gte":
|
||||
return source >= value
|
||||
case "lt":
|
||||
return source < value
|
||||
case "lte":
|
||||
return source <= value
|
||||
case "equals":
|
||||
return source == value
|
||||
case "not-equals":
|
||||
return source != value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseRuleSizeValue(raw string) (int64, bool) {
|
||||
value := strings.ToLower(strings.TrimSpace(raw))
|
||||
multiplier := int64(1)
|
||||
for _, suffix := range []struct {
|
||||
text string
|
||||
multiplier int64
|
||||
}{
|
||||
{"kb", 1024},
|
||||
{"k", 1024},
|
||||
{"mb", 1024 * 1024},
|
||||
{"m", 1024 * 1024},
|
||||
{"gb", 1024 * 1024 * 1024},
|
||||
{"g", 1024 * 1024 * 1024},
|
||||
{"b", 1},
|
||||
} {
|
||||
if strings.HasSuffix(value, suffix.text) {
|
||||
multiplier = suffix.multiplier
|
||||
value = strings.TrimSpace(strings.TrimSuffix(value, suffix.text))
|
||||
break
|
||||
}
|
||||
}
|
||||
n, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n * multiplier, true
|
||||
}
|
||||
|
||||
func ruleDateConditionMatches(condition MailRuleCondition, source time.Time) bool {
|
||||
if source.IsZero() {
|
||||
return false
|
||||
}
|
||||
target, ok := parseRuleDateValue(condition.Value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
source = source.UTC()
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
|
||||
case "before", "lt":
|
||||
return source.Before(target)
|
||||
case "after", "gt":
|
||||
return source.After(target)
|
||||
case "on", "equals":
|
||||
y1, m1, d1 := source.Date()
|
||||
y2, m2, d2 := target.Date()
|
||||
return y1 == y2 && m1 == m2 && d1 == d2
|
||||
case "not-equals":
|
||||
y1, m1, d1 := source.Date()
|
||||
y2, m2, d2 := target.Date()
|
||||
return y1 != y2 || m1 != m2 || d1 != d2
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseRuleDateValue(raw string) (time.Time, bool) {
|
||||
value := strings.TrimSpace(raw)
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, value); err == nil {
|
||||
return t.UTC(), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string, actions []MailRuleAction) error {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, action := range normalizeRuleActions(actions, "") {
|
||||
switch action.Type {
|
||||
case "archive":
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "trash":
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "move":
|
||||
target := ruleTargetFolder(action.Value)
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "star":
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, updated_at=? WHERE id=?`, now, messageID); err != nil {
|
||||
starred := true
|
||||
if err := a.updateMessageMaildirFlags(ctx, messageID, nil, &starred); err != nil {
|
||||
return err
|
||||
}
|
||||
modSeq, err := a.updateMessageModSeq(ctx, messageID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, modSeq, modSeq, now, messageID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "mark-read":
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, now, messageID); err != nil {
|
||||
read := true
|
||||
if err := a.updateMessageMaildirFlags(ctx, messageID, &read, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
modSeq, err := a.updateMessageModSeq(ctx, messageID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, modSeq, modSeq, now, messageID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "label":
|
||||
@@ -1128,19 +1366,21 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
|
||||
where += ` AND m.mailbox_id=?`
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id,m.mailbox_id,trim(m.from_addr || ' ' || COALESCE(m.from_name,'')),m.to_addrs,m.subject,m.snippet,m.body_text FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
messages := []ruleMessage{}
|
||||
var count int64
|
||||
for rows.Next() {
|
||||
var msg ruleMessage
|
||||
var toAddrs sql.NullString
|
||||
if err := rows.Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText); err != nil {
|
||||
var messageID string
|
||||
if err := rows.Scan(&messageID); err != nil {
|
||||
return count, err
|
||||
}
|
||||
msg.To = toAddrs.String
|
||||
msg, ok := a.ruleMessageByID(ctx, messageID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !ruleMatches(rule, msg) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
|
||||
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue", a.handleSendQueue)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit)
|
||||
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send-queue/{id}/retry", a.handleRetrySendQueue)
|
||||
r.With(a.requirePermission(PermissionMailSend)).Delete("/mail/send-queue/{id}", a.handleCancelSendQueue)
|
||||
r.With(a.requirePermission(PermissionMailSchedule)).Get("/mail/scheduled-sends", a.handleScheduledSends)
|
||||
r.With(a.requirePermission(PermissionMailSchedule), a.requirePermission(PermissionMailSend)).Post("/mail/schedule-send", a.handleScheduleSend)
|
||||
r.With(a.requirePermission(PermissionMailSchedule)).Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
|
||||
@@ -115,9 +119,11 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionAliasesUpdate)).Post("/admin/aliases/{id}", a.handleUpdateAlias)
|
||||
r.With(a.requirePermission(PermissionAliasesDelete)).Delete("/admin/aliases/{id}", a.handleDeleteAlias)
|
||||
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/messages", a.handleAdminMessages)
|
||||
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/send-audit", a.handleAdminSendAudit)
|
||||
r.With(a.requirePermission(PermissionMessagesRead)).Get("/admin/messages/{id}", a.handleAdminMessage)
|
||||
r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment)
|
||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/settings", a.handleGetSystemSettings)
|
||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth)
|
||||
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
|
||||
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
||||
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||
|
||||
@@ -16,12 +16,14 @@ const (
|
||||
sendQueueStatusSending = "sending"
|
||||
sendQueueStatusDelivered = "delivered"
|
||||
sendQueueStatusFailed = "failed"
|
||||
sendQueueStatusCanceled = "canceled"
|
||||
|
||||
sendAuditAccepted = "accepted"
|
||||
sendAuditQueued = "queued"
|
||||
sendAuditDelivered = "delivered"
|
||||
sendAuditFailed = "failed"
|
||||
sendAuditRetry = "retry"
|
||||
sendAuditCanceled = "canceled"
|
||||
|
||||
sendSourceWebmail = "webmail"
|
||||
sendSourceSubmission = "submission"
|
||||
@@ -85,7 +87,7 @@ func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error
|
||||
return "", err
|
||||
}
|
||||
if existingID != id {
|
||||
if status == sendQueueStatusDelivered || (status == sendQueueStatusFailed && attemptCount >= maxAttempts) {
|
||||
if status == sendQueueStatusDelivered || status == sendQueueStatusCanceled || (status == sendQueueStatusFailed && attemptCount >= maxAttempts) {
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE send_queue SET user_id=?,sent_message_id=?,mail_from=?,header_from=?,recipients_json=?,mime_base64=?,status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=?`,
|
||||
in.UserID, in.SentMessageID, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), recipientsJSON, mimeBase64, sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), existingID, status)
|
||||
if err != nil {
|
||||
|
||||
@@ -241,6 +241,15 @@ func (a *App) submitSMTPMessage(ctx context.Context, user *User, mb *Mailbox, ma
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if insertedSent {
|
||||
if err := a.rewriteMessageMaildir(ctx, sentID); err != nil {
|
||||
a.deleteMessage(ctx, sentID)
|
||||
if sentFolderID, ferr := a.ensureFolder(ctx, mb.ID, "Sent"); ferr == nil {
|
||||
a.deleteSentDedupeKey(ctx, mb.ID, sentFolderID, msg.MessageID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients})
|
||||
if sentID != "" {
|
||||
if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: msg.MessageID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients, MIMEBytes: prepared, Now: a.now().UTC()}); err != nil {
|
||||
|
||||
@@ -57,11 +57,14 @@ type Alias struct {
|
||||
}
|
||||
|
||||
type MailFolder struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
UIDValidity int64 `json:"uidValidity"`
|
||||
UIDNext int64 `json:"uidNext"`
|
||||
HighestModSeq int64 `json:"highestModseq"`
|
||||
}
|
||||
|
||||
type MailLabel struct {
|
||||
@@ -73,32 +76,43 @@ type MailLabel struct {
|
||||
}
|
||||
|
||||
type MailMessage struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||
FolderID string `json:"folderId"`
|
||||
Folder string `json:"folder"`
|
||||
MessageUID string `json:"messageUid"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Snippet string `json:"snippet"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||
IsRead bool `json:"isRead"`
|
||||
IsStarred bool `json:"isStarred"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Labels []MailLabel `json:"labels,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||
FolderID string `json:"folderId"`
|
||||
Folder string `json:"folder"`
|
||||
MessageUID string `json:"messageUid"`
|
||||
IMAPUID int64 `json:"imapUid"`
|
||||
IMAPModSeq int64 `json:"imapModseq"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Snippet string `json:"snippet"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||
IsRead bool `json:"isRead"`
|
||||
IsStarred bool `json:"isStarred"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Labels []MailLabel `json:"labels,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
Authentication MailAuthentication `json:"authentication"`
|
||||
}
|
||||
|
||||
type MailAuthentication struct {
|
||||
AuthenticationResults string `json:"authenticationResults"`
|
||||
ReceivedSPF string `json:"receivedSpf"`
|
||||
SPF string `json:"spf"`
|
||||
DKIM string `json:"dkim"`
|
||||
DMARC string `json:"dmarc"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
@@ -168,9 +182,11 @@ type MailRule struct {
|
||||
}
|
||||
|
||||
type MailRuleCondition struct {
|
||||
Field string `json:"field"`
|
||||
Operator string `json:"operator"`
|
||||
Value string `json:"value"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Operator string `json:"operator,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
MatchMode string `json:"matchMode,omitempty"`
|
||||
Conditions []MailRuleCondition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
type MailRuleAction struct {
|
||||
@@ -193,7 +209,10 @@ type MailStats struct {
|
||||
UnreadMessages int64 `json:"unreadMessages"`
|
||||
StarredMessages int64 `json:"starredMessages"`
|
||||
AttachmentCount int64 `json:"attachmentCount"`
|
||||
AttachmentBytes int64 `json:"attachmentBytes"`
|
||||
StorageBytes int64 `json:"storageBytes"`
|
||||
QuotaBytes int64 `json:"quotaBytes"`
|
||||
QuotaUsedPct float64 `json:"quotaUsedPct"`
|
||||
ByFolder []MailStatsFolderCount `json:"byFolder"`
|
||||
}
|
||||
|
||||
@@ -204,3 +223,40 @@ type MailStatsFolderCount struct {
|
||||
Unread int64 `json:"unread"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
type SendQueueEntry struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
SentMessageID string `json:"sentMessageId"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
Source string `json:"source"`
|
||||
MailFrom string `json:"mailFrom"`
|
||||
HeaderFrom string `json:"headerFrom"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Status string `json:"status"`
|
||||
AttemptCount int `json:"attemptCount"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
NextAttemptAt time.Time `json:"nextAttemptAt"`
|
||||
LastError string `json:"lastError"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
|
||||
}
|
||||
|
||||
type SendAuditEvent struct {
|
||||
ID string `json:"id"`
|
||||
QueueID string `json:"queueId"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
SentMessageID string `json:"sentMessageId"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Event string `json:"event"`
|
||||
Status string `json:"status"`
|
||||
MailFrom string `json:"mailFrom"`
|
||||
HeaderFrom string `json:"headerFrom"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -56,12 +56,14 @@ export type AdminOverview = { users: number; activeUsers: number; domains: numbe
|
||||
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
|
||||
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; createdAt: string }
|
||||
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
|
||||
export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number }
|
||||
export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
|
||||
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
|
||||
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
|
||||
export type MailAuthentication = { authenticationResults: string; receivedSpf: string; spf: string; dkim: string; dmarc: string }
|
||||
export type MailMessage = {
|
||||
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
|
||||
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; imapUid: number; imapModseq: number; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
|
||||
labels?: MailLabel[]
|
||||
authentication?: MailAuthentication
|
||||
}
|
||||
export type DNSRecord = { type: string; name: string; value: string; ttl: number }
|
||||
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
|
||||
@@ -70,15 +72,73 @@ export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc:
|
||||
export type DraftPayload = Omit<SendPayload, "attachments"> & { attachments?: SendPayload["attachments"] }
|
||||
export type ScheduleSendPayload = SendPayload & { draftId?: string; sendAt: string }
|
||||
export type ScheduledSend = { id: string; mailboxId: string; draftId?: string; subject: string; to: string[]; snippet: string; sendAt: string; status: "pending" | "sending" | "sent" | "failed" | "cancelled"; error?: string; createdAt: string; updatedAt: string; sentAt?: string }
|
||||
export type SendQueueStatus = "queued" | "sending" | "delivered" | "failed" | "canceled"
|
||||
export type SendQueueItem = {
|
||||
id: string
|
||||
mailboxId: string
|
||||
sentMessageId?: string
|
||||
messageId?: string
|
||||
mailFrom?: string
|
||||
headerFrom?: string
|
||||
subject: string
|
||||
recipients: string[]
|
||||
source: string
|
||||
status: SendQueueStatus
|
||||
attemptCount: number
|
||||
maxAttempts: number
|
||||
nextAttemptAt?: string
|
||||
lastError?: string
|
||||
error?: string
|
||||
failureReason?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deliveredAt?: string
|
||||
}
|
||||
export type SendQueueAuditEvent = {
|
||||
id: string
|
||||
queueId?: string
|
||||
mailboxId?: string
|
||||
mailboxAddress?: string
|
||||
sentMessageId?: string
|
||||
messageId?: string
|
||||
source?: string
|
||||
status?: SendQueueStatus
|
||||
event?: string
|
||||
eventType?: string
|
||||
mailFrom?: string
|
||||
headerFrom?: string
|
||||
recipients?: string[]
|
||||
message?: string
|
||||
error?: string
|
||||
attemptCount?: number
|
||||
createdAt: string
|
||||
}
|
||||
export type Contact = { id: string; name: string; email: string; note: string; createdAt: string }
|
||||
export type MailSignature = { id: string; mailboxId: string; name: string; content: string; isDefault: boolean; createdAt: string; updatedAt: string }
|
||||
export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string }
|
||||
export type MailRuleConditionField = "from" | "to" | "cc" | "subject" | "body" | "attachment" | "size" | "date"
|
||||
export type MailRuleConditionOperator = "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with" | "gt" | "gte" | "lt" | "lte" | "before" | "after" | "on"
|
||||
export type MailRuleCondition = { field?: MailRuleConditionField; operator?: MailRuleConditionOperator; value?: string; matchMode?: "all" | "any"; conditions?: MailRuleCondition[] }
|
||||
export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; value?: string; labelId?: string }
|
||||
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; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
|
||||
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 MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
|
||||
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
|
||||
export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number }
|
||||
export type MaildirSyncRun = { startedAt: string; finishedAt?: string; durationMs: number; status: "running" | "success" | "partial" | "error"; error?: string; counts: MaildirSyncCounts }
|
||||
export type MaildirSyncHealth = {
|
||||
configured: boolean
|
||||
enabled: boolean
|
||||
root: string
|
||||
scanSeconds: number
|
||||
workerStarted: boolean
|
||||
running: boolean
|
||||
lastRun?: MaildirSyncRun
|
||||
lastError?: string
|
||||
nextRunAt?: string
|
||||
recentErrors: string[]
|
||||
summary: MaildirSyncCounts
|
||||
}
|
||||
export type SystemSettings = {
|
||||
publicHostname: string
|
||||
publicBaseUrl: string
|
||||
|
||||
+24
-1
@@ -1,4 +1,4 @@
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
|
||||
export * from "./api-types"
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
@@ -94,7 +94,19 @@ export const api = {
|
||||
return request<ListResponse<MailMessage>>(`/api/admin/messages${suffix ? `?${suffix}` : ""}`)
|
||||
},
|
||||
adminMessage: (id: string) => request<MailMessage>(`/api/admin/messages/${id}`),
|
||||
adminSendAudit: (params: { mailboxId?: string; messageId?: string; event?: string; from?: string; to?: string; cursor?: string } = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
|
||||
if (params.messageId) query.set("messageId", params.messageId)
|
||||
if (params.event) query.set("event", params.event)
|
||||
if (params.from) query.set("from", params.from)
|
||||
if (params.to) query.set("to", params.to)
|
||||
if (params.cursor) query.set("cursor", params.cursor)
|
||||
const suffix = query.toString()
|
||||
return request<ListResponse<SendQueueAuditEvent>>(`/api/admin/send-audit${suffix ? `?${suffix}` : ""}`)
|
||||
},
|
||||
systemSettings: () => request<SystemSettings>("/api/admin/settings"),
|
||||
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
|
||||
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
|
||||
testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
mailTemplates: () => request<ListResponse<MailTemplate>>("/api/admin/mail-templates"),
|
||||
@@ -130,6 +142,17 @@ export const api = {
|
||||
scheduledSends: (mailboxId?: string) => request<ListResponse<ScheduledSend>>(`/api/mail/scheduled-sends${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||
scheduleSend: (payload: ScheduleSendPayload) => request<ScheduledSend>("/api/mail/schedule-send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
cancelScheduledSend: (id: string) => request<{ ok: boolean }>(`/api/mail/schedule-send/${id}`, { method: "DELETE" }),
|
||||
sendQueue: (params: { mailboxId?: string; status?: SendQueueStatus | "all"; cursor?: string } = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
|
||||
if (params.status && params.status !== "all") query.set("status", params.status)
|
||||
if (params.cursor) query.set("cursor", params.cursor)
|
||||
const suffix = query.toString()
|
||||
return request<ListResponse<SendQueueItem>>(`/api/mail/send-queue${suffix ? `?${suffix}` : ""}`)
|
||||
},
|
||||
sendQueueAudit: (id: string) => request<ListResponse<SendQueueAuditEvent>>(`/api/mail/send-queue/${id}/audit`),
|
||||
retrySendQueue: (id: string) => request<SendQueueItem>(`/api/mail/send-queue/${id}/retry`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
cancelSendQueue: (id: string) => request<SendQueueItem>(`/api/mail/send-queue/${id}`, { method: "DELETE" }),
|
||||
saveDraft: (payload: DraftPayload, id?: string) => request<MailMessage>(id ? `/api/mail/drafts/${id}` : "/api/mail/drafts", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
deleteDraft: (id: string) => request<{ ok: boolean }>(`/api/mail/drafts/${id}`, { method: "DELETE" }),
|
||||
markRead: (id: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
|
||||
|
||||
@@ -2,8 +2,8 @@ import * as React from "react"
|
||||
import DOMPurify from "dompurify"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { ArrowRight, BookOpen, CheckCircle2, Circle, Copy, ExternalLink, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||||
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
||||
import { ArrowRight, BookOpen, CheckCircle2, Circle, ClipboardList, Copy, ExternalLink, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||||
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -25,7 +25,7 @@ import { useToast } from "@/hooks/use-toast"
|
||||
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
|
||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "settings"
|
||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
|
||||
const sectionLabels: Record<Section, string> = {
|
||||
@@ -36,6 +36,7 @@ const sectionLabels: Record<Section, string> = {
|
||||
mailboxes: "邮箱账号",
|
||||
aliases: "别名转发",
|
||||
messages: "全部邮件",
|
||||
sendAudit: "发送审计",
|
||||
settings: "系统设置",
|
||||
}
|
||||
const sectionKeys = Object.keys(sectionLabels) as Section[]
|
||||
@@ -47,6 +48,7 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
|
||||
mailboxes: ["admin.mailboxes.view"],
|
||||
aliases: ["admin.aliases.view"],
|
||||
messages: ["admin.messages.view"],
|
||||
sendAudit: ["admin.messages.view"],
|
||||
settings: ["admin.settings.view", "admin.templates.view"],
|
||||
}
|
||||
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email"
|
||||
@@ -108,6 +110,7 @@ export function AdminPage() {
|
||||
{section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
|
||||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />}
|
||||
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
|
||||
</main>
|
||||
</ScrollArea>
|
||||
@@ -851,6 +854,119 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
const qc = useQueryClient()
|
||||
const [mailboxId, setMailboxId] = React.useState("all")
|
||||
const [event, setEvent] = React.useState("all")
|
||||
const [messageId, setMessageId] = React.useState("")
|
||||
const [from, setFrom] = React.useState("")
|
||||
const [to, setTo] = React.useState("")
|
||||
const audit = useInfiniteQuery({
|
||||
queryKey: ["admin", "send-audit", mailboxId, event, messageId, from, to],
|
||||
queryFn: ({ pageParam }) => api.adminSendAudit({
|
||||
mailboxId: mailboxId === "all" ? "" : mailboxId,
|
||||
event: event === "all" ? "" : event,
|
||||
messageId: messageId.trim(),
|
||||
from,
|
||||
to,
|
||||
cursor: typeof pageParam === "string" ? pageParam : "",
|
||||
}),
|
||||
initialPageParam: "",
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
||||
})
|
||||
const items = audit.data?.pages.flatMap((page) => page.items || []) || []
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle className="flex items-center gap-2"><ClipboardList className="h-5 w-5" />发送审计</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ["admin", "send-audit"] })}>
|
||||
<RefreshCcw className="h-4 w-4" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_180px_180px_160px_160px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input value={messageId} onChange={(event) => setMessageId(event.target.value)} placeholder="Message-ID 或已发送邮件 ID" className="pl-9" />
|
||||
</div>
|
||||
<Select value={mailboxId} onValueChange={setMailboxId}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部邮箱</SelectItem>
|
||||
{mailboxes.map((mailbox) => <SelectItem key={mailbox.id} value={mailbox.id}>{mailbox.address}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={event} onValueChange={setEvent}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部事件</SelectItem>
|
||||
{sendAuditEvents.map((item) => <SelectItem key={item} value={item}>{sendAuditEventLabel(item)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input type="date" value={from} onChange={(event) => setFrom(event.target.value)} aria-label="开始日期" />
|
||||
<Input type="date" value={to} onChange={(event) => setTo(event.target.value)} aria-label="结束日期" />
|
||||
</div>
|
||||
<div className="space-y-3 md:hidden">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">{sendAuditEventLabel(item.event || "")}</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">{item.mailboxAddress || item.mailboxId || "-"}</div>
|
||||
</div>
|
||||
<Badge variant={sendAuditBadgeVariant(item.event)}>{item.status || item.event || "-"}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
<div className="truncate">收件人:{(item.recipients || []).join(", ") || "-"}</div>
|
||||
<div className="truncate">Message-ID:{item.messageId || item.sentMessageId || "-"}</div>
|
||||
{item.error && <div className="line-clamp-2 text-destructive">错误:{item.error}</div>}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-muted-foreground">{formatDate(item.createdAt)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>事件</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>收件人</TableHead>
|
||||
<TableHead>Message-ID</TableHead>
|
||||
<TableHead>错误</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell><Badge variant={sendAuditBadgeVariant(item.event)}>{sendAuditEventLabel(item.event || "")}</Badge></TableCell>
|
||||
<TableCell className="max-w-[220px] truncate">{item.mailboxAddress || item.mailboxId || "-"}</TableCell>
|
||||
<TableCell className="max-w-[260px] truncate" title={(item.recipients || []).join(", ")}>{(item.recipients || []).join(", ") || "-"}</TableCell>
|
||||
<TableCell className="max-w-[240px] truncate" title={item.messageId || item.sentMessageId || ""}>{item.messageId || item.sentMessageId || "-"}</TableCell>
|
||||
<TableCell className="max-w-[260px] truncate text-destructive" title={item.error || ""}>{item.error || "-"}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-muted-foreground">{formatDate(item.createdAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{audit.isLoading && <Empty text="加载中..." />}
|
||||
{!audit.isLoading && items.length === 0 && <Empty text="暂无发送审计" />}
|
||||
{!audit.isLoading && audit.hasNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" disabled={audit.isFetchingNextPage} onClick={() => audit.fetchNextPage()}>
|
||||
{audit.isFetchingNextPage ? "加载中..." : "加载更多"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
@@ -864,6 +980,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||||
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "templates" | "security" | "about">("base")
|
||||
const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
|
||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||||
const [openRegistration, setOpenRegistration] = React.useState(false)
|
||||
@@ -912,6 +1029,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["admin", "settings"] })
|
||||
qc.invalidateQueries({ queryKey: ["admin", "maildir-sync", "health"] })
|
||||
qc.invalidateQueries({ queryKey: ["dns-records"] })
|
||||
qc.invalidateQueries({ queryKey: ["public-settings"] })
|
||||
toast({ title: "系统设置已保存" })
|
||||
@@ -1002,12 +1120,15 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
</CardContent>
|
||||
</Card>}
|
||||
|
||||
{settingsTab === "storage" && <Card>
|
||||
<CardHeader><CardTitle>存储设置</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Field name="maildirRoot" label="Maildir 根目录" defaultValue={settings?.maildirRoot || ""} required={false} />
|
||||
</CardContent>
|
||||
</Card>}
|
||||
{settingsTab === "storage" && <div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>存储设置</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Field name="maildirRoot" label="Maildir 根目录" defaultValue={settings?.maildirRoot || ""} required={false} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MaildirSyncHealthCard health={maildirHealth.data} loading={maildirHealth.isLoading} error={maildirHealth.error} onRefresh={() => maildirHealth.refetch()} refreshing={maildirHealth.isFetching} fallbackRoot={settings?.maildirRoot || ""} />
|
||||
</div>}
|
||||
|
||||
{settingsTab === "mail" && <Card>
|
||||
<CardHeader><CardTitle>邮件设置</CardTitle></CardHeader>
|
||||
@@ -1081,6 +1202,102 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
)
|
||||
}
|
||||
|
||||
function MaildirSyncHealthCard({ health, loading, error, onRefresh, refreshing, fallbackRoot }: { health?: MaildirSyncHealth; loading: boolean; error: Error | null; onRefresh: () => void; refreshing: boolean; fallbackRoot: string }) {
|
||||
const root = health?.root || fallbackRoot
|
||||
const configured = health?.configured ?? !!root
|
||||
const lastRun = health?.lastRun
|
||||
const counters = lastRun?.counts || health?.summary
|
||||
const recentErrors = health?.recentErrors || []
|
||||
const status = health?.running ? "running" : lastRun?.status || (configured ? "idle" : "disabled")
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>Maildir 同步健康</CardTitle>
|
||||
<div className="break-all text-xs text-muted-foreground">{root || "未配置 Maildir 根目录"}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={configured ? "default" : "secondary"}>{configured ? "已配置" : "未配置"}</Badge>
|
||||
<Badge variant={health?.running ? "default" : health?.workerStarted ? "outline" : "secondary"}>{health?.running ? "运行中" : health?.workerStarted ? "worker 已启动" : "worker 未启动"}</Badge>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRefresh} disabled={loading || refreshing}>
|
||||
<RefreshCcw className={cn("mr-2 h-4 w-4", refreshing && "animate-spin")} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error && <div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">{queryErrorMessage(error)}</div>}
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<InfoLine label="当前状态" value={<MaildirStatusBadge status={status} />} />
|
||||
<InfoLine label="最近开始" value={formatOptionalDate(lastRun?.startedAt)} />
|
||||
<InfoLine label="最近结束" value={formatOptionalDate(lastRun?.finishedAt)} />
|
||||
<InfoLine label="最近耗时" value={formatDuration(lastRun?.durationMs)} />
|
||||
<InfoLine label="扫描间隔" value={health?.scanSeconds ? `${health.scanSeconds} 秒` : "-"} />
|
||||
<InfoLine label="下次运行" value={formatOptionalDate(health?.nextRunAt)} />
|
||||
<InfoLine label="最后错误" value={lastRun?.error || health?.lastError || "-"} />
|
||||
<InfoLine label="错误数" value={counterValue(counters, "fileErrors")} />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{maildirCounterRows(counters).map((item) => <InfoBox key={item.key} label={item.label} value={item.value} />)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">最近错误</div>
|
||||
{recentErrors.length === 0 && <Empty text={loading ? "正在读取同步状态..." : "暂无同步错误"} />}
|
||||
{recentErrors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{recentErrors.slice(0, 5).map((item, index) => (
|
||||
<div key={`${item}-${index}`} className="rounded-lg border px-3 py-2 text-sm">
|
||||
<div className="break-words text-destructive">{item || "未知错误"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function MaildirStatusBadge({ status }: { status: string }) {
|
||||
const normalized = status.toLowerCase()
|
||||
if (normalized === "running") return <Badge>运行中</Badge>
|
||||
if (["ok", "success", "succeeded", "idle"].includes(normalized)) return <Badge variant="outline">{normalized === "idle" ? "等待下次扫描" : "正常"}</Badge>
|
||||
if (normalized === "partial") return <Badge variant="secondary">部分成功</Badge>
|
||||
if (["error", "failed", "failure"].includes(normalized)) return <Badge variant="destructive">失败</Badge>
|
||||
if (["disabled", "not_configured"].includes(normalized)) return <Badge variant="secondary">未启用</Badge>
|
||||
return <Badge variant="secondary">{status || "-"}</Badge>
|
||||
}
|
||||
|
||||
function maildirCounterRows(counters?: Record<string, number | undefined>) {
|
||||
return [
|
||||
{ key: "filesScanned", label: "扫描文件", value: counterValue(counters, "filesScanned") },
|
||||
{ key: "imported", label: "导入", value: counterValue(counters, "imported") },
|
||||
{ key: "backfilled", label: "回填", value: counterValue(counters, "backfilled") },
|
||||
{ key: "cleaned", label: "清理", value: counterValue(counters, "cleaned") },
|
||||
{ key: "fileErrors", label: "文件错误", value: counterValue(counters, "fileErrors") },
|
||||
]
|
||||
}
|
||||
|
||||
function counterValue(counters: Record<string, number | undefined> | undefined, key: string) {
|
||||
return Number(counters?.[key] || 0)
|
||||
}
|
||||
|
||||
function formatOptionalDate(value?: string) {
|
||||
return value ? formatDate(value) || "-" : "-"
|
||||
}
|
||||
|
||||
function formatDuration(value?: number) {
|
||||
if (!value) return "-"
|
||||
if (value < 1000) return `${value} ms`
|
||||
return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)} 秒`
|
||||
}
|
||||
|
||||
function queryErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "读取 Maildir 同步健康失败"
|
||||
}
|
||||
|
||||
function parseSemver(tag: string): number[] {
|
||||
return (tag.startsWith("v") ? tag.slice(1) : tag).split(".").map(Number)
|
||||
}
|
||||
@@ -1370,6 +1587,26 @@ function adminSenderTitle(message: MailMessage) {
|
||||
return name ? `${name} <${from}>` : from
|
||||
}
|
||||
|
||||
const sendAuditEvents = ["accepted", "queued", "retry", "delivered", "failed", "canceled"]
|
||||
|
||||
function sendAuditEventLabel(event: string) {
|
||||
switch (event) {
|
||||
case "accepted": return "已接受"
|
||||
case "queued": return "已入队"
|
||||
case "retry": return "重试"
|
||||
case "delivered": return "已投递"
|
||||
case "failed": return "失败"
|
||||
case "canceled": return "已取消"
|
||||
default: return event || "-"
|
||||
}
|
||||
}
|
||||
|
||||
function sendAuditBadgeVariant(event?: string) {
|
||||
if (event === "failed") return "destructive"
|
||||
if (event === "delivered" || event === "accepted") return "default"
|
||||
return "secondary"
|
||||
}
|
||||
|
||||
function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
|
||||
return <Card><CardContent className="flex items-center gap-3 p-4 sm:gap-4 sm:p-5"><div className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground sm:h-10 sm:w-10">{icon}</div><div className="min-w-0"><div className="truncate text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div></CardContent></Card>
|
||||
}
|
||||
|
||||
+296
-15
@@ -12,8 +12,8 @@ import Placeholder from "@tiptap/extension-placeholder"
|
||||
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react"
|
||||
import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend, PermissionLimits } from "@/lib/api"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react"
|
||||
import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { useDisplayMode } from "@/lib/display-mode"
|
||||
@@ -61,7 +61,7 @@ const folderLabels: Record<string, string> = {
|
||||
|
||||
type ComposeDraft = { key: string; id?: string; mailboxId?: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string; html?: string; files?: File[]; isDraft?: boolean }
|
||||
type MailFilter = "all" | "unread" | "starred" | "attachments"
|
||||
type MailView = "folder" | "starred" | "label" | "scheduled"
|
||||
type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue"
|
||||
type MailListResponse = { items?: MailMessage[]; nextCursor?: string }
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
type MailNotificationState = { latestId: string; latestReceivedAt: string }
|
||||
@@ -69,6 +69,7 @@ type ComposeSendIntent = { title: string; description: string; confirmText: stri
|
||||
type MailMenuItem =
|
||||
| { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number }
|
||||
| { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number }
|
||||
| { type: "sendQueue"; key: string; label: string; icon: React.ReactNode; count: number }
|
||||
| { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number }
|
||||
|
||||
const filterLabels: Record<MailFilter, string> = {
|
||||
@@ -103,6 +104,9 @@ export function MailPage() {
|
||||
const [bulkPending, setBulkPending] = React.useState(false)
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
|
||||
const [sendQueueStatus, setSendQueueStatus] = React.useState<SendQueueStatus | "all">("all")
|
||||
const [sendQueueAuditId, setSendQueueAuditId] = React.useState("")
|
||||
const [sendQueuePendingId, setSendQueuePendingId] = React.useState("")
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
|
||||
const [labelEditMode, setLabelEditMode] = React.useState(false)
|
||||
const [newLabelEditing, setNewLabelEditing] = React.useState(false)
|
||||
@@ -130,6 +134,9 @@ export function MailPage() {
|
||||
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels) })
|
||||
const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && hasPermission(user, "mail.stats.view") })
|
||||
const scheduledSends = useQuery({ queryKey: ["scheduled-sends", activeMailboxId], queryFn: () => api.scheduledSends(activeMailboxId), enabled: !!activeMailboxId && canScheduleMail, refetchInterval: 30000 })
|
||||
const canViewSendQueue = canReadMail
|
||||
const sendQueue = useQuery({ queryKey: ["send-queue", activeMailboxId, sendQueueStatus], queryFn: () => api.sendQueue({ mailboxId: activeMailboxId, status: sendQueueStatus }), enabled: !!activeMailboxId && canViewSendQueue, refetchInterval: 15000 })
|
||||
const sendQueueAudit = useQuery({ queryKey: ["send-queue-audit", sendQueueAuditId], queryFn: () => api.sendQueueAudit(sendQueueAuditId), enabled: !!sendQueueAuditId && canViewSendQueue })
|
||||
const mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false
|
||||
const inboxProbe = useQuery({
|
||||
queryKey: ["mail-notifications", activeMailboxId],
|
||||
@@ -148,7 +155,7 @@ export function MailPage() {
|
||||
},
|
||||
initialPageParam: "",
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
||||
enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && (mailView !== "label" || !!selectedLabelId),
|
||||
enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && (mailView !== "label" || !!selectedLabelId),
|
||||
})
|
||||
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail })
|
||||
function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
|
||||
@@ -276,6 +283,28 @@ export function MailPage() {
|
||||
onError: (error) => toast({ title: "操作失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||
onSettled: () => setCancelingScheduledId(""),
|
||||
})
|
||||
const retrySendQueue = useMutation({
|
||||
mutationFn: (item: SendQueueItem) => api.retrySendQueue(item.id),
|
||||
onMutate: (item) => setSendQueuePendingId(item.id),
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ["send-queue"] })
|
||||
await qc.invalidateQueries({ queryKey: ["send-queue-audit"] })
|
||||
toast({ title: "已重新加入发送队列" })
|
||||
},
|
||||
onError: (error) => toast({ title: "重试失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||
onSettled: () => setSendQueuePendingId(""),
|
||||
})
|
||||
const cancelSendQueue = useMutation({
|
||||
mutationFn: (item: SendQueueItem) => api.cancelSendQueue(item.id),
|
||||
onMutate: (item) => setSendQueuePendingId(item.id),
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ["send-queue"] })
|
||||
await qc.invalidateQueries({ queryKey: ["send-queue-audit"] })
|
||||
toast({ title: "已取消发送任务" })
|
||||
},
|
||||
onError: (error) => toast({ title: "取消失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||
onSettled: () => setSendQueuePendingId(""),
|
||||
})
|
||||
const markAllRead = useMutation({
|
||||
mutationFn: async (items: MailMessage[]) => {
|
||||
const unread = items.filter((message) => !message.isRead)
|
||||
@@ -403,6 +432,7 @@ export function MailPage() {
|
||||
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
||||
qc.invalidateQueries({ queryKey: ["labels"] }),
|
||||
qc.invalidateQueries({ queryKey: ["scheduled-sends"] }),
|
||||
qc.invalidateQueries({ queryKey: ["send-queue"] }),
|
||||
qc.invalidateQueries({ queryKey: ["mail-notifications"] }),
|
||||
]).finally(() => {
|
||||
setLastAutoRefreshAt(new Date())
|
||||
@@ -429,10 +459,16 @@ export function MailPage() {
|
||||
const visibleScheduledItems = scheduledQuery
|
||||
? scheduledItems.filter((item) => [item.subject, item.snippet, ...(item.to || [])].join(" ").toLowerCase().includes(scheduledQuery))
|
||||
: scheduledItems
|
||||
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail)
|
||||
const sendQueueItems = sendQueue.data?.items || []
|
||||
const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length
|
||||
const sendQueueQuery = query.trim().toLowerCase()
|
||||
const visibleSendQueueItems = sendQueueQuery
|
||||
? sendQueueItems.filter((item) => [item.subject, item.source, item.lastError, item.error, item.failureReason, ...(item.recipients || [])].join(" ").toLowerCase().includes(sendQueueQuery))
|
||||
: sendQueueItems
|
||||
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue)
|
||||
const labelItems = labels.data?.items || []
|
||||
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
|
||||
const viewTitle = mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
|
||||
const viewTitle = mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
|
||||
const emptyMessage = getEmptyMessage(mailView, folder, allMessages.length)
|
||||
const visibleMessageIds = visibleMessages.map((message) => message.id)
|
||||
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
|
||||
@@ -453,6 +489,7 @@ export function MailPage() {
|
||||
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
||||
qc.invalidateQueries({ queryKey: ["labels"] }),
|
||||
qc.invalidateQueries({ queryKey: ["scheduled-sends"] }),
|
||||
qc.invalidateQueries({ queryKey: ["send-queue"] }),
|
||||
])
|
||||
}
|
||||
async function runBulkAction(action: BulkAction) {
|
||||
@@ -576,6 +613,13 @@ export function MailPage() {
|
||||
setMailFilter("all")
|
||||
setMobileSidebarOpen(false)
|
||||
}
|
||||
function openSendQueue() {
|
||||
setMailView("sendQueue")
|
||||
setSelectedLabelId("")
|
||||
setSelectedId(null)
|
||||
setMailFilter("all")
|
||||
setMobileSidebarOpen(false)
|
||||
}
|
||||
function openLabel(labelId: string) {
|
||||
setSelectedLabelId(labelId)
|
||||
setMailView("label")
|
||||
@@ -654,9 +698,9 @@ export function MailPage() {
|
||||
{mailMenuItems.map((item) => (
|
||||
<SidebarMenuItem key={item.key}>
|
||||
<SidebarMenuButton
|
||||
isActive={item.type === "starred" ? mailView === "starred" : item.type === "scheduled" ? mailView === "scheduled" : mailView === "folder" && folder === item.folderName}
|
||||
isActive={item.type === "starred" ? mailView === "starred" : item.type === "scheduled" ? mailView === "scheduled" : item.type === "sendQueue" ? mailView === "sendQueue" : mailView === "folder" && folder === item.folderName}
|
||||
className={cn(sidebarCollapsed && "justify-center px-0")}
|
||||
onClick={() => item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : openFolder(item.folderName)}
|
||||
onClick={() => item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : item.type === "sendQueue" ? openSendQueue() : openFolder(item.folderName)}
|
||||
>
|
||||
{item.icon}
|
||||
{!sidebarCollapsed && <span>{item.label}</span>}
|
||||
@@ -772,6 +816,23 @@ export function MailPage() {
|
||||
/>
|
||||
) : mailView === "scheduled" ? (
|
||||
<PermissionEmptyState title="无定时发送权限" description="当前账号不能查看或管理定时发送任务。" onOpenSettings={openSettings} />
|
||||
) : mailView === "sendQueue" && canViewSendQueue ? (
|
||||
<SendQueueView
|
||||
compact={isMobile || displayMode === "compact"}
|
||||
items={visibleSendQueueItems}
|
||||
total={sendQueueItems.length}
|
||||
loading={sendQueue.isLoading}
|
||||
query={query}
|
||||
status={sendQueueStatus}
|
||||
pendingId={sendQueuePendingId}
|
||||
onStatusChange={setSendQueueStatus}
|
||||
onRetry={(item) => retrySendQueue.mutate(item)}
|
||||
onCancel={(item) => cancelSendQueue.mutate(item)}
|
||||
onAudit={(item) => setSendQueueAuditId(item.id)}
|
||||
canMutate={canSendMail}
|
||||
/>
|
||||
) : mailView === "sendQueue" ? (
|
||||
<PermissionEmptyState title="无发送队列权限" description="当前账号不能查看发送队列。" onOpenSettings={openSettings} />
|
||||
) : isMobile || displayMode === "compact" ? (
|
||||
<CompactMailView
|
||||
title={viewTitle}
|
||||
@@ -900,7 +961,7 @@ export function MailPage() {
|
||||
{canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>}
|
||||
<div className="relative basis-full">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
|
||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
@@ -924,7 +985,7 @@ export function MailPage() {
|
||||
{autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"}
|
||||
</div>
|
||||
)}
|
||||
{mailView !== "scheduled" && (
|
||||
{mailView !== "scheduled" && mailView !== "sendQueue" && (
|
||||
<>
|
||||
{canOrganizeMail && <Button variant="outline" size="sm" disabled={!activeMailboxId || markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" />全部已读</Button>}
|
||||
<DropdownMenu>
|
||||
@@ -944,7 +1005,7 @@ export function MailPage() {
|
||||
</div>
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" />
|
||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" />
|
||||
</div>
|
||||
</header>
|
||||
{contentView}
|
||||
@@ -954,7 +1015,13 @@ export function MailPage() {
|
||||
)}
|
||||
</SidebarProvider>
|
||||
|
||||
<ComposeDialog mailbox={selectedMailbox} 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"] }) }} />
|
||||
<ComposeDialog mailbox={selectedMailbox} 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"] }) }} />
|
||||
<SendQueueAuditDialog
|
||||
open={!!sendQueueAuditId}
|
||||
loading={sendQueueAudit.isLoading}
|
||||
events={sendQueueAudit.data?.items || []}
|
||||
onOpenChange={(open) => { if (!open) setSendQueueAuditId("") }}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!pendingConfirm}
|
||||
title={pendingConfirm?.title || ""}
|
||||
@@ -969,7 +1036,7 @@ export function MailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean): MailMenuItem[] {
|
||||
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean, sendQueueCount: number, includeSendQueue: boolean): MailMenuItem[] {
|
||||
const byName = new Map(folders.map((item) => [item.name, item]))
|
||||
const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), unreadCount: 0, totalCount: 0 })
|
||||
for (const item of folders) {
|
||||
@@ -985,10 +1052,13 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number, schedul
|
||||
}))
|
||||
const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: <Star className="h-4 w-4" />, count: starredCount }
|
||||
const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: <Clock3 className="h-4 w-4" />, count: scheduledCount }
|
||||
const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: <History className="h-4 w-4" />, count: sendQueueCount }
|
||||
const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox")
|
||||
const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0
|
||||
if (!includeScheduled) return [...folderItems.slice(0, insertAt), starredItem, ...folderItems.slice(insertAt)]
|
||||
return [...folderItems.slice(0, insertAt), starredItem, scheduledItem, ...folderItems.slice(insertAt)]
|
||||
const specialItems: MailMenuItem[] = [starredItem]
|
||||
if (includeScheduled) specialItems.push(scheduledItem)
|
||||
if (includeSendQueue) specialItems.push(sendQueueItem)
|
||||
return [...folderItems.slice(0, insertAt), ...specialItems, ...folderItems.slice(insertAt)]
|
||||
}
|
||||
|
||||
function FolderSkeleton() { return <div className="space-y-2 p-2"><Skeleton className="h-8 w-full" /><Skeleton className="h-8 w-4/5" /><Skeleton className="h-8 w-3/4" /></div> }
|
||||
@@ -996,6 +1066,7 @@ function MessageSkeleton() { return <div className="space-y-0">{Array.from({ len
|
||||
|
||||
function getEmptyMessage(mailView: MailView, folder: string, total: number) {
|
||||
if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件"
|
||||
if (mailView === "sendQueue") return total === 0 ? "发送队列为空" : "当前搜索没有匹配的发送任务"
|
||||
if (total > 0) return "当前筛选条件下没有邮件"
|
||||
if (mailView === "starred") return "暂无星标邮件"
|
||||
if (mailView === "label") return "当前标签没有邮件"
|
||||
@@ -1114,6 +1185,180 @@ function ScheduledStatusBadge({ status }: { status: ScheduledSend["status"] }) {
|
||||
)
|
||||
}
|
||||
|
||||
const sendQueueStatusOptions: { value: SendQueueStatus | "all"; label: string }[] = [
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "queued", label: "排队中" },
|
||||
{ value: "sending", label: "发送中" },
|
||||
{ value: "failed", label: "发送失败" },
|
||||
{ value: "delivered", label: "已投递" },
|
||||
{ value: "canceled", label: "已取消" },
|
||||
]
|
||||
|
||||
function SendQueueView({
|
||||
compact,
|
||||
items,
|
||||
total,
|
||||
loading,
|
||||
query,
|
||||
status,
|
||||
pendingId,
|
||||
onStatusChange,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onAudit,
|
||||
canMutate,
|
||||
}: {
|
||||
compact: boolean
|
||||
items: SendQueueItem[]
|
||||
total: number
|
||||
loading: boolean
|
||||
query: string
|
||||
status: SendQueueStatus | "all"
|
||||
pendingId: string
|
||||
onStatusChange: (status: SendQueueStatus | "all") => void
|
||||
onRetry: (item: SendQueueItem) => void
|
||||
onCancel: (item: SendQueueItem) => void
|
||||
onAudit: (item: SendQueueItem) => void
|
||||
canMutate: boolean
|
||||
}) {
|
||||
const empty = query.trim() ? "当前搜索没有匹配的发送任务" : "发送队列为空"
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
<div className={cn("flex shrink-0 items-center justify-between gap-3 border-b", compact ? "min-h-12 px-4 py-2" : "h-14 px-5")}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold"><History className="h-4 w-4" />发送队列</div>
|
||||
<div className="text-xs text-muted-foreground">{items.length} / {total} 个发送任务</div>
|
||||
</div>
|
||||
<Select value={status} onValueChange={(value) => onStatusChange(value as SendQueueStatus | "all")}>
|
||||
<SelectTrigger className="h-9 w-[132px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sendQueueStatusOptions.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{loading && <ScheduledSendSkeleton />}
|
||||
{!loading && items.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{empty}</div>}
|
||||
{!loading && items.map((item) => (
|
||||
<SendQueueRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
compact={compact}
|
||||
pending={pendingId === item.id}
|
||||
onRetry={() => onRetry(item)}
|
||||
onCancel={() => onCancel(item)}
|
||||
onAudit={() => onAudit(item)}
|
||||
canMutate={canMutate}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SendQueueRow({ item, compact, pending, onRetry, onCancel, onAudit, canMutate }: { item: SendQueueItem; compact: boolean; pending: boolean; onRetry: () => void; onCancel: () => void; onAudit: () => void; canMutate: boolean }) {
|
||||
const recipients = item.recipients?.length ? item.recipients.join(", ") : "未记录收件人"
|
||||
const failure = item.lastError || item.error || item.failureReason || ""
|
||||
const canRetry = item.status === "failed"
|
||||
const canCancel = item.status === "queued" || item.status === "failed"
|
||||
return (
|
||||
<div className={cn("border-b transition-colors hover:bg-accent/40", compact ? "p-4" : "px-5 py-4")}>
|
||||
<div className={cn("gap-4", compact ? "space-y-3" : "grid grid-cols-[minmax(0,1fr)_210px_220px] items-center")}>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">{item.subject || "(无主题)"}</span>
|
||||
<SendQueueStatusBadge status={item.status} />
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">发给 {recipients}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>来源:{sendQueueSourceLabel(item.source)}</span>
|
||||
<span>尝试:{item.attemptCount}/{item.maxAttempts}</span>
|
||||
{item.nextAttemptAt && <span>下次:{formatDateTime(item.nextAttemptAt)}</span>}
|
||||
</div>
|
||||
{failure && <div className="mt-2 line-clamp-2 text-xs text-destructive">{failure}</div>}
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="text-xs text-muted-foreground">更新时间</div>
|
||||
<div className="font-medium">{formatDateTime(item.updatedAt || item.createdAt)}</div>
|
||||
{item.deliveredAt && <div className="text-xs text-muted-foreground">投递于 {formatDateTime(item.deliveredAt)}</div>}
|
||||
</div>
|
||||
<div className={cn("flex flex-wrap gap-2", compact ? "justify-start" : "justify-end")}>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onAudit}>
|
||||
<History className="h-4 w-4" />时间线
|
||||
</Button>
|
||||
{canMutate && canRetry && (
|
||||
<Button type="button" variant="outline" size="sm" disabled={pending} onClick={onRetry}>
|
||||
<RotateCcw className="h-4 w-4" />{pending ? "处理中..." : "重试"}
|
||||
</Button>
|
||||
)}
|
||||
{canMutate && canCancel && (
|
||||
<Button type="button" variant="destructive" size="sm" disabled={pending} onClick={onCancel}>
|
||||
{pending ? "处理中..." : "取消"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SendQueueStatusBadge({ status }: { status: SendQueueStatus }) {
|
||||
const label = status === "queued" ? "排队中" : status === "sending" ? "发送中" : status === "delivered" ? "已投递" : status === "failed" ? "发送失败" : "已取消"
|
||||
return (
|
||||
<Badge variant={status === "failed" ? "destructive" : status === "sending" || status === "queued" ? "secondary" : "outline"} className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal">
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function SendQueueAuditDialog({ open, loading, events, onOpenChange }: { open: boolean; loading: boolean; events: SendQueueAuditEvent[]; onOpenChange: (open: boolean) => void }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(92vw,42rem)] max-w-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle>投递时间线</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-auto pr-1">
|
||||
{loading && <div className="space-y-3">{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-14 w-full" />)}</div>}
|
||||
{!loading && events.length === 0 && <div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">暂无投递事件</div>}
|
||||
{!loading && events.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{event.status && <SendQueueStatusBadge status={event.status} />}
|
||||
<span>{event.message || event.event || event.eventType || "队列事件"}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{formatDateTime(event.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{typeof event.attemptCount === "number" && <span>尝试次数:{event.attemptCount}</span>}
|
||||
{event.error && <span className="text-destructive">{event.error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function sendQueueSourceLabel(source: string) {
|
||||
const normalized = source.toLowerCase()
|
||||
if (normalized === "submission") return "SMTP Submission"
|
||||
if (normalized === "webmail") return "Webmail"
|
||||
if (normalized === "scheduled") return "定时发送"
|
||||
return source || "未知"
|
||||
}
|
||||
|
||||
type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "trash" | "spam" | "delete"
|
||||
|
||||
function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) {
|
||||
@@ -1718,6 +1963,7 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
|
||||
<MessageMetaRow label="接收时间">
|
||||
<span>{formatDateTime(message.receivedAt)}</span>
|
||||
</MessageMetaRow>
|
||||
<AuthenticationResultRow message={message} />
|
||||
{availableLabels && onAddLabel && onRemoveLabel && (
|
||||
<MessageMetaRow label="标签">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
@@ -1779,6 +2025,41 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
|
||||
)
|
||||
}
|
||||
|
||||
function AuthenticationResultRow({ message }: { message: MailMessage }) {
|
||||
const auth = message.authentication || { authenticationResults: "", receivedSpf: "", spf: "unknown", dkim: "unknown", dmarc: "unknown" }
|
||||
const title = [auth.authenticationResults, auth.receivedSpf].filter(Boolean).join("\n\n")
|
||||
return (
|
||||
<MessageMetaRow label="Auth">
|
||||
<div className="flex flex-wrap gap-1.5" title={title || undefined}>
|
||||
<AuthStatusBadge label="SPF" value={auth.spf} />
|
||||
<AuthStatusBadge label="DKIM" value={auth.dkim} />
|
||||
<AuthStatusBadge label="DMARC" value={auth.dmarc} />
|
||||
</div>
|
||||
</MessageMetaRow>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthStatusBadge({ label, value }: { label: string; value?: string }) {
|
||||
const status = normalizeAuthStatus(value)
|
||||
return (
|
||||
<Badge variant="outline" className={cn("rounded-md font-mono text-[11px] font-normal", authStatusClassName(status))}>
|
||||
{label}:{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeAuthStatus(value?: string) {
|
||||
const status = (value || "").trim().toLowerCase()
|
||||
if (["pass", "fail", "softfail", "neutral", "temperror", "permerror", "none"].includes(status)) return status
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function authStatusClassName(status: string) {
|
||||
if (status === "pass") return "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||
if (["fail", "softfail", "permerror"].includes(status)) return "border-red-300 bg-red-50 text-red-700"
|
||||
return "border-slate-300 bg-slate-50 text-slate-600"
|
||||
}
|
||||
|
||||
function MessageMetaRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)]">
|
||||
|
||||
@@ -812,8 +812,14 @@ type RuleCreatePayload = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const conditionFieldLabels: Record<MailRuleCondition["field"], string> = { from: "发件人地址", to: "收件人地址", subject: "邮件主题", body: "邮件正文" }
|
||||
const conditionOperatorLabels: Record<MailRuleCondition["operator"], string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是" }
|
||||
type RuleConditionField = NonNullable<MailRuleCondition["field"]>
|
||||
type RuleConditionOperator = NonNullable<MailRuleCondition["operator"]>
|
||||
const conditionFieldLabels: Record<RuleConditionField, string> = { from: "发件人地址", to: "收件人地址", cc: "抄送地址", subject: "邮件主题", body: "邮件正文", attachment: "附件名称", size: "邮件大小", date: "收信日期" }
|
||||
const conditionOperatorLabels: Record<RuleConditionOperator, string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是", gt: "大于", gte: "大于等于", lt: "小于", lte: "小于等于", before: "早于", after: "晚于", on: "当天" }
|
||||
const textConditionOperators: RuleConditionOperator[] = ["contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with"]
|
||||
const sizeConditionOperators: RuleConditionOperator[] = ["gt", "gte", "lt", "lte", "equals", "not-equals"]
|
||||
const dateConditionOperators: RuleConditionOperator[] = ["before", "after", "on", "equals", "not-equals"]
|
||||
const conditionFields = Object.keys(conditionFieldLabels) as RuleConditionField[]
|
||||
const ruleActionLabels: Record<MailRuleAction["type"], string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到" }
|
||||
|
||||
function RulesSection({ items, mailboxes, labels, open, onOpenChange, onCreate, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onDelete: (id: string) => void; pending: boolean }) {
|
||||
@@ -860,7 +866,14 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
}, [open, labels])
|
||||
|
||||
function updateCondition(index: number, patch: Partial<MailRuleCondition>) {
|
||||
setConditions((items) => items.map((item, i) => i === index ? { ...item, ...patch } : item))
|
||||
setConditions((items) => items.map((item, i) => {
|
||||
if (i !== index) return item
|
||||
const next = { ...item, ...patch }
|
||||
if (patch.field && !conditionOperatorsForField(patch.field).includes(next.operator || "contains")) {
|
||||
next.operator = defaultConditionOperator(patch.field)
|
||||
}
|
||||
return next
|
||||
}))
|
||||
}
|
||||
function updateAction(index: number, patch: Partial<MailRuleAction>) {
|
||||
setActions((items) => items.map((item, i) => i === index ? normalizeDraftAction({ ...item, ...patch }, availableLabels) : item))
|
||||
@@ -870,7 +883,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
function removeCondition(index: number) { setConditions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
|
||||
function removeAction(index: number) { setActions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
|
||||
|
||||
const validConditions = conditions.map((item) => ({ ...item, value: item.value.trim() })).filter((item) => item.value)
|
||||
const validConditions = conditions.map((item) => ({ ...item, value: (item.value || "").trim() })).filter((item) => item.field && item.operator && item.value)
|
||||
const validActions = actions.map((item) => normalizeDraftAction(item, availableLabels)).filter((item) => item.type !== "label" || item.value || item.labelId).filter((item) => item.type !== "move" || item.value)
|
||||
const canCreate = validConditions.length > 0 && validActions.length > 0 && !pending
|
||||
|
||||
@@ -902,15 +915,15 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
<div className="space-y-3">
|
||||
{conditions.map((condition, index) => (
|
||||
<div key={index} className="grid gap-3 md:grid-cols-[220px_150px_minmax(0,1fr)_auto_auto]">
|
||||
<Select value={condition.field} onValueChange={(value) => updateCondition(index, { field: value as MailRuleCondition["field"] })}>
|
||||
<Select value={condition.field || "from"} onValueChange={(value) => updateCondition(index, { field: value as RuleConditionField })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(Object.keys(conditionFieldLabels) as MailRuleCondition["field"][]).map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{conditionFields.map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Select value={condition.operator} onValueChange={(value) => updateCondition(index, { operator: value as MailRuleCondition["operator"] })}>
|
||||
<Select value={condition.operator || defaultConditionOperator(condition.field)} onValueChange={(value) => updateCondition(index, { operator: value as RuleConditionOperator })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(Object.keys(conditionOperatorLabels) as MailRuleCondition["operator"][]).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Input value={condition.value} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder="输入值" />
|
||||
<Input type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
|
||||
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
@@ -1012,9 +1025,38 @@ function normalizeDraftAction(action: MailRuleAction, labels: MailLabel[]): Mail
|
||||
return { type: action.type }
|
||||
}
|
||||
|
||||
function conditionOperatorsForField(field?: MailRuleCondition["field"]) {
|
||||
if (field === "size") return sizeConditionOperators
|
||||
if (field === "date") return dateConditionOperators
|
||||
return textConditionOperators
|
||||
}
|
||||
|
||||
function defaultConditionOperator(field?: MailRuleCondition["field"]): RuleConditionOperator {
|
||||
if (field === "size") return "gte"
|
||||
if (field === "date") return "on"
|
||||
return "contains"
|
||||
}
|
||||
|
||||
function conditionPlaceholder(field?: MailRuleCondition["field"]) {
|
||||
if (field === "size") return "例如 10mb"
|
||||
if (field === "date") return "选择日期"
|
||||
if (field === "attachment") return "输入附件名或扩展名"
|
||||
return "输入值"
|
||||
}
|
||||
|
||||
function conditionSummary(conditions: MailRuleCondition[] = [], fromContains = "", subjectContains = "") {
|
||||
const items = conditions.length > 0 ? conditions : [fromContains ? { field: "from", operator: "contains", value: fromContains } as MailRuleCondition : undefined, subjectContains ? { field: "subject", operator: "contains", value: subjectContains } as MailRuleCondition : undefined].filter(Boolean) as MailRuleCondition[]
|
||||
return items.map((item) => `${conditionFieldLabels[item.field]} ${conditionOperatorLabels[item.operator]} ${item.value}`).join(";") || "无条件"
|
||||
return items.map(conditionItemSummary).join(";") || "无条件"
|
||||
}
|
||||
|
||||
function conditionItemSummary(item: MailRuleCondition): string {
|
||||
if (item.conditions?.length) {
|
||||
const mode = item.matchMode === "any" ? "任一" : "全部"
|
||||
return `${mode}(${item.conditions.map(conditionItemSummary).join(";")})`
|
||||
}
|
||||
const field = item.field || "from"
|
||||
const operator = item.operator || defaultConditionOperator(field)
|
||||
return `${conditionFieldLabels[field]} ${conditionOperatorLabels[operator]} ${item.value || ""}`
|
||||
}
|
||||
|
||||
function actionSummary(action: MailRuleAction) {
|
||||
@@ -1063,7 +1105,8 @@ function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbo
|
||||
}
|
||||
|
||||
function StatsSummary({ stats }: { stats?: MailStats }) {
|
||||
const cards = [{ label: "总邮件", value: stats?.totalMessages || 0 }, { label: "未读", value: stats?.unreadMessages || 0 }, { label: "星标", value: stats?.starredMessages || 0 }, { label: "附件", value: stats?.attachmentCount || 0 }, { label: "容量", value: formatBytes(stats?.storageBytes || 0) }]
|
||||
const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0)
|
||||
const cards = [{ label: "总邮件", value: stats?.totalMessages || 0 }, { label: "未读", value: stats?.unreadMessages || 0 }, { label: "星标", value: stats?.starredMessages || 0 }, { label: "附件", value: `${stats?.attachmentCount || 0} / ${formatBytes(stats?.attachmentBytes || 0)}` }, { label: stats?.quotaBytes ? `容量 ${Math.min(stats.quotaUsedPct || 0, 999).toFixed(1)}%` : "容量", value: quotaLabel }]
|
||||
return <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">{cards.map((c) => <Card key={c.label}><CardContent className="p-4"><div className="text-2xl font-semibold tracking-tight">{c.value}</div><div className="text-xs text-muted-foreground">{c.label}</div></CardContent></Card>)}</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -29,23 +29,23 @@ auth_policy_request_attributes = login=%{requested_username} remote=%{rip} proto
|
||||
namespace inbox {
|
||||
inbox = yes
|
||||
mailbox Drafts {
|
||||
auto = create
|
||||
auto = subscribe
|
||||
special_use = \Drafts
|
||||
}
|
||||
mailbox Sent {
|
||||
auto = create
|
||||
auto = subscribe
|
||||
special_use = \Sent
|
||||
}
|
||||
mailbox Trash {
|
||||
auto = create
|
||||
auto = subscribe
|
||||
special_use = \Trash
|
||||
}
|
||||
mailbox Archive {
|
||||
auto = create
|
||||
auto = subscribe
|
||||
special_use = \Archive
|
||||
}
|
||||
mailbox Spam {
|
||||
auto = create
|
||||
auto = subscribe
|
||||
special_use = \Junk
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user