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 string) (int64, error) { 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, mailboxID, folder, messageIDHeader, flags, internalDate, sizeBytes, storagePath, nonce, cachedFrom, cachedTo, cachedSubject) if err != nil { return 0, err } return res.LastInsertId() } 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) { 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.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 { 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 } // 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 } // 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) } // 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) { 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) 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 " 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) } 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() }