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-14 13:04:55 +01:00
func ( d * DB ) InsertMessage ( mailboxID int64 , folder , messageIDHeader , flags string , internalDate time . Time , sizeBytes int64 , storagePath string , nonce [] byte , cachedFrom , cachedTo , cachedSubject string ) ( int64 , error ) {
2026-08-12 21:14:19 +01:00
res , err := d . Exec ( `INSERT INTO esrv_mailbox_messages
2026-08-14 13:04:55 +01:00
(mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, storage_path, nonce, cached_from, cached_to, cached_subject)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ,
mailboxID , folder , messageIDHeader , flags , internalDate , sizeBytes , storagePath , nonce , cachedFrom , cachedTo , cachedSubject )
2026-08-12 21:14:19 +01:00
if err != nil {
return 0 , err
}
return res . LastInsertId ()
}
2026-08-14 13:04:55 +01:00
const mailboxMessageColumns = `id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_to, cached_subject, storage_path, nonce, created_at`
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-14 13:04:55 +01:00
err := scan ( & m . ID , & m . MailboxID , & m . Folder , & m . MessageIDHeader , & m . Flags , & internalDate , & m . SizeBytes , & m . CachedFrom , & m . CachedTo , & m . CachedSubject , & m . StoragePath , & m . Nonce , & createdAt )
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-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
}
// 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.
func ( d * DB ) ListMessagesInFolderPage ( mailboxID int64 , folder string , offset , limit int ) ([] MailboxMessage , error ) {
rows , err := d . Query ( `SELECT ` + mailboxMessageColumns + ` FROM esrv_mailbox_messages
WHERE mailbox_id = ? AND folder = ? ORDER BY id DESC LIMIT ? OFFSET ?` , mailboxID , folder , limit , offset )
if err != nil {
return nil , err
}
return scanMailboxMessages ( rows )
}
// CountMessagesInFolder backs ListMessagesInFolderPage's pagination controls.
func ( d * DB ) CountMessagesInFolder ( mailboxID int64 , folder string ) ( int , error ) {
var n int
err := d . QueryRow ( `SELECT COUNT(*) FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?` , mailboxID , folder ). Scan ( & n )
return n , err
}
// 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-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
// exchanged mail with — its own Sent "To" list plus INBOX "From" senders — whose
// value contains prefix. Backs the compose recipient autocomplete; deliberately
// reuses message history already stored rather than a dedicated contacts table.
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 != ''
)
WHERE addr LIKE ? ESCAPE '\'
ORDER BY addr LIMIT 10` , mailboxID , mailboxID , like )
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 ()
}