This commit is contained in:
2026-05-24 17:15:48 +00:00
parent 329d5c665a
commit 063b3b643f
22 changed files with 1348 additions and 92 deletions
+125
View File
@@ -0,0 +1,125 @@
package db
import (
"context"
"database/sql"
"fmt"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
)
// SaveDMARCReport inserts a DMARC aggregate report and its IP-level records atomically.
func (d *DB) SaveDMARCReport(ctx context.Context, report *models.DMARCReport) error {
return d.WithTx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx, `
INSERT INTO dmarc_reports
(domain_id, org_name, org_email, report_id, date_begin, date_end,
policy_domain, policy_adkim, policy_aspf, policy_p, policy_pct)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
report.DomainID, report.OrgName, report.OrgEmail, report.ReportID,
report.DateBegin, report.DateEnd, report.PolicyDomain,
report.PolicyADKIM, report.PolicyASPF, report.PolicyP, report.PolicyPct,
)
if err != nil {
return fmt.Errorf("insert dmarc_report: %w", err)
}
reportID, err := res.LastInsertId()
if err != nil {
return fmt.Errorf("dmarc report last insert id: %w", err)
}
for i := range report.Records {
rec := &report.Records[i]
_, err := tx.ExecContext(ctx, `
INSERT INTO dmarc_records
(report_id, source_ip, count, disposition, dkim_result, spf_result,
header_from, envelope_from, dkim_domain, dkim_selector, spf_domain)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
reportID, rec.SourceIP, rec.Count, rec.Disposition,
rec.DKIMResult, rec.SPFResult, rec.HeaderFrom, rec.EnvelopeFrom,
rec.DKIMDomain, rec.DKIMSelector, rec.SPFDomain,
)
if err != nil {
return fmt.Errorf("insert dmarc_record: %w", err)
}
}
return nil
})
}
// ListDMARCReports returns the most recent DMARC reports for a domain, newest first.
// limit caps the number of reports returned (0 = use default 100).
func (d *DB) ListDMARCReports(ctx context.Context, domainID int64, limit int) ([]*models.DMARCReport, error) {
if limit <= 0 {
limit = 100
}
rows, err := d.db.QueryContext(ctx, `
SELECT id, domain_id, org_name, org_email, report_id, date_begin, date_end,
policy_domain, policy_adkim, policy_aspf, policy_p, policy_pct, received_at
FROM dmarc_reports
WHERE domain_id = ?
ORDER BY received_at DESC
LIMIT ?`, domainID, limit)
if err != nil {
return nil, fmt.Errorf("list dmarc_reports: %w", err)
}
defer rows.Close()
var reports []*models.DMARCReport
for rows.Next() {
var r models.DMARCReport
if err := rows.Scan(
&r.ID, &r.DomainID, &r.OrgName, &r.OrgEmail, &r.ReportID,
&r.DateBegin, &r.DateEnd, &r.PolicyDomain, &r.PolicyADKIM,
&r.PolicyASPF, &r.PolicyP, &r.PolicyPct, &r.ReceivedAt,
); err != nil {
return nil, fmt.Errorf("scan dmarc_report: %w", err)
}
reports = append(reports, &r)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Load IP-level records for each report.
for _, rep := range reports {
if err := d.loadDMARCRecords(ctx, rep); err != nil {
return nil, err
}
}
return reports, nil
}
// loadDMARCRecords fetches and attaches all IP-level records for one report.
func (d *DB) loadDMARCRecords(ctx context.Context, report *models.DMARCReport) error {
rows, err := d.db.QueryContext(ctx, `
SELECT id, report_id, source_ip, count, disposition, dkim_result, spf_result,
header_from, envelope_from, dkim_domain, dkim_selector, spf_domain
FROM dmarc_records WHERE report_id = ? ORDER BY id`, report.ID)
if err != nil {
return fmt.Errorf("load dmarc_records: %w", err)
}
defer rows.Close()
for rows.Next() {
var rec models.DMARCRecord
if err := rows.Scan(
&rec.ID, &rec.ReportID, &rec.SourceIP, &rec.Count, &rec.Disposition,
&rec.DKIMResult, &rec.SPFResult, &rec.HeaderFrom, &rec.EnvelopeFrom,
&rec.DKIMDomain, &rec.DKIMSelector, &rec.SPFDomain,
); err != nil {
return fmt.Errorf("scan dmarc_record: %w", err)
}
report.Records = append(report.Records, rec)
}
return rows.Err()
}
// DMARCReportCount returns the total number of reports stored for a domain.
func (d *DB) DMARCReportCount(ctx context.Context, domainID int64) (int, error) {
var n int
err := d.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM dmarc_reports WHERE domain_id=?", domainID).Scan(&n)
return n, err
}
+69 -40
View File
@@ -8,54 +8,93 @@ import (
"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)
// nullStr converts sql.NullString to plain string (empty if NULL).
func nullStr(ns sql.NullString) string { return ns.String }
// domainCols is the shared column list for all domain SELECT queries.
const domainCols = `id, name, enabled, dkim_private_enc, dkim_public, dkim_selector,
dkim_algo, spf_policy, dmarc_policy, max_users, max_quota_bytes, created_at, dmarc_rua`
// scanDomain reads a domain row from any row scanner.
func scanDomain(r interface {
Scan(dest ...any) error
}) (*models.Domain, error) {
var dom models.Domain
var privEnc []byte
err := row.Scan(
var dkimPublic, dkimSelector, dkimAlgo, spfPolicy, dmarcPolicy, dmarcRua sql.NullString
err := r.Scan(
&dom.ID, &dom.Name, &dom.Enabled,
&privEnc, &dom.DKIMPublic, &dom.DKIMSelector,
&dom.DKIMAlgo, &dom.SPFPolicy, &dom.DMARCPolicy,
&privEnc, &dkimPublic, &dkimSelector,
&dkimAlgo, &spfPolicy, &dmarcPolicy,
&dom.MaxUsers, &dom.MaxQuotaBytes, &dom.CreatedAt,
&dmarcRua,
)
if err != nil {
return nil, err
}
dom.DKIMPrivateEnc = privEnc
dom.DKIMPublic = nullStr(dkimPublic)
dom.DKIMSelector = nullStr(dkimSelector)
dom.DKIMAlgo = nullStr(dkimAlgo)
dom.SPFPolicy = nullStr(spfPolicy)
dom.DMARCPolicy = nullStr(dmarcPolicy)
dom.DMARCRua = nullStr(dmarcRua)
return &dom, nil
}
// 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 "+domainCols+" FROM domains WHERE lower(name) = lower(?)", name)
dom, err := scanDomain(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get domain: %w", err)
}
dom.DKIMPrivateEnc = privEnc
return &dom, nil
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,
)
row := d.db.QueryRowContext(ctx,
"SELECT "+domainCols+" FROM domains WHERE id = ?", id)
dom, err := scanDomain(row)
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
return dom, nil
}
// GetDomainByDMARCRua returns the enabled domain whose dmarc_rua matches the given address.
// Returns nil if no domain is configured for this address.
func (d *DB) GetDomainByDMARCRua(ctx context.Context, rua string) (*models.Domain, error) {
row := d.db.QueryRowContext(ctx,
"SELECT "+domainCols+" FROM domains WHERE lower(dmarc_rua) = lower(?) AND enabled=1", rua)
dom, err := scanDomain(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get domain by dmarc rua: %w", err)
}
return dom, nil
}
// SetDomainDMARCRua sets or clears the DMARC monitoring address for a domain.
// Pass an empty string to disable monitoring.
func (d *DB) SetDomainDMARCRua(ctx context.Context, domainID int64, rua string) error {
var val any
if rua != "" {
val = rua
} // else nil → NULL
_, err := d.db.ExecContext(ctx,
"UPDATE domains SET dmarc_rua=? WHERE id=?", val, domainID)
return err
}
// IsLocalDomain returns true if name is a known enabled domain.
@@ -72,10 +111,8 @@ func (d *DB) IsLocalDomain(ctx context.Context, name string) (bool, error) {
// 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`)
rows, err := d.db.QueryContext(ctx,
"SELECT "+domainCols+" FROM domains ORDER BY name")
if err != nil {
return nil, err
}
@@ -83,19 +120,11 @@ func (d *DB) ListDomains(ctx context.Context) ([]*models.Domain, error) {
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,
)
dom, err := scanDomain(rows)
if err != nil {
return nil, err
}
dom.DKIMPrivateEnc = privEnc
doms = append(doms, &dom)
doms = append(doms, dom)
}
return doms, rows.Err()
}
+41
View File
@@ -217,6 +217,47 @@ type AttachmentInsert struct {
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
+43
View File
@@ -16,6 +16,7 @@ type migration struct {
// migrations must be append-only. Never edit an applied migration.
var migrations = []migration{
{1, schemav1},
{2, schemav2},
}
// migrate applies any unapplied migrations in order.
@@ -335,3 +336,45 @@ CREATE TABLE IF NOT EXISTS spam_tokens (
);
CREATE INDEX IF NOT EXISTS idx_spam_tokens_user ON spam_tokens(user_id, token);
`
// ---- Schema v2: DMARC monitoring ----
const schemav2 = `
-- DMARC monitoring address per domain (nullable; empty = disabled)
ALTER TABLE domains ADD COLUMN dmarc_rua TEXT;
-- DMARC aggregate reports (one row per received report)
CREATE TABLE IF NOT EXISTS dmarc_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
org_name TEXT NOT NULL DEFAULT '',
org_email TEXT NOT NULL DEFAULT '',
report_id TEXT NOT NULL DEFAULT '',
date_begin INTEGER NOT NULL DEFAULT 0,
date_end INTEGER NOT NULL DEFAULT 0,
policy_domain TEXT NOT NULL DEFAULT '',
policy_adkim TEXT NOT NULL DEFAULT '',
policy_aspf TEXT NOT NULL DEFAULT '',
policy_p TEXT NOT NULL DEFAULT '',
policy_pct INTEGER NOT NULL DEFAULT 100,
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_dmarc_reports_domain ON dmarc_reports(domain_id, received_at);
-- DMARC report IP-level records (one row per source IP per report)
CREATE TABLE IF NOT EXISTS dmarc_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id INTEGER NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE,
source_ip TEXT NOT NULL DEFAULT '',
count INTEGER NOT NULL DEFAULT 0,
disposition TEXT NOT NULL DEFAULT '',
dkim_result TEXT NOT NULL DEFAULT '',
spf_result TEXT NOT NULL DEFAULT '',
header_from TEXT NOT NULL DEFAULT '',
envelope_from TEXT NOT NULL DEFAULT '',
dkim_domain TEXT NOT NULL DEFAULT '',
dkim_selector TEXT NOT NULL DEFAULT '',
spf_domain TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_dmarc_records_report ON dmarc_records(report_id);
`