a
This commit is contained in:
+2
-1
@@ -3,4 +3,5 @@ test*
|
||||
.db
|
||||
.sqlite
|
||||
.conf
|
||||
.json
|
||||
.json
|
||||
./mailgosend
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
||||
@@ -245,11 +247,18 @@ func scanUser(row *sql.Row) (*models.User, error) {
|
||||
// ---- Helpers ----
|
||||
|
||||
func realIP(r *http.Request) string {
|
||||
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
||||
return ip
|
||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
if parsed := net.ParseIP(strings.TrimSpace(xri)); parsed != nil {
|
||||
return parsed.String()
|
||||
}
|
||||
}
|
||||
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
||||
return ip
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
for _, part := range strings.Split(xff, ",") {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if parsed := net.ParseIP(trimmed); parsed != nil {
|
||||
return parsed.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
host, _, err := parseHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
|
||||
@@ -318,7 +318,7 @@ var allFields = []field{
|
||||
{key: "DNS_SECONDARY", defVal: "8.8.8.8"},
|
||||
|
||||
// --- DKIM ---
|
||||
{key: "DKIM_SELECTOR", defVal: "mail", comments: []string{
|
||||
{key: "DKIM_SELECTOR", defVal: "godkim", comments: []string{
|
||||
"--- DKIM ---",
|
||||
"Default DKIM selector for new domains.",
|
||||
}},
|
||||
@@ -485,7 +485,7 @@ func Load() (*Config, error) {
|
||||
BruteBanHours: atoi(get("BRUTE_BAN_HOURS"), 24),
|
||||
BruteWhitelist: parseIPs(get("BRUTE_WHITELIST_IPS")),
|
||||
TrustedProxies: trustedProxies,
|
||||
SecureCookie: atobool(get("SECURE_COOKIE"), false),
|
||||
SecureCookie: atobool(get("SECURE_COOKIE"), false) || strings.HasPrefix(strings.ToLower(get("BASE_URL")), "https://"),
|
||||
|
||||
SMTPHostname: smtpHostname,
|
||||
MaxRcptPer: atoi(get("MAX_RCPT_PER"), 100),
|
||||
@@ -494,7 +494,7 @@ func Load() (*Config, error) {
|
||||
DNSPrimary: orDefault(get("DNS_PRIMARY"), "1.1.1.1"),
|
||||
DNSSecondary: orDefault(get("DNS_SECONDARY"), "8.8.8.8"),
|
||||
|
||||
DKIMSelector: orDefault(get("DKIM_SELECTOR"), "mail"),
|
||||
DKIMSelector: orDefault(get("DKIM_SELECTOR"), "godkim"),
|
||||
DKIMAlgo: orDefault(get("DKIM_ALGO"), "rsa2048"),
|
||||
|
||||
SpamThreshold: atoi(get("SPAM_THRESHOLD"), 10),
|
||||
|
||||
@@ -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);
|
||||
`
|
||||
|
||||
@@ -327,13 +327,18 @@ func Verify(message []byte) (domain string, err error) {
|
||||
}
|
||||
|
||||
// DNSRecord returns the DKIM TXT record string for publishing.
|
||||
func DNSRecord(selector, domain, publicKeyPEM string) string {
|
||||
// algo must be "rsa2048" or "ed25519"; any other value defaults to "rsa".
|
||||
func DNSRecord(selector, domain, publicKeyPEM, algo string) string {
|
||||
block, _ := pem.Decode([]byte(publicKeyPEM))
|
||||
var p string
|
||||
if block != nil {
|
||||
p = base64.StdEncoding.EncodeToString(block.Bytes)
|
||||
}
|
||||
return fmt.Sprintf("%s._domainkey.%s. IN TXT \"v=DKIM1; k=rsa; p=%s\"", selector, domain, p)
|
||||
k := "rsa"
|
||||
if algo == "ed25519" {
|
||||
k = "ed25519"
|
||||
}
|
||||
return fmt.Sprintf("%s._domainkey.%s. IN TXT \"v=DKIM1; k=%s; p=%s\"", selector, domain, k, p)
|
||||
}
|
||||
|
||||
// SPFRecord returns the recommended SPF TXT record for a domain.
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// Package dmarcreport parses DMARC aggregate report emails (RFC 7489).
|
||||
// Handles gzip and zip compressed XML attachments from multipart emails.
|
||||
package dmarcreport
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Feedback is the top-level DMARC aggregate report (feedback element).
|
||||
type Feedback struct {
|
||||
ReportMetadata ReportMetadata `xml:"report_metadata"`
|
||||
PolicyPublished PolicyPublished `xml:"policy_published"`
|
||||
Records []Record `xml:"record"`
|
||||
}
|
||||
|
||||
// ReportMetadata holds reporter identification and date range.
|
||||
type ReportMetadata struct {
|
||||
OrgName string `xml:"org_name"`
|
||||
Email string `xml:"email"`
|
||||
ReportID string `xml:"report_id"`
|
||||
DateRange DateRange `xml:"date_range"`
|
||||
}
|
||||
|
||||
// DateRange is the Unix timestamp range the report covers.
|
||||
type DateRange struct {
|
||||
Begin int64 `xml:"begin"`
|
||||
End int64 `xml:"end"`
|
||||
}
|
||||
|
||||
// PolicyPublished is the DMARC policy in effect during the report period.
|
||||
type PolicyPublished struct {
|
||||
Domain string `xml:"domain"`
|
||||
ADKIM string `xml:"adkim"`
|
||||
ASPF string `xml:"aspf"`
|
||||
P string `xml:"p"`
|
||||
PCT int `xml:"pct"`
|
||||
}
|
||||
|
||||
// Record is one IP-level row in the report.
|
||||
type Record struct {
|
||||
Row Row `xml:"row"`
|
||||
Identifiers Identifiers `xml:"identifiers"`
|
||||
AuthResults AuthResults `xml:"auth_results"`
|
||||
}
|
||||
|
||||
// Row contains the source IP, message count, and policy evaluation result.
|
||||
type Row struct {
|
||||
SourceIP string `xml:"source_ip"`
|
||||
Count int `xml:"count"`
|
||||
PolicyEvaluated PolicyEvaluated `xml:"policy_evaluated"`
|
||||
}
|
||||
|
||||
// PolicyEvaluated is how DMARC evaluated this IP's messages.
|
||||
type PolicyEvaluated struct {
|
||||
Disposition string `xml:"disposition"`
|
||||
DKIM string `xml:"dkim"`
|
||||
SPF string `xml:"spf"`
|
||||
}
|
||||
|
||||
// Identifiers holds From and envelope domain information.
|
||||
type Identifiers struct {
|
||||
HeaderFrom string `xml:"header_from"`
|
||||
EnvelopeFrom string `xml:"envelope_from"`
|
||||
}
|
||||
|
||||
// AuthResults holds actual DKIM and SPF results.
|
||||
type AuthResults struct {
|
||||
DKIM DKIMAuthResult `xml:"dkim"`
|
||||
SPF SPFAuthResult `xml:"spf"`
|
||||
}
|
||||
|
||||
// DKIMAuthResult is the per-signature DKIM check result.
|
||||
type DKIMAuthResult struct {
|
||||
Domain string `xml:"domain"`
|
||||
Selector string `xml:"selector"`
|
||||
Result string `xml:"result"`
|
||||
}
|
||||
|
||||
// SPFAuthResult is the SPF check result.
|
||||
type SPFAuthResult struct {
|
||||
Domain string `xml:"domain"`
|
||||
Result string `xml:"result"`
|
||||
}
|
||||
|
||||
// ParseReportEmail extracts and parses a DMARC aggregate report from a raw email.
|
||||
// Handles multipart/mixed emails with gzip or zip compressed XML attachments.
|
||||
func ParseReportEmail(rawEmail []byte) (*Feedback, error) {
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(rawEmail))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse email: %w", err)
|
||||
}
|
||||
|
||||
ct := msg.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
ct = "text/plain"
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse content-type: %w", err)
|
||||
}
|
||||
|
||||
// Multipart email — scan parts for compressed XML attachment.
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||
for {
|
||||
part, partErr := mr.NextPart()
|
||||
if partErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if partErr != nil {
|
||||
return nil, fmt.Errorf("read multipart: %w", partErr)
|
||||
}
|
||||
|
||||
partCT := part.Header.Get("Content-Type")
|
||||
partMedia, _, _ := mime.ParseMediaType(partCT)
|
||||
|
||||
partData, readErr := io.ReadAll(io.LimitReader(part, 10<<20))
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("read part: %w", readErr)
|
||||
}
|
||||
|
||||
// Decode base64 Content-Transfer-Encoding if present.
|
||||
if strings.EqualFold(strings.TrimSpace(part.Header.Get("Content-Transfer-Encoding")), "base64") {
|
||||
clean := bytes.Map(func(r rune) rune {
|
||||
if r == '\r' || r == '\n' || r == ' ' || r == '\t' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, partData)
|
||||
decoded := make([]byte, base64.StdEncoding.DecodedLen(len(clean)))
|
||||
n, decErr := base64.StdEncoding.Decode(decoded, clean)
|
||||
if decErr != nil {
|
||||
continue // skip malformed parts
|
||||
}
|
||||
partData = decoded[:n]
|
||||
}
|
||||
|
||||
xmlData, decErr := decompressToXML(partMedia, partData)
|
||||
if decErr == nil && len(xmlData) > 0 {
|
||||
return ParseXML(xmlData)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no DMARC XML attachment found in multipart email")
|
||||
}
|
||||
|
||||
// Single-part — try decompressing the body directly.
|
||||
body, err := io.ReadAll(io.LimitReader(msg.Body, 10<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
xmlData, err := decompressToXML(mediaType, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decompress body: %w", err)
|
||||
}
|
||||
return ParseXML(xmlData)
|
||||
}
|
||||
|
||||
// ParseXML parses a raw DMARC XML aggregate report.
|
||||
func ParseXML(data []byte) (*Feedback, error) {
|
||||
var f Feedback
|
||||
if err := xml.Unmarshal(data, &f); err != nil {
|
||||
return nil, fmt.Errorf("parse dmarc xml: %w", err)
|
||||
}
|
||||
if f.ReportMetadata.ReportID == "" {
|
||||
return nil, fmt.Errorf("missing report_id in DMARC report XML")
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// decompressToXML attempts to return raw XML from possibly-compressed data.
|
||||
// Detection is by magic bytes, not MIME type (senders are inconsistent).
|
||||
func decompressToXML(mediaType string, data []byte) ([]byte, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("empty data")
|
||||
}
|
||||
|
||||
// gzip magic: 0x1f 0x8b
|
||||
if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b {
|
||||
r, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gzip open: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
out, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gzip read: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// zip magic: PK 0x03 0x04
|
||||
if len(data) >= 4 && data[0] == 'P' && data[1] == 'K' && data[2] == 0x03 && data[3] == 0x04 {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zip open: %w", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if strings.HasSuffix(strings.ToLower(f.Name), ".xml") {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zip entry open: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
out, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zip entry read: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no .xml file found in zip")
|
||||
}
|
||||
|
||||
// Try as raw XML.
|
||||
trimmed := bytes.TrimSpace(data)
|
||||
if bytes.HasPrefix(trimmed, []byte("<?xml")) || bytes.HasPrefix(trimmed, []byte("<feedback")) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unrecognised format (media-type: %s)", mediaType)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func SecureHeaders(next http.Handler) http.Handler {
|
||||
h := w.Header()
|
||||
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "SAMEORIGIN")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||
h.Set("Content-Security-Policy",
|
||||
|
||||
+48
-11
@@ -6,18 +6,55 @@ import "time"
|
||||
// ---- Domain & User ----
|
||||
|
||||
type Domain struct {
|
||||
ID int64
|
||||
Name string
|
||||
Enabled bool
|
||||
ID int64
|
||||
Name string
|
||||
Enabled bool
|
||||
DKIMPrivateEnc []byte // AES-256-GCM encrypted PEM private key
|
||||
DKIMPublic string // PEM public key (not secret)
|
||||
DKIMSelector string
|
||||
DKIMAlgo string // rsa2048 | ed25519
|
||||
SPFPolicy string // stored for reference
|
||||
DMARCPolicy string // stored for reference
|
||||
MaxUsers int
|
||||
MaxQuotaBytes int64
|
||||
CreatedAt time.Time
|
||||
DKIMPublic string // PEM public key (not secret)
|
||||
DKIMSelector string
|
||||
DKIMAlgo string // rsa2048 | ed25519
|
||||
SPFPolicy string // stored for reference
|
||||
DMARCPolicy string // stored for reference
|
||||
MaxUsers int
|
||||
MaxQuotaBytes int64
|
||||
CreatedAt time.Time
|
||||
DMARCRua string // monitoring address e.g. dmarc-abc123@domain.com; empty = disabled
|
||||
}
|
||||
|
||||
// ---- DMARC aggregate reports ----
|
||||
|
||||
// DMARCReport is a parsed RFC 7489 aggregate report received at the monitoring address.
|
||||
type DMARCReport struct {
|
||||
ID int64
|
||||
DomainID int64
|
||||
OrgName string
|
||||
OrgEmail string
|
||||
ReportID string
|
||||
DateBegin int64 // unix timestamp
|
||||
DateEnd int64 // unix timestamp
|
||||
PolicyDomain string
|
||||
PolicyADKIM string
|
||||
PolicyASPF string
|
||||
PolicyP string
|
||||
PolicyPct int
|
||||
ReceivedAt time.Time
|
||||
Records []DMARCRecord
|
||||
}
|
||||
|
||||
// DMARCRecord is one IP-level row within a DMARCReport.
|
||||
type DMARCRecord struct {
|
||||
ID int64
|
||||
ReportID int64
|
||||
SourceIP string
|
||||
Count int
|
||||
Disposition string // none | quarantine | reject
|
||||
DKIMResult string // pass | fail | none
|
||||
SPFResult string // pass | fail | none
|
||||
HeaderFrom string
|
||||
EnvelopeFrom string
|
||||
DKIMDomain string
|
||||
DKIMSelector string
|
||||
SPFDomain string
|
||||
}
|
||||
|
||||
type User struct {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
gosmtp "github.com/emersion/go-smtp"
|
||||
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/dkim"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/dmarcreport"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/spam"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/spf"
|
||||
@@ -58,6 +59,7 @@ type InboundSession struct {
|
||||
fromDomain string
|
||||
rcpts []string // validated local recipients (email addresses)
|
||||
rcptUsers []int64 // corresponding user IDs
|
||||
dmarcDomains []int64 // domain IDs whose dmarc_rua matched a recipient
|
||||
spfResult spf.Result
|
||||
log func(string, ...any)
|
||||
}
|
||||
@@ -123,6 +125,12 @@ func (s *InboundSession) Rcpt(to string, opts *gosmtp.RcptOptions) error {
|
||||
return &gosmtp.SMTPError{Code: 451, EnhancedCode: gosmtp.EnhancedCode{4, 3, 0}, Message: "temporary lookup error"}
|
||||
}
|
||||
if user == nil || !user.Enabled {
|
||||
// Check if this is a DMARC monitoring address for any domain.
|
||||
dom, domErr := s.deps.DB.GetDomainByDMARCRua(ctx, email)
|
||||
if domErr == nil && dom != nil {
|
||||
s.dmarcDomains = append(s.dmarcDomains, dom.ID)
|
||||
return nil // accepted for DMARC report processing
|
||||
}
|
||||
return &gosmtp.SMTPError{Code: 550, EnhancedCode: gosmtp.EnhancedCode{5, 1, 1}, Message: "user unknown"}
|
||||
}
|
||||
|
||||
@@ -233,14 +241,72 @@ func (s *InboundSession) Data(r io.Reader) error {
|
||||
s.log("delivered to %s (spam=%v score=%d)", rcptEmail, spamResult.IsSpam, spamResult.Total)
|
||||
}
|
||||
|
||||
// Process DMARC aggregate reports if any recipient was a monitoring address.
|
||||
if len(s.dmarcDomains) > 0 {
|
||||
s.processDMARCReport(ctx, raw)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processDMARCReport parses and stores a DMARC aggregate report for each monitoring domain.
|
||||
func (s *InboundSession) processDMARCReport(ctx context.Context, raw []byte) {
|
||||
feedback, err := dmarcreport.ParseReportEmail(raw)
|
||||
if err != nil {
|
||||
s.log("dmarc report parse error from %s: %v", s.from, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, domainID := range s.dmarcDomains {
|
||||
report := feedbackToModel(domainID, feedback)
|
||||
if storeErr := s.deps.DB.SaveDMARCReport(ctx, report); storeErr != nil {
|
||||
s.log("dmarc report store error (domain %d): %v", domainID, storeErr)
|
||||
} else {
|
||||
s.log("stored dmarc report from %s for domain %d (report_id=%s, records=%d)",
|
||||
feedback.ReportMetadata.OrgName, domainID,
|
||||
feedback.ReportMetadata.ReportID, len(feedback.Records))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// feedbackToModel converts a parsed DMARC Feedback to a models.DMARCReport.
|
||||
func feedbackToModel(domainID int64, f *dmarcreport.Feedback) *models.DMARCReport {
|
||||
report := &models.DMARCReport{
|
||||
DomainID: domainID,
|
||||
OrgName: f.ReportMetadata.OrgName,
|
||||
OrgEmail: f.ReportMetadata.Email,
|
||||
ReportID: f.ReportMetadata.ReportID,
|
||||
DateBegin: f.ReportMetadata.DateRange.Begin,
|
||||
DateEnd: f.ReportMetadata.DateRange.End,
|
||||
PolicyDomain: f.PolicyPublished.Domain,
|
||||
PolicyADKIM: f.PolicyPublished.ADKIM,
|
||||
PolicyASPF: f.PolicyPublished.ASPF,
|
||||
PolicyP: f.PolicyPublished.P,
|
||||
PolicyPct: f.PolicyPublished.PCT,
|
||||
}
|
||||
for _, rec := range f.Records {
|
||||
report.Records = append(report.Records, models.DMARCRecord{
|
||||
SourceIP: rec.Row.SourceIP,
|
||||
Count: rec.Row.Count,
|
||||
Disposition: rec.Row.PolicyEvaluated.Disposition,
|
||||
DKIMResult: rec.Row.PolicyEvaluated.DKIM,
|
||||
SPFResult: rec.Row.PolicyEvaluated.SPF,
|
||||
HeaderFrom: rec.Identifiers.HeaderFrom,
|
||||
EnvelopeFrom: rec.Identifiers.EnvelopeFrom,
|
||||
DKIMDomain: rec.AuthResults.DKIM.Domain,
|
||||
DKIMSelector: rec.AuthResults.DKIM.Selector,
|
||||
SPFDomain: rec.AuthResults.SPF.Domain,
|
||||
})
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func (s *InboundSession) Reset() {
|
||||
s.from = ""
|
||||
s.fromDomain = ""
|
||||
s.rcpts = s.rcpts[:0]
|
||||
s.rcptUsers = s.rcptUsers[:0]
|
||||
s.dmarcDomains = s.dmarcDomains[:0]
|
||||
s.spfResult = spf.ResultNone
|
||||
}
|
||||
|
||||
|
||||
@@ -200,11 +200,23 @@ func (w *QueueWorker) sendDSN(ctx context.Context, originalFrom, failedTo, reaso
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeDSNField strips CR and LF from a DSN field to prevent header injection.
|
||||
func sanitizeDSNField(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == '\r' || r == '\n' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
// buildDSN constructs a minimal RFC 3464 multipart/report message.
|
||||
func buildDSN(hostname, failedTo, reason string) ([]byte, error) {
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
failedTo = sanitizeDSNField(failedTo)
|
||||
reason = sanitizeDSNField(reason)
|
||||
now := time.Now().UTC()
|
||||
msgID := fmt.Sprintf("<dsn-%d@%s>", now.UnixNano(), hostname)
|
||||
|
||||
|
||||
@@ -232,6 +232,42 @@ func (s *Store) GetBodyParts(ctx context.Context, userID, messageID int64) (*Bod
|
||||
return bp, nil
|
||||
}
|
||||
|
||||
// GetAttachmentData decrypts and returns the bytes of the n-th attachment (0-based) for a message.
|
||||
// Also returns filename and content-type. Returns an error when the attachment does not exist.
|
||||
func (s *Store) GetAttachmentData(ctx context.Context, userID, messageID int64, n int) (data []byte, filename, contentType string, err error) {
|
||||
att, err := s.db.GetAttachmentByIndex(ctx, messageID, n)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("storage: get attachment index: %w", err)
|
||||
}
|
||||
if att == nil {
|
||||
return nil, "", "", fmt.Errorf("storage: attachment %d not found for message %d", n, messageID)
|
||||
}
|
||||
|
||||
key, err := s.crypt.DeriveKey("messages", userID)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("storage: derive key: %w", err)
|
||||
}
|
||||
|
||||
var encData []byte
|
||||
if att.DataPath != "" {
|
||||
encData, err = os.ReadFile(att.DataPath)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("storage: read attachment file: %w", err)
|
||||
}
|
||||
} else {
|
||||
encData = att.DataEnc
|
||||
}
|
||||
if len(encData) == 0 {
|
||||
return nil, "", "", fmt.Errorf("storage: attachment data empty")
|
||||
}
|
||||
|
||||
plain, err := crypto.Decrypt(key, encData)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("storage: decrypt attachment: %w", err)
|
||||
}
|
||||
return plain, att.Filename, att.ContentType, nil
|
||||
}
|
||||
|
||||
// parseMIME walks a raw RFC822 message and extracts body parts and attachments.
|
||||
func parseMIME(raw []byte) (bodyText, bodyHTML string, attachments []parsedAttachment) {
|
||||
m, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
|
||||
+54
-2
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,50 @@ const (
|
||||
RecoveryCodeLen = 8
|
||||
)
|
||||
|
||||
// ---- Replay protection ----
|
||||
|
||||
// replayCache prevents reuse of a TOTP code within its valid window (~90 s).
|
||||
// Key: "secret:step:code" — value: expiry time.
|
||||
var (
|
||||
replayMu sync.Mutex
|
||||
replayCache = map[string]time.Time{}
|
||||
)
|
||||
|
||||
// replayCacheKey builds the dedup key from the raw secret bytes, time step, and code.
|
||||
func replayCacheKey(secret string, step int64, code string) string {
|
||||
return fmt.Sprintf("%s:%d:%s", secret, step, code)
|
||||
}
|
||||
|
||||
// markUsed records a code as consumed. Expires after (Window+2)*Period seconds.
|
||||
func markUsed(secret string, step int64, code string) {
|
||||
key := replayCacheKey(secret, step, code)
|
||||
exp := time.Now().Add(time.Duration((Window+2)*Period) * time.Second)
|
||||
replayMu.Lock()
|
||||
replayCache[key] = exp
|
||||
replayMu.Unlock()
|
||||
}
|
||||
|
||||
// isReplay returns true when the code has already been consumed for this step.
|
||||
func isReplay(secret string, step int64, code string) bool {
|
||||
key := replayCacheKey(secret, step, code)
|
||||
replayMu.Lock()
|
||||
exp, ok := replayCache[key]
|
||||
replayMu.Unlock()
|
||||
return ok && time.Now().Before(exp)
|
||||
}
|
||||
|
||||
// pruneReplayCache removes expired entries. Caller must NOT hold replayMu.
|
||||
func pruneReplayCache() {
|
||||
now := time.Now()
|
||||
replayMu.Lock()
|
||||
for k, exp := range replayCache {
|
||||
if now.After(exp) {
|
||||
delete(replayCache, k)
|
||||
}
|
||||
}
|
||||
replayMu.Unlock()
|
||||
}
|
||||
|
||||
// GenerateSecret returns a cryptographically random base32-encoded TOTP secret.
|
||||
func GenerateSecret() (string, error) {
|
||||
raw := make([]byte, SecretBytes)
|
||||
@@ -58,16 +103,23 @@ func OTPAuthURI(secret, accountName, issuer string) string {
|
||||
}
|
||||
|
||||
// Verify checks a 6-digit code against the secret for the current time window (±Window steps).
|
||||
// Returns true if valid. secret is base32-encoded (no padding).
|
||||
// Returns true if valid. Codes are single-use within their validity window (replay protection).
|
||||
// secret is base32-encoded (no padding).
|
||||
func Verify(secret, code string) bool {
|
||||
raw, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
pruneReplayCache()
|
||||
now := time.Now().Unix()
|
||||
step := now / Period
|
||||
for w := int64(-Window); w <= int64(Window); w++ {
|
||||
if hotp(raw, step+w) == code {
|
||||
s := step + w
|
||||
if hotp(raw, s) == code {
|
||||
if isReplay(secret, s, code) {
|
||||
return false // replay: code already used in this window
|
||||
}
|
||||
markUsed(secret, s, code)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
+237
-15
@@ -2,6 +2,8 @@ package webadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptoRand "crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
@@ -78,7 +80,7 @@ func (s *Server) domainsCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if selector == "" {
|
||||
selector = "mail"
|
||||
selector = "godkim"
|
||||
}
|
||||
if !validIdentifier(selector) {
|
||||
redirect(w, r, "/admin/domains", "", "Invalid DKIM selector.")
|
||||
@@ -105,12 +107,19 @@ func (s *Server) domainsCreate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
type domainDetailData struct {
|
||||
basePage
|
||||
Domain *models.Domain
|
||||
Users []*models.User
|
||||
// DNS hint strings
|
||||
DKIMRecord string
|
||||
SPFHint string
|
||||
DMARCHint string
|
||||
Domain *models.Domain
|
||||
Users []*models.User
|
||||
Hostname string
|
||||
DMARCReportCount int
|
||||
// DNS records (what to configure)
|
||||
MXRecord string
|
||||
DKIMRecord string
|
||||
SPFHint string
|
||||
DMARCHint string
|
||||
AutoconfigCNAME string
|
||||
AutodiscoverCNAME string
|
||||
SMTPSRVRecord string
|
||||
IMAPSRVRecord string
|
||||
}
|
||||
|
||||
func (s *Server) domainDetail(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -127,9 +136,13 @@ func (s *Server) domainDetail(w http.ResponseWriter, r *http.Request) {
|
||||
users, _ := s.deps.DB.ListUsers(ctx, id)
|
||||
flash, errMsg := flashFrom(r)
|
||||
|
||||
hostname := s.deps.Cfg.Hostname
|
||||
if hostname == "" || hostname == "mail.example.com" {
|
||||
hostname = "mail." + dom.Name
|
||||
}
|
||||
|
||||
dkimRec := ""
|
||||
if dom.DKIMPublic != "" {
|
||||
// Strip PEM headers and newlines to get bare base64 for DNS TXT.
|
||||
pub := strings.ReplaceAll(dom.DKIMPublic, "-----BEGIN PUBLIC KEY-----", "")
|
||||
pub = strings.ReplaceAll(pub, "-----END PUBLIC KEY-----", "")
|
||||
pub = strings.ReplaceAll(pub, "\n", "")
|
||||
@@ -138,16 +151,126 @@ func (s *Server) domainDetail(w http.ResponseWriter, r *http.Request) {
|
||||
dom.DKIMSelector, dom.Name, dkimAlgoKey(dom.DKIMAlgo), pub)
|
||||
}
|
||||
|
||||
// Build DMARC hint — use monitoring address when configured.
|
||||
dmarcHint := ""
|
||||
if dom.DMARCRua != "" {
|
||||
dmarcHint = fmt.Sprintf(`_dmarc.%s IN TXT "v=DMARC1; p=quarantine; rua=mailto:%s"`, dom.Name, dom.DMARCRua)
|
||||
} else {
|
||||
dmarcHint = fmt.Sprintf(`_dmarc.%s IN TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@%s"`, dom.Name, dom.Name)
|
||||
}
|
||||
|
||||
dmarcCount, _ := s.deps.DB.DMARCReportCount(ctx, id)
|
||||
|
||||
s.render(w, "domain", domainDetailData{
|
||||
basePage: s.newBase(r, flash, errMsg),
|
||||
Domain: dom,
|
||||
Users: users,
|
||||
DKIMRecord: dkimRec,
|
||||
SPFHint: fmt.Sprintf(`%s IN TXT "v=spf1 a mx ~all"`, dom.Name),
|
||||
DMARCHint: fmt.Sprintf(`_dmarc.%s IN TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@%s"`, dom.Name, dom.Name),
|
||||
basePage: s.newBase(r, flash, errMsg),
|
||||
Domain: dom,
|
||||
Users: users,
|
||||
Hostname: hostname,
|
||||
DMARCReportCount: dmarcCount,
|
||||
MXRecord: fmt.Sprintf(`%s IN MX 10 %s.`, dom.Name, hostname),
|
||||
DKIMRecord: dkimRec,
|
||||
SPFHint: fmt.Sprintf(`%s IN TXT "v=spf1 a mx ~all"`, dom.Name),
|
||||
DMARCHint: dmarcHint,
|
||||
AutoconfigCNAME: fmt.Sprintf(`autoconfig.%s IN CNAME %s.`, dom.Name, hostname),
|
||||
AutodiscoverCNAME: fmt.Sprintf(`autodiscover.%s IN CNAME %s.`, dom.Name, hostname),
|
||||
SMTPSRVRecord: fmt.Sprintf(`_submission._tcp.%s IN SRV 0 1 587 %s.`, dom.Name, hostname),
|
||||
IMAPSRVRecord: fmt.Sprintf(`_imaps._tcp.%s IN SRV 0 1 993 %s.`, dom.Name, hostname),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- DNS check ----
|
||||
|
||||
type dnsCheckItem struct {
|
||||
Status string `json:"status"` // "ok" | "warn" | "error" | "missing"
|
||||
Found string `json:"found"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type dnsCheckResult struct {
|
||||
SPF dnsCheckItem `json:"spf"`
|
||||
MX dnsCheckItem `json:"mx"`
|
||||
DKIM dnsCheckItem `json:"dkim"`
|
||||
DMARC dnsCheckItem `json:"dmarc"`
|
||||
}
|
||||
|
||||
func (s *Server) domainDNSCheck(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id := pathID(r, "id")
|
||||
dom, err := s.deps.DB.GetDomainByID(ctx, id)
|
||||
if err != nil || dom == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
result := dnsCheckResult{
|
||||
SPF: dnsSPFCheck(dom.Name),
|
||||
MX: dnsMXCheck(dom.Name),
|
||||
DKIM: dnsDKIMCheck(dom.DKIMSelector, dom.Name),
|
||||
DMARC: dnsDMARCCheck(dom.Name),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||
log.Printf("[admin] dnscheck encode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func dnsSPFCheck(domain string) dnsCheckItem {
|
||||
txts, err := net.LookupTXT(domain)
|
||||
if err != nil {
|
||||
return dnsCheckItem{"missing", "", "DNS lookup failed: " + err.Error()}
|
||||
}
|
||||
for _, txt := range txts {
|
||||
if strings.HasPrefix(strings.TrimSpace(txt), "v=spf1") {
|
||||
return dnsCheckItem{"ok", txt, "SPF record found"}
|
||||
}
|
||||
}
|
||||
return dnsCheckItem{"error", "", "No SPF TXT record found. Add: v=spf1 a mx ~all"}
|
||||
}
|
||||
|
||||
func dnsMXCheck(domain string) dnsCheckItem {
|
||||
mxs, err := net.LookupMX(domain)
|
||||
if err != nil || len(mxs) == 0 {
|
||||
return dnsCheckItem{"warn", "", "No MX record found (optional but strongly recommended)"}
|
||||
}
|
||||
var parts []string
|
||||
for _, mx := range mxs {
|
||||
parts = append(parts, fmt.Sprintf("%d %s", mx.Pref, mx.Host))
|
||||
}
|
||||
return dnsCheckItem{"ok", strings.Join(parts, ", "), "MX record found"}
|
||||
}
|
||||
|
||||
func dnsDKIMCheck(selector, domain string) dnsCheckItem {
|
||||
if selector == "" {
|
||||
return dnsCheckItem{"warn", "", "No DKIM selector configured for this domain"}
|
||||
}
|
||||
name := selector + "._domainkey." + domain
|
||||
txts, err := net.LookupTXT(name)
|
||||
if err != nil || len(txts) == 0 {
|
||||
return dnsCheckItem{"error", "", "DKIM TXT record not found at " + name}
|
||||
}
|
||||
rec := strings.Join(txts, "")
|
||||
if strings.Contains(rec, "v=DKIM1") {
|
||||
return dnsCheckItem{"ok", rec, "DKIM record found at " + name}
|
||||
}
|
||||
return dnsCheckItem{"warn", rec, "TXT record found but missing v=DKIM1 tag at " + name}
|
||||
}
|
||||
|
||||
func dnsDMARCCheck(domain string) dnsCheckItem {
|
||||
txts, err := net.LookupTXT("_dmarc." + domain)
|
||||
if err != nil || len(txts) == 0 {
|
||||
return dnsCheckItem{"warn", "", "No DMARC record found (recommended for email reputation)"}
|
||||
}
|
||||
for _, txt := range txts {
|
||||
if strings.HasPrefix(strings.TrimSpace(txt), "v=DMARC1") {
|
||||
return dnsCheckItem{"ok", txt, "DMARC record found"}
|
||||
}
|
||||
}
|
||||
return dnsCheckItem{"warn", "", "No valid v=DMARC1 record found at _dmarc." + domain}
|
||||
}
|
||||
|
||||
func (s *Server) domainToggleEnable(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -606,6 +729,90 @@ func (s *Server) eventsList(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// ---- DMARC monitoring ----
|
||||
|
||||
// domainEnableDMARC generates a random DMARC monitoring address for a domain.
|
||||
func (s *Server) domainEnableDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
id := pathID(r, "id")
|
||||
dom, err := s.deps.DB.GetDomainByID(ctx, id)
|
||||
if err != nil || dom == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a random 6-byte token → 12 hex chars.
|
||||
token, err := generateToken(6)
|
||||
if err != nil {
|
||||
log.Printf("[admin] dmarc token gen: %v", err)
|
||||
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Failed to generate monitoring address.")
|
||||
return
|
||||
}
|
||||
rua := "dmarc-" + token + "@" + dom.Name
|
||||
|
||||
if err := s.deps.DB.SetDomainDMARCRua(ctx, id, rua); err != nil {
|
||||
log.Printf("[admin] dmarc enable: %v", err)
|
||||
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Failed to save monitoring address.")
|
||||
return
|
||||
}
|
||||
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id),
|
||||
"DMARC monitoring enabled. Update your DNS DMARC record with the new rua= address.", "")
|
||||
}
|
||||
|
||||
// domainDisableDMARC clears the DMARC monitoring address for a domain.
|
||||
func (s *Server) domainDisableDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
id := pathID(r, "id")
|
||||
if err := s.deps.DB.SetDomainDMARCRua(ctx, id, ""); err != nil {
|
||||
log.Printf("[admin] dmarc disable: %v", err)
|
||||
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Failed to disable monitoring.")
|
||||
return
|
||||
}
|
||||
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "DMARC monitoring disabled.", "")
|
||||
}
|
||||
|
||||
// domainDMARCReports renders the DMARC aggregate reports page for a domain.
|
||||
func (s *Server) domainDMARCReports(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id := pathID(r, "id")
|
||||
dom, err := s.deps.DB.GetDomainByID(ctx, id)
|
||||
if err != nil || dom == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
reports, err := s.deps.DB.ListDMARCReports(ctx, id, limit)
|
||||
if err != nil {
|
||||
log.Printf("[admin] dmarc reports: %v", err)
|
||||
}
|
||||
|
||||
flash, errMsg := flashFrom(r)
|
||||
s.render(w, "dmarc", struct {
|
||||
basePage
|
||||
Domain *models.Domain
|
||||
Reports []*models.DMARCReport
|
||||
}{
|
||||
basePage: s.newBase(r, flash, errMsg),
|
||||
Domain: dom,
|
||||
Reports: reports,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- internal helpers ----
|
||||
|
||||
// validateCSRF checks the CSRF token; on failure writes a 403 and returns false.
|
||||
@@ -632,6 +839,15 @@ func pathID(r *http.Request, key string) int64 {
|
||||
return id
|
||||
}
|
||||
|
||||
// generateToken returns n random bytes as a lowercase hex string.
|
||||
func generateToken(n int) (string, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := cryptoRand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", buf), nil
|
||||
}
|
||||
|
||||
// validDomain accepts simple dot-separated labels (a-z0-9 and hyphens).
|
||||
func validDomain(s string) bool {
|
||||
if len(s) < 3 || len(s) > 253 {
|
||||
@@ -791,7 +1007,7 @@ func (s *Server) setupPost(w http.ResponseWriter, r *http.Request) {
|
||||
// Create domain first (user references it).
|
||||
selector := s.deps.Cfg.DKIMSelector
|
||||
if selector == "" {
|
||||
selector = "mail"
|
||||
selector = "godkim"
|
||||
}
|
||||
algo := s.deps.Cfg.DKIMAlgo
|
||||
if algo == "" {
|
||||
@@ -804,6 +1020,12 @@ func (s *Server) setupPost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-generate DKIM key pair for the new domain.
|
||||
if err := s.generateDKIM(ctx, domainID, algo, selector); err != nil {
|
||||
log.Printf("[admin] setup dkim keygen: %v", err)
|
||||
// Non-fatal — admin can regenerate from domain detail page.
|
||||
}
|
||||
|
||||
// Create admin user.
|
||||
userID, err := s.deps.DB.CreateAdminUser(ctx, domainID, email, hash)
|
||||
if err != nil {
|
||||
|
||||
@@ -78,6 +78,10 @@ func (s *Server) setupRoutes() {
|
||||
m.HandleFunc("GET /admin/domains", s.require(s.domainsList))
|
||||
m.HandleFunc("POST /admin/domains", s.require(s.domainsCreate))
|
||||
m.HandleFunc("GET /admin/domains/{id}", s.require(s.domainDetail))
|
||||
m.HandleFunc("GET /admin/domains/{id}/dnscheck", s.require(s.domainDNSCheck))
|
||||
m.HandleFunc("GET /admin/domains/{id}/dmarc", s.require(s.domainDMARCReports))
|
||||
m.HandleFunc("POST /admin/domains/{id}/dmarc/enable", s.require(s.domainEnableDMARC))
|
||||
m.HandleFunc("POST /admin/domains/{id}/dmarc/disable", s.require(s.domainDisableDMARC))
|
||||
m.HandleFunc("POST /admin/domains/{id}/enable", s.require(s.domainToggleEnable))
|
||||
m.HandleFunc("POST /admin/domains/{id}/dkim", s.require(s.domainGenDKIM))
|
||||
m.HandleFunc("POST /admin/domains/{id}/limits", s.require(s.domainSetLimits))
|
||||
@@ -237,6 +241,48 @@ var tmplFuncs = template.FuncMap{
|
||||
"shortTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
|
||||
"isZero": func(t time.Time) bool { return t.IsZero() },
|
||||
"mb": func(b int64) int64 { return b / 1024 / 1024 },
|
||||
"not": func(v any) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return !val
|
||||
case string:
|
||||
return val == ""
|
||||
case int:
|
||||
return val == 0
|
||||
case int64:
|
||||
return val == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
},
|
||||
"unixDate": func(ts int64) string { return time.Unix(ts, 0).UTC().Format("2006-01-02") },
|
||||
// passBadge renders a coloured pass/fail/none badge for DMARC auth results.
|
||||
"passBadge": func(result string) template.HTML {
|
||||
switch result {
|
||||
case "pass":
|
||||
return template.HTML(`<span style="background:#064e3b;color:#6ee7b7;border-radius:.25rem;padding:.125rem .375rem;font-size:.7rem;font-weight:600">pass</span>`)
|
||||
case "fail":
|
||||
return template.HTML(`<span style="background:#7f1d1d;color:#fca5a5;border-radius:.25rem;padding:.125rem .375rem;font-size:.7rem;font-weight:600">fail</span>`)
|
||||
default:
|
||||
return template.HTML(`<span style="background:#1f2937;color:#9ca3af;border-radius:.25rem;padding:.125rem .375rem;font-size:.7rem">` + template.HTMLEscapeString(result) + `</span>`)
|
||||
}
|
||||
},
|
||||
// dispositionBadge renders a coloured badge for DMARC disposition values.
|
||||
"dispositionBadge": func(result string) template.HTML {
|
||||
switch result {
|
||||
case "none":
|
||||
return template.HTML(`<span style="background:#1f2937;color:#9ca3af;border-radius:.25rem;padding:.125rem .375rem;font-size:.7rem">none</span>`)
|
||||
case "quarantine":
|
||||
return template.HTML(`<span style="background:#78350f;color:#fbbf24;border-radius:.25rem;padding:.125rem .375rem;font-size:.7rem;font-weight:600">quarantine</span>`)
|
||||
case "reject":
|
||||
return template.HTML(`<span style="background:#7f1d1d;color:#fca5a5;border-radius:.25rem;padding:.125rem .375rem;font-size:.7rem;font-weight:600">reject</span>`)
|
||||
default:
|
||||
return template.HTML(`<span style="background:#1f2937;color:#9ca3af;border-radius:.25rem;padding:.125rem .375rem;font-size:.7rem">` + template.HTMLEscapeString(result) + `</span>`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func humanBytes(b int64) string {
|
||||
|
||||
@@ -62,10 +62,10 @@ func BuildRFC5322(p *ComposeParams) ([]byte, error) {
|
||||
writeHeader("Message-Id", p.MessageID)
|
||||
writeHeader("MIME-Version", "1.0")
|
||||
if p.InReplyTo != "" {
|
||||
writeHeader("In-Reply-To", p.InReplyTo)
|
||||
writeHeader("In-Reply-To", sanitizeHeaderValue(p.InReplyTo))
|
||||
}
|
||||
if p.References != "" {
|
||||
writeHeader("References", p.References)
|
||||
writeHeader("References", sanitizeHeaderValue(p.References))
|
||||
}
|
||||
|
||||
// Write body as quoted-printable text/plain.
|
||||
@@ -191,3 +191,13 @@ func (s *Server) enqueueForDelivery(ctx context.Context, fromEmail, toEmail stri
|
||||
_, err = s.deps.DB.EnqueueMessage(ctx, domID, fromEmail, toEmail, msgID, rawEnc, maxAge)
|
||||
return err
|
||||
}
|
||||
|
||||
// sanitizeHeaderValue strips CR and LF from a header value to prevent injection.
|
||||
func sanitizeHeaderValue(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == '\r' || r == '\n' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -255,8 +257,9 @@ func (s *Server) messageFlag(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Return to message or mailbox depending on referrer.
|
||||
// Validate returnTo to prevent open redirect: must start with "/" but not "//".
|
||||
returnTo := r.FormValue("return")
|
||||
if returnTo == "" {
|
||||
if returnTo == "" || !strings.HasPrefix(returnTo, "/") || strings.HasPrefix(returnTo, "//") {
|
||||
returnTo = fmt.Sprintf("/mail/%d/%d", boxID, uid64)
|
||||
}
|
||||
http.Redirect(w, r, returnTo, http.StatusSeeOther)
|
||||
@@ -470,7 +473,7 @@ func (s *Server) composeSend(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
toAddrs, err := parseAddressList(toRaw)
|
||||
if err != nil || len(toAddrs) == 0 {
|
||||
redirect(w, r, "/compose", "", "Invalid To address: "+err.Error())
|
||||
redirect(w, r, "/compose", "", "Invalid To address.")
|
||||
return
|
||||
}
|
||||
ccAddrs, _ := parseAddressList(ccRaw)
|
||||
@@ -902,3 +905,65 @@ func clearPendingTOTP(w http.ResponseWriter) {
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Attachment download ----
|
||||
|
||||
// messageAttachment serves a decrypted attachment by its 0-based index within a message.
|
||||
// The route is GET /mail/{boxid}/{uid}/attachment/{n}.
|
||||
func (s *Server) messageAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
user := s.currentUser(r)
|
||||
boxID := pathID(r, "boxid")
|
||||
uid64, err := strconv.ParseUint(r.PathValue("uid"), 10, 32)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 0 || n > 9999 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Verify mailbox ownership.
|
||||
box, err := s.deps.DB.GetMailboxByID(ctx, boxID)
|
||||
if err != nil || box == nil || box.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify message belongs to this mailbox.
|
||||
msg, err := s.deps.DB.GetIMAPMessageByUID(ctx, boxID, uint32(uid64))
|
||||
if err != nil || msg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data, filename, contentType, err := s.deps.Store.GetAttachmentData(ctx, user.ID, msg.ID, n)
|
||||
if err != nil {
|
||||
log.Printf("[webmail] attachment %d/%d/%d: %v", boxID, uid64, n, err)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize filename: use only the base name, discard any path components.
|
||||
safeFilename := filepath.Base(filename)
|
||||
if safeFilename == "." || safeFilename == "/" || safeFilename == "" {
|
||||
safeFilename = "attachment"
|
||||
}
|
||||
|
||||
// Restrict content-type to avoid serving HTML/JS from user data.
|
||||
if ct, _, err2 := mime.ParseMediaType(contentType); err2 != nil || ct == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": safeFilename}))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ func (s *Server) setupRoutes() {
|
||||
m.HandleFunc("POST /mail/{boxid}/{uid}/flag", s.require(s.messageFlag))
|
||||
m.HandleFunc("POST /mail/{boxid}/{uid}/trash", s.require(s.messageTrash))
|
||||
m.HandleFunc("POST /mail/{boxid}/{uid}/move", s.require(s.messageMove))
|
||||
m.HandleFunc("GET /mail/{boxid}/{uid}/attachment/{n}", s.require(s.messageAttachment))
|
||||
m.HandleFunc("POST /mail/{boxid}/expunge", s.require(s.mailboxExpunge))
|
||||
|
||||
// Compose
|
||||
@@ -156,7 +157,7 @@ func (s *Server) render(w http.ResponseWriter, page string, data any) {
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
if err := t.ExecuteTemplate(w, "base", data); err != nil {
|
||||
log.Printf("[webmail] template exec %s: %v", page, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
{{define "title"}}DMARC Reports — {{.Domain.Name}}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<a href="/admin/domains" class="text-gray-400 text-sm hover:text-white">Domains</a>
|
||||
<span class="text-gray-600">/</span>
|
||||
<a href="/admin/domains/{{.Domain.ID}}" class="text-gray-400 text-sm hover:text-white">{{.Domain.Name}}</a>
|
||||
<span class="text-gray-600">/</span>
|
||||
<h1 class="text-xl font-bold text-white">DMARC Reports</h1>
|
||||
</div>
|
||||
|
||||
{{if not .Domain.DMARCRua}}
|
||||
<div class="card text-center" style="max-width:32rem;margin:0 auto">
|
||||
<div class="text-sm text-gray-400 mb-3">DMARC monitoring is not enabled for this domain.</div>
|
||||
<a href="/admin/domains/{{.Domain.ID}}" class="btn btn-primary btn-sm">Back to domain</a>
|
||||
</div>
|
||||
{{else if eq (len .Reports) 0}}
|
||||
<div class="card text-center" style="max-width:32rem;margin:0 auto">
|
||||
<div class="text-sm font-semibold text-gray-300 mb-2">No reports yet</div>
|
||||
<div class="text-xs text-gray-500 mb-1">Monitoring address: <span class="text-blue-400">{{.Domain.DMARCRua}}</span></div>
|
||||
<div class="text-xs text-gray-500 mb-4">Reports arrive after external senders process your DMARC policy. This can take 24-48 hours after DNS propagation.</div>
|
||||
<a href="/admin/domains/{{.Domain.ID}}" class="btn btn-primary btn-sm">Back to domain</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-xs text-gray-500">Monitoring: <span class="text-blue-400">{{.Domain.DMARCRua}}</span> — {{len .Reports}} reports</div>
|
||||
<a href="/admin/domains/{{.Domain.ID}}" class="btn btn-primary btn-sm">Back to domain</a>
|
||||
</div>
|
||||
|
||||
{{range .Reports}}
|
||||
<div class="card" style="margin-bottom:1.25rem">
|
||||
<!-- Report header -->
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-white">{{.OrgName}}</div>
|
||||
<div class="text-xs text-gray-400">{{.OrgEmail}}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs text-gray-400">Report ID: <span class="text-gray-300">{{.ReportID}}</span></div>
|
||||
<div class="text-xs text-gray-500">{{shortTime .ReceivedAt}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date range + policy -->
|
||||
<div style="display:flex;gap:2rem;margin-bottom:.75rem;font-size:.75rem;color:#9ca3af">
|
||||
<div>Period: <span class="text-gray-300">{{unixDate .DateBegin}} to {{unixDate .DateEnd}}</span></div>
|
||||
<div>Policy: <span class="text-gray-300">{{.PolicyP}}</span>
|
||||
{{if .PolicyADKIM}}<span style="margin-left:.5rem">adkim=<span class="text-gray-300">{{.PolicyADKIM}}</span></span>{{end}}
|
||||
{{if .PolicyASPF}}<span style="margin-left:.5rem">aspf=<span class="text-gray-300">{{.PolicyASPF}}</span></span>{{end}}
|
||||
{{if .PolicyPct}}<span style="margin-left:.5rem">pct=<span class="text-gray-300">{{.PolicyPct}}%</span></span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP records table -->
|
||||
{{if .Records}}
|
||||
<div style="overflow:auto">
|
||||
<table style="font-size:.75rem;width:100%">
|
||||
<thead>
|
||||
<tr style="color:#6b7280;text-align:left;border-bottom:1px solid #374151">
|
||||
<th style="padding:.375rem .5rem;white-space:nowrap">Source IP</th>
|
||||
<th style="padding:.375rem .5rem;text-align:center">Count</th>
|
||||
<th style="padding:.375rem .5rem;text-align:center">Disposition</th>
|
||||
<th style="padding:.375rem .5rem;text-align:center">DKIM</th>
|
||||
<th style="padding:.375rem .5rem;text-align:center">SPF</th>
|
||||
<th style="padding:.375rem .5rem">From</th>
|
||||
<th style="padding:.375rem .5rem">DKIM domain</th>
|
||||
<th style="padding:.375rem .5rem">SPF domain</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Records}}
|
||||
<tr style="border-bottom:1px solid #1f2937">
|
||||
<td style="padding:.375rem .5rem;font-family:monospace;color:#93c5fd">{{.SourceIP}}</td>
|
||||
<td style="padding:.375rem .5rem;text-align:center;color:#d1d5db">{{.Count}}</td>
|
||||
<td style="padding:.375rem .5rem;text-align:center">{{dispositionBadge .Disposition}}</td>
|
||||
<td style="padding:.375rem .5rem;text-align:center">{{passBadge .DKIMResult}}</td>
|
||||
<td style="padding:.375rem .5rem;text-align:center">{{passBadge .SPFResult}}</td>
|
||||
<td style="padding:.375rem .5rem;color:#9ca3af">{{.HeaderFrom}}</td>
|
||||
<td style="padding:.375rem .5rem;color:#9ca3af;font-family:monospace">{{if .DKIMDomain}}{{.DKIMDomain}}{{if .DKIMSelector}}/{{.DKIMSelector}}{{end}}{{end}}</td>
|
||||
<td style="padding:.375rem .5rem;color:#9ca3af;font-family:monospace">{{.SPFDomain}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-xs text-gray-500">No IP records in this report.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -57,16 +57,14 @@
|
||||
|
||||
<!-- Right column -->
|
||||
<div>
|
||||
<!-- DKIM -->
|
||||
<!-- DKIM key management -->
|
||||
<div class="card">
|
||||
<div class="text-sm font-semibold text-gray-300 mb-3">DKIM key</div>
|
||||
{{if .Domain.DKIMPublic}}
|
||||
<div class="text-xs text-gray-400 mb-1">
|
||||
<div class="text-xs text-gray-400 mb-2">
|
||||
Selector: <span class="text-white">{{.Domain.DKIMSelector}}</span> /
|
||||
Algorithm: <span class="text-white">{{.Domain.DKIMAlgo}}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-300 mb-3 font-semibold">DNS TXT record:</div>
|
||||
<div style="background:#111827;border-radius:.375rem;padding:.625rem;font-size:.7rem;color:#6ee7b7;word-break:break-all;margin-bottom:.875rem;line-height:1.6">{{.DKIMRecord}}</div>
|
||||
{{else}}
|
||||
<div class="text-xs text-yellow-400 mb-3">No DKIM key generated yet.</div>
|
||||
{{end}}
|
||||
@@ -86,13 +84,84 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- DNS hints -->
|
||||
<!-- DMARC Monitoring -->
|
||||
<div class="card">
|
||||
<div class="text-sm font-semibold text-gray-300 mb-3">Recommended DNS records</div>
|
||||
<div class="text-sm font-semibold text-gray-300 mb-3">DMARC Monitoring</div>
|
||||
{{if .Domain.DMARCRua}}
|
||||
<div class="text-xs text-gray-400 mb-1">Monitoring address</div>
|
||||
<div style="font-family:monospace;font-size:.75rem;color:#60a5fa;word-break:break-all;margin-bottom:.75rem">{{.Domain.DMARCRua}}</div>
|
||||
<div class="text-xs text-gray-500 mb-3">
|
||||
Add this address to your DMARC DNS record as <span style="font-family:monospace;color:#9ca3af">rua=mailto:{{.Domain.DMARCRua}}</span>.
|
||||
External mail servers will send aggregate reports here every 24 hours.
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/admin/domains/{{.Domain.ID}}/dmarc" class="btn btn-primary btn-sm">
|
||||
View reports{{if .DMARCReportCount}} ({{.DMARCReportCount}}){{end}}
|
||||
</a>
|
||||
<form method="POST" action="/admin/domains/{{.Domain.ID}}/dmarc/disable"
|
||||
onsubmit="return confirm('Disable DMARC monitoring? Incoming reports will no longer be processed.')">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Disable</button>
|
||||
</form>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-xs text-gray-400 mb-3">
|
||||
Enable monitoring to receive DMARC aggregate reports from external mail servers.
|
||||
A unique email address will be generated for this domain and reports delivered to it will be parsed and stored.
|
||||
</div>
|
||||
<form method="POST" action="/admin/domains/{{.Domain.ID}}/dmarc/enable">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Enable DMARC monitoring</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- DNS Records -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-sm font-semibold text-gray-300">DNS Records</div>
|
||||
<button onclick="checkDNS({{.Domain.ID}}, this)" class="btn btn-primary btn-sm">Check DNS</button>
|
||||
</div>
|
||||
|
||||
<div class="text-xs font-semibold text-gray-400 mb-2" style="text-transform:uppercase;letter-spacing:.05em">Required</div>
|
||||
|
||||
<div class="text-xs text-gray-400 mb-1">MX</div>
|
||||
<div class="dns-record" style="margin-bottom:.75rem">{{.MXRecord}}</div>
|
||||
|
||||
<div class="text-xs text-gray-400 mb-1">SPF</div>
|
||||
<div style="background:#111827;border-radius:.375rem;padding:.5rem;font-size:.7rem;color:#93c5fd;word-break:break-all;margin-bottom:.75rem">{{.SPFHint}}</div>
|
||||
<div class="dns-record" style="margin-bottom:.75rem">{{.SPFHint}}</div>
|
||||
|
||||
{{if .DKIMRecord}}
|
||||
<div class="text-xs text-gray-400 mb-1">DKIM (selector: {{.Domain.DKIMSelector}})</div>
|
||||
<div class="dns-record" style="margin-bottom:.75rem">{{.DKIMRecord}}</div>
|
||||
{{else}}
|
||||
<div class="text-xs text-yellow-400 mb-3">Generate a DKIM key above to get the DKIM DNS record.</div>
|
||||
{{end}}
|
||||
|
||||
<div class="text-xs text-gray-400 mb-1">DMARC</div>
|
||||
<div style="background:#111827;border-radius:.375rem;padding:.5rem;font-size:.7rem;color:#93c5fd;word-break:break-all">{{.DMARCHint}}</div>
|
||||
<div class="dns-record" style="margin-bottom:.625rem">{{.DMARCHint}}</div>
|
||||
{{if .Domain.DMARCRua}}
|
||||
<div class="text-xs text-gray-500 mb-1">Monitoring address: <span style="color:#60a5fa;font-family:monospace">{{.Domain.DMARCRua}}</span></div>
|
||||
{{end}}
|
||||
<div style="margin-bottom:1rem"></div>
|
||||
|
||||
<div class="text-xs font-semibold text-gray-400 mb-2" style="text-transform:uppercase;letter-spacing:.05em">Optional (autoconfiguration)</div>
|
||||
<div class="text-xs text-gray-500 mb-2">Allows mail clients (Thunderbird, Outlook) to auto-discover server settings.</div>
|
||||
|
||||
<div class="text-xs text-gray-400 mb-1">Thunderbird autoconfig (CNAME)</div>
|
||||
<div class="dns-record" style="margin-bottom:.75rem">{{.AutoconfigCNAME}}</div>
|
||||
|
||||
<div class="text-xs text-gray-400 mb-1">Outlook autodiscover (CNAME)</div>
|
||||
<div class="dns-record" style="margin-bottom:.75rem">{{.AutodiscoverCNAME}}</div>
|
||||
|
||||
<div class="text-xs text-gray-400 mb-1">SMTP submission (SRV, RFC 6186)</div>
|
||||
<div class="dns-record" style="margin-bottom:.75rem">{{.SMTPSRVRecord}}</div>
|
||||
|
||||
<div class="text-xs text-gray-400 mb-1">IMAP SSL (SRV, RFC 6186)</div>
|
||||
<div class="dns-record">{{.IMAPSRVRecord}}</div>
|
||||
|
||||
<!-- DNS check results injected here -->
|
||||
<div id="dns-check-{{.Domain.ID}}" style="display:none;margin-top:1.25rem;padding-top:1rem;border-top:1px solid #374151"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,4 +212,69 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dns-record {
|
||||
background: #111827;
|
||||
border-radius: .375rem;
|
||||
padding: .5rem .625rem;
|
||||
font-size: .7rem;
|
||||
color: #93c5fd;
|
||||
word-break: break-all;
|
||||
line-height: 1.6;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function checkDNS(id, btn) {
|
||||
var orig = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Checking...';
|
||||
var div = document.getElementById('dns-check-' + id);
|
||||
div.style.display = 'block';
|
||||
div.innerHTML = '<div style="font-size:.75rem;color:#9ca3af">Performing DNS lookups...</div>';
|
||||
|
||||
fetch('/admin/domains/' + id + '/dnscheck')
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
var html = '<div style="font-size:.75rem;font-weight:600;color:#d1d5db;margin-bottom:.625rem">DNS Check Results</div>';
|
||||
html += dnsRow('MX', data.mx);
|
||||
html += dnsRow('SPF', data.spf);
|
||||
html += dnsRow('DKIM', data.dkim);
|
||||
html += dnsRow('DMARC', data.dmarc);
|
||||
div.innerHTML = html;
|
||||
})
|
||||
.catch(function(e) {
|
||||
div.innerHTML = '<div style="font-size:.75rem;color:#f87171">DNS check failed: ' + esc(e.message) + '</div>';
|
||||
})
|
||||
.finally(function() {
|
||||
btn.disabled = false;
|
||||
btn.textContent = orig;
|
||||
});
|
||||
}
|
||||
|
||||
function dnsRow(name, rec) {
|
||||
var colors = {ok:'#6ee7b7', warn:'#fbbf24', error:'#f87171', missing:'#9ca3af'};
|
||||
var labels = {ok:'OK', warn:'WARN', error:'FAIL', missing:'MISSING'};
|
||||
var c = colors[rec.status] || '#9ca3af';
|
||||
var l = labels[rec.status] || rec.status.toUpperCase();
|
||||
var h = '<div style="margin-bottom:.5rem;font-size:.75rem">';
|
||||
h += '<span style="color:' + c + ';font-weight:700;font-family:monospace">[' + l + ']</span> ';
|
||||
h += '<span style="color:#d1d5db;font-weight:600">' + esc(name) + '</span> ';
|
||||
h += '<span style="color:#9ca3af">' + esc(rec.message) + '</span>';
|
||||
if (rec.found) {
|
||||
h += '<div style="color:#6b7280;margin-top:.25rem;word-break:break-all;padding-left:1rem;font-size:.7rem;font-family:monospace">' + esc(rec.found) + '</div>';
|
||||
}
|
||||
h += '</div>';
|
||||
return h;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user