2026-06-14 01:07:48 +08:00
package app
import (
"context"
2026-06-15 21:50:44 +08:00
"database/sql"
"encoding/json"
2026-06-14 01:07:48 +08:00
"errors"
"net/http"
2026-08-03 00:16:03 +08:00
"sort"
2026-06-24 17:16:01 +08:00
"strconv"
2026-06-14 01:07:48 +08:00
"strings"
"time"
"github.com/go-chi/chi/v5"
)
2026-06-16 00:51:41 +08:00
type MailboxApplyOptions struct {
Enabled bool `json:"enabled"`
Domains [] Domain `json:"domains"`
ReservedPrefixes [] string `json:"reservedPrefixes,omitempty"`
}
func ( a * App ) handleMailboxApplyOptions ( w http . ResponseWriter , r * http . Request ) {
domains , err := a . mailboxApplyDomains ( r . Context ())
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load domains" )
return
}
respondJSON ( w , http . StatusOK , MailboxApplyOptions {
Enabled : a . cfg . UserMailboxApplyEnabled ,
Domains : domains ,
ReservedPrefixes : parseReservedPrefixes ( a . cfg . ReservedMailboxPrefixes ),
})
}
func ( a * App ) handleApplyMailbox ( w http . ResponseWriter , r * http . Request ) {
if ! a . cfg . UserMailboxApplyEnabled {
2026-06-16 10:40:37 +08:00
respondError ( w , http . StatusForbidden , "当前未开放邮箱申请" )
2026-06-16 00:51:41 +08:00
return
}
user := currentUser ( r )
var req struct {
DomainID string `json:"domainId"`
LocalPart string `json:"localPart"`
DisplayName string `json:"displayName"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
domainID := strings . TrimSpace ( req . DomainID )
if domainID == "" {
2026-06-16 10:40:37 +08:00
badRequest ( w , errors . New ( "请选择域名" ))
2026-06-16 00:51:41 +08:00
return
}
allowed , err := a . mailboxApplyDomainAllowed ( r . Context (), domainID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to check domain" )
return
}
if ! allowed {
2026-06-16 10:40:37 +08:00
respondError ( w , http . StatusForbidden , "该域名不可用" )
2026-06-16 00:51:41 +08:00
return
}
localPart := normalizeLocalPart ( req . LocalPart )
if localPart == "" {
2026-06-16 10:40:37 +08:00
badRequest ( w , errors . New ( "请输入邮箱前缀" ))
2026-06-16 00:51:41 +08:00
return
}
if len ( localPart ) > 64 {
2026-06-16 10:40:37 +08:00
badRequest ( w , errors . New ( "邮箱前缀过长" ))
2026-06-16 00:51:41 +08:00
return
}
reserved := map [ string ] bool {}
for _ , item := range parseReservedPrefixes ( a . cfg . ReservedMailboxPrefixes ) {
reserved [ item ] = true
}
if reserved [ localPart ] {
respondError ( w , http . StatusForbidden , "localPart is reserved" )
return
}
var exists int
if err := a . db . QueryRowContext ( r . Context (), `SELECT COUNT(*) FROM mailboxes WHERE domain_id=? AND local_part=?` , domainID , localPart ). Scan ( & exists ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to check mailbox" )
return
}
if exists > 0 {
2026-06-16 10:40:37 +08:00
respondError ( w , http . StatusConflict , "该邮箱地址已被占用" )
2026-06-16 00:51:41 +08:00
return
}
2026-08-02 15:07:16 +08:00
if user . Role != "admin" && user . Limits . MaxMailboxCount > 0 {
var ownedCount int
if err := a . db . QueryRowContext ( r . Context (), `SELECT COUNT(*) FROM mailboxes WHERE user_id=? AND status='active'` , user . ID ). Scan ( & ownedCount ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to check mailbox quota" )
return
}
if ownedCount >= user . Limits . MaxMailboxCount {
respondError ( w , http . StatusForbidden , "邮箱数量已达上限" )
return
}
}
2026-06-16 00:51:41 +08:00
var passwordHash string
if err := a . db . QueryRowContext ( r . Context (), `SELECT password_hash FROM users WHERE id=? AND disabled=0` , user . ID ). Scan ( & passwordHash ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load user" )
return
}
displayName := strings . TrimSpace ( req . DisplayName )
if len ([] rune ( displayName )) > 80 {
badRequest ( w , errors . New ( "displayName must be at most 80 characters" ))
return
}
mailboxID , err := a . createMailboxWithPasswordHash ( r . Context (), user . ID , domainID , localPart , displayName , passwordHash , 1024 , "active" )
if err != nil {
if strings . Contains ( strings . ToLower ( err . Error ()), "unique" ) {
2026-06-16 10:40:37 +08:00
respondError ( w , http . StatusConflict , "该邮箱地址已被占用" )
2026-06-16 00:51:41 +08:00
return
}
badRequest ( w , err )
return
}
mailbox , err := a . mailboxByID ( r . Context (), mailboxID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load mailbox" )
return
}
respondJSON ( w , http . StatusCreated , mailbox )
}
func ( a * App ) mailboxApplyDomains ( ctx context . Context ) ([] Domain , error ) {
if ! a . cfg . UserMailboxApplyEnabled {
return [] Domain {}, nil
}
ids := cleanIDList ( strings . Split ( a . cfg . UserMailboxDomainIDs , "," ))
if len ( ids ) == 0 {
return [] Domain {}, nil
}
items := make ([] Domain , 0 , len ( ids ))
for _ , id := range ids {
domain , err := a . domainByID ( ctx , id )
if errors . Is ( err , sql . ErrNoRows ) {
continue
}
if err != nil {
return nil , err
}
if domain . Status == "active" {
items = append ( items , * domain )
}
}
return items , nil
}
func ( a * App ) mailboxApplyDomainAllowed ( ctx context . Context , domainID string ) ( bool , error ) {
domains , err := a . mailboxApplyDomains ( ctx )
if err != nil {
return false , err
}
for _ , domain := range domains {
if domain . ID == domainID {
return true , nil
}
}
return false , nil
}
2026-06-14 01:07:48 +08:00
func ( a * App ) handleListContacts ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
rows , err := a . db . QueryContext ( r . Context (), `SELECT id,user_id,name,email,note,created_at FROM contacts WHERE user_id=? ORDER BY name,email` , user . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load contacts" )
return
}
defer rows . Close ()
items := [] Contact {}
for rows . Next () {
item , err := scanContact ( rows )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to scan contacts" )
return
}
items = append ( items , item )
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items })
}
func ( a * App ) handleCreateContact ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
var req struct {
Name string `json:"name"`
Email string `json:"email"`
Note string `json:"note"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
email := normalizeEmail ( req . Email )
if email == "" || ! strings . Contains ( email , "@" ) {
badRequest ( w , errors . New ( "invalid email" ))
return
}
name := strings . TrimSpace ( req . Name )
if name == "" {
name = email
}
id := newID ( "ctc" )
now := a . now (). UTC (). Format ( time . RFC3339Nano )
_ , err := a . db . ExecContext ( r . Context (), `INSERT INTO contacts(id,user_id,name,email,note,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)
ON CONFLICT(user_id,email) DO UPDATE SET name=excluded.name,note=excluded.note,updated_at=excluded.updated_at` ,
id , user . ID , name , email , strings . TrimSpace ( req . Note ), now , now )
if err != nil {
badRequest ( w , err )
return
}
row := a . db . QueryRowContext ( r . Context (), `SELECT id,user_id,name,email,note,created_at FROM contacts WHERE user_id=? AND email=?` , user . ID , email )
item , err := scanContact ( row )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load contact" )
return
}
respondJSON ( w , http . StatusCreated , item )
}
func ( a * App ) handleDeleteContact ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
res , err := a . db . ExecContext ( r . Context (), `DELETE FROM contacts WHERE id=? AND user_id=?` , chi . URLParam ( r , "id" ), user . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to delete contact" )
return
}
if n , _ := res . RowsAffected (); n == 0 {
respondError ( w , http . StatusNotFound , "contact not found" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true })
}
2026-06-16 23:15:35 +08:00
func ( a * App ) handleListSignatures ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
rows , err := a . db . QueryContext ( r . Context (), `SELECT id,user_id,mailbox_id,name,content,is_default,created_at,updated_at FROM mail_signatures WHERE user_id=? ORDER BY is_default DESC, updated_at DESC, created_at DESC` , user . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load signatures" )
return
}
defer rows . Close ()
items := [] MailSignature {}
for rows . Next () {
item , err := scanSignature ( rows )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to scan signatures" )
return
}
items = append ( items , item )
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items })
}
func ( a * App ) handleCreateSignature ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
var req struct {
MailboxID string `json:"mailboxId"`
Name string `json:"name"`
Content string `json:"content"`
IsDefault bool `json:"isDefault"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
mailboxID , name , content , ok := a . normalizeSignatureInput ( w , r , user . ID , req . MailboxID , req . Name , req . Content )
if ! ok {
return
}
id := newID ( "sig" )
now := a . now (). UTC (). Format ( time . RFC3339Nano )
tx , err := a . db . BeginTx ( r . Context (), nil )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to save signature" )
return
}
defer tx . Rollback ()
if req . IsDefault {
if _ , err := tx . ExecContext ( r . Context (), `UPDATE mail_signatures SET is_default=0, updated_at=? WHERE user_id=? AND mailbox_id=?` , now , user . ID , mailboxID ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update default signature" )
return
}
}
if _ , err := tx . ExecContext ( r . Context (), `INSERT INTO mail_signatures(id,user_id,mailbox_id,name,content,is_default,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)` ,
id , user . ID , mailboxID , name , content , boolInt ( req . IsDefault ), now , now ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to save signature" )
return
}
if err := tx . Commit (); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to save signature" )
return
}
item , err := a . signatureByID ( r . Context (), user . ID , id )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load signature" )
return
}
respondJSON ( w , http . StatusCreated , item )
}
func ( a * App ) handleUpdateSignature ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
id := chi . URLParam ( r , "id" )
_ , err := a . signatureByID ( r . Context (), user . ID , id )
if errors . Is ( err , sql . ErrNoRows ) {
respondError ( w , http . StatusNotFound , "signature not found" )
return
}
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load signature" )
return
}
var req struct {
MailboxID string `json:"mailboxId"`
Name string `json:"name"`
Content string `json:"content"`
IsDefault bool `json:"isDefault"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
mailboxID , name , content , ok := a . normalizeSignatureInput ( w , r , user . ID , req . MailboxID , req . Name , req . Content )
if ! ok {
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 update signature" )
return
}
defer tx . Rollback ()
if req . IsDefault {
if _ , err := tx . ExecContext ( r . Context (), `UPDATE mail_signatures SET is_default=0, updated_at=? WHERE user_id=? AND mailbox_id=?` , now , user . ID , mailboxID ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update default signature" )
return
}
}
if _ , err := tx . ExecContext ( r . Context (), `UPDATE mail_signatures SET mailbox_id=?, name=?, content=?, is_default=?, updated_at=? WHERE id=? AND user_id=?` ,
mailboxID , name , content , boolInt ( req . IsDefault ), now , id , user . ID ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update signature" )
return
}
if err := tx . Commit (); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update signature" )
return
}
item , err := a . signatureByID ( r . Context (), user . ID , id )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load signature" )
return
}
respondJSON ( w , http . StatusOK , item )
}
func ( a * App ) handleDeleteSignature ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
res , err := a . db . ExecContext ( r . Context (), `DELETE FROM mail_signatures WHERE id=? AND user_id=?` , chi . URLParam ( r , "id" ), user . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to delete signature" )
return
}
if n , _ := res . RowsAffected (); n == 0 {
respondError ( w , http . StatusNotFound , "signature not found" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true })
}
func ( a * App ) handleSetDefaultSignature ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
id := chi . URLParam ( r , "id" )
item , err := a . signatureByID ( r . Context (), user . ID , id )
if errors . Is ( err , sql . ErrNoRows ) {
respondError ( w , http . StatusNotFound , "signature not found" )
return
}
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load signature" )
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 update signature" )
return
}
defer tx . Rollback ()
if _ , err := tx . ExecContext ( r . Context (), `UPDATE mail_signatures SET is_default=0, updated_at=? WHERE user_id=? AND mailbox_id=?` , now , user . ID , item . MailboxID ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update signature" )
return
}
if _ , err := tx . ExecContext ( r . Context (), `UPDATE mail_signatures SET is_default=1, updated_at=? WHERE id=? AND user_id=?` , now , id , user . ID ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update signature" )
return
}
if err := tx . Commit (); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update signature" )
return
}
item , err = a . signatureByID ( r . Context (), user . ID , id )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load signature" )
return
}
respondJSON ( w , http . StatusOK , item )
}
func ( a * App ) handleDefaultSignature ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
mailboxID := strings . TrimSpace ( r . URL . Query (). Get ( "mailboxId" ))
if mailboxID != "" {
if _ , err := a . mailboxForUserByID ( r . Context (), user . ID , mailboxID ); err != nil {
respondError ( w , http . StatusForbidden , "mailbox not found" )
return
}
}
item , err := a . defaultSignatureForMailbox ( r . Context (), user . ID , mailboxID )
if errors . Is ( err , sql . ErrNoRows ) {
respondJSON ( w , http . StatusOK , map [ string ] any { "signature" : nil })
return
}
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load signature" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "signature" : item })
}
2026-06-14 01:07:48 +08:00
func ( a * App ) handleListRules ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
2026-06-15 21:50:44 +08:00
rows , err := a . db . QueryContext ( r . Context (), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? ORDER BY created_at DESC` , user . ID )
2026-06-14 01:07:48 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load rules" )
return
}
defer rows . Close ()
items := [] MailRule {}
for rows . Next () {
item , err := scanRule ( rows )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to scan rules" )
return
}
items = append ( items , item )
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items })
}
func ( a * App ) handleCreateRule ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
var req struct {
2026-06-15 21:50:44 +08:00
MailboxID string `json:"mailboxId"`
Name string `json:"name"`
MatchMode string `json:"matchMode"`
Conditions [] MailRuleCondition `json:"conditions"`
Actions [] MailRuleAction `json:"actions"`
FromContains string `json:"fromContains"`
SubjectContains string `json:"subjectContains"`
Action string `json:"action"`
ApplyToExisting bool `json:"applyToExisting"`
StopProcessing bool `json:"stopProcessing"`
Enabled * bool `json:"enabled"`
2026-06-14 01:07:48 +08:00
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
mailboxID , ok := a . optionalMailboxIDForUser ( r , req . MailboxID )
if ! ok {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
2026-06-24 17:16:01 +08:00
rawMatchMode := strings . ToLower ( strings . TrimSpace ( req . MatchMode ))
if rawMatchMode != "" && rawMatchMode != "all" && rawMatchMode != "and" && rawMatchMode != "any" && rawMatchMode != "or" {
2026-06-15 21:50:44 +08:00
badRequest ( w , errors . New ( "invalid match mode" ))
2026-06-14 01:07:48 +08:00
return
}
2026-06-24 17:16:01 +08:00
matchMode := normalizeRuleMatchMode ( rawMatchMode )
2026-06-15 21:50:44 +08:00
conditions := normalizeRuleConditions ( req . Conditions , req . FromContains , req . SubjectContains )
if len ( conditions ) == 0 {
2026-06-14 01:07:48 +08:00
badRequest ( w , errors . New ( "rule condition is required" ))
return
}
2026-06-15 21:50:44 +08:00
actions := normalizeRuleActions ( req . Actions , req . Action )
if len ( actions ) == 0 {
badRequest ( w , errors . New ( "rule action is required" ))
return
}
2026-08-02 20:32:34 +08:00
actions , err := a . cleanRuleActions ( r . Context (), user . ID , actions )
if err != nil {
badRequest ( w , err )
return
}
if len ( actions ) == 0 {
badRequest ( w , errors . New ( "rule action is required" ))
return
}
2026-06-15 21:50:44 +08:00
conditionsJSON , err := json . Marshal ( conditions )
if err != nil {
badRequest ( w , err )
return
}
actionsJSON , err := json . Marshal ( actions )
if err != nil {
badRequest ( w , err )
return
}
fromContains := legacyConditionValue ( conditions , "from" )
subjectContains := legacyConditionValue ( conditions , "subject" )
action := actions [ 0 ]. Type
2026-06-14 01:07:48 +08:00
name := strings . TrimSpace ( req . Name )
if name == "" {
name = "收件规则"
}
enabled := true
if req . Enabled != nil {
enabled = * req . Enabled
}
id := newID ( "rule" )
now := a . now (). UTC (). Format ( time . RFC3339Nano )
2026-06-15 21:50:44 +08:00
_ , err = a . db . ExecContext ( r . Context (), `INSERT INTO mail_rules(id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)` , id , user . ID , mailboxID , name , matchMode , string ( conditionsJSON ), string ( actionsJSON ), fromContains , subjectContains , action , boolInt ( req . ApplyToExisting ), boolInt ( req . StopProcessing ), boolInt ( enabled ), now , now )
2026-06-14 01:07:48 +08:00
if err != nil {
badRequest ( w , err )
return
}
2026-06-15 21:50:44 +08:00
appliedCount := int64 ( 0 )
if req . ApplyToExisting && enabled {
appliedCount , _ = a . applyRuleToExistingMessages ( r . Context (), user . ID , mailboxID , MailRule {
ID : id , UserID : user . ID , MailboxID : mailboxID , Name : name , MatchMode : matchMode ,
Conditions : conditions , Actions : actions , ApplyToExisting : req . ApplyToExisting , StopProcessing : req . StopProcessing ,
FromContains : fromContains , SubjectContains : subjectContains , Action : action , Enabled : enabled ,
})
}
row := a . db . QueryRowContext ( r . Context (), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=?` , id )
2026-06-14 01:07:48 +08:00
item , err := scanRule ( row )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load rule" )
return
}
2026-06-15 21:50:44 +08:00
item . AppliedExistingCount = appliedCount
2026-06-14 01:07:48 +08:00
respondJSON ( w , http . StatusCreated , item )
}
func ( a * App ) handleDeleteRule ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
res , err := a . db . ExecContext ( r . Context (), `DELETE FROM mail_rules WHERE id=? AND user_id=?` , chi . URLParam ( r , "id" ), user . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to delete rule" )
return
}
if n , _ := res . RowsAffected (); n == 0 {
respondError ( w , http . StatusNotFound , "rule not found" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true })
}
func ( a * App ) handleListBlockedSenders ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
rows , err := a . db . QueryContext ( r . Context (), `SELECT id,user_id,mailbox_id,email,reason,created_at FROM blocked_senders WHERE user_id=? ORDER BY created_at DESC` , user . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load blocked senders" )
return
}
defer rows . Close ()
items := [] BlockedSender {}
for rows . Next () {
item , err := scanBlockedSender ( rows )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to scan blocked senders" )
return
}
items = append ( items , item )
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items })
}
func ( a * App ) handleCreateBlockedSender ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
var req struct {
MailboxID string `json:"mailboxId"`
Email string `json:"email"`
Reason string `json:"reason"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
mailboxID , ok := a . optionalMailboxIDForUser ( r , req . MailboxID )
if ! ok {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
email := normalizeEmail ( req . Email )
if email == "" || ! strings . Contains ( email , "@" ) {
badRequest ( w , errors . New ( "invalid email" ))
return
}
id := newID ( "blk" )
now := a . now (). UTC (). Format ( time . RFC3339Nano )
_ , err := a . db . ExecContext ( r . Context (), `INSERT INTO blocked_senders(id,user_id,mailbox_id,email,reason,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)
ON CONFLICT(user_id,mailbox_id,email) DO UPDATE SET reason=excluded.reason,updated_at=excluded.updated_at` ,
id , user . ID , mailboxID , email , strings . TrimSpace ( req . Reason ), now , now )
if err != nil {
badRequest ( w , err )
return
}
row := a . db . QueryRowContext ( r . Context (), `SELECT id,user_id,mailbox_id,email,reason,created_at FROM blocked_senders WHERE user_id=? AND mailbox_id=? AND email=?` , user . ID , mailboxID , email )
item , err := scanBlockedSender ( row )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load blocked sender" )
return
}
respondJSON ( w , http . StatusCreated , item )
}
func ( a * App ) handleDeleteBlockedSender ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
res , err := a . db . ExecContext ( r . Context (), `DELETE FROM blocked_senders WHERE id=? AND user_id=?` , chi . URLParam ( r , "id" ), user . ID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to delete blocked sender" )
return
}
if n , _ := res . RowsAffected (); n == 0 {
respondError ( w , http . StatusNotFound , "blocked sender not found" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true })
}
func ( a * App ) handleMailStats ( w http . ResponseWriter , r * http . Request ) {
user := currentUser ( r )
mailboxID := strings . TrimSpace ( r . URL . Query (). Get ( "mailboxId" ))
2026-08-03 00:16:03 +08:00
rangeDays := mailStatsRangeDays ( r . URL . Query (). Get ( "days" ))
2026-06-14 01:07:48 +08:00
args := [] any { user . ID }
where := `mb.user_id=?`
2026-08-02 05:55:53 +08:00
if mailboxID != "" && ! isAllMailboxID ( mailboxID ) {
2026-06-14 01:07:48 +08:00
if _ , err := a . mailboxForCurrentUserWithID ( r , mailboxID ); err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
where += ` AND mb.id=?`
args = append ( args , mailboxID )
}
2026-08-03 00:16:03 +08:00
now := a . now (). UTC ()
stats := MailStats {
ByFolder : [] MailStatsFolderCount {},
Trend : emptyMailStatsTrend ( now , rangeDays ),
Distribution : [] MailStatsDistributionItem {},
TopContacts : [] MailStatsContact {},
}
row := a . db . QueryRowContext ( r . Context (), `SELECT COUNT(m.id),
COALESCE(SUM(CASE WHEN f.role NOT IN ('sent','drafts') THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN f.role='sent' THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN f.role='drafts' THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN m.is_starred=1 THEN 1 ELSE 0 END),0),
COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb
LEFT JOIN messages m ON m.mailbox_id=mb.id
LEFT JOIN folders f ON f.id=m.folder_id
WHERE ` + where , args ... )
if err := row . Scan ( & stats . TotalMessages , & stats . TotalIncoming , & stats . TotalOutgoing , & stats . UnreadMessages , & stats . DraftMessages , & stats . StarredMessages , & stats . StorageBytes ); err != nil {
2026-06-14 01:07:48 +08:00
respondError ( w , http . StatusInternalServerError , "failed to load stats" )
return
}
2026-08-03 00:16:03 +08:00
if stats . TotalMessages > 0 {
stats . AverageMessageBytes = stats . StorageBytes / stats . TotalMessages
}
2026-06-24 17:16:01 +08:00
if err := a . db . QueryRowContext ( r . Context (), `SELECT COUNT(a.id),COALESCE(SUM(a.size_bytes),0) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE ` + where , args ... ). Scan ( & stats . AttachmentCount , & stats . AttachmentBytes ); err != nil {
2026-06-14 01:07:48 +08:00
respondError ( w , http . StatusInternalServerError , "failed to load attachment stats" )
return
}
2026-08-03 00:16:03 +08:00
var attachmentMessageCount int64
if err := a . db . QueryRowContext ( r . Context (), `SELECT COUNT(m.id) FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id WHERE ` + where + ` AND m.has_attachments=1` , args ... ). Scan ( & attachmentMessageCount ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load attachment message stats" )
return
}
todayStart := time . Date ( now . Year (), now . Month (), now . Day (), 0 , 0 , 0 , 0 , time . UTC ). Format ( time . RFC3339Nano )
todayArgs := append ( append ([] any {}, args ... ), todayStart )
if err := a . db . QueryRowContext ( r . Context (), `SELECT COUNT(m.id)
FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id JOIN folders f ON f.id=m.folder_id
WHERE ` + where + ` AND f.role='sent' AND m.sent_at>=?` , todayArgs ... ). Scan ( & stats . TodayOutgoing ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load today stats" )
return
}
if err := a . db . QueryRowContext ( r . Context (), `SELECT COUNT(sq.id)
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id
WHERE ` + where + ` AND sq.status='failed'` , args ... ). Scan ( & stats . FailedSends ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load send queue stats" )
return
}
2026-08-02 05:55:53 +08:00
if mailboxID != "" && ! isAllMailboxID ( mailboxID ) {
2026-06-24 17:16:01 +08:00
var quotaMB int64
if err := a . db . QueryRowContext ( r . Context (), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?` , mailboxID , user . ID ). Scan ( & quotaMB ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load quota" )
return
}
stats . QuotaBytes = quotaMB * 1024 * 1024
if stats . QuotaBytes > 0 {
stats . QuotaUsedPct = float64 ( stats . StorageBytes ) / float64 ( stats . QuotaBytes ) * 100
}
2026-08-03 00:16:03 +08:00
} else {
var quotaMB int64
if err := a . db . QueryRowContext ( r . Context (), `SELECT COALESCE(SUM(mb.quota_mb),0) FROM mailboxes mb WHERE ` + where , args ... ). Scan ( & quotaMB ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load quota" )
return
}
stats . QuotaBytes = quotaMB * 1024 * 1024
if stats . QuotaBytes > 0 {
stats . QuotaUsedPct = float64 ( stats . StorageBytes ) / float64 ( stats . QuotaBytes ) * 100
}
2026-06-24 17:16:01 +08:00
}
2026-06-14 01:07:48 +08:00
rows , err := a . db . QueryContext ( r . Context (), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
WHERE ` + where + ` GROUP BY f.id,f.name,f.role ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END` , args ... )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load folder stats" )
return
}
defer rows . Close ()
for rows . Next () {
var item MailStatsFolderCount
if err := rows . Scan ( & item . Folder , & item . Role , & item . Count , & item . Unread , & item . Bytes ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to scan folder stats" )
return
}
stats . ByFolder = append ( stats . ByFolder , item )
}
2026-08-03 00:16:03 +08:00
if err := rows . Err (); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to scan folder stats" )
return
}
stats . Distribution = mailStatsDistribution ( stats . ByFolder , attachmentMessageCount , stats . StarredMessages )
if err := a . loadMailStatsTrend ( r . Context (), where , args , rangeDays , stats . Trend ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load trend stats" )
return
}
topContacts , err := a . mailStatsTopContacts ( r . Context (), where , args )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load contact stats" )
return
}
stats . TopContacts = topContacts
2026-06-14 01:07:48 +08:00
respondJSON ( w , http . StatusOK , stats )
}
2026-08-03 00:16:03 +08:00
func mailStatsRangeDays ( raw string ) int {
days , err := strconv . Atoi ( strings . TrimSpace ( raw ))
if err != nil || days <= 0 {
return 30
}
switch days {
case 7 , 30 , 90 , 365 :
return days
default :
if days < 7 {
return 7
}
if days > 365 {
return 365
}
return days
}
}
func emptyMailStatsTrend ( now time . Time , days int ) [] MailStatsTrendPoint {
today := time . Date ( now . Year (), now . Month (), now . Day (), 0 , 0 , 0 , 0 , time . UTC )
points := make ([] MailStatsTrendPoint , 0 , days )
for i := days - 1 ; i >= 0 ; i -- {
points = append ( points , MailStatsTrendPoint { Date : today . AddDate ( 0 , 0 , - i ). Format ( "2006-01-02" )})
}
return points
}
func mailStatsDistribution ( rows [] MailStatsFolderCount , attachmentMessages , starred int64 ) [] MailStatsDistributionItem {
roles := map [ string ] int64 {}
for _ , row := range rows {
roles [ strings . ToLower ( row . Role )] += row . Count
}
return [] MailStatsDistributionItem {
{ Key : "inbox" , Label : "收件箱" , Count : roles [ "inbox" ]},
{ Key : "archive" , Label : "已归档" , Count : roles [ "archive" ]},
{ Key : "spam" , Label : "垃圾邮件" , Count : roles [ "spam" ]},
{ Key : "trash" , Label : "已删除" , Count : roles [ "trash" ]},
{ Key : "attachments" , Label : "有附件" , Count : attachmentMessages },
{ Key : "starred" , Label : "已加旗标" , Count : starred },
}
}
func ( a * App ) loadMailStatsTrend ( ctx context . Context , where string , args [] any , days int , trend [] MailStatsTrendPoint ) error {
start := ""
if len ( trend ) > 0 {
start = trend [ 0 ]. Date + "T00:00:00Z"
}
queryArgs := append ( append ([] any {}, args ... ), start )
rows , err := a . db . QueryContext ( ctx , `SELECT substr(CASE WHEN f.role='sent' THEN m.sent_at ELSE m.received_at END, 1, 10),
COALESCE(SUM(CASE WHEN f.role='sent' THEN 0 ELSE 1 END),0),
COALESCE(SUM(CASE WHEN f.role='sent' THEN 1 ELSE 0 END),0)
FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id JOIN folders f ON f.id=m.folder_id
WHERE ` + where + ` AND f.role<>'drafts' AND (CASE WHEN f.role='sent' THEN m.sent_at ELSE m.received_at END)>=?
GROUP BY 1` , queryArgs ... )
if err != nil {
return err
}
defer rows . Close ()
byDate := map [ string ] * MailStatsTrendPoint {}
for i := range trend {
byDate [ trend [ i ]. Date ] = & trend [ i ]
}
for rows . Next () {
var date string
var incoming , outgoing int64
if err := rows . Scan ( & date , & incoming , & outgoing ); err != nil {
return err
}
if point := byDate [ date ]; point != nil {
point . Incoming = incoming
point . Outgoing = outgoing
}
}
return rows . Err ()
}
func ( a * App ) mailStatsTopContacts ( ctx context . Context , where string , args [] any ) ([] MailStatsContact , error ) {
rows , err := a . db . QueryContext ( ctx , `SELECT f.role,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs
FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id JOIN folders f ON f.id=m.folder_id
WHERE ` + where + `
ORDER BY m.received_at DESC LIMIT 2000` , args ... )
if err != nil {
return nil , err
}
defer rows . Close ()
counts := map [ string ] int64 {}
for rows . Next () {
var role , from , toJSON , ccJSON , bccJSON string
if err := rows . Scan ( & role , & from , & toJSON , & ccJSON , & bccJSON ); err != nil {
return nil , err
}
if strings . EqualFold ( role , "sent" ) {
for _ , email := range append ( append ( mailStatsEmailList ( toJSON ), mailStatsEmailList ( ccJSON ) ... ), mailStatsEmailList ( bccJSON ) ... ) {
if email != "" {
counts [ email ] ++
}
}
continue
}
if email := normalizeEmail ( from ); email != "" && strings . Contains ( email , "@" ) {
counts [ email ] ++
}
}
if err := rows . Err (); err != nil {
return nil , err
}
items := make ([] MailStatsContact , 0 , len ( counts ))
for email , count := range counts {
items = append ( items , MailStatsContact { Email : email , Count : count })
}
sort . Slice ( items , func ( i , j int ) bool {
if items [ i ]. Count == items [ j ]. Count {
return items [ i ]. Email < items [ j ]. Email
}
return items [ i ]. Count > items [ j ]. Count
})
if len ( items ) > 10 {
items = items [: 10 ]
}
return items , nil
}
func mailStatsEmailList ( raw string ) [] string {
var values [] string
if err := json . Unmarshal ([] byte ( raw ), & values ); err != nil {
return nil
}
out := make ([] string , 0 , len ( values ))
seen := map [ string ] bool {}
for _ , value := range values {
email := normalizeEmail ( value )
if email == "" || ! strings . Contains ( email , "@" ) || seen [ email ] {
continue
}
seen [ email ] = true
out = append ( out , email )
}
return out
}
2026-06-14 01:07:48 +08:00
func ( a * App ) handleMailCleanup ( w http . ResponseWriter , r * http . Request ) {
var req struct {
MailboxID string `json:"mailboxId"`
Target string `json:"target"`
}
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
}
target := strings . TrimSpace ( req . Target )
affected := int64 ( 0 )
switch target {
case "empty-trash" :
affected , err = a . deleteMessagesInFolder ( r . Context (), mb . ID , "Trash" )
case "empty-spam" :
affected , err = a . deleteMessagesInFolder ( r . Context (), mb . ID , "Spam" )
case "archive-read-inbox" :
affected , err = a . archiveReadInbox ( r . Context (), mb . ID )
default :
badRequest ( w , errors . New ( "invalid cleanup target" ))
return
}
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to cleanup messages" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true , "affected" : affected })
}
func ( a * App ) optionalMailboxIDForUser ( r * http . Request , mailboxID string ) ( string , bool ) {
mailboxID = strings . TrimSpace ( mailboxID )
if mailboxID == "" || mailboxID == "all" {
return "" , true
}
_ , err := a . mailboxForCurrentUserWithID ( r , mailboxID )
return mailboxID , err == nil
}
func ( a * App ) deleteMessagesInFolder ( ctx context . Context , mailboxID , folder string ) ( int64 , error ) {
folderID , err := a . ensureFolder ( ctx , mailboxID , folder )
if err != nil {
return 0 , err
}
rows , err := a . db . QueryContext ( ctx , `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=?` , mailboxID , folderID )
if err != nil {
return 0 , err
}
defer rows . Close ()
ids := [] string {}
for rows . Next () {
var id string
if err := rows . Scan ( & id ); err != nil {
return 0 , err
}
ids = append ( ids , id )
}
2026-06-24 15:18:06 +08:00
if err := rows . Err (); err != nil {
return 0 , err
}
if err := rows . Close (); err != nil {
return 0 , err
}
2026-06-14 01:07:48 +08:00
for _ , id := range ids {
2026-06-24 15:18:06 +08:00
a . deleteMessage ( ctx , id )
2026-06-14 01:07:48 +08:00
}
return int64 ( len ( ids )), nil
}
func ( a * App ) archiveReadInbox ( ctx context . Context , mailboxID string ) ( int64 , error ) {
inboxID , err := a . ensureFolder ( ctx , mailboxID , "Inbox" )
if err != nil {
return 0 , err
}
archiveID , err := a . ensureFolder ( ctx , mailboxID , "Archive" )
if err != nil {
return 0 , err
}
2026-06-24 15:18:06 +08:00
rows , err := a . db . QueryContext ( ctx , `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND is_read=1` , mailboxID , inboxID )
2026-06-14 01:07:48 +08:00
if err != nil {
return 0 , err
}
2026-06-24 15:18:06 +08:00
defer rows . Close ()
var ids [] string
for rows . Next () {
var id string
if err := rows . Scan ( & id ); err != nil {
return 0 , err
}
ids = append ( ids , id )
}
if err := rows . Err (); err != nil {
return 0 , err
}
if err := rows . Close (); err != nil {
return 0 , err
}
for _ , id := range ids {
if err := a . moveMessageMaildir ( ctx , id , archiveID ); err != nil {
return 0 , err
}
}
return int64 ( len ( ids )), nil
2026-06-14 01:07:48 +08:00
}
func scanContact ( row messageSummaryScanner ) ( Contact , error ) {
var item Contact
var created string
err := row . Scan ( & item . ID , & item . UserID , & item . Name , & item . Email , & item . Note , & created )
item . CreatedAt = parseTime ( created )
return item , err
}
2026-06-16 23:15:35 +08:00
func scanSignature ( row messageSummaryScanner ) ( MailSignature , error ) {
var item MailSignature
var isDefault int
var created , updated string
err := row . Scan ( & item . ID , & item . UserID , & item . MailboxID , & item . Name , & item . Content , & isDefault , & created , & updated )
item . IsDefault = intBool ( isDefault )
item . CreatedAt = parseTime ( created )
item . UpdatedAt = parseTime ( updated )
return item , err
}
func ( a * App ) normalizeSignatureInput ( w http . ResponseWriter , r * http . Request , userID , rawMailboxID , rawName , rawContent string ) ( string , string , string , bool ) {
mailboxID := strings . TrimSpace ( rawMailboxID )
if mailboxID != "" {
if _ , err := a . mailboxForUserByID ( r . Context (), userID , mailboxID ); err != nil {
respondError ( w , http . StatusForbidden , "mailbox not found" )
return "" , "" , "" , false
}
}
name := strings . TrimSpace ( rawName )
if name == "" {
badRequest ( w , errors . New ( "signature name is required" ))
return "" , "" , "" , false
}
if len ([] rune ( name )) > 80 {
badRequest ( w , errors . New ( "signature name is too long" ))
return "" , "" , "" , false
}
content := strings . TrimSpace ( rawContent )
if content == "" {
badRequest ( w , errors . New ( "signature content is required" ))
return "" , "" , "" , false
}
if len ([] rune ( content )) > 5000 {
badRequest ( w , errors . New ( "signature content is too long" ))
return "" , "" , "" , false
}
return mailboxID , name , content , true
}
func ( a * App ) signatureByID ( ctx context . Context , userID , id string ) ( MailSignature , error ) {
row := a . db . QueryRowContext ( ctx , `SELECT id,user_id,mailbox_id,name,content,is_default,created_at,updated_at FROM mail_signatures WHERE id=? AND user_id=?` , id , userID )
return scanSignature ( row )
}
func ( a * App ) mailboxForUserByID ( ctx context . Context , userID , mailboxID 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 id=? AND user_id=? AND status='active'` , mailboxID , userID )
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 ) defaultSignatureForMailbox ( ctx context . Context , userID , mailboxID string ) ( MailSignature , error ) {
row := a . db . QueryRowContext ( ctx , `SELECT id,user_id,mailbox_id,name,content,is_default,created_at,updated_at
FROM mail_signatures
WHERE user_id=? AND is_default=1 AND (mailbox_id=? OR mailbox_id='')
ORDER BY CASE WHEN mailbox_id=? THEN 0 ELSE 1 END, updated_at DESC
LIMIT 1` , userID , mailboxID , mailboxID )
return scanSignature ( row )
}
2026-06-14 01:07:48 +08:00
func scanRule ( row messageSummaryScanner ) ( MailRule , error ) {
var item MailRule
2026-06-15 21:50:44 +08:00
var enabled , applyToExisting , stopProcessing int
2026-06-14 01:07:48 +08:00
var created string
2026-06-15 21:50:44 +08:00
var conditionsJSON , actionsJSON string
err := row . Scan ( & item . ID , & item . UserID , & item . MailboxID , & item . Name , & item . MatchMode , & conditionsJSON , & actionsJSON , & item . FromContains , & item . SubjectContains , & item . Action , & applyToExisting , & stopProcessing , & enabled , & created )
if err == nil {
item . Conditions = decodeRuleConditions ( conditionsJSON , item . FromContains , item . SubjectContains )
item . Actions = decodeRuleActions ( actionsJSON , item . Action )
2026-06-24 17:16:01 +08:00
item . MatchMode = normalizeRuleMatchMode ( item . MatchMode )
2026-06-15 21:50:44 +08:00
}
item . ApplyToExisting = intBool ( applyToExisting )
item . StopProcessing = intBool ( stopProcessing )
2026-06-14 01:07:48 +08:00
item . Enabled = intBool ( enabled )
item . CreatedAt = parseTime ( created )
return item , err
}
func scanBlockedSender ( row messageSummaryScanner ) ( BlockedSender , error ) {
var item BlockedSender
var created string
err := row . Scan ( & item . ID , & item . UserID , & item . MailboxID , & item . Email , & item . Reason , & created )
item . CreatedAt = parseTime ( created )
return item , err
}
func ( a * App ) applyInboundControls ( ctx context . Context , messageID , mailboxID , from , subject string ) {
var userID string
if err := a . db . QueryRowContext ( ctx , `SELECT user_id FROM mailboxes WHERE id=?` , mailboxID ). Scan ( & userID ); err != nil {
return
}
2026-06-24 17:16:01 +08:00
if a . senderBlocked ( ctx , userID , mailboxID , from ) {
a . moveBlockedMessageToSpam ( ctx , messageID , mailboxID )
2026-06-14 01:07:48 +08:00
return
}
2026-06-15 21:50:44 +08:00
rows , err := a . db . QueryContext ( ctx , `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at` , userID , mailboxID )
2026-06-14 01:07:48 +08:00
if err != nil {
return
}
2026-06-15 21:50:44 +08:00
rules := [] MailRule {}
2026-06-14 01:07:48 +08:00
for rows . Next () {
2026-06-15 21:50:44 +08:00
rule , err := scanRule ( rows )
if err == nil {
rules = append ( rules , rule )
}
}
rows . Close ()
msg := ruleMessage { ID : messageID , MailboxID : mailboxID , From : from , Subject : subject }
2026-06-24 17:16:01 +08:00
msg , ok := a . ruleMessageByID ( ctx , messageID )
if ! ok {
msg = ruleMessage { ID : messageID , MailboxID : mailboxID , From : from , Subject : subject }
}
2026-06-15 21:50:44 +08:00
for _ , rule := range rules {
if ! ruleMatches ( rule , msg ) {
2026-06-14 01:07:48 +08:00
continue
}
2026-06-15 21:50:44 +08:00
_ = a . applyRuleActions ( ctx , mailboxID , messageID , rule . Actions )
if rule . StopProcessing {
2026-06-24 17:16:01 +08:00
break
2026-06-15 21:50:44 +08:00
}
}
2026-06-24 17:16:01 +08:00
if a . senderBlocked ( ctx , userID , mailboxID , from ) {
a . moveBlockedMessageToSpam ( ctx , messageID , mailboxID )
}
}
func ( a * App ) senderBlocked ( ctx context . Context , userID , mailboxID , from string ) bool {
from = normalizeEmail ( from )
if from == "" {
return false
}
var blocked int
_ = a . db . QueryRowContext ( ctx , `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?` , userID , mailboxID , from ). Scan ( & blocked )
return blocked > 0
}
func ( a * App ) moveBlockedMessageToSpam ( ctx context . Context , messageID , mailboxID string ) {
spamID , err := a . ensureFolder ( ctx , mailboxID , "Spam" )
if err != nil {
return
}
_ = a . moveMessageMaildir ( ctx , messageID , spamID )
2026-06-15 21:50:44 +08:00
}
type ruleMessage struct {
2026-06-24 17:16:01 +08:00
ID string
MailboxID string
From string
To string
CC string
Subject string
Snippet string
BodyText string
AttachmentNames string
SizeBytes int64
ReceivedAt time . Time
}
func ( a * App ) ruleMessageByID ( ctx context . Context , messageID string ) ( ruleMessage , bool ) {
var msg ruleMessage
var toAddrs , ccAddrs , receivedAt string
err := a . db . QueryRowContext ( ctx , `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?` , messageID ).
Scan ( & msg . ID , & msg . MailboxID , & msg . From , & toAddrs , & ccAddrs , & msg . Subject , & msg . Snippet , & msg . BodyText , & msg . SizeBytes , & receivedAt )
if err != nil {
return ruleMessage {}, false
}
msg . To = ruleAddressText ( toAddrs )
msg . CC = ruleAddressText ( ccAddrs )
msg . ReceivedAt = parseTime ( receivedAt )
msg . AttachmentNames = a . ruleAttachmentNames ( ctx , messageID )
return msg , true
}
func ruleAddressText ( raw string ) string {
var items [] string
if strings . TrimSpace ( raw ) != "" && json . Unmarshal ([] byte ( raw ), & items ) == nil {
return strings . Join ( items , " " )
}
return raw
}
func ( a * App ) ruleAttachmentNames ( ctx context . Context , messageID string ) string {
rows , err := a . db . QueryContext ( ctx , `SELECT filename,content_type FROM attachments WHERE message_id=? ORDER BY filename` , messageID )
if err != nil {
return ""
}
defer rows . Close ()
parts := [] string {}
for rows . Next () {
var filename , contentType string
if err := rows . Scan ( & filename , & contentType ); err != nil {
return strings . Join ( parts , " " )
}
parts = append ( parts , filename , contentType )
}
return strings . Join ( parts , " " )
2026-06-15 21:50:44 +08:00
}
func normalizeRuleConditions ( items [] MailRuleCondition , legacyFrom , legacySubject string ) [] MailRuleCondition {
if len ( items ) == 0 {
if strings . TrimSpace ( legacyFrom ) != "" {
items = append ( items , MailRuleCondition { Field : "from" , Operator : "contains" , Value : legacyFrom })
}
if strings . TrimSpace ( legacySubject ) != "" {
items = append ( items , MailRuleCondition { Field : "subject" , Operator : "contains" , Value : legacySubject })
}
}
out := [] MailRuleCondition {}
for _ , item := range items {
2026-06-24 17:16:01 +08:00
if normalized , ok := normalizeRuleCondition ( item ); ok {
out = append ( out , normalized )
2026-06-15 21:50:44 +08:00
}
}
return out
}
2026-06-24 17:16:01 +08:00
func normalizeRuleCondition ( item MailRuleCondition ) ( MailRuleCondition , bool ) {
matchMode := normalizeRuleMatchMode ( item . MatchMode )
if len ( item . Conditions ) > 0 {
children := normalizeRuleConditions ( item . Conditions , "" , "" )
if len ( children ) == 0 {
return MailRuleCondition {}, false
}
return MailRuleCondition { MatchMode : matchMode , Conditions : children }, true
}
field := strings . ToLower ( strings . TrimSpace ( item . Field ))
operator := strings . ToLower ( strings . TrimSpace ( item . Operator ))
value := strings . TrimSpace ( item . Value )
if value == "" {
return MailRuleCondition {}, false
}
switch field {
case "from" , "to" , "cc" , "subject" , "body" , "attachment" , "size" , "date" :
default :
return MailRuleCondition {}, false
}
if operator == "" {
operator = "contains"
}
switch operator {
case "contains" , "not-contains" , "equals" , "not-equals" , "starts-with" , "ends-with" :
case "gt" , "gte" , "lt" , "lte" , "before" , "after" , "on" :
default :
return MailRuleCondition {}, false
}
return MailRuleCondition { Field : field , Operator : operator , Value : value }, true
}
func normalizeRuleMatchMode ( matchMode string ) string {
switch strings . ToLower ( strings . TrimSpace ( matchMode )) {
case "any" , "or" :
return "any"
case "all" , "and" :
return "all"
default :
return "all"
}
}
2026-06-15 21:50:44 +08:00
func normalizeRuleActions ( items [] MailRuleAction , legacyAction string ) [] MailRuleAction {
if len ( items ) == 0 && strings . TrimSpace ( legacyAction ) != "" {
items = append ( items , MailRuleAction { Type : strings . TrimSpace ( legacyAction )})
}
out := [] MailRuleAction {}
for _ , item := range items {
2026-08-02 20:32:34 +08:00
typ := strings . ToLower ( strings . TrimSpace ( item . Type ))
2026-06-15 21:50:44 +08:00
value := strings . TrimSpace ( item . Value )
labelID := strings . TrimSpace ( item . LabelID )
2026-08-02 20:32:34 +08:00
if typ != "archive" && typ != "trash" && typ != "star" && typ != "mark-read" && typ != "label" && typ != "move" && typ != "forward" {
2026-06-14 01:07:48 +08:00
continue
}
2026-06-15 21:50:44 +08:00
if typ == "label" && value == "" && labelID == "" {
2026-06-14 01:07:48 +08:00
continue
}
2026-06-15 21:50:44 +08:00
if typ == "move" && value == "" {
continue
}
2026-08-02 20:32:34 +08:00
if typ == "forward" && value == "" {
continue
}
2026-06-15 21:50:44 +08:00
out = append ( out , MailRuleAction { Type : typ , Value : value , LabelID : labelID })
}
return out
}
2026-08-02 20:32:34 +08:00
func ( a * App ) cleanRuleActions ( ctx context . Context , userID string , actions [] MailRuleAction ) ([] MailRuleAction , error ) {
out := make ([] MailRuleAction , 0 , len ( actions ))
for _ , action := range actions {
if action . Type == "forward" {
targets , err := a . cleanForwardingTargets ( ctx , userID , splitRuleForwardTargets ( action . Value ))
if err != nil {
return nil , err
}
if len ( targets ) == 0 {
continue
}
action . Value = strings . Join ( targets , ", " )
}
out = append ( out , action )
}
return out , nil
}
2026-06-15 21:50:44 +08:00
func legacyConditionValue ( items [] MailRuleCondition , field string ) string {
for _ , item := range items {
if item . Field == field && item . Operator == "contains" {
return item . Value
}
}
return ""
}
func decodeRuleConditions ( raw , legacyFrom , legacySubject string ) [] MailRuleCondition {
var items [] MailRuleCondition
if strings . TrimSpace ( raw ) != "" {
_ = json . Unmarshal ([] byte ( raw ), & items )
}
return normalizeRuleConditions ( items , legacyFrom , legacySubject )
}
func decodeRuleActions ( raw , legacyAction string ) [] MailRuleAction {
var items [] MailRuleAction
if strings . TrimSpace ( raw ) != "" {
_ = json . Unmarshal ([] byte ( raw ), & items )
}
return normalizeRuleActions ( items , legacyAction )
}
func ruleMatches ( rule MailRule , msg ruleMessage ) bool {
conditions := rule . Conditions
if len ( conditions ) == 0 {
conditions = normalizeRuleConditions ( nil , rule . FromContains , rule . SubjectContains )
}
if len ( conditions ) == 0 {
return false
}
2026-06-24 17:16:01 +08:00
matchMode := normalizeRuleMatchMode ( rule . MatchMode )
return ruleConditionsMatch ( conditions , matchMode , msg )
}
func ruleConditionsMatch ( conditions [] MailRuleCondition , matchMode string , msg ruleMessage ) bool {
2026-06-15 21:50:44 +08:00
matched := 0
for _ , condition := range conditions {
if ruleConditionMatches ( condition , msg ) {
matched ++
if matchMode == "any" {
return true
}
} else if matchMode == "all" {
return false
}
}
return matched == len ( conditions )
}
func ruleConditionMatches ( condition MailRuleCondition , msg ruleMessage ) bool {
2026-06-24 17:16:01 +08:00
if len ( condition . Conditions ) > 0 {
return ruleConditionsMatch ( condition . Conditions , normalizeRuleMatchMode ( condition . MatchMode ), msg )
}
2026-06-15 21:50:44 +08:00
var source string
2026-06-24 17:16:01 +08:00
switch strings . ToLower ( strings . TrimSpace ( condition . Field )) {
2026-06-15 21:50:44 +08:00
case "from" :
source = msg . From
case "to" :
source = msg . To
2026-06-24 17:16:01 +08:00
case "cc" :
source = msg . CC
2026-06-15 21:50:44 +08:00
case "subject" :
source = msg . Subject
case "body" :
source = msg . BodyText
if source == "" {
source = msg . Snippet
}
2026-06-24 17:16:01 +08:00
case "attachment" :
source = msg . AttachmentNames
case "size" :
return ruleNumericConditionMatches ( condition , msg . SizeBytes )
case "date" :
return ruleDateConditionMatches ( condition , msg . ReceivedAt )
2026-06-15 21:50:44 +08:00
default :
return false
}
source = strings . ToLower ( source )
value := strings . ToLower ( condition . Value )
2026-06-24 17:16:01 +08:00
switch strings . ToLower ( strings . TrimSpace ( condition . Operator )) {
2026-06-15 21:50:44 +08:00
case "contains" :
return strings . Contains ( source , value )
case "not-contains" :
return ! strings . Contains ( source , value )
case "equals" :
return source == value
case "not-equals" :
return source != value
case "starts-with" :
return strings . HasPrefix ( source , value )
case "ends-with" :
return strings . HasSuffix ( source , value )
default :
return strings . Contains ( source , value )
}
}
2026-06-24 17:16:01 +08:00
func ruleNumericConditionMatches ( condition MailRuleCondition , source int64 ) bool {
value , ok := parseRuleSizeValue ( condition . Value )
if ! ok {
return false
}
switch strings . ToLower ( strings . TrimSpace ( condition . Operator )) {
case "gt" :
return source > value
case "gte" :
return source >= value
case "lt" :
return source < value
case "lte" :
return source <= value
case "equals" :
return source == value
case "not-equals" :
return source != value
default :
return false
}
}
func parseRuleSizeValue ( raw string ) ( int64 , bool ) {
value := strings . ToLower ( strings . TrimSpace ( raw ))
multiplier := int64 ( 1 )
for _ , suffix := range [] struct {
text string
multiplier int64
}{
{ "kb" , 1024 },
{ "k" , 1024 },
{ "mb" , 1024 * 1024 },
{ "m" , 1024 * 1024 },
{ "gb" , 1024 * 1024 * 1024 },
{ "g" , 1024 * 1024 * 1024 },
{ "b" , 1 },
} {
if strings . HasSuffix ( value , suffix . text ) {
multiplier = suffix . multiplier
value = strings . TrimSpace ( strings . TrimSuffix ( value , suffix . text ))
break
}
}
n , err := strconv . ParseInt ( value , 10 , 64 )
if err != nil || n < 0 {
return 0 , false
}
return n * multiplier , true
}
func ruleDateConditionMatches ( condition MailRuleCondition , source time . Time ) bool {
if source . IsZero () {
return false
}
target , ok := parseRuleDateValue ( condition . Value )
if ! ok {
return false
}
source = source . UTC ()
switch strings . ToLower ( strings . TrimSpace ( condition . Operator )) {
case "before" , "lt" :
return source . Before ( target )
case "after" , "gt" :
return source . After ( target )
case "on" , "equals" :
y1 , m1 , d1 := source . Date ()
y2 , m2 , d2 := target . Date ()
return y1 == y2 && m1 == m2 && d1 == d2
case "not-equals" :
y1 , m1 , d1 := source . Date ()
y2 , m2 , d2 := target . Date ()
return y1 != y2 || m1 != m2 || d1 != d2
default :
return false
}
}
func parseRuleDateValue ( raw string ) ( time . Time , bool ) {
value := strings . TrimSpace ( raw )
for _ , layout := range [] string { time . RFC3339Nano , time . RFC3339 , "2006-01-02" } {
if t , err := time . Parse ( layout , value ); err == nil {
return t . UTC (), true
}
}
return time . Time {}, false
}
2026-06-15 21:50:44 +08:00
func ( a * App ) applyRuleActions ( ctx context . Context , mailboxID , messageID string , actions [] MailRuleAction ) error {
now := a . now (). UTC (). Format ( time . RFC3339Nano )
for _ , action := range normalizeRuleActions ( actions , "" ) {
switch action . Type {
2026-06-14 01:07:48 +08:00
case "archive" :
if folderID , err := a . ensureFolder ( ctx , mailboxID , "Archive" ); err == nil {
2026-06-24 15:18:06 +08:00
if err := a . moveMessageMaildir ( ctx , messageID , folderID ); err != nil {
2026-06-15 21:50:44 +08:00
return err
}
2026-06-14 01:07:48 +08:00
}
case "trash" :
if folderID , err := a . ensureFolder ( ctx , mailboxID , "Trash" ); err == nil {
2026-06-24 15:18:06 +08:00
if err := a . moveMessageMaildir ( ctx , messageID , folderID ); err != nil {
2026-06-15 21:50:44 +08:00
return err
}
}
case "move" :
target := ruleTargetFolder ( action . Value )
if folderID , err := a . ensureFolder ( ctx , mailboxID , target ); err == nil {
2026-06-24 15:18:06 +08:00
if err := a . moveMessageMaildir ( ctx , messageID , folderID ); err != nil {
2026-06-15 21:50:44 +08:00
return err
}
2026-06-14 01:07:48 +08:00
}
case "star" :
2026-06-24 15:27:30 +08:00
starred := true
if err := a . updateMessageMaildirFlags ( ctx , messageID , nil , & starred ); err != nil {
return err
}
2026-06-24 16:23:45 +08:00
modSeq , err := a . updateMessageModSeq ( ctx , messageID , "" )
if err != nil {
return err
}
if _ , err := a . db . ExecContext ( ctx , `UPDATE messages SET is_starred=1, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?` , modSeq , modSeq , now , messageID ); err != nil {
2026-06-15 21:50:44 +08:00
return err
}
2026-06-14 01:07:48 +08:00
case "mark-read" :
2026-06-24 15:27:30 +08:00
read := true
if err := a . updateMessageMaildirFlags ( ctx , messageID , & read , nil ); err != nil {
return err
}
2026-06-24 16:23:45 +08:00
modSeq , err := a . updateMessageModSeq ( ctx , messageID , "" )
if err != nil {
return err
}
if _ , err := a . db . ExecContext ( ctx , `UPDATE messages SET is_read=1, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?` , modSeq , modSeq , now , messageID ); err != nil {
2026-06-15 21:50:44 +08:00
return err
}
case "label" :
if err := a . applyRuleLabel ( ctx , mailboxID , messageID , action ); err != nil {
return err
}
2026-08-02 20:32:34 +08:00
case "forward" :
if err := a . processRuleForwarding ( ctx , messageID , mailboxID , action ); err != nil {
return err
}
2026-06-15 21:50:44 +08:00
}
}
return nil
}
func ( a * App ) applyRuleLabel ( ctx context . Context , mailboxID , messageID string , action MailRuleAction ) error {
var label MailLabel
var err error
if action . LabelID != "" {
var count int
_ = a . db . QueryRowContext ( ctx , `SELECT COUNT(1) FROM mail_labels WHERE id=? AND mailbox_id=?` , action . LabelID , mailboxID ). Scan ( & count )
if count > 0 {
label . ID = action . LabelID
}
}
if label . ID == "" {
name := strings . TrimSpace ( action . Value )
if name == "" {
name = "规则标签"
}
label , err = a . ensureLabel ( ctx , mailboxID , name , "" )
if err != nil {
return err
}
}
_ , err = a . db . ExecContext ( ctx , `INSERT OR IGNORE INTO message_labels(message_id,label_id,created_at) VALUES(?,?,?)` , messageID , label . ID , a . now (). UTC (). Format ( time . RFC3339Nano ))
return err
}
func ruleTargetFolder ( value string ) string {
switch strings . ToLower ( strings . TrimSpace ( value )) {
case "inbox" :
return "Inbox"
case "archive" :
return "Archive"
case "spam" :
return "Spam"
case "trash" :
return "Trash"
default :
return "Archive"
}
}
func ( a * App ) applyRuleToExistingMessages ( ctx context . Context , userID , mailboxID string , rule MailRule ) ( int64 , error ) {
args := [] any { userID }
where := `mb.user_id=?`
if mailboxID != "" {
where += ` AND m.mailbox_id=?`
args = append ( args , mailboxID )
}
2026-06-24 17:16:01 +08:00
rows , err := a . db . QueryContext ( ctx , `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE ` + where , args ... )
2026-06-15 21:50:44 +08:00
if err != nil {
return 0 , err
}
messages := [] ruleMessage {}
var count int64
for rows . Next () {
2026-06-24 17:16:01 +08:00
var messageID string
if err := rows . Scan ( & messageID ); err != nil {
2026-06-15 21:50:44 +08:00
return count , err
}
2026-06-24 17:16:01 +08:00
msg , ok := a . ruleMessageByID ( ctx , messageID )
if ! ok {
continue
}
2026-06-15 21:50:44 +08:00
if ! ruleMatches ( rule , msg ) {
continue
}
messages = append ( messages , msg )
}
if err := rows . Err (); err != nil {
return count , err
}
rows . Close ()
for _ , msg := range messages {
if err := a . applyRuleActions ( ctx , msg . MailboxID , msg . ID , rule . Actions ); err != nil {
return count , err
2026-06-14 01:07:48 +08:00
}
2026-06-15 21:50:44 +08:00
count ++
2026-06-14 01:07:48 +08:00
}
2026-06-15 21:50:44 +08:00
return count , nil
2026-06-14 01:07:48 +08:00
}