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()
|
rows.Close()
|
||||||
for _, messageID := range messageIDs {
|
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)
|
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -786,7 +786,7 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
args = append(args, limit+1, offset)
|
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
|
FROM messages m
|
||||||
LEFT JOIN folders f ON f.id=m.folder_id
|
LEFT JOIN folders f ON f.id=m.folder_id
|
||||||
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_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)
|
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) {
|
func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
DomainID string `json:"domainId"`
|
DomainID string `json:"domainId"`
|
||||||
@@ -1039,6 +1149,6 @@ func (a *App) ensureFolder(ctx context.Context, mailboxID, folder string) (strin
|
|||||||
}
|
}
|
||||||
role := strings.ToLower(folder)
|
role := strings.ToLower(folder)
|
||||||
id = newID("fld")
|
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
|
return id, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
policy *HTMLPolicy
|
policy *HTMLPolicy
|
||||||
workerCancel context.CancelFunc
|
workerCancel context.CancelFunc
|
||||||
|
maildirHealth *maildirSyncHealthTracker
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg Config, logger *slog.Logger) (*App, error) {
|
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)
|
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 {
|
if err := a.configureSQLite(context.Background()); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
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,
|
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
role 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,
|
created_at TEXT NOT NULL,
|
||||||
UNIQUE(mailbox_id, name)
|
UNIQUE(mailbox_id, name)
|
||||||
)`,
|
)`,
|
||||||
@@ -226,7 +230,14 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
is_starred INTEGER NOT NULL DEFAULT 0,
|
is_starred INTEGER NOT NULL DEFAULT 0,
|
||||||
has_attachments INTEGER NOT NULL DEFAULT 0,
|
has_attachments INTEGER NOT NULL DEFAULT 0,
|
||||||
size_bytes 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 '',
|
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,
|
created_at TEXT NOT NULL,
|
||||||
updated_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 {
|
if err := a.migrateMessagesFromName(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.migrateMessageAuthentication(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := a.migrateUsersForTwoFactor(ctx); err != nil {
|
if err := a.migrateUsersForTwoFactor(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -428,12 +442,56 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
if err := a.migrateSendQueueMessageID(ctx); err != nil {
|
if err := a.migrateSendQueueMessageID(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.migrateIMAPMetadata(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
func (a *App) migrateSendQueueMessageID(ctx context.Context) error {
|
||||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(send_queue)`)
|
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(send_queue)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -582,7 +640,7 @@ func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, messageID := range messageIDs {
|
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 {
|
if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1029,7 +1087,7 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
for _, f := range defaultFolderDefs() {
|
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 {
|
if err != nil {
|
||||||
return "", err
|
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"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -28,25 +29,26 @@ type AttachmentInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type storedMessage struct {
|
type storedMessage struct {
|
||||||
MailboxID string
|
MailboxID string
|
||||||
FolderID string
|
FolderID string
|
||||||
RecipientAddr string
|
RecipientAddr string
|
||||||
MessageUID string
|
MessageUID string
|
||||||
MessageID string
|
MessageID string
|
||||||
Subject string
|
Subject string
|
||||||
From string
|
From string
|
||||||
FromName string
|
FromName string
|
||||||
To []string
|
To []string
|
||||||
CC []string
|
CC []string
|
||||||
BCC []string
|
BCC []string
|
||||||
SentAt time.Time
|
SentAt time.Time
|
||||||
ReceivedAt time.Time
|
ReceivedAt time.Time
|
||||||
Snippet string
|
Snippet string
|
||||||
BodyText string
|
BodyText string
|
||||||
BodyHTML string
|
BodyHTML string
|
||||||
IsRead bool
|
IsRead bool
|
||||||
IsStarred bool
|
IsStarred bool
|
||||||
RawPath string
|
RawPath string
|
||||||
|
Authentication MailAuthentication
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
|
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,
|
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,
|
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
|
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
|
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)
|
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{}
|
items := []MailFolder{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f MailFolder
|
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")
|
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||||
return
|
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, like, like, like, like, like)
|
||||||
}
|
}
|
||||||
args = append(args, limit+1, offset)
|
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 ?`
|
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...)
|
rows, err := a.db.QueryContext(r.Context(), query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -328,7 +331,15 @@ func (a *App) handleMailMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.URL.Query().Get("markRead") != "0" && !msg.IsRead && userHasPermission(currentUser(r), PermissionMailOrganize) {
|
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
|
msg.IsRead = true
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, msg)
|
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())
|
respondError(w, http.StatusForbidden, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, errMailboxQuotaExceeded) {
|
||||||
|
respondError(w, http.StatusInsufficientStorage, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
respondError(w, http.StatusInternalServerError, err.Error())
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -431,6 +446,7 @@ var errInvalidMIME = errors.New("invalid mime message")
|
|||||||
var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit")
|
var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit")
|
||||||
var errSMTPRateLimited = errors.New("smtp send rate limit exceeded")
|
var errSMTPRateLimited = errors.New("smtp send rate limit exceeded")
|
||||||
var errSenderNotAuthorized = errors.New("sender address is not authorized")
|
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) {
|
func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
|
||||||
if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to store sent message: %w", err)
|
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})
|
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 {
|
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)
|
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.RecipientAddr = normalizeEmail(rcpt)
|
||||||
copyMsg.MessageUID = newID("uid")
|
copyMsg.MessageUID = newID("uid")
|
||||||
copyMsg.IsRead = false
|
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
|
continue
|
||||||
}
|
}
|
||||||
if rcptMailbox.Status != "active" {
|
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.RecipientAddr = normalizeEmail(rcpt)
|
||||||
copyMsg.MessageUID = newID("uid")
|
copyMsg.MessageUID = newID("uid")
|
||||||
copyMsg.IsRead = false
|
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
|
continue
|
||||||
}
|
}
|
||||||
@@ -528,6 +552,7 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
|
|||||||
copyMsg.MessageUID = newID("uid")
|
copyMsg.MessageUID = newID("uid")
|
||||||
copyMsg.IsRead = false
|
copyMsg.IsRead = false
|
||||||
if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||||
|
_ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments)
|
||||||
a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
|
a.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")
|
respondError(w, http.StatusInternalServerError, "failed to save draft")
|
||||||
return
|
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)
|
msg, _ := a.messageByID(r.Context(), draftID, true)
|
||||||
respondJSON(w, http.StatusCreated, msg)
|
respondJSON(w, http.StatusCreated, msg)
|
||||||
return
|
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)
|
_ = a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(size_bytes),0) FROM attachments WHERE message_id=?`, draftID).Scan(&attachmentBytes)
|
||||||
size += 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=?`,
|
modSeq, err := a.updateMessageModSeq(r.Context(), draftID, existing.FolderID)
|
||||||
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)
|
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 {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update draft")
|
respondError(w, http.StatusInternalServerError, "failed to update draft")
|
||||||
return
|
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)
|
msg, _ := a.messageByID(r.Context(), draftID, true)
|
||||||
respondJSON(w, http.StatusOK, msg)
|
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")
|
respondError(w, http.StatusNotFound, "draft not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.deleteMessageMaildirFile(r.Context(), msg.ID)
|
||||||
a.deleteMessageFiles(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 {
|
if _, err := a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to delete draft")
|
respondError(w, http.StatusInternalServerError, "failed to delete draft")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
_, _ = a.bumpFolderModSeq(r.Context(), msg.FolderID)
|
||||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
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})
|
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) {
|
func (a *App) handleScheduleSend(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
MailboxID string `json:"mailboxId"`
|
MailboxID string `json:"mailboxId"`
|
||||||
@@ -1081,8 +1330,7 @@ func (a *App) processScheduledSend(ctx context.Context, id, mailboxID, draftID,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if draftID != "" {
|
if draftID != "" {
|
||||||
a.deleteMessageFiles(ctx, draftID)
|
a.deleteMessage(ctx, draftID)
|
||||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, draftID)
|
|
||||||
}
|
}
|
||||||
sentAt := a.now().UTC().Format(time.RFC3339Nano)
|
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 {
|
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 {
|
if req.Read != nil {
|
||||||
read = *req.Read
|
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 {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update message")
|
respondError(w, http.StatusInternalServerError, "failed to update message")
|
||||||
return
|
return
|
||||||
@@ -1142,7 +1399,16 @@ func (a *App) handleStar(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.Starred != nil {
|
if req.Starred != nil {
|
||||||
starred = *req.Starred
|
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 {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update message")
|
respondError(w, http.StatusInternalServerError, "failed to update message")
|
||||||
return
|
return
|
||||||
@@ -1168,8 +1434,7 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusInternalServerError, "failed to load folder")
|
respondError(w, http.StatusInternalServerError, "failed to load folder")
|
||||||
return
|
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 := a.moveMessageMaildir(r.Context(), msg.ID, folderID); err != nil {
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to move message")
|
respondError(w, http.StatusInternalServerError, "failed to move message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1183,14 +1448,20 @@ func (a *App) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.EqualFold(msg.Folder, "Trash") {
|
if strings.EqualFold(msg.Folder, "Trash") {
|
||||||
|
a.deleteMessageMaildirFile(r.Context(), msg.ID)
|
||||||
a.deleteMessageFiles(r.Context(), msg.ID)
|
a.deleteMessageFiles(r.Context(), msg.ID)
|
||||||
_, err = a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, 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 {
|
} else {
|
||||||
trashID, e := a.ensureFolder(r.Context(), msg.MailboxID, "Trash")
|
trashID, e := a.ensureFolder(r.Context(), msg.MailboxID, "Trash")
|
||||||
if e != nil {
|
if e != nil {
|
||||||
err = e
|
err = e
|
||||||
} else {
|
} 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 {
|
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) {
|
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)
|
FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id)
|
||||||
msg, err := scanMessageFull(row, includeBody)
|
msg, err := scanMessageFull(row, includeBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1359,6 +1630,9 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
|
|||||||
size += int64(len(decoded))
|
size += int64(len(decoded))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := a.ensureMailboxQuotaAvailable(ctx, db, msg.MailboxID, size); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
var mailboxID, folderID any
|
var mailboxID, folderID any
|
||||||
if strings.TrimSpace(msg.MailboxID) != "" {
|
if strings.TrimSpace(msg.MailboxID) != "" {
|
||||||
mailboxID = 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) != "" {
|
if strings.TrimSpace(msg.FolderID) != "" {
|
||||||
folderID = 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)
|
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)
|
auth := normalizeMailAuthentication(msg.Authentication)
|
||||||
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)
|
_, 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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -1381,6 +1664,33 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
|
|||||||
return id, nil
|
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 {
|
func (a *App) storeAttachment(ctx context.Context, messageID string, input AttachmentInput) error {
|
||||||
return a.storeAttachmentWithDB(ctx, a.db, messageID, input)
|
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) {
|
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.deleteMessageFiles(ctx, messageID)
|
||||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, 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 }
|
type messageSummaryScanner interface{ Scan(dest ...any) error }
|
||||||
@@ -1588,7 +1904,7 @@ func scanAdminMessageSummary(row messageSummaryScanner) (MailMessage, error) {
|
|||||||
var msg MailMessage
|
var msg MailMessage
|
||||||
var toJSON, ccJSON, bccJSON, sent, received string
|
var toJSON, ccJSON, bccJSON, sent, received string
|
||||||
var read, starred, hasAtt int
|
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 {
|
if err != nil {
|
||||||
return msg, err
|
return msg, err
|
||||||
}
|
}
|
||||||
@@ -1602,7 +1918,7 @@ func scanMessageSummary(row messageSummaryScanner) (MailMessage, error) {
|
|||||||
var msg MailMessage
|
var msg MailMessage
|
||||||
var toJSON, ccJSON, bccJSON, sent, received string
|
var toJSON, ccJSON, bccJSON, sent, received string
|
||||||
var read, starred, hasAtt int
|
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 {
|
if err != nil {
|
||||||
return msg, err
|
return msg, err
|
||||||
}
|
}
|
||||||
@@ -1615,17 +1931,109 @@ func scanMessageSummary(row messageSummaryScanner) (MailMessage, error) {
|
|||||||
func scanMessageFull(row messageSummaryScanner, includeBody bool) (MailMessage, error) {
|
func scanMessageFull(row messageSummaryScanner, includeBody bool) (MailMessage, error) {
|
||||||
var msg MailMessage
|
var msg MailMessage
|
||||||
var toJSON, ccJSON, bccJSON, sent, received string
|
var toJSON, ccJSON, bccJSON, sent, received string
|
||||||
|
var auth MailAuthentication
|
||||||
var read, starred, hasAtt int
|
var read, starred, hasAtt int
|
||||||
var bodyText, bodyHTML string
|
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 {
|
if err != nil {
|
||||||
return msg, err
|
return msg, err
|
||||||
}
|
}
|
||||||
msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON)
|
msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON)
|
||||||
msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received)
|
msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received)
|
||||||
msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt)
|
msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt)
|
||||||
|
msg.Authentication = normalizeMailAuthentication(auth)
|
||||||
if includeBody {
|
if includeBody {
|
||||||
msg.BodyText, msg.BodyHTML = bodyText, bodyHTML
|
msg.BodyText, msg.BodyHTML = bodyText, bodyHTML
|
||||||
}
|
}
|
||||||
return msg, nil
|
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 {
|
if interval <= 0 {
|
||||||
interval = 30 * time.Second
|
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())
|
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)
|
a.log.Warn("initial maildir sync failed", "error", err)
|
||||||
} else if n > 0 {
|
} else if n := counts.total(); n > 0 {
|
||||||
a.log.Info("initial maildir sync imported messages", "count", n)
|
a.log.Info("initial maildir sync processed messages", "count", n)
|
||||||
}
|
}
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
a.maildirHealth.markWorkerStopped()
|
||||||
a.log.Info("maildir sync worker stopped")
|
a.log.Info("maildir sync worker stopped")
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
n, err := a.syncMaildirOnce(ctx)
|
counts, err := a.syncMaildirOnceTracked(ctx, interval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.log.Warn("maildir sync failed", "error", err)
|
a.log.Warn("maildir sync failed", "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if n > 0 {
|
if n := counts.total(); n > 0 {
|
||||||
a.log.Info("maildir sync imported messages", "count", n)
|
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) {
|
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)
|
root := strings.TrimSpace(a.cfg.MaildirRoot)
|
||||||
if root == "" {
|
if root == "" {
|
||||||
return 0, nil
|
return maildirSyncCounts{}, nil
|
||||||
}
|
}
|
||||||
mailboxes, err := a.maildirMailboxes(ctx)
|
mailboxes, err := a.maildirMailboxes(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return maildirSyncCounts{}, err
|
||||||
}
|
}
|
||||||
imported := 0
|
counts := maildirSyncCounts{}
|
||||||
for _, mb := range mailboxes {
|
for _, mb := range mailboxes {
|
||||||
if mb.Unregistered {
|
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 {
|
if err != nil {
|
||||||
return imported, err
|
return counts, err
|
||||||
}
|
}
|
||||||
imported += count
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
folders, err := a.maildirFolders(ctx, mb.ID)
|
folders, err := a.maildirFolders(ctx, mb.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return imported, err
|
return counts, err
|
||||||
}
|
}
|
||||||
base := filepath.Join(root, mb.Domain, mb.LocalPart, "Maildir")
|
base := filepath.Join(root, mb.Domain, mb.LocalPart, "Maildir")
|
||||||
for _, folder := range folders {
|
for _, folder := range folders {
|
||||||
@@ -104,7 +129,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
|||||||
for _, sub := range []string{"new", "cur"} {
|
for _, sub := range []string{"new", "cur"} {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return imported, ctx.Err()
|
return counts, ctx.Err()
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
dir := filepath.Join(folderBase, sub)
|
dir := filepath.Join(folderBase, sub)
|
||||||
@@ -113,26 +138,39 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
|||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return imported, err
|
return counts, err
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
path := filepath.Join(dir, entry.Name())
|
path := filepath.Join(dir, entry.Name())
|
||||||
|
counts.FilesScanned++
|
||||||
ok, err := a.syncMaildirFile(ctx, mb, folder, path)
|
ok, err := a.syncMaildirFile(ctx, mb, folder, path)
|
||||||
if err != nil {
|
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)
|
a.log.Warn("maildir file import failed", "path", path, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ok {
|
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) {
|
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) {
|
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")
|
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||||
imported := 0
|
counts := maildirSyncCounts{}
|
||||||
for _, sub := range []string{"new", "cur"} {
|
for _, sub := range []string{"new", "cur"} {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return imported, ctx.Err()
|
return counts, ctx.Err()
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
dir := filepath.Join(base, sub)
|
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) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return imported, err
|
return counts, err
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
path := filepath.Join(dir, entry.Name())
|
path := filepath.Join(dir, entry.Name())
|
||||||
|
counts.FilesScanned++
|
||||||
ok, err := a.syncUnregisteredMaildirFile(ctx, mb, path)
|
ok, err := a.syncUnregisteredMaildirFile(ctx, mb, path)
|
||||||
if err != nil {
|
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)
|
a.log.Warn("unregistered maildir file import failed", "path", path, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
imported++
|
counts.Imported++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return imported, nil
|
return counts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox, path string) (bool, error) {
|
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 {
|
if exists, err := a.unregisteredMaildirMessageExists(ctx, path, msg.MessageID, msg.RecipientAddr); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
} else if exists {
|
} else if exists {
|
||||||
|
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
_, err = a.insertMessage(ctx, msg, attachments)
|
_, 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.MailboxID = mb.ID
|
||||||
msg.FolderID = folder.ID
|
msg.FolderID = folder.ID
|
||||||
msg.IsRead = !strings.EqualFold(folder.Name, "Inbox")
|
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
|
||||||
msg.RawPath = path
|
msg.RawPath = path
|
||||||
if msg.MessageUID == "" {
|
if msg.MessageUID == "" {
|
||||||
msg.MessageUID = newID("uid")
|
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 {
|
if exists, err := a.maildirMessageExists(ctx, mb.ID, folder.ID, path, msg.MessageID); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
} else if exists {
|
} 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
|
return false, nil
|
||||||
}
|
}
|
||||||
id, err := a.insertMessage(ctx, msg, attachments)
|
id, err := a.insertMessage(ctx, msg, attachments)
|
||||||
@@ -338,6 +393,210 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
|
|||||||
return count > 0, nil
|
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 {
|
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
|
||||||
domain = normalizeDomain(domain)
|
domain = normalizeDomain(domain)
|
||||||
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
|
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
|
receivedAt = sentAt
|
||||||
}
|
}
|
||||||
return storedMessage{
|
return storedMessage{
|
||||||
MessageUID: newID("uid"),
|
MessageUID: newID("uid"),
|
||||||
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
From: from,
|
From: from,
|
||||||
FromName: fromName,
|
FromName: fromName,
|
||||||
To: to,
|
To: to,
|
||||||
CC: cc,
|
CC: cc,
|
||||||
SentAt: sentAt,
|
SentAt: sentAt,
|
||||||
ReceivedAt: receivedAt,
|
ReceivedAt: receivedAt,
|
||||||
Snippet: snippetFrom(bodyText, bodyHTML),
|
Snippet: snippetFrom(bodyText, bodyHTML),
|
||||||
BodyText: bodyText,
|
BodyText: bodyText,
|
||||||
BodyHTML: bodyHTML,
|
BodyHTML: bodyHTML,
|
||||||
IsRead: false,
|
IsRead: false,
|
||||||
|
Authentication: parseMailAuthentication(textproto.MIMEHeader(m.Header)),
|
||||||
}, parsed.Attachments, nil
|
}, 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"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -466,14 +467,12 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
matchMode := strings.TrimSpace(req.MatchMode)
|
rawMatchMode := strings.ToLower(strings.TrimSpace(req.MatchMode))
|
||||||
if matchMode == "" {
|
if rawMatchMode != "" && rawMatchMode != "all" && rawMatchMode != "and" && rawMatchMode != "any" && rawMatchMode != "or" {
|
||||||
matchMode = "all"
|
|
||||||
}
|
|
||||||
if matchMode != "all" && matchMode != "any" {
|
|
||||||
badRequest(w, errors.New("invalid match mode"))
|
badRequest(w, errors.New("invalid match mode"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
matchMode := normalizeRuleMatchMode(rawMatchMode)
|
||||||
conditions := normalizeRuleConditions(req.Conditions, req.FromContains, req.SubjectContains)
|
conditions := normalizeRuleConditions(req.Conditions, req.FromContains, req.SubjectContains)
|
||||||
if len(conditions) == 0 {
|
if len(conditions) == 0 {
|
||||||
badRequest(w, errors.New("rule condition is required"))
|
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")
|
respondError(w, http.StatusInternalServerError, "failed to load stats")
|
||||||
return
|
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")
|
respondError(w, http.StatusInternalServerError, "failed to load attachment stats")
|
||||||
return
|
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)
|
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
|
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...)
|
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)
|
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 {
|
for _, id := range ids {
|
||||||
a.deleteMessageFiles(ctx, id)
|
a.deleteMessage(ctx, id)
|
||||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, id); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return int64(len(ids)), nil
|
return int64(len(ids)), nil
|
||||||
}
|
}
|
||||||
@@ -741,13 +754,31 @@ func (a *App) archiveReadInbox(ctx context.Context, mailboxID string) (int64, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
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`,
|
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND is_read=1`, mailboxID, inboxID)
|
||||||
archiveID, a.now().UTC().Format(time.RFC3339Nano), mailboxID, inboxID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
n, _ := res.RowsAffected()
|
defer rows.Close()
|
||||||
return n, nil
|
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) {
|
func scanContact(row messageSummaryScanner) (Contact, error) {
|
||||||
@@ -832,9 +863,7 @@ func scanRule(row messageSummaryScanner) (MailRule, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
item.Conditions = decodeRuleConditions(conditionsJSON, item.FromContains, item.SubjectContains)
|
item.Conditions = decodeRuleConditions(conditionsJSON, item.FromContains, item.SubjectContains)
|
||||||
item.Actions = decodeRuleActions(actionsJSON, item.Action)
|
item.Actions = decodeRuleActions(actionsJSON, item.Action)
|
||||||
if item.MatchMode == "" {
|
item.MatchMode = normalizeRuleMatchMode(item.MatchMode)
|
||||||
item.MatchMode = "all"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
item.ApplyToExisting = intBool(applyToExisting)
|
item.ApplyToExisting = intBool(applyToExisting)
|
||||||
item.StopProcessing = intBool(stopProcessing)
|
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 {
|
if err := a.db.QueryRowContext(ctx, `SELECT user_id FROM mailboxes WHERE id=?`, mailboxID).Scan(&userID); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
from = normalizeEmail(from)
|
if a.senderBlocked(ctx, userID, mailboxID, from) {
|
||||||
var blocked int
|
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
|
||||||
_ = 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)
|
|
||||||
}
|
|
||||||
return
|
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)
|
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()
|
rows.Close()
|
||||||
msg := ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
|
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 {
|
for _, rule := range rules {
|
||||||
if !ruleMatches(rule, msg) {
|
if !ruleMatches(rule, msg) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
|
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
|
||||||
if rule.StopProcessing {
|
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 {
|
type ruleMessage struct {
|
||||||
ID string
|
ID string
|
||||||
MailboxID string
|
MailboxID string
|
||||||
From string
|
From string
|
||||||
To string
|
To string
|
||||||
Subject string
|
CC string
|
||||||
Snippet string
|
Subject string
|
||||||
BodyText 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 {
|
func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubject string) []MailRuleCondition {
|
||||||
@@ -911,26 +1003,56 @@ func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubjec
|
|||||||
}
|
}
|
||||||
out := []MailRuleCondition{}
|
out := []MailRuleCondition{}
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
field := strings.TrimSpace(item.Field)
|
if normalized, ok := normalizeRuleCondition(item); ok {
|
||||||
operator := strings.TrimSpace(item.Operator)
|
out = append(out, normalized)
|
||||||
value := strings.TrimSpace(item.Value)
|
|
||||||
if value == "" {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
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
|
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 {
|
func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRuleAction {
|
||||||
if len(items) == 0 && strings.TrimSpace(legacyAction) != "" {
|
if len(items) == 0 && strings.TrimSpace(legacyAction) != "" {
|
||||||
items = append(items, MailRuleAction{Type: 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 {
|
if len(conditions) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
matchMode := rule.MatchMode
|
matchMode := normalizeRuleMatchMode(rule.MatchMode)
|
||||||
if matchMode == "" {
|
return ruleConditionsMatch(conditions, matchMode, msg)
|
||||||
matchMode = "all"
|
}
|
||||||
}
|
|
||||||
|
func ruleConditionsMatch(conditions []MailRuleCondition, matchMode string, msg ruleMessage) bool {
|
||||||
matched := 0
|
matched := 0
|
||||||
for _, condition := range conditions {
|
for _, condition := range conditions {
|
||||||
if ruleConditionMatches(condition, msg) {
|
if ruleConditionMatches(condition, msg) {
|
||||||
@@ -1006,12 +1129,17 @@ func ruleMatches(rule MailRule, msg ruleMessage) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ruleConditionMatches(condition MailRuleCondition, 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
|
var source string
|
||||||
switch condition.Field {
|
switch strings.ToLower(strings.TrimSpace(condition.Field)) {
|
||||||
case "from":
|
case "from":
|
||||||
source = msg.From
|
source = msg.From
|
||||||
case "to":
|
case "to":
|
||||||
source = msg.To
|
source = msg.To
|
||||||
|
case "cc":
|
||||||
|
source = msg.CC
|
||||||
case "subject":
|
case "subject":
|
||||||
source = msg.Subject
|
source = msg.Subject
|
||||||
case "body":
|
case "body":
|
||||||
@@ -1019,12 +1147,18 @@ func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
|
|||||||
if source == "" {
|
if source == "" {
|
||||||
source = msg.Snippet
|
source = msg.Snippet
|
||||||
}
|
}
|
||||||
|
case "attachment":
|
||||||
|
source = msg.AttachmentNames
|
||||||
|
case "size":
|
||||||
|
return ruleNumericConditionMatches(condition, msg.SizeBytes)
|
||||||
|
case "date":
|
||||||
|
return ruleDateConditionMatches(condition, msg.ReceivedAt)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
source = strings.ToLower(source)
|
source = strings.ToLower(source)
|
||||||
value := strings.ToLower(condition.Value)
|
value := strings.ToLower(condition.Value)
|
||||||
switch condition.Operator {
|
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
|
||||||
case "contains":
|
case "contains":
|
||||||
return strings.Contains(source, value)
|
return strings.Contains(source, value)
|
||||||
case "not-contains":
|
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 {
|
func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string, actions []MailRuleAction) error {
|
||||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
for _, action := range normalizeRuleActions(actions, "") {
|
for _, action := range normalizeRuleActions(actions, "") {
|
||||||
switch action.Type {
|
switch action.Type {
|
||||||
case "archive":
|
case "archive":
|
||||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "trash":
|
case "trash":
|
||||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "move":
|
case "move":
|
||||||
target := ruleTargetFolder(action.Value)
|
target := ruleTargetFolder(action.Value)
|
||||||
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "star":
|
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
|
return err
|
||||||
}
|
}
|
||||||
case "mark-read":
|
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
|
return err
|
||||||
}
|
}
|
||||||
case "label":
|
case "label":
|
||||||
@@ -1128,19 +1366,21 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
|
|||||||
where += ` AND m.mailbox_id=?`
|
where += ` AND m.mailbox_id=?`
|
||||||
args = append(args, mailboxID)
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
messages := []ruleMessage{}
|
messages := []ruleMessage{}
|
||||||
var count int64
|
var count int64
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var msg ruleMessage
|
var messageID string
|
||||||
var toAddrs sql.NullString
|
if err := rows.Scan(&messageID); err != nil {
|
||||||
if err := rows.Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText); err != nil {
|
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
msg.To = toAddrs.String
|
msg, ok := a.ruleMessageByID(ctx, messageID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !ruleMatches(rule, msg) {
|
if !ruleMatches(rule, msg) {
|
||||||
continue
|
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/starred", a.handleStarredMessages)
|
||||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
|
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(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)).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), a.requirePermission(PermissionMailSend)).Post("/mail/schedule-send", a.handleScheduleSend)
|
||||||
r.With(a.requirePermission(PermissionMailSchedule)).Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
|
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(PermissionAliasesUpdate)).Post("/admin/aliases/{id}", a.handleUpdateAlias)
|
||||||
r.With(a.requirePermission(PermissionAliasesDelete)).Delete("/admin/aliases/{id}", a.handleDeleteAlias)
|
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/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(PermissionMessagesRead)).Get("/admin/messages/{id}", a.handleAdminMessage)
|
||||||
r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment)
|
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/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(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
|
||||||
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
||||||
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
|
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ const (
|
|||||||
sendQueueStatusSending = "sending"
|
sendQueueStatusSending = "sending"
|
||||||
sendQueueStatusDelivered = "delivered"
|
sendQueueStatusDelivered = "delivered"
|
||||||
sendQueueStatusFailed = "failed"
|
sendQueueStatusFailed = "failed"
|
||||||
|
sendQueueStatusCanceled = "canceled"
|
||||||
|
|
||||||
sendAuditAccepted = "accepted"
|
sendAuditAccepted = "accepted"
|
||||||
sendAuditQueued = "queued"
|
sendAuditQueued = "queued"
|
||||||
sendAuditDelivered = "delivered"
|
sendAuditDelivered = "delivered"
|
||||||
sendAuditFailed = "failed"
|
sendAuditFailed = "failed"
|
||||||
sendAuditRetry = "retry"
|
sendAuditRetry = "retry"
|
||||||
|
sendAuditCanceled = "canceled"
|
||||||
|
|
||||||
sendSourceWebmail = "webmail"
|
sendSourceWebmail = "webmail"
|
||||||
sendSourceSubmission = "submission"
|
sendSourceSubmission = "submission"
|
||||||
@@ -85,7 +87,7 @@ func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if existingID != id {
|
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=?`,
|
_, 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)
|
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 {
|
if err != nil {
|
||||||
|
|||||||
@@ -241,6 +241,15 @@ func (a *App) submitSMTPMessage(ctx context.Context, user *User, mb *Mailbox, ma
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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})
|
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 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 {
|
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 {
|
type MailFolder struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
UnreadCount int `json:"unreadCount"`
|
UnreadCount int `json:"unreadCount"`
|
||||||
TotalCount int `json:"totalCount"`
|
TotalCount int `json:"totalCount"`
|
||||||
|
UIDValidity int64 `json:"uidValidity"`
|
||||||
|
UIDNext int64 `json:"uidNext"`
|
||||||
|
HighestModSeq int64 `json:"highestModseq"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MailLabel struct {
|
type MailLabel struct {
|
||||||
@@ -73,32 +76,43 @@ type MailLabel struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MailMessage struct {
|
type MailMessage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
MailboxID string `json:"mailboxId,omitempty"`
|
MailboxID string `json:"mailboxId,omitempty"`
|
||||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||||
FolderID string `json:"folderId"`
|
FolderID string `json:"folderId"`
|
||||||
Folder string `json:"folder"`
|
Folder string `json:"folder"`
|
||||||
MessageUID string `json:"messageUid"`
|
MessageUID string `json:"messageUid"`
|
||||||
MessageID string `json:"messageId"`
|
IMAPUID int64 `json:"imapUid"`
|
||||||
Subject string `json:"subject"`
|
IMAPModSeq int64 `json:"imapModseq"`
|
||||||
From string `json:"from"`
|
MessageID string `json:"messageId"`
|
||||||
FromName string `json:"fromName,omitempty"`
|
Subject string `json:"subject"`
|
||||||
To []string `json:"to"`
|
From string `json:"from"`
|
||||||
CC []string `json:"cc"`
|
FromName string `json:"fromName,omitempty"`
|
||||||
BCC []string `json:"bcc,omitempty"`
|
To []string `json:"to"`
|
||||||
SentAt time.Time `json:"sentAt"`
|
CC []string `json:"cc"`
|
||||||
ReceivedAt time.Time `json:"receivedAt"`
|
BCC []string `json:"bcc,omitempty"`
|
||||||
Snippet string `json:"snippet"`
|
SentAt time.Time `json:"sentAt"`
|
||||||
BodyText string `json:"bodyText,omitempty"`
|
ReceivedAt time.Time `json:"receivedAt"`
|
||||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
Snippet string `json:"snippet"`
|
||||||
IsRead bool `json:"isRead"`
|
BodyText string `json:"bodyText,omitempty"`
|
||||||
IsStarred bool `json:"isStarred"`
|
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||||
HasAttachments bool `json:"hasAttachments"`
|
IsRead bool `json:"isRead"`
|
||||||
SizeBytes int64 `json:"sizeBytes"`
|
IsStarred bool `json:"isStarred"`
|
||||||
Labels []MailLabel `json:"labels,omitempty"`
|
HasAttachments bool `json:"hasAttachments"`
|
||||||
Attachments []Attachment `json:"attachments,omitempty"`
|
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 {
|
type Attachment struct {
|
||||||
@@ -168,9 +182,11 @@ type MailRule struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MailRuleCondition struct {
|
type MailRuleCondition struct {
|
||||||
Field string `json:"field"`
|
Field string `json:"field,omitempty"`
|
||||||
Operator string `json:"operator"`
|
Operator string `json:"operator,omitempty"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value,omitempty"`
|
||||||
|
MatchMode string `json:"matchMode,omitempty"`
|
||||||
|
Conditions []MailRuleCondition `json:"conditions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MailRuleAction struct {
|
type MailRuleAction struct {
|
||||||
@@ -193,7 +209,10 @@ type MailStats struct {
|
|||||||
UnreadMessages int64 `json:"unreadMessages"`
|
UnreadMessages int64 `json:"unreadMessages"`
|
||||||
StarredMessages int64 `json:"starredMessages"`
|
StarredMessages int64 `json:"starredMessages"`
|
||||||
AttachmentCount int64 `json:"attachmentCount"`
|
AttachmentCount int64 `json:"attachmentCount"`
|
||||||
|
AttachmentBytes int64 `json:"attachmentBytes"`
|
||||||
StorageBytes int64 `json:"storageBytes"`
|
StorageBytes int64 `json:"storageBytes"`
|
||||||
|
QuotaBytes int64 `json:"quotaBytes"`
|
||||||
|
QuotaUsedPct float64 `json:"quotaUsedPct"`
|
||||||
ByFolder []MailStatsFolderCount `json:"byFolder"`
|
ByFolder []MailStatsFolderCount `json:"byFolder"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,3 +223,40 @@ type MailStatsFolderCount struct {
|
|||||||
Unread int64 `json:"unread"`
|
Unread int64 `json:"unread"`
|
||||||
Bytes int64 `json:"bytes"`
|
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 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 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 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 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 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 = {
|
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[]
|
labels?: MailLabel[]
|
||||||
|
authentication?: MailAuthentication
|
||||||
}
|
}
|
||||||
export type DNSRecord = { type: string; name: string; value: string; ttl: number }
|
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[] }> }
|
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 DraftPayload = Omit<SendPayload, "attachments"> & { attachments?: SendPayload["attachments"] }
|
||||||
export type ScheduleSendPayload = SendPayload & { draftId?: string; sendAt: string }
|
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 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 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 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 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 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 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 MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
|
||||||
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: 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 = {
|
export type SystemSettings = {
|
||||||
publicHostname: string
|
publicHostname: string
|
||||||
publicBaseUrl: 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"
|
export * from "./api-types"
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 15_000
|
const REQUEST_TIMEOUT_MS = 15_000
|
||||||
@@ -94,7 +94,19 @@ export const api = {
|
|||||||
return request<ListResponse<MailMessage>>(`/api/admin/messages${suffix ? `?${suffix}` : ""}`)
|
return request<ListResponse<MailMessage>>(`/api/admin/messages${suffix ? `?${suffix}` : ""}`)
|
||||||
},
|
},
|
||||||
adminMessage: (id: string) => request<MailMessage>(`/api/admin/messages/${id}`),
|
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"),
|
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) }),
|
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 }),
|
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"),
|
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)}` : ""}`),
|
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 }),
|
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" }),
|
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 }),
|
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" }),
|
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 }) }),
|
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 DOMPurify from "dompurify"
|
||||||
import { useSearchParams } from "react-router-dom"
|
import { useSearchParams } from "react-router-dom"
|
||||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
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 { 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, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
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 { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -25,7 +25,7 @@ import { useToast } from "@/hooks/use-toast"
|
|||||||
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||||
import type { PermissionKey } from "@/lib/api-types"
|
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 }
|
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||||
|
|
||||||
const sectionLabels: Record<Section, string> = {
|
const sectionLabels: Record<Section, string> = {
|
||||||
@@ -36,6 +36,7 @@ const sectionLabels: Record<Section, string> = {
|
|||||||
mailboxes: "邮箱账号",
|
mailboxes: "邮箱账号",
|
||||||
aliases: "别名转发",
|
aliases: "别名转发",
|
||||||
messages: "全部邮件",
|
messages: "全部邮件",
|
||||||
|
sendAudit: "发送审计",
|
||||||
settings: "系统设置",
|
settings: "系统设置",
|
||||||
}
|
}
|
||||||
const sectionKeys = Object.keys(sectionLabels) as Section[]
|
const sectionKeys = Object.keys(sectionLabels) as Section[]
|
||||||
@@ -47,6 +48,7 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
|
|||||||
mailboxes: ["admin.mailboxes.view"],
|
mailboxes: ["admin.mailboxes.view"],
|
||||||
aliases: ["admin.aliases.view"],
|
aliases: ["admin.aliases.view"],
|
||||||
messages: ["admin.messages.view"],
|
messages: ["admin.messages.view"],
|
||||||
|
sendAudit: ["admin.messages.view"],
|
||||||
settings: ["admin.settings.view", "admin.templates.view"],
|
settings: ["admin.settings.view", "admin.templates.view"],
|
||||||
}
|
}
|
||||||
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email"
|
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 === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
|
||||||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />}
|
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />}
|
||||||
|
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
||||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
|
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
|
||||||
</main>
|
</main>
|
||||||
</ScrollArea>
|
</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[] }) {
|
function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const user = me.data?.user
|
const user = me.data?.user
|
||||||
@@ -864,6 +980,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
|||||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
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 [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 [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||||||
const [openRegistration, setOpenRegistration] = React.useState(false)
|
const [openRegistration, setOpenRegistration] = React.useState(false)
|
||||||
@@ -912,6 +1029,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
|||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["admin", "settings"] })
|
qc.invalidateQueries({ queryKey: ["admin", "settings"] })
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "maildir-sync", "health"] })
|
||||||
qc.invalidateQueries({ queryKey: ["dns-records"] })
|
qc.invalidateQueries({ queryKey: ["dns-records"] })
|
||||||
qc.invalidateQueries({ queryKey: ["public-settings"] })
|
qc.invalidateQueries({ queryKey: ["public-settings"] })
|
||||||
toast({ title: "系统设置已保存" })
|
toast({ title: "系统设置已保存" })
|
||||||
@@ -1002,12 +1120,15 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>}
|
</Card>}
|
||||||
|
|
||||||
{settingsTab === "storage" && <Card>
|
{settingsTab === "storage" && <div className="space-y-6">
|
||||||
<CardHeader><CardTitle>存储设置</CardTitle></CardHeader>
|
<Card>
|
||||||
<CardContent>
|
<CardHeader><CardTitle>存储设置</CardTitle></CardHeader>
|
||||||
<Field name="maildirRoot" label="Maildir 根目录" defaultValue={settings?.maildirRoot || ""} required={false} />
|
<CardContent>
|
||||||
</CardContent>
|
<Field name="maildirRoot" label="Maildir 根目录" defaultValue={settings?.maildirRoot || ""} required={false} />
|
||||||
</Card>}
|
</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>
|
{settingsTab === "mail" && <Card>
|
||||||
<CardHeader><CardTitle>邮件设置</CardTitle></CardHeader>
|
<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[] {
|
function parseSemver(tag: string): number[] {
|
||||||
return (tag.startsWith("v") ? tag.slice(1) : tag).split(".").map(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
|
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 }) {
|
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>
|
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 { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
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 { 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, PermissionLimits } from "@/lib/api"
|
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 { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
||||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||||
import { useDisplayMode } from "@/lib/display-mode"
|
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 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 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 MailListResponse = { items?: MailMessage[]; nextCursor?: string }
|
||||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||||
type MailNotificationState = { latestId: string; latestReceivedAt: string }
|
type MailNotificationState = { latestId: string; latestReceivedAt: string }
|
||||||
@@ -69,6 +69,7 @@ type ComposeSendIntent = { title: string; description: string; confirmText: stri
|
|||||||
type MailMenuItem =
|
type MailMenuItem =
|
||||||
| { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number }
|
| { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number }
|
||||||
| { type: "scheduled"; 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 }
|
| { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number }
|
||||||
|
|
||||||
const filterLabels: Record<MailFilter, string> = {
|
const filterLabels: Record<MailFilter, string> = {
|
||||||
@@ -103,6 +104,9 @@ export function MailPage() {
|
|||||||
const [bulkPending, setBulkPending] = React.useState(false)
|
const [bulkPending, setBulkPending] = React.useState(false)
|
||||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||||
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
|
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 [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
|
||||||
const [labelEditMode, setLabelEditMode] = React.useState(false)
|
const [labelEditMode, setLabelEditMode] = React.useState(false)
|
||||||
const [newLabelEditing, setNewLabelEditing] = 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 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 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 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 mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false
|
||||||
const inboxProbe = useQuery({
|
const inboxProbe = useQuery({
|
||||||
queryKey: ["mail-notifications", activeMailboxId],
|
queryKey: ["mail-notifications", activeMailboxId],
|
||||||
@@ -148,7 +155,7 @@ export function MailPage() {
|
|||||||
},
|
},
|
||||||
initialPageParam: "",
|
initialPageParam: "",
|
||||||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
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 })
|
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail })
|
||||||
function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
|
function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
|
||||||
@@ -276,6 +283,28 @@ export function MailPage() {
|
|||||||
onError: (error) => toast({ title: "操作失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
onError: (error) => toast({ title: "操作失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
onSettled: () => setCancelingScheduledId(""),
|
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({
|
const markAllRead = useMutation({
|
||||||
mutationFn: async (items: MailMessage[]) => {
|
mutationFn: async (items: MailMessage[]) => {
|
||||||
const unread = items.filter((message) => !message.isRead)
|
const unread = items.filter((message) => !message.isRead)
|
||||||
@@ -403,6 +432,7 @@ export function MailPage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
||||||
qc.invalidateQueries({ queryKey: ["labels"] }),
|
qc.invalidateQueries({ queryKey: ["labels"] }),
|
||||||
qc.invalidateQueries({ queryKey: ["scheduled-sends"] }),
|
qc.invalidateQueries({ queryKey: ["scheduled-sends"] }),
|
||||||
|
qc.invalidateQueries({ queryKey: ["send-queue"] }),
|
||||||
qc.invalidateQueries({ queryKey: ["mail-notifications"] }),
|
qc.invalidateQueries({ queryKey: ["mail-notifications"] }),
|
||||||
]).finally(() => {
|
]).finally(() => {
|
||||||
setLastAutoRefreshAt(new Date())
|
setLastAutoRefreshAt(new Date())
|
||||||
@@ -429,10 +459,16 @@ export function MailPage() {
|
|||||||
const visibleScheduledItems = scheduledQuery
|
const visibleScheduledItems = scheduledQuery
|
||||||
? scheduledItems.filter((item) => [item.subject, item.snippet, ...(item.to || [])].join(" ").toLowerCase().includes(scheduledQuery))
|
? scheduledItems.filter((item) => [item.subject, item.snippet, ...(item.to || [])].join(" ").toLowerCase().includes(scheduledQuery))
|
||||||
: scheduledItems
|
: 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 labelItems = labels.data?.items || []
|
||||||
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
|
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 emptyMessage = getEmptyMessage(mailView, folder, allMessages.length)
|
||||||
const visibleMessageIds = visibleMessages.map((message) => message.id)
|
const visibleMessageIds = visibleMessages.map((message) => message.id)
|
||||||
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
|
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
|
||||||
@@ -453,6 +489,7 @@ export function MailPage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
||||||
qc.invalidateQueries({ queryKey: ["labels"] }),
|
qc.invalidateQueries({ queryKey: ["labels"] }),
|
||||||
qc.invalidateQueries({ queryKey: ["scheduled-sends"] }),
|
qc.invalidateQueries({ queryKey: ["scheduled-sends"] }),
|
||||||
|
qc.invalidateQueries({ queryKey: ["send-queue"] }),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
async function runBulkAction(action: BulkAction) {
|
async function runBulkAction(action: BulkAction) {
|
||||||
@@ -576,6 +613,13 @@ export function MailPage() {
|
|||||||
setMailFilter("all")
|
setMailFilter("all")
|
||||||
setMobileSidebarOpen(false)
|
setMobileSidebarOpen(false)
|
||||||
}
|
}
|
||||||
|
function openSendQueue() {
|
||||||
|
setMailView("sendQueue")
|
||||||
|
setSelectedLabelId("")
|
||||||
|
setSelectedId(null)
|
||||||
|
setMailFilter("all")
|
||||||
|
setMobileSidebarOpen(false)
|
||||||
|
}
|
||||||
function openLabel(labelId: string) {
|
function openLabel(labelId: string) {
|
||||||
setSelectedLabelId(labelId)
|
setSelectedLabelId(labelId)
|
||||||
setMailView("label")
|
setMailView("label")
|
||||||
@@ -654,9 +698,9 @@ export function MailPage() {
|
|||||||
{mailMenuItems.map((item) => (
|
{mailMenuItems.map((item) => (
|
||||||
<SidebarMenuItem key={item.key}>
|
<SidebarMenuItem key={item.key}>
|
||||||
<SidebarMenuButton
|
<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")}
|
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}
|
{item.icon}
|
||||||
{!sidebarCollapsed && <span>{item.label}</span>}
|
{!sidebarCollapsed && <span>{item.label}</span>}
|
||||||
@@ -772,6 +816,23 @@ export function MailPage() {
|
|||||||
/>
|
/>
|
||||||
) : mailView === "scheduled" ? (
|
) : mailView === "scheduled" ? (
|
||||||
<PermissionEmptyState title="无定时发送权限" description="当前账号不能查看或管理定时发送任务。" onOpenSettings={openSettings} />
|
<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" ? (
|
) : isMobile || displayMode === "compact" ? (
|
||||||
<CompactMailView
|
<CompactMailView
|
||||||
title={viewTitle}
|
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>}
|
{canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>}
|
||||||
<div className="relative basis-full">
|
<div className="relative basis-full">
|
||||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
@@ -924,7 +985,7 @@ export function MailPage() {
|
|||||||
{autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"}
|
{autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"}
|
||||||
</div>
|
</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>}
|
{canOrganizeMail && <Button variant="outline" size="sm" disabled={!activeMailboxId || markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" />全部已读</Button>}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -944,7 +1005,7 @@ export function MailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative w-full max-w-md">
|
<div className="relative w-full max-w-md">
|
||||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{contentView}
|
{contentView}
|
||||||
@@ -954,7 +1015,13 @@ export function MailPage() {
|
|||||||
)}
|
)}
|
||||||
</SidebarProvider>
|
</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
|
<ConfirmDialog
|
||||||
open={!!pendingConfirm}
|
open={!!pendingConfirm}
|
||||||
title={pendingConfirm?.title || ""}
|
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 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 })
|
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) {
|
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 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 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 inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox")
|
||||||
const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0
|
const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0
|
||||||
if (!includeScheduled) return [...folderItems.slice(0, insertAt), starredItem, ...folderItems.slice(insertAt)]
|
const specialItems: MailMenuItem[] = [starredItem]
|
||||||
return [...folderItems.slice(0, insertAt), starredItem, scheduledItem, ...folderItems.slice(insertAt)]
|
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> }
|
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) {
|
function getEmptyMessage(mailView: MailView, folder: string, total: number) {
|
||||||
if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件"
|
if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件"
|
||||||
|
if (mailView === "sendQueue") return total === 0 ? "发送队列为空" : "当前搜索没有匹配的发送任务"
|
||||||
if (total > 0) return "当前筛选条件下没有邮件"
|
if (total > 0) return "当前筛选条件下没有邮件"
|
||||||
if (mailView === "starred") return "暂无星标邮件"
|
if (mailView === "starred") return "暂无星标邮件"
|
||||||
if (mailView === "label") 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"
|
type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "trash" | "spam" | "delete"
|
||||||
|
|
||||||
function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) {
|
function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) {
|
||||||
@@ -1718,6 +1963,7 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
|
|||||||
<MessageMetaRow label="接收时间">
|
<MessageMetaRow label="接收时间">
|
||||||
<span>{formatDateTime(message.receivedAt)}</span>
|
<span>{formatDateTime(message.receivedAt)}</span>
|
||||||
</MessageMetaRow>
|
</MessageMetaRow>
|
||||||
|
<AuthenticationResultRow message={message} />
|
||||||
{availableLabels && onAddLabel && onRemoveLabel && (
|
{availableLabels && onAddLabel && onRemoveLabel && (
|
||||||
<MessageMetaRow label="标签">
|
<MessageMetaRow label="标签">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<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 }) {
|
function MessageMetaRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)]">
|
<div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)]">
|
||||||
|
|||||||
@@ -812,8 +812,14 @@ type RuleCreatePayload = {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const conditionFieldLabels: Record<MailRuleCondition["field"], string> = { from: "发件人地址", to: "收件人地址", subject: "邮件主题", body: "邮件正文" }
|
type RuleConditionField = NonNullable<MailRuleCondition["field"]>
|
||||||
const conditionOperatorLabels: Record<MailRuleCondition["operator"], string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是" }
|
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: "移动到" }
|
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 }) {
|
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])
|
}, [open, labels])
|
||||||
|
|
||||||
function updateCondition(index: number, patch: Partial<MailRuleCondition>) {
|
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>) {
|
function updateAction(index: number, patch: Partial<MailRuleAction>) {
|
||||||
setActions((items) => items.map((item, i) => i === index ? normalizeDraftAction({ ...item, ...patch }, availableLabels) : item))
|
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 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) }
|
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 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
|
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">
|
<div className="space-y-3">
|
||||||
{conditions.map((condition, index) => (
|
{conditions.map((condition, index) => (
|
||||||
<div key={index} className="grid gap-3 md:grid-cols-[220px_150px_minmax(0,1fr)_auto_auto]">
|
<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>
|
<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>
|
||||||
<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>
|
<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>
|
</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" 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>
|
<Button type="button" variant="ghost" size="icon" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1012,9 +1025,38 @@ function normalizeDraftAction(action: MailRuleAction, labels: MailLabel[]): Mail
|
|||||||
return { type: action.type }
|
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 = "") {
|
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[]
|
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) {
|
function actionSummary(action: MailRuleAction) {
|
||||||
@@ -1063,7 +1105,8 @@ function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatsSummary({ stats }: { stats?: MailStats }) {
|
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>
|
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 {
|
namespace inbox {
|
||||||
inbox = yes
|
inbox = yes
|
||||||
mailbox Drafts {
|
mailbox Drafts {
|
||||||
auto = create
|
auto = subscribe
|
||||||
special_use = \Drafts
|
special_use = \Drafts
|
||||||
}
|
}
|
||||||
mailbox Sent {
|
mailbox Sent {
|
||||||
auto = create
|
auto = subscribe
|
||||||
special_use = \Sent
|
special_use = \Sent
|
||||||
}
|
}
|
||||||
mailbox Trash {
|
mailbox Trash {
|
||||||
auto = create
|
auto = subscribe
|
||||||
special_use = \Trash
|
special_use = \Trash
|
||||||
}
|
}
|
||||||
mailbox Archive {
|
mailbox Archive {
|
||||||
auto = create
|
auto = subscribe
|
||||||
special_use = \Archive
|
special_use = \Archive
|
||||||
}
|
}
|
||||||
mailbox Spam {
|
mailbox Spam {
|
||||||
auto = create
|
auto = subscribe
|
||||||
special_use = \Junk
|
special_use = \Junk
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user