diff --git a/.gitignore b/.gitignore index ec61f6c..5e94e53 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ test* .db .sqlite .conf -.json \ No newline at end of file +.json +./mailgosend \ No newline at end of file diff --git a/internal/auth/session.go b/internal/auth/session.go index 18c890a..21e7dc6 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -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 { diff --git a/internal/config/config.go b/internal/config/config.go index 363ad4a..91f285e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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), diff --git a/internal/db/dmarc.go b/internal/db/dmarc.go new file mode 100644 index 0000000..be6cd19 --- /dev/null +++ b/internal/db/dmarc.go @@ -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 +} diff --git a/internal/db/domains.go b/internal/db/domains.go index 3927d9b..28c36b3 100644 --- a/internal/db/domains.go +++ b/internal/db/domains.go @@ -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() } diff --git a/internal/db/mailboxes.go b/internal/db/mailboxes.go index fe1ab3b..ae79b03 100644 --- a/internal/db/mailboxes.go +++ b/internal/db/mailboxes.go @@ -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 diff --git a/internal/db/migrate.go b/internal/db/migrate.go index 43a7e1f..cfefa31 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -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); +` diff --git a/internal/dkim/dkim.go b/internal/dkim/dkim.go index fd15576..febb873 100644 --- a/internal/dkim/dkim.go +++ b/internal/dkim/dkim.go @@ -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. diff --git a/internal/dmarcreport/report.go b/internal/dmarcreport/report.go new file mode 100644 index 0000000..10f8d19 --- /dev/null +++ b/internal/dmarcreport/report.go @@ -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(" 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 } diff --git a/internal/smtp/queue.go b/internal/smtp/queue.go index a28ee02..d846009 100644 --- a/internal/smtp/queue.go +++ b/internal/smtp/queue.go @@ -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("", now.UnixNano(), hostname) diff --git a/internal/storage/storage.go b/internal/storage/storage.go index efe3db3..7b5ce08 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -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)) diff --git a/internal/totp/totp.go b/internal/totp/totp.go index b19ab93..d4c9f5e 100644 --- a/internal/totp/totp.go +++ b/internal/totp/totp.go @@ -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 } } diff --git a/internal/webadmin/handlers.go b/internal/webadmin/handlers.go index b789999..6eebd32 100644 --- a/internal/webadmin/handlers.go +++ b/internal/webadmin/handlers.go @@ -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 { diff --git a/internal/webadmin/server.go b/internal/webadmin/server.go index 9606de0..7dcc219 100644 --- a/internal/webadmin/server.go +++ b/internal/webadmin/server.go @@ -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(`pass`) + case "fail": + return template.HTML(`fail`) + default: + return template.HTML(`` + template.HTMLEscapeString(result) + ``) + } + }, + // dispositionBadge renders a coloured badge for DMARC disposition values. + "dispositionBadge": func(result string) template.HTML { + switch result { + case "none": + return template.HTML(`none`) + case "quarantine": + return template.HTML(`quarantine`) + case "reject": + return template.HTML(`reject`) + default: + return template.HTML(`` + template.HTMLEscapeString(result) + ``) + } + }, } func humanBytes(b int64) string { diff --git a/internal/webclient/compose.go b/internal/webclient/compose.go index be7ca9d..08f7dd7 100644 --- a/internal/webclient/compose.go +++ b/internal/webclient/compose.go @@ -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) +} diff --git a/internal/webclient/handlers.go b/internal/webclient/handlers.go index 2e22dde..23f47b7 100644 --- a/internal/webclient/handlers.go +++ b/internal/webclient/handlers.go @@ -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) +} diff --git a/internal/webclient/server.go b/internal/webclient/server.go index cd61902..71ba8e3 100644 --- a/internal/webclient/server.go +++ b/internal/webclient/server.go @@ -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) } diff --git a/web/admin/templates/dmarc.html b/web/admin/templates/dmarc.html new file mode 100644 index 0000000..0d77445 --- /dev/null +++ b/web/admin/templates/dmarc.html @@ -0,0 +1,91 @@ +{{define "title"}}DMARC Reports — {{.Domain.Name}}{{end}} +{{define "content"}} +
+ Domains + / + {{.Domain.Name}} + / +

DMARC Reports

+
+ +{{if not .Domain.DMARCRua}} +
+
DMARC monitoring is not enabled for this domain.
+ Back to domain +
+{{else if eq (len .Reports) 0}} +
+
No reports yet
+
Monitoring address: {{.Domain.DMARCRua}}
+
Reports arrive after external senders process your DMARC policy. This can take 24-48 hours after DNS propagation.
+ Back to domain +
+{{else}} +
+
Monitoring: {{.Domain.DMARCRua}} — {{len .Reports}} reports
+ Back to domain +
+ +{{range .Reports}} +
+ +
+
+
{{.OrgName}}
+
{{.OrgEmail}}
+
+
+
Report ID: {{.ReportID}}
+
{{shortTime .ReceivedAt}}
+
+
+ + +
+
Period: {{unixDate .DateBegin}} to {{unixDate .DateEnd}}
+
Policy: {{.PolicyP}} + {{if .PolicyADKIM}}adkim={{.PolicyADKIM}}{{end}} + {{if .PolicyASPF}}aspf={{.PolicyASPF}}{{end}} + {{if .PolicyPct}}pct={{.PolicyPct}}%{{end}} +
+
+ + + {{if .Records}} +
+ + + + + + + + + + + + + + + {{range .Records}} + + + + + + + + + + + {{end}} + +
Source IPCountDispositionDKIMSPFFromDKIM domainSPF domain
{{.SourceIP}}{{.Count}}{{dispositionBadge .Disposition}}{{passBadge .DKIMResult}}{{passBadge .SPFResult}}{{.HeaderFrom}}{{if .DKIMDomain}}{{.DKIMDomain}}{{if .DKIMSelector}}/{{.DKIMSelector}}{{end}}{{end}}{{.SPFDomain}}
+
+ {{else}} +
No IP records in this report.
+ {{end}} +
+{{end}} +{{end}} +{{end}} diff --git a/web/admin/templates/domain.html b/web/admin/templates/domain.html index 6bfb675..23470a7 100644 --- a/web/admin/templates/domain.html +++ b/web/admin/templates/domain.html @@ -57,16 +57,14 @@
- +
DKIM key
{{if .Domain.DKIMPublic}} -
+
Selector: {{.Domain.DKIMSelector}} / Algorithm: {{.Domain.DKIMAlgo}}
-
DNS TXT record:
-
{{.DKIMRecord}}
{{else}}
No DKIM key generated yet.
{{end}} @@ -86,13 +84,84 @@
- +
-
Recommended DNS records
+
DMARC Monitoring
+ {{if .Domain.DMARCRua}} +
Monitoring address
+
{{.Domain.DMARCRua}}
+
+ Add this address to your DMARC DNS record as rua=mailto:{{.Domain.DMARCRua}}. + External mail servers will send aggregate reports here every 24 hours. +
+ + {{else}} +
+ 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. +
+
+ + +
+ {{end}} +
+ + +
+
+
DNS Records
+ +
+ +
Required
+ +
MX
+
{{.MXRecord}}
+
SPF
-
{{.SPFHint}}
+
{{.SPFHint}}
+ + {{if .DKIMRecord}} +
DKIM (selector: {{.Domain.DKIMSelector}})
+
{{.DKIMRecord}}
+ {{else}} +
Generate a DKIM key above to get the DKIM DNS record.
+ {{end}} +
DMARC
-
{{.DMARCHint}}
+
{{.DMARCHint}}
+ {{if .Domain.DMARCRua}} +
Monitoring address: {{.Domain.DMARCRua}}
+ {{end}} +
+ +
Optional (autoconfiguration)
+
Allows mail clients (Thunderbird, Outlook) to auto-discover server settings.
+ +
Thunderbird autoconfig (CNAME)
+
{{.AutoconfigCNAME}}
+ +
Outlook autodiscover (CNAME)
+
{{.AutodiscoverCNAME}}
+ +
SMTP submission (SRV, RFC 6186)
+
{{.SMTPSRVRecord}}
+ +
IMAP SSL (SRV, RFC 6186)
+
{{.IMAPSRVRecord}}
+ + +
@@ -143,4 +212,69 @@ {{end}} + + + + {{end}}