Files
mailgosend/internal/db/mailboxes.go
T
2026-05-24 17:15:48 +00:00

443 lines
13 KiB
Go

package db
import (
"context"
"database/sql"
"fmt"
"math/rand"
"time"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
)
// GetMailbox returns the mailbox with the given name for a user, or nil.
func (d *DB) GetMailbox(ctx context.Context, userID int64, name string) (*models.Mailbox, error) {
row := d.db.QueryRowContext(ctx, `
SELECT id, user_id, name, type, parent_id, uid_validity, uid_next, subscribed, created_at
FROM mailboxes WHERE user_id=? AND name=?`, userID, name)
return scanMailbox(row)
}
// GetMailboxByType returns the first mailbox of the given type for a user.
func (d *DB) GetMailboxByType(ctx context.Context, userID int64, mboxType string) (*models.Mailbox, error) {
row := d.db.QueryRowContext(ctx, `
SELECT id, user_id, name, type, parent_id, uid_validity, uid_next, subscribed, created_at
FROM mailboxes WHERE user_id=? AND type=? LIMIT 1`, userID, mboxType)
return scanMailbox(row)
}
// GetMailboxByID returns the mailbox by ID.
func (d *DB) GetMailboxByID(ctx context.Context, id int64) (*models.Mailbox, error) {
row := d.db.QueryRowContext(ctx, `
SELECT id, user_id, name, type, parent_id, uid_validity, uid_next, subscribed, created_at
FROM mailboxes WHERE id=?`, id)
return scanMailbox(row)
}
// ListMailboxes returns all subscribed mailboxes for a user, ordered by name.
func (d *DB) ListMailboxes(ctx context.Context, userID int64) ([]*models.Mailbox, error) {
rows, err := d.db.QueryContext(ctx, `
SELECT id, user_id, name, type, parent_id, uid_validity, uid_next, subscribed, created_at
FROM mailboxes WHERE user_id=? ORDER BY name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var mbs []*models.Mailbox
for rows.Next() {
mb, err := scanMailboxRow(rows)
if err != nil {
return nil, err
}
mbs = append(mbs, mb)
}
return mbs, rows.Err()
}
// CreateMailbox creates a mailbox. Returns the new mailbox with uid_validity set.
func (d *DB) CreateMailbox(ctx context.Context, userID int64, name, mboxType string, parentID *int64) (*models.Mailbox, error) {
uidValidity := uint32(rand.Int31()) //nolint:gosec — not a security value
if uidValidity == 0 {
uidValidity = 1
}
res, err := d.db.ExecContext(ctx, `
INSERT INTO mailboxes (user_id, name, type, parent_id, uid_validity, uid_next, subscribed, created_at)
VALUES (?, ?, ?, ?, ?, 1, 1, ?)`,
userID, name, mboxType, parentID, uidValidity, time.Now().UTC())
if err != nil {
return nil, fmt.Errorf("create mailbox: %w", err)
}
id, _ := res.LastInsertId()
return &models.Mailbox{
ID: id,
UserID: userID,
Name: name,
Type: mboxType,
ParentID: parentID,
UIDValidity: uidValidity,
UIDNext: 1,
Subscribed: true,
CreatedAt: time.Now().UTC(),
}, nil
}
// CreateDefaultMailboxes creates the standard mailbox set for a new user.
// Idempotent — skips any that already exist.
func (d *DB) CreateDefaultMailboxes(ctx context.Context, userID int64) error {
defaults := []struct {
name string
mboxType string
}{
{"INBOX", models.MailboxInbox},
{"Sent", models.MailboxSent},
{"Drafts", models.MailboxDrafts},
{"Trash", models.MailboxTrash},
{"Spam", models.MailboxSpam},
{"Archive", models.MailboxArchive},
}
for _, mb := range defaults {
existing, err := d.GetMailbox(ctx, userID, mb.name)
if err != nil {
return err
}
if existing != nil {
continue
}
if _, err := d.CreateMailbox(ctx, userID, mb.name, mb.mboxType, nil); err != nil {
return fmt.Errorf("create default mailbox %s: %w", mb.name, err)
}
}
return nil
}
// NextUID allocates the next UID for a mailbox atomically.
// Returns the UID to use for the new message.
func (d *DB) NextUID(ctx context.Context, mailboxID int64) (uint32, error) {
tx, err := d.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback() //nolint:errcheck
var next uint32
err = tx.QueryRowContext(ctx,
"SELECT uid_next FROM mailboxes WHERE id=?", mailboxID).Scan(&next)
if err != nil {
return 0, fmt.Errorf("read uid_next: %w", err)
}
_, err = tx.ExecContext(ctx,
"UPDATE mailboxes SET uid_next=uid_next+1 WHERE id=?", mailboxID)
if err != nil {
return 0, fmt.Errorf("increment uid_next: %w", err)
}
return next, tx.Commit()
}
// ---- Message operations ----
// InsertMessage stores a message record (body is already encrypted; call SaveRawBody separately).
func (d *DB) InsertMessage(ctx context.Context, m *MessageInsert) (int64, error) {
res, err := d.db.ExecContext(ctx, `
INSERT INTO messages
(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, received_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
m.MailboxID, m.UID, m.MessageID, m.Subject, m.FromEmail, m.FromName,
m.ToList, m.CCList, m.BCCList, m.ReplyTo, m.Date,
m.BodyTextEnc, m.BodyHTMLEnc, m.RawEnc,
m.SizeBytes, m.HasAttachment, m.IsRead, m.IsStarred, m.IsDraft,
m.Flags, m.SpamScore, time.Now().UTC(),
)
if err != nil {
return 0, fmt.Errorf("insert message: %w", err)
}
return res.LastInsertId()
}
// MessageInsert is the data transfer object for inserting a new message.
type MessageInsert 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
}
// InsertAttachment stores an attachment record for a message.
func (d *DB) InsertAttachment(ctx context.Context, a *AttachmentInsert) (int64, error) {
res, err := d.db.ExecContext(ctx, `
INSERT INTO attachments
(message_id, filename, content_type, size_bytes, data_enc, data_path,
content_id, inline, mime_path)
VALUES (?,?,?,?,?,?,?,?,?)`,
a.MessageID, a.Filename, a.ContentType, a.SizeBytes,
a.DataEnc, a.DataPath, a.ContentID, a.Inline, a.MIMEPath,
)
if err != nil {
return 0, fmt.Errorf("insert attachment: %w", err)
}
return res.LastInsertId()
}
// AttachmentInsert is the data transfer object for inserting an attachment.
type AttachmentInsert struct {
MessageID int64
Filename string
ContentType string
SizeBytes int64
DataEnc []byte
DataPath string
ContentID string
Inline bool
MIMEPath string
}
// Attachment holds an attachment row returned from the database.
type Attachment struct {
ID int64
MessageID int64
Filename string
ContentType string
SizeBytes int64
DataEnc []byte
DataPath string
ContentID string
Inline bool
MIMEPath string
}
// GetAttachmentByIndex returns the n-th attachment (0-based) for a message, ordered by id.
// Both inline and non-inline attachments are included.
// Returns nil, nil when n is out of range.
func (d *DB) GetAttachmentByIndex(ctx context.Context, messageID int64, n int) (*Attachment, error) {
if n < 0 {
return nil, nil
}
row := d.db.QueryRowContext(ctx, `
SELECT id, message_id, filename, content_type, size_bytes, data_enc, data_path,
content_id, inline, mime_path
FROM attachments
WHERE message_id = ?
ORDER BY id ASC
LIMIT 1 OFFSET ?`, messageID, n)
var a Attachment
err := row.Scan(&a.ID, &a.MessageID, &a.Filename, &a.ContentType, &a.SizeBytes,
&a.DataEnc, &a.DataPath, &a.ContentID, &a.Inline, &a.MIMEPath)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get attachment: %w", err)
}
return &a, nil
}
// GetMessageRaw returns the encrypted raw blob for a message.
func (d *DB) GetMessageRaw(ctx context.Context, messageID int64) ([]byte, error) {
var raw []byte
err := d.db.QueryRowContext(ctx,
"SELECT raw_enc FROM messages WHERE id=?", messageID).Scan(&raw)
if err == sql.ErrNoRows {
return nil, nil
}
return raw, err
}
// ListMessages returns messages in a mailbox ordered by UID descending.
// Only non-deleted messages are returned.
func (d *DB) ListMessages(ctx context.Context, mailboxID int64, limit, offset int) ([]*models.Message, error) {
rows, err := d.db.QueryContext(ctx, `
SELECT id, mailbox_id, uid, message_id, subject, from_email, from_name,
to_list, cc_list, bcc_list, reply_to, 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 DESC
LIMIT ? OFFSET ?`, mailboxID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []*models.Message
for rows.Next() {
var m models.Message
err := rows.Scan(
&m.ID, &m.MailboxID, &m.UID, &m.MessageID, &m.Subject,
&m.FromEmail, &m.FromName, &m.ToList, &m.CCList, &m.BCCList,
&m.ReplyTo, &m.Date, &m.SizeBytes, &m.HasAttachment,
&m.IsRead, &m.IsStarred, &m.IsDraft, &m.Flags,
&m.SpamScore, &m.ReceivedAt,
)
if err != nil {
return nil, err
}
msgs = append(msgs, &m)
}
return msgs, rows.Err()
}
// CountUnread returns the number of unread messages in a mailbox.
func (d *DB) CountUnread(ctx context.Context, mailboxID int64) (int, error) {
var n int
err := d.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM messages WHERE mailbox_id=? AND is_read=0 AND deleted_at IS NULL",
mailboxID).Scan(&n)
return n, err
}
// ---- Queue operations ----
// EnqueueMessage inserts a delivery queue entry. Returns the new queue ID.
func (d *DB) EnqueueMessage(ctx context.Context, domainID int64, from, to, msgID string, rawEnc []byte, maxAgeHours int) (int64, error) {
expires := time.Now().UTC().Add(time.Duration(maxAgeHours) * time.Hour)
res, err := d.db.ExecContext(ctx, `
INSERT INTO queue
(domain_id, from_addr, to_addr, raw_enc, message_id, status,
attempts, next_attempt, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?)`,
domainID, from, to, rawEnc, msgID,
time.Now().UTC(), time.Now().UTC(), expires)
if err != nil {
return 0, fmt.Errorf("enqueue: %w", err)
}
return res.LastInsertId()
}
// PeekQueue returns up to limit pending/retry-eligible queue entries.
func (d *DB) PeekQueue(ctx context.Context, limit int) ([]QueueRow, error) {
rows, err := d.db.QueryContext(ctx, `
SELECT id, domain_id, from_addr, to_addr, raw_enc, message_id,
status, attempts, expires_at
FROM queue
WHERE status IN ('pending','failed')
AND next_attempt <= ?
AND expires_at > ?
ORDER BY next_attempt ASC
LIMIT ?`,
time.Now().UTC(), time.Now().UTC(), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []QueueRow
for rows.Next() {
var q QueueRow
var domainID sql.NullInt64
err := rows.Scan(
&q.ID, &domainID, &q.FromAddr, &q.ToAddr,
&q.RawEnc, &q.MessageID, &q.Status, &q.Attempts, &q.ExpiresAt,
)
if err != nil {
return nil, err
}
if domainID.Valid {
q.DomainID = domainID.Int64
}
out = append(out, q)
}
return out, rows.Err()
}
// QueueRow is a minimal queue entry for the delivery worker.
type QueueRow struct {
ID int64
DomainID int64
FromAddr string
ToAddr string
RawEnc []byte
MessageID string
Status string
Attempts int
ExpiresAt time.Time
}
// SetQueueStatus updates the status of a queue entry.
func (d *DB) SetQueueStatus(ctx context.Context, id int64, status, errMsg string, nextAttempt *time.Time) error {
_, err := d.db.ExecContext(ctx, `
UPDATE queue
SET status=?, attempts=attempts+1, last_attempt=?,
error_log=error_log || ?, next_attempt=COALESCE(?, next_attempt)
WHERE id=?`,
status, time.Now().UTC(),
fmt.Sprintf("[%s] %s\n", time.Now().UTC().Format(time.RFC3339), errMsg),
nextAttempt, id)
return err
}
// LogDelivery inserts a delivery log entry.
func (d *DB) LogDelivery(ctx context.Context, queueID int64, from, to, status string, smtpCode int, smtpMsg, mxHost string) error {
_, err := d.db.ExecContext(ctx, `
INSERT INTO delivery_log (queue_id, from_addr, to_addr, status, smtp_code, smtp_message, mx_host, created_at)
VALUES (?,?,?,?,?,?,?,?)`,
queueID, from, to, status, smtpCode, smtpMsg, mxHost, time.Now().UTC())
return err
}
// ---- private ----
func scanMailbox(row *sql.Row) (*models.Mailbox, error) {
var mb models.Mailbox
var parentID sql.NullInt64
err := row.Scan(
&mb.ID, &mb.UserID, &mb.Name, &mb.Type,
&parentID, &mb.UIDValidity, &mb.UIDNext, &mb.Subscribed, &mb.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan mailbox: %w", err)
}
if parentID.Valid {
id := parentID.Int64
mb.ParentID = &id
}
return &mb, nil
}
func scanMailboxRow(rows *sql.Rows) (*models.Mailbox, error) {
var mb models.Mailbox
var parentID sql.NullInt64
err := rows.Scan(
&mb.ID, &mb.UserID, &mb.Name, &mb.Type,
&parentID, &mb.UIDValidity, &mb.UIDNext, &mb.Subscribed, &mb.CreatedAt,
)
if err != nil {
return nil, err
}
if parentID.Valid {
id := parentID.Int64
mb.ParentID = &id
}
return &mb, nil
}