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