initial
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
// Package db provides the database/sql wrapper and driver registration.
|
||||
// Default driver: modernc.org/sqlite (pure Go, no CGO).
|
||||
// Additional drivers registered via build tags: postgres, mysql, mssql.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // pure-Go SQLite driver
|
||||
)
|
||||
|
||||
// DB wraps sql.DB with convenience methods and prepared statement caching.
|
||||
type DB struct {
|
||||
db *sql.DB
|
||||
driver string
|
||||
}
|
||||
|
||||
// Open opens and validates the database connection, runs migrations, returns DB.
|
||||
func Open(driver, dsn string) (*DB, error) {
|
||||
if driver == "" {
|
||||
driver = "sqlite"
|
||||
}
|
||||
|
||||
// Map friendly driver names to database/sql driver names.
|
||||
sqlDriver := sqlDriverName(driver)
|
||||
|
||||
if driver == "sqlite" {
|
||||
dsn = sqliteDSN(dsn)
|
||||
}
|
||||
|
||||
sqlDB, err := sql.Open(sqlDriver, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db open %s: %w", driver, err)
|
||||
}
|
||||
|
||||
// Connection pool tuning.
|
||||
if driver == "sqlite" {
|
||||
// SQLite: serialise with single connection to avoid SQLITE_BUSY.
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
sqlDB.SetConnMaxLifetime(0)
|
||||
} else {
|
||||
sqlDB.SetMaxOpenConns(25)
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetConnMaxLifetime(5 * time.Minute)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("db ping: %w", err)
|
||||
}
|
||||
|
||||
d := &DB{db: sqlDB, driver: driver}
|
||||
|
||||
// Enable WAL mode for SQLite (dramatically improves concurrent read performance).
|
||||
if driver == "sqlite" {
|
||||
if _, err := sqlDB.Exec(`PRAGMA journal_mode=WAL`); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("sqlite WAL: %w", err)
|
||||
}
|
||||
if _, err := sqlDB.Exec(`PRAGMA foreign_keys=ON`); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("sqlite foreign_keys: %w", err)
|
||||
}
|
||||
if _, err := sqlDB.Exec(`PRAGMA busy_timeout=5000`); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("sqlite busy_timeout: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.migrate(); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying sql.DB.
|
||||
func (d *DB) Close() error { return d.db.Close() }
|
||||
|
||||
// Driver returns the driver name (sqlite / postgres / mysql / mssql).
|
||||
func (d *DB) Driver() string { return d.driver }
|
||||
|
||||
// SQL returns the underlying *sql.DB for direct use when needed.
|
||||
func (d *DB) SQL() *sql.DB { return d.db }
|
||||
|
||||
// Exec runs a query with a per-call context timeout.
|
||||
func (d *DB) Exec(query string, args ...any) (sql.Result, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return d.db.ExecContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
// QueryRow runs a single-row query.
|
||||
func (d *DB) QueryRow(query string, args ...any) *sql.Row {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return d.db.QueryRowContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
// Query runs a multi-row query.
|
||||
func (d *DB) Query(query string, args ...any) (*sql.Rows, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return d.db.QueryContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
// WithTx runs fn inside a transaction, rolling back on error or panic.
|
||||
func (d *DB) WithTx(ctx context.Context, fn func(*sql.Tx) error) error {
|
||||
tx, err := d.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
_ = tx.Rollback()
|
||||
panic(p) // re-raise
|
||||
}
|
||||
}()
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ---- Placeholder helper ----
|
||||
|
||||
// Placeholder returns the SQL parameter placeholder for the current driver.
|
||||
// SQLite and MySQL use ?, PostgreSQL uses $1, $2… MSSQL uses @p1, @p2…
|
||||
func (d *DB) Placeholder(n int) string {
|
||||
switch d.driver {
|
||||
case "postgres":
|
||||
return fmt.Sprintf("$%d", n)
|
||||
case "mssql":
|
||||
return fmt.Sprintf("@p%d", n)
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Private helpers ----
|
||||
|
||||
func sqlDriverName(driver string) string {
|
||||
switch driver {
|
||||
case "sqlite":
|
||||
return "sqlite" // modernc.org/sqlite registers as "sqlite"
|
||||
case "postgres":
|
||||
return "postgres"
|
||||
case "mysql":
|
||||
return "mysql"
|
||||
case "mssql":
|
||||
return "sqlserver"
|
||||
default:
|
||||
return driver
|
||||
}
|
||||
}
|
||||
|
||||
func sqliteDSN(path string) string {
|
||||
if path == "" {
|
||||
path = "./data/mail.db"
|
||||
}
|
||||
// modernc.org/sqlite DSN supports query parameters.
|
||||
return path + "?_pragma=foreign_keys(1)"
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
||||
)
|
||||
|
||||
// GetDomain returns the domain row by name, or nil if not found.
|
||||
func (d *DB) GetDomain(ctx context.Context, name string) (*models.Domain, error) {
|
||||
row := d.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, enabled, dkim_private_enc, dkim_public, dkim_selector,
|
||||
dkim_algo, spf_policy, dmarc_policy, max_users, max_quota_bytes, created_at
|
||||
FROM domains WHERE lower(name) = lower(?)`, name)
|
||||
|
||||
var dom models.Domain
|
||||
var privEnc []byte
|
||||
err := row.Scan(
|
||||
&dom.ID, &dom.Name, &dom.Enabled,
|
||||
&privEnc, &dom.DKIMPublic, &dom.DKIMSelector,
|
||||
&dom.DKIMAlgo, &dom.SPFPolicy, &dom.DMARCPolicy,
|
||||
&dom.MaxUsers, &dom.MaxQuotaBytes, &dom.CreatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get domain: %w", err)
|
||||
}
|
||||
dom.DKIMPrivateEnc = privEnc
|
||||
return &dom, nil
|
||||
}
|
||||
|
||||
// GetDomainByID returns the domain row by ID.
|
||||
func (d *DB) GetDomainByID(ctx context.Context, id int64) (*models.Domain, error) {
|
||||
row := d.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, enabled, dkim_private_enc, dkim_public, dkim_selector,
|
||||
dkim_algo, spf_policy, dmarc_policy, max_users, max_quota_bytes, created_at
|
||||
FROM domains WHERE id = ?`, id)
|
||||
|
||||
var dom models.Domain
|
||||
var privEnc []byte
|
||||
err := row.Scan(
|
||||
&dom.ID, &dom.Name, &dom.Enabled,
|
||||
&privEnc, &dom.DKIMPublic, &dom.DKIMSelector,
|
||||
&dom.DKIMAlgo, &dom.SPFPolicy, &dom.DMARCPolicy,
|
||||
&dom.MaxUsers, &dom.MaxQuotaBytes, &dom.CreatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get domain by id: %w", err)
|
||||
}
|
||||
dom.DKIMPrivateEnc = privEnc
|
||||
return &dom, nil
|
||||
}
|
||||
|
||||
// IsLocalDomain returns true if name is a known enabled domain.
|
||||
func (d *DB) IsLocalDomain(ctx context.Context, name string) (bool, error) {
|
||||
var count int
|
||||
err := d.db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM domains WHERE lower(name)=lower(?) AND enabled=1", name).
|
||||
Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// ListDomains returns all domains ordered by name.
|
||||
func (d *DB) ListDomains(ctx context.Context) ([]*models.Domain, error) {
|
||||
rows, err := d.db.QueryContext(ctx, `
|
||||
SELECT id, name, enabled, dkim_private_enc, dkim_public, dkim_selector,
|
||||
dkim_algo, spf_policy, dmarc_policy, max_users, max_quota_bytes, created_at
|
||||
FROM domains ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var doms []*models.Domain
|
||||
for rows.Next() {
|
||||
var dom models.Domain
|
||||
var privEnc []byte
|
||||
err := rows.Scan(
|
||||
&dom.ID, &dom.Name, &dom.Enabled,
|
||||
&privEnc, &dom.DKIMPublic, &dom.DKIMSelector,
|
||||
&dom.DKIMAlgo, &dom.SPFPolicy, &dom.DMARCPolicy,
|
||||
&dom.MaxUsers, &dom.MaxQuotaBytes, &dom.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dom.DKIMPrivateEnc = privEnc
|
||||
doms = append(doms, &dom)
|
||||
}
|
||||
return doms, rows.Err()
|
||||
}
|
||||
|
||||
// CreateDomain inserts a new domain. Returns the new ID.
|
||||
func (d *DB) CreateDomain(ctx context.Context, name, selector, algo string) (int64, error) {
|
||||
res, err := d.db.ExecContext(ctx, `
|
||||
INSERT INTO domains (name, enabled, dkim_selector, dkim_algo)
|
||||
VALUES (?, 1, ?, ?)`, name, selector, algo)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create domain: %w", err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// SaveDKIMKeys stores encrypted DKIM private key + public key for a domain.
|
||||
func (d *DB) SaveDKIMKeys(ctx context.Context, domainID int64, privEnc []byte, pubPEM string) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE domains SET dkim_private_enc=?, dkim_public=? WHERE id=?",
|
||||
privEnc, pubPEM, domainID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
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 ""
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// migration is a versioned schema change.
|
||||
type migration struct {
|
||||
version int
|
||||
up string // SQL to apply
|
||||
}
|
||||
|
||||
// migrations must be append-only. Never edit an applied migration.
|
||||
var migrations = []migration{
|
||||
{1, schemav1},
|
||||
}
|
||||
|
||||
// migrate applies any unapplied migrations in order.
|
||||
func (d *DB) migrate() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Ensure migrations table exists.
|
||||
_, err := d.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create migrations table: %w", err)
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
var count int
|
||||
err := d.db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.version).Scan(&count)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check migration %d: %w", m.version, err)
|
||||
}
|
||||
if count > 0 {
|
||||
continue // already applied
|
||||
}
|
||||
|
||||
if err := d.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, m.up); err != nil {
|
||||
return fmt.Errorf("apply migration %d: %w", m.version, err)
|
||||
}
|
||||
_, err := tx.ExecContext(ctx,
|
||||
"INSERT INTO schema_migrations (version) VALUES (?)", m.version)
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[db] applied migration %d\n", m.version)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Schema v1 (initial) ----
|
||||
|
||||
const schemav1 = `
|
||||
-- Domains
|
||||
CREATE TABLE IF NOT EXISTS domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
dkim_private_enc BLOB,
|
||||
dkim_public TEXT,
|
||||
dkim_selector TEXT NOT NULL DEFAULT 'mail',
|
||||
dkim_algo TEXT NOT NULL DEFAULT 'rsa2048',
|
||||
spf_policy TEXT,
|
||||
dmarc_policy TEXT,
|
||||
max_users INTEGER NOT NULL DEFAULT 0,
|
||||
max_quota_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Users
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
quota_bytes INTEGER NOT NULL DEFAULT 1073741824,
|
||||
used_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
admin BOOLEAN NOT NULL DEFAULT 0,
|
||||
domain_admin BOOLEAN NOT NULL DEFAULT 0,
|
||||
mfa_secret_enc BLOB,
|
||||
mfa_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
recovery_codes_enc BLOB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_domain ON users(domain_id);
|
||||
|
||||
-- User aliases
|
||||
CREATE TABLE IF NOT EXISTS user_aliases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
alias_email TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- Mailboxes (IMAP folders)
|
||||
CREATE TABLE IF NOT EXISTS mailboxes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'custom',
|
||||
parent_id INTEGER REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
uid_validity INTEGER NOT NULL DEFAULT 1,
|
||||
uid_next INTEGER NOT NULL DEFAULT 1,
|
||||
subscribed BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mailboxes_user ON mailboxes(user_id);
|
||||
|
||||
-- Messages
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
uid INTEGER NOT NULL,
|
||||
message_id TEXT,
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
from_email TEXT NOT NULL DEFAULT '',
|
||||
from_name TEXT NOT NULL DEFAULT '',
|
||||
to_list TEXT NOT NULL DEFAULT '',
|
||||
cc_list TEXT NOT NULL DEFAULT '',
|
||||
bcc_list TEXT NOT NULL DEFAULT '',
|
||||
reply_to TEXT NOT NULL DEFAULT '',
|
||||
date TIMESTAMP,
|
||||
body_text_enc BLOB,
|
||||
body_html_enc BLOB,
|
||||
raw_enc BLOB,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
has_attachment BOOLEAN NOT NULL DEFAULT 0,
|
||||
is_read BOOLEAN NOT NULL DEFAULT 0,
|
||||
is_starred BOOLEAN NOT NULL DEFAULT 0,
|
||||
is_draft BOOLEAN NOT NULL DEFAULT 0,
|
||||
flags TEXT NOT NULL DEFAULT '',
|
||||
spam_score INTEGER NOT NULL DEFAULT 0,
|
||||
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP,
|
||||
UNIQUE(mailbox_id, uid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_mailbox ON messages(mailbox_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_uid ON messages(mailbox_id, uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_date ON messages(mailbox_id, date);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_deleted ON messages(mailbox_id, deleted_at);
|
||||
|
||||
-- Attachments
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
data_enc BLOB,
|
||||
data_path TEXT,
|
||||
content_id TEXT,
|
||||
inline BOOLEAN NOT NULL DEFAULT 0,
|
||||
mime_path TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id);
|
||||
|
||||
-- Delivery queue
|
||||
CREATE TABLE IF NOT EXISTS queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER REFERENCES domains(id),
|
||||
from_addr TEXT NOT NULL,
|
||||
to_addr TEXT NOT NULL,
|
||||
raw_enc BLOB NOT NULL,
|
||||
message_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt TIMESTAMP,
|
||||
next_attempt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
error_log TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_status ON queue(status, next_attempt);
|
||||
|
||||
-- Delivery log
|
||||
CREATE TABLE IF NOT EXISTS delivery_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
queue_id INTEGER REFERENCES queue(id) ON DELETE SET NULL,
|
||||
from_addr TEXT NOT NULL,
|
||||
to_addr TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
smtp_code INTEGER NOT NULL DEFAULT 0,
|
||||
smtp_message TEXT NOT NULL DEFAULT '',
|
||||
mx_host TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_delivery_log_created ON delivery_log(created_at);
|
||||
|
||||
-- Sessions
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||
|
||||
-- IP bans
|
||||
CREATE TABLE IF NOT EXISTS ip_bans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL UNIQUE,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
banned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
released_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ip_bans_ip ON ip_bans(ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_ip_bans_expires ON ip_bans(expires_at);
|
||||
|
||||
-- Login attempts
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
user_email TEXT NOT NULL DEFAULT '',
|
||||
success BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON login_attempts(ip, created_at);
|
||||
|
||||
-- Security events
|
||||
CREATE TABLE IF NOT EXISTS security_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_security_events_created ON security_events(created_at);
|
||||
|
||||
-- External accounts (Gmail / Outlook / custom IMAP)
|
||||
CREATE TABLE IF NOT EXISTS external_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
email_address TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
access_token_enc BLOB,
|
||||
refresh_token_enc BLOB,
|
||||
token_expiry TIMESTAMP,
|
||||
imap_host TEXT NOT NULL DEFAULT '',
|
||||
imap_port INTEGER NOT NULL DEFAULT 993,
|
||||
smtp_host TEXT NOT NULL DEFAULT '',
|
||||
smtp_port INTEGER NOT NULL DEFAULT 587,
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
sync_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
last_sync TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_accounts_user ON external_accounts(user_id);
|
||||
|
||||
-- Address books (CardDAV)
|
||||
CREATE TABLE IF NOT EXISTS address_books (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '#4A90E2',
|
||||
sync_token INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Contacts (CardDAV)
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
address_book_id INTEGER NOT NULL REFERENCES address_books(id) ON DELETE CASCADE,
|
||||
uid TEXT NOT NULL,
|
||||
vcard_enc BLOB NOT NULL,
|
||||
etag TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(address_book_id, uid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_book ON contacts(address_book_id);
|
||||
|
||||
-- Calendars (CalDAV)
|
||||
CREATE TABLE IF NOT EXISTS calendars (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '#4CAF50',
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
sync_token INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Calendar events (CalDAV)
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
calendar_id INTEGER NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
|
||||
uid TEXT NOT NULL,
|
||||
ical_enc BLOB NOT NULL,
|
||||
etag TEXT NOT NULL,
|
||||
dt_start TIMESTAMP,
|
||||
dt_end TIMESTAMP,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
recurring BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(calendar_id, uid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_calendar ON calendar_events(calendar_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_dtstart ON calendar_events(calendar_id, dt_start);
|
||||
|
||||
-- Spam Bayesian tokens (per-user)
|
||||
CREATE TABLE IF NOT EXISTS spam_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL,
|
||||
spam_count INTEGER NOT NULL DEFAULT 0,
|
||||
ham_count INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id, token)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_spam_tokens_user ON spam_tokens(user_id, token);
|
||||
`
|
||||
@@ -0,0 +1,158 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
||||
)
|
||||
|
||||
// GetUserByEmail returns the user with the given email (case-insensitive), or nil.
|
||||
func (d *DB) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
|
||||
row := d.db.QueryRowContext(ctx, `
|
||||
SELECT id, domain_id, username, email, password_hash, display_name,
|
||||
quota_bytes, used_bytes, enabled, admin, domain_admin,
|
||||
mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login
|
||||
FROM users WHERE lower(email)=lower(?)`, email)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
// GetUserByID returns the user with the given ID, or nil.
|
||||
func (d *DB) GetUserByID(ctx context.Context, id int64) (*models.User, error) {
|
||||
row := d.db.QueryRowContext(ctx, `
|
||||
SELECT id, domain_id, username, email, password_hash, display_name,
|
||||
quota_bytes, used_bytes, enabled, admin, domain_admin,
|
||||
mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login
|
||||
FROM users WHERE id=?`, id)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
// UserExistsByEmail returns true if any user (enabled or not) has this email or alias.
|
||||
func (d *DB) UserExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||
var count int
|
||||
err := d.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT 1 FROM users WHERE lower(email)=lower(?) AND enabled=1
|
||||
UNION ALL
|
||||
SELECT 1 FROM user_aliases WHERE lower(alias_email)=lower(?)
|
||||
)`, email, email).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// ResolveEmail returns the canonical user for an email or alias, or nil.
|
||||
func (d *DB) ResolveEmail(ctx context.Context, email string) (*models.User, error) {
|
||||
// Direct match first.
|
||||
u, err := d.GetUserByEmail(ctx, email)
|
||||
if err != nil || u != nil {
|
||||
return u, err
|
||||
}
|
||||
|
||||
// Alias match.
|
||||
var userID int64
|
||||
err = d.db.QueryRowContext(ctx,
|
||||
"SELECT user_id FROM user_aliases WHERE lower(alias_email)=lower(?)", email).
|
||||
Scan(&userID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.GetUserByID(ctx, userID)
|
||||
}
|
||||
|
||||
// CreateUser inserts a new user. Returns the new ID.
|
||||
func (d *DB) CreateUser(ctx context.Context, domainID int64, username, email, passwordHash, displayName string, quotaBytes int64, domainAdmin bool) (int64, error) {
|
||||
res, err := d.db.ExecContext(ctx, `
|
||||
INSERT INTO users
|
||||
(domain_id, username, email, password_hash, display_name, quota_bytes,
|
||||
enabled, admin, domain_admin, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, 0, ?, ?)`,
|
||||
domainID, username, email, passwordHash, displayName, quotaBytes, domainAdmin,
|
||||
time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// UpdateUsedBytes sets the cached used_bytes for a user (approximate, updated on store).
|
||||
func (d *DB) UpdateUsedBytes(ctx context.Context, userID int64, delta int64) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE users SET used_bytes = MAX(0, used_bytes + ?) WHERE id=?",
|
||||
delta, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateLastLogin sets last_login to now.
|
||||
func (d *DB) UpdateLastLogin(ctx context.Context, userID int64) {
|
||||
d.db.ExecContext(ctx, //nolint:errcheck — best-effort
|
||||
"UPDATE users SET last_login=? WHERE id=?", time.Now().UTC(), userID)
|
||||
}
|
||||
|
||||
// ListUsers returns all users for a domain.
|
||||
func (d *DB) ListUsers(ctx context.Context, domainID int64) ([]*models.User, error) {
|
||||
rows, err := d.db.QueryContext(ctx, `
|
||||
SELECT id, domain_id, username, email, password_hash, display_name,
|
||||
quota_bytes, used_bytes, enabled, admin, domain_admin,
|
||||
mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login
|
||||
FROM users WHERE domain_id=? ORDER BY email`, domainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []*models.User
|
||||
for rows.Next() {
|
||||
var u models.User
|
||||
var mfaEnc, rcEnc []byte
|
||||
var lastLogin sql.NullTime
|
||||
err := rows.Scan(
|
||||
&u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash,
|
||||
&u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled,
|
||||
&u.Admin, &u.DomainAdmin,
|
||||
&mfaEnc, &u.MFAEnabled, &rcEnc,
|
||||
&u.CreatedAt, &lastLogin,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.MFASecretEnc = mfaEnc
|
||||
u.RecoveryCodesEnc = rcEnc
|
||||
if lastLogin.Valid {
|
||||
u.LastLogin = lastLogin.Time
|
||||
}
|
||||
users = append(users, &u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// ---- private ----
|
||||
|
||||
func scanUser(row *sql.Row) (*models.User, error) {
|
||||
var u models.User
|
||||
var mfaEnc, rcEnc []byte
|
||||
var lastLogin sql.NullTime
|
||||
|
||||
err := row.Scan(
|
||||
&u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash,
|
||||
&u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled,
|
||||
&u.Admin, &u.DomainAdmin,
|
||||
&mfaEnc, &u.MFAEnabled, &rcEnc,
|
||||
&u.CreatedAt, &lastLogin,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan user: %w", err)
|
||||
}
|
||||
u.MFASecretEnc = mfaEnc
|
||||
u.RecoveryCodesEnc = rcEnc
|
||||
if lastLogin.Valid {
|
||||
u.LastLogin = lastLogin.Time
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
Reference in New Issue
Block a user