Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a572bf13a | |||
| 2b846ac671 | |||
| b3669f189e | |||
| d28ed4adcc | |||
| b36adfdac2 | |||
| 632a8a4896 | |||
| f8d058f7e4 | |||
| 1788d49a59 | |||
| adcc822c9a | |||
| 18db36d937 | |||
| 295a34881d | |||
| 65e4c4f6b5 | |||
| d3bee62acd | |||
| d1876691dd | |||
| 2476fe0c19 | |||
| 7b0717412c | |||
| 5c9bea075a | |||
| fa450c9f9b | |||
| f2457c63ee | |||
| 8d434b9ca8 | |||
| ce9d705df0 | |||
| b309368225 | |||
| 1373479973 | |||
| 23b04bd343 | |||
| 36b4c4c60f |
@@ -0,0 +1,3 @@
|
||||
*.sh text eol=lf
|
||||
deploy/**/entrypoint.sh text eol=lf
|
||||
deploy/**/sync-dkim.sh text eol=lf
|
||||
@@ -191,6 +191,16 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
- 云厂商常默认封禁 25 端口;无法收发公网邮件时先检查端口、安全组、防火墙与反向 DNS。
|
||||
- SQLite 适合单机部署;多节点部署前需要迁移数据库,并同步调整 Postfix/Dovecot 查询配置。
|
||||
|
||||
## SMTP 提交
|
||||
|
||||
- 第三方客户端的 SMTP 提交 `465/587` 由 LanQin API 进程处理。
|
||||
- 启用 SMTP 提交前必须配置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`;API 不会用 localhost 自签证书对外提供 465/587。
|
||||
- Postfix 只保留 `25` 端口,用于公网入站邮件和内部/外部 relay。
|
||||
- Webmail/API 和第三方客户端发信都会先写入 Sent,再进入发送队列。
|
||||
- 发送队列由 LanQin API 后台 worker relay 到 `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`,失败会记录审计并按退避策略重试。
|
||||
- v1 支持本人邮箱发信;如需 send-as,可使用启用的别名转发 source 指向本人邮箱,或在数据库中配置 `send_as_grants`。
|
||||
- 如果客户端随后又通过 IMAP APPEND 写入自己的 Sent 副本,Maildir 同步会按 Sent 文件夹内的 `Message-ID` 去重。
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
|
||||
@@ -7,9 +7,12 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
smtpserver "github.com/emersion/go-smtp"
|
||||
|
||||
"lanqin-email-api/internal/app"
|
||||
)
|
||||
|
||||
@@ -29,6 +32,15 @@ func main() {
|
||||
Handler: svc.Router(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
submissionServers := &app.SubmissionServers{}
|
||||
if strings.TrimSpace(cfg.SubmissionAddr) != "" || strings.TrimSpace(cfg.SubmissionTLSAddr) != "" {
|
||||
tlsConfig, err := app.LoadServerTLSConfig(cfg)
|
||||
if err != nil {
|
||||
logger.Error("failed to initialize TLS config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
submissionServers = svc.NewSubmissionServers(tlsConfig)
|
||||
}
|
||||
|
||||
go func() {
|
||||
logger.Info("LanQin API listening", "addr", cfg.Addr)
|
||||
@@ -37,6 +49,24 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
if submissionServers.Plain != nil {
|
||||
go func() {
|
||||
logger.Info("LanQin SMTP submission listening", "addr", cfg.SubmissionAddr)
|
||||
if err := submissionServers.Plain.ListenAndServe(); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
|
||||
logger.Error("smtp submission server stopped unexpectedly", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if submissionServers.TLS != nil {
|
||||
go func() {
|
||||
logger.Info("LanQin SMTP implicit TLS submission listening", "addr", cfg.SubmissionTLSAddr)
|
||||
if err := submissionServers.TLS.ListenAndServeTLS(); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
|
||||
logger.Error("smtp tls submission server stopped unexpectedly", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -48,5 +78,9 @@ func main() {
|
||||
logger.Error("server shutdown failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := submissionServers.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("smtp submission shutdown failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("server stopped")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ require (
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/emersion/go-smtp v0.24.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
|
||||
@@ -2,6 +2,10 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
|
||||
@@ -715,7 +715,7 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rows.Close()
|
||||
for _, messageID := range messageIDs {
|
||||
a.deleteMessageFiles(r.Context(), messageID)
|
||||
a.deleteMessage(r.Context(), messageID)
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||
if err != nil {
|
||||
@@ -786,7 +786,7 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
args = append(args, limit+1, offset)
|
||||
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
FROM messages m
|
||||
LEFT JOIN folders f ON f.id=m.folder_id
|
||||
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id
|
||||
@@ -833,6 +833,116 @@ func (a *App) handleAdminMessage(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, msg)
|
||||
}
|
||||
|
||||
func (a *App) handleAdminSendAudit(w http.ResponseWriter, r *http.Request) {
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
messageID := strings.TrimSpace(r.URL.Query().Get("messageId"))
|
||||
event := strings.TrimSpace(r.URL.Query().Get("event"))
|
||||
from, err := adminAuditTimeParam(r.URL.Query().Get("from"), false)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
to, err := adminAuditTimeParam(r.URL.Query().Get("to"), true)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := 50
|
||||
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if mailboxID != "" && mailboxID != "all" {
|
||||
where = append(where, "sae.mailbox_id=?")
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
if messageID != "" {
|
||||
where = append(where, "(sq.message_id=? OR m.message_id=? OR sae.sent_message_id=?)")
|
||||
args = append(args, messageID, messageID, messageID)
|
||||
}
|
||||
if event != "" && event != "all" {
|
||||
if !isSendAuditEvent(event) {
|
||||
badRequest(w, errors.New("invalid event"))
|
||||
return
|
||||
}
|
||||
where = append(where, "sae.event=?")
|
||||
args = append(args, event)
|
||||
}
|
||||
if from != "" {
|
||||
where = append(where, "sae.created_at>=?")
|
||||
args = append(args, from)
|
||||
}
|
||||
if to != "" {
|
||||
where = append(where, "sae.created_at<=?")
|
||||
args = append(args, to)
|
||||
}
|
||||
args = append(args, limit+1, offset)
|
||||
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT sae.id,sae.queue_id,sae.mailbox_id,COALESCE(mb.address,''),sae.sent_message_id,COALESCE(sq.message_id,m.message_id,''),sae.source,sae.event,sae.status,sae.mail_from,sae.header_from,sae.recipients_json,sae.error,sae.created_at
|
||||
FROM send_audit_events sae
|
||||
LEFT JOIN mailboxes mb ON mb.id=sae.mailbox_id
|
||||
LEFT JOIN send_queue sq ON sq.id=sae.queue_id
|
||||
LEFT JOIN messages m ON m.id=sae.sent_message_id
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY sae.created_at DESC, sae.id DESC LIMIT ? OFFSET ?`, args...)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send audit")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []SendAuditEvent{}
|
||||
for rows.Next() {
|
||||
var item SendAuditEvent
|
||||
var recipientsJSON, createdAt string
|
||||
if err := rows.Scan(&item.ID, &item.QueueID, &item.MailboxID, &item.MailboxAddress, &item.SentMessageID, &item.MessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan send audit")
|
||||
return
|
||||
}
|
||||
item.Recipients = jsonDecodeSlice(recipientsJSON)
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send audit")
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
next = strconv.Itoa(offset + limit)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func adminAuditTimeParam(value string, endOfDay bool) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return t.UTC().Format(time.RFC3339Nano), nil
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", value); err == nil {
|
||||
if endOfDay {
|
||||
t = t.Add(24*time.Hour - time.Nanosecond)
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339Nano), nil
|
||||
}
|
||||
return "", errors.New("invalid time filter")
|
||||
}
|
||||
|
||||
func isSendAuditEvent(event string) bool {
|
||||
switch event {
|
||||
case sendAuditAccepted, sendAuditQueued, sendAuditRetry, sendAuditDelivered, sendAuditFailed, sendAuditCanceled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
DomainID string `json:"domainId"`
|
||||
@@ -1039,6 +1149,6 @@ func (a *App) ensureFolder(ctx context.Context, mailboxID, folder string) (strin
|
||||
}
|
||||
role := strings.ToLower(folder)
|
||||
id = newID("fld")
|
||||
_, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,created_at) VALUES(?,?,?,?,?)`, id, mailboxID, folder, role, a.now().UTC().Format(time.RFC3339Nano))
|
||||
_, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?)`, id, mailboxID, folder, role, a.newUIDValidity(), 1, 1, a.now().UTC().Format(time.RFC3339Nano))
|
||||
return id, err
|
||||
}
|
||||
|
||||
+239
-10
@@ -22,12 +22,13 @@ import (
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg Config
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
cfg Config
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
maildirHealth *maildirSyncHealthTracker
|
||||
}
|
||||
|
||||
func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
@@ -47,7 +48,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy()}
|
||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker()}
|
||||
if err := a.configureSQLite(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
@@ -71,9 +72,11 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
workerCtx, cancel := context.WithCancel(context.Background())
|
||||
a.workerCancel = cancel
|
||||
go a.scheduledSendWorker(workerCtx)
|
||||
if strings.TrimSpace(cfg.MaildirRoot) != "" {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) != "" {
|
||||
go a.maildirWorker(workerCtx)
|
||||
}
|
||||
go a.sendQueueWorker(workerCtx)
|
||||
go a.smtpEventsCleanupWorker(workerCtx)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -120,6 +123,7 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
permissions_json TEXT NOT NULL DEFAULT '[]',
|
||||
limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}',
|
||||
system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
@@ -198,6 +202,9 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
uid_validity INTEGER NOT NULL DEFAULT 0,
|
||||
uid_next INTEGER NOT NULL DEFAULT 1,
|
||||
highest_modseq INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(mailbox_id, name)
|
||||
)`,
|
||||
@@ -223,7 +230,14 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
is_starred INTEGER NOT NULL DEFAULT 0,
|
||||
has_attachments INTEGER NOT NULL DEFAULT 0,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
auth_results TEXT NOT NULL DEFAULT '',
|
||||
auth_spf TEXT NOT NULL DEFAULT 'unknown',
|
||||
auth_dkim TEXT NOT NULL DEFAULT 'unknown',
|
||||
auth_dmarc TEXT NOT NULL DEFAULT 'unknown',
|
||||
received_spf TEXT NOT NULL DEFAULT '',
|
||||
raw_path TEXT NOT NULL DEFAULT '',
|
||||
imap_uid INTEGER NOT NULL DEFAULT 0,
|
||||
imap_modseq INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
@@ -231,6 +245,60 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, from_name, snippet)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_mailbox_raw_path ON messages(mailbox_id, raw_path) WHERE raw_path <> '' AND mailbox_id IS NOT NULL`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_unregistered_raw_path ON messages(raw_path) WHERE raw_path <> '' AND mailbox_id IS NULL`,
|
||||
`CREATE TABLE IF NOT EXISTS sent_message_dedupe_keys (
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
folder_id TEXT NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||
message_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(mailbox_id, folder_id, message_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS send_as_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
address TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(mailbox_id, address)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS send_queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
sent_message_id TEXT NOT NULL DEFAULT '',
|
||||
message_id TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL,
|
||||
mail_from TEXT NOT NULL,
|
||||
header_from TEXT NOT NULL,
|
||||
recipients_json TEXT NOT NULL,
|
||||
mime_base64 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 5,
|
||||
next_attempt_at TEXT NOT NULL,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
delivered_at TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_send_queue_due ON send_queue(status, next_attempt_at, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS send_audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
queue_id TEXT NOT NULL DEFAULT '',
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
mailbox_id TEXT NOT NULL DEFAULT '',
|
||||
sent_message_id TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
mail_from TEXT NOT NULL DEFAULT '',
|
||||
header_from TEXT NOT NULL DEFAULT '',
|
||||
recipients_json TEXT NOT NULL DEFAULT '[]',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_send_audit_events_created ON send_audit_events(created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
@@ -254,6 +322,28 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
sent_at TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_scheduled_sends_due ON scheduled_sends(status, send_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS smtp_send_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_smtp_send_events_user_created ON smtp_send_events(user_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS imap_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_imap_events_user_created ON imap_events(user_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS pop3_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_pop3_events_user_created ON pop3_events(user_id, created_at)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -334,6 +424,9 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateMessagesFromName(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateMessageAuthentication(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateUsersForTwoFactor(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -343,12 +436,148 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migratePermissionGroupLimits(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateSendQueueMessageID(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateIMAPMetadata(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateMessageAuthentication(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
columns := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull int
|
||||
var dflt any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
columns[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
alter := []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{"auth_results", `ALTER TABLE messages ADD COLUMN auth_results TEXT NOT NULL DEFAULT ''`},
|
||||
{"auth_spf", `ALTER TABLE messages ADD COLUMN auth_spf TEXT NOT NULL DEFAULT 'unknown'`},
|
||||
{"auth_dkim", `ALTER TABLE messages ADD COLUMN auth_dkim TEXT NOT NULL DEFAULT 'unknown'`},
|
||||
{"auth_dmarc", `ALTER TABLE messages ADD COLUMN auth_dmarc TEXT NOT NULL DEFAULT 'unknown'`},
|
||||
{"received_spf", `ALTER TABLE messages ADD COLUMN received_spf TEXT NOT NULL DEFAULT ''`},
|
||||
}
|
||||
for _, item := range alter {
|
||||
if !columns[item.name] {
|
||||
if _, err := a.db.ExecContext(ctx, item.sql); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateSendQueueMessageID(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(send_queue)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasMessageID := false
|
||||
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 {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if name == "message_id" {
|
||||
hasMessageID = true
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasMessageID {
|
||||
if _, err := a.db.ExecContext(ctx, `ALTER TABLE send_queue ADD COLUMN message_id TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM send_queue
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY mailbox_id, source, message_id
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'queued' THEN 0
|
||||
WHEN 'sending' THEN 1
|
||||
WHEN 'failed' THEN 2
|
||||
WHEN 'delivered' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
created_at DESC,
|
||||
id DESC
|
||||
) AS row_num
|
||||
FROM send_queue
|
||||
WHERE message_id <> ''
|
||||
)
|
||||
WHERE row_num > 1
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_send_queue_mailbox_source_message_id ON send_queue(mailbox_id, source, message_id) WHERE message_id <> ''`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(permission_groups)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasLimits := false
|
||||
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 {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if name == "limits_json" {
|
||||
hasLimits = true
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasLimits {
|
||||
return nil
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `ALTER TABLE permission_groups ADD COLUMN limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}'`)
|
||||
return err
|
||||
}
|
||||
|
||||
// migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed()
|
||||
// that implicitly created an admin mailbox with display_name "LanQin Admin".
|
||||
// Current seed() creates mailboxes with display_name = admin email, so this migration
|
||||
@@ -411,7 +640,7 @@ func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
for _, messageID := range messageIDs {
|
||||
a.deleteMessageFiles(ctx, messageID)
|
||||
a.deleteMessage(ctx, messageID)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil {
|
||||
return err
|
||||
@@ -858,7 +1087,7 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
|
||||
return "", err
|
||||
}
|
||||
for _, f := range defaultFolderDefs() {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,created_at) VALUES(?,?,?,?,?)`, newID("fld"), id, f.name, f.role, now)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?)`, newID("fld"), id, f.name, f.role, a.newUIDValidity(), 1, 1, now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,11 @@ type Config struct {
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPRequireTLS bool
|
||||
SubmissionAddr string
|
||||
SubmissionTLSAddr string
|
||||
SubmissionMaxMessageMB int
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
MaildirRoot string
|
||||
MaildirScanSeconds int
|
||||
AllowInsecureHTTP bool
|
||||
@@ -55,6 +60,11 @@ func LoadConfig() Config {
|
||||
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
|
||||
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
|
||||
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
|
||||
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
|
||||
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
|
||||
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
|
||||
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
|
||||
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
|
||||
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
|
||||
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
|
||||
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxMaildirRecentErrors = 10
|
||||
|
||||
type maildirSyncCounts struct {
|
||||
FilesScanned int `json:"filesScanned"`
|
||||
Imported int `json:"imported"`
|
||||
Backfilled int `json:"backfilled"`
|
||||
Cleaned int `json:"cleaned"`
|
||||
FileErrors int `json:"fileErrors"`
|
||||
fileErrorDetails []string `json:"-"`
|
||||
}
|
||||
|
||||
func (c maildirSyncCounts) total() int {
|
||||
return c.Imported + c.Backfilled + c.Cleaned
|
||||
}
|
||||
|
||||
type maildirSyncRun struct {
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Counts maildirSyncCounts `json:"counts"`
|
||||
}
|
||||
|
||||
type maildirSyncHealthResponse struct {
|
||||
Configured bool `json:"configured"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Root string `json:"root"`
|
||||
ScanSeconds int `json:"scanSeconds"`
|
||||
WorkerStarted bool `json:"workerStarted"`
|
||||
Running bool `json:"running"`
|
||||
LastRun *maildirSyncRun `json:"lastRun,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
NextRunAt *time.Time `json:"nextRunAt,omitempty"`
|
||||
RecentErrors []string `json:"recentErrors"`
|
||||
Summary maildirSyncCounts `json:"summary"`
|
||||
}
|
||||
|
||||
type maildirSyncHealthTracker struct {
|
||||
mu sync.Mutex
|
||||
workerStarted bool
|
||||
running bool
|
||||
current *maildirSyncRun
|
||||
lastRun *maildirSyncRun
|
||||
lastError string
|
||||
nextRunAt *time.Time
|
||||
recentErrors []string
|
||||
summary maildirSyncCounts
|
||||
}
|
||||
|
||||
func newMaildirSyncHealthTracker() *maildirSyncHealthTracker {
|
||||
return &maildirSyncHealthTracker{}
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markWorkerStarted(nextRunAt *time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.workerStarted = true
|
||||
h.nextRunAt = cloneTimePtr(nextRunAt)
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markWorkerStopped() {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.workerStarted = false
|
||||
h.nextRunAt = nil
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markRunStarted(startedAt time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
run := &maildirSyncRun{StartedAt: startedAt.UTC(), Status: "running"}
|
||||
h.running = true
|
||||
h.current = run
|
||||
h.lastRun = cloneMaildirSyncRun(run)
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markRunFinished(finishedAt time.Time, counts maildirSyncCounts, err error, nextRunAt *time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
run := h.current
|
||||
if run == nil {
|
||||
run = &maildirSyncRun{StartedAt: finishedAt.UTC()}
|
||||
}
|
||||
finished := finishedAt.UTC()
|
||||
run.FinishedAt = &finished
|
||||
run.DurationMs = finished.Sub(run.StartedAt).Milliseconds()
|
||||
run.Counts = counts
|
||||
run.Status = "success"
|
||||
run.Error = ""
|
||||
if err != nil {
|
||||
run.Status = "error"
|
||||
run.Error = err.Error()
|
||||
h.lastError = run.Error
|
||||
h.pushRecentError(run.Error)
|
||||
} else if counts.FileErrors > 0 {
|
||||
run.Status = "partial"
|
||||
if len(counts.fileErrorDetails) > 0 {
|
||||
run.Error = counts.fileErrorDetails[0]
|
||||
h.lastError = run.Error
|
||||
}
|
||||
for _, detail := range counts.fileErrorDetails {
|
||||
h.pushRecentError(detail)
|
||||
}
|
||||
} else {
|
||||
h.lastError = ""
|
||||
}
|
||||
h.summary.FilesScanned += counts.FilesScanned
|
||||
h.summary.Imported += counts.Imported
|
||||
h.summary.Backfilled += counts.Backfilled
|
||||
h.summary.Cleaned += counts.Cleaned
|
||||
h.summary.FileErrors += counts.FileErrors
|
||||
h.running = false
|
||||
h.current = nil
|
||||
h.lastRun = cloneMaildirSyncRun(run)
|
||||
h.nextRunAt = cloneTimePtr(nextRunAt)
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) snapshot(cfg Config) maildirSyncHealthResponse {
|
||||
root := strings.TrimSpace(cfg.MaildirRoot)
|
||||
scanSeconds := cfg.MaildirScanSeconds
|
||||
if scanSeconds <= 0 {
|
||||
scanSeconds = 30
|
||||
}
|
||||
out := maildirSyncHealthResponse{
|
||||
Configured: root != "",
|
||||
Enabled: root != "",
|
||||
Root: root,
|
||||
ScanSeconds: scanSeconds,
|
||||
}
|
||||
if h == nil {
|
||||
return out
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out.WorkerStarted = h.workerStarted
|
||||
out.Running = h.running
|
||||
out.LastRun = cloneMaildirSyncRun(h.lastRun)
|
||||
out.LastError = h.lastError
|
||||
out.NextRunAt = cloneTimePtr(h.nextRunAt)
|
||||
out.RecentErrors = append([]string(nil), h.recentErrors...)
|
||||
out.Summary = h.summary
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) pushRecentError(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
h.recentErrors = append([]string{value}, h.recentErrors...)
|
||||
if len(h.recentErrors) > maxMaildirRecentErrors {
|
||||
h.recentErrors = h.recentErrors[:maxMaildirRecentErrors]
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMaildirSyncRun(in *maildirSyncRun) *maildirSyncRun {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
out.FinishedAt = cloneTimePtr(in.FinishedAt)
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneTimePtr(in *time.Time) *time.Time {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := in.UTC()
|
||||
return &out
|
||||
}
|
||||
|
||||
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.cfg))
|
||||
}
|
||||
@@ -49,54 +49,79 @@ func (a *App) maildirWorker(ctx context.Context) {
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
nextRunAt := a.now().UTC()
|
||||
a.maildirHealth.markWorkerStarted(&nextRunAt)
|
||||
a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
|
||||
if n, err := a.syncMaildirOnce(ctx); err != nil {
|
||||
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
|
||||
a.log.Warn("initial maildir sync failed", "error", err)
|
||||
} else if n > 0 {
|
||||
a.log.Info("initial maildir sync imported messages", "count", n)
|
||||
} else if n := counts.total(); n > 0 {
|
||||
a.log.Info("initial maildir sync processed messages", "count", n)
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.maildirHealth.markWorkerStopped()
|
||||
a.log.Info("maildir sync worker stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
n, err := a.syncMaildirOnce(ctx)
|
||||
counts, err := a.syncMaildirOnceTracked(ctx, interval)
|
||||
if err != nil {
|
||||
a.log.Warn("maildir sync failed", "error", err)
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
a.log.Info("maildir sync imported messages", "count", n)
|
||||
if n := counts.total(); n > 0 {
|
||||
a.log.Info("maildir sync processed messages", "count", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnceTracked(ctx context.Context, interval time.Duration) (maildirSyncCounts, error) {
|
||||
startedAt := a.now().UTC()
|
||||
a.maildirHealth.markRunStarted(startedAt)
|
||||
counts, err := a.syncMaildirOnceDetailed(ctx)
|
||||
finishedAt := a.now().UTC()
|
||||
var nextRunAt *time.Time
|
||||
if interval > 0 && err == nil {
|
||||
next := finishedAt.Add(interval)
|
||||
nextRunAt = &next
|
||||
}
|
||||
a.maildirHealth.markRunFinished(finishedAt, counts, err, nextRunAt)
|
||||
return counts, err
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
counts, err := a.syncMaildirOnceDetailed(ctx)
|
||||
return counts.total(), err
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
|
||||
root := strings.TrimSpace(a.cfg.MaildirRoot)
|
||||
if root == "" {
|
||||
return 0, nil
|
||||
return maildirSyncCounts{}, nil
|
||||
}
|
||||
mailboxes, err := a.maildirMailboxes(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return maildirSyncCounts{}, err
|
||||
}
|
||||
imported := 0
|
||||
counts := maildirSyncCounts{}
|
||||
for _, mb := range mailboxes {
|
||||
if mb.Unregistered {
|
||||
count, err := a.syncUnregisteredMaildir(ctx, mb)
|
||||
mbCounts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
|
||||
counts.FilesScanned += mbCounts.FilesScanned
|
||||
counts.Imported += mbCounts.Imported
|
||||
counts.FileErrors += mbCounts.FileErrors
|
||||
counts.fileErrorDetails = append(counts.fileErrorDetails, mbCounts.fileErrorDetails...)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
imported += count
|
||||
continue
|
||||
}
|
||||
folders, err := a.maildirFolders(ctx, mb.ID)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
base := filepath.Join(root, mb.Domain, mb.LocalPart, "Maildir")
|
||||
for _, folder := range folders {
|
||||
@@ -104,7 +129,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
for _, sub := range []string{"new", "cur"} {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return imported, ctx.Err()
|
||||
return counts, ctx.Err()
|
||||
default:
|
||||
}
|
||||
dir := filepath.Join(folderBase, sub)
|
||||
@@ -113,26 +138,39 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
counts.FilesScanned++
|
||||
ok, err := a.syncMaildirFile(ctx, mb, folder, path)
|
||||
if err != nil {
|
||||
counts.FileErrors++
|
||||
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
|
||||
a.log.Warn("maildir file import failed", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
imported++
|
||||
counts.Imported++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return imported, nil
|
||||
backfilled, err := a.backfillSQLiteMessagesToMaildir(ctx)
|
||||
if err != nil {
|
||||
return counts, err
|
||||
}
|
||||
counts.Backfilled += backfilled
|
||||
cleaned, err := a.cleanupMissingMaildirMessages(ctx)
|
||||
if err != nil {
|
||||
return counts, err
|
||||
}
|
||||
counts.Cleaned += cleaned
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
@@ -179,12 +217,17 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
|
||||
counts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
|
||||
return counts.Imported, err
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
|
||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
imported := 0
|
||||
counts := maildirSyncCounts{}
|
||||
for _, sub := range []string{"new", "cur"} {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return imported, ctx.Err()
|
||||
return counts, ctx.Err()
|
||||
default:
|
||||
}
|
||||
dir := filepath.Join(base, sub)
|
||||
@@ -193,24 +236,27 @@ func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (i
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
counts.FilesScanned++
|
||||
ok, err := a.syncUnregisteredMaildirFile(ctx, mb, path)
|
||||
if err != nil {
|
||||
counts.FileErrors++
|
||||
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
|
||||
a.log.Warn("unregistered maildir file import failed", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
imported++
|
||||
counts.Imported++
|
||||
}
|
||||
}
|
||||
}
|
||||
return imported, nil
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox, path string) (bool, error) {
|
||||
@@ -248,6 +294,7 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
|
||||
if exists, err := a.unregisteredMaildirMessageExists(ctx, path, msg.MessageID, msg.RecipientAddr); err != nil {
|
||||
return false, err
|
||||
} else if exists {
|
||||
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
|
||||
return false, nil
|
||||
}
|
||||
_, err = a.insertMessage(ctx, msg, attachments)
|
||||
@@ -291,7 +338,7 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
||||
}
|
||||
msg.MailboxID = mb.ID
|
||||
msg.FolderID = folder.ID
|
||||
msg.IsRead = !strings.EqualFold(folder.Name, "Inbox")
|
||||
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
|
||||
msg.RawPath = path
|
||||
if msg.MessageUID == "" {
|
||||
msg.MessageUID = newID("uid")
|
||||
@@ -311,6 +358,14 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
||||
if exists, err := a.maildirMessageExists(ctx, mb.ID, folder.ID, path, msg.MessageID); err != nil {
|
||||
return false, err
|
||||
} else if exists {
|
||||
if _, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if handled, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
|
||||
return false, err
|
||||
} else if handled {
|
||||
return false, nil
|
||||
}
|
||||
id, err := a.insertMessage(ctx, msg, attachments)
|
||||
@@ -338,6 +393,210 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (a *App) attachMaildirRawPathToExisting(ctx context.Context, mailboxID, folderID, rawPath, messageID string) {
|
||||
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path=''`,
|
||||
rawPath, a.now().UTC().Format(time.RFC3339Nano), mailboxID, folderID, messageID); err != nil {
|
||||
a.log.Warn("failed to attach maildir raw path to existing message", "path", rawPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
var samePathID, oldFolderID string
|
||||
var oldRead, oldStarred int
|
||||
var oldModSeq int64
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(folder_id,''),is_read,is_starred,imap_modseq FROM messages WHERE mailbox_id=? AND raw_path=?`, mailboxID, rawPath).Scan(&samePathID, &oldFolderID, &oldRead, &oldStarred, &oldModSeq)
|
||||
if err == nil {
|
||||
if oldFolderID != folderID {
|
||||
if oldFolderID != "" {
|
||||
if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`,
|
||||
folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, samePathID)
|
||||
return err == nil, err
|
||||
}
|
||||
modSeq := oldModSeq
|
||||
if oldRead != boolInt(read) || oldStarred != boolInt(starred) {
|
||||
modSeq, err = a.bumpFolderModSeq(ctx, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,is_read=?,is_starred=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`,
|
||||
rawPath, boolInt(read), boolInt(starred), modSeq, modSeq, now, samePathID)
|
||||
return err == nil, err
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(messageID) == "" {
|
||||
return false, nil
|
||||
}
|
||||
type candidate struct {
|
||||
ID string
|
||||
RawPath string
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE mailbox_id=? AND message_id=? AND message_id <> '' ORDER BY CASE WHEN folder_id=? THEN 0 ELSE 1 END, created_at`, mailboxID, messageID, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var chosen candidate
|
||||
for rows.Next() {
|
||||
var c candidate
|
||||
if err := rows.Scan(&c.ID, &c.RawPath); err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
if c.RawPath == "" || c.RawPath == rawPath {
|
||||
chosen = c
|
||||
break
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(c.RawPath)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
if ok {
|
||||
if _, err := os.Stat(c.RawPath); errors.Is(err, os.ErrNotExist) {
|
||||
chosen = c
|
||||
break
|
||||
} else if err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if chosen.ID == "" {
|
||||
a.removeDuplicateMaildirMessage(ctx, rawPath, mailboxID, folderID, messageID)
|
||||
return false, nil
|
||||
}
|
||||
var previousFolderID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(folder_id,'') FROM messages WHERE id=?`, chosen.ID).Scan(&previousFolderID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if previousFolderID != "" && previousFolderID != folderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, previousFolderID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, chosen.ID)
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailboxID, folderID, messageID string) {
|
||||
var existing string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path<>'' LIMIT 1`, mailboxID, folderID, messageID).Scan(&existing)
|
||||
if err != nil || existing == "" || existing == rawPath {
|
||||
return
|
||||
}
|
||||
a.removeMaildirPath(ctx, rawPath)
|
||||
}
|
||||
|
||||
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND raw_path<>'' AND updated_at<?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
type item struct {
|
||||
ID string
|
||||
RawPath string
|
||||
}
|
||||
var missing []item
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.ID, &it.RawPath); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(it.RawPath)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(it.RawPath); errors.Is(err, os.ErrNotExist) {
|
||||
missing = append(missing, it)
|
||||
} else if err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, it := range missing {
|
||||
a.deleteMessageFiles(ctx, it.ID)
|
||||
var folderID sql.NullString
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, it.ID).Scan(&folderID)
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, it.ID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if folderID.Valid && folderID.String != "" {
|
||||
if _, err := a.bumpFolderModSeq(ctx, folderID.String); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(missing), nil
|
||||
}
|
||||
|
||||
func maildirFlagsFromPath(path, folderName string) (bool, bool) {
|
||||
base := filepath.Base(path)
|
||||
flags := ""
|
||||
hasFlags := false
|
||||
for _, sep := range []string{maildirFlagSeparator(), ":2,", "!2,"} {
|
||||
if idx := strings.LastIndex(base, sep); idx >= 0 {
|
||||
flags = base[idx+len(sep):]
|
||||
hasFlags = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasFlags {
|
||||
return strings.ContainsRune(flags, 'S'), strings.ContainsRune(flags, 'F')
|
||||
}
|
||||
return !strings.EqualFold(folderName, "Inbox"), false
|
||||
}
|
||||
|
||||
func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, rawPath, messageID, recipient string) {
|
||||
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id IS NULL AND recipient_addr=? AND message_id=? AND message_id <> '' AND raw_path=''`,
|
||||
rawPath, a.now().UTC().Format(time.RFC3339Nano), recipient, messageID); err != nil {
|
||||
a.log.Warn("failed to attach unregistered maildir raw path to existing message", "path", rawPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
|
||||
domain = normalizeDomain(domain)
|
||||
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
|
||||
@@ -382,19 +641,20 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
||||
receivedAt = sentAt
|
||||
}
|
||||
return storedMessage{
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
||||
Subject: subject,
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
To: to,
|
||||
CC: cc,
|
||||
SentAt: sentAt,
|
||||
ReceivedAt: receivedAt,
|
||||
Snippet: snippetFrom(bodyText, bodyHTML),
|
||||
BodyText: bodyText,
|
||||
BodyHTML: bodyHTML,
|
||||
IsRead: false,
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
||||
Subject: subject,
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
To: to,
|
||||
CC: cc,
|
||||
SentAt: sentAt,
|
||||
ReceivedAt: receivedAt,
|
||||
Snippet: snippetFrom(bodyText, bodyHTML),
|
||||
BodyText: bodyText,
|
||||
BodyHTML: bodyHTML,
|
||||
IsRead: false,
|
||||
Authentication: parseMailAuthentication(textproto.MIMEHeader(m.Header)),
|
||||
}, parsed.Attachments, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
|
||||
return nil
|
||||
}
|
||||
raw, err := BuildMIME(MIMEMessage{
|
||||
From: msg.From,
|
||||
FromName: msg.FromName,
|
||||
To: msg.To,
|
||||
CC: msg.CC,
|
||||
BCC: msg.BCC,
|
||||
Subject: msg.Subject,
|
||||
Text: msg.BodyText,
|
||||
HTML: msg.BodyHTML,
|
||||
MessageID: msg.MessageID,
|
||||
Date: messageDate(msg),
|
||||
Attachments: attachments,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildir(ctx, messageID, raw, false)
|
||||
}
|
||||
|
||||
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
msg, err := a.storedMessageByID(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := BuildMIME(MIMEMessage{
|
||||
From: msg.From,
|
||||
FromName: msg.FromName,
|
||||
To: msg.To,
|
||||
CC: msg.CC,
|
||||
BCC: msg.BCC,
|
||||
Subject: msg.Subject,
|
||||
Text: msg.BodyText,
|
||||
HTML: msg.BodyHTML,
|
||||
MessageID: msg.MessageID,
|
||||
Date: messageDate(msg),
|
||||
Attachments: attachments,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildir(ctx, messageID, raw, true)
|
||||
}
|
||||
|
||||
func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, raw []byte, replace bool) error {
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildirFolder(ctx, messageID, state.FolderID, raw, replace, false)
|
||||
}
|
||||
|
||||
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if folderID != "" {
|
||||
oldFolderID := state.FolderID
|
||||
state.FolderID = folderID
|
||||
if updateFolder && oldFolderID != "" && oldFolderID != state.FolderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if state.MailboxID == "" || state.FolderID == "" {
|
||||
return nil
|
||||
}
|
||||
if !replace && state.RawPath != "" {
|
||||
if ok, err := a.pathIsUnderMaildirRoot(state.RawPath); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
if _, err := os.Stat(state.RawPath); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folderName, err := a.folderNameByID(ctx, state.FolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
folderBase := maildirFolderPath(base, folderName)
|
||||
subdir := "cur"
|
||||
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
|
||||
subdir = "new"
|
||||
}
|
||||
if err := ensureMaildirFolderDirs(folderBase); err != nil {
|
||||
return err
|
||||
}
|
||||
filename := maildirFilename(messageID, state.MessageID)
|
||||
tmpPath := filepath.Join(folderBase, "tmp", filename)
|
||||
finalPath := filepath.Join(folderBase, subdir, filename)
|
||||
finalPath = maildirPathWithFlags(finalPath, state.IsRead, state.IsStarred)
|
||||
if err := os.WriteFile(tmpPath, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, finalPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if replace || state.RawPath != "" {
|
||||
a.removeMaildirPath(ctx, state.RawPath)
|
||||
}
|
||||
if updateFolder {
|
||||
if state.IMAPUID > 0 && folderID == "" {
|
||||
modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, state.FolderID, finalPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
} else {
|
||||
meta, metaErr := a.nextIMAPMetadata(ctx, a.db, state.FolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, state.FolderID, finalPath, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
}
|
||||
} else {
|
||||
modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, finalPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
state, stateErr := a.maildirMessageState(ctx, messageID)
|
||||
if stateErr != nil {
|
||||
return stateErr
|
||||
}
|
||||
if state.FolderID != "" && state.FolderID != targetFolderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
meta, metaErr := a.nextIMAPMetadata(ctx, a.db, targetFolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
return err
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.MailboxID == "" {
|
||||
return nil
|
||||
}
|
||||
state.FolderID = targetFolderID
|
||||
if state.RawPath == "" {
|
||||
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
|
||||
}
|
||||
if _, err := os.Stat(state.RawPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folderName, err := a.folderNameByID(ctx, targetFolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
folderBase := maildirFolderPath(base, folderName)
|
||||
if err := ensureMaildirFolderDirs(folderBase); err != nil {
|
||||
return err
|
||||
}
|
||||
subdir := "cur"
|
||||
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
|
||||
subdir = "new"
|
||||
}
|
||||
targetPath := filepath.Join(folderBase, subdir, filepath.Base(state.RawPath))
|
||||
if filepath.Clean(targetPath) != filepath.Clean(state.RawPath) {
|
||||
if err := os.Rename(state.RawPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if state.FolderID != "" && state.FolderID != targetFolderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
meta, err := a.nextIMAPMetadata(ctx, a.db, targetFolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, targetPath, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) writeMessageToNewMaildirFolder(ctx context.Context, messageID, folderID string) error {
|
||||
msg, err := a.storedMessageByID(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg.FolderID = folderID
|
||||
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := BuildMIME(MIMEMessage{
|
||||
From: msg.From,
|
||||
FromName: msg.FromName,
|
||||
To: msg.To,
|
||||
CC: msg.CC,
|
||||
BCC: msg.BCC,
|
||||
Subject: msg.Subject,
|
||||
Text: msg.BodyText,
|
||||
HTML: msg.BodyHTML,
|
||||
MessageID: msg.MessageID,
|
||||
Date: messageDate(msg),
|
||||
Attachments: attachments,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildirFolder(ctx, messageID, folderID, raw, true, true)
|
||||
}
|
||||
|
||||
func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
|
||||
var rawPath string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE id=?`, messageID).Scan(&rawPath); err != nil {
|
||||
return
|
||||
}
|
||||
a.removeMaildirPath(ctx, rawPath)
|
||||
}
|
||||
|
||||
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.RawPath == "" {
|
||||
return nil
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(state.RawPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
currentRead := state.IsRead
|
||||
currentStarred := state.IsStarred
|
||||
if read != nil {
|
||||
currentRead = *read
|
||||
}
|
||||
if starred != nil {
|
||||
currentStarred = *starred
|
||||
}
|
||||
targetPath := maildirPathWithFlags(state.RawPath, currentRead, currentStarred)
|
||||
if filepath.Clean(targetPath) == filepath.Clean(state.RawPath) {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(state.RawPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
modSeq, err := a.bumpFolderModSeq(ctx, state.FolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, targetPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
|
||||
rawPath = strings.TrimSpace(rawPath)
|
||||
if rawPath == "" {
|
||||
return
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(rawPath)
|
||||
if err != nil || !ok {
|
||||
if err != nil {
|
||||
a.log.Warn("failed to validate maildir path", "path", rawPath, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := os.Remove(rawPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
a.log.Warn("failed to remove maildir message", "path", rawPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
rows.Close()
|
||||
return 0, ctx.Err()
|
||||
default:
|
||||
}
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, id := range ids {
|
||||
if err := a.rewriteMessageMaildir(ctx, id); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
type maildirMessageState struct {
|
||||
MailboxID string
|
||||
FolderID string
|
||||
MessageID string
|
||||
RawPath string
|
||||
IsRead bool
|
||||
IsStarred bool
|
||||
IMAPUID int64
|
||||
IMAPModSeq int64
|
||||
}
|
||||
|
||||
func (a *App) maildirMessageState(ctx context.Context, id string) (maildirMessageState, error) {
|
||||
var state maildirMessageState
|
||||
var mailboxID, folderID sql.NullString
|
||||
var read, starred int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT mailbox_id,folder_id,message_id,raw_path,is_read,is_starred,imap_uid,imap_modseq FROM messages WHERE id=?`, id).Scan(&mailboxID, &folderID, &state.MessageID, &state.RawPath, &read, &starred, &state.IMAPUID, &state.IMAPModSeq)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
state.MailboxID = mailboxID.String
|
||||
state.FolderID = folderID.String
|
||||
state.IsRead = intBool(read)
|
||||
state.IsStarred = intBool(starred)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (a *App) storedMessageByID(ctx context.Context, id string) (storedMessage, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT COALESCE(mailbox_id,''),COALESCE(folder_id,''),recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,raw_path FROM messages WHERE id=?`, id)
|
||||
var msg storedMessage
|
||||
var toJSON, ccJSON, bccJSON, sent, received string
|
||||
var read, starred int
|
||||
err := row.Scan(&msg.MailboxID, &msg.FolderID, &msg.RecipientAddr, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &msg.BodyText, &msg.BodyHTML, &read, &starred, &msg.RawPath)
|
||||
if err != nil {
|
||||
return msg, err
|
||||
}
|
||||
msg.To = jsonDecodeSlice(toJSON)
|
||||
msg.CC = jsonDecodeSlice(ccJSON)
|
||||
msg.BCC = jsonDecodeSlice(bccJSON)
|
||||
msg.SentAt = parseTime(sent)
|
||||
msg.ReceivedAt = parseTime(received)
|
||||
msg.IsRead = intBool(read)
|
||||
msg.IsStarred = intBool(starred)
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (a *App) attachmentInputsForMessage(ctx context.Context, messageID string) ([]AttachmentInput, error) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type,storage_path FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AttachmentInput
|
||||
for rows.Next() {
|
||||
var filename, contentType, storagePath string
|
||||
if err := rows.Scan(&filename, &contentType, &storagePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, AttachmentInput{Filename: filename, ContentType: contentType, ContentBase64: base64.StdEncoding.EncodeToString(data)})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) maildirMailboxByID(ctx context.Context, mailboxID string) (maildirMailbox, error) {
|
||||
var mb maildirMailbox
|
||||
err := a.db.QueryRowContext(ctx, `SELECT m.id,m.address,m.local_part,d.name FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE m.id=?`, mailboxID).Scan(&mb.ID, &mb.Address, &mb.LocalPart, &mb.Domain)
|
||||
return mb, err
|
||||
}
|
||||
|
||||
func (a *App) folderNameByID(ctx context.Context, folderID string) (string, error) {
|
||||
var name string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT name FROM folders WHERE id=?`, folderID).Scan(&name)
|
||||
return name, err
|
||||
}
|
||||
|
||||
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
|
||||
root := strings.TrimSpace(a.cfg.MaildirRoot)
|
||||
if root == "" || strings.TrimSpace(path) == "" {
|
||||
return false, nil
|
||||
}
|
||||
rootAbs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
pathAbs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rel, err := filepath.Rel(rootAbs, pathAbs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..", nil
|
||||
}
|
||||
|
||||
func ensureMaildirFolderDirs(folderBase string) error {
|
||||
for _, sub := range []string{"tmp", "new", "cur"} {
|
||||
if err := os.MkdirAll(filepath.Join(folderBase, sub), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maildirFilename(messageID, headerMessageID string) string {
|
||||
base := strings.TrimSpace(headerMessageID)
|
||||
if base == "" {
|
||||
base = messageID
|
||||
}
|
||||
return fmt.Sprintf("%d.%s.%s", time.Now().UnixNano(), safeMaildirName(messageID), safeMaildirName(base))
|
||||
}
|
||||
|
||||
func safeMaildirName(value string) string {
|
||||
value = strings.Trim(value, "<>")
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '.', r == '_', r == '-', r == '@':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "._-")
|
||||
if out == "" {
|
||||
out = "message"
|
||||
}
|
||||
if len(out) > 120 {
|
||||
out = out[:120]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func messageDate(msg storedMessage) time.Time {
|
||||
if !msg.SentAt.IsZero() {
|
||||
return msg.SentAt
|
||||
}
|
||||
if !msg.ReceivedAt.IsZero() {
|
||||
return msg.ReceivedAt
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func maildirPathWithFlags(path string, read, starred bool) string {
|
||||
dir := filepath.Dir(path)
|
||||
name := filepath.Base(path)
|
||||
if read || starred {
|
||||
dir = filepath.Join(filepath.Dir(dir), "cur")
|
||||
} else if filepath.Base(dir) == "cur" {
|
||||
dir = filepath.Join(filepath.Dir(dir), "new")
|
||||
}
|
||||
base := name
|
||||
sep := maildirFlagSeparator()
|
||||
existingFlags := ""
|
||||
if idx := strings.LastIndex(base, sep); idx >= 0 {
|
||||
existingFlags = base[idx+len(sep):]
|
||||
base = base[:idx]
|
||||
}
|
||||
flags := preserveMaildirFlags(existingFlags, "SF")
|
||||
if read {
|
||||
flags = appendMaildirFlag(flags, 'S')
|
||||
}
|
||||
if starred {
|
||||
flags = appendMaildirFlag(flags, 'F')
|
||||
}
|
||||
if flags != "" {
|
||||
base += sep + flags
|
||||
}
|
||||
return filepath.Join(dir, base)
|
||||
}
|
||||
|
||||
func preserveMaildirFlags(flags, managed string) string {
|
||||
var b strings.Builder
|
||||
for _, flag := range flags {
|
||||
if strings.ContainsRune(managed, flag) || strings.ContainsRune(b.String(), flag) {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(flag)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func appendMaildirFlag(flags string, flag rune) string {
|
||||
if strings.ContainsRune(flags, flag) {
|
||||
return flags
|
||||
}
|
||||
return flags + string(flag)
|
||||
}
|
||||
|
||||
func maildirFlagSeparator() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "!2,"
|
||||
}
|
||||
return ":2,"
|
||||
}
|
||||
@@ -13,8 +13,12 @@ func (a *App) handlePermissionCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": permissionCatalog()})
|
||||
}
|
||||
|
||||
func (a *App) handleDefaultPermissionLimits(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, defaultPermissionLimits())
|
||||
}
|
||||
|
||||
func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,description,permissions_json,system,created_at,updated_at
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,description,permissions_json,limits_json,system,created_at,updated_at
|
||||
FROM permission_groups
|
||||
ORDER BY created_at ASC,name ASC`)
|
||||
if err != nil {
|
||||
@@ -25,14 +29,15 @@ func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request)
|
||||
items := []PermissionGroup{}
|
||||
for rows.Next() {
|
||||
var item PermissionGroup
|
||||
var raw, created, updated string
|
||||
var rawPermissions, rawLimits, created, updated string
|
||||
var system int
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &raw, &system, &created, &updated); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &rawPermissions, &rawLimits, &system, &created, &updated); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan permission groups")
|
||||
return
|
||||
}
|
||||
item.Permissions = decodeStoredPermissions(raw)
|
||||
item.Permissions = decodeStoredPermissions(rawPermissions)
|
||||
item.Limits = decodeStoredLimits(rawLimits)
|
||||
item.System = intBool(system)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.UpdatedAt = parseTime(updated)
|
||||
@@ -83,9 +88,10 @@ func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits *PermissionLimits `json:"limits"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -105,10 +111,23 @@ func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request
|
||||
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
|
||||
return
|
||||
}
|
||||
limits := defaultPermissionLimits()
|
||||
if req.Limits != nil {
|
||||
var err error
|
||||
limits, err = normalizePermissionLimits(*req.Limits)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !actorCanGrantLimits(currentUser(r), limits) {
|
||||
respondError(w, http.StatusForbidden, "cannot grant limits above your own")
|
||||
return
|
||||
}
|
||||
id := newID("pg")
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,0,?,?)`, id, name, strings.TrimSpace(req.Description), encodePermissions(permissions), now, now); err != nil {
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,0,?,?)`, id, name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), now, now); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
@@ -132,9 +151,10 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits *PermissionLimits `json:"limits"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -154,8 +174,28 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
|
||||
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `UPDATE permission_groups SET name=?,description=?,permissions_json=?,updated_at=? WHERE id=?`,
|
||||
name, strings.TrimSpace(req.Description), encodePermissions(permissions), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
limits := defaultPermissionLimits()
|
||||
if req.Limits != nil {
|
||||
var err error
|
||||
limits, err = normalizePermissionLimits(*req.Limits)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var rawLimits string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT limits_json FROM permission_groups WHERE id=?`, id).Scan(&rawLimits); err != nil {
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
limits = decodeStoredLimits(rawLimits)
|
||||
}
|
||||
if !actorCanGrantLimits(currentUser(r), limits) {
|
||||
respondError(w, http.StatusForbidden, "cannot grant limits above your own")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `UPDATE permission_groups SET name=?,description=?,permissions_json=?,limits_json=?,updated_at=? WHERE id=?`,
|
||||
name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -125,14 +125,138 @@ type PermissionGroupSummary struct {
|
||||
}
|
||||
|
||||
type PermissionGroup struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
System bool `json:"system"`
|
||||
UserCount int `json:"userCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits PermissionLimits `json:"limits"`
|
||||
System bool `json:"system"`
|
||||
UserCount int `json:"userCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PermissionLimits struct {
|
||||
MaxAttachmentMB int `json:"maxAttachmentMb"`
|
||||
SMTPDailyLimit int `json:"smtpDailyLimit"`
|
||||
SMTPMinuteLimit int `json:"smtpMinuteLimit"`
|
||||
IMAPMinuteLimit int `json:"imapMinuteLimit"`
|
||||
POP3MinuteLimit int `json:"pop3MinuteLimit"`
|
||||
}
|
||||
|
||||
func defaultPermissionLimits() PermissionLimits {
|
||||
return PermissionLimits{
|
||||
MaxAttachmentMB: 25,
|
||||
SMTPDailyLimit: 200,
|
||||
SMTPMinuteLimit: 20,
|
||||
IMAPMinuteLimit: 200,
|
||||
POP3MinuteLimit: 150,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePermissionLimits(limits PermissionLimits) (PermissionLimits, error) {
|
||||
if limits.MaxAttachmentMB < 0 {
|
||||
return PermissionLimits{}, errors.New("maxAttachmentMb cannot be negative")
|
||||
}
|
||||
if limits.SMTPDailyLimit < 0 {
|
||||
return PermissionLimits{}, errors.New("smtpDailyLimit cannot be negative")
|
||||
}
|
||||
if limits.SMTPMinuteLimit < 0 {
|
||||
return PermissionLimits{}, errors.New("smtpMinuteLimit cannot be negative")
|
||||
}
|
||||
if limits.IMAPMinuteLimit < 0 {
|
||||
return PermissionLimits{}, errors.New("imapMinuteLimit cannot be negative")
|
||||
}
|
||||
if limits.POP3MinuteLimit < 0 {
|
||||
return PermissionLimits{}, errors.New("pop3MinuteLimit cannot be negative")
|
||||
}
|
||||
return limits, nil
|
||||
}
|
||||
|
||||
func decodeStoredLimits(value string) PermissionLimits {
|
||||
limits := defaultPermissionLimits()
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return limits
|
||||
}
|
||||
_ = json.Unmarshal([]byte(value), &limits)
|
||||
normalized, err := normalizePermissionLimits(limits)
|
||||
if err != nil {
|
||||
return defaultPermissionLimits()
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func encodePermissionLimits(limits PermissionLimits) string {
|
||||
normalized, err := normalizePermissionLimits(limits)
|
||||
if err != nil {
|
||||
normalized = defaultPermissionLimits()
|
||||
}
|
||||
data, _ := json.Marshal(normalized)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func mergePermissionLimits(left, right PermissionLimits) PermissionLimits {
|
||||
return PermissionLimits{
|
||||
MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB),
|
||||
SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit),
|
||||
SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit),
|
||||
IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit),
|
||||
POP3MinuteLimit: mergeLimitValue(left.POP3MinuteLimit, right.POP3MinuteLimit),
|
||||
}
|
||||
}
|
||||
|
||||
func mergeLimitValue(left, right int) int {
|
||||
if left == 0 || right == 0 {
|
||||
return 0
|
||||
}
|
||||
if right > left {
|
||||
return right
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
func minimalLimits() PermissionLimits {
|
||||
// minimalLimits sets every field to 1 so that mergePermissionLimits
|
||||
// (which takes the max of each field) produces correct aggregation
|
||||
// when no group has a limit set for a given field.
|
||||
return PermissionLimits{
|
||||
MaxAttachmentMB: 1,
|
||||
SMTPDailyLimit: 1,
|
||||
SMTPMinuteLimit: 1,
|
||||
IMAPMinuteLimit: 1,
|
||||
POP3MinuteLimit: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func actorCanGrantLimits(actor *User, limits PermissionLimits) bool {
|
||||
if actor == nil {
|
||||
return false
|
||||
}
|
||||
if actor.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
return canGrantLimitValue(actor.Limits.MaxAttachmentMB, limits.MaxAttachmentMB) &&
|
||||
canGrantLimitValue(actor.Limits.SMTPDailyLimit, limits.SMTPDailyLimit) &&
|
||||
canGrantLimitValue(actor.Limits.SMTPMinuteLimit, limits.SMTPMinuteLimit) &&
|
||||
canGrantLimitValue(actor.Limits.IMAPMinuteLimit, limits.IMAPMinuteLimit) &&
|
||||
canGrantLimitValue(actor.Limits.POP3MinuteLimit, limits.POP3MinuteLimit)
|
||||
}
|
||||
|
||||
func canGrantLimitValue(actorLimit, requestedLimit int) bool {
|
||||
if actorLimit == 0 {
|
||||
return true
|
||||
}
|
||||
if requestedLimit == 0 {
|
||||
return false
|
||||
}
|
||||
return requestedLimit <= actorLimit
|
||||
}
|
||||
|
||||
func attachmentLimitBytes(limits PermissionLimits) int64 {
|
||||
if limits.MaxAttachmentMB <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(limits.MaxAttachmentMB) * 1024 * 1024
|
||||
}
|
||||
|
||||
var legacyPermissionExpansions = map[string][]string{
|
||||
@@ -314,6 +438,7 @@ func defaultPermissionGroups() []PermissionGroup {
|
||||
Name: "超级管理员",
|
||||
Description: "拥有全部后台权限,由用户身份决定,不通过权限组分配。",
|
||||
Permissions: allPermissionKeys(),
|
||||
Limits: PermissionLimits{},
|
||||
System: true,
|
||||
},
|
||||
{
|
||||
@@ -321,6 +446,7 @@ func defaultPermissionGroups() []PermissionGroup {
|
||||
Name: "普通用户",
|
||||
Description: "仅可使用自己的邮箱功能,不包含后台权限。",
|
||||
Permissions: regularUserDefaultPermissions(),
|
||||
Limits: defaultPermissionLimits(),
|
||||
System: true,
|
||||
},
|
||||
}
|
||||
@@ -386,15 +512,15 @@ func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE permission_groups SET name=name || ' (' || id || ')' WHERE name=? AND id<>?`, item.Name, item.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
query := `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, permissions_json=excluded.permissions_json, system=excluded.system, updated_at=excluded.updated_at`
|
||||
query := `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, permissions_json=excluded.permissions_json, limits_json=excluded.limits_json, system=excluded.system, updated_at=excluded.updated_at`
|
||||
if item.ID == PermissionGroupRegular {
|
||||
query = `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?)
|
||||
query = `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, system=excluded.system, updated_at=excluded.updated_at`
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, query, item.ID, item.Name, item.Description, encodePermissions(item.Permissions), boolInt(item.System), now, now); err != nil {
|
||||
if _, err := a.db.ExecContext(ctx, query, item.ID, item.Name, item.Description, encodePermissions(item.Permissions), encodePermissionLimits(item.Limits), boolInt(item.System), now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -475,11 +601,16 @@ func (a *App) attachUserAuthorization(ctx context.Context, u *User) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits, err := a.limitsForUser(ctx, u.ID, u.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupIDs, groups, err := a.permissionGroupsForUser(ctx, u.ID, u.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Permissions = permissions
|
||||
u.Limits = limits
|
||||
u.PermissionGroupIDs = groupIDs
|
||||
u.PermissionGroups = groups
|
||||
u.Protected = a.isDefaultAdminUser(u)
|
||||
@@ -526,6 +657,59 @@ func (a *App) permissionsForUser(ctx context.Context, userID, role string) ([]st
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) limitsForUser(ctx context.Context, userID, role string) (PermissionLimits, error) {
|
||||
if role == "admin" {
|
||||
return PermissionLimits{}, nil
|
||||
}
|
||||
limits, ok, err := a.regularGroupLimits(ctx, nil)
|
||||
if err != nil {
|
||||
return PermissionLimits{}, err
|
||||
}
|
||||
if !ok {
|
||||
limits = defaultPermissionLimits()
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT pg.id,pg.limits_json
|
||||
FROM permission_groups pg
|
||||
JOIN user_permission_groups upg ON upg.group_id=pg.id
|
||||
WHERE upg.user_id=?`, userID)
|
||||
if err != nil {
|
||||
return PermissionLimits{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var groupID, raw string
|
||||
if err := rows.Scan(&groupID, &raw); err != nil {
|
||||
return PermissionLimits{}, err
|
||||
}
|
||||
if !isAssignablePermissionGroupID(groupID) {
|
||||
continue
|
||||
}
|
||||
limits = mergePermissionLimits(limits, decodeStoredLimits(raw))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return PermissionLimits{}, err
|
||||
}
|
||||
return limits, nil
|
||||
}
|
||||
|
||||
func (a *App) regularGroupLimits(ctx context.Context, tx *sql.Tx) (PermissionLimits, bool, error) {
|
||||
var raw string
|
||||
query := `SELECT limits_json FROM permission_groups WHERE id=?`
|
||||
var err error
|
||||
if tx != nil {
|
||||
err = tx.QueryRowContext(ctx, query, PermissionGroupRegular).Scan(&raw)
|
||||
} else {
|
||||
err = a.db.QueryRowContext(ctx, query, PermissionGroupRegular).Scan(&raw)
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return PermissionLimits{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return PermissionLimits{}, false, err
|
||||
}
|
||||
return decodeStoredLimits(raw), true, nil
|
||||
}
|
||||
|
||||
func (a *App) addRegularGroupPermissions(ctx context.Context, tx *sql.Tx, seen map[string]bool) error {
|
||||
var raw string
|
||||
query := `SELECT permissions_json FROM permission_groups WHERE id=?`
|
||||
@@ -568,6 +752,21 @@ func (a *App) effectivePermissionsForUserGroups(ctx context.Context, tx *sql.Tx,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) effectiveLimitsForUserGroups(ctx context.Context, tx *sql.Tx, groupIDs []string) (PermissionLimits, error) {
|
||||
limits, ok, err := a.regularGroupLimits(ctx, tx)
|
||||
if err != nil {
|
||||
return PermissionLimits{}, err
|
||||
}
|
||||
if !ok {
|
||||
limits = defaultPermissionLimits()
|
||||
}
|
||||
groupLimits, err := a.limitsForGroupIDs(ctx, tx, groupIDs)
|
||||
if err != nil {
|
||||
return PermissionLimits{}, err
|
||||
}
|
||||
return mergePermissionLimits(limits, groupLimits), nil
|
||||
}
|
||||
|
||||
func (a *App) permissionGroupsForUser(ctx context.Context, userID, role string) ([]string, []PermissionGroupSummary, error) {
|
||||
if role == "admin" {
|
||||
group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "超级管理员"}
|
||||
@@ -742,6 +941,31 @@ func (a *App) permissionsForGroupIDs(ctx context.Context, tx *sql.Tx, groupIDs [
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) limitsForGroupIDs(ctx context.Context, tx *sql.Tx, groupIDs []string) (PermissionLimits, error) {
|
||||
limits := minimalLimits()
|
||||
for _, groupID := range cleanIDList(groupIDs) {
|
||||
if !isAssignablePermissionGroupID(groupID) {
|
||||
return PermissionLimits{}, fmt.Errorf("permission group not assignable: %s", groupID)
|
||||
}
|
||||
var raw string
|
||||
query := `SELECT limits_json FROM permission_groups WHERE id=?`
|
||||
var err error
|
||||
if tx != nil {
|
||||
err = tx.QueryRowContext(ctx, query, groupID).Scan(&raw)
|
||||
} else {
|
||||
err = a.db.QueryRowContext(ctx, query, groupID).Scan(&raw)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return PermissionLimits{}, fmt.Errorf("permission group not found: %s", groupID)
|
||||
}
|
||||
return PermissionLimits{}, err
|
||||
}
|
||||
limits = mergePermissionLimits(limits, decodeStoredLimits(raw))
|
||||
}
|
||||
return limits, nil
|
||||
}
|
||||
|
||||
func (a *App) setUserPermissionGroups(ctx context.Context, tx *sql.Tx, userID string, groupIDs []string, actor *User) error {
|
||||
groupIDs = cleanIDList(groupIDs)
|
||||
for _, groupID := range groupIDs {
|
||||
@@ -756,6 +980,13 @@ func (a *App) setUserPermissionGroups(ctx context.Context, tx *sql.Tx, userID st
|
||||
if !actorCanGrantPermissions(actor, groupPermissions) {
|
||||
return errors.New("cannot assign permissions you do not hold")
|
||||
}
|
||||
groupLimits, err := a.effectiveLimitsForUserGroups(ctx, tx, groupIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !actorCanGrantLimits(actor, groupLimits) {
|
||||
return errors.New("cannot assign limits above your own")
|
||||
}
|
||||
exec := func(query string, args ...any) error {
|
||||
var err error
|
||||
if tx != nil {
|
||||
@@ -778,18 +1009,19 @@ func (a *App) setUserPermissionGroups(ctx context.Context, tx *sql.Tx, userID st
|
||||
}
|
||||
|
||||
func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGroup, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT pg.id,pg.name,pg.description,pg.permissions_json,pg.system,pg.created_at,pg.updated_at,COUNT(upg.user_id)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT pg.id,pg.name,pg.description,pg.permissions_json,pg.limits_json,pg.system,pg.created_at,pg.updated_at,COUNT(upg.user_id)
|
||||
FROM permission_groups pg
|
||||
LEFT JOIN user_permission_groups upg ON upg.group_id=pg.id
|
||||
WHERE pg.id=?
|
||||
GROUP BY pg.id,pg.name,pg.description,pg.permissions_json,pg.system,pg.created_at,pg.updated_at`, id)
|
||||
GROUP BY pg.id,pg.name,pg.description,pg.permissions_json,pg.limits_json,pg.system,pg.created_at,pg.updated_at`, id)
|
||||
var group PermissionGroup
|
||||
var raw, created, updated string
|
||||
var rawPermissions, rawLimits, created, updated string
|
||||
var system int
|
||||
if err := row.Scan(&group.ID, &group.Name, &group.Description, &raw, &system, &created, &updated, &group.UserCount); err != nil {
|
||||
if err := row.Scan(&group.ID, &group.Name, &group.Description, &rawPermissions, &rawLimits, &system, &created, &updated, &group.UserCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
group.Permissions = decodeStoredPermissions(raw)
|
||||
group.Permissions = decodeStoredPermissions(rawPermissions)
|
||||
group.Limits = decodeStoredLimits(rawLimits)
|
||||
group.System = intBool(system)
|
||||
group.CreatedAt = parseTime(created)
|
||||
group.UpdatedAt = parseTime(updated)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -466,14 +467,12 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
matchMode := strings.TrimSpace(req.MatchMode)
|
||||
if matchMode == "" {
|
||||
matchMode = "all"
|
||||
}
|
||||
if matchMode != "all" && matchMode != "any" {
|
||||
rawMatchMode := strings.ToLower(strings.TrimSpace(req.MatchMode))
|
||||
if rawMatchMode != "" && rawMatchMode != "all" && rawMatchMode != "and" && rawMatchMode != "any" && rawMatchMode != "or" {
|
||||
badRequest(w, errors.New("invalid match mode"))
|
||||
return
|
||||
}
|
||||
matchMode := normalizeRuleMatchMode(rawMatchMode)
|
||||
conditions := normalizeRuleConditions(req.Conditions, req.FromContains, req.SubjectContains)
|
||||
if len(conditions) == 0 {
|
||||
badRequest(w, errors.New("rule condition is required"))
|
||||
@@ -639,10 +638,21 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load stats")
|
||||
return
|
||||
}
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount); err != nil {
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id),COALESCE(SUM(a.size_bytes),0) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount, &stats.AttachmentBytes); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load attachment stats")
|
||||
return
|
||||
}
|
||||
if mailboxID != "" {
|
||||
var quotaMB int64
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan("aMB); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
||||
return
|
||||
}
|
||||
stats.QuotaBytes = quotaMB * 1024 * 1024
|
||||
if stats.QuotaBytes > 0 {
|
||||
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
|
||||
}
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
|
||||
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
|
||||
WHERE `+where+` GROUP BY f.id,f.name,f.role ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END`, args...)
|
||||
@@ -723,11 +733,14 @@ func (a *App) deleteMessagesInFolder(ctx context.Context, mailboxID, folder stri
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
a.deleteMessageFiles(ctx, id)
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
a.deleteMessage(ctx, id)
|
||||
}
|
||||
return int64(len(ids)), nil
|
||||
}
|
||||
@@ -741,13 +754,31 @@ func (a *App) archiveReadInbox(ctx context.Context, mailboxID string) (int64, er
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
res, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE mailbox_id=? AND folder_id=? AND is_read=1`,
|
||||
archiveID, a.now().UTC().Format(time.RFC3339Nano), mailboxID, inboxID)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND is_read=1`, mailboxID, inboxID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := a.moveMessageMaildir(ctx, id, archiveID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return int64(len(ids)), nil
|
||||
}
|
||||
|
||||
func scanContact(row messageSummaryScanner) (Contact, error) {
|
||||
@@ -832,9 +863,7 @@ func scanRule(row messageSummaryScanner) (MailRule, error) {
|
||||
if err == nil {
|
||||
item.Conditions = decodeRuleConditions(conditionsJSON, item.FromContains, item.SubjectContains)
|
||||
item.Actions = decodeRuleActions(actionsJSON, item.Action)
|
||||
if item.MatchMode == "" {
|
||||
item.MatchMode = "all"
|
||||
}
|
||||
item.MatchMode = normalizeRuleMatchMode(item.MatchMode)
|
||||
}
|
||||
item.ApplyToExisting = intBool(applyToExisting)
|
||||
item.StopProcessing = intBool(stopProcessing)
|
||||
@@ -856,13 +885,8 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT user_id FROM mailboxes WHERE id=?`, mailboxID).Scan(&userID); err != nil {
|
||||
return
|
||||
}
|
||||
from = normalizeEmail(from)
|
||||
var blocked int
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?`, userID, mailboxID, from).Scan(&blocked)
|
||||
if blocked > 0 {
|
||||
if spamID, err := a.ensureFolder(ctx, mailboxID, "Spam"); err == nil {
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, spamID, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
}
|
||||
if a.senderBlocked(ctx, userID, mailboxID, from) {
|
||||
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at`, userID, mailboxID)
|
||||
@@ -878,26 +902,94 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
||||
}
|
||||
rows.Close()
|
||||
msg := ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,subject,snippet,body_text FROM messages WHERE id=?`, messageID).Scan(&msg.From, &msg.To, &msg.Subject, &msg.Snippet, &msg.BodyText)
|
||||
msg, ok := a.ruleMessageByID(ctx, messageID)
|
||||
if !ok {
|
||||
msg = ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if !ruleMatches(rule, msg) {
|
||||
continue
|
||||
}
|
||||
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
|
||||
if rule.StopProcessing {
|
||||
return
|
||||
break
|
||||
}
|
||||
}
|
||||
if a.senderBlocked(ctx, userID, mailboxID, from) {
|
||||
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) senderBlocked(ctx context.Context, userID, mailboxID, from string) bool {
|
||||
from = normalizeEmail(from)
|
||||
if from == "" {
|
||||
return false
|
||||
}
|
||||
var blocked int
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?`, userID, mailboxID, from).Scan(&blocked)
|
||||
return blocked > 0
|
||||
}
|
||||
|
||||
func (a *App) moveBlockedMessageToSpam(ctx context.Context, messageID, mailboxID string) {
|
||||
spamID, err := a.ensureFolder(ctx, mailboxID, "Spam")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = a.moveMessageMaildir(ctx, messageID, spamID)
|
||||
}
|
||||
|
||||
type ruleMessage struct {
|
||||
ID string
|
||||
MailboxID string
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
Snippet string
|
||||
BodyText string
|
||||
ID string
|
||||
MailboxID string
|
||||
From string
|
||||
To string
|
||||
CC string
|
||||
Subject string
|
||||
Snippet string
|
||||
BodyText string
|
||||
AttachmentNames string
|
||||
SizeBytes int64
|
||||
ReceivedAt time.Time
|
||||
}
|
||||
|
||||
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
|
||||
var msg ruleMessage
|
||||
var toAddrs, ccAddrs, receivedAt string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
||||
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
|
||||
if err != nil {
|
||||
return ruleMessage{}, false
|
||||
}
|
||||
msg.To = ruleAddressText(toAddrs)
|
||||
msg.CC = ruleAddressText(ccAddrs)
|
||||
msg.ReceivedAt = parseTime(receivedAt)
|
||||
msg.AttachmentNames = a.ruleAttachmentNames(ctx, messageID)
|
||||
return msg, true
|
||||
}
|
||||
|
||||
func ruleAddressText(raw string) string {
|
||||
var items []string
|
||||
if strings.TrimSpace(raw) != "" && json.Unmarshal([]byte(raw), &items) == nil {
|
||||
return strings.Join(items, " ")
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer rows.Close()
|
||||
parts := []string{}
|
||||
for rows.Next() {
|
||||
var filename, contentType string
|
||||
if err := rows.Scan(&filename, &contentType); err != nil {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
parts = append(parts, filename, contentType)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubject string) []MailRuleCondition {
|
||||
@@ -911,26 +1003,56 @@ func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubjec
|
||||
}
|
||||
out := []MailRuleCondition{}
|
||||
for _, item := range items {
|
||||
field := strings.TrimSpace(item.Field)
|
||||
operator := strings.TrimSpace(item.Operator)
|
||||
value := strings.TrimSpace(item.Value)
|
||||
if value == "" {
|
||||
continue
|
||||
if normalized, ok := normalizeRuleCondition(item); ok {
|
||||
out = append(out, normalized)
|
||||
}
|
||||
if field != "from" && field != "to" && field != "subject" && field != "body" {
|
||||
continue
|
||||
}
|
||||
if operator == "" {
|
||||
operator = "contains"
|
||||
}
|
||||
if operator != "contains" && operator != "not-contains" && operator != "equals" && operator != "not-equals" && operator != "starts-with" && operator != "ends-with" {
|
||||
continue
|
||||
}
|
||||
out = append(out, MailRuleCondition{Field: field, Operator: operator, Value: value})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
|
||||
matchMode := normalizeRuleMatchMode(item.MatchMode)
|
||||
if len(item.Conditions) > 0 {
|
||||
children := normalizeRuleConditions(item.Conditions, "", "")
|
||||
if len(children) == 0 {
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
return MailRuleCondition{MatchMode: matchMode, Conditions: children}, true
|
||||
}
|
||||
field := strings.ToLower(strings.TrimSpace(item.Field))
|
||||
operator := strings.ToLower(strings.TrimSpace(item.Operator))
|
||||
value := strings.TrimSpace(item.Value)
|
||||
if value == "" {
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
switch field {
|
||||
case "from", "to", "cc", "subject", "body", "attachment", "size", "date":
|
||||
default:
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
if operator == "" {
|
||||
operator = "contains"
|
||||
}
|
||||
switch operator {
|
||||
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
|
||||
case "gt", "gte", "lt", "lte", "before", "after", "on":
|
||||
default:
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
|
||||
}
|
||||
|
||||
func normalizeRuleMatchMode(matchMode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(matchMode)) {
|
||||
case "any", "or":
|
||||
return "any"
|
||||
case "all", "and":
|
||||
return "all"
|
||||
default:
|
||||
return "all"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRuleAction {
|
||||
if len(items) == 0 && strings.TrimSpace(legacyAction) != "" {
|
||||
items = append(items, MailRuleAction{Type: strings.TrimSpace(legacyAction)})
|
||||
@@ -987,10 +1109,11 @@ func ruleMatches(rule MailRule, msg ruleMessage) bool {
|
||||
if len(conditions) == 0 {
|
||||
return false
|
||||
}
|
||||
matchMode := rule.MatchMode
|
||||
if matchMode == "" {
|
||||
matchMode = "all"
|
||||
}
|
||||
matchMode := normalizeRuleMatchMode(rule.MatchMode)
|
||||
return ruleConditionsMatch(conditions, matchMode, msg)
|
||||
}
|
||||
|
||||
func ruleConditionsMatch(conditions []MailRuleCondition, matchMode string, msg ruleMessage) bool {
|
||||
matched := 0
|
||||
for _, condition := range conditions {
|
||||
if ruleConditionMatches(condition, msg) {
|
||||
@@ -1006,12 +1129,17 @@ func ruleMatches(rule MailRule, msg ruleMessage) bool {
|
||||
}
|
||||
|
||||
func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
|
||||
if len(condition.Conditions) > 0 {
|
||||
return ruleConditionsMatch(condition.Conditions, normalizeRuleMatchMode(condition.MatchMode), msg)
|
||||
}
|
||||
var source string
|
||||
switch condition.Field {
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Field)) {
|
||||
case "from":
|
||||
source = msg.From
|
||||
case "to":
|
||||
source = msg.To
|
||||
case "cc":
|
||||
source = msg.CC
|
||||
case "subject":
|
||||
source = msg.Subject
|
||||
case "body":
|
||||
@@ -1019,12 +1147,18 @@ func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
|
||||
if source == "" {
|
||||
source = msg.Snippet
|
||||
}
|
||||
case "attachment":
|
||||
source = msg.AttachmentNames
|
||||
case "size":
|
||||
return ruleNumericConditionMatches(condition, msg.SizeBytes)
|
||||
case "date":
|
||||
return ruleDateConditionMatches(condition, msg.ReceivedAt)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
source = strings.ToLower(source)
|
||||
value := strings.ToLower(condition.Value)
|
||||
switch condition.Operator {
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
|
||||
case "contains":
|
||||
return strings.Contains(source, value)
|
||||
case "not-contains":
|
||||
@@ -1042,35 +1176,139 @@ func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func ruleNumericConditionMatches(condition MailRuleCondition, source int64) bool {
|
||||
value, ok := parseRuleSizeValue(condition.Value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
|
||||
case "gt":
|
||||
return source > value
|
||||
case "gte":
|
||||
return source >= value
|
||||
case "lt":
|
||||
return source < value
|
||||
case "lte":
|
||||
return source <= value
|
||||
case "equals":
|
||||
return source == value
|
||||
case "not-equals":
|
||||
return source != value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseRuleSizeValue(raw string) (int64, bool) {
|
||||
value := strings.ToLower(strings.TrimSpace(raw))
|
||||
multiplier := int64(1)
|
||||
for _, suffix := range []struct {
|
||||
text string
|
||||
multiplier int64
|
||||
}{
|
||||
{"kb", 1024},
|
||||
{"k", 1024},
|
||||
{"mb", 1024 * 1024},
|
||||
{"m", 1024 * 1024},
|
||||
{"gb", 1024 * 1024 * 1024},
|
||||
{"g", 1024 * 1024 * 1024},
|
||||
{"b", 1},
|
||||
} {
|
||||
if strings.HasSuffix(value, suffix.text) {
|
||||
multiplier = suffix.multiplier
|
||||
value = strings.TrimSpace(strings.TrimSuffix(value, suffix.text))
|
||||
break
|
||||
}
|
||||
}
|
||||
n, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n * multiplier, true
|
||||
}
|
||||
|
||||
func ruleDateConditionMatches(condition MailRuleCondition, source time.Time) bool {
|
||||
if source.IsZero() {
|
||||
return false
|
||||
}
|
||||
target, ok := parseRuleDateValue(condition.Value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
source = source.UTC()
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
|
||||
case "before", "lt":
|
||||
return source.Before(target)
|
||||
case "after", "gt":
|
||||
return source.After(target)
|
||||
case "on", "equals":
|
||||
y1, m1, d1 := source.Date()
|
||||
y2, m2, d2 := target.Date()
|
||||
return y1 == y2 && m1 == m2 && d1 == d2
|
||||
case "not-equals":
|
||||
y1, m1, d1 := source.Date()
|
||||
y2, m2, d2 := target.Date()
|
||||
return y1 != y2 || m1 != m2 || d1 != d2
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseRuleDateValue(raw string) (time.Time, bool) {
|
||||
value := strings.TrimSpace(raw)
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, value); err == nil {
|
||||
return t.UTC(), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string, actions []MailRuleAction) error {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, action := range normalizeRuleActions(actions, "") {
|
||||
switch action.Type {
|
||||
case "archive":
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "trash":
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "move":
|
||||
target := ruleTargetFolder(action.Value)
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "star":
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, updated_at=? WHERE id=?`, now, messageID); err != nil {
|
||||
starred := true
|
||||
if err := a.updateMessageMaildirFlags(ctx, messageID, nil, &starred); err != nil {
|
||||
return err
|
||||
}
|
||||
modSeq, err := a.updateMessageModSeq(ctx, messageID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, modSeq, modSeq, now, messageID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "mark-read":
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, now, messageID); err != nil {
|
||||
read := true
|
||||
if err := a.updateMessageMaildirFlags(ctx, messageID, &read, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
modSeq, err := a.updateMessageModSeq(ctx, messageID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, modSeq, modSeq, now, messageID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "label":
|
||||
@@ -1128,19 +1366,21 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
|
||||
where += ` AND m.mailbox_id=?`
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id,m.mailbox_id,trim(m.from_addr || ' ' || COALESCE(m.from_name,'')),m.to_addrs,m.subject,m.snippet,m.body_text FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
messages := []ruleMessage{}
|
||||
var count int64
|
||||
for rows.Next() {
|
||||
var msg ruleMessage
|
||||
var toAddrs sql.NullString
|
||||
if err := rows.Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText); err != nil {
|
||||
var messageID string
|
||||
if err := rows.Scan(&messageID); err != nil {
|
||||
return count, err
|
||||
}
|
||||
msg.To = toAddrs.String
|
||||
msg, ok := a.ruleMessageByID(ctx, messageID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !ruleMatches(rule, msg) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func (a *App) Router() http.Handler {
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(a.corsMiddleware)
|
||||
|
||||
r.Post("/auth-policy", a.handleAuthPolicy)
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "time": a.now().UTC()})
|
||||
})
|
||||
@@ -71,6 +72,10 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
|
||||
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue", a.handleSendQueue)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit)
|
||||
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send-queue/{id}/retry", a.handleRetrySendQueue)
|
||||
r.With(a.requirePermission(PermissionMailSend)).Delete("/mail/send-queue/{id}", a.handleCancelSendQueue)
|
||||
r.With(a.requirePermission(PermissionMailSchedule)).Get("/mail/scheduled-sends", a.handleScheduledSends)
|
||||
r.With(a.requirePermission(PermissionMailSchedule), a.requirePermission(PermissionMailSend)).Post("/mail/schedule-send", a.handleScheduleSend)
|
||||
r.With(a.requirePermission(PermissionMailSchedule)).Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
|
||||
@@ -95,6 +100,7 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionUsersUpdate)).Post("/admin/users/{id}", a.handleUpdateUser)
|
||||
r.With(a.requirePermission(PermissionUsersResetPassword)).Post("/admin/users/{id}/password", a.handleResetUserPassword)
|
||||
r.With(a.requirePermission(PermissionUsersDelete)).Delete("/admin/users/{id}", a.handleDeleteUser)
|
||||
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permission-limits/defaults", a.handleDefaultPermissionLimits)
|
||||
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permissions", a.handlePermissionCatalog)
|
||||
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permission-groups", a.handleListPermissionGroups)
|
||||
r.With(a.requirePermission(PermissionGroupsCreate)).Post("/admin/permission-groups", a.handleCreatePermissionGroup)
|
||||
@@ -113,9 +119,11 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionAliasesUpdate)).Post("/admin/aliases/{id}", a.handleUpdateAlias)
|
||||
r.With(a.requirePermission(PermissionAliasesDelete)).Delete("/admin/aliases/{id}", a.handleDeleteAlias)
|
||||
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/messages", a.handleAdminMessages)
|
||||
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/send-audit", a.handleAdminSendAudit)
|
||||
r.With(a.requirePermission(PermissionMessagesRead)).Get("/admin/messages/{id}", a.handleAdminMessage)
|
||||
r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment)
|
||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/settings", a.handleGetSystemSettings)
|
||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth)
|
||||
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
|
||||
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
||||
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
sendQueueStatusQueued = "queued"
|
||||
sendQueueStatusSending = "sending"
|
||||
sendQueueStatusDelivered = "delivered"
|
||||
sendQueueStatusFailed = "failed"
|
||||
sendQueueStatusCanceled = "canceled"
|
||||
|
||||
sendAuditAccepted = "accepted"
|
||||
sendAuditQueued = "queued"
|
||||
sendAuditDelivered = "delivered"
|
||||
sendAuditFailed = "failed"
|
||||
sendAuditRetry = "retry"
|
||||
sendAuditCanceled = "canceled"
|
||||
|
||||
sendSourceWebmail = "webmail"
|
||||
sendSourceSubmission = "submission"
|
||||
|
||||
sendQueueStaleAfter = 15 * time.Minute
|
||||
sendQueueConcurrency = 4
|
||||
|
||||
sendQueueDeliveredMarkerDir = "send_queue_delivered"
|
||||
)
|
||||
|
||||
type sendQueueInput struct {
|
||||
UserID string
|
||||
MailboxID string
|
||||
SentMessageID string
|
||||
MessageID string
|
||||
Source string
|
||||
MailFrom string
|
||||
HeaderFrom string
|
||||
Recipients []string
|
||||
MIMEBytes []byte
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
type sendQueueItem struct {
|
||||
ID string
|
||||
UserID string
|
||||
MailboxID string
|
||||
SentMessageID string
|
||||
MessageID string
|
||||
Source string
|
||||
MailFrom string
|
||||
HeaderFrom string
|
||||
Recipients []string
|
||||
MIMEBytes []byte
|
||||
AttemptCount int
|
||||
MaxAttempts int
|
||||
}
|
||||
|
||||
func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error) {
|
||||
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
|
||||
return "", nil
|
||||
}
|
||||
now := in.Now.UTC()
|
||||
if now.IsZero() {
|
||||
now = a.now().UTC()
|
||||
}
|
||||
id := newID("snd")
|
||||
messageID := strings.TrimSpace(in.MessageID)
|
||||
mimeBase64 := base64.StdEncoding.EncodeToString(in.MIMEBytes)
|
||||
recipientsJSON := jsonEncode(dedupeEmails(in.Recipients))
|
||||
_, err := a.db.ExecContext(ctx, `INSERT OR IGNORE INTO send_queue(id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,status,next_attempt_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, in.UserID, in.MailboxID, in.SentMessageID, messageID, in.Source, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), recipientsJSON, mimeBase64, sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(messageID) != "" {
|
||||
var existingID, status string
|
||||
var attemptCount, maxAttempts int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id,status,attempt_count,max_attempts FROM send_queue WHERE mailbox_id=? AND source=? AND message_id=?`, in.MailboxID, in.Source, messageID).Scan(&existingID, &status, &attemptCount, &maxAttempts); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if existingID != id {
|
||||
if status == sendQueueStatusDelivered || status == sendQueueStatusCanceled || (status == sendQueueStatusFailed && attemptCount >= maxAttempts) {
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE send_queue SET user_id=?,sent_message_id=?,mail_from=?,header_from=?,recipients_json=?,mime_base64=?,status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=?`,
|
||||
in.UserID, in.SentMessageID, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), recipientsJSON, mimeBase64, sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), existingID, status)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.deleteSendQueueDeliveredMarker(existingID)
|
||||
a.recordSendAudit(ctx, sendAuditQueued, sendQueueStatusQueued, sendAuditInput{
|
||||
QueueID: existingID,
|
||||
UserID: in.UserID,
|
||||
MailboxID: in.MailboxID,
|
||||
SentMessageID: in.SentMessageID,
|
||||
Source: in.Source,
|
||||
MailFrom: in.MailFrom,
|
||||
HeaderFrom: in.HeaderFrom,
|
||||
Recipients: in.Recipients,
|
||||
})
|
||||
}
|
||||
return existingID, nil
|
||||
}
|
||||
}
|
||||
a.recordSendAudit(ctx, sendAuditQueued, sendQueueStatusQueued, sendAuditInput{
|
||||
QueueID: id,
|
||||
UserID: in.UserID,
|
||||
MailboxID: in.MailboxID,
|
||||
SentMessageID: in.SentMessageID,
|
||||
Source: in.Source,
|
||||
MailFrom: in.MailFrom,
|
||||
HeaderFrom: in.HeaderFrom,
|
||||
Recipients: in.Recipients,
|
||||
})
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (a *App) sendQueueWorker(ctx context.Context) {
|
||||
a.log.Info("send queue worker started")
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := a.processDueSendQueue(ctx); err != nil {
|
||||
a.log.Warn("send queue worker failed", "error", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.log.Info("send queue worker stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) processDueSendQueue(ctx context.Context) error {
|
||||
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := a.recoverStaleSendQueueItems(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM send_queue WHERE (status=? OR (status=? AND attempt_count<max_attempts)) AND next_attempt_at<=? ORDER BY next_attempt_at, created_at LIMIT 20`, sendQueueStatusQueued, sendQueueStatusFailed, a.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
sem := make(chan struct{}, sendQueueConcurrency)
|
||||
done := make(chan struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case sem <- struct{}{}:
|
||||
}
|
||||
go func(id string) {
|
||||
defer func() {
|
||||
<-sem
|
||||
done <- struct{}{}
|
||||
}()
|
||||
a.processSendQueueItem(ctx, id)
|
||||
}(id)
|
||||
}
|
||||
for range ids {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) recoverStaleSendQueueItems(ctx context.Context) error {
|
||||
cutoff := a.now().UTC().Add(-sendQueueStaleAfter).Format(time.RFC3339Nano)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,attempt_count,max_attempts FROM send_queue WHERE status=? AND updated_at<=? AND attempt_count<max_attempts LIMIT 20`, sendQueueStatusSending, cutoff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []sendQueueItem
|
||||
for rows.Next() {
|
||||
var item sendQueueItem
|
||||
var recipientsJSON, mimeBase64 string
|
||||
if err := rows.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.SentMessageID, &item.MessageID, &item.Source, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &mimeBase64, &item.AttemptCount, &item.MaxAttempts); err != nil {
|
||||
return err
|
||||
}
|
||||
item.Recipients = jsonDecodeSlice(recipientsJSON)
|
||||
delivered, err := a.hasSendQueueDeliveredMarker(item.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delivered {
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
mimeBytes, err := base64.StdEncoding.DecodeString(mimeBase64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item.MIMEBytes = mimeBytes
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, item := range items {
|
||||
delivered, err := a.hasSendQueueDeliveredMarker(item.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delivered {
|
||||
if err := a.markSendQueueDelivered(ctx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
res, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,next_attempt_at=?,last_error=?,updated_at=? WHERE id=? AND status=?`, sendQueueStatusFailed, now, "send attempt interrupted", now, item.ID, sendQueueStatusSending)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
a.recordSendAudit(ctx, sendAuditRetry, sendQueueStatusFailed, sendAuditInputFromQueue(item, "send attempt interrupted"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) processSendQueueItem(ctx context.Context, id string) {
|
||||
item, err := a.claimSendQueueItem(ctx, id)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
a.log.Warn("failed to claim send queue item", "id", id, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := a.sendSMTP(item.MailFrom, item.Recipients, item.MIMEBytes); err != nil {
|
||||
a.markSendQueueFailed(ctx, item, err)
|
||||
return
|
||||
}
|
||||
if err := a.writeSendQueueDeliveredMarker(item.ID); err != nil {
|
||||
a.log.Warn("failed to persist send queue delivered marker", "id", item.ID, "error", err)
|
||||
}
|
||||
if err := a.markSendQueueDelivered(ctx, item); err != nil {
|
||||
a.log.Warn("failed to mark send queue delivered", "id", item.ID, "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) markSendQueueDelivered(ctx context.Context, item sendQueueItem) error {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,delivered_at=?,updated_at=?,last_error='',mime_base64='' WHERE id=?`, sendQueueStatusDelivered, now, now, item.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
a.deleteSendQueueDeliveredMarker(item.ID)
|
||||
a.recordSendAudit(ctx, sendAuditDelivered, sendQueueStatusDelivered, sendAuditInputFromQueue(item, ""))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) claimSendQueueItem(ctx context.Context, id string) (sendQueueItem, error) {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
res, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,attempt_count=attempt_count+1,updated_at=? WHERE id=? AND (status=? OR (status=? AND attempt_count<max_attempts)) AND next_attempt_at<=?`, sendQueueStatusSending, now, id, sendQueueStatusQueued, sendQueueStatusFailed, now)
|
||||
if err != nil {
|
||||
return sendQueueItem{}, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return sendQueueItem{}, sql.ErrNoRows
|
||||
}
|
||||
var item sendQueueItem
|
||||
var recipientsJSON, mimeBase64 string
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,attempt_count,max_attempts FROM send_queue WHERE id=?`, id)
|
||||
if err := row.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.SentMessageID, &item.MessageID, &item.Source, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &mimeBase64, &item.AttemptCount, &item.MaxAttempts); err != nil {
|
||||
return sendQueueItem{}, err
|
||||
}
|
||||
item.Recipients = jsonDecodeSlice(recipientsJSON)
|
||||
mimeBytes, err := base64.StdEncoding.DecodeString(mimeBase64)
|
||||
if err != nil {
|
||||
return sendQueueItem{}, err
|
||||
}
|
||||
item.MIMEBytes = mimeBytes
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (a *App) markSendQueueFailed(ctx context.Context, item sendQueueItem, sendErr error) {
|
||||
now := a.now().UTC()
|
||||
status := sendQueueStatusFailed
|
||||
nextAttempt := now.Add(sendRetryDelay(item.AttemptCount))
|
||||
if item.AttemptCount >= item.MaxAttempts {
|
||||
nextAttempt = now.Add(365 * 24 * time.Hour)
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,next_attempt_at=?,last_error=?,updated_at=? WHERE id=?`, status, nextAttempt.Format(time.RFC3339Nano), sendErr.Error(), now.Format(time.RFC3339Nano), item.ID)
|
||||
if err != nil {
|
||||
a.log.Warn("failed to mark send queue failed", "id", item.ID, "error", err)
|
||||
}
|
||||
event := sendAuditRetry
|
||||
if item.AttemptCount >= item.MaxAttempts {
|
||||
event = sendAuditFailed
|
||||
}
|
||||
a.recordSendAudit(ctx, event, status, sendAuditInputFromQueue(item, sendErr.Error()))
|
||||
}
|
||||
|
||||
func sendRetryDelay(attempt int) time.Duration {
|
||||
if attempt < 1 {
|
||||
attempt = 1
|
||||
}
|
||||
delays := []time.Duration{30 * time.Second, 2 * time.Minute, 10 * time.Minute, time.Hour, 6 * time.Hour}
|
||||
if attempt > len(delays) {
|
||||
return delays[len(delays)-1]
|
||||
}
|
||||
return delays[attempt-1]
|
||||
}
|
||||
|
||||
type sendAuditInput struct {
|
||||
QueueID string
|
||||
UserID string
|
||||
MailboxID string
|
||||
SentMessageID string
|
||||
Source string
|
||||
MailFrom string
|
||||
HeaderFrom string
|
||||
Recipients []string
|
||||
Error string
|
||||
}
|
||||
|
||||
func sendAuditInputFromQueue(item sendQueueItem, errorText string) sendAuditInput {
|
||||
return sendAuditInput{
|
||||
QueueID: item.ID,
|
||||
UserID: item.UserID,
|
||||
MailboxID: item.MailboxID,
|
||||
SentMessageID: item.SentMessageID,
|
||||
Source: item.Source,
|
||||
MailFrom: item.MailFrom,
|
||||
HeaderFrom: item.HeaderFrom,
|
||||
Recipients: item.Recipients,
|
||||
Error: errorText,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) recordSendAudit(ctx context.Context, event, status string, in sendAuditInput) {
|
||||
source := strings.TrimSpace(in.Source)
|
||||
if source == "" {
|
||||
source = "unknown"
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `INSERT INTO send_audit_events(id,queue_id,user_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, newID("audit"), in.QueueID, in.UserID, in.MailboxID, in.SentMessageID, source, event, status, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), jsonEncode(dedupeEmails(in.Recipients)), in.Error, a.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
a.log.Warn("failed to record send audit", "event", event, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) sendQueueDeliveredMarkerPath(id string) string {
|
||||
safeID := filepath.Base(strings.TrimSpace(id))
|
||||
if safeID == "" || safeID == "." {
|
||||
safeID = "unknown"
|
||||
}
|
||||
return filepath.Join(a.cfg.DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
|
||||
}
|
||||
|
||||
func (a *App) writeSendQueueDeliveredMarker(id string) error {
|
||||
path := a.sendQueueDeliveredMarkerPath(id)
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := filepath.Join(dir, filepath.Base(path)+"."+newID("tmp"))
|
||||
if err := os.WriteFile(tmp, []byte(a.now().UTC().Format(time.RFC3339Nano)), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) hasSendQueueDeliveredMarker(id string) (bool, error) {
|
||||
_, err := os.Stat(a.sendQueueDeliveredMarkerPath(id))
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (a *App) deleteSendQueueDeliveredMarker(id string) {
|
||||
err := os.Remove(a.sendQueueDeliveredMarkerPath(id))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
a.log.Warn("failed to remove send queue delivered marker", "id", id, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) authorizedSender(ctx context.Context, mb *Mailbox, from string) (string, string, error) {
|
||||
from = normalizeEmail(from)
|
||||
if from == "" {
|
||||
from = normalizeEmail(mb.Address)
|
||||
}
|
||||
if from == normalizeEmail(mb.Address) {
|
||||
return normalizeEmail(mb.Address), mb.DisplayName, nil
|
||||
}
|
||||
var displayName string
|
||||
var enabled int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT display_name,enabled FROM send_as_grants WHERE mailbox_id=? AND address=?`, mb.ID, from).Scan(&displayName, &enabled)
|
||||
if err == nil {
|
||||
if enabled == 0 {
|
||||
return "", "", errSenderNotAuthorized
|
||||
}
|
||||
return from, strings.TrimSpace(displayName), nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", err
|
||||
}
|
||||
var aliasDestination string
|
||||
err = a.db.QueryRowContext(ctx, `SELECT destination FROM aliases WHERE source=? AND enabled=1`, from).Scan(&aliasDestination)
|
||||
if err == nil {
|
||||
for _, destination := range strings.Split(aliasDestination, ",") {
|
||||
if normalizeEmail(destination) == normalizeEmail(mb.Address) {
|
||||
return from, mb.DisplayName, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", errSenderNotAuthorized
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
netmail "net/mail"
|
||||
"net/textproto"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
smtpserver "github.com/emersion/go-smtp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSubmissionMaxRecipients = 200
|
||||
)
|
||||
|
||||
type SubmissionServers struct {
|
||||
Plain *smtpserver.Server
|
||||
TLS *smtpserver.Server
|
||||
}
|
||||
|
||||
func (s *SubmissionServers) Shutdown(ctx context.Context) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
var errs []error
|
||||
if s.Plain != nil {
|
||||
if err := s.Plain.Shutdown(ctx); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if s.TLS != nil {
|
||||
if err := s.TLS.Shutdown(ctx); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers {
|
||||
return &SubmissionServers{
|
||||
Plain: a.newSubmissionServer(a.cfg.SubmissionAddr, tlsConfig),
|
||||
TLS: a.newSubmissionServer(a.cfg.SubmissionTLSAddr, tlsConfig),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) newSubmissionServer(addr string, tlsConfig *tls.Config) *smtpserver.Server {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
return nil
|
||||
}
|
||||
s := smtpserver.NewServer(submissionBackend{app: a})
|
||||
s.Addr = addr
|
||||
s.Domain = a.cfg.PublicHostname
|
||||
s.TLSConfig = tlsConfig
|
||||
s.AllowInsecureAuth = false
|
||||
s.MaxRecipients = defaultSubmissionMaxRecipients
|
||||
s.MaxMessageBytes = int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
|
||||
s.ReadTimeout = smtpSessionTimeout
|
||||
s.WriteTimeout = smtpSessionTimeout
|
||||
s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0)
|
||||
return s
|
||||
}
|
||||
|
||||
func LoadServerTLSConfig(cfg Config) (*tls.Config, error) {
|
||||
certFile, keyFile := strings.TrimSpace(cfg.TLSCertFile), strings.TrimSpace(cfg.TLSKeyFile)
|
||||
if certFile == "" || keyFile == "" {
|
||||
return nil, errors.New("LANQIN_TLS_CERT_FILE and LANQIN_TLS_KEY_FILE are required when SMTP submission is enabled")
|
||||
}
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cert, nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type submissionLogWriter struct {
|
||||
log slogLogger
|
||||
}
|
||||
|
||||
func (w submissionLogWriter) Write(p []byte) (int, error) {
|
||||
if w.log != nil {
|
||||
w.log.Warn(strings.TrimSpace(string(p)))
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
type slogLogger interface {
|
||||
Warn(msg string, args ...any)
|
||||
}
|
||||
|
||||
type submissionBackend struct {
|
||||
app *App
|
||||
}
|
||||
|
||||
func (b submissionBackend) NewSession(*smtpserver.Conn) (smtpserver.Session, error) {
|
||||
return &submissionSession{app: b.app}, nil
|
||||
}
|
||||
|
||||
type submissionSession struct {
|
||||
app *App
|
||||
user *User
|
||||
mailbox *Mailbox
|
||||
mailFrom string
|
||||
recipients []string
|
||||
}
|
||||
|
||||
func (s *submissionSession) AuthMechanisms() []string {
|
||||
return []string{sasl.Plain}
|
||||
}
|
||||
|
||||
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
|
||||
if !strings.EqualFold(mech, sasl.Plain) {
|
||||
return nil, smtpserver.ErrAuthUnknownMechanism
|
||||
}
|
||||
return sasl.NewPlainServer(func(identity, username, password string) error {
|
||||
user, mailbox, err := s.app.authenticateSubmission(context.Background(), username, password)
|
||||
if err != nil {
|
||||
return smtpserver.ErrAuthFailed
|
||||
}
|
||||
s.user, s.mailbox = user, mailbox
|
||||
return nil
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *submissionSession) Mail(from string, _ *smtpserver.MailOptions) error {
|
||||
if s.user == nil || s.mailbox == nil {
|
||||
return smtpserver.ErrAuthRequired
|
||||
}
|
||||
from = normalizeEmail(from)
|
||||
authorized, _, err := s.app.authorizedSender(context.Background(), s.mailbox, from)
|
||||
if err != nil || from == "" || from != authorized {
|
||||
return smtpError(553, smtpserver.EnhancedCode{5, 7, 1}, "sender must match authenticated mailbox")
|
||||
}
|
||||
s.mailFrom = from
|
||||
s.recipients = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *submissionSession) Rcpt(to string, _ *smtpserver.RcptOptions) error {
|
||||
if s.user == nil || s.mailbox == nil {
|
||||
return smtpserver.ErrAuthRequired
|
||||
}
|
||||
to = normalizeEmail(to)
|
||||
if to == "" || !strings.Contains(to, "@") {
|
||||
return smtpError(501, smtpserver.EnhancedCode{5, 1, 3}, "invalid recipient")
|
||||
}
|
||||
s.recipients = append(s.recipients, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *submissionSession) Data(r io.Reader) error {
|
||||
if s.user == nil || s.mailbox == nil {
|
||||
return smtpserver.ErrAuthRequired
|
||||
}
|
||||
if s.mailFrom == "" || len(s.recipients) == 0 {
|
||||
return smtpError(503, smtpserver.EnhancedCode{5, 5, 1}, "missing sender or recipients")
|
||||
}
|
||||
if err := s.app.submitSMTPMessage(context.Background(), s.user, s.mailbox, s.mailFrom, s.recipients, r); err != nil {
|
||||
var smtpErr *smtpserver.SMTPError
|
||||
if errors.As(err, &smtpErr) {
|
||||
return smtpErr
|
||||
}
|
||||
return smtpError(451, smtpserver.EnhancedCode{4, 0, 0}, "message submission failed")
|
||||
}
|
||||
s.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *submissionSession) Reset() {
|
||||
s.mailFrom = ""
|
||||
s.recipients = nil
|
||||
}
|
||||
|
||||
func (s *submissionSession) Logout() error {
|
||||
s.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) authenticateSubmission(ctx context.Context, username, password string) (*User, *Mailbox, error) {
|
||||
address := normalizeEmail(username)
|
||||
if address == "" {
|
||||
return nil, nil, errors.New("missing username")
|
||||
}
|
||||
var mb Mailbox
|
||||
var passwordHash, created string
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,password_hash,quota_mb,status,created_at
|
||||
FROM mailboxes WHERE address=? AND status='active'`, address)
|
||||
if err := row.Scan(&mb.ID, &mb.UserID, &mb.DomainID, &mb.LocalPart, &mb.Address, &mb.DisplayName, &passwordHash, &mb.QuotaMB, &mb.Status, &created); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
mb.CreatedAt = parseTime(created)
|
||||
user, err := a.userByID(ctx, mb.UserID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if user.Disabled {
|
||||
return nil, nil, errors.New("user disabled")
|
||||
}
|
||||
if !userHasPermission(user, PermissionMailSend) {
|
||||
return nil, nil, errors.New("send permission required")
|
||||
}
|
||||
return user, &mb, nil
|
||||
}
|
||||
|
||||
func (a *App) submitSMTPMessage(ctx context.Context, user *User, mb *Mailbox, mailFrom string, recipients []string, r io.Reader) error {
|
||||
if err := a.recordSMTPRate(ctx, user, mb); err != nil {
|
||||
if errors.Is(err, errSMTPRateLimited) {
|
||||
return smtpError(452, smtpserver.EnhancedCode{4, 7, 0}, err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
raw, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prepared, msg, attachments, err := a.prepareSubmittedMessage(ctx, raw, mb, mailFrom, recipients)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg.MailboxID = mb.ID
|
||||
sentID, insertedSent, err := a.insertSentMessageOnce(ctx, msg, attachments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if insertedSent {
|
||||
if err := a.rewriteMessageMaildir(ctx, sentID); err != nil {
|
||||
a.deleteMessage(ctx, sentID)
|
||||
if sentFolderID, ferr := a.ensureFolder(ctx, mb.ID, "Sent"); ferr == nil {
|
||||
a.deleteSentDedupeKey(ctx, mb.ID, sentFolderID, msg.MessageID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients})
|
||||
if sentID != "" {
|
||||
if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: msg.MessageID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients, MIMEBytes: prepared, Now: a.now().UTC()}); err != nil {
|
||||
if insertedSent {
|
||||
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
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) prepareSubmittedMessage(ctx context.Context, raw []byte, mb *Mailbox, mailFrom string, recipients []string) ([]byte, storedMessage, []AttachmentInput, error) {
|
||||
header, body, err := readMessageHeader(raw)
|
||||
if err != nil {
|
||||
return nil, storedMessage{}, nil, smtpError(554, smtpserver.EnhancedCode{5, 6, 0}, "invalid message")
|
||||
}
|
||||
fromAddress, fromName, ok := singleHeaderAddress(header.Get("From"))
|
||||
if !ok || fromAddress == "" {
|
||||
return nil, storedMessage{}, nil, smtpError(550, smtpserver.EnhancedCode{5, 7, 1}, "From header must contain exactly one address")
|
||||
}
|
||||
authAddress, fromName, err := a.authorizedSender(ctx, mb, fromAddress)
|
||||
if err != nil || normalizeEmail(mailFrom) != authAddress || normalizeEmail(fromAddress) != authAddress {
|
||||
return nil, storedMessage{}, nil, smtpError(553, smtpserver.EnhancedCode{5, 7, 1}, "sender must match authenticated mailbox")
|
||||
}
|
||||
now := a.now().UTC()
|
||||
messageID := strings.TrimSpace(header.Get("Message-Id"))
|
||||
if messageID == "" {
|
||||
messageID = fmt.Sprintf("<%s@%s>", newID("msg"), domainPart(authAddress))
|
||||
header.Set("Message-ID", messageID)
|
||||
} else {
|
||||
header.Set("Message-ID", messageID)
|
||||
}
|
||||
sentAt := parseMailDate(header.Get("Date"))
|
||||
if sentAt.IsZero() {
|
||||
sentAt = now
|
||||
header.Set("Date", sentAt.Format(time.RFC1123Z))
|
||||
}
|
||||
header.Del("Bcc")
|
||||
prepared := serializeMessage(header, body)
|
||||
msg, attachments, err := a.parseMaildirMessage(prepared, authAddress)
|
||||
if err != nil {
|
||||
return nil, storedMessage{}, nil, smtpError(554, smtpserver.EnhancedCode{5, 6, 0}, "invalid message")
|
||||
}
|
||||
if msg.MessageID == "" {
|
||||
msg.MessageID = messageID
|
||||
}
|
||||
if msg.SentAt.IsZero() {
|
||||
msg.SentAt = sentAt
|
||||
}
|
||||
if msg.ReceivedAt.IsZero() {
|
||||
msg.ReceivedAt = sentAt
|
||||
}
|
||||
msg.From = authAddress
|
||||
msg.FromName = fromName
|
||||
msg.To = dedupeEmails(msg.To)
|
||||
msg.CC = dedupeEmails(msg.CC)
|
||||
msg.BCC = deduceBCCRecipients(recipients, addressList(header.Get("To")), addressList(header.Get("Cc")))
|
||||
msg.IsRead = true
|
||||
msg.RawPath = ""
|
||||
if msg.Subject == "" {
|
||||
msg.Subject = "(no subject)"
|
||||
}
|
||||
if msg.Snippet == "" {
|
||||
msg.Snippet = snippetFrom(msg.BodyText, msg.BodyHTML)
|
||||
}
|
||||
return prepared, msg, attachments, nil
|
||||
}
|
||||
|
||||
func (a *App) insertSentMessageOnce(ctx context.Context, msg storedMessage, attachments []AttachmentInput) (string, bool, error) {
|
||||
sentFolderID, err := a.ensureFolder(ctx, msg.MailboxID, "Sent")
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
msg.FolderID = sentFolderID
|
||||
if msg.MessageUID == "" {
|
||||
msg.MessageUID = newID("uid")
|
||||
}
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
committed := false
|
||||
messageIDForCleanup := ""
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
if messageIDForCleanup != "" {
|
||||
a.deleteMessageFiles(ctx, messageIDForCleanup)
|
||||
}
|
||||
}
|
||||
}()
|
||||
if msg.MessageID != "" {
|
||||
existing, err := sentMessageIDByMessageID(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID)
|
||||
if err == nil {
|
||||
if err := a.insertSentDedupeKeyWithDB(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID); err != nil && !errors.Is(err, errSentDedupeExists) {
|
||||
return "", false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
committed = true
|
||||
return existing, false, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, err
|
||||
}
|
||||
if err := a.insertSentDedupeKeyWithDB(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID); err != nil {
|
||||
if errors.Is(err, errSentDedupeExists) {
|
||||
existing, qerr := sentMessageIDByMessageID(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID)
|
||||
if qerr == nil {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
committed = true
|
||||
return existing, false, nil
|
||||
}
|
||||
if errors.Is(qerr, sql.ErrNoRows) {
|
||||
return "", false, fmt.Errorf("sent dedupe key exists without sent message: %w", errSentDedupeExists)
|
||||
}
|
||||
return "", false, qerr
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
}
|
||||
id, err := a.insertMessageWithDB(ctx, tx, msg, attachments)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
messageIDForCleanup = id
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
committed = true
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
var errSentDedupeExists = errors.New("sent message already exists")
|
||||
|
||||
func (a *App) insertSentDedupeKey(ctx context.Context, mailboxID, folderID, messageID string) error {
|
||||
return a.insertSentDedupeKeyWithDB(ctx, a.db, mailboxID, folderID, messageID)
|
||||
}
|
||||
|
||||
func (a *App) insertSentDedupeKeyWithDB(ctx context.Context, db dbExecutor, mailboxID, folderID, messageID string) error {
|
||||
if strings.TrimSpace(messageID) == "" {
|
||||
return nil
|
||||
}
|
||||
res, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO sent_message_dedupe_keys(mailbox_id,folder_id,message_id,created_at) VALUES(?,?,?,?)`, mailboxID, folderID, messageID, a.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
|
||||
return errSentDedupeExists
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sentMessageIDByMessageID(ctx context.Context, db dbQueryer, mailboxID, folderID, messageID string) (string, error) {
|
||||
var existing string
|
||||
err := db.QueryRowContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' LIMIT 1`, mailboxID, folderID, messageID).Scan(&existing)
|
||||
return existing, err
|
||||
}
|
||||
|
||||
func (a *App) deleteSentDedupeKey(ctx context.Context, mailboxID, folderID, messageID string) {
|
||||
if strings.TrimSpace(messageID) == "" {
|
||||
return
|
||||
}
|
||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM sent_message_dedupe_keys WHERE mailbox_id=? AND folder_id=? AND message_id=?`, mailboxID, folderID, messageID)
|
||||
}
|
||||
|
||||
func readMessageHeader(raw []byte) (textproto.MIMEHeader, []byte, error) {
|
||||
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
body, err := io.ReadAll(msg.Body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return textproto.MIMEHeader(msg.Header), body, nil
|
||||
}
|
||||
|
||||
func serializeMessage(header textproto.MIMEHeader, body []byte) []byte {
|
||||
var buf bytes.Buffer
|
||||
keys := make([]string, 0, len(header))
|
||||
for key := range header {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.SliceStable(keys, func(i, j int) bool {
|
||||
return textproto.CanonicalMIMEHeaderKey(keys[i]) < textproto.CanonicalMIMEHeaderKey(keys[j])
|
||||
})
|
||||
for _, key := range keys {
|
||||
values := header[key]
|
||||
canonical := textproto.CanonicalMIMEHeaderKey(key)
|
||||
for _, value := range values {
|
||||
fmt.Fprintf(&buf, "%s: %s\r\n", canonical, strings.ReplaceAll(strings.ReplaceAll(value, "\r", ""), "\n", " "))
|
||||
}
|
||||
}
|
||||
buf.WriteString("\r\n")
|
||||
buf.Write(body)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func singleHeaderAddress(value string) (string, string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", "", false
|
||||
}
|
||||
items, err := netmail.ParseAddressList(value)
|
||||
if err != nil || len(items) != 1 {
|
||||
decoded := decodeMIMEHeader(value)
|
||||
items, err = netmail.ParseAddressList(decoded)
|
||||
if err != nil || len(items) != 1 {
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
item := items[0]
|
||||
return normalizeEmail(item.Address), strings.TrimSpace(decodeMIMEHeader(item.Name)), true
|
||||
}
|
||||
|
||||
func deduceBCCRecipients(envelope, to, cc []string) []string {
|
||||
visible := map[string]bool{}
|
||||
for _, item := range append(to, cc...) {
|
||||
if email := normalizeEmail(item); email != "" {
|
||||
visible[email] = true
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, item := range envelope {
|
||||
email := normalizeEmail(item)
|
||||
if email == "" || visible[email] || seen[email] {
|
||||
continue
|
||||
}
|
||||
seen[email] = true
|
||||
out = append(out, email)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func domainPart(email string) string {
|
||||
parts := strings.SplitN(normalizeEmail(email), "@", 2)
|
||||
if len(parts) != 2 || parts[1] == "" {
|
||||
return "lanqin.local"
|
||||
}
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
func smtpError(code int, enhanced smtpserver.EnhancedCode, message string) *smtpserver.SMTPError {
|
||||
return &smtpserver.SMTPError{Code: code, EnhancedCode: enhanced, Message: message}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ type User struct {
|
||||
Protected bool `json:"protected"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits PermissionLimits `json:"limits"`
|
||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||
PermissionGroups []PermissionGroupSummary `json:"permissionGroups"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
@@ -56,11 +57,14 @@ type Alias struct {
|
||||
}
|
||||
|
||||
type MailFolder struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
UIDValidity int64 `json:"uidValidity"`
|
||||
UIDNext int64 `json:"uidNext"`
|
||||
HighestModSeq int64 `json:"highestModseq"`
|
||||
}
|
||||
|
||||
type MailLabel struct {
|
||||
@@ -72,32 +76,43 @@ type MailLabel struct {
|
||||
}
|
||||
|
||||
type MailMessage struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||
FolderID string `json:"folderId"`
|
||||
Folder string `json:"folder"`
|
||||
MessageUID string `json:"messageUid"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Snippet string `json:"snippet"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||
IsRead bool `json:"isRead"`
|
||||
IsStarred bool `json:"isStarred"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Labels []MailLabel `json:"labels,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||
FolderID string `json:"folderId"`
|
||||
Folder string `json:"folder"`
|
||||
MessageUID string `json:"messageUid"`
|
||||
IMAPUID int64 `json:"imapUid"`
|
||||
IMAPModSeq int64 `json:"imapModseq"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Snippet string `json:"snippet"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||
IsRead bool `json:"isRead"`
|
||||
IsStarred bool `json:"isStarred"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Labels []MailLabel `json:"labels,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
Authentication MailAuthentication `json:"authentication"`
|
||||
}
|
||||
|
||||
type MailAuthentication struct {
|
||||
AuthenticationResults string `json:"authenticationResults"`
|
||||
ReceivedSPF string `json:"receivedSpf"`
|
||||
SPF string `json:"spf"`
|
||||
DKIM string `json:"dkim"`
|
||||
DMARC string `json:"dmarc"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
@@ -167,9 +182,11 @@ type MailRule struct {
|
||||
}
|
||||
|
||||
type MailRuleCondition struct {
|
||||
Field string `json:"field"`
|
||||
Operator string `json:"operator"`
|
||||
Value string `json:"value"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Operator string `json:"operator,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
MatchMode string `json:"matchMode,omitempty"`
|
||||
Conditions []MailRuleCondition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
type MailRuleAction struct {
|
||||
@@ -192,7 +209,10 @@ type MailStats struct {
|
||||
UnreadMessages int64 `json:"unreadMessages"`
|
||||
StarredMessages int64 `json:"starredMessages"`
|
||||
AttachmentCount int64 `json:"attachmentCount"`
|
||||
AttachmentBytes int64 `json:"attachmentBytes"`
|
||||
StorageBytes int64 `json:"storageBytes"`
|
||||
QuotaBytes int64 `json:"quotaBytes"`
|
||||
QuotaUsedPct float64 `json:"quotaUsedPct"`
|
||||
ByFolder []MailStatsFolderCount `json:"byFolder"`
|
||||
}
|
||||
|
||||
@@ -203,3 +223,40 @@ type MailStatsFolderCount struct {
|
||||
Unread int64 `json:"unread"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
type SendQueueEntry struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
SentMessageID string `json:"sentMessageId"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
Source string `json:"source"`
|
||||
MailFrom string `json:"mailFrom"`
|
||||
HeaderFrom string `json:"headerFrom"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Status string `json:"status"`
|
||||
AttemptCount int `json:"attemptCount"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
NextAttemptAt time.Time `json:"nextAttemptAt"`
|
||||
LastError string `json:"lastError"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
|
||||
}
|
||||
|
||||
type SendAuditEvent struct {
|
||||
ID string `json:"id"`
|
||||
QueueID string `json:"queueId"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
SentMessageID string `json:"sentMessageId"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Event string `json:"event"`
|
||||
Status string `json:"status"`
|
||||
MailFrom string `json:"mailFrom"`
|
||||
HeaderFrom string `json:"headerFrom"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -22,7 +22,21 @@ type HTMLPolicy struct{ policy *bluemonday.Policy }
|
||||
|
||||
func NewHTMLPolicy() *HTMLPolicy {
|
||||
p := bluemonday.UGCPolicy()
|
||||
p.AllowAttrs("style").OnElements("p", "span", "div", "table", "td", "th")
|
||||
p.AllowElements("html", "head", "body", "center", "font")
|
||||
p.AllowAttrs("style").Globally()
|
||||
p.AllowAttrs("class").Matching(bluemonday.SpaceSeparatedTokens).Globally()
|
||||
p.AllowAttrs("align", "valign").Matching(bluemonday.Paragraph).Globally()
|
||||
p.AllowAttrs("width", "height").Matching(bluemonday.NumberOrPercent).Globally()
|
||||
p.AllowAttrs("bgcolor", "color").Matching(regexp.MustCompile(`(?i)^#[0-9a-f]{3,8}$|^[a-z][a-z0-9 -]{0,31}$`)).Globally()
|
||||
p.AllowAttrs("border", "cellpadding", "cellspacing").Matching(bluemonday.Number).OnElements("table")
|
||||
p.AllowStyles(
|
||||
"background", "background-color", "background-image", "border", "border-collapse", "border-color",
|
||||
"border-radius", "border-spacing", "border-style", "border-width", "box-shadow", "color", "display",
|
||||
"font", "font-family", "font-size", "font-style", "font-weight", "height", "letter-spacing",
|
||||
"line-height", "margin", "margin-bottom", "margin-left", "margin-right", "margin-top", "max-width",
|
||||
"min-width", "opacity", "padding", "padding-bottom", "padding-left", "padding-right", "padding-top",
|
||||
"text-align", "text-decoration", "text-transform", "vertical-align", "white-space", "width",
|
||||
).MatchingHandler(safeEmailCSSValue).Globally()
|
||||
return &HTMLPolicy{policy: p}
|
||||
}
|
||||
|
||||
@@ -30,7 +44,67 @@ func (p *HTMLPolicy) Sanitize(s string) string {
|
||||
if p == nil || p.policy == nil {
|
||||
return s
|
||||
}
|
||||
return p.policy.Sanitize(s)
|
||||
styles, withoutStyles := extractSafeEmailStyles(s)
|
||||
clean := p.policy.Sanitize(withoutStyles)
|
||||
if len(styles) == 0 {
|
||||
return clean
|
||||
}
|
||||
return strings.Join(styles, "") + clean
|
||||
}
|
||||
|
||||
var emailStyleTagRe = regexp.MustCompile(`(?is)<style\b([^>]*)>(.*?)</style>`)
|
||||
|
||||
func extractSafeEmailStyles(value string) ([]string, string) {
|
||||
styles := []string{}
|
||||
withoutStyles := emailStyleTagRe.ReplaceAllStringFunc(value, func(tag string) string {
|
||||
match := emailStyleTagRe.FindStringSubmatch(tag)
|
||||
if len(match) != 3 {
|
||||
return ""
|
||||
}
|
||||
attrs, css := match[1], strings.TrimSpace(match[2])
|
||||
if !safeEmailStyleAttrs(attrs) || !safeEmailCSSBlock(css) {
|
||||
return ""
|
||||
}
|
||||
styles = append(styles, `<style type="text/css">`+css+`</style>`)
|
||||
return ""
|
||||
})
|
||||
return styles, withoutStyles
|
||||
}
|
||||
|
||||
func safeEmailStyleAttrs(attrs string) bool {
|
||||
attrs = strings.ToLower(strings.TrimSpace(attrs))
|
||||
if attrs == "" {
|
||||
return true
|
||||
}
|
||||
return regexp.MustCompile(`^\s*type\s*=\s*["']?text/css["']?\s*$`).MatchString(attrs)
|
||||
}
|
||||
|
||||
func safeEmailCSSBlock(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" || len(value) > 50000 {
|
||||
return false
|
||||
}
|
||||
unsafe := []string{"expression", "javascript:", "vbscript:", "data:", "behavior", "-moz-binding", "@import", "</", "url("}
|
||||
for _, token := range unsafe {
|
||||
if strings.Contains(value, token) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeEmailCSSValue(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" || len(value) > 512 {
|
||||
return false
|
||||
}
|
||||
unsafe := []string{"expression", "javascript:", "vbscript:", "data:", "behavior", "-moz-binding", "@import", "</", "url("}
|
||||
for _, token := range unsafe {
|
||||
if strings.Contains(value, token) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func newID(prefix string) string {
|
||||
|
||||
@@ -47,20 +47,23 @@ export type PermissionKey =
|
||||
| "admin.templates.update"
|
||||
| "admin.templates.reset"
|
||||
export type PermissionInfo = { key: PermissionKey; label: string; description: string; category: string }
|
||||
export type PermissionLimits = { maxAttachmentMb: number; smtpDailyLimit: number; smtpMinuteLimit: number; imapMinuteLimit: number; pop3MinuteLimit: number }
|
||||
export type PermissionGroupSummary = { id: string; name: string }
|
||||
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; system: boolean; userCount: number; createdAt: string; updatedAt: string }
|
||||
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; permissions: PermissionKey[]; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
|
||||
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
|
||||
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
|
||||
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
|
||||
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
|
||||
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
|
||||
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; createdAt: string }
|
||||
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
|
||||
export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number }
|
||||
export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
|
||||
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
|
||||
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
|
||||
export type MailAuthentication = { authenticationResults: string; receivedSpf: string; spf: string; dkim: string; dmarc: string }
|
||||
export type MailMessage = {
|
||||
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
|
||||
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; imapUid: number; imapModseq: number; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
|
||||
labels?: MailLabel[]
|
||||
authentication?: MailAuthentication
|
||||
}
|
||||
export type DNSRecord = { type: string; name: string; value: string; ttl: number }
|
||||
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
|
||||
@@ -69,15 +72,73 @@ export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc:
|
||||
export type DraftPayload = Omit<SendPayload, "attachments"> & { attachments?: SendPayload["attachments"] }
|
||||
export type ScheduleSendPayload = SendPayload & { draftId?: string; sendAt: string }
|
||||
export type ScheduledSend = { id: string; mailboxId: string; draftId?: string; subject: string; to: string[]; snippet: string; sendAt: string; status: "pending" | "sending" | "sent" | "failed" | "cancelled"; error?: string; createdAt: string; updatedAt: string; sentAt?: string }
|
||||
export type SendQueueStatus = "queued" | "sending" | "delivered" | "failed" | "canceled"
|
||||
export type SendQueueItem = {
|
||||
id: string
|
||||
mailboxId: string
|
||||
sentMessageId?: string
|
||||
messageId?: string
|
||||
mailFrom?: string
|
||||
headerFrom?: string
|
||||
subject: string
|
||||
recipients: string[]
|
||||
source: string
|
||||
status: SendQueueStatus
|
||||
attemptCount: number
|
||||
maxAttempts: number
|
||||
nextAttemptAt?: string
|
||||
lastError?: string
|
||||
error?: string
|
||||
failureReason?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deliveredAt?: string
|
||||
}
|
||||
export type SendQueueAuditEvent = {
|
||||
id: string
|
||||
queueId?: string
|
||||
mailboxId?: string
|
||||
mailboxAddress?: string
|
||||
sentMessageId?: string
|
||||
messageId?: string
|
||||
source?: string
|
||||
status?: SendQueueStatus
|
||||
event?: string
|
||||
eventType?: string
|
||||
mailFrom?: string
|
||||
headerFrom?: string
|
||||
recipients?: string[]
|
||||
message?: string
|
||||
error?: string
|
||||
attemptCount?: number
|
||||
createdAt: string
|
||||
}
|
||||
export type Contact = { id: string; name: string; email: string; note: string; createdAt: string }
|
||||
export type MailSignature = { id: string; mailboxId: string; name: string; content: string; isDefault: boolean; createdAt: string; updatedAt: string }
|
||||
export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string }
|
||||
export type MailRuleConditionField = "from" | "to" | "cc" | "subject" | "body" | "attachment" | "size" | "date"
|
||||
export type MailRuleConditionOperator = "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with" | "gt" | "gte" | "lt" | "lte" | "before" | "after" | "on"
|
||||
export type MailRuleCondition = { field?: MailRuleConditionField; operator?: MailRuleConditionOperator; value?: string; matchMode?: "all" | "any"; conditions?: MailRuleCondition[] }
|
||||
export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; value?: string; labelId?: string }
|
||||
export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; enabled: boolean; createdAt: string; appliedExistingCount?: number }
|
||||
export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
|
||||
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
|
||||
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; attachmentBytes: number; storageBytes: number; quotaBytes: number; quotaUsedPct: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
|
||||
export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
|
||||
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
|
||||
export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number }
|
||||
export type MaildirSyncRun = { startedAt: string; finishedAt?: string; durationMs: number; status: "running" | "success" | "partial" | "error"; error?: string; counts: MaildirSyncCounts }
|
||||
export type MaildirSyncHealth = {
|
||||
configured: boolean
|
||||
enabled: boolean
|
||||
root: string
|
||||
scanSeconds: number
|
||||
workerStarted: boolean
|
||||
running: boolean
|
||||
lastRun?: MaildirSyncRun
|
||||
lastError?: string
|
||||
nextRunAt?: string
|
||||
recentErrors: string[]
|
||||
summary: MaildirSyncCounts
|
||||
}
|
||||
export type SystemSettings = {
|
||||
publicHostname: string
|
||||
publicBaseUrl: string
|
||||
|
||||
+27
-3
@@ -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 } from "./api-types"
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
|
||||
export * from "./api-types"
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
@@ -64,8 +64,9 @@ export const api = {
|
||||
adminOverview: () => request<AdminOverview>("/api/admin/overview"),
|
||||
users: () => request<ListResponse<AdminUser>>("/api/admin/users"),
|
||||
permissionGroups: () => request<ListResponse<PermissionGroup> & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"),
|
||||
createPermissionGroup: (payload: { name: string; description: string; permissions: PermissionKey[] }) => request<PermissionGroup>("/api/admin/permission-groups", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[] }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
createPermissionGroup: (payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>("/api/admin/permission-groups", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
|
||||
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
|
||||
createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
@@ -93,7 +94,19 @@ export const api = {
|
||||
return request<ListResponse<MailMessage>>(`/api/admin/messages${suffix ? `?${suffix}` : ""}`)
|
||||
},
|
||||
adminMessage: (id: string) => request<MailMessage>(`/api/admin/messages/${id}`),
|
||||
adminSendAudit: (params: { mailboxId?: string; messageId?: string; event?: string; from?: string; to?: string; cursor?: string } = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
|
||||
if (params.messageId) query.set("messageId", params.messageId)
|
||||
if (params.event) query.set("event", params.event)
|
||||
if (params.from) query.set("from", params.from)
|
||||
if (params.to) query.set("to", params.to)
|
||||
if (params.cursor) query.set("cursor", params.cursor)
|
||||
const suffix = query.toString()
|
||||
return request<ListResponse<SendQueueAuditEvent>>(`/api/admin/send-audit${suffix ? `?${suffix}` : ""}`)
|
||||
},
|
||||
systemSettings: () => request<SystemSettings>("/api/admin/settings"),
|
||||
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
|
||||
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
|
||||
testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
mailTemplates: () => request<ListResponse<MailTemplate>>("/api/admin/mail-templates"),
|
||||
@@ -129,6 +142,17 @@ export const api = {
|
||||
scheduledSends: (mailboxId?: string) => request<ListResponse<ScheduledSend>>(`/api/mail/scheduled-sends${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||
scheduleSend: (payload: ScheduleSendPayload) => request<ScheduledSend>("/api/mail/schedule-send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
cancelScheduledSend: (id: string) => request<{ ok: boolean }>(`/api/mail/schedule-send/${id}`, { method: "DELETE" }),
|
||||
sendQueue: (params: { mailboxId?: string; status?: SendQueueStatus | "all"; cursor?: string } = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
|
||||
if (params.status && params.status !== "all") query.set("status", params.status)
|
||||
if (params.cursor) query.set("cursor", params.cursor)
|
||||
const suffix = query.toString()
|
||||
return request<ListResponse<SendQueueItem>>(`/api/mail/send-queue${suffix ? `?${suffix}` : ""}`)
|
||||
},
|
||||
sendQueueAudit: (id: string) => request<ListResponse<SendQueueAuditEvent>>(`/api/mail/send-queue/${id}/audit`),
|
||||
retrySendQueue: (id: string) => request<SendQueueItem>(`/api/mail/send-queue/${id}/retry`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
cancelSendQueue: (id: string) => request<SendQueueItem>(`/api/mail/send-queue/${id}`, { method: "DELETE" }),
|
||||
saveDraft: (payload: DraftPayload, id?: string) => request<MailMessage>(id ? `/api/mail/drafts/${id}` : "/api/mail/drafts", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
deleteDraft: (id: string) => request<{ ok: boolean }>(`/api/mail/drafts/${id}`, { method: "DELETE" }),
|
||||
markRead: (id: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
|
||||
|
||||
+371
-16
@@ -2,8 +2,8 @@ import * as React from "react"
|
||||
import DOMPurify from "dompurify"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { ArrowRight, BookOpen, CheckCircle2, Circle, Copy, 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, SystemSettings } from "@/lib/api"
|
||||
import { ArrowRight, BookOpen, CheckCircle2, Circle, ClipboardList, Copy, ExternalLink, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||||
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -25,7 +25,7 @@ import { useToast } from "@/hooks/use-toast"
|
||||
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
|
||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "settings"
|
||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
|
||||
const sectionLabels: Record<Section, string> = {
|
||||
@@ -36,6 +36,7 @@ const sectionLabels: Record<Section, string> = {
|
||||
mailboxes: "邮箱账号",
|
||||
aliases: "别名转发",
|
||||
messages: "全部邮件",
|
||||
sendAudit: "发送审计",
|
||||
settings: "系统设置",
|
||||
}
|
||||
const sectionKeys = Object.keys(sectionLabels) as Section[]
|
||||
@@ -47,11 +48,13 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
|
||||
mailboxes: ["admin.mailboxes.view"],
|
||||
aliases: ["admin.aliases.view"],
|
||||
messages: ["admin.messages.view"],
|
||||
sendAudit: ["admin.messages.view"],
|
||||
settings: ["admin.settings.view", "admin.templates.view"],
|
||||
}
|
||||
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email"
|
||||
const projectTag = import.meta.env.VITE_APP_VERSION || ""
|
||||
const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "")
|
||||
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
|
||||
|
||||
export function AdminPage() {
|
||||
const me = useMe()
|
||||
@@ -107,6 +110,7 @@ export function AdminPage() {
|
||||
{section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
|
||||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />}
|
||||
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
|
||||
</main>
|
||||
</ScrollArea>
|
||||
@@ -349,6 +353,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
||||
</DropdownMenu>}
|
||||
</div>
|
||||
<PermissionBadges permissions={group.permissions} catalog={catalog} />
|
||||
<PermissionLimitBadges limits={group.limits} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -366,9 +371,15 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
|
||||
const [internalOpen, setInternalOpen] = React.useState(false)
|
||||
const dialogOpen = open ?? internalOpen
|
||||
const setDialogOpen = onOpenChange ?? setInternalOpen
|
||||
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits, enabled: dialogOpen })
|
||||
const defaultLimits = defaultLimitsQuery.data || defaultPermissionLimits
|
||||
const [permissions, setPermissions] = React.useState<PermissionKey[]>(group?.permissions || [])
|
||||
const [limits, setLimits] = React.useState<PermissionLimits>(group?.limits || defaultPermissionLimits)
|
||||
React.useEffect(() => {
|
||||
if (dialogOpen) setPermissions(group?.permissions || [])
|
||||
if (dialogOpen) {
|
||||
setPermissions(group?.permissions || [])
|
||||
setLimits(group?.limits || defaultPermissionLimits)
|
||||
}
|
||||
}, [dialogOpen, group])
|
||||
const mutation = useMutation({
|
||||
mutationFn: (form: FormData) => {
|
||||
@@ -376,6 +387,7 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
|
||||
name: String(form.get("name") || ""),
|
||||
description: String(form.get("description") || ""),
|
||||
permissions,
|
||||
limits,
|
||||
}
|
||||
return group ? api.updatePermissionGroup(group.id, payload) : api.createPermissionGroup(payload)
|
||||
},
|
||||
@@ -401,6 +413,7 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
|
||||
<Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" />
|
||||
<Field name="description" label="说明" defaultValue={group?.description || ""} required={false} />
|
||||
</div>
|
||||
<PermissionLimitEditor value={limits} onChange={setLimits} />
|
||||
<PermissionPicker catalog={catalog} value={permissions} onChange={setPermissions} />
|
||||
<DialogFooter><Button disabled={mutation.isPending}>{mutation.isPending ? "保存中..." : "保存"}</Button></DialogFooter>
|
||||
</form>
|
||||
@@ -455,6 +468,43 @@ function PermissionPicker({ catalog, value, onChange }: { catalog: PermissionInf
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionLimitEditor({ value, onChange }: { value: PermissionLimits; onChange: (value: PermissionLimits) => void }) {
|
||||
function update(key: keyof PermissionLimits, raw: string) {
|
||||
const next = Number(raw)
|
||||
onChange({ ...value, [key]: Number.isFinite(next) && next > 0 ? Math.floor(next) : 0 })
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border p-3">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Label>账号配额</Label>
|
||||
<span className="text-xs text-muted-foreground">填 0 表示不限制</span>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>附件上限 MB</Label>
|
||||
<Input type="number" min={0} value={value.maxAttachmentMb} onChange={(event) => update("maxAttachmentMb", event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>SMTP 每日封数</Label>
|
||||
<Input type="number" min={0} value={value.smtpDailyLimit} onChange={(event) => update("smtpDailyLimit", event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>SMTP 每分钟封数</Label>
|
||||
<Input type="number" min={0} value={value.smtpMinuteLimit} onChange={(event) => update("smtpMinuteLimit", event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>IMAP 每分钟命令数</Label>
|
||||
<Input type="number" min={0} value={value.imapMinuteLimit} onChange={(event) => update("imapMinuteLimit", event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>POP3 每分钟命令数</Label>
|
||||
<Input type="number" min={0} value={value.pop3MinuteLimit} onChange={(event) => update("pop3MinuteLimit", event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionBadges({ permissions, catalog }: { permissions: PermissionKey[]; catalog: PermissionInfo[] }) {
|
||||
const labelByKey = new Map(catalog.map((item) => [item.key, item.label]))
|
||||
if (permissions.length === 0) return <div className="mt-3 text-sm text-muted-foreground">无后台权限</div>
|
||||
@@ -468,6 +518,24 @@ function PermissionBadges({ permissions, catalog }: { permissions: PermissionKey
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionLimitBadges({ limits }: { limits?: PermissionLimits }) {
|
||||
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits })
|
||||
const value = limits || defaultLimitsQuery.data || defaultPermissionLimits
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary" className="font-normal">附件 {limitText(value.maxAttachmentMb, "MB")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">SMTP 每日 {limitText(value.smtpDailyLimit, "封")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">SMTP 每分钟 {limitText(value.smtpMinuteLimit, "封")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">IMAP 每分钟 {limitText(value.imapMinuteLimit, "次")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">POP3 每分钟 {limitText(value.pop3MinuteLimit, "次")}</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function limitText(value: number, unit: string) {
|
||||
return value > 0 ? `${value} ${unit}` : "不限"
|
||||
}
|
||||
|
||||
function groupPermissionCatalog(catalog: PermissionInfo[]) {
|
||||
const order: string[] = []
|
||||
const grouped = new Map<string, PermissionInfo[]>()
|
||||
@@ -786,6 +854,119 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
const qc = useQueryClient()
|
||||
const [mailboxId, setMailboxId] = React.useState("all")
|
||||
const [event, setEvent] = React.useState("all")
|
||||
const [messageId, setMessageId] = React.useState("")
|
||||
const [from, setFrom] = React.useState("")
|
||||
const [to, setTo] = React.useState("")
|
||||
const audit = useInfiniteQuery({
|
||||
queryKey: ["admin", "send-audit", mailboxId, event, messageId, from, to],
|
||||
queryFn: ({ pageParam }) => api.adminSendAudit({
|
||||
mailboxId: mailboxId === "all" ? "" : mailboxId,
|
||||
event: event === "all" ? "" : event,
|
||||
messageId: messageId.trim(),
|
||||
from,
|
||||
to,
|
||||
cursor: typeof pageParam === "string" ? pageParam : "",
|
||||
}),
|
||||
initialPageParam: "",
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
||||
})
|
||||
const items = audit.data?.pages.flatMap((page) => page.items || []) || []
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle className="flex items-center gap-2"><ClipboardList className="h-5 w-5" />发送审计</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ["admin", "send-audit"] })}>
|
||||
<RefreshCcw className="h-4 w-4" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_180px_180px_160px_160px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input value={messageId} onChange={(event) => setMessageId(event.target.value)} placeholder="Message-ID 或已发送邮件 ID" className="pl-9" />
|
||||
</div>
|
||||
<Select value={mailboxId} onValueChange={setMailboxId}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部邮箱</SelectItem>
|
||||
{mailboxes.map((mailbox) => <SelectItem key={mailbox.id} value={mailbox.id}>{mailbox.address}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={event} onValueChange={setEvent}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部事件</SelectItem>
|
||||
{sendAuditEvents.map((item) => <SelectItem key={item} value={item}>{sendAuditEventLabel(item)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input type="date" value={from} onChange={(event) => setFrom(event.target.value)} aria-label="开始日期" />
|
||||
<Input type="date" value={to} onChange={(event) => setTo(event.target.value)} aria-label="结束日期" />
|
||||
</div>
|
||||
<div className="space-y-3 md:hidden">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">{sendAuditEventLabel(item.event || "")}</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">{item.mailboxAddress || item.mailboxId || "-"}</div>
|
||||
</div>
|
||||
<Badge variant={sendAuditBadgeVariant(item.event)}>{item.status || item.event || "-"}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
<div className="truncate">收件人:{(item.recipients || []).join(", ") || "-"}</div>
|
||||
<div className="truncate">Message-ID:{item.messageId || item.sentMessageId || "-"}</div>
|
||||
{item.error && <div className="line-clamp-2 text-destructive">错误:{item.error}</div>}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-muted-foreground">{formatDate(item.createdAt)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>事件</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>收件人</TableHead>
|
||||
<TableHead>Message-ID</TableHead>
|
||||
<TableHead>错误</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell><Badge variant={sendAuditBadgeVariant(item.event)}>{sendAuditEventLabel(item.event || "")}</Badge></TableCell>
|
||||
<TableCell className="max-w-[220px] truncate">{item.mailboxAddress || item.mailboxId || "-"}</TableCell>
|
||||
<TableCell className="max-w-[260px] truncate" title={(item.recipients || []).join(", ")}>{(item.recipients || []).join(", ") || "-"}</TableCell>
|
||||
<TableCell className="max-w-[240px] truncate" title={item.messageId || item.sentMessageId || ""}>{item.messageId || item.sentMessageId || "-"}</TableCell>
|
||||
<TableCell className="max-w-[260px] truncate text-destructive" title={item.error || ""}>{item.error || "-"}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-muted-foreground">{formatDate(item.createdAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{audit.isLoading && <Empty text="加载中..." />}
|
||||
{!audit.isLoading && items.length === 0 && <Empty text="暂无发送审计" />}
|
||||
{!audit.isLoading && audit.hasNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" disabled={audit.isFetchingNextPage} onClick={() => audit.fetchNextPage()}>
|
||||
{audit.isFetchingNextPage ? "加载中..." : "加载更多"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
@@ -799,6 +980,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||||
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "templates" | "security" | "about">("base")
|
||||
const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
|
||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||||
const [openRegistration, setOpenRegistration] = React.useState(false)
|
||||
@@ -847,6 +1029,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["admin", "settings"] })
|
||||
qc.invalidateQueries({ queryKey: ["admin", "maildir-sync", "health"] })
|
||||
qc.invalidateQueries({ queryKey: ["dns-records"] })
|
||||
qc.invalidateQueries({ queryKey: ["public-settings"] })
|
||||
toast({ title: "系统设置已保存" })
|
||||
@@ -937,12 +1120,15 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
</CardContent>
|
||||
</Card>}
|
||||
|
||||
{settingsTab === "storage" && <Card>
|
||||
<CardHeader><CardTitle>存储设置</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Field name="maildirRoot" label="Maildir 根目录" defaultValue={settings?.maildirRoot || ""} required={false} />
|
||||
</CardContent>
|
||||
</Card>}
|
||||
{settingsTab === "storage" && <div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>存储设置</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Field name="maildirRoot" label="Maildir 根目录" defaultValue={settings?.maildirRoot || ""} required={false} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MaildirSyncHealthCard health={maildirHealth.data} loading={maildirHealth.isLoading} error={maildirHealth.error} onRefresh={() => maildirHealth.refetch()} refreshing={maildirHealth.isFetching} fallbackRoot={settings?.maildirRoot || ""} />
|
||||
</div>}
|
||||
|
||||
{settingsTab === "mail" && <Card>
|
||||
<CardHeader><CardTitle>邮件设置</CardTitle></CardHeader>
|
||||
@@ -1016,7 +1202,143 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
)
|
||||
}
|
||||
|
||||
function MaildirSyncHealthCard({ health, loading, error, onRefresh, refreshing, fallbackRoot }: { health?: MaildirSyncHealth; loading: boolean; error: Error | null; onRefresh: () => void; refreshing: boolean; fallbackRoot: string }) {
|
||||
const root = health?.root || fallbackRoot
|
||||
const configured = health?.configured ?? !!root
|
||||
const lastRun = health?.lastRun
|
||||
const counters = lastRun?.counts || health?.summary
|
||||
const recentErrors = health?.recentErrors || []
|
||||
const status = health?.running ? "running" : lastRun?.status || (configured ? "idle" : "disabled")
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>Maildir 同步健康</CardTitle>
|
||||
<div className="break-all text-xs text-muted-foreground">{root || "未配置 Maildir 根目录"}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={configured ? "default" : "secondary"}>{configured ? "已配置" : "未配置"}</Badge>
|
||||
<Badge variant={health?.running ? "default" : health?.workerStarted ? "outline" : "secondary"}>{health?.running ? "运行中" : health?.workerStarted ? "worker 已启动" : "worker 未启动"}</Badge>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRefresh} disabled={loading || refreshing}>
|
||||
<RefreshCcw className={cn("mr-2 h-4 w-4", refreshing && "animate-spin")} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error && <div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">{queryErrorMessage(error)}</div>}
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<InfoLine label="当前状态" value={<MaildirStatusBadge status={status} />} />
|
||||
<InfoLine label="最近开始" value={formatOptionalDate(lastRun?.startedAt)} />
|
||||
<InfoLine label="最近结束" value={formatOptionalDate(lastRun?.finishedAt)} />
|
||||
<InfoLine label="最近耗时" value={formatDuration(lastRun?.durationMs)} />
|
||||
<InfoLine label="扫描间隔" value={health?.scanSeconds ? `${health.scanSeconds} 秒` : "-"} />
|
||||
<InfoLine label="下次运行" value={formatOptionalDate(health?.nextRunAt)} />
|
||||
<InfoLine label="最后错误" value={lastRun?.error || health?.lastError || "-"} />
|
||||
<InfoLine label="错误数" value={counterValue(counters, "fileErrors")} />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{maildirCounterRows(counters).map((item) => <InfoBox key={item.key} label={item.label} value={item.value} />)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">最近错误</div>
|
||||
{recentErrors.length === 0 && <Empty text={loading ? "正在读取同步状态..." : "暂无同步错误"} />}
|
||||
{recentErrors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{recentErrors.slice(0, 5).map((item, index) => (
|
||||
<div key={`${item}-${index}`} className="rounded-lg border px-3 py-2 text-sm">
|
||||
<div className="break-words text-destructive">{item || "未知错误"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function MaildirStatusBadge({ status }: { status: string }) {
|
||||
const normalized = status.toLowerCase()
|
||||
if (normalized === "running") return <Badge>运行中</Badge>
|
||||
if (["ok", "success", "succeeded", "idle"].includes(normalized)) return <Badge variant="outline">{normalized === "idle" ? "等待下次扫描" : "正常"}</Badge>
|
||||
if (normalized === "partial") return <Badge variant="secondary">部分成功</Badge>
|
||||
if (["error", "failed", "failure"].includes(normalized)) return <Badge variant="destructive">失败</Badge>
|
||||
if (["disabled", "not_configured"].includes(normalized)) return <Badge variant="secondary">未启用</Badge>
|
||||
return <Badge variant="secondary">{status || "-"}</Badge>
|
||||
}
|
||||
|
||||
function maildirCounterRows(counters?: Record<string, number | undefined>) {
|
||||
return [
|
||||
{ key: "filesScanned", label: "扫描文件", value: counterValue(counters, "filesScanned") },
|
||||
{ key: "imported", label: "导入", value: counterValue(counters, "imported") },
|
||||
{ key: "backfilled", label: "回填", value: counterValue(counters, "backfilled") },
|
||||
{ key: "cleaned", label: "清理", value: counterValue(counters, "cleaned") },
|
||||
{ key: "fileErrors", label: "文件错误", value: counterValue(counters, "fileErrors") },
|
||||
]
|
||||
}
|
||||
|
||||
function counterValue(counters: Record<string, number | undefined> | undefined, key: string) {
|
||||
return Number(counters?.[key] || 0)
|
||||
}
|
||||
|
||||
function formatOptionalDate(value?: string) {
|
||||
return value ? formatDate(value) || "-" : "-"
|
||||
}
|
||||
|
||||
function formatDuration(value?: number) {
|
||||
if (!value) return "-"
|
||||
if (value < 1000) return `${value} ms`
|
||||
return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)} 秒`
|
||||
}
|
||||
|
||||
function queryErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "读取 Maildir 同步健康失败"
|
||||
}
|
||||
|
||||
function parseSemver(tag: string): number[] {
|
||||
return (tag.startsWith("v") ? tag.slice(1) : tag).split(".").map(Number)
|
||||
}
|
||||
|
||||
function AboutProjectCard() {
|
||||
const { toast } = useToast()
|
||||
const latestRelease = useQuery({
|
||||
queryKey: ["github", "latest-release"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("https://api.github.com/repos/LanQin996/LanQin-Email/releases/latest")
|
||||
if (!res.ok) throw new Error("rate limited or unavailable")
|
||||
return res.json() as Promise<{ tag_name: string; html_url: string }>
|
||||
},
|
||||
enabled: !!projectTag,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
retry: 1,
|
||||
})
|
||||
const updateAvailable = React.useMemo(() => {
|
||||
if (!projectTag || !latestRelease.data) return false
|
||||
const current = parseSemver(projectTag)
|
||||
const latest = parseSemver(latestRelease.data.tag_name)
|
||||
for (let i = 0; i < Math.max(current.length, latest.length); i++) {
|
||||
const a = current[i] ?? 0
|
||||
const b = latest[i] ?? 0
|
||||
if (b > a) return true
|
||||
if (a > b) return false
|
||||
}
|
||||
return false
|
||||
}, [projectTag, latestRelease.data])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (updateAvailable && latestRelease.data) {
|
||||
toast({
|
||||
title: "发现新版本",
|
||||
description: `${latestRelease.data.tag_name} 已可用,点击版本号查看详情。`,
|
||||
})
|
||||
}
|
||||
// Only toast once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [updateAvailable])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1025,12 +1347,25 @@ function AboutProjectCard() {
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<AboutRow label="版本">
|
||||
{projectTag ? (
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={projectReleaseUrl} target="_blank" rel="noreferrer">
|
||||
<GitBranch className="h-5 w-5 text-primary" />
|
||||
{projectTag}
|
||||
</a>
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={projectReleaseUrl} target="_blank" rel="noreferrer">
|
||||
<GitBranch className="h-5 w-5 text-primary" />
|
||||
{projectTag}
|
||||
</a>
|
||||
</Button>
|
||||
{updateAvailable && latestRelease.data && (
|
||||
<Button type="button" variant="default" className="h-11 px-4 text-base font-normal" asChild>
|
||||
<a href={latestRelease.data.html_url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-5 w-5" />
|
||||
新版本 {latestRelease.data.tag_name}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{latestRelease.isLoading && (
|
||||
<span className="text-xs text-muted-foreground">检查更新中...</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" disabled>
|
||||
<GitBranch className="h-5 w-5 text-muted-foreground" />
|
||||
@@ -1252,6 +1587,26 @@ function adminSenderTitle(message: MailMessage) {
|
||||
return name ? `${name} <${from}>` : from
|
||||
}
|
||||
|
||||
const sendAuditEvents = ["accepted", "queued", "retry", "delivered", "failed", "canceled"]
|
||||
|
||||
function sendAuditEventLabel(event: string) {
|
||||
switch (event) {
|
||||
case "accepted": return "已接受"
|
||||
case "queued": return "已入队"
|
||||
case "retry": return "重试"
|
||||
case "delivered": return "已投递"
|
||||
case "failed": return "失败"
|
||||
case "canceled": return "已取消"
|
||||
default: return event || "-"
|
||||
}
|
||||
}
|
||||
|
||||
function sendAuditBadgeVariant(event?: string) {
|
||||
if (event === "failed") return "destructive"
|
||||
if (event === "delivered" || event === "accepted") return "default"
|
||||
return "secondary"
|
||||
}
|
||||
|
||||
function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
|
||||
return <Card><CardContent className="flex items-center gap-3 p-4 sm:gap-4 sm:p-5"><div className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground sm:h-10 sm:w-10">{icon}</div><div className="min-w-0"><div className="truncate text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div></CardContent></Card>
|
||||
}
|
||||
|
||||
+430
-20
@@ -12,8 +12,8 @@ import Placeholder from "@tiptap/extension-placeholder"
|
||||
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react"
|
||||
import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend } from "@/lib/api"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react"
|
||||
import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { useDisplayMode } from "@/lib/display-mode"
|
||||
@@ -61,7 +61,7 @@ const folderLabels: Record<string, string> = {
|
||||
|
||||
type ComposeDraft = { key: string; id?: string; mailboxId?: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string; html?: string; files?: File[]; isDraft?: boolean }
|
||||
type MailFilter = "all" | "unread" | "starred" | "attachments"
|
||||
type MailView = "folder" | "starred" | "label" | "scheduled"
|
||||
type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue"
|
||||
type MailListResponse = { items?: MailMessage[]; nextCursor?: string }
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
type MailNotificationState = { latestId: string; latestReceivedAt: string }
|
||||
@@ -69,6 +69,7 @@ type ComposeSendIntent = { title: string; description: string; confirmText: stri
|
||||
type MailMenuItem =
|
||||
| { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number }
|
||||
| { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number }
|
||||
| { type: "sendQueue"; key: string; label: string; icon: React.ReactNode; count: number }
|
||||
| { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number }
|
||||
|
||||
const filterLabels: Record<MailFilter, string> = {
|
||||
@@ -103,6 +104,9 @@ export function MailPage() {
|
||||
const [bulkPending, setBulkPending] = React.useState(false)
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
|
||||
const [sendQueueStatus, setSendQueueStatus] = React.useState<SendQueueStatus | "all">("all")
|
||||
const [sendQueueAuditId, setSendQueueAuditId] = React.useState("")
|
||||
const [sendQueuePendingId, setSendQueuePendingId] = React.useState("")
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
|
||||
const [labelEditMode, setLabelEditMode] = React.useState(false)
|
||||
const [newLabelEditing, setNewLabelEditing] = React.useState(false)
|
||||
@@ -130,6 +134,9 @@ export function MailPage() {
|
||||
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels) })
|
||||
const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && hasPermission(user, "mail.stats.view") })
|
||||
const scheduledSends = useQuery({ queryKey: ["scheduled-sends", activeMailboxId], queryFn: () => api.scheduledSends(activeMailboxId), enabled: !!activeMailboxId && canScheduleMail, refetchInterval: 30000 })
|
||||
const canViewSendQueue = canReadMail
|
||||
const sendQueue = useQuery({ queryKey: ["send-queue", activeMailboxId, sendQueueStatus], queryFn: () => api.sendQueue({ mailboxId: activeMailboxId, status: sendQueueStatus }), enabled: !!activeMailboxId && canViewSendQueue, refetchInterval: 15000 })
|
||||
const sendQueueAudit = useQuery({ queryKey: ["send-queue-audit", sendQueueAuditId], queryFn: () => api.sendQueueAudit(sendQueueAuditId), enabled: !!sendQueueAuditId && canViewSendQueue })
|
||||
const mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false
|
||||
const inboxProbe = useQuery({
|
||||
queryKey: ["mail-notifications", activeMailboxId],
|
||||
@@ -148,7 +155,7 @@ export function MailPage() {
|
||||
},
|
||||
initialPageParam: "",
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
||||
enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && (mailView !== "label" || !!selectedLabelId),
|
||||
enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && (mailView !== "label" || !!selectedLabelId),
|
||||
})
|
||||
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail })
|
||||
function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
|
||||
@@ -276,6 +283,28 @@ export function MailPage() {
|
||||
onError: (error) => toast({ title: "操作失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||
onSettled: () => setCancelingScheduledId(""),
|
||||
})
|
||||
const retrySendQueue = useMutation({
|
||||
mutationFn: (item: SendQueueItem) => api.retrySendQueue(item.id),
|
||||
onMutate: (item) => setSendQueuePendingId(item.id),
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ["send-queue"] })
|
||||
await qc.invalidateQueries({ queryKey: ["send-queue-audit"] })
|
||||
toast({ title: "已重新加入发送队列" })
|
||||
},
|
||||
onError: (error) => toast({ title: "重试失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||
onSettled: () => setSendQueuePendingId(""),
|
||||
})
|
||||
const cancelSendQueue = useMutation({
|
||||
mutationFn: (item: SendQueueItem) => api.cancelSendQueue(item.id),
|
||||
onMutate: (item) => setSendQueuePendingId(item.id),
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ["send-queue"] })
|
||||
await qc.invalidateQueries({ queryKey: ["send-queue-audit"] })
|
||||
toast({ title: "已取消发送任务" })
|
||||
},
|
||||
onError: (error) => toast({ title: "取消失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||
onSettled: () => setSendQueuePendingId(""),
|
||||
})
|
||||
const markAllRead = useMutation({
|
||||
mutationFn: async (items: MailMessage[]) => {
|
||||
const unread = items.filter((message) => !message.isRead)
|
||||
@@ -403,6 +432,7 @@ export function MailPage() {
|
||||
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
||||
qc.invalidateQueries({ queryKey: ["labels"] }),
|
||||
qc.invalidateQueries({ queryKey: ["scheduled-sends"] }),
|
||||
qc.invalidateQueries({ queryKey: ["send-queue"] }),
|
||||
qc.invalidateQueries({ queryKey: ["mail-notifications"] }),
|
||||
]).finally(() => {
|
||||
setLastAutoRefreshAt(new Date())
|
||||
@@ -429,10 +459,16 @@ export function MailPage() {
|
||||
const visibleScheduledItems = scheduledQuery
|
||||
? scheduledItems.filter((item) => [item.subject, item.snippet, ...(item.to || [])].join(" ").toLowerCase().includes(scheduledQuery))
|
||||
: scheduledItems
|
||||
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail)
|
||||
const sendQueueItems = sendQueue.data?.items || []
|
||||
const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length
|
||||
const sendQueueQuery = query.trim().toLowerCase()
|
||||
const visibleSendQueueItems = sendQueueQuery
|
||||
? sendQueueItems.filter((item) => [item.subject, item.source, item.lastError, item.error, item.failureReason, ...(item.recipients || [])].join(" ").toLowerCase().includes(sendQueueQuery))
|
||||
: sendQueueItems
|
||||
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue)
|
||||
const labelItems = labels.data?.items || []
|
||||
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
|
||||
const viewTitle = mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
|
||||
const viewTitle = mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
|
||||
const emptyMessage = getEmptyMessage(mailView, folder, allMessages.length)
|
||||
const visibleMessageIds = visibleMessages.map((message) => message.id)
|
||||
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
|
||||
@@ -453,6 +489,7 @@ export function MailPage() {
|
||||
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
||||
qc.invalidateQueries({ queryKey: ["labels"] }),
|
||||
qc.invalidateQueries({ queryKey: ["scheduled-sends"] }),
|
||||
qc.invalidateQueries({ queryKey: ["send-queue"] }),
|
||||
])
|
||||
}
|
||||
async function runBulkAction(action: BulkAction) {
|
||||
@@ -576,6 +613,13 @@ export function MailPage() {
|
||||
setMailFilter("all")
|
||||
setMobileSidebarOpen(false)
|
||||
}
|
||||
function openSendQueue() {
|
||||
setMailView("sendQueue")
|
||||
setSelectedLabelId("")
|
||||
setSelectedId(null)
|
||||
setMailFilter("all")
|
||||
setMobileSidebarOpen(false)
|
||||
}
|
||||
function openLabel(labelId: string) {
|
||||
setSelectedLabelId(labelId)
|
||||
setMailView("label")
|
||||
@@ -654,9 +698,9 @@ export function MailPage() {
|
||||
{mailMenuItems.map((item) => (
|
||||
<SidebarMenuItem key={item.key}>
|
||||
<SidebarMenuButton
|
||||
isActive={item.type === "starred" ? mailView === "starred" : item.type === "scheduled" ? mailView === "scheduled" : mailView === "folder" && folder === item.folderName}
|
||||
isActive={item.type === "starred" ? mailView === "starred" : item.type === "scheduled" ? mailView === "scheduled" : item.type === "sendQueue" ? mailView === "sendQueue" : mailView === "folder" && folder === item.folderName}
|
||||
className={cn(sidebarCollapsed && "justify-center px-0")}
|
||||
onClick={() => item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : openFolder(item.folderName)}
|
||||
onClick={() => item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : item.type === "sendQueue" ? openSendQueue() : openFolder(item.folderName)}
|
||||
>
|
||||
{item.icon}
|
||||
{!sidebarCollapsed && <span>{item.label}</span>}
|
||||
@@ -772,6 +816,23 @@ export function MailPage() {
|
||||
/>
|
||||
) : mailView === "scheduled" ? (
|
||||
<PermissionEmptyState title="无定时发送权限" description="当前账号不能查看或管理定时发送任务。" onOpenSettings={openSettings} />
|
||||
) : mailView === "sendQueue" && canViewSendQueue ? (
|
||||
<SendQueueView
|
||||
compact={isMobile || displayMode === "compact"}
|
||||
items={visibleSendQueueItems}
|
||||
total={sendQueueItems.length}
|
||||
loading={sendQueue.isLoading}
|
||||
query={query}
|
||||
status={sendQueueStatus}
|
||||
pendingId={sendQueuePendingId}
|
||||
onStatusChange={setSendQueueStatus}
|
||||
onRetry={(item) => retrySendQueue.mutate(item)}
|
||||
onCancel={(item) => cancelSendQueue.mutate(item)}
|
||||
onAudit={(item) => setSendQueueAuditId(item.id)}
|
||||
canMutate={canSendMail}
|
||||
/>
|
||||
) : mailView === "sendQueue" ? (
|
||||
<PermissionEmptyState title="无发送队列权限" description="当前账号不能查看发送队列。" onOpenSettings={openSettings} />
|
||||
) : isMobile || displayMode === "compact" ? (
|
||||
<CompactMailView
|
||||
title={viewTitle}
|
||||
@@ -867,7 +928,7 @@ export function MailPage() {
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-6">
|
||||
<div className="mail-html prose max-w-none text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(selected.bodyHtml || `<pre>${escapeHtml(selected.bodyText || "")}</pre>`) }} />
|
||||
<MailHtmlFrame message={selected} />
|
||||
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium">附件</div><div className="space-y-2">{selected.attachments.map((a) => canDownloadAttachments ? <a className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-accent" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a> : <div className="flex items-center justify-between rounded-md border p-3 text-sm text-muted-foreground" key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span>{formatBytes(a.sizeBytes)}</span></div>)}</div></div>}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -900,7 +961,7 @@ export function MailPage() {
|
||||
{canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>}
|
||||
<div className="relative basis-full">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
|
||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
@@ -924,7 +985,7 @@ export function MailPage() {
|
||||
{autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"}
|
||||
</div>
|
||||
)}
|
||||
{mailView !== "scheduled" && (
|
||||
{mailView !== "scheduled" && mailView !== "sendQueue" && (
|
||||
<>
|
||||
{canOrganizeMail && <Button variant="outline" size="sm" disabled={!activeMailboxId || markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" />全部已读</Button>}
|
||||
<DropdownMenu>
|
||||
@@ -944,7 +1005,7 @@ export function MailPage() {
|
||||
</div>
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" />
|
||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" />
|
||||
</div>
|
||||
</header>
|
||||
{contentView}
|
||||
@@ -954,7 +1015,13 @@ export function MailPage() {
|
||||
)}
|
||||
</SidebarProvider>
|
||||
|
||||
<ComposeDialog mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} canSend={canSendMail} canManageDrafts={canManageDrafts} canSchedule={canScheduleMail} canManageSignatures={canManageSignatures} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }) }} />
|
||||
<ComposeDialog mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} limits={user?.limits} canSend={canSendMail} canManageDrafts={canManageDrafts} canSchedule={canScheduleMail} canManageSignatures={canManageSignatures} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }); qc.invalidateQueries({ queryKey: ["send-queue"] }) }} />
|
||||
<SendQueueAuditDialog
|
||||
open={!!sendQueueAuditId}
|
||||
loading={sendQueueAudit.isLoading}
|
||||
events={sendQueueAudit.data?.items || []}
|
||||
onOpenChange={(open) => { if (!open) setSendQueueAuditId("") }}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!pendingConfirm}
|
||||
title={pendingConfirm?.title || ""}
|
||||
@@ -969,7 +1036,7 @@ export function MailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean): MailMenuItem[] {
|
||||
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean, sendQueueCount: number, includeSendQueue: boolean): MailMenuItem[] {
|
||||
const byName = new Map(folders.map((item) => [item.name, item]))
|
||||
const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), unreadCount: 0, totalCount: 0 })
|
||||
for (const item of folders) {
|
||||
@@ -985,10 +1052,13 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number, schedul
|
||||
}))
|
||||
const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: <Star className="h-4 w-4" />, count: starredCount }
|
||||
const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: <Clock3 className="h-4 w-4" />, count: scheduledCount }
|
||||
const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: <History className="h-4 w-4" />, count: sendQueueCount }
|
||||
const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox")
|
||||
const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0
|
||||
if (!includeScheduled) return [...folderItems.slice(0, insertAt), starredItem, ...folderItems.slice(insertAt)]
|
||||
return [...folderItems.slice(0, insertAt), starredItem, scheduledItem, ...folderItems.slice(insertAt)]
|
||||
const specialItems: MailMenuItem[] = [starredItem]
|
||||
if (includeScheduled) specialItems.push(scheduledItem)
|
||||
if (includeSendQueue) specialItems.push(sendQueueItem)
|
||||
return [...folderItems.slice(0, insertAt), ...specialItems, ...folderItems.slice(insertAt)]
|
||||
}
|
||||
|
||||
function FolderSkeleton() { return <div className="space-y-2 p-2"><Skeleton className="h-8 w-full" /><Skeleton className="h-8 w-4/5" /><Skeleton className="h-8 w-3/4" /></div> }
|
||||
@@ -996,6 +1066,7 @@ function MessageSkeleton() { return <div className="space-y-0">{Array.from({ len
|
||||
|
||||
function getEmptyMessage(mailView: MailView, folder: string, total: number) {
|
||||
if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件"
|
||||
if (mailView === "sendQueue") return total === 0 ? "发送队列为空" : "当前搜索没有匹配的发送任务"
|
||||
if (total > 0) return "当前筛选条件下没有邮件"
|
||||
if (mailView === "starred") return "暂无星标邮件"
|
||||
if (mailView === "label") return "当前标签没有邮件"
|
||||
@@ -1114,6 +1185,180 @@ function ScheduledStatusBadge({ status }: { status: ScheduledSend["status"] }) {
|
||||
)
|
||||
}
|
||||
|
||||
const sendQueueStatusOptions: { value: SendQueueStatus | "all"; label: string }[] = [
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "queued", label: "排队中" },
|
||||
{ value: "sending", label: "发送中" },
|
||||
{ value: "failed", label: "发送失败" },
|
||||
{ value: "delivered", label: "已投递" },
|
||||
{ value: "canceled", label: "已取消" },
|
||||
]
|
||||
|
||||
function SendQueueView({
|
||||
compact,
|
||||
items,
|
||||
total,
|
||||
loading,
|
||||
query,
|
||||
status,
|
||||
pendingId,
|
||||
onStatusChange,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onAudit,
|
||||
canMutate,
|
||||
}: {
|
||||
compact: boolean
|
||||
items: SendQueueItem[]
|
||||
total: number
|
||||
loading: boolean
|
||||
query: string
|
||||
status: SendQueueStatus | "all"
|
||||
pendingId: string
|
||||
onStatusChange: (status: SendQueueStatus | "all") => void
|
||||
onRetry: (item: SendQueueItem) => void
|
||||
onCancel: (item: SendQueueItem) => void
|
||||
onAudit: (item: SendQueueItem) => void
|
||||
canMutate: boolean
|
||||
}) {
|
||||
const empty = query.trim() ? "当前搜索没有匹配的发送任务" : "发送队列为空"
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
<div className={cn("flex shrink-0 items-center justify-between gap-3 border-b", compact ? "min-h-12 px-4 py-2" : "h-14 px-5")}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold"><History className="h-4 w-4" />发送队列</div>
|
||||
<div className="text-xs text-muted-foreground">{items.length} / {total} 个发送任务</div>
|
||||
</div>
|
||||
<Select value={status} onValueChange={(value) => onStatusChange(value as SendQueueStatus | "all")}>
|
||||
<SelectTrigger className="h-9 w-[132px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sendQueueStatusOptions.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{loading && <ScheduledSendSkeleton />}
|
||||
{!loading && items.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{empty}</div>}
|
||||
{!loading && items.map((item) => (
|
||||
<SendQueueRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
compact={compact}
|
||||
pending={pendingId === item.id}
|
||||
onRetry={() => onRetry(item)}
|
||||
onCancel={() => onCancel(item)}
|
||||
onAudit={() => onAudit(item)}
|
||||
canMutate={canMutate}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SendQueueRow({ item, compact, pending, onRetry, onCancel, onAudit, canMutate }: { item: SendQueueItem; compact: boolean; pending: boolean; onRetry: () => void; onCancel: () => void; onAudit: () => void; canMutate: boolean }) {
|
||||
const recipients = item.recipients?.length ? item.recipients.join(", ") : "未记录收件人"
|
||||
const failure = item.lastError || item.error || item.failureReason || ""
|
||||
const canRetry = item.status === "failed"
|
||||
const canCancel = item.status === "queued" || item.status === "failed"
|
||||
return (
|
||||
<div className={cn("border-b transition-colors hover:bg-accent/40", compact ? "p-4" : "px-5 py-4")}>
|
||||
<div className={cn("gap-4", compact ? "space-y-3" : "grid grid-cols-[minmax(0,1fr)_210px_220px] items-center")}>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">{item.subject || "(无主题)"}</span>
|
||||
<SendQueueStatusBadge status={item.status} />
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">发给 {recipients}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>来源:{sendQueueSourceLabel(item.source)}</span>
|
||||
<span>尝试:{item.attemptCount}/{item.maxAttempts}</span>
|
||||
{item.nextAttemptAt && <span>下次:{formatDateTime(item.nextAttemptAt)}</span>}
|
||||
</div>
|
||||
{failure && <div className="mt-2 line-clamp-2 text-xs text-destructive">{failure}</div>}
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="text-xs text-muted-foreground">更新时间</div>
|
||||
<div className="font-medium">{formatDateTime(item.updatedAt || item.createdAt)}</div>
|
||||
{item.deliveredAt && <div className="text-xs text-muted-foreground">投递于 {formatDateTime(item.deliveredAt)}</div>}
|
||||
</div>
|
||||
<div className={cn("flex flex-wrap gap-2", compact ? "justify-start" : "justify-end")}>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onAudit}>
|
||||
<History className="h-4 w-4" />时间线
|
||||
</Button>
|
||||
{canMutate && canRetry && (
|
||||
<Button type="button" variant="outline" size="sm" disabled={pending} onClick={onRetry}>
|
||||
<RotateCcw className="h-4 w-4" />{pending ? "处理中..." : "重试"}
|
||||
</Button>
|
||||
)}
|
||||
{canMutate && canCancel && (
|
||||
<Button type="button" variant="destructive" size="sm" disabled={pending} onClick={onCancel}>
|
||||
{pending ? "处理中..." : "取消"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SendQueueStatusBadge({ status }: { status: SendQueueStatus }) {
|
||||
const label = status === "queued" ? "排队中" : status === "sending" ? "发送中" : status === "delivered" ? "已投递" : status === "failed" ? "发送失败" : "已取消"
|
||||
return (
|
||||
<Badge variant={status === "failed" ? "destructive" : status === "sending" || status === "queued" ? "secondary" : "outline"} className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal">
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function SendQueueAuditDialog({ open, loading, events, onOpenChange }: { open: boolean; loading: boolean; events: SendQueueAuditEvent[]; onOpenChange: (open: boolean) => void }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(92vw,42rem)] max-w-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle>投递时间线</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-auto pr-1">
|
||||
{loading && <div className="space-y-3">{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-14 w-full" />)}</div>}
|
||||
{!loading && events.length === 0 && <div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">暂无投递事件</div>}
|
||||
{!loading && events.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{event.status && <SendQueueStatusBadge status={event.status} />}
|
||||
<span>{event.message || event.event || event.eventType || "队列事件"}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{formatDateTime(event.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{typeof event.attemptCount === "number" && <span>尝试次数:{event.attemptCount}</span>}
|
||||
{event.error && <span className="text-destructive">{event.error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function sendQueueSourceLabel(source: string) {
|
||||
const normalized = source.toLowerCase()
|
||||
if (normalized === "submission") return "SMTP Submission"
|
||||
if (normalized === "webmail") return "Webmail"
|
||||
if (normalized === "scheduled") return "定时发送"
|
||||
return source || "未知"
|
||||
}
|
||||
|
||||
type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "trash" | "spam" | "delete"
|
||||
|
||||
function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) {
|
||||
@@ -1402,7 +1647,7 @@ function CompactMessageDetail({
|
||||
/>
|
||||
</div>
|
||||
<div className="py-6 sm:py-8">
|
||||
<div className="mail-html prose max-w-none text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(selected.bodyHtml || `<pre>${escapeHtml(selected.bodyText || "")}</pre>`) }} />
|
||||
<MailHtmlFrame message={selected} />
|
||||
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium">附件</div><div className="space-y-2">{selected.attachments.map((a) => canDownloadAttachments ? <a className="flex flex-col gap-1 rounded-md border p-3 text-sm hover:bg-accent sm:flex-row sm:items-center sm:justify-between" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex min-w-0 items-center gap-2"><Paperclip className="h-4 w-4 shrink-0" /><span className="truncate">{a.filename}</span></span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a> : <div className="flex flex-col gap-1 rounded-md border p-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between" key={a.id}><span className="flex min-w-0 items-center gap-2"><Paperclip className="h-4 w-4 shrink-0" /><span className="truncate">{a.filename}</span></span><span>{formatBytes(a.sizeBytes)}</span></div>)}</div></div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1412,6 +1657,62 @@ function CompactMessageDetail({
|
||||
)
|
||||
}
|
||||
|
||||
function MailHtmlFrame({ message }: { message: MailMessage }) {
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null)
|
||||
const [height, setHeight] = React.useState(260)
|
||||
const srcDoc = React.useMemo(() => buildMailFrameSrcDoc(message.bodyHtml || "", message.bodyText || ""), [message.bodyHtml, message.bodyText])
|
||||
|
||||
const resize = React.useCallback(() => {
|
||||
const doc = iframeRef.current?.contentDocument
|
||||
if (!doc) return
|
||||
const body = doc.body
|
||||
const html = doc.documentElement
|
||||
const nextHeight = Math.max(180, Math.ceil(Math.max(body?.scrollHeight || 0, body?.offsetHeight || 0, html?.scrollHeight || 0, html?.offsetHeight || 0)))
|
||||
setHeight(nextHeight)
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
setHeight(260)
|
||||
const frame = iframeRef.current
|
||||
if (!frame) return
|
||||
let observer: ResizeObserver | undefined
|
||||
const timers = [window.setTimeout(resize, 0), window.setTimeout(resize, 120), window.setTimeout(resize, 600)]
|
||||
const attach = () => {
|
||||
const doc = frame.contentDocument
|
||||
if (!doc) return
|
||||
doc.querySelectorAll("a[href]").forEach((link) => {
|
||||
link.setAttribute("target", "_blank")
|
||||
link.setAttribute("rel", "noopener noreferrer")
|
||||
})
|
||||
resize()
|
||||
if ("ResizeObserver" in window) {
|
||||
observer = new ResizeObserver(resize)
|
||||
observer.observe(doc.documentElement)
|
||||
if (doc.body) observer.observe(doc.body)
|
||||
}
|
||||
doc.querySelectorAll("img").forEach((img) => img.addEventListener("load", resize, { once: true }))
|
||||
}
|
||||
frame.addEventListener("load", attach)
|
||||
return () => {
|
||||
frame.removeEventListener("load", attach)
|
||||
observer?.disconnect()
|
||||
timers.forEach((timer) => window.clearTimeout(timer))
|
||||
}
|
||||
}, [resize, srcDoc])
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="邮件正文"
|
||||
className="block w-full border-0 bg-white"
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
referrerPolicy="no-referrer"
|
||||
srcDoc={srcDoc}
|
||||
style={{ height }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactMessageRow({ message, active, checked, scheduled, onCheckedChange, onClick, onStar, canOrganize }: { message: MailMessage; active: boolean; checked: boolean; scheduled?: boolean; onCheckedChange: (checked: boolean) => void; onClick: () => void; onStar: () => void; canOrganize: boolean }) {
|
||||
const visibleLabels = (message.labels || []).slice(0, 2)
|
||||
const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0)
|
||||
@@ -1662,6 +1963,7 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
|
||||
<MessageMetaRow label="接收时间">
|
||||
<span>{formatDateTime(message.receivedAt)}</span>
|
||||
</MessageMetaRow>
|
||||
<AuthenticationResultRow message={message} />
|
||||
{availableLabels && onAddLabel && onRemoveLabel && (
|
||||
<MessageMetaRow label="标签">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
@@ -1723,6 +2025,41 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
|
||||
)
|
||||
}
|
||||
|
||||
function AuthenticationResultRow({ message }: { message: MailMessage }) {
|
||||
const auth = message.authentication || { authenticationResults: "", receivedSpf: "", spf: "unknown", dkim: "unknown", dmarc: "unknown" }
|
||||
const title = [auth.authenticationResults, auth.receivedSpf].filter(Boolean).join("\n\n")
|
||||
return (
|
||||
<MessageMetaRow label="Auth">
|
||||
<div className="flex flex-wrap gap-1.5" title={title || undefined}>
|
||||
<AuthStatusBadge label="SPF" value={auth.spf} />
|
||||
<AuthStatusBadge label="DKIM" value={auth.dkim} />
|
||||
<AuthStatusBadge label="DMARC" value={auth.dmarc} />
|
||||
</div>
|
||||
</MessageMetaRow>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthStatusBadge({ label, value }: { label: string; value?: string }) {
|
||||
const status = normalizeAuthStatus(value)
|
||||
return (
|
||||
<Badge variant="outline" className={cn("rounded-md font-mono text-[11px] font-normal", authStatusClassName(status))}>
|
||||
{label}:{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeAuthStatus(value?: string) {
|
||||
const status = (value || "").trim().toLowerCase()
|
||||
if (["pass", "fail", "softfail", "neutral", "temperror", "permerror", "none"].includes(status)) return status
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function authStatusClassName(status: string) {
|
||||
if (status === "pass") return "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||
if (["fail", "softfail", "permerror"].includes(status)) return "border-red-300 bg-red-50 text-red-700"
|
||||
return "border-slate-300 bg-slate-50 text-slate-600"
|
||||
}
|
||||
|
||||
function MessageMetaRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)]">
|
||||
@@ -1816,7 +2153,7 @@ function MailLabelBadge({ label }: { label: MailLabel }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSchedule, canManageSignatures, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; canSend: boolean; canManageDrafts: boolean; canSchedule: boolean; canManageSignatures: boolean; onOpenChange: (v: boolean) => void; onSent: () => void }) {
|
||||
function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts, canSchedule, canManageSignatures, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; limits?: PermissionLimits; canSend: boolean; canManageDrafts: boolean; canSchedule: boolean; canManageSignatures: boolean; onOpenChange: (v: boolean) => void; onSent: () => void }) {
|
||||
const { toast } = useToast()
|
||||
const qc = useQueryClient()
|
||||
const [files, setFiles] = React.useState<File[]>([])
|
||||
@@ -1841,6 +2178,8 @@ function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSche
|
||||
const composerText = draft?.html || (draft?.text !== undefined ? draft.text : signatureText ? `\n\n-- \n${signatureText}` : "")
|
||||
const [body, setBody] = React.useState<ComposerValue>(() => draft?.html !== undefined ? htmlComposerValue(draft.html) : plainTextComposerValue(composerText))
|
||||
const activeMailboxId = draft?.mailboxId || mailbox?.id || ""
|
||||
const maxAttachmentBytes = attachmentLimitBytes(limits)
|
||||
const maxAttachmentText = maxAttachmentBytes > 0 ? formatBytes(maxAttachmentBytes) : "不限"
|
||||
const composePayload = React.useMemo<DraftPayload>(() => ({
|
||||
mailboxId: activeMailboxId,
|
||||
to: splitEmails(toValue),
|
||||
@@ -1975,9 +2314,30 @@ function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSche
|
||||
})
|
||||
}
|
||||
|
||||
function addFiles(nextFiles: File[]) {
|
||||
if (nextFiles.length === 0) return
|
||||
const allowed = maxAttachmentBytes > 0 ? nextFiles.filter((file) => file.size <= maxAttachmentBytes) : nextFiles
|
||||
const blockedCount = nextFiles.length - allowed.length
|
||||
if (blockedCount > 0) {
|
||||
toast({ title: "附件超过权限组上限", description: `当前单个附件上限 ${maxAttachmentText}` })
|
||||
}
|
||||
if (allowed.length > 0) {
|
||||
setAttachmentsTouched(true)
|
||||
setFiles((current) => [...current, ...allowed])
|
||||
}
|
||||
}
|
||||
|
||||
function attachmentsWithinLimit() {
|
||||
if (maxAttachmentBytes <= 0) return true
|
||||
if (files.every((file) => file.size <= maxAttachmentBytes)) return true
|
||||
toast({ title: "附件超过权限组上限", description: `当前单个附件上限 ${maxAttachmentText}` })
|
||||
return false
|
||||
}
|
||||
|
||||
async function prepareSend() {
|
||||
if (!canSend) return
|
||||
if (!mailbox) return
|
||||
if (!attachmentsWithinLimit()) return
|
||||
const attachments = await Promise.all(files.map(fileToAttachment))
|
||||
const to = splitEmails(toValue)
|
||||
const cc = showCc ? splitEmails(ccValue) : []
|
||||
@@ -2015,6 +2375,7 @@ function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSche
|
||||
toast({ title: "请选择发件邮箱" })
|
||||
return
|
||||
}
|
||||
if (!attachmentsWithinLimit()) return
|
||||
const attachments = await Promise.all(files.map(fileToAttachment))
|
||||
const payload: SendPayload & { draftId?: string; sendAt: string } = {
|
||||
mailboxId: mailbox.id,
|
||||
@@ -2093,8 +2454,9 @@ function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSche
|
||||
defaultHtml={draft?.html}
|
||||
files={files}
|
||||
signatureText={signatureText}
|
||||
maxAttachmentText={maxAttachmentText}
|
||||
onChange={setBody}
|
||||
onPickFiles={(nextFiles) => { setAttachmentsTouched(true); setFiles((current) => [...current, ...nextFiles]) }}
|
||||
onPickFiles={addFiles}
|
||||
onRemoveFile={(index) => { setAttachmentsTouched(true); setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)) }}
|
||||
/>
|
||||
</div>
|
||||
@@ -2328,7 +2690,7 @@ function scheduleToNodeAttributes(schedule: ScheduleDraft) {
|
||||
}
|
||||
}
|
||||
|
||||
function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onChange, onPickFiles, onRemoveFile }: { defaultValue: string; defaultHtml?: string; files: File[]; signatureText: string; onChange: (value: ComposerValue) => void; onPickFiles: (files: File[]) => void; onRemoveFile: (index: number) => void }) {
|
||||
function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, maxAttachmentText, onChange, onPickFiles, onRemoveFile }: { defaultValue: string; defaultHtml?: string; files: File[]; signatureText: string; maxAttachmentText: string; onChange: (value: ComposerValue) => void; onPickFiles: (files: File[]) => void; onRemoveFile: (index: number) => void }) {
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const dirtyRef = React.useRef(false)
|
||||
const lastDefaultRef = React.useRef(`${defaultValue}\n${defaultHtml || ""}`)
|
||||
@@ -2507,6 +2869,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
|
||||
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setHorizontalRule().run()}><span className="h-4 w-4 border-t border-current" aria-hidden />分隔线</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">附件 {maxAttachmentText}</span>
|
||||
<ToolbarTextButton label="日程" icon={<Calendar className="h-4 w-4" />} onClick={() => setScheduleOpen(true)} />
|
||||
<DropdownMenu open={emojiOpen} onOpenChange={setEmojiOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -3030,6 +3393,49 @@ function plainTextToHtmlFragment(value: string) { return value.split("\n").map((
|
||||
function escapeHtml(value: string) {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'")
|
||||
}
|
||||
function buildMailFrameSrcDoc(bodyHtml: string, bodyText: string) {
|
||||
const rawBody = bodyHtml.trim() ? bodyHtml : `<pre>${escapeHtml(bodyText || "")}</pre>`
|
||||
const sanitized = DOMPurify.sanitize(rawBody, {
|
||||
ADD_ATTR: ["style", "type", "align", "valign", "bgcolor", "border", "cellpadding", "cellspacing", "width", "height"],
|
||||
ADD_TAGS: ["html", "head", "body", "style", "center", "font"],
|
||||
WHOLE_DOCUMENT: /<html[\s>]/i.test(rawBody) || /<body[\s>]/i.test(rawBody),
|
||||
})
|
||||
if (/<html[\s>]/i.test(sanitized) || /<body[\s>]/i.test(sanitized)) {
|
||||
const hasHead = /<head[\s>]/i.test(sanitized)
|
||||
const withBase = hasHead
|
||||
? sanitized.replace(/<head([^>]*)>/i, `<head$1><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><base target="_blank">${mailFrameBaseStyle()}`)
|
||||
: sanitized.replace(/<html([^>]*)>/i, `<html$1><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><base target="_blank">${mailFrameBaseStyle()}</head>`)
|
||||
return /<!doctype/i.test(withBase) ? withBase : `<!doctype html>${withBase}`
|
||||
}
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<base target="_blank">
|
||||
${mailFrameBaseStyle()}
|
||||
</head>
|
||||
<body>${sanitized}</body>
|
||||
</html>`
|
||||
}
|
||||
function mailFrameBaseStyle() {
|
||||
return `<style>
|
||||
html, body { margin: 0; padding: 0; background: #fff; color: #111827; }
|
||||
body {
|
||||
box-sizing: border-box;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
table { max-width: 100%; }
|
||||
pre { white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
a { color: #2563eb; }
|
||||
</style>`
|
||||
}
|
||||
function sanitizeComposerHtml(value: string) {
|
||||
return DOMPurify.sanitize(value || "")
|
||||
}
|
||||
@@ -3065,6 +3471,10 @@ function quoteMessage(message: MailMessage) {
|
||||
return `\n\n----- 原始邮件 -----\nFrom: ${senderTitle(message)}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${message.subject}\n\n${quote}`
|
||||
}
|
||||
function stripHtml(html: string) { const div = document.createElement("div"); div.innerHTML = DOMPurify.sanitize(html); return div.textContent || div.innerText || "" }
|
||||
function attachmentLimitBytes(limits?: PermissionLimits) {
|
||||
const mb = limits?.maxAttachmentMb || 0
|
||||
return mb > 0 ? mb * 1024 * 1024 : 0
|
||||
}
|
||||
async function fileToAttachment(file: File) {
|
||||
const buffer = await file.arrayBuffer()
|
||||
let binary = ""
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
|
||||
import { QRCodeSVG } from "qrcode.react"
|
||||
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats } from "@/lib/api"
|
||||
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
|
||||
import { cn, formatBytes } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
|
||||
@@ -303,9 +303,27 @@ export function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
function ProfileOverview({ user, profile, password, passwordFormRef, stats, showStats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
|
||||
function ProfileOverview({ user, profile, password, passwordFormRef, stats, showStats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string; limits?: PermissionLimits }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>账号配额</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<LimitBadge label="附件上限" value={user.limits?.maxAttachmentMb} unit="MB" />
|
||||
<LimitBadge label="SMTP 每日" value={user.limits?.smtpDailyLimit} unit="封" />
|
||||
<LimitBadge label="SMTP 每分钟" value={user.limits?.smtpMinuteLimit} unit="封" />
|
||||
<LimitBadge label="IMAP 每分钟" value={user.limits?.imapMinuteLimit} unit="次" />
|
||||
<LimitBadge label="POP3 每分钟" value={user.limits?.pop3MinuteLimit} unit="次" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{showStats && <StatsSummary stats={stats} />}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>账户信息</CardTitle>
|
||||
@@ -451,7 +469,18 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, show
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{showStats && <StatsSummary stats={stats} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LimitBadge({ label, value, unit }: { label: string; value?: number; unit: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold tabular-nums tracking-tight">
|
||||
{value !== undefined && value > 0 ? value : "不限"}
|
||||
</div>
|
||||
{value !== undefined && value > 0 && <div className="text-xs text-muted-foreground">{unit}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -783,8 +812,14 @@ type RuleCreatePayload = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const conditionFieldLabels: Record<MailRuleCondition["field"], string> = { from: "发件人地址", to: "收件人地址", subject: "邮件主题", body: "邮件正文" }
|
||||
const conditionOperatorLabels: Record<MailRuleCondition["operator"], string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是" }
|
||||
type RuleConditionField = NonNullable<MailRuleCondition["field"]>
|
||||
type RuleConditionOperator = NonNullable<MailRuleCondition["operator"]>
|
||||
const conditionFieldLabels: Record<RuleConditionField, string> = { from: "发件人地址", to: "收件人地址", cc: "抄送地址", subject: "邮件主题", body: "邮件正文", attachment: "附件名称", size: "邮件大小", date: "收信日期" }
|
||||
const conditionOperatorLabels: Record<RuleConditionOperator, string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是", gt: "大于", gte: "大于等于", lt: "小于", lte: "小于等于", before: "早于", after: "晚于", on: "当天" }
|
||||
const textConditionOperators: RuleConditionOperator[] = ["contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with"]
|
||||
const sizeConditionOperators: RuleConditionOperator[] = ["gt", "gte", "lt", "lte", "equals", "not-equals"]
|
||||
const dateConditionOperators: RuleConditionOperator[] = ["before", "after", "on", "equals", "not-equals"]
|
||||
const conditionFields = Object.keys(conditionFieldLabels) as RuleConditionField[]
|
||||
const ruleActionLabels: Record<MailRuleAction["type"], string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到" }
|
||||
|
||||
function RulesSection({ items, mailboxes, labels, open, onOpenChange, onCreate, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onDelete: (id: string) => void; pending: boolean }) {
|
||||
@@ -831,7 +866,14 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
}, [open, labels])
|
||||
|
||||
function updateCondition(index: number, patch: Partial<MailRuleCondition>) {
|
||||
setConditions((items) => items.map((item, i) => i === index ? { ...item, ...patch } : item))
|
||||
setConditions((items) => items.map((item, i) => {
|
||||
if (i !== index) return item
|
||||
const next = { ...item, ...patch }
|
||||
if (patch.field && !conditionOperatorsForField(patch.field).includes(next.operator || "contains")) {
|
||||
next.operator = defaultConditionOperator(patch.field)
|
||||
}
|
||||
return next
|
||||
}))
|
||||
}
|
||||
function updateAction(index: number, patch: Partial<MailRuleAction>) {
|
||||
setActions((items) => items.map((item, i) => i === index ? normalizeDraftAction({ ...item, ...patch }, availableLabels) : item))
|
||||
@@ -841,7 +883,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
function removeCondition(index: number) { setConditions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
|
||||
function removeAction(index: number) { setActions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
|
||||
|
||||
const validConditions = conditions.map((item) => ({ ...item, value: item.value.trim() })).filter((item) => item.value)
|
||||
const validConditions = conditions.map((item) => ({ ...item, value: (item.value || "").trim() })).filter((item) => item.field && item.operator && item.value)
|
||||
const validActions = actions.map((item) => normalizeDraftAction(item, availableLabels)).filter((item) => item.type !== "label" || item.value || item.labelId).filter((item) => item.type !== "move" || item.value)
|
||||
const canCreate = validConditions.length > 0 && validActions.length > 0 && !pending
|
||||
|
||||
@@ -873,15 +915,15 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
<div className="space-y-3">
|
||||
{conditions.map((condition, index) => (
|
||||
<div key={index} className="grid gap-3 md:grid-cols-[220px_150px_minmax(0,1fr)_auto_auto]">
|
||||
<Select value={condition.field} onValueChange={(value) => updateCondition(index, { field: value as MailRuleCondition["field"] })}>
|
||||
<Select value={condition.field || "from"} onValueChange={(value) => updateCondition(index, { field: value as RuleConditionField })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(Object.keys(conditionFieldLabels) as MailRuleCondition["field"][]).map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{conditionFields.map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Select value={condition.operator} onValueChange={(value) => updateCondition(index, { operator: value as MailRuleCondition["operator"] })}>
|
||||
<Select value={condition.operator || defaultConditionOperator(condition.field)} onValueChange={(value) => updateCondition(index, { operator: value as RuleConditionOperator })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(Object.keys(conditionOperatorLabels) as MailRuleCondition["operator"][]).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Input value={condition.value} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder="输入值" />
|
||||
<Input type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
|
||||
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
@@ -983,9 +1025,38 @@ function normalizeDraftAction(action: MailRuleAction, labels: MailLabel[]): Mail
|
||||
return { type: action.type }
|
||||
}
|
||||
|
||||
function conditionOperatorsForField(field?: MailRuleCondition["field"]) {
|
||||
if (field === "size") return sizeConditionOperators
|
||||
if (field === "date") return dateConditionOperators
|
||||
return textConditionOperators
|
||||
}
|
||||
|
||||
function defaultConditionOperator(field?: MailRuleCondition["field"]): RuleConditionOperator {
|
||||
if (field === "size") return "gte"
|
||||
if (field === "date") return "on"
|
||||
return "contains"
|
||||
}
|
||||
|
||||
function conditionPlaceholder(field?: MailRuleCondition["field"]) {
|
||||
if (field === "size") return "例如 10mb"
|
||||
if (field === "date") return "选择日期"
|
||||
if (field === "attachment") return "输入附件名或扩展名"
|
||||
return "输入值"
|
||||
}
|
||||
|
||||
function conditionSummary(conditions: MailRuleCondition[] = [], fromContains = "", subjectContains = "") {
|
||||
const items = conditions.length > 0 ? conditions : [fromContains ? { field: "from", operator: "contains", value: fromContains } as MailRuleCondition : undefined, subjectContains ? { field: "subject", operator: "contains", value: subjectContains } as MailRuleCondition : undefined].filter(Boolean) as MailRuleCondition[]
|
||||
return items.map((item) => `${conditionFieldLabels[item.field]} ${conditionOperatorLabels[item.operator]} ${item.value}`).join(";") || "无条件"
|
||||
return items.map(conditionItemSummary).join(";") || "无条件"
|
||||
}
|
||||
|
||||
function conditionItemSummary(item: MailRuleCondition): string {
|
||||
if (item.conditions?.length) {
|
||||
const mode = item.matchMode === "any" ? "任一" : "全部"
|
||||
return `${mode}(${item.conditions.map(conditionItemSummary).join(";")})`
|
||||
}
|
||||
const field = item.field || "from"
|
||||
const operator = item.operator || defaultConditionOperator(field)
|
||||
return `${conditionFieldLabels[field]} ${conditionOperatorLabels[operator]} ${item.value || ""}`
|
||||
}
|
||||
|
||||
function actionSummary(action: MailRuleAction) {
|
||||
@@ -1034,7 +1105,8 @@ function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbo
|
||||
}
|
||||
|
||||
function StatsSummary({ stats }: { stats?: MailStats }) {
|
||||
const cards = [{ label: "总邮件", value: stats?.totalMessages || 0 }, { label: "未读", value: stats?.unreadMessages || 0 }, { label: "星标", value: stats?.starredMessages || 0 }, { label: "附件", value: stats?.attachmentCount || 0 }, { label: "容量", value: formatBytes(stats?.storageBytes || 0) }]
|
||||
const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0)
|
||||
const cards = [{ label: "总邮件", value: stats?.totalMessages || 0 }, { label: "未读", value: stats?.unreadMessages || 0 }, { label: "星标", value: stats?.starredMessages || 0 }, { label: "附件", value: `${stats?.attachmentCount || 0} / ${formatBytes(stats?.attachmentBytes || 0)}` }, { label: stats?.quotaBytes ? `容量 ${Math.min(stats.quotaUsedPct || 0, 999).toFixed(1)}%` : "容量", value: quotaLabel }]
|
||||
return <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">{cards.map((c) => <Card key={c.label}><CardContent className="p-4"><div className="text-2xl font-semibold tracking-tight">{c.value}</div><div className="text-xs text-muted-foreground">{c.label}</div></CardContent></Card>)}</div>
|
||||
}
|
||||
|
||||
|
||||
Vendored
+6
-1
@@ -1,4 +1,9 @@
|
||||
declare module "dompurify" {
|
||||
const DOMPurify: { sanitize: (source: string) => string }
|
||||
type SanitizeConfig = {
|
||||
ADD_ATTR?: string[]
|
||||
ADD_TAGS?: string[]
|
||||
WHOLE_DOCUMENT?: boolean
|
||||
}
|
||||
const DOMPurify: { sanitize: (source: string, config?: SanitizeConfig) => string }
|
||||
export default DOMPurify
|
||||
}
|
||||
|
||||
+10
-1
@@ -28,7 +28,7 @@ LANQIN_PUBLIC_BASE_URL=https://mail.example.com
|
||||
|
||||
# 邮件客户端 TLS 证书。用于 SMTP 465/587、IMAP 993、POP3 995。
|
||||
# 生产环境请挂载真实证书,并确保域名包含 LANQIN_PUBLIC_HOSTNAME。
|
||||
# 留空时会使用容器自带 localhost 自签证书,第三方客户端会提示证书不匹配。
|
||||
# 留空时 Dovecot/Postfix 会使用容器自带 localhost 自签证书;LanQin API 的 SMTP submission 不会启用。
|
||||
LANQIN_TLS_CERT_FILE=
|
||||
LANQIN_TLS_KEY_FILE=
|
||||
|
||||
@@ -84,15 +84,24 @@ LANQIN_TURNSTILE_SECRET_KEY=
|
||||
# SMTP 发信
|
||||
# =========================
|
||||
# 单容器部署默认提交给容器内 Postfix。
|
||||
# Split stack 会由 docker-compose.stack.yml 默认覆盖为 postfix:25。
|
||||
# 如需在 split stack 使用外部 SMTP,可设置 LANQIN_STACK_SMTP_HOST / LANQIN_STACK_SMTP_PORT。
|
||||
# 如果要走外部 SMTP,把 Host/Port/Username/Password 改成外部服务配置。
|
||||
LANQIN_SMTP_HOST=127.0.0.1
|
||||
LANQIN_SMTP_PORT=25
|
||||
LANQIN_STACK_SMTP_HOST=
|
||||
LANQIN_STACK_SMTP_PORT=
|
||||
LANQIN_SMTP_USERNAME=
|
||||
LANQIN_SMTP_PASSWORD=
|
||||
|
||||
# 外部 SMTP 要求 STARTTLS / TLS 时改 true;本机 Postfix 默认 false。
|
||||
LANQIN_SMTP_REQUIRE_TLS=false
|
||||
|
||||
# 第三方客户端 SMTP 提交,由 LanQin API 监听 587/465;启用前必须配置可读 TLS 证书。
|
||||
LANQIN_SUBMISSION_ADDR=
|
||||
LANQIN_SUBMISSION_TLS_ADDR=
|
||||
LANQIN_SUBMISSION_MAX_MESSAGE_MB=35
|
||||
|
||||
# =========================
|
||||
# 收件 / Maildir 同步
|
||||
# =========================
|
||||
|
||||
+11
-4
@@ -128,18 +128,21 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
|
||||
- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`。
|
||||
- Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。
|
||||
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。
|
||||
- 第三方客户端通过 SMTP `465/587` 发信时,Postfix 会把已认证发件人的邮件自动 BCC 到 `发件人+Sent@域名`,Dovecot LMTP 会保存到该邮箱的 `Sent` 文件夹,Webmail 扫描后会显示在“已发送”。
|
||||
- 第三方客户端可通过 LanQin API 提供的 SMTP `465/587` 发信;Webmail/API 和第三方客户端的“已发送”都由 API 写入,外发投递进入发送队列并由 API worker relay/retry,客户端后续 IMAP APPEND 到 Sent 会按 `Message-ID` 去重。
|
||||
- send-as v1 支持本人邮箱、启用的别名转发 source 指向本人邮箱,或数据库表 `send_as_grants` 中显式授权的地址。
|
||||
|
||||
## 邮件客户端 TLS 证书
|
||||
|
||||
Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/POP3 端口不会使用 Web 反代的证书。
|
||||
如果第三方客户端连接 `465/587/993/995` 时提示证书是 `localhost`,说明 Postfix/Dovecot 仍在使用容器自带的测试证书。
|
||||
如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。
|
||||
|
||||
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
|
||||
|
||||
```env
|
||||
LANQIN_TLS_CERT_FILE=/certs/fullchain.pem
|
||||
LANQIN_TLS_KEY_FILE=/certs/privkey.pem
|
||||
LANQIN_SUBMISSION_ADDR=:587
|
||||
LANQIN_SUBMISSION_TLS_ADDR=:465
|
||||
```
|
||||
|
||||
单容器示例:
|
||||
@@ -170,12 +173,16 @@ LANQIN_SMTP_PORT=25
|
||||
LANQIN_SMTP_REQUIRE_TLS=false
|
||||
```
|
||||
|
||||
如果页面提示 `smtp delivery failed: EOF`,通常是 Postfix 会话被中断。优先检查:
|
||||
Split stack 使用 `docker-compose.stack.yml` 时,API 容器默认会把 `LANQIN_SMTP_HOST` 覆盖为 `postfix`,让 Webmail 和 SMTP 提交都 relay 到 Postfix service。只有改用外部 SMTP 时才需要在 `.env` 明确填写 `LANQIN_STACK_SMTP_HOST` / `LANQIN_STACK_SMTP_PORT`。
|
||||
|
||||
如果发送队列里出现 relay 失败,通常是 Postfix 会话被中断或外部 SMTP 配置错误。优先检查:
|
||||
|
||||
```bash
|
||||
docker compose exec lanqin-email supervisorctl status
|
||||
docker compose exec lanqin-email postconf -M smtp/inet submission/inet
|
||||
docker compose exec lanqin-email postconf -M smtp/inet
|
||||
# SMTP 提交 465/587 由 LanQin API 提供,不再由 Postfix 监听。
|
||||
docker compose exec lanqin-email sqlite3 /data/lanqin.db "select key,value from system_settings where key like 'smtp%' order by key;"
|
||||
docker compose exec lanqin-email sqlite3 /data/lanqin.db "select status,attempt_count,last_error from send_queue order by created_at desc limit 10;"
|
||||
docker compose logs --tail=200 lanqin-email
|
||||
```
|
||||
|
||||
|
||||
@@ -7,11 +7,14 @@ set -eu
|
||||
: "${LANQIN_ADDR:=127.0.0.1:8080}"
|
||||
: "${LANQIN_SMTP_HOST:=127.0.0.1}"
|
||||
: "${LANQIN_SMTP_PORT:=25}"
|
||||
: "${LANQIN_SUBMISSION_ADDR:=}"
|
||||
: "${LANQIN_SUBMISSION_TLS_ADDR:=}"
|
||||
: "${LANQIN_SUBMISSION_MAX_MESSAGE_MB:=35}"
|
||||
: "${LANQIN_MAILDIR_ROOT:=/var/mail/vhosts}"
|
||||
: "${LANQIN_TLS_CERT_FILE:=}"
|
||||
: "${LANQIN_TLS_KEY_FILE:=}"
|
||||
|
||||
export LANQIN_DATA_DIR LANQIN_DB_PATH LANQIN_ADDR LANQIN_SMTP_HOST LANQIN_SMTP_PORT LANQIN_MAILDIR_ROOT
|
||||
export LANQIN_DATA_DIR LANQIN_DB_PATH LANQIN_ADDR LANQIN_SMTP_HOST LANQIN_SMTP_PORT LANQIN_SUBMISSION_ADDR LANQIN_SUBMISSION_TLS_ADDR LANQIN_SUBMISSION_MAX_MESSAGE_MB LANQIN_MAILDIR_ROOT LANQIN_TLS_CERT_FILE LANQIN_TLS_KEY_FILE
|
||||
|
||||
addgroup --system --gid 5000 vmail 2>/dev/null || true
|
||||
adduser --system --uid 5000 --gid 5000 --home /var/mail/vhosts --no-create-home vmail 2>/dev/null || true
|
||||
@@ -23,28 +26,44 @@ elif id rspamd >/dev/null 2>&1; then
|
||||
chown -R rspamd:rspamd /run/rspamd /var/lib/rspamd 2>/dev/null || true
|
||||
fi
|
||||
|
||||
AUTH_POLICY_NONCE_FILE="${LANQIN_AUTH_POLICY_NONCE_FILE:-/data/dovecot-auth-policy-nonce}"
|
||||
mkdir -p "$(dirname "$AUTH_POLICY_NONCE_FILE")"
|
||||
if [ ! -s "$AUTH_POLICY_NONCE_FILE" ]; then
|
||||
od -An -tx1 -N32 /dev/urandom | tr -d ' \n' > "$AUTH_POLICY_NONCE_FILE"
|
||||
fi
|
||||
chmod 600 "$AUTH_POLICY_NONCE_FILE" 2>/dev/null || true
|
||||
AUTH_POLICY_HASH_NONCE="$(cat "$AUTH_POLICY_NONCE_FILE")"
|
||||
|
||||
TLS_CERT=/etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
TLS_KEY=/etc/ssl/private/ssl-cert-snakeoil.key
|
||||
if [ -n "$LANQIN_TLS_CERT_FILE" ] || [ -n "$LANQIN_TLS_KEY_FILE" ]; then
|
||||
if [ -f "$LANQIN_TLS_CERT_FILE" ] && [ -f "$LANQIN_TLS_KEY_FILE" ]; then
|
||||
TLS_CERT="$LANQIN_TLS_CERT_FILE"
|
||||
TLS_KEY="$LANQIN_TLS_KEY_FILE"
|
||||
: "${LANQIN_SUBMISSION_ADDR:=:587}"
|
||||
: "${LANQIN_SUBMISSION_TLS_ADDR:=:465}"
|
||||
else
|
||||
echo "warning: LANQIN_TLS_CERT_FILE/LANQIN_TLS_KEY_FILE not readable; using snakeoil localhost certificate" >&2
|
||||
fi
|
||||
fi
|
||||
if [ -n "$LANQIN_SUBMISSION_ADDR$LANQIN_SUBMISSION_TLS_ADDR" ] && { [ "$TLS_CERT" = "/etc/ssl/certs/ssl-cert-snakeoil.pem" ] || [ "$TLS_KEY" = "/etc/ssl/private/ssl-cert-snakeoil.key" ]; }; then
|
||||
echo "warning: SMTP submission disabled because LANQIN_TLS_CERT_FILE/LANQIN_TLS_KEY_FILE are not configured with readable certificate files" >&2
|
||||
LANQIN_SUBMISSION_ADDR=""
|
||||
LANQIN_SUBMISSION_TLS_ADDR=""
|
||||
fi
|
||||
export LANQIN_SUBMISSION_ADDR LANQIN_SUBMISSION_TLS_ADDR
|
||||
|
||||
postconf -e "myhostname = ${LANQIN_PUBLIC_HOSTNAME}"
|
||||
postconf -e "myorigin = ${LANQIN_PUBLIC_HOSTNAME}"
|
||||
postconf -e "smtpd_tls_cert_file = ${TLS_CERT}"
|
||||
postconf -e "smtpd_tls_key_file = ${TLS_KEY}"
|
||||
postconf -e "virtual_transport = lmtp:inet:127.0.0.1:24"
|
||||
postconf -e "smtpd_sasl_path = inet:127.0.0.1:12345"
|
||||
postconf -e "milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen}"
|
||||
postconf -e "smtpd_milters = inet:127.0.0.1:11332"
|
||||
postconf -e "non_smtpd_milters = inet:127.0.0.1:11332"
|
||||
sed -i "s#^ssl_cert = <.*#ssl_cert = <${TLS_CERT}#" /etc/dovecot/dovecot.conf
|
||||
sed -i "s#^ssl_key = <.*#ssl_key = <${TLS_KEY}#" /etc/dovecot/dovecot.conf
|
||||
sed -i "s#^auth_policy_hash_nonce = .*#auth_policy_hash_nonce = ${AUTH_POLICY_HASH_NONCE}#" /etc/dovecot/dovecot.conf
|
||||
|
||||
# Rspamd DKIM keys are exported after API seed/migrations create the SQLite DB.
|
||||
/usr/local/bin/lanqin-api >/tmp/lanqin-api-bootstrap.log 2>&1 &
|
||||
|
||||
@@ -17,5 +17,5 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api
|
||||
EXPOSE 8080
|
||||
EXPOSE 8080 465 587
|
||||
CMD ["lanqin-api"]
|
||||
|
||||
@@ -2,9 +2,21 @@ services:
|
||||
api:
|
||||
image: ${LANQIN_API_IMAGE:-ghcr.io/lanqin996/lanqin-email-api:latest}
|
||||
env_file: .env
|
||||
environment:
|
||||
LANQIN_SMTP_HOST: ${LANQIN_STACK_SMTP_HOST:-postfix}
|
||||
LANQIN_SMTP_PORT: ${LANQIN_STACK_SMTP_PORT:-25}
|
||||
LANQIN_SUBMISSION_ADDR: ${LANQIN_SUBMISSION_ADDR:-}
|
||||
LANQIN_SUBMISSION_TLS_ADDR: ${LANQIN_SUBMISSION_TLS_ADDR:-}
|
||||
volumes:
|
||||
- ./data:/data:ro
|
||||
- ./data:/data
|
||||
- ./mail:/var/mail/vhosts:ro
|
||||
# 生产环境如需第三方客户端校验证书,请取消下面挂载的注释,并在 .env 配置:
|
||||
# LANQIN_TLS_CERT_FILE=/certs/fullchain.pem
|
||||
# LANQIN_TLS_KEY_FILE=/certs/privkey.pem
|
||||
# - /etc/letsencrypt/live/${LANQIN_PUBLIC_HOSTNAME}:/certs:ro
|
||||
ports:
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
depends_on:
|
||||
- dovecot
|
||||
- postfix
|
||||
@@ -36,8 +48,6 @@ services:
|
||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
ports:
|
||||
- "25:25"
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
depends_on:
|
||||
- dovecot
|
||||
- rspamd
|
||||
|
||||
@@ -2,4 +2,4 @@ driver = sqlite
|
||||
connect = /data/lanqin.db
|
||||
default_pass_scheme = BLF-CRYPT
|
||||
password_query = SELECT address AS user, password_hash AS password FROM mailboxes WHERE address = '%u' AND status = 'active'
|
||||
user_query = SELECT '/var/mail/vhosts/' || d.name || '/' || m.local_part AS home, 'maildir:/var/mail/vhosts/' || d.name || '/' || m.local_part || '/Maildir' AS mail, 5000 AS uid, 5000 AS gid FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active' AND d.status='active' UNION SELECT '/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__' AS home, 'maildir:/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__/Maildir' AS mail, 5000 AS uid, 5000 AS gid WHERE EXISTS (SELECT 1 FROM system_settings WHERE key='catchAllEnabled' AND value='true') AND EXISTS (SELECT 1 FROM domains WHERE name=lower(substr('%u', instr('%u', '@') + 1)) AND status='active') AND NOT EXISTS (SELECT 1 FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active')
|
||||
user_query = SELECT '/var/mail/vhosts/' || d.name || '/' || m.local_part AS home, 'maildir:/var/mail/vhosts/' || d.name || '/' || m.local_part || '/Maildir' AS mail, 5000 AS uid, 5000 AS gid, '*:storage=' || CAST(m.quota_mb AS TEXT) || 'M' AS quota_rule FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active' AND d.status='active' UNION SELECT '/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__' AS home, 'maildir:/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__/Maildir' AS mail, 5000 AS uid, 5000 AS gid, '*:storage=1024M' AS quota_rule WHERE EXISTS (SELECT 1 FROM system_settings WHERE key='catchAllEnabled' AND value='true') AND EXISTS (SELECT 1 FROM domains WHERE name=lower(substr('%u', instr('%u', '@') + 1)) AND status='active') AND NOT EXISTS (SELECT 1 FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active')
|
||||
|
||||
@@ -10,21 +10,42 @@ ssl_cert = </etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
ssl_key = </etc/ssl/private/ssl-cert-snakeoil.key
|
||||
|
||||
mail_home = /var/mail/vhosts/%d/%n
|
||||
mail_max_userip_connections = 10
|
||||
recipient_delimiter = +
|
||||
|
||||
plugin {
|
||||
quota = maildir:User quota
|
||||
quota_rule = *:storage=1G
|
||||
# auth_policy_server is configured via dovecot-sql.conf.ext user_query
|
||||
}
|
||||
|
||||
auth_policy_server_url = http://127.0.0.1:8080/auth-policy
|
||||
auth_policy_server_api_header = Content-Type: application/json
|
||||
auth_policy_hash_mech = sha256
|
||||
auth_policy_hash_truncate = 12
|
||||
auth_policy_hash_nonce = __LANQIN_AUTH_POLICY_HASH_NONCE__
|
||||
auth_policy_request_attributes = login=%{requested_username} remote=%{rip} protocol=%s
|
||||
|
||||
namespace inbox {
|
||||
inbox = yes
|
||||
mailbox Drafts {
|
||||
auto = subscribe
|
||||
special_use = \Drafts
|
||||
}
|
||||
mailbox Sent {
|
||||
auto = subscribe
|
||||
special_use = \Sent
|
||||
}
|
||||
mailbox Trash {
|
||||
auto = subscribe
|
||||
special_use = \Trash
|
||||
}
|
||||
mailbox Archive {
|
||||
auto = subscribe
|
||||
special_use = \Archive
|
||||
}
|
||||
mailbox Spam {
|
||||
auto = subscribe
|
||||
special_use = \Junk
|
||||
}
|
||||
}
|
||||
@@ -38,6 +59,14 @@ userdb {
|
||||
args = /etc/dovecot/dovecot-sql.conf.ext
|
||||
}
|
||||
|
||||
protocol imap {
|
||||
mail_plugins = quota imap_quota
|
||||
}
|
||||
|
||||
protocol pop3 {
|
||||
mail_plugins = quota
|
||||
}
|
||||
|
||||
service imap-login {
|
||||
inet_listener imaps {
|
||||
port = 993
|
||||
|
||||
@@ -4,8 +4,15 @@ set -eu
|
||||
: "${LANQIN_TLS_KEY_FILE:=}"
|
||||
addgroup --system --gid 5000 vmail 2>/dev/null || true
|
||||
adduser --system --uid 5000 --gid 5000 --home /var/mail/vhosts --no-create-home vmail 2>/dev/null || true
|
||||
mkdir -p /var/mail/vhosts
|
||||
mkdir -p /data /var/mail/vhosts
|
||||
chown -R 5000:5000 /var/mail/vhosts
|
||||
AUTH_POLICY_NONCE_FILE="${LANQIN_AUTH_POLICY_NONCE_FILE:-/data/dovecot-auth-policy-nonce}"
|
||||
mkdir -p "$(dirname "$AUTH_POLICY_NONCE_FILE")"
|
||||
if [ ! -s "$AUTH_POLICY_NONCE_FILE" ]; then
|
||||
od -An -tx1 -N32 /dev/urandom | tr -d ' \n' > "$AUTH_POLICY_NONCE_FILE"
|
||||
fi
|
||||
chmod 600 "$AUTH_POLICY_NONCE_FILE" 2>/dev/null || true
|
||||
AUTH_POLICY_HASH_NONCE="$(cat "$AUTH_POLICY_NONCE_FILE")"
|
||||
TLS_CERT=/etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
TLS_KEY=/etc/ssl/private/ssl-cert-snakeoil.key
|
||||
if [ -n "$LANQIN_TLS_CERT_FILE" ] || [ -n "$LANQIN_TLS_KEY_FILE" ]; then
|
||||
@@ -18,4 +25,5 @@ if [ -n "$LANQIN_TLS_CERT_FILE" ] || [ -n "$LANQIN_TLS_KEY_FILE" ]; then
|
||||
fi
|
||||
sed -i "s#^ssl_cert = <.*#ssl_cert = <${TLS_CERT}#" /etc/dovecot/dovecot.conf
|
||||
sed -i "s#^ssl_key = <.*#ssl_key = <${TLS_KEY}#" /etc/dovecot/dovecot.conf
|
||||
sed -i "s#^auth_policy_hash_nonce = .*#auth_policy_hash_nonce = ${AUTH_POLICY_HASH_NONCE}#" /etc/dovecot/dovecot.conf
|
||||
exec dovecot -F
|
||||
|
||||
@@ -11,5 +11,5 @@ COPY master.cf /etc/postfix/master.cf
|
||||
COPY sqlite-*.cf /etc/postfix/
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
EXPOSE 25 465 587
|
||||
EXPOSE 25
|
||||
CMD ["/entrypoint.sh"]
|
||||
|
||||
@@ -14,13 +14,11 @@ virtual_transport = lmtp:inet:dovecot:24
|
||||
virtual_mailbox_base = /var/mail/vhosts
|
||||
|
||||
smtpd_banner = $myhostname ESMTP LanQin Email
|
||||
smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination
|
||||
smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination
|
||||
smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
|
||||
smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination
|
||||
|
||||
# Submission auth via Dovecot.
|
||||
smtpd_sasl_type = dovecot
|
||||
smtpd_sasl_path = inet:dovecot:12345
|
||||
smtpd_sasl_auth_enable = yes
|
||||
# 465/587 提交由 LanQin API 处理;Postfix 25 只负责入站和内部 relay。
|
||||
smtpd_sasl_auth_enable = no
|
||||
smtpd_tls_cert_file = /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
smtpd_tls_key_file = /etc/ssl/private/ssl-cert-snakeoil.key
|
||||
smtpd_tls_security_level = may
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
smtp inet n - n - - smtpd
|
||||
submission inet n - n - - smtpd
|
||||
-o syslog_name=postfix/submission
|
||||
-o smtpd_tls_security_level=may
|
||||
-o smtpd_sasl_auth_enable=yes
|
||||
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
||||
-o sender_bcc_maps=sqlite:/etc/postfix/sqlite-sender-bcc.cf
|
||||
smtps inet n - n - - smtpd
|
||||
-o syslog_name=postfix/smtps
|
||||
-o smtpd_tls_wrappermode=yes
|
||||
-o smtpd_sasl_auth_enable=yes
|
||||
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
||||
-o sender_bcc_maps=sqlite:/etc/postfix/sqlite-sender-bcc.cf
|
||||
pickup unix n - n 60 1 pickup
|
||||
cleanup unix n - n - 0 cleanup
|
||||
qmgr unix n - n 300 1 qmgr
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
dbpath = /data/lanqin.db
|
||||
query = SELECT local_part || '+Sent@' || substr(address, instr(address, '@') + 1) FROM mailboxes WHERE lower(address)=lower('%s') AND status='active'
|
||||
Reference in New Issue
Block a user