diff --git a/.gitignore b/.gitignore
index 7ece05e..e943630 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,5 +26,5 @@ logs/
*.sqlite3
# OpenAPI generated files
-internal/api/models.gen.go
-internal/api/server.gen.go
\ No newline at end of file
+internal/api/server.gen.go
+internal/model/types.gen.go
diff --git a/Dockerfile b/Dockerfile
index 3d9440a..4568784 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -121,6 +121,7 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
perl-xml-libxml \
postfix \
postfix-pcre \
+ rspamd \
spamassassin \
spamassassin-client \
supervisor \
@@ -143,8 +144,11 @@ 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 -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
# Copy the built application
COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver
@@ -154,6 +158,7 @@ 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
@@ -165,7 +170,13 @@ 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_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net
+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
# Volume for persistent data
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
diff --git a/README.md b/README.md
index 3b28292..4c4013b 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 scores, DNS records, blacklist status, content quality, and more
+- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin and rspamd 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,6 +26,7 @@ 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
@@ -162,10 +163,27 @@ 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, ...
+It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ...
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
-Choose one of the following way to integrate happyDeliver in your existing setup:
+#### 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.
#### Postfix LMTP Transport
@@ -269,7 +287,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 score
+- **Spam**: SpamAssassin and rspamd scores (combined 50/50)
- **Content**: HTML quality, links, images, unsubscribe
## Funding
diff --git a/api/config-models.yaml b/api/config-models.yaml
index 9c3425c..aa2fb0e 100644
--- a/api/config-models.yaml
+++ b/api/config-models.yaml
@@ -1,5 +1,9 @@
-package: api
+package: model
generate:
models: true
- embedded-spec: false
-output: internal/api/models.gen.go
+ embedded-spec: true
+output: internal/model/types.gen.go
+output-options:
+ skip-prune: true
+import-mapping:
+ ./schemas.yaml: "-"
diff --git a/api/config-server.yaml b/api/config-server.yaml
index 20f8daf..347dbaf 100644
--- a/api/config-server.yaml
+++ b/api/config-server.yaml
@@ -1,5 +1,8 @@
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 8463007..2dbf304 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -76,6 +76,49 @@ 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:
@@ -253,1024 +296,74 @@ 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"
-
+ $ref: './schemas.yaml#/components/schemas/Test'
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"
-
+ $ref: './schemas.yaml#/components/schemas/TestResponse'
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
-
+ $ref: './schemas.yaml#/components/schemas/Report'
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"
-
+ $ref: './schemas.yaml#/components/schemas/ScoreSummary'
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"]
-
+ $ref: './schemas.yaml#/components/schemas/ContentAnalysis'
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"
-
+ $ref: './schemas.yaml#/components/schemas/ContentIssue'
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
-
+ $ref: './schemas.yaml#/components/schemas/LinkCheck'
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
-
+ $ref: './schemas.yaml#/components/schemas/ImageCheck'
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
-
+ $ref: './schemas.yaml#/components/schemas/HeaderAnalysis'
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"]
-
+ $ref: './schemas.yaml#/components/schemas/HeaderCheck'
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"
-
+ $ref: './schemas.yaml#/components/schemas/ReceivedHop'
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"
-
+ $ref: './schemas.yaml#/components/schemas/DKIMDomainInfo'
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"]
-
+ $ref: './schemas.yaml#/components/schemas/DomainAlignment'
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"
-
+ $ref: './schemas.yaml#/components/schemas/HeaderIssue'
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)
-
+ $ref: './schemas.yaml#/components/schemas/AuthenticationResults'
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
-
+ $ref: './schemas.yaml#/components/schemas/AuthResult'
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"
-
+ $ref: './schemas.yaml#/components/schemas/ARCResult'
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)"
-
+ $ref: './schemas.yaml#/components/schemas/IPRevResult'
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
-
+ $ref: './schemas.yaml#/components/schemas/SpamAssassinResult'
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
- description:
- type: string
- description: Human-readable description of what this test checks
- example: "Bayes spam probability is 0 to 1%"
-
+ $ref: './schemas.yaml#/components/schemas/SpamTestDetail'
+ RspamdResult:
+ $ref: './schemas.yaml#/components/schemas/RspamdResult'
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
-
+ $ref: './schemas.yaml#/components/schemas/DNSResults'
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"
-
+ $ref: './schemas.yaml#/components/schemas/MXRecord'
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"
-
+ $ref: './schemas.yaml#/components/schemas/SPFRecord'
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"
-
+ $ref: './schemas.yaml#/components/schemas/DKIMRecord'
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"
-
+ $ref: './schemas.yaml#/components/schemas/DMARCRecord'
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"
-
+ $ref: './schemas.yaml#/components/schemas/BIMIRecord'
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
-
+ $ref: './schemas.yaml#/components/schemas/BlacklistCheck'
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
-
+ $ref: './schemas.yaml#/components/schemas/Status'
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
-
+ $ref: './schemas.yaml#/components/schemas/Error'
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"
-
+ $ref: './schemas.yaml#/components/schemas/DomainTestRequest'
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'
-
+ $ref: './schemas.yaml#/components/schemas/DomainTestResponse'
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}$'
-
+ $ref: './schemas.yaml#/components/schemas/BlacklistCheckRequest'
BlacklistCheckResponse:
- 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+"
+ $ref: './schemas.yaml#/components/schemas/BlacklistCheckResponse'
+ TestSummary:
+ $ref: './schemas.yaml#/components/schemas/TestSummary'
+ TestListResponse:
+ $ref: './schemas.yaml#/components/schemas/TestListResponse'
diff --git a/api/schemas.yaml b/api/schemas.yaml
new file mode 100644
index 0000000..df0b416
--- /dev/null
+++ b/api/schemas.yaml
@@ -0,0 +1,1173 @@
+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 3769365..2199eeb 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -110,14 +110,38 @@ 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
-Note that the hostname of the container is used to filter the authentication tests results.
+### Receiver Hostname
-Example:
+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):
```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 1bc3eff..ef45b61 100644
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -15,6 +15,10 @@ 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 fcdb75c..5a73fb3 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
+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
non_smtpd_milters = $smtpd_milters
# SPF policy checking
diff --git a/docker/rspamd/local.d/actions.conf b/docker/rspamd/local.d/actions.conf
new file mode 100644
index 0000000..f3ed60c
--- /dev/null
+++ b/docker/rspamd/local.d/actions.conf
@@ -0,0 +1,5 @@
+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
new file mode 100644
index 0000000..378b8a3
--- /dev/null
+++ b/docker/rspamd/local.d/milter_headers.conf
@@ -0,0 +1,5 @@
+# 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
new file mode 100644
index 0000000..485d0c9
--- /dev/null
+++ b/docker/rspamd/local.d/options.inc
@@ -0,0 +1,3 @@
+# 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
new file mode 100644
index 0000000..04c9a1d
--- /dev/null
+++ b/docker/rspamd/local.d/worker-proxy.inc
@@ -0,0 +1,6 @@
+# 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 c248ef6..ce9a31c 100644
--- a/docker/spamassassin/local.cf
+++ b/docker/spamassassin/local.cf
@@ -48,3 +48,14 @@ 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 c0c7002..74f1810 100644
--- a/docker/supervisor/supervisord.conf
+++ b/docker/supervisor/supervisord.conf
@@ -33,6 +33,16 @@ 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 d1ee5ab..324c52c 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/openapi.yaml
+//go:generate go tool oapi-codegen -config api/config-models.yaml api/schemas.yaml
//go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml
diff --git a/go.mod b/go.mod
index e9da3d6..038eb22 100644
--- a/go.mod
+++ b/go.mod
@@ -1,15 +1,15 @@
module git.happydns.org/happyDeliver
-go 1.24.6
+go 1.25.0
require (
github.com/JGLTechnologies/gin-rate-limit v1.5.6
github.com/emersion/go-smtp v0.24.0
github.com/getkin/kin-openapi v0.133.0
- github.com/gin-gonic/gin v1.11.0
+ github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0
- github.com/oapi-codegen/runtime v1.1.2
- golang.org/x/net v0.49.0
+ github.com/oapi-codegen/runtime v1.3.0
+ golang.org/x/net v0.52.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
@@ -64,14 +64,14 @@ require (
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.uber.org/mock v0.6.0 // indirect
+ go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.23.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
+ golang.org/x/crypto v0.49.0 // indirect
+ golang.org/x/mod v0.33.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.42.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
+ golang.org/x/tools v0.42.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 96ea7bc..10c9b72 100644
--- a/go.sum
+++ b/go.sum
@@ -10,12 +10,8 @@ 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=
@@ -40,22 +36,16 @@ 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.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.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/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
+github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
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=
@@ -66,8 +56,6 @@ 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=
@@ -75,8 +63,6 @@ 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=
@@ -104,8 +90,6 @@ 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=
@@ -134,8 +118,6 @@ 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=
@@ -150,8 +132,8 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
-github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI=
-github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
+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.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=
@@ -176,12 +158,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/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.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=
@@ -216,6 +194,8 @@ 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=
+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/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
@@ -223,11 +203,11 @@ 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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
-golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-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/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
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=
@@ -235,13 +215,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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
-golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+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/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=
@@ -257,24 +237,21 @@ 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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
-golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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.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/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
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.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
-golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
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=
@@ -287,8 +264,6 @@ 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 80c8f9a..de2d5df 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -31,6 +31,7 @@ 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"
@@ -40,8 +41,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 *DNSResults, score int, grade string)
- CheckBlacklistIP(ip string) (checks []BlacklistCheck, listedCount int, score int, grade string, 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)
}
// APIHandler implements the ServerInterface for handling API requests
@@ -79,11 +80,11 @@ func (h *APIHandler) CreateTest(c *gin.Context) {
)
// Return response
- c.JSON(http.StatusCreated, TestResponse{
+ c.JSON(http.StatusCreated, model.TestResponse{
Id: base32ID,
Email: openapi_types.Email(email),
- Status: TestResponseStatusPending,
- Message: stringPtr("Send your test email to the given address"),
+ Status: model.TestResponseStatusPending,
+ Message: utils.PtrTo("Send your test email to the given address"),
})
}
@@ -93,10 +94,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, Error{
+ c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_id",
Message: "Invalid test ID format",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
@@ -104,20 +105,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, Error{
+ c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to check test status",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
// Determine status based on report existence
- var apiStatus TestStatus
+ var apiStatus model.TestStatus
if reportExists {
- apiStatus = TestStatusAnalyzed
+ apiStatus = model.TestStatusAnalyzed
} else {
- apiStatus = TestStatusPending
+ apiStatus = model.TestStatusPending
}
// Generate test email address using Base32-encoded UUID
@@ -127,7 +128,7 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
h.config.Email.Domain,
)
- c.JSON(http.StatusOK, Test{
+ c.JSON(http.StatusOK, model.Test{
Id: id,
Email: openapi_types.Email(email),
Status: apiStatus,
@@ -140,10 +141,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, Error{
+ c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_id",
Message: "Invalid test ID format",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
@@ -151,16 +152,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, Error{
+ c.JSON(http.StatusNotFound, model.Error{
Error: "not_found",
Message: "Report not found",
})
return
}
- c.JSON(http.StatusInternalServerError, Error{
+ c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to retrieve report",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
@@ -175,10 +176,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, Error{
+ c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_id",
Message: "Invalid test ID format",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
@@ -186,16 +187,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, Error{
+ c.JSON(http.StatusNotFound, model.Error{
Error: "not_found",
Message: "Email not found",
})
return
}
- c.JSON(http.StatusInternalServerError, Error{
+ c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to retrieve raw email",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
@@ -209,10 +210,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, Error{
+ c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_id",
Message: "Invalid test ID format",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
@@ -221,16 +222,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, Error{
+ c.JSON(http.StatusNotFound, model.Error{
Error: "not_found",
Message: "Email not found",
})
return
}
- c.JSON(http.StatusInternalServerError, Error{
+ c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to retrieve email",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
@@ -238,20 +239,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, Error{
+ c.JSON(http.StatusInternalServerError, model.Error{
Error: "analysis_error",
Message: "Failed to re-analyze email",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
// Update the report in storage
if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil {
- c.JSON(http.StatusInternalServerError, Error{
+ c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to update report",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
@@ -267,24 +268,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 := StatusComponentsDatabaseUp
+ dbStatus := model.StatusComponentsDatabaseUp
if _, err := h.storage.ReportExists(uuid.New()); err != nil {
- dbStatus = StatusComponentsDatabaseDown
+ dbStatus = model.StatusComponentsDatabaseDown
}
// Determine overall status
- overallStatus := Healthy
- if dbStatus == StatusComponentsDatabaseDown {
- overallStatus = Unhealthy
+ overallStatus := model.Healthy
+ if dbStatus == model.StatusComponentsDatabaseDown {
+ overallStatus = model.Unhealthy
}
- mtaStatus := StatusComponentsMtaUp
- c.JSON(http.StatusOK, Status{
+ mtaStatus := model.StatusComponentsMtaUp
+ c.JSON(http.StatusOK, model.Status{
Status: overallStatus,
Version: version.Version,
Components: &struct {
- Database *StatusComponentsDatabase `json:"database,omitempty"`
- Mta *StatusComponentsMta `json:"mta,omitempty"`
+ Database *model.StatusComponentsDatabase `json:"database,omitempty"`
+ Mta *model.StatusComponentsMta `json:"mta,omitempty"`
}{
Database: &dbStatus,
Mta: &mtaStatus,
@@ -296,14 +297,14 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
// TestDomain performs synchronous domain analysis
// (POST /domain)
func (h *APIHandler) TestDomain(c *gin.Context) {
- var request DomainTestRequest
+ var request model.DomainTestRequest
// Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil {
- c.JSON(http.StatusBadRequest, Error{
+ c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_request",
Message: "Invalid request body",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
@@ -312,28 +313,28 @@ func (h *APIHandler) TestDomain(c *gin.Context) {
dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain)
// Convert grade string to DomainTestResponseGrade enum
- var responseGrade DomainTestResponseGrade
+ var responseGrade model.DomainTestResponseGrade
switch grade {
case "A+":
- responseGrade = DomainTestResponseGradeA
+ responseGrade = model.DomainTestResponseGradeA
case "A":
- responseGrade = DomainTestResponseGradeA1
+ responseGrade = model.DomainTestResponseGradeA1
case "B":
- responseGrade = DomainTestResponseGradeB
+ responseGrade = model.DomainTestResponseGradeB
case "C":
- responseGrade = DomainTestResponseGradeC
+ responseGrade = model.DomainTestResponseGradeC
case "D":
- responseGrade = DomainTestResponseGradeD
+ responseGrade = model.DomainTestResponseGradeD
case "E":
- responseGrade = DomainTestResponseGradeE
+ responseGrade = model.DomainTestResponseGradeE
case "F":
- responseGrade = DomainTestResponseGradeF
+ responseGrade = model.DomainTestResponseGradeF
default:
- responseGrade = DomainTestResponseGradeF
+ responseGrade = model.DomainTestResponseGradeF
}
// Build response
- response := DomainTestResponse{
+ response := model.DomainTestResponse{
Domain: request.Domain,
Score: score,
Grade: responseGrade,
@@ -346,37 +347,79 @@ 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 BlacklistCheckRequest
+ var request model.BlacklistCheckRequest
// Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil {
- c.JSON(http.StatusBadRequest, Error{
+ c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_request",
Message: "Invalid request body",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
// Perform blacklist check using analyzer
- checks, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
+ checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
if err != nil {
- c.JSON(http.StatusBadRequest, Error{
+ c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_ip",
Message: "Invalid IP address",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
// Build response
- response := BlacklistCheckResponse{
+ response := model.BlacklistCheckResponse{
Ip: request.Ip,
- Checks: checks,
+ Blacklists: checks,
+ Whitelists: &whitelists,
ListedCount: listedCount,
Score: score,
- Grade: BlacklistCheckResponseGrade(grade),
+ Grade: model.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/config/cli.go b/internal/config/cli.go
index 3accc99..fcc914f 100644
--- a/internal/config/cli.go
+++ b/internal/config/cli.go
@@ -34,14 +34,17 @@ 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 4a335c9..b264994 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -34,6 +34,11 @@ 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
@@ -45,6 +50,7 @@ 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
@@ -58,6 +64,7 @@ type EmailConfig struct {
Domain string
TestAddressPrefix string
LMTPAddr string
+ ReceiverHostname string
}
// AnalysisConfig contains timeout and behavior settings for email analysis
@@ -65,7 +72,9 @@ type AnalysisConfig struct {
DNSTimeout time.Duration
HTTPTimeout time.Duration
RBLs []string
- CheckAllIPs bool // Check all IPs found in headers, not just the first one
+ 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)
}
// DefaultConfig returns a configuration with sensible defaults
@@ -83,11 +92,13 @@ 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 062a091..f06f535 100644
--- a/internal/receiver/receiver.go
+++ b/internal/receiver/receiver.go
@@ -98,6 +98,17 @@ 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 39b2eb6..86605df 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -30,6 +30,9 @@ 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 (
@@ -45,6 +48,7 @@ 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
@@ -139,6 +143,72 @@ 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/internal/api/helpers.go b/internal/utils/ptr.go
similarity index 91%
rename from internal/api/helpers.go
rename to internal/utils/ptr.go
index cce306a..748d6ba 100644
--- a/internal/api/helpers.go
+++ b/internal/utils/ptr.go
@@ -1,5 +1,5 @@
// This file is part of the happyDeliver (R) project.
-// Copyright (c) 2025 happyDomain
+// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
@@ -19,11 +19,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-package api
-
-func stringPtr(s string) *string {
- return &s
-}
+package utils
// PtrTo returns a pointer to the provided value
func PtrTo[T any](v T) *T {
diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go
index e7ae561..5f57df3 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/api"
+ "git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/config"
)
@@ -41,10 +41,13 @@ 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{
@@ -56,7 +59,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
type AnalysisResult struct {
Email *EmailMessage
Results *AnalysisResults
- Report *api.Report
+ Report *model.Report
}
// AnalyzeEmailBytes performs complete email analysis from raw bytes
@@ -110,7 +113,7 @@ func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byt
}
// AnalyzeDomain performs DNS analysis for a domain and returns the results
-func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string) {
+func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, string) {
// Perform DNS analysis
dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain)
@@ -120,22 +123,28 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string)
return dnsResults, score, grade
}
-// CheckBlacklistIP checks a single IP address against DNS blacklists
-func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, int, int, string, error) {
+// 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) {
// Check the IP against all configured RBLs
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
if err != nil {
- return nil, 0, 0, "", err
+ return nil, nil, 0, 0, "", err
}
// Calculate score using the existing function
// Create a minimal RBLResults structure for scoring
- results := &RBLResults{
- Checks: map[string][]api.BlacklistCheck{ip: checks},
+ results := &DNSListResults{
+ Checks: map[string][]model.BlacklistCheck{ip: checks},
IPsChecked: []string{ip},
ListedCount: listedCount,
}
- score, grade := a.analyzer.generator.rblChecker.CalculateRBLScore(results)
+ score, grade := a.analyzer.generator.rblChecker.CalculateScore(results, false)
- return checks, listedCount, score, grade, nil
+ // 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
}
diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go
index 07f7794..da31b1c 100644
--- a/pkg/analyzer/authentication.go
+++ b/pkg/analyzer/authentication.go
@@ -24,23 +24,25 @@ package analyzer
import (
"strings"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
)
// AuthenticationAnalyzer analyzes email authentication results
-type AuthenticationAnalyzer struct{}
+type AuthenticationAnalyzer struct {
+ receiverHostname string
+}
// NewAuthenticationAnalyzer creates a new authentication analyzer
-func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
- return &AuthenticationAnalyzer{}
+func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer {
+ return &AuthenticationAnalyzer{receiverHostname: receiverHostname}
}
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
-func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults {
- results := &api.AuthenticationResults{}
+func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults {
+ results := &model.AuthenticationResults{}
// Parse Authentication-Results headers
- authHeaders := email.GetAuthenticationResults()
+ authHeaders := email.GetAuthenticationResults(a.receiverHostname)
for _, header := range authHeaders {
a.parseAuthenticationResultsHeader(header, results)
}
@@ -63,7 +65,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
// 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 *api.AuthenticationResults) {
+func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *model.AuthenticationResults) {
// Split by semicolon to get individual results
parts := strings.Split(header, ";")
if len(parts) < 2 {
@@ -89,7 +91,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
dkimResult := a.parseDKIMResult(part)
if dkimResult != nil {
if results.Dkim == nil {
- dkimList := []api.AuthResult{*dkimResult}
+ dkimList := []model.AuthResult{*dkimResult}
results.Dkim = &dkimList
} else {
*results.Dkim = append(*results.Dkim, *dkimResult)
@@ -143,34 +145,39 @@ 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 *api.AuthenticationResults) (int, string) {
+func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.AuthenticationResults) (int, string) {
if results == nil {
return 0, ""
}
score := 0
- // IPRev (15 points)
- score += 15 * a.calculateIPRevScore(results) / 100
+ // Core authentication (90 points total)
+ // SPF (30 points)
+ score += 30 * a.calculateSPFScore(results) / 100
- // SPF (25 points)
- score += 25 * a.calculateSPFScore(results) / 100
+ // DKIM (30 points)
+ score += 30 * a.calculateDKIMScore(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
+ // DMARC (30 points)
+ score += 30 * 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 01b7505..e7333ce 100644
--- a/pkg/analyzer/authentication_arc.go
+++ b/pkg/analyzer/authentication_arc.go
@@ -27,7 +27,8 @@ import (
"slices"
"strings"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
// textprotoCanonical converts a header name to canonical form
@@ -52,24 +53,24 @@ func pluralize(count int) string {
// parseARCResult parses ARC result from Authentication-Results
// Example: arc=pass
-func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
- result := &api.ARCResult{}
+func (a *AuthenticationAnalyzer) parseARCResult(part string) *model.ARCResult {
+ result := &model.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 = api.ARCResultResult(resultStr)
+ result.Result = model.ARCResultResult(resultStr)
}
- result.Details = api.PtrTo(strings.TrimPrefix(part, "arc="))
+ result.Details = utils.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) *api.ARCResult {
+func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARCResult {
// Get all ARC-related headers
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
@@ -80,8 +81,8 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe
return nil
}
- result := &api.ARCResult{
- Result: api.ARCResultResultNone,
+ result := &model.ARCResult{
+ Result: model.ARCResultResultNone,
}
// Count the ARC chain length (number of sets)
@@ -94,15 +95,15 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe
// Determine overall result
if chainLength == 0 {
- result.Result = api.ARCResultResultNone
+ result.Result = model.ARCResultResultNone
details := "No ARC chain present"
result.Details = &details
} else if !chainValid {
- result.Result = api.ARCResultResultFail
+ result.Result = model.ARCResultResultFail
details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
result.Details = &details
} else {
- result.Result = api.ARCResultResultPass
+ result.Result = model.ARCResultResultPass
details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
result.Details = &details
}
@@ -111,7 +112,7 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe
}
// enhanceARCResult enhances an existing ARC result with chain information
-func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) {
+func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *model.ARCResult) {
if arcResult == nil {
return
}
diff --git a/pkg/analyzer/authentication_arc_test.go b/pkg/analyzer/authentication_arc_test.go
index 9269d70..ac51d0b 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/api"
+ "git.happydns.org/happyDeliver/internal/model"
)
func TestParseARCResult(t *testing.T) {
tests := []struct {
name string
part string
- expectedResult api.ARCResultResult
+ expectedResult model.ARCResultResult
}{
{
name: "ARC pass",
part: "arc=pass",
- expectedResult: api.ARCResultResultPass,
+ expectedResult: model.ARCResultResultPass,
},
{
name: "ARC fail",
part: "arc=fail",
- expectedResult: api.ARCResultResultFail,
+ expectedResult: model.ARCResultResultFail,
},
{
name: "ARC none",
part: "arc=none",
- expectedResult: api.ARCResultResultNone,
+ expectedResult: model.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 0d68281..9654ac7 100644
--- a/pkg/analyzer/authentication_bimi.go
+++ b/pkg/analyzer/authentication_bimi.go
@@ -25,19 +25,20 @@ import (
"regexp"
"strings"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
// parseBIMIResult parses BIMI result from Authentication-Results
// Example: bimi=pass header.d=example.com header.selector=default
-func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
- result := &api.AuthResult{}
+func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *model.AuthResult {
+ result := &model.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 = api.AuthResultResult(resultStr)
+ result.Result = model.AuthResultResult(resultStr)
}
// Extract domain (header.d or d)
@@ -54,17 +55,17 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
result.Selector = &selector
}
- result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi="))
+ result.Details = utils.PtrTo(strings.TrimPrefix(part, "bimi="))
return result
}
-func (a *AuthenticationAnalyzer) calculateBIMIScore(results *api.AuthenticationResults) (score int) {
+func (a *AuthenticationAnalyzer) calculateBIMIScore(results *model.AuthenticationResults) (score int) {
if results.Bimi != nil {
switch results.Bimi.Result {
- case api.AuthResultResultPass:
+ case model.AuthResultResultPass:
return 100
- case api.AuthResultResultDeclined:
+ case model.AuthResultResultDeclined:
return 59
default: // fail
return 0
diff --git a/pkg/analyzer/authentication_bimi_test.go b/pkg/analyzer/authentication_bimi_test.go
index b1b5468..440f356 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/api"
+ "git.happydns.org/happyDeliver/internal/model"
)
func TestParseBIMIResult(t *testing.T) {
tests := []struct {
name string
part string
- expectedResult api.AuthResultResult
+ expectedResult model.AuthResultResult
expectedDomain string
expectedSelector string
}{
{
name: "BIMI pass with domain and selector",
part: "bimi=pass header.d=example.com header.selector=default",
- expectedResult: api.AuthResultResultPass,
+ expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "BIMI fail",
part: "bimi=fail header.d=example.com header.selector=default",
- expectedResult: api.AuthResultResultFail,
+ expectedResult: model.AuthResultResultFail,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "BIMI with short form (d= and selector=)",
part: "bimi=pass d=example.com selector=v1",
- expectedResult: api.AuthResultResultPass,
+ expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "v1",
},
{
name: "BIMI none",
part: "bimi=none header.d=example.com",
- expectedResult: api.AuthResultResultNone,
+ expectedResult: model.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 b6cf5f8..4165d8b 100644
--- a/pkg/analyzer/authentication_dkim.go
+++ b/pkg/analyzer/authentication_dkim.go
@@ -25,19 +25,20 @@ import (
"regexp"
"strings"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
// parseDKIMResult parses DKIM result from Authentication-Results
// Example: dkim=pass header.d=example.com header.s=selector1
-func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
- result := &api.AuthResult{}
+func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *model.AuthResult {
+ result := &model.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 = api.AuthResultResult(resultStr)
+ result.Result = model.AuthResultResult(resultStr)
}
// Extract domain (header.d or d)
@@ -54,18 +55,18 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
result.Selector = &selector
}
- result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim="))
+ result.Details = utils.PtrTo(strings.TrimPrefix(part, "dkim="))
return result
}
-func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) {
+func (a *AuthenticationAnalyzer) calculateDKIMScore(results *model.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 == api.AuthResultResultPass {
+ if dkim.Result == model.AuthResultResultPass {
hasPass = true
} else {
hasNonPass = true
diff --git a/pkg/analyzer/authentication_dkim_test.go b/pkg/analyzer/authentication_dkim_test.go
index 2aab530..0576854 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/api"
+ "git.happydns.org/happyDeliver/internal/model"
)
func TestParseDKIMResult(t *testing.T) {
tests := []struct {
name string
part string
- expectedResult api.AuthResultResult
+ expectedResult model.AuthResultResult
expectedDomain string
expectedSelector string
}{
{
name: "DKIM pass with domain and selector",
part: "dkim=pass header.d=example.com header.s=default",
- expectedResult: api.AuthResultResultPass,
+ expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "DKIM fail",
part: "dkim=fail header.d=example.com header.s=selector1",
- expectedResult: api.AuthResultResultFail,
+ expectedResult: model.AuthResultResultFail,
expectedDomain: "example.com",
expectedSelector: "selector1",
},
{
name: "DKIM with short form (d= and s=)",
part: "dkim=pass d=example.com s=default",
- expectedResult: api.AuthResultResultPass,
+ expectedResult: model.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 329a5c9..c89093d 100644
--- a/pkg/analyzer/authentication_dmarc.go
+++ b/pkg/analyzer/authentication_dmarc.go
@@ -25,19 +25,20 @@ import (
"regexp"
"strings"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
// parseDMARCResult parses DMARC result from Authentication-Results
// Example: dmarc=pass action=none header.from=example.com
-func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
- result := &api.AuthResult{}
+func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *model.AuthResult {
+ result := &model.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 = api.AuthResultResult(resultStr)
+ result.Result = model.AuthResultResult(resultStr)
}
// Extract domain (header.from)
@@ -47,17 +48,17 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
result.Domain = &domain
}
- result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc="))
+ result.Details = utils.PtrTo(strings.TrimPrefix(part, "dmarc="))
return result
}
-func (a *AuthenticationAnalyzer) calculateDMARCScore(results *api.AuthenticationResults) (score int) {
+func (a *AuthenticationAnalyzer) calculateDMARCScore(results *model.AuthenticationResults) (score int) {
if results.Dmarc != nil {
switch results.Dmarc.Result {
- case api.AuthResultResultPass:
+ case model.AuthResultResultPass:
return 100
- case api.AuthResultResultNone:
+ case model.AuthResultResultNone:
return 33
default: // fail
return 0
diff --git a/pkg/analyzer/authentication_dmarc_test.go b/pkg/analyzer/authentication_dmarc_test.go
index d7fda84..69779a7 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/api"
+ "git.happydns.org/happyDeliver/internal/model"
)
func TestParseDMARCResult(t *testing.T) {
tests := []struct {
name string
part string
- expectedResult api.AuthResultResult
+ expectedResult model.AuthResultResult
expectedDomain string
}{
{
name: "DMARC pass",
part: "dmarc=pass action=none header.from=example.com",
- expectedResult: api.AuthResultResultPass,
+ expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
},
{
name: "DMARC fail",
part: "dmarc=fail action=quarantine header.from=example.com",
- expectedResult: api.AuthResultResultFail,
+ expectedResult: model.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 6538cbb..3ed045c 100644
--- a/pkg/analyzer/authentication_iprev.go
+++ b/pkg/analyzer/authentication_iprev.go
@@ -25,19 +25,20 @@ import (
"regexp"
"strings"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
// 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) *api.IPRevResult {
- result := &api.IPRevResult{}
+func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *model.IPRevResult {
+ result := &model.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 = api.IPRevResultResult(resultStr)
+ result.Result = model.IPRevResultResult(resultStr)
}
// Extract IP address (smtp.remote-ip or remote-ip)
@@ -54,20 +55,20 @@ func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult
result.Hostname = &hostname
}
- result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev="))
+ result.Details = utils.PtrTo(strings.TrimPrefix(part, "iprev="))
return result
}
-func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.AuthenticationResults) (score int) {
+func (a *AuthenticationAnalyzer) calculateIPRevScore(results *model.AuthenticationResults) (score int) {
if results.Iprev != nil {
switch results.Iprev.Result {
- case api.Pass:
+ case model.Pass:
return 100
default: // fail, temperror, permerror
return 0
}
}
- return 0
+ return 100
}
diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go
index d0529b5..55f85d5 100644
--- a/pkg/analyzer/authentication_iprev_test.go
+++ b/pkg/analyzer/authentication_iprev_test.go
@@ -24,76 +24,77 @@ package analyzer
import (
"testing"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
func TestParseIPRevResult(t *testing.T) {
tests := []struct {
name string
part string
- expectedResult api.IPRevResultResult
+ expectedResult model.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: api.Pass,
- expectedIP: api.PtrTo("195.110.101.58"),
- expectedHostname: api.PtrTo("authsmtp74.register.it"),
+ expectedResult: model.Pass,
+ expectedIP: utils.PtrTo("195.110.101.58"),
+ expectedHostname: utils.PtrTo("authsmtp74.register.it"),
},
{
name: "IPRev pass without smtp prefix",
part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)",
- expectedResult: api.Pass,
- expectedIP: api.PtrTo("192.0.2.1"),
- expectedHostname: api.PtrTo("mail.example.com"),
+ expectedResult: model.Pass,
+ expectedIP: utils.PtrTo("192.0.2.1"),
+ expectedHostname: utils.PtrTo("mail.example.com"),
},
{
name: "IPRev fail",
part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)",
- expectedResult: api.Fail,
- expectedIP: api.PtrTo("198.51.100.42"),
- expectedHostname: api.PtrTo("unknown.host.com"),
+ expectedResult: model.Fail,
+ expectedIP: utils.PtrTo("198.51.100.42"),
+ expectedHostname: utils.PtrTo("unknown.host.com"),
},
{
name: "IPRev temperror",
part: "iprev=temperror smtp.remote-ip=203.0.113.1",
- expectedResult: api.Temperror,
- expectedIP: api.PtrTo("203.0.113.1"),
+ expectedResult: model.Temperror,
+ expectedIP: utils.PtrTo("203.0.113.1"),
expectedHostname: nil,
},
{
name: "IPRev permerror",
part: "iprev=permerror smtp.remote-ip=192.0.2.100",
- expectedResult: api.Permerror,
- expectedIP: api.PtrTo("192.0.2.100"),
+ expectedResult: model.Permerror,
+ expectedIP: utils.PtrTo("192.0.2.100"),
expectedHostname: nil,
},
{
name: "IPRev with IPv6",
part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)",
- expectedResult: api.Pass,
- expectedIP: api.PtrTo("2001:db8::1"),
- expectedHostname: api.PtrTo("ipv6.example.com"),
+ expectedResult: model.Pass,
+ expectedIP: utils.PtrTo("2001:db8::1"),
+ expectedHostname: utils.PtrTo("ipv6.example.com"),
},
{
name: "IPRev with subdomain hostname",
part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)",
- expectedResult: api.Pass,
- expectedIP: api.PtrTo("192.0.2.50"),
- expectedHostname: api.PtrTo("mail.subdomain.example.com"),
+ expectedResult: model.Pass,
+ expectedIP: utils.PtrTo("192.0.2.50"),
+ expectedHostname: utils.PtrTo("mail.subdomain.example.com"),
},
{
name: "IPRev pass without parentheses",
part: "iprev=pass smtp.remote-ip=192.0.2.200",
- expectedResult: api.Pass,
- expectedIP: api.PtrTo("192.0.2.200"),
+ expectedResult: model.Pass,
+ expectedIP: utils.PtrTo("192.0.2.200"),
expectedHostname: nil,
},
}
- analyzer := NewAuthenticationAnalyzer()
+ analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -142,29 +143,29 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
tests := []struct {
name string
header string
- expectedIPRevResult *api.IPRevResultResult
+ expectedIPRevResult *model.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: api.PtrTo(api.Pass),
- expectedIP: api.PtrTo("195.110.101.58"),
- expectedHostname: api.PtrTo("authsmtp74.register.it"),
+ expectedIPRevResult: utils.PtrTo(model.Pass),
+ expectedIP: utils.PtrTo("195.110.101.58"),
+ expectedHostname: utils.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: api.PtrTo(api.Pass),
- expectedIP: api.PtrTo("192.0.2.1"),
- expectedHostname: api.PtrTo("mail.example.com"),
+ expectedIPRevResult: utils.PtrTo(model.Pass),
+ expectedIP: utils.PtrTo("192.0.2.1"),
+ expectedHostname: utils.PtrTo("mail.example.com"),
},
{
name: "IPRev fail",
header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42",
- expectedIPRevResult: api.PtrTo(api.Fail),
- expectedIP: api.PtrTo("198.51.100.42"),
+ expectedIPRevResult: utils.PtrTo(model.Fail),
+ expectedIP: utils.PtrTo("198.51.100.42"),
expectedHostname: nil,
},
{
@@ -175,17 +176,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: api.PtrTo(api.Pass),
- expectedIP: api.PtrTo("192.0.2.1"),
- expectedHostname: api.PtrTo("first.com"),
+ expectedIPRevResult: utils.PtrTo(model.Pass),
+ expectedIP: utils.PtrTo("192.0.2.1"),
+ expectedHostname: utils.PtrTo("first.com"),
},
}
- analyzer := NewAuthenticationAnalyzer()
+ analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- results := &api.AuthenticationResults{}
+ results := &model.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(tt.header, results)
// Check IPRev
diff --git a/pkg/analyzer/authentication_spf.go b/pkg/analyzer/authentication_spf.go
index 479c325..1488c98 100644
--- a/pkg/analyzer/authentication_spf.go
+++ b/pkg/analyzer/authentication_spf.go
@@ -25,19 +25,20 @@ import (
"regexp"
"strings"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
// parseSPFResult parses SPF result from Authentication-Results
// Example: spf=pass smtp.mailfrom=sender@example.com
-func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult {
- result := &api.AuthResult{}
+func (a *AuthenticationAnalyzer) parseSPFResult(part string) *model.AuthResult {
+ result := &model.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 = api.AuthResultResult(resultStr)
+ result.Result = model.AuthResultResult(resultStr)
}
// Extract domain
@@ -51,25 +52,35 @@ func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult {
}
}
- result.Details = api.PtrTo(strings.TrimPrefix(part, "spf="))
+ result.Details = utils.PtrTo(strings.TrimPrefix(part, "spf="))
return result
}
// parseLegacySPF attempts to parse SPF from Received-SPF header
-func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
+func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *model.AuthResult {
receivedSPF := email.Header.Get("Received-SPF")
if receivedSPF == "" {
return nil
}
- result := &api.AuthResult{}
+ // 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{}
// Extract result (first word)
parts := strings.Fields(receivedSPF)
if len(parts) > 0 {
resultStr := strings.ToLower(parts[0])
- result.Result = api.AuthResultResult(resultStr)
+ result.Result = model.AuthResultResult(resultStr)
}
result.Details = &receivedSPF
@@ -87,14 +98,14 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe
return result
}
-func (a *AuthenticationAnalyzer) calculateSPFScore(results *api.AuthenticationResults) (score int) {
+func (a *AuthenticationAnalyzer) calculateSPFScore(results *model.AuthenticationResults) (score int) {
if results.Spf != nil {
switch results.Spf.Result {
- case api.AuthResultResultPass:
+ case model.AuthResultResultPass:
return 100
- case api.AuthResultResultNeutral, api.AuthResultResultNone:
+ case model.AuthResultResultNeutral, model.AuthResultResultNone:
return 50
- case api.AuthResultResultSoftfail:
+ case model.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 7a84c49..210505a 100644
--- a/pkg/analyzer/authentication_spf_test.go
+++ b/pkg/analyzer/authentication_spf_test.go
@@ -24,43 +24,44 @@ package analyzer
import (
"testing"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
func TestParseSPFResult(t *testing.T) {
tests := []struct {
name string
part string
- expectedResult api.AuthResultResult
+ expectedResult model.AuthResultResult
expectedDomain string
}{
{
name: "SPF pass with domain",
part: "spf=pass smtp.mailfrom=sender@example.com",
- expectedResult: api.AuthResultResultPass,
+ expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
},
{
name: "SPF fail",
part: "spf=fail smtp.mailfrom=sender@example.com",
- expectedResult: api.AuthResultResultFail,
+ expectedResult: model.AuthResultResultFail,
expectedDomain: "example.com",
},
{
name: "SPF neutral",
part: "spf=neutral smtp.mailfrom=sender@example.com",
- expectedResult: api.AuthResultResultNeutral,
+ expectedResult: model.AuthResultResultNeutral,
expectedDomain: "example.com",
},
{
name: "SPF softfail",
part: "spf=softfail smtp.mailfrom=sender@example.com",
- expectedResult: api.AuthResultResultSoftfail,
+ expectedResult: model.AuthResultResultSoftfail,
expectedDomain: "example.com",
},
}
- analyzer := NewAuthenticationAnalyzer()
+ analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -84,7 +85,7 @@ func TestParseLegacySPF(t *testing.T) {
tests := []struct {
name string
receivedSPF string
- expectedResult api.AuthResultResult
+ expectedResult model.AuthResultResult
expectedDomain *string
expectNil bool
}{
@@ -97,8 +98,8 @@ func TestParseLegacySPF(t *testing.T) {
envelope-from="user@example.com";
helo=smtp.example.com;
client-ip=192.0.2.10`,
- expectedResult: api.AuthResultResultPass,
- expectedDomain: api.PtrTo("example.com"),
+ expectedResult: model.AuthResultResultPass,
+ expectedDomain: utils.PtrTo("example.com"),
},
{
name: "SPF fail with sender",
@@ -109,43 +110,43 @@ func TestParseLegacySPF(t *testing.T) {
sender="sender@test.com";
helo=smtp.test.com;
client-ip=192.0.2.20`,
- expectedResult: api.AuthResultResultFail,
- expectedDomain: api.PtrTo("test.com"),
+ expectedResult: model.AuthResultResultFail,
+ expectedDomain: utils.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: api.AuthResultResultSoftfail,
- expectedDomain: api.PtrTo("example.org"),
+ expectedResult: model.AuthResultResultSoftfail,
+ expectedDomain: utils.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: api.AuthResultResultNeutral,
- expectedDomain: api.PtrTo("domain.net"),
+ expectedResult: model.AuthResultResultNeutral,
+ expectedDomain: utils.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: api.AuthResultResultNone,
- expectedDomain: api.PtrTo("company.io"),
+ expectedResult: model.AuthResultResultNone,
+ expectedDomain: utils.PtrTo("company.io"),
},
{
name: "SPF temperror",
receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"",
- expectedResult: api.AuthResultResultTemperror,
- expectedDomain: api.PtrTo("shop.example"),
+ expectedResult: model.AuthResultResultTemperror,
+ expectedDomain: utils.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: api.AuthResultResultPermerror,
- expectedDomain: api.PtrTo("invalid.test"),
+ expectedResult: model.AuthResultResultPermerror,
+ expectedDomain: utils.PtrTo("invalid.test"),
},
{
name: "SPF pass without domain extraction",
receivedSPF: "pass (example.com: 192.0.2.50 is authorized)",
- expectedResult: api.AuthResultResultPass,
+ expectedResult: model.AuthResultResultPass,
expectedDomain: nil,
},
{
@@ -156,12 +157,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: api.AuthResultResultPass,
- expectedDomain: api.PtrTo("mail.example.net"),
+ expectedResult: model.AuthResultResultPass,
+ expectedDomain: utils.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 27901b5..44c1abb 100644
--- a/pkg/analyzer/authentication_test.go
+++ b/pkg/analyzer/authentication_test.go
@@ -24,83 +24,84 @@ package analyzer
import (
"testing"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
func TestGetAuthenticationScore(t *testing.T) {
tests := []struct {
name string
- results *api.AuthenticationResults
+ results *model.AuthenticationResults
expectedScore int
}{
{
name: "Perfect authentication (SPF + DKIM + DMARC)",
- results: &api.AuthenticationResults{
- Spf: &api.AuthResult{
- Result: api.AuthResultResultPass,
+ results: &model.AuthenticationResults{
+ Spf: &model.AuthResult{
+ Result: model.AuthResultResultPass,
},
- Dkim: &[]api.AuthResult{
- {Result: api.AuthResultResultPass},
+ Dkim: &[]model.AuthResult{
+ {Result: model.AuthResultResultPass},
},
- Dmarc: &api.AuthResult{
- Result: api.AuthResultResultPass,
+ Dmarc: &model.AuthResult{
+ Result: model.AuthResultResultPass,
},
},
expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25
},
{
name: "SPF and DKIM only",
- results: &api.AuthenticationResults{
- Spf: &api.AuthResult{
- Result: api.AuthResultResultPass,
+ results: &model.AuthenticationResults{
+ Spf: &model.AuthResult{
+ Result: model.AuthResultResultPass,
},
- Dkim: &[]api.AuthResult{
- {Result: api.AuthResultResultPass},
+ Dkim: &[]model.AuthResult{
+ {Result: model.AuthResultResultPass},
},
},
expectedScore: 48, // SPF=25 + DKIM=23
},
{
name: "SPF fail, DKIM pass",
- results: &api.AuthenticationResults{
- Spf: &api.AuthResult{
- Result: api.AuthResultResultFail,
+ results: &model.AuthenticationResults{
+ Spf: &model.AuthResult{
+ Result: model.AuthResultResultFail,
},
- Dkim: &[]api.AuthResult{
- {Result: api.AuthResultResultPass},
+ Dkim: &[]model.AuthResult{
+ {Result: model.AuthResultResultPass},
},
},
expectedScore: 23, // SPF=0 + DKIM=23
},
{
name: "SPF softfail",
- results: &api.AuthenticationResults{
- Spf: &api.AuthResult{
- Result: api.AuthResultResultSoftfail,
+ results: &model.AuthenticationResults{
+ Spf: &model.AuthResult{
+ Result: model.AuthResultResultSoftfail,
},
},
expectedScore: 4,
},
{
name: "No authentication",
- results: &api.AuthenticationResults{},
+ results: &model.AuthenticationResults{},
expectedScore: 0,
},
{
name: "BIMI adds to score",
- results: &api.AuthenticationResults{
- Spf: &api.AuthResult{
- Result: api.AuthResultResultPass,
+ results: &model.AuthenticationResults{
+ Spf: &model.AuthResult{
+ Result: model.AuthResultResultPass,
},
- Bimi: &api.AuthResult{
- Result: api.AuthResultResultPass,
+ Bimi: &model.AuthResult{
+ Result: model.AuthResultResultPass,
},
},
expectedScore: 35, // SPF (25) + BIMI (10)
},
}
- scorer := NewAuthenticationAnalyzer()
+ scorer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -117,30 +118,30 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
tests := []struct {
name string
header string
- expectedSPFResult *api.AuthResultResult
+ expectedSPFResult *model.AuthResultResult
expectedSPFDomain *string
expectedDKIMCount int
- expectedDKIMResult *api.AuthResultResult
- expectedDMARCResult *api.AuthResultResult
+ expectedDKIMResult *model.AuthResultResult
+ expectedDMARCResult *model.AuthResultResult
expectedDMARCDomain *string
- expectedBIMIResult *api.AuthResultResult
- expectedARCResult *api.ARCResultResult
+ expectedBIMIResult *model.AuthResultResult
+ expectedARCResult *model.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: api.PtrTo(api.AuthResultResultPass),
- expectedSPFDomain: api.PtrTo("example.com"),
+ expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedSPFDomain: utils.PtrTo("example.com"),
expectedDKIMCount: 1,
- expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
- expectedDMARCResult: api.PtrTo(api.AuthResultResultPass),
- expectedDMARCDomain: api.PtrTo("example.com"),
+ expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedDMARCDomain: utils.PtrTo("example.com"),
},
{
name: "SPF only",
header: "mail.example.com; spf=pass smtp.mailfrom=user@domain.com",
- expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
- expectedSPFDomain: api.PtrTo("domain.com"),
+ expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedSPFDomain: utils.PtrTo("domain.com"),
expectedDKIMCount: 0,
expectedDMARCResult: nil,
},
@@ -149,68 +150,68 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1",
expectedSPFResult: nil,
expectedDKIMCount: 1,
- expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
+ expectedDKIMResult: utils.PtrTo(model.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: api.PtrTo(api.AuthResultResultPass),
+ expectedDKIMResult: utils.PtrTo(model.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: api.PtrTo(api.AuthResultResultFail),
- expectedSPFDomain: api.PtrTo("example.com"),
+ expectedSPFResult: utils.PtrTo(model.AuthResultResultFail),
+ expectedSPFDomain: utils.PtrTo("example.com"),
expectedDKIMCount: 1,
- expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
+ expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
expectedDMARCResult: nil,
},
{
name: "SPF softfail",
header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com",
- expectedSPFResult: api.PtrTo(api.AuthResultResultSoftfail),
- expectedSPFDomain: api.PtrTo("example.com"),
+ expectedSPFResult: utils.PtrTo(model.AuthResultResultSoftfail),
+ expectedSPFDomain: utils.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: api.PtrTo(api.AuthResultResultPass),
+ expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
expectedDKIMCount: 1,
- expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
- expectedDMARCResult: api.PtrTo(api.AuthResultResultFail),
- expectedDMARCDomain: api.PtrTo("example.com"),
+ expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedDMARCResult: utils.PtrTo(model.AuthResultResultFail),
+ expectedDMARCDomain: utils.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: api.PtrTo(api.AuthResultResultPass),
- expectedSPFDomain: api.PtrTo("example.com"),
+ expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedSPFDomain: utils.PtrTo("example.com"),
expectedDKIMCount: 0,
- expectedBIMIResult: api.PtrTo(api.AuthResultResultPass),
+ expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass),
},
{
name: "ARC pass",
header: "mail.example.com; arc=pass",
expectedSPFResult: nil,
expectedDKIMCount: 0,
- expectedARCResult: api.PtrTo(api.ARCResultResultPass),
+ expectedARCResult: utils.PtrTo(model.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: api.PtrTo(api.AuthResultResultPass),
- expectedSPFDomain: api.PtrTo("example.com"),
+ expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedSPFDomain: utils.PtrTo("example.com"),
expectedDKIMCount: 1,
- 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),
+ 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),
},
{
name: "Empty header (authserv-id only)",
@@ -221,8 +222,8 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
{
name: "Empty parts with semicolons",
header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;",
- expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
- expectedSPFDomain: api.PtrTo("example.com"),
+ expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
+ expectedSPFDomain: utils.PtrTo("example.com"),
expectedDKIMCount: 0,
},
{
@@ -230,28 +231,28 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
header: "mail.example.com; dkim=pass d=example.com s=selector1",
expectedSPFResult: nil,
expectedDKIMCount: 1,
- expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
+ expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
},
{
name: "SPF neutral",
header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com",
- expectedSPFResult: api.PtrTo(api.AuthResultResultNeutral),
- expectedSPFDomain: api.PtrTo("example.com"),
+ expectedSPFResult: utils.PtrTo(model.AuthResultResultNeutral),
+ expectedSPFDomain: utils.PtrTo("example.com"),
expectedDKIMCount: 0,
},
{
name: "SPF none",
header: "mail.example.com; spf=none",
- expectedSPFResult: api.PtrTo(api.AuthResultResultNone),
+ expectedSPFResult: utils.PtrTo(model.AuthResultResultNone),
expectedDKIMCount: 0,
},
}
- analyzer := NewAuthenticationAnalyzer()
+ analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- results := &api.AuthenticationResults{}
+ results := &model.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(tt.header, results)
// Check SPF
@@ -353,17 +354,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 := &api.AuthenticationResults{}
+ results := &model.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Spf == nil {
t.Fatal("Expected SPF result, got nil")
}
- if results.Spf.Result != api.AuthResultResultPass {
+ if results.Spf.Result != model.AuthResultResultPass {
t.Errorf("Expected first SPF result (pass), got %v", results.Spf.Result)
}
if results.Spf.Domain == nil || *results.Spf.Domain != "example.com" {
@@ -373,13 +374,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 := &api.AuthenticationResults{}
+ results := &model.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Dmarc == nil {
t.Fatal("Expected DMARC result, got nil")
}
- if results.Dmarc.Result != api.AuthResultResultPass {
+ if results.Dmarc.Result != model.AuthResultResultPass {
t.Errorf("Expected first DMARC result (pass), got %v", results.Dmarc.Result)
}
if results.Dmarc.Domain == nil || *results.Dmarc.Domain != "first.com" {
@@ -389,26 +390,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 := &api.AuthenticationResults{}
+ results := &model.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Arc == nil {
t.Fatal("Expected ARC result, got nil")
}
- if results.Arc.Result != api.ARCResultResultPass {
+ if results.Arc.Result != model.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 := &api.AuthenticationResults{}
+ results := &model.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Bimi == nil {
t.Fatal("Expected BIMI result, got nil")
}
- if results.Bimi.Result != api.AuthResultResultPass {
+ if results.Bimi.Result != model.AuthResultResultPass {
t.Errorf("Expected first BIMI result (pass), got %v", results.Bimi.Result)
}
if results.Bimi.Domain == nil || *results.Bimi.Domain != "first.com" {
@@ -419,7 +420,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 := &api.AuthenticationResults{}
+ results := &model.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Dkim == nil {
@@ -428,10 +429,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 != api.AuthResultResultPass {
+ if (*results.Dkim)[0].Result != model.AuthResultResultPass {
t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result)
}
- if (*results.Dkim)[1].Result != api.AuthResultResultFail {
+ if (*results.Dkim)[1].Result != model.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 36da2b0..ec1571c 100644
--- a/pkg/analyzer/authentication_x_aligned_from.go
+++ b/pkg/analyzer/authentication_x_aligned_from.go
@@ -25,34 +25,35 @@ import (
"regexp"
"strings"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
// parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results
// Example: x-aligned-from=pass (Address match)
-func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *api.AuthResult {
- result := &api.AuthResult{}
+func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *model.AuthResult {
+ result := &model.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 = api.AuthResultResult(resultStr)
+ result.Result = model.AuthResultResult(resultStr)
}
// Extract details (everything after the result)
- result.Details = api.PtrTo(strings.TrimPrefix(part, "x-aligned-from="))
+ result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-aligned-from="))
return result
}
-func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.AuthenticationResults) (score int) {
+func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.AuthenticationResults) (score int) {
if results.XAlignedFrom != nil {
switch results.XAlignedFrom.Result {
- case api.AuthResultResultPass:
+ case model.AuthResultResultPass:
// pass: positive contribution
return 100
- case api.AuthResultResultFail:
+ case model.AuthResultResultFail:
// fail: negative contribution
return 0
default:
@@ -61,5 +62,5 @@ func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.Authent
}
}
- return 0
+ return 100
}
diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go
index 220ac39..1ea6d1c 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/api"
+ "git.happydns.org/happyDeliver/internal/model"
)
func TestParseXAlignedFromResult(t *testing.T) {
tests := []struct {
name string
part string
- expectedResult api.AuthResultResult
+ expectedResult model.AuthResultResult
expectedDetail string
}{
{
name: "x-aligned-from pass with details",
part: "x-aligned-from=pass (Address match)",
- expectedResult: api.AuthResultResultPass,
+ expectedResult: model.AuthResultResultPass,
expectedDetail: "pass (Address match)",
},
{
name: "x-aligned-from fail with reason",
part: "x-aligned-from=fail (Address mismatch)",
- expectedResult: api.AuthResultResultFail,
+ expectedResult: model.AuthResultResultFail,
expectedDetail: "fail (Address mismatch)",
},
{
name: "x-aligned-from pass minimal",
part: "x-aligned-from=pass",
- expectedResult: api.AuthResultResultPass,
+ expectedResult: model.AuthResultResultPass,
expectedDetail: "pass",
},
{
name: "x-aligned-from neutral",
part: "x-aligned-from=neutral (No alignment check performed)",
- expectedResult: api.AuthResultResultNeutral,
+ expectedResult: model.AuthResultResultNeutral,
expectedDetail: "neutral (No alignment check performed)",
},
{
name: "x-aligned-from none",
part: "x-aligned-from=none",
- expectedResult: api.AuthResultResultNone,
+ expectedResult: model.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 *api.AuthResult
+ result *model.AuthResult
expectedScore int
}{
{
name: "pass result gives positive score",
- result: &api.AuthResult{
- Result: api.AuthResultResultPass,
+ result: &model.AuthResult{
+ Result: model.AuthResultResultPass,
},
expectedScore: 100,
},
{
name: "fail result gives zero score",
- result: &api.AuthResult{
- Result: api.AuthResultResultFail,
+ result: &model.AuthResult{
+ Result: model.AuthResultResultFail,
},
expectedScore: 0,
},
{
name: "neutral result gives zero score",
- result: &api.AuthResult{
- Result: api.AuthResultResultNeutral,
+ result: &model.AuthResult{
+ Result: model.AuthResultResultNeutral,
},
expectedScore: 0,
},
{
name: "none result gives zero score",
- result: &api.AuthResult{
- Result: api.AuthResultResultNone,
+ result: &model.AuthResult{
+ Result: model.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 := &api.AuthenticationResults{
+ results := &model.AuthenticationResults{
XAlignedFrom: tt.result,
}
diff --git a/pkg/analyzer/authentication_x_google_dkim.go b/pkg/analyzer/authentication_x_google_dkim.go
index 4bba469..b33279e 100644
--- a/pkg/analyzer/authentication_x_google_dkim.go
+++ b/pkg/analyzer/authentication_x_google_dkim.go
@@ -25,19 +25,20 @@ import (
"regexp"
"strings"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
)
// 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) *api.AuthResult {
- result := &api.AuthResult{}
+func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *model.AuthResult {
+ result := &model.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 = api.AuthResultResult(resultStr)
+ result.Result = model.AuthResultResult(resultStr)
}
// Extract domain (header.d or d)
@@ -54,15 +55,15 @@ func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthRe
result.Selector = &selector
}
- result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim="))
+ result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-google-dkim="))
return result
}
-func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *api.AuthenticationResults) (score int) {
+func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *model.AuthenticationResults) (score int) {
if results.XGoogleDkim != nil {
switch results.XGoogleDkim.Result {
- case api.AuthResultResultPass:
+ case model.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 be29a08..4013340 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/api"
+ "git.happydns.org/happyDeliver/internal/model"
)
func TestParseXGoogleDKIMResult(t *testing.T) {
tests := []struct {
name string
part string
- expectedResult api.AuthResultResult
+ expectedResult model.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: api.AuthResultResultPass,
+ expectedResult: model.AuthResultResultPass,
expectedDomain: "1e100.net",
},
{
name: "x-google-dkim pass with short form",
part: "x-google-dkim=pass d=gmail.com",
- expectedResult: api.AuthResultResultPass,
+ expectedResult: model.AuthResultResultPass,
expectedDomain: "gmail.com",
},
{
name: "x-google-dkim fail",
part: "x-google-dkim=fail header.d=example.com",
- expectedResult: api.AuthResultResultFail,
+ expectedResult: model.AuthResultResultFail,
expectedDomain: "example.com",
},
{
name: "x-google-dkim with minimal info",
part: "x-google-dkim=pass",
- expectedResult: api.AuthResultResultPass,
+ expectedResult: model.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 05aecfa..06f8ddf 100644
--- a/pkg/analyzer/content.go
+++ b/pkg/analyzer/content.go
@@ -32,15 +32,17 @@ import (
"time"
"unicode"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
+ "git.happydns.org/happyDeliver/internal/utils"
"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
+ Timeout time.Duration
+ httpClient *http.Client
+ listUnsubscribeURLs []string // URLs from List-Unsubscribe header
+ hasOneClickUnsubscribe bool // True if List-Unsubscribe-Post: List-Unsubscribe=One-Click
}
// NewContentAnalyzer creates a new content analyzer with configurable timeout
@@ -115,6 +117,10 @@ 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()
@@ -723,15 +729,16 @@ func (c *ContentAnalyzer) normalizeText(text string) string {
}
// GenerateContentAnalysis creates structured content analysis from results
-func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.ContentAnalysis {
+func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *model.ContentAnalysis {
if results == nil {
return nil
}
- analysis := &api.ContentAnalysis{
- HasHtml: api.PtrTo(results.HTMLContent != ""),
- HasPlaintext: api.PtrTo(results.TextContent != ""),
- HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe),
+ analysis := &model.ContentAnalysis{
+ HasHtml: utils.PtrTo(results.HTMLContent != ""),
+ HasPlaintext: utils.PtrTo(results.TextContent != ""),
+ HasUnsubscribeLink: utils.PtrTo(results.HasUnsubscribe),
+ UnsubscribeMethods: &[]model.ContentAnalysisUnsubscribeMethods{},
}
// Calculate text-to-image ratio (inverse of image-to-text)
@@ -744,16 +751,16 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
}
// Build HTML issues
- htmlIssues := []api.ContentIssue{}
+ htmlIssues := []model.ContentIssue{}
// Add HTML parsing errors
if !results.HTMLValid && len(results.HTMLErrors) > 0 {
for _, errMsg := range results.HTMLErrors {
- htmlIssues = append(htmlIssues, api.ContentIssue{
- Type: api.BrokenHtml,
- Severity: api.ContentIssueSeverityHigh,
+ htmlIssues = append(htmlIssues, model.ContentIssue{
+ Type: model.BrokenHtml,
+ Severity: model.ContentIssueSeverityHigh,
Message: errMsg,
- Advice: api.PtrTo("Fix HTML structure errors to improve email rendering across clients"),
+ Advice: utils.PtrTo("Fix HTML structure errors to improve email rendering across clients"),
})
}
}
@@ -767,53 +774,53 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
}
}
if missingAltCount > 0 {
- htmlIssues = append(htmlIssues, api.ContentIssue{
- Type: api.MissingAlt,
- Severity: api.ContentIssueSeverityMedium,
+ htmlIssues = append(htmlIssues, model.ContentIssue{
+ Type: model.MissingAlt,
+ Severity: model.ContentIssueSeverityMedium,
Message: fmt.Sprintf("%d image(s) missing alt attributes", missingAltCount),
- Advice: api.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"),
+ Advice: utils.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, api.ContentIssue{
- Type: api.ExcessiveImages,
- Severity: api.ContentIssueSeverityMedium,
+ htmlIssues = append(htmlIssues, model.ContentIssue{
+ Type: model.ExcessiveImages,
+ Severity: model.ContentIssueSeverityMedium,
Message: "Email is excessively image-heavy",
- Advice: api.PtrTo("Reduce the number of images relative to text content"),
+ Advice: utils.PtrTo("Reduce the number of images relative to text content"),
})
}
// Add suspicious URL issues
for _, suspURL := range results.SuspiciousURLs {
- htmlIssues = append(htmlIssues, api.ContentIssue{
- Type: api.SuspiciousLink,
- Severity: api.ContentIssueSeverityHigh,
+ htmlIssues = append(htmlIssues, model.ContentIssue{
+ Type: model.SuspiciousLink,
+ Severity: model.ContentIssueSeverityHigh,
Message: "Suspicious URL detected",
Location: &suspURL,
- Advice: api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"),
+ Advice: utils.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"),
})
}
// Add harmful HTML tag issues
for _, harmfulIssue := range results.HarmfullIssues {
- htmlIssues = append(htmlIssues, api.ContentIssue{
- Type: api.DangerousHtml,
- Severity: api.ContentIssueSeverityCritical,
+ htmlIssues = append(htmlIssues, model.ContentIssue{
+ Type: model.DangerousHtml,
+ Severity: model.ContentIssueSeverityCritical,
Message: harmfulIssue,
- Advice: api.PtrTo("Remove dangerous HTML tags like
- {#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 8dc57b0..a4fda45 100644
--- a/web/src/lib/components/EmailPathCard.svelte
+++ b/web/src/lib/components/EmailPathCard.svelte
@@ -1,5 +1,6 @@
{#if receivedChain && receivedChain.length > 0}
-
-
Email Path (Received Chain)
-
+
+
+
{#each receivedChain as hop, i}
@@ -30,7 +40,7 @@
: "-"}
- {#if hop.with || hop.id}
+ {#if hop.with || hop.id || hop.from}
{#if hop.with}
diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte
index b26b492..73c39e8 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, xAlignedFrom }: Props = $props();
+ let { dmarcRecord, headerAnalysis, headerGrade, headerScore }: Props = $props();
diff --git a/web/src/lib/components/HistoryTable.svelte b/web/src/lib/components/HistoryTable.svelte
new file mode 100644
index 0000000..737d025
--- /dev/null
+++ b/web/src/lib/components/HistoryTable.svelte
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+ | Grade |
+ Score |
+ Domain |
+ Date |
+ |
+
+
+
+ {#each tests as test}
+ goto(`/test/${test.test_id}`)}>
+ |
+
+ |
+
+ {test.score}%
+ |
+
+ {#if test.from_domain}
+ {test.from_domain}
+ {:else}
+ -
+ {/if}
+ |
+
+ {formatDate(test.created_at)}
+ |
+
+
+ |
+
+ {/each}
+
+
+
+
+
diff --git a/web/src/lib/components/PtrForwardRecordsDisplay.svelte b/web/src/lib/components/PtrForwardRecordsDisplay.svelte
index 77ce6c8..8ed723b 100644
--- a/web/src/lib/components/PtrForwardRecordsDisplay.svelte
+++ b/web/src/lib/components/PtrForwardRecordsDisplay.svelte
@@ -21,6 +21,11 @@
);
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}
@@ -63,15 +68,31 @@
Forward Resolution (A/AAAA):
{#each ptrForwardRecords as ip}
-
- {#if senderIp && ip === senderIp}
- Match
- {:else}
- Different
- {/if}
- {ip}
-
+ {#if ip === senderIp || !fcrDnsIsValid || showDifferent}
+
+ {#if senderIp && ip === senderIp}
+ Match
+ {:else}
+ Different
+ {/if}
+ {ip}
+
+ {/if}
{/each}
+ {#if fcrDnsIsValid && differentCount > 0}
+
+
+
+ {/if}
{#if fcrDnsIsValid}
diff --git a/web/src/lib/components/RspamdCard.svelte b/web/src/lib/components/RspamdCard.svelte
new file mode 100644
index 0000000..4c2795b
--- /dev/null
+++ b/web/src/lib/components/RspamdCard.svelte
@@ -0,0 +1,153 @@
+
+
+
+
+
+
+
+ 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}
+
+
+
+
+
+ | Symbol |
+ Score |
+ Description |
+
+
+
+ {#each Object.entries(rspamd.symbols).sort(([, a], [, b]) => b.score - a.score) as [symbolName, symbol]}
+ 0
+ ? "table-warning"
+ : symbol.score < 0
+ ? "table-success"
+ : ""}
+ >
+ |
+ {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 ?? ""} |
+
+ {/each}
+
+
+
+
+ {/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 2da105e..cc88c23 100644
--- a/web/src/lib/components/SpamAssassinCard.svelte
+++ b/web/src/lib/components/SpamAssassinCard.svelte
@@ -6,11 +6,9 @@
interface Props {
spamassassin: SpamAssassinResult;
- spamGrade?: string;
- spamScore?: number;
}
- let { spamassassin, spamGrade, spamScore }: Props = $props();
+ let { spamassassin }: Props = $props();
@@ -21,13 +19,13 @@
SpamAssassin Analysis
- {#if spamScore !== undefined}
-
- {spamScore}%
+ {#if spamassassin.deliverability_score !== undefined}
+
+ {spamassassin.deliverability_score}%
{/if}
- {#if spamGrade !== undefined}
-
+ {#if spamassassin.deliverability_grade !== undefined}
+
{/if}
diff --git a/web/src/lib/components/SummaryCard.svelte b/web/src/lib/components/SummaryCard.svelte
index 199bc94..518e996 100644
--- a/web/src/lib/components/SummaryCard.svelte
+++ b/web/src/lib/components/SummaryCard.svelte
@@ -25,16 +25,32 @@
// Email sender information
const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender";
- const hasDkim = report.authentication?.dkim && report.authentication?.dkim.length > 0;
- const dkimPassed = hasDkim && report.authentication?.dkim?.some((d) => d.result === "pass");
+ 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");
segments.push({ text: "Received a " });
segments.push({
- text: dkimPassed ? "DKIM-signed" : "non-DKIM-signed",
- highlight: { color: dkimPassed ? "good" : "danger", bold: true },
- link: "#authentication-dkim",
+ text: hasDkim ? "DKIM-signed" : "non-DKIM-signed",
+ highlight: {
+ color: hasDkim ? (dkimPassed ? "good" : "warning") : "danger",
+ bold: true,
+ },
+ link: hasDkim && dkimPassed ? "#authentication-dkim" : "#dns-details",
});
- segments.push({ text: " email from " });
+ 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: mailFrom,
highlight: { emphasis: true },
@@ -113,7 +129,7 @@
} else if (spfResult === "temperror" || spfResult === "permerror") {
segments.push({
text: "encountered an error",
- highlight: { color: "warning", bold: true },
+ highlight: { color: "danger", bold: true },
link: "#authentication-spf",
});
segments.push({ text: ", check your SPF record configuration" });
@@ -331,7 +347,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 " });
@@ -422,6 +438,17 @@
});
}
+ // 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
new file mode 100644
index 0000000..13fd86b
--- /dev/null
+++ b/web/src/lib/components/WhitelistCard.svelte
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+ 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}
+
+ |
+
+ {check.error
+ ? "Error"
+ : check.listed
+ ? "Listed"
+ : "Not listed"}
+
+ |
+ {check.rbl} |
+
+ {/each}
+
+
+
+ {/each}
+
+
+
diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts
index 3c76feb..a593801 100644
--- a/web/src/lib/components/index.ts
+++ b/web/src/lib/components/index.ts
@@ -19,7 +19,10 @@ 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 87662ba..962868c 100644
--- a/web/src/lib/stores/config.ts
+++ b/web/src/lib/stores/config.ts
@@ -25,6 +25,8 @@ 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 362202b..ea24293 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) return stored;
+ if (stored === "light" || stored === "dark") 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 077f340..92bb4db 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -40,7 +40,17 @@
{/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 7c23d10..b9259fe 100644
--- a/web/src/routes/+page.svelte
+++ b/web/src/routes/+page.svelte
@@ -1,12 +1,30 @@
+
+
+ Test History - happyDeliver
+
+
+
+
+
+
+
+
+ Test History
+
+
+
+
+ {#if loading}
+
+
+ Loading...
+
+
Loading tests...
+
+ {:else if error}
+
+
+ {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 bf44d20..113209d 100644
--- a/web/src/routes/test/[test]/+page.svelte
+++ b/web/src/routes/test/[test]/+page.svelte
@@ -3,21 +3,26 @@
import { onDestroy } from "svelte";
import { getReport, getTest, reanalyzeReport } from "$lib/api";
- import type { Report, Test } from "$lib/api/types.gen";
+ import type { BlacklistCheck, 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);
@@ -290,6 +295,15 @@
+
+ {#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0}
+
+ {/if}
+
{#if report.dns_results}
@@ -320,17 +334,45 @@
{/if}
- {#if report.blacklists && Object.keys(report.blacklists).length > 0}
-
-
-
+ {#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)}
+ {: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}
@@ -347,16 +389,19 @@
{/if}
-
- {#if report.spamassassin}
+
+ {#if report.spamassassin || report.rspamd}
-
-
-
+ {#if report.spamassassin}
+
+
+
+ {/if}
+ {#if report.rspamd}
+
+
+
+ {/if}
{/if}