2026-06-14 01:07:48 +08:00
package app
import (
"context"
2026-06-15 17:37:40 +08:00
"database/sql"
2026-06-14 01:07:48 +08:00
"encoding/base64"
2026-06-17 13:43:19 +08:00
"encoding/json"
2026-06-14 01:07:48 +08:00
"errors"
"fmt"
"io"
"net/http"
2026-06-24 17:16:01 +08:00
"net/textproto"
2026-06-14 01:07:48 +08:00
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
2026-06-23 11:25:38 +08:00
// mailMessagesPageSize is the max number of messages returned per page in mail listing.
const mailMessagesPageSize = 30
2026-06-25 15:42:36 +08:00
const customFolderDefaultSortOrderBase = 100000
2026-08-02 05:55:53 +08:00
func isAllMailboxID ( mailboxID string ) bool {
return strings . EqualFold ( strings . TrimSpace ( mailboxID ), "all" )
}
2026-06-14 01:07:48 +08:00
type AttachmentInput struct {
Filename string `json:"filename"`
ContentType string `json:"contentType"`
ContentBase64 string `json:"contentBase64"`
}
type storedMessage struct {
2026-06-24 17:16:01 +08:00
MailboxID string
FolderID string
RecipientAddr string
MessageUID string
MessageID string
Subject string
From string
FromName string
To [] string
CC [] string
BCC [] string
SentAt time . Time
ReceivedAt time . Time
Snippet string
BodyText string
BodyHTML string
IsRead bool
IsStarred bool
RawPath string
Authentication MailAuthentication
2026-06-14 01:07:48 +08:00
}
func ( a * App ) handleMyMailboxes ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
2026-08-02 11:23:44 +08:00
rows , err := a . db . QueryContext ( r . Context (), `SELECT mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at,
COALESCE(SUM(CASE WHEN lower(f.name)='inbox' AND m.is_read=0 THEN 1 ELSE 0 END),0) AS unread_count
2026-06-15 23:41:21 +08:00
FROM mailboxes mb
JOIN domains d ON d.id=mb.domain_id
2026-08-02 11:23:44 +08:00
LEFT JOIN folders f ON f.mailbox_id=mb.id
LEFT JOIN messages m ON m.folder_id=f.id
2026-06-15 23:41:21 +08:00
WHERE mb.user_id=? AND mb.status='active' AND d.status='active'
2026-08-02 11:23:44 +08:00
GROUP BY mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
2026-06-15 23:41:21 +08:00
ORDER BY mb.address` , user . ID )
2026-06-14 01:07:48 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load mailboxes" )
return
}
defer rows . Close ()
items := [] Mailbox {}
for rows . Next () {
var m Mailbox
var created string
2026-08-02 11:23:44 +08:00
if err := rows . Scan ( & m . ID , & m . UserID , & m . DomainID , & m . LocalPart , & m . Address , & m . DisplayName , & m . QuotaMB , & m . Status , & created , & m . UnreadCount ); err != nil {
2026-06-14 01:07:48 +08:00
respondError ( w , http . StatusInternalServerError , "failed to scan mailboxes" )
return
}
m . UserEmail = user . Email
m . CreatedAt = parseTime ( created )
items = append ( items , m )
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items })
}
func ( a * App ) handleMailFolders ( w http . ResponseWriter , r * http . Request ) {
2026-08-02 05:55:53 +08:00
if isAllMailboxID ( r . URL . Query (). Get ( "mailboxId" )) {
a . handleAllMailFolders ( w , r )
return
}
2026-06-14 01:07:48 +08:00
mb , err := a . mailboxForCurrentUser ( r )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
rows , err := a . db . QueryContext ( r . Context (), `SELECT f.id,f.name,f.role,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
2026-06-24 16:23:45 +08:00
COUNT(m.id) AS total,
2026-06-25 15:42:36 +08:00
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
2026-06-14 01:07:48 +08:00
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
2026-06-25 15:42:36 +08:00
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
ORDER BY CASE
WHEN lower(f.name)='inbox' THEN 1000
WHEN lower(f.name)='sent' THEN 5000
WHEN lower(f.name)='drafts' THEN 6000
WHEN lower(f.name)='archive' THEN 7000
WHEN lower(f.name)='spam' THEN 8000
WHEN lower(f.name)='trash' THEN 9000
ELSE f.sort_order
END, f.created_at,f.name` , mb . ID )
2026-06-14 01:07:48 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load folders" )
return
}
defer rows . Close ()
items := [] MailFolder {}
for rows . Next () {
var f MailFolder
2026-06-25 15:42:36 +08:00
if err := rows . Scan ( & f . ID , & f . Name , & f . Role , & f . UnreadCount , & f . TotalCount , & f . SortOrder , & f . UIDValidity , & f . UIDNext , & f . HighestModSeq ); err != nil {
2026-06-14 01:07:48 +08:00
respondError ( w , http . StatusInternalServerError , "failed to scan folders" )
return
}
items = append ( items , f )
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items })
}
2026-08-02 05:55:53 +08:00
func ( a * App ) handleAllMailFolders ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
rows , err := a . db . QueryContext ( r . Context (), `SELECT 'all-' || lower(f.name),f.name,f.role,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total,
MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
FROM folders f
JOIN mailboxes mb ON mb.id=f.mailbox_id
LEFT JOIN messages m ON m.folder_id=f.id
WHERE mb.user_id=? AND mb.status='active'
GROUP BY f.name,f.role
ORDER BY CASE
WHEN lower(f.name)='inbox' THEN 1000
WHEN lower(f.name)='sent' THEN 5000
WHEN lower(f.name)='drafts' THEN 6000
WHEN lower(f.name)='archive' THEN 7000
WHEN lower(f.name)='spam' THEN 8000
WHEN lower(f.name)='trash' THEN 9000
ELSE MIN(f.sort_order)
END, f.name` , user . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load folders" )
return
}
defer rows . Close ()
items := [] MailFolder {}
for rows . Next () {
var f MailFolder
if err := rows . Scan ( & f . ID , & f . Name , & f . Role , & f . UnreadCount , & f . TotalCount , & f . SortOrder , & f . UIDValidity , & f . UIDNext , & f . HighestModSeq ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to scan folders" )
return
}
items = append ( items , f )
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items })
}
2026-06-25 15:42:36 +08:00
func ( a * App ) handleReorderMailFolders ( w http . ResponseWriter , r * http . Request ) {
mb , err := a . mailboxForCurrentUser ( r )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
var req struct {
FolderIDs [] string `json:"folderIds"`
Folders [] struct {
ID string `json:"id"`
SortOrder int `json:"sortOrder"`
} `json:"folders"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
seen := map [ string ] bool {}
if len ( req . Folders ) == 0 {
for i , id := range req . FolderIDs {
id = strings . TrimSpace ( id )
req . Folders = append ( req . Folders , struct {
ID string `json:"id"`
SortOrder int `json:"sortOrder"`
}{ ID : id , SortOrder : customFolderDefaultSortOrderBase + i + 1 })
}
}
for i := range req . Folders {
req . Folders [ i ]. ID = strings . TrimSpace ( req . Folders [ i ]. ID )
if req . Folders [ i ]. ID == "" || req . Folders [ i ]. SortOrder <= 0 || seen [ req . Folders [ i ]. ID ] {
badRequest ( w , errors . New ( "invalid folder order" ))
return
}
seen [ req . Folders [ i ]. ID ] = true
}
if len ( req . Folders ) == 0 {
badRequest ( w , errors . New ( "folderIds is required" ))
return
}
rows , err := a . db . QueryContext ( r . Context (), `SELECT id,name FROM folders WHERE mailbox_id=?` , mb . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load folders" )
return
}
customIDs := map [ string ] bool {}
for rows . Next () {
var id , name string
if err := rows . Scan ( & id , & name ); err != nil {
rows . Close ()
respondError ( w , http . StatusInternalServerError , "failed to scan folders" )
return
}
if ! isSystemFolderName ( name ) {
customIDs [ id ] = true
}
}
if err := rows . Err (); err != nil {
rows . Close ()
respondError ( w , http . StatusInternalServerError , "failed to scan folders" )
return
}
rows . Close ()
if len ( customIDs ) != len ( req . Folders ) {
badRequest ( w , errors . New ( "invalid folder order" ))
return
}
for _ , item := range req . Folders {
if ! customIDs [ item . ID ] {
badRequest ( w , errors . New ( "invalid folder order" ))
return
}
}
tx , err := a . db . BeginTx ( r . Context (), nil )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to reorder folders" )
return
}
defer tx . Rollback ()
for _ , item := range req . Folders {
res , err := tx . ExecContext ( r . Context (), `UPDATE folders SET sort_order=? WHERE id=? AND mailbox_id=?` , item . SortOrder , item . ID , mb . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to reorder folders" )
return
}
if affected , err := res . RowsAffected (); err != nil || affected != 1 {
badRequest ( w , errors . New ( "invalid folder order" ))
return
}
}
if err := tx . Commit (); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to reorder folders" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true })
}
2026-06-25 14:11:38 +08:00
func ( a * App ) handleCreateMailFolder ( w http . ResponseWriter , r * http . Request ) {
mb , err := a . mailboxForCurrentUser ( r )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
var req struct {
Name string `json:"name"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
name , err := normalizeCustomFolderName ( req . Name )
if err != nil {
badRequest ( w , err )
return
}
if isSystemFolderName ( name ) {
badRequest ( w , errors . New ( "system folder already exists" ))
return
}
2026-06-25 15:42:36 +08:00
folderID , err := a . ensureCustomFolder ( r . Context (), mb . ID , name )
2026-06-25 14:11:38 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to create folder" )
return
}
folder , err := a . folderByID ( r . Context (), folderID , mb . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load folder" )
return
}
respondJSON ( w , http . StatusCreated , folder )
}
2026-06-25 15:56:57 +08:00
func ( a * App ) handleDeleteMailFolder ( w http . ResponseWriter , r * http . Request ) {
mb , err := a . mailboxForCurrentUser ( r )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
folderID := strings . TrimSpace ( chi . URLParam ( r , "id" ))
if folderID == "" {
badRequest ( w , errors . New ( "folder id is required" ))
return
}
var folderName string
if err := a . db . QueryRowContext ( r . Context (), `SELECT name FROM folders WHERE id=? AND mailbox_id=?` , folderID , mb . ID ). Scan ( & folderName ); err != nil {
if errors . Is ( err , sql . ErrNoRows ) {
respondError ( w , http . StatusNotFound , "folder not found" )
return
}
respondError ( w , http . StatusInternalServerError , "failed to load folder" )
return
}
if isSystemFolderName ( folderName ) {
badRequest ( w , errors . New ( "system folders cannot be deleted" ))
return
}
inboxID , err := a . ensureFolder ( r . Context (), mb . ID , "Inbox" )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load inbox" )
return
}
now := a . now (). UTC (). Format ( time . RFC3339Nano )
tx , err := a . db . BeginTx ( r . Context (), nil )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to delete folder" )
return
}
defer tx . Rollback ()
rows , err := tx . QueryContext ( r . Context (), `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? ORDER BY received_at,id` , mb . ID , folderID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load folder messages" )
return
}
var messageIDs [] string
for rows . Next () {
var id string
if err := rows . Scan ( & id ); err != nil {
rows . Close ()
respondError ( w , http . StatusInternalServerError , "failed to scan folder messages" )
return
}
messageIDs = append ( messageIDs , id )
}
if err := rows . Err (); err != nil {
rows . Close ()
respondError ( w , http . StatusInternalServerError , "failed to scan folder messages" )
return
}
rows . Close ()
for _ , messageID := range messageIDs {
meta , err := a . nextIMAPMetadata ( r . Context (), tx , inboxID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to allocate message uid" )
return
}
if _ , err := tx . ExecContext ( r . Context (), `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?` , inboxID , meta . UID , meta . ModSeq , now , messageID ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to move folder messages" )
return
}
}
if _ , err := tx . ExecContext ( r . Context (), `DELETE FROM folders WHERE id=? AND mailbox_id=?` , folderID , mb . ID ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to delete folder" )
return
}
if err := tx . Commit (); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to delete folder" )
return
}
_ , _ = a . bumpFolderModSeq ( r . Context (), inboxID )
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true , "moved" : len ( messageIDs )})
}
2026-06-25 15:42:36 +08:00
func ( a * App ) ensureCustomFolder ( ctx context . Context , mailboxID , name string ) ( string , error ) {
return a . ensureFolder ( ctx , mailboxID , name )
}
func ( a * App ) nextCustomFolderSortOrder ( ctx context . Context , mailboxID string ) ( int , error ) {
var maxOrder int
if err := a . db . QueryRowContext ( ctx , `SELECT COALESCE(MAX(sort_order),0) FROM folders WHERE mailbox_id=? AND lower(name) NOT IN ('inbox','sent','drafts','archive','spam','trash')` , mailboxID ). Scan ( & maxOrder ); err != nil {
return 0 , err
}
if maxOrder < customFolderDefaultSortOrderBase {
maxOrder = customFolderDefaultSortOrderBase
}
return maxOrder + 1 , nil
}
2026-06-14 01:07:48 +08:00
func ( a * App ) handleMailMessages ( w http . ResponseWriter , r * http . Request ) {
2026-08-02 05:55:53 +08:00
if isAllMailboxID ( r . URL . Query (). Get ( "mailboxId" )) {
user := currentUser ( r )
if labelID := strings . TrimSpace ( r . URL . Query (). Get ( "labelId" )); labelID != "" {
if ! a . labelBelongsToUser ( r . Context (), labelID , user . ID ) {
respondError ( w , http . StatusNotFound , "label not found" )
return
}
a . respondMailMessageList ( w , r , `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)` , [] any { user . ID , labelID })
return
}
folder := r . URL . Query (). Get ( "folder" )
if folder == "" {
folder = "Inbox"
}
if normalized , err := normalizeFolderNameForUser ( folder ); err != nil {
badRequest ( w , err )
return
} else {
folder = normalized
}
a . respondMailMessageList ( w , r , `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND f.name=?` , [] any { user . ID , folder })
return
}
2026-06-14 01:07:48 +08:00
mb , err := a . mailboxForCurrentUser ( r )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
2026-06-15 17:37:40 +08:00
if labelID := strings . TrimSpace ( r . URL . Query (). Get ( "labelId" )); labelID != "" {
if ! a . labelBelongsToMailbox ( r . Context (), labelID , mb . ID ) {
respondError ( w , http . StatusNotFound , "label not found" )
return
}
a . respondMailMessageList ( w , r , `m.mailbox_id=? AND EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)` , [] any { mb . ID , labelID })
return
}
2026-06-14 01:07:48 +08:00
folder := r . URL . Query (). Get ( "folder" )
if folder == "" {
folder = "Inbox"
}
2026-06-25 14:11:38 +08:00
if normalized , err := normalizeFolderNameForUser ( folder ); err != nil {
badRequest ( w , err )
return
} else {
folder = normalized
}
2026-06-14 01:07:48 +08:00
folderID , err := a . ensureFolder ( r . Context (), mb . ID , folder )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load folder" )
return
}
2026-06-15 17:37:40 +08:00
a . respondMailMessageList ( w , r , `m.mailbox_id=? AND m.folder_id=?` , [] any { mb . ID , folderID })
}
func ( a * App ) handleStarredMessages ( w http . ResponseWriter , r * http . Request ) {
2026-08-02 05:55:53 +08:00
if isAllMailboxID ( r . URL . Query (). Get ( "mailboxId" )) {
user := currentUser ( r )
a . respondMailMessageList ( w , r , `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND m.is_starred=1` , [] any { user . ID })
return
}
2026-06-15 17:37:40 +08:00
mb , err := a . mailboxForCurrentUser ( r )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
a . respondMailMessageList ( w , r , `m.mailbox_id=? AND m.is_starred=1` , [] any { mb . ID })
}
func ( a * App ) respondMailMessageList ( w http . ResponseWriter , r * http . Request , where string , args [] any ) {
2026-06-14 01:07:48 +08:00
q := strings . TrimSpace ( r . URL . Query (). Get ( "q" ))
offset , _ := strconv . Atoi ( r . URL . Query (). Get ( "cursor" ))
if offset < 0 {
offset = 0
}
2026-06-23 11:25:38 +08:00
limit := mailMessagesPageSize
2026-06-14 01:07:48 +08:00
if q != "" {
2026-08-02 05:55:53 +08:00
where += ` AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.to_addrs LIKE ? OR m.cc_addrs LIKE ? OR m.recipient_addr LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)`
2026-06-14 01:07:48 +08:00
like := "%" + q + "%"
2026-08-02 05:55:53 +08:00
args = append ( args , like , like , like , like , like , like , like , like )
}
var err error
where , args , err = appendMailMessageSearchFilters ( r , where , args )
if err != nil {
badRequest ( w , err )
return
2026-06-14 01:07:48 +08:00
}
args = append ( args , limit + 1 , offset )
2026-06-24 16:23:45 +08:00
query := `SELECT m.id,m.mailbox_id,m.folder_id,COALESCE(f.name,''),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
2026-06-15 17:37:40 +08:00
FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE ` + where + ` ORDER BY m.received_at DESC LIMIT ? OFFSET ?`
2026-06-14 01:07:48 +08:00
rows , err := a . db . QueryContext ( r . Context (), query , args ... )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load messages" )
return
}
defer rows . Close ()
items := [] MailMessage {}
for rows . Next () {
2026-06-15 17:37:40 +08:00
msg , err := scanMessageSummary ( rows )
2026-06-14 01:07:48 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to scan messages" )
return
}
items = append ( items , msg )
}
next := ""
if len ( items ) > limit {
items = items [: limit ]
next = strconv . Itoa ( offset + limit )
}
2026-06-15 17:37:40 +08:00
if err := a . attachLabelsToMessages ( r . Context (), items ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load labels" )
return
}
2026-06-14 01:07:48 +08:00
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items , "nextCursor" : next })
}
2026-08-02 05:55:53 +08:00
func appendMailMessageSearchFilters ( r * http . Request , where string , args [] any ) ( string , [] any , error ) {
if from := strings . TrimSpace ( r . URL . Query (). Get ( "from" )); from != "" {
where += ` AND (m.from_addr LIKE ? OR m.from_name LIKE ?)`
like := "%" + from + "%"
args = append ( args , like , like )
}
if to := strings . TrimSpace ( r . URL . Query (). Get ( "to" )); to != "" {
where += ` AND (m.to_addrs LIKE ? OR m.cc_addrs LIKE ? OR m.bcc_addrs LIKE ? OR m.recipient_addr LIKE ?)`
like := "%" + to + "%"
args = append ( args , like , like , like , like )
}
if subject := strings . TrimSpace ( r . URL . Query (). Get ( "subject" )); subject != "" {
where += ` AND m.subject LIKE ?`
args = append ( args , "%" + subject + "%" )
}
switch strings . ToLower ( strings . TrimSpace ( r . URL . Query (). Get ( "attachmentMode" ))) {
case "with" :
where += ` AND m.has_attachments=1`
case "without" :
where += ` AND m.has_attachments=0`
default :
if mailSearchFlag ( r , "hasAttachments" ) {
where += ` AND m.has_attachments=1`
}
}
if minSize , ok , err := mailSearchSizeBytes ( r . URL . Query (). Get ( "minSizeKb" ), "minSizeKb" ); err != nil {
return where , args , err
} else if ok {
where += ` AND m.size_bytes>=?`
args = append ( args , minSize )
}
if maxSize , ok , err := mailSearchSizeBytes ( r . URL . Query (). Get ( "maxSizeKb" ), "maxSizeKb" ); err != nil {
return where , args , err
} else if ok {
where += ` AND m.size_bytes<=?`
args = append ( args , maxSize )
}
switch strings . ToLower ( strings . TrimSpace ( r . URL . Query (). Get ( "readStatus" ))) {
case "read" :
where += ` AND m.is_read=1`
case "unread" :
where += ` AND m.is_read=0`
default :
if mailSearchFlag ( r , "unread" ) {
where += ` AND m.is_read=0`
}
}
switch strings . ToLower ( strings . TrimSpace ( r . URL . Query (). Get ( "flagStatus" ))) {
case "starred" :
where += ` AND m.is_starred=1`
case "unstarred" :
where += ` AND m.is_starred=0`
default :
if mailSearchFlag ( r , "starred" ) {
where += ` AND m.is_starred=1`
}
}
if start , ok , err := mailSearchDateBoundary ( r . URL . Query (). Get ( "startDate" ), false ); err != nil {
return where , args , err
} else if ok {
where += ` AND m.received_at>=?`
args = append ( args , start )
}
if end , ok , err := mailSearchDateBoundary ( r . URL . Query (). Get ( "endDate" ), true ); err != nil {
return where , args , err
} else if ok {
where += ` AND m.received_at<=?`
args = append ( args , end )
}
return where , args , nil
}
func mailSearchFlag ( r * http . Request , key string ) bool {
switch strings . ToLower ( strings . TrimSpace ( r . URL . Query (). Get ( key ))) {
case "1" , "true" , "yes" , "on" :
return true
default :
return false
}
}
func mailSearchDateBoundary ( value string , endOfDay bool ) ( string , bool , error ) {
value = strings . TrimSpace ( value )
if value == "" {
return "" , false , nil
}
if len ( value ) == len ( "2006-01-02" ) {
t , err := time . Parse ( "2006-01-02" , value )
if err != nil {
return "" , false , fmt . Errorf ( "invalid date %q" , value )
}
if endOfDay {
t = t . AddDate ( 0 , 0 , 1 ). Add ( - time . Nanosecond )
}
return t . UTC (). Format ( time . RFC3339Nano ), true , nil
}
t , err := time . Parse ( time . RFC3339Nano , value )
if err != nil {
return "" , false , fmt . Errorf ( "invalid date %q" , value )
}
return t . UTC (). Format ( time . RFC3339Nano ), true , nil
}
func mailSearchSizeBytes ( value string , key string ) ( int64 , bool , error ) {
value = strings . TrimSpace ( value )
if value == "" {
return 0 , false , nil
}
kb , err := strconv . ParseInt ( value , 10 , 64 )
if err != nil || kb < 0 {
return 0 , false , fmt . Errorf ( "invalid %s %q" , key , value )
}
return kb * 1024 , true , nil
}
2026-06-15 17:37:40 +08:00
func ( a * App ) handleMailLabels ( w http . ResponseWriter , r * http . Request ) {
2026-08-02 05:55:53 +08:00
if isAllMailboxID ( r . URL . Query (). Get ( "mailboxId" )) {
labels , err := a . labelsForUser ( r . Context (), currentUser ( r ). ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load labels" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : labels })
return
}
2026-06-15 17:37:40 +08:00
mb , err := a . mailboxForCurrentUser ( r )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
labels , err := a . labelsForMailbox ( r . Context (), mb . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load labels" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : labels })
}
func ( a * App ) handleCreateMailLabel ( w http . ResponseWriter , r * http . Request ) {
mb , err := a . mailboxForCurrentUser ( r )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
var req struct {
Name string `json:"name"`
Color string `json:"color"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
label , err := a . ensureLabel ( r . Context (), mb . ID , req . Name , req . Color )
if err != nil {
badRequest ( w , err )
return
}
respondJSON ( w , http . StatusCreated , label )
}
2026-06-22 13:54:32 +08:00
func ( a * App ) handleDeleteMailLabel ( w http . ResponseWriter , r * http . Request ) {
mb , err := a . mailboxForCurrentUser ( r )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
labelID := chi . URLParam ( r , "id" )
if labelID == "" {
badRequest ( w , fmt . Errorf ( "label id is required" ))
return
}
2026-06-22 14:01:38 +08:00
ctx := r . Context ()
2026-06-22 14:11:11 +08:00
if ! a . labelBelongsToMailbox ( ctx , labelID , mb . ID ) {
respondError ( w , http . StatusNotFound , "label not found" )
2026-06-22 14:01:38 +08:00
return
}
2026-06-22 14:11:11 +08:00
tx , err := a . db . BeginTx ( ctx , nil )
2026-06-22 19:56:43 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to begin transaction" )
return
}
defer tx . Rollback ()
2026-06-22 20:20:44 +08:00
if _ , err := tx . ExecContext ( ctx , "DELETE FROM message_labels WHERE label_id = ?" , labelID ); err != nil {
2026-06-22 20:12:56 +08:00
respondError ( w , http . StatusInternalServerError , "failed to remove label associations" )
return
}
2026-06-22 14:01:38 +08:00
result , err := tx . ExecContext ( ctx , `DELETE FROM mail_labels WHERE id=? AND mailbox_id=?` , labelID , mb . ID )
2026-06-22 13:54:32 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to delete label" )
return
}
if n , _ := result . RowsAffected (); n == 0 {
respondError ( w , http . StatusNotFound , "label not found" )
return
}
2026-06-22 14:01:38 +08:00
if err := tx . Commit (); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to commit transaction" )
return
}
labels , err := a . labelsForMailbox ( ctx , mb . ID )
2026-06-22 13:54:32 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load labels" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "labels" : labels })
}
2026-06-15 17:37:40 +08:00
func ( a * App ) handleAddMessageLabel ( w http . ResponseWriter , r * http . Request ) {
msg , err := a . loadMessageForRequest ( r , chi . URLParam ( r , "id" ), false )
if err != nil {
respondError ( w , http . StatusNotFound , "message not found" )
return
}
var req struct {
Name string `json:"name"`
Color string `json:"color"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
label , err := a . ensureLabel ( r . Context (), msg . MailboxID , req . Name , req . Color )
if err != nil {
badRequest ( w , err )
return
}
_ , err = a . db . ExecContext ( r . Context (), `INSERT OR IGNORE INTO message_labels(message_id,label_id,created_at) VALUES(?,?,?)` , msg . ID , label . ID , a . now (). UTC (). Format ( time . RFC3339Nano ))
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to add label" )
return
}
labels , err := a . labelsForMessage ( r . Context (), msg . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load labels" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "labels" : labels })
}
func ( a * App ) handleRemoveMessageLabel ( w http . ResponseWriter , r * http . Request ) {
msg , err := a . loadMessageForRequest ( r , chi . URLParam ( r , "id" ), false )
if err != nil {
respondError ( w , http . StatusNotFound , "message not found" )
return
}
labelID := strings . TrimSpace ( chi . URLParam ( r , "labelID" ))
if ! a . labelBelongsToMailbox ( r . Context (), labelID , msg . MailboxID ) {
respondError ( w , http . StatusNotFound , "label not found" )
return
}
if _ , err := a . db . ExecContext ( r . Context (), `DELETE FROM message_labels WHERE message_id=? AND label_id=?` , msg . ID , labelID ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to remove label" )
return
}
labels , err := a . labelsForMessage ( r . Context (), msg . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load labels" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "labels" : labels })
}
2026-06-14 01:07:48 +08:00
func ( a * App ) handleMailMessage ( w http . ResponseWriter , r * http . Request ) {
msg , err := a . loadMessageForRequest ( r , chi . URLParam ( r , "id" ), true )
if err != nil {
respondError ( w , http . StatusNotFound , "message not found" )
return
}
2026-06-22 15:54:15 +08:00
if r . URL . Query (). Get ( "markRead" ) != "0" && ! msg . IsRead && userHasPermission ( currentUser ( r ), PermissionMailOrganize ) {
2026-06-24 15:33:56 +08:00
read := true
if err := a . updateMessageMaildirFlags ( r . Context (), msg . ID , & read , nil ); err != nil {
a . log . Warn ( "failed to update maildir read flag" , "message_id" , msg . ID , "error" , err )
}
2026-06-24 16:23:45 +08:00
if modSeq , err := a . updateMessageModSeq ( r . Context (), msg . ID , msg . FolderID ); err == nil && modSeq > 0 {
_ , _ = a . db . ExecContext ( r . Context (), `UPDATE messages SET is_read=1, imap_modseq=?, updated_at=? WHERE id=?` , modSeq , a . now (). UTC (). Format ( time . RFC3339Nano ), msg . ID )
} else {
_ , _ = a . db . ExecContext ( r . Context (), `UPDATE messages SET is_read=1, updated_at=? WHERE id=?` , a . now (). UTC (). Format ( time . RFC3339Nano ), msg . ID )
}
2026-06-15 21:50:44 +08:00
msg . IsRead = true
}
2026-06-14 01:07:48 +08:00
respondJSON ( w , http . StatusOK , msg )
}
2026-06-17 13:43:19 +08:00
type mailComposeInput struct {
MailboxID string `json:"mailboxId"`
2026-06-24 11:27:32 +08:00
From string `json:"from"`
FromName string `json:"fromName"`
2026-06-17 13:43:19 +08:00
To [] string `json:"to"`
CC [] string `json:"cc"`
BCC [] string `json:"bcc"`
Subject string `json:"subject"`
Text string `json:"text"`
HTML string `json:"html"`
Attachments [] AttachmentInput `json:"attachments"`
}
type mailDraftInput struct {
MailboxID string `json:"mailboxId"`
To [] string `json:"to"`
CC [] string `json:"cc"`
BCC [] string `json:"bcc"`
Subject string `json:"subject"`
Text string `json:"text"`
HTML string `json:"html"`
Attachments * [] AttachmentInput `json:"attachments"`
}
type scheduledSendPayload struct {
MailboxID string `json:"mailboxId"`
2026-06-24 11:27:32 +08:00
From string `json:"from"`
FromName string `json:"fromName"`
2026-06-17 13:43:19 +08:00
To [] string `json:"to"`
CC [] string `json:"cc"`
BCC [] string `json:"bcc"`
Subject string `json:"subject"`
Text string `json:"text"`
HTML string `json:"html"`
Attachments [] AttachmentInput `json:"attachments"`
DraftID string `json:"draftId,omitempty"`
}
type ScheduledSend struct {
ID string `json:"id"`
MailboxID string `json:"mailboxId"`
DraftID string `json:"draftId,omitempty"`
Subject string `json:"subject"`
To [] string `json:"to"`
Snippet string `json:"snippet"`
SendAt time . Time `json:"sendAt"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
CreatedAt time . Time `json:"createdAt"`
UpdatedAt time . Time `json:"updatedAt"`
SentAt * time . Time `json:"sentAt,omitempty"`
}
2026-06-14 01:07:48 +08:00
func ( a * App ) handleMailSend ( w http . ResponseWriter , r * http . Request ) {
2026-06-17 13:43:19 +08:00
var req mailComposeInput
2026-06-14 01:07:48 +08:00
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
mb , err := a . mailboxForCurrentUserWithID ( r , req . MailboxID )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
2026-06-23 11:09:36 +08:00
msg , err := a . sendMailNow ( r . Context (), currentUser ( r ), mb , req )
2026-06-17 13:43:19 +08:00
if err != nil {
if errors . Is ( err , errNoRecipients ) {
badRequest ( w , err )
return
}
if errors . Is ( err , errInvalidMIME ) {
badRequest ( w , err )
return
}
2026-06-23 11:09:36 +08:00
if errors . Is ( err , errAttachmentTooLarge ) {
badRequest ( w , err )
return
}
if errors . Is ( err , errSMTPRateLimited ) {
respondError ( w , http . StatusTooManyRequests , err . Error ())
return
}
2026-06-24 13:10:24 +08:00
if errors . Is ( err , errSenderNotAuthorized ) {
respondError ( w , http . StatusForbidden , err . Error ())
return
}
2026-06-24 17:16:01 +08:00
if errors . Is ( err , errMailboxQuotaExceeded ) {
respondError ( w , http . StatusInsufficientStorage , err . Error ())
return
}
2026-06-17 13:43:19 +08:00
respondError ( w , http . StatusInternalServerError , err . Error ())
return
}
respondJSON ( w , http . StatusCreated , msg )
}
var errNoRecipients = errors . New ( "at least one recipient is required" )
var errInvalidMIME = errors . New ( "invalid mime message" )
2026-06-23 11:09:36 +08:00
var errAttachmentTooLarge = errors . New ( "attachment size exceeds permission limit" )
var errSMTPRateLimited = errors . New ( "smtp send rate limit exceeded" )
2026-06-24 13:10:24 +08:00
var errSenderNotAuthorized = errors . New ( "sender address is not authorized" )
2026-06-24 17:16:01 +08:00
var errMailboxQuotaExceeded = errors . New ( "mailbox quota exceeded" )
2026-06-17 13:43:19 +08:00
2026-06-23 11:09:36 +08:00
func ( a * App ) sendMailNow ( ctx context . Context , user * User , mb * Mailbox , req mailComposeInput ) ( * MailMessage , error ) {
2026-06-29 16:52:07 +08:00
return a . sendMailWithSource ( ctx , user , mb , req , sendSourceWebmail )
}
func ( a * App ) sendMailWithSource ( ctx context . Context , user * User , mb * Mailbox , req mailComposeInput , source string ) ( * MailMessage , error ) {
source = strings . TrimSpace ( source )
if source == "" {
source = sendSourceWebmail
}
2026-06-23 11:09:36 +08:00
if err := validateAttachmentLimit ( req . Attachments , userLimits ( user )); err != nil {
return nil , err
}
2026-06-14 01:07:48 +08:00
req . To , req . CC , req . BCC = dedupeEmails ( req . To ), dedupeEmails ( req . CC ), dedupeEmails ( req . BCC )
allRecipients := append ( append ([] string {}, req . To ... ), append ( req . CC , req . BCC ... ) ... )
if len ( allRecipients ) == 0 {
2026-06-17 13:43:19 +08:00
return nil , errNoRecipients
2026-06-14 01:07:48 +08:00
}
if strings . TrimSpace ( req . Subject ) == "" {
req . Subject = "(no subject)"
}
req . HTML = a . policy . Sanitize ( req . HTML )
if strings . TrimSpace ( req . Text ) == "" {
req . Text = stripTags ( req . HTML )
}
if strings . TrimSpace ( req . HTML ) == "" {
req . HTML = "<p>" + htmlEscape ( req . Text ) + "</p>"
}
now := a . now (). UTC ()
2026-06-24 11:27:32 +08:00
fromAddress , fromName , err := a . authorizedSender ( ctx , mb , req . From )
if err != nil {
return nil , err
}
if strings . TrimSpace ( req . FromName ) != "" && normalizeEmail ( req . From ) == normalizeEmail ( mb . Address ) {
fromName = strings . TrimSpace ( req . FromName )
}
2026-06-14 01:07:48 +08:00
messageID := fmt . Sprintf ( "<%s@%s>" , newID ( "msg" ), strings . Split ( mb . Address , "@" )[ 1 ])
mimeBytes , err := BuildMIME ( MIMEMessage {
2026-06-24 11:27:32 +08:00
From : fromAddress , FromName : fromName , To : req . To , CC : req . CC , BCC : req . BCC , Subject : req . Subject , Text : req . Text , HTML : req . HTML , MessageID : messageID , Date : now , Attachments : req . Attachments ,
2026-06-14 01:07:48 +08:00
})
if err != nil {
2026-06-17 13:43:19 +08:00
return nil , fmt . Errorf ( "%w: %v" , errInvalidMIME , err )
2026-06-14 01:07:48 +08:00
}
2026-06-23 11:09:36 +08:00
if err := a . recordSMTPRate ( ctx , user , mb ); err != nil {
return nil , err
}
2026-06-14 01:07:48 +08:00
2026-06-17 13:43:19 +08:00
sentFolderID , err := a . ensureFolder ( ctx , mb . ID , "Sent" )
2026-06-14 01:07:48 +08:00
if err != nil {
2026-06-17 13:43:19 +08:00
return nil , fmt . Errorf ( "failed to load sent folder: %w" , err )
2026-06-14 01:07:48 +08:00
}
2026-06-24 11:27:32 +08:00
base := storedMessage { MailboxID : mb . ID , FolderID : sentFolderID , MessageUID : newID ( "uid" ), MessageID : messageID , Subject : req . Subject , From : fromAddress , FromName : fromName , To : req . To , CC : req . CC , BCC : req . BCC , SentAt : now , ReceivedAt : now , Snippet : snippetFrom ( req . Text , req . HTML ), BodyText : req . Text , BodyHTML : req . HTML , IsRead : true }
2026-06-17 13:43:19 +08:00
sentID , err := a . insertMessage ( ctx , base , req . Attachments )
2026-06-14 01:07:48 +08:00
if err != nil {
2026-06-17 13:43:19 +08:00
return nil , fmt . Errorf ( "failed to store sent message: %w" , err )
2026-06-14 01:07:48 +08:00
}
2026-06-24 15:18:06 +08:00
if err := a . writeRawMessageToMaildir ( ctx , sentID , mimeBytes , false ); err != nil {
a . deleteMessage ( ctx , sentID )
return nil , fmt . Errorf ( "failed to store sent message in maildir: %w" , err )
}
2026-06-29 16:52:07 +08:00
a . recordSendAudit ( ctx , sendAuditAccepted , sendQueueStatusQueued , sendAuditInput { UserID : user . ID , MailboxID : mb . ID , SentMessageID : sentID , Source : source , MailFrom : fromAddress , HeaderFrom : fromAddress , Recipients : allRecipients })
if _ , err := a . enqueueSend ( ctx , sendQueueInput { UserID : user . ID , MailboxID : mb . ID , SentMessageID : sentID , MessageID : messageID , Source : source , MailFrom : fromAddress , HeaderFrom : fromAddress , Recipients : allRecipients , MIMEBytes : mimeBytes , Now : now }); err != nil {
2026-06-24 11:27:32 +08:00
a . deleteMessage ( ctx , sentID )
return nil , fmt . Errorf ( "failed to enqueue delivery: %w" , err )
}
2026-06-14 01:07:48 +08:00
2026-06-15 00:37:43 +08:00
// Development/local-domain delivery: known local recipients go to their Inbox.
// When catch-all is enabled, unknown local recipients are stored as unregistered
// messages visible only in the admin "全部邮件" view.
2026-06-14 01:07:48 +08:00
localRecipients := append ( req . To , req . CC ... )
localRecipients = append ( localRecipients , req . BCC ... )
for _ , rcpt := range localRecipients {
2026-06-17 13:43:19 +08:00
rcptMailbox , err := a . mailboxByAddress ( ctx , rcpt )
2026-06-14 01:07:48 +08:00
if err != nil {
2026-06-17 13:43:19 +08:00
if ! a . cfg . CatchAllEnabled || ! a . isLocalDomainAddress ( ctx , rcpt ) {
2026-06-15 00:37:43 +08:00
continue
}
copyMsg := base
copyMsg . MailboxID = ""
copyMsg . FolderID = ""
copyMsg . RecipientAddr = normalizeEmail ( rcpt )
copyMsg . MessageUID = newID ( "uid" )
copyMsg . IsRead = false
2026-06-24 15:18:06 +08:00
if copyID , err := a . insertMessage ( ctx , copyMsg , req . Attachments ); err == nil {
_ = a . writeStoredMessageToMaildir ( ctx , copyID , copyMsg , req . Attachments )
}
2026-06-15 00:37:43 +08:00
continue
}
if rcptMailbox . Status != "active" {
2026-06-17 13:43:19 +08:00
if a . cfg . CatchAllEnabled && a . isLocalDomainAddress ( ctx , rcpt ) {
2026-06-15 00:37:43 +08:00
copyMsg := base
copyMsg . MailboxID = ""
copyMsg . FolderID = ""
copyMsg . RecipientAddr = normalizeEmail ( rcpt )
copyMsg . MessageUID = newID ( "uid" )
copyMsg . IsRead = false
2026-06-24 15:18:06 +08:00
if copyID , err := a . insertMessage ( ctx , copyMsg , req . Attachments ); err == nil {
_ = a . writeStoredMessageToMaildir ( ctx , copyID , copyMsg , req . Attachments )
}
2026-06-15 00:37:43 +08:00
}
2026-06-14 01:07:48 +08:00
continue
}
2026-06-17 13:43:19 +08:00
inboxID , err := a . ensureFolder ( ctx , rcptMailbox . ID , "Inbox" )
2026-06-14 01:07:48 +08:00
if err != nil {
continue
}
copyMsg := base
copyMsg . MailboxID = rcptMailbox . ID
copyMsg . FolderID = inboxID
copyMsg . MessageUID = newID ( "uid" )
copyMsg . IsRead = false
2026-06-17 13:43:19 +08:00
if inboxMsgID , err := a . insertMessage ( ctx , copyMsg , req . Attachments ); err == nil {
2026-06-24 15:18:06 +08:00
_ = a . writeStoredMessageToMaildir ( ctx , inboxMsgID , copyMsg , req . Attachments )
2026-06-17 13:43:19 +08:00
a . applyInboundControls ( ctx , inboxMsgID , rcptMailbox . ID , copyMsg . From , copyMsg . Subject )
2026-08-02 07:37:07 +08:00
a . processInboundForwarding ( ctx , inboxMsgID , rcptMailbox . ID , mimeBytes )
2026-06-14 01:07:48 +08:00
}
}
2026-06-17 13:43:19 +08:00
msg , _ := a . messageByID ( ctx , sentID , true )
return msg , nil
}
2026-06-23 11:09:36 +08:00
func userLimits ( user * User ) PermissionLimits {
if user == nil {
return defaultPermissionLimits ()
}
return user . Limits
}
func validateAttachmentLimit ( attachments [] AttachmentInput , limits PermissionLimits ) error {
limitBytes := attachmentLimitBytes ( limits )
if limitBytes == 0 {
return nil
}
for _ , att := range attachments {
decodedLen , err := decodedBase64Len ( att . ContentBase64 )
if err != nil {
return fmt . Errorf ( "%w: %v" , errInvalidMIME , err )
}
if decodedLen > limitBytes {
return fmt . Errorf ( "%w: max %d MB" , errAttachmentTooLarge , limits . MaxAttachmentMB )
}
}
return nil
}
func decodedBase64Len ( value string ) ( int64 , error ) {
value = strings . TrimSpace ( value )
if value == "" {
return 0 , nil
}
data , err := base64 . StdEncoding . DecodeString ( value )
if err != nil {
return 0 , err
}
return int64 ( len ( data )), nil
}
2026-06-23 11:25:38 +08:00
var errIMAPRateLimited = errors . New ( "imap rate limit exceeded" )
var errPOP3RateLimited = errors . New ( "pop3 rate limit exceeded" )
func ( a * App ) checkAndRecordProtocolRate ( ctx context . Context , user * User , mb * Mailbox , table string , dailyLimit , minuteLimit int ) error {
if dailyLimit == 0 && minuteLimit == 0 {
return nil
}
now := a . now (). UTC ()
tx , err := a . db . BeginTx ( ctx , nil )
if err != nil {
return err
}
defer tx . Rollback ()
if dailyLimit > 0 {
var count int
if err := tx . QueryRowContext ( ctx , "SELECT COUNT(*) FROM " + table + " WHERE user_id=? AND created_at>=?" , user . ID , now . Add ( - 24 * time . Hour ). Format ( time . RFC3339Nano )). Scan ( & count ); err != nil {
return err
}
if count >= dailyLimit {
return fmt . Errorf ( "daily limit %d" , dailyLimit )
}
}
if minuteLimit > 0 {
var count int
if err := tx . QueryRowContext ( ctx , "SELECT COUNT(*) FROM " + table + " WHERE user_id=? AND created_at>=?" , user . ID , now . Add ( - time . Minute ). Format ( time . RFC3339Nano )). Scan ( & count ); err != nil {
return err
}
if count >= minuteLimit {
return fmt . Errorf ( "per-minute limit %d" , minuteLimit )
}
}
if _ , err := tx . ExecContext ( ctx , "INSERT INTO " + table + "(id,user_id,mailbox_id,created_at) VALUES(?,?,?,?)" , newID ( "evt" ), user . ID , mb . ID , now . Format ( time . RFC3339Nano )); err != nil {
return err
}
return tx . Commit ()
}
func ( a * App ) handleAuthPolicy ( w http . ResponseWriter , r * http . Request ) {
var req struct {
2026-06-23 21:52:05 +08:00
Login string `json:"login"`
Protocol string `json:"protocol"`
Username string `json:"username"`
IP string `json:"ip"`
Remote string `json:"remote"`
Success * bool `json:"success"`
PolicyReject * bool `json:"policy_reject"`
2026-06-23 11:25:38 +08:00
}
if err := decodeJSON ( r , & req ); err != nil {
2026-06-23 21:52:05 +08:00
respondJSON ( w , http . StatusOK , map [ string ] int { "status" : 0 })
2026-06-23 11:25:38 +08:00
return
}
var user * User
2026-06-23 21:52:05 +08:00
var mailbox * Mailbox
login := normalizeEmail ( req . Login )
if login == "" {
login = normalizeEmail ( req . Username )
}
if login != "" {
if mb , err := a . mailboxByAddress ( r . Context (), login ); err == nil {
mailbox = mb
user , _ = a . userByID ( r . Context (), mb . UserID )
}
if user == nil {
var passHash string
user , passHash , _ = a . userByEmail ( r . Context (), login )
_ = passHash
}
2026-06-23 11:25:38 +08:00
}
if user == nil || user . Disabled {
2026-06-23 21:52:05 +08:00
respondJSON ( w , http . StatusOK , map [ string ] any { "status" : - 1 , "msg" : "user not found or disabled" })
2026-06-23 11:25:38 +08:00
return
}
if user . Role == "admin" {
2026-06-23 21:52:05 +08:00
respondJSON ( w , http . StatusOK , map [ string ] int { "status" : 0 })
2026-06-23 11:25:38 +08:00
return
}
limits := user . Limits
var err error
2026-06-23 21:52:05 +08:00
switch strings . ToLower ( req . Protocol ) {
case "imap" :
2026-06-23 11:25:38 +08:00
if limits . IMAPMinuteLimit > 0 {
2026-06-23 21:52:05 +08:00
if mailbox == nil {
err = errors . New ( "mailbox not found" )
} else {
err = a . checkAndRecordProtocolRate ( r . Context (), user , mailbox , "imap_events" , 0 , limits . IMAPMinuteLimit )
}
2026-06-23 11:25:38 +08:00
}
2026-06-23 21:52:05 +08:00
case "pop3" :
2026-06-23 11:25:38 +08:00
if limits . POP3MinuteLimit > 0 {
2026-06-23 21:52:05 +08:00
if mailbox == nil {
err = errors . New ( "mailbox not found" )
} else {
err = a . checkAndRecordProtocolRate ( r . Context (), user , mailbox , "pop3_events" , 0 , limits . POP3MinuteLimit )
}
2026-06-23 11:25:38 +08:00
}
}
if err != nil {
2026-06-23 21:52:05 +08:00
respondJSON ( w , http . StatusOK , map [ string ] any { "status" : - 1 , "msg" : err . Error ()})
2026-06-23 11:25:38 +08:00
} else {
2026-06-23 21:52:05 +08:00
respondJSON ( w , http . StatusOK , map [ string ] int { "status" : 0 })
2026-06-23 11:25:38 +08:00
}
}
2026-06-23 11:09:36 +08:00
func ( a * App ) recordSMTPRate ( ctx context . Context , user * User , mb * Mailbox ) error {
if user == nil || mb == nil || user . Role == "admin" {
return nil
}
limits := user . Limits
if limits . SMTPDailyLimit == 0 && limits . SMTPMinuteLimit == 0 {
return nil
}
now := a . now (). UTC ()
tx , err := a . db . BeginTx ( ctx , nil )
if err != nil {
return err
}
defer tx . Rollback ()
if limits . SMTPDailyLimit > 0 {
var count int
if err := tx . QueryRowContext ( ctx , `SELECT COUNT(*) FROM smtp_send_events WHERE user_id=? AND created_at>=?` , user . ID , now . Add ( - 24 * time . Hour ). Format ( time . RFC3339Nano )). Scan ( & count ); err != nil {
return err
}
if count >= limits . SMTPDailyLimit {
return fmt . Errorf ( "%w: daily limit %d" , errSMTPRateLimited , limits . SMTPDailyLimit )
}
}
if limits . SMTPMinuteLimit > 0 {
var count int
if err := tx . QueryRowContext ( ctx , `SELECT COUNT(*) FROM smtp_send_events WHERE user_id=? AND created_at>=?` , user . ID , now . Add ( - time . Minute ). Format ( time . RFC3339Nano )). Scan ( & count ); err != nil {
return err
}
if count >= limits . SMTPMinuteLimit {
return fmt . Errorf ( "%w: per-minute limit %d" , errSMTPRateLimited , limits . SMTPMinuteLimit )
}
}
if _ , err := tx . ExecContext ( ctx , `INSERT INTO smtp_send_events(id,user_id,mailbox_id,created_at) VALUES(?,?,?,?)` , newID ( "smtp" ), user . ID , mb . ID , now . Format ( time . RFC3339Nano )); err != nil {
return err
}
return tx . Commit ()
}
2026-06-17 13:43:19 +08:00
func ( a * App ) handleSaveDraft ( w http . ResponseWriter , r * http . Request ) {
var req mailDraftInput
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
mb , err := a . mailboxForCurrentUserWithID ( r , req . MailboxID )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
2026-06-23 11:09:36 +08:00
if req . Attachments != nil {
if err := validateAttachmentLimit ( * req . Attachments , userLimits ( currentUser ( r ))); err != nil {
if errors . Is ( err , errAttachmentTooLarge ) || errors . Is ( err , errInvalidMIME ) {
badRequest ( w , err )
return
}
respondError ( w , http . StatusInternalServerError , err . Error ())
return
}
}
2026-06-17 13:43:19 +08:00
compose := mailComposeInput { MailboxID : req . MailboxID , To : req . To , CC : req . CC , BCC : req . BCC , Subject : req . Subject , Text : req . Text , HTML : req . HTML }
compose . To , compose . CC , compose . BCC = dedupeEmails ( compose . To ), dedupeEmails ( compose . CC ), dedupeEmails ( compose . BCC )
subject := strings . TrimSpace ( compose . Subject )
if subject == "" {
subject = "(无主题)"
}
compose . HTML = a . policy . Sanitize ( compose . HTML )
if strings . TrimSpace ( compose . Text ) == "" {
compose . Text = stripTags ( compose . HTML )
}
if strings . TrimSpace ( compose . HTML ) == "" && strings . TrimSpace ( compose . Text ) != "" {
compose . HTML = "<p>" + htmlEscape ( compose . Text ) + "</p>"
}
now := a . now (). UTC ()
draftID := strings . TrimSpace ( chi . URLParam ( r , "id" ))
draftsFolderID , err := a . ensureFolder ( r . Context (), mb . ID , "Drafts" )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load drafts folder" )
return
}
if draftID == "" {
messageID := fmt . Sprintf ( "<%s@%s>" , newID ( "draft" ), strings . Split ( mb . Address , "@" )[ 1 ])
attachments := [] AttachmentInput {}
if req . Attachments != nil {
attachments = * req . Attachments
}
stored := storedMessage { MailboxID : mb . ID , FolderID : draftsFolderID , MessageUID : newID ( "uid" ), MessageID : messageID , Subject : subject , From : mb . Address , FromName : mb . DisplayName , To : compose . To , CC : compose . CC , BCC : compose . BCC , SentAt : now , ReceivedAt : now , Snippet : snippetFrom ( compose . Text , compose . HTML ), BodyText : compose . Text , BodyHTML : compose . HTML , IsRead : true }
draftID , err = a . insertMessage ( r . Context (), stored , attachments )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to save draft" )
return
}
2026-06-24 15:18:06 +08:00
if err := a . writeStoredMessageToMaildir ( r . Context (), draftID , stored , attachments ); err != nil {
a . deleteMessage ( r . Context (), draftID )
respondError ( w , http . StatusInternalServerError , "failed to save draft" )
return
}
2026-06-17 13:43:19 +08:00
msg , _ := a . messageByID ( r . Context (), draftID , true )
respondJSON ( w , http . StatusCreated , msg )
return
}
existing , err := a . loadMessageForRequest ( r , draftID , false )
if err != nil || ! strings . EqualFold ( existing . Folder , "Drafts" ) || existing . MailboxID != mb . ID {
respondError ( w , http . StatusNotFound , "draft not found" )
return
}
size := int64 ( len ( compose . Text ) + len ( compose . HTML ))
hasAttachments := existing . HasAttachments
if req . Attachments != nil {
a . deleteMessageFiles ( r . Context (), draftID )
if _ , err := a . db . ExecContext ( r . Context (), `DELETE FROM attachments WHERE message_id=?` , draftID ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to replace draft attachments" )
return
}
hasAttachments = len ( * req . Attachments ) > 0
for _ , att := range * req . Attachments {
if decoded , err := base64 . StdEncoding . DecodeString ( att . ContentBase64 ); err == nil {
size += int64 ( len ( decoded ))
}
}
} else {
var attachmentBytes int64
_ = a . db . QueryRowContext ( r . Context (), `SELECT COALESCE(SUM(size_bytes),0) FROM attachments WHERE message_id=?` , draftID ). Scan ( & attachmentBytes )
size += attachmentBytes
}
2026-06-24 16:23:45 +08:00
modSeq , err := a . updateMessageModSeq ( r . Context (), draftID , existing . FolderID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update draft" )
return
}
_ , err = a . db . ExecContext ( r . Context (), `UPDATE messages SET subject=?,to_addrs=?,cc_addrs=?,bcc_addrs=?,sent_at=?,received_at=?,snippet=?,body_text=?,body_html=?,is_read=1,has_attachments=?,size_bytes=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?` ,
subject , jsonEncode ( compose . To ), jsonEncode ( compose . CC ), jsonEncode ( compose . BCC ), now . Format ( time . RFC3339Nano ), now . Format ( time . RFC3339Nano ), snippetFrom ( compose . Text , compose . HTML ), compose . Text , compose . HTML , boolInt ( hasAttachments ), size , modSeq , modSeq , now . Format ( time . RFC3339Nano ), draftID )
2026-06-17 13:43:19 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update draft" )
return
}
if req . Attachments != nil {
for _ , att := range * req . Attachments {
if err := a . storeAttachment ( r . Context (), draftID , att ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to store draft attachment" )
return
}
}
}
2026-06-24 15:18:06 +08:00
if err := a . rewriteMessageMaildir ( r . Context (), draftID ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update draft" )
return
}
2026-06-17 13:43:19 +08:00
msg , _ := a . messageByID ( r . Context (), draftID , true )
respondJSON ( w , http . StatusOK , msg )
}
func ( a * App ) handleDeleteDraft ( w http . ResponseWriter , r * http . Request ) {
msg , err := a . loadMessageForRequest ( r , chi . URLParam ( r , "id" ), false )
if err != nil || ! strings . EqualFold ( msg . Folder , "Drafts" ) {
respondError ( w , http . StatusNotFound , "draft not found" )
return
}
2026-06-24 15:18:06 +08:00
a . deleteMessageMaildirFile ( r . Context (), msg . ID )
2026-06-17 13:43:19 +08:00
a . deleteMessageFiles ( r . Context (), msg . ID )
if _ , err := a . db . ExecContext ( r . Context (), `DELETE FROM messages WHERE id=?` , msg . ID ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to delete draft" )
return
}
2026-06-24 16:23:45 +08:00
_ , _ = a . bumpFolderModSeq ( r . Context (), msg . FolderID )
2026-06-17 13:43:19 +08:00
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true })
}
func ( a * App ) handleScheduledSends ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
2026-08-02 05:55:53 +08:00
mailboxID := strings . TrimSpace ( r . URL . Query (). Get ( "mailboxId" ))
args := [] any { user . ID }
where := `user_id=?`
if ! isAllMailboxID ( mailboxID ) {
mb , err := a . mailboxForCurrentUser ( r )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
where += ` AND mailbox_id=?`
args = append ( args , mb . ID )
2026-06-17 13:43:19 +08:00
}
2026-08-02 05:55:53 +08:00
args = append ( args , "pending" , "sending" , "failed" )
2026-06-17 13:43:19 +08:00
rows , err := a . db . QueryContext ( r . Context (), `SELECT id,mailbox_id,draft_id,payload_json,send_at,status,error,created_at,updated_at,sent_at
FROM scheduled_sends
2026-08-02 05:55:53 +08:00
WHERE ` + where + ` AND status IN (?,?,?)
ORDER BY send_at ASC, created_at DESC` , args ... )
2026-06-17 13:43:19 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load scheduled sends" )
return
}
defer rows . Close ()
items := [] ScheduledSend {}
for rows . Next () {
var item ScheduledSend
var draftID , errorText , sentAt sql . NullString
var payloadJSON , sendAt , createdAt , updatedAt string
if err := rows . Scan ( & item . ID , & item . MailboxID , & draftID , & payloadJSON , & sendAt , & item . Status , & errorText , & createdAt , & updatedAt , & sentAt ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to scan scheduled sends" )
return
}
if draftID . Valid {
item . DraftID = draftID . String
}
if errorText . Valid {
item . Error = errorText . String
}
item . SendAt = parseTime ( sendAt )
item . CreatedAt = parseTime ( createdAt )
item . UpdatedAt = parseTime ( updatedAt )
item . SentAt = nullableTime ( sentAt )
applyScheduledSendPreview ( & item , payloadJSON )
items = append ( items , item )
}
if err := rows . Err (); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load scheduled sends" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items })
}
2026-06-24 16:39:40 +08:00
func ( a * App ) handleSendQueue ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
2026-08-02 05:55:53 +08:00
mailboxID := strings . TrimSpace ( r . URL . Query (). Get ( "mailboxId" ))
2026-06-24 16:39:40 +08:00
status := strings . TrimSpace ( r . URL . Query (). Get ( "status" ))
2026-08-02 05:55:53 +08:00
if strings . EqualFold ( status , "all" ) {
status = ""
}
2026-06-25 16:13:44 +08:00
cursorCreatedAt , cursorID , offsetCursor , err := parseSendQueueCursor ( r . URL . Query (). Get ( "cursor" ))
if err != nil {
badRequest ( w , err )
return
2026-06-24 16:39:40 +08:00
}
limit := 30
2026-08-02 05:55:53 +08:00
args := [] any { user . ID }
where := `mb.user_id=?`
if ! isAllMailboxID ( mailboxID ) {
mb , err := a . mailboxForCurrentUser ( r )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
where += ` AND sq.mailbox_id=?`
args = append ( args , mb . ID )
}
2026-06-24 16:39:40 +08:00
if status != "" {
if ! validSendQueueStatus ( status ) {
badRequest ( w , errors . New ( "invalid send queue status" ))
return
}
where += ` AND sq.status=?`
args = append ( args , status )
}
2026-06-25 16:13:44 +08:00
if messageID := strings . TrimSpace ( r . URL . Query (). Get ( "messageId" )); messageID != "" {
where += ` AND (sq.message_id=? OR sq.sent_message_id=? OR m.message_id=?)`
args = append ( args , messageID , messageID , messageID )
}
if recipient := normalizeEmail ( r . URL . Query (). Get ( "recipient" )); recipient != "" {
where += ` AND sq.recipients_json LIKE ?`
args = append ( args , "%" + recipient + "%" )
}
if from := strings . TrimSpace ( r . URL . Query (). Get ( "from" )); from != "" {
t , err := parseTimeQuery ( from )
if err != nil {
badRequest ( w , errors . New ( "invalid from time" ))
return
}
where += ` AND sq.created_at>=?`
args = append ( args , t . UTC (). Format ( time . RFC3339Nano ))
}
if to := strings . TrimSpace ( r . URL . Query (). Get ( "to" )); to != "" {
t , err := parseTimeQuery ( to )
if err != nil {
badRequest ( w , errors . New ( "invalid to time" ))
return
}
where += ` AND sq.created_at<=?`
args = append ( args , t . UTC (). Format ( time . RFC3339Nano ))
}
if cursorCreatedAt != "" && cursorID != "" {
where += ` AND (sq.created_at < ? OR (sq.created_at = ? AND sq.id < ?))`
args = append ( args , cursorCreatedAt , cursorCreatedAt , cursorID )
}
args = append ( args , limit + 1 )
query := `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id WHERE ` + where + ` ORDER BY sq.created_at DESC, sq.id DESC LIMIT ?`
if offsetCursor > 0 {
args = append ( args , offsetCursor )
query += ` OFFSET ?`
}
rows , err := a . db . QueryContext ( r . Context (), query , args ... )
2026-06-24 16:39:40 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load send queue" )
return
}
defer rows . Close ()
items := [] SendQueueEntry {}
for rows . Next () {
item , err := scanSendQueueEntry ( rows )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to scan send queue" )
return
}
items = append ( items , item )
}
if err := rows . Err (); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load send queue" )
return
}
next := ""
if len ( items ) > limit {
items = items [: limit ]
2026-06-25 16:13:44 +08:00
last := items [ len ( items ) - 1 ]
next = encodeSendQueueCursor ( last . CreatedAt , last . ID )
2026-06-24 16:39:40 +08:00
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items , "nextCursor" : next })
}
func ( a * App ) handleSendQueueAudit ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
id := strings . TrimSpace ( chi . URLParam ( r , "id" ))
if ! a . sendQueueBelongsToUser ( r . Context (), id , user . ID ) {
respondError ( w , http . StatusNotFound , "send queue item not found" )
return
}
rows , err := a . db . QueryContext ( r . Context (), `SELECT id,queue_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at
FROM send_audit_events WHERE queue_id=? ORDER BY created_at ASC, id ASC` , id )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load send audit" )
return
}
defer rows . Close ()
items := [] SendAuditEvent {}
for rows . Next () {
var item SendAuditEvent
var recipientsJSON , createdAt string
if err := rows . Scan ( & item . ID , & item . QueueID , & item . MailboxID , & item . SentMessageID , & item . Source , & item . Event , & item . Status , & item . MailFrom , & item . HeaderFrom , & recipientsJSON , & item . Error , & createdAt ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to scan send audit" )
return
}
item . Recipients = jsonDecodeSlice ( recipientsJSON )
item . CreatedAt = parseTime ( createdAt )
items = append ( items , item )
}
if err := rows . Err (); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load send audit" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items })
}
func ( a * App ) handleRetrySendQueue ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
id := strings . TrimSpace ( chi . URLParam ( r , "id" ))
item , err := a . loadSendQueueEntryForUser ( r . Context (), id , user . ID )
if err != nil {
respondError ( w , http . StatusNotFound , "send queue item not found" )
return
}
if item . Status != sendQueueStatusFailed {
badRequest ( w , errors . New ( "send queue item is not failed" ))
return
}
now := a . now (). UTC (). Format ( time . RFC3339Nano )
res , err := a . db . ExecContext ( r . Context (), `UPDATE send_queue SET status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=? AND EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=send_queue.mailbox_id AND mb.user_id=?)` ,
sendQueueStatusQueued , now , now , id , sendQueueStatusFailed , user . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to retry send queue item" )
return
}
if n , _ := res . RowsAffected (); n == 0 {
respondError ( w , http . StatusNotFound , "send queue item not found" )
return
}
a . deleteSendQueueDeliveredMarker ( id )
a . recordSendAudit ( r . Context (), sendAuditRetry , sendQueueStatusQueued , sendAuditInput {
QueueID : item . ID ,
UserID : user . ID ,
MailboxID : item . MailboxID ,
SentMessageID : item . SentMessageID ,
Source : item . Source ,
MailFrom : item . MailFrom ,
HeaderFrom : item . HeaderFrom ,
Recipients : item . Recipients ,
})
updated , err := a . loadSendQueueEntryForUser ( r . Context (), id , user . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load send queue item" )
return
}
respondJSON ( w , http . StatusOK , updated )
}
func ( a * App ) handleCancelSendQueue ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
id := strings . TrimSpace ( chi . URLParam ( r , "id" ))
item , err := a . loadSendQueueEntryForUser ( r . Context (), id , user . ID )
if err != nil {
respondError ( w , http . StatusNotFound , "send queue item not found" )
return
}
if item . Status != sendQueueStatusQueued && item . Status != sendQueueStatusFailed {
badRequest ( w , errors . New ( "send queue item cannot be canceled" ))
return
}
now := a . now (). UTC (). Format ( time . RFC3339Nano )
res , err := a . db . ExecContext ( r . Context (), `UPDATE send_queue SET status=?,last_error='',updated_at=? WHERE id=? AND status IN (?,?) AND EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=send_queue.mailbox_id AND mb.user_id=?)` ,
sendQueueStatusCanceled , now , id , sendQueueStatusQueued , sendQueueStatusFailed , user . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to cancel send queue item" )
return
}
if n , _ := res . RowsAffected (); n == 0 {
respondError ( w , http . StatusNotFound , "send queue item not found" )
return
}
a . deleteSendQueueDeliveredMarker ( id )
a . recordSendAudit ( r . Context (), sendAuditCanceled , sendQueueStatusCanceled , sendAuditInput {
QueueID : item . ID ,
UserID : user . ID ,
MailboxID : item . MailboxID ,
SentMessageID : item . SentMessageID ,
Source : item . Source ,
MailFrom : item . MailFrom ,
HeaderFrom : item . HeaderFrom ,
Recipients : item . Recipients ,
})
updated , err := a . loadSendQueueEntryForUser ( r . Context (), id , user . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load send queue item" )
return
}
respondJSON ( w , http . StatusOK , updated )
}
type sendQueueEntryScanner interface { Scan ( dest ... any ) error }
func scanSendQueueEntry ( row sendQueueEntryScanner ) ( SendQueueEntry , error ) {
var item SendQueueEntry
var recipientsJSON , nextAttemptAt , createdAt , updatedAt string
var deliveredAt sql . NullString
err := row . Scan ( & item . ID , & item . MailboxID , & item . SentMessageID , & item . MessageID , & item . Subject , & item . Source , & item . MailFrom , & item . HeaderFrom , & recipientsJSON , & item . Status , & item . AttemptCount , & item . MaxAttempts , & nextAttemptAt , & item . LastError , & createdAt , & updatedAt , & deliveredAt )
if err != nil {
return item , err
}
item . Recipients = jsonDecodeSlice ( recipientsJSON )
item . NextAttemptAt = parseTime ( nextAttemptAt )
item . CreatedAt = parseTime ( createdAt )
item . UpdatedAt = parseTime ( updatedAt )
item . DeliveredAt = nullableTime ( deliveredAt )
return item , nil
}
func ( a * App ) loadSendQueueEntryForUser ( ctx context . Context , id , userID string ) ( SendQueueEntry , error ) {
row := a . db . QueryRowContext ( ctx , `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id WHERE sq.id=? AND mb.user_id=?` , id , userID )
return scanSendQueueEntry ( row )
}
2026-06-25 16:13:44 +08:00
type sendQueueCursor struct {
CreatedAt string `json:"createdAt"`
ID string `json:"id"`
}
func encodeSendQueueCursor ( createdAt time . Time , id string ) string {
payload , _ := json . Marshal ( sendQueueCursor { CreatedAt : createdAt . UTC (). Format ( time . RFC3339Nano ), ID : id })
return base64 . RawURLEncoding . EncodeToString ( payload )
}
func parseSendQueueCursor ( raw string ) ( createdAt string , id string , offset int , err error ) {
raw = strings . TrimSpace ( raw )
if raw == "" {
return "" , "" , 0 , nil
}
if n , convErr := strconv . Atoi ( raw ); convErr == nil {
if n < 0 {
return "" , "" , 0 , errors . New ( "invalid cursor" )
}
return "" , "" , n , nil
}
data , decodeErr := base64 . RawURLEncoding . DecodeString ( raw )
if decodeErr != nil {
return "" , "" , 0 , errors . New ( "invalid cursor" )
}
var cursor sendQueueCursor
if err := json . Unmarshal ( data , & cursor ); err != nil {
return "" , "" , 0 , errors . New ( "invalid cursor" )
}
t , err := parseTimeQuery ( cursor . CreatedAt )
if err != nil || strings . TrimSpace ( cursor . ID ) == "" {
return "" , "" , 0 , errors . New ( "invalid cursor" )
}
return t . UTC (). Format ( time . RFC3339Nano ), strings . TrimSpace ( cursor . ID ), 0 , nil
}
2026-06-24 16:39:40 +08:00
func ( a * App ) sendQueueBelongsToUser ( ctx context . Context , id , userID string ) bool {
var count int
_ = a . db . QueryRowContext ( ctx , `SELECT COUNT(1) FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id WHERE sq.id=? AND mb.user_id=?` , id , userID ). Scan ( & count )
return count > 0
}
2026-06-25 16:13:44 +08:00
func parseTimeQuery ( raw string ) ( time . Time , error ) {
raw = strings . TrimSpace ( raw )
if raw == "" {
return time . Time {}, errors . New ( "time is required" )
}
if t , err := time . Parse ( time . RFC3339Nano , raw ); err == nil {
return t , nil
}
if t , err := time . Parse ( "2006-01-02" , raw ); err == nil {
return t , nil
}
return time . Time {}, errors . New ( "invalid time" )
}
2026-06-24 16:39:40 +08:00
func validSendQueueStatus ( status string ) bool {
switch status {
case sendQueueStatusQueued , sendQueueStatusSending , sendQueueStatusDelivered , sendQueueStatusFailed , sendQueueStatusCanceled :
return true
default :
return false
}
}
2026-06-17 13:43:19 +08:00
func ( a * App ) handleScheduleSend ( w http . ResponseWriter , r * http . Request ) {
var req struct {
MailboxID string `json:"mailboxId"`
To [] string `json:"to"`
CC [] string `json:"cc"`
BCC [] string `json:"bcc"`
Subject string `json:"subject"`
Text string `json:"text"`
HTML string `json:"html"`
Attachments [] AttachmentInput `json:"attachments"`
DraftID string `json:"draftId"`
SendAt string `json:"sendAt"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
mb , err := a . mailboxForCurrentUserWithID ( r , req . MailboxID )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
sendAt , err := time . Parse ( time . RFC3339Nano , strings . TrimSpace ( req . SendAt ))
if err != nil {
badRequest ( w , errors . New ( "sendAt is required" ))
return
}
if ! sendAt . After ( a . now (). Add ( 30 * time . Second )) {
badRequest ( w , errors . New ( "sendAt must be in the future" ))
return
}
compose := mailComposeInput { MailboxID : req . MailboxID , To : req . To , CC : req . CC , BCC : req . BCC , Subject : req . Subject , Text : req . Text , HTML : req . HTML , Attachments : req . Attachments }
2026-06-23 11:09:36 +08:00
if err := validateAttachmentLimit ( compose . Attachments , userLimits ( currentUser ( r ))); err != nil {
if errors . Is ( err , errAttachmentTooLarge ) || errors . Is ( err , errInvalidMIME ) {
badRequest ( w , err )
return
}
respondError ( w , http . StatusInternalServerError , err . Error ())
return
}
2026-06-17 13:43:19 +08:00
compose . To , compose . CC , compose . BCC = dedupeEmails ( compose . To ), dedupeEmails ( compose . CC ), dedupeEmails ( compose . BCC )
if len ( append ( append ([] string {}, compose . To ... ), append ( compose . CC , compose . BCC ... ) ... )) == 0 {
badRequest ( w , errNoRecipients )
return
}
compose . HTML = a . policy . Sanitize ( compose . HTML )
if strings . TrimSpace ( compose . Text ) == "" {
compose . Text = stripTags ( compose . HTML )
}
if strings . TrimSpace ( compose . HTML ) == "" {
compose . HTML = "<p>" + htmlEscape ( compose . Text ) + "</p>"
}
if strings . TrimSpace ( compose . Subject ) == "" {
compose . Subject = "(no subject)"
}
draftID := strings . TrimSpace ( req . DraftID )
if draftID != "" {
msg , err := a . loadMessageForRequest ( r , draftID , false )
if err != nil || ! strings . EqualFold ( msg . Folder , "Drafts" ) || msg . MailboxID != mb . ID {
respondError ( w , http . StatusNotFound , "draft not found" )
return
}
}
now := a . now (). UTC (). Format ( time . RFC3339Nano )
2026-06-24 11:27:32 +08:00
payload := scheduledSendPayload { MailboxID : compose . MailboxID , From : compose . From , FromName : compose . FromName , To : compose . To , CC : compose . CC , BCC : compose . BCC , Subject : compose . Subject , Text : compose . Text , HTML : compose . HTML , Attachments : compose . Attachments , DraftID : draftID }
2026-06-17 13:43:19 +08:00
item := ScheduledSend { ID : newID ( "sched" ), MailboxID : mb . ID , DraftID : draftID , Subject : payload . Subject , To : payload . To , Snippet : snippetFrom ( payload . Text , payload . HTML ), SendAt : sendAt . UTC (), Status : "pending" , CreatedAt : parseTime ( now ), UpdatedAt : parseTime ( now )}
if _ , err := a . db . ExecContext ( r . Context (), `INSERT INTO scheduled_sends(id,user_id,mailbox_id,draft_id,payload_json,send_at,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)` , item . ID , currentUser ( r ). ID , mb . ID , nullableString ( draftID ), jsonEncode ( payload ), item . SendAt . Format ( time . RFC3339Nano ), item . Status , now , now ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to schedule send" )
return
}
respondJSON ( w , http . StatusCreated , item )
}
func applyScheduledSendPreview ( item * ScheduledSend , payloadJSON string ) {
var payload scheduledSendPayload
if err := json . Unmarshal ([] byte ( payloadJSON ), & payload ); err != nil {
item . Subject = "(no subject)"
return
}
item . Subject = strings . TrimSpace ( payload . Subject )
if item . Subject == "" {
item . Subject = "(no subject)"
}
item . To = payload . To
item . Snippet = snippetFrom ( payload . Text , payload . HTML )
}
func ( a * App ) handleCancelScheduledSend ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
id := strings . TrimSpace ( chi . URLParam ( r , "id" ))
var status string
if err := a . db . QueryRowContext ( r . Context (), `SELECT status FROM scheduled_sends WHERE id=? AND user_id=?` , id , user . ID ). Scan ( & status ); err != nil {
respondError ( w , http . StatusNotFound , "scheduled send not found" )
return
}
if status != "pending" && status != "failed" {
badRequest ( w , errors . New ( "scheduled send is not pending" ))
return
}
if _ , err := a . db . ExecContext ( r . Context (), `UPDATE scheduled_sends SET status='cancelled',updated_at=? WHERE id=? AND user_id=?` , a . now (). UTC (). Format ( time . RFC3339Nano ), id , user . ID ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to cancel scheduled send" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true })
}
func ( a * App ) scheduledSendWorker ( ctx context . Context ) {
a . log . Info ( "scheduled send worker started" )
ticker := time . NewTicker ( 30 * time . Second )
defer ticker . Stop ()
for {
if err := a . processDueScheduledSends ( ctx ); err != nil {
a . log . Warn ( "scheduled send worker failed" , "error" , err )
}
select {
case <- ctx . Done ():
a . log . Info ( "scheduled send worker stopped" )
return
case <- ticker . C :
}
}
}
2026-06-23 11:25:38 +08:00
func ( a * App ) smtpEventsCleanupWorker ( ctx context . Context ) {
a . log . Info ( "smtp events cleanup worker started" )
ticker := time . NewTicker ( 10 * time . Minute )
defer ticker . Stop ()
for {
select {
case <- ctx . Done ():
a . log . Info ( "smtp events cleanup worker stopped" )
return
case <- ticker . C :
a . cleanupStaleEvents ( ctx )
}
}
}
func ( a * App ) cleanupStaleEvents ( ctx context . Context ) {
cutoff := a . now (). UTC (). Add ( - 24 * time . Hour ). Format ( time . RFC3339Nano )
for _ , table := range [] string { "smtp_send_events" , "imap_events" , "pop3_events" } {
result , err := a . db . ExecContext ( ctx , "DELETE FROM " + table + " WHERE created_at<?" , cutoff )
if err != nil {
a . log . Warn ( "event cleanup failed" , "table" , table , "error" , err )
} else if n , _ := result . RowsAffected (); n > 0 {
a . log . Debug ( "event cleanup deleted rows" , "table" , table , "count" , n )
}
}
}
2026-06-17 13:43:19 +08:00
func ( a * App ) processDueScheduledSends ( ctx context . Context ) error {
rows , err := a . db . QueryContext ( ctx , `SELECT id,mailbox_id,draft_id,payload_json FROM scheduled_sends WHERE status='pending' AND send_at<=? ORDER BY send_at LIMIT 20` , a . now (). UTC (). Format ( time . RFC3339Nano ))
if err != nil {
return err
}
defer rows . Close ()
type dueItem struct {
id , mailboxID , draftID , payloadJSON string
}
items := [] dueItem {}
for rows . Next () {
var item dueItem
var draftID sql . NullString
if err := rows . Scan ( & item . id , & item . mailboxID , & draftID , & item . payloadJSON ); err != nil {
return err
}
if draftID . Valid {
item . draftID = draftID . String
}
items = append ( items , item )
}
for _ , item := range items {
a . processScheduledSend ( ctx , item . id , item . mailboxID , item . draftID , item . payloadJSON )
}
return rows . Err ()
}
func ( a * App ) processScheduledSend ( ctx context . Context , id , mailboxID , draftID , payloadJSON string ) {
now := a . now (). UTC (). Format ( time . RFC3339Nano )
res , err := a . db . ExecContext ( ctx , `UPDATE scheduled_sends SET status='sending',updated_at=? WHERE id=? AND status='pending'` , now , id )
if err != nil {
a . log . Warn ( "failed to claim scheduled send" , "id" , id , "error" , err )
return
}
if affected , _ := res . RowsAffected (); affected == 0 {
return
}
var payload scheduledSendPayload
if err := json . Unmarshal ([] byte ( payloadJSON ), & payload ); err != nil {
a . markScheduledSendFailed ( ctx , id , "invalid scheduled payload" )
return
}
mb , err := a . mailboxByID ( ctx , mailboxID )
if err != nil || mb . Status != "active" {
a . markScheduledSendFailed ( ctx , id , "mailbox not found" )
return
}
2026-06-22 15:54:15 +08:00
user , err := a . userByID ( ctx , mb . UserID )
if err != nil || ! userHasPermission ( user , PermissionMailSchedule ) || ! userHasPermission ( user , PermissionMailSend ) {
a . markScheduledSendFailed ( ctx , id , "mail send permission revoked" )
return
}
2026-06-17 13:43:19 +08:00
compose := mailComposeInput { MailboxID : payload . MailboxID , To : payload . To , CC : payload . CC , BCC : payload . BCC , Subject : payload . Subject , Text : payload . Text , HTML : payload . HTML , Attachments : payload . Attachments }
2026-06-23 11:09:36 +08:00
if _ , err := a . sendMailNow ( ctx , user , mb , compose ); err != nil {
2026-06-17 13:43:19 +08:00
a . markScheduledSendFailed ( ctx , id , err . Error ())
return
}
if draftID != "" {
2026-06-24 15:18:06 +08:00
a . deleteMessage ( ctx , draftID )
2026-06-17 13:43:19 +08:00
}
sentAt := a . now (). UTC (). Format ( time . RFC3339Nano )
if _ , err := a . db . ExecContext ( ctx , `UPDATE scheduled_sends SET status='sent',sent_at=?,updated_at=?,error='' WHERE id=?` , sentAt , sentAt , id ); err != nil {
a . log . Warn ( "failed to mark scheduled send sent" , "id" , id , "error" , err )
}
}
func ( a * App ) markScheduledSendFailed ( ctx context . Context , id , message string ) {
if _ , err := a . db . ExecContext ( ctx , `UPDATE scheduled_sends SET status='failed',error=?,updated_at=? WHERE id=?` , message , a . now (). UTC (). Format ( time . RFC3339Nano ), id ); err != nil {
a . log . Warn ( "failed to mark scheduled send failed" , "id" , id , "error" , err )
}
2026-06-14 01:07:48 +08:00
}
2026-06-15 00:37:43 +08:00
func ( a * App ) isLocalDomainAddress ( ctx context . Context , address string ) bool {
parts := strings . Split ( normalizeEmail ( address ), "@" )
if len ( parts ) != 2 || parts [ 1 ] == "" {
return false
}
var count int
_ = a . db . QueryRowContext ( ctx , `SELECT COUNT(1) FROM domains WHERE name=? AND status='active'` , parts [ 1 ]). Scan ( & count )
return count > 0
}
2026-06-14 01:07:48 +08:00
func ( a * App ) handleMarkRead ( w http . ResponseWriter , r * http . Request ) {
msg , err := a . loadMessageForRequest ( r , chi . URLParam ( r , "id" ), false )
if err != nil {
respondError ( w , http . StatusNotFound , "message not found" )
return
}
var req struct {
Read * bool `json:"read"`
}
_ = decodeJSON ( r , & req )
read := true
if req . Read != nil {
read = * req . Read
}
2026-06-24 15:27:30 +08:00
if err := a . updateMessageMaildirFlags ( r . Context (), msg . ID , & read , nil ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update message" )
return
}
2026-06-24 16:23:45 +08:00
modSeq , err := a . updateMessageModSeq ( r . Context (), msg . ID , msg . FolderID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update message" )
return
}
_ , err = a . db . ExecContext ( r . Context (), `UPDATE messages SET is_read=?, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?` , boolInt ( read ), modSeq , modSeq , a . now (). UTC (). Format ( time . RFC3339Nano ), msg . ID )
2026-06-14 01:07:48 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update message" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true , "read" : read })
}
func ( a * App ) handleStar ( w http . ResponseWriter , r * http . Request ) {
msg , err := a . loadMessageForRequest ( r , chi . URLParam ( r , "id" ), false )
if err != nil {
respondError ( w , http . StatusNotFound , "message not found" )
return
}
var req struct {
Starred * bool `json:"starred"`
}
_ = decodeJSON ( r , & req )
starred := ! msg . IsStarred
if req . Starred != nil {
starred = * req . Starred
}
2026-06-24 15:27:30 +08:00
if err := a . updateMessageMaildirFlags ( r . Context (), msg . ID , nil , & starred ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update message" )
return
}
2026-06-24 16:23:45 +08:00
modSeq , err := a . updateMessageModSeq ( r . Context (), msg . ID , msg . FolderID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update message" )
return
}
_ , err = a . db . ExecContext ( r . Context (), `UPDATE messages SET is_starred=?, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?` , boolInt ( starred ), modSeq , modSeq , a . now (). UTC (). Format ( time . RFC3339Nano ), msg . ID )
2026-06-14 01:07:48 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update message" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true , "starred" : starred })
}
func ( a * App ) handleMove ( w http . ResponseWriter , r * http . Request ) {
msg , err := a . loadMessageForRequest ( r , chi . URLParam ( r , "id" ), false )
if err != nil {
respondError ( w , http . StatusNotFound , "message not found" )
return
}
var req struct {
Folder string `json:"folder"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
2026-06-25 14:11:38 +08:00
folder , err := normalizeFolderNameForUser ( req . Folder )
if err != nil {
badRequest ( w , err )
return
}
folderID , err := a . ensureFolder ( r . Context (), msg . MailboxID , folder )
2026-06-14 01:07:48 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load folder" )
return
}
2026-06-24 15:18:06 +08:00
if err := a . moveMessageMaildir ( r . Context (), msg . ID , folderID ); err != nil {
2026-06-14 01:07:48 +08:00
respondError ( w , http . StatusInternalServerError , "failed to move message" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true })
}
2026-06-25 14:11:38 +08:00
func ( a * App ) folderByID ( ctx context . Context , folderID , mailboxID string ) ( * MailFolder , error ) {
row := a . db . QueryRowContext ( ctx , `SELECT f.id,f.name,f.role,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total,
2026-06-25 15:42:36 +08:00
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
2026-06-25 14:11:38 +08:00
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
2026-06-25 15:42:36 +08:00
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq` , folderID , mailboxID )
2026-06-25 14:11:38 +08:00
var f MailFolder
2026-06-25 15:42:36 +08:00
if err := row . Scan ( & f . ID , & f . Name , & f . Role , & f . UnreadCount , & f . TotalCount , & f . SortOrder , & f . UIDValidity , & f . UIDNext , & f . HighestModSeq ); err != nil {
2026-06-25 14:11:38 +08:00
return nil , err
}
return & f , nil
}
func normalizeCustomFolderName ( raw string ) ( string , error ) {
name := strings . Join ( strings . Fields ( raw ), " " )
if name == "" {
return "" , errors . New ( "folder name is required" )
}
if len ([] rune ( name )) > 48 {
return "" , errors . New ( "folder name is too long" )
}
if strings . ContainsAny ( name , `/\:` ) || strings . Contains ( name , ".." ) {
return "" , errors . New ( "folder name contains invalid characters" )
}
for _ , r := range name {
if r < 32 || r == 127 {
return "" , errors . New ( "folder name contains invalid characters" )
}
}
return name , nil
}
func normalizeFolderNameForUser ( raw string ) ( string , error ) {
name := strings . TrimSpace ( raw )
if isSystemFolderName ( name ) {
switch strings . ToLower ( name ) {
case "inbox" :
return "Inbox" , nil
case "sent" :
return "Sent" , nil
case "drafts" :
return "Drafts" , nil
case "archive" :
return "Archive" , nil
case "spam" :
return "Spam" , nil
case "trash" :
return "Trash" , nil
}
}
return normalizeCustomFolderName ( raw )
}
func isSystemFolderName ( name string ) bool {
switch strings . ToLower ( strings . TrimSpace ( name )) {
case "inbox" , "sent" , "drafts" , "archive" , "spam" , "trash" :
return true
default :
return false
}
}
2026-06-14 01:07:48 +08:00
func ( a * App ) handleDeleteMessage ( w http . ResponseWriter , r * http . Request ) {
msg , err := a . loadMessageForRequest ( r , chi . URLParam ( r , "id" ), false )
if err != nil {
respondError ( w , http . StatusNotFound , "message not found" )
return
}
if strings . EqualFold ( msg . Folder , "Trash" ) {
2026-06-24 15:18:06 +08:00
a . deleteMessageMaildirFile ( r . Context (), msg . ID )
2026-06-14 01:07:48 +08:00
a . deleteMessageFiles ( r . Context (), msg . ID )
_ , err = a . db . ExecContext ( r . Context (), `DELETE FROM messages WHERE id=?` , msg . ID )
2026-06-24 16:23:45 +08:00
if err == nil {
_ , _ = a . bumpFolderModSeq ( r . Context (), msg . FolderID )
}
2026-06-14 01:07:48 +08:00
} else {
trashID , e := a . ensureFolder ( r . Context (), msg . MailboxID , "Trash" )
if e != nil {
err = e
} else {
2026-06-24 15:18:06 +08:00
if e := a . moveMessageMaildir ( r . Context (), msg . ID , trashID ); e != nil {
err = e
}
2026-06-14 01:07:48 +08:00
}
}
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to delete message" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true })
}
func ( a * App ) handleAttachment ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
attID := chi . URLParam ( r , "id" )
row := a . db . QueryRowContext ( r . Context (), `SELECT a.filename,a.content_type,a.size_bytes,a.storage_path
FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id
WHERE a.id=? AND mb.user_id=?` , attID , user . ID )
var filename , contentType , path string
var size int64
if err := row . Scan ( & filename , & contentType , & size , & path ); err != nil {
respondError ( w , http . StatusNotFound , "attachment not found" )
return
}
f , err := os . Open ( path )
if err != nil {
respondError ( w , http . StatusNotFound , "attachment file missing" )
return
}
defer f . Close ()
w . Header (). Set ( "Content-Type" , contentType )
w . Header (). Set ( "Content-Disposition" , `attachment; filename="` + strings . ReplaceAll ( filename , `"` , "" ) + `"` )
w . Header (). Set ( "Content-Length" , strconv . FormatInt ( size , 10 ))
_ , _ = io . Copy ( w , f )
}
2026-06-15 00:37:43 +08:00
func ( a * App ) handleAdminAttachment ( w http . ResponseWriter , r * http . Request ) {
attID := chi . URLParam ( r , "id" )
2026-08-03 16:16:20 +08:00
row := a . db . QueryRowContext ( r . Context (), `SELECT a.filename,a.content_type,a.size_bytes,a.storage_path,COALESCE(m.mailbox_id,'') FROM attachments a JOIN messages m ON m.id=a.message_id WHERE a.id=?` , attID )
var filename , contentType , path , mailboxID string
2026-06-15 00:37:43 +08:00
var size int64
2026-08-03 16:16:20 +08:00
if err := row . Scan ( & filename , & contentType , & size , & path , & mailboxID ); err != nil {
2026-06-15 00:37:43 +08:00
respondError ( w , http . StatusNotFound , "attachment not found" )
return
}
2026-08-03 16:16:20 +08:00
user := currentUser ( r )
if mailboxID == "" && ( user == nil || user . Role != "admin" ) {
respondError ( w , http . StatusForbidden , "system admin required" )
return
}
2026-06-15 00:37:43 +08:00
f , err := os . Open ( path )
if err != nil {
respondError ( w , http . StatusNotFound , "attachment file missing" )
return
}
defer f . Close ()
w . Header (). Set ( "Content-Type" , contentType )
w . Header (). Set ( "Content-Disposition" , `attachment; filename="` + strings . ReplaceAll ( filename , `"` , "" ) + `"` )
w . Header (). Set ( "Content-Length" , strconv . FormatInt ( size , 10 ))
_ , _ = io . Copy ( w , f )
}
2026-06-14 01:07:48 +08:00
func ( a * App ) handleEvents ( w http . ResponseWriter , r * http . Request ) {
w . Header (). Set ( "Content-Type" , "text/event-stream" )
w . Header (). Set ( "Cache-Control" , "no-cache" )
w . Header (). Set ( "Connection" , "keep-alive" )
flusher , _ := w .( http . Flusher )
fmt . Fprintf ( w , "event: sync\ndata: {\"status\":\"connected\"}\n\n" )
if flusher != nil {
flusher . Flush ()
}
ticker := time . NewTicker ( 25 * time . Second )
defer ticker . Stop ()
for {
select {
case <- r . Context (). Done ():
return
case t := <- ticker . C :
fmt . Fprintf ( w , "event: heartbeat\ndata: {\"time\":\"%s\"}\n\n" , t . UTC (). Format ( time . RFC3339 ))
if flusher != nil {
flusher . Flush ()
}
}
}
}
func ( a * App ) mailboxForCurrentUser ( r * http . Request ) ( * Mailbox , error ) {
return a . mailboxForCurrentUserWithID ( r , r . URL . Query (). Get ( "mailboxId" ))
}
func ( a * App ) mailboxForCurrentUserWithID ( r * http . Request , mailboxID string ) ( * Mailbox , error ) {
user := currentUser ( r )
if user == nil {
return nil , errors . New ( "no user" )
}
mailboxID = strings . TrimSpace ( mailboxID )
if mailboxID != "" {
row := a . db . QueryRowContext ( r . Context (), `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at
FROM mailboxes WHERE id=? AND user_id=? AND status='active'` , mailboxID , user . ID )
var m Mailbox
var created string
if err := row . Scan ( & m . ID , & m . UserID , & m . DomainID , & m . LocalPart , & m . Address , & m . DisplayName , & m . QuotaMB , & m . Status , & created ); err != nil {
return nil , err
}
m . UserEmail = user . Email
m . CreatedAt = parseTime ( created )
return & m , nil
}
return a . mailboxForUser ( r . Context (), user . ID )
}
func ( a * App ) mailboxByAddress ( ctx context . Context , address string ) ( * Mailbox , error ) {
row := a . db . QueryRowContext ( ctx , `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE address=? AND status='active'` , normalizeEmail ( address ))
var m Mailbox
var created string
if err := row . Scan ( & m . ID , & m . UserID , & m . DomainID , & m . LocalPart , & m . Address , & m . DisplayName , & m . QuotaMB , & m . Status , & created ); err != nil {
return nil , err
}
m . CreatedAt = parseTime ( created )
return & m , nil
}
func ( a * App ) loadMessageForRequest ( r * http . Request , id string , includeBody bool ) ( * MailMessage , error ) {
user := currentUser ( r )
row := a . db . QueryRowContext ( r . Context (), `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE m.id=? AND mb.user_id=?` , id , user . ID )
var messageID string
if err := row . Scan ( & messageID ); err != nil {
return nil , err
}
return a . messageByID ( r . Context (), messageID , includeBody )
}
func ( a * App ) messageByID ( ctx context . Context , id string , includeBody bool ) ( * MailMessage , error ) {
2026-06-24 17:16:01 +08:00
row := a . db . QueryRowContext ( ctx , `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes,COALESCE(m.auth_results,''),COALESCE(m.auth_spf,'unknown'),COALESCE(m.auth_dkim,'unknown'),COALESCE(m.auth_dmarc,'unknown'),COALESCE(m.received_spf,'')
2026-06-15 00:37:43 +08:00
FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?` , id )
2026-06-14 01:07:48 +08:00
msg , err := scanMessageFull ( row , includeBody )
if err != nil {
return nil , err
}
if includeBody {
atts , err := a . attachmentsForMessage ( ctx , id )
if err != nil {
return nil , err
}
msg . Attachments = atts
}
2026-06-15 17:37:40 +08:00
labels , err := a . labelsForMessage ( ctx , id )
if err != nil {
return nil , err
}
msg . Labels = labels
2026-06-25 16:13:44 +08:00
if includeBody {
_ = a . db . QueryRowContext ( ctx , `SELECT id,status FROM send_queue WHERE sent_message_id=? ORDER BY created_at DESC,id DESC LIMIT 1` , id ). Scan ( & msg . SendQueueID , & msg . SendQueueStatus )
}
2026-06-14 01:07:48 +08:00
return & msg , nil
}
func ( a * App ) insertMessage ( ctx context . Context , msg storedMessage , attachments [] AttachmentInput ) ( string , error ) {
2026-06-24 13:26:53 +08:00
return a . insertMessageWithDB ( ctx , a . db , msg , attachments )
}
type dbExecutor interface {
ExecContext ( ctx context . Context , query string , args ... any ) ( sql . Result , error )
}
type dbQueryer interface {
QueryRowContext ( ctx context . Context , query string , args ... any ) * sql . Row
}
func ( a * App ) insertMessageWithDB ( ctx context . Context , db dbExecutor , msg storedMessage , attachments [] AttachmentInput ) ( string , error ) {
2026-06-14 01:07:48 +08:00
id := newID ( "mail" )
now := a . now (). UTC (). Format ( time . RFC3339Nano )
hasAttachments := len ( attachments ) > 0
size := int64 ( len ( msg . BodyText ) + len ( msg . BodyHTML ))
for _ , att := range attachments {
if decoded , err := base64 . StdEncoding . DecodeString ( att . ContentBase64 ); err == nil {
size += int64 ( len ( decoded ))
}
}
2026-06-24 17:16:01 +08:00
if err := a . ensureMailboxQuotaAvailable ( ctx , db , msg . MailboxID , size ); err != nil {
return "" , err
}
2026-06-15 00:37:43 +08:00
var mailboxID , folderID any
if strings . TrimSpace ( msg . MailboxID ) != "" {
mailboxID = msg . MailboxID
}
if strings . TrimSpace ( msg . FolderID ) != "" {
folderID = msg . FolderID
}
2026-06-24 16:23:45 +08:00
imapUID , imapModSeq := int64 ( 0 ), int64 ( 1 )
if strings . TrimSpace ( msg . FolderID ) != "" {
meta , err := a . nextIMAPMetadata ( ctx , db , msg . FolderID )
if err != nil {
return "" , err
}
imapUID , imapModSeq = meta . UID , meta . ModSeq
}
2026-06-15 00:37:43 +08:00
recipientAddr := normalizeEmail ( msg . RecipientAddr )
2026-06-24 17:16:01 +08:00
auth := normalizeMailAuthentication ( msg . Authentication )
_ , err := db . ExecContext ( ctx , `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,auth_results,auth_spf,auth_dkim,auth_dmarc,received_spf,raw_path,imap_uid,imap_modseq,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)` , id , mailboxID , folderID , recipientAddr , msg . MessageUID , msg . MessageID , msg . Subject , msg . From , msg . FromName , jsonEncode ( msg . To ), jsonEncode ( msg . CC ), jsonEncode ( msg . BCC ), msg . SentAt . Format ( time . RFC3339Nano ), msg . ReceivedAt . Format ( time . RFC3339Nano ), msg . Snippet , msg . BodyText , msg . BodyHTML , boolInt ( msg . IsRead ), boolInt ( msg . IsStarred ), boolInt ( hasAttachments ), size , auth . AuthenticationResults , auth . SPF , auth . DKIM , auth . DMARC , auth . ReceivedSPF , msg . RawPath , imapUID , imapModSeq , now , now )
2026-06-14 01:07:48 +08:00
if err != nil {
return "" , err
}
for _ , att := range attachments {
2026-06-24 13:26:53 +08:00
if err := a . storeAttachmentWithDB ( ctx , db , id , att ); err != nil {
a . deleteMessageFiles ( ctx , id )
2026-06-14 01:07:48 +08:00
return "" , err
}
}
return id , nil
}
2026-06-24 17:16:01 +08:00
func ( a * App ) ensureMailboxQuotaAvailable ( ctx context . Context , db dbExecutor , mailboxID string , addBytes int64 ) error {
mailboxID = strings . TrimSpace ( mailboxID )
if mailboxID == "" || addBytes <= 0 {
return nil
}
rowDB , ok := db .( dbQueryer )
if ! ok {
return nil
}
var quotaMB int64
if err := rowDB . QueryRowContext ( ctx , `SELECT quota_mb FROM mailboxes WHERE id=? AND status='active'` , mailboxID ). Scan ( & quotaMB ); err != nil {
return err
}
if quotaMB <= 0 {
return nil
}
var used int64
if err := rowDB . QueryRowContext ( ctx , `SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_id=?` , mailboxID ). Scan ( & used ); err != nil {
return err
}
quotaBytes := quotaMB * 1024 * 1024
if used + addBytes > quotaBytes {
return fmt . Errorf ( "%w: used %d bytes, adding %d bytes exceeds %d bytes" , errMailboxQuotaExceeded , used , addBytes , quotaBytes )
}
return nil
}
2026-06-14 01:07:48 +08:00
func ( a * App ) storeAttachment ( ctx context . Context , messageID string , input AttachmentInput ) error {
2026-06-24 13:26:53 +08:00
return a . storeAttachmentWithDB ( ctx , a . db , messageID , input )
}
func ( a * App ) storeAttachmentWithDB ( ctx context . Context , db dbExecutor , messageID string , input AttachmentInput ) error {
2026-06-14 01:07:48 +08:00
filename := filepath . Base ( strings . TrimSpace ( input . Filename ))
if filename == "." || filename == "" {
filename = "attachment.bin"
}
contentType := input . ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
data , err := base64 . StdEncoding . DecodeString ( input . ContentBase64 )
if err != nil {
return err
}
dir := filepath . Join ( a . cfg . DataDir , "attachments" , messageID )
if err := os . MkdirAll ( dir , 0 o755 ); err != nil {
return err
}
id := newID ( "att" )
path := filepath . Join ( dir , id + "_" + filename )
if err := os . WriteFile ( path , data , 0 o600 ); err != nil {
return err
}
2026-06-24 13:26:53 +08:00
_ , err = db . ExecContext ( ctx , `INSERT INTO attachments(id,message_id,filename,content_type,size_bytes,storage_path,created_at) VALUES(?,?,?,?,?,?,?)` , id , messageID , filename , contentType , len ( data ), path , a . now (). UTC (). Format ( time . RFC3339Nano ))
2026-06-14 01:07:48 +08:00
return err
}
func ( a * App ) attachmentsForMessage ( ctx context . Context , messageID string ) ([] Attachment , error ) {
rows , err := a . db . QueryContext ( ctx , `SELECT id,message_id,filename,content_type,size_bytes,created_at FROM attachments WHERE message_id=? ORDER BY filename` , messageID )
if err != nil {
return nil , err
}
defer rows . Close ()
items := [] Attachment {}
for rows . Next () {
var item Attachment
var created string
if err := rows . Scan ( & item . ID , & item . MessageID , & item . Filename , & item . ContentType , & item . SizeBytes , & created ); err != nil {
return nil , err
}
item . CreatedAt = parseTime ( created )
items = append ( items , item )
}
return items , nil
}
2026-06-15 17:37:40 +08:00
func ( a * App ) labelsForMailbox ( ctx context . Context , mailboxID string ) ([] MailLabel , error ) {
rows , err := a . db . QueryContext ( ctx , `SELECT l.id,l.mailbox_id,l.name,l.color,COUNT(ml.message_id)
FROM mail_labels l LEFT JOIN message_labels ml ON ml.label_id=l.id
WHERE l.mailbox_id=?
GROUP BY l.id,l.mailbox_id,l.name,l.color
ORDER BY lower(l.name)` , mailboxID )
if err != nil {
return nil , err
}
defer rows . Close ()
items := [] MailLabel {}
for rows . Next () {
var item MailLabel
if err := rows . Scan ( & item . ID , & item . MailboxID , & item . Name , & item . Color , & item . MessageCount ); err != nil {
return nil , err
}
items = append ( items , item )
}
return items , rows . Err ()
}
2026-08-02 05:55:53 +08:00
func ( a * App ) labelsForUser ( ctx context . Context , userID string ) ([] MailLabel , error ) {
rows , err := a . db . QueryContext ( ctx , `SELECT l.id,l.mailbox_id,l.name,l.color,COUNT(ml.message_id)
FROM mail_labels l
JOIN mailboxes mb ON mb.id=l.mailbox_id
LEFT JOIN message_labels ml ON ml.label_id=l.id
WHERE mb.user_id=? AND mb.status='active'
GROUP BY l.id,l.mailbox_id,l.name,l.color
ORDER BY lower(l.name)` , userID )
if err != nil {
return nil , err
}
defer rows . Close ()
items := [] MailLabel {}
for rows . Next () {
var item MailLabel
if err := rows . Scan ( & item . ID , & item . MailboxID , & item . Name , & item . Color , & item . MessageCount ); err != nil {
return nil , err
}
items = append ( items , item )
}
return items , rows . Err ()
}
2026-06-15 17:37:40 +08:00
func ( a * App ) labelsForMessage ( ctx context . Context , messageID string ) ([] MailLabel , error ) {
rows , err := a . db . QueryContext ( ctx , `SELECT l.id,l.mailbox_id,l.name,l.color
FROM mail_labels l JOIN message_labels ml ON ml.label_id=l.id
WHERE ml.message_id=?
ORDER BY lower(l.name)` , messageID )
if err != nil {
return nil , err
}
defer rows . Close ()
items := [] MailLabel {}
for rows . Next () {
var item MailLabel
if err := rows . Scan ( & item . ID , & item . MailboxID , & item . Name , & item . Color ); err != nil {
return nil , err
}
items = append ( items , item )
}
return items , rows . Err ()
}
func ( a * App ) attachLabelsToMessages ( ctx context . Context , items [] MailMessage ) error {
if len ( items ) == 0 {
return nil
}
ids := make ([] string , 0 , len ( items ))
index := make ( map [ string ] int , len ( items ))
args := make ([] any , 0 , len ( items ))
for i := range items {
ids = append ( ids , "?" )
index [ items [ i ]. ID ] = i
args = append ( args , items [ i ]. ID )
}
rows , err := a . db . QueryContext ( ctx , `SELECT ml.message_id,l.id,l.mailbox_id,l.name,l.color
FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id
WHERE ml.message_id IN (` + strings . Join ( ids , "," ) + `)
ORDER BY lower(l.name)` , args ... )
if err != nil {
return err
}
defer rows . Close ()
for rows . Next () {
var messageID string
var label MailLabel
if err := rows . Scan ( & messageID , & label . ID , & label . MailboxID , & label . Name , & label . Color ); err != nil {
return err
}
if itemIndex , ok := index [ messageID ]; ok {
items [ itemIndex ]. Labels = append ( items [ itemIndex ]. Labels , label )
}
}
return rows . Err ()
}
func ( a * App ) ensureLabel ( ctx context . Context , mailboxID , name , color string ) ( MailLabel , error ) {
name = normalizeLabelName ( name )
if name == "" {
return MailLabel {}, errors . New ( "label name is required" )
}
color = normalizeLabelColor ( color )
now := a . now (). UTC (). Format ( time . RFC3339Nano )
var existing MailLabel
row := a . db . QueryRowContext ( ctx , `SELECT id,mailbox_id,name,color FROM mail_labels WHERE mailbox_id=? AND lower(name)=lower(?)` , mailboxID , name )
if err := row . Scan ( & existing . ID , & existing . MailboxID , & existing . Name , & existing . Color ); err == nil {
if color != "" && color != existing . Color {
if _ , err := a . db . ExecContext ( ctx , `UPDATE mail_labels SET color=?, updated_at=? WHERE id=?` , color , now , existing . ID ); err != nil {
return MailLabel {}, err
}
existing . Color = color
}
return existing , nil
} else if ! errors . Is ( err , sql . ErrNoRows ) {
return MailLabel {}, err
}
id := newID ( "lbl" )
if color == "" {
color = "#64748b"
}
_ , err := a . db . ExecContext ( ctx , `INSERT INTO mail_labels(id,mailbox_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)` , id , mailboxID , name , color , now , now )
if err != nil {
return MailLabel {}, err
}
return MailLabel { ID : id , MailboxID : mailboxID , Name : name , Color : color }, nil
}
func ( a * App ) labelBelongsToMailbox ( ctx context . Context , labelID , mailboxID string ) bool {
var count int
_ = a . db . QueryRowContext ( ctx , `SELECT COUNT(1) FROM mail_labels WHERE id=? AND mailbox_id=?` , labelID , mailboxID ). Scan ( & count )
return count > 0
}
2026-08-02 05:55:53 +08:00
func ( a * App ) labelBelongsToUser ( ctx context . Context , labelID , userID string ) bool {
var count int
_ = a . db . QueryRowContext ( ctx , `SELECT COUNT(1) FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id WHERE l.id=? AND mb.user_id=?` , labelID , userID ). Scan ( & count )
return count > 0
}
2026-06-15 17:37:40 +08:00
func normalizeLabelName ( name string ) string {
name = strings . Join ( strings . Fields ( strings . TrimSpace ( name )), " " )
if len ([] rune ( name )) > 32 {
name = string ([] rune ( name )[: 32 ])
}
return name
}
func normalizeLabelColor ( color string ) string {
color = strings . TrimSpace ( color )
if len ( color ) != 7 || ! strings . HasPrefix ( color , "#" ) {
return ""
}
for _ , r := range color [ 1 :] {
if !(( r >= '0' && r <= '9' ) || ( r >= 'a' && r <= 'f' ) || ( r >= 'A' && r <= 'F' )) {
return ""
}
}
return strings . ToLower ( color )
}
2026-06-14 01:07:48 +08:00
func ( a * App ) deleteMessageFiles ( ctx context . Context , messageID string ) {
rows , err := a . db . QueryContext ( ctx , `SELECT storage_path FROM attachments WHERE message_id=?` , messageID )
if err != nil {
return
}
defer rows . Close ()
for rows . Next () {
var p string
if rows . Scan ( & p ) == nil {
_ = os . Remove ( p )
}
}
_ = os . RemoveAll ( filepath . Join ( a . cfg . DataDir , "attachments" , messageID ))
}
2026-06-24 10:47:16 +08:00
func ( a * App ) deleteMessage ( ctx context . Context , messageID string ) {
2026-06-24 16:23:45 +08:00
var folderID sql . NullString
_ = a . db . QueryRowContext ( ctx , `SELECT folder_id FROM messages WHERE id=?` , messageID ). Scan ( & folderID )
2026-06-24 15:18:06 +08:00
a . deleteMessageMaildirFile ( ctx , messageID )
2026-06-24 10:47:16 +08:00
a . deleteMessageFiles ( ctx , messageID )
_ , _ = a . db . ExecContext ( ctx , `DELETE FROM messages WHERE id=?` , messageID )
2026-06-24 16:23:45 +08:00
if folderID . Valid && folderID . String != "" {
_ , _ = a . bumpFolderModSeq ( ctx , folderID . String )
}
2026-06-24 10:47:16 +08:00
}
2026-06-14 01:07:48 +08:00
type messageSummaryScanner interface { Scan ( dest ... any ) error }
2026-06-15 00:37:43 +08:00
func scanAdminMessageSummary ( row messageSummaryScanner ) ( MailMessage , error ) {
var msg MailMessage
var toJSON , ccJSON , bccJSON , sent , received string
var read , starred , hasAtt int
2026-06-24 16:23:45 +08:00
err := row . Scan ( & msg . ID , & msg . MailboxID , & msg . MailboxAddress , & msg . OwnerEmail , & msg . RecipientAddr , & msg . FolderID , & msg . Folder , & msg . MessageUID , & msg . IMAPUID , & msg . IMAPModSeq , & msg . MessageID , & msg . Subject , & msg . From , & msg . FromName , & toJSON , & ccJSON , & bccJSON , & sent , & received , & msg . Snippet , & read , & starred , & hasAtt , & msg . SizeBytes )
2026-06-15 00:37:43 +08:00
if err != nil {
return msg , err
}
msg . To , msg . CC , msg . BCC = jsonDecodeSlice ( toJSON ), jsonDecodeSlice ( ccJSON ), jsonDecodeSlice ( bccJSON )
msg . SentAt , msg . ReceivedAt = parseTime ( sent ), parseTime ( received )
msg . IsRead , msg . IsStarred , msg . HasAttachments = intBool ( read ), intBool ( starred ), intBool ( hasAtt )
return msg , nil
}
2026-06-15 17:37:40 +08:00
func scanMessageSummary ( row messageSummaryScanner ) ( MailMessage , error ) {
2026-06-14 01:07:48 +08:00
var msg MailMessage
var toJSON , ccJSON , bccJSON , sent , received string
var read , starred , hasAtt int
2026-06-24 16:23:45 +08:00
err := row . Scan ( & msg . ID , & msg . MailboxID , & msg . FolderID , & msg . Folder , & msg . MessageUID , & msg . IMAPUID , & msg . IMAPModSeq , & msg . MessageID , & msg . Subject , & msg . From , & msg . FromName , & toJSON , & ccJSON , & bccJSON , & sent , & received , & msg . Snippet , & read , & starred , & hasAtt , & msg . SizeBytes )
2026-06-14 01:07:48 +08:00
if err != nil {
return msg , err
}
msg . To , msg . CC , msg . BCC = jsonDecodeSlice ( toJSON ), jsonDecodeSlice ( ccJSON ), jsonDecodeSlice ( bccJSON )
msg . SentAt , msg . ReceivedAt = parseTime ( sent ), parseTime ( received )
msg . IsRead , msg . IsStarred , msg . HasAttachments = intBool ( read ), intBool ( starred ), intBool ( hasAtt )
return msg , nil
}
func scanMessageFull ( row messageSummaryScanner , includeBody bool ) ( MailMessage , error ) {
var msg MailMessage
var toJSON , ccJSON , bccJSON , sent , received string
2026-06-24 17:16:01 +08:00
var auth MailAuthentication
2026-06-14 01:07:48 +08:00
var read , starred , hasAtt int
var bodyText , bodyHTML string
2026-06-24 17:16:01 +08:00
err := row . Scan ( & msg . ID , & msg . MailboxID , & msg . RecipientAddr , & msg . FolderID , & msg . Folder , & msg . MessageUID , & msg . IMAPUID , & msg . IMAPModSeq , & msg . MessageID , & msg . Subject , & msg . From , & msg . FromName , & toJSON , & ccJSON , & bccJSON , & sent , & received , & msg . Snippet , & bodyText , & bodyHTML , & read , & starred , & hasAtt , & msg . SizeBytes , & auth . AuthenticationResults , & auth . SPF , & auth . DKIM , & auth . DMARC , & auth . ReceivedSPF )
2026-06-14 01:07:48 +08:00
if err != nil {
return msg , err
}
msg . To , msg . CC , msg . BCC = jsonDecodeSlice ( toJSON ), jsonDecodeSlice ( ccJSON ), jsonDecodeSlice ( bccJSON )
msg . SentAt , msg . ReceivedAt = parseTime ( sent ), parseTime ( received )
msg . IsRead , msg . IsStarred , msg . HasAttachments = intBool ( read ), intBool ( starred ), intBool ( hasAtt )
2026-06-24 17:16:01 +08:00
msg . Authentication = normalizeMailAuthentication ( auth )
2026-06-14 01:07:48 +08:00
if includeBody {
msg . BodyText , msg . BodyHTML = bodyText , bodyHTML
}
return msg , nil
}
2026-06-24 17:16:01 +08:00
func parseMailAuthentication ( header textproto . MIMEHeader ) MailAuthentication {
authResults := strings . Join ( header . Values ( "Authentication-Results" ), "\n" )
receivedSPF := strings . Join ( header . Values ( "Received-SPF" ), "\n" )
auth := MailAuthentication {
AuthenticationResults : strings . TrimSpace ( authResults ),
ReceivedSPF : strings . TrimSpace ( receivedSPF ),
SPF : "unknown" ,
DKIM : "unknown" ,
DMARC : "unknown" ,
}
for _ , value := range header . Values ( "Authentication-Results" ) {
for _ , field := range strings . FieldsFunc ( value , func ( r rune ) bool {
return r == ';' || r == '\r' || r == '\n'
}) {
key , result , ok := authMethodResult ( field )
if ! ok {
continue
}
switch key {
case "spf" :
auth . SPF = result
case "dkim" :
auth . DKIM = result
case "dmarc" :
auth . DMARC = result
}
}
}
if auth . SPF == "unknown" {
for _ , value := range header . Values ( "Received-SPF" ) {
if result := firstAuthStatus ( value ); result != "unknown" {
auth . SPF = result
break
}
}
}
return normalizeMailAuthentication ( auth )
}
func authMethodResult ( value string ) ( string , string , bool ) {
value = strings . TrimSpace ( value )
if value == "" {
return "" , "" , false
}
parts := strings . SplitN ( value , "=" , 2 )
if len ( parts ) != 2 {
return "" , "" , false
}
method := strings . ToLower ( strings . TrimSpace ( parts [ 0 ]))
if method != "spf" && method != "dkim" && method != "dmarc" {
return "" , "" , false
}
return method , firstAuthStatus ( parts [ 1 ]), true
}
func firstAuthStatus ( value string ) string {
value = strings . TrimSpace ( value )
if value == "" {
return "unknown"
}
value = strings . ToLower ( strings . Fields ( value )[ 0 ])
if idx := strings . IndexAny ( value , "();," ); idx >= 0 {
value = value [: idx ]
}
switch value {
case "pass" , "fail" , "softfail" , "neutral" , "temperror" , "permerror" , "none" :
return value
default :
return "unknown"
}
}
func normalizeMailAuthentication ( auth MailAuthentication ) MailAuthentication {
auth . AuthenticationResults = strings . TrimSpace ( auth . AuthenticationResults )
auth . ReceivedSPF = strings . TrimSpace ( auth . ReceivedSPF )
auth . SPF = normalizeAuthStatus ( auth . SPF )
auth . DKIM = normalizeAuthStatus ( auth . DKIM )
auth . DMARC = normalizeAuthStatus ( auth . DMARC )
return auth
}
func normalizeAuthStatus ( value string ) string {
switch strings . ToLower ( strings . TrimSpace ( value )) {
case "pass" , "fail" , "softfail" , "neutral" , "temperror" , "permerror" , "none" :
return strings . ToLower ( strings . TrimSpace ( value ))
default :
return "unknown"
}
}