diff --git a/migrations/0003_create_report_tables.down.sql b/migrations/0003_create_report_tables.down.sql
new file mode 100644
index 0000000..7ea8489
--- /dev/null
+++ b/migrations/0003_create_report_tables.down.sql
@@ -0,0 +1,4 @@
+DROP TABLE IF EXISTS tls_rpt_report_policies;
+DROP TABLE IF EXISTS tls_rpt_reports;
+DROP TABLE IF EXISTS dmarc_report_records;
+DROP TABLE IF EXISTS dmarc_reports;
diff --git a/migrations/0003_create_report_tables.up.sql b/migrations/0003_create_report_tables.up.sql
new file mode 100644
index 0000000..d2970e9
--- /dev/null
+++ b/migrations/0003_create_report_tables.up.sql
@@ -0,0 +1,49 @@
+-- 0003_create_report_tables.up.sql
+-- Tables for DMARC aggregate (RUA) records and TLS-RPT failure reports
+
+CREATE TABLE IF NOT EXISTS dmarc_reports (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ domain_id UUID REFERENCES domains(id) ON DELETE CASCADE,
+ org_name VARCHAR(255) NOT NULL,
+ report_id VARCHAR(255) NOT NULL,
+ domain VARCHAR(255) NOT NULL DEFAULT '',
+ date_begin TIMESTAMPTZ,
+ date_end TIMESTAMPTZ,
+ raw_xml TEXT,
+ summary JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+ALTER TABLE dmarc_reports ALTER COLUMN domain_id DROP NOT NULL;
+ALTER TABLE dmarc_reports ADD COLUMN IF NOT EXISTS domain VARCHAR(255) NOT NULL DEFAULT '';
+ALTER TABLE dmarc_reports ADD COLUMN IF NOT EXISTS date_range_begin TIMESTAMPTZ;
+ALTER TABLE dmarc_reports ADD COLUMN IF NOT EXISTS date_range_end TIMESTAMPTZ;
+
+CREATE TABLE IF NOT EXISTS dmarc_report_records (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ report_id UUID NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE,
+ source_ip INET NOT NULL,
+ count INT NOT NULL,
+ disposition VARCHAR(32) NOT NULL,
+ dkim_result VARCHAR(32) NOT NULL,
+ spf_result VARCHAR(32) NOT NULL,
+ header_from VARCHAR(255) NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS tls_rpt_reports (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ organization_name VARCHAR(255) NOT NULL,
+ report_id VARCHAR(255) NOT NULL,
+ domain VARCHAR(255) NOT NULL,
+ date_range_begin TIMESTAMPTZ NOT NULL,
+ date_range_end TIMESTAMPTZ NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS tls_rpt_report_policies (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ report_id UUID NOT NULL REFERENCES tls_rpt_reports(id) ON DELETE CASCADE,
+ policy_type VARCHAR(64) NOT NULL,
+ success_count INT NOT NULL DEFAULT 0,
+ failure_count INT NOT NULL DEFAULT 0
+);
diff --git a/services/protocols/cmd/lambdamail-protocols/main.go b/services/protocols/cmd/lambdamail-protocols/main.go
index adabdb1..a37d2a2 100644
--- a/services/protocols/cmd/lambdamail-protocols/main.go
+++ b/services/protocols/cmd/lambdamail-protocols/main.go
@@ -13,11 +13,11 @@ import (
gosmtp "github.com/emersion/go-smtp"
"lambdamail/protocols/internal/application/usecase"
- "lambdamail/protocols/internal/health"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/postgres"
tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
imappresentation "lambdamail/protocols/internal/presentation/imap"
+ httppresentation "lambdamail/protocols/internal/presentation/http"
managesievepresentation "lambdamail/protocols/internal/presentation/managesieve"
pop3presentation "lambdamail/protocols/internal/presentation/pop3"
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
@@ -134,11 +134,14 @@ func main() {
}
}()
- handler := health.Handler(func() error { return pool.Ping(ctx) })
+ reportRepo := postgres.NewReportRepository(pool)
+ reportUseCase := usecase.NewIngestReportsUseCase(reportRepo)
+ router := httppresentation.NewRouter(reportUseCase, func() error { return pool.Ping(ctx) })
+
addr := ":8080"
- log.Printf("lambdamail-protocols health listening on %s", addr)
- if err := http.ListenAndServe(addr, handler); err != nil {
- log.Fatalf("serve: %v", err)
+ log.Printf("lambdamail-protocols HTTP service listening on %s", addr)
+ if err := http.ListenAndServe(addr, router); err != nil {
+ log.Fatalf("http serve: %v", err)
}
}
diff --git a/services/protocols/internal/application/port/report_repository.go b/services/protocols/internal/application/port/report_repository.go
new file mode 100644
index 0000000..261bd19
--- /dev/null
+++ b/services/protocols/internal/application/port/report_repository.go
@@ -0,0 +1,13 @@
+package port
+
+import (
+ "context"
+
+ "lambdamail/protocols/internal/domain/entity"
+)
+
+// ReportRepository manages storage for ingested DMARC and TLS-RPT reports.
+type ReportRepository interface {
+ SaveDmarcReport(ctx context.Context, report *entity.DmarcReport) error
+ SaveTlsRptReport(ctx context.Context, report *entity.TlsRptReport) error
+}
diff --git a/services/protocols/internal/application/usecase/ingest_reports.go b/services/protocols/internal/application/usecase/ingest_reports.go
new file mode 100644
index 0000000..0e2c370
--- /dev/null
+++ b/services/protocols/internal/application/usecase/ingest_reports.go
@@ -0,0 +1,47 @@
+package usecase
+
+import (
+ "context"
+ "fmt"
+
+ "lambdamail/protocols/internal/application/port"
+ "lambdamail/protocols/internal/domain/entity"
+)
+
+type IngestReportsUseCase struct {
+ reportRepo port.ReportRepository
+}
+
+func NewIngestReportsUseCase(reportRepo port.ReportRepository) *IngestReportsUseCase {
+ return &IngestReportsUseCase{reportRepo: reportRepo}
+}
+
+func (uc *IngestReportsUseCase) IngestDmarc(ctx context.Context, payload []byte) (*entity.DmarcReport, error) {
+ report, err := entity.ParseDmarcXmlReport(payload)
+ if err != nil {
+ return nil, fmt.Errorf("parse dmarc report: %w", err)
+ }
+
+ if uc.reportRepo != nil {
+ if err := uc.reportRepo.SaveDmarcReport(ctx, report); err != nil {
+ return nil, fmt.Errorf("save dmarc report: %w", err)
+ }
+ }
+
+ return report, nil
+}
+
+func (uc *IngestReportsUseCase) IngestTlsRpt(ctx context.Context, payload []byte) (*entity.TlsRptReport, error) {
+ report, err := entity.ParseTlsRptReport(payload)
+ if err != nil {
+ return nil, fmt.Errorf("parse tls-rpt report: %w", err)
+ }
+
+ if uc.reportRepo != nil {
+ if err := uc.reportRepo.SaveTlsRptReport(ctx, report); err != nil {
+ return nil, fmt.Errorf("save tls-rpt report: %w", err)
+ }
+ }
+
+ return report, nil
+}
diff --git a/services/protocols/internal/application/usecase/ingest_reports_test.go b/services/protocols/internal/application/usecase/ingest_reports_test.go
new file mode 100644
index 0000000..72ce724
--- /dev/null
+++ b/services/protocols/internal/application/usecase/ingest_reports_test.go
@@ -0,0 +1,62 @@
+package usecase
+
+import (
+ "context"
+ "testing"
+
+ "lambdamail/protocols/internal/domain/entity"
+)
+
+type fakeReportRepository struct {
+ dmarcReports []*entity.DmarcReport
+ tlsRptReports []*entity.TlsRptReport
+}
+
+func (f *fakeReportRepository) SaveDmarcReport(_ context.Context, report *entity.DmarcReport) error {
+ f.dmarcReports = append(f.dmarcReports, report)
+ return nil
+}
+
+func (f *fakeReportRepository) SaveTlsRptReport(_ context.Context, report *entity.TlsRptReport) error {
+ f.tlsRptReports = append(f.tlsRptReports, report)
+ return nil
+}
+
+func TestIngestReportsUseCase_IngestDmarcAndTlsRpt(t *testing.T) {
+ repo := &fakeReportRepository{}
+ uc := NewIngestReportsUseCase(repo)
+
+ ctx := context.Background()
+
+ // DMARC XML
+ dmarcPayload := `
+
+ test.orgr1100200
+ test.domain
+ 1.2.3.45nonepasspass
test.domain
+`
+
+ report1, err := uc.IngestDmarc(ctx, []byte(dmarcPayload))
+ if err != nil || report1.OrgName != "test.org" {
+ t.Fatalf("IngestDmarc: %v", err)
+ }
+ if len(repo.dmarcReports) != 1 {
+ t.Errorf("dmarcReports count = %d, want 1", len(repo.dmarcReports))
+ }
+
+ // TLS-RPT JSON
+ tlsRptPayload := `{
+ "organization-name": "test.org",
+ "report-id": "r2",
+ "date-range": {"start-datetime": "2026-07-25T00:00:00Z", "end-datetime": "2026-07-25T23:59:59Z"},
+ "policies": [{"policy": {"policy-type": "sts", "domain": "test.domain"}, "summary": {"total-successful-session-count": 10, "total-failure-session-count": 0}}]
+ }`
+
+ report2, err := uc.IngestTlsRpt(ctx, []byte(tlsRptPayload))
+ if err != nil || report2.OrganizationName != "test.org" {
+ t.Fatalf("IngestTlsRpt: %v", err)
+ }
+ if len(repo.tlsRptReports) != 1 {
+ t.Errorf("tlsRptReports count = %d, want 1", len(repo.tlsRptReports))
+ }
+}
diff --git a/services/protocols/internal/domain/entity/dmarc_report.go b/services/protocols/internal/domain/entity/dmarc_report.go
new file mode 100644
index 0000000..a06c98a
--- /dev/null
+++ b/services/protocols/internal/domain/entity/dmarc_report.go
@@ -0,0 +1,103 @@
+package entity
+
+import (
+ "bytes"
+ "compress/gzip"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "time"
+)
+
+type DmarcRecord struct {
+ SourceIP string
+ Count int
+ Disposition string
+ DKIMResult string
+ SPFResult string
+ HeaderFrom string
+}
+
+type DmarcReport struct {
+ OrgName string
+ ReportID string
+ Domain string
+ DateRangeBegin time.Time
+ DateRangeEnd time.Time
+ Records []DmarcRecord
+}
+
+type dmarcXmlReport struct {
+ ReportMetadata struct {
+ OrgName string `xml:"org_name"`
+ ReportID string `xml:"report_id"`
+ DateRange struct {
+ Begin int64 `xml:"begin"`
+ End int64 `xml:"end"`
+ } `xml:"date_range"`
+ } `xml:"report_metadata"`
+ PolicyPublished struct {
+ Domain string `xml:"domain"`
+ } `xml:"policy_published"`
+ Record []struct {
+ Row struct {
+ SourceIP string `xml:"source_ip"`
+ Count int `xml:"count"`
+ PolicyEvaluated struct {
+ Disposition string `xml:"disposition"`
+ DKIM string `xml:"dkim"`
+ SPF string `xml:"spf"`
+ } `xml:"policy_evaluated"`
+ } `xml:"row"`
+ Identifiers struct {
+ HeaderFrom string `xml:"header_from"`
+ } `xml:"identifiers"`
+ } `xml:"record"`
+}
+
+// ParseDmarcXmlReport parses raw XML or gzipped XML DMARC aggregate report.
+func ParseDmarcXmlReport(payload []byte) (*DmarcReport, error) {
+ data := payload
+ if isGzip(payload) {
+ gz, err := gzip.NewReader(bytes.NewReader(payload))
+ if err != nil {
+ return nil, fmt.Errorf("gzip reader: %w", err)
+ }
+ defer gz.Close()
+ decompressed, err := io.ReadAll(gz)
+ if err != nil {
+ return nil, fmt.Errorf("decompress gzip: %w", err)
+ }
+ data = decompressed
+ }
+
+ var parsed dmarcXmlReport
+ if err := xml.Unmarshal(data, &parsed); err != nil {
+ return nil, fmt.Errorf("unmarshal dmarc xml: %w", err)
+ }
+
+ report := &DmarcReport{
+ OrgName: parsed.ReportMetadata.OrgName,
+ ReportID: parsed.ReportMetadata.ReportID,
+ Domain: parsed.PolicyPublished.Domain,
+ DateRangeBegin: time.Unix(parsed.ReportMetadata.DateRange.Begin, 0),
+ DateRangeEnd: time.Unix(parsed.ReportMetadata.DateRange.End, 0),
+ }
+
+ for _, r := range parsed.Record {
+ report.Records = append(report.Records, DmarcRecord{
+ SourceIP: r.Row.SourceIP,
+ Count: r.Row.Count,
+ Disposition: r.Row.PolicyEvaluated.Disposition,
+ DKIMResult: r.Row.PolicyEvaluated.DKIM,
+ SPFResult: r.Row.PolicyEvaluated.SPF,
+ HeaderFrom: r.Identifiers.HeaderFrom,
+ })
+ }
+
+ return report, nil
+}
+
+func isGzip(payload []byte) bool {
+ return len(payload) >= 2 && payload[0] == 0x1f && payload[1] == 0x8b
+}
diff --git a/services/protocols/internal/domain/entity/dmarc_report_test.go b/services/protocols/internal/domain/entity/dmarc_report_test.go
new file mode 100644
index 0000000..d8777a1
--- /dev/null
+++ b/services/protocols/internal/domain/entity/dmarc_report_test.go
@@ -0,0 +1,96 @@
+package entity
+
+import (
+ "bytes"
+ "compress/gzip"
+ "testing"
+)
+
+func TestParseDmarcXmlReport_ValidXML(t *testing.T) {
+ xmlData := `
+
+
+ google.com
+ 123456
+
+ 1609459200
+ 1609545600
+
+
+
+ example.test
+
+
+
+ 198.51.100.1
+ 10
+
+ none
+ pass
+ pass
+
+
+
+ example.test
+
+
+`
+
+ report, err := ParseDmarcXmlReport([]byte(xmlData))
+ if err != nil {
+ t.Fatalf("ParseDmarcXmlReport: %v", err)
+ }
+
+ if report.OrgName != "google.com" || report.Domain != "example.test" {
+ t.Errorf("unexpected report metadata: %+v", report)
+ }
+ if len(report.Records) != 1 || report.Records[0].Count != 10 {
+ t.Errorf("unexpected records: %+v", report.Records)
+ }
+
+ // Test Gzip handling
+ var gzBuf bytes.Buffer
+ gw := gzip.NewWriter(&gzBuf)
+ gw.Write([]byte(xmlData))
+ gw.Close()
+
+ gzReport, err := ParseDmarcXmlReport(gzBuf.Bytes())
+ if err != nil || gzReport.OrgName != "google.com" {
+ t.Fatalf("ParseDmarcXmlReport gzip: %v", err)
+ }
+}
+
+func TestParseTlsRptReport_ValidJSON(t *testing.T) {
+ jsonData := `{
+ "organization-name": "Google LLC",
+ "report-id": "2026-07-25-001",
+ "date-range": {
+ "start-datetime": "2026-07-25T00:00:00Z",
+ "end-datetime": "2026-07-25T23:59:59Z"
+ },
+ "policies": [
+ {
+ "policy": {
+ "policy-type": "sts",
+ "domain": "example.test"
+ },
+ "summary": {
+ "total-successful-session-count": 100,
+ "total-failure-session-count": 0
+ }
+ }
+ ]
+ }`
+
+ report, err := ParseTlsRptReport([]byte(jsonData))
+ if err != nil {
+ t.Fatalf("ParseTlsRptReport: %v", err)
+ }
+
+ if report.OrganizationName != "Google LLC" || report.Domain != "example.test" {
+ t.Errorf("unexpected tlsrpt metadata: %+v", report)
+ }
+ if len(report.Policies) != 1 || report.Policies[0].SuccessCount != 100 {
+ t.Errorf("unexpected policies: %+v", report.Policies)
+ }
+}
diff --git a/services/protocols/internal/domain/entity/tls_rpt_report.go b/services/protocols/internal/domain/entity/tls_rpt_report.go
new file mode 100644
index 0000000..f58ac00
--- /dev/null
+++ b/services/protocols/internal/domain/entity/tls_rpt_report.go
@@ -0,0 +1,92 @@
+package entity
+
+import (
+ "bytes"
+ "compress/gzip"
+ "encoding/json"
+ "fmt"
+ "io"
+ "time"
+)
+
+type TlsRptPolicy struct {
+ PolicyType string
+ SuccessCount int
+ FailureCount int
+}
+
+type TlsRptReport struct {
+ OrganizationName string
+ ReportID string
+ Domain string
+ DateRangeBegin time.Time
+ DateRangeEnd time.Time
+ Policies []TlsRptPolicy
+}
+
+type tlsRptJsonReport struct {
+ OrganizationName string `json:"organization-name"`
+ ReportID string `json:"report-id"`
+ DateRange struct {
+ StartDatetime string `json:"start-datetime"`
+ EndDatetime string `json:"end-datetime"`
+ } `json:"date-range"`
+ Policies []struct {
+ Policy struct {
+ PolicyType string `json:"policy-type"`
+ Domain string `json:"domain"`
+ } `json:"policy"`
+ Summary struct {
+ TotalSuccessfulSessionCount int `json:"total-successful-session-count"`
+ TotalFailureSessionCount int `json:"total-failure-session-count"`
+ } `json:"summary"`
+ } `json:"policies"`
+}
+
+// ParseTlsRptReport parses JSON or gzipped JSON TLS-RPT report.
+func ParseTlsRptReport(payload []byte) (*TlsRptReport, error) {
+ data := payload
+ if isGzip(payload) {
+ gz, err := gzip.NewReader(bytes.NewReader(payload))
+ if err != nil {
+ return nil, fmt.Errorf("gzip reader: %w", err)
+ }
+ defer gz.Close()
+ decompressed, err := io.ReadAll(gz)
+ if err != nil {
+ return nil, fmt.Errorf("decompress gzip: %w", err)
+ }
+ data = decompressed
+ }
+
+ var parsed tlsRptJsonReport
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ return nil, fmt.Errorf("unmarshal tls-rpt json: %w", err)
+ }
+
+ beginTime, _ := time.Parse(time.RFC3339, parsed.DateRange.StartDatetime)
+ endTime, _ := time.Parse(time.RFC3339, parsed.DateRange.EndDatetime)
+
+ domain := ""
+ if len(parsed.Policies) > 0 {
+ domain = parsed.Policies[0].Policy.Domain
+ }
+
+ report := &TlsRptReport{
+ OrganizationName: parsed.OrganizationName,
+ ReportID: parsed.ReportID,
+ Domain: domain,
+ DateRangeBegin: beginTime,
+ DateRangeEnd: endTime,
+ }
+
+ for _, p := range parsed.Policies {
+ report.Policies = append(report.Policies, TlsRptPolicy{
+ PolicyType: p.Policy.PolicyType,
+ SuccessCount: p.Summary.TotalSuccessfulSessionCount,
+ FailureCount: p.Summary.TotalFailureSessionCount,
+ })
+ }
+
+ return report, nil
+}
diff --git a/services/protocols/internal/domain/valueobject/autoconfig_xml.go b/services/protocols/internal/domain/valueobject/autoconfig_xml.go
new file mode 100644
index 0000000..b38e3de
--- /dev/null
+++ b/services/protocols/internal/domain/valueobject/autoconfig_xml.go
@@ -0,0 +1,79 @@
+package valueobject
+
+import (
+ "fmt"
+)
+
+// BuildThunderbirdAutoconfigXML generates Mozilla Thunderbird config-v1.1 XML payload.
+func BuildThunderbirdAutoconfigXML(domainName, mailHost string) string {
+ if mailHost == "" {
+ mailHost = fmt.Sprintf("mail.%s", domainName)
+ }
+
+ return fmt.Sprintf(`
+
+
+ %s
+ LambdaMail - %s
+ %s
+
+ %s
+ 993
+ SSL
+ password-cleartext
+ %%EMAILADDRESS%%
+
+
+ %s
+ 995
+ SSL
+ password-cleartext
+ %%EMAILADDRESS%%
+
+
+ %s
+ 587
+ STARTTLS
+ password-cleartext
+ %%EMAILADDRESS%%
+
+
+`, domainName, domainName, domainName, domainName, mailHost, mailHost, mailHost)
+}
+
+// BuildOutlookAutodiscoverXML generates Microsoft POX Autodiscover response XML.
+func BuildOutlookAutodiscoverXML(domainName, mailHost, emailAddress string) string {
+ if mailHost == "" {
+ mailHost = fmt.Sprintf("mail.%s", domainName)
+ }
+
+ return fmt.Sprintf(`
+
+
+
+ %s
+
+
+ email
+ settings
+
+ IMAP
+ %s
+ 993
+ on
+ on
+ %s
+
+
+ SMTP
+ %s
+ 587
+ off
+ TLS
+ on
+ %s
+
+
+
+`, emailAddress, mailHost, emailAddress, mailHost, emailAddress)
+}
diff --git a/services/protocols/internal/domain/valueobject/autoconfig_xml_test.go b/services/protocols/internal/domain/valueobject/autoconfig_xml_test.go
new file mode 100644
index 0000000..f6bb3b4
--- /dev/null
+++ b/services/protocols/internal/domain/valueobject/autoconfig_xml_test.go
@@ -0,0 +1,26 @@
+package valueobject
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestBuildThunderbirdAutoconfigXML(t *testing.T) {
+ xml := BuildThunderbirdAutoconfigXML("example.test", "mail.example.test")
+ if !strings.Contains(xml, "mail.example.test") {
+ t.Errorf("missing mail host: %s", xml)
+ }
+ if !strings.Contains(xml, "993") {
+ t.Errorf("missing port 993: %s", xml)
+ }
+}
+
+func TestBuildOutlookAutodiscoverXML(t *testing.T) {
+ xml := BuildOutlookAutodiscoverXML("example.test", "mail.example.test", "user@example.test")
+ if !strings.Contains(xml, "mail.example.test") {
+ t.Errorf("missing Server: %s", xml)
+ }
+ if !strings.Contains(xml, "user@example.test") {
+ t.Errorf("missing LoginName: %s", xml)
+ }
+}
diff --git a/services/protocols/internal/domain/valueobject/mta_sts_policy.go b/services/protocols/internal/domain/valueobject/mta_sts_policy.go
new file mode 100644
index 0000000..258b010
--- /dev/null
+++ b/services/protocols/internal/domain/valueobject/mta_sts_policy.go
@@ -0,0 +1,44 @@
+package valueobject
+
+import (
+ "fmt"
+ "strings"
+)
+
+type MtaStsMode string
+
+const (
+ MtaStsModeTesting MtaStsMode = "testing"
+ MtaStsModeEnforce MtaStsMode = "enforce"
+ MtaStsModeNone MtaStsMode = "none"
+)
+
+type MtaStsPolicy struct {
+ Version string
+ Mode MtaStsMode
+ MX []string
+ MaxAge int
+}
+
+func NewMtaStsPolicy(mode MtaStsMode, mx []string, maxAgeSeconds int) MtaStsPolicy {
+ if maxAgeSeconds <= 0 {
+ maxAgeSeconds = 604800 // default 7 days
+ }
+ return MtaStsPolicy{
+ Version: "STSv1",
+ Mode: mode,
+ MX: mx,
+ MaxAge: maxAgeSeconds,
+ }
+}
+
+func (p MtaStsPolicy) Format() string {
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("version: %s\r\n", p.Version))
+ sb.WriteString(fmt.Sprintf("mode: %s\r\n", p.Mode))
+ for _, mxHost := range p.MX {
+ sb.WriteString(fmt.Sprintf("mx: %s\r\n", mxHost))
+ }
+ sb.WriteString(fmt.Sprintf("max_age: %d\r\n", p.MaxAge))
+ return sb.String()
+}
diff --git a/services/protocols/internal/domain/valueobject/mta_sts_policy_test.go b/services/protocols/internal/domain/valueobject/mta_sts_policy_test.go
new file mode 100644
index 0000000..8b26569
--- /dev/null
+++ b/services/protocols/internal/domain/valueobject/mta_sts_policy_test.go
@@ -0,0 +1,24 @@
+package valueobject
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestMtaStsPolicy_Format(t *testing.T) {
+ policy := NewMtaStsPolicy(MtaStsModeEnforce, []string{"mail.example.test"}, 604800)
+ formatted := policy.Format()
+
+ if !strings.Contains(formatted, "version: STSv1") {
+ t.Errorf("missing version: %s", formatted)
+ }
+ if !strings.Contains(formatted, "mode: enforce") {
+ t.Errorf("missing mode: %s", formatted)
+ }
+ if !strings.Contains(formatted, "mx: mail.example.test") {
+ t.Errorf("missing mx: %s", formatted)
+ }
+ if !strings.Contains(formatted, "max_age: 604800") {
+ t.Errorf("missing max_age: %s", formatted)
+ }
+}
diff --git a/services/protocols/internal/e2e/dns_sync_e2e_test.go b/services/protocols/internal/e2e/dns_sync_e2e_test.go
index 87f123f..11a7d90 100644
--- a/services/protocols/internal/e2e/dns_sync_e2e_test.go
+++ b/services/protocols/internal/e2e/dns_sync_e2e_test.go
@@ -39,20 +39,19 @@ func TestDnsSyncAndSystemAliasesEndToEnd(t *testing.T) {
defer pool.Close()
root := repoRoot(t)
- sql, err := os.ReadFile(filepath.Join(root, "migrations", "0001_init_schema.up.sql"))
- if err != nil {
- t.Fatalf("read migration 0001: %v", err)
+ migrations := []string{
+ "0001_init_schema.up.sql",
+ "0002_add_is_system_to_aliases.up.sql",
+ "0003_create_report_tables.up.sql",
}
- if _, err := pool.Exec(ctx, string(sql)); err != nil {
- t.Fatalf("apply migration 0001: %v", err)
- }
-
- sql0002, err := os.ReadFile(filepath.Join(root, "migrations", "0002_add_is_system_to_aliases.up.sql"))
- if err != nil {
- t.Fatalf("read migration 0002: %v", err)
- }
- if _, err := pool.Exec(ctx, string(sql0002)); err != nil {
- t.Fatalf("apply migration 0002: %v", err)
+ for _, m := range migrations {
+ sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
+ if err != nil {
+ t.Fatalf("read migration %s: %v", m, err)
+ }
+ if _, err := pool.Exec(ctx, string(sql)); err != nil {
+ t.Fatalf("apply migration %s: %v", m, err)
+ }
}
// Seed domain & admin mailbox
diff --git a/services/protocols/internal/e2e/f2_complete_e2e_test.go b/services/protocols/internal/e2e/f2_complete_e2e_test.go
index 3232d10..24ee8e2 100644
--- a/services/protocols/internal/e2e/f2_complete_e2e_test.go
+++ b/services/protocols/internal/e2e/f2_complete_e2e_test.go
@@ -53,20 +53,19 @@ func TestF2CompleteSuiteEndToEnd(t *testing.T) {
defer pool.Close()
root := repoRoot(t)
- sql, err := os.ReadFile(filepath.Join(root, "migrations", "0001_init_schema.up.sql"))
- if err != nil {
- t.Fatalf("read migration 0001: %v", err)
+ migrations := []string{
+ "0001_init_schema.up.sql",
+ "0002_add_is_system_to_aliases.up.sql",
+ "0003_create_report_tables.up.sql",
}
- if _, err := pool.Exec(ctx, string(sql)); err != nil {
- t.Fatalf("apply migration 0001: %v", err)
- }
-
- sql0002, err := os.ReadFile(filepath.Join(root, "migrations", "0002_add_is_system_to_aliases.up.sql"))
- if err != nil {
- t.Fatalf("read migration 0002: %v", err)
- }
- if _, err := pool.Exec(ctx, string(sql0002)); err != nil {
- t.Fatalf("apply migration 0002: %v", err)
+ for _, m := range migrations {
+ sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
+ if err != nil {
+ t.Fatalf("read migration %s: %v", m, err)
+ }
+ if _, err := pool.Exec(ctx, string(sql)); err != nil {
+ t.Fatalf("apply migration %s: %v", m, err)
+ }
}
// 2. Seed domain, mailbox, INBOX, Archive
diff --git a/services/protocols/internal/e2e/http_services_e2e_test.go b/services/protocols/internal/e2e/http_services_e2e_test.go
new file mode 100644
index 0000000..3f4ac95
--- /dev/null
+++ b/services/protocols/internal/e2e/http_services_e2e_test.go
@@ -0,0 +1,145 @@
+package e2e
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ embeddedpostgres "github.com/fergusstrange/embedded-postgres"
+
+ appusecase "lambdamail/protocols/internal/application/usecase"
+ "lambdamail/protocols/internal/infrastructure/postgres"
+ httppresentation "lambdamail/protocols/internal/presentation/http"
+)
+
+func TestHttpServicesEndToEnd(t *testing.T) {
+ if testing.Short() {
+ t.Skip("e2e test skipped in -short mode")
+ }
+ ctx := context.Background()
+
+ runtimeDir := t.TempDir()
+ pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
+ Port(54337).
+ RuntimePath(runtimeDir).
+ StartTimeout(120 * time.Second))
+ if err := pg.Start(); err != nil {
+ t.Fatalf("start embedded postgres: %v", err)
+ }
+ defer pg.Stop()
+
+ dbURL := "postgres://postgres:postgres@localhost:54337/postgres?sslmode=disable"
+ pool, err := postgres.NewPool(ctx, dbURL)
+ if err != nil {
+ t.Fatalf("connect pool: %v", err)
+ }
+ defer pool.Close()
+
+ root := repoRoot(t)
+ migrations := []string{
+ "0001_init_schema.up.sql",
+ "0002_add_is_system_to_aliases.up.sql",
+ "0003_create_report_tables.up.sql",
+ }
+
+ for _, m := range migrations {
+ sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
+ if err != nil {
+ t.Fatalf("read migration %s: %v", m, err)
+ }
+ if _, err := pool.Exec(ctx, string(sql)); err != nil {
+ t.Fatalf("apply migration %s: %v", m, err)
+ }
+ }
+
+ reportRepo := postgres.NewReportRepository(pool)
+ reportUseCase := appusecase.NewIngestReportsUseCase(reportRepo)
+ router := httppresentation.NewRouter(reportUseCase, func() error { return pool.Ping(ctx) })
+
+ ts := httptest.NewServer(router)
+ defer ts.Close()
+
+ client := ts.Client()
+
+ // 1. MTA-STS policy check
+ req, _ := http.NewRequest(http.MethodGet, ts.URL+"/.well-known/mta-sts.txt", nil)
+ req.Host = "mta-sts.httpdomain.test"
+ resp, err := client.Do(req)
+ if err != nil || resp.StatusCode != http.StatusOK {
+ t.Fatalf("mta-sts request failed: err=%v, code=%d", err, resp.StatusCode)
+ }
+ bodyBytes, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if !strings.Contains(string(bodyBytes), "version: STSv1") || !strings.Contains(string(bodyBytes), "mx: mail.httpdomain.test") {
+ t.Errorf("mta-sts policy mismatch: %s", string(bodyBytes))
+ }
+
+ // 2. Thunderbird autoconfig XML check
+ req, _ = http.NewRequest(http.MethodGet, ts.URL+"/mail/config-v1.1.xml", nil)
+ req.Host = "autoconfig.httpdomain.test"
+ resp, err = client.Do(req)
+ if err != nil || resp.StatusCode != http.StatusOK {
+ t.Fatalf("autoconfig request failed: err=%v, code=%d", err, resp.StatusCode)
+ }
+ bodyBytes, _ = io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if !strings.Contains(string(bodyBytes), "") {
+ t.Errorf("autoconfig xml mismatch: %s", string(bodyBytes))
+ }
+
+ // 3. Security.txt check
+ req, _ = http.NewRequest(http.MethodGet, ts.URL+"/.well-known/security.txt", nil)
+ req.Host = "httpdomain.test"
+ resp, err = client.Do(req)
+ if err != nil || resp.StatusCode != http.StatusOK {
+ t.Fatalf("security.txt request failed: err=%v, code=%d", err, resp.StatusCode)
+ }
+ bodyBytes, _ = io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if !strings.Contains(string(bodyBytes), "Contact: mailto:security@httpdomain.test") {
+ t.Errorf("security.txt mismatch: %s", string(bodyBytes))
+ }
+
+ // 4. Ingest DMARC report over HTTP and verify PostgreSQL persistence
+ dmarcPayload := `
+
+ httporg.come2e12316000000001600086400
+ httpdomain.test
+ 198.51.100.512nonepasspass
httpdomain.test
+`
+
+ resp, err = client.Post(ts.URL+"/api/v1/reports/dmarc", "application/xml", bytes.NewBufferString(dmarcPayload))
+ if err != nil || resp.StatusCode != http.StatusOK {
+ t.Fatalf("dmarc ingest failed: err=%v, code=%d", err, resp.StatusCode)
+ }
+
+ var dmarcCount int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM dmarc_reports WHERE org_name = 'httporg.com'`).Scan(&dmarcCount); err != nil || dmarcCount != 1 {
+ t.Fatalf("expected 1 dmarc report in DB, got count=%d, err=%v", dmarcCount, err)
+ }
+
+ // 5. Ingest TLS-RPT report over HTTP and verify PostgreSQL persistence
+ tlsRptPayload := `{
+ "organization-name": "httporg.com",
+ "report-id": "tls123",
+ "date-range": {"start-datetime": "2026-07-25T00:00:00Z", "end-datetime": "2026-07-25T23:59:59Z"},
+ "policies": [{"policy": {"policy-type": "sts", "domain": "httpdomain.test"}, "summary": {"total-successful-session-count": 50, "total-failure-session-count": 0}}]
+ }`
+
+ resp, err = client.Post(ts.URL+"/api/v1/reports/tlsrpt", "application/json", bytes.NewBufferString(tlsRptPayload))
+ if err != nil || resp.StatusCode != http.StatusOK {
+ t.Fatalf("tls-rpt ingest failed: err=%v, code=%d", err, resp.StatusCode)
+ }
+
+ var tlsRptCount int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM tls_rpt_reports WHERE organization_name = 'httporg.com'`).Scan(&tlsRptCount); err != nil || tlsRptCount != 1 {
+ t.Fatalf("expected 1 tls-rpt report in DB, got count=%d, err=%v", tlsRptCount, err)
+ }
+}
diff --git a/services/protocols/internal/infrastructure/postgres/report_repository.go b/services/protocols/internal/infrastructure/postgres/report_repository.go
new file mode 100644
index 0000000..1ac38ac
--- /dev/null
+++ b/services/protocols/internal/infrastructure/postgres/report_repository.go
@@ -0,0 +1,79 @@
+package postgres
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "lambdamail/protocols/internal/domain/entity"
+)
+
+type ReportRepository struct {
+ pool *pgxpool.Pool
+}
+
+func NewReportRepository(pool *pgxpool.Pool) *ReportRepository {
+ return &ReportRepository{pool: pool}
+}
+
+func (r *ReportRepository) SaveDmarcReport(ctx context.Context, report *entity.DmarcReport) error {
+ tx, err := r.pool.Begin(ctx)
+ if err != nil {
+ return fmt.Errorf("begin tx: %w", err)
+ }
+ defer tx.Rollback(ctx)
+
+ var reportID uuid.UUID
+ err = tx.QueryRow(ctx, `
+ INSERT INTO dmarc_reports (org_name, report_id, domain, date_range_begin, date_range_end)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING id
+ `, report.OrgName, report.ReportID, report.Domain, report.DateRangeBegin, report.DateRangeEnd).Scan(&reportID)
+ if err != nil {
+ return fmt.Errorf("insert dmarc report header: %w", err)
+ }
+
+ for _, rec := range report.Records {
+ _, err := tx.Exec(ctx, `
+ INSERT INTO dmarc_report_records (report_id, source_ip, count, disposition, dkim_result, spf_result, header_from)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ `, reportID, rec.SourceIP, rec.Count, rec.Disposition, rec.DKIMResult, rec.SPFResult, rec.HeaderFrom)
+ if err != nil {
+ return fmt.Errorf("insert dmarc record: %w", err)
+ }
+ }
+
+ return tx.Commit(ctx)
+}
+
+func (r *ReportRepository) SaveTlsRptReport(ctx context.Context, report *entity.TlsRptReport) error {
+ tx, err := r.pool.Begin(ctx)
+ if err != nil {
+ return fmt.Errorf("begin tx: %w", err)
+ }
+ defer tx.Rollback(ctx)
+
+ var reportID uuid.UUID
+ err = tx.QueryRow(ctx, `
+ INSERT INTO tls_rpt_reports (organization_name, report_id, domain, date_range_begin, date_range_end)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING id
+ `, report.OrganizationName, report.ReportID, report.Domain, report.DateRangeBegin, report.DateRangeEnd).Scan(&reportID)
+ if err != nil {
+ return fmt.Errorf("insert tls-rpt report header: %w", err)
+ }
+
+ for _, pol := range report.Policies {
+ _, err := tx.Exec(ctx, `
+ INSERT INTO tls_rpt_report_policies (report_id, policy_type, success_count, failure_count)
+ VALUES ($1, $2, $3, $4)
+ `, reportID, pol.PolicyType, pol.SuccessCount, pol.FailureCount)
+ if err != nil {
+ return fmt.Errorf("insert tls-rpt policy: %w", err)
+ }
+ }
+
+ return tx.Commit(ctx)
+}
diff --git a/services/protocols/internal/infrastructure/postgres/report_repository_test.go b/services/protocols/internal/infrastructure/postgres/report_repository_test.go
new file mode 100644
index 0000000..95d9608
--- /dev/null
+++ b/services/protocols/internal/infrastructure/postgres/report_repository_test.go
@@ -0,0 +1,58 @@
+package postgres
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "lambdamail/protocols/internal/domain/entity"
+)
+
+func TestReportRepository_SaveDmarcAndTlsRpt(t *testing.T) {
+ dbURL := os.Getenv("TEST_DATABASE_URL")
+ if dbURL == "" {
+ t.Skip("TEST_DATABASE_URL not set - skipping Postgres integration test")
+ }
+
+ ctx := context.Background()
+ pool, err := NewPool(ctx, dbURL)
+ if err != nil {
+ t.Fatalf("connect pool: %v", err)
+ }
+ defer pool.Close()
+
+ repo := NewReportRepository(pool)
+
+ // Save DMARC
+ dmarc := &entity.DmarcReport{
+ OrgName: "google.com",
+ ReportID: "r123",
+ Domain: "example.test",
+ DateRangeBegin: time.Now().Add(-24 * time.Hour),
+ DateRangeEnd: time.Now(),
+ Records: []entity.DmarcRecord{
+ {SourceIP: "192.0.2.1", Count: 5, Disposition: "none", DKIMResult: "pass", SPFResult: "pass", HeaderFrom: "example.test"},
+ },
+ }
+
+ if err := repo.SaveDmarcReport(ctx, dmarc); err != nil {
+ t.Fatalf("SaveDmarcReport: %v", err)
+ }
+
+ // Save TLS-RPT
+ tlsRpt := &entity.TlsRptReport{
+ OrganizationName: "Google LLC",
+ ReportID: "tr123",
+ Domain: "example.test",
+ DateRangeBegin: time.Now().Add(-24 * time.Hour),
+ DateRangeEnd: time.Now(),
+ Policies: []entity.TlsRptPolicy{
+ {PolicyType: "sts", SuccessCount: 10, FailureCount: 0},
+ },
+ }
+
+ if err := repo.SaveTlsRptReport(ctx, tlsRpt); err != nil {
+ t.Fatalf("SaveTlsRptReport: %v", err)
+ }
+}
diff --git a/services/protocols/internal/presentation/http/router.go b/services/protocols/internal/presentation/http/router.go
new file mode 100644
index 0000000..28eb897
--- /dev/null
+++ b/services/protocols/internal/presentation/http/router.go
@@ -0,0 +1,155 @@
+package httppresentation
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ appusecase "lambdamail/protocols/internal/application/usecase"
+ "lambdamail/protocols/internal/domain/valueobject"
+)
+
+type Router struct {
+ mux *http.ServeMux
+ reportUseCase *appusecase.IngestReportsUseCase
+ pingFunc func() error
+}
+
+func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() error) *Router {
+ r := &Router{
+ mux: http.NewServeMux(),
+ reportUseCase: reportUseCase,
+ pingFunc: pingFunc,
+ }
+ r.registerRoutes()
+ return r
+}
+
+func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+ r.mux.ServeHTTP(w, req)
+}
+
+func (r *Router) registerRoutes() {
+ r.mux.HandleFunc("/health", r.handleHealth)
+ r.mux.HandleFunc("/.well-known/mta-sts.txt", r.handleMtaSts)
+ r.mux.HandleFunc("/.well-known/security.txt", r.handleSecurityTxt)
+ r.mux.HandleFunc("/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
+ r.mux.HandleFunc("/.well-known/autoconfig/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
+ r.mux.HandleFunc("/autodiscover/autodiscover.xml", r.handleOutlookAutodiscover)
+ r.mux.HandleFunc("/api/v1/reports/dmarc", r.handleDmarcIngest)
+ r.mux.HandleFunc("/api/v1/reports/tlsrpt", r.handleTlsRptIngest)
+}
+
+func (r *Router) handleHealth(w http.ResponseWriter, _ *http.Request) {
+ if r.pingFunc != nil {
+ if err := r.pingFunc(); err != nil {
+ http.Error(w, fmt.Sprintf("unhealthy: %v", err), http.StatusServiceUnavailable)
+ return
+ }
+ }
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("OK\n"))
+}
+
+func (r *Router) handleMtaSts(w http.ResponseWriter, req *http.Request) {
+ host := extractHost(req.Host)
+ domainName := strings.TrimPrefix(host, "mta-sts.")
+
+ policy := valueobject.NewMtaStsPolicy(valueobject.MtaStsModeEnforce, []string{fmt.Sprintf("mail.%s", domainName)}, 604800)
+
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(policy.Format()))
+}
+
+func (r *Router) handleSecurityTxt(w http.ResponseWriter, req *http.Request) {
+ host := extractHost(req.Host)
+ expires := time.Now().AddDate(1, 0, 0).Format(time.RFC3339)
+
+ content := fmt.Sprintf("Contact: mailto:security@%s\r\nExpires: %s\r\nPreferred-Languages: en, pt-BR, es\r\n", host, expires)
+
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(content))
+}
+
+func (r *Router) handleThunderbirdAutoconfig(w http.ResponseWriter, req *http.Request) {
+ host := extractHost(req.Host)
+ domainName := strings.TrimPrefix(host, "autoconfig.")
+
+ xml := valueobject.BuildThunderbirdAutoconfigXML(domainName, fmt.Sprintf("mail.%s", domainName))
+
+ w.Header().Set("Content-Type", "application/xml; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(xml))
+}
+
+func (r *Router) handleOutlookAutodiscover(w http.ResponseWriter, req *http.Request) {
+ host := extractHost(req.Host)
+ domainName := strings.TrimPrefix(host, "autodiscover.")
+
+ xml := valueobject.BuildOutlookAutodiscoverXML(domainName, fmt.Sprintf("mail.%s", domainName), fmt.Sprintf("user@%s", domainName))
+
+ w.Header().Set("Content-Type", "text/xml; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(xml))
+}
+
+func (r *Router) handleDmarcIngest(w http.ResponseWriter, req *http.Request) {
+ if req.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ body, err := io.ReadAll(req.Body)
+ if err != nil {
+ http.Error(w, "Read body failed", http.StatusBadRequest)
+ return
+ }
+ if r.reportUseCase == nil {
+ http.Error(w, "Ingestion unconfigured", http.StatusInternalServerError)
+ return
+ }
+ if _, err := r.reportUseCase.IngestDmarc(req.Context(), body); err != nil {
+ http.Error(w, fmt.Sprintf("Parse error: %v", err), http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{"status":"ok"}`))
+}
+
+func (r *Router) handleTlsRptIngest(w http.ResponseWriter, req *http.Request) {
+ if req.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ body, err := io.ReadAll(req.Body)
+ if err != nil {
+ http.Error(w, "Read body failed", http.StatusBadRequest)
+ return
+ }
+ if r.reportUseCase == nil {
+ http.Error(w, "Ingestion unconfigured", http.StatusInternalServerError)
+ return
+ }
+ if _, err := r.reportUseCase.IngestTlsRpt(req.Context(), body); err != nil {
+ http.Error(w, fmt.Sprintf("Parse error: %v", err), http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{"status":"ok"}`))
+}
+
+func extractHost(hostHeader string) string {
+ if h, _, err := strings.Cut(hostHeader, ":"); err && h != "" {
+ return h
+ }
+ if hostHeader == "" {
+ return "example.test"
+ }
+ return hostHeader
+}
diff --git a/services/protocols/internal/presentation/http/router_test.go b/services/protocols/internal/presentation/http/router_test.go
new file mode 100644
index 0000000..60e8a67
--- /dev/null
+++ b/services/protocols/internal/presentation/http/router_test.go
@@ -0,0 +1,69 @@
+package httppresentation
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ appusecase "lambdamail/protocols/internal/application/usecase"
+)
+
+func TestRouter_MtaSts_Autoconfig_SecurityTxt_Ingest(t *testing.T) {
+ uc := appusecase.NewIngestReportsUseCase(nil)
+ router := NewRouter(uc, func() error { return nil })
+
+ // 1. Health
+ req := httptest.NewRequest(http.MethodGet, "/health", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Errorf("health status = %d", w.Code)
+ }
+
+ // 2. MTA-STS
+ req = httptest.NewRequest(http.MethodGet, "/.well-known/mta-sts.txt", nil)
+ req.Host = "mta-sts.example.test"
+ w = httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "version: STSv1") {
+ t.Errorf("mta-sts response = %q", w.Body.String())
+ }
+
+ // 3. Thunderbird Autoconfig
+ req = httptest.NewRequest(http.MethodGet, "/mail/config-v1.1.xml", nil)
+ req.Host = "autoconfig.example.test"
+ w = httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "orgid12dom`
+ req = httptest.NewRequest(http.MethodPost, "/api/v1/reports/dmarc", bytes.NewBufferString(dmarcPayload))
+ w = httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"status":"ok"`) {
+ t.Errorf("dmarc ingest status = %d, body = %q", w.Code, w.Body.String())
+ }
+}