443 lines
13 KiB
Go
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
|
|
}
|