diff --git a/.drone.yml b/.drone.yml index 779952f..053beb0 100644 --- a/.drone.yml +++ b/.drone.yml @@ -9,7 +9,7 @@ platform: steps: - name: frontend - image: node:24-alpine + image: node:22-alpine commands: - cd web - npm install --network-timeout=100000 @@ -21,7 +21,7 @@ steps: commands: - apk add --no-cache git - go generate ./... - - go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver + - go build -tags netgo -ldflags '-w -X main.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver - ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver environment: CGO_ENABLED: 0 @@ -35,7 +35,7 @@ steps: commands: - apk add --no-cache git - go generate ./... - - go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ + - go build -tags netgo -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ - ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver environment: CGO_ENABLED: 0 @@ -47,7 +47,7 @@ steps: image: golang:1-alpine commands: - apk add --no-cache git - - go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ + - go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ environment: CGO_ENABLED: 0 GOOS: darwin @@ -61,7 +61,7 @@ steps: image: golang:1-alpine commands: - apk add --no-cache git - - go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ + - go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/ environment: CGO_ENABLED: 0 GOOS: darwin diff --git a/.gitignore b/.gitignore index e943630..7ece05e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,5 @@ logs/ *.sqlite3 # OpenAPI generated files -internal/api/server.gen.go -internal/model/types.gen.go +internal/api/models.gen.go +internal/api/server.gen.go \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 4568784..e731aa3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Multi-stage Dockerfile for happyDeliver with integrated MTA # Stage 1: Build the Svelte application -FROM node:24-alpine AS nodebuild +FROM node:22-alpine AS nodebuild WORKDIR /build @@ -31,97 +31,19 @@ COPY --from=nodebuild /build/web/build/ ./web/build/ RUN go generate ./... && \ CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver -# Stage 3: Prepare perl and spamass-milt -FROM alpine:3 AS pl - -RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \ - apk add --no-cache \ - build-base \ - libmilter-dev \ - musl-obstack-dev \ - openssl \ - openssl-dev \ - perl-app-cpanminus \ - perl-alien-libxml2 \ - perl-class-load-xs \ - perl-cpanel-json-xs \ - perl-crypt-openssl-rsa \ - perl-crypt-openssl-random \ - perl-crypt-openssl-verify \ - perl-crypt-openssl-x509 \ - perl-dbd-sqlite \ - perl-dbi \ - perl-email-address-xs \ - perl-json-xs \ - perl-list-moreutils \ - perl-moose \ - perl-net-idn-encode@edge \ - perl-net-ssleay \ - perl-netaddr-ip \ - perl-package-stash \ - perl-params-util \ - perl-params-validate \ - perl-proc-processtable \ - perl-sereal-decoder \ - perl-sereal-encoder \ - perl-socket6 \ - perl-sub-identify \ - perl-variable-magic \ - perl-xml-libxml \ - perl-dev \ - spamassassin-client \ - zlib-dev \ - && \ - ln -s /usr/bin/ld /bin/ld - -RUN cpanm --notest Mail::SPF && \ - cpanm --notest Mail::Milter::Authentication - -RUN wget https://download.savannah.nongnu.org/releases/spamass-milt/spamass-milter-0.4.0.tar.gz && \ - tar xzf spamass-milter-0.4.0.tar.gz && \ - cd spamass-milter-0.4.0 && \ - ./configure && make install - -# Stage 4: Runtime image with Postfix and all filters +# Stage 3: Runtime image with Postfix and all filters FROM alpine:3 # Install all required packages -RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \ - apk add --no-cache \ +RUN apk add --no-cache \ bash \ ca-certificates \ - libmilter \ - openssl \ - perl \ - perl-alien-libxml2 \ - perl-class-load-xs \ - perl-cpanel-json-xs \ - perl-crypt-openssl-rsa \ - perl-crypt-openssl-random \ - perl-crypt-openssl-verify \ - perl-crypt-openssl-x509 \ - perl-dbd-sqlite \ - perl-dbi \ - perl-email-address-xs \ - perl-json-xs \ - perl-list-moreutils \ - perl-moose \ - perl-net-idn-encode@edge \ - perl-net-ssleay \ - perl-netaddr-ip \ - perl-package-stash \ - perl-params-util \ - perl-params-validate \ - perl-proc-processtable \ - perl-sereal-decoder \ - perl-sereal-encoder \ - perl-socket6 \ - perl-sub-identify \ - perl-variable-magic \ - perl-xml-libxml \ + opendkim \ + opendkim-utils \ + opendmarc \ postfix \ postfix-pcre \ - rspamd \ + postfix-policyd-spf-perl \ spamassassin \ spamassassin-client \ supervisor \ @@ -129,8 +51,8 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap tzdata \ && rm -rf /var/cache/apk/* -# Copy Mail::Milter::Authentication and its dependancies -COPY --from=pl /usr/local/ /usr/local/ +# Get test-only version of postfix-policyd-spf-perl +ADD https://git.nemunai.re/happyDomain/postfix-policyd-spf-perl/raw/branch/master/postfix-policyd-spf-perl /usr/bin/postfix-policyd-spf-perl # Create happydeliver user and group RUN addgroup -g 1000 happydeliver && \ @@ -140,15 +62,12 @@ RUN addgroup -g 1000 happydeliver && \ RUN mkdir -p /etc/happydeliver \ /var/lib/happydeliver \ /var/log/happydeliver \ - /var/cache/authentication_milter \ - /var/lib/authentication_milter \ - /var/spool/postfix/authentication_milter \ - /var/spool/postfix/spamassassin \ - /var/spool/postfix/rspamd \ + /var/spool/postfix/opendkim \ + /var/spool/postfix/opendmarc \ + /etc/opendkim/keys \ && chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \ - && chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin \ - && chown rspamd:mail /var/spool/postfix/rspamd \ - && chmod 750 /var/spool/postfix/rspamd + && chown -R opendkim:postfix /var/spool/postfix/opendkim \ + && chown -R opendmarc:postfix /var/spool/postfix/opendmarc # Copy the built application COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver @@ -156,9 +75,9 @@ RUN chmod +x /usr/local/bin/happyDeliver # Copy configuration files COPY docker/postfix/ /etc/postfix/ -COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json +COPY docker/opendkim/ /etc/opendkim/ +COPY docker/opendmarc/ /etc/opendmarc/ COPY docker/spamassassin/ /etc/mail/spamassassin/ -COPY docker/rspamd/local.d/ /etc/rspamd/local.d/ COPY docker/supervisor/ /etc/supervisor/ COPY docker/entrypoint.sh /entrypoint.sh @@ -170,21 +89,11 @@ RUN chmod +x /entrypoint.sh EXPOSE 25 8080 # Default configuration -ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \ - HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db \ - HAPPYDELIVER_DOMAIN=happydeliver.local \ - HAPPYDELIVER_ADDRESS_PREFIX=test- \ - HAPPYDELIVER_DNS_TIMEOUT=5s \ - HAPPYDELIVER_HTTP_TIMEOUT=10s \ - HAPPYDELIVER_RSPAMD_API_URL=http://127.0.0.1:11334 +ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net # Volume for persistent data VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"] -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ - CMD wget --quiet --tries=1 --spider http://localhost:8080/api/status || exit 1 - # Set entrypoint ENTRYPOINT ["/entrypoint.sh"] CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"] diff --git a/README.md b/README.md index 4010d7e..c76e248 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,28 @@ -# happyDeliver - Email Deliverability Tester - -![banner](banner.webp) +# happyDeliver An open-source email deliverability testing platform that analyzes test emails and provides detailed deliverability reports with scoring. ## Features -- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin and rspamd scores, DNS records, blacklist status, content quality, and more +- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more - **REST API**: Full-featured API for creating tests and retrieving reports - **LMTP Server**: Built-in LMTP server for seamless MTA integration -- **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers +- **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers - **Database Storage**: SQLite or PostgreSQL support - **Configurable**: via environment or config file for all settings -![A sample deliverability report](web/static/img/report.webp) - ## Quick Start ### With Docker (Recommended) -The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, authentication_milter, SpamAssassin, and the happyDeliver application. +The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, OpenDKIM, OpenDMARC, SpamAssassin, and the happyDeliver application. #### What's included in the Docker container: - **Postfix MTA**: Receives emails on port 25 -- **authentication_milter**: Entreprise grade email authentication +- **OpenDKIM**: DKIM signature verification +- **OpenDMARC**: DMARC policy validation - **SpamAssassin**: Spam scoring and analysis -- **rspamd**: Second spam filter for cross-validated scoring - **happyDeliver API**: REST API server on port 8080 - **SQLite Database**: Persistent storage for tests and reports @@ -38,7 +34,7 @@ git clone https://git.nemunai.re/happyDomain/happyDeliver.git cd happydeliver # Edit docker-compose.yml to set your domain -# Change HAPPYDELIVER_DOMAIN environment variable and hostname +# Change HAPPYDELIVER_DOMAIN and HOSTNAME environment variables # Build and start docker-compose up -d @@ -64,86 +60,12 @@ docker run -d \ -p 25:25 \ -p 8080:8080 \ -e HAPPYDELIVER_DOMAIN=yourdomain.com \ - --hostname mail.yourdomain.com \ + -e HOSTNAME=mail.yourdomain.com \ -v $(pwd)/data:/var/lib/happydeliver \ -v $(pwd)/logs:/var/log/happydeliver \ happydeliver:latest ``` -#### 3. Configure TLS Certificates (Optional but Recommended) - -To enable TLS encryption for incoming SMTP connections, you can configure Postfix to use your SSL/TLS certificates. This is highly recommended for production deployments. - -##### Using docker-compose - -Add the certificate paths to your `docker-compose.yml`: - -```yaml -environment: - - POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt - - POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key -volumes: - - /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro - - /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro -``` - -##### Using docker run - -```bash -docker run -d \ - --name happydeliver \ - -p 25:25 \ - -p 8080:8080 \ - -e HAPPYDELIVER_DOMAIN=yourdomain.com \ - -e POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt \ - -e POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key \ - --hostname mail.yourdomain.com \ - -v /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro \ - -v /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro \ - -v $(pwd)/data:/var/lib/happydeliver \ - -v $(pwd)/logs:/var/log/happydeliver \ - happydeliver:latest -``` - -**Notes:** -- The certificate file should contain the full certificate chain (certificate + intermediate CAs) -- The private key file must be readable by the postfix user inside the container -- TLS is configured with `smtpd_tls_security_level = may`, which means it's opportunistic (STARTTLS supported but not required) -- If both environment variables are not set, Postfix will run without TLS support - -#### 4. Configure Network and DNS - -##### Open SMTP Port - -Port 25 (SMTP) must be accessible from the internet to receive test emails: - -```bash -# Check if port 25 is listening -netstat -ln | grep :25 - -# Allow port 25 through firewall (example with ufw) -sudo ufw allow 25/tcp - -# For iptables -sudo iptables -A INPUT -p tcp --dport 25 -j ACCEPT -``` - -**Note:** Many ISPs and cloud providers block port 25 by default to prevent spam. You may need to request port 25 to be unblocked through your provider's support. - -##### Configure DNS Records - -Point your domain to the server's IP address. - -``` -yourdomain.com. IN A 203.0.113.10 -yourdomain.com. IN AAAA 2001:db8::10 -``` - -Replace `yourdomain.com` with the value you set for `HAPPYDELIVER_DOMAIN` and IPs accordingly. - -There is no need for an MX record here since the same host will serve both HTTP and SMTP. - - ### Manual Build #### 1. Build @@ -163,27 +85,10 @@ The server will start on `http://localhost:8080` by default. #### 3. Integrate with your existing e-mail setup -It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ... +It is expected your setup annotate the email with eg. opendkim, spamassassin, ... happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations. -#### Receiver Hostname - -happyDeliver filters `Authentication-Results` headers by hostname to only trust headers added by your MTA (and not headers that may have been injected by the sender). By default, it uses the system hostname (`os.Hostname()`). - -If your MTA's `authserv-id` (the hostname at the beginning of `Authentication-Results` headers) differs from the machine running happyDeliver, you must set it explicitly: - -```bash -./happyDeliver server -receiver-hostname mail.example.com -``` - -Or via environment variable: -```bash -HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ./happyDeliver server -``` - -**How to find the correct value:** look at the `Authentication-Results` headers in a received email. They start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...` — in this case, use `mail.example.com`. - -If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname. +Choose one of the following way to integrate happyDeliver in your existing setup: #### Postfix LMTP Transport @@ -201,9 +106,9 @@ You'll obtain the best results with a custom [transport rule](https://www.postfi ``` # Transport map - route test emails to happyDeliver LMTP server - # Pattern: test-@yourdomain.com -> LMTP on localhost:2525 + # Pattern: test-@yourdomain.com -> LMTP on localhost:2525 - /^test-[a-zA-Z2-7-]{26,30}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525 + /^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525 ``` 3. Append the created file to `transport_maps` in your `main.cf`: @@ -237,7 +142,7 @@ Response: ```json { "id": "550e8400-e29b-41d4-a716-446655440000", - "email": "test-kfauqaao-ukj2if3n-fgrfkiafaa@localhost", + "email": "test-550e8400@localhost", "status": "pending", "message": "Send your test email to the address above" } @@ -279,43 +184,22 @@ cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com **Note:** In production, emails are delivered via LMTP (see integration instructions above). -## Use with happyDomain - -happyDeliver can be driven by [happyDomain](https://happydomain.org) through -the [`checker-happydeliver`](https://git.nemunai.re/happyDomain/checker-happydeliver) -plugin, so the deliverability of a domain you manage is monitored alongside -its DNS and inbound SMTP posture. - -How it works: - -1. Attach the **Outbound deliverability** checker to the mail service of a zone - in happyDomain. Point it at a happyDeliver instance via `happydeliver_url`; - operators can configure a default instance globally. -2. On each run, the checker calls `POST /api/test` to allocate a fresh - recipient address, prompts the user (or an automated sender) to mail it from - the tested domain, then polls `GET /api/test/{id}` until the report is - ready. -3. The structured report from `GET /api/report/{id}` is translated into - happyDomain rule states: CRIT/WARN/INFO on SPF, DKIM, DMARC, alignment, spam - score, blacklists and headers, plus an overall score threshold - (`min_score`/`warn_score`). -4. Runs repeat on a configurable interval so a regression in deliverability (a - new RBL listing, a DKIM key rotation gone wrong, a broken SPF include, ...) - surfaces as a domain-level alert in happyDomain. - -See the [`checker-happydeliver` repository](https://git.nemunai.re/happyDomain/checker-happydeliver) -for build instructions and the full list of run options. - ## Scoring System -The deliverability score is calculated from A to F based on: +The deliverability score is calculated from 0 to 10 based on: -- **DNS**: Step-by-step analysis of PTR, Forward-Confirmed Reverse DNS, MX, SPF, DKIM, DMARC and BIMI records -- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation -- **Blacklist**: RBL/DNSBL checks -- **Headers**: Required headers, MIME structure, Domain alignment -- **Spam**: SpamAssassin and rspamd scores (combined 50/50) -- **Content**: HTML quality, links, images, unsubscribe +- **Authentication (3 pts)**: SPF, DKIM, DMARC validation +- **Spam (2 pts)**: SpamAssassin score +- **Blacklist (2 pts)**: RBL/DNSBL checks +- **Content (2 pts)**: HTML quality, links, images, unsubscribe +- **Headers (1 pt)**: Required headers, MIME structure + +**Ratings:** +- 9-10: Excellent +- 7-8.9: Good +- 5-6.9: Fair +- 3-4.9: Poor +- 0-2.9: Critical ## Funding diff --git a/api/config-models.yaml b/api/config-models.yaml index aa2fb0e..9c3425c 100644 --- a/api/config-models.yaml +++ b/api/config-models.yaml @@ -1,9 +1,5 @@ -package: model +package: api generate: models: true - embedded-spec: true -output: internal/model/types.gen.go -output-options: - skip-prune: true -import-mapping: - ./schemas.yaml: "-" + embedded-spec: false +output: internal/api/models.gen.go diff --git a/api/config-server.yaml b/api/config-server.yaml index 347dbaf..20f8daf 100644 --- a/api/config-server.yaml +++ b/api/config-server.yaml @@ -1,8 +1,5 @@ package: api generate: gin-server: true - models: true embedded-spec: true output: internal/api/server.gen.go -import-mapping: - ./schemas.yaml: git.happydns.org/happyDeliver/internal/model diff --git a/api/openapi.yaml b/api/openapi.yaml index 2dbf304..467f62c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -52,7 +52,7 @@ paths: tags: - tests summary: Get test status - description: Check if a report exists for the given test ID (base32-encoded). Returns pending if no report exists, analyzed if a report is available. + description: Check if a report exists for the given test ID. Returns pending if no report exists, analyzed if a report is available. operationId: getTest parameters: - name: id @@ -60,8 +60,7 @@ paths: required: true schema: type: string - pattern: '^[a-z0-9-]+$' - description: Base32-encoded test ID (with hyphens) + format: uuid responses: '200': description: Test status retrieved successfully @@ -76,49 +75,6 @@ paths: schema: $ref: '#/components/schemas/Error' - /tests: - get: - tags: - - tests - summary: List all tests - description: Returns a paginated list of test summaries with scores and grades. Can be disabled via server configuration. - operationId: listTests - parameters: - - name: offset - in: query - schema: - type: integer - minimum: 0 - default: 0 - description: Number of items to skip - - name: limit - in: query - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - description: Maximum number of items to return - responses: - '200': - description: List of test summaries - content: - application/json: - schema: - $ref: '#/components/schemas/TestListResponse' - '403': - description: Test listing is disabled - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - '500': - description: Internal server error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /report/{id}: get: tags: @@ -132,8 +88,7 @@ paths: required: true schema: type: string - pattern: '^[a-z0-9-]+$' - description: Base32-encoded test ID (with hyphens) + format: uuid responses: '200': description: Report retrieved successfully @@ -161,8 +116,7 @@ paths: required: true schema: type: string - pattern: '^[a-z0-9-]+$' - description: Base32-encoded test ID (with hyphens) + format: uuid responses: '200': description: Raw email retrieved successfully @@ -177,107 +131,6 @@ paths: schema: $ref: '#/components/schemas/Error' - /report/{id}/reanalyze: - post: - tags: - - reports - summary: Reanalyze email and regenerate report - description: Re-run the analysis on the stored raw email to regenerate the report with the latest analyzer version. This is useful after analyzer improvements or bug fixes. - operationId: reanalyzeReport - parameters: - - name: id - in: path - required: true - schema: - type: string - pattern: '^[a-z0-9-]+$' - description: Base32-encoded test ID (with hyphens) - responses: - '200': - description: Report regenerated successfully - content: - application/json: - schema: - $ref: '#/components/schemas/Report' - '404': - description: Email not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - '500': - description: Internal server error during reanalysis - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - - /domain: - post: - tags: - - tests - summary: Test a domain's email configuration - description: Analyzes DNS records (MX, SPF, DMARC, BIMI) for a domain without requiring an actual email to be sent. Returns results immediately. - operationId: testDomain - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DomainTestRequest' - responses: - '200': - description: Domain test completed successfully - content: - application/json: - schema: - $ref: '#/components/schemas/DomainTestResponse' - '400': - description: Invalid request - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - '500': - description: Internal server error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - - /blacklist: - post: - tags: - - tests - summary: Check an IP address against DNS blacklists - description: Tests a single IP address (IPv4 or IPv6) against configured DNS-based blacklists (RBLs) without requiring an actual email to be sent. Returns results immediately. - operationId: checkBlacklist - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/BlacklistCheckRequest' - responses: - '200': - description: Blacklist check completed successfully - content: - application/json: - schema: - $ref: '#/components/schemas/BlacklistCheckResponse' - '400': - description: Invalid request - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - '500': - description: Internal server error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /status: get: tags: @@ -296,74 +149,359 @@ paths: components: schemas: Test: - $ref: './schemas.yaml#/components/schemas/Test' + type: object + required: + - id + - email + - status + - created_at + properties: + id: + type: string + format: uuid + description: Unique test identifier + example: "550e8400-e29b-41d4-a716-446655440000" + email: + type: string + format: email + description: Unique test email address + example: "test-550e8400@example.com" + status: + type: string + enum: [pending, analyzed] + description: Current test status (pending = no report yet, analyzed = report available) + example: "analyzed" + created_at: + type: string + format: date-time + description: Test creation timestamp + updated_at: + type: string + format: date-time + description: Last update timestamp + TestResponse: - $ref: './schemas.yaml#/components/schemas/TestResponse' + type: object + required: + - id + - email + - status + properties: + id: + type: string + format: uuid + example: "550e8400-e29b-41d4-a716-446655440000" + email: + type: string + format: email + example: "test-550e8400@example.com" + status: + type: string + enum: [pending] + example: "pending" + message: + type: string + example: "Send your test email to the address above" + Report: - $ref: './schemas.yaml#/components/schemas/Report' + type: object + required: + - id + - test_id + - score + - checks + - created_at + properties: + id: + type: string + format: uuid + description: Report identifier + test_id: + type: string + format: uuid + description: Associated test ID + score: + type: number + format: float + minimum: 0 + maximum: 10 + description: Overall deliverability score (0-10) + example: 8.5 + summary: + $ref: '#/components/schemas/ScoreSummary' + checks: + type: array + items: + $ref: '#/components/schemas/Check' + authentication: + $ref: '#/components/schemas/AuthenticationResults' + spamassassin: + $ref: '#/components/schemas/SpamAssassinResult' + dns_records: + type: array + items: + $ref: '#/components/schemas/DNSRecord' + blacklists: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + raw_headers: + type: string + description: Raw email headers + created_at: + type: string + format: date-time + ScoreSummary: - $ref: './schemas.yaml#/components/schemas/ScoreSummary' - ContentAnalysis: - $ref: './schemas.yaml#/components/schemas/ContentAnalysis' - ContentIssue: - $ref: './schemas.yaml#/components/schemas/ContentIssue' - LinkCheck: - $ref: './schemas.yaml#/components/schemas/LinkCheck' - ImageCheck: - $ref: './schemas.yaml#/components/schemas/ImageCheck' - HeaderAnalysis: - $ref: './schemas.yaml#/components/schemas/HeaderAnalysis' - HeaderCheck: - $ref: './schemas.yaml#/components/schemas/HeaderCheck' - ReceivedHop: - $ref: './schemas.yaml#/components/schemas/ReceivedHop' - DKIMDomainInfo: - $ref: './schemas.yaml#/components/schemas/DKIMDomainInfo' - DomainAlignment: - $ref: './schemas.yaml#/components/schemas/DomainAlignment' - HeaderIssue: - $ref: './schemas.yaml#/components/schemas/HeaderIssue' + type: object + required: + - authentication_score + - spam_score + - blacklist_score + - content_score + - header_score + properties: + authentication_score: + type: number + format: float + minimum: 0 + maximum: 3 + description: SPF/DKIM/DMARC score (max 3 pts) + example: 2.8 + spam_score: + type: number + format: float + minimum: 0 + maximum: 2 + description: SpamAssassin score (max 2 pts) + example: 1.5 + blacklist_score: + type: number + format: float + minimum: 0 + maximum: 2 + description: Blacklist check score (max 2 pts) + example: 2.0 + content_score: + type: number + format: float + minimum: 0 + maximum: 2 + description: Content quality score (max 2 pts) + example: 1.8 + header_score: + type: number + format: float + minimum: 0 + maximum: 1 + description: Header quality score (max 1 pt) + example: 0.9 + + Check: + type: object + required: + - category + - name + - status + - score + - message + properties: + category: + type: string + enum: [authentication, dns, content, blacklist, headers, spam] + description: Check category + example: "authentication" + name: + type: string + description: Check name + example: "DKIM Signature" + status: + type: string + enum: [pass, fail, warn, info, error] + description: Check result status + example: "pass" + score: + type: number + format: float + description: Points contributed to total score + example: 1.0 + message: + type: string + description: Human-readable result message + example: "DKIM signature is valid" + details: + type: string + description: Additional details (may be JSON) + severity: + type: string + enum: [critical, high, medium, low, info] + description: Issue severity + example: "info" + advice: + type: string + description: Remediation advice + example: "Your DKIM configuration is correct" + AuthenticationResults: - $ref: './schemas.yaml#/components/schemas/AuthenticationResults' + type: object + properties: + spf: + $ref: '#/components/schemas/AuthResult' + dkim: + type: array + items: + $ref: '#/components/schemas/AuthResult' + dmarc: + $ref: '#/components/schemas/AuthResult' + AuthResult: - $ref: './schemas.yaml#/components/schemas/AuthResult' - ARCResult: - $ref: './schemas.yaml#/components/schemas/ARCResult' - IPRevResult: - $ref: './schemas.yaml#/components/schemas/IPRevResult' + type: object + required: + - result + properties: + result: + type: string + enum: [pass, fail, none, neutral, softfail, temperror, permerror] + 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 + SpamAssassinResult: - $ref: './schemas.yaml#/components/schemas/SpamAssassinResult' - SpamTestDetail: - $ref: './schemas.yaml#/components/schemas/SpamTestDetail' - RspamdResult: - $ref: './schemas.yaml#/components/schemas/RspamdResult' - DNSResults: - $ref: './schemas.yaml#/components/schemas/DNSResults' - MXRecord: - $ref: './schemas.yaml#/components/schemas/MXRecord' - SPFRecord: - $ref: './schemas.yaml#/components/schemas/SPFRecord' - DKIMRecord: - $ref: './schemas.yaml#/components/schemas/DKIMRecord' - DMARCRecord: - $ref: './schemas.yaml#/components/schemas/DMARCRecord' - BIMIRecord: - $ref: './schemas.yaml#/components/schemas/BIMIRecord' + type: object + required: + - score + - required_score + - is_spam + properties: + 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"] + report: + type: string + description: Full SpamAssassin report + + DNSRecord: + type: object + required: + - domain + - record_type + - status + properties: + domain: + type: string + description: Domain name + example: "example.com" + record_type: + type: string + enum: [MX, SPF, DKIM, DMARC] + description: DNS record type + example: "SPF" + status: + type: string + enum: [found, missing, invalid] + description: Record status + example: "found" + value: + type: string + description: Record value + example: "v=spf1 include:_spf.example.com ~all" + BlacklistCheck: - $ref: './schemas.yaml#/components/schemas/BlacklistCheck' + type: object + required: + - ip + - rbl + - listed + properties: + ip: + type: string + description: IP address checked + example: "192.0.2.1" + 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" + Status: - $ref: './schemas.yaml#/components/schemas/Status' + type: object + required: + - status + - version + properties: + status: + type: string + enum: [healthy, degraded, unhealthy] + description: Overall service status + example: "healthy" + version: + type: string + description: Service version + example: "0.1.0-dev" + components: + type: object + properties: + database: + type: string + enum: [up, down] + example: "up" + mta: + type: string + enum: [up, down] + example: "up" + uptime: + type: integer + description: Service uptime in seconds + example: 3600 + Error: - $ref: './schemas.yaml#/components/schemas/Error' - DomainTestRequest: - $ref: './schemas.yaml#/components/schemas/DomainTestRequest' - DomainTestResponse: - $ref: './schemas.yaml#/components/schemas/DomainTestResponse' - BlacklistCheckRequest: - $ref: './schemas.yaml#/components/schemas/BlacklistCheckRequest' - BlacklistCheckResponse: - $ref: './schemas.yaml#/components/schemas/BlacklistCheckResponse' - TestSummary: - $ref: './schemas.yaml#/components/schemas/TestSummary' - TestListResponse: - $ref: './schemas.yaml#/components/schemas/TestListResponse' + 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 diff --git a/api/schemas.yaml b/api/schemas.yaml deleted file mode 100644 index df0b416..0000000 --- a/api/schemas.yaml +++ /dev/null @@ -1,1173 +0,0 @@ -openapi: 3.0.3 -info: - title: happyDeliver Schemas - description: Shared schema definitions for happyDeliver - version: 0.1.0 - -paths: {} - -components: - schemas: - Test: - type: object - required: - - id - - email - - status - properties: - id: - type: string - pattern: '^[a-z0-9-]+$' - description: Unique test identifier (base32-encoded with hyphens) - example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" - email: - type: string - format: email - description: Unique test email address - example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" - status: - type: string - enum: [pending, analyzed] - description: Current test status (pending = no report yet, analyzed = report available) - example: "analyzed" - - TestResponse: - type: object - required: - - id - - email - - status - properties: - id: - type: string - pattern: '^[a-z0-9-]+$' - description: Unique test identifier (base32-encoded with hyphens) - example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" - email: - type: string - format: email - example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" - status: - type: string - enum: [pending] - example: "pending" - message: - type: string - example: "Send your test email to the address above" - - Report: - type: object - required: - - id - - test_id - - score - - grade - - created_at - properties: - id: - type: string - pattern: '^[a-z0-9-]+$' - description: Report identifier (base32-encoded with hyphens) - test_id: - type: string - pattern: '^[a-z0-9-]+$' - description: Associated test ID (base32-encoded with hyphens) - score: - type: integer - minimum: 0 - maximum: 100 - description: Overall deliverability score as percentage (0-100) - example: 85 - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - summary: - $ref: '#/components/schemas/ScoreSummary' - authentication: - $ref: '#/components/schemas/AuthenticationResults' - spamassassin: - $ref: '#/components/schemas/SpamAssassinResult' - rspamd: - $ref: '#/components/schemas/RspamdResult' - dns_results: - $ref: '#/components/schemas/DNSResults' - blacklists: - type: object - additionalProperties: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - description: Map of IP addresses to their blacklist check results (array of checks per IP) - example: - "192.0.2.1": - - rbl: "zen.spamhaus.org" - listed: false - - rbl: "bl.spamcop.net" - listed: false - whitelists: - type: object - additionalProperties: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - description: Map of IP addresses to their DNS whitelist check results (informational only) - example: - "192.0.2.1": - - rbl: "list.dnswl.org" - listed: false - - rbl: "swl.spamhaus.org" - listed: false - content_analysis: - $ref: '#/components/schemas/ContentAnalysis' - header_analysis: - $ref: '#/components/schemas/HeaderAnalysis' - raw_headers: - type: string - description: Raw email headers - created_at: - type: string - format: date-time - - ScoreSummary: - type: object - required: - - dns_score - - dns_grade - - authentication_score - - authentication_grade - - spam_score - - spam_grade - - blacklist_score - - blacklist_grade - - header_score - - header_grade - - content_score - - content_grade - properties: - dns_score: - type: integer - minimum: 0 - maximum: 100 - description: DNS records score (in percentage) - example: 42 - dns_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - authentication_score: - type: integer - minimum: 0 - maximum: 100 - description: SPF/DKIM/DMARC score (in percentage) - example: 28 - authentication_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - spam_score: - type: integer - minimum: 0 - maximum: 100 - description: Spam filter score (SpamAssassin + rspamd combined, in percentage) - example: 15 - spam_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - blacklist_score: - type: integer - minimum: 0 - maximum: 100 - description: Blacklist check score (in percentage) - example: 20 - blacklist_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - header_score: - type: integer - minimum: 0 - maximum: 100 - description: Header quality score (in percentage) - example: 9 - header_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - content_score: - type: integer - minimum: 0 - maximum: 100 - description: Content quality score (in percentage) - example: 18 - content_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - - ContentAnalysis: - type: object - properties: - has_html: - type: boolean - description: Whether email contains HTML part - example: true - has_plaintext: - type: boolean - description: Whether email contains plaintext part - example: true - html_issues: - type: array - items: - $ref: '#/components/schemas/ContentIssue' - description: Issues found in HTML content - links: - type: array - items: - $ref: '#/components/schemas/LinkCheck' - description: Analysis of links found in the email - images: - type: array - items: - $ref: '#/components/schemas/ImageCheck' - description: Analysis of images in the email - text_to_image_ratio: - type: number - format: float - description: Ratio of text to images (higher is better) - example: 0.75 - has_unsubscribe_link: - type: boolean - description: Whether email contains an unsubscribe link - example: true - unsubscribe_methods: - type: array - items: - type: string - enum: [link, mailto, list-unsubscribe-header, one-click] - description: Available unsubscribe methods - example: ["link", "list-unsubscribe-header"] - - ContentIssue: - type: object - required: - - type - - severity - - message - properties: - type: - type: string - enum: [broken_html, missing_alt, excessive_images, obfuscated_url, suspicious_link, dangerous_html] - description: Type of content issue - example: "missing_alt" - severity: - type: string - enum: [critical, high, medium, low, info] - description: Issue severity - example: "medium" - message: - type: string - description: Human-readable description - example: "3 images are missing alt attributes" - location: - type: string - description: Where the issue was found - example: "HTML body line 42" - advice: - type: string - description: How to fix this issue - example: "Add descriptive alt text to all images for better accessibility and deliverability" - - LinkCheck: - type: object - required: - - url - - status - properties: - url: - type: string - format: uri - description: The URL found in the email - example: "https://example.com/page" - status: - type: string - enum: [valid, broken, suspicious, redirected, timeout] - description: Link validation status - example: "valid" - http_code: - type: integer - description: HTTP status code received - example: 200 - redirect_chain: - type: array - items: - type: string - description: URLs in the redirect chain, if any - example: ["https://example.com", "https://www.example.com"] - is_shortened: - type: boolean - description: Whether this is a URL shortener - example: false - - ImageCheck: - type: object - required: - - has_alt - properties: - src: - type: string - description: Image source URL or path - example: "https://example.com/logo.png" - has_alt: - type: boolean - description: Whether image has alt attribute - example: true - alt_text: - type: string - description: Alt text content - example: "Company Logo" - is_tracking_pixel: - type: boolean - description: Whether this appears to be a tracking pixel (1x1 image) - example: false - - HeaderAnalysis: - type: object - properties: - has_mime_structure: - type: boolean - description: Whether body has a MIME structure - example: true - headers: - type: object - additionalProperties: - $ref: '#/components/schemas/HeaderCheck' - description: Map of header names to their check results (e.g., "from", "to", "dkim-signature") - example: - from: - present: true - value: "sender@example.com" - valid: true - importance: "required" - date: - present: true - value: "Mon, 1 Jan 2024 12:00:00 +0000" - valid: true - importance: "required" - received_chain: - type: array - items: - $ref: '#/components/schemas/ReceivedHop' - description: Chain of Received headers showing email path - domain_alignment: - $ref: '#/components/schemas/DomainAlignment' - issues: - type: array - items: - $ref: '#/components/schemas/HeaderIssue' - description: Issues found in headers - - HeaderCheck: - type: object - required: - - present - properties: - present: - type: boolean - description: Whether the header is present - example: true - value: - type: string - description: Header value - example: "sender@example.com" - valid: - type: boolean - description: Whether the value is valid/well-formed - example: true - importance: - type: string - enum: [required, recommended, optional, newsletter] - description: How important this header is for deliverability - example: "required" - issues: - type: array - items: - type: string - description: Any issues with this header - example: ["Invalid date format"] - - ReceivedHop: - type: object - properties: - from: - type: string - description: Sending server hostname - example: "mail.example.com" - by: - type: string - description: Receiving server hostname - example: "mx.receiver.com" - with: - type: string - description: Protocol used - example: "ESMTPS" - id: - type: string - description: Message ID at this hop - timestamp: - type: string - format: date-time - description: When this hop occurred - ip: - type: string - description: IP address of the sending server (IPv4 or IPv6) - example: "192.0.2.1" - reverse: - type: string - description: Reverse DNS (PTR record) for the IP address - example: "mail.example.com" - - DKIMDomainInfo: - type: object - required: - - domain - - org_domain - properties: - domain: - type: string - description: DKIM signature domain - example: "mail.example.com" - org_domain: - type: string - description: Organizational domain extracted from DKIM domain (using Public Suffix List) - example: "example.com" - - DomainAlignment: - type: object - properties: - from_domain: - type: string - description: Domain from From header - example: "example.com" - from_org_domain: - type: string - description: Organizational domain extracted from From header (using Public Suffix List) - example: "example.com" - return_path_domain: - type: string - description: Domain from Return-Path header - example: "example.com" - return_path_org_domain: - type: string - description: Organizational domain extracted from Return-Path header (using Public Suffix List) - example: "example.com" - dkim_domains: - type: array - items: - $ref: '#/components/schemas/DKIMDomainInfo' - description: Domains from DKIM signatures with their organizational domains - aligned: - type: boolean - description: Whether all domains align (strict alignment - exact match) - example: true - relaxed_aligned: - type: boolean - description: Whether domains satisfy relaxed alignment (organizational domain match) - example: true - issues: - type: array - items: - type: string - description: Alignment issues - example: ["Return-Path domain does not match From domain"] - - HeaderIssue: - type: object - required: - - header - - severity - - message - properties: - header: - type: string - description: Header name - example: "Date" - severity: - type: string - enum: [critical, high, medium, low, info] - description: Issue severity - example: "medium" - message: - type: string - description: Human-readable description - example: "Date header is in the future" - advice: - type: string - description: How to fix this issue - example: "Ensure your mail server clock is synchronized with NTP" - - AuthenticationResults: - type: object - properties: - spf: - $ref: '#/components/schemas/AuthResult' - dkim: - type: array - items: - $ref: '#/components/schemas/AuthResult' - dmarc: - $ref: '#/components/schemas/AuthResult' - bimi: - $ref: '#/components/schemas/AuthResult' - arc: - $ref: '#/components/schemas/ARCResult' - iprev: - $ref: '#/components/schemas/IPRevResult' - x_google_dkim: - $ref: '#/components/schemas/AuthResult' - description: Google-specific DKIM authentication result (x-google-dkim) - x_aligned_from: - $ref: '#/components/schemas/AuthResult' - description: X-Aligned-From authentication result (checks address alignment) - - AuthResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped] - description: Authentication result - example: "pass" - domain: - type: string - description: Domain being authenticated - example: "example.com" - selector: - type: string - description: DKIM selector (for DKIM only) - example: "default" - details: - type: string - description: Additional details about the result - - ARCResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, none] - description: Overall ARC chain validation result - example: "pass" - chain_valid: - type: boolean - description: Whether the ARC chain signatures are valid - example: true - chain_length: - type: integer - description: Number of ARC sets in the chain - example: 2 - details: - type: string - description: Additional details about ARC validation - example: "ARC chain valid with 2 intermediaries" - - IPRevResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, temperror, permerror] - description: IP reverse DNS lookup result - example: "pass" - ip: - type: string - description: IP address that was checked - example: "195.110.101.58" - hostname: - type: string - description: Hostname from reverse DNS lookup (PTR record) - example: "authsmtp74.register.it" - details: - type: string - description: Additional details about the IP reverse lookup - example: "smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)" - - SpamAssassinResult: - type: object - required: - - score - - required_score - - is_spam - - test_details - properties: - deliverability_score: - type: integer - minimum: 0 - maximum: 100 - description: SpamAssassin deliverability score (0-100, higher is better) - example: 80 - deliverability_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade for SpamAssassin deliverability score - example: "B" - version: - type: string - description: SpamAssassin version - example: "SpamAssassin 4.0.1" - score: - type: number - format: float - description: SpamAssassin spam score - example: 2.3 - required_score: - type: number - format: float - description: Threshold for spam classification - example: 5.0 - is_spam: - type: boolean - description: Whether message is classified as spam - example: false - tests: - type: array - items: - type: string - description: List of triggered SpamAssassin tests - example: ["BAYES_00", "DKIM_SIGNED"] - test_details: - type: object - additionalProperties: - $ref: '#/components/schemas/SpamTestDetail' - description: Map of test names to their detailed results - example: - BAYES_00: - name: "BAYES_00" - score: -1.9 - description: "Bayes spam probability is 0 to 1%" - DKIM_SIGNED: - name: "DKIM_SIGNED" - score: 0.1 - description: "Message has a DKIM or DK signature, not necessarily valid" - report: - type: string - description: Full SpamAssassin report - - SpamTestDetail: - type: object - required: - - name - - score - properties: - name: - type: string - description: Test name - example: "BAYES_00" - score: - type: number - format: float - description: Score contribution of this test - example: -1.9 - params: - type: string - description: Symbol parameters or options - example: "0.02" - description: - type: string - description: Human-readable description of what this test checks - example: "Bayes spam probability is 0 to 1%" - - RspamdResult: - type: object - required: - - score - - threshold - - is_spam - - symbols - properties: - deliverability_score: - type: integer - minimum: 0 - maximum: 100 - description: rspamd deliverability score (0-100, higher is better) - example: 85 - deliverability_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade for rspamd deliverability score - example: "A" - score: - type: number - format: float - description: rspamd spam score - example: -3.91 - threshold: - type: number - format: float - description: Score threshold for spam classification - example: 15.0 - action: - type: string - description: rspamd action (no action, add header, rewrite subject, soft reject, reject) - example: "no action" - is_spam: - type: boolean - description: Whether message is classified as spam (action is reject or soft reject) - example: false - server: - type: string - description: rspamd server that processed the message - example: "rspamd.example.com" - symbols: - type: object - additionalProperties: - $ref: '#/components/schemas/SpamTestDetail' - description: Map of triggered rspamd symbols to their details - example: - BAYES_HAM: - name: "BAYES_HAM" - score: -1.9 - params: "0.02" - report: - type: string - description: Full rspamd report (raw X-Spamd-Result header) - - - DNSResults: - type: object - required: - - from_domain - properties: - from_domain: - type: string - description: From Domain name - example: "example.com" - rp_domain: - type: string - description: Return Path Domain name - example: "example.com" - from_mx_records: - type: array - items: - $ref: '#/components/schemas/MXRecord' - description: MX records for the From domain - rp_mx_records: - type: array - items: - $ref: '#/components/schemas/MXRecord' - description: MX records for the Return-Path domain - spf_records: - type: array - items: - $ref: '#/components/schemas/SPFRecord' - description: SPF records found (includes resolved include directives) - dkim_records: - type: array - items: - $ref: '#/components/schemas/DKIMRecord' - description: DKIM records found - dmarc_record: - $ref: '#/components/schemas/DMARCRecord' - bimi_record: - $ref: '#/components/schemas/BIMIRecord' - ptr_records: - type: array - items: - type: string - description: PTR (reverse DNS) records for the sender IP address - example: ["mail.example.com", "smtp.example.com"] - ptr_forward_records: - type: array - items: - type: string - description: A or AAAA records resolved from the PTR hostnames (forward confirmation) - example: ["192.0.2.1", "2001:db8::1"] - errors: - type: array - items: - type: string - description: DNS lookup errors - - MXRecord: - type: object - required: - - host - - priority - - valid - properties: - host: - type: string - description: MX hostname - example: "mail.example.com" - priority: - type: integer - format: uint16 - description: MX priority (lower is higher priority) - example: 10 - valid: - type: boolean - description: Whether the MX record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "Failed to lookup MX records" - - SPFRecord: - type: object - required: - - valid - properties: - domain: - type: string - description: Domain this SPF record belongs to - example: "example.com" - record: - type: string - description: SPF record content - example: "v=spf1 include:_spf.example.com ~all" - valid: - type: boolean - description: Whether the SPF record is valid - example: true - all_qualifier: - type: string - enum: ["+", "-", "~", "?"] - description: "Qualifier for the 'all' mechanism: + (pass), - (fail), ~ (softfail), ? (neutral)" - example: "~" - error: - type: string - description: Error message if validation failed - example: "No SPF record found" - - DKIMRecord: - type: object - required: - - selector - - domain - - valid - properties: - selector: - type: string - description: DKIM selector - example: "default" - domain: - type: string - description: Domain name - example: "example.com" - record: - type: string - description: DKIM record content - example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..." - valid: - type: boolean - description: Whether the DKIM record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "No DKIM record found" - - DMARCRecord: - type: object - required: - - valid - properties: - record: - type: string - description: DMARC record content - example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" - policy: - type: string - enum: [none, quarantine, reject, unknown] - description: DMARC policy - example: "quarantine" - subdomain_policy: - type: string - enum: [none, quarantine, reject, unknown] - description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy - example: "quarantine" - percentage: - type: integer - minimum: 0 - maximum: 100 - description: Percentage of messages subjected to filtering (pct tag, default 100) - example: 100 - spf_alignment: - type: string - enum: [relaxed, strict] - description: SPF alignment mode (aspf tag) - example: "relaxed" - dkim_alignment: - type: string - enum: [relaxed, strict] - description: DKIM alignment mode (adkim tag) - example: "relaxed" - valid: - type: boolean - description: Whether the DMARC record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "No DMARC record found" - - BIMIRecord: - type: object - required: - - selector - - domain - - valid - properties: - selector: - type: string - description: BIMI selector - example: "default" - domain: - type: string - description: Domain name - example: "example.com" - record: - type: string - description: BIMI record content - example: "v=BIMI1; l=https://example.com/logo.svg" - logo_url: - type: string - format: uri - description: URL to the brand logo (SVG) - example: "https://example.com/logo.svg" - vmc_url: - type: string - format: uri - description: URL to Verified Mark Certificate (optional) - example: "https://example.com/vmc.pem" - valid: - type: boolean - description: Whether the BIMI record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "No BIMI record found" - - BlacklistCheck: - type: object - required: - - rbl - - listed - properties: - rbl: - type: string - description: RBL/DNSBL name - example: "zen.spamhaus.org" - listed: - type: boolean - description: Whether IP is listed - example: false - response: - type: string - description: RBL response code or message - example: "127.0.0.2" - error: - type: string - description: RBL error if any - - Status: - type: object - required: - - status - - version - properties: - status: - type: string - enum: [healthy, degraded, unhealthy] - description: Overall service status - example: "healthy" - version: - type: string - description: Service version - example: "0.1.0-dev" - components: - type: object - properties: - database: - type: string - enum: [up, down] - example: "up" - mta: - type: string - enum: [up, down] - example: "up" - uptime: - type: integer - description: Service uptime in seconds - example: 3600 - - Error: - type: object - required: - - error - - message - properties: - error: - type: string - description: Error code - example: "not_found" - message: - type: string - description: Human-readable error message - example: "Test not found" - details: - type: string - description: Additional error details - - DomainTestRequest: - type: object - required: - - domain - properties: - domain: - type: string - pattern: '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' - description: Domain name to test (e.g., example.com) - example: "example.com" - - DomainTestResponse: - type: object - required: - - domain - - score - - grade - - dns_results - properties: - domain: - type: string - description: The tested domain name - example: "example.com" - score: - type: integer - minimum: 0 - maximum: 100 - description: Overall domain configuration score (0-100) - example: 85 - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score - example: "A" - dns_results: - $ref: '#/components/schemas/DNSResults' - - BlacklistCheckRequest: - type: object - required: - - ip - properties: - ip: - type: string - description: IPv4 or IPv6 address to check against blacklists - example: "192.0.2.1" - pattern: '^([0-9]{1,3}\.){3}[0-9]{1,3}$|^([0-9a-fA-F]{0,4}:){7}[0-9a-fA-F]{0,4}$|^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$|^([0-9a-fA-F]{0,4}:){1,6}:([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$' - - BlacklistCheckResponse: - type: object - required: - - ip - - blacklists - - listed_count - - score - - grade - properties: - ip: - type: string - description: The IP address that was checked - example: "192.0.2.1" - blacklists: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - description: List of blacklist check results - listed_count: - type: integer - description: Number of blacklists that have this IP listed - example: 0 - score: - type: integer - minimum: 0 - maximum: 100 - description: Blacklist score (0-100, higher is better) - example: 100 - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score - example: "A+" - whitelists: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - description: List of DNS whitelist check results (informational only) - - TestSummary: - type: object - required: - - test_id - - score - - grade - - created_at - properties: - test_id: - type: string - pattern: '^[a-z0-9-]+$' - description: Test identifier (base32-encoded with hyphens) - score: - type: integer - minimum: 0 - maximum: 100 - description: Overall deliverability score (0-100) - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade - from_domain: - type: string - description: Sender domain extracted from the report - created_at: - type: string - format: date-time - - TestListResponse: - type: object - required: - - tests - - total - - offset - - limit - properties: - tests: - type: array - items: - $ref: '#/components/schemas/TestSummary' - total: - type: integer - description: Total number of tests - offset: - type: integer - description: Current offset - limit: - type: integer - description: Current limit diff --git a/banner.webp b/banner.webp deleted file mode 100644 index 8ed7da1..0000000 Binary files a/banner.webp and /dev/null differ diff --git a/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go index 3caf4d1..01d99f1 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -29,12 +29,13 @@ import ( "git.happydns.org/happyDeliver/internal/app" "git.happydns.org/happyDeliver/internal/config" - "git.happydns.org/happyDeliver/internal/version" ) +const version = "0.1.0-dev" + func main() { - fmt.Fprintln(os.Stderr, "happyDeliver - Email Deliverability Testing Platform") - fmt.Fprintf(os.Stderr, "Version: %s\n", version.Version) + fmt.Println("happyDeliver - Email Deliverability Testing Platform") + fmt.Printf("Version: %s\n", version) cfg, err := config.ConsolidateConfig() if err != nil { @@ -52,20 +53,8 @@ func main() { if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil { log.Fatalf("Analyzer error: %v", err) } - case "backup": - if err := app.RunBackup(cfg); err != nil { - log.Fatalf("Backup error: %v", err) - } - case "restore": - inputFile := "" - if len(flag.Args()) >= 2 { - inputFile = flag.Args()[1] - } - if err := app.RunRestore(cfg, inputFile); err != nil { - log.Fatalf("Restore error: %v", err) - } case "version": - fmt.Println(version.Version) + fmt.Println(version) default: fmt.Printf("Unknown command: %s\n", command) printUsage() @@ -75,11 +64,9 @@ func main() { func printUsage() { fmt.Println("\nCommand availables:") - fmt.Println(" happyDeliver server - Start the API server") - fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal") - fmt.Println(" happyDeliver backup - Backup database to stdout as JSON") - fmt.Println(" happyDeliver restore [file] - Restore database from JSON file or stdin") - fmt.Println(" happyDeliver version - Print version information") + fmt.Println(" happyDeliver server - Start the API server") + fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal") + fmt.Println(" happyDeliver version - Print version information") fmt.Println("") flag.Usage() } diff --git a/docker-compose.yml b/docker-compose.yml index ccfd313..4ba64c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,14 +3,14 @@ services: build: context: . dockerfile: Dockerfile - image: happydomain/happydeliver:latest + image: happydeliver:latest container_name: happydeliver - # Set a hostname hostname: mail.happydeliver.local environment: - # Set your domain - HAPPYDELIVER_DOMAIN: happydeliver.local + # Set your domain and hostname + DOMAIN: happydeliver.local + HOSTNAME: mail.happydeliver.local ports: # SMTP port @@ -23,9 +23,18 @@ services: - ./data:/var/lib/happydeliver # Log files - ./logs:/var/log/happydeliver + # Optional: Override config + # - ./custom-config.yaml:/etc/happydeliver/config.yaml restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/api/status"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + volumes: data: logs: diff --git a/docker/README.md b/docker/README.md index 2199eeb..45cce6b 100644 --- a/docker/README.md +++ b/docker/README.md @@ -109,37 +109,12 @@ Default configuration for the Docker environment: The container accepts these environment variables: -- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local) -- `HAPPYDELIVER_RECEIVER_HOSTNAME`: Hostname used to filter `Authentication-Results` headers (see below) -- `POSTFIX_CERT_FILE` / `POSTFIX_KEY_FILE`: TLS certificate and key paths for Postfix SMTP - -### Receiver Hostname - -happyDeliver filters `Authentication-Results` headers by hostname to only trust results from the expected MTA. By default, it uses the system hostname (i.e., the container's `--hostname`). - -In the all-in-one Docker container, the container hostname is also used as the `authserv-id` in the embedded Postfix and authentication_milter, so everything matches automatically. - -**When bypassing the embedded Postfix** (e.g., routing emails from your own MTA via LMTP), your MTA's `authserv-id` will likely differ from the container hostname. In that case, set `HAPPYDELIVER_RECEIVER_HOSTNAME` to your MTA's hostname: +- `DOMAIN`: Email domain for test addresses (default: happydeliver.local) +- `HOSTNAME`: Container hostname (default: mail.happydeliver.local) +Example: ```bash -docker run -d \ - -e HAPPYDELIVER_DOMAIN=example.com \ - -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com \ - ... -``` - -To find the correct value, look at the `Authentication-Results` headers in a received email — they start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...`. - -If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname. - -Example (all-in-one, no override needed): -```bash -docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ... -``` - -Example (external MTA integration): -```bash -docker run -e HAPPYDELIVER_DOMAIN=example.com -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ... +docker run -e DOMAIN=example.com -e HOSTNAME=mail.example.com ... ``` ## Volumes diff --git a/docker/authentication_milter/authentication_milter.json b/docker/authentication_milter/authentication_milter.json deleted file mode 100644 index 5db3bbc..0000000 --- a/docker/authentication_milter/authentication_milter.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "logtoerr" : "1", - "error_log" : "", - "connection" : "unix:/var/spool/postfix/authentication_milter/authentication_milter.sock", - "umask" : "0007", - "runas" : "mail", - "rungroup" : "mail", - "authserv_id" : "__HOSTNAME__", - - "connect_timeout" : 30, - "command_timeout" : 30, - "content_timeout" : 300, - "dns_timeout" : 10, - "dns_retry" : 2, - - "handlers" : { - - "Sanitize" : { - "hosts_to_remove" : [ - "__HOSTNAME__" - ], - "extra_auth_results_types" : [ - "X-Spam-Status", - "X-Spam-Report", - "X-Spam-Level", - "X-Spam-Checker-Version" - ] - }, - - "SPF" : { - "hide_none" : 0 - }, - - "DKIM" : { - "hide_none" : 0, - }, - - "XGoogleDKIM" : { - "hide_none" : 1, - }, - - "ARC" : { - "hide_none" : 0, - }, - - "DMARC" : { - "hide_none" : 0, - "detect_list_id" : "1" - }, - - "BIMI" : {}, - - "PTR" : {}, - - "SenderID" : { - "hide_none" : 1 - }, - - "IPRev" : {}, - - "Auth" : {}, - - "AlignedFrom" : {}, - - "LocalIP" : {}, - - "TrustedIP" : { - "trusted_ip_list" : [] - }, - - "!AddID" : {}, - - "ReturnOK" : {} - } -} diff --git a/docker/authentication_milter/mail-dmarc.ini b/docker/authentication_milter/mail-dmarc.ini deleted file mode 100644 index 8097ac6..0000000 --- a/docker/authentication_milter/mail-dmarc.ini +++ /dev/null @@ -1,58 +0,0 @@ -; This is YOU. DMARC reports include information about the reports. Enter it here. -[organization] -domain = example.com -org_name = My Company Limited -email = admin@example.com -extra_contact_info = http://example.com - -; aggregate DMARC reports need to be stored somewhere. Any database -; with a DBI module (MySQL, SQLite, DBD, etc.) should work. -; SQLite and MySQL are tested. -; Default is sqlite. -[report_store] -backend = SQL -;dsn = dbi:SQLite:dbname=dmarc_reports.sqlite -dsn = dbi:mysql:database=dmarc_reporting_database;host=localhost;port=3306 -user = authmilterusername -pass = authmiltpassword - -; backend can be perl or libopendmarc -[dmarc] -backend = perl - -[dns] -timeout = 5 -public_suffix_list = share/public_suffix_list - -[smtp] -; hostname is the external FQDN of this MTA -hostname = mx1.example.com -cc = dmarc.copy@example.com - -; list IP addresses to whitelist (bypass DMARC reject/quarantine) -; see sample whitelist in share/dmarc_whitelist -whitelist = /path/to/etc/dmarc_whitelist - -; By default, we attempt to email directly to the report recipient. -; Set these to relay via a SMTP smart host. -smarthost = mx2.example.com -smartuser = dmarccopyusername -smartpass = dmarccopypassword - -[imap] -server = mail.example.com -user = -pass = -; the imap folder where new dmarc messages will be found -folder = dmarc -; the folders to store processed reports (a=aggregate, f=forensic) -f_done = dmarc.forensic -a_done = dmarc.aggregate - -[http] -port = 8080 - -[https] -port = 8443 -ssl_crt = -ssl_key = \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ef45b61..445602d 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -4,42 +4,34 @@ set -e echo "Starting happyDeliver container..." # Get environment variables with defaults -[ -n "${HOSTNAME}" ] || HOSTNAME=$(hostname) +HOSTNAME="${HOSTNAME:-mail.happydeliver.local}" HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}" echo "Hostname: $HOSTNAME" echo "Domain: $HAPPYDELIVER_DOMAIN" -# Create socket directories -mkdir -p /var/spool/postfix/authentication_milter -chown mail:mail /var/spool/postfix/authentication_milter -chmod 750 /var/spool/postfix/authentication_milter +# Create runtime directories +mkdir -p /var/run/opendkim /var/run/opendmarc +chown opendkim:postfix /var/run/opendkim +chown opendmarc:postfix /var/run/opendmarc -mkdir -p /var/spool/postfix/rspamd -chown rspamd:mail /var/spool/postfix/rspamd -chmod 750 /var/spool/postfix/rspamd +# Create socket directories +mkdir -p /var/spool/postfix/opendkim /var/spool/postfix/opendmarc +chown opendkim:postfix /var/spool/postfix/opendkim +chown opendmarc:postfix /var/spool/postfix/opendmarc +chmod 750 /var/spool/postfix/opendkim /var/spool/postfix/opendmarc # Create log directory -mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter +mkdir -p /var/log/happydeliver chown happydeliver:happydeliver /var/log/happydeliver -chown mail:mail /var/cache/authentication_milter /run/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter # Replace placeholders in Postfix configuration echo "Configuring Postfix..." sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/g" /etc/postfix/main.cf -# Add certificates to postfix -[ -n "${POSTFIX_CERT_FILE}" ] && [ -n "${POSTFIX_KEY_FILE}" ] && { - cat <> /etc/postfix/main.cf -smtpd_tls_cert_file = ${POSTFIX_CERT_FILE} -smtpd_tls_key_file = ${POSTFIX_KEY_FILE} -smtpd_tls_security_level = may -EOF -} - -# Replace placeholders in configurations -sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/authentication_milter.json +# Replace placeholders in OpenDMARC configuration +sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/opendmarc/opendmarc.conf # Initialize Postfix aliases if [ -f /etc/postfix/aliases ]; then diff --git a/docker/opendkim/opendkim.conf b/docker/opendkim/opendkim.conf new file mode 100644 index 0000000..8fe2f8c --- /dev/null +++ b/docker/opendkim/opendkim.conf @@ -0,0 +1,39 @@ +# OpenDKIM configuration for happyDeliver +# Verifies DKIM signatures on incoming emails + +# Log to syslog +Syslog yes +SyslogSuccess yes +LogWhy yes + +# Run as this user and group +UserID opendkim:mail + +UMask 002 + +# Socket for Postfix communication +Socket unix:/var/spool/postfix/opendkim/opendkim.sock + +# Process ID file +PidFile /var/run/opendkim/opendkim.pid + +# Operating mode - verify only (not signing) +Mode v + +# Canonicalization methods +Canonicalization relaxed/simple + +# DNS timeout +DNSTimeout 5 + +# Add header for verification results +AlwaysAddARHeader yes + +# Accept unsigned mail +On-NoSignature accept + +# Always add Authentication-Results header +AlwaysAddARHeader yes + +# Maximum verification attempts +MaximumSignaturesToVerify 3 diff --git a/docker/opendmarc/opendmarc.conf b/docker/opendmarc/opendmarc.conf new file mode 100644 index 0000000..882e11c --- /dev/null +++ b/docker/opendmarc/opendmarc.conf @@ -0,0 +1,41 @@ +# OpenDMARC configuration for happyDeliver +# Verifies DMARC policies on incoming emails + +# Socket for Postfix communication +Socket unix:/var/spool/postfix/opendmarc/opendmarc.sock + +# Process ID file +PidFile /var/run/opendmarc/opendmarc.pid + +# Run as this user and group +UserID opendmarc:mail + +UMask 002 + +# Syslog configuration +Syslog true +SyslogFacility mail + +# Ignore authentication results from other hosts +IgnoreAuthenticatedClients true + +# Accept mail even if DMARC fails (we're analyzing, not filtering) +RejectFailures false + +# Trust Authentication-Results headers from localhost only +TrustedAuthservIDs __HOSTNAME__ + +# Add DMARC results to Authentication-Results header +#AddAuthenticationResults true + +# DNS timeout +DNSTimeout 5 + +# History file (for reporting) +# HistoryFile /var/spool/opendmarc/opendmarc.dat + +# Ignore hosts file +# IgnoreHosts /etc/opendmarc/ignore.hosts + +# Public suffix list +# PublicSuffixList /usr/share/publicsuffix/public_suffix_list.dat diff --git a/docker/postfix/main.cf b/docker/postfix/main.cf index 5a73fb3..913eb57 100644 --- a/docker/postfix/main.cf +++ b/docker/postfix/main.cf @@ -10,7 +10,7 @@ inet_interfaces = all inet_protocols = ipv4 # Recipient settings -mydestination = localhost.$mydomain, localhost +mydestination = $myhostname, localhost.$mydomain, localhost mynetworks = 127.0.0.0/8 [::1]/128 # Relay settings - accept mail for our test domain @@ -28,13 +28,14 @@ transport_maps = pcre:/etc/postfix/transport_maps # OpenDKIM for DKIM verification milter_default_action = accept milter_protocol = 6 -smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock unix:/var/spool/postfix/rspamd/rspamd-milter.sock +smtpd_milters = unix:/var/spool/postfix/opendkim/opendkim.sock, unix:/var/spool/postfix/opendmarc/opendmarc.sock non_smtpd_milters = $smtpd_milters # SPF policy checking smtpd_recipient_restrictions = permit_mynetworks, - reject_unauth_destination + reject_unauth_destination, + check_policy_service unix:private/policy-spf # Logging debug_peer_level = 2 diff --git a/docker/postfix/master.cf b/docker/postfix/master.cf index 9c2ac57..92976a4 100644 --- a/docker/postfix/master.cf +++ b/docker/postfix/master.cf @@ -2,6 +2,7 @@ # SMTP service smtp inet n - n - - smtpd + -o content_filter=spamassassin # Pickup service pickup unix n - n 60 1 pickup @@ -73,6 +74,10 @@ scache unix - - n - 1 scache maildrop unix - n n - - pipe flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient} +# SPF policy service +policy-spf unix - n n - 0 spawn + user=nobody argv=/usr/bin/postfix-policyd-spf-perl + # SpamAssassin content filter spamassassin unix - n n - - pipe user=mail argv=/usr/bin/spamc -f -e /usr/sbin/sendmail -oi -f ${sender} ${recipient} diff --git a/docker/postfix/transport_maps b/docker/postfix/transport_maps index cc1deed..49fdb98 100644 --- a/docker/postfix/transport_maps +++ b/docker/postfix/transport_maps @@ -1,4 +1,4 @@ # Transport map - route test emails to happyDeliver LMTP server -# Pattern: test-@domain.com -> LMTP on localhost:2525 +# Pattern: test-@domain.com -> LMTP on localhost:2525 -/^test-[a-zA-Z2-7-]{26,30}@.*$/ lmtp:inet:127.0.0.1:2525 +/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@.*$/ lmtp:inet:127.0.0.1:2525 diff --git a/docker/rspamd/local.d/actions.conf b/docker/rspamd/local.d/actions.conf deleted file mode 100644 index f3ed60c..0000000 --- a/docker/rspamd/local.d/actions.conf +++ /dev/null @@ -1,5 +0,0 @@ -no_action = 0; -reject = null; -add_header = null; -rewrite_subject = null; -greylist = null; \ No newline at end of file diff --git a/docker/rspamd/local.d/milter_headers.conf b/docker/rspamd/local.d/milter_headers.conf deleted file mode 100644 index 378b8a3..0000000 --- a/docker/rspamd/local.d/milter_headers.conf +++ /dev/null @@ -1,5 +0,0 @@ -# Add "extended Rspamd headers" -extended_spam_headers = true; - -skip_local = false; -skip_authenticated = false; \ No newline at end of file diff --git a/docker/rspamd/local.d/options.inc b/docker/rspamd/local.d/options.inc deleted file mode 100644 index 485d0c9..0000000 --- a/docker/rspamd/local.d/options.inc +++ /dev/null @@ -1,3 +0,0 @@ -# rspamd options for happyDeliver -# Disable Bayes learning to keep the setup stateless -use_redis = false; diff --git a/docker/rspamd/local.d/worker-proxy.inc b/docker/rspamd/local.d/worker-proxy.inc deleted file mode 100644 index 04c9a1d..0000000 --- a/docker/rspamd/local.d/worker-proxy.inc +++ /dev/null @@ -1,6 +0,0 @@ -# Enable rspamd milter proxy worker via Unix socket for Postfix integration -bind_socket = "/var/spool/postfix/rspamd/rspamd-milter.sock mode=0660 owner=rspamd group=mail"; -upstream "local" { - default = yes; - self_scan = yes; -} diff --git a/docker/spamassassin/local.cf b/docker/spamassassin/local.cf index ce9a31c..c248ef6 100644 --- a/docker/spamassassin/local.cf +++ b/docker/spamassassin/local.cf @@ -48,14 +48,3 @@ rbl_timeout 5 # Don't use user-specific rules user_scores_dsn_timeout 3 user_scores_sql_override 0 - -# Disable Validity network rules -dns_query_restriction deny sa-trusted.bondedsender.org -dns_query_restriction deny sa-accredit.habeas.com -dns_query_restriction deny bl.score.senderscore.com -score RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0 -score RCVD_IN_VALIDITY_RPBL_BLOCKED 0 -score RCVD_IN_VALIDITY_SAFE_BLOCKED 0 -score RCVD_IN_VALIDITY_CERTIFIED 0 -score RCVD_IN_VALIDITY_RPBL 0 -score RCVD_IN_VALIDITY_SAFE 0 \ No newline at end of file diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf index 74f1810..1a0666e 100644 --- a/docker/supervisor/supervisord.conf +++ b/docker/supervisor/supervisord.conf @@ -22,26 +22,27 @@ autostart=true autorestart=true priority=9 -# Authentication Milter service -[program:authentication_milter] -command=/usr/local/bin/authentication_milter --pidfile /run/authentication_milter/authentication_milter.pid +# OpenDKIM service +[program:opendkim] +command=/usr/sbin/opendkim -f -x /etc/opendkim/opendkim.conf autostart=true autorestart=true priority=10 -stdout_logfile=/var/log/happydeliver/authentication_milter.log -stderr_logfile=/var/log/happydeliver/authentication_milter.log -user=mail +stdout_logfile=/var/log/happydeliver/opendkim.log +stderr_logfile=/var/log/happydeliver/opendkim_error.log +user=opendkim group=mail -# rspamd spam filter -[program:rspamd] -command=/usr/bin/rspamd -f -u rspamd -g mail +# OpenDMARC service +[program:opendmarc] +command=/usr/sbin/opendmarc -f -c /etc/opendmarc/opendmarc.conf autostart=true autorestart=true priority=11 -stdout_logfile=/var/log/happydeliver/rspamd.log -stderr_logfile=/var/log/happydeliver/rspamd_error.log -user=root +stdout_logfile=/var/log/happydeliver/opendmarc.log +stderr_logfile=/var/log/happydeliver/opendmarc_error.log +user=opendmarc +group=mail # SpamAssassin daemon [program:spamd] @@ -53,18 +54,6 @@ stdout_logfile=/var/log/happydeliver/spamd.log stderr_logfile=/var/log/happydeliver/spamd_error.log user=root -# SpamAssassin milter -[program:spamass_milter] -command=/usr/local/sbin/spamass-milter -p /var/spool/postfix/spamassassin/spamass-milter.sock -m -autostart=true -autorestart=true -priority=7 -stdout_logfile=/var/log/happydeliver/spamass-milter.log -stderr_logfile=/var/log/happydeliver/spamass-milter_error.log -user=mail -group=mail -umask=007 - # Postfix service [program:postfix] command=/usr/sbin/postfix start-fg diff --git a/generate.go b/generate.go index 324c52c..d1ee5ab 100644 --- a/generate.go +++ b/generate.go @@ -21,5 +21,5 @@ package main -//go:generate go tool oapi-codegen -config api/config-models.yaml api/schemas.yaml +//go:generate go tool oapi-codegen -config api/config-models.yaml api/openapi.yaml //go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml diff --git a/go.mod b/go.mod index bcf45d7..e51b1d5 100644 --- a/go.mod +++ b/go.mod @@ -1,42 +1,37 @@ module git.happydns.org/happyDeliver -go 1.25.0 +go 1.24.6 require ( - github.com/JGLTechnologies/gin-rate-limit v1.5.8 github.com/emersion/go-smtp v0.24.0 - github.com/getkin/kin-openapi v0.135.0 - github.com/gin-gonic/gin v1.12.0 + github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 - github.com/oapi-codegen/runtime v1.3.0 - golang.org/x/net v0.53.0 + github.com/oapi-codegen/runtime v1.1.2 + golang.org/x/net v0.45.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 - gorm.io/gorm v1.31.1 + gorm.io/gorm v1.31.0 ) require ( - github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic v1.15.0 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect - github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/getkin/kin-openapi v0.132.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/go-openapi/jsonpointer v0.22.4 // indirect - github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.30.1 // indirect - github.com/goccy/go-json v0.10.5 // indirect - github.com/goccy/go-yaml v1.19.2 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -44,37 +39,34 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mailru/easyjson v0.9.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.33 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 // indirect - github.com/oasdiff/yaml v0.0.9 // indirect - github.com/oasdiff/yaml3 v0.0.9 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.0 // indirect - github.com/redis/go-redis/v9 v9.18.0 // indirect - github.com/speakeasy-api/jsonpath v0.6.3 // indirect - github.com/speakeasy-api/openapi v1.19.2 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.1 // indirect + github.com/speakeasy-api/jsonpath v0.6.0 // indirect + github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect - github.com/woodsbury/decimal128 v1.4.0 // indirect - go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect - go.uber.org/atomic v1.11.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/arch v0.23.0 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect - golang.org/x/tools v0.43.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/mod v0.28.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/tools v0.37.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 872377c..939e263 100644 --- a/go.sum +++ b/go.sum @@ -1,32 +1,15 @@ -github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0= -github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g= -github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= -github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= -github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= -github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= -github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= @@ -37,35 +20,33 @@ github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4Mc github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= -github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg= -github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk= +github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= -github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= -github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= -github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= -github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= -github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= -github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= -github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= -github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= -github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -91,8 +72,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= -github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -103,7 +84,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -115,12 +95,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= -github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= -github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -131,14 +111,14 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 h1:/8daqIYZfwnsHEAZdHUu9m0D5LA+5DoJCP7zLlT5Cs0= -github.com/oapi-codegen/oapi-codegen/v2 v2.7.0/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw= -github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA= -github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY= -github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= -github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= -github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g= -github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU= +github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w= +github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= @@ -155,67 +135,51 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= -github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= -github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= -github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= -github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg= +github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= -github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= -github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M= -github.com/speakeasy-api/openapi v1.19.2/go.mod h1:UfKa7FqE4jgexJZuj51MmdHAFGmDv0Zaw3+yOd81YKU= -github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8= +github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= +github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= +github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= -github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= -github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= -github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= -go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= -go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= -golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -223,13 +187,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -245,21 +209,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -272,8 +236,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -295,5 +259,5 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= -gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= -gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= +gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go new file mode 100644 index 0000000..3588280 --- /dev/null +++ b/internal/analyzer/analyzer.go @@ -0,0 +1,87 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "bytes" + "fmt" + + "github.com/google/uuid" + + "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/config" +) + +// EmailAnalyzer provides high-level email analysis functionality +// This is the main entry point for analyzing emails from both LMTP and CLI +type EmailAnalyzer struct { + generator *ReportGenerator +} + +// NewEmailAnalyzer creates a new email analyzer with the given configuration +func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { + generator := NewReportGenerator( + cfg.Analysis.DNSTimeout, + cfg.Analysis.HTTPTimeout, + cfg.Analysis.RBLs, + ) + + return &EmailAnalyzer{ + generator: generator, + } +} + +// AnalysisResult contains the complete analysis result +type AnalysisResult struct { + Email *EmailMessage + Results *AnalysisResults + Report *api.Report +} + +// AnalyzeEmailBytes performs complete email analysis from raw bytes +func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*AnalysisResult, error) { + // Parse the email + emailMsg, err := ParseEmail(bytes.NewReader(rawEmail)) + if err != nil { + return nil, fmt.Errorf("failed to parse email: %w", err) + } + + // Analyze the email + results := a.generator.AnalyzeEmail(emailMsg) + + // Generate the report + report := a.generator.GenerateReport(testID, results) + + return &AnalysisResult{ + Email: emailMsg, + Results: results, + Report: report, + }, nil +} + +// GetScoreSummaryText returns a human-readable score summary +func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string { + if result == nil || result.Results == nil { + return "" + } + return a.generator.GetScoreSummaryText(result.Results) +} diff --git a/internal/analyzer/authentication.go b/internal/analyzer/authentication.go new file mode 100644 index 0000000..45df0a3 --- /dev/null +++ b/internal/analyzer/authentication.go @@ -0,0 +1,511 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "fmt" + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// AuthenticationAnalyzer analyzes email authentication results +type AuthenticationAnalyzer struct{} + +// NewAuthenticationAnalyzer creates a new authentication analyzer +func NewAuthenticationAnalyzer() *AuthenticationAnalyzer { + return &AuthenticationAnalyzer{} +} + +// AnalyzeAuthentication extracts and analyzes authentication results from email headers +func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults { + results := &api.AuthenticationResults{} + + // Parse Authentication-Results headers + authHeaders := email.GetAuthenticationResults() + for _, header := range authHeaders { + a.parseAuthenticationResultsHeader(header, results) + } + + // If no Authentication-Results headers, try to parse legacy headers + if results.Spf == nil { + results.Spf = a.parseLegacySPF(email) + } + + if results.Dkim == nil || len(*results.Dkim) == 0 { + dkimResults := a.parseLegacyDKIM(email) + if len(dkimResults) > 0 { + results.Dkim = &dkimResults + } + } + + return results +} + +// 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) { + // Split by semicolon to get individual results + parts := strings.Split(header, ";") + if len(parts) < 2 { + return + } + + // Skip the authserv-id (first part) + for i := 1; i < len(parts); i++ { + part := strings.TrimSpace(parts[i]) + if part == "" { + continue + } + + // Parse SPF + if strings.HasPrefix(part, "spf=") { + if results.Spf == nil { + results.Spf = a.parseSPFResult(part) + } + } + + // Parse DKIM + if strings.HasPrefix(part, "dkim=") { + dkimResult := a.parseDKIMResult(part) + if dkimResult != nil { + if results.Dkim == nil { + dkimList := []api.AuthResult{*dkimResult} + results.Dkim = &dkimList + } else { + *results.Dkim = append(*results.Dkim, *dkimResult) + } + } + } + + // Parse DMARC + if strings.HasPrefix(part, "dmarc=") { + if results.Dmarc == nil { + results.Dmarc = a.parseDMARCResult(part) + } + } + } +} + +// 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{} + + // 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) + } + + // Extract domain + domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + email := matches[1] + // Extract domain from email + if idx := strings.Index(email, "@"); idx != -1 { + domain := email[idx+1:] + result.Domain = &domain + } + } + + // Extract details + if idx := strings.Index(part, "("); idx != -1 { + endIdx := strings.Index(part[idx:], ")") + if endIdx != -1 { + details := strings.TrimSpace(part[idx+1 : idx+endIdx]) + result.Details = &details + } + } + + return result +} + +// 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{} + + // 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) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.s or s) + selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + // Extract details + if idx := strings.Index(part, "("); idx != -1 { + endIdx := strings.Index(part[idx:], ")") + if endIdx != -1 { + details := strings.TrimSpace(part[idx+1 : idx+endIdx]) + result.Details = &details + } + } + + return result +} + +// 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{} + + // 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) + } + + // Extract domain (header.from) + domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract details (action, policy, etc.) + var detailsParts []string + actionRe := regexp.MustCompile(`action=([^\s;]+)`) + if matches := actionRe.FindStringSubmatch(part); len(matches) > 1 { + detailsParts = append(detailsParts, fmt.Sprintf("action=%s", matches[1])) + } + + if len(detailsParts) > 0 { + details := strings.Join(detailsParts, " ") + result.Details = &details + } + + return result +} + +// parseLegacySPF attempts to parse SPF from Received-SPF header +func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { + receivedSPF := email.Header.Get("Received-SPF") + if receivedSPF == "" { + return nil + } + + result := &api.AuthResult{} + + // Extract result (first word) + parts := strings.Fields(receivedSPF) + if len(parts) > 0 { + resultStr := strings.ToLower(parts[0]) + result.Result = api.AuthResultResult(resultStr) + } + + // Try to extract domain + domainRe := regexp.MustCompile(`(?:envelope-from|sender)=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { + email := matches[1] + if idx := strings.Index(email, "@"); idx != -1 { + domain := email[idx+1:] + result.Domain = &domain + } + } + + return result +} + +// parseLegacyDKIM attempts to parse DKIM from DKIM-Signature header +func (a *AuthenticationAnalyzer) parseLegacyDKIM(email *EmailMessage) []api.AuthResult { + var results []api.AuthResult + + // Get all DKIM-Signature headers + dkimHeaders := email.Header[textprotoCanonical("DKIM-Signature")] + for _, dkimHeader := range dkimHeaders { + result := api.AuthResult{ + Result: api.AuthResultResultNone, // We can't determine pass/fail from signature alone + } + + // Extract domain (d=) + domainRe := regexp.MustCompile(`d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (s=) + selectorRe := regexp.MustCompile(`s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + details := "DKIM signature present (verification status unknown)" + result.Details = &details + + results = append(results, result) + } + + return results +} + +// textprotoCanonical converts a header name to canonical form +func textprotoCanonical(s string) string { + // Simple implementation - capitalize each word + words := strings.Split(s, "-") + for i, word := range words { + if len(word) > 0 { + words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) + } + } + return strings.Join(words, "-") +} + +// GetAuthenticationScore calculates the authentication score (0-3 points) +func (a *AuthenticationAnalyzer) GetAuthenticationScore(results *api.AuthenticationResults) float32 { + var score float32 = 0.0 + + // SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail + if results.Spf != nil { + switch results.Spf.Result { + case api.AuthResultResultPass: + score += 1.0 + case api.AuthResultResultNeutral, api.AuthResultResultSoftfail: + score += 0.5 + } + } + + // DKIM: 1 point for at least one pass + if results.Dkim != nil && len(*results.Dkim) > 0 { + for _, dkim := range *results.Dkim { + if dkim.Result == api.AuthResultResultPass { + score += 1.0 + break + } + } + } + + // DMARC: 1 point for pass + if results.Dmarc != nil { + switch results.Dmarc.Result { + case api.AuthResultResultPass: + score += 1.0 + } + } + + // Cap at 3 points maximum + if score > 3.0 { + score = 3.0 + } + + return score +} + +// GenerateAuthenticationChecks generates check results for authentication +func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check { + var checks []api.Check + + // SPF check + if results.Spf != nil { + check := a.generateSPFCheck(results.Spf) + checks = append(checks, check) + } else { + checks = append(checks, api.Check{ + Category: api.Authentication, + Name: "SPF Record", + Status: api.CheckStatusWarn, + Score: 0.0, + Message: "No SPF authentication result found", + Severity: api.PtrTo(api.Medium), + Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"), + }) + } + + // DKIM check + if results.Dkim != nil && len(*results.Dkim) > 0 { + for i, dkim := range *results.Dkim { + check := a.generateDKIMCheck(&dkim, i) + checks = append(checks, check) + } + } else { + checks = append(checks, api.Check{ + Category: api.Authentication, + Name: "DKIM Signature", + Status: api.CheckStatusWarn, + Score: 0.0, + Message: "No DKIM signature found", + Severity: api.PtrTo(api.Medium), + Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"), + }) + } + + // DMARC check + if results.Dmarc != nil { + check := a.generateDMARCCheck(results.Dmarc) + checks = append(checks, check) + } else { + checks = append(checks, api.Check{ + Category: api.Authentication, + Name: "DMARC Policy", + Status: api.CheckStatusWarn, + Score: 0.0, + Message: "No DMARC authentication result found", + Severity: api.PtrTo(api.Medium), + Advice: api.PtrTo("Implement DMARC policy for your domain"), + }) + } + + return checks +} + +func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: "SPF Record", + } + + switch spf.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Message = "SPF validation passed" + check.Severity = api.PtrTo(api.Info) + check.Advice = api.PtrTo("Your SPF record is properly configured") + case api.AuthResultResultFail: + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Message = "SPF validation failed" + check.Severity = api.PtrTo(api.Critical) + check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server") + case api.AuthResultResultSoftfail: + check.Status = api.CheckStatusWarn + check.Score = 0.5 + check.Message = "SPF validation softfail" + check.Severity = api.PtrTo(api.Medium) + check.Advice = api.PtrTo("Review your SPF record configuration") + case api.AuthResultResultNeutral: + check.Status = api.CheckStatusWarn + check.Score = 0.5 + check.Message = "SPF validation neutral" + check.Severity = api.PtrTo(api.Low) + check.Advice = api.PtrTo("Consider tightening your SPF policy") + default: + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result) + check.Severity = api.PtrTo(api.Medium) + check.Advice = api.PtrTo("Review your SPF record configuration") + } + + if spf.Domain != nil { + details := fmt.Sprintf("Domain: %s", *spf.Domain) + check.Details = &details + } + + return check +} + +func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: fmt.Sprintf("DKIM Signature #%d", index+1), + } + + switch dkim.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Message = "DKIM signature is valid" + check.Severity = api.PtrTo(api.Info) + check.Advice = api.PtrTo("Your DKIM signature is properly configured") + case api.AuthResultResultFail: + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Message = "DKIM signature validation failed" + check.Severity = api.PtrTo(api.High) + check.Advice = api.PtrTo("Check your DKIM keys and signing configuration") + default: + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result) + check.Severity = api.PtrTo(api.Medium) + check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly") + } + + var detailsParts []string + if dkim.Domain != nil { + detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain)) + } + if dkim.Selector != nil { + detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector)) + } + if len(detailsParts) > 0 { + details := strings.Join(detailsParts, ", ") + check.Details = &details + } + + return check +} + +func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: "DMARC Policy", + } + + switch dmarc.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Message = "DMARC validation passed" + check.Severity = api.PtrTo(api.Info) + check.Advice = api.PtrTo("Your DMARC policy is properly aligned") + case api.AuthResultResultFail: + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Message = "DMARC validation failed" + check.Severity = api.PtrTo(api.High) + check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain") + default: + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result) + check.Severity = api.PtrTo(api.Medium) + check.Advice = api.PtrTo("Configure DMARC policy for your domain") + } + + if dmarc.Domain != nil { + details := fmt.Sprintf("Domain: %s", *dmarc.Domain) + check.Details = &details + } + + return check +} diff --git a/internal/analyzer/content.go b/internal/analyzer/content.go new file mode 100644 index 0000000..bad38c9 --- /dev/null +++ b/internal/analyzer/content.go @@ -0,0 +1,830 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "time" + "unicode" + + "git.happydns.org/happyDeliver/internal/api" + "golang.org/x/net/html" +) + +// ContentAnalyzer analyzes email content (HTML, links, images) +type ContentAnalyzer struct { + Timeout time.Duration + httpClient *http.Client +} + +// NewContentAnalyzer creates a new content analyzer with configurable timeout +func NewContentAnalyzer(timeout time.Duration) *ContentAnalyzer { + if timeout == 0 { + timeout = 10 * time.Second // Default timeout + } + return &ContentAnalyzer{ + Timeout: timeout, + httpClient: &http.Client{ + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // Allow up to 10 redirects + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + return nil + }, + }, + } +} + +// ContentResults represents content analysis results +type ContentResults struct { + HTMLValid bool + HTMLErrors []string + Links []LinkCheck + Images []ImageCheck + HasUnsubscribe bool + UnsubscribeLinks []string + TextContent string + HTMLContent string + TextPlainRatio float32 // Ratio of plain text to HTML consistency + ImageTextRatio float32 // Ratio of images to text + SuspiciousURLs []string + ContentIssues []string +} + +// LinkCheck represents a link validation result +type LinkCheck struct { + URL string + Valid bool + Status int + Error string + IsSafe bool + Warning string +} + +// ImageCheck represents an image validation result +type ImageCheck struct { + Src string + HasAlt bool + AltText string + Valid bool + Error string + IsBroken bool +} + +// AnalyzeContent performs content analysis on email message +func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults { + results := &ContentResults{} + + // Get HTML and text parts + htmlParts := email.GetHTMLParts() + textParts := email.GetTextParts() + + // Analyze HTML parts + if len(htmlParts) > 0 { + for _, part := range htmlParts { + c.analyzeHTML(part.Content, results) + } + } + + // Analyze text parts + if len(textParts) > 0 { + for _, part := range textParts { + results.TextContent += part.Content + } + } + + // Check plain text/HTML consistency + if len(htmlParts) > 0 && len(textParts) > 0 { + results.TextPlainRatio = c.calculateTextPlainConsistency(results.TextContent, results.HTMLContent) + } + + return results +} + +// analyzeHTML parses and analyzes HTML content +func (c *ContentAnalyzer) analyzeHTML(htmlContent string, results *ContentResults) { + results.HTMLContent = htmlContent + + // Parse HTML + doc, err := html.Parse(strings.NewReader(htmlContent)) + if err != nil { + results.HTMLValid = false + results.HTMLErrors = append(results.HTMLErrors, fmt.Sprintf("Failed to parse HTML: %v", err)) + return + } + + results.HTMLValid = true + + // Traverse HTML tree + c.traverseHTML(doc, results) + + // Calculate image-to-text ratio + if results.HTMLContent != "" { + textLength := len(c.extractTextFromHTML(htmlContent)) + imageCount := len(results.Images) + if textLength > 0 { + results.ImageTextRatio = float32(imageCount) / float32(textLength) * 1000 // Images per 1000 chars + } + } +} + +// traverseHTML recursively traverses HTML nodes +func (c *ContentAnalyzer) traverseHTML(n *html.Node, results *ContentResults) { + if n.Type == html.ElementNode { + switch n.Data { + case "a": + // Extract and validate links + href := c.getAttr(n, "href") + if href != "" { + // Check for unsubscribe links + if c.isUnsubscribeLink(href, n) { + results.HasUnsubscribe = true + results.UnsubscribeLinks = append(results.UnsubscribeLinks, href) + } + + // Validate link + linkCheck := c.validateLink(href) + results.Links = append(results.Links, linkCheck) + + // Check for suspicious URLs + if !linkCheck.IsSafe { + results.SuspiciousURLs = append(results.SuspiciousURLs, href) + } + } + + case "img": + // Extract and validate images + src := c.getAttr(n, "src") + alt := c.getAttr(n, "alt") + + imageCheck := ImageCheck{ + Src: src, + HasAlt: alt != "", + AltText: alt, + Valid: src != "", + } + + if src == "" { + imageCheck.Error = "Image missing src attribute" + } + + results.Images = append(results.Images, imageCheck) + } + } + + // Traverse children + for child := n.FirstChild; child != nil; child = child.NextSibling { + c.traverseHTML(child, results) + } +} + +// getAttr gets an attribute value from an HTML node +func (c *ContentAnalyzer) getAttr(n *html.Node, key string) string { + for _, attr := range n.Attr { + if attr.Key == key { + return attr.Val + } + } + return "" +} + +// isUnsubscribeLink checks if a link is an unsubscribe link +func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool { + // Check href for unsubscribe keywords + lowerHref := strings.ToLower(href) + unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe"} + for _, keyword := range unsubKeywords { + if strings.Contains(lowerHref, keyword) { + return true + } + } + + // Check link text for unsubscribe keywords + text := c.getNodeText(node) + lowerText := strings.ToLower(text) + for _, keyword := range unsubKeywords { + if strings.Contains(lowerText, keyword) { + return true + } + } + + return false +} + +// getNodeText extracts text content from a node +func (c *ContentAnalyzer) getNodeText(n *html.Node) string { + if n.Type == html.TextNode { + return n.Data + } + var text string + for child := n.FirstChild; child != nil; child = child.NextSibling { + text += c.getNodeText(child) + } + return text +} + +// validateLink validates a URL and checks if it's accessible +func (c *ContentAnalyzer) validateLink(urlStr string) LinkCheck { + check := LinkCheck{ + URL: urlStr, + IsSafe: true, + } + + // Parse URL + parsedURL, err := url.Parse(urlStr) + if err != nil { + check.Valid = false + check.Error = fmt.Sprintf("Invalid URL: %v", err) + return check + } + + // Check URL safety + if c.isSuspiciousURL(urlStr, parsedURL) { + check.IsSafe = false + check.Warning = "URL appears suspicious (obfuscated, shortened, or unusual)" + } + + // Only check HTTP/HTTPS links + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + check.Valid = true + return check + } + + // Check if link is accessible (with timeout) + ctx, cancel := context.WithTimeout(context.Background(), c.Timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "HEAD", urlStr, nil) + if err != nil { + check.Valid = false + check.Error = fmt.Sprintf("Failed to create request: %v", err) + return check + } + + // Set a reasonable user agent + req.Header.Set("User-Agent", "HappyDeliver/1.0 (Email Deliverability Tester)") + + resp, err := c.httpClient.Do(req) + if err != nil { + // Don't fail on timeout/connection errors for external links + // Just mark as warning + check.Valid = true + check.Status = 0 + check.Warning = fmt.Sprintf("Could not verify link: %v", err) + return check + } + defer resp.Body.Close() + + check.Status = resp.StatusCode + check.Valid = true + + // Check for error status codes + if resp.StatusCode >= 400 { + check.Error = fmt.Sprintf("Link returns %d status", resp.StatusCode) + } + + return check +} + +// isSuspiciousURL checks if a URL looks suspicious +func (c *ContentAnalyzer) isSuspiciousURL(urlStr string, parsedURL *url.URL) bool { + // Check for IP address instead of domain + if c.isIPAddress(parsedURL.Host) { + return true + } + + // Check for URL shorteners (common ones) + shorteners := []string{ + "bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co", + "buff.ly", "is.gd", "bl.ink", "short.io", + } + for _, shortener := range shorteners { + if strings.Contains(strings.ToLower(parsedURL.Host), shortener) { + return true + } + } + + // Check for excessive subdomains (possible obfuscation) + parts := strings.Split(parsedURL.Host, ".") + if len(parts) > 4 { + return true + } + + // Check for URL obfuscation techniques + if strings.Count(urlStr, "@") > 0 { // @ in URL (possible phishing) + return true + } + + // Check for suspicious characters in domain + if strings.ContainsAny(parsedURL.Host, "[]()<>") { + return true + } + + return false +} + +// isIPAddress checks if a string is an IP address +func (c *ContentAnalyzer) isIPAddress(host string) bool { + // Remove port if present + if idx := strings.LastIndex(host, ":"); idx != -1 { + host = host[:idx] + } + + // Simple check for IPv4 + parts := strings.Split(host, ".") + if len(parts) == 4 { + for _, part := range parts { + // Check if all characters are digits + for _, ch := range part { + if !unicode.IsDigit(ch) { + return false + } + } + } + return true + } + + // Check for IPv6 (contains colons) + if strings.Contains(host, ":") { + return true + } + + return false +} + +// extractTextFromHTML extracts plain text from HTML +func (c *ContentAnalyzer) extractTextFromHTML(htmlContent string) string { + doc, err := html.Parse(strings.NewReader(htmlContent)) + if err != nil { + return "" + } + + var text strings.Builder + var extract func(*html.Node) + extract = func(n *html.Node) { + if n.Type == html.TextNode { + text.WriteString(n.Data) + } + // Skip script and style tags + if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") { + return + } + for child := n.FirstChild; child != nil; child = child.NextSibling { + extract(child) + } + } + extract(doc) + + return text.String() +} + +// calculateTextPlainConsistency compares plain text and HTML versions +func (c *ContentAnalyzer) calculateTextPlainConsistency(plainText, htmlText string) float32 { + // Extract text from HTML + htmlPlainText := c.extractTextFromHTML(htmlText) + + // Normalize both texts + plainNorm := c.normalizeText(plainText) + htmlNorm := c.normalizeText(htmlPlainText) + + // Calculate similarity using simple word overlap + plainWords := strings.Fields(plainNorm) + htmlWords := strings.Fields(htmlNorm) + + if len(plainWords) == 0 || len(htmlWords) == 0 { + return 0.0 + } + + // Count common words + commonWords := 0 + plainWordSet := make(map[string]bool) + for _, word := range plainWords { + plainWordSet[word] = true + } + + for _, word := range htmlWords { + if plainWordSet[word] { + commonWords++ + } + } + + // Calculate ratio (Jaccard similarity approximation) + maxWords := len(plainWords) + if len(htmlWords) > maxWords { + maxWords = len(htmlWords) + } + + if maxWords == 0 { + return 0.0 + } + + return float32(commonWords) / float32(maxWords) +} + +// normalizeText normalizes text for comparison +func (c *ContentAnalyzer) normalizeText(text string) string { + // Convert to lowercase + text = strings.ToLower(text) + + // Remove extra whitespace + text = strings.TrimSpace(text) + text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ") + + return text +} + +// GenerateContentChecks generates check results for content analysis +func (c *ContentAnalyzer) GenerateContentChecks(results *ContentResults) []api.Check { + var checks []api.Check + + if results == nil { + return checks + } + + // HTML validity check + checks = append(checks, c.generateHTMLValidityCheck(results)) + + // Link checks + checks = append(checks, c.generateLinkChecks(results)...) + + // Image checks + checks = append(checks, c.generateImageChecks(results)...) + + // Unsubscribe link check + checks = append(checks, c.generateUnsubscribeCheck(results)) + + // Text/HTML consistency check + if results.TextContent != "" && results.HTMLContent != "" { + checks = append(checks, c.generateTextConsistencyCheck(results)) + } + + // Image-to-text ratio check + if len(results.Images) > 0 && results.HTMLContent != "" { + checks = append(checks, c.generateImageRatioCheck(results)) + } + + // Suspicious URLs check + if len(results.SuspiciousURLs) > 0 { + checks = append(checks, c.generateSuspiciousURLCheck(results)) + } + + return checks +} + +// generateHTMLValidityCheck creates a check for HTML validity +func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api.Check { + check := api.Check{ + Category: api.Content, + Name: "HTML Structure", + } + + if !results.HTMLValid { + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Severity = api.PtrTo(api.Medium) + check.Message = "HTML structure is invalid" + if len(results.HTMLErrors) > 0 { + details := strings.Join(results.HTMLErrors, "; ") + check.Details = &details + } + check.Advice = api.PtrTo("Fix HTML structure errors to improve email rendering") + } else { + check.Status = api.CheckStatusPass + check.Score = 0.2 + check.Severity = api.PtrTo(api.Info) + check.Message = "HTML structure is valid" + check.Advice = api.PtrTo("Your HTML is well-formed") + } + + return check +} + +// generateLinkChecks creates checks for links +func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Check { + var checks []api.Check + + if len(results.Links) == 0 { + return checks + } + + // Count broken links + brokenLinks := 0 + warningLinks := 0 + for _, link := range results.Links { + if link.Status >= 400 { + brokenLinks++ + } else if link.Warning != "" { + warningLinks++ + } + } + + check := api.Check{ + Category: api.Content, + Name: "Links", + } + + if brokenLinks > 0 { + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Severity = api.PtrTo(api.High) + check.Message = fmt.Sprintf("Found %d broken link(s)", brokenLinks) + check.Advice = api.PtrTo("Fix or remove broken links to improve deliverability") + details := fmt.Sprintf("Total links: %d, Broken: %d", len(results.Links), brokenLinks) + check.Details = &details + } else if warningLinks > 0 { + check.Status = api.CheckStatusWarn + check.Score = 0.3 + check.Severity = api.PtrTo(api.Low) + check.Message = fmt.Sprintf("Found %d link(s) that could not be verified", warningLinks) + check.Advice = api.PtrTo("Review links that could not be verified") + details := fmt.Sprintf("Total links: %d, Unverified: %d", len(results.Links), warningLinks) + check.Details = &details + } else { + check.Status = api.CheckStatusPass + check.Score = 0.4 + check.Severity = api.PtrTo(api.Info) + check.Message = fmt.Sprintf("All %d link(s) are valid", len(results.Links)) + check.Advice = api.PtrTo("Your links are working properly") + } + + checks = append(checks, check) + return checks +} + +// generateImageChecks creates checks for images +func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Check { + var checks []api.Check + + if len(results.Images) == 0 { + return checks + } + + // Count images without alt text + noAltCount := 0 + for _, img := range results.Images { + if !img.HasAlt { + noAltCount++ + } + } + + check := api.Check{ + Category: api.Content, + Name: "Image Alt Attributes", + } + + if noAltCount == len(results.Images) { + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Severity = api.PtrTo(api.Medium) + check.Message = "No images have alt attributes" + check.Advice = api.PtrTo("Add alt text to all images for accessibility and deliverability") + details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images)) + check.Details = &details + } else if noAltCount > 0 { + check.Status = api.CheckStatusWarn + check.Score = 0.2 + check.Severity = api.PtrTo(api.Low) + check.Message = fmt.Sprintf("%d image(s) missing alt attributes", noAltCount) + check.Advice = api.PtrTo("Add alt text to all images for better accessibility") + details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images)) + check.Details = &details + } else { + check.Status = api.CheckStatusPass + check.Score = 0.3 + check.Severity = api.PtrTo(api.Info) + check.Message = "All images have alt attributes" + check.Advice = api.PtrTo("Your images are properly tagged for accessibility") + } + + checks = append(checks, check) + return checks +} + +// generateUnsubscribeCheck creates a check for unsubscribe links +func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api.Check { + check := api.Check{ + Category: api.Content, + Name: "Unsubscribe Link", + } + + if !results.HasUnsubscribe { + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Severity = api.PtrTo(api.Low) + check.Message = "No unsubscribe link found" + check.Advice = api.PtrTo("Add an unsubscribe link for marketing emails (RFC 8058)") + } else { + check.Status = api.CheckStatusPass + check.Score = 0.3 + check.Severity = api.PtrTo(api.Info) + check.Message = fmt.Sprintf("Found %d unsubscribe link(s)", len(results.UnsubscribeLinks)) + check.Advice = api.PtrTo("Your email includes an unsubscribe option") + } + + return check +} + +// generateTextConsistencyCheck creates a check for text/HTML consistency +func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) api.Check { + check := api.Check{ + Category: api.Content, + Name: "Plain Text Consistency", + } + + consistency := results.TextPlainRatio + + if consistency < 0.3 { + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Severity = api.PtrTo(api.Low) + check.Message = "Plain text and HTML versions differ significantly" + check.Advice = api.PtrTo("Ensure plain text and HTML versions convey the same content") + details := fmt.Sprintf("Consistency: %.0f%%", consistency*100) + check.Details = &details + } else { + check.Status = api.CheckStatusPass + check.Score = 0.3 + check.Severity = api.PtrTo(api.Info) + check.Message = "Plain text and HTML versions are consistent" + check.Advice = api.PtrTo("Your multipart email is well-structured") + details := fmt.Sprintf("Consistency: %.0f%%", consistency*100) + check.Details = &details + } + + return check +} + +// generateImageRatioCheck creates a check for image-to-text ratio +func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.Check { + check := api.Check{ + Category: api.Content, + Name: "Image-to-Text Ratio", + } + + ratio := results.ImageTextRatio + + // Flag if more than 1 image per 100 characters (very image-heavy) + if ratio > 10.0 { + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Severity = api.PtrTo(api.Medium) + check.Message = "Email is excessively image-heavy" + check.Advice = api.PtrTo("Reduce the number of images relative to text content") + details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio) + check.Details = &details + } else if ratio > 5.0 { + check.Status = api.CheckStatusWarn + check.Score = 0.2 + check.Severity = api.PtrTo(api.Low) + check.Message = "Email has high image-to-text ratio" + check.Advice = api.PtrTo("Consider adding more text content relative to images") + details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio) + check.Details = &details + } else { + check.Status = api.CheckStatusPass + check.Score = 0.3 + check.Severity = api.PtrTo(api.Info) + check.Message = "Image-to-text ratio is reasonable" + check.Advice = api.PtrTo("Your content has a good balance of images and text") + details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio) + check.Details = &details + } + + return check +} + +// generateSuspiciousURLCheck creates a check for suspicious URLs +func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) api.Check { + check := api.Check{ + Category: api.Content, + Name: "Suspicious URLs", + } + + count := len(results.SuspiciousURLs) + + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Severity = api.PtrTo(api.Medium) + check.Message = fmt.Sprintf("Found %d suspicious URL(s)", count) + check.Advice = api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails") + + if count <= 3 { + details := strings.Join(results.SuspiciousURLs, ", ") + check.Details = &details + } else { + details := fmt.Sprintf("%s, and %d more", strings.Join(results.SuspiciousURLs[:3], ", "), count-3) + check.Details = &details + } + + return check +} + +// GetContentScore calculates the content score (0-2 points) +func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 { + if results == nil { + return 0.0 + } + + var score float32 = 0.0 + + // HTML validity (0.2 points) + if results.HTMLValid { + score += 0.2 + } + + // Links (0.4 points) + if len(results.Links) > 0 { + brokenLinks := 0 + for _, link := range results.Links { + if link.Status >= 400 { + brokenLinks++ + } + } + if brokenLinks == 0 { + score += 0.4 + } + } else { + // No links is neutral, give partial score + score += 0.2 + } + + // Images (0.3 points) + if len(results.Images) > 0 { + noAltCount := 0 + for _, img := range results.Images { + if !img.HasAlt { + noAltCount++ + } + } + if noAltCount == 0 { + score += 0.3 + } else if noAltCount < len(results.Images) { + score += 0.15 + } + } else { + // No images is neutral + score += 0.15 + } + + // Unsubscribe link (0.3 points) + if results.HasUnsubscribe { + score += 0.3 + } + + // Text consistency (0.3 points) + if results.TextPlainRatio >= 0.3 { + score += 0.3 + } + + // Image ratio (0.3 points) + if results.ImageTextRatio <= 5.0 { + score += 0.3 + } else if results.ImageTextRatio <= 10.0 { + score += 0.15 + } + + // Penalize suspicious URLs (deduct up to 0.5 points) + if len(results.SuspiciousURLs) > 0 { + penalty := float32(len(results.SuspiciousURLs)) * 0.1 + if penalty > 0.5 { + penalty = 0.5 + } + score -= penalty + } + + // Ensure score is between 0 and 2 + if score < 0 { + score = 0 + } + if score > 2.0 { + score = 2.0 + } + + return score +} diff --git a/pkg/analyzer/content_test.go b/internal/analyzer/content_test.go similarity index 58% rename from pkg/analyzer/content_test.go rename to internal/analyzer/content_test.go index 4ad01a8..342f3cb 100644 --- a/pkg/analyzer/content_test.go +++ b/internal/analyzer/content_test.go @@ -28,6 +28,7 @@ import ( "testing" "time" + "git.happydns.org/happyDeliver/internal/api" "golang.org/x/net/html" ) @@ -76,17 +77,17 @@ func TestExtractTextFromHTML(t *testing.T) { { name: "Multiple elements", html: "

Title

Paragraph

", - expectedText: "Title Paragraph", + expectedText: "TitleParagraph", }, { name: "With script tag", html: "

Text

More

", - expectedText: "Text More", + expectedText: "TextMore", }, { name: "With style tag", html: "

Text

More

", - expectedText: "Text More", + expectedText: "TextMore", }, { name: "Empty HTML", @@ -144,74 +145,6 @@ func TestIsUnsubscribeLink(t *testing.T) { linkText: "Read more", expected: false, }, - // Multilingual keyword detection - URL path - { - name: "German abmelden in URL", - href: "https://example.com/abmelden?id=42", - linkText: "Click here", - expected: true, - }, - { - name: "French se-desabonner slug in URL (no accent/space - not detected by keyword)", - href: "https://example.com/se-desabonner?id=42", - linkText: "Click here", - expected: false, - }, - // Multilingual keyword detection - link text - { - name: "German Abmelden in link text", - href: "https://example.com/manage?id=42&lang=de", - linkText: "Abmelden", - expected: true, - }, - { - name: "French Se désabonner in link text", - href: "https://example.com/manage?id=42&lang=fr", - linkText: "Se désabonner", - expected: true, - }, - { - name: "Russian Отписаться in link text", - href: "https://example.com/manage?id=42&lang=ru", - linkText: "Отписаться", - expected: true, - }, - { - name: "Chinese 退订 in link text", - href: "https://example.com/manage?id=42&lang=zh", - linkText: "退订", - expected: true, - }, - { - name: "Japanese 登録を取り消す in link text", - href: "https://example.com/manage?id=42&lang=ja", - linkText: "登録を取り消す", - expected: true, - }, - { - name: "Korean 구독 해지 in link text", - href: "https://example.com/manage?id=42&lang=ko", - linkText: "구독 해지", - expected: true, - }, - { - name: "Dutch Uitschrijven in link text", - href: "https://example.com/manage?id=42&lang=nl", - linkText: "Uitschrijven", - expected: true, - }, - { - name: "Polish Odsubskrybuj in link text", - href: "https://example.com/manage?id=42&lang=pl", - linkText: "Odsubskrybuj", - expected: true, - }, - { - name: "Turkish Üyeliği sonlandır in link text", - href: "https://example.com/manage?id=42&lang=tr", - linkText: "Üyeliği sonlandır", - expected: true, - }, } analyzer := NewContentAnalyzer(5 * time.Second) @@ -281,16 +214,6 @@ func TestIsSuspiciousURL(t *testing.T) { url: "https://mail.example.com/page", expected: false, }, - { - name: "Mailto with @ symbol", - url: "mailto:support@example.com", - expected: false, - }, - { - name: "Mailto with multiple @ symbols", - url: "mailto:user@subdomain@example.com", - expected: false, - }, } analyzer := NewContentAnalyzer(5 * time.Second) @@ -685,6 +608,453 @@ func TestAnalyzeContent_ImageAltAttributes(t *testing.T) { } } +func TestGenerateHTMLValidityCheck(t *testing.T) { + tests := []struct { + name string + results *ContentResults + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "Valid HTML", + results: &ContentResults{ + HTMLValid: true, + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 0.2, + }, + { + name: "Invalid HTML", + results: &ContentResults{ + HTMLValid: false, + HTMLErrors: []string{"Parse error"}, + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, + }, + } + + analyzer := NewContentAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateHTMLValidityCheck(tt.results) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Content { + t.Errorf("Category = %v, want %v", check.Category, api.Content) + } + }) + } +} + +func TestGenerateLinkChecks(t *testing.T) { + tests := []struct { + name string + results *ContentResults + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "All links valid", + results: &ContentResults{ + Links: []LinkCheck{ + {URL: "https://example.com", Valid: true, Status: 200}, + {URL: "https://example.org", Valid: true, Status: 200}, + }, + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 0.4, + }, + { + name: "Broken links", + results: &ContentResults{ + Links: []LinkCheck{ + {URL: "https://example.com", Valid: true, Status: 404, Error: "Not found"}, + }, + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, + }, + { + name: "Links with warnings", + results: &ContentResults{ + Links: []LinkCheck{ + {URL: "https://example.com", Valid: true, Warning: "Could not verify"}, + }, + }, + expectedStatus: api.CheckStatusWarn, + expectedScore: 0.3, + }, + { + name: "No links", + results: &ContentResults{}, + }, + } + + analyzer := NewContentAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checks := analyzer.generateLinkChecks(tt.results) + + if tt.name == "No links" { + if len(checks) != 0 { + t.Errorf("Expected no checks, got %d", len(checks)) + } + return + } + + if len(checks) == 0 { + t.Fatal("Expected at least one check") + } + + check := checks[0] + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + }) + } +} + +func TestGenerateImageChecks(t *testing.T) { + tests := []struct { + name string + results *ContentResults + expectedStatus api.CheckStatus + }{ + { + name: "All images have alt", + results: &ContentResults{ + Images: []ImageCheck{ + {Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"}, + {Src: "img2.jpg", HasAlt: true, AltText: "Alt 2"}, + }, + }, + expectedStatus: api.CheckStatusPass, + }, + { + name: "No images have alt", + results: &ContentResults{ + Images: []ImageCheck{ + {Src: "img1.jpg", HasAlt: false}, + {Src: "img2.jpg", HasAlt: false}, + }, + }, + expectedStatus: api.CheckStatusFail, + }, + { + name: "Some images have alt", + results: &ContentResults{ + Images: []ImageCheck{ + {Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"}, + {Src: "img2.jpg", HasAlt: false}, + }, + }, + expectedStatus: api.CheckStatusWarn, + }, + } + + analyzer := NewContentAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checks := analyzer.generateImageChecks(tt.results) + + if len(checks) == 0 { + t.Fatal("Expected at least one check") + } + + check := checks[0] + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Category != api.Content { + t.Errorf("Category = %v, want %v", check.Category, api.Content) + } + }) + } +} + +func TestGenerateUnsubscribeCheck(t *testing.T) { + tests := []struct { + name string + results *ContentResults + expectedStatus api.CheckStatus + }{ + { + name: "Has unsubscribe link", + results: &ContentResults{ + HasUnsubscribe: true, + UnsubscribeLinks: []string{"https://example.com/unsubscribe"}, + }, + expectedStatus: api.CheckStatusPass, + }, + { + name: "No unsubscribe link", + results: &ContentResults{}, + expectedStatus: api.CheckStatusWarn, + }, + } + + analyzer := NewContentAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateUnsubscribeCheck(tt.results) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Category != api.Content { + t.Errorf("Category = %v, want %v", check.Category, api.Content) + } + }) + } +} + +func TestGenerateTextConsistencyCheck(t *testing.T) { + tests := []struct { + name string + results *ContentResults + expectedStatus api.CheckStatus + }{ + { + name: "High consistency", + results: &ContentResults{ + TextPlainRatio: 0.8, + }, + expectedStatus: api.CheckStatusPass, + }, + { + name: "Low consistency", + results: &ContentResults{ + TextPlainRatio: 0.1, + }, + expectedStatus: api.CheckStatusWarn, + }, + } + + analyzer := NewContentAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateTextConsistencyCheck(tt.results) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + }) + } +} + +func TestGenerateImageRatioCheck(t *testing.T) { + tests := []struct { + name string + results *ContentResults + expectedStatus api.CheckStatus + }{ + { + name: "Reasonable ratio", + results: &ContentResults{ + ImageTextRatio: 3.0, + Images: []ImageCheck{{}, {}, {}}, + }, + expectedStatus: api.CheckStatusPass, + }, + { + name: "High ratio", + results: &ContentResults{ + ImageTextRatio: 7.0, + Images: make([]ImageCheck, 7), + }, + expectedStatus: api.CheckStatusWarn, + }, + { + name: "Excessive ratio", + results: &ContentResults{ + ImageTextRatio: 15.0, + Images: make([]ImageCheck, 15), + }, + expectedStatus: api.CheckStatusFail, + }, + } + + analyzer := NewContentAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateImageRatioCheck(tt.results) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + }) + } +} + +func TestGenerateSuspiciousURLCheck(t *testing.T) { + results := &ContentResults{ + SuspiciousURLs: []string{ + "https://bit.ly/abc123", + "https://192.168.1.1/page", + }, + } + + analyzer := NewContentAnalyzer(5 * time.Second) + check := analyzer.generateSuspiciousURLCheck(results) + + if check.Status != api.CheckStatusWarn { + t.Errorf("Status = %v, want %v", check.Status, api.CheckStatusWarn) + } + if check.Category != api.Content { + t.Errorf("Category = %v, want %v", check.Category, api.Content) + } + if !strings.Contains(check.Message, "2") { + t.Error("Message should mention the count of suspicious URLs") + } +} + +func TestGetContentScore(t *testing.T) { + tests := []struct { + name string + results *ContentResults + minScore float32 + maxScore float32 + }{ + { + name: "Nil results", + results: nil, + minScore: 0.0, + maxScore: 0.0, + }, + { + name: "Perfect content", + results: &ContentResults{ + HTMLValid: true, + Links: []LinkCheck{{Valid: true, Status: 200}}, + Images: []ImageCheck{{HasAlt: true}}, + HasUnsubscribe: true, + TextPlainRatio: 0.8, + ImageTextRatio: 3.0, + }, + minScore: 1.8, + maxScore: 2.0, + }, + { + name: "Poor content", + results: &ContentResults{ + HTMLValid: false, + Links: []LinkCheck{{Valid: true, Status: 404}}, + Images: []ImageCheck{{HasAlt: false}}, + HasUnsubscribe: false, + TextPlainRatio: 0.1, + ImageTextRatio: 15.0, + SuspiciousURLs: []string{"url1", "url2"}, + }, + minScore: 0.0, + maxScore: 0.5, + }, + { + name: "Average content", + results: &ContentResults{ + HTMLValid: true, + Links: []LinkCheck{{Valid: true, Status: 200}}, + Images: []ImageCheck{{HasAlt: true}}, + HasUnsubscribe: false, + TextPlainRatio: 0.5, + ImageTextRatio: 4.0, + }, + minScore: 1.0, + maxScore: 1.8, + }, + } + + analyzer := NewContentAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score := analyzer.GetContentScore(tt.results) + + if score < tt.minScore || score > tt.maxScore { + t.Errorf("GetContentScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) + } + + // Ensure score is capped at 2.0 + if score > 2.0 { + t.Errorf("Score %v exceeds maximum of 2.0", score) + } + + // Ensure score is not negative + if score < 0.0 { + t.Errorf("Score %v is negative", score) + } + }) + } +} + +func TestGenerateContentChecks(t *testing.T) { + tests := []struct { + name string + results *ContentResults + minChecks int + }{ + { + name: "Nil results", + results: nil, + minChecks: 0, + }, + { + name: "Complete results", + results: &ContentResults{ + HTMLValid: true, + Links: []LinkCheck{{Valid: true}}, + Images: []ImageCheck{{HasAlt: true}}, + HasUnsubscribe: true, + TextContent: "Plain text", + HTMLContent: "

HTML text

", + ImageTextRatio: 3.0, + }, + minChecks: 5, // HTML, Links, Images, Unsubscribe, Text consistency, Image ratio + }, + { + name: "With suspicious URLs", + results: &ContentResults{ + HTMLValid: true, + SuspiciousURLs: []string{"url1"}, + }, + minChecks: 3, // HTML, Unsubscribe, Suspicious URLs + }, + } + + analyzer := NewContentAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checks := analyzer.GenerateContentChecks(tt.results) + + if len(checks) < tt.minChecks { + t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) + } + + // Verify all checks have the Content category + for _, check := range checks { + if check.Category != api.Content { + t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Content) + } + } + }) + } +} + // Helper functions for testing func parseHTML(htmlStr string) (*html.Node, error) { @@ -706,276 +1076,3 @@ func findFirstLink(n *html.Node) *html.Node { func parseURL(urlStr string) (*url.URL, error) { return url.Parse(urlStr) } - -func TestHasDomainMisalignment(t *testing.T) { - tests := []struct { - name string - href string - linkText string - expected bool - reason string - }{ - // Phishing cases - should return true - { - name: "Obvious phishing - different domains", - href: "https://evil.com/page", - linkText: "Click here to verify your paypal.com account", - expected: true, - reason: "Link text shows 'paypal.com' but URL points to 'evil.com'", - }, - { - name: "Domain in link text differs from URL", - href: "http://attacker.net", - linkText: "Visit google.com for more info", - expected: true, - reason: "Link text shows 'google.com' but URL points to 'attacker.net'", - }, - { - name: "URL shown in text differs from actual URL", - href: "https://phishing-site.xyz/login", - linkText: "https://www.bank.example.com/secure", - expected: true, - reason: "Full URL in text doesn't match actual destination", - }, - { - name: "Similar but different domain", - href: "https://paypa1.com/login", - linkText: "Login to your paypal.com account", - expected: true, - reason: "Typosquatting: 'paypa1.com' vs 'paypal.com'", - }, - { - name: "Subdomain spoofing", - href: "https://paypal.com.evil.com/login", - linkText: "Verify your paypal.com account", - expected: true, - reason: "Domain is 'evil.com', not 'paypal.com'", - }, - { - name: "Multiple domains in text, none match", - href: "https://badsite.com", - linkText: "Transfer from bank.com to paypal.com", - expected: true, - reason: "Neither 'bank.com' nor 'paypal.com' matches 'badsite.com'", - }, - - // Legitimate cases - should return false - { - name: "Exact domain match", - href: "https://example.com/page", - linkText: "Visit example.com for more information", - expected: false, - reason: "Domains match exactly", - }, - { - name: "Legitimate subdomain", - href: "https://mail.google.com/inbox", - linkText: "Check your google.com email", - expected: false, - reason: "Subdomain of the mentioned domain", - }, - { - name: "www prefix variation", - href: "https://www.example.com/page", - linkText: "Visit example.com", - expected: false, - reason: "www prefix is acceptable variation", - }, - { - name: "Generic link text - click here", - href: "https://anywhere.com", - linkText: "click here", - expected: false, - reason: "Generic text doesn't contain a domain", - }, - { - name: "Generic link text - read more", - href: "https://example.com/article", - linkText: "Read more", - expected: false, - reason: "Generic text doesn't contain a domain", - }, - { - name: "Generic link text - learn more", - href: "https://example.com/info", - linkText: "Learn More", - expected: false, - reason: "Generic text doesn't contain a domain (case insensitive)", - }, - { - name: "No domain in link text", - href: "https://example.com/page", - linkText: "Click to continue", - expected: false, - reason: "Link text has no domain reference", - }, - { - name: "Short link text", - href: "https://example.com", - linkText: "Go", - expected: false, - reason: "Text too short to contain meaningful domain", - }, - { - name: "Empty link text", - href: "https://example.com", - linkText: "", - expected: false, - reason: "Empty text cannot contain domain", - }, - { - name: "Mailto link - matching domain", - href: "mailto:support@example.com", - linkText: "Email support@example.com", - expected: false, - reason: "Mailto email matches text email", - }, - { - name: "Mailto link - domain mismatch (phishing)", - href: "mailto:attacker@evil.com", - linkText: "Contact support@paypal.com for help", - expected: true, - reason: "Mailto domain 'evil.com' doesn't match text domain 'paypal.com'", - }, - { - name: "Mailto link - generic text", - href: "mailto:info@example.com", - linkText: "Contact us", - expected: false, - reason: "Generic text without domain reference", - }, - { - name: "Mailto link - same domain different user", - href: "mailto:sales@example.com", - linkText: "Email support@example.com", - expected: false, - reason: "Both emails share the same domain", - }, - { - name: "Mailto link - text shows only domain", - href: "mailto:info@example.com", - linkText: "Write to example.com", - expected: false, - reason: "Text domain matches mailto domain", - }, - { - name: "Mailto link - domain in text doesn't match", - href: "mailto:scam@phishing.net", - linkText: "Reply to customer-service@amazon.com", - expected: true, - reason: "Mailto domain 'phishing.net' doesn't match 'amazon.com' in text", - }, - { - name: "Tel link", - href: "tel:+1234567890", - linkText: "Call example.com support", - expected: false, - reason: "Non-HTTP(S) links are excluded", - }, - { - name: "Same base domain with different subdomains", - href: "https://www.example.com/page", - linkText: "Visit blog.example.com", - expected: false, - reason: "Both share same base domain 'example.com'", - }, - { - name: "URL with path matches domain in text", - href: "https://example.com/section/page", - linkText: "Go to example.com", - expected: false, - reason: "Domain matches, path doesn't matter", - }, - { - name: "Generic text - subscribe", - href: "https://newsletter.example.com/signup", - linkText: "Subscribe", - expected: false, - reason: "Generic call-to-action text", - }, - { - name: "Generic text - unsubscribe", - href: "https://example.com/unsubscribe?id=123", - linkText: "Unsubscribe", - expected: false, - reason: "Generic unsubscribe text", - }, - { - name: "Generic text - download", - href: "https://files.example.com/document.pdf", - linkText: "Download", - expected: false, - reason: "Generic action text", - }, - { - name: "Descriptive text without domain", - href: "https://shop.example.com/products", - linkText: "View our latest products", - expected: false, - reason: "No domain mentioned in text", - }, - - // Edge cases - { - name: "Domain-like text but not valid domain", - href: "https://example.com", - linkText: "Save up to 50.00 dollars", - expected: false, - reason: "50.00 looks like domain but isn't", - }, - { - name: "Text with http prefix matching domain", - href: "https://example.com/page", - linkText: "Visit http://example.com", - expected: false, - reason: "Domains match despite different protocols in display", - }, - { - name: "Port in URL should not affect matching", - href: "https://example.com:8080/page", - linkText: "Go to example.com", - expected: false, - reason: "Port number doesn't affect domain matching", - }, - { - name: "Whitespace in link text", - href: "https://example.com", - linkText: " example.com ", - expected: false, - reason: "Whitespace should be trimmed", - }, - { - name: "Multiple spaces in generic text", - href: "https://example.com", - linkText: "click here", - expected: false, - reason: "Generic text with extra spaces", - }, - { - name: "Anchor fragment in URL", - href: "https://example.com/page#section", - linkText: "example.com section", - expected: false, - reason: "Fragment doesn't affect domain matching", - }, - { - name: "Query parameters in URL", - href: "https://example.com/page?utm_source=email", - linkText: "Visit example.com", - expected: false, - reason: "Query params don't affect domain matching", - }, - } - - analyzer := NewContentAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.hasDomainMisalignment(tt.href, tt.linkText) - if result != tt.expected { - t.Errorf("hasDomainMisalignment(%q, %q) = %v, want %v\nReason: %s", - tt.href, tt.linkText, result, tt.expected, tt.reason) - } - }) - } -} diff --git a/internal/analyzer/dns.go b/internal/analyzer/dns.go new file mode 100644 index 0000000..07c0346 --- /dev/null +++ b/internal/analyzer/dns.go @@ -0,0 +1,566 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "net" + "regexp" + "strings" + "time" + + "git.happydns.org/happyDeliver/internal/api" +) + +// DNSAnalyzer analyzes DNS records for email domains +type DNSAnalyzer struct { + Timeout time.Duration + resolver *net.Resolver +} + +// NewDNSAnalyzer creates a new DNS analyzer with configurable timeout +func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer { + if timeout == 0 { + timeout = 10 * time.Second // Default timeout + } + return &DNSAnalyzer{ + Timeout: timeout, + resolver: &net.Resolver{ + PreferGo: true, + }, + } +} + +// DNSResults represents DNS validation results for an email +type DNSResults struct { + Domain string + MXRecords []MXRecord + SPFRecord *SPFRecord + DKIMRecords []DKIMRecord + DMARCRecord *DMARCRecord + Errors []string +} + +// MXRecord represents an MX record +type MXRecord struct { + Host string + Priority uint16 + Valid bool + Error string +} + +// SPFRecord represents an SPF record +type SPFRecord struct { + Record string + Valid bool + Error string +} + +// DKIMRecord represents a DKIM record +type DKIMRecord struct { + Selector string + Domain string + Record string + Valid bool + Error string +} + +// DMARCRecord represents a DMARC record +type DMARCRecord struct { + Record string + Policy string // none, quarantine, reject + Valid bool + Error string +} + +// AnalyzeDNS performs DNS validation for the email's domain +func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults { + // Extract domain from From address + domain := d.extractDomain(email) + if domain == "" { + return &DNSResults{ + Errors: []string{"Unable to extract domain from email"}, + } + } + + results := &DNSResults{ + Domain: domain, + } + + // Check MX records + results.MXRecords = d.checkMXRecords(domain) + + // Check SPF record + results.SPFRecord = d.checkSPFRecord(domain) + + // Check DKIM records (from authentication results) + if authResults != nil && authResults.Dkim != nil { + for _, dkim := range *authResults.Dkim { + if dkim.Domain != nil && dkim.Selector != nil { + dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector) + if dkimRecord != nil { + results.DKIMRecords = append(results.DKIMRecords, *dkimRecord) + } + } + } + } + + // Check DMARC record + results.DMARCRecord = d.checkDMARCRecord(domain) + + return results +} + +// extractDomain extracts the domain from the email's From address +func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string { + if email.From != nil && email.From.Address != "" { + parts := strings.Split(email.From.Address, "@") + if len(parts) == 2 { + return strings.ToLower(strings.TrimSpace(parts[1])) + } + } + return "" +} + +// checkMXRecords looks up MX records for a domain +func (d *DNSAnalyzer) checkMXRecords(domain string) []MXRecord { + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + mxRecords, err := d.resolver.LookupMX(ctx, domain) + if err != nil { + return []MXRecord{ + { + Valid: false, + Error: fmt.Sprintf("Failed to lookup MX records: %v", err), + }, + } + } + + if len(mxRecords) == 0 { + return []MXRecord{ + { + Valid: false, + Error: "No MX records found", + }, + } + } + + var results []MXRecord + for _, mx := range mxRecords { + results = append(results, MXRecord{ + Host: mx.Host, + Priority: mx.Pref, + Valid: true, + }) + } + + return results +} + +// checkSPFRecord looks up and validates SPF record for a domain +func (d *DNSAnalyzer) checkSPFRecord(domain string) *SPFRecord { + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, domain) + if err != nil { + return &SPFRecord{ + Valid: false, + Error: fmt.Sprintf("Failed to lookup TXT records: %v", err), + } + } + + // Find SPF record (starts with "v=spf1") + var spfRecord string + spfCount := 0 + for _, txt := range txtRecords { + if strings.HasPrefix(txt, "v=spf1") { + spfRecord = txt + spfCount++ + } + } + + if spfCount == 0 { + return &SPFRecord{ + Valid: false, + Error: "No SPF record found", + } + } + + if spfCount > 1 { + return &SPFRecord{ + Record: spfRecord, + Valid: false, + Error: "Multiple SPF records found (RFC violation)", + } + } + + // Basic validation + if !d.validateSPF(spfRecord) { + return &SPFRecord{ + Record: spfRecord, + Valid: false, + Error: "SPF record appears malformed", + } + } + + return &SPFRecord{ + Record: spfRecord, + Valid: true, + } +} + +// validateSPF performs basic SPF record validation +func (d *DNSAnalyzer) validateSPF(record string) bool { + // Must start with v=spf1 + if !strings.HasPrefix(record, "v=spf1") { + return false + } + + // Check for common syntax issues + // Should have a final mechanism (all, +all, -all, ~all, ?all) + validEndings := []string{" all", " +all", " -all", " ~all", " ?all"} + hasValidEnding := false + for _, ending := range validEndings { + if strings.HasSuffix(record, ending) { + hasValidEnding = true + break + } + } + + return hasValidEnding +} + +// checkDKIMRecord looks up and validates DKIM record for a domain and selector +func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord { + // DKIM records are at: selector._domainkey.domain + dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) + if err != nil { + return &DKIMRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: fmt.Sprintf("Failed to lookup DKIM record: %v", err), + } + } + + if len(txtRecords) == 0 { + return &DKIMRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: "No DKIM record found", + } + } + + // Concatenate all TXT record parts (DKIM can be split) + dkimRecord := strings.Join(txtRecords, "") + + // Basic validation - should contain "v=DKIM1" and "p=" (public key) + if !d.validateDKIM(dkimRecord) { + return &DKIMRecord{ + Selector: selector, + Domain: domain, + Record: dkimRecord, + Valid: false, + Error: "DKIM record appears malformed", + } + } + + return &DKIMRecord{ + Selector: selector, + Domain: domain, + Record: dkimRecord, + Valid: true, + } +} + +// validateDKIM performs basic DKIM record validation +func (d *DNSAnalyzer) validateDKIM(record string) bool { + // Should contain p= tag (public key) + if !strings.Contains(record, "p=") { + return false + } + + // Often contains v=DKIM1 but not required + // If v= is present, it should be DKIM1 + if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") { + return false + } + + return true +} + +// checkDMARCRecord looks up and validates DMARC record for a domain +func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord { + // DMARC records are at: _dmarc.domain + dmarcDomain := fmt.Sprintf("_dmarc.%s", domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain) + if err != nil { + return &DMARCRecord{ + Valid: false, + Error: fmt.Sprintf("Failed to lookup DMARC record: %v", err), + } + } + + // Find DMARC record (starts with "v=DMARC1") + var dmarcRecord string + for _, txt := range txtRecords { + if strings.HasPrefix(txt, "v=DMARC1") { + dmarcRecord = txt + break + } + } + + if dmarcRecord == "" { + return &DMARCRecord{ + Valid: false, + Error: "No DMARC record found", + } + } + + // Extract policy + policy := d.extractDMARCPolicy(dmarcRecord) + + // Basic validation + if !d.validateDMARC(dmarcRecord) { + return &DMARCRecord{ + Record: dmarcRecord, + Policy: policy, + Valid: false, + Error: "DMARC record appears malformed", + } + } + + return &DMARCRecord{ + Record: dmarcRecord, + Policy: policy, + Valid: true, + } +} + +// extractDMARCPolicy extracts the policy from a DMARC record +func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { + // Look for p=none, p=quarantine, or p=reject + re := regexp.MustCompile(`p=(none|quarantine|reject)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return matches[1] + } + return "unknown" +} + +// validateDMARC performs basic DMARC record validation +func (d *DNSAnalyzer) validateDMARC(record string) bool { + // Must start with v=DMARC1 + if !strings.HasPrefix(record, "v=DMARC1") { + return false + } + + // Must have a policy tag + if !strings.Contains(record, "p=") { + return false + } + + return true +} + +// GenerateDNSChecks generates check results for DNS validation +func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check { + var checks []api.Check + + if results == nil { + return checks + } + + // MX record check + checks = append(checks, d.generateMXCheck(results)) + + // SPF record check + if results.SPFRecord != nil { + checks = append(checks, d.generateSPFCheck(results.SPFRecord)) + } + + // DKIM record checks + for _, dkim := range results.DKIMRecords { + checks = append(checks, d.generateDKIMCheck(&dkim)) + } + + // DMARC record check + if results.DMARCRecord != nil { + checks = append(checks, d.generateDMARCCheck(results.DMARCRecord)) + } + + return checks +} + +// generateMXCheck creates a check for MX records +func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check { + check := api.Check{ + Category: api.Dns, + Name: "MX Records", + } + + if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid { + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Severity = api.PtrTo(api.Critical) + + if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" { + check.Message = results.MXRecords[0].Error + } else { + check.Message = "No valid MX records found" + } + check.Advice = api.PtrTo("Configure MX records for your domain to receive email") + } else { + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Severity = api.PtrTo(api.Info) + check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords)) + + // Add details about MX records + var mxList []string + for _, mx := range results.MXRecords { + mxList = append(mxList, fmt.Sprintf("%s (priority %d)", mx.Host, mx.Priority)) + } + details := strings.Join(mxList, ", ") + check.Details = &details + check.Advice = api.PtrTo("Your MX records are properly configured") + } + + return check +} + +// generateSPFCheck creates a check for SPF records +func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check { + check := api.Check{ + Category: api.Dns, + Name: "SPF Record", + } + + if !spf.Valid { + // If no record exists at all, it's a failure + if spf.Record == "" { + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Message = spf.Error + check.Severity = api.PtrTo(api.High) + check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability") + } else { + // If record exists but is invalid, it's a warning + check.Status = api.CheckStatusWarn + check.Score = 0.5 + check.Message = "SPF record found but appears invalid" + check.Severity = api.PtrTo(api.Medium) + check.Advice = api.PtrTo("Review and fix your SPF record syntax") + check.Details = &spf.Record + } + } else { + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Message = "Valid SPF record found" + check.Severity = api.PtrTo(api.Info) + check.Details = &spf.Record + check.Advice = api.PtrTo("Your SPF record is properly configured") + } + + return check +} + +// generateDKIMCheck creates a check for DKIM records +func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check { + check := api.Check{ + Category: api.Dns, + Name: fmt.Sprintf("DKIM Record (%s)", dkim.Selector), + } + + if !dkim.Valid { + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error) + check.Severity = api.PtrTo(api.High) + check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used") + details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) + check.Details = &details + } else { + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Message = "Valid DKIM record found" + check.Severity = api.PtrTo(api.Info) + details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) + check.Details = &details + check.Advice = api.PtrTo("Your DKIM record is properly published") + } + + return check +} + +// generateDMARCCheck creates a check for DMARC records +func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check { + check := api.Check{ + Category: api.Dns, + Name: "DMARC Record", + } + + if !dmarc.Valid { + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Message = dmarc.Error + check.Severity = api.PtrTo(api.High) + check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing") + } else { + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy) + check.Severity = api.PtrTo(api.Info) + check.Details = &dmarc.Record + + // Provide advice based on policy + switch dmarc.Policy { + case "none": + advice := "DMARC policy is set to 'none' (monitoring only). Consider upgrading to 'quarantine' or 'reject' for better protection" + check.Advice = &advice + case "quarantine": + advice := "DMARC policy is set to 'quarantine'. This provides good protection" + check.Advice = &advice + case "reject": + advice := "DMARC policy is set to 'reject'. This provides the strongest protection" + check.Advice = &advice + default: + advice := "Your DMARC record is properly configured" + check.Advice = &advice + } + } + + return check +} diff --git a/internal/analyzer/dns_test.go b/internal/analyzer/dns_test.go new file mode 100644 index 0000000..fe501d5 --- /dev/null +++ b/internal/analyzer/dns_test.go @@ -0,0 +1,633 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "net/mail" + "strings" + "testing" + "time" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestNewDNSAnalyzer(t *testing.T) { + tests := []struct { + name string + timeout time.Duration + expectedTimeout time.Duration + }{ + { + name: "Default timeout", + timeout: 0, + expectedTimeout: 10 * time.Second, + }, + { + name: "Custom timeout", + timeout: 5 * time.Second, + expectedTimeout: 5 * time.Second, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + analyzer := NewDNSAnalyzer(tt.timeout) + if analyzer.Timeout != tt.expectedTimeout { + t.Errorf("Timeout = %v, want %v", analyzer.Timeout, tt.expectedTimeout) + } + if analyzer.resolver == nil { + t.Error("Resolver should not be nil") + } + }) + } +} + +func TestExtractDomain(t *testing.T) { + tests := []struct { + name string + fromAddress string + expectedDomain string + }{ + { + name: "Valid email", + fromAddress: "user@example.com", + expectedDomain: "example.com", + }, + { + name: "Email with subdomain", + fromAddress: "user@mail.example.com", + expectedDomain: "mail.example.com", + }, + { + name: "Email with uppercase", + fromAddress: "User@Example.COM", + expectedDomain: "example.com", + }, + { + name: "Invalid email (no @)", + fromAddress: "invalid-email", + expectedDomain: "", + }, + { + name: "Empty email", + fromAddress: "", + expectedDomain: "", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: make(mail.Header), + } + if tt.fromAddress != "" { + email.From = &mail.Address{ + Address: tt.fromAddress, + } + } + + domain := analyzer.extractDomain(email) + if domain != tt.expectedDomain { + t.Errorf("extractDomain() = %q, want %q", domain, tt.expectedDomain) + } + }) + } +} + +func TestValidateSPF(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid SPF with -all", + record: "v=spf1 include:_spf.example.com -all", + expected: true, + }, + { + name: "Valid SPF with ~all", + record: "v=spf1 ip4:192.0.2.0/24 ~all", + expected: true, + }, + { + name: "Valid SPF with +all", + record: "v=spf1 +all", + expected: true, + }, + { + name: "Valid SPF with ?all", + record: "v=spf1 mx ?all", + expected: true, + }, + { + name: "Invalid SPF - no version", + record: "include:_spf.example.com -all", + expected: false, + }, + { + name: "Invalid SPF - no all mechanism", + record: "v=spf1 include:_spf.example.com", + expected: false, + }, + { + name: "Invalid SPF - wrong version", + record: "v=spf2 include:_spf.example.com -all", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateSPF(tt.record) + if result != tt.expected { + t.Errorf("validateSPF(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} + +func TestValidateDKIM(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid DKIM with version", + record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", + expected: true, + }, + { + name: "Valid DKIM without version", + record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", + expected: true, + }, + { + name: "Invalid DKIM - no public key", + record: "v=DKIM1; k=rsa", + expected: false, + }, + { + name: "Invalid DKIM - wrong version", + record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...", + expected: false, + }, + { + name: "Invalid DKIM - empty", + record: "", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateDKIM(tt.record) + if result != tt.expected { + t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} + +func TestExtractDMARCPolicy(t *testing.T) { + tests := []struct { + name string + record string + expectedPolicy string + }{ + { + name: "Policy none", + record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com", + expectedPolicy: "none", + }, + { + name: "Policy quarantine", + record: "v=DMARC1; p=quarantine; pct=100", + expectedPolicy: "quarantine", + }, + { + name: "Policy reject", + record: "v=DMARC1; p=reject; sp=reject", + expectedPolicy: "reject", + }, + { + name: "No policy", + record: "v=DMARC1", + expectedPolicy: "unknown", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCPolicy(tt.record) + if result != tt.expectedPolicy { + t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy) + } + }) + } +} + +func TestValidateDMARC(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid DMARC", + record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", + expected: true, + }, + { + name: "Valid DMARC minimal", + record: "v=DMARC1; p=none", + expected: true, + }, + { + name: "Invalid DMARC - no version", + record: "p=quarantine", + expected: false, + }, + { + name: "Invalid DMARC - no policy", + record: "v=DMARC1", + expected: false, + }, + { + name: "Invalid DMARC - wrong version", + record: "v=DMARC2; p=reject", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateDMARC(tt.record) + if result != tt.expected { + t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} + +func TestGenerateMXCheck(t *testing.T) { + tests := []struct { + name string + results *DNSResults + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "Valid MX records", + results: &DNSResults{ + Domain: "example.com", + MXRecords: []MXRecord{ + {Host: "mail.example.com", Priority: 10, Valid: true}, + {Host: "mail2.example.com", Priority: 20, Valid: true}, + }, + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 1.0, + }, + { + name: "No MX records", + results: &DNSResults{ + Domain: "example.com", + MXRecords: []MXRecord{ + {Valid: false, Error: "No MX records found"}, + }, + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, + }, + { + name: "MX lookup failed", + results: &DNSResults{ + Domain: "example.com", + MXRecords: []MXRecord{ + {Valid: false, Error: "DNS lookup failed"}, + }, + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateMXCheck(tt.results) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Dns { + t.Errorf("Category = %v, want %v", check.Category, api.Dns) + } + }) + } +} + +func TestGenerateSPFCheck(t *testing.T) { + tests := []struct { + name string + spf *SPFRecord + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "Valid SPF", + spf: &SPFRecord{ + Record: "v=spf1 include:_spf.example.com -all", + Valid: true, + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 1.0, + }, + { + name: "Invalid SPF", + spf: &SPFRecord{ + Record: "v=spf1 invalid syntax", + Valid: false, + Error: "SPF record appears malformed", + }, + expectedStatus: api.CheckStatusWarn, + expectedScore: 0.5, + }, + { + name: "No SPF record", + spf: &SPFRecord{ + Valid: false, + Error: "No SPF record found", + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateSPFCheck(tt.spf) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Dns { + t.Errorf("Category = %v, want %v", check.Category, api.Dns) + } + }) + } +} + +func TestGenerateDKIMCheck(t *testing.T) { + tests := []struct { + name string + dkim *DKIMRecord + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "Valid DKIM", + dkim: &DKIMRecord{ + Selector: "default", + Domain: "example.com", + Record: "v=DKIM1; k=rsa; p=MIGfMA0...", + Valid: true, + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 1.0, + }, + { + name: "Invalid DKIM", + dkim: &DKIMRecord{ + Selector: "default", + Domain: "example.com", + Valid: false, + Error: "No DKIM record found", + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateDKIMCheck(tt.dkim) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Dns { + t.Errorf("Category = %v, want %v", check.Category, api.Dns) + } + if !strings.Contains(check.Name, tt.dkim.Selector) { + t.Errorf("Check name should contain selector %s", tt.dkim.Selector) + } + }) + } +} + +func TestGenerateDMARCCheck(t *testing.T) { + tests := []struct { + name string + dmarc *DMARCRecord + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "Valid DMARC - reject", + dmarc: &DMARCRecord{ + Record: "v=DMARC1; p=reject", + Policy: "reject", + Valid: true, + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 1.0, + }, + { + name: "Valid DMARC - quarantine", + dmarc: &DMARCRecord{ + Record: "v=DMARC1; p=quarantine", + Policy: "quarantine", + Valid: true, + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 1.0, + }, + { + name: "Valid DMARC - none", + dmarc: &DMARCRecord{ + Record: "v=DMARC1; p=none", + Policy: "none", + Valid: true, + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 1.0, + }, + { + name: "No DMARC record", + dmarc: &DMARCRecord{ + Valid: false, + Error: "No DMARC record found", + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateDMARCCheck(tt.dmarc) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Dns { + t.Errorf("Category = %v, want %v", check.Category, api.Dns) + } + + // Check that advice mentions policy for valid DMARC + if tt.dmarc.Valid && check.Advice != nil { + if tt.dmarc.Policy == "none" && !strings.Contains(*check.Advice, "none") { + t.Error("Advice should mention 'none' policy") + } + } + }) + } +} + +func TestGenerateDNSChecks(t *testing.T) { + tests := []struct { + name string + results *DNSResults + minChecks int + }{ + { + name: "Nil results", + results: nil, + minChecks: 0, + }, + { + name: "Complete results", + results: &DNSResults{ + Domain: "example.com", + MXRecords: []MXRecord{ + {Host: "mail.example.com", Priority: 10, Valid: true}, + }, + SPFRecord: &SPFRecord{ + Record: "v=spf1 include:_spf.example.com -all", + Valid: true, + }, + DKIMRecords: []DKIMRecord{ + { + Selector: "default", + Domain: "example.com", + Valid: true, + }, + }, + DMARCRecord: &DMARCRecord{ + Record: "v=DMARC1; p=quarantine", + Policy: "quarantine", + Valid: true, + }, + }, + minChecks: 4, // MX, SPF, DKIM, DMARC + }, + { + name: "Partial results", + results: &DNSResults{ + Domain: "example.com", + MXRecords: []MXRecord{ + {Host: "mail.example.com", Priority: 10, Valid: true}, + }, + }, + minChecks: 1, // Only MX + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checks := analyzer.GenerateDNSChecks(tt.results) + + if len(checks) < tt.minChecks { + t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) + } + + // Verify all checks have the DNS category + for _, check := range checks { + if check.Category != api.Dns { + t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Dns) + } + } + }) + } +} + +func TestAnalyzeDNS_NoDomain(t *testing.T) { + analyzer := NewDNSAnalyzer(5 * time.Second) + email := &EmailMessage{ + Header: make(mail.Header), + // No From address + } + + results := analyzer.AnalyzeDNS(email, nil) + + if results == nil { + t.Fatal("Expected results, got nil") + } + + if len(results.Errors) == 0 { + t.Error("Expected error when no domain can be extracted") + } +} diff --git a/pkg/analyzer/parser.go b/internal/analyzer/parser.go similarity index 79% rename from pkg/analyzer/parser.go rename to internal/analyzer/parser.go index 00de151..13c012c 100644 --- a/pkg/analyzer/parser.go +++ b/internal/analyzer/parser.go @@ -211,27 +211,8 @@ func buildRawHeaders(header mail.Header) string { } // GetAuthenticationResults extracts Authentication-Results headers -// If receiverHostname is provided, only returns headers that begin with that hostname -func (e *EmailMessage) GetAuthenticationResults(receiverHostname string) []string { - allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] - - // If no hostname specified, return all results - if receiverHostname == "" { - return allResults - } - - // Filter results that begin with the specified hostname - var filtered []string - prefix := receiverHostname + ";" - for _, result := range allResults { - // Trim whitespace and check if it starts with hostname; - trimmed := strings.TrimSpace(result) - if strings.HasPrefix(trimmed, prefix) { - filtered = append(filtered, result) - } - } - - return filtered +func (e *EmailMessage) GetAuthenticationResults() []string { + return e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] } // GetSpamAssassinHeaders extracts SpamAssassin-related headers @@ -249,33 +230,6 @@ func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string { } for _, headerName := range saHeaders { - if values, ok := e.Header[headerName]; ok && len(values) > 0 { - for _, value := range values { - if strings.TrimSpace(value) != "" { - headers[headerName] = value - break - } - } - } else if value := e.Header.Get(headerName); value != "" { - headers[headerName] = value - } - } - - return headers -} - -// GetRspamdHeaders extracts rspamd-related headers -func (e *EmailMessage) GetRspamdHeaders() map[string]string { - headers := make(map[string]string) - - rspamdHeaders := []string{ - "X-Spamd-Result", - "X-Rspamd-Score", - "X-Rspamd-Action", - "X-Rspamd-Server", - } - - for _, headerName := range rspamdHeaders { if value := e.Header.Get(headerName); value != "" { headers[headerName] = value } @@ -321,20 +275,3 @@ func (e *EmailMessage) GetHeaderValue(key string) string { func (e *EmailMessage) HasHeader(key string) bool { return e.Header.Get(key) != "" } - -// GetListUnsubscribeURLs parses the List-Unsubscribe header and returns all URLs. -// The header format is: , , ... -func (e *EmailMessage) GetListUnsubscribeURLs() []string { - value := e.Header.Get("List-Unsubscribe") - if value == "" { - return nil - } - var urls []string - for _, part := range strings.Split(value, ",") { - part = strings.TrimSpace(part) - if strings.HasPrefix(part, "<") && strings.HasSuffix(part, ">") { - urls = append(urls, part[1:len(part)-1]) - } - } - return urls -} diff --git a/pkg/analyzer/parser_test.go b/internal/analyzer/parser_test.go similarity index 98% rename from pkg/analyzer/parser_test.go rename to internal/analyzer/parser_test.go index 196e8e2..571f542 100644 --- a/pkg/analyzer/parser_test.go +++ b/internal/analyzer/parser_test.go @@ -120,7 +120,7 @@ Body content. t.Fatalf("Failed to parse email: %v", err) } - authResults := email.GetAuthenticationResults("example.com") + authResults := email.GetAuthenticationResults() if len(authResults) != 2 { t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults)) } diff --git a/internal/analyzer/rbl.go b/internal/analyzer/rbl.go new file mode 100644 index 0000000..be7366c --- /dev/null +++ b/internal/analyzer/rbl.go @@ -0,0 +1,408 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "fmt" + "net" + "regexp" + "strings" + "time" + + "git.happydns.org/happyDeliver/internal/api" +) + +// RBLChecker checks IP addresses against DNS-based blacklists +type RBLChecker struct { + Timeout time.Duration + RBLs []string + resolver *net.Resolver +} + +// DefaultRBLs is a list of commonly used RBL providers +var DefaultRBLs = []string{ + "zen.spamhaus.org", // Spamhaus combined list + "bl.spamcop.net", // SpamCop + "dnsbl.sorbs.net", // SORBS + "b.barracudacentral.org", // Barracuda + "cbl.abuseat.org", // CBL (Composite Blocking List) + "dnsbl-1.uceprotect.net", // UCEPROTECT Level 1 +} + +// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list +func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker { + if timeout == 0 { + timeout = 5 * time.Second // Default timeout + } + if len(rbls) == 0 { + rbls = DefaultRBLs + } + return &RBLChecker{ + Timeout: timeout, + RBLs: rbls, + resolver: &net.Resolver{ + PreferGo: true, + }, + } +} + +// RBLResults represents the results of RBL checks +type RBLResults struct { + Checks []RBLCheck + IPsChecked []string + ListedCount int +} + +// RBLCheck represents a single RBL check result +type RBLCheck struct { + IP string + RBL string + Listed bool + Response string + Error string +} + +// CheckEmail checks all IPs found in the email headers against RBLs +func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { + results := &RBLResults{} + + // Extract IPs from Received headers + ips := r.extractIPs(email) + if len(ips) == 0 { + return results + } + + results.IPsChecked = ips + + // Check each IP against all RBLs + for _, ip := range ips { + for _, rbl := range r.RBLs { + check := r.checkIP(ip, rbl) + results.Checks = append(results.Checks, check) + if check.Listed { + results.ListedCount++ + } + } + } + + return results +} + +// extractIPs extracts IP addresses from Received headers +func (r *RBLChecker) extractIPs(email *EmailMessage) []string { + var ips []string + seenIPs := make(map[string]bool) + + // Get all Received headers + receivedHeaders := email.Header["Received"] + + // Regex patterns for IP addresses + // Match IPv4: xxx.xxx.xxx.xxx + ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`) + + // Look for IPs in Received headers + for _, received := range receivedHeaders { + // Find all IPv4 addresses + matches := ipv4Pattern.FindAllString(received, -1) + for _, match := range matches { + // Skip private/reserved IPs + if !r.isPublicIP(match) { + continue + } + // Avoid duplicates + if !seenIPs[match] { + ips = append(ips, match) + seenIPs[match] = true + } + } + } + + // If no IPs found in Received headers, try X-Originating-IP + if len(ips) == 0 { + originatingIP := email.Header.Get("X-Originating-IP") + if originatingIP != "" { + // Extract IP from formats like "[192.0.2.1]" or "192.0.2.1" + cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]") + // Remove any whitespace + cleanIP = strings.TrimSpace(cleanIP) + matches := ipv4Pattern.FindString(cleanIP) + if matches != "" && r.isPublicIP(matches) { + ips = append(ips, matches) + } + } + } + + return ips +} + +// isPublicIP checks if an IP address is public (not private, loopback, or reserved) +func (r *RBLChecker) isPublicIP(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + + // Check if it's a private network + if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return false + } + + // Additional checks for reserved ranges + // 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3) + if ip.IsUnspecified() { + return false + } + + return true +} + +// checkIP checks a single IP against a single RBL +func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck { + check := RBLCheck{ + IP: ip, + RBL: rbl, + } + + // Reverse the IP for DNSBL query + reversedIP := r.reverseIP(ip) + if reversedIP == "" { + check.Error = "Failed to reverse IP address" + return check + } + + // Construct DNSBL query: reversed-ip.rbl-domain + query := fmt.Sprintf("%s.%s", reversedIP, rbl) + + // Perform DNS lookup with timeout + ctx, cancel := context.WithTimeout(context.Background(), r.Timeout) + defer cancel() + + addrs, err := r.resolver.LookupHost(ctx, query) + if err != nil { + // Most likely not listed (NXDOMAIN) + if dnsErr, ok := err.(*net.DNSError); ok { + if dnsErr.IsNotFound { + check.Listed = false + return check + } + } + // Other DNS errors + check.Error = fmt.Sprintf("DNS lookup failed: %v", err) + return check + } + + // If we got a response, the IP is listed + if len(addrs) > 0 { + check.Listed = true + check.Response = addrs[0] // Return code (e.g., 127.0.0.2) + } + + return check +} + +// reverseIP reverses an IPv4 address for DNSBL queries +// Example: 192.0.2.1 -> 1.2.0.192 +func (r *RBLChecker) reverseIP(ipStr string) string { + ip := net.ParseIP(ipStr) + if ip == nil { + return "" + } + + // Convert to IPv4 + ipv4 := ip.To4() + if ipv4 == nil { + return "" // IPv6 not supported yet + } + + // Reverse the octets + return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]) +} + +// GetBlacklistScore calculates the blacklist contribution to deliverability (0-2 points) +// Scoring: +// - Not listed on any RBL: 2 points (excellent) +// - Listed on 1 RBL: 1 point (warning) +// - Listed on 2-3 RBLs: 0.5 points (poor) +// - Listed on 4+ RBLs: 0 points (critical) +func (r *RBLChecker) GetBlacklistScore(results *RBLResults) float32 { + if results == nil || len(results.IPsChecked) == 0 { + // No IPs to check, give benefit of doubt + return 2.0 + } + + listedCount := results.ListedCount + + if listedCount == 0 { + return 2.0 + } else if listedCount == 1 { + return 1.0 + } else if listedCount <= 3 { + return 0.5 + } + + return 0.0 +} + +// GenerateRBLChecks generates check results for RBL analysis +func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check { + var checks []api.Check + + if results == nil { + return checks + } + + // If no IPs were checked, add a warning + if len(results.IPsChecked) == 0 { + checks = append(checks, api.Check{ + Category: api.Blacklist, + Name: "RBL Check", + Status: api.CheckStatusWarn, + Score: 1.0, + Message: "No public IP addresses found to check", + Severity: api.PtrTo(api.Low), + Advice: api.PtrTo("Unable to extract sender IP from email headers"), + }) + return checks + } + + // Create a summary check + summaryCheck := r.generateSummaryCheck(results) + checks = append(checks, summaryCheck) + + // Create individual checks for each listing + for _, check := range results.Checks { + if check.Listed { + detailCheck := r.generateListingCheck(&check) + checks = append(checks, detailCheck) + } + } + + return checks +} + +// generateSummaryCheck creates an overall RBL summary check +func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check { + check := api.Check{ + Category: api.Blacklist, + Name: "RBL Summary", + } + + score := r.GetBlacklistScore(results) + check.Score = score + + totalChecks := len(results.Checks) + listedCount := results.ListedCount + + if listedCount == 0 { + check.Status = api.CheckStatusPass + check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs)) + check.Severity = api.PtrTo(api.Info) + check.Advice = api.PtrTo("Your sending IP has a good reputation") + } else if listedCount == 1 { + check.Status = api.CheckStatusWarn + check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks) + check.Severity = api.PtrTo(api.Medium) + check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate") + } else if listedCount <= 3 { + check.Status = api.CheckStatusWarn + check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks) + check.Severity = api.PtrTo(api.High) + check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action") + } else { + check.Status = api.CheckStatusFail + check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks) + check.Severity = api.PtrTo(api.Critical) + check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL") + } + + // Add details about IPs checked + if len(results.IPsChecked) > 0 { + details := fmt.Sprintf("IPs checked: %s", strings.Join(results.IPsChecked, ", ")) + check.Details = &details + } + + return check +} + +// generateListingCheck creates a check for a specific RBL listing +func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { + check := api.Check{ + Category: api.Blacklist, + Name: fmt.Sprintf("RBL: %s", rblCheck.RBL), + Status: api.CheckStatusFail, + Score: 0.0, + } + + check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL) + + // Determine severity based on which RBL + if strings.Contains(rblCheck.RBL, "spamhaus") { + check.Severity = api.PtrTo(api.Critical) + advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting") + check.Advice = &advice + } else if strings.Contains(rblCheck.RBL, "spamcop") { + check.Severity = api.PtrTo(api.High) + advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting") + check.Advice = &advice + } else { + check.Severity = api.PtrTo(api.High) + advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL) + check.Advice = &advice + } + + // Add response code details + if rblCheck.Response != "" { + details := fmt.Sprintf("Response: %s", rblCheck.Response) + check.Details = &details + } + + return check +} + +// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL +func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { + seenIPs := make(map[string]bool) + var listedIPs []string + + for _, check := range results.Checks { + if check.Listed && !seenIPs[check.IP] { + listedIPs = append(listedIPs, check.IP) + seenIPs[check.IP] = true + } + } + + return listedIPs +} + +// GetRBLsForIP returns all RBLs that list a specific IP +func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string { + var rbls []string + + for _, check := range results.Checks { + if check.IP == ip && check.Listed { + rbls = append(rbls, check.RBL) + } + } + + return rbls +} diff --git a/pkg/analyzer/rbl_test.go b/internal/analyzer/rbl_test.go similarity index 57% rename from pkg/analyzer/rbl_test.go rename to internal/analyzer/rbl_test.go index 8620038..a75ef19 100644 --- a/pkg/analyzer/rbl_test.go +++ b/internal/analyzer/rbl_test.go @@ -23,10 +23,11 @@ package analyzer import ( "net/mail" + "strings" "testing" "time" - "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/api" ) func TestNewRBLChecker(t *testing.T) { @@ -55,12 +56,12 @@ func TestNewRBLChecker(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - checker := NewRBLChecker(tt.timeout, tt.rbls, false) + checker := NewRBLChecker(tt.timeout, tt.rbls) if checker.Timeout != tt.expectedTimeout { t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout) } - if len(checker.Lists) != tt.expectedRBLs { - t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs) + if len(checker.RBLs) != tt.expectedRBLs { + t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs) } if checker.resolver == nil { t.Error("Resolver should not be nil") @@ -97,7 +98,7 @@ func TestReverseIP(t *testing.T) { }, } - checker := NewRBLChecker(5*time.Second, nil, false) + checker := NewRBLChecker(5*time.Second, nil) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -157,7 +158,7 @@ func TestIsPublicIP(t *testing.T) { }, } - checker := NewRBLChecker(5*time.Second, nil, false) + checker := NewRBLChecker(5*time.Second, nil) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -237,7 +238,7 @@ func TestExtractIPs(t *testing.T) { },*/ } - checker := NewRBLChecker(5*time.Second, nil, false) + checker := NewRBLChecker(5*time.Second, nil) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -265,68 +266,68 @@ func TestExtractIPs(t *testing.T) { func TestGetBlacklistScore(t *testing.T) { tests := []struct { name string - results *DNSListResults - expectedScore int + results *RBLResults + expectedScore float32 }{ { name: "Nil results", results: nil, - expectedScore: 100, + expectedScore: 2.0, }, { name: "No IPs checked", - results: &DNSListResults{ + results: &RBLResults{ IPsChecked: []string{}, }, - expectedScore: 100, + expectedScore: 2.0, }, { name: "Not listed on any RBL", - results: &DNSListResults{ + results: &RBLResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 0, }, - expectedScore: 100, + expectedScore: 2.0, }, { name: "Listed on 1 RBL", - results: &DNSListResults{ + results: &RBLResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 1, }, - expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16) + expectedScore: 1.0, }, { name: "Listed on 2 RBLs", - results: &DNSListResults{ + results: &RBLResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 2, }, - expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33) + expectedScore: 0.5, }, { name: "Listed on 3 RBLs", - results: &DNSListResults{ + results: &RBLResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 3, }, - expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50) + expectedScore: 0.5, }, { name: "Listed on 4+ RBLs", - results: &DNSListResults{ + results: &RBLResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 4, }, - expectedScore: 34, // 100 - 4*100/6 = 34 (integer division: 400/6=66) + expectedScore: 0.0, }, } - checker := NewRBLChecker(5*time.Second, nil, false) + checker := NewRBLChecker(5*time.Second, nil) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score, _ := checker.CalculateScore(tt.results) + score := checker.GetBlacklistScore(tt.results) if score != tt.expectedScore { t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore) } @@ -334,24 +335,215 @@ func TestGetBlacklistScore(t *testing.T) { } } -func TestGetUniqueListedIPs(t *testing.T) { - results := &DNSListResults{ - Checks: map[string][]model.BlacklistCheck{ - "198.51.100.1": { - {Rbl: "zen.spamhaus.org", Listed: true}, - {Rbl: "bl.spamcop.net", Listed: true}, +func TestGenerateSummaryCheck(t *testing.T) { + tests := []struct { + name string + results *RBLResults + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "Not listed", + results: &RBLResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 0, + Checks: make([]RBLCheck, 6), // 6 default RBLs }, - "198.51.100.2": { - {Rbl: "zen.spamhaus.org", Listed: true}, - {Rbl: "bl.spamcop.net", Listed: false}, + expectedStatus: api.CheckStatusPass, + expectedScore: 2.0, + }, + { + name: "Listed on 1 RBL", + results: &RBLResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 1, + Checks: make([]RBLCheck, 6), }, - "198.51.100.3": { - {Rbl: "zen.spamhaus.org", Listed: false}, + expectedStatus: api.CheckStatusWarn, + expectedScore: 1.0, + }, + { + name: "Listed on 2 RBLs", + results: &RBLResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 2, + Checks: make([]RBLCheck, 6), }, + expectedStatus: api.CheckStatusWarn, + expectedScore: 0.5, + }, + { + name: "Listed on 4+ RBLs", + results: &RBLResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 4, + Checks: make([]RBLCheck, 6), + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, }, } - checker := NewRBLChecker(5*time.Second, nil, false) + checker := NewRBLChecker(5*time.Second, nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := checker.generateSummaryCheck(tt.results) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Blacklist { + t.Errorf("Category = %v, want %v", check.Category, api.Blacklist) + } + }) + } +} + +func TestGenerateListingCheck(t *testing.T) { + tests := []struct { + name string + rblCheck *RBLCheck + expectedStatus api.CheckStatus + expectedSeverity api.CheckSeverity + }{ + { + name: "Spamhaus listing", + rblCheck: &RBLCheck{ + IP: "198.51.100.1", + RBL: "zen.spamhaus.org", + Listed: true, + Response: "127.0.0.2", + }, + expectedStatus: api.CheckStatusFail, + expectedSeverity: api.Critical, + }, + { + name: "SpamCop listing", + rblCheck: &RBLCheck{ + IP: "198.51.100.1", + RBL: "bl.spamcop.net", + Listed: true, + Response: "127.0.0.2", + }, + expectedStatus: api.CheckStatusFail, + expectedSeverity: api.High, + }, + { + name: "Other RBL listing", + rblCheck: &RBLCheck{ + IP: "198.51.100.1", + RBL: "dnsbl.sorbs.net", + Listed: true, + Response: "127.0.0.2", + }, + expectedStatus: api.CheckStatusFail, + expectedSeverity: api.High, + }, + } + + checker := NewRBLChecker(5*time.Second, nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := checker.generateListingCheck(tt.rblCheck) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Severity == nil || *check.Severity != tt.expectedSeverity { + t.Errorf("Severity = %v, want %v", check.Severity, tt.expectedSeverity) + } + if check.Category != api.Blacklist { + t.Errorf("Category = %v, want %v", check.Category, api.Blacklist) + } + if !strings.Contains(check.Name, tt.rblCheck.RBL) { + t.Errorf("Check name should contain RBL name %s", tt.rblCheck.RBL) + } + }) + } +} + +func TestGenerateRBLChecks(t *testing.T) { + tests := []struct { + name string + results *RBLResults + minChecks int + }{ + { + name: "Nil results", + results: nil, + minChecks: 0, + }, + { + name: "No IPs checked", + results: &RBLResults{ + IPsChecked: []string{}, + }, + minChecks: 1, // Warning check + }, + { + name: "Not listed on any RBL", + results: &RBLResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 0, + Checks: []RBLCheck{ + {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: false}, + {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: false}, + }, + }, + minChecks: 1, // Summary check only + }, + { + name: "Listed on 2 RBLs", + results: &RBLResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 2, + Checks: []RBLCheck{ + {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true}, + {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true}, + {IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false}, + }, + }, + minChecks: 3, // Summary + 2 listing checks + }, + } + + checker := NewRBLChecker(5*time.Second, nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checks := checker.GenerateRBLChecks(tt.results) + + if len(checks) < tt.minChecks { + t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) + } + + // Verify all checks have the Blacklist category + for _, check := range checks { + if check.Category != api.Blacklist { + t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Blacklist) + } + } + }) + } +} + +func TestGetUniqueListedIPs(t *testing.T) { + results := &RBLResults{ + Checks: []RBLCheck{ + {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true}, + {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true}, + {IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true}, + {IP: "198.51.100.2", RBL: "bl.spamcop.net", Listed: false}, + {IP: "198.51.100.3", RBL: "zen.spamhaus.org", Listed: false}, + }, + } + + checker := NewRBLChecker(5*time.Second, nil) listedIPs := checker.GetUniqueListedIPs(results) expectedIPs := []string{"198.51.100.1", "198.51.100.2"} @@ -363,20 +555,16 @@ func TestGetUniqueListedIPs(t *testing.T) { } func TestGetRBLsForIP(t *testing.T) { - results := &DNSListResults{ - Checks: map[string][]model.BlacklistCheck{ - "198.51.100.1": { - {Rbl: "zen.spamhaus.org", Listed: true}, - {Rbl: "bl.spamcop.net", Listed: true}, - {Rbl: "dnsbl.sorbs.net", Listed: false}, - }, - "198.51.100.2": { - {Rbl: "zen.spamhaus.org", Listed: true}, - }, + results := &RBLResults{ + Checks: []RBLCheck{ + {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true}, + {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true}, + {IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false}, + {IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true}, }, } - checker := NewRBLChecker(5*time.Second, nil, false) + checker := NewRBLChecker(5*time.Second, nil) tests := []struct { name string @@ -402,7 +590,7 @@ func TestGetRBLsForIP(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rbls := checker.GetListsForIP(results, tt.ip) + rbls := checker.GetRBLsForIP(results, tt.ip) if len(rbls) != len(tt.expectedRBLs) { t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs)) diff --git a/internal/analyzer/report.go b/internal/analyzer/report.go new file mode 100644 index 0000000..fe30c6c --- /dev/null +++ b/internal/analyzer/report.go @@ -0,0 +1,348 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "time" + + "git.happydns.org/happyDeliver/internal/api" + "github.com/google/uuid" +) + +// ReportGenerator generates comprehensive deliverability reports +type ReportGenerator struct { + authAnalyzer *AuthenticationAnalyzer + spamAnalyzer *SpamAssassinAnalyzer + dnsAnalyzer *DNSAnalyzer + rblChecker *RBLChecker + contentAnalyzer *ContentAnalyzer + scorer *DeliverabilityScorer +} + +// NewReportGenerator creates a new report generator +func NewReportGenerator( + dnsTimeout time.Duration, + httpTimeout time.Duration, + rbls []string, +) *ReportGenerator { + return &ReportGenerator{ + authAnalyzer: NewAuthenticationAnalyzer(), + spamAnalyzer: NewSpamAssassinAnalyzer(), + dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), + rblChecker: NewRBLChecker(dnsTimeout, rbls), + contentAnalyzer: NewContentAnalyzer(httpTimeout), + scorer: NewDeliverabilityScorer(), + } +} + +// AnalysisResults contains all intermediate analysis results +type AnalysisResults struct { + Email *EmailMessage + Authentication *api.AuthenticationResults + SpamAssassin *SpamAssassinResult + DNS *DNSResults + RBL *RBLResults + Content *ContentResults + Score *ScoringResult +} + +// AnalyzeEmail performs complete email analysis +func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { + results := &AnalysisResults{ + Email: email, + } + + // Run all analyzers + results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) + results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) + results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication) + results.RBL = r.rblChecker.CheckEmail(email) + results.Content = r.contentAnalyzer.AnalyzeContent(email) + + // Calculate overall score + results.Score = r.scorer.CalculateScore( + results.Authentication, + results.SpamAssassin, + results.RBL, + results.Content, + email, + ) + + return results +} + +// GenerateReport creates a complete API report from analysis results +func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report { + reportID := uuid.New() + now := time.Now() + + report := &api.Report{ + Id: reportID, + TestId: testID, + Score: results.Score.OverallScore, + CreatedAt: now, + } + + // Build score summary + report.Summary = &api.ScoreSummary{ + AuthenticationScore: results.Score.AuthScore, + SpamScore: results.Score.SpamScore, + BlacklistScore: results.Score.BlacklistScore, + ContentScore: results.Score.ContentScore, + HeaderScore: results.Score.HeaderScore, + } + + // Collect all checks from different analyzers + checks := []api.Check{} + + // Authentication checks + if results.Authentication != nil { + authChecks := r.authAnalyzer.GenerateAuthenticationChecks(results.Authentication) + checks = append(checks, authChecks...) + } + + // DNS checks + if results.DNS != nil { + dnsChecks := r.dnsAnalyzer.GenerateDNSChecks(results.DNS) + checks = append(checks, dnsChecks...) + } + + // RBL checks + if results.RBL != nil { + rblChecks := r.rblChecker.GenerateRBLChecks(results.RBL) + checks = append(checks, rblChecks...) + } + + // SpamAssassin checks + if results.SpamAssassin != nil { + spamChecks := r.spamAnalyzer.GenerateSpamAssassinChecks(results.SpamAssassin) + checks = append(checks, spamChecks...) + } + + // Content checks + if results.Content != nil { + contentChecks := r.contentAnalyzer.GenerateContentChecks(results.Content) + checks = append(checks, contentChecks...) + } + + // Header checks + headerChecks := r.scorer.GenerateHeaderChecks(results.Email) + checks = append(checks, headerChecks...) + + report.Checks = checks + + // Add authentication results + report.Authentication = results.Authentication + + // Add SpamAssassin result + if results.SpamAssassin != nil { + report.Spamassassin = &api.SpamAssassinResult{ + Score: float32(results.SpamAssassin.Score), + RequiredScore: float32(results.SpamAssassin.RequiredScore), + IsSpam: results.SpamAssassin.IsSpam, + } + + if len(results.SpamAssassin.Tests) > 0 { + report.Spamassassin.Tests = &results.SpamAssassin.Tests + } + + if results.SpamAssassin.RawReport != "" { + report.Spamassassin.Report = &results.SpamAssassin.RawReport + } + } + + // Add DNS records + if results.DNS != nil { + dnsRecords := r.buildDNSRecords(results.DNS) + if len(dnsRecords) > 0 { + report.DnsRecords = &dnsRecords + } + } + + // Add blacklist checks + if results.RBL != nil && len(results.RBL.Checks) > 0 { + blacklistChecks := make([]api.BlacklistCheck, 0, len(results.RBL.Checks)) + for _, check := range results.RBL.Checks { + blCheck := api.BlacklistCheck{ + Ip: check.IP, + Rbl: check.RBL, + Listed: check.Listed, + } + if check.Response != "" { + blCheck.Response = &check.Response + } + blacklistChecks = append(blacklistChecks, blCheck) + } + report.Blacklists = &blacklistChecks + } + + // Add raw headers + if results.Email != nil && results.Email.RawHeaders != "" { + report.RawHeaders = &results.Email.RawHeaders + } + + return report +} + +// buildDNSRecords converts DNS analysis results to API DNS records +func (r *ReportGenerator) buildDNSRecords(dns *DNSResults) []api.DNSRecord { + records := []api.DNSRecord{} + + if dns == nil { + return records + } + + // MX records + if len(dns.MXRecords) > 0 { + for _, mx := range dns.MXRecords { + status := api.Found + if !mx.Valid { + if mx.Error != "" { + status = api.Missing + } else { + status = api.Invalid + } + } + + record := api.DNSRecord{ + Domain: dns.Domain, + RecordType: api.MX, + Status: status, + } + + if mx.Host != "" { + value := mx.Host + record.Value = &value + } + + records = append(records, record) + } + } + + // SPF record + if dns.SPFRecord != nil { + status := api.Found + if !dns.SPFRecord.Valid { + if dns.SPFRecord.Record == "" { + status = api.Missing + } else { + status = api.Invalid + } + } + + record := api.DNSRecord{ + Domain: dns.Domain, + RecordType: api.SPF, + Status: status, + } + + if dns.SPFRecord.Record != "" { + record.Value = &dns.SPFRecord.Record + } + + records = append(records, record) + } + + // DKIM records + for _, dkim := range dns.DKIMRecords { + status := api.Found + if !dkim.Valid { + if dkim.Record == "" { + status = api.Missing + } else { + status = api.Invalid + } + } + + record := api.DNSRecord{ + Domain: dkim.Domain, + RecordType: api.DKIM, + Status: status, + } + + if dkim.Record != "" { + // Include selector in value for clarity + value := dkim.Record + record.Value = &value + } + + records = append(records, record) + } + + // DMARC record + if dns.DMARCRecord != nil { + status := api.Found + if !dns.DMARCRecord.Valid { + if dns.DMARCRecord.Record == "" { + status = api.Missing + } else { + status = api.Invalid + } + } + + record := api.DNSRecord{ + Domain: dns.Domain, + RecordType: api.DMARC, + Status: status, + } + + if dns.DMARCRecord.Record != "" { + record.Value = &dns.DMARCRecord.Record + } + + records = append(records, record) + } + + return records +} + +// GenerateRawEmail returns the raw email message as a string +func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string { + if email == nil { + return "" + } + + raw := email.RawHeaders + if email.RawBody != "" { + raw += "\n" + email.RawBody + } + + return raw +} + +// GetRecommendations returns actionable recommendations based on the score +func (r *ReportGenerator) GetRecommendations(results *AnalysisResults) []string { + if results == nil || results.Score == nil { + return []string{} + } + + return results.Score.Recommendations +} + +// GetScoreSummaryText returns a human-readable score summary +func (r *ReportGenerator) GetScoreSummaryText(results *AnalysisResults) string { + if results == nil || results.Score == nil { + return "" + } + + return r.scorer.GetScoreSummary(results.Score) +} diff --git a/internal/analyzer/report_test.go b/internal/analyzer/report_test.go new file mode 100644 index 0000000..4a8fe00 --- /dev/null +++ b/internal/analyzer/report_test.go @@ -0,0 +1,501 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "net/mail" + "net/textproto" + "strings" + "testing" + "time" + + "git.happydns.org/happyDeliver/internal/api" + "github.com/google/uuid" +) + +func TestNewReportGenerator(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + if gen == nil { + t.Fatal("Expected report generator, got nil") + } + + if gen.authAnalyzer == nil { + t.Error("authAnalyzer should not be nil") + } + if gen.spamAnalyzer == nil { + t.Error("spamAnalyzer should not be nil") + } + if gen.dnsAnalyzer == nil { + t.Error("dnsAnalyzer should not be nil") + } + if gen.rblChecker == nil { + t.Error("rblChecker should not be nil") + } + if gen.contentAnalyzer == nil { + t.Error("contentAnalyzer should not be nil") + } + if gen.scorer == nil { + t.Error("scorer should not be nil") + } +} + +func TestAnalyzeEmail(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + + email := createTestEmail() + + results := gen.AnalyzeEmail(email) + + if results == nil { + t.Fatal("Expected analysis results, got nil") + } + + if results.Email == nil { + t.Error("Email should not be nil") + } + + if results.Authentication == nil { + t.Error("Authentication should not be nil") + } + + // SpamAssassin might be nil if headers don't exist + // DNS results should exist + // RBL results should exist + // Content results should exist + + if results.Score == nil { + t.Error("Score should not be nil") + } + + // Verify score is within bounds + if results.Score.OverallScore < 0 || results.Score.OverallScore > 10 { + t.Errorf("Overall score %v is out of bounds", results.Score.OverallScore) + } +} + +func TestGenerateReport(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + testID := uuid.New() + + email := createTestEmail() + results := gen.AnalyzeEmail(email) + + report := gen.GenerateReport(testID, results) + + if report == nil { + t.Fatal("Expected report, got nil") + } + + // Verify required fields + if report.Id == uuid.Nil { + t.Error("Report ID should not be empty") + } + + if report.TestId != testID { + t.Errorf("TestId = %s, want %s", report.TestId, testID) + } + + if report.Score < 0 || report.Score > 10 { + t.Errorf("Score %v is out of bounds", report.Score) + } + + if report.Summary == nil { + t.Error("Summary should not be nil") + } + + if len(report.Checks) == 0 { + t.Error("Checks should not be empty") + } + + // Verify score summary + if report.Summary != nil { + if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 3 { + t.Errorf("AuthenticationScore %v is out of bounds", report.Summary.AuthenticationScore) + } + if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 2 { + t.Errorf("SpamScore %v is out of bounds", report.Summary.SpamScore) + } + if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 2 { + t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore) + } + if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 2 { + t.Errorf("ContentScore %v is out of bounds", report.Summary.ContentScore) + } + if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 1 { + t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore) + } + } + + // Verify checks have required fields + for i, check := range report.Checks { + if string(check.Category) == "" { + t.Errorf("Check %d: Category should not be empty", i) + } + if check.Name == "" { + t.Errorf("Check %d: Name should not be empty", i) + } + if string(check.Status) == "" { + t.Errorf("Check %d: Status should not be empty", i) + } + if check.Message == "" { + t.Errorf("Check %d: Message should not be empty", i) + } + } +} + +func TestGenerateReportWithSpamAssassin(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + testID := uuid.New() + + email := createTestEmailWithSpamAssassin() + results := gen.AnalyzeEmail(email) + + report := gen.GenerateReport(testID, results) + + if report.Spamassassin == nil { + t.Error("SpamAssassin result should not be nil") + } + + if report.Spamassassin != nil { + if report.Spamassassin.Score == 0 && report.Spamassassin.RequiredScore == 0 { + t.Error("SpamAssassin scores should be set") + } + } +} + +func TestBuildDNSRecords(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + + tests := []struct { + name string + dns *DNSResults + expectedCount int + expectTypes []api.DNSRecordRecordType + }{ + { + name: "Nil DNS results", + dns: nil, + expectedCount: 0, + }, + { + name: "Complete DNS results", + dns: &DNSResults{ + Domain: "example.com", + MXRecords: []MXRecord{ + {Host: "mail.example.com", Priority: 10, Valid: true}, + }, + SPFRecord: &SPFRecord{ + Record: "v=spf1 include:_spf.example.com -all", + Valid: true, + }, + DKIMRecords: []DKIMRecord{ + { + Selector: "default", + Domain: "example.com", + Record: "v=DKIM1; k=rsa; p=...", + Valid: true, + }, + }, + DMARCRecord: &DMARCRecord{ + Record: "v=DMARC1; p=quarantine", + Valid: true, + }, + }, + expectedCount: 4, // MX, SPF, DKIM, DMARC + expectTypes: []api.DNSRecordRecordType{api.MX, api.SPF, api.DKIM, api.DMARC}, + }, + { + name: "Missing records", + dns: &DNSResults{ + Domain: "example.com", + SPFRecord: &SPFRecord{ + Valid: false, + Error: "No SPF record found", + }, + }, + expectedCount: 1, + expectTypes: []api.DNSRecordRecordType{api.SPF}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + records := gen.buildDNSRecords(tt.dns) + + if len(records) != tt.expectedCount { + t.Errorf("Got %d DNS records, want %d", len(records), tt.expectedCount) + } + + // Verify expected types are present + if tt.expectTypes != nil { + foundTypes := make(map[api.DNSRecordRecordType]bool) + for _, record := range records { + foundTypes[record.RecordType] = true + } + + for _, expectedType := range tt.expectTypes { + if !foundTypes[expectedType] { + t.Errorf("Expected DNS record type %s not found", expectedType) + } + } + } + + // Verify all records have required fields + for i, record := range records { + if record.Domain == "" { + t.Errorf("Record %d: Domain should not be empty", i) + } + if string(record.RecordType) == "" { + t.Errorf("Record %d: RecordType should not be empty", i) + } + if string(record.Status) == "" { + t.Errorf("Record %d: Status should not be empty", i) + } + } + }) + } +} + +func TestGenerateRawEmail(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + + tests := []struct { + name string + email *EmailMessage + expected string + }{ + { + name: "Nil email", + email: nil, + expected: "", + }, + { + name: "Email with headers only", + email: &EmailMessage{ + RawHeaders: "From: sender@example.com\nTo: recipient@example.com\n", + RawBody: "", + }, + expected: "From: sender@example.com\nTo: recipient@example.com\n", + }, + { + name: "Email with headers and body", + email: &EmailMessage{ + RawHeaders: "From: sender@example.com\n", + RawBody: "This is the email body", + }, + expected: "From: sender@example.com\n\nThis is the email body", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw := gen.GenerateRawEmail(tt.email) + if raw != tt.expected { + t.Errorf("GenerateRawEmail() = %q, want %q", raw, tt.expected) + } + }) + } +} + +func TestGetRecommendations(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + + tests := []struct { + name string + results *AnalysisResults + expectCount int + }{ + { + name: "Nil results", + results: nil, + expectCount: 0, + }, + { + name: "Results with score", + results: &AnalysisResults{ + Score: &ScoringResult{ + OverallScore: 5.0, + Rating: "Fair", + AuthScore: 1.5, + SpamScore: 1.0, + BlacklistScore: 1.5, + ContentScore: 0.5, + HeaderScore: 0.5, + Recommendations: []string{ + "Improve authentication", + "Fix content issues", + }, + }, + }, + expectCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recs := gen.GetRecommendations(tt.results) + if len(recs) != tt.expectCount { + t.Errorf("Got %d recommendations, want %d", len(recs), tt.expectCount) + } + }) + } +} + +func TestGetScoreSummaryText(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + + tests := []struct { + name string + results *AnalysisResults + expectEmpty bool + expectString string + }{ + { + name: "Nil results", + results: nil, + expectEmpty: true, + }, + { + name: "Results with score", + results: &AnalysisResults{ + Score: &ScoringResult{ + OverallScore: 8.5, + Rating: "Good", + AuthScore: 2.5, + SpamScore: 1.8, + BlacklistScore: 2.0, + ContentScore: 1.5, + HeaderScore: 0.7, + CategoryBreakdown: map[string]CategoryScore{ + "Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"}, + "Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"}, + "Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"}, + "Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"}, + "Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"}, + }, + }, + }, + expectEmpty: false, + expectString: "8.5/10", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + summary := gen.GetScoreSummaryText(tt.results) + if tt.expectEmpty { + if summary != "" { + t.Errorf("Expected empty summary, got %q", summary) + } + } else { + if summary == "" { + t.Error("Expected non-empty summary") + } + if tt.expectString != "" && !strings.Contains(summary, tt.expectString) { + t.Errorf("Summary should contain %q, got %q", tt.expectString, summary) + } + } + }) + } +} + +func TestReportCategories(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + testID := uuid.New() + + email := createComprehensiveTestEmail() + results := gen.AnalyzeEmail(email) + report := gen.GenerateReport(testID, results) + + // Verify all check categories are present + categories := make(map[api.CheckCategory]bool) + for _, check := range report.Checks { + categories[check.Category] = true + } + + expectedCategories := []api.CheckCategory{ + api.Authentication, + api.Dns, + api.Headers, + } + + for _, cat := range expectedCategories { + if !categories[cat] { + t.Errorf("Expected category %s not found in checks", cat) + } + } +} + +// Helper functions + +func createTestEmail() *EmailMessage { + header := make(mail.Header) + header[textproto.CanonicalMIMEHeaderKey("From")] = []string{"sender@example.com"} + header[textproto.CanonicalMIMEHeaderKey("To")] = []string{"recipient@example.com"} + header[textproto.CanonicalMIMEHeaderKey("Subject")] = []string{"Test Email"} + header[textproto.CanonicalMIMEHeaderKey("Date")] = []string{"Mon, 01 Jan 2024 12:00:00 +0000"} + header[textproto.CanonicalMIMEHeaderKey("Message-ID")] = []string{""} + + return &EmailMessage{ + Header: header, + From: &mail.Address{Address: "sender@example.com"}, + To: []*mail.Address{{Address: "recipient@example.com"}}, + Subject: "Test Email", + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{ + { + ContentType: "text/plain", + Content: "This is a test email", + IsText: true, + }, + }, + RawHeaders: "From: sender@example.com\nTo: recipient@example.com\nSubject: Test Email\nDate: Mon, 01 Jan 2024 12:00:00 +0000\nMessage-ID: \n", + RawBody: "This is a test email", + } +} + +func createTestEmailWithSpamAssassin() *EmailMessage { + email := createTestEmail() + email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Status")] = []string{"No, score=2.3 required=5.0"} + email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Score")] = []string{"2.3"} + email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Flag")] = []string{"NO"} + return email +} + +func createComprehensiveTestEmail() *EmailMessage { + email := createTestEmailWithSpamAssassin() + + // Add authentication headers + email.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] = []string{ + "example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com; dmarc=pass", + } + + // Add HTML content + email.Parts = append(email.Parts, MessagePart{ + ContentType: "text/html", + Content: "

Test

Link", + IsHTML: true, + }) + + return email +} diff --git a/internal/analyzer/scoring.go b/internal/analyzer/scoring.go new file mode 100644 index 0000000..07f6a34 --- /dev/null +++ b/internal/analyzer/scoring.go @@ -0,0 +1,506 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "fmt" + "strings" + "time" + + "git.happydns.org/happyDeliver/internal/api" +) + +// DeliverabilityScorer aggregates all analysis results and computes overall score +type DeliverabilityScorer struct{} + +// NewDeliverabilityScorer creates a new deliverability scorer +func NewDeliverabilityScorer() *DeliverabilityScorer { + return &DeliverabilityScorer{} +} + +// ScoringResult represents the complete scoring result +type ScoringResult struct { + OverallScore float32 + Rating string // Excellent, Good, Fair, Poor, Critical + AuthScore float32 + SpamScore float32 + BlacklistScore float32 + ContentScore float32 + HeaderScore float32 + Recommendations []string + CategoryBreakdown map[string]CategoryScore +} + +// CategoryScore represents score breakdown for a category +type CategoryScore struct { + Score float32 + MaxScore float32 + Percentage float32 + Status string // Pass, Warn, Fail +} + +// CalculateScore computes the overall deliverability score from all analyzers +func (s *DeliverabilityScorer) CalculateScore( + authResults *api.AuthenticationResults, + spamResult *SpamAssassinResult, + rblResults *RBLResults, + contentResults *ContentResults, + email *EmailMessage, +) *ScoringResult { + result := &ScoringResult{ + CategoryBreakdown: make(map[string]CategoryScore), + Recommendations: []string{}, + } + + // Calculate individual scores + authAnalyzer := NewAuthenticationAnalyzer() + result.AuthScore = authAnalyzer.GetAuthenticationScore(authResults) + + spamAnalyzer := NewSpamAssassinAnalyzer() + result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult) + + rblChecker := NewRBLChecker(10*time.Second, DefaultRBLs) + result.BlacklistScore = rblChecker.GetBlacklistScore(rblResults) + + contentAnalyzer := NewContentAnalyzer(10 * time.Second) + result.ContentScore = contentAnalyzer.GetContentScore(contentResults) + + // Calculate header quality score + result.HeaderScore = s.calculateHeaderScore(email) + + // Calculate overall score (out of 10) + result.OverallScore = result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore + + // Ensure score is within bounds + if result.OverallScore > 10.0 { + result.OverallScore = 10.0 + } + if result.OverallScore < 0.0 { + result.OverallScore = 0.0 + } + + // Determine rating + result.Rating = s.determineRating(result.OverallScore) + + // Build category breakdown + result.CategoryBreakdown["Authentication"] = CategoryScore{ + Score: result.AuthScore, + MaxScore: 3.0, + Percentage: (result.AuthScore / 3.0) * 100, + Status: s.getCategoryStatus(result.AuthScore, 3.0), + } + + result.CategoryBreakdown["Spam Filters"] = CategoryScore{ + Score: result.SpamScore, + MaxScore: 2.0, + Percentage: (result.SpamScore / 2.0) * 100, + Status: s.getCategoryStatus(result.SpamScore, 2.0), + } + + result.CategoryBreakdown["Blacklists"] = CategoryScore{ + Score: result.BlacklistScore, + MaxScore: 2.0, + Percentage: (result.BlacklistScore / 2.0) * 100, + Status: s.getCategoryStatus(result.BlacklistScore, 2.0), + } + + result.CategoryBreakdown["Content Quality"] = CategoryScore{ + Score: result.ContentScore, + MaxScore: 2.0, + Percentage: (result.ContentScore / 2.0) * 100, + Status: s.getCategoryStatus(result.ContentScore, 2.0), + } + + result.CategoryBreakdown["Email Structure"] = CategoryScore{ + Score: result.HeaderScore, + MaxScore: 1.0, + Percentage: (result.HeaderScore / 1.0) * 100, + Status: s.getCategoryStatus(result.HeaderScore, 1.0), + } + + // Generate recommendations + result.Recommendations = s.generateRecommendations(result) + + return result +} + +// calculateHeaderScore evaluates email structural quality (0-1 point) +func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 { + if email == nil { + return 0.0 + } + + score := float32(0.0) + requiredHeaders := 0 + presentHeaders := 0 + + // Check required headers (RFC 5322) + headers := map[string]bool{ + "From": false, + "Date": false, + "Message-ID": false, + } + + for header := range headers { + requiredHeaders++ + if email.HasHeader(header) && email.GetHeaderValue(header) != "" { + headers[header] = true + presentHeaders++ + } + } + + // Score based on required headers (0.4 points) + if presentHeaders == requiredHeaders { + score += 0.4 + } else { + score += 0.4 * (float32(presentHeaders) / float32(requiredHeaders)) + } + + // Check recommended headers (0.3 points) + recommendedHeaders := []string{"Subject", "To", "Reply-To"} + recommendedPresent := 0 + for _, header := range recommendedHeaders { + if email.HasHeader(header) && email.GetHeaderValue(header) != "" { + recommendedPresent++ + } + } + score += 0.3 * (float32(recommendedPresent) / float32(len(recommendedHeaders))) + + // Check for proper MIME structure (0.2 points) + if len(email.Parts) > 0 { + score += 0.2 + } + + // Check Message-ID format (0.1 points) + if messageID := email.GetHeaderValue("Message-ID"); messageID != "" { + if s.isValidMessageID(messageID) { + score += 0.1 + } + } + + // Ensure score doesn't exceed 1.0 + if score > 1.0 { + score = 1.0 + } + + return score +} + +// isValidMessageID checks if a Message-ID has proper format +func (s *DeliverabilityScorer) isValidMessageID(messageID string) bool { + // Basic check: should be in format <...@...> + if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") { + return false + } + + // Remove angle brackets + messageID = strings.TrimPrefix(messageID, "<") + messageID = strings.TrimSuffix(messageID, ">") + + // Should contain @ symbol + if !strings.Contains(messageID, "@") { + return false + } + + parts := strings.Split(messageID, "@") + if len(parts) != 2 { + return false + } + + // Both parts should be non-empty + return len(parts[0]) > 0 && len(parts[1]) > 0 +} + +// determineRating determines the rating based on overall score +func (s *DeliverabilityScorer) determineRating(score float32) string { + switch { + case score >= 9.0: + return "Excellent" + case score >= 7.0: + return "Good" + case score >= 5.0: + return "Fair" + case score >= 3.0: + return "Poor" + default: + return "Critical" + } +} + +// getCategoryStatus determines status for a category +func (s *DeliverabilityScorer) getCategoryStatus(score, maxScore float32) string { + percentage := (score / maxScore) * 100 + + switch { + case percentage >= 80.0: + return "Pass" + case percentage >= 50.0: + return "Warn" + default: + return "Fail" + } +} + +// generateRecommendations creates actionable recommendations based on scores +func (s *DeliverabilityScorer) generateRecommendations(result *ScoringResult) []string { + var recommendations []string + + // Authentication recommendations + if result.AuthScore < 2.0 { + recommendations = append(recommendations, "🔐 Improve email authentication by configuring SPF, DKIM, and DMARC records") + } else if result.AuthScore < 3.0 { + recommendations = append(recommendations, "🔐 Fine-tune your email authentication setup for optimal deliverability") + } + + // Spam recommendations + if result.SpamScore < 1.0 { + recommendations = append(recommendations, "⚠️ Reduce spam triggers by reviewing email content and avoiding spam-like patterns") + } else if result.SpamScore < 1.5 { + recommendations = append(recommendations, "⚠️ Monitor spam score and address any flagged content issues") + } + + // Blacklist recommendations + if result.BlacklistScore < 1.0 { + recommendations = append(recommendations, "🚫 Your IP is listed on blacklists - take immediate action to delist and improve sender reputation") + } else if result.BlacklistScore < 2.0 { + recommendations = append(recommendations, "🚫 Monitor your IP reputation and ensure clean sending practices") + } + + // Content recommendations + if result.ContentScore < 1.0 { + recommendations = append(recommendations, "📝 Improve email content quality: fix broken links, add alt text to images, and ensure proper HTML structure") + } else if result.ContentScore < 1.5 { + recommendations = append(recommendations, "📝 Enhance email content by optimizing images and ensuring text/HTML consistency") + } + + // Header recommendations + if result.HeaderScore < 0.5 { + recommendations = append(recommendations, "📧 Fix email structure by adding required headers (From, Date, Message-ID)") + } else if result.HeaderScore < 1.0 { + recommendations = append(recommendations, "📧 Improve email headers by ensuring all recommended fields are present") + } + + // Overall recommendations based on rating + if result.Rating == "Excellent" { + recommendations = append(recommendations, "✅ Your email has excellent deliverability - maintain current practices") + } else if result.Rating == "Critical" { + recommendations = append(recommendations, "🆘 Critical issues detected - emails will likely be rejected or marked as spam") + } + + return recommendations +} + +// GenerateHeaderChecks creates checks for email header quality +func (s *DeliverabilityScorer) GenerateHeaderChecks(email *EmailMessage) []api.Check { + var checks []api.Check + + if email == nil { + return checks + } + + // Required headers check + checks = append(checks, s.generateRequiredHeadersCheck(email)) + + // Recommended headers check + checks = append(checks, s.generateRecommendedHeadersCheck(email)) + + // Message-ID check + checks = append(checks, s.generateMessageIDCheck(email)) + + // MIME structure check + checks = append(checks, s.generateMIMEStructureCheck(email)) + + return checks +} + +// generateRequiredHeadersCheck checks for required RFC 5322 headers +func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage) api.Check { + check := api.Check{ + Category: api.Headers, + Name: "Required Headers", + } + + requiredHeaders := []string{"From", "Date", "Message-ID"} + missing := []string{} + + for _, header := range requiredHeaders { + if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { + missing = append(missing, header) + } + } + + if len(missing) == 0 { + check.Status = api.CheckStatusPass + check.Score = 0.4 + check.Severity = api.PtrTo(api.Info) + check.Message = "All required headers are present" + check.Advice = api.PtrTo("Your email has proper RFC 5322 headers") + } else { + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Severity = api.PtrTo(api.Critical) + check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", ")) + check.Advice = api.PtrTo("Add all required headers to ensure email deliverability") + details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) + check.Details = &details + } + + return check +} + +// generateRecommendedHeadersCheck checks for recommended headers +func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check { + check := api.Check{ + Category: api.Headers, + Name: "Recommended Headers", + } + + recommendedHeaders := []string{"Subject", "To", "Reply-To"} + missing := []string{} + + for _, header := range recommendedHeaders { + if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { + missing = append(missing, header) + } + } + + if len(missing) == 0 { + check.Status = api.CheckStatusPass + check.Score = 0.3 + check.Severity = api.PtrTo(api.Info) + check.Message = "All recommended headers are present" + check.Advice = api.PtrTo("Your email includes all recommended headers") + } else if len(missing) < len(recommendedHeaders) { + check.Status = api.CheckStatusWarn + check.Score = 0.15 + check.Severity = api.PtrTo(api.Low) + check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", ")) + check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability") + details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) + check.Details = &details + } else { + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Severity = api.PtrTo(api.Medium) + check.Message = "Missing all recommended headers" + check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation") + } + + return check +} + +// generateMessageIDCheck validates Message-ID header +func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.Check { + check := api.Check{ + Category: api.Headers, + Name: "Message-ID Format", + } + + messageID := email.GetHeaderValue("Message-ID") + + if messageID == "" { + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Severity = api.PtrTo(api.High) + check.Message = "Message-ID header is missing" + check.Advice = api.PtrTo("Add a unique Message-ID header to your email") + } else if !s.isValidMessageID(messageID) { + check.Status = api.CheckStatusWarn + check.Score = 0.05 + check.Severity = api.PtrTo(api.Medium) + check.Message = "Message-ID format is invalid" + check.Advice = api.PtrTo("Use proper Message-ID format: ") + check.Details = &messageID + } else { + check.Status = api.CheckStatusPass + check.Score = 0.1 + check.Severity = api.PtrTo(api.Info) + check.Message = "Message-ID is properly formatted" + check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards") + check.Details = &messageID + } + + return check +} + +// generateMIMEStructureCheck validates MIME structure +func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) api.Check { + check := api.Check{ + Category: api.Headers, + Name: "MIME Structure", + } + + if len(email.Parts) == 0 { + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Severity = api.PtrTo(api.Low) + check.Message = "No MIME parts detected" + check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility") + } else { + check.Status = api.CheckStatusPass + check.Score = 0.2 + check.Severity = api.PtrTo(api.Info) + check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts)) + check.Advice = api.PtrTo("Your email has proper MIME structure") + + // Add details about parts + partTypes := []string{} + for _, part := range email.Parts { + if part.ContentType != "" { + partTypes = append(partTypes, part.ContentType) + } + } + if len(partTypes) > 0 { + details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", ")) + check.Details = &details + } + } + + return check +} + +// GetScoreSummary generates a human-readable summary of the score +func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string { + var summary strings.Builder + + summary.WriteString(fmt.Sprintf("Overall Score: %.1f/10 (%s)\n\n", result.OverallScore, result.Rating)) + summary.WriteString("Category Breakdown:\n") + summary.WriteString(fmt.Sprintf(" • Authentication: %.1f/3.0 (%.0f%%) - %s\n", + result.AuthScore, result.CategoryBreakdown["Authentication"].Percentage, result.CategoryBreakdown["Authentication"].Status)) + summary.WriteString(fmt.Sprintf(" • Spam Filters: %.1f/2.0 (%.0f%%) - %s\n", + result.SpamScore, result.CategoryBreakdown["Spam Filters"].Percentage, result.CategoryBreakdown["Spam Filters"].Status)) + summary.WriteString(fmt.Sprintf(" • Blacklists: %.1f/2.0 (%.0f%%) - %s\n", + result.BlacklistScore, result.CategoryBreakdown["Blacklists"].Percentage, result.CategoryBreakdown["Blacklists"].Status)) + summary.WriteString(fmt.Sprintf(" • Content Quality: %.1f/2.0 (%.0f%%) - %s\n", + result.ContentScore, result.CategoryBreakdown["Content Quality"].Percentage, result.CategoryBreakdown["Content Quality"].Status)) + summary.WriteString(fmt.Sprintf(" • Email Structure: %.1f/1.0 (%.0f%%) - %s\n", + result.HeaderScore, result.CategoryBreakdown["Email Structure"].Percentage, result.CategoryBreakdown["Email Structure"].Status)) + + if len(result.Recommendations) > 0 { + summary.WriteString("\nRecommendations:\n") + for _, rec := range result.Recommendations { + summary.WriteString(fmt.Sprintf(" %s\n", rec)) + } + } + + return summary.String() +} diff --git a/internal/analyzer/scoring_test.go b/internal/analyzer/scoring_test.go new file mode 100644 index 0000000..b28182d --- /dev/null +++ b/internal/analyzer/scoring_test.go @@ -0,0 +1,762 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "net/mail" + "net/textproto" + "strings" + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestNewDeliverabilityScorer(t *testing.T) { + scorer := NewDeliverabilityScorer() + if scorer == nil { + t.Fatal("Expected scorer, got nil") + } +} + +func TestIsValidMessageID(t *testing.T) { + tests := []struct { + name string + messageID string + expected bool + }{ + { + name: "Valid Message-ID", + messageID: "", + expected: true, + }, + { + name: "Valid with UUID", + messageID: "<550e8400-e29b-41d4-a716-446655440000@example.com>", + expected: true, + }, + { + name: "Missing angle brackets", + messageID: "abc123@example.com", + expected: false, + }, + { + name: "Missing @ symbol", + messageID: "", + expected: false, + }, + { + name: "Multiple @ symbols", + messageID: "", + expected: false, + }, + { + name: "Empty local part", + messageID: "<@example.com>", + expected: false, + }, + { + name: "Empty domain part", + messageID: "", + expected: false, + }, + { + name: "Empty", + messageID: "", + expected: false, + }, + } + + scorer := NewDeliverabilityScorer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := scorer.isValidMessageID(tt.messageID) + if result != tt.expected { + t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected) + } + }) + } +} + +func TestCalculateHeaderScore(t *testing.T) { + tests := []struct { + name string + email *EmailMessage + minScore float32 + maxScore float32 + }{ + { + name: "Nil email", + email: nil, + minScore: 0.0, + maxScore: 0.0, + }, + { + name: "Perfect headers", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "To": "recipient@example.com", + "Subject": "Test", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + "Reply-To": "reply@example.com", + }), + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 0.7, + maxScore: 1.0, + }, + { + name: "Missing required headers", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "Subject": "Test", + }), + }, + minScore: 0.0, + maxScore: 0.4, + }, + { + name: "Required only, no recommended", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + }), + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 0.4, + maxScore: 0.8, + }, + { + name: "Invalid Message-ID format", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "invalid-message-id", + "Subject": "Test", + "To": "recipient@example.com", + "Reply-To": "reply@example.com", + }), + MessageID: "invalid-message-id", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 0.7, + maxScore: 1.0, + }, + } + + scorer := NewDeliverabilityScorer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score := scorer.calculateHeaderScore(tt.email) + if score < tt.minScore || score > tt.maxScore { + t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) + } + }) + } +} + +func TestDetermineRating(t *testing.T) { + tests := []struct { + name string + score float32 + expected string + }{ + {name: "Excellent - 10.0", score: 10.0, expected: "Excellent"}, + {name: "Excellent - 9.5", score: 9.5, expected: "Excellent"}, + {name: "Excellent - 9.0", score: 9.0, expected: "Excellent"}, + {name: "Good - 8.5", score: 8.5, expected: "Good"}, + {name: "Good - 7.0", score: 7.0, expected: "Good"}, + {name: "Fair - 6.5", score: 6.5, expected: "Fair"}, + {name: "Fair - 5.0", score: 5.0, expected: "Fair"}, + {name: "Poor - 4.5", score: 4.5, expected: "Poor"}, + {name: "Poor - 3.0", score: 3.0, expected: "Poor"}, + {name: "Critical - 2.5", score: 2.5, expected: "Critical"}, + {name: "Critical - 0.0", score: 0.0, expected: "Critical"}, + } + + scorer := NewDeliverabilityScorer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := scorer.determineRating(tt.score) + if result != tt.expected { + t.Errorf("determineRating(%v) = %q, want %q", tt.score, result, tt.expected) + } + }) + } +} + +func TestGetCategoryStatus(t *testing.T) { + tests := []struct { + name string + score float32 + maxScore float32 + expected string + }{ + {name: "Pass - 100%", score: 3.0, maxScore: 3.0, expected: "Pass"}, + {name: "Pass - 90%", score: 2.7, maxScore: 3.0, expected: "Pass"}, + {name: "Pass - 80%", score: 2.4, maxScore: 3.0, expected: "Pass"}, + {name: "Warn - 75%", score: 2.25, maxScore: 3.0, expected: "Warn"}, + {name: "Warn - 50%", score: 1.5, maxScore: 3.0, expected: "Warn"}, + {name: "Fail - 40%", score: 1.2, maxScore: 3.0, expected: "Fail"}, + {name: "Fail - 0%", score: 0.0, maxScore: 3.0, expected: "Fail"}, + } + + scorer := NewDeliverabilityScorer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := scorer.getCategoryStatus(tt.score, tt.maxScore) + if result != tt.expected { + t.Errorf("getCategoryStatus(%v, %v) = %q, want %q", tt.score, tt.maxScore, result, tt.expected) + } + }) + } +} + +func TestCalculateScore(t *testing.T) { + tests := []struct { + name string + authResults *api.AuthenticationResults + spamResult *SpamAssassinResult + rblResults *RBLResults + contentResults *ContentResults + email *EmailMessage + minScore float32 + maxScore float32 + expectedRating string + }{ + { + name: "Perfect email", + authResults: &api.AuthenticationResults{ + Spf: &api.AuthResult{Result: api.AuthResultResultPass}, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + Dmarc: &api.AuthResult{Result: api.AuthResultResultPass}, + }, + spamResult: &SpamAssassinResult{ + Score: -1.0, + RequiredScore: 5.0, + }, + rblResults: &RBLResults{ + Checks: []RBLCheck{ + {IP: "192.0.2.1", Listed: false}, + }, + }, + contentResults: &ContentResults{ + HTMLValid: true, + Links: []LinkCheck{{Valid: true, Status: 200}}, + Images: []ImageCheck{{HasAlt: true}}, + HasUnsubscribe: true, + TextPlainRatio: 0.8, + ImageTextRatio: 3.0, + }, + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "To": "recipient@example.com", + "Subject": "Test", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + "Reply-To": "reply@example.com", + }), + MessageID: "", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 9.0, + maxScore: 10.0, + expectedRating: "Excellent", + }, + { + name: "Poor email - auth issues", + authResults: &api.AuthenticationResults{ + Spf: &api.AuthResult{Result: api.AuthResultResultFail}, + Dkim: &[]api.AuthResult{}, + Dmarc: nil, + }, + spamResult: &SpamAssassinResult{ + Score: 8.0, + RequiredScore: 5.0, + }, + rblResults: &RBLResults{ + Checks: []RBLCheck{ + { + IP: "192.0.2.1", + RBL: "zen.spamhaus.org", + Listed: true, + }, + }, + ListedCount: 1, + }, + contentResults: &ContentResults{ + HTMLValid: false, + Links: []LinkCheck{{Valid: true, Status: 404}}, + HasUnsubscribe: false, + }, + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + }), + }, + minScore: 0.0, + maxScore: 5.0, + expectedRating: "Poor", + }, + { + name: "Average email", + authResults: &api.AuthenticationResults{ + Spf: &api.AuthResult{Result: api.AuthResultResultPass}, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + Dmarc: nil, + }, + spamResult: &SpamAssassinResult{ + Score: 4.0, + RequiredScore: 5.0, + }, + rblResults: &RBLResults{ + Checks: []RBLCheck{ + {IP: "192.0.2.1", Listed: false}, + }, + }, + contentResults: &ContentResults{ + HTMLValid: true, + Links: []LinkCheck{{Valid: true, Status: 200}}, + HasUnsubscribe: false, + }, + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + }), + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 6.0, + maxScore: 9.0, + expectedRating: "Good", + }, + } + + scorer := NewDeliverabilityScorer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := scorer.CalculateScore( + tt.authResults, + tt.spamResult, + tt.rblResults, + tt.contentResults, + tt.email, + ) + + if result == nil { + t.Fatal("Expected result, got nil") + } + + // Check overall score + if result.OverallScore < tt.minScore || result.OverallScore > tt.maxScore { + t.Errorf("OverallScore = %v, want between %v and %v", result.OverallScore, tt.minScore, tt.maxScore) + } + + // Check rating + if result.Rating != tt.expectedRating { + t.Errorf("Rating = %q, want %q", result.Rating, tt.expectedRating) + } + + // Verify score is within bounds + if result.OverallScore < 0.0 || result.OverallScore > 10.0 { + t.Errorf("OverallScore %v is out of bounds [0.0, 10.0]", result.OverallScore) + } + + // Verify category breakdown exists + if len(result.CategoryBreakdown) != 5 { + t.Errorf("Expected 5 categories, got %d", len(result.CategoryBreakdown)) + } + + // Verify recommendations exist + if len(result.Recommendations) == 0 && result.Rating != "Excellent" { + t.Error("Expected recommendations for non-excellent rating") + } + + // Verify category scores add up to overall score + totalCategoryScore := result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore + if totalCategoryScore < result.OverallScore-0.01 || totalCategoryScore > result.OverallScore+0.01 { + t.Errorf("Category scores sum (%.2f) doesn't match overall score (%.2f)", + totalCategoryScore, result.OverallScore) + } + }) + } +} + +func TestGenerateRecommendations(t *testing.T) { + tests := []struct { + name string + result *ScoringResult + expectedMinCount int + shouldContainKeyword string + }{ + { + name: "Excellent - minimal recommendations", + result: &ScoringResult{ + OverallScore: 9.5, + Rating: "Excellent", + AuthScore: 3.0, + SpamScore: 2.0, + BlacklistScore: 2.0, + ContentScore: 2.0, + HeaderScore: 1.0, + }, + expectedMinCount: 1, + shouldContainKeyword: "Excellent", + }, + { + name: "Critical - many recommendations", + result: &ScoringResult{ + OverallScore: 1.0, + Rating: "Critical", + AuthScore: 0.5, + SpamScore: 0.0, + BlacklistScore: 0.0, + ContentScore: 0.3, + HeaderScore: 0.2, + }, + expectedMinCount: 5, + shouldContainKeyword: "Critical", + }, + { + name: "Poor authentication", + result: &ScoringResult{ + OverallScore: 5.0, + Rating: "Fair", + AuthScore: 1.5, + SpamScore: 2.0, + BlacklistScore: 2.0, + ContentScore: 1.5, + HeaderScore: 1.0, + }, + expectedMinCount: 1, + shouldContainKeyword: "authentication", + }, + { + name: "Blacklist issues", + result: &ScoringResult{ + OverallScore: 4.0, + Rating: "Poor", + AuthScore: 3.0, + SpamScore: 2.0, + BlacklistScore: 0.5, + ContentScore: 1.5, + HeaderScore: 1.0, + }, + expectedMinCount: 1, + shouldContainKeyword: "blacklist", + }, + } + + scorer := NewDeliverabilityScorer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recommendations := scorer.generateRecommendations(tt.result) + + if len(recommendations) < tt.expectedMinCount { + t.Errorf("Got %d recommendations, want at least %d", len(recommendations), tt.expectedMinCount) + } + + // Check if expected keyword appears in any recommendation + found := false + for _, rec := range recommendations { + if strings.Contains(strings.ToLower(rec), strings.ToLower(tt.shouldContainKeyword)) { + found = true + break + } + } + + if !found { + t.Errorf("No recommendation contains keyword %q. Recommendations: %v", + tt.shouldContainKeyword, recommendations) + } + }) + } +} + +func TestGenerateRequiredHeadersCheck(t *testing.T) { + tests := []struct { + name string + email *EmailMessage + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "All required headers present", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + }), + From: &mail.Address{Address: "sender@example.com"}, + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 0.4, + }, + { + name: "Missing all required headers", + email: &EmailMessage{ + Header: make(mail.Header), + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, + }, + { + name: "Missing some required headers", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + }), + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, + }, + } + + scorer := NewDeliverabilityScorer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := scorer.generateRequiredHeadersCheck(tt.email) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Headers { + t.Errorf("Category = %v, want %v", check.Category, api.Headers) + } + }) + } +} + +func TestGenerateMessageIDCheck(t *testing.T) { + tests := []struct { + name string + messageID string + expectedStatus api.CheckStatus + }{ + { + name: "Valid Message-ID", + messageID: "", + expectedStatus: api.CheckStatusPass, + }, + { + name: "Invalid Message-ID format", + messageID: "invalid-message-id", + expectedStatus: api.CheckStatusWarn, + }, + { + name: "Missing Message-ID", + messageID: "", + expectedStatus: api.CheckStatusFail, + }, + } + + scorer := NewDeliverabilityScorer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "Message-ID": tt.messageID, + }), + } + + check := scorer.generateMessageIDCheck(email) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Category != api.Headers { + t.Errorf("Category = %v, want %v", check.Category, api.Headers) + } + }) + } +} + +func TestGenerateMIMEStructureCheck(t *testing.T) { + tests := []struct { + name string + parts []MessagePart + expectedStatus api.CheckStatus + }{ + { + name: "With MIME parts", + parts: []MessagePart{ + {ContentType: "text/plain", Content: "test"}, + {ContentType: "text/html", Content: "

test

"}, + }, + expectedStatus: api.CheckStatusPass, + }, + { + name: "No MIME parts", + parts: []MessagePart{}, + expectedStatus: api.CheckStatusWarn, + }, + } + + scorer := NewDeliverabilityScorer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: make(mail.Header), + Parts: tt.parts, + } + + check := scorer.generateMIMEStructureCheck(email) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + }) + } +} + +func TestGenerateHeaderChecks(t *testing.T) { + tests := []struct { + name string + email *EmailMessage + minChecks int + }{ + { + name: "Nil email", + email: nil, + minChecks: 0, + }, + { + name: "Complete email", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "To": "recipient@example.com", + "Subject": "Test", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + "Reply-To": "reply@example.com", + }), + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minChecks: 4, // Required, Recommended, Message-ID, MIME + }, + } + + scorer := NewDeliverabilityScorer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checks := scorer.GenerateHeaderChecks(tt.email) + + if len(checks) < tt.minChecks { + t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) + } + + // Verify all checks have the Headers category + for _, check := range checks { + if check.Category != api.Headers { + t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers) + } + } + }) + } +} + +func TestGetScoreSummary(t *testing.T) { + result := &ScoringResult{ + OverallScore: 8.5, + Rating: "Good", + AuthScore: 2.5, + SpamScore: 1.8, + BlacklistScore: 2.0, + ContentScore: 1.5, + HeaderScore: 0.7, + CategoryBreakdown: map[string]CategoryScore{ + "Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"}, + "Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"}, + "Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"}, + "Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"}, + "Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"}, + }, + Recommendations: []string{ + "Improve content quality", + "Add more headers", + }, + } + + scorer := NewDeliverabilityScorer() + summary := scorer.GetScoreSummary(result) + + // Check that summary contains key information + if !strings.Contains(summary, "8.5") { + t.Error("Summary should contain overall score") + } + if !strings.Contains(summary, "Good") { + t.Error("Summary should contain rating") + } + if !strings.Contains(summary, "Authentication") { + t.Error("Summary should contain category names") + } + if !strings.Contains(summary, "Recommendations") { + t.Error("Summary should contain recommendations section") + } +} + +// Helper function to create mail.Header with specific fields +func createHeaderWithFields(fields map[string]string) mail.Header { + header := make(mail.Header) + for key, value := range fields { + if value != "" { + // Use canonical MIME header key format + canonicalKey := textproto.CanonicalMIMEHeaderKey(key) + header[canonicalKey] = []string{value} + } + } + return header +} diff --git a/internal/analyzer/spamassassin.go b/internal/analyzer/spamassassin.go new file mode 100644 index 0000000..78a6a72 --- /dev/null +++ b/internal/analyzer/spamassassin.go @@ -0,0 +1,340 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers +type SpamAssassinAnalyzer struct{} + +// NewSpamAssassinAnalyzer creates a new SpamAssassin analyzer +func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer { + return &SpamAssassinAnalyzer{} +} + +// SpamAssassinResult represents parsed SpamAssassin results +type SpamAssassinResult struct { + IsSpam bool + Score float64 + RequiredScore float64 + Tests []string + TestDetails map[string]SpamTestDetail + Version string + RawReport string +} + +// SpamTestDetail contains details about a specific spam test +type SpamTestDetail struct { + Name string + Score float64 + Description string +} + +// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers +func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAssassinResult { + headers := email.GetSpamAssassinHeaders() + if len(headers) == 0 { + return nil + } + + result := &SpamAssassinResult{ + TestDetails: make(map[string]SpamTestDetail), + } + + // Parse X-Spam-Status header + if statusHeader, ok := headers["X-Spam-Status"]; ok { + a.parseSpamStatus(statusHeader, result) + } + + // Parse X-Spam-Score header (as fallback if not in X-Spam-Status) + if scoreHeader, ok := headers["X-Spam-Score"]; ok && result.Score == 0 { + if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil { + result.Score = score + } + } + + // Parse X-Spam-Flag header (as fallback) + if flagHeader, ok := headers["X-Spam-Flag"]; ok { + result.IsSpam = strings.TrimSpace(strings.ToUpper(flagHeader)) == "YES" + } + + // Parse X-Spam-Report header for detailed test results + if reportHeader, ok := headers["X-Spam-Report"]; ok { + result.RawReport = reportHeader + a.parseSpamReport(reportHeader, result) + } + + // Parse X-Spam-Checker-Version + if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok { + result.Version = strings.TrimSpace(versionHeader) + } + + return result +} + +// parseSpamStatus parses the X-Spam-Status header +// Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no +func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssassinResult) { + // Check if spam (first word) + parts := strings.SplitN(header, ",", 2) + if len(parts) > 0 { + firstPart := strings.TrimSpace(parts[0]) + result.IsSpam = strings.EqualFold(firstPart, "yes") + } + + // Extract score + scoreRe := regexp.MustCompile(`score=(-?\d+\.?\d*)`) + if matches := scoreRe.FindStringSubmatch(header); len(matches) > 1 { + if score, err := strconv.ParseFloat(matches[1], 64); err == nil { + result.Score = score + } + } + + // Extract required score + requiredRe := regexp.MustCompile(`required=(-?\d+\.?\d*)`) + if matches := requiredRe.FindStringSubmatch(header); len(matches) > 1 { + if required, err := strconv.ParseFloat(matches[1], 64); err == nil { + result.RequiredScore = required + } + } + + // Extract tests + testsRe := regexp.MustCompile(`tests=([^\s]+)`) + if matches := testsRe.FindStringSubmatch(header); len(matches) > 1 { + testsStr := matches[1] + // Tests can be comma or space separated + tests := strings.FieldsFunc(testsStr, func(r rune) bool { + return r == ',' || r == ' ' + }) + result.Tests = tests + } +} + +// parseSpamReport parses the X-Spam-Report header to extract test details +// Format varies, but typically: +// * 1.5 TEST_NAME Description of test +// * 0.0 TEST_NAME2 Description +func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) { + // Split by lines + lines := strings.Split(report, "\n") + + // Regex to match test lines: * score TEST_NAME Description + testRe := regexp.MustCompile(`^\s*\*\s+(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`) + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + matches := testRe.FindStringSubmatch(line) + if len(matches) > 3 { + testName := matches[2] + score, _ := strconv.ParseFloat(matches[1], 64) + description := strings.TrimSpace(matches[3]) + + detail := SpamTestDetail{ + Name: testName, + Score: score, + Description: description, + } + result.TestDetails[testName] = detail + } + } +} + +// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability (0-2 points) +// Scoring: +// - Score <= 0: 2 points (excellent) +// - Score < required: 1.5 points (good) +// - Score slightly above required (< 2x): 1 point (borderline) +// - Score moderately high (< 3x required): 0.5 points (poor) +// - Score very high: 0 points (spam) +func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) float32 { + if result == nil { + return 0.0 + } + + score := result.Score + required := result.RequiredScore + if required == 0 { + required = 5.0 // Default SpamAssassin threshold + } + + // Calculate deliverability score + if score <= 0 { + return 2.0 + } else if score < required { + // Linear scaling from 1.5 to 2.0 based on how negative/low the score is + ratio := score / required + return 1.5 + (0.5 * (1.0 - float32(ratio))) + } else if score < required*2 { + // Slightly above threshold + return 1.0 + } else if score < required*3 { + // Moderately high + return 0.5 + } + + // Very high spam score + return 0.0 +} + +// GenerateSpamAssassinChecks generates check results for SpamAssassin analysis +func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinResult) []api.Check { + var checks []api.Check + + if result == nil { + checks = append(checks, api.Check{ + Category: api.Spam, + Name: "SpamAssassin Analysis", + Status: api.CheckStatusWarn, + Score: 0.0, + Message: "No SpamAssassin headers found", + Severity: api.PtrTo(api.Medium), + Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"), + }) + return checks + } + + // Main spam score check + mainCheck := a.generateMainSpamCheck(result) + checks = append(checks, mainCheck) + + // Add checks for significant spam tests (score > 1.0 or < -1.0) + for _, test := range result.Tests { + if detail, ok := result.TestDetails[test]; ok { + if detail.Score > 1.0 || detail.Score < -1.0 { + check := a.generateTestCheck(detail) + checks = append(checks, check) + } + } + } + + return checks +} + +// generateMainSpamCheck creates the main spam score check +func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) api.Check { + check := api.Check{ + Category: api.Spam, + Name: "SpamAssassin Score", + } + + score := result.Score + required := result.RequiredScore + if required == 0 { + required = 5.0 + } + + delivScore := a.GetSpamAssassinScore(result) + check.Score = delivScore + + // Determine status and message based on score + if score <= 0 { + check.Status = api.CheckStatusPass + check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required) + check.Severity = api.PtrTo(api.Info) + check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices") + } else if score < required { + check.Status = api.CheckStatusPass + check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required) + check.Severity = api.PtrTo(api.Info) + check.Advice = api.PtrTo("Your email passes spam filters") + } else if score < required*1.5 { + check.Status = api.CheckStatusWarn + check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required) + check.Severity = api.PtrTo(api.Medium) + check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below") + } else if score < required*2 { + check.Status = api.CheckStatusWarn + check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required) + check.Severity = api.PtrTo(api.High) + check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests") + } else { + check.Status = api.CheckStatusFail + check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required) + check.Severity = api.PtrTo(api.Critical) + check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures") + } + + // Add details + if len(result.Tests) > 0 { + details := fmt.Sprintf("Triggered %d tests: %s", len(result.Tests), strings.Join(result.Tests[:min(5, len(result.Tests))], ", ")) + if len(result.Tests) > 5 { + details += fmt.Sprintf(" and %d more", len(result.Tests)-5) + } + check.Details = &details + } + + return check +} + +// generateTestCheck creates a check for a specific spam test +func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Check { + check := api.Check{ + Category: api.Spam, + Name: fmt.Sprintf("Spam Test: %s", detail.Name), + } + + if detail.Score > 0 { + // Negative indicator (increases spam score) + if detail.Score > 2.0 { + check.Status = api.CheckStatusFail + check.Severity = api.PtrTo(api.High) + } else { + check.Status = api.CheckStatusWarn + check.Severity = api.PtrTo(api.Medium) + } + check.Score = 0.0 + check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score) + advice := fmt.Sprintf("%s. This test adds %.1f to your spam score", detail.Description, detail.Score) + check.Advice = &advice + } else { + // Positive indicator (decreases spam score) + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Severity = api.PtrTo(api.Info) + check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score) + advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score) + check.Advice = &advice + } + + check.Details = &detail.Description + + return check +} + +// min returns the minimum of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/pkg/analyzer/spamassassin_test.go b/internal/analyzer/spamassassin_test.go similarity index 51% rename from pkg/analyzer/spamassassin_test.go rename to internal/analyzer/spamassassin_test.go index d5e67a9..4682ed3 100644 --- a/pkg/analyzer/spamassassin_test.go +++ b/internal/analyzer/spamassassin_test.go @@ -22,13 +22,11 @@ package analyzer import ( - "bytes" "net/mail" "strings" "testing" - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" + "git.happydns.org/happyDeliver/internal/api" ) func TestParseSpamStatus(t *testing.T) { @@ -36,8 +34,8 @@ func TestParseSpamStatus(t *testing.T) { name string header string expectedIsSpam bool - expectedScore float32 - expectedReq float32 + expectedScore float64 + expectedReq float64 expectedTests []string }{ { @@ -78,8 +76,8 @@ func TestParseSpamStatus(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := &model.SpamAssassinResult{ - TestDetails: make(map[string]model.SpamTestDetail), + result := &SpamAssassinResult{ + TestDetails: make(map[string]SpamTestDetail), } analyzer.parseSpamStatus(tt.header, result) @@ -92,12 +90,8 @@ func TestParseSpamStatus(t *testing.T) { if result.RequiredScore != tt.expectedReq { t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, tt.expectedReq) } - if len(tt.expectedTests) > 0 { - if result.Tests == nil { - t.Errorf("Tests = nil, want %v", tt.expectedTests) - } else if !stringSliceEqual(*result.Tests, tt.expectedTests) { - t.Errorf("Tests = %v, want %v", *result.Tests, tt.expectedTests) - } + if len(tt.expectedTests) > 0 && !stringSliceEqual(result.Tests, tt.expectedTests) { + t.Errorf("Tests = %v, want %v", result.Tests, tt.expectedTests) } }) } @@ -116,27 +110,27 @@ func TestParseSpamReport(t *testing.T) { ` analyzer := NewSpamAssassinAnalyzer() - result := &model.SpamAssassinResult{ - TestDetails: make(map[string]model.SpamTestDetail), + result := &SpamAssassinResult{ + TestDetails: make(map[string]SpamTestDetail), } analyzer.parseSpamReport(report, result) - expectedTests := map[string]model.SpamTestDetail{ + expectedTests := map[string]SpamTestDetail{ "BAYES_99": { Name: "BAYES_99", Score: 5.0, - Description: utils.PtrTo("Bayes spam probability is 99 to 100%"), + Description: "Bayes spam probability is 99 to 100%", }, "SPOOFED_SENDER": { Name: "SPOOFED_SENDER", Score: 3.5, - Description: utils.PtrTo("From address doesn't match envelope sender"), + Description: "From address doesn't match envelope sender", }, "ALL_TRUSTED": { Name: "ALL_TRUSTED", Score: -1.0, - Description: utils.PtrTo("All mail servers are trusted"), + Description: "All mail servers are trusted", }, } @@ -149,8 +143,8 @@ func TestParseSpamReport(t *testing.T) { if detail.Score != expected.Score { t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expected.Score) } - if *detail.Description != *expected.Description { - t.Errorf("Test %s description = %q, want %q", testName, *detail.Description, *expected.Description) + if detail.Description != expected.Description { + t.Errorf("Test %s description = %q, want %q", testName, detail.Description, expected.Description) } } } @@ -158,63 +152,56 @@ func TestParseSpamReport(t *testing.T) { func TestGetSpamAssassinScore(t *testing.T) { tests := []struct { name string - result *model.SpamAssassinResult - expectedScore int - minScore int - maxScore int + result *SpamAssassinResult + expectedScore float32 + minScore float32 + maxScore float32 }{ { name: "Nil result", result: nil, - expectedScore: 100, + expectedScore: 0.0, }, { name: "Excellent score (negative)", - result: &model.SpamAssassinResult{ + result: &SpamAssassinResult{ Score: -2.5, RequiredScore: 5.0, }, - expectedScore: 100, + expectedScore: 2.0, }, { name: "Good score (below threshold)", - result: &model.SpamAssassinResult{ + result: &SpamAssassinResult{ Score: 2.0, RequiredScore: 5.0, }, - expectedScore: 80, // 100 - round(2*100/5) = 100 - 40 = 60 + minScore: 1.5, + maxScore: 2.0, }, { - name: "Score at threshold", - result: &model.SpamAssassinResult{ - Score: 5.0, - RequiredScore: 5.0, - }, - expectedScore: 0, // >= threshold = 0 - }, - { - name: "Above threshold (spam)", - result: &model.SpamAssassinResult{ + name: "Borderline (just above threshold)", + result: &SpamAssassinResult{ Score: 6.0, RequiredScore: 5.0, }, - expectedScore: 0, // >= threshold = 0 + expectedScore: 1.0, }, { name: "High spam score", - result: &model.SpamAssassinResult{ + result: &SpamAssassinResult{ Score: 12.0, RequiredScore: 5.0, }, - expectedScore: 0, // >= threshold = 0 + expectedScore: 0.5, }, { name: "Very high spam score", - result: &model.SpamAssassinResult{ + result: &SpamAssassinResult{ Score: 20.0, RequiredScore: 5.0, }, - expectedScore: 0, // >= threshold = 0 + expectedScore: 0.0, }, } @@ -222,7 +209,7 @@ func TestGetSpamAssassinScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score, _ := analyzer.CalculateSpamAssassinScore(tt.result) + score := analyzer.GetSpamAssassinScore(tt.result) if tt.minScore > 0 || tt.maxScore > 0 { if score < tt.minScore || score > tt.maxScore { @@ -242,7 +229,7 @@ func TestAnalyzeSpamAssassin(t *testing.T) { name string headers map[string]string expectedIsSpam bool - expectedScore float32 + expectedScore float64 expectedHasDetails bool }{ { @@ -308,6 +295,86 @@ func TestAnalyzeSpamAssassin(t *testing.T) { } } +func TestGenerateSpamAssassinChecks(t *testing.T) { + tests := []struct { + name string + result *SpamAssassinResult + expectedStatus api.CheckStatus + minChecks int + }{ + { + name: "Nil result", + result: nil, + expectedStatus: api.CheckStatusWarn, + minChecks: 1, + }, + { + name: "Clean email", + result: &SpamAssassinResult{ + IsSpam: false, + Score: -0.5, + RequiredScore: 5.0, + Tests: []string{"ALL_TRUSTED"}, + TestDetails: map[string]SpamTestDetail{ + "ALL_TRUSTED": { + Name: "ALL_TRUSTED", + Score: -1.5, + Description: "All mail servers are trusted", + }, + }, + }, + expectedStatus: api.CheckStatusPass, + minChecks: 2, // Main check + one test detail + }, + { + name: "Spam email", + result: &SpamAssassinResult{ + IsSpam: true, + Score: 15.0, + RequiredScore: 5.0, + Tests: []string{"BAYES_99", "SPOOFED_SENDER"}, + TestDetails: map[string]SpamTestDetail{ + "BAYES_99": { + Name: "BAYES_99", + Score: 5.0, + Description: "Bayes spam probability is 99 to 100%", + }, + "SPOOFED_SENDER": { + Name: "SPOOFED_SENDER", + Score: 3.5, + Description: "From address doesn't match envelope sender", + }, + }, + }, + expectedStatus: api.CheckStatusFail, + minChecks: 3, // Main check + two significant tests + }, + } + + analyzer := NewSpamAssassinAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checks := analyzer.GenerateSpamAssassinChecks(tt.result) + + if len(checks) < tt.minChecks { + t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) + } + + // Check main check (first one) + if len(checks) > 0 { + mainCheck := checks[0] + if mainCheck.Status != tt.expectedStatus { + t.Errorf("Main check status = %v, want %v", mainCheck.Status, tt.expectedStatus) + } + if mainCheck.Category != api.Spam { + t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam) + } + } + }) + } +} + func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) { analyzer := NewSpamAssassinAnalyzer() email := &EmailMessage{ @@ -321,147 +388,95 @@ func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) { } } -const sampleEmailWithSpamassassinHeader = `X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on e4a8b8eb87ec -X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID, - DKIM_VALID_AU,RCVD_IN_VALIDITY_CERTIFIED_BLOCKED, - RCVD_IN_VALIDITY_RPBL_BLOCKED,RCVD_IN_VALIDITY_SAFE_BLOCKED, - SPF_HELO_NONE,SPF_PASS autolearn=disabled version=4.0.1 -X-Spam-Level: -X-Spam-Report: - * 0.0 RCVD_IN_VALIDITY_SAFE_BLOCKED RBL: ADMINISTRATOR NOTICE: The query - * to Validity was blocked. See - * https://knowledge.validity.com/hc/en-us/articles/20961730681243 for - * more information. - * [80.67.179.207 listed in sa-accredit.habeas.com] - * 0.0 RCVD_IN_VALIDITY_RPBL_BLOCKED RBL: ADMINISTRATOR NOTICE: The query - * to Validity was blocked. See - * https://knowledge.validity.com/hc/en-us/articles/20961730681243 for - * more information. - * [80.67.179.207 listed in bl.score.senderscore.com] - * 0.0 RCVD_IN_VALIDITY_CERTIFIED_BLOCKED RBL: ADMINISTRATOR NOTICE: The - * query to Validity was blocked. See - * https://knowledge.validity.com/hc/en-us/articles/20961730681243 for - * more information. - * [80.67.179.207 listed in sa-trusted.bondedsender.org] - * -0.0 SPF_PASS SPF: sender matches SPF record - * 0.0 SPF_HELO_NONE SPF: HELO does not publish an SPF Record - * -0.1 DKIM_VALID Message has at least one valid DKIM or DK signature - * 0.1 DKIM_SIGNED Message has a DKIM or DK signature, not necessarily - * valid - * -0.1 DKIM_VALID_AU Message has a valid DKIM or DK signature from author's - * domain -Date: Sun, 19 Oct 2025 08:37:30 +0000 -Message-ID: -MIME-Version: 1.0 -Content-Type: text/plain; charset=utf-8 -Content-Disposition: inline -Content-Transfer-Encoding: 8bit - -BODY` - -// TestAnalyzeRealEmailExample tests the analyzer with the real example email file -func TestAnalyzeRealEmailExample(t *testing.T) { - // Parse the email using the standard net/mail package - email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithSpamassassinHeader)) - if err != nil { - t.Fatalf("Failed to parse email: %v", err) - } - - // Create analyzer and analyze SpamAssassin headers +func TestGenerateMainSpamCheck(t *testing.T) { analyzer := NewSpamAssassinAnalyzer() - result := analyzer.AnalyzeSpamAssassin(email) - // Validate that we got a result - if result == nil { - t.Fatal("Expected SpamAssassin result, got nil") + tests := []struct { + name string + score float64 + required float64 + expectedStatus api.CheckStatus + }{ + {"Excellent", -1.0, 5.0, api.CheckStatusPass}, + {"Good", 2.0, 5.0, api.CheckStatusPass}, + {"Borderline", 6.0, 5.0, api.CheckStatusWarn}, + {"High", 8.0, 5.0, api.CheckStatusWarn}, + {"Very High", 15.0, 5.0, api.CheckStatusFail}, } - // Validate IsSpam flag (should be false for this email) - if result.IsSpam { - t.Error("IsSpam should be false for real_example.eml") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &SpamAssassinResult{ + Score: tt.score, + RequiredScore: tt.required, + } + + check := analyzer.generateMainSpamCheck(result) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Category != api.Spam { + t.Errorf("Category = %v, want %v", check.Category, api.Spam) + } + if !strings.Contains(check.Message, "spam score") { + t.Error("Message should contain 'spam score'") + } + }) + } +} + +func TestGenerateTestCheck(t *testing.T) { + analyzer := NewSpamAssassinAnalyzer() + + tests := []struct { + name string + detail SpamTestDetail + expectedStatus api.CheckStatus + }{ + { + name: "High penalty test", + detail: SpamTestDetail{ + Name: "BAYES_99", + Score: 5.0, + Description: "Bayes spam probability is 99 to 100%", + }, + expectedStatus: api.CheckStatusFail, + }, + { + name: "Medium penalty test", + detail: SpamTestDetail{ + Name: "HTML_MESSAGE", + Score: 1.5, + Description: "Contains HTML", + }, + expectedStatus: api.CheckStatusWarn, + }, + { + name: "Positive test", + detail: SpamTestDetail{ + Name: "ALL_TRUSTED", + Score: -2.0, + Description: "All mail servers are trusted", + }, + expectedStatus: api.CheckStatusPass, + }, } - // Validate score (should be -0.1) - var expectedScore float32 = -0.1 - if result.Score != expectedScore { - t.Errorf("Score = %v, want %v", result.Score, expectedScore) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateTestCheck(tt.detail) - // Validate required score (should be 5.0) - var expectedRequired float32 = 5.0 - if result.RequiredScore != expectedRequired { - t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, expectedRequired) - } - - // Validate version - if result.Version == nil { - t.Errorf("Version should contain 'SpamAssassin', got: nil") - } else if !strings.Contains(*result.Version, "SpamAssassin") { - t.Errorf("Version should contain 'SpamAssassin', got: %s", *result.Version) - } - - // Validate that tests were extracted - if len(*result.Tests) == 0 { - t.Error("Expected tests to be extracted, got none") - } - - // Check for expected tests from the real email - expectedTests := map[string]bool{ - "DKIM_SIGNED": true, - "DKIM_VALID": true, - "DKIM_VALID_AU": true, - "SPF_PASS": true, - "SPF_HELO_NONE": true, - } - - for _, testName := range *result.Tests { - if expectedTests[testName] { - t.Logf("Found expected test: %s", testName) - } - } - - // Validate that test details were parsed from X-Spam-Report - if len(result.TestDetails) == 0 { - t.Error("Expected test details to be parsed from X-Spam-Report, got none") - } - - // Log what we actually got for debugging - t.Logf("Parsed %d test details from X-Spam-Report", len(result.TestDetails)) - for name, detail := range result.TestDetails { - t.Logf(" %s: score=%v, description=%s", name, detail.Score, *detail.Description) - } - - // Define expected test details with their scores - expectedTestDetails := map[string]float32{ - "SPF_PASS": -0.0, - "SPF_HELO_NONE": 0.0, - "DKIM_VALID": -0.1, - "DKIM_SIGNED": 0.1, - "DKIM_VALID_AU": -0.1, - "RCVD_IN_VALIDITY_SAFE_BLOCKED": 0.0, - "RCVD_IN_VALIDITY_RPBL_BLOCKED": 0.0, - "RCVD_IN_VALIDITY_CERTIFIED_BLOCKED": 0.0, - } - - // Iterate over expected tests and verify they exist in TestDetails - for testName, expectedScore := range expectedTestDetails { - detail, ok := result.TestDetails[testName] - if !ok { - t.Errorf("Expected test %s not found in TestDetails", testName) - continue - } - if detail.Score != expectedScore { - t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expectedScore) - } - if detail.Description == nil || *detail.Description == "" { - t.Errorf("Test %s should have a description", testName) - } - } - - // Test GetSpamAssassinScore - score, _ := analyzer.CalculateSpamAssassinScore(result) - if score != 100 { - t.Errorf("GetSpamAssassinScore() = %v, want 100 (excellent score for negative spam score)", score) + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Category != api.Spam { + t.Errorf("Category = %v, want %v", check.Category, api.Spam) + } + if !strings.Contains(check.Name, tt.detail.Name) { + t.Errorf("Check name should contain test name %s", tt.detail.Name) + } + }) } } diff --git a/internal/api/handlers.go b/internal/api/handlers.go index de2d5df..79d839e 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -31,34 +31,21 @@ 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" ) -// EmailAnalyzer defines the interface for email analysis -// This interface breaks the circular dependency with pkg/analyzer -type EmailAnalyzer interface { - AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error) - AnalyzeDomain(domain string) (dnsResults *model.DNSResults, score int, grade string) - CheckBlacklistIP(ip string) (checks []model.BlacklistCheck, whitelists []model.BlacklistCheck, listedCount int, score int, grade string, err error) -} - // APIHandler implements the ServerInterface for handling API requests type APIHandler struct { storage storage.Storage config *config.Config - analyzer EmailAnalyzer startTime time.Time } // NewAPIHandler creates a new API handler -func NewAPIHandler(store storage.Storage, cfg *config.Config, analyzer EmailAnalyzer) *APIHandler { +func NewAPIHandler(store storage.Storage, cfg *config.Config) *APIHandler { return &APIHandler{ storage: store, config: cfg, - analyzer: analyzer, startTime: time.Now(), } } @@ -69,99 +56,79 @@ func (h *APIHandler) CreateTest(c *gin.Context) { // Generate a unique test ID (no database record created) testID := uuid.New() - // Convert UUID to base32 string for the API response - base32ID := utils.UUIDToBase32(testID) - - // Generate test email address using Base32-encoded UUID + // Generate test email address email := fmt.Sprintf("%s%s@%s", h.config.Email.TestAddressPrefix, - base32ID, + testID.String(), h.config.Email.Domain, ) // Return response - c.JSON(http.StatusCreated, model.TestResponse{ - Id: base32ID, + c.JSON(http.StatusCreated, TestResponse{ + Id: testID, Email: openapi_types.Email(email), - Status: model.TestResponseStatusPending, - Message: utils.PtrTo("Send your test email to the given address"), + Status: TestResponseStatusPending, + Message: stringPtr("Send your test email to the address above"), }) } // GetTest retrieves test metadata // (GET /test/{id}) -func (h *APIHandler) GetTest(c *gin.Context, id string) { - // Convert base32 ID to UUID - testUUID, err := utils.Base32ToUUID(id) - if err != nil { - c.JSON(http.StatusBadRequest, model.Error{ - Error: "invalid_id", - Message: "Invalid test ID format", - Details: utils.PtrTo(err.Error()), - }) - return - } - +func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) { // Check if a report exists for this test ID - reportExists, err := h.storage.ReportExists(testUUID) + reportExists, err := h.storage.ReportExists(id) if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{ + c.JSON(http.StatusInternalServerError, Error{ Error: "internal_error", Message: "Failed to check test status", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } // Determine status based on report existence - var apiStatus model.TestStatus + var apiStatus TestStatus if reportExists { - apiStatus = model.TestStatusAnalyzed + apiStatus = TestStatusAnalyzed } else { - apiStatus = model.TestStatusPending + apiStatus = TestStatusPending } - // Generate test email address using Base32-encoded UUID + // Generate test email address email := fmt.Sprintf("%s%s@%s", h.config.Email.TestAddressPrefix, - id, + id.String(), h.config.Email.Domain, ) - c.JSON(http.StatusOK, model.Test{ - Id: id, - Email: openapi_types.Email(email), - Status: apiStatus, + // Return current time for CreatedAt/UpdatedAt since we don't track tests anymore + now := time.Now() + + c.JSON(http.StatusOK, Test{ + Id: id, + Email: openapi_types.Email(email), + Status: apiStatus, + CreatedAt: now, + UpdatedAt: &now, }) } // GetReport retrieves the detailed analysis report // (GET /report/{id}) -func (h *APIHandler) GetReport(c *gin.Context, id string) { - // Convert base32 ID to UUID - testUUID, err := utils.Base32ToUUID(id) - if err != nil { - c.JSON(http.StatusBadRequest, model.Error{ - Error: "invalid_id", - Message: "Invalid test ID format", - Details: utils.PtrTo(err.Error()), - }) - return - } - - reportJSON, _, err := h.storage.GetReport(testUUID) +func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) { + reportJSON, _, err := h.storage.GetReport(id) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, model.Error{ + c.JSON(http.StatusNotFound, Error{ Error: "not_found", Message: "Report not found", }) return } - c.JSON(http.StatusInternalServerError, model.Error{ + c.JSON(http.StatusInternalServerError, Error{ Error: "internal_error", Message: "Failed to retrieve report", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } @@ -172,31 +139,20 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) { // GetRawEmail retrieves the raw annotated email // (GET /report/{id}/raw) -func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { - // Convert base32 ID to UUID - testUUID, err := utils.Base32ToUUID(id) - if err != nil { - c.JSON(http.StatusBadRequest, model.Error{ - Error: "invalid_id", - Message: "Invalid test ID format", - Details: utils.PtrTo(err.Error()), - }) - return - } - - _, rawEmail, err := h.storage.GetReport(testUUID) +func (h *APIHandler) GetRawEmail(c *gin.Context, id openapi_types.UUID) { + _, rawEmail, err := h.storage.GetReport(id) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, model.Error{ + c.JSON(http.StatusNotFound, Error{ Error: "not_found", Message: "Email not found", }) return } - c.JSON(http.StatusInternalServerError, model.Error{ + c.JSON(http.StatusInternalServerError, Error{ Error: "internal_error", Message: "Failed to retrieve raw email", - Details: utils.PtrTo(err.Error()), + Details: stringPtr(err.Error()), }) return } @@ -204,63 +160,6 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { c.Data(http.StatusOK, "text/plain", rawEmail) } -// ReanalyzeReport re-analyzes an existing email and regenerates the report -// (POST /report/{id}/reanalyze) -func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { - // Convert base32 ID to UUID - testUUID, err := utils.Base32ToUUID(id) - if err != nil { - c.JSON(http.StatusBadRequest, model.Error{ - Error: "invalid_id", - Message: "Invalid test ID format", - Details: utils.PtrTo(err.Error()), - }) - return - } - - // Retrieve the existing report (mainly to get the raw email) - _, rawEmail, err := h.storage.GetReport(testUUID) - if err != nil { - if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, model.Error{ - Error: "not_found", - Message: "Email not found", - }) - return - } - c.JSON(http.StatusInternalServerError, model.Error{ - Error: "internal_error", - Message: "Failed to retrieve email", - Details: utils.PtrTo(err.Error()), - }) - return - } - - // Re-analyze the email using the current analyzer - reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID) - if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{ - Error: "analysis_error", - Message: "Failed to re-analyze email", - Details: utils.PtrTo(err.Error()), - }) - return - } - - // Update the report in storage - if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil { - c.JSON(http.StatusInternalServerError, model.Error{ - Error: "internal_error", - Message: "Failed to update report", - Details: utils.PtrTo(err.Error()), - }) - return - } - - // Return the updated report JSON directly - c.Data(http.StatusOK, "application/json", reportJSON) -} - // GetStatus retrieves service health status // (GET /status) func (h *APIHandler) GetStatus(c *gin.Context) { @@ -268,24 +167,24 @@ func (h *APIHandler) GetStatus(c *gin.Context) { uptime := int(time.Since(h.startTime).Seconds()) // Check database connectivity by trying to check if a report exists - dbStatus := model.StatusComponentsDatabaseUp + dbStatus := StatusComponentsDatabaseUp if _, err := h.storage.ReportExists(uuid.New()); err != nil { - dbStatus = model.StatusComponentsDatabaseDown + dbStatus = StatusComponentsDatabaseDown } // Determine overall status - overallStatus := model.Healthy - if dbStatus == model.StatusComponentsDatabaseDown { - overallStatus = model.Unhealthy + overallStatus := Healthy + if dbStatus == StatusComponentsDatabaseDown { + overallStatus = Unhealthy } - mtaStatus := model.StatusComponentsMtaUp - c.JSON(http.StatusOK, model.Status{ + mtaStatus := StatusComponentsMtaUp + c.JSON(http.StatusOK, Status{ Status: overallStatus, - Version: version.Version, + Version: "0.1.0-dev", Components: &struct { - Database *model.StatusComponentsDatabase `json:"database,omitempty"` - Mta *model.StatusComponentsMta `json:"mta,omitempty"` + Database *StatusComponentsDatabase `json:"database,omitempty"` + Mta *StatusComponentsMta `json:"mta,omitempty"` }{ Database: &dbStatus, Mta: &mtaStatus, @@ -293,133 +192,3 @@ func (h *APIHandler) GetStatus(c *gin.Context) { Uptime: &uptime, }) } - -// TestDomain performs synchronous domain analysis -// (POST /domain) -func (h *APIHandler) TestDomain(c *gin.Context) { - var request model.DomainTestRequest - - // Bind and validate request - if err := c.ShouldBindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, model.Error{ - Error: "invalid_request", - Message: "Invalid request body", - Details: utils.PtrTo(err.Error()), - }) - return - } - - // Perform domain analysis - dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain) - - // Convert grade string to DomainTestResponseGrade enum - var responseGrade model.DomainTestResponseGrade - switch grade { - case "A+": - responseGrade = model.DomainTestResponseGradeA - case "A": - responseGrade = model.DomainTestResponseGradeA1 - case "B": - responseGrade = model.DomainTestResponseGradeB - case "C": - responseGrade = model.DomainTestResponseGradeC - case "D": - responseGrade = model.DomainTestResponseGradeD - case "E": - responseGrade = model.DomainTestResponseGradeE - case "F": - responseGrade = model.DomainTestResponseGradeF - default: - responseGrade = model.DomainTestResponseGradeF - } - - // Build response - response := model.DomainTestResponse{ - Domain: request.Domain, - Score: score, - Grade: responseGrade, - DnsResults: *dnsResults, - } - - c.JSON(http.StatusOK, response) -} - -// CheckBlacklist checks an IP address against DNS blacklists -// (POST /blacklist) -func (h *APIHandler) CheckBlacklist(c *gin.Context) { - var request model.BlacklistCheckRequest - - // Bind and validate request - if err := c.ShouldBindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, model.Error{ - Error: "invalid_request", - Message: "Invalid request body", - Details: utils.PtrTo(err.Error()), - }) - return - } - - // Perform blacklist check using analyzer - checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip) - if err != nil { - c.JSON(http.StatusBadRequest, model.Error{ - Error: "invalid_ip", - Message: "Invalid IP address", - Details: utils.PtrTo(err.Error()), - }) - return - } - - // Build response - response := model.BlacklistCheckResponse{ - Ip: request.Ip, - Blacklists: checks, - Whitelists: &whitelists, - ListedCount: listedCount, - Score: score, - Grade: model.BlacklistCheckResponseGrade(grade), - } - - c.JSON(http.StatusOK, response) -} - -// ListTests returns a paginated list of test summaries -// (GET /tests) -func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) { - if h.config.DisableTestList { - c.JSON(http.StatusForbidden, model.Error{ - Error: "feature_disabled", - Message: "Test listing is disabled on this instance", - }) - return - } - - offset := 0 - limit := 20 - if params.Offset != nil { - offset = *params.Offset - } - if params.Limit != nil { - limit = *params.Limit - if limit > 100 { - limit = 100 - } - } - - tests, total, err := h.storage.ListReportSummaries(offset, limit) - if err != nil { - c.JSON(http.StatusInternalServerError, model.Error{ - Error: "internal_error", - Message: "Failed to list tests", - Details: utils.PtrTo(err.Error()), - }) - return - } - - c.JSON(http.StatusOK, model.TestListResponse{ - Tests: tests, - Total: int(total), - Offset: offset, - Limit: limit, - }) -} diff --git a/internal/utils/ptr.go b/internal/api/helpers.go similarity index 91% rename from internal/utils/ptr.go rename to internal/api/helpers.go index 748d6ba..cce306a 100644 --- a/internal/utils/ptr.go +++ b/internal/api/helpers.go @@ -1,5 +1,5 @@ // This file is part of the happyDeliver (R) project. -// Copyright (c) 2026 happyDomain +// Copyright (c) 2025 happyDomain // Authors: Pierre-Olivier Mercier, et al. // // This program is offered under a commercial and under the AGPL license. @@ -19,7 +19,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package utils +package api + +func stringPtr(s string) *string { + return &s +} // PtrTo returns a pointer to the provided value func PtrTo[T any](v T) *T { diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go index d8336a5..87a4e0a 100644 --- a/internal/app/cli_analyzer.go +++ b/internal/app/cli_analyzer.go @@ -31,8 +31,9 @@ import ( "github.com/google/uuid" + "git.happydns.org/happyDeliver/internal/analyzer" + "git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/config" - "git.happydns.org/happyDeliver/pkg/analyzer" ) // RunAnalyzer runs the standalone email analyzer (from stdin) @@ -86,549 +87,57 @@ func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error { // outputHumanReadable outputs a human-readable summary func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyzer.EmailAnalyzer, writer io.Writer) error { - report := result.Report - - // Header with overall score + // Header fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT") fmt.Fprintln(writer, strings.Repeat("=", 70)) - fmt.Fprintf(writer, "\nOverall Score: %d/100 (Grade: %s)\n", report.Score, report.Grade) - fmt.Fprintf(writer, "Test ID: %s\n", report.TestId) - fmt.Fprintf(writer, "Generated: %s\n", report.CreatedAt.Format("2006-01-02 15:04:05 MST")) - // Score Summary - if report.Summary != nil { - fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) - fmt.Fprintln(writer, "SCORE BREAKDOWN") - fmt.Fprintln(writer, strings.Repeat("-", 70)) + // Score summary + summary := emailAnalyzer.GetScoreSummaryText(result) + fmt.Fprintln(writer, summary) - summary := report.Summary - fmt.Fprintf(writer, " DNS Configuration: %3d%% (%s)\n", - summary.DnsScore, summary.DnsGrade) - fmt.Fprintf(writer, " Authentication: %3d%% (%s)\n", - summary.AuthenticationScore, summary.AuthenticationGrade) - fmt.Fprintf(writer, " Blacklist Status: %3d%% (%s)\n", - summary.BlacklistScore, summary.BlacklistGrade) - fmt.Fprintf(writer, " Header Quality: %3d%% (%s)\n", - summary.HeaderScore, summary.HeaderGrade) - fmt.Fprintf(writer, " Spam Score: %3d%% (%s)\n", - summary.SpamScore, summary.SpamGrade) - fmt.Fprintf(writer, " Content Quality: %3d%% (%s)\n", - summary.ContentScore, summary.ContentGrade) + // Detailed checks + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "DETAILED CHECK RESULTS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + // Group checks by category + categories := make(map[api.CheckCategory][]api.Check) + for _, check := range result.Report.Checks { + categories[check.Category] = append(categories[check.Category], check) } - // DNS Results - if report.DnsResults != nil { - fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) - fmt.Fprintln(writer, "DNS CONFIGURATION") - fmt.Fprintln(writer, strings.Repeat("-", 70)) + // Print checks by category + categoryOrder := []api.CheckCategory{ + api.Authentication, + api.Dns, + api.Blacklist, + api.Content, + api.Headers, + } - dns := report.DnsResults - fmt.Fprintf(writer, "\nFrom Domain: %s\n", dns.FromDomain) - if dns.RpDomain != nil && *dns.RpDomain != dns.FromDomain { - fmt.Fprintf(writer, "Return-Path Domain: %s\n", *dns.RpDomain) + for _, category := range categoryOrder { + checks, ok := categories[category] + if !ok || len(checks) == 0 { + continue } - // MX Records - if dns.FromMxRecords != nil && len(*dns.FromMxRecords) > 0 { - fmt.Fprintln(writer, "\n MX Records (From Domain):") - for _, mx := range *dns.FromMxRecords { - status := "✓" - if !mx.Valid { - status = "✗" - } - fmt.Fprintf(writer, " %s [%d] %s", status, mx.Priority, mx.Host) - if mx.Error != nil { - fmt.Fprintf(writer, " - ERROR: %s", *mx.Error) - } - fmt.Fprintln(writer) + fmt.Fprintf(writer, "\n%s:\n", category) + for _, check := range checks { + statusSymbol := "✓" + if check.Status == api.CheckStatusFail { + statusSymbol = "✗" + } else if check.Status == api.CheckStatusWarn { + statusSymbol = "⚠" } - } - // SPF Records - if dns.SpfRecords != nil && len(*dns.SpfRecords) > 0 { - fmt.Fprintln(writer, "\n SPF Records:") - for _, spf := range *dns.SpfRecords { - status := "✓" - if !spf.Valid { - status = "✗" - } - fmt.Fprintf(writer, " %s ", status) - if spf.Domain != nil { - fmt.Fprintf(writer, "Domain: %s", *spf.Domain) - } - if spf.AllQualifier != nil { - fmt.Fprintf(writer, " (all: %s)", *spf.AllQualifier) - } - fmt.Fprintln(writer) - if spf.Record != nil { - fmt.Fprintf(writer, " %s\n", *spf.Record) - } - if spf.Error != nil { - fmt.Fprintf(writer, " ERROR: %s\n", *spf.Error) - } - } - } - - // DKIM Records - if dns.DkimRecords != nil && len(*dns.DkimRecords) > 0 { - fmt.Fprintln(writer, "\n DKIM Records:") - for _, dkim := range *dns.DkimRecords { - status := "✓" - if !dkim.Valid { - status = "✗" - } - fmt.Fprintf(writer, " %s Selector: %s, Domain: %s\n", status, dkim.Selector, dkim.Domain) - if dkim.Record != nil { - fmt.Fprintf(writer, " %s\n", *dkim.Record) - } - if dkim.Error != nil { - fmt.Fprintf(writer, " ERROR: %s\n", *dkim.Error) - } - } - } - - // DMARC Record - if dns.DmarcRecord != nil { - fmt.Fprintln(writer, "\n DMARC Record:") - status := "✓" - if !dns.DmarcRecord.Valid { - status = "✗" - } - fmt.Fprintf(writer, " %s Valid: %t", status, dns.DmarcRecord.Valid) - if dns.DmarcRecord.Policy != nil { - fmt.Fprintf(writer, ", Policy: %s", *dns.DmarcRecord.Policy) - } - if dns.DmarcRecord.SubdomainPolicy != nil { - fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy) - } - fmt.Fprintln(writer) - if dns.DmarcRecord.Record != nil { - fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record) - } - if dns.DmarcRecord.Error != nil { - fmt.Fprintf(writer, " ERROR: %s\n", *dns.DmarcRecord.Error) - } - } - - // BIMI Record - if dns.BimiRecord != nil { - fmt.Fprintln(writer, "\n BIMI Record:") - status := "✓" - if !dns.BimiRecord.Valid { - status = "✗" - } - fmt.Fprintf(writer, " %s Valid: %t, Selector: %s, Domain: %s\n", - status, dns.BimiRecord.Valid, dns.BimiRecord.Selector, dns.BimiRecord.Domain) - if dns.BimiRecord.LogoUrl != nil { - fmt.Fprintf(writer, " Logo URL: %s\n", *dns.BimiRecord.LogoUrl) - } - if dns.BimiRecord.VmcUrl != nil { - fmt.Fprintf(writer, " VMC URL: %s\n", *dns.BimiRecord.VmcUrl) - } - if dns.BimiRecord.Record != nil { - fmt.Fprintf(writer, " %s\n", *dns.BimiRecord.Record) - } - if dns.BimiRecord.Error != nil { - fmt.Fprintf(writer, " ERROR: %s\n", *dns.BimiRecord.Error) - } - } - - // PTR Records - if dns.PtrRecords != nil && len(*dns.PtrRecords) > 0 { - fmt.Fprintln(writer, "\n PTR (Reverse DNS) Records:") - for _, ptr := range *dns.PtrRecords { - fmt.Fprintf(writer, " %s\n", ptr) - } - } - - // DNS Errors - if dns.Errors != nil && len(*dns.Errors) > 0 { - fmt.Fprintln(writer, "\n DNS Errors:") - for _, err := range *dns.Errors { - fmt.Fprintf(writer, " ! %s\n", err) + fmt.Fprintf(writer, " %s %s: %s\n", statusSymbol, check.Name, check.Message) + if check.Advice != nil && *check.Advice != "" { + fmt.Fprintf(writer, " → %s\n", *check.Advice) } } } - // Authentication Results - if report.Authentication != nil { - fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) - fmt.Fprintln(writer, "AUTHENTICATION RESULTS") - fmt.Fprintln(writer, strings.Repeat("-", 70)) - - auth := report.Authentication - - // SPF - if auth.Spf != nil { - fmt.Fprintf(writer, "\n SPF: %s", strings.ToUpper(string(auth.Spf.Result))) - if auth.Spf.Domain != nil { - fmt.Fprintf(writer, " (domain: %s)", *auth.Spf.Domain) - } - if auth.Spf.Details != nil { - fmt.Fprintf(writer, "\n Details: %s", *auth.Spf.Details) - } - fmt.Fprintln(writer) - } - - // DKIM - if auth.Dkim != nil && len(*auth.Dkim) > 0 { - fmt.Fprintln(writer, "\n DKIM:") - for i, dkim := range *auth.Dkim { - fmt.Fprintf(writer, " [%d] %s", i+1, strings.ToUpper(string(dkim.Result))) - if dkim.Domain != nil { - fmt.Fprintf(writer, " (domain: %s", *dkim.Domain) - if dkim.Selector != nil { - fmt.Fprintf(writer, ", selector: %s", *dkim.Selector) - } - fmt.Fprintf(writer, ")") - } - if dkim.Details != nil { - fmt.Fprintf(writer, "\n Details: %s", *dkim.Details) - } - fmt.Fprintln(writer) - } - } - - // DMARC - if auth.Dmarc != nil { - fmt.Fprintf(writer, "\n DMARC: %s", strings.ToUpper(string(auth.Dmarc.Result))) - if auth.Dmarc.Domain != nil { - fmt.Fprintf(writer, " (domain: %s)", *auth.Dmarc.Domain) - } - if auth.Dmarc.Details != nil { - fmt.Fprintf(writer, "\n Details: %s", *auth.Dmarc.Details) - } - fmt.Fprintln(writer) - } - - // ARC - if auth.Arc != nil { - fmt.Fprintf(writer, "\n ARC: %s", strings.ToUpper(string(auth.Arc.Result))) - if auth.Arc.ChainLength != nil { - fmt.Fprintf(writer, " (chain length: %d)", *auth.Arc.ChainLength) - } - if auth.Arc.ChainValid != nil { - fmt.Fprintf(writer, " [valid: %t]", *auth.Arc.ChainValid) - } - if auth.Arc.Details != nil { - fmt.Fprintf(writer, "\n Details: %s", *auth.Arc.Details) - } - fmt.Fprintln(writer) - } - - // BIMI - if auth.Bimi != nil { - fmt.Fprintf(writer, "\n BIMI: %s", strings.ToUpper(string(auth.Bimi.Result))) - if auth.Bimi.Domain != nil { - fmt.Fprintf(writer, " (domain: %s)", *auth.Bimi.Domain) - } - if auth.Bimi.Details != nil { - fmt.Fprintf(writer, "\n Details: %s", *auth.Bimi.Details) - } - fmt.Fprintln(writer) - } - - // IP Reverse - if auth.Iprev != nil { - fmt.Fprintf(writer, "\n IP Reverse DNS: %s", strings.ToUpper(string(auth.Iprev.Result))) - if auth.Iprev.Ip != nil { - fmt.Fprintf(writer, " (ip: %s", *auth.Iprev.Ip) - if auth.Iprev.Hostname != nil { - fmt.Fprintf(writer, " -> %s", *auth.Iprev.Hostname) - } - fmt.Fprintf(writer, ")") - } - if auth.Iprev.Details != nil { - fmt.Fprintf(writer, "\n Details: %s", *auth.Iprev.Details) - } - fmt.Fprintln(writer) - } - } - - // Blacklist Results - if report.Blacklists != nil && len(*report.Blacklists) > 0 { - fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) - fmt.Fprintln(writer, "BLACKLIST CHECKS") - fmt.Fprintln(writer, strings.Repeat("-", 70)) - - totalChecks := 0 - totalListed := 0 - for ip, checks := range *report.Blacklists { - totalChecks += len(checks) - fmt.Fprintf(writer, "\n IP Address: %s\n", ip) - for _, check := range checks { - status := "✓" - if check.Listed { - status = "✗" - totalListed++ - } - fmt.Fprintf(writer, " %s %s", status, check.Rbl) - if check.Listed { - fmt.Fprintf(writer, " - LISTED") - if check.Response != nil { - fmt.Fprintf(writer, " (%s)", *check.Response) - } - } else { - fmt.Fprintf(writer, " - OK") - } - fmt.Fprintln(writer) - if check.Error != nil { - fmt.Fprintf(writer, " ERROR: %s\n", *check.Error) - } - } - } - fmt.Fprintf(writer, "\n Summary: %d/%d blacklists triggered\n", totalListed, totalChecks) - } - - // Header Analysis - if report.HeaderAnalysis != nil { - fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) - fmt.Fprintln(writer, "HEADER ANALYSIS") - fmt.Fprintln(writer, strings.Repeat("-", 70)) - - header := report.HeaderAnalysis - - // Domain Alignment - if header.DomainAlignment != nil { - fmt.Fprintln(writer, "\n Domain Alignment:") - align := header.DomainAlignment - if align.FromDomain != nil { - fmt.Fprintf(writer, " From Domain: %s", *align.FromDomain) - if align.FromOrgDomain != nil { - fmt.Fprintf(writer, " (org: %s)", *align.FromOrgDomain) - } - fmt.Fprintln(writer) - } - if align.ReturnPathDomain != nil { - fmt.Fprintf(writer, " Return-Path Domain: %s", *align.ReturnPathDomain) - if align.ReturnPathOrgDomain != nil { - fmt.Fprintf(writer, " (org: %s)", *align.ReturnPathOrgDomain) - } - fmt.Fprintln(writer) - } - if align.Aligned != nil { - fmt.Fprintf(writer, " Strict Alignment: %t\n", *align.Aligned) - } - if align.RelaxedAligned != nil { - fmt.Fprintf(writer, " Relaxed Alignment: %t\n", *align.RelaxedAligned) - } - if align.Issues != nil && len(*align.Issues) > 0 { - fmt.Fprintln(writer, " Issues:") - for _, issue := range *align.Issues { - fmt.Fprintf(writer, " - %s\n", issue) - } - } - } - - // Required/Important Headers - if header.Headers != nil { - fmt.Fprintln(writer, "\n Standard Headers:") - importantHeaders := []string{"from", "to", "subject", "date", "message-id", "dkim-signature"} - for _, hdrName := range importantHeaders { - if hdr, ok := (*header.Headers)[hdrName]; ok { - status := "✗" - if hdr.Present { - status = "✓" - } - fmt.Fprintf(writer, " %s %s: ", status, strings.ToUpper(hdrName)) - if hdr.Present { - if hdr.Valid != nil && !*hdr.Valid { - fmt.Fprintf(writer, "INVALID") - } else { - fmt.Fprintf(writer, "OK") - } - if hdr.Importance != nil { - fmt.Fprintf(writer, " [%s]", *hdr.Importance) - } - } else { - fmt.Fprintf(writer, "MISSING") - } - fmt.Fprintln(writer) - if hdr.Issues != nil && len(*hdr.Issues) > 0 { - for _, issue := range *hdr.Issues { - fmt.Fprintf(writer, " - %s\n", issue) - } - } - } - } - } - - // Header Issues - if header.Issues != nil && len(*header.Issues) > 0 { - fmt.Fprintln(writer, "\n Header Issues:") - for _, issue := range *header.Issues { - fmt.Fprintf(writer, " [%s] %s: %s\n", - strings.ToUpper(string(issue.Severity)), issue.Header, issue.Message) - if issue.Advice != nil { - fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice) - } - } - } - - // Received Chain - if header.ReceivedChain != nil && len(*header.ReceivedChain) > 0 { - fmt.Fprintln(writer, "\n Email Path (Received Chain):") - for i, hop := range *header.ReceivedChain { - fmt.Fprintf(writer, " [%d] ", i+1) - if hop.From != nil { - fmt.Fprintf(writer, "%s", *hop.From) - if hop.Ip != nil { - fmt.Fprintf(writer, " (%s)", *hop.Ip) - } - } - if hop.By != nil { - fmt.Fprintf(writer, " -> %s", *hop.By) - } - fmt.Fprintln(writer) - if hop.Timestamp != nil { - fmt.Fprintf(writer, " Time: %s\n", hop.Timestamp.Format("2006-01-02 15:04:05 MST")) - } - } - } - } - - // SpamAssassin Results - if report.Spamassassin != nil { - fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) - fmt.Fprintln(writer, "SPAMASSASSIN ANALYSIS") - fmt.Fprintln(writer, strings.Repeat("-", 70)) - - sa := report.Spamassassin - fmt.Fprintf(writer, "\n Score: %.2f / %.2f", sa.Score, sa.RequiredScore) - if sa.IsSpam { - fmt.Fprintf(writer, " (SPAM)") - } else { - fmt.Fprintf(writer, " (HAM)") - } - fmt.Fprintln(writer) - - if sa.Version != nil { - fmt.Fprintf(writer, " Version: %s\n", *sa.Version) - } - - if len(sa.TestDetails) > 0 { - fmt.Fprintln(writer, "\n Triggered Tests:") - for _, test := range sa.TestDetails { - scoreStr := "+" - if test.Score < 0 { - scoreStr = "" - } - fmt.Fprintf(writer, " [%s%.2f] %s", scoreStr, test.Score, test.Name) - if test.Description != nil { - fmt.Fprintf(writer, "\n %s", *test.Description) - } - fmt.Fprintln(writer) - } - } - } - - // Content Analysis - if report.ContentAnalysis != nil { - fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) - fmt.Fprintln(writer, "CONTENT ANALYSIS") - fmt.Fprintln(writer, strings.Repeat("-", 70)) - - content := report.ContentAnalysis - - // Basic content info - fmt.Fprintln(writer, "\n Content Structure:") - if content.HasPlaintext != nil { - fmt.Fprintf(writer, " Has Plaintext: %t\n", *content.HasPlaintext) - } - if content.HasHtml != nil { - fmt.Fprintf(writer, " Has HTML: %t\n", *content.HasHtml) - } - if content.TextToImageRatio != nil { - fmt.Fprintf(writer, " Text-to-Image Ratio: %.2f\n", *content.TextToImageRatio) - } - - // Unsubscribe - if content.HasUnsubscribeLink != nil { - fmt.Fprintf(writer, " Has Unsubscribe Link: %t\n", *content.HasUnsubscribeLink) - if *content.HasUnsubscribeLink && content.UnsubscribeMethods != nil && len(*content.UnsubscribeMethods) > 0 { - fmt.Fprintf(writer, " Unsubscribe Methods: ") - for i, method := range *content.UnsubscribeMethods { - if i > 0 { - fmt.Fprintf(writer, ", ") - } - fmt.Fprintf(writer, "%s", method) - } - fmt.Fprintln(writer) - } - } - - // Links - if content.Links != nil && len(*content.Links) > 0 { - fmt.Fprintf(writer, "\n Links (%d total):\n", len(*content.Links)) - for _, link := range *content.Links { - status := "" - switch link.Status { - case "valid": - status = "✓" - case "broken": - status = "✗" - case "suspicious": - status = "⚠" - case "redirected": - status = "→" - case "timeout": - status = "⏱" - } - fmt.Fprintf(writer, " %s [%s] %s", status, link.Status, link.Url) - if link.HttpCode != nil { - fmt.Fprintf(writer, " (HTTP %d)", *link.HttpCode) - } - fmt.Fprintln(writer) - if link.RedirectChain != nil && len(*link.RedirectChain) > 0 { - fmt.Fprintln(writer, " Redirect chain:") - for _, url := range *link.RedirectChain { - fmt.Fprintf(writer, " -> %s\n", url) - } - } - } - } - - // Images - if content.Images != nil && len(*content.Images) > 0 { - fmt.Fprintf(writer, "\n Images (%d total):\n", len(*content.Images)) - missingAlt := 0 - trackingPixels := 0 - for _, img := range *content.Images { - if !img.HasAlt { - missingAlt++ - } - if img.IsTrackingPixel != nil && *img.IsTrackingPixel { - trackingPixels++ - } - } - fmt.Fprintf(writer, " Images with ALT text: %d/%d\n", - len(*content.Images)-missingAlt, len(*content.Images)) - if trackingPixels > 0 { - fmt.Fprintf(writer, " Tracking pixels detected: %d\n", trackingPixels) - } - } - - // HTML Issues - if content.HtmlIssues != nil && len(*content.HtmlIssues) > 0 { - fmt.Fprintln(writer, "\n Content Issues:") - for _, issue := range *content.HtmlIssues { - fmt.Fprintf(writer, " [%s] %s: %s\n", - strings.ToUpper(string(issue.Severity)), issue.Type, issue.Message) - if issue.Location != nil { - fmt.Fprintf(writer, " Location: %s\n", *issue.Location) - } - if issue.Advice != nil { - fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice) - } - } - } - } - - // Footer fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) - fmt.Fprintf(writer, "Report generated by happyDeliver - https://happydeliver.org\n") - fmt.Fprintln(writer, strings.Repeat("=", 70)) - return nil } diff --git a/internal/app/cli_backup.go b/internal/app/cli_backup.go deleted file mode 100644 index 4b01fbb..0000000 --- a/internal/app/cli_backup.go +++ /dev/null @@ -1,156 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package app - -import ( - "encoding/json" - "fmt" - "io" - "os" - - "git.happydns.org/happyDeliver/internal/config" - "git.happydns.org/happyDeliver/internal/storage" -) - -// BackupData represents the structure of a backup file -type BackupData struct { - Version string `json:"version"` - Reports []storage.Report `json:"reports"` -} - -// RunBackup exports the database to stdout as JSON -func RunBackup(cfg *config.Config) error { - if err := cfg.Validate(); err != nil { - return err - } - - // Initialize storage - store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) - if err != nil { - return fmt.Errorf("failed to connect to database: %w", err) - } - defer store.Close() - - fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type) - - // Get all reports from the database - reports, err := storage.GetAllReports(store) - if err != nil { - return fmt.Errorf("failed to retrieve reports: %w", err) - } - - fmt.Fprintf(os.Stderr, "Found %d reports to backup\n", len(reports)) - - // Create backup data structure - backup := BackupData{ - Version: "1.0", - Reports: reports, - } - - // Encode to JSON and write to stdout - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") - if err := encoder.Encode(backup); err != nil { - return fmt.Errorf("failed to encode backup data: %w", err) - } - - return nil -} - -// RunRestore imports the database from a JSON file or stdin -func RunRestore(cfg *config.Config, inputPath string) error { - if err := cfg.Validate(); err != nil { - return err - } - - // Determine input source - var reader io.Reader - if inputPath == "" || inputPath == "-" { - fmt.Fprintln(os.Stderr, "Reading backup from stdin...") - reader = os.Stdin - } else { - inFile, err := os.Open(inputPath) - if err != nil { - return fmt.Errorf("failed to open backup file: %w", err) - } - defer inFile.Close() - fmt.Fprintf(os.Stderr, "Reading backup from file: %s\n", inputPath) - reader = inFile - } - - // Decode JSON - var backup BackupData - decoder := json.NewDecoder(reader) - if err := decoder.Decode(&backup); err != nil { - if err == io.EOF { - return fmt.Errorf("backup file is empty or corrupted") - } - return fmt.Errorf("failed to decode backup data: %w", err) - } - - fmt.Fprintf(os.Stderr, "Backup version: %s\n", backup.Version) - fmt.Fprintf(os.Stderr, "Found %d reports in backup\n", len(backup.Reports)) - - // Initialize storage - store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) - if err != nil { - return fmt.Errorf("failed to connect to database: %w", err) - } - defer store.Close() - - fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type) - - // Restore reports - restored, skipped, failed := 0, 0, 0 - for _, report := range backup.Reports { - // Check if report already exists - exists, err := store.ReportExists(report.TestID) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: Failed to check if report %s exists: %v\n", report.TestID, err) - failed++ - continue - } - - if exists { - fmt.Fprintf(os.Stderr, "Report %s already exists, skipping\n", report.TestID) - skipped++ - continue - } - - // Create the report - _, err = storage.CreateReportFromBackup(store, &report) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: Failed to restore report %s: %v\n", report.TestID, err) - failed++ - continue - } - - restored++ - } - - fmt.Fprintf(os.Stderr, "Restore completed: %d restored, %d skipped, %d failed\n", restored, skipped, failed) - if failed > 0 { - return fmt.Errorf("restore completed with %d failures", failed) - } - - return nil -} diff --git a/internal/app/server.go b/internal/app/server.go index 7149f45..332516b 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -25,16 +25,13 @@ import ( "context" "log" "os" - "time" - ratelimit "github.com/JGLTechnologies/gin-rate-limit" "github.com/gin-gonic/gin" "git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/lmtp" "git.happydns.org/happyDeliver/internal/storage" - "git.happydns.org/happyDeliver/pkg/analyzer" "git.happydns.org/happyDeliver/web" ) @@ -66,11 +63,8 @@ func RunServer(cfg *config.Config) error { } }() - // Create analyzer adapter for API - analyzerAdapter := analyzer.NewAPIAdapter(cfg) - // Create API handler - handler := api.NewAPIHandler(store, cfg, analyzerAdapter) + handler := api.NewAPIHandler(store, cfg) // Set up Gin router if os.Getenv("GIN_MODE") == "" { @@ -78,30 +72,8 @@ func RunServer(cfg *config.Config) error { } router := gin.Default() - apiGroup := router.Group("/api") - - if cfg.RateLimit > 0 { - // Set up rate limiting (2x to handle burst) - rateLimitStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{ - Rate: 2 * time.Second, - Limit: 2 * cfg.RateLimit, - }) - rateLimiter := ratelimit.RateLimiter(rateLimitStore, &ratelimit.Options{ - ErrorHandler: func(c *gin.Context, info ratelimit.Info) { - c.JSON(429, gin.H{ - "error": "rate_limit_exceeded", - "message": "Too many requests. Try again in " + time.Until(info.ResetTime).String(), - }) - }, - KeyFunc: func(c *gin.Context) string { - return c.ClientIP() - }, - }) - - apiGroup.Use(rateLimiter) - } - // Register API routes + apiGroup := router.Group("/api") api.RegisterHandlers(apiGroup, handler) web.DeclareRoutes(cfg, router) diff --git a/internal/config/cli.go b/internal/config/cli.go index fcc914f..93c18ce 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -34,17 +34,10 @@ func declareFlags(o *Config) { flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails") flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)") flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address") - flag.StringVar(&o.Email.ReceiverHostname, "receiver-hostname", o.Email.ReceiverHostname, "Hostname used to filter Authentication-Results headers (defaults to os.Hostname())") flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query") flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query") flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)") - flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)") - flag.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)") flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever") - flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)") - flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey") - flag.StringVar(&o.CustomLogoURL, "custom-logo-url", o.CustomLogoURL, "URL for custom logo image in the web UI") - flag.BoolVar(&o.DisableTestList, "disable-test-list", o.DisableTestList, "Disable the public test listing endpoint") // Others flags are declared in some other files likes sources, storages, ... when they need specials configurations } diff --git a/internal/config/config.go b/internal/config/config.go index b264994..d59045b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,7 +25,6 @@ import ( "flag" "fmt" "log" - "net/url" "os" "path" "strings" @@ -34,11 +33,6 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) -func getHostname() string { - h, _ := os.Hostname() - return h -} - // Config represents the application configuration type Config struct { DevProxy string @@ -47,10 +41,6 @@ type Config struct { Email EmailConfig Analysis AnalysisConfig ReportRetention time.Duration // How long to keep reports. 0 = keep forever - RateLimit uint // API rate limit (requests per second per IP) - SurveyURL url.URL // URL for user feedback survey - CustomLogoURL string // URL for custom logo image in the web UI - DisableTestList bool // Disable the public test listing endpoint } // DatabaseConfig contains database connection settings @@ -64,26 +54,21 @@ type EmailConfig struct { Domain string TestAddressPrefix string LMTPAddr string - ReceiverHostname string } // AnalysisConfig contains timeout and behavior settings for email analysis type AnalysisConfig struct { - DNSTimeout time.Duration - HTTPTimeout time.Duration - RBLs []string - DNSWLs []string - CheckAllIPs bool // Check all IPs found in headers, not just the first one - RspamdAPIURL string // rspamd API URL for fetching symbol descriptions (empty = use embedded list) + DNSTimeout time.Duration + HTTPTimeout time.Duration + RBLs []string } // DefaultConfig returns a configuration with sensible defaults func DefaultConfig() *Config { return &Config{ DevProxy: "", - Bind: ":8080", + Bind: ":8081", ReportRetention: 0, // Keep reports forever by default - RateLimit: 1, // is in fact 2 requests per 2 seconds per IP (default) Database: DatabaseConfig{ Type: "sqlite", DSN: "happydeliver.db", @@ -92,14 +77,11 @@ func DefaultConfig() *Config { Domain: "happydeliver.local", TestAddressPrefix: "test-", LMTPAddr: "127.0.0.1:2525", - ReceiverHostname: getHostname(), }, Analysis: AnalysisConfig{ DNSTimeout: 5 * time.Second, HTTPTimeout: 10 * time.Second, RBLs: []string{}, - DNSWLs: []string{}, - CheckAllIPs: false, // By default, only check the first IP }, } } @@ -131,7 +113,7 @@ func ConsolidateConfig() (opts *Config, err error) { // If config file exists, read configuration from it for _, filename := range configLocations { - if _, e := os.Stat(filename); !os.IsNotExist(e) && !os.IsPermission(e) { + if _, e := os.Stat(filename); !os.IsNotExist(e) { log.Printf("Loading configuration from %s\n", filename) err = parseFile(opts, filename) if err != nil { diff --git a/internal/config/custom.go b/internal/config/custom.go index 97c8d71..9461632 100644 --- a/internal/config/custom.go +++ b/internal/config/custom.go @@ -23,7 +23,6 @@ package config import ( "fmt" - "net/url" "strings" ) @@ -44,25 +43,3 @@ func (i *StringArray) Set(value string) error { return nil } - -type URL struct { - URL *url.URL -} - -func (i *URL) String() string { - if i.URL != nil { - return i.URL.String() - } else { - return "" - } -} - -func (i *URL) Set(value string) error { - u, err := url.Parse(value) - if err != nil { - return err - } - - *i.URL = *u - return nil -} diff --git a/internal/lmtp/server.go b/internal/lmtp/server.go index a9b36b9..1d9a720 100644 --- a/internal/lmtp/server.go +++ b/internal/lmtp/server.go @@ -92,10 +92,6 @@ func (s *Session) Data(r io.Reader) error { log.Printf("LMTP: Received %d bytes", len(emailData)) - // Prepend Return-Path header from envelope sender - returnPath := fmt.Sprintf("Return-Path: <%s>\r\n", s.from) - emailData = append([]byte(returnPath), emailData...) - // Process email for each recipient // LMTP requires per-recipient status, but go-smtp handles this internally for _, recipient := range s.recipients { diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index f06f535..db1c2ea 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -22,7 +22,6 @@ package receiver import ( - "encoding/base32" "encoding/json" "fmt" "io" @@ -32,9 +31,9 @@ import ( "github.com/google/uuid" + "git.happydns.org/happyDeliver/internal/analyzer" "git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/storage" - "git.happydns.org/happyDeliver/pkg/analyzer" ) // EmailReceiver handles incoming emails from the MTA @@ -96,18 +95,7 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string return fmt.Errorf("failed to analyze email: %w", err) } - log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score) - - // Warn if the last Received hop doesn't match the expected receiver hostname - if r.config.Email.ReceiverHostname != "" && - result.Report.HeaderAnalysis != nil && - result.Report.HeaderAnalysis.ReceivedChain != nil && - len(*result.Report.HeaderAnalysis.ReceivedChain) > 0 { - lastHop := (*result.Report.HeaderAnalysis.ReceivedChain)[0] - if lastHop.By != nil && *lastHop.By != r.config.Email.ReceiverHostname { - log.Printf("WARNING: Last Received hop 'by' field (%s) does not match expected receiver hostname (%s): check your RECEIVER_HOSTNAME config as authentication results will be false", *lastHop.By, r.config.Email.ReceiverHostname) - } - } + log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score) // Marshal report to JSON reportJSON, err := json.Marshal(result.Report) @@ -124,34 +112,8 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string return nil } -// base32ToUUID converts a URL-safe Base32 string (without padding) to a UUID -// Hyphens are ignored during decoding -func base32ToUUID(encoded string) (uuid.UUID, error) { - // Remove hyphens for decoding - encoded = strings.ReplaceAll(encoded, "-", "") - - // Convert to uppercase for Base32 decoding - encoded = strings.ToUpper(encoded) - - // Decode from Base32 - decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded) - if err != nil { - return uuid.Nil, fmt.Errorf("failed to decode base32: %w", err) - } - - // Ensure we have exactly 16 bytes for UUID - if len(decoded) != 16 { - return uuid.Nil, fmt.Errorf("decoded bytes length is %d, expected 16", len(decoded)) - } - - // Convert bytes to UUID - var id uuid.UUID - copy(id[:], decoded) - return id, nil -} - // extractTestID extracts the UUID from the test email address -// Expected format: test-@domain.com +// Expected format: test-@domain.com func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) { // Remove angle brackets if present (e.g., ) email = strings.Trim(email, "<>") @@ -171,10 +133,10 @@ func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) { uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix) - // Decode Base32 to UUID - testID, err := base32ToUUID(uuidStr) + // Parse UUID + testID, err := uuid.Parse(uuidStr) if err != nil { - return uuid.Nil, fmt.Errorf("invalid Base32 encoding in email address: %s - %w", uuidStr, err) + return uuid.Nil, fmt.Errorf("invalid UUID in email address: %s", uuidStr) } return testID, nil diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 86605df..7c27279 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -30,9 +30,6 @@ import ( "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" - - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" ) var ( @@ -46,9 +43,7 @@ type Storage interface { CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err error) ReportExists(testID uuid.UUID) (bool, error) - UpdateReport(testID uuid.UUID, reportJSON []byte) error DeleteOldReports(olderThan time.Time) (int64, error) - ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) // Close closes the database connection Close() error @@ -112,7 +107,7 @@ func (s *DBStorage) ReportExists(testID uuid.UUID) (bool, error) { // GetReport retrieves a report by test ID, returning the raw JSON and email bytes func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) { var dbReport Report - if err := s.db.Where("test_id = ?", testID).Order("created_at DESC").First(&dbReport).Error; err != nil { + if err := s.db.First(&dbReport, "test_id = ?", testID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, ErrNotFound } @@ -122,18 +117,6 @@ func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) { return dbReport.ReportJSON, dbReport.RawEmail, nil } -// UpdateReport updates the report JSON for an existing test ID -func (s *DBStorage) UpdateReport(testID uuid.UUID, reportJSON []byte) error { - result := s.db.Model(&Report{}).Where("test_id = ?", testID).Update("report_json", reportJSON) - if result.Error != nil { - return fmt.Errorf("failed to update report: %w", result.Error) - } - if result.RowsAffected == 0 { - return ErrNotFound - } - return nil -} - // DeleteOldReports deletes reports older than the specified time func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) { result := s.db.Where("created_at < ?", olderThan).Delete(&Report{}) @@ -143,72 +126,6 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) { return result.RowsAffected, nil } -// reportSummaryRow is used internally to scan SQL results before converting to model.TestSummary -type reportSummaryRow struct { - TestID uuid.UUID - Score int - Grade string - FromDomain string - CreatedAt time.Time -} - -// ListReportSummaries returns a paginated list of lightweight report summaries -func (s *DBStorage) ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) { - var total int64 - if err := s.db.Model(&Report{}).Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count reports: %w", err) - } - - if total == 0 { - return []model.TestSummary{}, 0, nil - } - - var selectExpr string - switch s.db.Dialector.Name() { - case "postgres": - selectExpr = `test_id, ` + - `(convert_from(report_json, 'UTF8')::jsonb->>'score')::int as score, ` + - `convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` + - `convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` + - `created_at` - case "sqlite": - selectExpr = `test_id, ` + - `json_extract(report_json, '$.score') as score, ` + - `json_extract(report_json, '$.grade') as grade, ` + - `json_extract(report_json, '$.dns_results.from_domain') as from_domain, ` + - `created_at` - default: - return nil, 0, fmt.Errorf("history tests list not implemented in this database dialect") - } - - var rows []reportSummaryRow - err := s.db.Model(&Report{}). - Select(selectExpr). - Order("created_at DESC"). - Offset(offset). - Limit(limit). - Scan(&rows).Error - if err != nil { - return nil, 0, fmt.Errorf("failed to list report summaries: %w", err) - } - - summaries := make([]model.TestSummary, 0, len(rows)) - for _, r := range rows { - s := model.TestSummary{ - TestId: utils.UUIDToBase32(r.TestID), - Score: r.Score, - Grade: model.TestSummaryGrade(r.Grade), - CreatedAt: r.CreatedAt, - } - if r.FromDomain != "" { - s.FromDomain = utils.PtrTo(r.FromDomain) - } - summaries = append(summaries, s) - } - - return summaries, total, nil -} - // Close closes the database connection func (s *DBStorage) Close() error { sqlDB, err := s.db.DB() @@ -217,33 +134,3 @@ func (s *DBStorage) Close() error { } return sqlDB.Close() } - -// GetAllReports retrieves all reports from the database -func GetAllReports(s Storage) ([]Report, error) { - dbStorage, ok := s.(*DBStorage) - if !ok { - return nil, fmt.Errorf("storage type does not support GetAllReports") - } - - var reports []Report - if err := dbStorage.db.Find(&reports).Error; err != nil { - return nil, fmt.Errorf("failed to retrieve reports: %w", err) - } - - return reports, nil -} - -// CreateReportFromBackup creates a report from backup data, preserving timestamps -func CreateReportFromBackup(s Storage, report *Report) (*Report, error) { - dbStorage, ok := s.(*DBStorage) - if !ok { - return nil, fmt.Errorf("storage type does not support CreateReportFromBackup") - } - - // Use Create to insert the report with all fields including timestamps - if err := dbStorage.db.Create(report).Error; err != nil { - return nil, fmt.Errorf("failed to create report from backup: %w", err) - } - - return report, nil -} diff --git a/internal/utils/uuid.go b/internal/utils/uuid.go deleted file mode 100644 index ebbbbdf..0000000 --- a/internal/utils/uuid.go +++ /dev/null @@ -1,75 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package utils - -import ( - "encoding/base32" - "fmt" - "strings" - - "github.com/google/uuid" -) - -// UUIDToBase32 converts a UUID to a URL-safe Base32 string (without padding) -// with hyphens every 7 characters for better readability -func UUIDToBase32(id uuid.UUID) string { - // Use RFC 4648 Base32 encoding (URL-safe) - encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(id[:]) - // Convert to lowercase for better readability - encoded = strings.ToLower(encoded) - - // Insert hyphens every 7 characters - var result strings.Builder - for i, char := range encoded { - if i > 0 && i%7 == 0 { - result.WriteRune('-') - } - result.WriteRune(char) - } - - return result.String() -} - -// Base32ToUUID converts a base32-encoded string back to a UUID -// Accepts strings with or without hyphens -func Base32ToUUID(encoded string) (uuid.UUID, error) { - // Remove hyphens - encoded = strings.ReplaceAll(encoded, "-", "") - // Convert to uppercase for decoding - encoded = strings.ToUpper(encoded) - - // Decode base32 - decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded) - if err != nil { - return uuid.UUID{}, fmt.Errorf("invalid base32 encoding: %w", err) - } - - // Ensure we have exactly 16 bytes for a UUID - if len(decoded) != 16 { - return uuid.UUID{}, fmt.Errorf("invalid UUID length: expected 16 bytes, got %d", len(decoded)) - } - - // Convert byte slice to UUID - var id uuid.UUID - copy(id[:], decoded) - return id, nil -} diff --git a/internal/version/version.go b/internal/version/version.go deleted file mode 100644 index a46c79f..0000000 --- a/internal/version/version.go +++ /dev/null @@ -1,26 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package version - -// Version is the application version. It can be set at build time using ldflags: -// go build -ldflags "-X git.happydns.org/happyDeliver/internal/version.Version=1.2.3" -var Version = "(custom build)" diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go deleted file mode 100644 index 5f57df3..0000000 --- a/pkg/analyzer/analyzer.go +++ /dev/null @@ -1,150 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "bytes" - "encoding/json" - "fmt" - - "github.com/google/uuid" - - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/config" -) - -// EmailAnalyzer provides high-level email analysis functionality -// This is the main entry point for analyzing emails from both LMTP and CLI -type EmailAnalyzer struct { - generator *ReportGenerator -} - -// NewEmailAnalyzer creates a new email analyzer with the given configuration -func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { - generator := NewReportGenerator( - cfg.Email.ReceiverHostname, - cfg.Analysis.DNSTimeout, - cfg.Analysis.HTTPTimeout, - cfg.Analysis.RBLs, - cfg.Analysis.DNSWLs, - cfg.Analysis.CheckAllIPs, - cfg.Analysis.RspamdAPIURL, - ) - - return &EmailAnalyzer{ - generator: generator, - } -} - -// AnalysisResult contains the complete analysis result -type AnalysisResult struct { - Email *EmailMessage - Results *AnalysisResults - Report *model.Report -} - -// AnalyzeEmailBytes performs complete email analysis from raw bytes -func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*AnalysisResult, error) { - // Parse the email - emailMsg, err := ParseEmail(bytes.NewReader(rawEmail)) - if err != nil { - return nil, fmt.Errorf("failed to parse email: %w", err) - } - - // Analyze the email - results := a.generator.AnalyzeEmail(emailMsg) - - // Generate the report - report := a.generator.GenerateReport(testID, results) - - return &AnalysisResult{ - Email: emailMsg, - Results: results, - Report: report, - }, nil -} - -// APIAdapter adapts the EmailAnalyzer to work with the API package -// This adapter implements the interface expected by the API handler -type APIAdapter struct { - analyzer *EmailAnalyzer -} - -// NewAPIAdapter creates a new API adapter for the email analyzer -func NewAPIAdapter(cfg *config.Config) *APIAdapter { - return &APIAdapter{ - analyzer: NewEmailAnalyzer(cfg), - } -} - -// AnalyzeEmailBytes performs analysis and returns JSON bytes directly -func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byte, error) { - result, err := a.analyzer.AnalyzeEmailBytes(rawEmail, testID) - if err != nil { - return nil, err - } - - // Marshal report to JSON - reportJSON, err := json.Marshal(result.Report) - if err != nil { - return nil, fmt.Errorf("failed to marshal report: %w", err) - } - - return reportJSON, nil -} - -// AnalyzeDomain performs DNS analysis for a domain and returns the results -func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, string) { - // Perform DNS analysis - dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain) - - // Calculate score - score, grade := a.analyzer.generator.dnsAnalyzer.CalculateDomainOnlyScore(dnsResults) - - return dnsResults, score, grade -} - -// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists -func (a *APIAdapter) CheckBlacklistIP(ip string) ([]model.BlacklistCheck, []model.BlacklistCheck, int, int, string, error) { - // Check the IP against all configured RBLs - checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip) - if err != nil { - return nil, nil, 0, 0, "", err - } - - // Calculate score using the existing function - // Create a minimal RBLResults structure for scoring - results := &DNSListResults{ - Checks: map[string][]model.BlacklistCheck{ip: checks}, - IPsChecked: []string{ip}, - ListedCount: listedCount, - } - score, grade := a.analyzer.generator.rblChecker.CalculateScore(results, false) - - // Check the IP against all configured DNSWLs (informational only) - whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip) - if err != nil { - whitelists = nil - } - - return checks, whitelists, listedCount, score, grade, nil -} diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go deleted file mode 100644 index da31b1c..0000000 --- a/pkg/analyzer/authentication.go +++ /dev/null @@ -1,187 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "strings" - - "git.happydns.org/happyDeliver/internal/model" -) - -// AuthenticationAnalyzer analyzes email authentication results -type AuthenticationAnalyzer struct { - receiverHostname string -} - -// NewAuthenticationAnalyzer creates a new authentication analyzer -func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer { - return &AuthenticationAnalyzer{receiverHostname: receiverHostname} -} - -// AnalyzeAuthentication extracts and analyzes authentication results from email headers -func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults { - results := &model.AuthenticationResults{} - - // Parse Authentication-Results headers - authHeaders := email.GetAuthenticationResults(a.receiverHostname) - for _, header := range authHeaders { - a.parseAuthenticationResultsHeader(header, results) - } - - // If no Authentication-Results headers, try to parse legacy headers - if results.Spf == nil { - results.Spf = a.parseLegacySPF(email) - } - - // Parse ARC headers if not already parsed from Authentication-Results - if results.Arc == nil { - results.Arc = a.parseARCHeaders(email) - } else { - // Enhance the ARC result with chain information from raw headers - a.enhanceARCResult(email, results.Arc) - } - - return results -} - -// parseAuthenticationResultsHeader parses an Authentication-Results header -// Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com -func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *model.AuthenticationResults) { - // Split by semicolon to get individual results - parts := strings.Split(header, ";") - if len(parts) < 2 { - return - } - - // Skip the authserv-id (first part) - for i := 1; i < len(parts); i++ { - part := strings.TrimSpace(parts[i]) - if part == "" { - continue - } - - // Parse SPF - if strings.HasPrefix(part, "spf=") { - if results.Spf == nil { - results.Spf = a.parseSPFResult(part) - } - } - - // Parse DKIM - if strings.HasPrefix(part, "dkim=") { - dkimResult := a.parseDKIMResult(part) - if dkimResult != nil { - if results.Dkim == nil { - dkimList := []model.AuthResult{*dkimResult} - results.Dkim = &dkimList - } else { - *results.Dkim = append(*results.Dkim, *dkimResult) - } - } - } - - // Parse DMARC - if strings.HasPrefix(part, "dmarc=") { - if results.Dmarc == nil { - results.Dmarc = a.parseDMARCResult(part) - } - } - - // Parse BIMI - if strings.HasPrefix(part, "bimi=") { - if results.Bimi == nil { - results.Bimi = a.parseBIMIResult(part) - } - } - - // Parse ARC - if strings.HasPrefix(part, "arc=") { - if results.Arc == nil { - results.Arc = a.parseARCResult(part) - } - } - - // Parse IPRev - if strings.HasPrefix(part, "iprev=") { - if results.Iprev == nil { - results.Iprev = a.parseIPRevResult(part) - } - } - - // Parse x-google-dkim - if strings.HasPrefix(part, "x-google-dkim=") { - if results.XGoogleDkim == nil { - results.XGoogleDkim = a.parseXGoogleDKIMResult(part) - } - } - - // Parse x-aligned-from - if strings.HasPrefix(part, "x-aligned-from=") { - if results.XAlignedFrom == nil { - results.XAlignedFrom = a.parseXAlignedFromResult(part) - } - } - } -} - -// CalculateAuthenticationScore calculates the authentication score from auth results -// Returns a score from 0-100 where higher is better -func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.AuthenticationResults) (int, string) { - if results == nil { - return 0, "" - } - - score := 0 - - // Core authentication (90 points total) - // SPF (30 points) - score += 30 * a.calculateSPFScore(results) / 100 - - // DKIM (30 points) - score += 30 * a.calculateDKIMScore(results) / 100 - - // DMARC (30 points) - score += 30 * a.calculateDMARCScore(results) / 100 - - // BIMI (10 points) - score += 10 * a.calculateBIMIScore(results) / 100 - - // Penalty-only: IPRev (up to -7 points on failure) - if iprevScore := a.calculateIPRevScore(results); iprevScore < 100 { - score += 7 * (iprevScore - 100) / 100 - } - - // Penalty-only: X-Google-DKIM (up to -12 points on failure) - score += 12 * a.calculateXGoogleDKIMScore(results) / 100 - - // Penalty-only: X-Aligned-From (up to -5 points on failure) - if xAlignedScore := a.calculateXAlignedFromScore(results); xAlignedScore < 100 { - score += 5 * (xAlignedScore - 100) / 100 - } - - // Ensure score doesn't exceed 100 - if score > 100 { - score = 100 - } - - return score, ScoreToGrade(score) -} diff --git a/pkg/analyzer/authentication_arc.go b/pkg/analyzer/authentication_arc.go deleted file mode 100644 index e7333ce..0000000 --- a/pkg/analyzer/authentication_arc.go +++ /dev/null @@ -1,184 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "fmt" - "regexp" - "slices" - "strings" - - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" -) - -// textprotoCanonical converts a header name to canonical form -func textprotoCanonical(s string) string { - // Simple implementation - capitalize each word - words := strings.Split(s, "-") - for i, word := range words { - if len(word) > 0 { - words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) - } - } - return strings.Join(words, "-") -} - -// pluralize returns "y" or "ies" based on count -func pluralize(count int) string { - if count == 1 { - return "y" - } - return "ies" -} - -// parseARCResult parses ARC result from Authentication-Results -// Example: arc=pass -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 = model.ARCResultResult(resultStr) - } - - 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) *model.ARCResult { - // Get all ARC-related headers - arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] - arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] - arcSeal := email.Header[textprotoCanonical("ARC-Seal")] - - // If no ARC headers present, return nil - if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 { - return nil - } - - result := &model.ARCResult{ - Result: model.ARCResultResultNone, - } - - // Count the ARC chain length (number of sets) - chainLength := len(arcSeal) - result.ChainLength = &chainLength - - // Validate the ARC chain - chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) - result.ChainValid = &chainValid - - // Determine overall result - if chainLength == 0 { - result.Result = model.ARCResultResultNone - details := "No ARC chain present" - result.Details = &details - } else if !chainValid { - result.Result = model.ARCResultResultFail - details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength) - result.Details = &details - } else { - result.Result = model.ARCResultResultPass - details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength)) - result.Details = &details - } - - return result -} - -// enhanceARCResult enhances an existing ARC result with chain information -func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *model.ARCResult) { - if arcResult == nil { - return - } - - // Get ARC headers - arcSeal := email.Header[textprotoCanonical("ARC-Seal")] - arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] - arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] - - // Set chain length if not already set - if arcResult.ChainLength == nil { - chainLength := len(arcSeal) - arcResult.ChainLength = &chainLength - } - - // Validate chain if not already validated - if arcResult.ChainValid == nil { - chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) - arcResult.ChainValid = &chainValid - } -} - -// validateARCChain validates the ARC chain for completeness -// Each instance should have all three headers with matching instance numbers -func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool { - // All three header types should have the same count - if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) { - return false - } - - if len(arcSeal) == 0 { - return true // No ARC chain is technically valid - } - - // Extract instance numbers from each header type - sealInstances := a.extractARCInstances(arcSeal) - sigInstances := a.extractARCInstances(arcMessageSig) - authInstances := a.extractARCInstances(arcAuthResults) - - // Check that all instance numbers match and are sequential starting from 1 - if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) { - return false - } - - // Verify instances are sequential from 1 to N - for i := 1; i <= len(sealInstances); i++ { - if !slices.Contains(sealInstances, i) || !slices.Contains(sigInstances, i) || !slices.Contains(authInstances, i) { - return false - } - } - - return true -} - -// extractARCInstances extracts instance numbers from ARC headers -func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int { - var instances []int - re := regexp.MustCompile(`i=(\d+)`) - - for _, header := range headers { - if matches := re.FindStringSubmatch(header); len(matches) > 1 { - var instance int - fmt.Sscanf(matches[1], "%d", &instance) - instances = append(instances, instance) - } - } - - return instances -} diff --git a/pkg/analyzer/authentication_arc_test.go b/pkg/analyzer/authentication_arc_test.go deleted file mode 100644 index ac51d0b..0000000 --- a/pkg/analyzer/authentication_arc_test.go +++ /dev/null @@ -1,150 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "testing" - - "git.happydns.org/happyDeliver/internal/model" -) - -func TestParseARCResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult model.ARCResultResult - }{ - { - name: "ARC pass", - part: "arc=pass", - expectedResult: model.ARCResultResultPass, - }, - { - name: "ARC fail", - part: "arc=fail", - expectedResult: model.ARCResultResultFail, - }, - { - name: "ARC none", - part: "arc=none", - expectedResult: model.ARCResultResultNone, - }, - } - - analyzer := NewAuthenticationAnalyzer("") - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseARCResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - }) - } -} - -func TestValidateARCChain(t *testing.T) { - tests := []struct { - name string - arcAuthResults []string - arcMessageSig []string - arcSeal []string - expectedValid bool - }{ - { - name: "Empty chain is valid", - arcAuthResults: []string{}, - arcMessageSig: []string{}, - arcSeal: []string{}, - expectedValid: true, - }, - { - name: "Valid chain with single hop", - arcAuthResults: []string{ - "i=1; example.com; spf=pass", - }, - arcMessageSig: []string{ - "i=1; a=rsa-sha256; d=example.com", - }, - arcSeal: []string{ - "i=1; a=rsa-sha256; s=arc; d=example.com", - }, - expectedValid: true, - }, - { - name: "Valid chain with two hops", - arcAuthResults: []string{ - "i=1; example.com; spf=pass", - "i=2; relay.com; arc=pass", - }, - arcMessageSig: []string{ - "i=1; a=rsa-sha256; d=example.com", - "i=2; a=rsa-sha256; d=relay.com", - }, - arcSeal: []string{ - "i=1; a=rsa-sha256; s=arc; d=example.com", - "i=2; a=rsa-sha256; s=arc; d=relay.com", - }, - expectedValid: true, - }, - { - name: "Invalid chain - missing one header type", - arcAuthResults: []string{ - "i=1; example.com; spf=pass", - }, - arcMessageSig: []string{ - "i=1; a=rsa-sha256; d=example.com", - }, - arcSeal: []string{}, - expectedValid: false, - }, - { - name: "Invalid chain - non-sequential instances", - arcAuthResults: []string{ - "i=1; example.com; spf=pass", - "i=3; relay.com; arc=pass", - }, - arcMessageSig: []string{ - "i=1; a=rsa-sha256; d=example.com", - "i=3; a=rsa-sha256; d=relay.com", - }, - arcSeal: []string{ - "i=1; a=rsa-sha256; s=arc; d=example.com", - "i=3; a=rsa-sha256; s=arc; d=relay.com", - }, - expectedValid: false, - }, - } - - analyzer := NewAuthenticationAnalyzer("") - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - valid := analyzer.validateARCChain(tt.arcAuthResults, tt.arcMessageSig, tt.arcSeal) - - if valid != tt.expectedValid { - t.Errorf("validateARCChain() = %v, want %v", valid, tt.expectedValid) - } - }) - } -} diff --git a/pkg/analyzer/authentication_bimi.go b/pkg/analyzer/authentication_bimi.go deleted file mode 100644 index 9654ac7..0000000 --- a/pkg/analyzer/authentication_bimi.go +++ /dev/null @@ -1,76 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "regexp" - "strings" - - "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) *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 = model.AuthResultResult(resultStr) - } - - // Extract domain (header.d or d) - domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (header.selector or selector) - selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - result.Details = utils.PtrTo(strings.TrimPrefix(part, "bimi=")) - - return result -} - -func (a *AuthenticationAnalyzer) calculateBIMIScore(results *model.AuthenticationResults) (score int) { - if results.Bimi != nil { - switch results.Bimi.Result { - case model.AuthResultResultPass: - return 100 - case model.AuthResultResultDeclined: - return 59 - default: // fail - return 0 - } - } - - return 0 -} diff --git a/pkg/analyzer/authentication_bimi_test.go b/pkg/analyzer/authentication_bimi_test.go deleted file mode 100644 index 440f356..0000000 --- a/pkg/analyzer/authentication_bimi_test.go +++ /dev/null @@ -1,94 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "testing" - - "git.happydns.org/happyDeliver/internal/model" -) - -func TestParseBIMIResult(t *testing.T) { - tests := []struct { - name string - part string - 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: model.AuthResultResultPass, - expectedDomain: "example.com", - expectedSelector: "default", - }, - { - name: "BIMI fail", - part: "bimi=fail header.d=example.com header.selector=default", - 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: model.AuthResultResultPass, - expectedDomain: "example.com", - expectedSelector: "v1", - }, - { - name: "BIMI none", - part: "bimi=none header.d=example.com", - expectedResult: model.AuthResultResultNone, - expectedDomain: "example.com", - }, - } - - analyzer := NewAuthenticationAnalyzer("") - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseBIMIResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - if result.Domain == nil || *result.Domain != tt.expectedDomain { - var gotDomain string - if result.Domain != nil { - gotDomain = *result.Domain - } - t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) - } - if tt.expectedSelector != "" { - if result.Selector == nil || *result.Selector != tt.expectedSelector { - var gotSelector string - if result.Selector != nil { - gotSelector = *result.Selector - } - t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) - } - } - }) - } -} diff --git a/pkg/analyzer/authentication_dkim.go b/pkg/analyzer/authentication_dkim.go deleted file mode 100644 index 4165d8b..0000000 --- a/pkg/analyzer/authentication_dkim.go +++ /dev/null @@ -1,87 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "regexp" - "strings" - - "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) *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 = model.AuthResultResult(resultStr) - } - - // Extract domain (header.d or d) - domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (header.s or s) - selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - result.Details = utils.PtrTo(strings.TrimPrefix(part, "dkim=")) - - return result -} - -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 == model.AuthResultResultPass { - hasPass = true - } else { - hasNonPass = true - } - } - if hasPass && hasNonPass { - // Could be better - return 90 - } else if hasPass { - return 100 - } else { - // Has DKIM signatures but none passed - return 20 - } - } - - return 0 -} diff --git a/pkg/analyzer/authentication_dkim_test.go b/pkg/analyzer/authentication_dkim_test.go deleted file mode 100644 index 0576854..0000000 --- a/pkg/analyzer/authentication_dkim_test.go +++ /dev/null @@ -1,86 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "testing" - - "git.happydns.org/happyDeliver/internal/model" -) - -func TestParseDKIMResult(t *testing.T) { - tests := []struct { - name string - part string - 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: model.AuthResultResultPass, - expectedDomain: "example.com", - expectedSelector: "default", - }, - { - name: "DKIM fail", - part: "dkim=fail header.d=example.com header.s=selector1", - 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: model.AuthResultResultPass, - expectedDomain: "example.com", - expectedSelector: "default", - }, - } - - analyzer := NewAuthenticationAnalyzer("") - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseDKIMResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - if result.Domain == nil || *result.Domain != tt.expectedDomain { - var gotDomain string - if result.Domain != nil { - gotDomain = *result.Domain - } - t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) - } - if result.Selector == nil || *result.Selector != tt.expectedSelector { - var gotSelector string - if result.Selector != nil { - gotSelector = *result.Selector - } - t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) - } - }) - } -} diff --git a/pkg/analyzer/authentication_dmarc.go b/pkg/analyzer/authentication_dmarc.go deleted file mode 100644 index c89093d..0000000 --- a/pkg/analyzer/authentication_dmarc.go +++ /dev/null @@ -1,69 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "regexp" - "strings" - - "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) *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 = model.AuthResultResult(resultStr) - } - - // Extract domain (header.from) - domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - result.Details = utils.PtrTo(strings.TrimPrefix(part, "dmarc=")) - - return result -} - -func (a *AuthenticationAnalyzer) calculateDMARCScore(results *model.AuthenticationResults) (score int) { - if results.Dmarc != nil { - switch results.Dmarc.Result { - case model.AuthResultResultPass: - return 100 - case model.AuthResultResultNone: - return 33 - default: // fail - return 0 - } - } - - return 0 -} diff --git a/pkg/analyzer/authentication_dmarc_test.go b/pkg/analyzer/authentication_dmarc_test.go deleted file mode 100644 index 69779a7..0000000 --- a/pkg/analyzer/authentication_dmarc_test.go +++ /dev/null @@ -1,69 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "testing" - - "git.happydns.org/happyDeliver/internal/model" -) - -func TestParseDMARCResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult model.AuthResultResult - expectedDomain string - }{ - { - name: "DMARC pass", - part: "dmarc=pass action=none header.from=example.com", - expectedResult: model.AuthResultResultPass, - expectedDomain: "example.com", - }, - { - name: "DMARC fail", - part: "dmarc=fail action=quarantine header.from=example.com", - expectedResult: model.AuthResultResultFail, - expectedDomain: "example.com", - }, - } - - analyzer := NewAuthenticationAnalyzer("") - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseDMARCResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - if result.Domain == nil || *result.Domain != tt.expectedDomain { - var gotDomain string - if result.Domain != nil { - gotDomain = *result.Domain - } - t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) - } - }) - } -} diff --git a/pkg/analyzer/authentication_iprev.go b/pkg/analyzer/authentication_iprev.go deleted file mode 100644 index 3ed045c..0000000 --- a/pkg/analyzer/authentication_iprev.go +++ /dev/null @@ -1,74 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "regexp" - "strings" - - "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) *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 = model.IPRevResultResult(resultStr) - } - - // Extract IP address (smtp.remote-ip or remote-ip) - ipRe := regexp.MustCompile(`(?:smtp\.)?remote-ip=([^\s;()]+)`) - if matches := ipRe.FindStringSubmatch(part); len(matches) > 1 { - ip := matches[1] - result.Ip = &ip - } - - // Extract hostname from parentheses - hostnameRe := regexp.MustCompile(`\(([^)]+)\)`) - if matches := hostnameRe.FindStringSubmatch(part); len(matches) > 1 { - hostname := matches[1] - result.Hostname = &hostname - } - - result.Details = utils.PtrTo(strings.TrimPrefix(part, "iprev=")) - - return result -} - -func (a *AuthenticationAnalyzer) calculateIPRevScore(results *model.AuthenticationResults) (score int) { - if results.Iprev != nil { - switch results.Iprev.Result { - case model.Pass: - return 100 - default: // fail, temperror, permerror - return 0 - } - } - - return 100 -} diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go deleted file mode 100644 index 55f85d5..0000000 --- a/pkg/analyzer/authentication_iprev_test.go +++ /dev/null @@ -1,226 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "testing" - - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" -) - -func TestParseIPRevResult(t *testing.T) { - tests := []struct { - name string - part string - 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: 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: 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: 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: 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: 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: 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: 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: model.Pass, - expectedIP: utils.PtrTo("192.0.2.200"), - expectedHostname: nil, - }, - } - - analyzer := NewAuthenticationAnalyzer("") - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseIPRevResult(tt.part) - - // Check result - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - - // Check IP - if tt.expectedIP != nil { - if result.Ip == nil { - t.Errorf("IP = nil, want %v", *tt.expectedIP) - } else if *result.Ip != *tt.expectedIP { - t.Errorf("IP = %v, want %v", *result.Ip, *tt.expectedIP) - } - } else { - if result.Ip != nil { - t.Errorf("IP = %v, want nil", *result.Ip) - } - } - - // Check hostname - if tt.expectedHostname != nil { - if result.Hostname == nil { - t.Errorf("Hostname = nil, want %v", *tt.expectedHostname) - } else if *result.Hostname != *tt.expectedHostname { - t.Errorf("Hostname = %v, want %v", *result.Hostname, *tt.expectedHostname) - } - } else { - if result.Hostname != nil { - t.Errorf("Hostname = %v, want nil", *result.Hostname) - } - } - - // Check details - if result.Details == nil { - t.Error("Expected Details to be set, got nil") - } - }) - } -} - -func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { - tests := []struct { - name string - header string - 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: 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: 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: utils.PtrTo(model.Fail), - expectedIP: utils.PtrTo("198.51.100.42"), - expectedHostname: nil, - }, - { - name: "No IPRev in header", - header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com", - expectedIPRevResult: nil, - }, - { - name: "Multiple IPRev results - only first is parsed", - header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)", - expectedIPRevResult: utils.PtrTo(model.Pass), - expectedIP: utils.PtrTo("192.0.2.1"), - expectedHostname: utils.PtrTo("first.com"), - }, - } - - analyzer := NewAuthenticationAnalyzer("") - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - results := &model.AuthenticationResults{} - analyzer.parseAuthenticationResultsHeader(tt.header, results) - - // Check IPRev - if tt.expectedIPRevResult != nil { - if results.Iprev == nil { - t.Errorf("Expected IPRev result, got nil") - } else { - if results.Iprev.Result != *tt.expectedIPRevResult { - t.Errorf("IPRev Result = %v, want %v", results.Iprev.Result, *tt.expectedIPRevResult) - } - if tt.expectedIP != nil { - if results.Iprev.Ip == nil || *results.Iprev.Ip != *tt.expectedIP { - var gotIP string - if results.Iprev.Ip != nil { - gotIP = *results.Iprev.Ip - } - t.Errorf("IPRev IP = %v, want %v", gotIP, *tt.expectedIP) - } - } - if tt.expectedHostname != nil { - if results.Iprev.Hostname == nil || *results.Iprev.Hostname != *tt.expectedHostname { - var gotHostname string - if results.Iprev.Hostname != nil { - gotHostname = *results.Iprev.Hostname - } - t.Errorf("IPRev Hostname = %v, want %v", gotHostname, *tt.expectedHostname) - } - } - } - } else { - if results.Iprev != nil { - t.Errorf("Expected no IPRev result, got %+v", results.Iprev) - } - } - }) - } -} diff --git a/pkg/analyzer/authentication_spf.go b/pkg/analyzer/authentication_spf.go deleted file mode 100644 index 1488c98..0000000 --- a/pkg/analyzer/authentication_spf.go +++ /dev/null @@ -1,116 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "regexp" - "strings" - - "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) *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 = model.AuthResultResult(resultStr) - } - - // Extract domain - domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - email := matches[1] - // Extract domain from email - if idx := strings.Index(email, "@"); idx != -1 { - domain := email[idx+1:] - result.Domain = &domain - } - } - - 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) *model.AuthResult { - receivedSPF := email.Header.Get("Received-SPF") - if receivedSPF == "" { - return nil - } - - // Verify receiver matches our hostname - if a.receiverHostname != "" { - receiverRe := regexp.MustCompile(`receiver=([^\s;]+)`) - if matches := receiverRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { - if matches[1] != a.receiverHostname { - return nil - } - } - } - - result := &model.AuthResult{} - - // Extract result (first word) - parts := strings.Fields(receivedSPF) - if len(parts) > 0 { - resultStr := strings.ToLower(parts[0]) - result.Result = model.AuthResultResult(resultStr) - } - - result.Details = &receivedSPF - - // Try to extract domain - domainRe := regexp.MustCompile(`(?:envelope-from|sender)="?([^\s;"]+)`) - if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { - email := matches[1] - if idx := strings.Index(email, "@"); idx != -1 { - domain := email[idx+1:] - result.Domain = &domain - } - } - - return result -} - -func (a *AuthenticationAnalyzer) calculateSPFScore(results *model.AuthenticationResults) (score int) { - if results.Spf != nil { - switch results.Spf.Result { - case model.AuthResultResultPass: - return 100 - case model.AuthResultResultNeutral, model.AuthResultResultNone: - return 50 - case model.AuthResultResultSoftfail: - return 17 - default: // fail, temperror, permerror - return 0 - } - } - - return 0 -} diff --git a/pkg/analyzer/authentication_spf_test.go b/pkg/analyzer/authentication_spf_test.go deleted file mode 100644 index 210505a..0000000 --- a/pkg/analyzer/authentication_spf_test.go +++ /dev/null @@ -1,213 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "testing" - - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" -) - -func TestParseSPFResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult model.AuthResultResult - expectedDomain string - }{ - { - name: "SPF pass with domain", - part: "spf=pass smtp.mailfrom=sender@example.com", - expectedResult: model.AuthResultResultPass, - expectedDomain: "example.com", - }, - { - name: "SPF fail", - part: "spf=fail smtp.mailfrom=sender@example.com", - expectedResult: model.AuthResultResultFail, - expectedDomain: "example.com", - }, - { - name: "SPF neutral", - part: "spf=neutral smtp.mailfrom=sender@example.com", - expectedResult: model.AuthResultResultNeutral, - expectedDomain: "example.com", - }, - { - name: "SPF softfail", - part: "spf=softfail smtp.mailfrom=sender@example.com", - expectedResult: model.AuthResultResultSoftfail, - expectedDomain: "example.com", - }, - } - - analyzer := NewAuthenticationAnalyzer("") - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseSPFResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - if result.Domain == nil || *result.Domain != tt.expectedDomain { - var gotDomain string - if result.Domain != nil { - gotDomain = *result.Domain - } - t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) - } - }) - } -} - -func TestParseLegacySPF(t *testing.T) { - tests := []struct { - name string - receivedSPF string - expectedResult model.AuthResultResult - expectedDomain *string - expectNil bool - }{ - { - name: "SPF pass with envelope-from", - receivedSPF: `pass - (mail.example.com: 192.0.2.10 is authorized to use 'user@example.com' in 'mfrom' identity (mechanism 'ip4:192.0.2.10' matched)) - receiver=mx.receiver.com; - identity=mailfrom; - envelope-from="user@example.com"; - helo=smtp.example.com; - client-ip=192.0.2.10`, - expectedResult: model.AuthResultResultPass, - expectedDomain: utils.PtrTo("example.com"), - }, - { - name: "SPF fail with sender", - receivedSPF: `fail - (mail.example.com: domain of sender@test.com does not designate 192.0.2.20 as permitted sender) - receiver=mx.receiver.com; - identity=mailfrom; - sender="sender@test.com"; - helo=smtp.test.com; - client-ip=192.0.2.20`, - 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: 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: 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: 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: 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: model.AuthResultResultPermerror, - expectedDomain: utils.PtrTo("invalid.test"), - }, - { - name: "SPF pass without domain extraction", - receivedSPF: "pass (example.com: 192.0.2.50 is authorized)", - expectedResult: model.AuthResultResultPass, - expectedDomain: nil, - }, - { - name: "Empty Received-SPF header", - receivedSPF: "", - expectNil: true, - }, - { - name: "SPF with unquoted envelope-from", - receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net", - expectedResult: model.AuthResultResultPass, - expectedDomain: utils.PtrTo("mail.example.net"), - }, - } - - analyzer := NewAuthenticationAnalyzer("") - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a mock email message with Received-SPF header - email := &EmailMessage{ - Header: make(map[string][]string), - } - if tt.receivedSPF != "" { - email.Header["Received-Spf"] = []string{tt.receivedSPF} - } - - result := analyzer.parseLegacySPF(email) - - if tt.expectNil { - if result != nil { - t.Errorf("Expected nil result, got %+v", result) - } - return - } - - if result == nil { - t.Fatal("Expected non-nil result, got nil") - } - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - - if tt.expectedDomain != nil { - if result.Domain == nil { - t.Errorf("Domain = nil, want %v", *tt.expectedDomain) - } else if *result.Domain != *tt.expectedDomain { - t.Errorf("Domain = %v, want %v", *result.Domain, *tt.expectedDomain) - } - } else { - if result.Domain != nil { - t.Errorf("Domain = %v, want nil", *result.Domain) - } - } - - if result.Details == nil { - t.Error("Expected Details to be set, got nil") - } else if *result.Details != tt.receivedSPF { - t.Errorf("Details = %v, want %v", *result.Details, tt.receivedSPF) - } - }) - } -} diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go deleted file mode 100644 index 44c1abb..0000000 --- a/pkg/analyzer/authentication_test.go +++ /dev/null @@ -1,439 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "testing" - - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" -) - -func TestGetAuthenticationScore(t *testing.T) { - tests := []struct { - name string - results *model.AuthenticationResults - expectedScore int - }{ - { - name: "Perfect authentication (SPF + DKIM + DMARC)", - results: &model.AuthenticationResults{ - Spf: &model.AuthResult{ - Result: model.AuthResultResultPass, - }, - Dkim: &[]model.AuthResult{ - {Result: model.AuthResultResultPass}, - }, - Dmarc: &model.AuthResult{ - Result: model.AuthResultResultPass, - }, - }, - expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25 - }, - { - name: "SPF and DKIM only", - results: &model.AuthenticationResults{ - Spf: &model.AuthResult{ - Result: model.AuthResultResultPass, - }, - Dkim: &[]model.AuthResult{ - {Result: model.AuthResultResultPass}, - }, - }, - expectedScore: 48, // SPF=25 + DKIM=23 - }, - { - name: "SPF fail, DKIM pass", - results: &model.AuthenticationResults{ - Spf: &model.AuthResult{ - Result: model.AuthResultResultFail, - }, - Dkim: &[]model.AuthResult{ - {Result: model.AuthResultResultPass}, - }, - }, - expectedScore: 23, // SPF=0 + DKIM=23 - }, - { - name: "SPF softfail", - results: &model.AuthenticationResults{ - Spf: &model.AuthResult{ - Result: model.AuthResultResultSoftfail, - }, - }, - expectedScore: 4, - }, - { - name: "No authentication", - results: &model.AuthenticationResults{}, - expectedScore: 0, - }, - { - name: "BIMI adds to score", - results: &model.AuthenticationResults{ - Spf: &model.AuthResult{ - Result: model.AuthResultResultPass, - }, - Bimi: &model.AuthResult{ - Result: model.AuthResultResultPass, - }, - }, - expectedScore: 35, // SPF (25) + BIMI (10) - }, - } - - scorer := NewAuthenticationAnalyzer("") - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - score, _ := scorer.CalculateAuthenticationScore(tt.results) - - if score != tt.expectedScore { - t.Errorf("Score = %v, want %v", score, tt.expectedScore) - } - }) - } -} - -func TestParseAuthenticationResultsHeader(t *testing.T) { - tests := []struct { - name string - header string - expectedSPFResult *model.AuthResultResult - expectedSPFDomain *string - expectedDKIMCount int - expectedDKIMResult *model.AuthResultResult - expectedDMARCResult *model.AuthResultResult - expectedDMARCDomain *string - 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: utils.PtrTo(model.AuthResultResultPass), - expectedSPFDomain: utils.PtrTo("example.com"), - expectedDKIMCount: 1, - 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: utils.PtrTo(model.AuthResultResultPass), - expectedSPFDomain: utils.PtrTo("domain.com"), - expectedDKIMCount: 0, - expectedDMARCResult: nil, - }, - { - name: "DKIM only", - header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1", - expectedSPFResult: nil, - expectedDKIMCount: 1, - 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: 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: utils.PtrTo(model.AuthResultResultFail), - expectedSPFDomain: utils.PtrTo("example.com"), - expectedDKIMCount: 1, - expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), - expectedDMARCResult: nil, - }, - { - name: "SPF softfail", - header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com", - expectedSPFResult: utils.PtrTo(model.AuthResultResultSoftfail), - expectedSPFDomain: utils.PtrTo("example.com"), - expectedDKIMCount: 0, - expectedDMARCResult: nil, - }, - { - name: "DMARC fail", - header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=fail action=quarantine header.from=example.com", - expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), - expectedDKIMCount: 1, - 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: utils.PtrTo(model.AuthResultResultPass), - expectedSPFDomain: utils.PtrTo("example.com"), - expectedDKIMCount: 0, - expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass), - }, - { - name: "ARC pass", - header: "mail.example.com; arc=pass", - expectedSPFResult: nil, - expectedDKIMCount: 0, - 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: utils.PtrTo(model.AuthResultResultPass), - expectedSPFDomain: utils.PtrTo("example.com"), - expectedDKIMCount: 1, - expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), - expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass), - expectedDMARCDomain: utils.PtrTo("example.com"), - expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass), - expectedARCResult: utils.PtrTo(model.ARCResultResultPass), - }, - { - name: "Empty header (authserv-id only)", - header: "mx.google.com", - expectedSPFResult: nil, - expectedDKIMCount: 0, - }, - { - name: "Empty parts with semicolons", - header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;", - expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), - expectedSPFDomain: utils.PtrTo("example.com"), - expectedDKIMCount: 0, - }, - { - name: "DKIM with short form parameters", - header: "mail.example.com; dkim=pass d=example.com s=selector1", - expectedSPFResult: nil, - expectedDKIMCount: 1, - expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), - }, - { - name: "SPF neutral", - header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com", - expectedSPFResult: utils.PtrTo(model.AuthResultResultNeutral), - expectedSPFDomain: utils.PtrTo("example.com"), - expectedDKIMCount: 0, - }, - { - name: "SPF none", - header: "mail.example.com; spf=none", - expectedSPFResult: utils.PtrTo(model.AuthResultResultNone), - expectedDKIMCount: 0, - }, - } - - analyzer := NewAuthenticationAnalyzer("") - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - results := &model.AuthenticationResults{} - analyzer.parseAuthenticationResultsHeader(tt.header, results) - - // Check SPF - if tt.expectedSPFResult != nil { - if results.Spf == nil { - t.Errorf("Expected SPF result, got nil") - } else { - if results.Spf.Result != *tt.expectedSPFResult { - t.Errorf("SPF Result = %v, want %v", results.Spf.Result, *tt.expectedSPFResult) - } - if tt.expectedSPFDomain != nil { - if results.Spf.Domain == nil || *results.Spf.Domain != *tt.expectedSPFDomain { - var gotDomain string - if results.Spf.Domain != nil { - gotDomain = *results.Spf.Domain - } - t.Errorf("SPF Domain = %v, want %v", gotDomain, *tt.expectedSPFDomain) - } - } - } - } else { - if results.Spf != nil { - t.Errorf("Expected no SPF result, got %+v", results.Spf) - } - } - - // Check DKIM count and result - if results.Dkim == nil { - if tt.expectedDKIMCount != 0 { - t.Errorf("Expected %d DKIM results, got nil", tt.expectedDKIMCount) - } - } else { - if len(*results.Dkim) != tt.expectedDKIMCount { - t.Errorf("DKIM count = %d, want %d", len(*results.Dkim), tt.expectedDKIMCount) - } - if tt.expectedDKIMResult != nil && len(*results.Dkim) > 0 { - if (*results.Dkim)[0].Result != *tt.expectedDKIMResult { - t.Errorf("DKIM Result = %v, want %v", (*results.Dkim)[0].Result, *tt.expectedDKIMResult) - } - } - } - - // Check DMARC - if tt.expectedDMARCResult != nil { - if results.Dmarc == nil { - t.Errorf("Expected DMARC result, got nil") - } else { - if results.Dmarc.Result != *tt.expectedDMARCResult { - t.Errorf("DMARC Result = %v, want %v", results.Dmarc.Result, *tt.expectedDMARCResult) - } - if tt.expectedDMARCDomain != nil { - if results.Dmarc.Domain == nil || *results.Dmarc.Domain != *tt.expectedDMARCDomain { - var gotDomain string - if results.Dmarc.Domain != nil { - gotDomain = *results.Dmarc.Domain - } - t.Errorf("DMARC Domain = %v, want %v", gotDomain, *tt.expectedDMARCDomain) - } - } - } - } else { - if results.Dmarc != nil { - t.Errorf("Expected no DMARC result, got %+v", results.Dmarc) - } - } - - // Check BIMI - if tt.expectedBIMIResult != nil { - if results.Bimi == nil { - t.Errorf("Expected BIMI result, got nil") - } else { - if results.Bimi.Result != *tt.expectedBIMIResult { - t.Errorf("BIMI Result = %v, want %v", results.Bimi.Result, *tt.expectedBIMIResult) - } - } - } else { - if results.Bimi != nil { - t.Errorf("Expected no BIMI result, got %+v", results.Bimi) - } - } - - // Check ARC - if tt.expectedARCResult != nil { - if results.Arc == nil { - t.Errorf("Expected ARC result, got nil") - } else { - if results.Arc.Result != *tt.expectedARCResult { - t.Errorf("ARC Result = %v, want %v", results.Arc.Result, *tt.expectedARCResult) - } - } - } else { - if results.Arc != nil { - t.Errorf("Expected no ARC result, got %+v", results.Arc) - } - } - }) - } -} - -func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { - // This test verifies that only the first occurrence of each auth method is parsed - analyzer := NewAuthenticationAnalyzer("") - - t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) { - header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com" - results := &model.AuthenticationResults{} - analyzer.parseAuthenticationResultsHeader(header, results) - - if results.Spf == nil { - t.Fatal("Expected SPF result, got nil") - } - 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" { - t.Errorf("Expected domain from first SPF result") - } - }) - - t.Run("Multiple DMARC results - only first is parsed", func(t *testing.T) { - header := "mail.example.com; dmarc=pass header.from=first.com; dmarc=fail header.from=second.com" - results := &model.AuthenticationResults{} - analyzer.parseAuthenticationResultsHeader(header, results) - - if results.Dmarc == nil { - t.Fatal("Expected DMARC result, got nil") - } - 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" { - t.Errorf("Expected domain from first DMARC result") - } - }) - - t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) { - header := "mail.example.com; arc=pass; arc=fail" - results := &model.AuthenticationResults{} - analyzer.parseAuthenticationResultsHeader(header, results) - - if results.Arc == nil { - t.Fatal("Expected ARC result, got nil") - } - 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 := &model.AuthenticationResults{} - analyzer.parseAuthenticationResultsHeader(header, results) - - if results.Bimi == nil { - t.Fatal("Expected BIMI result, got nil") - } - 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" { - t.Errorf("Expected domain from first BIMI result") - } - }) - - t.Run("Multiple DKIM results - all are parsed", func(t *testing.T) { - // DKIM is special - multiple signatures should all be collected - header := "mail.example.com; dkim=pass header.d=first.com header.s=s1; dkim=fail header.d=second.com header.s=s2" - results := &model.AuthenticationResults{} - analyzer.parseAuthenticationResultsHeader(header, results) - - if results.Dkim == nil { - t.Fatal("Expected DKIM results, got nil") - } - if len(*results.Dkim) != 2 { - t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim)) - } - 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 != 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 deleted file mode 100644 index ec1571c..0000000 --- a/pkg/analyzer/authentication_x_aligned_from.go +++ /dev/null @@ -1,66 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "regexp" - "strings" - - "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) *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 = model.AuthResultResult(resultStr) - } - - // Extract details (everything after the result) - result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-aligned-from=")) - - return result -} - -func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.AuthenticationResults) (score int) { - if results.XAlignedFrom != nil { - switch results.XAlignedFrom.Result { - case model.AuthResultResultPass: - // pass: positive contribution - return 100 - case model.AuthResultResultFail: - // fail: negative contribution - return 0 - default: - // neutral, none, etc.: no impact - return 0 - } - } - - return 100 -} diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go deleted file mode 100644 index 1ea6d1c..0000000 --- a/pkg/analyzer/authentication_x_aligned_from_test.go +++ /dev/null @@ -1,144 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "testing" - - "git.happydns.org/happyDeliver/internal/model" -) - -func TestParseXAlignedFromResult(t *testing.T) { - tests := []struct { - name string - part string - expectedResult model.AuthResultResult - expectedDetail string - }{ - { - name: "x-aligned-from pass with details", - part: "x-aligned-from=pass (Address match)", - expectedResult: model.AuthResultResultPass, - expectedDetail: "pass (Address match)", - }, - { - name: "x-aligned-from fail with reason", - part: "x-aligned-from=fail (Address mismatch)", - expectedResult: model.AuthResultResultFail, - expectedDetail: "fail (Address mismatch)", - }, - { - name: "x-aligned-from pass minimal", - part: "x-aligned-from=pass", - expectedResult: model.AuthResultResultPass, - expectedDetail: "pass", - }, - { - name: "x-aligned-from neutral", - part: "x-aligned-from=neutral (No alignment check performed)", - expectedResult: model.AuthResultResultNeutral, - expectedDetail: "neutral (No alignment check performed)", - }, - { - name: "x-aligned-from none", - part: "x-aligned-from=none", - expectedResult: model.AuthResultResultNone, - expectedDetail: "none", - }, - } - - analyzer := NewAuthenticationAnalyzer("") - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseXAlignedFromResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - - if result.Details == nil { - t.Errorf("Details = nil, want %v", tt.expectedDetail) - } else if *result.Details != tt.expectedDetail { - t.Errorf("Details = %v, want %v", *result.Details, tt.expectedDetail) - } - }) - } -} - -func TestCalculateXAlignedFromScore(t *testing.T) { - tests := []struct { - name string - result *model.AuthResult - expectedScore int - }{ - { - name: "pass result gives positive score", - result: &model.AuthResult{ - Result: model.AuthResultResultPass, - }, - expectedScore: 100, - }, - { - name: "fail result gives zero score", - result: &model.AuthResult{ - Result: model.AuthResultResultFail, - }, - expectedScore: 0, - }, - { - name: "neutral result gives zero score", - result: &model.AuthResult{ - Result: model.AuthResultResultNeutral, - }, - expectedScore: 0, - }, - { - name: "none result gives zero score", - result: &model.AuthResult{ - Result: model.AuthResultResultNone, - }, - expectedScore: 0, - }, - { - name: "nil result gives zero score", - result: nil, - expectedScore: 0, - }, - } - - analyzer := NewAuthenticationAnalyzer("") - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - results := &model.AuthenticationResults{ - XAlignedFrom: tt.result, - } - - score := analyzer.calculateXAlignedFromScore(results) - - if score != tt.expectedScore { - t.Errorf("Score = %v, want %v", score, tt.expectedScore) - } - }) - } -} diff --git a/pkg/analyzer/authentication_x_google_dkim.go b/pkg/analyzer/authentication_x_google_dkim.go deleted file mode 100644 index b33279e..0000000 --- a/pkg/analyzer/authentication_x_google_dkim.go +++ /dev/null @@ -1,74 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "regexp" - "strings" - - "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) *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 = model.AuthResultResult(resultStr) - } - - // Extract domain (header.d or d) - domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) - if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { - domain := matches[1] - result.Domain = &domain - } - - // Extract selector (header.s or s) - though not always present in x-google-dkim - selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) - if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { - selector := matches[1] - result.Selector = &selector - } - - result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) - - return result -} - -func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *model.AuthenticationResults) (score int) { - if results.XGoogleDkim != nil { - switch results.XGoogleDkim.Result { - case model.AuthResultResultPass: - // pass: don't alter the score - default: // fail - return -100 - } - } - - return 0 -} diff --git a/pkg/analyzer/authentication_x_google_dkim_test.go b/pkg/analyzer/authentication_x_google_dkim_test.go deleted file mode 100644 index 4013340..0000000 --- a/pkg/analyzer/authentication_x_google_dkim_test.go +++ /dev/null @@ -1,83 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "testing" - - "git.happydns.org/happyDeliver/internal/model" -) - -func TestParseXGoogleDKIMResult(t *testing.T) { - tests := []struct { - name string - part string - 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: model.AuthResultResultPass, - expectedDomain: "1e100.net", - }, - { - name: "x-google-dkim pass with short form", - part: "x-google-dkim=pass d=gmail.com", - expectedResult: model.AuthResultResultPass, - expectedDomain: "gmail.com", - }, - { - name: "x-google-dkim fail", - part: "x-google-dkim=fail header.d=example.com", - expectedResult: model.AuthResultResultFail, - expectedDomain: "example.com", - }, - { - name: "x-google-dkim with minimal info", - part: "x-google-dkim=pass", - expectedResult: model.AuthResultResultPass, - }, - } - - analyzer := NewAuthenticationAnalyzer("") - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseXGoogleDKIMResult(tt.part) - - if result.Result != tt.expectedResult { - t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) - } - if tt.expectedDomain != "" { - if result.Domain == nil || *result.Domain != tt.expectedDomain { - var gotDomain string - if result.Domain != nil { - gotDomain = *result.Domain - } - t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) - } - } - }) - } -} diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go deleted file mode 100644 index 06f8ddf..0000000 --- a/pkg/analyzer/content.go +++ /dev/null @@ -1,987 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package analyzer - -import ( - "context" - "fmt" - "net/http" - "net/url" - "regexp" - "slices" - "strings" - "time" - "unicode" - - "git.happydns.org/happyDeliver/internal/model" - "git.happydns.org/happyDeliver/internal/utils" - "golang.org/x/net/html" -) - -// ContentAnalyzer analyzes email content (HTML, links, images) -type ContentAnalyzer struct { - Timeout time.Duration - httpClient *http.Client - listUnsubscribeURLs []string // URLs from List-Unsubscribe header - hasOneClickUnsubscribe bool // True if List-Unsubscribe-Post: List-Unsubscribe=One-Click -} - -// NewContentAnalyzer creates a new content analyzer with configurable timeout -func NewContentAnalyzer(timeout time.Duration) *ContentAnalyzer { - if timeout == 0 { - timeout = 10 * time.Second // Default timeout - } - return &ContentAnalyzer{ - Timeout: timeout, - httpClient: &http.Client{ - Timeout: timeout, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - // Allow up to 10 redirects - if len(via) >= 10 { - return fmt.Errorf("too many redirects") - } - return nil - }, - }, - } -} - -// ContentResults represents content analysis results -type ContentResults struct { - IsMultipart bool - HTMLValid bool - HTMLErrors []string - Links []LinkCheck - Images []ImageCheck - HasUnsubscribe bool - UnsubscribeLinks []string - TextContent string - HTMLContent string - TextPlainRatio float32 // Ratio of plain text to HTML consistency - ImageTextRatio float32 // Ratio of images to text - SuspiciousURLs []string - ContentIssues []string - HarmfullIssues []string -} - -// HasPlaintext returns true if the email has plain text content -func (r *ContentResults) HasPlaintext() bool { - return r.TextContent != "" -} - -// LinkCheck represents a link validation result -type LinkCheck struct { - URL string - Valid bool - Status int - Error string - IsSafe bool - Warning string -} - -// ImageCheck represents an image validation result -type ImageCheck struct { - Src string - HasAlt bool - AltText string - Valid bool - Error string - IsBroken bool -} - -// AnalyzeContent performs content analysis on email message -func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults { - results := &ContentResults{} - - results.IsMultipart = len(email.Parts) > 1 - - // Parse List-Unsubscribe header URLs for use in link detection - c.listUnsubscribeURLs = email.GetListUnsubscribeURLs() - - // Check for one-click unsubscribe support - listUnsubscribePost := email.Header.Get("List-Unsubscribe-Post") - c.hasOneClickUnsubscribe = strings.EqualFold(strings.TrimSpace(listUnsubscribePost), "List-Unsubscribe=One-Click") - - // Get HTML and text parts - htmlParts := email.GetHTMLParts() - textParts := email.GetTextParts() - - // Analyze HTML parts - if len(htmlParts) > 0 { - for _, part := range htmlParts { - c.analyzeHTML(part.Content, results) - } - } - - // Analyze text parts - if len(textParts) > 0 { - for _, part := range textParts { - results.TextContent += part.Content - } - // Extract and validate links from plain text - c.analyzeTextLinks(results.TextContent, results) - } - - // Check plain text/HTML consistency - if len(htmlParts) > 0 && len(textParts) > 0 { - results.TextPlainRatio = c.calculateTextPlainConsistency(results.TextContent, results.HTMLContent) - } else if !results.IsMultipart { - results.TextPlainRatio = 1.0 - } - - return results -} - -// analyzeTextLinks extracts and validates URLs from plain text -func (c *ContentAnalyzer) analyzeTextLinks(textContent string, results *ContentResults) { - // Regular expression to match URLs in plain text - // Matches http://, https://, and www. URLs - urlRegex := regexp.MustCompile(`(?i)\b(?:https?://|www\.)[^\s<>"{}|\\^\[\]` + "`" + `]+`) - - matches := urlRegex.FindAllString(textContent, -1) - - for _, match := range matches { - // Normalize URL (add http:// if missing) - urlStr := match - if strings.HasPrefix(strings.ToLower(urlStr), "www.") { - urlStr = "http://" + urlStr - } - - // Check if this URL already exists in results.Links (from HTML analysis) - exists := false - for _, link := range results.Links { - if link.URL == urlStr { - exists = true - break - } - } - - // Only validate if not already checked - if !exists { - linkCheck := c.validateLink(urlStr) - results.Links = append(results.Links, linkCheck) - - // Check for suspicious URLs - if !linkCheck.IsSafe { - results.SuspiciousURLs = append(results.SuspiciousURLs, urlStr) - } - } - } -} - -// analyzeHTML parses and analyzes HTML content -func (c *ContentAnalyzer) analyzeHTML(htmlContent string, results *ContentResults) { - results.HTMLContent = htmlContent - - // Parse HTML - doc, err := html.Parse(strings.NewReader(htmlContent)) - if err != nil { - results.HTMLValid = false - results.HTMLErrors = append(results.HTMLErrors, fmt.Sprintf("Failed to parse HTML: %v", err)) - return - } - - results.HTMLValid = true - - // Traverse HTML tree - c.traverseHTML(doc, results) - - // Calculate image-to-text ratio - if results.HTMLContent != "" { - textLength := len(c.extractTextFromHTML(htmlContent)) - imageCount := len(results.Images) - if textLength > 0 { - results.ImageTextRatio = float32(imageCount) / float32(textLength) * 1000 // Images per 1000 chars - } - } -} - -// traverseHTML recursively traverses HTML nodes -func (c *ContentAnalyzer) traverseHTML(n *html.Node, results *ContentResults) { - if n.Type == html.ElementNode { - switch n.Data { - case "a": - // Extract and validate links - href := c.getAttr(n, "href") - if href != "" { - // Check for unsubscribe links - if c.isUnsubscribeLink(href, n) { - results.HasUnsubscribe = true - results.UnsubscribeLinks = append(results.UnsubscribeLinks, href) - } - - // Validate link - linkCheck := c.validateLink(href) - - // Check for domain misalignment (phishing detection) - linkText := c.getNodeText(n) - if c.hasDomainMisalignment(href, linkText) { - linkCheck.IsSafe = false - if linkCheck.Warning == "" { - linkCheck.Warning = "Link text domain does not match actual URL domain (possible phishing)" - } else { - linkCheck.Warning += "; Link text domain does not match actual URL domain (possible phishing)" - } - } - - results.Links = append(results.Links, linkCheck) - - // Check for suspicious URLs - if !linkCheck.IsSafe { - results.SuspiciousURLs = append(results.SuspiciousURLs, href) - } - } - - case "img": - // Extract and validate images - src := c.getAttr(n, "src") - alt := c.getAttr(n, "alt") - - imageCheck := ImageCheck{ - Src: src, - HasAlt: alt != "", - AltText: alt, - Valid: src != "", - } - - if src == "" { - imageCheck.Error = "Image missing src attribute" - } - - results.Images = append(results.Images, imageCheck) - - case "script": - // JavaScript in emails is a security risk and typically blocked - results.HarmfullIssues = append(results.HarmfullIssues, "Dangerous
%sveltekit.body%
diff --git a/web/src/lib/assets/favicon.svg b/web/src/lib/assets/favicon.svg deleted file mode 100644 index fb235b0..0000000 --- a/web/src/lib/assets/favicon.svg +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - - - - - - - image/svg+xml - - - - - - - - - h - - - - - diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte deleted file mode 100644 index 46a4d2d..0000000 --- a/web/src/lib/components/AuthenticationCard.svelte +++ /dev/null @@ -1,567 +0,0 @@ - - -
-
-

- - - Authentication - - - {#if authenticationScore !== undefined} - - {authenticationScore}% - - {/if} - {#if authenticationGrade !== undefined} - - {/if} - -

-
- {#if allRequiredMissing} -
-
- - No authentication results found. -

- This usually means either: -

-
    -
  • - The receiving mail server is not configured to verify email authentication - (no Authentication-Results header was found in the message). -
  • -
  • - The Authentication-Results header exists but the receiver - hostname does not match the configured - --receiver-hostname value. -
  • -
-
-
- {/if} -
- - {#if authentication.iprev} -
-
- -
- IP Reverse DNS - - {authentication.iprev.result} - - {#if authentication.iprev.ip} -
- IP Address: - {authentication.iprev.ip} -
- {/if} - {#if authentication.iprev.hostname} -
- Hostname: - {authentication.iprev.hostname} -
- {/if} - {#if authentication.iprev.details} -
{authentication.iprev.details}
- {/if} -
-
-
- {/if} - - -
-
- {#if authentication.spf} - -
- SPF - - {authentication.spf.result} - - {#if authentication.spf.domain} -
- Domain: - {authentication.spf.domain} -
- {/if} - {#if authentication.spf.details} -
{authentication.spf.details}
- {/if} -
- {:else} - -
- SPF - - {getAuthResultText("missing")} - -
- SPF record is required for proper email authentication -
-
- {/if} -
-
- - -
- {#if authentication.dkim && authentication.dkim.length > 0} - {#each authentication.dkim as dkim, i} -
0}> - -
- DKIM{authentication.dkim.length > 1 ? ` #${i + 1}` : ""} - - {dkim.result} - - {#if dkim.domain} -
- Domain: - {dkim.domain} -
- {/if} - {#if dkim.selector} -
- Selector: - {dkim.selector} -
- {/if} - {#if dkim.details} -
{dkim.details}
- {/if} -
-
- {/each} - {:else} -
- -
- DKIM - - {getAuthResultText("missing")} - -
- DKIM signature is required for proper email authentication -
-
-
- {/if} -
- - - {#if authentication.x_google_dkim} -
-
- -
- X-Google-DKIM - - - {authentication.x_google_dkim.result} - - {#if authentication.x_google_dkim.domain} -
- Domain: - {authentication.x_google_dkim.domain} -
- {/if} - {#if authentication.x_google_dkim.selector} -
- Selector: - {authentication.x_google_dkim.selector} -
- {/if} - {#if authentication.x_google_dkim.details} -
{authentication.x_google_dkim
-                                    .details}
- {/if} -
-
-
- {/if} - - - {#if authentication.x_aligned_from} -
-
- -
- X-Aligned-From - - - {authentication.x_aligned_from.result} - - {#if authentication.x_aligned_from.domain} -
- Domain: - {authentication.x_aligned_from.domain} -
- {/if} - {#if authentication.x_aligned_from.details} -
{authentication.x_aligned_from
-                                    .details}
- {/if} -
-
-
- {/if} - - -
-
- {#if authentication.dmarc} - -
- DMARC - - {authentication.dmarc.result} - - {#if authentication.dmarc.domain} -
- Domain: - {authentication.dmarc.domain} -
- {/if} - {#snippet DMARCPolicy(policy: string)} -
- Policy: - - {policy} - -
- {/snippet} - {#if authentication.dmarc.result != "none"} - {#if authentication.dmarc.details && authentication.dmarc.details.indexOf("policy.published-domain-policy=") > 0} - {@const policy = authentication.dmarc.details.replace( - /^.*policy.published-domain-policy=([^\s]+).*$/, - "$1", - )} - {@render DMARCPolicy(policy)} - {:else if authentication.dmarc.domain && dnsResults?.dmarc_record?.policy} - {@render DMARCPolicy(dnsResults.dmarc_record.policy)} - {/if} - {/if} - {#if authentication.dmarc.details} -
{authentication.dmarc.details}
- {/if} -
- {:else} - -
- DMARC - - {getAuthResultText("missing")} - -
- DMARC policy is required for proper email authentication -
-
- {/if} -
-
- - -
-
- {#if authentication.bimi && authentication.bimi.result != "none"} - -
- BIMI - - {authentication.bimi.result} - - {#if authentication.bimi.details} -
{authentication.bimi.details}
- {/if} -
- {:else if authentication.bimi && authentication.bimi.result == "none"} - -
- BIMI - NONE -
- Brand Indicators for Message Identification -
- {#if authentication.bimi.details} -
{authentication.bimi.details}
- {/if} -
- {:else} - -
- BIMI - Optional -
- Brand Indicators for Message Identification (optional enhancement) -
-
- {/if} -
-
- - - {#if authentication.arc} -
-
- -
- ARC - - {authentication.arc.result} - - {#if authentication.arc.chain_length} -
- Chain length: {authentication.arc.chain_length} -
- {/if} - {#if authentication.arc.details} -
{authentication.arc.details}
- {/if} -
-
-
- {/if} -
-
diff --git a/web/src/lib/components/BimiRecordDisplay.svelte b/web/src/lib/components/BimiRecordDisplay.svelte deleted file mode 100644 index 889e24f..0000000 --- a/web/src/lib/components/BimiRecordDisplay.svelte +++ /dev/null @@ -1,77 +0,0 @@ - - -{#if bimiRecord} -
-
-
- - Brand Indicators for Message Identification -
- BIMI -
-
-

- BIMI allows your brand logo to be displayed next to your emails in supported mail - clients. Requires strong DMARC enforcement (quarantine or reject policy) and - optionally a Verified Mark Certificate (VMC). -

- -
- -
- Selector: {bimiRecord.selector} - Domain: {bimiRecord.domain} -
-
- Status: - {#if bimiRecord.valid} - Valid - {:else} - Invalid - {/if} -
- {#if bimiRecord.logo_url} -
- Logo URL: - {bimiRecord.logo_url} -
- {/if} - {#if bimiRecord.vmc_url} -
- VMC URL: - {bimiRecord.vmc_url} -
- {/if} - {#if bimiRecord.record} -
- Record:
- {bimiRecord.record} -
- {/if} - {#if bimiRecord.error} -
- Error: - {bimiRecord.error} -
- {/if} -
-
-{/if} diff --git a/web/src/lib/components/BlacklistCard.svelte b/web/src/lib/components/BlacklistCard.svelte deleted file mode 100644 index bb80acb..0000000 --- a/web/src/lib/components/BlacklistCard.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - -
-
-

- - - Blacklist Checks - - - {#if blacklistScore !== undefined} - - {blacklistScore}% - - {/if} - {#if blacklistGrade !== undefined} - - {/if} - -

-
-
-
- {#each Object.entries(blacklists) as [ip, checks]} -
-
- - {ip} -
- - - {#each checks as check} - - - - - {/each} - -
- - {check.error - ? "Error" - : check.listed - ? "Listed" - : "Clean"} - - {check.rbl}
-
- {/each} -
-
-
diff --git a/web/src/lib/components/CheckCard.svelte b/web/src/lib/components/CheckCard.svelte new file mode 100644 index 0000000..bc5741c --- /dev/null +++ b/web/src/lib/components/CheckCard.svelte @@ -0,0 +1,74 @@ + + +
+
+
+
+ +
+
+
+
+
{check.name}
+ {check.category} +
+ {check.score.toFixed(1)} pts +
+ +

{check.message}

+ + {#if check.advice} + + {/if} + + {#if check.details} +
+ Technical Details +
{check.details}
+
+ {/if} +
+
+
+
+ + diff --git a/web/src/lib/components/ContentAnalysisCard.svelte b/web/src/lib/components/ContentAnalysisCard.svelte deleted file mode 100644 index 51c4e5b..0000000 --- a/web/src/lib/components/ContentAnalysisCard.svelte +++ /dev/null @@ -1,199 +0,0 @@ - - -
-
-

- - - Content Analysis - - - {#if contentScore !== undefined} - - {contentScore}% - - {/if} - {#if contentGrade !== undefined} - - {/if} - -

-
-
-
-
-
- - HTML Part -
-
- - Plaintext Part -
- {#if typeof contentAnalysis.has_unsubscribe_link === "boolean"} -
- - Unsubscribe Link -
- {/if} -
-
- {#if contentAnalysis.text_to_image_ratio !== undefined} -
- Text to Image Ratio: - {contentAnalysis.text_to_image_ratio.toFixed(2)} -
- {/if} - {#if contentAnalysis.unsubscribe_methods && contentAnalysis.unsubscribe_methods.length > 0} -
- Unsubscribe Methods: -
- {#each contentAnalysis.unsubscribe_methods as method} - {method} - {/each} -
-
- {/if} -
-
- - {#if contentAnalysis.html_issues && contentAnalysis.html_issues.length > 0} -
-
Content Issues
- {#each contentAnalysis.html_issues as issue} -
-
-
- {issue.type} -
{issue.message}
- {#if issue.location} -
{issue.location}
- {/if} - {#if issue.advice} -
- - {issue.advice} -
- {/if} -
- {issue.severity} -
-
- {/each} -
- {/if} - - {#if contentAnalysis.links && contentAnalysis.links.length > 0} -
-
Links ({contentAnalysis.links.length})
-
- - - - - - - - - - {#each contentAnalysis.links as link} - - - - - - {/each} - -
URLStatusHTTP Code
- {link.url} - {#if link.is_shortened} - Shortened - {/if} - - - {link.status} - - {link.http_code || "-"}
-
-
- {/if} - - {#if contentAnalysis.images && contentAnalysis.images.length > 0} -
-
Images ({contentAnalysis.images.length})
-
- - - - - - - - - - {#each contentAnalysis.images as image} - - - - - - {/each} - -
SourceAlt TextTracking
{image.src || "-"} - {#if image.has_alt} - - {image.alt_text || "Present"} - {:else} - - Missing - {/if} - - {#if image.is_tracking_pixel} - Tracking Pixel - {:else} - - - {/if} -
-
-
- {/if} -
-
diff --git a/web/src/lib/components/DkimRecordsDisplay.svelte b/web/src/lib/components/DkimRecordsDisplay.svelte deleted file mode 100644 index 11a1b00..0000000 --- a/web/src/lib/components/DkimRecordsDisplay.svelte +++ /dev/null @@ -1,74 +0,0 @@ - - -
-
-
- - DomainKeys Identified Mail -
- DKIM -
-
-

- DKIM cryptographically signs your emails, proving they haven't been tampered with in - transit. Receiving servers verify this signature against your DNS records. -

-
-
- {#if dkimRecords && dkimRecords.length > 0} - {#each dkimRecords as dkim} -
-
- Selector: {dkim.selector} - Domain: {dkim.domain} -
-
- Status: - {#if dkim.valid} - Valid - {:else} - Invalid - {/if} -
- {#if dkim.record} -
- Record:
- {dkim.record} -
- {/if} - {#if dkim.error} -
- Error: - {dkim.error} -
- {/if} -
- {/each} - {:else} -
- - No DKIM signatures found in this email. DKIM provides cryptographic authentication and - helps avoid spoofing, thus improving deliverability. -
- {/if} -
-
diff --git a/web/src/lib/components/DmarcRecordDisplay.svelte b/web/src/lib/components/DmarcRecordDisplay.svelte deleted file mode 100644 index b7a3e7b..0000000 --- a/web/src/lib/components/DmarcRecordDisplay.svelte +++ /dev/null @@ -1,271 +0,0 @@ - - -{#if dmarcRecord} -
-
-
- - Domain-based Message Authentication -
- DMARC -
-
-

- DMARC enforces domain alignment requirements (regardless of the policy). It builds - on SPF and DKIM by telling receiving servers what to do with emails that fail - authentication checks. It also enables reporting so you can monitor your email - security. -

- -
- - -
- Status: - {#if dmarcRecord.valid} - Valid - {:else} - Invalid - {/if} -
- - - {#if dmarcRecord.policy} -
- Policy: - - {dmarcRecord.policy} - - {#if dmarcRecord.policy === "reject"} -
- - Maximum protection — emails failing DMARC checks are rejected. - This provides the strongest defense against spoofing and phishing. -
- {:else if dmarcRecord.policy === "quarantine"} -
- - Good protection — emails failing DMARC checks are - quarantined (sent to spam). This is a safe middle ground.
- - Once you've validated your configuration and ensured all legitimate mail - passes, consider upgrading to p=reject for maximum protection. -
- {:else if dmarcRecord.policy === "none"} -
- - Monitoring only — emails failing DMARC are delivered - normally. This is only recommended during initial setup.
- - After monitoring reports, upgrade to p=quarantine or - p=reject to actively protect your domain. -
- {:else} -
- - Unknown policy — the policy value is not recognized. Valid - options are: none, quarantine, or reject. -
- {/if} -
- {/if} - - - {#if dmarcRecord.subdomain_policy} - {@const mainStrength = policyStrength(dmarcRecord.policy)} - {@const subStrength = policyStrength(dmarcRecord.subdomain_policy)} -
- Subdomain Policy: - - {dmarcRecord.subdomain_policy} - - {#if subStrength >= mainStrength} -
- - Good configuration — subdomain policy is equal to or stricter - than main policy. -
- {:else} -
- - Weaker subdomain protection — consider setting - sp={dmarcRecord.policy} to match your main policy for consistent - protection. -
- {/if} -
- {:else if dmarcRecord.policy} -
- Subdomain Policy: - Inherits main policy -
- - Good default — subdomains inherit the main policy ({dmarcRecord.policy}) which provides consistent protection. -
-
- {/if} - - - {#if dmarcRecord.percentage !== undefined} -
- Enforcement Percentage: - - {dmarcRecord.percentage}% - - {#if dmarcRecord.percentage === 100} -
- - Full enforcement — all messages are subject to DMARC policy. - This provides maximum protection. -
- {:else if dmarcRecord.percentage >= 50} -
- - Partial enforcement — only {dmarcRecord.percentage}% of - messages are subject to DMARC policy. Consider increasing to - pct=100 once you've validated your configuration. -
- {:else} -
- - Low enforcement — only {dmarcRecord.percentage}% of - messages are protected. Gradually increase to pct=100 for full - protection. -
- {/if} -
- {:else if dmarcRecord.policy} -
- Enforcement Percentage: - 100% (default) -
- - Full enforcement — all messages are subject to DMARC policy - by default. -
-
- {/if} - - - {#if dmarcRecord.spf_alignment} -
- SPF Alignment: - - {dmarcRecord.spf_alignment} - - {#if dmarcRecord.spf_alignment === "relaxed"} -
- - Recommended for most senders — ensures legitimate - subdomain mail passes.
- - For maximum brand protection, consider strict alignment (aspf=s) once your sending domains are standardized. -
- {:else} -
- - Maximum brand protection — only exact domain matches are - accepted. Ensure all legitimate mail comes from the exact From domain. -
- {/if} -
- {/if} - - - {#if dmarcRecord.dkim_alignment} -
- DKIM Alignment: - - {dmarcRecord.dkim_alignment} - - {#if dmarcRecord.dkim_alignment === "relaxed"} -
- - Recommended for most senders — ensures legitimate - subdomain mail passes.
- - For maximum brand protection, consider strict alignment (adkim=s) once your sending domains are standardized. -
- {:else} -
- - Maximum brand protection — only exact domain matches are - accepted. Ensure all DKIM signatures use the exact From domain. -
- {/if} -
- {/if} - - - {#if dmarcRecord.record} -
- Record:
- {dmarcRecord.record} -
- {/if} - - - {#if dmarcRecord.error} -
- Error: - {dmarcRecord.error} -
- {/if} -
-
-{/if} diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte deleted file mode 100644 index b7997b0..0000000 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ /dev/null @@ -1,174 +0,0 @@ - - -
-
-

- - - DNS Records - - - {#if dnsScore !== undefined} - - {dnsScore}% - - {/if} - {#if dnsGrade !== undefined} - - {/if} - -

-
-
- {#if !dnsResults} -

No DNS results available

- {:else} - {#if dnsResults.errors && dnsResults.errors.length > 0} -
- Errors: -
    - {#each dnsResults.errors as error} -
  • {error}
  • - {/each} -
-
- {/if} - - {#if !domainOnly} - - {#if receivedChain && receivedChain.length > 0} -
-

- Received from: {receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0] - .ip}]) -

-
- {/if} - - - - - - - -
- - -
-
-

- Return-Path Domain: - {dnsResults.rp_domain || dnsResults.from_domain} -

- {#if (domainAlignment && !domainAlignment.aligned && !domainAlignment.relaxed_aligned) || (domainAlignment && !domainAlignment.aligned && domainAlignment.relaxed_aligned && dnsResults.dmarc_record && dnsResults.dmarc_record.spf_alignment === "strict") || (!domainAlignment && dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain)} - - Differs from From domain - - - - See domain alignment - - {:else} - Same as From domain - {/if} -
-
- - - {#if dnsResults.rp_mx_records && dnsResults.rp_mx_records.length > 0} - - {/if} - {/if} - - - - - {#if !domainOnly} -
- - -
-

- From Domain: {dnsResults.from_domain} -

- {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} - - Differs from Return-Path - domain - - {/if} -
- {/if} - - - {#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0} - - {/if} - - {#if !domainOnly} - - - {/if} - - - - - - - {/if} -
-
diff --git a/web/src/lib/components/EmailAddressDisplay.svelte b/web/src/lib/components/EmailAddressDisplay.svelte index 5b5f051..aa79f9e 100644 --- a/web/src/lib/components/EmailAddressDisplay.svelte +++ b/web/src/lib/components/EmailAddressDisplay.svelte @@ -1,13 +1,10 @@ -
-
- +
+
+ {email}
{#if copied} - + Copied to clipboard! {/if} diff --git a/web/src/lib/components/EmailPathCard.svelte b/web/src/lib/components/EmailPathCard.svelte deleted file mode 100644 index a4fda45..0000000 --- a/web/src/lib/components/EmailPathCard.svelte +++ /dev/null @@ -1,67 +0,0 @@ - - -{#if receivedChain && receivedChain.length > 0} -
-
-

- - Email Path -

-
-
- {#each receivedChain as hop, i} -
-
-
- {receivedChain.length - i} - {hop.reverse || "-"} - {#if hop.ip}({hop.ip}){/if} → {hop.by || - "Unknown"} -
- - {hop.timestamp - ? new Intl.DateTimeFormat("default", { - dateStyle: "long", - timeStyle: "short", - }).format(new Date(hop.timestamp)) - : "-"} - -
- {#if hop.with || hop.id || hop.from} -

- {#if hop.with} - - Protocol: - {hop.with} - - {/if} - {#if hop.id} - - ID: {hop.id} - - {/if} - {#if hop.from} - - Helo: {hop.from} - - {/if} -

- {/if} -
- {/each} -
-
-{/if} diff --git a/web/src/lib/components/ErrorDisplay.svelte b/web/src/lib/components/ErrorDisplay.svelte deleted file mode 100644 index 96cfae2..0000000 --- a/web/src/lib/components/ErrorDisplay.svelte +++ /dev/null @@ -1,158 +0,0 @@ - - -
-
- -
- -
- - -

{status}

- - -

{getErrorTitle(status)}

- - -

{getErrorDescription(status)}

- - - {#if message && message !== defaultDescription} - - {/if} - - - {#if showActions} -
- - - Go Home - - -
- {/if} - - - {#if status === 404 && showActions} -
-

Looking for something specific?

- -
- {/if} -
-
- - diff --git a/web/src/lib/components/GradeDisplay.svelte b/web/src/lib/components/GradeDisplay.svelte deleted file mode 100644 index f9d1f78..0000000 --- a/web/src/lib/components/GradeDisplay.svelte +++ /dev/null @@ -1,59 +0,0 @@ - - - - {#if grade} - {grade} - {:else} - {score}% - {/if} - - - diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte deleted file mode 100644 index 73c39e8..0000000 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ /dev/null @@ -1,425 +0,0 @@ - - -
-
-

- - - Header Analysis - - - {#if headerScore !== undefined} - - {headerScore}% - - {/if} - {#if headerGrade !== undefined} - - {/if} - -

-
-
- {#if headerAnalysis.issues && headerAnalysis.issues.length > 0} -
-
Issues
- {#each headerAnalysis.issues as issue} -
-
-
- {issue.header} -
{issue.message}
- {#if issue.advice} -
- - {issue.advice} -
- {/if} -
- {issue.severity} -
-
- {/each} -
- {/if} - - {#if headerAnalysis.domain_alignment} - {@const spfStrictAligned = - headerAnalysis.domain_alignment.from_domain === - headerAnalysis.domain_alignment.return_path_domain} - {@const spfRelaxedAligned = - headerAnalysis.domain_alignment.from_org_domain === - headerAnalysis.domain_alignment.return_path_org_domain} -
-
-
- - Domain Alignment -
-
-
-

- Domain alignment ensures that the visible "From" domain matches the domain - used for authentication (Return-Path or DKIM signature). Proper alignment is - crucial for DMARC compliance, regardless of the policy. It helps prevent - email spoofing by verifying that the sender domain is consistent across all - authentication layers. Only one of the following lines needs to pass. -

- {#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} -
- {#each headerAnalysis.domain_alignment.issues as issue} -
- - {issue} -
- {/each} -
- {/if} -
-
-
-
- SPF -
-
-
-
- Strict Alignment -
- - - {spfStrictAligned ? "Pass" : "Fail"} - -
-
Exact domain match
-
-
- Relaxed Alignment -
- - - {spfRelaxedAligned ? "Pass" : "Fail"} - -
-
- Organizational domain match -
-
-
- From Domain -
- - {headerAnalysis.domain_alignment.from_domain || "-"} - -
- {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} -
- Org: - - {headerAnalysis.domain_alignment.from_org_domain} - -
- {/if} -
-
- Return-Path Domain -
- - {headerAnalysis.domain_alignment.return_path_domain || - "-"} - -
- {#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain} -
- Org: - - {headerAnalysis.domain_alignment - .return_path_org_domain} - -
- {/if} -
-
- - - {#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain} -
- {#if dmarcRecord.spf_alignment === "strict"} - - Strict SPF alignment required — Your DMARC policy - requires exact domain match. The Return-Path domain must exactly - match the From domain for SPF to pass DMARC alignment. - {:else} - - Relaxed SPF alignment allowed — Your DMARC policy - allows organizational domain matching. As long as both domains - share the same organizational domain (e.g., mail.example.com - and example.com), SPF alignment can pass. - {/if} -
- {/if} -
-
- - {#each headerAnalysis.domain_alignment.dkim_domains as dkim_domain} - {@const dkim_aligned = - dkim_domain.domain === headerAnalysis.domain_alignment.from_domain} - {@const dkim_relaxed_aligned = - dkim_domain.org_domain === - headerAnalysis.domain_alignment.from_org_domain} -
-
- DKIM -
-
-
-
-
- Strict Alignment -
- - - {dkim_aligned ? "Pass" : "Fail"} - -
-
- Exact domain match -
-
-
- Relaxed Alignment -
- - - {dkim_relaxed_aligned - ? "Pass" - : "Fail"} - -
-
- Organizational domain match -
-
-
- From Domain -
- {headerAnalysis.domain_alignment.from_domain || - "-"} -
- {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} -
- Org: {headerAnalysis.domain_alignment - .from_org_domain} -
- {/if} -
-
- Signature Domain -
{dkim_domain.domain || "-"}
- {#if dkim_domain.domain !== dkim_domain.org_domain} -
- Org: {dkim_domain.org_domain} -
- {/if} -
-
- - - {#if dmarcRecord && dkim_domain.domain !== headerAnalysis.domain_alignment.from_domain} - {#if dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain} -
- {#if dmarcRecord.dkim_alignment === "strict"} - - Strict DKIM alignment required — - Your DMARC policy requires exact domain match. The - DKIM signature domain must exactly match the From - domain for DKIM to pass DMARC alignment. - {:else} - - Relaxed DKIM alignment allowed — - Your DMARC policy allows organizational domain matching. - As long as both domains share the same organizational - domain (e.g., mail.example.com and example.com), - DKIM alignment can pass. - {/if} -
- {/if} - {/if} -
-
-
- {/each} -
-
- {/if} - - {#if headerAnalysis.headers && Object.keys(headerAnalysis.headers).length > 0} -
-
Headers
-
- - - - - - - - - - - - {#each Object.entries(headerAnalysis.headers).sort((a, b) => { - const importanceOrder = { required: 0, recommended: 1, optional: 2, newsletter: 3 }; - const aImportance = importanceOrder[a[1].importance || "optional"]; - const bImportance = importanceOrder[b[1].importance || "optional"]; - return aImportance - bImportance; - }) as [name, check]} - - - - - - - - {/each} - -
When?PresentValidValue
- {name} - - {#if check.importance} - - {check.importance} - - {/if} - - - - {#if check.present && check.valid !== undefined} - - {:else} - - - {/if} - - {check.value || "-"} - {#if check.issues && check.issues.length > 0} - {#each check.issues as issue} -
- - {issue} -
- {/each} - {/if} -
-
-
- {/if} -
-
diff --git a/web/src/lib/components/HistoryTable.svelte b/web/src/lib/components/HistoryTable.svelte deleted file mode 100644 index 737d025..0000000 --- a/web/src/lib/components/HistoryTable.svelte +++ /dev/null @@ -1,72 +0,0 @@ - - -
- - - - - - - - - - - - {#each tests as test} - goto(`/test/${test.test_id}`)}> - - - - - - - {/each} - -
GradeScoreDomainDate
- - - {test.score}% - - {#if test.from_domain} - {test.from_domain} - {:else} - - - {/if} - - {formatDate(test.created_at)} - - -
-
- - diff --git a/web/src/lib/components/Logo.svelte b/web/src/lib/components/Logo.svelte deleted file mode 100644 index 6bba400..0000000 --- a/web/src/lib/components/Logo.svelte +++ /dev/null @@ -1,42 +0,0 @@ - - - - happyDeliver - - - - - - - - - diff --git a/web/src/lib/components/MxRecordsDisplay.svelte b/web/src/lib/components/MxRecordsDisplay.svelte deleted file mode 100644 index 893cae6..0000000 --- a/web/src/lib/components/MxRecordsDisplay.svelte +++ /dev/null @@ -1,55 +0,0 @@ - - -
-
-
- - {title} -
- MX -
-
- {#if description} -

{description}

- {/if} -
-
- {#each mxRecords as mx} -
-
- {#if mx.valid} - Valid - {:else} - Invalid - {/if} -
Host: {mx.host}
-
Priority: {mx.priority}
-
- {#if mx.error} - {mx.error} - {/if} -
- {/each} -
-
diff --git a/web/src/lib/components/PendingState.svelte b/web/src/lib/components/PendingState.svelte index afbc426..ab9a6f8 100644 --- a/web/src/lib/components/PendingState.svelte +++ b/web/src/lib/components/PendingState.svelte @@ -1,25 +1,12 @@
@@ -39,37 +26,13 @@
- {#if fetching || nextfetch === 0} - Looking for new email... - {:else if nextfetch} -
- - Next inbox check in {nextfetch} second{#if nextfetch > 1}s{/if}... - - {#if nbfetch > 0} - - {/if} -
- {:else} - Checking for email every 3 seconds... - {/if} + Checking for email every 3 seconds...
@@ -80,11 +43,11 @@
What we'll check:
-
+
  • - SPF, DKIM, DMARC, BIMI + SPF, DKIM, DMARC
  • DNS Records diff --git a/web/src/lib/components/PtrForwardRecordsDisplay.svelte b/web/src/lib/components/PtrForwardRecordsDisplay.svelte deleted file mode 100644 index 8ed723b..0000000 --- a/web/src/lib/components/PtrForwardRecordsDisplay.svelte +++ /dev/null @@ -1,124 +0,0 @@ - - -{#if ptrRecords && ptrRecords.length > 0} -
    -
    -
    - - Forward-Confirmed Reverse DNS -
    - FCrDNS -
    -
    -

    - Forward-confirmed reverse DNS (FCrDNS) verifies that the PTR hostname resolves back - to the original sender IP. This double-check helps establish sender legitimacy. -

    - {#if senderIp} -
    - Original Sender IP: {senderIp} -
    - {/if} -
    - {#if hasForwardRecords} -
    -
    -
    - PTR Hostname(s): - {#each ptrRecords as ptr} -
    - {ptr} -
    - {/each} -
    -
    - Forward Resolution (A/AAAA): - {#each ptrForwardRecords as ip} - {#if ip === senderIp || !fcrDnsIsValid || showDifferent} -
    - {#if senderIp && ip === senderIp} - Match - {:else} - Different - {/if} - {ip} -
    - {/if} - {/each} - {#if fcrDnsIsValid && differentCount > 0} -
    - -
    - {/if} -
    - {#if fcrDnsIsValid} -
    - - Success: Forward-confirmed reverse DNS is properly configured. - The PTR hostname resolves back to the sender IP. -
    - {:else} -
    - - Warning: The PTR hostname does not resolve back to the sender - IP. This may impact deliverability. -
    - {/if} -
    -
    - {:else} -
    -
    -
    - - Error: PTR hostname(s) found but could not resolve to any IP - addresses. Check your DNS configuration. -
    -
    -
    - {/if} -
    -{/if} diff --git a/web/src/lib/components/PtrRecordsDisplay.svelte b/web/src/lib/components/PtrRecordsDisplay.svelte deleted file mode 100644 index c88d7cd..0000000 --- a/web/src/lib/components/PtrRecordsDisplay.svelte +++ /dev/null @@ -1,85 +0,0 @@ - - -{#if ptrRecords && ptrRecords.length > 0} -
    -
    -
    - - Reverse DNS -
    - PTR -
    -
    -

    - PTR (pointer record), also known as reverse DNS maps IP addresses back to hostnames. - Having proper PTR records is important as many mail servers verify that the sending - IP has a valid reverse DNS entry. -

    - {#if senderIp} -
    - Sender IP: {senderIp} -
    - {/if} -
    -
    - {#each ptrRecords as ptr} -
    -
    - Found - {ptr} -
    -
    - {/each} - {#if ptrRecords.length > 1} -
    -
    - - Warning: Multiple PTR records found. While not strictly an error, - having multiple PTR records can cause issues with some mail servers. It's recommended - to have exactly one PTR record per IP address. -
    -
    - {/if} -
    -
    -{:else if senderIp} -
    -
    -
    - - Reverse DNS (PTR) -
    - PTR -
    -
    -

    - PTR records (reverse DNS) map IP addresses back to hostnames. Having proper PTR - records is important for email deliverability. -

    -
    - Sender IP: {senderIp} -
    -
    - - Error: No PTR records found for the sender IP. Contact your email service - provider to configure reverse DNS. -
    -
    -
    -{/if} diff --git a/web/src/lib/components/RspamdCard.svelte b/web/src/lib/components/RspamdCard.svelte deleted file mode 100644 index 4c2795b..0000000 --- a/web/src/lib/components/RspamdCard.svelte +++ /dev/null @@ -1,153 +0,0 @@ - - -
    -
    -

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

    -
    -
    -
    -
    - Score: - - {rspamd.score.toFixed(2)} / {rspamd.threshold.toFixed(1)} - -
    -
    - Classified as: - - {rspamd.is_spam ? "SPAM" : "HAM"} - -
    -
    - Action: - - {effectiveAction.label} - -
    -
    - - {#if rspamd.symbols && Object.keys(rspamd.symbols).length > 0} -
    -
    - - - - - - - - - - {#each Object.entries(rspamd.symbols).sort(([, a], [, b]) => b.score - a.score) as [symbolName, symbol]} - 0 - ? "table-warning" - : symbol.score < 0 - ? "table-success" - : ""} - > - - - - - {/each} - -
    SymbolScoreDescription
    - {symbolName} - {#if symbol.params} - - {symbol.params} - - {/if} - - 0 - ? "text-danger fw-bold" - : symbol.score < 0 - ? "text-success fw-bold" - : "text-muted"} - > - {symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)} - - {symbol.description ?? ""}
    -
    -
    - {/if} - - {#if rspamd.report} -
    - Raw Report -
    {rspamd.report}
    -
    - {/if} -
    -
    - - diff --git a/web/src/lib/components/ScoreCard.svelte b/web/src/lib/components/ScoreCard.svelte index 7a80dc4..65aa706 100644 --- a/web/src/lib/components/ScoreCard.svelte +++ b/web/src/lib/components/ScoreCard.svelte @@ -1,162 +1,71 @@ -
    +
    -
    - {#if reanalyzing} -
    - {:else} - - {/if} -
    -

    - {#if reanalyzing} - Analyzing in progress… - {:else} - {getScoreLabel(grade)} - {/if} -

    +

    + {score.toFixed(1)}/10 +

    +

    {getScoreLabel(score)}

    Overall Deliverability Score

    {#if summary}
    -
    - -
    - - DNS -
    -
    +
    +
    + Authentication + {summary.authentication_score.toFixed(1)}/3 +
    -
    - -
    - - Authentication -
    -
    +
    +
    + Spam Score + {summary.spam_score.toFixed(1)}/2 +
    -
    - -
    - - Blacklists -
    -
    +
    +
    + Blacklists + {summary.blacklist_score.toFixed(1)}/2 +
    -
    - -
    - - Headers -
    -
    +
    +
    + Content + {summary.content_score.toFixed(1)}/2 +
    - -
    - -
    - - Content -
    -
    +
    +
    + Headers + {summary.header_score.toFixed(1)}/1 +
    {/if}
    - - diff --git a/web/src/lib/components/SpamAssassinCard.svelte b/web/src/lib/components/SpamAssassinCard.svelte index cc88c23..3d4872c 100644 --- a/web/src/lib/components/SpamAssassinCard.svelte +++ b/web/src/lib/components/SpamAssassinCard.svelte @@ -1,8 +1,5 @@ -
    -
    -

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

    +
    +
    +
    + SpamAssassin Analysis +
    @@ -46,55 +30,12 @@
    - {#if spamassassin.test_details && Object.keys(spamassassin.test_details).length > 0} -
    -
    - - - - - - - - - - {#each Object.entries(spamassassin.test_details) as [testName, detail]} - 0 - ? "table-warning" - : detail.score < 0 - ? "table-success" - : ""} - > - - - - - {/each} - -
    Test NameScoreDescription
    {testName} - 0 - ? "text-danger fw-bold" - : detail.score < 0 - ? "text-success fw-bold" - : "text-muted"} - > - {detail.score > 0 ? "+" : ""}{detail.score.toFixed(1)} - - {detail.description || ""}
    -
    -
    - {:else if spamassassin.tests && spamassassin.tests.length > 0} + {#if spamassassin.tests && spamassassin.tests.length > 0}
    Tests Triggered:
    {#each spamassassin.tests as test} - {test} + {test} {/each}
    @@ -102,11 +43,8 @@ {#if spamassassin.report}
    - Raw Report -
    {spamassassin.report}
    + Full Report +
    {spamassassin.report}
    {/if}
    @@ -124,15 +62,4 @@ details summary:hover { color: var(--bs-primary); } - - /* Darker table colors in dark mode */ - :global([data-bs-theme="dark"]) .table-warning { - --bs-table-bg: rgba(255, 193, 7, 0.2); - --bs-table-border-color: rgba(255, 193, 7, 0.3); - } - - :global([data-bs-theme="dark"]) .table-success { - --bs-table-bg: rgba(25, 135, 84, 0.2); - --bs-table-border-color: rgba(25, 135, 84, 0.3); - } diff --git a/web/src/lib/components/SpfRecordsDisplay.svelte b/web/src/lib/components/SpfRecordsDisplay.svelte deleted file mode 100644 index 2ebb2c2..0000000 --- a/web/src/lib/components/SpfRecordsDisplay.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - -{#if spfRecords && spfRecords.length > 0} -
    -
    -
    - - Sender Policy Framework -
    - SPF -
    -
    -

    - SPF specifies which mail servers are authorized to send emails on behalf of your - domain. Receiving servers check the sender's IP address against your SPF record to - prevent email spoofing. -

    -
    -
    - {#each spfRecords as spf, index} -
    - {#if spf.domain} -
    - Domain: {spf.domain} - {#if index > 0} - Included - {/if} -
    - {/if} -
    - Status: - {#if spf.valid} - Valid - {:else} - Invalid - {/if} -
    - {#if spf.all_qualifier} -
    - All Mechanism Policy: - {#if spf.all_qualifier === "-"} - Strict (-all) - {:else if spf.all_qualifier === "~"} - Softfail (~all) - {:else if spf.all_qualifier === "+"} - Pass (+all) - {:else if spf.all_qualifier === "?"} - Neutral (?all) - {/if} - {#if index === 0 || (index === 1 && spfRecords[0].record?.includes("redirect="))} -
    - {#if spf.all_qualifier === "-"} - All unauthorized servers will be rejected. This is the - recommended strict policy. - {:else if dmarcStrict} - While your DMARC {dmarcRecord?.policy} policy provides some protection, - consider using -all for better security with some - old mailbox providers. - {:else if spf.all_qualifier === "~"} - Unauthorized servers will softfail. Consider using -all for stricter policy, though this rarely affects legitimate - email deliverability. - {:else if spf.all_qualifier === "+"} - All servers are allowed to send email. This severely weakens - email authentication. Use -all for strict policy. - {:else if spf.all_qualifier === "?"} - No statement about unauthorized servers. Use -all for strict policy to prevent spoofing. - {/if} -
    - {/if} -
    - {/if} - {#if spf.record} -
    - Record:
    - {spf.record} -
    - {/if} - {#if spf.error} -
    - - {spf.valid ? "Warning:" : "Error:"} - {spf.error} -
    - {/if} -
    - {/each} -
    -
    -{/if} diff --git a/web/src/lib/components/SummaryCard.svelte b/web/src/lib/components/SummaryCard.svelte deleted file mode 100644 index 518e996..0000000 --- a/web/src/lib/components/SummaryCard.svelte +++ /dev/null @@ -1,607 +0,0 @@ - - -
    -
    -
    - - Summary -
    -

    - {#each summarySegments as segment} - {#if segment.link} - - {segment.text} - - {:else if segment.highlight} - - {segment.text} - - {:else} - {segment.text} - {/if} - {/each} - Overall, your email received a grade {#if report.grade == "A" || report.grade == "A+"}, well done 🎉{:else if report.grade == "C" || report.grade == "D"}: - you should try to increase your score to ensure inbox delivery.{:else if report.grade == "E"}: - you could have delivery issues with common providers.{:else if report.grade == "F"}: - it will most likely be rejected by most providers.{:else}!{/if} Check the details below - 🔽 -

    - {@render children?.()} -
    -
    - - diff --git a/web/src/lib/components/TinySurvey.svelte b/web/src/lib/components/TinySurvey.svelte deleted file mode 100644 index 805af0e..0000000 --- a/web/src/lib/components/TinySurvey.svelte +++ /dev/null @@ -1,96 +0,0 @@ - - -{#if $appConfig.survey_url} -
    - {#if step === 0} - {#if question}{@render question()}{:else} -

    Help us to design a better tool, rate this report!

    - {/if} -
    - {#each [...Array(5).keys()] as i} - - {/each} -
    - {:else if step === 1} -

    - {#if responses.stars == 5}Thank you! Would you like to tell us more? - {:else if responses.stars == 4}What are we missing to earn 5 stars? - {:else}How could we improve? - {/if} -

    - - - - {:else if step === 2} -

    - Thank you so much for taking the time to share your feedback! -

    - {/if} -
    -{/if} diff --git a/web/src/lib/components/WhitelistCard.svelte b/web/src/lib/components/WhitelistCard.svelte deleted file mode 100644 index 13fd86b..0000000 --- a/web/src/lib/components/WhitelistCard.svelte +++ /dev/null @@ -1,62 +0,0 @@ - - -
    -
    -

    - - - Whitelist Checks - - Informational -

    -
    -
    -

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

    - -
    - {#each Object.entries(whitelists) as [ip, checks]} -
    -
    - - {ip} -
    - - - {#each checks as check} - - - - - {/each} - -
    - - {check.error - ? "Error" - : check.listed - ? "Listed" - : "Not listed"} - - {check.rbl}
    -
    - {/each} -
    -
    -
    diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index a593801..8da4188 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -1,28 +1,8 @@ // Component exports -export { default as AuthenticationCard } from "./AuthenticationCard.svelte"; -export { default as BimiRecordDisplay } from "./BimiRecordDisplay.svelte"; -export { default as BlacklistCard } from "./BlacklistCard.svelte"; -export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte"; -export { default as DkimRecordsDisplay } from "./DkimRecordsDisplay.svelte"; -export { default as DmarcRecordDisplay } from "./DmarcRecordDisplay.svelte"; -export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte"; -export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte"; -export { default as EmailPathCard } from "./EmailPathCard.svelte"; -export { default as ErrorDisplay } from "./ErrorDisplay.svelte"; export { default as FeatureCard } from "./FeatureCard.svelte"; -export { default as GradeDisplay } from "./GradeDisplay.svelte"; -export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte"; export { default as HowItWorksStep } from "./HowItWorksStep.svelte"; -export { default as Logo } from "./Logo.svelte"; -export { default as MxRecordsDisplay } from "./MxRecordsDisplay.svelte"; -export { default as PendingState } from "./PendingState.svelte"; -export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte"; -export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte"; export { default as ScoreCard } from "./ScoreCard.svelte"; -export { default as RspamdCard } from "./RspamdCard.svelte"; +export { default as CheckCard } from "./CheckCard.svelte"; export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte"; -export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte"; -export { default as SummaryCard } from "./SummaryCard.svelte"; -export { default as HistoryTable } from "./HistoryTable.svelte"; -export { default as TinySurvey } from "./TinySurvey.svelte"; -export { default as WhitelistCard } from "./WhitelistCard.svelte"; +export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte"; +export { default as PendingState } from "./PendingState.svelte"; diff --git a/web/src/lib/hey-api.ts b/web/src/lib/hey-api.ts index 6983e5d..e75e70a 100644 --- a/web/src/lib/hey-api.ts +++ b/web/src/lib/hey-api.ts @@ -7,8 +7,8 @@ export class NotAuthorizedError extends Error { } } -async function customFetch(input: RequestInfo | URL, init?: RequestInit): Promise { - const response = await fetch(input, init); +async function customFetch(url: string, init: RequestInit): Promise { + const response = await fetch(url, init); if (response.status === 400) { const json = await response.json(); diff --git a/web/src/lib/score.ts b/web/src/lib/score.ts deleted file mode 100644 index e9d9bae..0000000 --- a/web/src/lib/score.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function getScoreColorClass(percentage: number): string { - if (percentage >= 85) return "success"; - if (percentage >= 50) return "warning"; - return "danger"; -} diff --git a/web/src/lib/stores/config.ts b/web/src/lib/stores/config.ts deleted file mode 100644 index 962868c..0000000 --- a/web/src/lib/stores/config.ts +++ /dev/null @@ -1,54 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import { writable } from "svelte/store"; - -interface AppConfig { - report_retention?: number; - survey_url?: string; - custom_logo_url?: string; - rbls?: string[]; - test_list_enabled?: boolean; -} - -const defaultConfig: AppConfig = { - report_retention: 0, - survey_url: "", - rbls: [], -}; - -function getConfigFromScriptTag(): AppConfig | null { - if (typeof document !== "undefined") { - const configScript = document.getElementById("app-config"); - if (configScript) { - try { - return JSON.parse(configScript.textContent || ""); - } catch (e) { - console.error("Failed to parse app config:", e); - } - } - } - return null; -} - -const initialConfig = getConfigFromScriptTag() || defaultConfig; - -export const appConfig = writable(initialConfig); diff --git a/web/src/lib/stores/theme.ts b/web/src/lib/stores/theme.ts deleted file mode 100644 index ea24293..0000000 --- a/web/src/lib/stores/theme.ts +++ /dev/null @@ -1,41 +0,0 @@ -// This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain -// Authors: Pierre-Olivier Mercier, et al. -// -// This program is offered under a commercial and under the AGPL license. -// For commercial licensing, contact us at . -// -// For AGPL licensing: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import { browser } from "$app/environment"; -import { writable } from "svelte/store"; - -const getInitialTheme = () => { - if (!browser) return "light"; - - const stored = localStorage.getItem("theme"); - if (stored === "light" || stored === "dark") return stored; - - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; -}; - -export const theme = writable<"light" | "dark">(getInitialTheme()); - -theme.subscribe((value) => { - if (browser) { - localStorage.setItem("theme", value); - document.documentElement.setAttribute("data-bs-theme", value); - } -}); diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte index 0e103e5..5d0514c 100644 --- a/web/src/routes/+error.svelte +++ b/web/src/routes/+error.svelte @@ -1,9 +1,8 @@ @@ -28,5 +55,96 @@
    - +
    +
    + +
    + +
    + + +

    {status}

    + + +

    {getErrorTitle(status)}

    + + +

    {getErrorDescription(status)}

    + + + {#if message !== getErrorDescription(status)} + + {/if} + + +
    + + + Go Home + + +
    + + + {#if status === 404} +
    +

    Looking for something specific?

    + +
    + {/if} +
    +
    + + diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 92bb4db..9ed83d4 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,68 +1,24 @@ - - - -
    -
    - - diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index b9259fe..f0709a1 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,30 +1,10 @@ - happyDeliver. Test Your Email Deliverability. + happyDeliver - Email Deliverability Testing -
    +
    @@ -170,7 +108,7 @@ and more. Open-source, self-hosted, and privacy-focused.

    - -{#if $appConfig.test_list_enabled && recentTests.length > 0} -
    -
    -
    -
    -

    Recently Tested

    -

    Latest deliverability reports from this instance

    -
    -
    - - -
    -
    -{/if} - -
    +

    Comprehensive Email Analysis

    - Your favorite deliverability tester, open-source and self-hostable for complete - privacy and control. + Your favorite deliverability tester, open-source and + self-hostable for complete privacy and control.

    -
    +
    {#each features as feature}
    @@ -244,7 +156,7 @@
    -
    +
    @@ -278,56 +190,15 @@ {/if}
    - -
    diff --git a/web/src/routes/blacklist/+page.svelte b/web/src/routes/blacklist/+page.svelte deleted file mode 100644 index d2946b8..0000000 --- a/web/src/routes/blacklist/+page.svelte +++ /dev/null @@ -1,197 +0,0 @@ - - - - Blacklist Check - happyDeliver - - -
    -
    -
    - -
    -

    - - Check IP Blacklist Status -

    -

    - Test an IP address against multiple DNS-based blacklists (RBLs) to check its - reputation. -

    -
    - - -
    -
    -

    Enter IP Address

    -
    - - - - - -
    - - {#if error} - - {/if} - - - - Enter an IPv4 address (e.g., 192.0.2.1) or IPv6 address (e.g., 2001:db8::1) - -
    -
    - - -
    -
    -
    -
    -

    - - What's Checked -

    -
      - {#each $appConfig.rbls as rbl} -
    • - {rbl} -
    • - {/each} -
    -
    -
    -
    - -
    -
    -
    -

    - - Why Check Blacklists? -

    -

    - DNS-based blacklists (RBLs) are used by email servers to identify - and block spam sources. Being listed can severely impact email - deliverability. -

    -

    - This tool checks your IP against multiple popular RBLs to help you: -

    -
      -
    • - Monitor IP reputation -
    • -
    • - Identify deliverability - issues -
    • -
    • - Take corrective action -
    • -
    -
    -
    -
    -
    - - -
    -

    - - Need Complete Email Analysis? -

    -

    - For comprehensive deliverability testing including DKIM verification, content - analysis, spam scoring, and more: -

    - - - Send Test Email - -
    -
    -
    -
    - - diff --git a/web/src/routes/blacklist/[ip]/+page.svelte b/web/src/routes/blacklist/[ip]/+page.svelte deleted file mode 100644 index 89676ed..0000000 --- a/web/src/routes/blacklist/[ip]/+page.svelte +++ /dev/null @@ -1,272 +0,0 @@ - - - - {ip} - Blacklist Check - happyDeliver - - -
    -
    -
    - -
    -
    -

    - - Blacklist Analysis -

    - - - Check Another IP - -
    -
    - - {#if loading} - -
    -
    -
    - Loading... -
    -

    Checking {ip}...

    -

    Querying DNS-based blacklists

    -
    -
    - {:else if error} - -
    -
    - -

    Check Failed

    -

    {error}

    - -
    -
    - {:else if result} - -
    - -
    -
    -
    -
    -

    - {result.ip} -

    - {#if result.listed_count === 0} -
    - - Not Listed -

    - This IP address is not listed on any checked - blacklists. -

    -
    - {:else} -
    - - Listed on {result.listed_count} blacklist{result.listed_count > - 1 - ? "s" - : ""} -

    - This IP address is listed on {result.listed_count} of - {result.blacklists.length} checked blacklist{result - .blacklists.length > 1 - ? "s" - : ""}. -

    -
    - {/if} -
    -
    -
    - - Blacklist Score -
    -
    -
    -
    - -
    -
    -
    - -
    - -
    - -
    - - - {#if result.whitelists && result.whitelists.length > 0} -
    - -
    - {/if} -
    - - -
    -
    -

    - - What This Means -

    - {#if result.listed_count === 0} -

    - Good news! This IP address is not currently listed - on any of the checked DNS-based blacklists (RBLs). This indicates - a good sender reputation and should not negatively impact email deliverability. -

    - {:else} -

    - Warning: This IP address is listed on {result.listed_count} - blacklist{result.listed_count > 1 ? "s" : ""}. Being listed can - significantly impact email deliverability as many mail servers - use these blacklists to filter incoming mail. -

    -
    -

    Recommended Actions:

    -
      -
    • - Investigate the cause of the listing (compromised - system, spam complaints, etc.) -
    • -
    • - Fix any security issues or stop sending practices that - led to the listing -
    • -
    • - Request delisting from each RBL (check their websites - for removal procedures) -
    • -
    • - Monitor your IP reputation regularly to prevent future - listings -
    • -
    -
    - {/if} -
    -
    - - -
    -
    -

    - - Want Complete Email Analysis? -

    -

    - This blacklist check tests IP reputation only. For comprehensive - deliverability testing including DKIM verification, content - analysis, spam scoring, and DNS configuration: -

    - - - Send Test Email - -
    -
    -
    - {/if} -
    -
    -
    - - diff --git a/web/src/routes/domain/+page.svelte b/web/src/routes/domain/+page.svelte deleted file mode 100644 index df67f4e..0000000 --- a/web/src/routes/domain/+page.svelte +++ /dev/null @@ -1,187 +0,0 @@ - - - - Domain Test - happyDeliver - - -
    -
    -
    - -
    -

    - - Test Domain Configuration -

    -

    - Check your domain's email DNS records (MX, SPF, DMARC, BIMI) without sending an - email. -

    -
    - - -
    -
    -

    Enter Domain Name

    -
    - - - - - -
    - - {#if error} - - {/if} - - - - Enter a domain name like "example.com" or "mail.example.org" - -
    -
    - - -
    -
    -
    -
    -

    - - What's Checked -

    -
      -
    • - MX Records -
    • -
    • - SPF Records -
    • -
    • - DMARC Policy -
    • -
    • - BIMI Support -
    • -
    • - Disposable Domain Check -
    • -
    -
    -
    -
    - -
    -
    -
    -

    - - Need More? -

    -

    - For complete email deliverability analysis including: -

    -
      -
    • - DKIM Verification -
    • -
    • - Content & Header Analysis -
    • -
    • - Spam Scoring -
    • -
    • - Blacklist Checks -
    • -
    - - - Send Test Email - -
    -
    -
    -
    -
    -
    -
    - - diff --git a/web/src/routes/domain/[domain]/+page.svelte b/web/src/routes/domain/[domain]/+page.svelte deleted file mode 100644 index d866e21..0000000 --- a/web/src/routes/domain/[domain]/+page.svelte +++ /dev/null @@ -1,190 +0,0 @@ - - - - {domain} - Domain Test - happyDeliver - - -
    -
    -
    - -
    -
    -

    - - Domain Analysis -

    - - - Test Another Domain - -
    -
    - - {#if loading} - -
    -
    -
    - Loading... -
    -

    Analyzing {domain}...

    -

    Checking DNS records and configuration

    -
    -
    - {:else if error} - -
    -
    - -

    Analysis Failed

    -

    {error}

    - -
    -
    - {:else if result} - -
    - -
    -
    -
    -
    -

    - {result.domain} -

    - {#if result.is_disposable} -
    - - Disposable Email Provider Detected -

    - This domain is a known temporary/disposable email - service. Emails from this domain may have lower - deliverability. -

    -
    - {:else} -

    Domain Configuration Score

    - {/if} -
    -
    -
    - - DNS -
    -
    -
    -
    - -
    -
    -
    - - - - - -
    -
    -

    - - Want Complete Email Analysis? -

    -

    - This domain-only test checks DNS configuration. For comprehensive - deliverability testing including DKIM verification, content - analysis, spam scoring, and blacklist checks: -

    - - - Send a Test Email - -
    -
    -
    - {/if} -
    -
    -
    - - diff --git a/web/src/routes/history/+page.svelte b/web/src/routes/history/+page.svelte deleted file mode 100644 index 6925ab8..0000000 --- a/web/src/routes/history/+page.svelte +++ /dev/null @@ -1,189 +0,0 @@ - - - - Test History - happyDeliver - - -
    -
    -
    -
    -

    - - Test History -

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

    Loading tests...

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

    No tests yet

    -

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

    - -
    - {:else} - - - - {#if totalPages > 1} - - {/if} - {/if} -
    -
    -
    diff --git a/web/src/routes/test/+page.ts b/web/src/routes/test/+page.ts deleted file mode 100644 index 8f8fd5b..0000000 --- a/web/src/routes/test/+page.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { error, redirect, type Load } from "@sveltejs/kit"; - -import { createTest as apiCreateTest } from "$lib/api"; - -export const prerender = false; -export const ssr = false; - -export const load: Load = async ({}) => { - let response; - try { - response = await apiCreateTest(); - } catch (err) { - const errorObj = err as { response?: { status?: number }; message?: string }; - error(errorObj.response?.status || 500, errorObj.message || "Unknown error"); - } - - if (response.response.ok && response.data) { - redirect(302, `/test/${response.data.id}`); - } else { - error(response.response.status, response.error); - } -}; diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 113209d..f70bc53 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -1,86 +1,18 @@ - - {report - ? `Test${report.dns_results ? ` of ${report.dns_results.from_domain}` : ""} ${report.test_id?.slice(0, 7) || ""}` - : test - ? `Test ${test.id.slice(0, 7)}` - : "Loading..."} - happyDeliver - + {test ? `Test ${test.id.slice(0, 8)} - happyDeliver` : "Loading..."}
    @@ -216,212 +72,50 @@

    Loading test...

    {:else if error} - +
    +
    + +
    +
    {:else if test && test.status !== "analyzed"} - fetchTest()} - /> + {:else if report}
    -
    -
    -
    -
    - -
    -
    - -
    -
    - -
    - -
    - -
    -
    +
    - - {#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0} -
    -
    - -
    + +
    +
    +

    Detailed Checks

    + {#each report.checks as check} + + {/each}
    - {/if} +
    - - {#if report.dns_results} -
    -
    - -
    -
    - {/if} - - - {#if report.authentication} -
    -
    - -
    -
    - {/if} - - - {#snippet blacklistChecks(blacklists: BlacklistRecords, report: Report)} - - {/snippet} - - - {#snippet whitelistChecks(whitelists: BlacklistRecords)} - - {/snippet} - - - {#if report.blacklists && report.whitelists && Object.keys(report.blacklists).length == 1 && Object.keys(report.whitelists).length == 1} + + {#if report.spamassassin}
    -
    - {@render blacklistChecks(report.blacklists, report)} -
    -
    - {@render whitelistChecks(report.whitelists)} -
    -
    - {:else} - {#if report.blacklists && Object.keys(report.blacklists).length > 0} -
    -
    - {@render blacklistChecks(report.blacklists, report)} -
    -
    - {/if} - - {#if report.whitelists && Object.keys(report.whitelists).length > 0} -
    -
    - {@render whitelistChecks(report.whitelists)} -
    -
    - {/if} - {/if} - - - {#if report.header_analysis} - {/if} - - {#if report.spamassassin || report.rspamd} -
    - {#if report.spamassassin} -
    - -
    - {/if} - {#if report.rspamd} -
    - -
    - {/if} -
    - {/if} - - - {#if report.content_analysis} -
    -
    - -
    -
    - {/if} - - +
    - + Test Another Email @@ -446,51 +140,4 @@ transform: translateY(0); } } - - .menu-container { - position: relative; - } - - .menu-dropdown { - position: absolute; - top: 100%; - right: 0; - margin-top: 0.25rem; - background: white; - border: 1px solid #dee2e6; - border-radius: 0.375rem; - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); - min-width: 250px; - z-index: 1000; - padding: 0.5rem 0; - } - - .menu-item { - display: block; - width: 100%; - padding: 0.5rem 1rem; - background: none; - border: none; - text-align: left; - color: #212529; - text-decoration: none; - cursor: pointer; - transition: background-color 0.15s ease-in-out; - font-size: 1rem; - } - - .menu-item:hover:not(:disabled) { - background-color: #f8f9fa; - } - - .menu-item:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - .menu-divider { - margin: 0.5rem 0; - border: 0; - border-top: 1px solid #dee2e6; - } diff --git a/web/src/routes/test/[test]/+page.ts b/web/src/routes/test/[test]/+page.ts deleted file mode 100644 index ae88a27..0000000 --- a/web/src/routes/test/[test]/+page.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const prerender = false; -export const ssr = false; diff --git a/web/static/img/og.webp b/web/static/img/og.webp deleted file mode 100644 index 986dda5..0000000 Binary files a/web/static/img/og.webp and /dev/null differ diff --git a/web/static/img/report.webp b/web/static/img/report.webp deleted file mode 100644 index d3df7a9..0000000 Binary files a/web/static/img/report.webp and /dev/null differ