2026-08-12 21:14:19 +01:00
package db
import (
"database/sql"
"errors"
2026-08-14 13:04:55 +01:00
"strings"
2026-08-12 21:14:19 +01:00
"time"
)
// InsertMessage records a stored message's index row (the ciphertext itself already
// lives at storagePath — see internal/mailstore). Returns the new row's id, which
// doubles as the IMAP UID in later milestones.
2026-08-15 12:35:44 +01:00
func ( d * DB ) InsertMessage ( mailboxID int64 , folder , messageIDHeader , flags string , internalDate time . Time , sizeBytes int64 , storagePath string , nonce [] byte , cachedFrom , cachedTo , cachedSubject , cachedPreview string ) ( int64 , error ) {
2026-08-12 21:14:19 +01:00
res , err := d . Exec ( `INSERT INTO esrv_mailbox_messages
2026-08-15 12:35:44 +01:00
(mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, storage_path, nonce, cached_from, cached_to, cached_subject, cached_preview)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ,
mailboxID , folder , messageIDHeader , flags , internalDate , sizeBytes , storagePath , nonce , cachedFrom , cachedTo , cachedSubject , cachedPreview )
2026-08-12 21:14:19 +01:00
if err != nil {
return 0 , err
}
return res . LastInsertId ()
}
2026-08-15 12:35:44 +01:00
const mailboxMessageColumns = `id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_to, cached_subject, cached_preview, storage_path, nonce, created_at`
2026-08-14 13:04:55 +01:00
func scanMailboxMessage ( scan func ( dest ... any ) error ) ( MailboxMessage , error ) {
2026-08-12 21:14:19 +01:00
var m MailboxMessage
var internalDate , createdAt string
2026-08-15 12:35:44 +01:00
err := scan ( & m . ID , & m . MailboxID , & m . Folder , & m . MessageIDHeader , & m . Flags , & internalDate , & m . SizeBytes , & m . CachedFrom , & m . CachedTo , & m . CachedSubject , & m . CachedPreview , & m . StoragePath , & m . Nonce , & createdAt )
2026-08-14 13:04:55 +01:00
if err != nil {
return m , err
}
m . InternalDate , _ = parseTime ( internalDate )
m . CreatedAt , _ = parseTime ( createdAt )
return m , nil
}
func ( d * DB ) GetMessageByUID ( mailboxID , uid int64 ) ( * MailboxMessage , error ) {
row := d . QueryRow ( `SELECT ` + mailboxMessageColumns + ` FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?` , uid , mailboxID )
m , err := scanMailboxMessage ( row . Scan )
if err != nil {
2026-08-12 21:14:19 +01:00
if errors . Is ( err , sql . ErrNoRows ) {
return nil , nil
}
return nil , err
}
return & m , nil
}
func ( d * DB ) DeleteMessage ( mailboxID , uid int64 ) error {
_ , err := d . Exec ( `DELETE FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?` , uid , mailboxID )
return err
}
2026-08-14 13:04:55 +01:00
// MoveMessage reassigns a message to a different folder — pure metadata change, the
// on-disk ciphertext at storage_path never moves.
func ( d * DB ) MoveMessage ( mailboxID , uid int64 , newFolder string ) error {
_ , err := d . Exec ( `UPDATE esrv_mailbox_messages SET folder = ? WHERE id = ? AND mailbox_id = ?` , newFolder , uid , mailboxID )
return err
}
2026-08-15 16:31:27 +01:00
// MoveMessageToTrash moves a message to Trash, first capturing its current folder
// into restore_folder so RestoreMessage can put it back later (mirrors
// db.MoveFolderToTrash's identical capture for folders).
func ( d * DB ) MoveMessageToTrash ( mailboxID , uid int64 ) error {
var folder string
if err := d . QueryRow ( `SELECT folder FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?` , uid , mailboxID ). Scan ( & folder ); err != nil {
return err
}
_ , err := d . Exec ( `UPDATE esrv_mailbox_messages SET restore_folder = ?, folder = 'Trash' WHERE id = ? AND mailbox_id = ?` , folder , uid , mailboxID )
return err
}
// RestoreMessage moves a message back to the folder it was in immediately before
// being moved to Trash (see MoveMessageToTrash), or INBOX if that's unknown (e.g. a
// message trashed before this feature existed, or moved to Trash some other way).
func ( d * DB ) RestoreMessage ( mailboxID , uid int64 ) error {
var restoreFolder string
if err := d . QueryRow ( `SELECT restore_folder FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?` , uid , mailboxID ). Scan ( & restoreFolder ); err != nil {
return err
}
if restoreFolder == "" {
restoreFolder = "INBOX"
}
_ , err := d . Exec ( `UPDATE esrv_mailbox_messages SET folder = ?, restore_folder = '' WHERE id = ? AND mailbox_id = ?` , restoreFolder , uid , mailboxID )
return err
}
2026-08-12 21:14:19 +01:00
// ListMessageUIDsForMailbox returns every stored message's UID for mailboxID — used by
// mailbox removal to delete each one's on-disk ciphertext via mailstore before the
// mailbox row itself is removed.
func ( d * DB ) ListMessageUIDsForMailbox ( mailboxID int64 ) ([] int64 , error ) {
rows , err := d . Query ( `SELECT id FROM esrv_mailbox_messages WHERE mailbox_id = ? ORDER BY id ASC` , mailboxID )
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] int64
for rows . Next () {
var id int64
if err := rows . Scan ( & id ); err != nil {
return nil , err
}
out = append ( out , id )
}
return out , rows . Err ()
}
2026-08-14 13:04:55 +01:00
func scanMailboxMessages ( rows * sql . Rows ) ([] MailboxMessage , error ) {
defer rows . Close ()
var out [] MailboxMessage
for rows . Next () {
m , err := scanMailboxMessage ( rows . Scan )
if err != nil {
return nil , err
}
out = append ( out , m )
}
return out , rows . Err ()
}
2026-08-12 21:14:19 +01:00
// ListMessagesForMailbox returns every stored message's full row for mailboxID,
// ordered ascending by UID (id) — this ordering IS the IMAP sequence-number mapping
// (index+1 == seqNum) that internal/imapserver relies on.
func ( d * DB ) ListMessagesForMailbox ( mailboxID int64 ) ([] MailboxMessage , error ) {
2026-08-14 13:04:55 +01:00
rows , err := d . Query ( `SELECT ` + mailboxMessageColumns + ` FROM esrv_mailbox_messages WHERE mailbox_id = ? ORDER BY id ASC` , mailboxID )
2026-08-12 21:14:19 +01:00
if err != nil {
return nil , err
}
2026-08-14 13:04:55 +01:00
return scanMailboxMessages ( rows )
2026-08-12 21:14:19 +01:00
}
2026-08-15 12:35:44 +01:00
// UpdateMessageCachedFields overwrites a message's cached_from/cached_to/
// cached_subject/cached_preview — the display-only fields derived from the message's
// own content at store time. Used by mailstore.RebuildMessageCache to re-derive them
// for messages stored before a caching fix/addition landed (those fields are
// otherwise only ever computed once, at delivery time, never retroactively).
func ( d * DB ) UpdateMessageCachedFields ( id int64 , cachedFrom , cachedTo , cachedSubject , cachedPreview string ) error {
_ , err := d . Exec ( `UPDATE esrv_mailbox_messages SET cached_from = ?, cached_to = ?, cached_subject = ?, cached_preview = ? WHERE id = ?` ,
cachedFrom , cachedTo , cachedSubject , cachedPreview , id )
return err
}
2026-08-12 21:14:19 +01:00
// SetMessageFlags overwrites a message's stored IMAP flags (space-separated), scoped
// to mailboxID so a session can't touch another mailbox's message by guessing a UID.
func ( d * DB ) SetMessageFlags ( mailboxID , uid int64 , flags string ) error {
_ , err := d . Exec ( `UPDATE esrv_mailbox_messages SET flags = ? WHERE id = ? AND mailbox_id = ?` , flags , uid , mailboxID )
return err
}
// ListMessagesInFolder is ListMessagesForMailbox scoped to one folder — internal/imapserver
// uses this (not the unscoped version) so a filter rule's move_to_folder action produces
// mail that's actually browsable in its own folder, not mixed into every SELECT.
func ( d * DB ) ListMessagesInFolder ( mailboxID int64 , folder string ) ([] MailboxMessage , error ) {
2026-08-14 13:04:55 +01:00
rows , err := d . Query ( `SELECT ` + mailboxMessageColumns + ` FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ? ORDER BY id ASC` , mailboxID , folder )
if err != nil {
return nil , err
}
return scanMailboxMessages ( rows )
}
// ListMessagesInFolderPage is ListMessagesInFolder with newest-first pagination, for
// the webmail client's folder view — a mailbox can accumulate far more mail than is
// reasonable to render in one page.
2026-08-15 12:35:44 +01:00
// sortColumnAndDir maps the folder view's ?sort=/&dir= query params to a safe,
// hardcoded SQL ORDER BY fragment — never interpolates the raw query values
// themselves, only picks between two known-safe literals, so this stays injection-safe
// however sort/dir arrive from the URL. "date" (the default) sorts by id, which tracks
// insertion/received order — the same ordering ListMessagesInFolderPage always used,
// just now also selectable ascending.
func sortColumnAndDir ( sortBy , sortDir string ) string {
col := "id"
if sortBy == "from" {
col = "cached_from"
}
dir := "DESC"
if sortDir == "asc" {
dir = "ASC"
}
// Tie-break on id in the same direction so same-sender/same-instant rows still
// have a stable, deterministic order across pages.
return col + " " + dir + ", id " + dir
}
2026-08-15 16:31:27 +01:00
func ( d * DB ) ListMessagesInFolderPage ( mailboxID int64 , folder string , unreadOnly , starredOnly bool , sortBy , sortDir string , offset , limit int ) ([] MailboxMessage , error ) {
2026-08-15 12:35:44 +01:00
query := `SELECT ` + mailboxMessageColumns + ` FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?`
args := [] any { mailboxID , folder }
if unreadOnly {
query += ` AND flags NOT LIKE '%\Seen%' ESCAPE '\'`
}
2026-08-15 16:31:27 +01:00
if starredOnly {
query += ` AND flags LIKE '%\Flagged%' ESCAPE '\'`
}
2026-08-15 12:35:44 +01:00
query += ` ORDER BY ` + sortColumnAndDir ( sortBy , sortDir ) + ` LIMIT ? OFFSET ?`
args = append ( args , limit , offset )
rows , err := d . Query ( query , args ... )
2026-08-14 13:04:55 +01:00
if err != nil {
return nil , err
}
return scanMailboxMessages ( rows )
}
// CountMessagesInFolder backs ListMessagesInFolderPage's pagination controls.
2026-08-15 16:31:27 +01:00
func ( d * DB ) CountMessagesInFolder ( mailboxID int64 , folder string , unreadOnly , starredOnly bool ) ( int , error ) {
2026-08-15 12:35:44 +01:00
query := `SELECT COUNT(*) FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?`
args := [] any { mailboxID , folder }
if unreadOnly {
query += ` AND flags NOT LIKE '%\Seen%' ESCAPE '\'`
}
2026-08-15 16:31:27 +01:00
if starredOnly {
query += ` AND flags LIKE '%\Flagged%' ESCAPE '\'`
}
2026-08-14 13:04:55 +01:00
var n int
2026-08-15 12:35:44 +01:00
err := d . QueryRow ( query , args ... ). Scan ( & n )
2026-08-14 13:04:55 +01:00
return n , err
}
2026-08-15 16:31:27 +01:00
// MarkAllReadInFolder adds \Seen to every currently-unread message in one folder —
// the folder-context-menu "Mark all as read" action's bulk equivalent of opening each
// message individually. Messages that already have other flags (e.g. \Flagged) keep
// them; only \Seen is added.
func ( d * DB ) MarkAllReadInFolder ( mailboxID int64 , folder string ) error {
_ , err := d . Exec ( `UPDATE esrv_mailbox_messages SET flags = TRIM(flags || ' \Seen')
WHERE mailbox_id = ? AND folder = ? AND flags NOT LIKE '%\Seen%' ESCAPE '\'` , mailboxID , folder )
return err
}
// ToggleMessageStarred flips \Flagged (IMAP's standard "starred/important" flag) on
// one message — reusing the existing flags column instead of a new schema column
// keeps this consistent with a desktop IMAP client's own star/flag button acting on
// the same message.
func ( d * DB ) ToggleMessageStarred ( mailboxID , uid int64 ) error {
msg , err := d . GetMessageByUID ( mailboxID , uid )
if err != nil || msg == nil {
return err
}
var newFlags string
if isStarred ( msg . Flags ) {
newFlags = strings . TrimSpace ( strings . ReplaceAll ( msg . Flags , `\Flagged` , "" ))
} else {
newFlags = strings . TrimSpace ( msg . Flags + ` \Flagged` )
}
return d . SetMessageFlags ( mailboxID , uid , newFlags )
}
// isStarred reports whether flags (the same space-separated IMAP flags string
// isUnread checks in internal/webui) includes \Flagged.
func isStarred ( flags string ) bool {
for _ , f := range strings . Fields ( flags ) {
if f == `\Flagged` {
return true
}
}
return false
}
2026-08-14 13:04:55 +01:00
// escapeLike backslash-escapes a user-supplied LIKE pattern's own special characters
// (%, _, and the escape character itself) so a search for e.g. "50% off" or a
// filename with an underscore doesn't get interpreted as a wildcard.
func escapeLike ( s string ) string {
r := strings . NewReplacer ( `\` , `\\` , `%` , `\%` , `_` , `\_` )
return r . Replace ( s )
}
// SearchMessagesInFolder finds messages whose cached subject/from/to contain query
// (case-insensitive substring, not a full-text index — see the webmail search
// handler's doc comment for why that's the deliberate scope here), newest first.
// folder == "" searches every folder in the mailbox.
func ( d * DB ) SearchMessagesInFolder ( mailboxID int64 , folder , query string , offset , limit int ) ([] MailboxMessage , error ) {
like := "%" + escapeLike ( query ) + "%"
args := [] any { mailboxID }
folderClause := ""
if folder != "" {
folderClause = "AND folder = ? "
args = append ( args , folder )
}
args = append ( args , like , like , like , limit , offset )
rows , err := d . Query ( `SELECT ` + mailboxMessageColumns + ` FROM esrv_mailbox_messages
WHERE mailbox_id = ? ` + folderClause + `AND (cached_subject LIKE ? ESCAPE '\' OR cached_from LIKE ? ESCAPE '\' OR cached_to LIKE ? ESCAPE '\')
ORDER BY id DESC LIMIT ? OFFSET ?` , args ... )
if err != nil {
return nil , err
}
return scanMailboxMessages ( rows )
}
// CountSearchMessagesInFolder backs SearchMessagesInFolder's pagination controls.
func ( d * DB ) CountSearchMessagesInFolder ( mailboxID int64 , folder , query string ) ( int , error ) {
like := "%" + escapeLike ( query ) + "%"
args := [] any { mailboxID }
folderClause := ""
if folder != "" {
folderClause = "AND folder = ? "
args = append ( args , folder )
}
args = append ( args , like , like , like )
var n int
err := d . QueryRow ( `SELECT COUNT(*) FROM esrv_mailbox_messages
WHERE mailbox_id = ? ` + folderClause + `AND (cached_subject LIKE ? ESCAPE '\' OR cached_from LIKE ? ESCAPE '\' OR cached_to LIKE ? ESCAPE '\')` , args ... ). Scan ( & n )
return n , err
}
// CountUnreadByFolder returns every folder's unread count in one query (GROUP BY,
// not one query per folder) — mirrors isUnread's own check
// (internal/webui/webmail_mail.go) but done in SQL so the sidebar's badge counts are
// cheap to compute on every folder-view render without a full row fetch. A folder
// with zero unread messages simply has no entry in the returned map.
func ( d * DB ) CountUnreadByFolder ( mailboxID int64 ) ( map [ string ] int , error ) {
rows , err := d . Query ( `SELECT folder, COUNT(*) FROM esrv_mailbox_messages
WHERE mailbox_id = ? AND flags NOT LIKE '%\Seen%' GROUP BY folder` , mailboxID )
2026-08-12 21:14:19 +01:00
if err != nil {
return nil , err
}
defer rows . Close ()
2026-08-14 13:04:55 +01:00
out := map [ string ] int {}
2026-08-15 12:35:44 +01:00
for rows . Next () {
var folder string
var n int
if err := rows . Scan ( & folder , & n ); err != nil {
return nil , err
}
out [ folder ] = n
}
return out , rows . Err ()
}
// CountMessagesByFolder returns every folder's total message count in one query — the
// total half of the sidebar's "total / unread" display, mirroring CountUnreadByFolder's
// shape exactly (a folder with zero messages simply has no entry in the returned map).
func ( d * DB ) CountMessagesByFolder ( mailboxID int64 ) ( map [ string ] int , error ) {
rows , err := d . Query ( `SELECT folder, COUNT(*) FROM esrv_mailbox_messages
WHERE mailbox_id = ? GROUP BY folder` , mailboxID )
if err != nil {
return nil , err
}
defer rows . Close ()
out := map [ string ] int {}
2026-08-12 21:14:19 +01:00
for rows . Next () {
2026-08-14 13:04:55 +01:00
var folder string
var n int
if err := rows . Scan ( & folder , & n ); err != nil {
2026-08-12 21:14:19 +01:00
return nil , err
}
2026-08-14 13:04:55 +01:00
out [ folder ] = n
}
return out , rows . Err ()
}
// SuggestRecipients returns up to 10 distinct addresses (as originally cached — a
// display name like "Name <addr@example.com>" is kept as-is, not parsed apart, since
// that's exactly what a To/Cc/Bcc field already accepts) this mailbox has previously
2026-08-15 21:49:25 +01:00
// exchanged mail with — its own Sent "To" list plus INBOX "From" senders, plus its
// saved address book (esrv_mailbox_contacts, formatted the same "Name <addr>" way so
// a contact's name is searchable and shown, not just its address) — whose value
// contains prefix. Backs the compose recipient autocomplete.
2026-08-14 13:04:55 +01:00
func ( d * DB ) SuggestRecipients ( mailboxID int64 , prefix string ) ([] string , error ) {
like := "%" + escapeLike ( prefix ) + "%"
rows , err := d . Query ( `
SELECT addr FROM (
SELECT cached_to AS addr FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = 'Sent' AND cached_to != ''
UNION
SELECT cached_from AS addr FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = 'INBOX' AND cached_from != ''
2026-08-15 21:49:25 +01:00
UNION
SELECT name || ' <' || email || '>' AS addr FROM esrv_mailbox_contacts WHERE mailbox_id = ?
2026-08-14 13:04:55 +01:00
)
WHERE addr LIKE ? ESCAPE '\'
2026-08-15 21:49:25 +01:00
ORDER BY addr LIMIT 10` , mailboxID , mailboxID , mailboxID , like )
2026-08-14 13:04:55 +01:00
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] string
for rows . Next () {
var addr string
if err := rows . Scan ( & addr ); err != nil {
return nil , err
}
out = append ( out , addr )
2026-08-12 21:14:19 +01:00
}
return out , rows . Err ()
}
// DistinctFoldersForMailbox returns every folder name that has at least one stored
// message, plus "INBOX" always (even if empty) — the folder list internal/imapserver's
// LIST command reports.
func ( d * DB ) DistinctFoldersForMailbox ( mailboxID int64 ) ([] string , error ) {
rows , err := d . Query ( `SELECT DISTINCT folder FROM esrv_mailbox_messages WHERE mailbox_id = ?` , mailboxID )
if err != nil {
return nil , err
}
defer rows . Close ()
seen := map [ string ] bool { "INBOX" : true }
out := [] string { "INBOX" }
for rows . Next () {
var folder string
if err := rows . Scan ( & folder ); err != nil {
return nil , err
}
if ! seen [ folder ] {
seen [ folder ] = true
out = append ( out , folder )
}
}
return out , rows . Err ()
}