2026-06-24 11:27:32 +08:00
package app
import (
"context"
"database/sql"
"encoding/base64"
"errors"
2026-06-24 13:35:37 +08:00
"os"
"path/filepath"
2026-06-24 11:27:32 +08:00
"strings"
"time"
)
const (
sendQueueStatusQueued = "queued"
sendQueueStatusSending = "sending"
sendQueueStatusDelivered = "delivered"
sendQueueStatusFailed = "failed"
2026-06-24 16:39:40 +08:00
sendQueueStatusCanceled = "canceled"
2026-06-24 11:27:32 +08:00
sendAuditAccepted = "accepted"
sendAuditQueued = "queued"
sendAuditDelivered = "delivered"
sendAuditFailed = "failed"
sendAuditRetry = "retry"
2026-06-24 16:39:40 +08:00
sendAuditCanceled = "canceled"
2026-06-24 11:27:32 +08:00
2026-08-02 09:08:20 +08:00
sendSourceWebmail = "webmail"
sendSourceSubmission = "submission"
sendSourceOpenAPI = "open_api"
sendSourceForwarding = "forwarding"
2026-08-02 20:32:34 +08:00
sendSourceRuleForwarding = "rule_forwarding"
2026-08-02 09:08:20 +08:00
sendSourceForwardingVerification = "forwarding_verification"
2026-06-24 11:27:32 +08:00
2026-06-24 13:10:24 +08:00
sendQueueStaleAfter = 15 * time . Minute
sendQueueConcurrency = 4
2026-06-24 13:35:37 +08:00
sendQueueDeliveredMarkerDir = "send_queue_delivered"
2026-06-24 11:27:32 +08:00
)
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 )
2026-06-24 11:33:40 +08:00
mimeBase64 := base64 . StdEncoding . EncodeToString ( in . MIMEBytes )
recipientsJSON := jsonEncode ( dedupeEmails ( in . Recipients ))
2026-06-24 11:27:32 +08:00
_ , 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(?,?,?,?,?,?,?,?,?,?,?,?,?,?)` ,
2026-06-24 11:33:40 +08:00
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 ))
2026-06-24 11:27:32 +08:00
if err != nil {
return "" , err
}
if strings . TrimSpace ( messageID ) != "" {
2026-06-24 11:33:40 +08:00
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 {
2026-06-24 11:27:32 +08:00
return "" , err
}
if existingID != id {
2026-06-24 16:39:40 +08:00
if status == sendQueueStatusDelivered || status == sendQueueStatusCanceled || ( status == sendQueueStatusFailed && attemptCount >= maxAttempts ) {
2026-06-24 13:10:24 +08:00
_ , 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 )
2026-06-24 11:33:40 +08:00
if err != nil {
return "" , err
}
2026-06-24 13:35:37 +08:00
a . deleteSendQueueDeliveredMarker ( existingID )
2026-06-24 11:33:40 +08:00
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 ,
})
}
2026-06-24 11:27:32 +08:00
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 {
2026-06-25 21:36:59 +08:00
select {
case <- ctx . Done ():
a . log . Info ( "send queue worker stopped" )
return
default :
}
2026-06-24 11:27:32 +08:00
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
}
2026-06-24 13:10:24 +08:00
sem := make ( chan struct {}, sendQueueConcurrency )
done := make ( chan struct {}, len ( ids ))
2026-06-24 11:27:32 +08:00
for _ , id := range ids {
2026-06-24 13:10:24 +08:00
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 :
}
2026-06-24 11:27:32 +08:00
}
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 )
2026-06-24 13:35:37 +08:00
delivered , err := a . hasSendQueueDeliveredMarker ( item . ID )
if err != nil {
return err
}
if delivered {
items = append ( items , item )
continue
}
2026-06-24 11:27:32 +08:00
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 {
2026-06-24 13:35:37 +08:00
delivered , err := a . hasSendQueueDeliveredMarker ( item . ID )
if err != nil {
return err
}
if delivered {
if err := a . markSendQueueDelivered ( ctx , item ); err != nil {
return err
}
continue
}
2026-06-25 21:36:59 +08:00
res , err := a . db . ExecContext ( ctx , `UPDATE send_queue SET status=?,next_attempt_at=?,last_error=?,updated_at=? WHERE id=? AND status=?` , sendQueueStatusQueued , now , "send attempt interrupted" , now , item . ID , sendQueueStatusSending )
2026-06-24 11:27:32 +08:00
if err != nil {
return err
}
if n , _ := res . RowsAffected (); n > 0 {
2026-06-25 21:36:59 +08:00
a . recordSendAudit ( ctx , sendAuditRetry , sendQueueStatusQueued , sendAuditInputFromQueue ( item , "send attempt interrupted" ))
2026-06-24 11:27:32 +08:00
}
}
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
}
2026-06-24 13:35:37 +08:00
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 {
2026-06-24 11:27:32 +08:00
a . log . Warn ( "failed to mark send queue delivered" , "id" , item . ID , "error" , err )
return
}
2026-06-24 13:35:37 +08:00
}
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 )
2026-06-24 11:27:32 +08:00
a . recordSendAudit ( ctx , sendAuditDelivered , sendQueueStatusDelivered , sendAuditInputFromQueue ( item , "" ))
2026-06-24 13:35:37 +08:00
return nil
2026-06-24 11:27:32 +08:00
}
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"
}
2026-07-10 10:47:58 +08:00
id := newID ( "audit" )
createdAt := a . now (). UTC ()
item := SendAuditEvent { ID : id , QueueID : in . QueueID , MailboxID : in . MailboxID , SentMessageID : in . SentMessageID , Source : source , Event : event , Status : status , MailFrom : normalizeEmail ( in . MailFrom ), HeaderFrom : normalizeEmail ( in . HeaderFrom ), Recipients : dedupeEmails ( in . Recipients ), Error : in . Error , CreatedAt : createdAt }
tx , err := a . db . BeginTx ( ctx , nil )
2026-06-24 11:27:32 +08:00
if err != nil {
2026-07-10 10:47:58 +08:00
a . log . Warn ( "failed to start send audit transaction" , "event" , event , "error" , err )
return
}
defer tx . Rollback ()
if _ , err := tx . 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(?,?,?,?,?,?,?,?,?,?,?,?,?)` , id , in . QueueID , in . UserID , in . MailboxID , in . SentMessageID , source , event , status , item . MailFrom , item . HeaderFrom , jsonEncode ( item . Recipients ), in . Error , createdAt . Format ( time . RFC3339Nano )); err != nil {
2026-06-24 11:27:32 +08:00
a . log . Warn ( "failed to record send audit" , "event" , event , "error" , err )
2026-07-10 10:47:58 +08:00
return
}
if err := a . enqueueStatusWebhook ( ctx , tx , "audit:" + id , "send." + event , in . MailboxID , item ); err != nil {
a . log . Warn ( "failed to enqueue send status webhook" , "event" , event , "error" , err )
return
}
if err := tx . Commit (); err != nil {
a . log . Warn ( "failed to commit send audit" , "event" , event , "error" , err )
2026-06-24 11:27:32 +08:00
}
}
2026-06-24 13:35:37 +08:00
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 , 0 o700 ); 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 )), 0 o600 ); 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 )
}
}
2026-06-24 11:27:32 +08:00
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 {
2026-06-24 13:10:24 +08:00
return "" , "" , errSenderNotAuthorized
2026-06-24 11:27:32 +08:00
}
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 )
2026-06-24 13:10:24 +08:00
if err == nil {
for _ , destination := range strings . Split ( aliasDestination , "," ) {
if normalizeEmail ( destination ) == normalizeEmail ( mb . Address ) {
return from , mb . DisplayName , nil
}
}
2026-06-24 11:27:32 +08:00
}
2026-06-24 13:10:24 +08:00
return "" , "" , errSenderNotAuthorized
2026-06-24 11:27:32 +08:00
}