285 lines
8.5 KiB
Go
285 lines
8.5 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
|
)
|
|
|
|
// IMAPMessage is a lightweight message descriptor used by the IMAP layer.
|
|
// The raw/body blobs are NOT loaded here — fetch separately via GetMessageRaw.
|
|
type IMAPMessage struct {
|
|
ID int64
|
|
MailboxID int64
|
|
UID uint32
|
|
MessageID string // RFC 2822 Message-ID header
|
|
Subject string
|
|
FromEmail string
|
|
FromName string
|
|
ToList string
|
|
Date time.Time
|
|
SizeBytes int64
|
|
HasAttachment bool
|
|
IsRead bool
|
|
IsStarred bool
|
|
IsDraft bool
|
|
IsDeleted bool // deleted_at IS NOT NULL
|
|
Flags string
|
|
SpamScore int
|
|
ReceivedAt time.Time
|
|
}
|
|
|
|
// ListIMAPMessages returns all non-deleted messages in a mailbox ordered by UID ascending.
|
|
func (d *DB) ListIMAPMessages(ctx context.Context, mailboxID int64) ([]*IMAPMessage, error) {
|
|
rows, err := d.db.QueryContext(ctx, `
|
|
SELECT id, mailbox_id, uid, message_id, subject, from_email, from_name,
|
|
to_list, date, size_bytes, has_attachment,
|
|
is_read, is_starred, is_draft, flags, spam_score, received_at
|
|
FROM messages
|
|
WHERE mailbox_id = ? AND deleted_at IS NULL
|
|
ORDER BY uid ASC`, mailboxID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []*IMAPMessage
|
|
for rows.Next() {
|
|
m, err := scanIMAPMessage(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GetIMAPMessageByUID returns one message by UID within a mailbox.
|
|
func (d *DB) GetIMAPMessageByUID(ctx context.Context, mailboxID int64, uid uint32) (*IMAPMessage, error) {
|
|
rows, err := d.db.QueryContext(ctx, `
|
|
SELECT id, mailbox_id, uid, message_id, subject, from_email, from_name,
|
|
to_list, date, size_bytes, has_attachment,
|
|
is_read, is_starred, is_draft, flags, spam_score, received_at
|
|
FROM messages
|
|
WHERE mailbox_id = ? AND uid = ? AND deleted_at IS NULL
|
|
LIMIT 1`, mailboxID, uid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
if !rows.Next() {
|
|
return nil, nil
|
|
}
|
|
return scanIMAPMessage(rows)
|
|
}
|
|
|
|
// SetMessageFlags updates the mutable flags for a message.
|
|
func (d *DB) SetMessageFlags(ctx context.Context, messageID int64, isRead, isStarred, isDraft bool, extraFlags string) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE messages SET is_read=?, is_starred=?, is_draft=?, flags=? WHERE id=?",
|
|
isRead, isStarred, isDraft, extraFlags, messageID)
|
|
return err
|
|
}
|
|
|
|
// SoftDeleteMessage marks a message as deleted (sets deleted_at).
|
|
func (d *DB) SoftDeleteMessage(ctx context.Context, messageID int64) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE messages SET deleted_at=? WHERE id=?", time.Now().UTC(), messageID)
|
|
return err
|
|
}
|
|
|
|
// HardDeleteMessages physically removes all soft-deleted messages from a mailbox.
|
|
// Returns the UIDs of deleted messages (for EXPUNGE responses).
|
|
func (d *DB) HardDeleteMessages(ctx context.Context, mailboxID int64) ([]uint32, error) {
|
|
rows, err := d.db.QueryContext(ctx,
|
|
"SELECT uid FROM messages WHERE mailbox_id=? AND deleted_at IS NOT NULL ORDER BY uid ASC",
|
|
mailboxID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var uids []uint32
|
|
for rows.Next() {
|
|
var uid uint32
|
|
if err := rows.Scan(&uid); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
uids = append(uids, uid)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(uids) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Delete attachments first (FK).
|
|
_, err = d.db.ExecContext(ctx, `
|
|
DELETE FROM attachments WHERE message_id IN (
|
|
SELECT id FROM messages WHERE mailbox_id=? AND deleted_at IS NOT NULL
|
|
)`, mailboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("delete attachments: %w", err)
|
|
}
|
|
_, err = d.db.ExecContext(ctx,
|
|
"DELETE FROM messages WHERE mailbox_id=? AND deleted_at IS NOT NULL", mailboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("delete messages: %w", err)
|
|
}
|
|
return uids, nil
|
|
}
|
|
|
|
// CopyMessageToMailbox duplicates a message row to another mailbox.
|
|
// Returns the new UID.
|
|
func (d *DB) CopyMessageToMailbox(ctx context.Context, srcMsgID, destMailboxID, userID int64) (uint32, error) {
|
|
// Read source.
|
|
var src struct {
|
|
mailboxID int64
|
|
uid uint32
|
|
messageID string
|
|
subject string
|
|
fromEmail string
|
|
fromName string
|
|
toList string
|
|
ccList string
|
|
bccList string
|
|
replyTo string
|
|
date time.Time
|
|
bodyTextEnc []byte
|
|
bodyHTMLEnc []byte
|
|
rawEnc []byte
|
|
sizeBytes int64
|
|
hasAttachment bool
|
|
isRead bool
|
|
isStarred bool
|
|
isDraft bool
|
|
flags string
|
|
spamScore int
|
|
}
|
|
err := d.db.QueryRowContext(ctx, `
|
|
SELECT mailbox_id, uid, message_id, subject, from_email, from_name,
|
|
to_list, cc_list, bcc_list, reply_to, date,
|
|
body_text_enc, body_html_enc, raw_enc,
|
|
size_bytes, has_attachment, is_read, is_starred, is_draft,
|
|
flags, spam_score
|
|
FROM messages WHERE id=? AND deleted_at IS NULL`, srcMsgID).Scan(
|
|
&src.mailboxID, &src.uid, &src.messageID, &src.subject,
|
|
&src.fromEmail, &src.fromName, &src.toList, &src.ccList, &src.bccList, &src.replyTo,
|
|
&src.date, &src.bodyTextEnc, &src.bodyHTMLEnc, &src.rawEnc,
|
|
&src.sizeBytes, &src.hasAttachment, &src.isRead, &src.isStarred, &src.isDraft,
|
|
&src.flags, &src.spamScore,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return 0, fmt.Errorf("source message %d not found", srcMsgID)
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("copy message read: %w", err)
|
|
}
|
|
|
|
// Allocate UID in destination.
|
|
uid, err := d.NextUID(ctx, destMailboxID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("copy message uid: %w", err)
|
|
}
|
|
|
|
ins := &MessageInsert{
|
|
MailboxID: destMailboxID,
|
|
UID: uid,
|
|
MessageID: src.messageID,
|
|
Subject: src.subject,
|
|
FromEmail: src.fromEmail,
|
|
FromName: src.fromName,
|
|
ToList: src.toList,
|
|
CCList: src.ccList,
|
|
BCCList: src.bccList,
|
|
ReplyTo: src.replyTo,
|
|
Date: src.date,
|
|
BodyTextEnc: src.bodyTextEnc,
|
|
BodyHTMLEnc: src.bodyHTMLEnc,
|
|
RawEnc: src.rawEnc,
|
|
SizeBytes: src.sizeBytes,
|
|
HasAttachment: src.hasAttachment,
|
|
IsRead: src.isRead,
|
|
IsStarred: src.isStarred,
|
|
IsDraft: src.isDraft,
|
|
Flags: src.flags,
|
|
SpamScore: src.spamScore,
|
|
}
|
|
if _, err := d.InsertMessage(ctx, ins); err != nil {
|
|
return 0, fmt.Errorf("copy message insert: %w", err)
|
|
}
|
|
return uid, nil
|
|
}
|
|
|
|
// RenameMailbox updates the name field of a mailbox.
|
|
func (d *DB) RenameMailbox(ctx context.Context, mailboxID int64, newName string) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE mailboxes SET name=? WHERE id=?", newName, mailboxID)
|
|
return err
|
|
}
|
|
|
|
// SetMailboxSubscribed updates the subscribed flag on a mailbox.
|
|
func (d *DB) SetMailboxSubscribed(ctx context.Context, mailboxID int64, subscribed bool) error {
|
|
_, err := d.db.ExecContext(ctx,
|
|
"UPDATE mailboxes SET subscribed=? WHERE id=?", subscribed, mailboxID)
|
|
return err
|
|
}
|
|
|
|
// GetMailboxMessageCounts returns (total, unseen) counts for a mailbox.
|
|
func (d *DB) GetMailboxMessageCounts(ctx context.Context, mailboxID int64) (total, unseen int64, err error) {
|
|
err = d.db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*), COUNT(CASE WHEN is_read=0 THEN 1 END)
|
|
FROM messages WHERE mailbox_id=? AND deleted_at IS NULL`, mailboxID).Scan(&total, &unseen)
|
|
return
|
|
}
|
|
|
|
// GetMailboxSize returns the total size in bytes of all messages in a mailbox.
|
|
func (d *DB) GetMailboxSize(ctx context.Context, mailboxID int64) (int64, error) {
|
|
var sz sql.NullInt64
|
|
err := d.db.QueryRowContext(ctx,
|
|
"SELECT SUM(size_bytes) FROM messages WHERE mailbox_id=? AND deleted_at IS NULL",
|
|
mailboxID).Scan(&sz)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return sz.Int64, nil
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
func scanIMAPMessage(rows *sql.Rows) (*IMAPMessage, error) {
|
|
m := &IMAPMessage{}
|
|
err := rows.Scan(
|
|
&m.ID, &m.MailboxID, &m.UID, &m.MessageID, &m.Subject,
|
|
&m.FromEmail, &m.FromName, &m.ToList,
|
|
&m.Date, &m.SizeBytes, &m.HasAttachment,
|
|
&m.IsRead, &m.IsStarred, &m.IsDraft,
|
|
&m.Flags, &m.SpamScore, &m.ReceivedAt,
|
|
)
|
|
return m, err
|
|
}
|
|
|
|
// mailboxTypeToAttr converts our type string to an IMAP special-use string.
|
|
// Callers handle the conversion to imap.MailboxAttr themselves.
|
|
func MailboxTypeToSpecialUse(mboxType string) string {
|
|
switch mboxType {
|
|
case models.MailboxSent:
|
|
return `\Sent`
|
|
case models.MailboxDrafts:
|
|
return `\Drafts`
|
|
case models.MailboxTrash:
|
|
return `\Trash`
|
|
case models.MailboxSpam:
|
|
return `\Junk`
|
|
case models.MailboxArchive:
|
|
return `\Archive`
|
|
case models.MailboxInbox:
|
|
return `\Inbox`
|
|
}
|
|
return ""
|
|
}
|