diff --git a/.gitignore b/.gitignore index e943630..7ece05e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,5 @@ logs/ *.sqlite3 # OpenAPI generated files -internal/api/server.gen.go -internal/model/types.gen.go +internal/api/models.gen.go +internal/api/server.gen.go \ No newline at end of file diff --git a/api/config-models.yaml b/api/config-models.yaml index aa2fb0e..9c3425c 100644 --- a/api/config-models.yaml +++ b/api/config-models.yaml @@ -1,9 +1,5 @@ -package: model +package: api generate: models: true - embedded-spec: true -output: internal/model/types.gen.go -output-options: - skip-prune: true -import-mapping: - ./schemas.yaml: "-" + embedded-spec: false +output: internal/api/models.gen.go diff --git a/api/config-server.yaml b/api/config-server.yaml index 347dbaf..20f8daf 100644 --- a/api/config-server.yaml +++ b/api/config-server.yaml @@ -1,8 +1,5 @@ package: api generate: gin-server: true - models: true embedded-spec: true output: internal/api/server.gen.go -import-mapping: - ./schemas.yaml: git.happydns.org/happyDeliver/internal/model diff --git a/api/openapi.yaml b/api/openapi.yaml index 2dbf304..ee56cff 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -296,74 +296,1165 @@ paths: components: schemas: Test: - $ref: './schemas.yaml#/components/schemas/Test' + type: object + required: + - id + - email + - status + properties: + id: + type: string + pattern: '^[a-z0-9-]+$' + description: Unique test identifier (base32-encoded with hyphens) + example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" + email: + type: string + format: email + description: Unique test email address + example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" + status: + type: string + enum: [pending, analyzed] + description: Current test status (pending = no report yet, analyzed = report available) + example: "analyzed" + TestResponse: - $ref: './schemas.yaml#/components/schemas/TestResponse' + type: object + required: + - id + - email + - status + properties: + id: + type: string + pattern: '^[a-z0-9-]+$' + description: Unique test identifier (base32-encoded with hyphens) + example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" + email: + type: string + format: email + example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" + status: + type: string + enum: [pending] + example: "pending" + message: + type: string + example: "Send your test email to the address above" + Report: - $ref: './schemas.yaml#/components/schemas/Report' + type: object + required: + - id + - test_id + - score + - grade + - created_at + properties: + id: + type: string + pattern: '^[a-z0-9-]+$' + description: Report identifier (base32-encoded with hyphens) + test_id: + type: string + pattern: '^[a-z0-9-]+$' + description: Associated test ID (base32-encoded with hyphens) + score: + type: integer + minimum: 0 + maximum: 100 + description: Overall deliverability score as percentage (0-100) + example: 85 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + summary: + $ref: '#/components/schemas/ScoreSummary' + authentication: + $ref: '#/components/schemas/AuthenticationResults' + spamassassin: + $ref: '#/components/schemas/SpamAssassinResult' + rspamd: + $ref: '#/components/schemas/RspamdResult' + dns_results: + $ref: '#/components/schemas/DNSResults' + blacklists: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: Map of IP addresses to their blacklist check results (array of checks per IP) + example: + "192.0.2.1": + - rbl: "zen.spamhaus.org" + listed: false + - rbl: "bl.spamcop.net" + listed: false + whitelists: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: Map of IP addresses to their DNS whitelist check results (informational only) + example: + "192.0.2.1": + - rbl: "list.dnswl.org" + listed: false + - rbl: "swl.spamhaus.org" + listed: false + content_analysis: + $ref: '#/components/schemas/ContentAnalysis' + header_analysis: + $ref: '#/components/schemas/HeaderAnalysis' + raw_headers: + type: string + description: Raw email headers + created_at: + type: string + format: date-time + ScoreSummary: - $ref: './schemas.yaml#/components/schemas/ScoreSummary' + type: object + required: + - dns_score + - dns_grade + - authentication_score + - authentication_grade + - spam_score + - spam_grade + - blacklist_score + - blacklist_grade + - header_score + - header_grade + - content_score + - content_grade + properties: + dns_score: + type: integer + minimum: 0 + maximum: 100 + description: DNS records score (in percentage) + example: 42 + dns_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + authentication_score: + type: integer + minimum: 0 + maximum: 100 + description: SPF/DKIM/DMARC score (in percentage) + example: 28 + authentication_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + spam_score: + type: integer + minimum: 0 + maximum: 100 + description: Spam filter score (SpamAssassin + rspamd combined, in percentage) + example: 15 + spam_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + blacklist_score: + type: integer + minimum: 0 + maximum: 100 + description: Blacklist check score (in percentage) + example: 20 + blacklist_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + header_score: + type: integer + minimum: 0 + maximum: 100 + description: Header quality score (in percentage) + example: 9 + header_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + content_score: + type: integer + minimum: 0 + maximum: 100 + description: Content quality score (in percentage) + example: 18 + content_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + ContentAnalysis: - $ref: './schemas.yaml#/components/schemas/ContentAnalysis' + type: object + properties: + has_html: + type: boolean + description: Whether email contains HTML part + example: true + has_plaintext: + type: boolean + description: Whether email contains plaintext part + example: true + html_issues: + type: array + items: + $ref: '#/components/schemas/ContentIssue' + description: Issues found in HTML content + links: + type: array + items: + $ref: '#/components/schemas/LinkCheck' + description: Analysis of links found in the email + images: + type: array + items: + $ref: '#/components/schemas/ImageCheck' + description: Analysis of images in the email + text_to_image_ratio: + type: number + format: float + description: Ratio of text to images (higher is better) + example: 0.75 + has_unsubscribe_link: + type: boolean + description: Whether email contains an unsubscribe link + example: true + unsubscribe_methods: + type: array + items: + type: string + enum: [link, mailto, list-unsubscribe-header, one-click] + description: Available unsubscribe methods + example: ["link", "list-unsubscribe-header"] + ContentIssue: - $ref: './schemas.yaml#/components/schemas/ContentIssue' + type: object + required: + - type + - severity + - message + properties: + type: + type: string + enum: [broken_html, missing_alt, excessive_images, obfuscated_url, suspicious_link, dangerous_html] + description: Type of content issue + example: "missing_alt" + severity: + type: string + enum: [critical, high, medium, low, info] + description: Issue severity + example: "medium" + message: + type: string + description: Human-readable description + example: "3 images are missing alt attributes" + location: + type: string + description: Where the issue was found + example: "HTML body line 42" + advice: + type: string + description: How to fix this issue + example: "Add descriptive alt text to all images for better accessibility and deliverability" + LinkCheck: - $ref: './schemas.yaml#/components/schemas/LinkCheck' + type: object + required: + - url + - status + properties: + url: + type: string + format: uri + description: The URL found in the email + example: "https://example.com/page" + status: + type: string + enum: [valid, broken, suspicious, redirected, timeout] + description: Link validation status + example: "valid" + http_code: + type: integer + description: HTTP status code received + example: 200 + redirect_chain: + type: array + items: + type: string + description: URLs in the redirect chain, if any + example: ["https://example.com", "https://www.example.com"] + is_shortened: + type: boolean + description: Whether this is a URL shortener + example: false + ImageCheck: - $ref: './schemas.yaml#/components/schemas/ImageCheck' + type: object + required: + - has_alt + properties: + src: + type: string + description: Image source URL or path + example: "https://example.com/logo.png" + has_alt: + type: boolean + description: Whether image has alt attribute + example: true + alt_text: + type: string + description: Alt text content + example: "Company Logo" + is_tracking_pixel: + type: boolean + description: Whether this appears to be a tracking pixel (1x1 image) + example: false + HeaderAnalysis: - $ref: './schemas.yaml#/components/schemas/HeaderAnalysis' + type: object + properties: + has_mime_structure: + type: boolean + description: Whether body has a MIME structure + example: true + headers: + type: object + additionalProperties: + $ref: '#/components/schemas/HeaderCheck' + description: Map of header names to their check results (e.g., "from", "to", "dkim-signature") + example: + from: + present: true + value: "sender@example.com" + valid: true + importance: "required" + date: + present: true + value: "Mon, 1 Jan 2024 12:00:00 +0000" + valid: true + importance: "required" + received_chain: + type: array + items: + $ref: '#/components/schemas/ReceivedHop' + description: Chain of Received headers showing email path + domain_alignment: + $ref: '#/components/schemas/DomainAlignment' + issues: + type: array + items: + $ref: '#/components/schemas/HeaderIssue' + description: Issues found in headers + HeaderCheck: - $ref: './schemas.yaml#/components/schemas/HeaderCheck' + type: object + required: + - present + properties: + present: + type: boolean + description: Whether the header is present + example: true + value: + type: string + description: Header value + example: "sender@example.com" + valid: + type: boolean + description: Whether the value is valid/well-formed + example: true + importance: + type: string + enum: [required, recommended, optional, newsletter] + description: How important this header is for deliverability + example: "required" + issues: + type: array + items: + type: string + description: Any issues with this header + example: ["Invalid date format"] + ReceivedHop: - $ref: './schemas.yaml#/components/schemas/ReceivedHop' + type: object + properties: + from: + type: string + description: Sending server hostname + example: "mail.example.com" + by: + type: string + description: Receiving server hostname + example: "mx.receiver.com" + with: + type: string + description: Protocol used + example: "ESMTPS" + id: + type: string + description: Message ID at this hop + timestamp: + type: string + format: date-time + description: When this hop occurred + ip: + type: string + description: IP address of the sending server (IPv4 or IPv6) + example: "192.0.2.1" + reverse: + type: string + description: Reverse DNS (PTR record) for the IP address + example: "mail.example.com" + DKIMDomainInfo: - $ref: './schemas.yaml#/components/schemas/DKIMDomainInfo' + type: object + required: + - domain + - org_domain + properties: + domain: + type: string + description: DKIM signature domain + example: "mail.example.com" + org_domain: + type: string + description: Organizational domain extracted from DKIM domain (using Public Suffix List) + example: "example.com" + DomainAlignment: - $ref: './schemas.yaml#/components/schemas/DomainAlignment' + type: object + properties: + from_domain: + type: string + description: Domain from From header + example: "example.com" + from_org_domain: + type: string + description: Organizational domain extracted from From header (using Public Suffix List) + example: "example.com" + return_path_domain: + type: string + description: Domain from Return-Path header + example: "example.com" + return_path_org_domain: + type: string + description: Organizational domain extracted from Return-Path header (using Public Suffix List) + example: "example.com" + dkim_domains: + type: array + items: + $ref: '#/components/schemas/DKIMDomainInfo' + description: Domains from DKIM signatures with their organizational domains + aligned: + type: boolean + description: Whether all domains align (strict alignment - exact match) + example: true + relaxed_aligned: + type: boolean + description: Whether domains satisfy relaxed alignment (organizational domain match) + example: true + issues: + type: array + items: + type: string + description: Alignment issues + example: ["Return-Path domain does not match From domain"] + HeaderIssue: - $ref: './schemas.yaml#/components/schemas/HeaderIssue' + type: object + required: + - header + - severity + - message + properties: + header: + type: string + description: Header name + example: "Date" + severity: + type: string + enum: [critical, high, medium, low, info] + description: Issue severity + example: "medium" + message: + type: string + description: Human-readable description + example: "Date header is in the future" + advice: + type: string + description: How to fix this issue + example: "Ensure your mail server clock is synchronized with NTP" + AuthenticationResults: - $ref: './schemas.yaml#/components/schemas/AuthenticationResults' + type: object + properties: + spf: + $ref: '#/components/schemas/AuthResult' + dkim: + type: array + items: + $ref: '#/components/schemas/AuthResult' + dmarc: + $ref: '#/components/schemas/AuthResult' + bimi: + $ref: '#/components/schemas/AuthResult' + arc: + $ref: '#/components/schemas/ARCResult' + iprev: + $ref: '#/components/schemas/IPRevResult' + x_google_dkim: + $ref: '#/components/schemas/AuthResult' + description: Google-specific DKIM authentication result (x-google-dkim) + x_aligned_from: + $ref: '#/components/schemas/AuthResult' + description: X-Aligned-From authentication result (checks address alignment) + AuthResult: - $ref: './schemas.yaml#/components/schemas/AuthResult' + type: object + required: + - result + properties: + result: + type: string + enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped] + description: Authentication result + example: "pass" + domain: + type: string + description: Domain being authenticated + example: "example.com" + selector: + type: string + description: DKIM selector (for DKIM only) + example: "default" + details: + type: string + description: Additional details about the result + ARCResult: - $ref: './schemas.yaml#/components/schemas/ARCResult' + type: object + required: + - result + properties: + result: + type: string + enum: [pass, fail, none] + description: Overall ARC chain validation result + example: "pass" + chain_valid: + type: boolean + description: Whether the ARC chain signatures are valid + example: true + chain_length: + type: integer + description: Number of ARC sets in the chain + example: 2 + details: + type: string + description: Additional details about ARC validation + example: "ARC chain valid with 2 intermediaries" + IPRevResult: - $ref: './schemas.yaml#/components/schemas/IPRevResult' + type: object + required: + - result + properties: + result: + type: string + enum: [pass, fail, temperror, permerror] + description: IP reverse DNS lookup result + example: "pass" + ip: + type: string + description: IP address that was checked + example: "195.110.101.58" + hostname: + type: string + description: Hostname from reverse DNS lookup (PTR record) + example: "authsmtp74.register.it" + details: + type: string + description: Additional details about the IP reverse lookup + example: "smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)" + SpamAssassinResult: - $ref: './schemas.yaml#/components/schemas/SpamAssassinResult' + type: object + required: + - score + - required_score + - is_spam + - test_details + properties: + deliverability_score: + type: integer + minimum: 0 + maximum: 100 + description: SpamAssassin deliverability score (0-100, higher is better) + example: 80 + deliverability_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade for SpamAssassin deliverability score + example: "B" + version: + type: string + description: SpamAssassin version + example: "SpamAssassin 4.0.1" + score: + type: number + format: float + description: SpamAssassin spam score + example: 2.3 + required_score: + type: number + format: float + description: Threshold for spam classification + example: 5.0 + is_spam: + type: boolean + description: Whether message is classified as spam + example: false + tests: + type: array + items: + type: string + description: List of triggered SpamAssassin tests + example: ["BAYES_00", "DKIM_SIGNED"] + test_details: + type: object + additionalProperties: + $ref: '#/components/schemas/SpamTestDetail' + description: Map of test names to their detailed results + example: + BAYES_00: + name: "BAYES_00" + score: -1.9 + description: "Bayes spam probability is 0 to 1%" + DKIM_SIGNED: + name: "DKIM_SIGNED" + score: 0.1 + description: "Message has a DKIM or DK signature, not necessarily valid" + report: + type: string + description: Full SpamAssassin report + SpamTestDetail: - $ref: './schemas.yaml#/components/schemas/SpamTestDetail' + type: object + required: + - name + - score + properties: + name: + type: string + description: Test name + example: "BAYES_00" + score: + type: number + format: float + description: Score contribution of this test + example: -1.9 + params: + type: string + description: Symbol parameters or options + example: "0.02" + description: + type: string + description: Human-readable description of what this test checks + example: "Bayes spam probability is 0 to 1%" + RspamdResult: - $ref: './schemas.yaml#/components/schemas/RspamdResult' + type: object + required: + - score + - threshold + - is_spam + - symbols + properties: + deliverability_score: + type: integer + minimum: 0 + maximum: 100 + description: rspamd deliverability score (0-100, higher is better) + example: 85 + deliverability_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade for rspamd deliverability score + example: "A" + score: + type: number + format: float + description: rspamd spam score + example: -3.91 + threshold: + type: number + format: float + description: Score threshold for spam classification + example: 15.0 + action: + type: string + description: rspamd action (no action, add header, rewrite subject, soft reject, reject) + example: "no action" + is_spam: + type: boolean + description: Whether message is classified as spam (action is reject or soft reject) + example: false + server: + type: string + description: rspamd server that processed the message + example: "rspamd.example.com" + symbols: + type: object + additionalProperties: + $ref: '#/components/schemas/SpamTestDetail' + description: Map of triggered rspamd symbols to their details + example: + BAYES_HAM: + name: "BAYES_HAM" + score: -1.9 + params: "0.02" + report: + type: string + description: Full rspamd report (raw X-Spamd-Result header) + + DNSResults: - $ref: './schemas.yaml#/components/schemas/DNSResults' + type: object + required: + - from_domain + properties: + from_domain: + type: string + description: From Domain name + example: "example.com" + rp_domain: + type: string + description: Return Path Domain name + example: "example.com" + from_mx_records: + type: array + items: + $ref: '#/components/schemas/MXRecord' + description: MX records for the From domain + rp_mx_records: + type: array + items: + $ref: '#/components/schemas/MXRecord' + description: MX records for the Return-Path domain + spf_records: + type: array + items: + $ref: '#/components/schemas/SPFRecord' + description: SPF records found (includes resolved include directives) + dkim_records: + type: array + items: + $ref: '#/components/schemas/DKIMRecord' + description: DKIM records found + dmarc_record: + $ref: '#/components/schemas/DMARCRecord' + bimi_record: + $ref: '#/components/schemas/BIMIRecord' + ptr_records: + type: array + items: + type: string + description: PTR (reverse DNS) records for the sender IP address + example: ["mail.example.com", "smtp.example.com"] + ptr_forward_records: + type: array + items: + type: string + description: A or AAAA records resolved from the PTR hostnames (forward confirmation) + example: ["192.0.2.1", "2001:db8::1"] + errors: + type: array + items: + type: string + description: DNS lookup errors + MXRecord: - $ref: './schemas.yaml#/components/schemas/MXRecord' + type: object + required: + - host + - priority + - valid + properties: + host: + type: string + description: MX hostname + example: "mail.example.com" + priority: + type: integer + format: uint16 + description: MX priority (lower is higher priority) + example: 10 + valid: + type: boolean + description: Whether the MX record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "Failed to lookup MX records" + SPFRecord: - $ref: './schemas.yaml#/components/schemas/SPFRecord' + type: object + required: + - valid + properties: + domain: + type: string + description: Domain this SPF record belongs to + example: "example.com" + record: + type: string + description: SPF record content + example: "v=spf1 include:_spf.example.com ~all" + valid: + type: boolean + description: Whether the SPF record is valid + example: true + all_qualifier: + type: string + enum: ["+", "-", "~", "?"] + description: "Qualifier for the 'all' mechanism: + (pass), - (fail), ~ (softfail), ? (neutral)" + example: "~" + error: + type: string + description: Error message if validation failed + example: "No SPF record found" + DKIMRecord: - $ref: './schemas.yaml#/components/schemas/DKIMRecord' + type: object + required: + - selector + - domain + - valid + properties: + selector: + type: string + description: DKIM selector + example: "default" + domain: + type: string + description: Domain name + example: "example.com" + record: + type: string + description: DKIM record content + example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..." + valid: + type: boolean + description: Whether the DKIM record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No DKIM record found" + DMARCRecord: - $ref: './schemas.yaml#/components/schemas/DMARCRecord' + type: object + required: + - valid + properties: + record: + type: string + description: DMARC record content + example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" + policy: + type: string + enum: [none, quarantine, reject, unknown] + description: DMARC policy + example: "quarantine" + subdomain_policy: + type: string + enum: [none, quarantine, reject, unknown] + description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy + example: "quarantine" + percentage: + type: integer + minimum: 0 + maximum: 100 + description: Percentage of messages subjected to filtering (pct tag, default 100) + example: 100 + spf_alignment: + type: string + enum: [relaxed, strict] + description: SPF alignment mode (aspf tag) + example: "relaxed" + dkim_alignment: + type: string + enum: [relaxed, strict] + description: DKIM alignment mode (adkim tag) + example: "relaxed" + valid: + type: boolean + description: Whether the DMARC record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No DMARC record found" + BIMIRecord: - $ref: './schemas.yaml#/components/schemas/BIMIRecord' + type: object + required: + - selector + - domain + - valid + properties: + selector: + type: string + description: BIMI selector + example: "default" + domain: + type: string + description: Domain name + example: "example.com" + record: + type: string + description: BIMI record content + example: "v=BIMI1; l=https://example.com/logo.svg" + logo_url: + type: string + format: uri + description: URL to the brand logo (SVG) + example: "https://example.com/logo.svg" + vmc_url: + type: string + format: uri + description: URL to Verified Mark Certificate (optional) + example: "https://example.com/vmc.pem" + valid: + type: boolean + description: Whether the BIMI record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No BIMI record found" + BlacklistCheck: - $ref: './schemas.yaml#/components/schemas/BlacklistCheck' + type: object + required: + - rbl + - listed + properties: + rbl: + type: string + description: RBL/DNSBL name + example: "zen.spamhaus.org" + listed: + type: boolean + description: Whether IP is listed + example: false + response: + type: string + description: RBL response code or message + example: "127.0.0.2" + error: + type: string + description: RBL error if any + Status: - $ref: './schemas.yaml#/components/schemas/Status' + type: object + required: + - status + - version + properties: + status: + type: string + enum: [healthy, degraded, unhealthy] + description: Overall service status + example: "healthy" + version: + type: string + description: Service version + example: "0.1.0-dev" + components: + type: object + properties: + database: + type: string + enum: [up, down] + example: "up" + mta: + type: string + enum: [up, down] + example: "up" + uptime: + type: integer + description: Service uptime in seconds + example: 3600 + Error: - $ref: './schemas.yaml#/components/schemas/Error' + type: object + required: + - error + - message + properties: + error: + type: string + description: Error code + example: "not_found" + message: + type: string + description: Human-readable error message + example: "Test not found" + details: + type: string + description: Additional error details + DomainTestRequest: - $ref: './schemas.yaml#/components/schemas/DomainTestRequest' + type: object + required: + - domain + properties: + domain: + type: string + pattern: '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' + description: Domain name to test (e.g., example.com) + example: "example.com" + DomainTestResponse: - $ref: './schemas.yaml#/components/schemas/DomainTestResponse' + type: object + required: + - domain + - score + - grade + - dns_results + properties: + domain: + type: string + description: The tested domain name + example: "example.com" + score: + type: integer + minimum: 0 + maximum: 100 + description: Overall domain configuration score (0-100) + example: 85 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score + example: "A" + dns_results: + $ref: '#/components/schemas/DNSResults' + BlacklistCheckRequest: - $ref: './schemas.yaml#/components/schemas/BlacklistCheckRequest' + type: object + required: + - ip + properties: + ip: + type: string + description: IPv4 or IPv6 address to check against blacklists + example: "192.0.2.1" + pattern: '^([0-9]{1,3}\.){3}[0-9]{1,3}$|^([0-9a-fA-F]{0,4}:){7}[0-9a-fA-F]{0,4}$|^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$|^([0-9a-fA-F]{0,4}:){1,6}:([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$' + BlacklistCheckResponse: - $ref: './schemas.yaml#/components/schemas/BlacklistCheckResponse' + type: object + required: + - ip + - blacklists + - listed_count + - score + - grade + properties: + ip: + type: string + description: The IP address that was checked + example: "192.0.2.1" + blacklists: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: List of blacklist check results + listed_count: + type: integer + description: Number of blacklists that have this IP listed + example: 0 + score: + type: integer + minimum: 0 + maximum: 100 + description: Blacklist score (0-100, higher is better) + example: 100 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score + example: "A+" + whitelists: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: List of DNS whitelist check results (informational only) + TestSummary: - $ref: './schemas.yaml#/components/schemas/TestSummary' + type: object + required: + - test_id + - score + - grade + - created_at + properties: + test_id: + type: string + pattern: '^[a-z0-9-]+$' + description: Test identifier (base32-encoded with hyphens) + score: + type: integer + minimum: 0 + maximum: 100 + description: Overall deliverability score (0-100) + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade + from_domain: + type: string + description: Sender domain extracted from the report + created_at: + type: string + format: date-time + TestListResponse: - $ref: './schemas.yaml#/components/schemas/TestListResponse' + type: object + required: + - tests + - total + - offset + - limit + properties: + tests: + type: array + items: + $ref: '#/components/schemas/TestSummary' + total: + type: integer + description: Total number of tests + offset: + type: integer + description: Current offset + limit: + type: integer + description: Current limit diff --git a/api/schemas.yaml b/api/schemas.yaml deleted file mode 100644 index df0b416..0000000 --- a/api/schemas.yaml +++ /dev/null @@ -1,1173 +0,0 @@ -openapi: 3.0.3 -info: - title: happyDeliver Schemas - description: Shared schema definitions for happyDeliver - version: 0.1.0 - -paths: {} - -components: - schemas: - Test: - type: object - required: - - id - - email - - status - properties: - id: - type: string - pattern: '^[a-z0-9-]+$' - description: Unique test identifier (base32-encoded with hyphens) - example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" - email: - type: string - format: email - description: Unique test email address - example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" - status: - type: string - enum: [pending, analyzed] - description: Current test status (pending = no report yet, analyzed = report available) - example: "analyzed" - - TestResponse: - type: object - required: - - id - - email - - status - properties: - id: - type: string - pattern: '^[a-z0-9-]+$' - description: Unique test identifier (base32-encoded with hyphens) - example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" - email: - type: string - format: email - example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" - status: - type: string - enum: [pending] - example: "pending" - message: - type: string - example: "Send your test email to the address above" - - Report: - type: object - required: - - id - - test_id - - score - - grade - - created_at - properties: - id: - type: string - pattern: '^[a-z0-9-]+$' - description: Report identifier (base32-encoded with hyphens) - test_id: - type: string - pattern: '^[a-z0-9-]+$' - description: Associated test ID (base32-encoded with hyphens) - score: - type: integer - minimum: 0 - maximum: 100 - description: Overall deliverability score as percentage (0-100) - example: 85 - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - summary: - $ref: '#/components/schemas/ScoreSummary' - authentication: - $ref: '#/components/schemas/AuthenticationResults' - spamassassin: - $ref: '#/components/schemas/SpamAssassinResult' - rspamd: - $ref: '#/components/schemas/RspamdResult' - dns_results: - $ref: '#/components/schemas/DNSResults' - blacklists: - type: object - additionalProperties: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - description: Map of IP addresses to their blacklist check results (array of checks per IP) - example: - "192.0.2.1": - - rbl: "zen.spamhaus.org" - listed: false - - rbl: "bl.spamcop.net" - listed: false - whitelists: - type: object - additionalProperties: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - description: Map of IP addresses to their DNS whitelist check results (informational only) - example: - "192.0.2.1": - - rbl: "list.dnswl.org" - listed: false - - rbl: "swl.spamhaus.org" - listed: false - content_analysis: - $ref: '#/components/schemas/ContentAnalysis' - header_analysis: - $ref: '#/components/schemas/HeaderAnalysis' - raw_headers: - type: string - description: Raw email headers - created_at: - type: string - format: date-time - - ScoreSummary: - type: object - required: - - dns_score - - dns_grade - - authentication_score - - authentication_grade - - spam_score - - spam_grade - - blacklist_score - - blacklist_grade - - header_score - - header_grade - - content_score - - content_grade - properties: - dns_score: - type: integer - minimum: 0 - maximum: 100 - description: DNS records score (in percentage) - example: 42 - dns_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - authentication_score: - type: integer - minimum: 0 - maximum: 100 - description: SPF/DKIM/DMARC score (in percentage) - example: 28 - authentication_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - spam_score: - type: integer - minimum: 0 - maximum: 100 - description: Spam filter score (SpamAssassin + rspamd combined, in percentage) - example: 15 - spam_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - blacklist_score: - type: integer - minimum: 0 - maximum: 100 - description: Blacklist check score (in percentage) - example: 20 - blacklist_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - header_score: - type: integer - minimum: 0 - maximum: 100 - description: Header quality score (in percentage) - example: 9 - header_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - content_score: - type: integer - minimum: 0 - maximum: 100 - description: Content quality score (in percentage) - example: 18 - content_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - - ContentAnalysis: - type: object - properties: - has_html: - type: boolean - description: Whether email contains HTML part - example: true - has_plaintext: - type: boolean - description: Whether email contains plaintext part - example: true - html_issues: - type: array - items: - $ref: '#/components/schemas/ContentIssue' - description: Issues found in HTML content - links: - type: array - items: - $ref: '#/components/schemas/LinkCheck' - description: Analysis of links found in the email - images: - type: array - items: - $ref: '#/components/schemas/ImageCheck' - description: Analysis of images in the email - text_to_image_ratio: - type: number - format: float - description: Ratio of text to images (higher is better) - example: 0.75 - has_unsubscribe_link: - type: boolean - description: Whether email contains an unsubscribe link - example: true - unsubscribe_methods: - type: array - items: - type: string - enum: [link, mailto, list-unsubscribe-header, one-click] - description: Available unsubscribe methods - example: ["link", "list-unsubscribe-header"] - - ContentIssue: - type: object - required: - - type - - severity - - message - properties: - type: - type: string - enum: [broken_html, missing_alt, excessive_images, obfuscated_url, suspicious_link, dangerous_html] - description: Type of content issue - example: "missing_alt" - severity: - type: string - enum: [critical, high, medium, low, info] - description: Issue severity - example: "medium" - message: - type: string - description: Human-readable description - example: "3 images are missing alt attributes" - location: - type: string - description: Where the issue was found - example: "HTML body line 42" - advice: - type: string - description: How to fix this issue - example: "Add descriptive alt text to all images for better accessibility and deliverability" - - LinkCheck: - type: object - required: - - url - - status - properties: - url: - type: string - format: uri - description: The URL found in the email - example: "https://example.com/page" - status: - type: string - enum: [valid, broken, suspicious, redirected, timeout] - description: Link validation status - example: "valid" - http_code: - type: integer - description: HTTP status code received - example: 200 - redirect_chain: - type: array - items: - type: string - description: URLs in the redirect chain, if any - example: ["https://example.com", "https://www.example.com"] - is_shortened: - type: boolean - description: Whether this is a URL shortener - example: false - - ImageCheck: - type: object - required: - - has_alt - properties: - src: - type: string - description: Image source URL or path - example: "https://example.com/logo.png" - has_alt: - type: boolean - description: Whether image has alt attribute - example: true - alt_text: - type: string - description: Alt text content - example: "Company Logo" - is_tracking_pixel: - type: boolean - description: Whether this appears to be a tracking pixel (1x1 image) - example: false - - HeaderAnalysis: - type: object - properties: - has_mime_structure: - type: boolean - description: Whether body has a MIME structure - example: true - headers: - type: object - additionalProperties: - $ref: '#/components/schemas/HeaderCheck' - description: Map of header names to their check results (e.g., "from", "to", "dkim-signature") - example: - from: - present: true - value: "sender@example.com" - valid: true - importance: "required" - date: - present: true - value: "Mon, 1 Jan 2024 12:00:00 +0000" - valid: true - importance: "required" - received_chain: - type: array - items: - $ref: '#/components/schemas/ReceivedHop' - description: Chain of Received headers showing email path - domain_alignment: - $ref: '#/components/schemas/DomainAlignment' - issues: - type: array - items: - $ref: '#/components/schemas/HeaderIssue' - description: Issues found in headers - - HeaderCheck: - type: object - required: - - present - properties: - present: - type: boolean - description: Whether the header is present - example: true - value: - type: string - description: Header value - example: "sender@example.com" - valid: - type: boolean - description: Whether the value is valid/well-formed - example: true - importance: - type: string - enum: [required, recommended, optional, newsletter] - description: How important this header is for deliverability - example: "required" - issues: - type: array - items: - type: string - description: Any issues with this header - example: ["Invalid date format"] - - ReceivedHop: - type: object - properties: - from: - type: string - description: Sending server hostname - example: "mail.example.com" - by: - type: string - description: Receiving server hostname - example: "mx.receiver.com" - with: - type: string - description: Protocol used - example: "ESMTPS" - id: - type: string - description: Message ID at this hop - timestamp: - type: string - format: date-time - description: When this hop occurred - ip: - type: string - description: IP address of the sending server (IPv4 or IPv6) - example: "192.0.2.1" - reverse: - type: string - description: Reverse DNS (PTR record) for the IP address - example: "mail.example.com" - - DKIMDomainInfo: - type: object - required: - - domain - - org_domain - properties: - domain: - type: string - description: DKIM signature domain - example: "mail.example.com" - org_domain: - type: string - description: Organizational domain extracted from DKIM domain (using Public Suffix List) - example: "example.com" - - DomainAlignment: - type: object - properties: - from_domain: - type: string - description: Domain from From header - example: "example.com" - from_org_domain: - type: string - description: Organizational domain extracted from From header (using Public Suffix List) - example: "example.com" - return_path_domain: - type: string - description: Domain from Return-Path header - example: "example.com" - return_path_org_domain: - type: string - description: Organizational domain extracted from Return-Path header (using Public Suffix List) - example: "example.com" - dkim_domains: - type: array - items: - $ref: '#/components/schemas/DKIMDomainInfo' - description: Domains from DKIM signatures with their organizational domains - aligned: - type: boolean - description: Whether all domains align (strict alignment - exact match) - example: true - relaxed_aligned: - type: boolean - description: Whether domains satisfy relaxed alignment (organizational domain match) - example: true - issues: - type: array - items: - type: string - description: Alignment issues - example: ["Return-Path domain does not match From domain"] - - HeaderIssue: - type: object - required: - - header - - severity - - message - properties: - header: - type: string - description: Header name - example: "Date" - severity: - type: string - enum: [critical, high, medium, low, info] - description: Issue severity - example: "medium" - message: - type: string - description: Human-readable description - example: "Date header is in the future" - advice: - type: string - description: How to fix this issue - example: "Ensure your mail server clock is synchronized with NTP" - - AuthenticationResults: - type: object - properties: - spf: - $ref: '#/components/schemas/AuthResult' - dkim: - type: array - items: - $ref: '#/components/schemas/AuthResult' - dmarc: - $ref: '#/components/schemas/AuthResult' - bimi: - $ref: '#/components/schemas/AuthResult' - arc: - $ref: '#/components/schemas/ARCResult' - iprev: - $ref: '#/components/schemas/IPRevResult' - x_google_dkim: - $ref: '#/components/schemas/AuthResult' - description: Google-specific DKIM authentication result (x-google-dkim) - x_aligned_from: - $ref: '#/components/schemas/AuthResult' - description: X-Aligned-From authentication result (checks address alignment) - - AuthResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped] - description: Authentication result - example: "pass" - domain: - type: string - description: Domain being authenticated - example: "example.com" - selector: - type: string - description: DKIM selector (for DKIM only) - example: "default" - details: - type: string - description: Additional details about the result - - ARCResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, none] - description: Overall ARC chain validation result - example: "pass" - chain_valid: - type: boolean - description: Whether the ARC chain signatures are valid - example: true - chain_length: - type: integer - description: Number of ARC sets in the chain - example: 2 - details: - type: string - description: Additional details about ARC validation - example: "ARC chain valid with 2 intermediaries" - - IPRevResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, temperror, permerror] - description: IP reverse DNS lookup result - example: "pass" - ip: - type: string - description: IP address that was checked - example: "195.110.101.58" - hostname: - type: string - description: Hostname from reverse DNS lookup (PTR record) - example: "authsmtp74.register.it" - details: - type: string - description: Additional details about the IP reverse lookup - example: "smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)" - - SpamAssassinResult: - type: object - required: - - score - - required_score - - is_spam - - test_details - properties: - deliverability_score: - type: integer - minimum: 0 - maximum: 100 - description: SpamAssassin deliverability score (0-100, higher is better) - example: 80 - deliverability_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade for SpamAssassin deliverability score - example: "B" - version: - type: string - description: SpamAssassin version - example: "SpamAssassin 4.0.1" - score: - type: number - format: float - description: SpamAssassin spam score - example: 2.3 - required_score: - type: number - format: float - description: Threshold for spam classification - example: 5.0 - is_spam: - type: boolean - description: Whether message is classified as spam - example: false - tests: - type: array - items: - type: string - description: List of triggered SpamAssassin tests - example: ["BAYES_00", "DKIM_SIGNED"] - test_details: - type: object - additionalProperties: - $ref: '#/components/schemas/SpamTestDetail' - description: Map of test names to their detailed results - example: - BAYES_00: - name: "BAYES_00" - score: -1.9 - description: "Bayes spam probability is 0 to 1%" - DKIM_SIGNED: - name: "DKIM_SIGNED" - score: 0.1 - description: "Message has a DKIM or DK signature, not necessarily valid" - report: - type: string - description: Full SpamAssassin report - - SpamTestDetail: - type: object - required: - - name - - score - properties: - name: - type: string - description: Test name - example: "BAYES_00" - score: - type: number - format: float - description: Score contribution of this test - example: -1.9 - params: - type: string - description: Symbol parameters or options - example: "0.02" - description: - type: string - description: Human-readable description of what this test checks - example: "Bayes spam probability is 0 to 1%" - - RspamdResult: - type: object - required: - - score - - threshold - - is_spam - - symbols - properties: - deliverability_score: - type: integer - minimum: 0 - maximum: 100 - description: rspamd deliverability score (0-100, higher is better) - example: 85 - deliverability_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade for rspamd deliverability score - example: "A" - score: - type: number - format: float - description: rspamd spam score - example: -3.91 - threshold: - type: number - format: float - description: Score threshold for spam classification - example: 15.0 - action: - type: string - description: rspamd action (no action, add header, rewrite subject, soft reject, reject) - example: "no action" - is_spam: - type: boolean - description: Whether message is classified as spam (action is reject or soft reject) - example: false - server: - type: string - description: rspamd server that processed the message - example: "rspamd.example.com" - symbols: - type: object - additionalProperties: - $ref: '#/components/schemas/SpamTestDetail' - description: Map of triggered rspamd symbols to their details - example: - BAYES_HAM: - name: "BAYES_HAM" - score: -1.9 - params: "0.02" - report: - type: string - description: Full rspamd report (raw X-Spamd-Result header) - - - DNSResults: - type: object - required: - - from_domain - properties: - from_domain: - type: string - description: From Domain name - example: "example.com" - rp_domain: - type: string - description: Return Path Domain name - example: "example.com" - from_mx_records: - type: array - items: - $ref: '#/components/schemas/MXRecord' - description: MX records for the From domain - rp_mx_records: - type: array - items: - $ref: '#/components/schemas/MXRecord' - description: MX records for the Return-Path domain - spf_records: - type: array - items: - $ref: '#/components/schemas/SPFRecord' - description: SPF records found (includes resolved include directives) - dkim_records: - type: array - items: - $ref: '#/components/schemas/DKIMRecord' - description: DKIM records found - dmarc_record: - $ref: '#/components/schemas/DMARCRecord' - bimi_record: - $ref: '#/components/schemas/BIMIRecord' - ptr_records: - type: array - items: - type: string - description: PTR (reverse DNS) records for the sender IP address - example: ["mail.example.com", "smtp.example.com"] - ptr_forward_records: - type: array - items: - type: string - description: A or AAAA records resolved from the PTR hostnames (forward confirmation) - example: ["192.0.2.1", "2001:db8::1"] - errors: - type: array - items: - type: string - description: DNS lookup errors - - MXRecord: - type: object - required: - - host - - priority - - valid - properties: - host: - type: string - description: MX hostname - example: "mail.example.com" - priority: - type: integer - format: uint16 - description: MX priority (lower is higher priority) - example: 10 - valid: - type: boolean - description: Whether the MX record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "Failed to lookup MX records" - - SPFRecord: - type: object - required: - - valid - properties: - domain: - type: string - description: Domain this SPF record belongs to - example: "example.com" - record: - type: string - description: SPF record content - example: "v=spf1 include:_spf.example.com ~all" - valid: - type: boolean - description: Whether the SPF record is valid - example: true - all_qualifier: - type: string - enum: ["+", "-", "~", "?"] - description: "Qualifier for the 'all' mechanism: + (pass), - (fail), ~ (softfail), ? (neutral)" - example: "~" - error: - type: string - description: Error message if validation failed - example: "No SPF record found" - - DKIMRecord: - type: object - required: - - selector - - domain - - valid - properties: - selector: - type: string - description: DKIM selector - example: "default" - domain: - type: string - description: Domain name - example: "example.com" - record: - type: string - description: DKIM record content - example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..." - valid: - type: boolean - description: Whether the DKIM record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "No DKIM record found" - - DMARCRecord: - type: object - required: - - valid - properties: - record: - type: string - description: DMARC record content - example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" - policy: - type: string - enum: [none, quarantine, reject, unknown] - description: DMARC policy - example: "quarantine" - subdomain_policy: - type: string - enum: [none, quarantine, reject, unknown] - description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy - example: "quarantine" - percentage: - type: integer - minimum: 0 - maximum: 100 - description: Percentage of messages subjected to filtering (pct tag, default 100) - example: 100 - spf_alignment: - type: string - enum: [relaxed, strict] - description: SPF alignment mode (aspf tag) - example: "relaxed" - dkim_alignment: - type: string - enum: [relaxed, strict] - description: DKIM alignment mode (adkim tag) - example: "relaxed" - valid: - type: boolean - description: Whether the DMARC record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "No DMARC record found" - - BIMIRecord: - type: object - required: - - selector - - domain - - valid - properties: - selector: - type: string - description: BIMI selector - example: "default" - domain: - type: string - description: Domain name - example: "example.com" - record: - type: string - description: BIMI record content - example: "v=BIMI1; l=https://example.com/logo.svg" - logo_url: - type: string - format: uri - description: URL to the brand logo (SVG) - example: "https://example.com/logo.svg" - vmc_url: - type: string - format: uri - description: URL to Verified Mark Certificate (optional) - example: "https://example.com/vmc.pem" - valid: - type: boolean - description: Whether the BIMI record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "No BIMI record found" - - BlacklistCheck: - type: object - required: - - rbl - - listed - properties: - rbl: - type: string - description: RBL/DNSBL name - example: "zen.spamhaus.org" - listed: - type: boolean - description: Whether IP is listed - example: false - response: - type: string - description: RBL response code or message - example: "127.0.0.2" - error: - type: string - description: RBL error if any - - Status: - type: object - required: - - status - - version - properties: - status: - type: string - enum: [healthy, degraded, unhealthy] - description: Overall service status - example: "healthy" - version: - type: string - description: Service version - example: "0.1.0-dev" - components: - type: object - properties: - database: - type: string - enum: [up, down] - example: "up" - mta: - type: string - enum: [up, down] - example: "up" - uptime: - type: integer - description: Service uptime in seconds - example: 3600 - - Error: - type: object - required: - - error - - message - properties: - error: - type: string - description: Error code - example: "not_found" - message: - type: string - description: Human-readable error message - example: "Test not found" - details: - type: string - description: Additional error details - - DomainTestRequest: - type: object - required: - - domain - properties: - domain: - type: string - pattern: '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' - description: Domain name to test (e.g., example.com) - example: "example.com" - - DomainTestResponse: - type: object - required: - - domain - - score - - grade - - dns_results - properties: - domain: - type: string - description: The tested domain name - example: "example.com" - score: - type: integer - minimum: 0 - maximum: 100 - description: Overall domain configuration score (0-100) - example: 85 - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score - example: "A" - dns_results: - $ref: '#/components/schemas/DNSResults' - - BlacklistCheckRequest: - type: object - required: - - ip - properties: - ip: - type: string - description: IPv4 or IPv6 address to check against blacklists - example: "192.0.2.1" - pattern: '^([0-9]{1,3}\.){3}[0-9]{1,3}$|^([0-9a-fA-F]{0,4}:){7}[0-9a-fA-F]{0,4}$|^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$|^([0-9a-fA-F]{0,4}:){1,6}:([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$' - - BlacklistCheckResponse: - type: object - required: - - ip - - blacklists - - listed_count - - score - - grade - properties: - ip: - type: string - description: The IP address that was checked - example: "192.0.2.1" - blacklists: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - description: List of blacklist check results - listed_count: - type: integer - description: Number of blacklists that have this IP listed - example: 0 - score: - type: integer - minimum: 0 - maximum: 100 - description: Blacklist score (0-100, higher is better) - example: 100 - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score - example: "A+" - whitelists: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - description: List of DNS whitelist check results (informational only) - - TestSummary: - type: object - required: - - test_id - - score - - grade - - created_at - properties: - test_id: - type: string - pattern: '^[a-z0-9-]+$' - description: Test identifier (base32-encoded with hyphens) - score: - type: integer - minimum: 0 - maximum: 100 - description: Overall deliverability score (0-100) - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade - from_domain: - type: string - description: Sender domain extracted from the report - created_at: - type: string - format: date-time - - TestListResponse: - type: object - required: - - tests - - total - - offset - - limit - properties: - tests: - type: array - items: - $ref: '#/components/schemas/TestSummary' - total: - type: integer - description: Total number of tests - offset: - type: integer - description: Current offset - limit: - type: integer - description: Current limit diff --git a/generate.go b/generate.go index 324c52c..d1ee5ab 100644 --- a/generate.go +++ b/generate.go @@ -21,5 +21,5 @@ package main -//go:generate go tool oapi-codegen -config api/config-models.yaml api/schemas.yaml +//go:generate go tool oapi-codegen -config api/config-models.yaml api/openapi.yaml //go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml diff --git a/go.mod b/go.mod index 7f1285f..2cb24a7 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.25.0 require ( github.com/JGLTechnologies/gin-rate-limit v1.5.6 github.com/emersion/go-smtp v0.24.0 - github.com/getkin/kin-openapi v0.133.0 github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 github.com/oapi-codegen/runtime v1.4.0 @@ -16,7 +15,6 @@ require ( ) require ( - github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect @@ -26,6 +24,7 @@ require ( github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/swag/jsonname v0.25.4 // indirect diff --git a/go.sum b/go.sum index 10c9b72..a79c0ba 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,5 @@ github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI= github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww= -github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= -github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -102,7 +98,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -132,8 +127,8 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc= github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ= -github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA= -github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY= +github.com/oapi-codegen/runtime v1.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4= +github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= @@ -170,7 +165,6 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= -github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/internal/api/handlers.go b/internal/api/handlers.go index de2d5df..e524b40 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -31,7 +31,6 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" "git.happydns.org/happyDeliver/internal/config" - "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/storage" "git.happydns.org/happyDeliver/internal/utils" "git.happydns.org/happyDeliver/internal/version" @@ -41,8 +40,8 @@ import ( // This interface breaks the circular dependency with pkg/analyzer type EmailAnalyzer interface { AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error) - AnalyzeDomain(domain string) (dnsResults *model.DNSResults, score int, grade string) - CheckBlacklistIP(ip string) (checks []model.BlacklistCheck, whitelists []model.BlacklistCheck, listedCount int, score int, grade string, err error) + AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string) + CheckBlacklistIP(ip string) (checks []BlacklistCheck, whitelists []BlacklistCheck, listedCount int, score int, grade string, err error) } // APIHandler implements the ServerInterface for handling API requests @@ -80,11 +79,11 @@ func (h *APIHandler) CreateTest(c *gin.Context) { ) // Return response - c.JSON(http.StatusCreated, model.TestResponse{ + c.JSON(http.StatusCreated, TestResponse{ Id: base32ID, Email: openapi_types.Email(email), - Status: model.TestResponseStatusPending, - Message: utils.PtrTo("Send your test email to the given address"), + Status: TestResponseStatusPending, + Message: stringPtr("Send your test email to the given address"), }) } @@ -94,10 +93,10 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, model.Error{ + c.JSON(http.StatusBadRequest, Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } @@ -105,20 +104,20 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) { // Check if a report exists for this test ID reportExists, err := h.storage.ReportExists(testUUID) if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{ + c.JSON(http.StatusInternalServerError, Error{ Error: "internal_error", Message: "Failed to check test status", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } // Determine status based on report existence - var apiStatus model.TestStatus + var apiStatus TestStatus if reportExists { - apiStatus = model.TestStatusAnalyzed + apiStatus = TestStatusAnalyzed } else { - apiStatus = model.TestStatusPending + apiStatus = TestStatusPending } // Generate test email address using Base32-encoded UUID @@ -128,7 +127,7 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) { h.config.Email.Domain, ) - c.JSON(http.StatusOK, model.Test{ + c.JSON(http.StatusOK, Test{ Id: id, Email: openapi_types.Email(email), Status: apiStatus, @@ -141,10 +140,10 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, model.Error{ + c.JSON(http.StatusBadRequest, Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } @@ -152,16 +151,16 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) { reportJSON, _, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, model.Error{ + c.JSON(http.StatusNotFound, Error{ Error: "not_found", Message: "Report not found", }) return } - c.JSON(http.StatusInternalServerError, model.Error{ + c.JSON(http.StatusInternalServerError, Error{ Error: "internal_error", Message: "Failed to retrieve report", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } @@ -176,10 +175,10 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, model.Error{ + c.JSON(http.StatusBadRequest, Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } @@ -187,16 +186,16 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { _, rawEmail, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, model.Error{ + c.JSON(http.StatusNotFound, Error{ Error: "not_found", Message: "Email not found", }) return } - c.JSON(http.StatusInternalServerError, model.Error{ + c.JSON(http.StatusInternalServerError, Error{ Error: "internal_error", Message: "Failed to retrieve raw email", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } @@ -210,10 +209,10 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, model.Error{ + c.JSON(http.StatusBadRequest, Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } @@ -222,16 +221,16 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { _, rawEmail, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, model.Error{ + c.JSON(http.StatusNotFound, Error{ Error: "not_found", Message: "Email not found", }) return } - c.JSON(http.StatusInternalServerError, model.Error{ + c.JSON(http.StatusInternalServerError, Error{ Error: "internal_error", Message: "Failed to retrieve email", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } @@ -239,20 +238,20 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { // Re-analyze the email using the current analyzer reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID) if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{ + c.JSON(http.StatusInternalServerError, Error{ Error: "analysis_error", Message: "Failed to re-analyze email", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } // Update the report in storage if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil { - c.JSON(http.StatusInternalServerError, model.Error{ + c.JSON(http.StatusInternalServerError, Error{ Error: "internal_error", Message: "Failed to update report", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } @@ -268,24 +267,24 @@ func (h *APIHandler) GetStatus(c *gin.Context) { uptime := int(time.Since(h.startTime).Seconds()) // Check database connectivity by trying to check if a report exists - dbStatus := model.StatusComponentsDatabaseUp + dbStatus := StatusComponentsDatabaseUp if _, err := h.storage.ReportExists(uuid.New()); err != nil { - dbStatus = model.StatusComponentsDatabaseDown + dbStatus = StatusComponentsDatabaseDown } // Determine overall status - overallStatus := model.Healthy - if dbStatus == model.StatusComponentsDatabaseDown { - overallStatus = model.Unhealthy + overallStatus := Healthy + if dbStatus == StatusComponentsDatabaseDown { + overallStatus = Unhealthy } - mtaStatus := model.StatusComponentsMtaUp - c.JSON(http.StatusOK, model.Status{ + mtaStatus := StatusComponentsMtaUp + c.JSON(http.StatusOK, Status{ Status: overallStatus, Version: version.Version, Components: &struct { - Database *model.StatusComponentsDatabase `json:"database,omitempty"` - Mta *model.StatusComponentsMta `json:"mta,omitempty"` + Database *StatusComponentsDatabase `json:"database,omitempty"` + Mta *StatusComponentsMta `json:"mta,omitempty"` }{ Database: &dbStatus, Mta: &mtaStatus, @@ -297,14 +296,14 @@ func (h *APIHandler) GetStatus(c *gin.Context) { // TestDomain performs synchronous domain analysis // (POST /domain) func (h *APIHandler) TestDomain(c *gin.Context) { - var request model.DomainTestRequest + var request DomainTestRequest // Bind and validate request if err := c.ShouldBindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, model.Error{ + c.JSON(http.StatusBadRequest, Error{ Error: "invalid_request", Message: "Invalid request body", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } @@ -313,28 +312,28 @@ func (h *APIHandler) TestDomain(c *gin.Context) { dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain) // Convert grade string to DomainTestResponseGrade enum - var responseGrade model.DomainTestResponseGrade + var responseGrade DomainTestResponseGrade switch grade { case "A+": - responseGrade = model.DomainTestResponseGradeA + responseGrade = DomainTestResponseGradeA case "A": - responseGrade = model.DomainTestResponseGradeA1 + responseGrade = DomainTestResponseGradeA1 case "B": - responseGrade = model.DomainTestResponseGradeB + responseGrade = DomainTestResponseGradeB case "C": - responseGrade = model.DomainTestResponseGradeC + responseGrade = DomainTestResponseGradeC case "D": - responseGrade = model.DomainTestResponseGradeD + responseGrade = DomainTestResponseGradeD case "E": - responseGrade = model.DomainTestResponseGradeE + responseGrade = DomainTestResponseGradeE case "F": - responseGrade = model.DomainTestResponseGradeF + responseGrade = DomainTestResponseGradeF default: - responseGrade = model.DomainTestResponseGradeF + responseGrade = DomainTestResponseGradeF } // Build response - response := model.DomainTestResponse{ + response := DomainTestResponse{ Domain: request.Domain, Score: score, Grade: responseGrade, @@ -347,14 +346,14 @@ func (h *APIHandler) TestDomain(c *gin.Context) { // CheckBlacklist checks an IP address against DNS blacklists // (POST /blacklist) func (h *APIHandler) CheckBlacklist(c *gin.Context) { - var request model.BlacklistCheckRequest + var request BlacklistCheckRequest // Bind and validate request if err := c.ShouldBindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, model.Error{ + c.JSON(http.StatusBadRequest, Error{ Error: "invalid_request", Message: "Invalid request body", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } @@ -362,22 +361,22 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) { // Perform blacklist check using analyzer checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip) if err != nil { - c.JSON(http.StatusBadRequest, model.Error{ + c.JSON(http.StatusBadRequest, Error{ Error: "invalid_ip", Message: "Invalid IP address", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } // Build response - response := model.BlacklistCheckResponse{ + response := BlacklistCheckResponse{ Ip: request.Ip, Blacklists: checks, Whitelists: &whitelists, ListedCount: listedCount, Score: score, - Grade: model.BlacklistCheckResponseGrade(grade), + Grade: BlacklistCheckResponseGrade(grade), } c.JSON(http.StatusOK, response) @@ -387,7 +386,7 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) { // (GET /tests) func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) { if h.config.DisableTestList { - c.JSON(http.StatusForbidden, model.Error{ + c.JSON(http.StatusForbidden, Error{ Error: "feature_disabled", Message: "Test listing is disabled on this instance", }) @@ -406,17 +405,51 @@ func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) { } } - tests, total, err := h.storage.ListReportSummaries(offset, limit) + summaries, total, err := h.storage.ListReportSummaries(offset, limit) if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{ + c.JSON(http.StatusInternalServerError, Error{ Error: "internal_error", Message: "Failed to list tests", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } - c.JSON(http.StatusOK, model.TestListResponse{ + tests := make([]TestSummary, 0, len(summaries)) + for _, s := range summaries { + base32ID := utils.UUIDToBase32(s.TestID) + + var grade TestSummaryGrade + switch s.Grade { + case "A+": + grade = TestSummaryGradeA + case "A": + grade = TestSummaryGradeA1 + case "B": + grade = TestSummaryGradeB + case "C": + grade = TestSummaryGradeC + case "D": + grade = TestSummaryGradeD + case "E": + grade = TestSummaryGradeE + default: + grade = TestSummaryGradeF + } + + summary := TestSummary{ + TestId: base32ID, + Score: s.Score, + Grade: grade, + CreatedAt: s.CreatedAt, + } + if s.FromDomain != "" { + summary.FromDomain = stringPtr(s.FromDomain) + } + tests = append(tests, summary) + } + + c.JSON(http.StatusOK, TestListResponse{ Tests: tests, Total: int(total), Offset: offset, diff --git a/internal/utils/ptr.go b/internal/api/helpers.go similarity index 91% rename from internal/utils/ptr.go rename to internal/api/helpers.go index 748d6ba..cce306a 100644 --- a/internal/utils/ptr.go +++ b/internal/api/helpers.go @@ -1,5 +1,5 @@ // This file is part of the happyDeliver (R) project. -// Copyright (c) 2026 happyDomain +// Copyright (c) 2025 happyDomain // Authors: Pierre-Olivier Mercier, et al. // // This program is offered under a commercial and under the AGPL license. @@ -19,7 +19,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package utils +package api + +func stringPtr(s string) *string { + return &s +} // PtrTo returns a pointer to the provided value func PtrTo[T any](v T) *T { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 86605df..1077e74 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -30,9 +30,6 @@ import ( "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" - - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" ) var ( @@ -48,12 +45,21 @@ type Storage interface { ReportExists(testID uuid.UUID) (bool, error) UpdateReport(testID uuid.UUID, reportJSON []byte) error DeleteOldReports(olderThan time.Time) (int64, error) - ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) + ListReportSummaries(offset, limit int) ([]ReportSummary, int64, error) // Close closes the database connection Close() error } +// ReportSummary is a lightweight projection of Report for listing +type ReportSummary struct { + TestID uuid.UUID + Score int + Grade string + FromDomain string + CreatedAt time.Time +} + // DBStorage implements Storage using GORM type DBStorage struct { db *gorm.DB @@ -143,24 +149,15 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) { return result.RowsAffected, nil } -// reportSummaryRow is used internally to scan SQL results before converting to model.TestSummary -type reportSummaryRow struct { - TestID uuid.UUID - Score int - Grade string - FromDomain string - CreatedAt time.Time -} - // ListReportSummaries returns a paginated list of lightweight report summaries -func (s *DBStorage) ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) { +func (s *DBStorage) ListReportSummaries(offset, limit int) ([]ReportSummary, int64, error) { var total int64 if err := s.db.Model(&Report{}).Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count reports: %w", err) } if total == 0 { - return []model.TestSummary{}, 0, nil + return []ReportSummary{}, 0, nil } var selectExpr string @@ -171,41 +168,25 @@ func (s *DBStorage) ListReportSummaries(offset, limit int) ([]model.TestSummary, `convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` + `convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` + `created_at` - case "sqlite": + default: // sqlite selectExpr = `test_id, ` + `json_extract(report_json, '$.score') as score, ` + `json_extract(report_json, '$.grade') as grade, ` + `json_extract(report_json, '$.dns_results.from_domain') as from_domain, ` + `created_at` - default: - return nil, 0, fmt.Errorf("history tests list not implemented in this database dialect") } - var rows []reportSummaryRow + var summaries []ReportSummary err := s.db.Model(&Report{}). Select(selectExpr). Order("created_at DESC"). Offset(offset). Limit(limit). - Scan(&rows).Error + Scan(&summaries).Error if err != nil { return nil, 0, fmt.Errorf("failed to list report summaries: %w", err) } - summaries := make([]model.TestSummary, 0, len(rows)) - for _, r := range rows { - s := model.TestSummary{ - TestId: utils.UUIDToBase32(r.TestID), - Score: r.Score, - Grade: model.TestSummaryGrade(r.Grade), - CreatedAt: r.CreatedAt, - } - if r.FromDomain != "" { - s.FromDomain = utils.PtrTo(r.FromDomain) - } - summaries = append(summaries, s) - } - return summaries, total, nil } diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 5f57df3..f21d1f8 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -28,7 +28,7 @@ import ( "github.com/google/uuid" - "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/config" ) @@ -59,7 +59,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { type AnalysisResult struct { Email *EmailMessage Results *AnalysisResults - Report *model.Report + Report *api.Report } // AnalyzeEmailBytes performs complete email analysis from raw bytes @@ -113,7 +113,7 @@ func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byt } // AnalyzeDomain performs DNS analysis for a domain and returns the results -func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, string) { +func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string) { // Perform DNS analysis dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain) @@ -124,7 +124,7 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, strin } // CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists -func (a *APIAdapter) CheckBlacklistIP(ip string) ([]model.BlacklistCheck, []model.BlacklistCheck, int, int, string, error) { +func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.BlacklistCheck, int, int, string, error) { // Check the IP against all configured RBLs checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip) if err != nil { @@ -134,7 +134,7 @@ func (a *APIAdapter) CheckBlacklistIP(ip string) ([]model.BlacklistCheck, []mode // Calculate score using the existing function // Create a minimal RBLResults structure for scoring results := &DNSListResults{ - Checks: map[string][]model.BlacklistCheck{ip: checks}, + Checks: map[string][]api.BlacklistCheck{ip: checks}, IPsChecked: []string{ip}, ListedCount: listedCount, } diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index da31b1c..2beeb1f 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -24,7 +24,7 @@ package analyzer import ( "strings" - "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/api" ) // AuthenticationAnalyzer analyzes email authentication results @@ -38,8 +38,8 @@ func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer } // AnalyzeAuthentication extracts and analyzes authentication results from email headers -func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults { - results := &model.AuthenticationResults{} +func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults { + results := &api.AuthenticationResults{} // Parse Authentication-Results headers authHeaders := email.GetAuthenticationResults(a.receiverHostname) @@ -65,7 +65,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *mod // parseAuthenticationResultsHeader parses an Authentication-Results header // Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com -func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *model.AuthenticationResults) { +func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *api.AuthenticationResults) { // Split by semicolon to get individual results parts := strings.Split(header, ";") if len(parts) < 2 { @@ -91,7 +91,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, dkimResult := a.parseDKIMResult(part) if dkimResult != nil { if results.Dkim == nil { - dkimList := []model.AuthResult{*dkimResult} + dkimList := []api.AuthResult{*dkimResult} results.Dkim = &dkimList } else { *results.Dkim = append(*results.Dkim, *dkimResult) @@ -145,7 +145,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, // CalculateAuthenticationScore calculates the authentication score from auth results // Returns a score from 0-100 where higher is better -func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.AuthenticationResults) (int, string) { +func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) (int, string) { if results == nil { return 0, "" } diff --git a/pkg/analyzer/authentication_arc.go b/pkg/analyzer/authentication_arc.go index e7333ce..01b7505 100644 --- a/pkg/analyzer/authentication_arc.go +++ b/pkg/analyzer/authentication_arc.go @@ -27,8 +27,7 @@ import ( "slices" "strings" - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" + "git.happydns.org/happyDeliver/internal/api" ) // textprotoCanonical converts a header name to canonical form @@ -53,24 +52,24 @@ func pluralize(count int) string { // parseARCResult parses ARC result from Authentication-Results // Example: arc=pass -func (a *AuthenticationAnalyzer) parseARCResult(part string) *model.ARCResult { - result := &model.ARCResult{} +func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { + result := &api.ARCResult{} // Extract result (pass, fail, none) re := regexp.MustCompile(`arc=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = model.ARCResultResult(resultStr) + result.Result = api.ARCResultResult(resultStr) } - result.Details = utils.PtrTo(strings.TrimPrefix(part, "arc=")) + result.Details = api.PtrTo(strings.TrimPrefix(part, "arc=")) return result } // parseARCHeaders parses ARC headers from email message // ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal -func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARCResult { +func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult { // Get all ARC-related headers arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] @@ -81,8 +80,8 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARC return nil } - result := &model.ARCResult{ - Result: model.ARCResultResultNone, + result := &api.ARCResult{ + Result: api.ARCResultResultNone, } // Count the ARC chain length (number of sets) @@ -95,15 +94,15 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARC // Determine overall result if chainLength == 0 { - result.Result = model.ARCResultResultNone + result.Result = api.ARCResultResultNone details := "No ARC chain present" result.Details = &details } else if !chainValid { - result.Result = model.ARCResultResultFail + result.Result = api.ARCResultResultFail details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength) result.Details = &details } else { - result.Result = model.ARCResultResultPass + result.Result = api.ARCResultResultPass details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength)) result.Details = &details } @@ -112,7 +111,7 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARC } // enhanceARCResult enhances an existing ARC result with chain information -func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *model.ARCResult) { +func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) { if arcResult == nil { return } diff --git a/pkg/analyzer/authentication_arc_test.go b/pkg/analyzer/authentication_arc_test.go index ac51d0b..7f2f99e 100644 --- a/pkg/analyzer/authentication_arc_test.go +++ b/pkg/analyzer/authentication_arc_test.go @@ -24,29 +24,29 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/api" ) func TestParseARCResult(t *testing.T) { tests := []struct { name string part string - expectedResult model.ARCResultResult + expectedResult api.ARCResultResult }{ { name: "ARC pass", part: "arc=pass", - expectedResult: model.ARCResultResultPass, + expectedResult: api.ARCResultResultPass, }, { name: "ARC fail", part: "arc=fail", - expectedResult: model.ARCResultResultFail, + expectedResult: api.ARCResultResultFail, }, { name: "ARC none", part: "arc=none", - expectedResult: model.ARCResultResultNone, + expectedResult: api.ARCResultResultNone, }, } diff --git a/pkg/analyzer/authentication_bimi.go b/pkg/analyzer/authentication_bimi.go index 9654ac7..0d68281 100644 --- a/pkg/analyzer/authentication_bimi.go +++ b/pkg/analyzer/authentication_bimi.go @@ -25,20 +25,19 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" + "git.happydns.org/happyDeliver/internal/api" ) // parseBIMIResult parses BIMI result from Authentication-Results // Example: bimi=pass header.d=example.com header.selector=default -func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *model.AuthResult { - result := &model.AuthResult{} +func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { + result := &api.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`bimi=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = model.AuthResultResult(resultStr) + result.Result = api.AuthResultResult(resultStr) } // Extract domain (header.d or d) @@ -55,17 +54,17 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *model.AuthResult result.Selector = &selector } - result.Details = utils.PtrTo(strings.TrimPrefix(part, "bimi=")) + result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi=")) return result } -func (a *AuthenticationAnalyzer) calculateBIMIScore(results *model.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateBIMIScore(results *api.AuthenticationResults) (score int) { if results.Bimi != nil { switch results.Bimi.Result { - case model.AuthResultResultPass: + case api.AuthResultResultPass: return 100 - case model.AuthResultResultDeclined: + case api.AuthResultResultDeclined: return 59 default: // fail return 0 diff --git a/pkg/analyzer/authentication_bimi_test.go b/pkg/analyzer/authentication_bimi_test.go index 440f356..7cb9c85 100644 --- a/pkg/analyzer/authentication_bimi_test.go +++ b/pkg/analyzer/authentication_bimi_test.go @@ -24,42 +24,42 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/api" ) func TestParseBIMIResult(t *testing.T) { tests := []struct { name string part string - expectedResult model.AuthResultResult + expectedResult api.AuthResultResult expectedDomain string expectedSelector string }{ { name: "BIMI pass with domain and selector", part: "bimi=pass header.d=example.com header.selector=default", - expectedResult: model.AuthResultResultPass, + expectedResult: api.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "default", }, { name: "BIMI fail", part: "bimi=fail header.d=example.com header.selector=default", - expectedResult: model.AuthResultResultFail, + expectedResult: api.AuthResultResultFail, expectedDomain: "example.com", expectedSelector: "default", }, { name: "BIMI with short form (d= and selector=)", part: "bimi=pass d=example.com selector=v1", - expectedResult: model.AuthResultResultPass, + expectedResult: api.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "v1", }, { name: "BIMI none", part: "bimi=none header.d=example.com", - expectedResult: model.AuthResultResultNone, + expectedResult: api.AuthResultResultNone, expectedDomain: "example.com", }, } diff --git a/pkg/analyzer/authentication_dkim.go b/pkg/analyzer/authentication_dkim.go index 4165d8b..b6cf5f8 100644 --- a/pkg/analyzer/authentication_dkim.go +++ b/pkg/analyzer/authentication_dkim.go @@ -25,20 +25,19 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" + "git.happydns.org/happyDeliver/internal/api" ) // parseDKIMResult parses DKIM result from Authentication-Results // Example: dkim=pass header.d=example.com header.s=selector1 -func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *model.AuthResult { - result := &model.AuthResult{} +func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { + result := &api.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`dkim=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = model.AuthResultResult(resultStr) + result.Result = api.AuthResultResult(resultStr) } // Extract domain (header.d or d) @@ -55,18 +54,18 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *model.AuthResult result.Selector = &selector } - result.Details = utils.PtrTo(strings.TrimPrefix(part, "dkim=")) + result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim=")) return result } -func (a *AuthenticationAnalyzer) calculateDKIMScore(results *model.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) { // Expect at least one passing signature if results.Dkim != nil && len(*results.Dkim) > 0 { hasPass := false hasNonPass := false for _, dkim := range *results.Dkim { - if dkim.Result == model.AuthResultResultPass { + if dkim.Result == api.AuthResultResultPass { hasPass = true } else { hasNonPass = true diff --git a/pkg/analyzer/authentication_dkim_test.go b/pkg/analyzer/authentication_dkim_test.go index 0576854..3218639 100644 --- a/pkg/analyzer/authentication_dkim_test.go +++ b/pkg/analyzer/authentication_dkim_test.go @@ -24,35 +24,35 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/api" ) func TestParseDKIMResult(t *testing.T) { tests := []struct { name string part string - expectedResult model.AuthResultResult + expectedResult api.AuthResultResult expectedDomain string expectedSelector string }{ { name: "DKIM pass with domain and selector", part: "dkim=pass header.d=example.com header.s=default", - expectedResult: model.AuthResultResultPass, + expectedResult: api.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "default", }, { name: "DKIM fail", part: "dkim=fail header.d=example.com header.s=selector1", - expectedResult: model.AuthResultResultFail, + expectedResult: api.AuthResultResultFail, expectedDomain: "example.com", expectedSelector: "selector1", }, { name: "DKIM with short form (d= and s=)", part: "dkim=pass d=example.com s=default", - expectedResult: model.AuthResultResultPass, + expectedResult: api.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "default", }, diff --git a/pkg/analyzer/authentication_dmarc.go b/pkg/analyzer/authentication_dmarc.go index c89093d..329a5c9 100644 --- a/pkg/analyzer/authentication_dmarc.go +++ b/pkg/analyzer/authentication_dmarc.go @@ -25,20 +25,19 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" + "git.happydns.org/happyDeliver/internal/api" ) // parseDMARCResult parses DMARC result from Authentication-Results // Example: dmarc=pass action=none header.from=example.com -func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *model.AuthResult { - result := &model.AuthResult{} +func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { + result := &api.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`dmarc=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = model.AuthResultResult(resultStr) + result.Result = api.AuthResultResult(resultStr) } // Extract domain (header.from) @@ -48,17 +47,17 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *model.AuthResult result.Domain = &domain } - result.Details = utils.PtrTo(strings.TrimPrefix(part, "dmarc=")) + result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc=")) return result } -func (a *AuthenticationAnalyzer) calculateDMARCScore(results *model.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateDMARCScore(results *api.AuthenticationResults) (score int) { if results.Dmarc != nil { switch results.Dmarc.Result { - case model.AuthResultResultPass: + case api.AuthResultResultPass: return 100 - case model.AuthResultResultNone: + case api.AuthResultResultNone: return 33 default: // fail return 0 diff --git a/pkg/analyzer/authentication_dmarc_test.go b/pkg/analyzer/authentication_dmarc_test.go index 69779a7..3b8fb08 100644 --- a/pkg/analyzer/authentication_dmarc_test.go +++ b/pkg/analyzer/authentication_dmarc_test.go @@ -24,26 +24,26 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/api" ) func TestParseDMARCResult(t *testing.T) { tests := []struct { name string part string - expectedResult model.AuthResultResult + expectedResult api.AuthResultResult expectedDomain string }{ { name: "DMARC pass", part: "dmarc=pass action=none header.from=example.com", - expectedResult: model.AuthResultResultPass, + expectedResult: api.AuthResultResultPass, expectedDomain: "example.com", }, { name: "DMARC fail", part: "dmarc=fail action=quarantine header.from=example.com", - expectedResult: model.AuthResultResultFail, + expectedResult: api.AuthResultResultFail, expectedDomain: "example.com", }, } diff --git a/pkg/analyzer/authentication_iprev.go b/pkg/analyzer/authentication_iprev.go index 3ed045c..e799094 100644 --- a/pkg/analyzer/authentication_iprev.go +++ b/pkg/analyzer/authentication_iprev.go @@ -25,20 +25,19 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" + "git.happydns.org/happyDeliver/internal/api" ) // parseIPRevResult parses IP reverse lookup result from Authentication-Results // Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it) -func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *model.IPRevResult { - result := &model.IPRevResult{} +func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult { + result := &api.IPRevResult{} // Extract result (pass, fail, temperror, permerror, none) re := regexp.MustCompile(`iprev=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = model.IPRevResultResult(resultStr) + result.Result = api.IPRevResultResult(resultStr) } // Extract IP address (smtp.remote-ip or remote-ip) @@ -55,15 +54,15 @@ func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *model.IPRevResul result.Hostname = &hostname } - result.Details = utils.PtrTo(strings.TrimPrefix(part, "iprev=")) + result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev=")) return result } -func (a *AuthenticationAnalyzer) calculateIPRevScore(results *model.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.AuthenticationResults) (score int) { if results.Iprev != nil { switch results.Iprev.Result { - case model.Pass: + case api.Pass: return 100 default: // fail, temperror, permerror return 0 diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go index 55f85d5..5b46995 100644 --- a/pkg/analyzer/authentication_iprev_test.go +++ b/pkg/analyzer/authentication_iprev_test.go @@ -24,72 +24,71 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" + "git.happydns.org/happyDeliver/internal/api" ) func TestParseIPRevResult(t *testing.T) { tests := []struct { name string part string - expectedResult model.IPRevResultResult + expectedResult api.IPRevResultResult expectedIP *string expectedHostname *string }{ { name: "IPRev pass with IP and hostname", part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", - expectedResult: model.Pass, - expectedIP: utils.PtrTo("195.110.101.58"), - expectedHostname: utils.PtrTo("authsmtp74.register.it"), + expectedResult: api.Pass, + expectedIP: api.PtrTo("195.110.101.58"), + expectedHostname: api.PtrTo("authsmtp74.register.it"), }, { name: "IPRev pass without smtp prefix", part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)", - expectedResult: model.Pass, - expectedIP: utils.PtrTo("192.0.2.1"), - expectedHostname: utils.PtrTo("mail.example.com"), + expectedResult: api.Pass, + expectedIP: api.PtrTo("192.0.2.1"), + expectedHostname: api.PtrTo("mail.example.com"), }, { name: "IPRev fail", part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)", - expectedResult: model.Fail, - expectedIP: utils.PtrTo("198.51.100.42"), - expectedHostname: utils.PtrTo("unknown.host.com"), + expectedResult: api.Fail, + expectedIP: api.PtrTo("198.51.100.42"), + expectedHostname: api.PtrTo("unknown.host.com"), }, { name: "IPRev temperror", part: "iprev=temperror smtp.remote-ip=203.0.113.1", - expectedResult: model.Temperror, - expectedIP: utils.PtrTo("203.0.113.1"), + expectedResult: api.Temperror, + expectedIP: api.PtrTo("203.0.113.1"), expectedHostname: nil, }, { name: "IPRev permerror", part: "iprev=permerror smtp.remote-ip=192.0.2.100", - expectedResult: model.Permerror, - expectedIP: utils.PtrTo("192.0.2.100"), + expectedResult: api.Permerror, + expectedIP: api.PtrTo("192.0.2.100"), expectedHostname: nil, }, { name: "IPRev with IPv6", part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)", - expectedResult: model.Pass, - expectedIP: utils.PtrTo("2001:db8::1"), - expectedHostname: utils.PtrTo("ipv6.example.com"), + expectedResult: api.Pass, + expectedIP: api.PtrTo("2001:db8::1"), + expectedHostname: api.PtrTo("ipv6.example.com"), }, { name: "IPRev with subdomain hostname", part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)", - expectedResult: model.Pass, - expectedIP: utils.PtrTo("192.0.2.50"), - expectedHostname: utils.PtrTo("mail.subdomain.example.com"), + expectedResult: api.Pass, + expectedIP: api.PtrTo("192.0.2.50"), + expectedHostname: api.PtrTo("mail.subdomain.example.com"), }, { name: "IPRev pass without parentheses", part: "iprev=pass smtp.remote-ip=192.0.2.200", - expectedResult: model.Pass, - expectedIP: utils.PtrTo("192.0.2.200"), + expectedResult: api.Pass, + expectedIP: api.PtrTo("192.0.2.200"), expectedHostname: nil, }, } @@ -143,29 +142,29 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { tests := []struct { name string header string - expectedIPRevResult *model.IPRevResultResult + expectedIPRevResult *api.IPRevResultResult expectedIP *string expectedHostname *string }{ { name: "IPRev pass in Authentication-Results", header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", - expectedIPRevResult: utils.PtrTo(model.Pass), - expectedIP: utils.PtrTo("195.110.101.58"), - expectedHostname: utils.PtrTo("authsmtp74.register.it"), + expectedIPRevResult: api.PtrTo(api.Pass), + expectedIP: api.PtrTo("195.110.101.58"), + expectedHostname: api.PtrTo("authsmtp74.register.it"), }, { name: "IPRev with other authentication methods", header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com", - expectedIPRevResult: utils.PtrTo(model.Pass), - expectedIP: utils.PtrTo("192.0.2.1"), - expectedHostname: utils.PtrTo("mail.example.com"), + expectedIPRevResult: api.PtrTo(api.Pass), + expectedIP: api.PtrTo("192.0.2.1"), + expectedHostname: api.PtrTo("mail.example.com"), }, { name: "IPRev fail", header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42", - expectedIPRevResult: utils.PtrTo(model.Fail), - expectedIP: utils.PtrTo("198.51.100.42"), + expectedIPRevResult: api.PtrTo(api.Fail), + expectedIP: api.PtrTo("198.51.100.42"), expectedHostname: nil, }, { @@ -176,9 +175,9 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { { name: "Multiple IPRev results - only first is parsed", header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)", - expectedIPRevResult: utils.PtrTo(model.Pass), - expectedIP: utils.PtrTo("192.0.2.1"), - expectedHostname: utils.PtrTo("first.com"), + expectedIPRevResult: api.PtrTo(api.Pass), + expectedIP: api.PtrTo("192.0.2.1"), + expectedHostname: api.PtrTo("first.com"), }, } @@ -186,7 +185,7 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results := &model.AuthenticationResults{} + results := &api.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(tt.header, results) // Check IPRev diff --git a/pkg/analyzer/authentication_spf.go b/pkg/analyzer/authentication_spf.go index 1488c98..fc41e3c 100644 --- a/pkg/analyzer/authentication_spf.go +++ b/pkg/analyzer/authentication_spf.go @@ -25,20 +25,19 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" + "git.happydns.org/happyDeliver/internal/api" ) // parseSPFResult parses SPF result from Authentication-Results // Example: spf=pass smtp.mailfrom=sender@example.com -func (a *AuthenticationAnalyzer) parseSPFResult(part string) *model.AuthResult { - result := &model.AuthResult{} +func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult { + result := &api.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`spf=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = model.AuthResultResult(resultStr) + result.Result = api.AuthResultResult(resultStr) } // Extract domain @@ -52,13 +51,13 @@ func (a *AuthenticationAnalyzer) parseSPFResult(part string) *model.AuthResult { } } - result.Details = utils.PtrTo(strings.TrimPrefix(part, "spf=")) + result.Details = api.PtrTo(strings.TrimPrefix(part, "spf=")) return result } // parseLegacySPF attempts to parse SPF from Received-SPF header -func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *model.AuthResult { +func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { receivedSPF := email.Header.Get("Received-SPF") if receivedSPF == "" { return nil @@ -74,13 +73,13 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *model.Auth } } - result := &model.AuthResult{} + result := &api.AuthResult{} // Extract result (first word) parts := strings.Fields(receivedSPF) if len(parts) > 0 { resultStr := strings.ToLower(parts[0]) - result.Result = model.AuthResultResult(resultStr) + result.Result = api.AuthResultResult(resultStr) } result.Details = &receivedSPF @@ -98,14 +97,14 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *model.Auth return result } -func (a *AuthenticationAnalyzer) calculateSPFScore(results *model.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateSPFScore(results *api.AuthenticationResults) (score int) { if results.Spf != nil { switch results.Spf.Result { - case model.AuthResultResultPass: + case api.AuthResultResultPass: return 100 - case model.AuthResultResultNeutral, model.AuthResultResultNone: + case api.AuthResultResultNeutral, api.AuthResultResultNone: return 50 - case model.AuthResultResultSoftfail: + case api.AuthResultResultSoftfail: return 17 default: // fail, temperror, permerror return 0 diff --git a/pkg/analyzer/authentication_spf_test.go b/pkg/analyzer/authentication_spf_test.go index 210505a..960aef5 100644 --- a/pkg/analyzer/authentication_spf_test.go +++ b/pkg/analyzer/authentication_spf_test.go @@ -24,39 +24,38 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" + "git.happydns.org/happyDeliver/internal/api" ) func TestParseSPFResult(t *testing.T) { tests := []struct { name string part string - expectedResult model.AuthResultResult + expectedResult api.AuthResultResult expectedDomain string }{ { name: "SPF pass with domain", part: "spf=pass smtp.mailfrom=sender@example.com", - expectedResult: model.AuthResultResultPass, + expectedResult: api.AuthResultResultPass, expectedDomain: "example.com", }, { name: "SPF fail", part: "spf=fail smtp.mailfrom=sender@example.com", - expectedResult: model.AuthResultResultFail, + expectedResult: api.AuthResultResultFail, expectedDomain: "example.com", }, { name: "SPF neutral", part: "spf=neutral smtp.mailfrom=sender@example.com", - expectedResult: model.AuthResultResultNeutral, + expectedResult: api.AuthResultResultNeutral, expectedDomain: "example.com", }, { name: "SPF softfail", part: "spf=softfail smtp.mailfrom=sender@example.com", - expectedResult: model.AuthResultResultSoftfail, + expectedResult: api.AuthResultResultSoftfail, expectedDomain: "example.com", }, } @@ -85,7 +84,7 @@ func TestParseLegacySPF(t *testing.T) { tests := []struct { name string receivedSPF string - expectedResult model.AuthResultResult + expectedResult api.AuthResultResult expectedDomain *string expectNil bool }{ @@ -98,8 +97,8 @@ func TestParseLegacySPF(t *testing.T) { envelope-from="user@example.com"; helo=smtp.example.com; client-ip=192.0.2.10`, - expectedResult: model.AuthResultResultPass, - expectedDomain: utils.PtrTo("example.com"), + expectedResult: api.AuthResultResultPass, + expectedDomain: api.PtrTo("example.com"), }, { name: "SPF fail with sender", @@ -110,43 +109,43 @@ func TestParseLegacySPF(t *testing.T) { sender="sender@test.com"; helo=smtp.test.com; client-ip=192.0.2.20`, - expectedResult: model.AuthResultResultFail, - expectedDomain: utils.PtrTo("test.com"), + expectedResult: api.AuthResultResultFail, + expectedDomain: api.PtrTo("test.com"), }, { name: "SPF softfail", receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"", - expectedResult: model.AuthResultResultSoftfail, - expectedDomain: utils.PtrTo("example.org"), + expectedResult: api.AuthResultResultSoftfail, + expectedDomain: api.PtrTo("example.org"), }, { name: "SPF neutral", receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"", - expectedResult: model.AuthResultResultNeutral, - expectedDomain: utils.PtrTo("domain.net"), + expectedResult: api.AuthResultResultNeutral, + expectedDomain: api.PtrTo("domain.net"), }, { name: "SPF none", receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"", - expectedResult: model.AuthResultResultNone, - expectedDomain: utils.PtrTo("company.io"), + expectedResult: api.AuthResultResultNone, + expectedDomain: api.PtrTo("company.io"), }, { name: "SPF temperror", receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"", - expectedResult: model.AuthResultResultTemperror, - expectedDomain: utils.PtrTo("shop.example"), + expectedResult: api.AuthResultResultTemperror, + expectedDomain: api.PtrTo("shop.example"), }, { name: "SPF permerror", receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"", - expectedResult: model.AuthResultResultPermerror, - expectedDomain: utils.PtrTo("invalid.test"), + expectedResult: api.AuthResultResultPermerror, + expectedDomain: api.PtrTo("invalid.test"), }, { name: "SPF pass without domain extraction", receivedSPF: "pass (example.com: 192.0.2.50 is authorized)", - expectedResult: model.AuthResultResultPass, + expectedResult: api.AuthResultResultPass, expectedDomain: nil, }, { @@ -157,8 +156,8 @@ func TestParseLegacySPF(t *testing.T) { { name: "SPF with unquoted envelope-from", receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net", - expectedResult: model.AuthResultResultPass, - expectedDomain: utils.PtrTo("mail.example.net"), + expectedResult: api.AuthResultResultPass, + expectedDomain: api.PtrTo("mail.example.net"), }, } diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index 44c1abb..7122f53 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -24,77 +24,76 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" + "git.happydns.org/happyDeliver/internal/api" ) func TestGetAuthenticationScore(t *testing.T) { tests := []struct { name string - results *model.AuthenticationResults + results *api.AuthenticationResults expectedScore int }{ { name: "Perfect authentication (SPF + DKIM + DMARC)", - results: &model.AuthenticationResults{ - Spf: &model.AuthResult{ - Result: model.AuthResultResultPass, + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, }, - Dkim: &[]model.AuthResult{ - {Result: model.AuthResultResultPass}, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, }, - Dmarc: &model.AuthResult{ - Result: model.AuthResultResultPass, + Dmarc: &api.AuthResult{ + Result: api.AuthResultResultPass, }, }, expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25 }, { name: "SPF and DKIM only", - results: &model.AuthenticationResults{ - Spf: &model.AuthResult{ - Result: model.AuthResultResultPass, + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, }, - Dkim: &[]model.AuthResult{ - {Result: model.AuthResultResultPass}, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, }, }, expectedScore: 48, // SPF=25 + DKIM=23 }, { name: "SPF fail, DKIM pass", - results: &model.AuthenticationResults{ - Spf: &model.AuthResult{ - Result: model.AuthResultResultFail, + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultFail, }, - Dkim: &[]model.AuthResult{ - {Result: model.AuthResultResultPass}, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, }, }, expectedScore: 23, // SPF=0 + DKIM=23 }, { name: "SPF softfail", - results: &model.AuthenticationResults{ - Spf: &model.AuthResult{ - Result: model.AuthResultResultSoftfail, + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultSoftfail, }, }, expectedScore: 4, }, { name: "No authentication", - results: &model.AuthenticationResults{}, + results: &api.AuthenticationResults{}, expectedScore: 0, }, { name: "BIMI adds to score", - results: &model.AuthenticationResults{ - Spf: &model.AuthResult{ - Result: model.AuthResultResultPass, + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, }, - Bimi: &model.AuthResult{ - Result: model.AuthResultResultPass, + Bimi: &api.AuthResult{ + Result: api.AuthResultResultPass, }, }, expectedScore: 35, // SPF (25) + BIMI (10) @@ -118,30 +117,30 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { tests := []struct { name string header string - expectedSPFResult *model.AuthResultResult + expectedSPFResult *api.AuthResultResult expectedSPFDomain *string expectedDKIMCount int - expectedDKIMResult *model.AuthResultResult - expectedDMARCResult *model.AuthResultResult + expectedDKIMResult *api.AuthResultResult + expectedDMARCResult *api.AuthResultResult expectedDMARCDomain *string - expectedBIMIResult *model.AuthResultResult - expectedARCResult *model.ARCResultResult + expectedBIMIResult *api.AuthResultResult + expectedARCResult *api.ARCResultResult }{ { name: "Complete authentication results", header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com", - expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), - expectedSPFDomain: utils.PtrTo("example.com"), + expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedSPFDomain: api.PtrTo("example.com"), expectedDKIMCount: 1, - expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), - expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass), - expectedDMARCDomain: utils.PtrTo("example.com"), + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDMARCResult: api.PtrTo(api.AuthResultResultPass), + expectedDMARCDomain: api.PtrTo("example.com"), }, { name: "SPF only", header: "mail.example.com; spf=pass smtp.mailfrom=user@domain.com", - expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), - expectedSPFDomain: utils.PtrTo("domain.com"), + expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedSPFDomain: api.PtrTo("domain.com"), expectedDKIMCount: 0, expectedDMARCResult: nil, }, @@ -150,68 +149,68 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1", expectedSPFResult: nil, expectedDKIMCount: 1, - expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), }, { name: "Multiple DKIM signatures", header: "mail.example.com; dkim=pass header.d=example.com header.s=s1; dkim=pass header.d=example.com header.s=s2", expectedSPFResult: nil, expectedDKIMCount: 2, - expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), expectedDMARCResult: nil, }, { name: "SPF fail with DKIM pass", header: "mail.example.com; spf=fail smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default", - expectedSPFResult: utils.PtrTo(model.AuthResultResultFail), - expectedSPFDomain: utils.PtrTo("example.com"), + expectedSPFResult: api.PtrTo(api.AuthResultResultFail), + expectedSPFDomain: api.PtrTo("example.com"), expectedDKIMCount: 1, - expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), expectedDMARCResult: nil, }, { name: "SPF softfail", header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com", - expectedSPFResult: utils.PtrTo(model.AuthResultResultSoftfail), - expectedSPFDomain: utils.PtrTo("example.com"), + expectedSPFResult: api.PtrTo(api.AuthResultResultSoftfail), + expectedSPFDomain: api.PtrTo("example.com"), expectedDKIMCount: 0, expectedDMARCResult: nil, }, { name: "DMARC fail", header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=fail action=quarantine header.from=example.com", - expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFResult: api.PtrTo(api.AuthResultResultPass), expectedDKIMCount: 1, - expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), - expectedDMARCResult: utils.PtrTo(model.AuthResultResultFail), - expectedDMARCDomain: utils.PtrTo("example.com"), + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDMARCResult: api.PtrTo(api.AuthResultResultFail), + expectedDMARCDomain: api.PtrTo("example.com"), }, { name: "BIMI pass", header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; bimi=pass header.d=example.com header.selector=default", - expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), - expectedSPFDomain: utils.PtrTo("example.com"), + expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedSPFDomain: api.PtrTo("example.com"), expectedDKIMCount: 0, - expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass), + expectedBIMIResult: api.PtrTo(api.AuthResultResultPass), }, { name: "ARC pass", header: "mail.example.com; arc=pass", expectedSPFResult: nil, expectedDKIMCount: 0, - expectedARCResult: utils.PtrTo(model.ARCResultResultPass), + expectedARCResult: api.PtrTo(api.ARCResultResultPass), }, { name: "All authentication methods", header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com; bimi=pass header.d=example.com header.selector=v1; arc=pass", - expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), - expectedSPFDomain: utils.PtrTo("example.com"), + expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedSPFDomain: api.PtrTo("example.com"), expectedDKIMCount: 1, - expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), - expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass), - expectedDMARCDomain: utils.PtrTo("example.com"), - expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass), - expectedARCResult: utils.PtrTo(model.ARCResultResultPass), + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDMARCResult: api.PtrTo(api.AuthResultResultPass), + expectedDMARCDomain: api.PtrTo("example.com"), + expectedBIMIResult: api.PtrTo(api.AuthResultResultPass), + expectedARCResult: api.PtrTo(api.ARCResultResultPass), }, { name: "Empty header (authserv-id only)", @@ -222,8 +221,8 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { { name: "Empty parts with semicolons", header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;", - expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), - expectedSPFDomain: utils.PtrTo("example.com"), + expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedSPFDomain: api.PtrTo("example.com"), expectedDKIMCount: 0, }, { @@ -231,19 +230,19 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { header: "mail.example.com; dkim=pass d=example.com s=selector1", expectedSPFResult: nil, expectedDKIMCount: 1, - expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), }, { name: "SPF neutral", header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com", - expectedSPFResult: utils.PtrTo(model.AuthResultResultNeutral), - expectedSPFDomain: utils.PtrTo("example.com"), + expectedSPFResult: api.PtrTo(api.AuthResultResultNeutral), + expectedSPFDomain: api.PtrTo("example.com"), expectedDKIMCount: 0, }, { name: "SPF none", header: "mail.example.com; spf=none", - expectedSPFResult: utils.PtrTo(model.AuthResultResultNone), + expectedSPFResult: api.PtrTo(api.AuthResultResultNone), expectedDKIMCount: 0, }, } @@ -252,7 +251,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results := &model.AuthenticationResults{} + results := &api.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(tt.header, results) // Check SPF @@ -358,13 +357,13 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) { header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com" - results := &model.AuthenticationResults{} + results := &api.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Spf == nil { t.Fatal("Expected SPF result, got nil") } - if results.Spf.Result != model.AuthResultResultPass { + if results.Spf.Result != api.AuthResultResultPass { t.Errorf("Expected first SPF result (pass), got %v", results.Spf.Result) } if results.Spf.Domain == nil || *results.Spf.Domain != "example.com" { @@ -374,13 +373,13 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { t.Run("Multiple DMARC results - only first is parsed", func(t *testing.T) { header := "mail.example.com; dmarc=pass header.from=first.com; dmarc=fail header.from=second.com" - results := &model.AuthenticationResults{} + results := &api.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Dmarc == nil { t.Fatal("Expected DMARC result, got nil") } - if results.Dmarc.Result != model.AuthResultResultPass { + if results.Dmarc.Result != api.AuthResultResultPass { t.Errorf("Expected first DMARC result (pass), got %v", results.Dmarc.Result) } if results.Dmarc.Domain == nil || *results.Dmarc.Domain != "first.com" { @@ -390,26 +389,26 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) { header := "mail.example.com; arc=pass; arc=fail" - results := &model.AuthenticationResults{} + results := &api.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Arc == nil { t.Fatal("Expected ARC result, got nil") } - if results.Arc.Result != model.ARCResultResultPass { + if results.Arc.Result != api.ARCResultResultPass { t.Errorf("Expected first ARC result (pass), got %v", results.Arc.Result) } }) t.Run("Multiple BIMI results - only first is parsed", func(t *testing.T) { header := "mail.example.com; bimi=pass header.d=first.com; bimi=fail header.d=second.com" - results := &model.AuthenticationResults{} + results := &api.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Bimi == nil { t.Fatal("Expected BIMI result, got nil") } - if results.Bimi.Result != model.AuthResultResultPass { + if results.Bimi.Result != api.AuthResultResultPass { t.Errorf("Expected first BIMI result (pass), got %v", results.Bimi.Result) } if results.Bimi.Domain == nil || *results.Bimi.Domain != "first.com" { @@ -420,7 +419,7 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { t.Run("Multiple DKIM results - all are parsed", func(t *testing.T) { // DKIM is special - multiple signatures should all be collected header := "mail.example.com; dkim=pass header.d=first.com header.s=s1; dkim=fail header.d=second.com header.s=s2" - results := &model.AuthenticationResults{} + results := &api.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Dkim == nil { @@ -429,10 +428,10 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { if len(*results.Dkim) != 2 { t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim)) } - if (*results.Dkim)[0].Result != model.AuthResultResultPass { + if (*results.Dkim)[0].Result != api.AuthResultResultPass { t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result) } - if (*results.Dkim)[1].Result != model.AuthResultResultFail { + if (*results.Dkim)[1].Result != api.AuthResultResultFail { t.Errorf("Expected second DKIM result to be fail, got %v", (*results.Dkim)[1].Result) } }) diff --git a/pkg/analyzer/authentication_x_aligned_from.go b/pkg/analyzer/authentication_x_aligned_from.go index ec1571c..eb0cf98 100644 --- a/pkg/analyzer/authentication_x_aligned_from.go +++ b/pkg/analyzer/authentication_x_aligned_from.go @@ -25,35 +25,34 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" + "git.happydns.org/happyDeliver/internal/api" ) // parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results // Example: x-aligned-from=pass (Address match) -func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *model.AuthResult { - result := &model.AuthResult{} +func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *api.AuthResult { + result := &api.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`x-aligned-from=([\w]+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = model.AuthResultResult(resultStr) + result.Result = api.AuthResultResult(resultStr) } // Extract details (everything after the result) - result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-aligned-from=")) + result.Details = api.PtrTo(strings.TrimPrefix(part, "x-aligned-from=")) return result } -func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.AuthenticationResults) (score int) { if results.XAlignedFrom != nil { switch results.XAlignedFrom.Result { - case model.AuthResultResultPass: + case api.AuthResultResultPass: // pass: positive contribution return 100 - case model.AuthResultResultFail: + case api.AuthResultResultFail: // fail: negative contribution return 0 default: diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go index 1ea6d1c..0fdd69d 100644 --- a/pkg/analyzer/authentication_x_aligned_from_test.go +++ b/pkg/analyzer/authentication_x_aligned_from_test.go @@ -24,44 +24,44 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/api" ) func TestParseXAlignedFromResult(t *testing.T) { tests := []struct { name string part string - expectedResult model.AuthResultResult + expectedResult api.AuthResultResult expectedDetail string }{ { name: "x-aligned-from pass with details", part: "x-aligned-from=pass (Address match)", - expectedResult: model.AuthResultResultPass, + expectedResult: api.AuthResultResultPass, expectedDetail: "pass (Address match)", }, { name: "x-aligned-from fail with reason", part: "x-aligned-from=fail (Address mismatch)", - expectedResult: model.AuthResultResultFail, + expectedResult: api.AuthResultResultFail, expectedDetail: "fail (Address mismatch)", }, { name: "x-aligned-from pass minimal", part: "x-aligned-from=pass", - expectedResult: model.AuthResultResultPass, + expectedResult: api.AuthResultResultPass, expectedDetail: "pass", }, { name: "x-aligned-from neutral", part: "x-aligned-from=neutral (No alignment check performed)", - expectedResult: model.AuthResultResultNeutral, + expectedResult: api.AuthResultResultNeutral, expectedDetail: "neutral (No alignment check performed)", }, { name: "x-aligned-from none", part: "x-aligned-from=none", - expectedResult: model.AuthResultResultNone, + expectedResult: api.AuthResultResultNone, expectedDetail: "none", }, } @@ -88,34 +88,34 @@ func TestParseXAlignedFromResult(t *testing.T) { func TestCalculateXAlignedFromScore(t *testing.T) { tests := []struct { name string - result *model.AuthResult + result *api.AuthResult expectedScore int }{ { name: "pass result gives positive score", - result: &model.AuthResult{ - Result: model.AuthResultResultPass, + result: &api.AuthResult{ + Result: api.AuthResultResultPass, }, expectedScore: 100, }, { name: "fail result gives zero score", - result: &model.AuthResult{ - Result: model.AuthResultResultFail, + result: &api.AuthResult{ + Result: api.AuthResultResultFail, }, expectedScore: 0, }, { name: "neutral result gives zero score", - result: &model.AuthResult{ - Result: model.AuthResultResultNeutral, + result: &api.AuthResult{ + Result: api.AuthResultResultNeutral, }, expectedScore: 0, }, { name: "none result gives zero score", - result: &model.AuthResult{ - Result: model.AuthResultResultNone, + result: &api.AuthResult{ + Result: api.AuthResultResultNone, }, expectedScore: 0, }, @@ -130,7 +130,7 @@ func TestCalculateXAlignedFromScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results := &model.AuthenticationResults{ + results := &api.AuthenticationResults{ XAlignedFrom: tt.result, } diff --git a/pkg/analyzer/authentication_x_google_dkim.go b/pkg/analyzer/authentication_x_google_dkim.go index b33279e..4bba469 100644 --- a/pkg/analyzer/authentication_x_google_dkim.go +++ b/pkg/analyzer/authentication_x_google_dkim.go @@ -25,20 +25,19 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" + "git.happydns.org/happyDeliver/internal/api" ) // parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results // Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6 -func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *model.AuthResult { - result := &model.AuthResult{} +func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthResult { + result := &api.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`x-google-dkim=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = model.AuthResultResult(resultStr) + result.Result = api.AuthResultResult(resultStr) } // Extract domain (header.d or d) @@ -55,15 +54,15 @@ func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *model.Auth result.Selector = &selector } - result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) + result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) return result } -func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *model.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *api.AuthenticationResults) (score int) { if results.XGoogleDkim != nil { switch results.XGoogleDkim.Result { - case model.AuthResultResultPass: + case api.AuthResultResultPass: // pass: don't alter the score default: // fail return -100 diff --git a/pkg/analyzer/authentication_x_google_dkim_test.go b/pkg/analyzer/authentication_x_google_dkim_test.go index 4013340..f9704c0 100644 --- a/pkg/analyzer/authentication_x_google_dkim_test.go +++ b/pkg/analyzer/authentication_x_google_dkim_test.go @@ -24,39 +24,39 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/api" ) func TestParseXGoogleDKIMResult(t *testing.T) { tests := []struct { name string part string - expectedResult model.AuthResultResult + expectedResult api.AuthResultResult expectedDomain string expectedSelector string }{ { name: "x-google-dkim pass with domain", part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6", - expectedResult: model.AuthResultResultPass, + expectedResult: api.AuthResultResultPass, expectedDomain: "1e100.net", }, { name: "x-google-dkim pass with short form", part: "x-google-dkim=pass d=gmail.com", - expectedResult: model.AuthResultResultPass, + expectedResult: api.AuthResultResultPass, expectedDomain: "gmail.com", }, { name: "x-google-dkim fail", part: "x-google-dkim=fail header.d=example.com", - expectedResult: model.AuthResultResultFail, + expectedResult: api.AuthResultResultFail, expectedDomain: "example.com", }, { name: "x-google-dkim with minimal info", part: "x-google-dkim=pass", - expectedResult: model.AuthResultResultPass, + expectedResult: api.AuthResultResultPass, }, } diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index 06f8ddf..d14d157 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -32,8 +32,7 @@ import ( "time" "unicode" - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" + "git.happydns.org/happyDeliver/internal/api" "golang.org/x/net/html" ) @@ -729,16 +728,16 @@ func (c *ContentAnalyzer) normalizeText(text string) string { } // GenerateContentAnalysis creates structured content analysis from results -func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *model.ContentAnalysis { +func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.ContentAnalysis { if results == nil { return nil } - analysis := &model.ContentAnalysis{ - HasHtml: utils.PtrTo(results.HTMLContent != ""), - HasPlaintext: utils.PtrTo(results.TextContent != ""), - HasUnsubscribeLink: utils.PtrTo(results.HasUnsubscribe), - UnsubscribeMethods: &[]model.ContentAnalysisUnsubscribeMethods{}, + analysis := &api.ContentAnalysis{ + HasHtml: api.PtrTo(results.HTMLContent != ""), + HasPlaintext: api.PtrTo(results.TextContent != ""), + HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe), + UnsubscribeMethods: &[]api.ContentAnalysisUnsubscribeMethods{}, } // Calculate text-to-image ratio (inverse of image-to-text) @@ -751,16 +750,16 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode } // Build HTML issues - htmlIssues := []model.ContentIssue{} + htmlIssues := []api.ContentIssue{} // Add HTML parsing errors if !results.HTMLValid && len(results.HTMLErrors) > 0 { for _, errMsg := range results.HTMLErrors { - htmlIssues = append(htmlIssues, model.ContentIssue{ - Type: model.BrokenHtml, - Severity: model.ContentIssueSeverityHigh, + htmlIssues = append(htmlIssues, api.ContentIssue{ + Type: api.BrokenHtml, + Severity: api.ContentIssueSeverityHigh, Message: errMsg, - Advice: utils.PtrTo("Fix HTML structure errors to improve email rendering across clients"), + Advice: api.PtrTo("Fix HTML structure errors to improve email rendering across clients"), }) } } @@ -774,53 +773,53 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode } } if missingAltCount > 0 { - htmlIssues = append(htmlIssues, model.ContentIssue{ - Type: model.MissingAlt, - Severity: model.ContentIssueSeverityMedium, + htmlIssues = append(htmlIssues, api.ContentIssue{ + Type: api.MissingAlt, + Severity: api.ContentIssueSeverityMedium, Message: fmt.Sprintf("%d image(s) missing alt attributes", missingAltCount), - Advice: utils.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"), + Advice: api.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"), }) } } // Add excessive images issue if results.ImageTextRatio > 10.0 { - htmlIssues = append(htmlIssues, model.ContentIssue{ - Type: model.ExcessiveImages, - Severity: model.ContentIssueSeverityMedium, + htmlIssues = append(htmlIssues, api.ContentIssue{ + Type: api.ExcessiveImages, + Severity: api.ContentIssueSeverityMedium, Message: "Email is excessively image-heavy", - Advice: utils.PtrTo("Reduce the number of images relative to text content"), + Advice: api.PtrTo("Reduce the number of images relative to text content"), }) } // Add suspicious URL issues for _, suspURL := range results.SuspiciousURLs { - htmlIssues = append(htmlIssues, model.ContentIssue{ - Type: model.SuspiciousLink, - Severity: model.ContentIssueSeverityHigh, + htmlIssues = append(htmlIssues, api.ContentIssue{ + Type: api.SuspiciousLink, + Severity: api.ContentIssueSeverityHigh, Message: "Suspicious URL detected", Location: &suspURL, - Advice: utils.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"), + Advice: api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"), }) } // Add harmful HTML tag issues for _, harmfulIssue := range results.HarmfullIssues { - htmlIssues = append(htmlIssues, model.ContentIssue{ - Type: model.DangerousHtml, - Severity: model.ContentIssueSeverityCritical, + htmlIssues = append(htmlIssues, api.ContentIssue{ + Type: api.DangerousHtml, + Severity: api.ContentIssueSeverityCritical, Message: harmfulIssue, - Advice: utils.PtrTo("Remove dangerous HTML tags like