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/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 ee56cff..2dbf304 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -296,1165 +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'
- 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
-
+ $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: 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"
-
+ $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, 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
-
+ $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:
- 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
-
+ $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
- 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%"
-
+ $ref: './schemas.yaml#/components/schemas/SpamTestDetail'
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)
-
-
+ $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
- - 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)
-
+ $ref: './schemas.yaml#/components/schemas/BlacklistCheckResponse'
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
-
+ $ref: './schemas.yaml#/components/schemas/TestSummary'
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
+ $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/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/internal/api/handlers.go b/internal/api/handlers.go
index e524b40..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, whitelists []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,14 +347,14 @@ func (h *APIHandler) TestDomain(c *gin.Context) {
// CheckBlacklist checks an IP address against DNS blacklists
// (POST /blacklist)
func (h *APIHandler) CheckBlacklist(c *gin.Context) {
- var request 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
}
@@ -361,22 +362,22 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
// Perform blacklist check using analyzer
checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
if err != nil {
- c.JSON(http.StatusBadRequest, 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,
Blacklists: checks,
Whitelists: &whitelists,
ListedCount: listedCount,
Score: score,
- Grade: BlacklistCheckResponseGrade(grade),
+ Grade: model.BlacklistCheckResponseGrade(grade),
}
c.JSON(http.StatusOK, response)
@@ -386,7 +387,7 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
// (GET /tests)
func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) {
if h.config.DisableTestList {
- c.JSON(http.StatusForbidden, Error{
+ c.JSON(http.StatusForbidden, model.Error{
Error: "feature_disabled",
Message: "Test listing is disabled on this instance",
})
@@ -405,51 +406,17 @@ func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) {
}
}
- summaries, total, err := h.storage.ListReportSummaries(offset, limit)
+ tests, total, err := h.storage.ListReportSummaries(offset, limit)
if err != nil {
- c.JSON(http.StatusInternalServerError, Error{
+ c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to list tests",
- Details: stringPtr(err.Error()),
+ Details: utils.PtrTo(err.Error()),
})
return
}
- tests := make([]TestSummary, 0, len(summaries))
- for _, s := range summaries {
- base32ID := utils.UUIDToBase32(s.TestID)
-
- var grade TestSummaryGrade
- switch s.Grade {
- case "A+":
- grade = TestSummaryGradeA
- case "A":
- grade = TestSummaryGradeA1
- case "B":
- grade = TestSummaryGradeB
- case "C":
- grade = TestSummaryGradeC
- case "D":
- grade = TestSummaryGradeD
- case "E":
- grade = TestSummaryGradeE
- default:
- grade = TestSummaryGradeF
- }
-
- summary := TestSummary{
- TestId: base32ID,
- Score: s.Score,
- Grade: grade,
- CreatedAt: s.CreatedAt,
- }
- if s.FromDomain != "" {
- summary.FromDomain = stringPtr(s.FromDomain)
- }
- tests = append(tests, summary)
- }
-
- c.JSON(http.StatusOK, TestListResponse{
+ c.JSON(http.StatusOK, model.TestListResponse{
Tests: tests,
Total: int(total),
Offset: offset,
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index 1077e74..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,21 +48,12 @@ 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) ([]ReportSummary, int64, error)
+ ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error)
// Close closes the database connection
Close() error
}
-// ReportSummary is a lightweight projection of Report for listing
-type ReportSummary struct {
- TestID uuid.UUID
- Score int
- Grade string
- FromDomain string
- CreatedAt time.Time
-}
-
// DBStorage implements Storage using GORM
type DBStorage struct {
db *gorm.DB
@@ -149,15 +143,24 @@ 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) ([]ReportSummary, int64, error) {
+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 []ReportSummary{}, 0, nil
+ return []model.TestSummary{}, 0, nil
}
var selectExpr string
@@ -168,25 +171,41 @@ func (s *DBStorage) ListReportSummaries(offset, limit int) ([]ReportSummary, int
`convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` +
`convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` +
`created_at`
- default: // sqlite
+ 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 summaries []ReportSummary
+ var rows []reportSummaryRow
err := s.db.Model(&Report{}).
Select(selectExpr).
Order("created_at DESC").
Offset(offset).
Limit(limit).
- Scan(&summaries).Error
+ 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
}
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 f21d1f8..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"
)
@@ -59,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
@@ -113,7 +113,7 @@ func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byt
}
// AnalyzeDomain performs DNS analysis for a domain and returns the results
-func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string) {
+func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, string) {
// Perform DNS analysis
dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain)
@@ -124,7 +124,7 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string)
}
// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists
-func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.BlacklistCheck, int, int, string, error) {
+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 {
@@ -134,7 +134,7 @@ func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.Bl
// Calculate score using the existing function
// Create a minimal RBLResults structure for scoring
results := &DNSListResults{
- Checks: map[string][]api.BlacklistCheck{ip: checks},
+ Checks: map[string][]model.BlacklistCheck{ip: checks},
IPsChecked: []string{ip},
ListedCount: listedCount,
}
diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go
index 2beeb1f..da31b1c 100644
--- a/pkg/analyzer/authentication.go
+++ b/pkg/analyzer/authentication.go
@@ -24,7 +24,7 @@ package analyzer
import (
"strings"
- "git.happydns.org/happyDeliver/internal/api"
+ "git.happydns.org/happyDeliver/internal/model"
)
// AuthenticationAnalyzer analyzes email authentication results
@@ -38,8 +38,8 @@ func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer
}
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
-func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults {
- results := &api.AuthenticationResults{}
+func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults {
+ results := &model.AuthenticationResults{}
// Parse Authentication-Results headers
authHeaders := email.GetAuthenticationResults(a.receiverHostname)
@@ -65,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 {
@@ -91,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)
@@ -145,7 +145,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
// CalculateAuthenticationScore calculates the authentication score from auth results
// Returns a score from 0-100 where higher is better
-func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) (int, string) {
+func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.AuthenticationResults) (int, string) {
if results == nil {
return 0, ""
}
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 7f2f99e..ac51d0b 100644
--- a/pkg/analyzer/authentication_arc_test.go
+++ b/pkg/analyzer/authentication_arc_test.go
@@ -24,29 +24,29 @@ package analyzer
import (
"testing"
- "git.happydns.org/happyDeliver/internal/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,
},
}
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 7cb9c85..440f356 100644
--- a/pkg/analyzer/authentication_bimi_test.go
+++ b/pkg/analyzer/authentication_bimi_test.go
@@ -24,42 +24,42 @@ package analyzer
import (
"testing"
- "git.happydns.org/happyDeliver/internal/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",
},
}
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 3218639..0576854 100644
--- a/pkg/analyzer/authentication_dkim_test.go
+++ b/pkg/analyzer/authentication_dkim_test.go
@@ -24,35 +24,35 @@ package analyzer
import (
"testing"
- "git.happydns.org/happyDeliver/internal/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",
},
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 3b8fb08..69779a7 100644
--- a/pkg/analyzer/authentication_dmarc_test.go
+++ b/pkg/analyzer/authentication_dmarc_test.go
@@ -24,26 +24,26 @@ package analyzer
import (
"testing"
- "git.happydns.org/happyDeliver/internal/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",
},
}
diff --git a/pkg/analyzer/authentication_iprev.go b/pkg/analyzer/authentication_iprev.go
index e799094..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,15 +55,15 @@ 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
diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go
index 5b46995..55f85d5 100644
--- a/pkg/analyzer/authentication_iprev_test.go
+++ b/pkg/analyzer/authentication_iprev_test.go
@@ -24,71 +24,72 @@ 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,
},
}
@@ -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,9 +176,9 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
{
name: "Multiple IPRev results - only first is parsed",
header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)",
- expectedIPRevResult: 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"),
},
}
@@ -185,7 +186,7 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
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 fc41e3c..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,13 +52,13 @@ 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
@@ -73,13 +74,13 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe
}
}
- result := &api.AuthResult{}
+ 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
@@ -97,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 960aef5..210505a 100644
--- a/pkg/analyzer/authentication_spf_test.go
+++ b/pkg/analyzer/authentication_spf_test.go
@@ -24,38 +24,39 @@ 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",
},
}
@@ -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,8 +157,8 @@ func TestParseLegacySPF(t *testing.T) {
{
name: "SPF with unquoted envelope-from",
receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net",
- expectedResult: api.AuthResultResultPass,
- expectedDomain: api.PtrTo("mail.example.net"),
+ expectedResult: model.AuthResultResultPass,
+ expectedDomain: utils.PtrTo("mail.example.net"),
},
}
diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go
index 7122f53..44c1abb 100644
--- a/pkg/analyzer/authentication_test.go
+++ b/pkg/analyzer/authentication_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 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)
@@ -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,19 +231,19 @@ 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,
},
}
@@ -251,7 +252,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
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
@@ -357,13 +358,13 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) {
header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com"
- results := &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 eb0cf98..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:
diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go
index 0fdd69d..1ea6d1c 100644
--- a/pkg/analyzer/authentication_x_aligned_from_test.go
+++ b/pkg/analyzer/authentication_x_aligned_from_test.go
@@ -24,44 +24,44 @@ package analyzer
import (
"testing"
- "git.happydns.org/happyDeliver/internal/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",
},
}
@@ -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,
},
@@ -130,7 +130,7 @@ func TestCalculateXAlignedFromScore(t *testing.T) {
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 f9704c0..4013340 100644
--- a/pkg/analyzer/authentication_x_google_dkim_test.go
+++ b/pkg/analyzer/authentication_x_google_dkim_test.go
@@ -24,39 +24,39 @@ package analyzer
import (
"testing"
- "git.happydns.org/happyDeliver/internal/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,
},
}
diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go
index d14d157..06f8ddf 100644
--- a/pkg/analyzer/content.go
+++ b/pkg/analyzer/content.go
@@ -32,7 +32,8 @@ 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"
)
@@ -728,16 +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),
- UnsubscribeMethods: &[]api.ContentAnalysisUnsubscribeMethods{},
+ 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)
@@ -750,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"),
})
}
}
@@ -773,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