2026-06-29 15:31:20 +08:00
package app
import (
"context"
2026-07-10 10:47:58 +08:00
"crypto/sha256"
2026-06-29 15:31:20 +08:00
"database/sql"
2026-07-10 10:47:58 +08:00
"encoding/base64"
"encoding/hex"
"encoding/json"
2026-06-29 15:31:20 +08:00
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt"
)
2026-06-29 16:52:07 +08:00
func ( a * App ) handleOpenAPIListDomains ( w http . ResponseWriter , r * http . Request ) {
2026-07-10 10:47:58 +08:00
limit := parseOpenAPILimit ( r , 50 , 100 )
sortValue , cursorID , err := parseOpenAPIListCursor ( r . URL . Query (). Get ( "cursor" ))
if err != nil {
badRequest ( w , err )
return
}
rows , err := a . db . QueryContext ( r . Context (), `SELECT id,name,status,dkim_selector,dkim_public_key,dns_status,dns_checked_at,created_at FROM domains
WHERE (?='' OR name>? OR (name=? AND id>?)) ORDER BY name,id LIMIT ?` , sortValue , sortValue , sortValue , cursorID , limit + 1 )
2026-06-29 15:31:20 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to list domains" )
return
}
defer rows . Close ()
items := [] Domain {}
for rows . Next () {
item , err := scanDomain ( rows )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to scan domains" )
return
}
items = append ( items , item )
}
if err := rows . Err (); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to list domains" )
return
}
2026-07-10 10:47:58 +08:00
next := ""
if len ( items ) > limit {
items = items [: limit ]
last := items [ len ( items ) - 1 ]
next = encodeOpenAPIListCursor ( last . Name , last . ID )
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items , "nextCursor" : next })
2026-06-29 15:31:20 +08:00
}
2026-06-29 16:52:07 +08:00
func ( a * App ) handleOpenAPICreateDomain ( w http . ResponseWriter , r * http . Request ) {
2026-06-29 15:31:20 +08:00
var req struct {
Name string `json:"name"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
id , err := a . createDomainTx ( r . Context (), nil , req . Name )
if err != nil {
badRequest ( w , err )
return
}
domain , err := a . domainByID ( r . Context (), id )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load domain" )
return
}
respondJSON ( w , http . StatusCreated , domain )
}
2026-06-29 16:52:07 +08:00
func ( a * App ) handleOpenAPIGetDomain ( w http . ResponseWriter , r * http . Request ) {
2026-06-29 15:31:20 +08:00
domain , err := a . domainByID ( r . Context (), chi . URLParam ( r , "id" ))
if err != nil {
respondError ( w , http . StatusNotFound , "domain not found" )
return
}
respondJSON ( w , http . StatusOK , domain )
}
2026-06-29 16:52:07 +08:00
func ( a * App ) handleOpenAPIUpdateDomain ( w http . ResponseWriter , r * http . Request ) {
2026-06-29 15:31:20 +08:00
var req struct {
Status string `json:"status"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
status := strings . TrimSpace ( req . Status )
if status != "active" && status != "disabled" {
badRequest ( w , errors . New ( "invalid status" ))
return
}
id := chi . URLParam ( r , "id" )
res , err := a . db . ExecContext ( r . Context (), `UPDATE domains SET status=?, updated_at=? WHERE id=?` , status , a . now (). UTC (). Format ( time . RFC3339Nano ), id )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update domain" )
return
}
if affected , _ := res . RowsAffected (); affected == 0 {
respondError ( w , http . StatusNotFound , "domain not found" )
return
}
domain , err := a . domainByID ( r . Context (), id )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load domain" )
return
}
respondJSON ( w , http . StatusOK , domain )
}
2026-06-29 16:52:07 +08:00
func ( a * App ) handleOpenAPIDeleteDomain ( w http . ResponseWriter , r * http . Request ) {
2026-06-29 15:31:20 +08:00
id := chi . URLParam ( r , "id" )
var count int
if err := a . db . QueryRowContext ( r . Context (), `SELECT COUNT(*) FROM mailboxes WHERE domain_id=?` , id ). Scan ( & count ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to check domain" )
return
}
if count > 0 {
badRequest ( w , errors . New ( "domain still has mailboxes" ))
return
}
res , err := a . db . ExecContext ( r . Context (), `DELETE FROM domains WHERE id=?` , id )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to delete domain" )
return
}
if affected , _ := res . RowsAffected (); affected == 0 {
respondError ( w , http . StatusNotFound , "domain not found" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true })
}
2026-06-29 16:52:07 +08:00
func ( a * App ) handleOpenAPIListMailboxes ( w http . ResponseWriter , r * http . Request ) {
2026-07-10 10:47:58 +08:00
limit := parseOpenAPILimit ( r , 50 , 100 )
sortValue , cursorID , err := parseOpenAPIListCursor ( r . URL . Query (). Get ( "cursor" ))
if err != nil {
badRequest ( w , err )
return
}
2026-06-29 15:31:20 +08:00
rows , err := a . db . QueryContext ( r . Context (), `SELECT mb.id,mb.user_id,u.email,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
2026-07-10 10:47:58 +08:00
FROM mailboxes mb JOIN users u ON u.id=mb.user_id
WHERE (?='' OR mb.address>? OR (mb.address=? AND mb.id>?)) ORDER BY mb.address,mb.id LIMIT ?` , sortValue , sortValue , sortValue , cursorID , limit + 1 )
2026-06-29 15:31:20 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to list mailboxes" )
return
}
defer rows . Close ()
items := [] Mailbox {}
for rows . Next () {
item , err := scanMailbox ( rows )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to scan mailboxes" )
return
}
items = append ( items , item )
}
if err := rows . Err (); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to list mailboxes" )
return
}
2026-07-10 10:47:58 +08:00
next := ""
if len ( items ) > limit {
items = items [: limit ]
last := items [ len ( items ) - 1 ]
next = encodeOpenAPIListCursor ( last . Address , last . ID )
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items , "nextCursor" : next })
2026-06-29 15:31:20 +08:00
}
2026-06-29 16:52:07 +08:00
func ( a * App ) handleOpenAPICreateMailbox ( w http . ResponseWriter , r * http . Request ) {
2026-06-29 15:31:20 +08:00
var req struct {
DomainID string `json:"domainId"`
LocalPart string `json:"localPart"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
QuotaMB int `json:"quotaMb"`
OwnerEmail string `json:"ownerEmail"`
UserID string `json:"userId"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
if err := requireString ( "domainId" , req . DomainID ); err != nil {
badRequest ( w , err )
return
}
if err := requireString ( "localPart" , req . LocalPart ); err != nil {
badRequest ( w , err )
return
}
2026-08-03 22:39:51 +08:00
if ! hasMinimumPasswordLength ( req . Password ) {
badRequest ( w , errors . New ( "password must be at least 6 characters" ))
2026-06-29 15:31:20 +08:00
return
}
domain , err := a . domainByID ( r . Context (), req . DomainID )
if err != nil {
respondError ( w , http . StatusNotFound , "domain not found" )
return
}
localPart := normalizeLocalPart ( req . LocalPart )
if localPart == "" {
badRequest ( w , errors . New ( "localPart is required" ))
return
}
address := localPart + "@" + domain . Name
displayName := strings . TrimSpace ( req . DisplayName )
if displayName == "" {
displayName = address
}
2026-07-10 10:47:58 +08:00
passwordHash , err := bcrypt . GenerateFromPassword ([] byte ( req . Password ), bcrypt . DefaultCost )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to hash password" )
return
}
tx , err := a . db . BeginTx ( r . Context (), nil )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to start transaction" )
return
}
defer tx . Rollback ()
userID , err := a . resolveMailboxOwnerTx ( r . Context (), tx , req . UserID , req . OwnerEmail , address , displayName , string ( passwordHash ))
2026-06-29 15:31:20 +08:00
if err != nil {
respondMailboxOwnerError ( w , err )
return
}
2026-07-10 10:47:58 +08:00
mailboxID , err := a . createMailboxWithPasswordHashTx ( r . Context (), tx , userID , req . DomainID , localPart , displayName , string ( passwordHash ), req . QuotaMB , "active" )
2026-06-29 15:31:20 +08:00
if err != nil {
badRequest ( w , err )
return
}
2026-07-10 10:47:58 +08:00
if err := tx . Commit (); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to create mailbox" )
return
}
2026-06-29 15:31:20 +08:00
mailbox , err := a . mailboxByID ( r . Context (), mailboxID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load mailbox" )
return
}
respondJSON ( w , http . StatusCreated , mailbox )
}
2026-06-29 16:52:07 +08:00
func ( a * App ) handleOpenAPIGetMailbox ( w http . ResponseWriter , r * http . Request ) {
2026-06-29 15:31:20 +08:00
mailbox , err := a . mailboxByID ( r . Context (), chi . URLParam ( r , "id" ))
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
respondJSON ( w , http . StatusOK , mailbox )
}
2026-06-29 16:52:07 +08:00
func ( a * App ) handleOpenAPIUpdateMailbox ( w http . ResponseWriter , r * http . Request ) {
2026-06-29 15:31:20 +08:00
id := chi . URLParam ( r , "id" )
current , err := a . mailboxByID ( r . Context (), id )
if err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
var req struct {
DisplayName string `json:"displayName"`
QuotaMB int `json:"quotaMb"`
Status string `json:"status"`
UserID string `json:"userId"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
displayName := strings . TrimSpace ( req . DisplayName )
if displayName == "" {
displayName = current . DisplayName
}
quotaMB := req . QuotaMB
if quotaMB <= 0 {
quotaMB = current . QuotaMB
}
status := strings . TrimSpace ( req . Status )
if status == "" {
status = current . Status
}
if status != "active" && status != "disabled" {
badRequest ( w , errors . New ( "invalid status" ))
return
}
userID := strings . TrimSpace ( req . UserID )
if userID == "" {
userID = current . UserID
}
if err := a . ensureActiveUserExists ( r . Context (), userID ); err != nil {
respondMailboxOwnerError ( w , err )
return
}
res , err := a . db . ExecContext ( r . Context (), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?` ,
userID , displayName , quotaMB , status , a . now (). UTC (). Format ( time . RFC3339Nano ), id )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to update mailbox" )
return
}
if affected , _ := res . RowsAffected (); affected == 0 {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
mailbox , err := a . mailboxByID ( r . Context (), id )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load mailbox" )
return
}
respondJSON ( w , http . StatusOK , mailbox )
}
2026-06-29 16:52:07 +08:00
func ( a * App ) handleOpenAPIDeleteMailbox ( w http . ResponseWriter , r * http . Request ) {
2026-06-29 15:31:20 +08:00
id := chi . URLParam ( r , "id" )
rows , err := a . db . QueryContext ( r . Context (), `SELECT id FROM messages WHERE mailbox_id=?` , id )
if err != nil {
2026-08-04 20:57:41 +08:00
respondError ( w , http . StatusInternalServerError , "加载邮箱邮件失败" )
2026-06-29 15:31:20 +08:00
return
}
messageIDs := [] string {}
for rows . Next () {
var messageID string
if rows . Scan ( & messageID ) == nil {
messageIDs = append ( messageIDs , messageID )
}
}
rows . Close ()
for _ , messageID := range messageIDs {
a . deleteMessage ( r . Context (), messageID )
}
res , err := a . db . ExecContext ( r . Context (), `DELETE FROM mailboxes WHERE id=?` , id )
if err != nil {
2026-08-04 20:57:41 +08:00
respondError ( w , http . StatusInternalServerError , "删除邮箱失败" )
2026-06-29 15:31:20 +08:00
return
}
if affected , _ := res . RowsAffected (); affected == 0 {
2026-08-04 20:57:41 +08:00
respondError ( w , http . StatusNotFound , "邮箱不存在或已被删除" )
2026-06-29 15:31:20 +08:00
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true })
}
2026-07-10 10:47:58 +08:00
func ( a * App ) handleOpenAPIResetMailboxPassword ( w http . ResponseWriter , r * http . Request ) {
var req struct {
Password string `json:"password"`
}
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
2026-08-03 22:39:51 +08:00
if ! hasMinimumPasswordLength ( req . Password ) {
badRequest ( w , errors . New ( "password must be at least 6 characters" ))
2026-07-10 10:47:58 +08:00
return
}
var userID string
if err := a . db . QueryRowContext ( r . Context (), `SELECT user_id FROM mailboxes WHERE id=?` , chi . URLParam ( r , "id" )). Scan ( & userID ); err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
hash , err := bcrypt . GenerateFromPassword ([] byte ( req . Password ), bcrypt . DefaultCost )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to hash password" )
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 start transaction" )
return
}
defer tx . Rollback ()
if _ , err := tx . ExecContext ( r . Context (), `UPDATE users SET password_hash=?,updated_at=? WHERE id=?` , string ( hash ), now , userID ); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to reset password" )
return
}
res , err := tx . ExecContext ( r . Context (), `UPDATE mailboxes SET password_hash=?,updated_at=? WHERE user_id=?` , string ( hash ), now , userID )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to reset mailbox passwords" )
return
}
if err := tx . Commit (); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to save password" )
return
}
affected , _ := res . RowsAffected ()
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true , "affectedMailboxes" : affected })
}
2026-06-29 16:52:07 +08:00
func ( a * App ) handleOpenAPISendMail ( w http . ResponseWriter , r * http . Request ) {
2026-06-29 15:31:20 +08:00
var req mailComposeInput
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-07-10 10:47:58 +08:00
idempotencyKey := strings . TrimSpace ( r . Header . Get ( "Idempotency-Key" ))
requestJSON , _ := json . Marshal ( req )
requestSum := sha256 . Sum256 ( requestJSON )
requestHash := hex . EncodeToString ( requestSum [:])
if idempotencyKey != "" {
if len ( idempotencyKey ) > 128 || strings . ContainsAny ( idempotencyKey , "\r\n" ) {
badRequest ( w , errors . New ( "invalid Idempotency-Key" ))
return
}
status , replayed , err := a . reserveOpenAPISendIdempotency ( r . Context (), currentUser ( r ). ID , idempotencyKey , requestHash )
if err != nil {
respondError ( w , http . StatusConflict , err . Error ())
return
}
if replayed {
w . Header (). Set ( "Idempotency-Replayed" , "true" )
respondJSON ( w , http . StatusOK , status )
return
}
}
2026-06-29 16:52:07 +08:00
msg , err := a . sendMailWithSource ( r . Context (), currentUser ( r ), mb , req , sendSourceOpenAPI )
2026-06-29 15:31:20 +08:00
if err != nil {
2026-07-10 10:47:58 +08:00
if idempotencyKey != "" {
_ , _ = a . db . ExecContext ( r . Context (), `DELETE FROM send_idempotency_keys WHERE user_id=? AND idempotency_key=? AND sent_message_id=''` , currentUser ( r ). ID , idempotencyKey )
}
2026-06-29 15:31:20 +08:00
respondSendError ( w , err )
return
}
2026-06-29 16:52:07 +08:00
status := openAPISendStatusFromMessage ( msg , mb . Address )
2026-06-29 15:31:20 +08:00
if msg . SendQueueID != "" {
if item , err := a . loadSendQueueEntryForUser ( r . Context (), msg . SendQueueID , mb . UserID ); err == nil {
2026-06-29 16:52:07 +08:00
status = openAPISendStatusFromQueue ( item , mb . Address )
2026-06-29 15:31:20 +08:00
}
} else {
item , err := a . loadLatestSendQueueForMailboxMessage ( r . Context (), msg . ID , mb . ID )
if err == nil {
2026-06-29 16:52:07 +08:00
status = openAPISendStatusFromQueue ( item , mb . Address )
2026-06-29 15:31:20 +08:00
}
}
2026-07-10 10:47:58 +08:00
a . applyDeliveryStatus ( r . Context (), & status )
if idempotencyKey != "" {
_ , _ = a . db . ExecContext ( r . Context (), `UPDATE send_idempotency_keys SET sent_message_id=?,queue_id=? WHERE user_id=? AND idempotency_key=?` , status . MessageID , status . QueueID , currentUser ( r ). ID , idempotencyKey )
}
2026-06-29 15:31:20 +08:00
respondJSON ( w , http . StatusCreated , status )
}
2026-06-29 16:52:07 +08:00
func ( a * App ) handleOpenAPISendStatus ( w http . ResponseWriter , r * http . Request ) {
2026-06-29 15:31:20 +08:00
user := currentUser ( r )
id := strings . TrimSpace ( chi . URLParam ( r , "id" ))
item , err := a . loadSendQueueEntryForUser ( r . Context (), id , user . ID )
if err != nil {
item , err = a . loadSendQueueEntryForSentMessage ( r . Context (), id , user . ID )
}
if err == nil {
mailboxAddress := ""
if mb , mbErr := a . mailboxByID ( r . Context (), item . MailboxID ); mbErr == nil {
mailboxAddress = mb . Address
}
2026-07-10 10:47:58 +08:00
status := openAPISendStatusFromQueue ( item , mailboxAddress )
a . applyDeliveryStatus ( r . Context (), & status )
respondJSON ( w , http . StatusOK , status )
2026-06-29 15:31:20 +08:00
return
}
2026-06-29 16:52:07 +08:00
msg , err := a . loadOpenAPISentMessageForUser ( r . Context (), id , user . ID )
2026-06-29 15:31:20 +08:00
if err != nil {
respondError ( w , http . StatusNotFound , "send item not found" )
return
}
mailboxAddress := ""
if mb , mbErr := a . mailboxByID ( r . Context (), msg . MailboxID ); mbErr == nil {
mailboxAddress = mb . Address
}
2026-06-29 16:52:07 +08:00
respondJSON ( w , http . StatusOK , openAPISendStatusFromMessage ( msg , mailboxAddress ))
2026-06-29 15:31:20 +08:00
}
2026-06-29 16:52:07 +08:00
func ( a * App ) handleOpenAPIMailboxMessages ( w http . ResponseWriter , r * http . Request ) {
2026-06-29 15:31:20 +08:00
user := currentUser ( r )
mailboxID := strings . TrimSpace ( chi . URLParam ( r , "id" ))
if _ , err := a . mailboxForUserByID ( r . Context (), user . ID , mailboxID ); err != nil {
respondError ( w , http . StatusNotFound , "mailbox not found" )
return
}
2026-06-29 16:52:07 +08:00
limit := parseOpenAPILimit ( r , 30 , 100 )
2026-07-10 10:47:58 +08:00
cursorReceivedAt , cursorID , offset , err := parseOpenAPIMessageCursor ( r . URL . Query (). Get ( "cursor" ))
if err != nil {
badRequest ( w , err )
return
}
2026-06-29 15:31:20 +08:00
folder := strings . TrimSpace ( r . URL . Query (). Get ( "folder" ))
if folder == "" {
folder = "Inbox"
}
where := "m.mailbox_id=?"
args := [] any { mailboxID }
if folder != "" && ! strings . EqualFold ( folder , "all" ) {
where += " AND lower(f.name)=lower(?)"
args = append ( args , folder )
}
if q := strings . TrimSpace ( r . URL . Query (). Get ( "q" )); q != "" {
where += " AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.to_addrs LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)"
like := "%" + q + "%"
args = append ( args , like , like , like , like , like , like )
}
2026-07-10 10:47:58 +08:00
if cursorReceivedAt != "" {
where += " AND (m.received_at<? OR (m.received_at=? AND m.id<?))"
args = append ( args , cursorReceivedAt , cursorReceivedAt , cursorID )
}
args = append ( args , limit + 1 )
query := `SELECT m.id,m.mailbox_id,m.folder_id,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-29 15:31:20 +08:00
FROM messages m JOIN folders f ON f.id=m.folder_id
2026-07-10 10:47:58 +08:00
WHERE ` + where + `
ORDER BY m.received_at DESC,m.id DESC LIMIT ?`
if offset > 0 {
query += " OFFSET ?"
args = append ( args , offset )
}
rows , err := a . db . QueryContext ( r . Context (), query , args ... )
2026-06-29 15:31:20 +08:00
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load messages" )
return
}
defer rows . Close ()
items := [] MailMessage {}
for rows . Next () {
item , err := scanMessageSummary ( rows )
if err != nil {
respondError ( w , http . StatusInternalServerError , "failed to scan messages" )
return
}
items = append ( items , item )
}
if err := rows . Err (); err != nil {
respondError ( w , http . StatusInternalServerError , "failed to load messages" )
return
}
nextCursor := ""
if len ( items ) > limit {
items = items [: limit ]
2026-07-10 10:47:58 +08:00
last := items [ len ( items ) - 1 ]
nextCursor = encodeOpenAPIMessageCursor ( last . ReceivedAt , last . ID )
2026-06-29 15:31:20 +08:00
}
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : items , "nextCursor" : nextCursor })
}
type domainScanner interface { Scan ( dest ... any ) error }
func scanDomain ( row domainScanner ) ( Domain , error ) {
var item Domain
var checked sql . NullString
var created string
err := row . Scan ( & item . ID , & item . Name , & item . Status , & item . DKIMSelector , & item . DKIMPublicKey , & item . DNSStatus , & checked , & created )
if err != nil {
return item , err
}
item . DNSCheckedAt = nullableTime ( checked )
item . CreatedAt = parseTime ( created )
return item , nil
}
type mailboxScanner interface { Scan ( dest ... any ) error }
func scanMailbox ( row mailboxScanner ) ( Mailbox , error ) {
var item Mailbox
var created string
err := row . Scan ( & item . ID , & item . UserID , & item . UserEmail , & item . DomainID , & item . LocalPart , & item . Address , & item . DisplayName , & item . QuotaMB , & item . Status , & created )
if err != nil {
return item , err
}
item . CreatedAt = parseTime ( created )
return item , nil
}
2026-06-29 16:52:07 +08:00
type openAPISendStatus struct {
2026-07-10 10:47:58 +08:00
ID string `json:"id"`
QueueID string `json:"queueId,omitempty"`
Status string `json:"status"`
QueueStatus string `json:"queueStatus,omitempty"`
MessageID string `json:"messageId"`
RFCMessageID string `json:"rfcMessageId"`
MailboxID string `json:"mailboxId"`
MailboxAddress string `json:"mailboxAddress,omitempty"`
Subject string `json:"subject,omitempty"`
Recipients [] string `json:"recipients,omitempty"`
AttemptCount int `json:"attemptCount,omitempty"`
MaxAttempts int `json:"maxAttempts,omitempty"`
NextAttemptAt * time . Time `json:"nextAttemptAt,omitempty"`
LastError string `json:"lastError,omitempty"`
CreatedAt time . Time `json:"createdAt"`
UpdatedAt * time . Time `json:"updatedAt,omitempty"`
DeliveredAt * time . Time `json:"deliveredAt,omitempty"`
RecipientStatuses [] openAPIRecipientStatus `json:"recipientStatuses,omitempty"`
}
type openAPIRecipientStatus struct {
Recipient string `json:"recipient"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Provider string `json:"provider,omitempty"`
OccurredAt time . Time `json:"occurredAt"`
2026-06-29 15:31:20 +08:00
}
2026-06-29 16:52:07 +08:00
func openAPISendStatusFromQueue ( item SendQueueEntry , mailboxAddress string ) openAPISendStatus {
2026-07-10 10:47:58 +08:00
status := item . Status
if status == sendQueueStatusDelivered {
status = "relayed"
}
2026-06-29 16:52:07 +08:00
return openAPISendStatus {
2026-07-10 10:47:58 +08:00
ID : firstNonEmpty ( item . SentMessageID , item . ID ),
2026-06-29 15:31:20 +08:00
QueueID : item . ID ,
2026-07-10 10:47:58 +08:00
Status : status ,
QueueStatus : item . Status ,
2026-06-29 15:31:20 +08:00
MessageID : item . SentMessageID ,
RFCMessageID : item . MessageID ,
MailboxID : item . MailboxID ,
MailboxAddress : mailboxAddress ,
Subject : item . Subject ,
Recipients : item . Recipients ,
AttemptCount : item . AttemptCount ,
MaxAttempts : item . MaxAttempts ,
NextAttemptAt : timePtr ( item . NextAttemptAt ),
LastError : item . LastError ,
CreatedAt : item . CreatedAt ,
UpdatedAt : timePtr ( item . UpdatedAt ),
DeliveredAt : item . DeliveredAt ,
}
}
2026-07-10 10:47:58 +08:00
func ( a * App ) reserveOpenAPISendIdempotency ( ctx context . Context , userID , key , requestHash string ) ( openAPISendStatus , bool , error ) {
_ , _ = a . db . ExecContext ( ctx , `DELETE FROM send_idempotency_keys WHERE created_at<?` , a . now (). UTC (). Add ( - 24 * time . Hour ). Format ( time . RFC3339Nano ))
res , err := a . db . ExecContext ( ctx , `INSERT OR IGNORE INTO send_idempotency_keys(user_id,idempotency_key,request_hash,created_at) VALUES(?,?,?,?)` , userID , key , requestHash , a . now (). UTC (). Format ( time . RFC3339Nano ))
if err != nil {
return openAPISendStatus {}, false , err
}
if n , _ := res . RowsAffected (); n > 0 {
return openAPISendStatus {}, false , nil
}
var storedHash , sentMessageID , queueID string
if err := a . db . QueryRowContext ( ctx , `SELECT request_hash,sent_message_id,queue_id FROM send_idempotency_keys WHERE user_id=? AND idempotency_key=?` , userID , key ). Scan ( & storedHash , & sentMessageID , & queueID ); err != nil {
return openAPISendStatus {}, false , err
}
if storedHash != requestHash {
return openAPISendStatus {}, false , errors . New ( "Idempotency-Key was already used with a different request" )
}
if sentMessageID == "" {
return openAPISendStatus {}, false , errors . New ( "a request with this Idempotency-Key is still processing" )
}
item , err := a . loadSendQueueEntryForUser ( ctx , queueID , userID )
if err != nil {
return openAPISendStatus {}, false , err
}
status := openAPISendStatusFromQueue ( item , item . MailFrom )
a . applyDeliveryStatus ( ctx , & status )
return status , true , nil
}
func ( a * App ) applyDeliveryStatus ( ctx context . Context , status * openAPISendStatus ) {
rows , err := a . db . QueryContext ( ctx , `SELECT recipient,status,reason,provider,occurred_at FROM delivery_events
WHERE sent_message_id=? ORDER BY occurred_at DESC,id DESC` , status . MessageID )
if err != nil {
return
}
defer rows . Close ()
seen := map [ string ] bool {}
counts := map [ string ] int {}
for rows . Next () {
var item openAPIRecipientStatus
var occurredAt string
if rows . Scan ( & item . Recipient , & item . Status , & item . Reason , & item . Provider , & occurredAt ) != nil || seen [ item . Recipient ] {
continue
}
seen [ item . Recipient ] = true
item . OccurredAt = parseTime ( occurredAt )
status . RecipientStatuses = append ( status . RecipientStatuses , item )
counts [ item . Status ] ++
}
if len ( status . RecipientStatuses ) == 0 {
return
}
if len ( status . RecipientStatuses ) < len ( status . Recipients ) || len ( counts ) > 1 {
status . Status = "partial"
return
}
for _ , value := range [] string { "complained" , "bounced" , "rejected" , "deferred" , "delivered" } {
if counts [ value ] > 0 {
status . Status = value
return
}
}
}
func firstNonEmpty ( values ... string ) string {
for _ , value := range values {
if strings . TrimSpace ( value ) != "" {
return value
}
}
return ""
}
type openAPIMessageCursor struct {
ReceivedAt string `json:"receivedAt"`
ID string `json:"id"`
}
type openAPIListCursor struct {
Sort string `json:"sort"`
ID string `json:"id"`
}
func encodeOpenAPIMessageCursor ( receivedAt time . Time , id string ) string {
payload , _ := json . Marshal ( openAPIMessageCursor { ReceivedAt : receivedAt . UTC (). Format ( time . RFC3339Nano ), ID : id })
return base64 . RawURLEncoding . EncodeToString ( payload )
}
func parseOpenAPIMessageCursor ( raw string ) ( receivedAt , 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 , err := base64 . RawURLEncoding . DecodeString ( raw )
if err != nil {
return "" , "" , 0 , errors . New ( "invalid cursor" )
}
var cursor openAPIMessageCursor
if err := json . Unmarshal ( data , & cursor ); err != nil || cursor . ReceivedAt == "" || cursor . ID == "" {
return "" , "" , 0 , errors . New ( "invalid cursor" )
}
if _ , err := time . Parse ( time . RFC3339Nano , cursor . ReceivedAt ); err != nil {
return "" , "" , 0 , errors . New ( "invalid cursor" )
}
return cursor . ReceivedAt , cursor . ID , 0 , nil
}
func encodeOpenAPIListCursor ( sortValue , id string ) string {
payload , _ := json . Marshal ( openAPIListCursor { Sort : sortValue , ID : id })
return base64 . RawURLEncoding . EncodeToString ( payload )
}
func parseOpenAPIListCursor ( raw string ) ( sortValue , id string , err error ) {
raw = strings . TrimSpace ( raw )
if raw == "" {
return "" , "" , nil
}
data , err := base64 . RawURLEncoding . DecodeString ( raw )
if err != nil {
return "" , "" , errors . New ( "invalid cursor" )
}
var cursor openAPIListCursor
if err := json . Unmarshal ( data , & cursor ); err != nil || cursor . Sort == "" || cursor . ID == "" {
return "" , "" , errors . New ( "invalid cursor" )
}
return cursor . Sort , cursor . ID , nil
}
2026-06-29 16:52:07 +08:00
func openAPISendStatusFromMessage ( msg * MailMessage , mailboxAddress string ) openAPISendStatus {
2026-06-29 15:31:20 +08:00
recipients := append ( append ([] string {}, msg . To ... ), msg . CC ... )
recipients = append ( recipients , msg . BCC ... )
2026-06-29 16:52:07 +08:00
return openAPISendStatus {
2026-06-29 15:31:20 +08:00
ID : msg . ID ,
Status : sendAuditAccepted ,
MessageID : msg . ID ,
RFCMessageID : msg . MessageID ,
MailboxID : msg . MailboxID ,
MailboxAddress : mailboxAddress ,
Subject : msg . Subject ,
Recipients : dedupeEmails ( recipients ),
CreatedAt : msg . ReceivedAt ,
}
}
func timePtr ( t time . Time ) * time . Time {
if t . IsZero () {
return nil
}
return & t
}
2026-07-10 10:47:58 +08:00
func ( a * App ) resolveMailboxOwnerTx ( ctx context . Context , tx * sql . Tx , userID , ownerEmail , address , displayName , passwordHash string ) ( string , error ) {
2026-06-29 15:31:20 +08:00
userID = strings . TrimSpace ( userID )
if userID != "" {
2026-07-10 10:47:58 +08:00
var disabled int
if err := tx . QueryRowContext ( ctx , `SELECT disabled FROM users WHERE id=?` , userID ). Scan ( & disabled ); err != nil {
if errors . Is ( err , sql . ErrNoRows ) {
return "" , errNotFound
}
2026-06-29 15:31:20 +08:00
return "" , err
}
2026-07-10 10:47:58 +08:00
if intBool ( disabled ) {
return "" , errors . New ( "owner user is disabled" )
}
2026-06-29 15:31:20 +08:00
return userID , nil
}
email := normalizeEmail ( ownerEmail )
if email == "" {
email = address
}
if ! strings . Contains ( email , "@" ) {
return "" , errors . New ( "invalid owner email" )
}
var existing string
2026-08-02 16:56:04 +08:00
err := tx . QueryRowContext ( ctx , `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0` , email , email ). Scan ( & existing )
2026-06-29 15:31:20 +08:00
if err == nil {
return existing , nil
}
if ! errors . Is ( err , sql . ErrNoRows ) {
return "" , err
}
2026-07-10 10:47:58 +08:00
userID = newID ( "usr" )
2026-06-29 15:31:20 +08:00
now := a . now (). UTC (). Format ( time . RFC3339Nano )
if displayName == "" {
displayName = email
}
2026-08-02 16:56:04 +08:00
_ , err = tx . ExecContext ( ctx , `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)` , userID , email , email , displayName , "user" , passwordHash , 0 , now , now )
2026-07-10 10:47:58 +08:00
return userID , err
2026-06-29 15:31:20 +08:00
}
func ( a * App ) ensureActiveUserExists ( ctx context . Context , userID string ) error {
var disabled int
if err := a . db . QueryRowContext ( ctx , `SELECT disabled FROM users WHERE id=?` , userID ). Scan ( & disabled ); err != nil {
if errors . Is ( err , sql . ErrNoRows ) {
return errNotFound
}
return err
}
if intBool ( disabled ) {
return errors . New ( "owner user is disabled" )
}
return nil
}
func respondMailboxOwnerError ( w http . ResponseWriter , err error ) {
if errors . Is ( err , errNotFound ) {
respondError ( w , http . StatusNotFound , "owner user not found" )
return
}
if err != nil {
badRequest ( w , err )
return
}
}
func respondSendError ( w http . ResponseWriter , err error ) {
switch {
case errors . Is ( err , errNoRecipients ), errors . Is ( err , errInvalidMIME ), errors . Is ( err , errAttachmentTooLarge ):
badRequest ( w , err )
case errors . Is ( err , errSMTPRateLimited ):
respondError ( w , http . StatusTooManyRequests , err . Error ())
case errors . Is ( err , errSenderNotAuthorized ):
respondError ( w , http . StatusForbidden , err . Error ())
case errors . Is ( err , errMailboxQuotaExceeded ):
respondError ( w , http . StatusInsufficientStorage , err . Error ())
default :
respondError ( w , http . StatusInternalServerError , err . Error ())
}
}
func ( a * App ) loadLatestSendQueueForMessage ( ctx context . Context , sentMessageID , 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.sent_message_id=? AND mb.user_id=? ORDER BY sq.created_at DESC, sq.id DESC LIMIT 1` , sentMessageID , userID )
return scanSendQueueEntry ( row )
}
func ( a * App ) loadLatestSendQueueForMailboxMessage ( ctx context . Context , sentMessageID , mailboxID 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 LEFT JOIN messages m ON m.id=sq.sent_message_id
WHERE sq.sent_message_id=? AND sq.mailbox_id=? ORDER BY sq.created_at DESC, sq.id DESC LIMIT 1` , sentMessageID , mailboxID )
return scanSendQueueEntry ( row )
}
func ( a * App ) loadSendQueueEntryForSentMessage ( ctx context . Context , sentMessageID , userID string ) ( SendQueueEntry , error ) {
return a . loadLatestSendQueueForMessage ( ctx , sentMessageID , userID )
}
2026-06-29 16:52:07 +08:00
func ( a * App ) loadOpenAPISentMessageForUser ( ctx context . Context , id , userID string ) ( * MailMessage , error ) {
2026-06-29 15:31:20 +08:00
var messageID string
err := a . db . QueryRowContext ( ctx , `SELECT m.id
FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id JOIN folders f ON f.id=m.folder_id
WHERE (m.id=? OR m.message_id=?) AND mb.user_id=? AND lower(f.name)='sent'
ORDER BY m.received_at DESC LIMIT 1` , id , id , userID ). Scan ( & messageID )
if err != nil {
return nil , err
}
return a . messageByID ( ctx , messageID , false )
}
2026-06-29 16:52:07 +08:00
func parseOpenAPILimit ( r * http . Request , defaultLimit , maxLimit int ) int {
2026-06-29 15:31:20 +08:00
limit , err := strconv . Atoi ( r . URL . Query (). Get ( "limit" ))
if err != nil || limit <= 0 {
return defaultLimit
}
if limit > maxLimit {
return maxLimit
}
return limit
}