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/Dockerfile b/Dockerfile index 4568784..3d9440a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -121,7 +121,6 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap perl-xml-libxml \ postfix \ postfix-pcre \ - rspamd \ spamassassin \ spamassassin-client \ supervisor \ @@ -144,11 +143,8 @@ RUN mkdir -p /etc/happydeliver \ /var/lib/authentication_milter \ /var/spool/postfix/authentication_milter \ /var/spool/postfix/spamassassin \ - /var/spool/postfix/rspamd \ && chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \ - && chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin \ - && chown rspamd:mail /var/spool/postfix/rspamd \ - && chmod 750 /var/spool/postfix/rspamd + && chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin # Copy the built application COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver @@ -158,7 +154,6 @@ RUN chmod +x /usr/local/bin/happyDeliver COPY docker/postfix/ /etc/postfix/ COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json COPY docker/spamassassin/ /etc/mail/spamassassin/ -COPY docker/rspamd/local.d/ /etc/rspamd/local.d/ COPY docker/supervisor/ /etc/supervisor/ COPY docker/entrypoint.sh /entrypoint.sh @@ -170,13 +165,7 @@ RUN chmod +x /entrypoint.sh EXPOSE 25 8080 # Default configuration -ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \ - HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db \ - HAPPYDELIVER_DOMAIN=happydeliver.local \ - HAPPYDELIVER_ADDRESS_PREFIX=test- \ - HAPPYDELIVER_DNS_TIMEOUT=5s \ - HAPPYDELIVER_HTTP_TIMEOUT=10s \ - HAPPYDELIVER_RSPAMD_API_URL=http://127.0.0.1:11334 +ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net # Volume for persistent data VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"] diff --git a/README.md b/README.md index 4010d7e..3b28292 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ An open-source email deliverability testing platform that analyzes test emails a ## Features -- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin and rspamd scores, DNS records, blacklist status, content quality, and more +- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more - **REST API**: Full-featured API for creating tests and retrieving reports - **LMTP Server**: Built-in LMTP server for seamless MTA integration - **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers @@ -26,7 +26,6 @@ The easiest way to run happyDeliver is using the all-in-one Docker container tha - **Postfix MTA**: Receives emails on port 25 - **authentication_milter**: Entreprise grade email authentication - **SpamAssassin**: Spam scoring and analysis -- **rspamd**: Second spam filter for cross-validated scoring - **happyDeliver API**: REST API server on port 8080 - **SQLite Database**: Persistent storage for tests and reports @@ -163,27 +162,10 @@ The server will start on `http://localhost:8080` by default. #### 3. Integrate with your existing e-mail setup -It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ... +It is expected your setup annotate the email with eg. opendkim, spamassassin, ... happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations. -#### Receiver Hostname - -happyDeliver filters `Authentication-Results` headers by hostname to only trust headers added by your MTA (and not headers that may have been injected by the sender). By default, it uses the system hostname (`os.Hostname()`). - -If your MTA's `authserv-id` (the hostname at the beginning of `Authentication-Results` headers) differs from the machine running happyDeliver, you must set it explicitly: - -```bash -./happyDeliver server -receiver-hostname mail.example.com -``` - -Or via environment variable: -```bash -HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ./happyDeliver server -``` - -**How to find the correct value:** look at the `Authentication-Results` headers in a received email. They start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...` — in this case, use `mail.example.com`. - -If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname. +Choose one of the following way to integrate happyDeliver in your existing setup: #### Postfix LMTP Transport @@ -279,33 +261,6 @@ cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com **Note:** In production, emails are delivered via LMTP (see integration instructions above). -## Use with happyDomain - -happyDeliver can be driven by [happyDomain](https://happydomain.org) through -the [`checker-happydeliver`](https://git.nemunai.re/happyDomain/checker-happydeliver) -plugin, so the deliverability of a domain you manage is monitored alongside -its DNS and inbound SMTP posture. - -How it works: - -1. Attach the **Outbound deliverability** checker to the mail service of a zone - in happyDomain. Point it at a happyDeliver instance via `happydeliver_url`; - operators can configure a default instance globally. -2. On each run, the checker calls `POST /api/test` to allocate a fresh - recipient address, prompts the user (or an automated sender) to mail it from - the tested domain, then polls `GET /api/test/{id}` until the report is - ready. -3. The structured report from `GET /api/report/{id}` is translated into - happyDomain rule states: CRIT/WARN/INFO on SPF, DKIM, DMARC, alignment, spam - score, blacklists and headers, plus an overall score threshold - (`min_score`/`warn_score`). -4. Runs repeat on a configurable interval so a regression in deliverability (a - new RBL listing, a DKIM key rotation gone wrong, a broken SPF include, ...) - surfaces as a domain-level alert in happyDomain. - -See the [`checker-happydeliver` repository](https://git.nemunai.re/happyDomain/checker-happydeliver) -for build instructions and the full list of run options. - ## Scoring System The deliverability score is calculated from A to F based on: @@ -314,7 +269,7 @@ The deliverability score is calculated from A to F based on: - **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation - **Blacklist**: RBL/DNSBL checks - **Headers**: Required headers, MIME structure, Domain alignment -- **Spam**: SpamAssassin and rspamd scores (combined 50/50) +- **Spam**: SpamAssassin score - **Content**: HTML quality, links, images, unsubscribe ## Funding 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..8463007 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -76,49 +76,6 @@ paths: schema: $ref: '#/components/schemas/Error' - /tests: - get: - tags: - - tests - summary: List all tests - description: Returns a paginated list of test summaries with scores and grades. Can be disabled via server configuration. - operationId: listTests - parameters: - - name: offset - in: query - schema: - type: integer - minimum: 0 - default: 0 - description: Number of items to skip - - name: limit - in: query - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - description: Maximum number of items to return - responses: - '200': - description: List of test summaries - content: - application/json: - schema: - $ref: '#/components/schemas/TestListResponse' - '403': - description: Test listing is disabled - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - '500': - description: Internal server error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /report/{id}: get: tags: @@ -296,74 +253,1024 @@ 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' + 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 + 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: SpamAssassin score (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] + 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: + 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' - RspamdResult: - $ref: './schemas.yaml#/components/schemas/RspamdResult' + 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 + description: + type: string + description: Human-readable description of what this test checks + example: "Bayes spam probability is 0 to 1%" + 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' - TestSummary: - $ref: './schemas.yaml#/components/schemas/TestSummary' - TestListResponse: - $ref: './schemas.yaml#/components/schemas/TestListResponse' + type: object + required: + - ip + - checks + - listed_count + - score + - grade + properties: + ip: + type: string + description: The IP address that was checked + example: "192.0.2.1" + checks: + 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+" 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/docker/README.md b/docker/README.md index 2199eeb..3769365 100644 --- a/docker/README.md +++ b/docker/README.md @@ -110,38 +110,14 @@ Default configuration for the Docker environment: The container accepts these environment variables: - `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local) -- `HAPPYDELIVER_RECEIVER_HOSTNAME`: Hostname used to filter `Authentication-Results` headers (see below) -- `POSTFIX_CERT_FILE` / `POSTFIX_KEY_FILE`: TLS certificate and key paths for Postfix SMTP -### Receiver Hostname +Note that the hostname of the container is used to filter the authentication tests results. -happyDeliver filters `Authentication-Results` headers by hostname to only trust results from the expected MTA. By default, it uses the system hostname (i.e., the container's `--hostname`). - -In the all-in-one Docker container, the container hostname is also used as the `authserv-id` in the embedded Postfix and authentication_milter, so everything matches automatically. - -**When bypassing the embedded Postfix** (e.g., routing emails from your own MTA via LMTP), your MTA's `authserv-id` will likely differ from the container hostname. In that case, set `HAPPYDELIVER_RECEIVER_HOSTNAME` to your MTA's hostname: - -```bash -docker run -d \ - -e HAPPYDELIVER_DOMAIN=example.com \ - -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com \ - ... -``` - -To find the correct value, look at the `Authentication-Results` headers in a received email — they start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...`. - -If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname. - -Example (all-in-one, no override needed): +Example: ```bash docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ... ``` -Example (external MTA integration): -```bash -docker run -e HAPPYDELIVER_DOMAIN=example.com -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ... -``` - ## Volumes **Required volumes:** diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ef45b61..1bc3eff 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -15,10 +15,6 @@ mkdir -p /var/spool/postfix/authentication_milter chown mail:mail /var/spool/postfix/authentication_milter chmod 750 /var/spool/postfix/authentication_milter -mkdir -p /var/spool/postfix/rspamd -chown rspamd:mail /var/spool/postfix/rspamd -chmod 750 /var/spool/postfix/rspamd - # Create log directory mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter chown happydeliver:happydeliver /var/log/happydeliver diff --git a/docker/postfix/main.cf b/docker/postfix/main.cf index 5a73fb3..fcdb75c 100644 --- a/docker/postfix/main.cf +++ b/docker/postfix/main.cf @@ -28,7 +28,7 @@ transport_maps = pcre:/etc/postfix/transport_maps # OpenDKIM for DKIM verification milter_default_action = accept milter_protocol = 6 -smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock unix:/var/spool/postfix/rspamd/rspamd-milter.sock +smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock non_smtpd_milters = $smtpd_milters # SPF policy checking diff --git a/docker/rspamd/local.d/actions.conf b/docker/rspamd/local.d/actions.conf deleted file mode 100644 index f3ed60c..0000000 --- a/docker/rspamd/local.d/actions.conf +++ /dev/null @@ -1,5 +0,0 @@ -no_action = 0; -reject = null; -add_header = null; -rewrite_subject = null; -greylist = null; \ No newline at end of file diff --git a/docker/rspamd/local.d/milter_headers.conf b/docker/rspamd/local.d/milter_headers.conf deleted file mode 100644 index 378b8a3..0000000 --- a/docker/rspamd/local.d/milter_headers.conf +++ /dev/null @@ -1,5 +0,0 @@ -# Add "extended Rspamd headers" -extended_spam_headers = true; - -skip_local = false; -skip_authenticated = false; \ No newline at end of file diff --git a/docker/rspamd/local.d/options.inc b/docker/rspamd/local.d/options.inc deleted file mode 100644 index 485d0c9..0000000 --- a/docker/rspamd/local.d/options.inc +++ /dev/null @@ -1,3 +0,0 @@ -# rspamd options for happyDeliver -# Disable Bayes learning to keep the setup stateless -use_redis = false; diff --git a/docker/rspamd/local.d/worker-proxy.inc b/docker/rspamd/local.d/worker-proxy.inc deleted file mode 100644 index 04c9a1d..0000000 --- a/docker/rspamd/local.d/worker-proxy.inc +++ /dev/null @@ -1,6 +0,0 @@ -# Enable rspamd milter proxy worker via Unix socket for Postfix integration -bind_socket = "/var/spool/postfix/rspamd/rspamd-milter.sock mode=0660 owner=rspamd group=mail"; -upstream "local" { - default = yes; - self_scan = yes; -} diff --git a/docker/spamassassin/local.cf b/docker/spamassassin/local.cf index ce9a31c..c248ef6 100644 --- a/docker/spamassassin/local.cf +++ b/docker/spamassassin/local.cf @@ -48,14 +48,3 @@ rbl_timeout 5 # Don't use user-specific rules user_scores_dsn_timeout 3 user_scores_sql_override 0 - -# Disable Validity network rules -dns_query_restriction deny sa-trusted.bondedsender.org -dns_query_restriction deny sa-accredit.habeas.com -dns_query_restriction deny bl.score.senderscore.com -score RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0 -score RCVD_IN_VALIDITY_RPBL_BLOCKED 0 -score RCVD_IN_VALIDITY_SAFE_BLOCKED 0 -score RCVD_IN_VALIDITY_CERTIFIED 0 -score RCVD_IN_VALIDITY_RPBL 0 -score RCVD_IN_VALIDITY_SAFE 0 \ No newline at end of file diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf index 74f1810..c0c7002 100644 --- a/docker/supervisor/supervisord.conf +++ b/docker/supervisor/supervisord.conf @@ -33,16 +33,6 @@ stderr_logfile=/var/log/happydeliver/authentication_milter.log user=mail group=mail -# rspamd spam filter -[program:rspamd] -command=/usr/bin/rspamd -f -u rspamd -g mail -autostart=true -autorestart=true -priority=11 -stdout_logfile=/var/log/happydeliver/rspamd.log -stderr_logfile=/var/log/happydeliver/rspamd_error.log -user=root - # SpamAssassin daemon [program:spamd] command=/usr/sbin/spamd --max-children 5 --helper-home-dir /var/lib/spamassassin --syslog stderr --pidfile /var/run/spamd.pid 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 bcf45d7..e9da3d6 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,15 @@ module git.happydns.org/happyDeliver -go 1.25.0 +go 1.24.6 require ( - github.com/JGLTechnologies/gin-rate-limit v1.5.8 + github.com/JGLTechnologies/gin-rate-limit v1.5.6 github.com/emersion/go-smtp v0.24.0 - github.com/getkin/kin-openapi v0.135.0 - github.com/gin-gonic/gin v1.12.0 + github.com/getkin/kin-openapi v0.133.0 + github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 - github.com/oapi-codegen/runtime v1.3.0 - golang.org/x/net v0.53.0 + github.com/oapi-codegen/runtime v1.1.2 + golang.org/x/net v0.49.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 @@ -50,31 +50,30 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 // indirect - github.com/oasdiff/yaml v0.0.9 // indirect - github.com/oasdiff/yaml3 v0.0.9 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect - github.com/redis/go-redis/v9 v9.18.0 // indirect - github.com/speakeasy-api/jsonpath v0.6.3 // indirect - github.com/speakeasy-api/openapi v1.19.2 // indirect + github.com/redis/go-redis/v9 v9.17.2 // indirect + github.com/speakeasy-api/jsonpath v0.6.0 // indirect + github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect github.com/woodsbury/decimal128 v1.4.0 // indirect - go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect - go.uber.org/atomic v1.11.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.uber.org/mock v0.6.0 // indirect golang.org/x/arch v0.23.0 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.40.0 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 872377c..96ea7bc 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0= -github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g= +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= @@ -10,8 +10,12 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= +github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= +github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -22,9 +26,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= @@ -37,16 +40,22 @@ github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4Mc github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= +github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg= -github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= -github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-openapi/jsonpointer v0.22.2 h1:JDQEe4B9j6K3tQ7HQQTZfjR59IURhjjLxet2FB4KHyg= +github.com/go-openapi/jsonpointer v0.22.2/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= @@ -57,6 +66,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= @@ -64,6 +75,8 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -91,6 +104,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= @@ -119,6 +134,8 @@ github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8 github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -131,14 +148,14 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 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.7.0 h1:/8daqIYZfwnsHEAZdHUu9m0D5LA+5DoJCP7zLlT5Cs0= -github.com/oapi-codegen/oapi-codegen/v2 v2.7.0/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw= -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/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= -github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= -github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g= -github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +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.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +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= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= @@ -155,23 +172,26 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE= +github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= -github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= -github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= +github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= +github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= -github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= -github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M= -github.com/speakeasy-api/openapi v1.19.2/go.mod h1:UfKa7FqE4jgexJZuj51MmdHAFGmDv0Zaw3+yOd81YKU= +github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8= +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= @@ -196,26 +216,18 @@ github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= -go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -223,13 +235,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -245,21 +257,24 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -272,6 +287,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/api/handlers.go b/internal/api/handlers.go index de2d5df..80c8f9a 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, 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,79 +346,37 @@ 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 } // Perform blacklist check using analyzer - checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip) + checks, 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, + Checks: checks, ListedCount: listedCount, Score: score, - Grade: model.BlacklistCheckResponseGrade(grade), + Grade: BlacklistCheckResponseGrade(grade), } c.JSON(http.StatusOK, response) } - -// ListTests returns a paginated list of test summaries -// (GET /tests) -func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) { - if h.config.DisableTestList { - c.JSON(http.StatusForbidden, model.Error{ - Error: "feature_disabled", - Message: "Test listing is disabled on this instance", - }) - return - } - - offset := 0 - limit := 20 - if params.Offset != nil { - offset = *params.Offset - } - if params.Limit != nil { - limit = *params.Limit - if limit > 100 { - limit = 100 - } - } - - tests, total, err := h.storage.ListReportSummaries(offset, limit) - if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{ - Error: "internal_error", - Message: "Failed to list tests", - Details: utils.PtrTo(err.Error()), - }) - return - } - - c.JSON(http.StatusOK, model.TestListResponse{ - Tests: tests, - Total: int(total), - Offset: offset, - Limit: limit, - }) -} 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/config/cli.go b/internal/config/cli.go index fcc914f..3accc99 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -34,17 +34,14 @@ func declareFlags(o *Config) { flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails") flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)") flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address") - flag.StringVar(&o.Email.ReceiverHostname, "receiver-hostname", o.Email.ReceiverHostname, "Hostname used to filter Authentication-Results headers (defaults to os.Hostname())") flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query") flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query") flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)") flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)") - flag.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)") flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever") flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)") flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey") flag.StringVar(&o.CustomLogoURL, "custom-logo-url", o.CustomLogoURL, "URL for custom logo image in the web UI") - flag.BoolVar(&o.DisableTestList, "disable-test-list", o.DisableTestList, "Disable the public test listing endpoint") // Others flags are declared in some other files likes sources, storages, ... when they need specials configurations } diff --git a/internal/config/config.go b/internal/config/config.go index b264994..4a335c9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,11 +34,6 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) -func getHostname() string { - h, _ := os.Hostname() - return h -} - // Config represents the application configuration type Config struct { DevProxy string @@ -50,7 +45,6 @@ type Config struct { RateLimit uint // API rate limit (requests per second per IP) SurveyURL url.URL // URL for user feedback survey CustomLogoURL string // URL for custom logo image in the web UI - DisableTestList bool // Disable the public test listing endpoint } // DatabaseConfig contains database connection settings @@ -64,7 +58,6 @@ type EmailConfig struct { Domain string TestAddressPrefix string LMTPAddr string - ReceiverHostname string } // AnalysisConfig contains timeout and behavior settings for email analysis @@ -72,9 +65,7 @@ type AnalysisConfig struct { DNSTimeout time.Duration HTTPTimeout time.Duration RBLs []string - DNSWLs []string - CheckAllIPs bool // Check all IPs found in headers, not just the first one - RspamdAPIURL string // rspamd API URL for fetching symbol descriptions (empty = use embedded list) + CheckAllIPs bool // Check all IPs found in headers, not just the first one } // DefaultConfig returns a configuration with sensible defaults @@ -92,13 +83,11 @@ func DefaultConfig() *Config { Domain: "happydeliver.local", TestAddressPrefix: "test-", LMTPAddr: "127.0.0.1:2525", - ReceiverHostname: getHostname(), }, Analysis: AnalysisConfig{ DNSTimeout: 5 * time.Second, HTTPTimeout: 10 * time.Second, RBLs: []string{}, - DNSWLs: []string{}, CheckAllIPs: false, // By default, only check the first IP }, } diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index f06f535..062a091 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -98,17 +98,6 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score) - // Warn if the last Received hop doesn't match the expected receiver hostname - if r.config.Email.ReceiverHostname != "" && - result.Report.HeaderAnalysis != nil && - result.Report.HeaderAnalysis.ReceivedChain != nil && - len(*result.Report.HeaderAnalysis.ReceivedChain) > 0 { - lastHop := (*result.Report.HeaderAnalysis.ReceivedChain)[0] - if lastHop.By != nil && *lastHop.By != r.config.Email.ReceiverHostname { - log.Printf("WARNING: Last Received hop 'by' field (%s) does not match expected receiver hostname (%s): check your RECEIVER_HOSTNAME config as authentication results will be false", *lastHop.By, r.config.Email.ReceiverHostname) - } - } - // Marshal report to JSON reportJSON, err := json.Marshal(result.Report) if err != nil { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 86605df..39b2eb6 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,7 +45,6 @@ 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) // Close closes the database connection Close() error @@ -143,72 +139,6 @@ 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) { - 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 - } - - var selectExpr string - switch s.db.Dialector.Name() { - case "postgres": - selectExpr = `test_id, ` + - `(convert_from(report_json, 'UTF8')::jsonb->>'score')::int as score, ` + - `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": - 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 - err := s.db.Model(&Report{}). - Select(selectExpr). - Order("created_at DESC"). - Offset(offset). - Limit(limit). - Scan(&rows).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 -} - // Close closes the database connection func (s *DBStorage) Close() error { sqlDB, err := s.db.DB() diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 5f57df3..e7ae561 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" ) @@ -41,13 +41,10 @@ type EmailAnalyzer struct { // NewEmailAnalyzer creates a new email analyzer with the given configuration func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { generator := NewReportGenerator( - cfg.Email.ReceiverHostname, cfg.Analysis.DNSTimeout, cfg.Analysis.HTTPTimeout, cfg.Analysis.RBLs, - cfg.Analysis.DNSWLs, cfg.Analysis.CheckAllIPs, - cfg.Analysis.RspamdAPIURL, ) return &EmailAnalyzer{ @@ -59,7 +56,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 +110,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) @@ -123,28 +120,22 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, strin return dnsResults, score, grade } -// 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) { +// CheckBlacklistIP checks a single IP address against DNS blacklists +func (a *APIAdapter) CheckBlacklistIP(ip string) ([]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 { - return nil, nil, 0, 0, "", err + return nil, 0, 0, "", err } // Calculate score using the existing function // Create a minimal RBLResults structure for scoring - results := &DNSListResults{ - Checks: map[string][]model.BlacklistCheck{ip: checks}, + results := &RBLResults{ + Checks: map[string][]api.BlacklistCheck{ip: checks}, IPsChecked: []string{ip}, ListedCount: listedCount, } - score, grade := a.analyzer.generator.rblChecker.CalculateScore(results, false) + score, grade := a.analyzer.generator.rblChecker.CalculateRBLScore(results) - // Check the IP against all configured DNSWLs (informational only) - whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip) - if err != nil { - whitelists = nil - } - - return checks, whitelists, listedCount, score, grade, nil + return checks, listedCount, score, grade, nil } diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index da31b1c..07f7794 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -24,25 +24,23 @@ package analyzer import ( "strings" - "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/api" ) // AuthenticationAnalyzer analyzes email authentication results -type AuthenticationAnalyzer struct { - receiverHostname string -} +type AuthenticationAnalyzer struct{} // NewAuthenticationAnalyzer creates a new authentication analyzer -func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer { - return &AuthenticationAnalyzer{receiverHostname: receiverHostname} +func NewAuthenticationAnalyzer() *AuthenticationAnalyzer { + return &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) + authHeaders := email.GetAuthenticationResults() for _, header := range authHeaders { a.parseAuthenticationResultsHeader(header, results) } @@ -65,7 +63,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 +89,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,39 +143,34 @@ 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, "" } score := 0 - // Core authentication (90 points total) - // SPF (30 points) - score += 30 * a.calculateSPFScore(results) / 100 + // IPRev (15 points) + score += 15 * a.calculateIPRevScore(results) / 100 - // DKIM (30 points) - score += 30 * a.calculateDKIMScore(results) / 100 + // SPF (25 points) + score += 25 * a.calculateSPFScore(results) / 100 - // DMARC (30 points) - score += 30 * a.calculateDMARCScore(results) / 100 + // DKIM (23 points) + score += 23 * a.calculateDKIMScore(results) / 100 + + // X-Google-DKIM (optional) - penalty if failed + score += 12 * a.calculateXGoogleDKIMScore(results) / 100 + + // X-Aligned-From + score += 2 * a.calculateXAlignedFromScore(results) / 100 + + // DMARC (25 points) + score += 25 * a.calculateDMARCScore(results) / 100 // BIMI (10 points) score += 10 * a.calculateBIMIScore(results) / 100 - // Penalty-only: IPRev (up to -7 points on failure) - if iprevScore := a.calculateIPRevScore(results); iprevScore < 100 { - score += 7 * (iprevScore - 100) / 100 - } - - // Penalty-only: X-Google-DKIM (up to -12 points on failure) - score += 12 * a.calculateXGoogleDKIMScore(results) / 100 - - // Penalty-only: X-Aligned-From (up to -5 points on failure) - if xAlignedScore := a.calculateXAlignedFromScore(results); xAlignedScore < 100 { - score += 5 * (xAlignedScore - 100) / 100 - } - // Ensure score doesn't exceed 100 if score > 100 { score = 100 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..9269d70 100644 --- a/pkg/analyzer/authentication_arc_test.go +++ b/pkg/analyzer/authentication_arc_test.go @@ -24,33 +24,33 @@ 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, }, } - analyzer := NewAuthenticationAnalyzer("") + analyzer := NewAuthenticationAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -136,7 +136,7 @@ func TestValidateARCChain(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer("") + analyzer := NewAuthenticationAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { 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..b1b5468 100644 --- a/pkg/analyzer/authentication_bimi_test.go +++ b/pkg/analyzer/authentication_bimi_test.go @@ -24,47 +24,47 @@ 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", }, } - analyzer := NewAuthenticationAnalyzer("") + analyzer := NewAuthenticationAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { 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..2aab530 100644 --- a/pkg/analyzer/authentication_dkim_test.go +++ b/pkg/analyzer/authentication_dkim_test.go @@ -24,41 +24,41 @@ 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", }, } - analyzer := NewAuthenticationAnalyzer("") + analyzer := NewAuthenticationAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { 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..d7fda84 100644 --- a/pkg/analyzer/authentication_dmarc_test.go +++ b/pkg/analyzer/authentication_dmarc_test.go @@ -24,31 +24,31 @@ 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", }, } - analyzer := NewAuthenticationAnalyzer("") + analyzer := NewAuthenticationAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_iprev.go b/pkg/analyzer/authentication_iprev.go index 3ed045c..6538cbb 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,20 +54,20 @@ 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 } } - return 100 + return 0 } diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go index 55f85d5..d0529b5 100644 --- a/pkg/analyzer/authentication_iprev_test.go +++ b/pkg/analyzer/authentication_iprev_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 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, }, } - analyzer := NewAuthenticationAnalyzer("") + analyzer := NewAuthenticationAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -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,17 +175,17 @@ 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"), }, } - analyzer := NewAuthenticationAnalyzer("") + analyzer := NewAuthenticationAnalyzer() 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..479c325 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,35 +51,25 @@ 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 } - // Verify receiver matches our hostname - if a.receiverHostname != "" { - receiverRe := regexp.MustCompile(`receiver=([^\s;]+)`) - if matches := receiverRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { - if matches[1] != a.receiverHostname { - return nil - } - } - } - - 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 +87,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..7a84c49 100644 --- a/pkg/analyzer/authentication_spf_test.go +++ b/pkg/analyzer/authentication_spf_test.go @@ -24,44 +24,43 @@ 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", }, } - analyzer := NewAuthenticationAnalyzer("") + analyzer := NewAuthenticationAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -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,12 +156,12 @@ 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"), }, } - analyzer := NewAuthenticationAnalyzer("") + analyzer := NewAuthenticationAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index 44c1abb..27901b5 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -24,84 +24,83 @@ 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) }, } - scorer := NewAuthenticationAnalyzer("") + scorer := NewAuthenticationAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -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,28 +230,28 @@ 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, }, } - analyzer := NewAuthenticationAnalyzer("") + analyzer := NewAuthenticationAnalyzer() 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 @@ -354,17 +353,17 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { // This test verifies that only the first occurrence of each auth method is parsed - analyzer := NewAuthenticationAnalyzer("") + analyzer := NewAuthenticationAnalyzer() 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..36da2b0 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: @@ -62,5 +61,5 @@ func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.Authe } } - return 100 + return 0 } diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go index 1ea6d1c..220ac39 100644 --- a/pkg/analyzer/authentication_x_aligned_from_test.go +++ b/pkg/analyzer/authentication_x_aligned_from_test.go @@ -24,49 +24,49 @@ 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", }, } - analyzer := NewAuthenticationAnalyzer("") + analyzer := NewAuthenticationAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -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, }, @@ -126,11 +126,11 @@ func TestCalculateXAlignedFromScore(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer("") + analyzer := NewAuthenticationAnalyzer() 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..be29a08 100644 --- a/pkg/analyzer/authentication_x_google_dkim_test.go +++ b/pkg/analyzer/authentication_x_google_dkim_test.go @@ -24,43 +24,43 @@ 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, }, } - analyzer := NewAuthenticationAnalyzer("") + analyzer := NewAuthenticationAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index 06f8ddf..05aecfa 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -32,17 +32,15 @@ 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" ) // ContentAnalyzer analyzes email content (HTML, links, images) type ContentAnalyzer struct { - Timeout time.Duration - httpClient *http.Client - listUnsubscribeURLs []string // URLs from List-Unsubscribe header - hasOneClickUnsubscribe bool // True if List-Unsubscribe-Post: List-Unsubscribe=One-Click + Timeout time.Duration + httpClient *http.Client + listUnsubscribeURLs []string // URLs from List-Unsubscribe header } // NewContentAnalyzer creates a new content analyzer with configurable timeout @@ -117,10 +115,6 @@ func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults { // Parse List-Unsubscribe header URLs for use in link detection c.listUnsubscribeURLs = email.GetListUnsubscribeURLs() - // Check for one-click unsubscribe support - listUnsubscribePost := email.Header.Get("List-Unsubscribe-Post") - c.hasOneClickUnsubscribe = strings.EqualFold(strings.TrimSpace(listUnsubscribePost), "List-Unsubscribe=One-Click") - // Get HTML and text parts htmlParts := email.GetHTMLParts() textParts := email.GetTextParts() @@ -729,16 +723,15 @@ 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), } // Calculate text-to-image ratio (inverse of image-to-text) @@ -751,16 +744,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 +767,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
-

+

Blacklist Checks @@ -33,7 +35,11 @@

-
+ {#if receivedChain} + + {/if} + +
{#each Object.entries(blacklists) as [ip, checks]}
diff --git a/web/src/lib/components/EmailPathCard.svelte b/web/src/lib/components/EmailPathCard.svelte index a4fda45..8dc57b0 100644 --- a/web/src/lib/components/EmailPathCard.svelte +++ b/web/src/lib/components/EmailPathCard.svelte @@ -1,6 +1,5 @@ {#if receivedChain && receivedChain.length > 0} -
-
-

- - Email Path -

-
-
+
+
Email Path (Received Chain)
+
{#each receivedChain as hop, i}
@@ -40,7 +30,7 @@ : "-"}
- {#if hop.with || hop.id || hop.from} + {#if hop.with || hop.id}

{#if hop.with} diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index 73c39e8..b26b492 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -11,7 +11,7 @@ headerScore?: number; } - let { dmarcRecord, headerAnalysis, headerGrade, headerScore }: Props = $props(); + let { dmarcRecord, headerAnalysis, headerGrade, headerScore, xAlignedFrom }: Props = $props();

diff --git a/web/src/lib/components/HistoryTable.svelte b/web/src/lib/components/HistoryTable.svelte deleted file mode 100644 index 737d025..0000000 --- a/web/src/lib/components/HistoryTable.svelte +++ /dev/null @@ -1,72 +0,0 @@ - - -
- - - - - - - - - - - - {#each tests as test} - goto(`/test/${test.test_id}`)}> - - - - - - - {/each} - -
GradeScoreDomainDate
- - - {test.score}% - - {#if test.from_domain} - {test.from_domain} - {:else} - - - {/if} - - {formatDate(test.created_at)} - - -
-
- - diff --git a/web/src/lib/components/PtrForwardRecordsDisplay.svelte b/web/src/lib/components/PtrForwardRecordsDisplay.svelte index 8ed723b..77ce6c8 100644 --- a/web/src/lib/components/PtrForwardRecordsDisplay.svelte +++ b/web/src/lib/components/PtrForwardRecordsDisplay.svelte @@ -21,11 +21,6 @@ ); const hasForwardRecords = $derived(ptrForwardRecords && ptrForwardRecords.length > 0); - - let showDifferent = $state(false); - const differentCount = $derived( - ptrForwardRecords ? ptrForwardRecords.filter((ip) => ip !== senderIp).length : 0, - ); {#if ptrRecords && ptrRecords.length > 0} @@ -68,31 +63,15 @@
Forward Resolution (A/AAAA): {#each ptrForwardRecords as ip} - {#if ip === senderIp || !fcrDnsIsValid || showDifferent} -
- {#if senderIp && ip === senderIp} - Match - {:else} - Different - {/if} - {ip} -
- {/if} - {/each} - {#if fcrDnsIsValid && differentCount > 0} -
- +
+ {#if senderIp && ip === senderIp} + Match + {:else} + Different + {/if} + {ip}
- {/if} + {/each}
{#if fcrDnsIsValid}
diff --git a/web/src/lib/components/RspamdCard.svelte b/web/src/lib/components/RspamdCard.svelte deleted file mode 100644 index 4c2795b..0000000 --- a/web/src/lib/components/RspamdCard.svelte +++ /dev/null @@ -1,153 +0,0 @@ - - -
-
-

- - - rspamd Analysis - - - {#if rspamd.deliverability_score !== undefined} - - {rspamd.deliverability_score}% - - {/if} - {#if rspamd.deliverability_grade !== undefined} - - {/if} - -

-
-
-
-
- Score: - - {rspamd.score.toFixed(2)} / {rspamd.threshold.toFixed(1)} - -
-
- Classified as: - - {rspamd.is_spam ? "SPAM" : "HAM"} - -
-
- Action: - - {effectiveAction.label} - -
-
- - {#if rspamd.symbols && Object.keys(rspamd.symbols).length > 0} -
-
- - - - - - - - - - {#each Object.entries(rspamd.symbols).sort(([, a], [, b]) => b.score - a.score) as [symbolName, symbol]} - 0 - ? "table-warning" - : symbol.score < 0 - ? "table-success" - : ""} - > - - - - - {/each} - -
SymbolScoreDescription
- {symbolName} - {#if symbol.params} - - {symbol.params} - - {/if} - - 0 - ? "text-danger fw-bold" - : symbol.score < 0 - ? "text-success fw-bold" - : "text-muted"} - > - {symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)} - - {symbol.description ?? ""}
-
-
- {/if} - - {#if rspamd.report} -
- Raw Report -
{rspamd.report}
-
- {/if} -
-
- - diff --git a/web/src/lib/components/SpamAssassinCard.svelte b/web/src/lib/components/SpamAssassinCard.svelte index cc88c23..2da105e 100644 --- a/web/src/lib/components/SpamAssassinCard.svelte +++ b/web/src/lib/components/SpamAssassinCard.svelte @@ -6,9 +6,11 @@ interface Props { spamassassin: SpamAssassinResult; + spamGrade?: string; + spamScore?: number; } - let { spamassassin }: Props = $props(); + let { spamassassin, spamGrade, spamScore }: Props = $props();
@@ -19,13 +21,13 @@ SpamAssassin Analysis - {#if spamassassin.deliverability_score !== undefined} - - {spamassassin.deliverability_score}% + {#if spamScore !== undefined} + + {spamScore}% {/if} - {#if spamassassin.deliverability_grade !== undefined} - + {#if spamGrade !== undefined} + {/if}
diff --git a/web/src/lib/components/SummaryCard.svelte b/web/src/lib/components/SummaryCard.svelte index 518e996..199bc94 100644 --- a/web/src/lib/components/SummaryCard.svelte +++ b/web/src/lib/components/SummaryCard.svelte @@ -25,32 +25,16 @@ // Email sender information const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender"; - const hasDkim = - report.dns_results?.dkim_records && report.dns_results?.dkim_records?.length > 0; - const dkimPassed = - report.authentication?.dkim && - report.authentication?.dkim.length > 0 && - report.authentication?.dkim?.some((d) => d.result === "pass"); + const hasDkim = report.authentication?.dkim && report.authentication?.dkim.length > 0; + const dkimPassed = hasDkim && report.authentication?.dkim?.some((d) => d.result === "pass"); segments.push({ text: "Received a " }); segments.push({ - text: hasDkim ? "DKIM-signed" : "non-DKIM-signed", - highlight: { - color: hasDkim ? (dkimPassed ? "good" : "warning") : "danger", - bold: true, - }, - link: hasDkim && dkimPassed ? "#authentication-dkim" : "#dns-details", + text: dkimPassed ? "DKIM-signed" : "non-DKIM-signed", + highlight: { color: dkimPassed ? "good" : "danger", bold: true }, + link: "#authentication-dkim", }); - segments.push({ text: " email" }); - if (hasDkim && !dkimPassed) { - segments.push({ text: " with " }); - segments.push({ - text: "an invalid signature", - highlight: { color: "danger", bold: true }, - link: "#authentication-dkim", - }); - } - segments.push({ text: " from " }); + segments.push({ text: " email from " }); segments.push({ text: mailFrom, highlight: { emphasis: true }, @@ -129,7 +113,7 @@ } else if (spfResult === "temperror" || spfResult === "permerror") { segments.push({ text: "encountered an error", - highlight: { color: "danger", bold: true }, + highlight: { color: "warning", bold: true }, link: "#authentication-spf", }); segments.push({ text: ", check your SPF record configuration" }); @@ -347,7 +331,7 @@ highlight: { color: "good", bold: true }, link: "#dns-bimi", }); - if (bimiResult?.details && bimiResult.details.indexOf("declined") == 0) { + if (bimiResult.details && bimiResult.details.indexOf("declined") == 0) { segments.push({ text: " declined to participate" }); } else if (bimiResult?.result === "fail") { segments.push({ text: " but " }); @@ -438,17 +422,6 @@ }); } - // One-click unsubscribe check - const unsubscribeMethods = report.content_analysis?.unsubscribe_methods; - if (unsubscribeMethods && unsubscribeMethods.length > 0 && !unsubscribeMethods.includes("one-click")) { - segments.push({ text: ". This email could benefit from " }); - segments.push({ - text: "one-click unsubscribe", - highlight: { color: "warning", bold: true }, - link: "#content-details", - }); - } - // Content/spam assessment const spamAssassin = report.spamassassin; const contentScore = report.summary?.content_score || 0; diff --git a/web/src/lib/components/WhitelistCard.svelte b/web/src/lib/components/WhitelistCard.svelte deleted file mode 100644 index 13fd86b..0000000 --- a/web/src/lib/components/WhitelistCard.svelte +++ /dev/null @@ -1,62 +0,0 @@ - - -
-
-

- - - Whitelist Checks - - Informational -

-
-
-

- DNS whitelists identify trusted senders. Being listed here is a positive signal, but has - no impact on the overall score. -

- -
- {#each Object.entries(whitelists) as [ip, checks]} -
-
- - {ip} -
- - - {#each checks as check} - - - - - {/each} - -
- - {check.error - ? "Error" - : check.listed - ? "Listed" - : "Not listed"} - - {check.rbl}
-
- {/each} -
-
-
diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index a593801..3c76feb 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -19,10 +19,7 @@ export { default as PendingState } from "./PendingState.svelte"; export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte"; export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte"; export { default as ScoreCard } from "./ScoreCard.svelte"; -export { default as RspamdCard } from "./RspamdCard.svelte"; export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte"; export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte"; export { default as SummaryCard } from "./SummaryCard.svelte"; -export { default as HistoryTable } from "./HistoryTable.svelte"; export { default as TinySurvey } from "./TinySurvey.svelte"; -export { default as WhitelistCard } from "./WhitelistCard.svelte"; diff --git a/web/src/lib/stores/config.ts b/web/src/lib/stores/config.ts index 962868c..87662ba 100644 --- a/web/src/lib/stores/config.ts +++ b/web/src/lib/stores/config.ts @@ -25,8 +25,6 @@ interface AppConfig { report_retention?: number; survey_url?: string; custom_logo_url?: string; - rbls?: string[]; - test_list_enabled?: boolean; } const defaultConfig: AppConfig = { diff --git a/web/src/lib/stores/theme.ts b/web/src/lib/stores/theme.ts index ea24293..362202b 100644 --- a/web/src/lib/stores/theme.ts +++ b/web/src/lib/stores/theme.ts @@ -26,7 +26,7 @@ const getInitialTheme = () => { if (!browser) return "light"; const stored = localStorage.getItem("theme"); - if (stored === "light" || stored === "dark") return stored; + if (stored) return stored; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 92bb4db..077f340 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -40,17 +40,7 @@ {/if} - {#if $appConfig.test_list_enabled} - - {/if} -
+
Open-Source Email Deliverability Tester diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index b9259fe..7c23d10 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,30 +1,12 @@ - - - Test History - happyDeliver - - -
-
-
-
-

- - Test History -

- -
- - {#if loading} -
-
- Loading... -
-

Loading tests...

-
- {:else if error} - - {:else if tests.length === 0} -
- -

No tests yet

-

- Send a test email to get your first deliverability - report. -

- -
- {:else} - - - - {#if totalPages > 1} - - {/if} - {/if} -
-
-
diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 113209d..bf44d20 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -3,26 +3,21 @@ import { onDestroy } from "svelte"; import { getReport, getTest, reanalyzeReport } from "$lib/api"; - import type { BlacklistCheck, Report, Test } from "$lib/api/types.gen"; + import type { Report, Test } from "$lib/api/types.gen"; import { AuthenticationCard, BlacklistCard, ContentAnalysisCard, DnsRecordsCard, - EmailPathCard, ErrorDisplay, HeaderAnalysisCard, PendingState, - RspamdCard, ScoreCard, SpamAssassinCard, SummaryCard, TinySurvey, - WhitelistCard, } from "$lib/components"; - type BlacklistRecords = Record; - let testId = $derived(page.params.test); let test = $state(null); let report = $state(null); @@ -295,15 +290,6 @@
- - {#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0} -
-
- -
-
- {/if} - {#if report.dns_results}
@@ -334,45 +320,17 @@ {/if} - {#snippet blacklistChecks(blacklists: BlacklistRecords, report: Report)} - - {/snippet} - - - {#snippet whitelistChecks(whitelists: BlacklistRecords)} - - {/snippet} - - - {#if report.blacklists && report.whitelists && Object.keys(report.blacklists).length == 1 && Object.keys(report.whitelists).length == 1} -
-
- {@render blacklistChecks(report.blacklists, report)} -
-
- {@render whitelistChecks(report.whitelists)} + {#if report.blacklists && Object.keys(report.blacklists).length > 0} +
+
+
- {:else} - {#if report.blacklists && Object.keys(report.blacklists).length > 0} -
-
- {@render blacklistChecks(report.blacklists, report)} -
-
- {/if} - - {#if report.whitelists && Object.keys(report.whitelists).length > 0} -
-
- {@render whitelistChecks(report.whitelists)} -
-
- {/if} {/if} @@ -389,19 +347,16 @@
{/if} - - {#if report.spamassassin || report.rspamd} + + {#if report.spamassassin}
- {#if report.spamassassin} -
- -
- {/if} - {#if report.rspamd} -
- -
- {/if} +
+ +
{/if}