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.45nonepasspasstest.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.512nonepasspasshttpdomain.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()) + } +}