a
This commit is contained in:
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user