2026-06-14 01:07:48 +08:00
package app
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
type AttachmentInput struct {
Filename string `json:"filename"`
ContentType string `json:"contentType"`
ContentBase64 string `json:"contentBase64"`
}
type storedMessage struct {
2026-06-15 00:37:43 +08:00
MailboxID string
FolderID string
RecipientAddr string
MessageUID string
MessageID string
Subject string
From 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
2026-06-14 01:07:48 +08:00
}
func ( a * App ) handleMyMailboxes ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
rows , err := a . db . QueryContext ( r . Context (), `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at
FROM mailboxes WHERE user_id=? AND status='active' ORDER BY address` , user . ID )
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
if err := rows . Scan ( & m . ID , & m . UserID , & m . DomainID , & m . LocalPart , & m . Address , & m . DisplayName , & m . QuotaMB , & m . Status , & created ); err != nil {
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 ) {
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,
COUNT(m.id) AS total
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role
ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END, f.name` , mb . ID )
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 ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to scan folders" )
return
}
items = append ( items , f )
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items })
}
func ( a * App ) handleMailMessages ( w http . ResponseWriter , r * http . Request ) {
mb , err := a . mailboxForCurrentUser ( r )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
folder := r . URL . Query (). Get ( "folder" )
if folder == "" {
folder = "Inbox"
}
folderID , err := a . ensureFolder ( r . Context (), mb . ID , folder )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load folder" )
return
}
q := strings . TrimSpace ( r . URL . Query (). Get ( "q" ))
offset , _ := strconv . Atoi ( r . URL . Query (). Get ( "cursor" ))
if offset < 0 {
offset = 0
}
limit := 30
args := [] any { mb . ID , folderID }
where := `mailbox_id=? AND folder_id=?`
if q != "" {
where += ` AND (subject LIKE ? OR from_addr LIKE ? OR snippet LIKE ? OR body_text LIKE ?)`
like := "%" + q + "%"
args = append ( args , like , like , like , like )
}
args = append ( args , limit + 1 , offset )
query := `SELECT id,mailbox_id,folder_id,message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,is_read,is_starred,has_attachments,size_bytes
FROM messages WHERE ` + where + ` ORDER BY received_at DESC LIMIT ? OFFSET ?`
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 () {
msg , err := scanMessageSummary ( rows , folder )
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 )
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items , "nextCursor" : next })
}
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
}
_ , _ = a . db . ExecContext ( r . Context (), `UPDATE messages SET is_read=1, updated_at=? WHERE id=?` , a . now (). UTC (). Format ( time . RFC3339Nano ), msg . ID )
msg . IsRead = true
respondJSON ( w , http . StatusOK , msg )
}
func ( a * App ) handleMailSend ( 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"`
}
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
}
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 {
badRequest ( w , errors . New ( "at least one recipient is required" ))
return
}
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 ()
messageID := fmt . Sprintf ( "<%s@%s>" , newID ( "msg" ), strings . Split ( mb . Address , "@" )[ 1 ])
mimeBytes , err := BuildMIME ( MIMEMessage {
From : mb . Address , 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 ,
})
if err != nil {
badRequest ( w , err )
return
}
if a . cfg . SMTPHost != "" {
if err := a . sendSMTP ( mb . Address , allRecipients , mimeBytes ); err != nil {
a . log . Warn ( "smtp delivery failed; keeping local sent copy" , "error" , err )
}
}
sentFolderID , err := a . ensureFolder ( r . Context (), mb . ID , "Sent" )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load sent folder" )
return
}
base := storedMessage { MailboxID : mb . ID , FolderID : sentFolderID , MessageUID : newID ( "uid" ), MessageID : messageID , Subject : req . Subject , From : mb . Address , 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 }
sentID , err := a . insertMessage ( r . Context (), base , req . Attachments )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to store sent message" )
return
}
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 {
rcptMailbox , err := a . mailboxByAddress ( r . Context (), rcpt )
if err != nil {
2026-06-15 00:37:43 +08:00
if ! a . cfg . CatchAllEnabled || ! a . isLocalDomainAddress ( r . Context (), rcpt ) {
continue
}
copyMsg := base
copyMsg . MailboxID = ""
copyMsg . FolderID = ""
copyMsg . RecipientAddr = normalizeEmail ( rcpt )
copyMsg . MessageUID = newID ( "uid" )
copyMsg . IsRead = false
_ , _ = a . insertMessage ( r . Context (), copyMsg , req . Attachments )
continue
}
if rcptMailbox . Status != "active" {
if a . cfg . CatchAllEnabled && a . isLocalDomainAddress ( r . Context (), rcpt ) {
copyMsg := base
copyMsg . MailboxID = ""
copyMsg . FolderID = ""
copyMsg . RecipientAddr = normalizeEmail ( rcpt )
copyMsg . MessageUID = newID ( "uid" )
copyMsg . IsRead = false
_ , _ = a . insertMessage ( r . Context (), copyMsg , req . Attachments )
}
2026-06-14 01:07:48 +08:00
continue
}
inboxID , err := a . ensureFolder ( r . Context (), rcptMailbox . ID , "Inbox" )
if err != nil {
continue
}
copyMsg := base
copyMsg . MailboxID = rcptMailbox . ID
copyMsg . FolderID = inboxID
copyMsg . MessageUID = newID ( "uid" )
copyMsg . IsRead = false
if inboxMsgID , err := a . insertMessage ( r . Context (), copyMsg , req . Attachments ); err == nil {
a . applyInboundControls ( r . Context (), inboxMsgID , rcptMailbox . ID , copyMsg . From , copyMsg . Subject )
}
}
msg , _ := a . messageByID ( r . Context (), sentID , true )
respondJSON ( w , http . StatusCreated , msg )
}
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
}
_ , err = a . db . ExecContext ( r . Context (), `UPDATE messages SET is_read=?, updated_at=? WHERE id=?` , boolInt ( read ), a . now (). UTC (). Format ( time . RFC3339Nano ), msg . ID )
if err != 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
}
_ , err = a . db . ExecContext ( r . Context (), `UPDATE messages SET is_starred=?, updated_at=? WHERE id=?` , boolInt ( starred ), a . now (). UTC (). Format ( time . RFC3339Nano ), msg . ID )
if err != 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
}
folderID , err := a . ensureFolder ( r . Context (), msg . MailboxID , req . Folder )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load folder" )
return
}
_ , err = a . db . ExecContext ( r . Context (), `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?` , folderID , a . now (). UTC (). Format ( time . RFC3339Nano ), msg . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to move message" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true })
}
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" ) {
a . deleteMessageFiles ( r . Context (), msg . ID )
_ , err = a . db . ExecContext ( r . Context (), `DELETE FROM messages WHERE id=?` , msg . ID )
} else {
trashID , e := a . ensureFolder ( r . Context (), msg . MailboxID , "Trash" )
if e != nil {
err = e
} else {
_ , err = a . db . ExecContext ( r . Context (), `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?` , trashID , a . now (). UTC (). Format ( time . RFC3339Nano ), msg . ID )
}
}
if 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" )
row := a . db . QueryRowContext ( r . Context (), `SELECT filename,content_type,size_bytes,storage_path FROM attachments WHERE id=?` , attID )
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-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-15 00:37:43 +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.message_id,m.subject,m.from_addr,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
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
}
return & msg , nil
}
func ( a * App ) insertMessage ( ctx context . Context , msg storedMessage , attachments [] AttachmentInput ) ( string , error ) {
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-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
}
recipientAddr := normalizeEmail ( msg . RecipientAddr )
_ , err := a . db . ExecContext ( ctx , `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)` , id , mailboxID , folderID , recipientAddr , msg . MessageUID , msg . MessageID , msg . Subject , msg . From , jsonEncode ( msg . To ), jsonEncode ( msg . CC ), jsonEncode ( msg . BCC ), msg . SentAt . Format ( time . RFC3339Nano ), msg . ReceivedAt . Format ( time . RFC3339Nano ), msg . Snippet , msg . BodyText , msg . BodyHTML , boolInt ( msg . IsRead ), boolInt ( msg . IsStarred ), boolInt ( hasAttachments ), size , msg . RawPath , now , now )
2026-06-14 01:07:48 +08:00
if err != nil {
return "" , err
}
for _ , att := range attachments {
if err := a . storeAttachment ( ctx , id , att ); err != nil {
return "" , err
}
}
return id , nil
}
func ( a * App ) storeAttachment ( ctx context . Context , messageID string , input AttachmentInput ) error {
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
}
_ , err = a . 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 ))
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
}
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 ))
}
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
err := row . Scan ( & msg . ID , & msg . MailboxID , & msg . MailboxAddress , & msg . OwnerEmail , & msg . RecipientAddr , & msg . FolderID , & msg . Folder , & msg . MessageUID , & msg . MessageID , & msg . Subject , & msg . From , & toJSON , & ccJSON , & bccJSON , & sent , & received , & msg . Snippet , & read , & starred , & hasAtt , & msg . SizeBytes )
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-14 01:07:48 +08:00
func scanMessageSummary ( row messageSummaryScanner , folder string ) ( MailMessage , error ) {
var msg MailMessage
var toJSON , ccJSON , bccJSON , sent , received string
var read , starred , hasAtt int
err := row . Scan ( & msg . ID , & msg . MailboxID , & msg . FolderID , & msg . MessageUID , & msg . MessageID , & msg . Subject , & msg . From , & toJSON , & ccJSON , & bccJSON , & sent , & received , & msg . Snippet , & read , & starred , & hasAtt , & msg . SizeBytes )
if err != nil {
return msg , err
}
msg . Folder = folder
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
var read , starred , hasAtt int
var bodyText , bodyHTML string
2026-06-15 00:37:43 +08:00
err := row . Scan ( & msg . ID , & msg . MailboxID , & msg . RecipientAddr , & msg . FolderID , & msg . Folder , & msg . MessageUID , & msg . MessageID , & msg . Subject , & msg . From , & toJSON , & ccJSON , & bccJSON , & sent , & received , & msg . Snippet , & bodyText , & bodyHTML , & 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 )
if includeBody {
msg . BodyText , msg . BodyHTML = bodyText , bodyHTML
}
return msg , nil
}