560 lines
23 KiB
Go
560 lines
23 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
"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.
|
|
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) {
|
|
// modseq/created_modseq share one freshly computed value — this row is both
|
|
// "created" and "at its current state" as of the same event. thread_id is left at
|
|
// its column default (0) here: it depends on the message's own id, which doesn't
|
|
// exist until after this INSERT returns — see db.ThreadForMessage/
|
|
// SetMessageThreadID, called separately by mailstore.storeMessage right after.
|
|
modseq, err := d.nextMessageModseq(mailboxID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
res, err := d.Exec(`INSERT INTO esrv_mailbox_messages
|
|
(mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, storage_path, nonce, cached_from, cached_to, cached_subject, cached_preview, modseq, created_modseq)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
mailboxID, folder, messageIDHeader, flags, internalDate, sizeBytes, storagePath, nonce, cachedFrom, cachedTo, cachedSubject, cachedPreview, modseq, modseq)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
// SetMessageThreadID sets a message's thread_id after insert (see InsertMessage's
|
|
// comment on why this can't happen at insert time) — called by mailstore.storeMessage
|
|
// right after InsertMessage returns the new uid. Does not bump modseq: threading is
|
|
// resolved once, immediately after creation, before any JMAP client could have
|
|
// observed the row without it — not a later, independently-visible mutation.
|
|
func (d *DB) SetMessageThreadID(mailboxID, uid, threadID int64) error {
|
|
_, err := d.Exec(`UPDATE esrv_mailbox_messages SET thread_id = ? WHERE id = ? AND mailbox_id = ?`, threadID, uid, mailboxID)
|
|
return err
|
|
}
|
|
|
|
// ThreadForMessage resolves the JMAP (internal/jmap) thread_id a new message with the
|
|
// given raw References/In-Reply-To header values should join, within one mailbox.
|
|
// Checked most-recent-first (In-Reply-To, then References read newest-to-oldest per
|
|
// RFC 5322 §3.6.4's ordering) against every existing message's message_id_header;
|
|
// returns 0 if nothing matches — the caller (mailstore.storeMessage) then uses the new
|
|
// message's own id as its thread_id, the root of a new thread. Deliberately
|
|
// simplified: a message referencing two independently-threaded messages joins
|
|
// whichever is matched first, not a full union-find merge of both threads — not the
|
|
// full RFC 5256 THREAD REFERENCES algorithm, an accepted, documented scope cut.
|
|
func (d *DB) ThreadForMessage(mailboxID int64, references, inReplyTo string) (int64, error) {
|
|
ids := extractMessageIDTokens(inReplyTo)
|
|
ids = append(ids, extractMessageIDTokens(references)...)
|
|
for _, id := range ids {
|
|
var threadID int64
|
|
err := d.QueryRow(`SELECT thread_id FROM esrv_mailbox_messages WHERE mailbox_id = ? AND message_id_header = ? LIMIT 1`, mailboxID, id).Scan(&threadID)
|
|
if err == nil {
|
|
return threadID, nil
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return 0, err
|
|
}
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
// extractMessageIDTokens splits a References/In-Reply-To header value into individual
|
|
// Message-ID tokens, angle brackets stripped (matching the bare-form storage
|
|
// convention smtpserver.extractMessageID already uses for message_id_header), most-
|
|
// recently-referenced first (the header itself lists oldest-first per RFC 5322
|
|
// §3.6.4, reversed here).
|
|
func extractMessageIDTokens(header string) []string {
|
|
var out []string
|
|
for _, tok := range strings.Fields(header) {
|
|
tok = strings.TrimPrefix(strings.TrimSuffix(tok, ">"), "<")
|
|
if tok != "" {
|
|
out = append(out, tok)
|
|
}
|
|
}
|
|
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
|
out[i], out[j] = out[j], out[i]
|
|
}
|
|
return out
|
|
}
|
|
|
|
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, thread_id, modseq, created_modseq`
|
|
|
|
func scanMailboxMessage(scan func(dest ...any) error) (MailboxMessage, error) {
|
|
var m MailboxMessage
|
|
var internalDate, createdAt string
|
|
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, &m.ThreadID, &m.Modseq, &m.CreatedModseq)
|
|
if err != nil {
|
|
return m, err
|
|
}
|
|
m.InternalDate, _ = parseTime(internalDate)
|
|
m.CreatedAt, _ = parseTime(createdAt)
|
|
return m, nil
|
|
}
|
|
|
|
// nextMessageModseq returns the next per-mailbox modseq value for esrv_mailbox_messages
|
|
// — a plain SELECT MAX+1, safe without a transaction because sqlDB.SetMaxOpenConns(1)
|
|
// already serializes every DB access through one connection app-wide (same reasoning
|
|
// as crud_relay_queue.go's ClaimDueRelayQueueItems select-then-update). Backs JMAP's
|
|
// (internal/jmap) Email/changes incremental sync.
|
|
func (d *DB) nextMessageModseq(mailboxID int64) (int64, error) {
|
|
var n int64
|
|
err := d.QueryRow(`SELECT COALESCE(MAX(modseq), 0) + 1 FROM esrv_mailbox_messages WHERE mailbox_id = ?`, mailboxID).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
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 {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
// DeleteMessage hard-deletes a message row, first leaving a tombstone behind so JMAP's
|
|
// (internal/jmap) Email/changes can still report it destroyed — a deleted row has no
|
|
// id of its own left to derive one from otherwise.
|
|
func (d *DB) DeleteMessage(mailboxID, uid int64) error {
|
|
modseq, err := d.nextMessageModseq(mailboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := d.Exec(`INSERT INTO esrv_mailbox_message_tombstones (mailbox_id, message_id, modseq) VALUES (?, ?, ?)`, mailboxID, uid, modseq); err != nil {
|
|
return err
|
|
}
|
|
_, err = d.Exec(`DELETE FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?`, uid, mailboxID)
|
|
return err
|
|
}
|
|
|
|
// 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 {
|
|
modseq, err := d.nextMessageModseq(mailboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = d.Exec(`UPDATE esrv_mailbox_messages SET folder = ?, modseq = ? WHERE id = ? AND mailbox_id = ?`, newFolder, modseq, uid, mailboxID)
|
|
return err
|
|
}
|
|
|
|
// 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
|
|
}
|
|
modseq, err := d.nextMessageModseq(mailboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = d.Exec(`UPDATE esrv_mailbox_messages SET restore_folder = ?, folder = 'Trash', modseq = ? WHERE id = ? AND mailbox_id = ?`, folder, modseq, 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"
|
|
}
|
|
modseq, err := d.nextMessageModseq(mailboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = d.Exec(`UPDATE esrv_mailbox_messages SET folder = ?, restore_folder = '', modseq = ? WHERE id = ? AND mailbox_id = ?`, restoreFolder, modseq, uid, mailboxID)
|
|
return err
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
// 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) {
|
|
rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages WHERE mailbox_id = ? ORDER BY id ASC`, mailboxID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanMailboxMessages(rows)
|
|
}
|
|
|
|
// 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 {
|
|
var mailboxID int64
|
|
if err := d.QueryRow(`SELECT mailbox_id FROM esrv_mailbox_messages WHERE id = ?`, id).Scan(&mailboxID); err != nil {
|
|
return err
|
|
}
|
|
modseq, err := d.nextMessageModseq(mailboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = d.Exec(`UPDATE esrv_mailbox_messages SET cached_from = ?, cached_to = ?, cached_subject = ?, cached_preview = ?, modseq = ? WHERE id = ?`,
|
|
cachedFrom, cachedTo, cachedSubject, cachedPreview, modseq, id)
|
|
return err
|
|
}
|
|
|
|
// UpdateMessageContent overwrites an existing message row's size/nonce/message-id/
|
|
// cached display fields after its ciphertext has been rewritten in place on disk (see
|
|
// mailstore.Store.UpdateMessage) — everything InsertMessage sets except folder, flags,
|
|
// and storage_path, which don't change for an in-place content replacement. Scoped to
|
|
// mailboxID so a caller can't touch another mailbox's message by guessing a uid.
|
|
func (d *DB) UpdateMessageContent(mailboxID, id int64, sizeBytes int64, nonce []byte, messageIDHeader, cachedFrom, cachedTo, cachedSubject, cachedPreview string) error {
|
|
modseq, err := d.nextMessageModseq(mailboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = d.Exec(`UPDATE esrv_mailbox_messages SET size_bytes = ?, nonce = ?, message_id_header = ?, cached_from = ?, cached_to = ?, cached_subject = ?, cached_preview = ?, modseq = ? WHERE id = ? AND mailbox_id = ?`,
|
|
sizeBytes, nonce, messageIDHeader, cachedFrom, cachedTo, cachedSubject, cachedPreview, modseq, id, mailboxID)
|
|
return err
|
|
}
|
|
|
|
// 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 {
|
|
modseq, err := d.nextMessageModseq(mailboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = d.Exec(`UPDATE esrv_mailbox_messages SET flags = ?, modseq = ? WHERE id = ? AND mailbox_id = ?`, flags, modseq, 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) {
|
|
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.
|
|
// 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
|
|
}
|
|
|
|
func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, unreadOnly, starredOnly bool, sortBy, sortDir string, offset, limit int) ([]MailboxMessage, error) {
|
|
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 '\'`
|
|
}
|
|
if starredOnly {
|
|
query += ` AND flags LIKE '%\Flagged%' ESCAPE '\'`
|
|
}
|
|
query += ` ORDER BY ` + sortColumnAndDir(sortBy, sortDir) + ` LIMIT ? OFFSET ?`
|
|
args = append(args, limit, offset)
|
|
rows, err := d.Query(query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanMailboxMessages(rows)
|
|
}
|
|
|
|
// ListNewMessagesInFolder returns messages in folder with id > afterID, respecting the
|
|
// same unread/starred filters the folder view itself applies — backs the webmail
|
|
// list's polling endpoint (webmailMailPoll) so newly arrived mail can be patched into
|
|
// an open folder view without a full page reload. Ordered oldest-first (ascending id)
|
|
// so the caller can prepend each row in turn and end up with the newest at the very
|
|
// top, matching the folder view's own default newest-first order.
|
|
func (d *DB) ListNewMessagesInFolder(mailboxID int64, folder string, afterID int64, unreadOnly, starredOnly bool) ([]MailboxMessage, error) {
|
|
query := `SELECT ` + mailboxMessageColumns + ` FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ? AND id > ?`
|
|
args := []any{mailboxID, folder, afterID}
|
|
if unreadOnly {
|
|
query += ` AND flags NOT LIKE '%\Seen%' ESCAPE '\'`
|
|
}
|
|
if starredOnly {
|
|
query += ` AND flags LIKE '%\Flagged%' ESCAPE '\'`
|
|
}
|
|
query += ` ORDER BY id ASC LIMIT 50`
|
|
rows, err := d.Query(query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanMailboxMessages(rows)
|
|
}
|
|
|
|
// CountMessagesInFolder backs ListMessagesInFolderPage's pagination controls.
|
|
func (d *DB) CountMessagesInFolder(mailboxID int64, folder string, unreadOnly, starredOnly bool) (int, error) {
|
|
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 '\'`
|
|
}
|
|
if starredOnly {
|
|
query += ` AND flags LIKE '%\Flagged%' ESCAPE '\'`
|
|
}
|
|
var n int
|
|
err := d.QueryRow(query, args...).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// 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 {
|
|
// Every row this bulk update touches shares one modseq value — they all changed
|
|
// together as one logical event, same as any other batch mutation JMAP's
|
|
// Email/changes needs to report (it only needs modseq > sinceState per row, not a
|
|
// unique value per row).
|
|
modseq, err := d.nextMessageModseq(mailboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = d.Exec(`UPDATE esrv_mailbox_messages SET flags = TRIM(flags || ' \Seen'), modseq = ?
|
|
WHERE mailbox_id = ? AND folder = ? AND flags NOT LIKE '%\Seen%' ESCAPE '\'`, modseq, 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
|
|
}
|
|
|
|
// 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)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := map[string]int{}
|
|
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{}
|
|
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()
|
|
}
|
|
|
|
// 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, 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.
|
|
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 != ''
|
|
UNION
|
|
SELECT name || ' <' || email || '>' AS addr FROM esrv_mailbox_contacts WHERE mailbox_id = ?
|
|
)
|
|
WHERE addr LIKE ? ESCAPE '\'
|
|
ORDER BY addr LIMIT 10`, mailboxID, 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)
|
|
}
|
|
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()
|
|
}
|