Compare commits
52 commits
f/unsubscr
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e540377bd9 | |||
| 16b7dcb057 | |||
| dfa38e8a26 | |||
| dee848d887 | |||
| b158336451 | |||
| a36824cf27 | |||
| 7d3009d7d0 | |||
| 5c104f3c99 | |||
| 3c192f17fd | |||
| 35fc997390 | |||
| 2fcee1b885 | |||
| 26025c96a2 | |||
| 76ee50a100 | |||
| 71e0832416 | |||
| c96a8b92b8 | |||
| b1c18a3894 | |||
| c8e28c31ee | |||
| 1d8ee637da | |||
| 968f42761f | |||
| 2b70115834 | |||
| d65840000a | |||
| 61503a1c1f | |||
| 26025644b0 | |||
| bd02b8f9ba | |||
| a3b539179e | |||
| 8b6154c183 | |||
| 56e6494a75 | |||
| 0176c3803d | |||
| 21e16fd847 | |||
| edfe498b27 | |||
| 27650a3496 | |||
| d9b9ea87c6 | |||
| bb47bb7c29 | |||
| da93d6d706 | |||
| 2a2bfe46a8 | |||
| 55e9bcd3d0 | |||
| 28424729a5 | |||
| 3cc39c9c54 | |||
| f9c5c815d1 | |||
| 4245f93ce4 | |||
| 9679b381c7 | |||
| 7b9c45fb68 | |||
| b619ebf8c3 | |||
| a146940a65 | |||
| e811d02b3b | |||
| 8fda7746a1 | |||
| 96e83ff70d | |||
| 6b983f0506 | |||
| c50e18a347 | |||
| 054cd8ae25 | |||
| c2917f8580 | |||
| b39a9dc625 |
67 changed files with 10326 additions and 1212 deletions
15
Dockerfile
15
Dockerfile
|
|
@ -121,6 +121,7 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
|
||||||
perl-xml-libxml \
|
perl-xml-libxml \
|
||||||
postfix \
|
postfix \
|
||||||
postfix-pcre \
|
postfix-pcre \
|
||||||
|
rspamd \
|
||||||
spamassassin \
|
spamassassin \
|
||||||
spamassassin-client \
|
spamassassin-client \
|
||||||
supervisor \
|
supervisor \
|
||||||
|
|
@ -143,8 +144,11 @@ RUN mkdir -p /etc/happydeliver \
|
||||||
/var/lib/authentication_milter \
|
/var/lib/authentication_milter \
|
||||||
/var/spool/postfix/authentication_milter \
|
/var/spool/postfix/authentication_milter \
|
||||||
/var/spool/postfix/spamassassin \
|
/var/spool/postfix/spamassassin \
|
||||||
|
/var/spool/postfix/rspamd \
|
||||||
&& chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \
|
&& chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \
|
||||||
&& chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin
|
&& chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin \
|
||||||
|
&& chown rspamd:mail /var/spool/postfix/rspamd \
|
||||||
|
&& chmod 750 /var/spool/postfix/rspamd
|
||||||
|
|
||||||
# Copy the built application
|
# Copy the built application
|
||||||
COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver
|
COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver
|
||||||
|
|
@ -154,6 +158,7 @@ RUN chmod +x /usr/local/bin/happyDeliver
|
||||||
COPY docker/postfix/ /etc/postfix/
|
COPY docker/postfix/ /etc/postfix/
|
||||||
COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json
|
COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json
|
||||||
COPY docker/spamassassin/ /etc/mail/spamassassin/
|
COPY docker/spamassassin/ /etc/mail/spamassassin/
|
||||||
|
COPY docker/rspamd/local.d/ /etc/rspamd/local.d/
|
||||||
COPY docker/supervisor/ /etc/supervisor/
|
COPY docker/supervisor/ /etc/supervisor/
|
||||||
COPY docker/entrypoint.sh /entrypoint.sh
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
|
@ -165,7 +170,13 @@ RUN chmod +x /entrypoint.sh
|
||||||
EXPOSE 25 8080
|
EXPOSE 25 8080
|
||||||
|
|
||||||
# Default configuration
|
# Default configuration
|
||||||
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net
|
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \
|
||||||
|
HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db \
|
||||||
|
HAPPYDELIVER_DOMAIN=happydeliver.local \
|
||||||
|
HAPPYDELIVER_ADDRESS_PREFIX=test- \
|
||||||
|
HAPPYDELIVER_DNS_TIMEOUT=5s \
|
||||||
|
HAPPYDELIVER_HTTP_TIMEOUT=10s \
|
||||||
|
HAPPYDELIVER_RSPAMD_API_URL=http://127.0.0.1:11334
|
||||||
|
|
||||||
# Volume for persistent data
|
# Volume for persistent data
|
||||||
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
|
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
|
||||||
|
|
|
||||||
26
README.md
26
README.md
|
|
@ -6,7 +6,7 @@ An open-source email deliverability testing platform that analyzes test emails a
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more
|
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin and rspamd scores, DNS records, blacklist status, content quality, and more
|
||||||
- **REST API**: Full-featured API for creating tests and retrieving reports
|
- **REST API**: Full-featured API for creating tests and retrieving reports
|
||||||
- **LMTP Server**: Built-in LMTP server for seamless MTA integration
|
- **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**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers
|
||||||
|
|
@ -26,6 +26,7 @@ The easiest way to run happyDeliver is using the all-in-one Docker container tha
|
||||||
- **Postfix MTA**: Receives emails on port 25
|
- **Postfix MTA**: Receives emails on port 25
|
||||||
- **authentication_milter**: Entreprise grade email authentication
|
- **authentication_milter**: Entreprise grade email authentication
|
||||||
- **SpamAssassin**: Spam scoring and analysis
|
- **SpamAssassin**: Spam scoring and analysis
|
||||||
|
- **rspamd**: Second spam filter for cross-validated scoring
|
||||||
- **happyDeliver API**: REST API server on port 8080
|
- **happyDeliver API**: REST API server on port 8080
|
||||||
- **SQLite Database**: Persistent storage for tests and reports
|
- **SQLite Database**: Persistent storage for tests and reports
|
||||||
|
|
||||||
|
|
@ -162,10 +163,27 @@ The server will start on `http://localhost:8080` by default.
|
||||||
|
|
||||||
#### 3. Integrate with your existing e-mail setup
|
#### 3. Integrate with your existing e-mail setup
|
||||||
|
|
||||||
It is expected your setup annotate the email with eg. opendkim, spamassassin, ...
|
It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ...
|
||||||
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
|
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
|
||||||
|
|
||||||
Choose one of the following way to integrate happyDeliver in your existing setup:
|
#### Receiver Hostname
|
||||||
|
|
||||||
|
happyDeliver filters `Authentication-Results` headers by hostname to only trust headers added by your MTA (and not headers that may have been injected by the sender). By default, it uses the system hostname (`os.Hostname()`).
|
||||||
|
|
||||||
|
If your MTA's `authserv-id` (the hostname at the beginning of `Authentication-Results` headers) differs from the machine running happyDeliver, you must set it explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./happyDeliver server -receiver-hostname mail.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via environment variable:
|
||||||
|
```bash
|
||||||
|
HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ./happyDeliver server
|
||||||
|
```
|
||||||
|
|
||||||
|
**How to find the correct value:** look at the `Authentication-Results` headers in a received email. They start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...` — in this case, use `mail.example.com`.
|
||||||
|
|
||||||
|
If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname.
|
||||||
|
|
||||||
#### Postfix LMTP Transport
|
#### Postfix LMTP Transport
|
||||||
|
|
||||||
|
|
@ -269,7 +287,7 @@ The deliverability score is calculated from A to F based on:
|
||||||
- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation
|
- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation
|
||||||
- **Blacklist**: RBL/DNSBL checks
|
- **Blacklist**: RBL/DNSBL checks
|
||||||
- **Headers**: Required headers, MIME structure, Domain alignment
|
- **Headers**: Required headers, MIME structure, Domain alignment
|
||||||
- **Spam**: SpamAssassin score
|
- **Spam**: SpamAssassin and rspamd scores (combined 50/50)
|
||||||
- **Content**: HTML quality, links, images, unsubscribe
|
- **Content**: HTML quality, links, images, unsubscribe
|
||||||
|
|
||||||
## Funding
|
## Funding
|
||||||
|
|
|
||||||
|
|
@ -333,6 +333,8 @@ components:
|
||||||
$ref: '#/components/schemas/AuthenticationResults'
|
$ref: '#/components/schemas/AuthenticationResults'
|
||||||
spamassassin:
|
spamassassin:
|
||||||
$ref: '#/components/schemas/SpamAssassinResult'
|
$ref: '#/components/schemas/SpamAssassinResult'
|
||||||
|
rspamd:
|
||||||
|
$ref: '#/components/schemas/RspamdResult'
|
||||||
dns_results:
|
dns_results:
|
||||||
$ref: '#/components/schemas/DNSResults'
|
$ref: '#/components/schemas/DNSResults'
|
||||||
blacklists:
|
blacklists:
|
||||||
|
|
@ -348,6 +350,19 @@ components:
|
||||||
listed: false
|
listed: false
|
||||||
- rbl: "bl.spamcop.net"
|
- rbl: "bl.spamcop.net"
|
||||||
listed: false
|
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:
|
content_analysis:
|
||||||
$ref: '#/components/schemas/ContentAnalysis'
|
$ref: '#/components/schemas/ContentAnalysis'
|
||||||
header_analysis:
|
header_analysis:
|
||||||
|
|
@ -401,7 +416,7 @@ components:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 0
|
minimum: 0
|
||||||
maximum: 100
|
maximum: 100
|
||||||
description: SpamAssassin score (in percentage)
|
description: Spam filter score (SpamAssassin + rspamd combined, in percentage)
|
||||||
example: 15
|
example: 15
|
||||||
spam_grade:
|
spam_grade:
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -774,7 +789,7 @@ components:
|
||||||
properties:
|
properties:
|
||||||
result:
|
result:
|
||||||
type: string
|
type: string
|
||||||
enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass]
|
enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped]
|
||||||
description: Authentication result
|
description: Authentication result
|
||||||
example: "pass"
|
example: "pass"
|
||||||
domain:
|
domain:
|
||||||
|
|
@ -843,6 +858,17 @@ components:
|
||||||
- is_spam
|
- is_spam
|
||||||
- test_details
|
- test_details
|
||||||
properties:
|
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:
|
version:
|
||||||
type: string
|
type: string
|
||||||
description: SpamAssassin version
|
description: SpamAssassin version
|
||||||
|
|
@ -900,11 +926,71 @@ components:
|
||||||
format: float
|
format: float
|
||||||
description: Score contribution of this test
|
description: Score contribution of this test
|
||||||
example: -1.9
|
example: -1.9
|
||||||
|
params:
|
||||||
|
type: string
|
||||||
|
description: Symbol parameters or options
|
||||||
|
example: "0.02"
|
||||||
description:
|
description:
|
||||||
type: string
|
type: string
|
||||||
description: Human-readable description of what this test checks
|
description: Human-readable description of what this test checks
|
||||||
example: "Bayes spam probability is 0 to 1%"
|
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:
|
DNSResults:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
|
|
@ -1245,7 +1331,7 @@ components:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- ip
|
- ip
|
||||||
- checks
|
- blacklists
|
||||||
- listed_count
|
- listed_count
|
||||||
- score
|
- score
|
||||||
- grade
|
- grade
|
||||||
|
|
@ -1254,7 +1340,7 @@ components:
|
||||||
type: string
|
type: string
|
||||||
description: The IP address that was checked
|
description: The IP address that was checked
|
||||||
example: "192.0.2.1"
|
example: "192.0.2.1"
|
||||||
checks:
|
blacklists:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/BlacklistCheck'
|
$ref: '#/components/schemas/BlacklistCheck'
|
||||||
|
|
@ -1274,3 +1360,8 @@ components:
|
||||||
enum: [A+, A, B, C, D, E, F]
|
enum: [A+, A, B, C, D, E, F]
|
||||||
description: Letter grade representation of the score
|
description: Letter grade representation of the score
|
||||||
example: "A+"
|
example: "A+"
|
||||||
|
whitelists:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/BlacklistCheck'
|
||||||
|
description: List of DNS whitelist check results (informational only)
|
||||||
|
|
|
||||||
|
|
@ -110,14 +110,38 @@ Default configuration for the Docker environment:
|
||||||
The container accepts these environment variables:
|
The container accepts these environment variables:
|
||||||
|
|
||||||
- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local)
|
- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local)
|
||||||
|
- `HAPPYDELIVER_RECEIVER_HOSTNAME`: Hostname used to filter `Authentication-Results` headers (see below)
|
||||||
|
- `POSTFIX_CERT_FILE` / `POSTFIX_KEY_FILE`: TLS certificate and key paths for Postfix SMTP
|
||||||
|
|
||||||
Note that the hostname of the container is used to filter the authentication tests results.
|
### Receiver Hostname
|
||||||
|
|
||||||
Example:
|
happyDeliver filters `Authentication-Results` headers by hostname to only trust results from the expected MTA. By default, it uses the system hostname (i.e., the container's `--hostname`).
|
||||||
|
|
||||||
|
In the all-in-one Docker container, the container hostname is also used as the `authserv-id` in the embedded Postfix and authentication_milter, so everything matches automatically.
|
||||||
|
|
||||||
|
**When bypassing the embedded Postfix** (e.g., routing emails from your own MTA via LMTP), your MTA's `authserv-id` will likely differ from the container hostname. In that case, set `HAPPYDELIVER_RECEIVER_HOSTNAME` to your MTA's hostname:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-e HAPPYDELIVER_DOMAIN=example.com \
|
||||||
|
-e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com \
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
To find the correct value, look at the `Authentication-Results` headers in a received email — they start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...`.
|
||||||
|
|
||||||
|
If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname.
|
||||||
|
|
||||||
|
Example (all-in-one, no override needed):
|
||||||
```bash
|
```bash
|
||||||
docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ...
|
docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Example (external MTA integration):
|
||||||
|
```bash
|
||||||
|
docker run -e HAPPYDELIVER_DOMAIN=example.com -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ...
|
||||||
|
```
|
||||||
|
|
||||||
## Volumes
|
## Volumes
|
||||||
|
|
||||||
**Required volumes:**
|
**Required volumes:**
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ mkdir -p /var/spool/postfix/authentication_milter
|
||||||
chown mail:mail /var/spool/postfix/authentication_milter
|
chown mail:mail /var/spool/postfix/authentication_milter
|
||||||
chmod 750 /var/spool/postfix/authentication_milter
|
chmod 750 /var/spool/postfix/authentication_milter
|
||||||
|
|
||||||
|
mkdir -p /var/spool/postfix/rspamd
|
||||||
|
chown rspamd:mail /var/spool/postfix/rspamd
|
||||||
|
chmod 750 /var/spool/postfix/rspamd
|
||||||
|
|
||||||
# Create log directory
|
# 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 /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter
|
||||||
chown happydeliver:happydeliver /var/log/happydeliver
|
chown happydeliver:happydeliver /var/log/happydeliver
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ transport_maps = pcre:/etc/postfix/transport_maps
|
||||||
# OpenDKIM for DKIM verification
|
# OpenDKIM for DKIM verification
|
||||||
milter_default_action = accept
|
milter_default_action = accept
|
||||||
milter_protocol = 6
|
milter_protocol = 6
|
||||||
smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock
|
smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock unix:/var/spool/postfix/rspamd/rspamd-milter.sock
|
||||||
non_smtpd_milters = $smtpd_milters
|
non_smtpd_milters = $smtpd_milters
|
||||||
|
|
||||||
# SPF policy checking
|
# SPF policy checking
|
||||||
|
|
|
||||||
5
docker/rspamd/local.d/actions.conf
Normal file
5
docker/rspamd/local.d/actions.conf
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
no_action = 0;
|
||||||
|
reject = null;
|
||||||
|
add_header = null;
|
||||||
|
rewrite_subject = null;
|
||||||
|
greylist = null;
|
||||||
5
docker/rspamd/local.d/milter_headers.conf
Normal file
5
docker/rspamd/local.d/milter_headers.conf
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Add "extended Rspamd headers"
|
||||||
|
extended_spam_headers = true;
|
||||||
|
|
||||||
|
skip_local = false;
|
||||||
|
skip_authenticated = false;
|
||||||
3
docker/rspamd/local.d/options.inc
Normal file
3
docker/rspamd/local.d/options.inc
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# rspamd options for happyDeliver
|
||||||
|
# Disable Bayes learning to keep the setup stateless
|
||||||
|
use_redis = false;
|
||||||
6
docker/rspamd/local.d/worker-proxy.inc
Normal file
6
docker/rspamd/local.d/worker-proxy.inc
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Enable rspamd milter proxy worker via Unix socket for Postfix integration
|
||||||
|
bind_socket = "/var/spool/postfix/rspamd/rspamd-milter.sock mode=0660 owner=rspamd group=mail";
|
||||||
|
upstream "local" {
|
||||||
|
default = yes;
|
||||||
|
self_scan = yes;
|
||||||
|
}
|
||||||
|
|
@ -48,3 +48,14 @@ rbl_timeout 5
|
||||||
# Don't use user-specific rules
|
# Don't use user-specific rules
|
||||||
user_scores_dsn_timeout 3
|
user_scores_dsn_timeout 3
|
||||||
user_scores_sql_override 0
|
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
|
||||||
|
|
@ -33,6 +33,16 @@ stderr_logfile=/var/log/happydeliver/authentication_milter.log
|
||||||
user=mail
|
user=mail
|
||||||
group=mail
|
group=mail
|
||||||
|
|
||||||
|
# rspamd spam filter
|
||||||
|
[program:rspamd]
|
||||||
|
command=/usr/bin/rspamd -f -u rspamd -g mail
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=11
|
||||||
|
stdout_logfile=/var/log/happydeliver/rspamd.log
|
||||||
|
stderr_logfile=/var/log/happydeliver/rspamd_error.log
|
||||||
|
user=root
|
||||||
|
|
||||||
# SpamAssassin daemon
|
# SpamAssassin daemon
|
||||||
[program:spamd]
|
[program:spamd]
|
||||||
command=/usr/sbin/spamd --max-children 5 --helper-home-dir /var/lib/spamassassin --syslog stderr --pidfile /var/run/spamd.pid
|
command=/usr/sbin/spamd --max-children 5 --helper-home-dir /var/lib/spamassassin --syslog stderr --pidfile /var/run/spamd.pid
|
||||||
|
|
|
||||||
22
go.mod
22
go.mod
|
|
@ -1,15 +1,15 @@
|
||||||
module git.happydns.org/happyDeliver
|
module git.happydns.org/happyDeliver
|
||||||
|
|
||||||
go 1.24.6
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/JGLTechnologies/gin-rate-limit v1.5.6
|
github.com/JGLTechnologies/gin-rate-limit v1.5.6
|
||||||
github.com/emersion/go-smtp v0.24.0
|
github.com/emersion/go-smtp v0.24.0
|
||||||
github.com/getkin/kin-openapi v0.133.0
|
github.com/getkin/kin-openapi v0.133.0
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/oapi-codegen/runtime v1.1.2
|
github.com/oapi-codegen/runtime v1.3.0
|
||||||
golang.org/x/net v0.49.0
|
golang.org/x/net v0.52.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
|
|
@ -64,14 +64,14 @@ require (
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||||
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
||||||
go.uber.org/mock v0.6.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
golang.org/x/arch v0.23.0 // indirect
|
golang.org/x/arch v0.23.0 // indirect
|
||||||
golang.org/x/crypto v0.47.0 // indirect
|
golang.org/x/crypto v0.49.0 // indirect
|
||||||
golang.org/x/mod v0.31.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
golang.org/x/tools v0.40.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|
|
||||||
65
go.sum
65
go.sum
|
|
@ -10,12 +10,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
github.com/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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
|
||||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
|
||||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
|
@ -40,22 +36,16 @@ github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4Mc
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.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 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
github.com/go-openapi/jsonpointer v0.22.2 h1:JDQEe4B9j6K3tQ7HQQTZfjR59IURhjjLxet2FB4KHyg=
|
|
||||||
github.com/go-openapi/jsonpointer v0.22.2/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
|
||||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
|
||||||
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
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 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||||
|
|
@ -66,8 +56,6 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
|
||||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
|
||||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
github.com/go-playground/validator/v10 v10.30.1 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.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||||
|
|
@ -75,8 +63,6 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
github.com/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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
|
@ -104,8 +90,6 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
|
||||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
|
||||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
|
@ -134,8 +118,6 @@ github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8
|
||||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
github.com/mattn/go-sqlite3 v1.14.33 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.33/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-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
|
@ -150,8 +132,8 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
|
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
|
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
|
||||||
github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI=
|
github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA=
|
||||||
github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 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/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 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||||
|
|
@ -176,12 +158,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
|
|
||||||
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
|
|
||||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
|
|
||||||
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
|
||||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
|
|
@ -216,6 +194,8 @@ github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN
|
||||||
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
|
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
|
||||||
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
|
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/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||||
|
|
@ -223,11 +203,11 @@ golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
|
@ -235,13 +215,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-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-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.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-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-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.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|
@ -257,24 +237,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-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-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.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|
@ -287,8 +264,6 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.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-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.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ import (
|
||||||
type EmailAnalyzer interface {
|
type EmailAnalyzer interface {
|
||||||
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
|
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
|
||||||
AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string)
|
AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string)
|
||||||
CheckBlacklistIP(ip string) (checks []BlacklistCheck, listedCount int, score int, grade string, err error)
|
CheckBlacklistIP(ip string) (checks []BlacklistCheck, whitelists []BlacklistCheck, listedCount int, score int, grade string, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIHandler implements the ServerInterface for handling API requests
|
// APIHandler implements the ServerInterface for handling API requests
|
||||||
|
|
@ -359,7 +359,7 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform blacklist check using analyzer
|
// Perform blacklist check using analyzer
|
||||||
checks, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
|
checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, Error{
|
c.JSON(http.StatusBadRequest, Error{
|
||||||
Error: "invalid_ip",
|
Error: "invalid_ip",
|
||||||
|
|
@ -372,7 +372,8 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
||||||
// Build response
|
// Build response
|
||||||
response := BlacklistCheckResponse{
|
response := BlacklistCheckResponse{
|
||||||
Ip: request.Ip,
|
Ip: request.Ip,
|
||||||
Checks: checks,
|
Blacklists: checks,
|
||||||
|
Whitelists: &whitelists,
|
||||||
ListedCount: listedCount,
|
ListedCount: listedCount,
|
||||||
Score: score,
|
Score: score,
|
||||||
Grade: BlacklistCheckResponseGrade(grade),
|
Grade: BlacklistCheckResponseGrade(grade),
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,12 @@ func declareFlags(o *Config) {
|
||||||
flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails")
|
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.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.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.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.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.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.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.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.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.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ import (
|
||||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func getHostname() string {
|
||||||
|
h, _ := os.Hostname()
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
// Config represents the application configuration
|
// Config represents the application configuration
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DevProxy string
|
DevProxy string
|
||||||
|
|
@ -58,6 +63,7 @@ type EmailConfig struct {
|
||||||
Domain string
|
Domain string
|
||||||
TestAddressPrefix string
|
TestAddressPrefix string
|
||||||
LMTPAddr string
|
LMTPAddr string
|
||||||
|
ReceiverHostname string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalysisConfig contains timeout and behavior settings for email analysis
|
// AnalysisConfig contains timeout and behavior settings for email analysis
|
||||||
|
|
@ -65,7 +71,9 @@ type AnalysisConfig struct {
|
||||||
DNSTimeout time.Duration
|
DNSTimeout time.Duration
|
||||||
HTTPTimeout time.Duration
|
HTTPTimeout time.Duration
|
||||||
RBLs []string
|
RBLs []string
|
||||||
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
DNSWLs []string
|
||||||
|
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
||||||
|
RspamdAPIURL string // rspamd API URL for fetching symbol descriptions (empty = use embedded list)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig returns a configuration with sensible defaults
|
// DefaultConfig returns a configuration with sensible defaults
|
||||||
|
|
@ -83,11 +91,13 @@ func DefaultConfig() *Config {
|
||||||
Domain: "happydeliver.local",
|
Domain: "happydeliver.local",
|
||||||
TestAddressPrefix: "test-",
|
TestAddressPrefix: "test-",
|
||||||
LMTPAddr: "127.0.0.1:2525",
|
LMTPAddr: "127.0.0.1:2525",
|
||||||
|
ReceiverHostname: getHostname(),
|
||||||
},
|
},
|
||||||
Analysis: AnalysisConfig{
|
Analysis: AnalysisConfig{
|
||||||
DNSTimeout: 5 * time.Second,
|
DNSTimeout: 5 * time.Second,
|
||||||
HTTPTimeout: 10 * time.Second,
|
HTTPTimeout: 10 * time.Second,
|
||||||
RBLs: []string{},
|
RBLs: []string{},
|
||||||
|
DNSWLs: []string{},
|
||||||
CheckAllIPs: false, // By default, only check the first IP
|
CheckAllIPs: false, // By default, only check the first IP
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,17 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
|
||||||
|
|
||||||
log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score)
|
log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score)
|
||||||
|
|
||||||
|
// Warn if the last Received hop doesn't match the expected receiver hostname
|
||||||
|
if r.config.Email.ReceiverHostname != "" &&
|
||||||
|
result.Report.HeaderAnalysis != nil &&
|
||||||
|
result.Report.HeaderAnalysis.ReceivedChain != nil &&
|
||||||
|
len(*result.Report.HeaderAnalysis.ReceivedChain) > 0 {
|
||||||
|
lastHop := (*result.Report.HeaderAnalysis.ReceivedChain)[0]
|
||||||
|
if lastHop.By != nil && *lastHop.By != r.config.Email.ReceiverHostname {
|
||||||
|
log.Printf("WARNING: Last Received hop 'by' field (%s) does not match expected receiver hostname (%s): check your RECEIVER_HOSTNAME config as authentication results will be false", *lastHop.By, r.config.Email.ReceiverHostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Marshal report to JSON
|
// Marshal report to JSON
|
||||||
reportJSON, err := json.Marshal(result.Report)
|
reportJSON, err := json.Marshal(result.Report)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,13 @@ type EmailAnalyzer struct {
|
||||||
// NewEmailAnalyzer creates a new email analyzer with the given configuration
|
// NewEmailAnalyzer creates a new email analyzer with the given configuration
|
||||||
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
|
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
|
||||||
generator := NewReportGenerator(
|
generator := NewReportGenerator(
|
||||||
|
cfg.Email.ReceiverHostname,
|
||||||
cfg.Analysis.DNSTimeout,
|
cfg.Analysis.DNSTimeout,
|
||||||
cfg.Analysis.HTTPTimeout,
|
cfg.Analysis.HTTPTimeout,
|
||||||
cfg.Analysis.RBLs,
|
cfg.Analysis.RBLs,
|
||||||
|
cfg.Analysis.DNSWLs,
|
||||||
cfg.Analysis.CheckAllIPs,
|
cfg.Analysis.CheckAllIPs,
|
||||||
|
cfg.Analysis.RspamdAPIURL,
|
||||||
)
|
)
|
||||||
|
|
||||||
return &EmailAnalyzer{
|
return &EmailAnalyzer{
|
||||||
|
|
@ -120,22 +123,28 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string)
|
||||||
return dnsResults, score, grade
|
return dnsResults, score, grade
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckBlacklistIP checks a single IP address against DNS blacklists
|
// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists
|
||||||
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, int, int, string, error) {
|
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.BlacklistCheck, int, int, string, error) {
|
||||||
// Check the IP against all configured RBLs
|
// Check the IP against all configured RBLs
|
||||||
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
|
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, 0, "", err
|
return nil, nil, 0, 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate score using the existing function
|
// Calculate score using the existing function
|
||||||
// Create a minimal RBLResults structure for scoring
|
// Create a minimal RBLResults structure for scoring
|
||||||
results := &RBLResults{
|
results := &DNSListResults{
|
||||||
Checks: map[string][]api.BlacklistCheck{ip: checks},
|
Checks: map[string][]api.BlacklistCheck{ip: checks},
|
||||||
IPsChecked: []string{ip},
|
IPsChecked: []string{ip},
|
||||||
ListedCount: listedCount,
|
ListedCount: listedCount,
|
||||||
}
|
}
|
||||||
score, grade := a.analyzer.generator.rblChecker.CalculateRBLScore(results)
|
score, grade := a.analyzer.generator.rblChecker.CalculateScore(results, false)
|
||||||
|
|
||||||
return checks, listedCount, score, grade, nil
|
// Check the IP against all configured DNSWLs (informational only)
|
||||||
|
whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip)
|
||||||
|
if err != nil {
|
||||||
|
whitelists = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return checks, whitelists, listedCount, score, grade, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthenticationAnalyzer analyzes email authentication results
|
// AuthenticationAnalyzer analyzes email authentication results
|
||||||
type AuthenticationAnalyzer struct{}
|
type AuthenticationAnalyzer struct {
|
||||||
|
receiverHostname string
|
||||||
|
}
|
||||||
|
|
||||||
// NewAuthenticationAnalyzer creates a new authentication analyzer
|
// NewAuthenticationAnalyzer creates a new authentication analyzer
|
||||||
func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
|
func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer {
|
||||||
return &AuthenticationAnalyzer{}
|
return &AuthenticationAnalyzer{receiverHostname: receiverHostname}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
|
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
|
||||||
|
|
@ -40,7 +42,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
|
||||||
results := &api.AuthenticationResults{}
|
results := &api.AuthenticationResults{}
|
||||||
|
|
||||||
// Parse Authentication-Results headers
|
// Parse Authentication-Results headers
|
||||||
authHeaders := email.GetAuthenticationResults()
|
authHeaders := email.GetAuthenticationResults(a.receiverHostname)
|
||||||
for _, header := range authHeaders {
|
for _, header := range authHeaders {
|
||||||
a.parseAuthenticationResultsHeader(header, results)
|
a.parseAuthenticationResultsHeader(header, results)
|
||||||
}
|
}
|
||||||
|
|
@ -150,27 +152,32 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe
|
||||||
|
|
||||||
score := 0
|
score := 0
|
||||||
|
|
||||||
// IPRev (15 points)
|
// Core authentication (90 points total)
|
||||||
score += 15 * a.calculateIPRevScore(results) / 100
|
// SPF (30 points)
|
||||||
|
score += 30 * a.calculateSPFScore(results) / 100
|
||||||
|
|
||||||
// SPF (25 points)
|
// DKIM (30 points)
|
||||||
score += 25 * a.calculateSPFScore(results) / 100
|
score += 30 * a.calculateDKIMScore(results) / 100
|
||||||
|
|
||||||
// DKIM (23 points)
|
// DMARC (30 points)
|
||||||
score += 23 * a.calculateDKIMScore(results) / 100
|
score += 30 * a.calculateDMARCScore(results) / 100
|
||||||
|
|
||||||
// X-Google-DKIM (optional) - penalty if failed
|
|
||||||
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
|
|
||||||
|
|
||||||
// X-Aligned-From
|
|
||||||
score += 2 * a.calculateXAlignedFromScore(results) / 100
|
|
||||||
|
|
||||||
// DMARC (25 points)
|
|
||||||
score += 25 * a.calculateDMARCScore(results) / 100
|
|
||||||
|
|
||||||
// BIMI (10 points)
|
// BIMI (10 points)
|
||||||
score += 10 * a.calculateBIMIScore(results) / 100
|
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
|
// Ensure score doesn't exceed 100
|
||||||
if score > 100 {
|
if score > 100 {
|
||||||
score = 100
|
score = 100
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ func TestParseARCResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -136,7 +136,7 @@ func TestValidateARCChain(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ func TestParseBIMIResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ func TestParseDKIMResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ func TestParseDMARCResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -69,5 +69,5 @@ func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.Authentication
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 100
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ func TestParseIPRevResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -181,7 +181,7 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,16 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe
|
||||||
return nil
|
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 := &api.AuthResult{}
|
result := &api.AuthResult{}
|
||||||
|
|
||||||
// Extract result (first word)
|
// Extract result (first word)
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ func TestParseSPFResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -161,7 +161,7 @@ func TestParseLegacySPF(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ func TestGetAuthenticationScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
scorer := NewAuthenticationAnalyzer()
|
scorer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -247,7 +247,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -353,7 +353,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
|
|
||||||
func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
||||||
// This test verifies that only the first occurrence of each auth method is parsed
|
// This test verifies that only the first occurrence of each auth method is parsed
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) {
|
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"
|
header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com"
|
||||||
|
|
|
||||||
|
|
@ -61,5 +61,5 @@ func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.Authent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 100
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ func TestParseXAlignedFromResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -126,7 +126,7 @@ func TestCalculateXAlignedFromScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ func TestParseXGoogleDKIMResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
@ -37,8 +38,10 @@ import (
|
||||||
|
|
||||||
// ContentAnalyzer analyzes email content (HTML, links, images)
|
// ContentAnalyzer analyzes email content (HTML, links, images)
|
||||||
type ContentAnalyzer struct {
|
type ContentAnalyzer struct {
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
httpClient *http.Client
|
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
|
// NewContentAnalyzer creates a new content analyzer with configurable timeout
|
||||||
|
|
@ -110,6 +113,13 @@ func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults {
|
||||||
|
|
||||||
results.IsMultipart = len(email.Parts) > 1
|
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
|
// Get HTML and text parts
|
||||||
htmlParts := email.GetHTMLParts()
|
htmlParts := email.GetHTMLParts()
|
||||||
textParts := email.GetTextParts()
|
textParts := email.GetTextParts()
|
||||||
|
|
@ -331,9 +341,14 @@ func (c *ContentAnalyzer) getAttr(n *html.Node, key string) string {
|
||||||
|
|
||||||
// isUnsubscribeLink checks if a link is an unsubscribe link
|
// isUnsubscribeLink checks if a link is an unsubscribe link
|
||||||
func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool {
|
func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool {
|
||||||
|
// First check: does the href match a URL from the List-Unsubscribe header?
|
||||||
|
if slices.Contains(c.listUnsubscribeURLs, href) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// Check href for unsubscribe keywords
|
// Check href for unsubscribe keywords
|
||||||
lowerHref := strings.ToLower(href)
|
lowerHref := strings.ToLower(href)
|
||||||
unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe"}
|
unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe", "отписване", "desubscripció", "zrušit odběr", "dad-danysgrifio", "afmeld", "abmelden", "διαγραφή", "darse de baja", "poistu postituslistalta", "se désabonner", "ביטול רישום", "leiratkozás", "cancella iscrizione", "登録を取り消す", "구독 해지", "വരിക്കാരനല്ലാതാകുക", "uitschrijven", "meld av", "odsubskrybuj", "cancelar assinatura", "cancelar subscrição", "dezabonare", "отписаться", "avsluta prenumeration", "zrušiť odber", "odjava", "üyeliği sonlandır", "відписатися", "hủy đăng ký", "退订", "退訂"}
|
||||||
for _, keyword := range unsubKeywords {
|
for _, keyword := range unsubKeywords {
|
||||||
if strings.Contains(lowerHref, keyword) {
|
if strings.Contains(lowerHref, keyword) {
|
||||||
return true
|
return true
|
||||||
|
|
@ -439,7 +454,8 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
|
||||||
// Extract the actual destination domain/email based on scheme
|
// Extract the actual destination domain/email based on scheme
|
||||||
var actualDomain string
|
var actualDomain string
|
||||||
|
|
||||||
if parsedURL.Scheme == "mailto" {
|
switch parsedURL.Scheme {
|
||||||
|
case "mailto":
|
||||||
// Extract email address from mailto: URL
|
// Extract email address from mailto: URL
|
||||||
// Format can be: mailto:user@domain.com or mailto:user@domain.com?subject=...
|
// Format can be: mailto:user@domain.com or mailto:user@domain.com?subject=...
|
||||||
mailtoAddr := parsedURL.Opaque
|
mailtoAddr := parsedURL.Opaque
|
||||||
|
|
@ -457,7 +473,8 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
|
||||||
} else {
|
} else {
|
||||||
return false // Invalid mailto
|
return false // Invalid mailto
|
||||||
}
|
}
|
||||||
} else if parsedURL.Scheme == "http" || parsedURL.Scheme == "https" {
|
case "http":
|
||||||
|
case "https":
|
||||||
// Check if URL has a host
|
// Check if URL has a host
|
||||||
if parsedURL.Host == "" {
|
if parsedURL.Host == "" {
|
||||||
return false
|
return false
|
||||||
|
|
@ -469,7 +486,7 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
|
||||||
actualDomain = actualDomain[:idx]
|
actualDomain = actualDomain[:idx]
|
||||||
}
|
}
|
||||||
actualDomain = strings.ToLower(actualDomain)
|
actualDomain = strings.ToLower(actualDomain)
|
||||||
} else {
|
default:
|
||||||
// Skip checks for other URL schemes (tel, etc.)
|
// Skip checks for other URL schemes (tel, etc.)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -492,10 +509,8 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
|
||||||
"email us", "contact us", "send email", "get in touch", "reach out",
|
"email us", "contact us", "send email", "get in touch", "reach out",
|
||||||
"contact", "email", "write to us",
|
"contact", "email", "write to us",
|
||||||
}
|
}
|
||||||
for _, generic := range genericTexts {
|
if slices.Contains(genericTexts, linkText) {
|
||||||
if linkText == generic {
|
return false
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain-like patterns from link text using regex
|
// Extract domain-like patterns from link text using regex
|
||||||
|
|
@ -562,10 +577,8 @@ func (c *ContentAnalyzer) isSuspiciousURL(urlStr string, parsedURL *url.URL) boo
|
||||||
"bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co",
|
"bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co",
|
||||||
"buff.ly", "is.gd", "bl.ink", "short.io",
|
"buff.ly", "is.gd", "bl.ink", "short.io",
|
||||||
}
|
}
|
||||||
for _, shortener := range shorteners {
|
if slices.Contains(shorteners, strings.ToLower(parsedURL.Host)) {
|
||||||
if strings.ToLower(parsedURL.Host) == shortener {
|
return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for excessive subdomains (possible obfuscation)
|
// Check for excessive subdomains (possible obfuscation)
|
||||||
|
|
@ -724,6 +737,7 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
|
||||||
HasHtml: api.PtrTo(results.HTMLContent != ""),
|
HasHtml: api.PtrTo(results.HTMLContent != ""),
|
||||||
HasPlaintext: api.PtrTo(results.TextContent != ""),
|
HasPlaintext: api.PtrTo(results.TextContent != ""),
|
||||||
HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe),
|
HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe),
|
||||||
|
UnsubscribeMethods: &[]api.ContentAnalysisUnsubscribeMethods{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate text-to-image ratio (inverse of image-to-text)
|
// Calculate text-to-image ratio (inverse of image-to-text)
|
||||||
|
|
@ -870,8 +884,19 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
|
||||||
|
|
||||||
// Unsubscribe methods
|
// Unsubscribe methods
|
||||||
if results.HasUnsubscribe {
|
if results.HasUnsubscribe {
|
||||||
methods := []api.ContentAnalysisUnsubscribeMethods{api.Link}
|
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Link)
|
||||||
analysis.UnsubscribeMethods = &methods
|
}
|
||||||
|
|
||||||
|
for _, url := range c.listUnsubscribeURLs {
|
||||||
|
if strings.HasPrefix(url, "mailto:") {
|
||||||
|
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Mailto)
|
||||||
|
} else if strings.HasPrefix(url, "http:") || strings.HasPrefix(url, "https:") {
|
||||||
|
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.ListUnsubscribeHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(*analysis.UnsubscribeMethods, api.ListUnsubscribeHeader) && c.hasOneClickUnsubscribe {
|
||||||
|
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.OneClick)
|
||||||
}
|
}
|
||||||
|
|
||||||
return analysis
|
return analysis
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,74 @@ func TestIsUnsubscribeLink(t *testing.T) {
|
||||||
linkText: "Read more",
|
linkText: "Read more",
|
||||||
expected: false,
|
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)
|
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DN
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeDNS performs DNS validation for the email's domain
|
// AnalyzeDNS performs DNS validation for the email's domain
|
||||||
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.DNSResults {
|
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *api.HeaderAnalysis) *api.DNSResults {
|
||||||
// Extract domain from From address
|
// Extract domain from From address
|
||||||
if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
|
if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
|
||||||
return &api.DNSResults{
|
return &api.DNSResults{
|
||||||
|
|
@ -104,19 +104,14 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
|
||||||
// SPF validates the MAIL FROM command, which corresponds to Return-Path
|
// SPF validates the MAIL FROM command, which corresponds to Return-Path
|
||||||
results.SpfRecords = d.checkSPFRecords(spfDomain)
|
results.SpfRecords = d.checkSPFRecords(spfDomain)
|
||||||
|
|
||||||
// Check DKIM records (from authentication results)
|
// Check DKIM records by parsing DKIM-Signature headers directly
|
||||||
// DKIM can be for any domain, but typically the From domain
|
for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) {
|
||||||
if authResults != nil && authResults.Dkim != nil {
|
dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector)
|
||||||
for _, dkim := range *authResults.Dkim {
|
if dkimRecord != nil {
|
||||||
if dkim.Domain != nil && dkim.Selector != nil {
|
if results.DkimRecords == nil {
|
||||||
dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector)
|
results.DkimRecords = new([]api.DKIMRecord)
|
||||||
if dkimRecord != nil {
|
|
||||||
if results.DkimRecords == nil {
|
|
||||||
results.DkimRecords = new([]api.DKIMRecord)
|
|
||||||
}
|
|
||||||
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,38 @@ import (
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header.
|
||||||
|
type DKIMHeader struct {
|
||||||
|
Domain string
|
||||||
|
Selector string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDKIMSignatures extracts domain and selector from DKIM-Signature header values.
|
||||||
|
func parseDKIMSignatures(signatures []string) []DKIMHeader {
|
||||||
|
var results []DKIMHeader
|
||||||
|
for _, sig := range signatures {
|
||||||
|
var domain, selector string
|
||||||
|
for _, part := range strings.Split(sig, ";") {
|
||||||
|
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||||
|
if len(kv) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(kv[0])
|
||||||
|
val := strings.TrimSpace(kv[1])
|
||||||
|
switch key {
|
||||||
|
case "d":
|
||||||
|
domain = val
|
||||||
|
case "s":
|
||||||
|
selector = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if domain != "" && selector != "" {
|
||||||
|
results = append(results, DKIMHeader{Domain: domain, Selector: selector})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector
|
// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector
|
||||||
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord {
|
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord {
|
||||||
// DKIM records are at: selector._domainkey.domain
|
// DKIM records are at: selector._domainkey.domain
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,220 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestParseDKIMSignatures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
signatures []string
|
||||||
|
expected []DKIMHeader
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Empty input",
|
||||||
|
signatures: nil,
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty string",
|
||||||
|
signatures: []string{""},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Simple Gmail-style",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=from:to:subject:date:message-id; bh=abcdef1234567890=; b=SIGNATURE_DATA_HERE==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Microsoft 365 style",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=contoso.com; s=selector1; h=From:Date:Subject:Message-ID; bh=UErATeHehIIPIXPeUA==; b=SIGNATURE_DATA==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tab-folded multiline (Postfix-style)",
|
||||||
|
signatures: []string{
|
||||||
|
"v=1; a=rsa-sha256; c=relaxed/simple; d=nemunai.re; s=thot;\r\n\tt=1760866834; bh=YNB7c8Qgm8YGn9X1FAXTcdpO7t4YSZFiMrmpCfD/3zw=;\r\n\th=From:To:Subject;\r\n\tb=T4TFaypMpsHGYCl3PGLwmzOYRF11rYjC7lF8V5VFU+ldvG8WBpFn==",
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Space-folded multiline (RFC-style)",
|
||||||
|
signatures: []string{
|
||||||
|
"v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8Gwps==",
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "d= and s= on separate continuation lines",
|
||||||
|
signatures: []string{
|
||||||
|
"v=1; a=rsa-sha256;\r\n\tc=relaxed/relaxed;\r\n\td=mycompany.com;\r\n\ts=selector1;\r\n\tbh=hash=;\r\n\tb=sig==",
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No space after semicolons",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.net;s=mail;h=from:to:subject;bh=abc=;b=xyz==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "example.net", Selector: "mail"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiple spaces after semicolons",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Ed25519 signature (RFC 8463)",
|
||||||
|
signatures: []string{
|
||||||
|
"v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQ==",
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiple signatures (ESP double-signing)",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=mydomain.com; s=mail; h=from:to:subject; bh=hash1=; b=sig1==`,
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{
|
||||||
|
{Domain: "mydomain.com", Selector: "mail"},
|
||||||
|
{Domain: "sendib.com", Selector: "mail"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Dual-algorithm signing (Ed25519 + RSA, same domain, different selectors)",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; s=brisbane; h=from:to:subject; bh=hash=; b=edSig==`,
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{
|
||||||
|
{Domain: "football.example.com", Selector: "brisbane"},
|
||||||
|
{Domain: "football.example.com", Selector: "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Amazon SES long selectors",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/simple; d=amazonses.com; s=224i4yxa5dv7c2xz3womw6peuabd; h=from:to:subject; bh=sesHash=; b=sesSig==`,
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{
|
||||||
|
{Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd"},
|
||||||
|
{Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Subdomain in d=",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=mail.example.co.uk; s=dkim2025; h=from:to:subject; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Deeply nested subdomain",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=bounce.transactional.mail.example.com; s=s2048; h=from:to:subject; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Selector with hyphens (Microsoft 365 custom domain style)",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1-contoso-com; h=from:to:subject; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Selector with dots",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=smtp.mail; h=from:to:subject; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Single-character selector",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=tiny.io; s=x; h=from:to:subject; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Postmark-style timestamp selector, s= before d=",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha1; c=relaxed/relaxed; s=20130519032151pm; d=postmarkapp.com; h=From:Date:Subject; bh=vYFvy46eesUDGJ45hyBTH30JfN4=; b=iHeFQ+7rCiSQs3DPjR2eUSZSv4i==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "d= and s= at the very end",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to:subject; bh=hash=; b=sig==; d=example.net; s=trailing`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Full tag set",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; d=example.com; s=selector1; c=relaxed/simple; q=dns/txt; i=user@example.com; t=1255993973; x=1256598773; h=From:Sender:Reply-To:Subject:Date:Message-Id:To:Cc; bh=+7qxGePcmmrtZAIVQAtkSSGHfQ/ftNuvUTWJ3vXC9Zc=; b=dB85+qM+If1KGQmqMLNpqLgNtUaG5dhGjYjQD6/QXtXmViJx8tf9gLEjcHr+musLCAvr0Fsn1DA3ZLLlUxpf4AR==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing d= tag",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; s=selector1; h=from:to; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing s= tag",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; h=from:to; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing both d= and s= tags",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mix of valid and invalid signatures",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=good.com; s=sel1; h=from:to; bh=hash=; b=sig==`,
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; s=orphan; h=from:to; bh=hash=; b=sig==`,
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=also-good.com; s=sel2; h=from:to; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{
|
||||||
|
{Domain: "good.com", Selector: "sel1"},
|
||||||
|
{Domain: "also-good.com", Selector: "sel2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := parseDKIMSignatures(tt.signatures)
|
||||||
|
if len(result) != len(tt.expected) {
|
||||||
|
t.Fatalf("parseDKIMSignatures() returned %d results, want %d\n got: %+v\n want: %+v", len(result), len(tt.expected), result, tt.expected)
|
||||||
|
}
|
||||||
|
for i := range tt.expected {
|
||||||
|
if result[i].Domain != tt.expected[i].Domain {
|
||||||
|
t.Errorf("result[%d].Domain = %q, want %q", i, result[i].Domain, tt.expected[i].Domain)
|
||||||
|
}
|
||||||
|
if result[i].Selector != tt.expected[i].Selector {
|
||||||
|
t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateDKIM(t *testing.T) {
|
func TestValidateDKIM(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,13 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
|
||||||
maxGrade -= 1
|
maxGrade -= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check MIME-Version header (-5 points if present but not "1.0")
|
||||||
|
if check, exists := headers["mime-version"]; exists && check.Present {
|
||||||
|
if check.Valid != nil && !*check.Valid {
|
||||||
|
score -= 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check Message-ID format (10 points)
|
// Check Message-ID format (10 points)
|
||||||
if check, exists := headers["message-id"]; exists && check.Present {
|
if check, exists := headers["message-id"]; exists && check.Present {
|
||||||
// If Valid is set and true, award points
|
// If Valid is set and true, award points
|
||||||
|
|
@ -266,6 +273,10 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults
|
||||||
headers[strings.ToLower(headerName)] = *check
|
headers[strings.ToLower(headerName)] = *check
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check MIME-Version header (recommended but absence is not penalized)
|
||||||
|
mimeVersionCheck := h.checkHeader(email, "MIME-Version", "recommended")
|
||||||
|
headers[strings.ToLower("MIME-Version")] = *mimeVersionCheck
|
||||||
|
|
||||||
// Check optional headers
|
// Check optional headers
|
||||||
optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"}
|
optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"}
|
||||||
for _, headerName := range optionalHeaders {
|
for _, headerName := range optionalHeaders {
|
||||||
|
|
@ -320,12 +331,21 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
|
||||||
valid = false
|
valid = false
|
||||||
headerIssues = append(headerIssues, "Invalid Message-ID format (should be <id@domain>)")
|
headerIssues = append(headerIssues, "Invalid Message-ID format (should be <id@domain>)")
|
||||||
}
|
}
|
||||||
|
if len(email.Header["Message-Id"]) > 1 {
|
||||||
|
valid = false
|
||||||
|
headerIssues = append(headerIssues, fmt.Sprintf("Multiple Message-ID headers found (%d); only one is allowed", len(email.Header["Message-Id"])))
|
||||||
|
}
|
||||||
case "Date":
|
case "Date":
|
||||||
// Validate date format
|
// Validate date format
|
||||||
if _, err := h.parseEmailDate(value); err != nil {
|
if _, err := h.parseEmailDate(value); err != nil {
|
||||||
valid = false
|
valid = false
|
||||||
headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err))
|
headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err))
|
||||||
}
|
}
|
||||||
|
case "MIME-Version":
|
||||||
|
if value != "1.0" {
|
||||||
|
valid = false
|
||||||
|
headerIssues = append(headerIssues, fmt.Sprintf("MIME-Version should be '1.0', got '%s'", value))
|
||||||
|
}
|
||||||
case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path":
|
case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path":
|
||||||
// Parse address header using net/mail and get normalized address
|
// Parse address header using net/mail and get normalized address
|
||||||
if normalizedAddr, err := h.validateAddressHeader(value); err != nil {
|
if normalizedAddr, err := h.validateAddressHeader(value); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -28,16 +28,9 @@ import (
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var hostname = ""
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
hostname, _ = os.Hostname()
|
|
||||||
}
|
|
||||||
|
|
||||||
// EmailMessage represents a parsed email message
|
// EmailMessage represents a parsed email message
|
||||||
type EmailMessage struct {
|
type EmailMessage struct {
|
||||||
Header mail.Header
|
Header mail.Header
|
||||||
|
|
@ -218,18 +211,18 @@ func buildRawHeaders(header mail.Header) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAuthenticationResults extracts Authentication-Results headers
|
// GetAuthenticationResults extracts Authentication-Results headers
|
||||||
// If hostname is provided, only returns headers that begin with that hostname
|
// If receiverHostname is provided, only returns headers that begin with that hostname
|
||||||
func (e *EmailMessage) GetAuthenticationResults() []string {
|
func (e *EmailMessage) GetAuthenticationResults(receiverHostname string) []string {
|
||||||
allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
|
allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
|
||||||
|
|
||||||
// If no hostname specified, return all results
|
// If no hostname specified, return all results
|
||||||
if hostname == "" {
|
if receiverHostname == "" {
|
||||||
return allResults
|
return allResults
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter results that begin with the specified hostname
|
// Filter results that begin with the specified hostname
|
||||||
var filtered []string
|
var filtered []string
|
||||||
prefix := hostname + ";"
|
prefix := receiverHostname + ";"
|
||||||
for _, result := range allResults {
|
for _, result := range allResults {
|
||||||
// Trim whitespace and check if it starts with hostname;
|
// Trim whitespace and check if it starts with hostname;
|
||||||
trimmed := strings.TrimSpace(result)
|
trimmed := strings.TrimSpace(result)
|
||||||
|
|
@ -256,6 +249,33 @@ func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, headerName := range saHeaders {
|
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 != "" {
|
if value := e.Header.Get(headerName); value != "" {
|
||||||
headers[headerName] = value
|
headers[headerName] = value
|
||||||
}
|
}
|
||||||
|
|
@ -301,3 +321,20 @@ func (e *EmailMessage) GetHeaderValue(key string) string {
|
||||||
func (e *EmailMessage) HasHeader(key string) bool {
|
func (e *EmailMessage) HasHeader(key string) bool {
|
||||||
return e.Header.Get(key) != ""
|
return e.Header.Get(key) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetListUnsubscribeURLs parses the List-Unsubscribe header and returns all URLs.
|
||||||
|
// The header format is: <url1>, <url2>, ...
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,9 +106,6 @@ Content-Type: text/html; charset=utf-8
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetAuthenticationResults(t *testing.T) {
|
func TestGetAuthenticationResults(t *testing.T) {
|
||||||
// Force hostname
|
|
||||||
hostname = "example.com"
|
|
||||||
|
|
||||||
rawEmail := `From: sender@example.com
|
rawEmail := `From: sender@example.com
|
||||||
To: recipient@example.com
|
To: recipient@example.com
|
||||||
Subject: Test Email
|
Subject: Test Email
|
||||||
|
|
@ -123,7 +120,7 @@ Body content.
|
||||||
t.Fatalf("Failed to parse email: %v", err)
|
t.Fatalf("Failed to parse email: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
authResults := email.GetAuthenticationResults()
|
authResults := email.GetAuthenticationResults("example.com")
|
||||||
if len(authResults) != 2 {
|
if len(authResults) != 2 {
|
||||||
t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults))
|
t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,17 +27,21 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RBLChecker checks IP addresses against DNS-based blacklists
|
// DNSListChecker checks IP addresses against DNS-based block/allow lists.
|
||||||
type RBLChecker struct {
|
// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags.
|
||||||
Timeout time.Duration
|
type DNSListChecker struct {
|
||||||
RBLs []string
|
Timeout time.Duration
|
||||||
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
Lists []string
|
||||||
resolver *net.Resolver
|
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
||||||
|
filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors
|
||||||
|
resolver *net.Resolver
|
||||||
|
informationalSet map[string]bool // Lists whose hits don't count toward the score
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRBLs is a list of commonly used RBL providers
|
// DefaultRBLs is a list of commonly used RBL providers
|
||||||
|
|
@ -48,40 +52,83 @@ var DefaultRBLs = []string{
|
||||||
"b.barracudacentral.org", // Barracuda
|
"b.barracudacentral.org", // Barracuda
|
||||||
"cbl.abuseat.org", // CBL (Composite Blocking List)
|
"cbl.abuseat.org", // CBL (Composite Blocking List)
|
||||||
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
|
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
|
||||||
|
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational)
|
||||||
|
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational)
|
||||||
|
"psbl.surriel.com", // PSBL
|
||||||
|
"dnsbl.dronebl.org", // DroneBL
|
||||||
|
"bl.mailspike.net", // Mailspike BL
|
||||||
|
"z.mailspike.net", // Mailspike Z
|
||||||
|
"bl.rbl-dns.com", // RBL-DNS
|
||||||
|
"bl.nszones.com", // NSZones
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultInformationalRBLs lists RBLs that are checked but not counted in the score.
|
||||||
|
// These are typically broader lists where being listed is less definitive.
|
||||||
|
var DefaultInformationalRBLs = []string{
|
||||||
|
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2: entire netblocks, may cause false positives
|
||||||
|
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultDNSWLs is a list of commonly used DNSWL providers
|
||||||
|
var DefaultDNSWLs = []string{
|
||||||
|
"list.dnswl.org", // DNSWL.org — the main DNS whitelist
|
||||||
|
"swl.spamhaus.org", // Spamhaus Safe Whitelist
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
|
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
|
||||||
func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *RBLChecker {
|
func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker {
|
||||||
if timeout == 0 {
|
if timeout == 0 {
|
||||||
timeout = 5 * time.Second // Default timeout
|
timeout = 5 * time.Second
|
||||||
}
|
}
|
||||||
if len(rbls) == 0 {
|
if len(rbls) == 0 {
|
||||||
rbls = DefaultRBLs
|
rbls = DefaultRBLs
|
||||||
}
|
}
|
||||||
return &RBLChecker{
|
informationalSet := make(map[string]bool, len(DefaultInformationalRBLs))
|
||||||
Timeout: timeout,
|
for _, rbl := range DefaultInformationalRBLs {
|
||||||
RBLs: rbls,
|
informationalSet[rbl] = true
|
||||||
CheckAllIPs: checkAllIPs,
|
}
|
||||||
resolver: &net.Resolver{
|
return &DNSListChecker{
|
||||||
PreferGo: true,
|
Timeout: timeout,
|
||||||
},
|
Lists: rbls,
|
||||||
|
CheckAllIPs: checkAllIPs,
|
||||||
|
filterErrorCodes: true,
|
||||||
|
resolver: &net.Resolver{PreferGo: true},
|
||||||
|
informationalSet: informationalSet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RBLResults represents the results of RBL checks
|
// NewDNSWLChecker creates a new DNSWL checker with configurable timeout and DNSWL list
|
||||||
type RBLResults struct {
|
func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *DNSListChecker {
|
||||||
Checks map[string][]api.BlacklistCheck // Map of IP -> list of RBL checks for that IP
|
if timeout == 0 {
|
||||||
IPsChecked []string
|
timeout = 5 * time.Second
|
||||||
ListedCount int
|
}
|
||||||
|
if len(dnswls) == 0 {
|
||||||
|
dnswls = DefaultDNSWLs
|
||||||
|
}
|
||||||
|
return &DNSListChecker{
|
||||||
|
Timeout: timeout,
|
||||||
|
Lists: dnswls,
|
||||||
|
CheckAllIPs: checkAllIPs,
|
||||||
|
filterErrorCodes: false,
|
||||||
|
resolver: &net.Resolver{PreferGo: true},
|
||||||
|
informationalSet: make(map[string]bool),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckEmail checks all IPs found in the email headers against RBLs
|
// DNSListResults represents the results of DNS list checks
|
||||||
func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
|
type DNSListResults struct {
|
||||||
results := &RBLResults{
|
Checks map[string][]api.BlacklistCheck // Map of IP -> list of checks for that IP
|
||||||
|
IPsChecked []string
|
||||||
|
ListedCount int // Total listings including informational entries
|
||||||
|
RelevantListedCount int // Listings on scoring (non-informational) lists only
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckEmail checks all IPs found in the email headers against the configured lists
|
||||||
|
func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
||||||
|
results := &DNSListResults{
|
||||||
Checks: make(map[string][]api.BlacklistCheck),
|
Checks: make(map[string][]api.BlacklistCheck),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract IPs from Received headers
|
|
||||||
ips := r.extractIPs(email)
|
ips := r.extractIPs(email)
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
return results
|
return results
|
||||||
|
|
@ -89,17 +136,18 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
|
||||||
|
|
||||||
results.IPsChecked = ips
|
results.IPsChecked = ips
|
||||||
|
|
||||||
// Check each IP against all RBLs
|
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
for _, rbl := range r.RBLs {
|
for _, list := range r.Lists {
|
||||||
check := r.checkIP(ip, rbl)
|
check := r.checkIP(ip, list)
|
||||||
results.Checks[ip] = append(results.Checks[ip], check)
|
results.Checks[ip] = append(results.Checks[ip], check)
|
||||||
if check.Listed {
|
if check.Listed {
|
||||||
results.ListedCount++
|
results.ListedCount++
|
||||||
|
if !r.informationalSet[list] {
|
||||||
|
results.RelevantListedCount++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only check the first IP unless CheckAllIPs is enabled
|
|
||||||
if !r.CheckAllIPs {
|
if !r.CheckAllIPs {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -108,20 +156,26 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckIP checks a single IP address against all configured RBLs
|
// CheckIP checks a single IP address against all configured lists in parallel
|
||||||
func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
|
func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
|
||||||
// Validate that it's a valid IP address
|
|
||||||
if !r.isPublicIP(ip) {
|
if !r.isPublicIP(ip) {
|
||||||
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
|
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
var checks []api.BlacklistCheck
|
checks := make([]api.BlacklistCheck, len(r.Lists))
|
||||||
listedCount := 0
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
// Check the IP against all RBLs
|
for i, list := range r.Lists {
|
||||||
for _, rbl := range r.RBLs {
|
wg.Add(1)
|
||||||
check := r.checkIP(ip, rbl)
|
go func(i int, list string) {
|
||||||
checks = append(checks, check)
|
defer wg.Done()
|
||||||
|
checks[i] = r.checkIP(ip, list)
|
||||||
|
}(i, list)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
listedCount := 0
|
||||||
|
for _, check := range checks {
|
||||||
if check.Listed {
|
if check.Listed {
|
||||||
listedCount++
|
listedCount++
|
||||||
}
|
}
|
||||||
|
|
@ -131,27 +185,19 @@ func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractIPs extracts IP addresses from Received headers
|
// extractIPs extracts IP addresses from Received headers
|
||||||
func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
|
func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
|
||||||
var ips []string
|
var ips []string
|
||||||
seenIPs := make(map[string]bool)
|
seenIPs := make(map[string]bool)
|
||||||
|
|
||||||
// Get all Received headers
|
|
||||||
receivedHeaders := email.Header["Received"]
|
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`)
|
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 {
|
for _, received := range receivedHeaders {
|
||||||
// Find all IPv4 addresses
|
|
||||||
matches := ipv4Pattern.FindAllString(received, -1)
|
matches := ipv4Pattern.FindAllString(received, -1)
|
||||||
for _, match := range matches {
|
for _, match := range matches {
|
||||||
// Skip private/reserved IPs
|
|
||||||
if !r.isPublicIP(match) {
|
if !r.isPublicIP(match) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Avoid duplicates
|
|
||||||
if !seenIPs[match] {
|
if !seenIPs[match] {
|
||||||
ips = append(ips, match)
|
ips = append(ips, match)
|
||||||
seenIPs[match] = true
|
seenIPs[match] = true
|
||||||
|
|
@ -159,13 +205,10 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no IPs found in Received headers, try X-Originating-IP
|
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
originatingIP := email.Header.Get("X-Originating-IP")
|
originatingIP := email.Header.Get("X-Originating-IP")
|
||||||
if originatingIP != "" {
|
if originatingIP != "" {
|
||||||
// Extract IP from formats like "[192.0.2.1]" or "192.0.2.1"
|
|
||||||
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
|
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
|
||||||
// Remove any whitespace
|
|
||||||
cleanIP = strings.TrimSpace(cleanIP)
|
cleanIP = strings.TrimSpace(cleanIP)
|
||||||
matches := ipv4Pattern.FindString(cleanIP)
|
matches := ipv4Pattern.FindString(cleanIP)
|
||||||
if matches != "" && r.isPublicIP(matches) {
|
if matches != "" && r.isPublicIP(matches) {
|
||||||
|
|
@ -178,19 +221,16 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
|
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
|
||||||
func (r *RBLChecker) isPublicIP(ipStr string) bool {
|
func (r *DNSListChecker) isPublicIP(ipStr string) bool {
|
||||||
ip := net.ParseIP(ipStr)
|
ip := net.ParseIP(ipStr)
|
||||||
if ip == nil {
|
if ip == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a private network
|
|
||||||
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||||
return false
|
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() {
|
if ip.IsUnspecified() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -198,51 +238,43 @@ func (r *RBLChecker) isPublicIP(ipStr string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkIP checks a single IP against a single RBL
|
// checkIP checks a single IP against a single DNS list
|
||||||
func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck {
|
func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck {
|
||||||
check := api.BlacklistCheck{
|
check := api.BlacklistCheck{
|
||||||
Rbl: rbl,
|
Rbl: list,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reverse the IP for DNSBL query
|
|
||||||
reversedIP := r.reverseIP(ip)
|
reversedIP := r.reverseIP(ip)
|
||||||
if reversedIP == "" {
|
if reversedIP == "" {
|
||||||
check.Error = api.PtrTo("Failed to reverse IP address")
|
check.Error = api.PtrTo("Failed to reverse IP address")
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct DNSBL query: reversed-ip.rbl-domain
|
query := fmt.Sprintf("%s.%s", reversedIP, list)
|
||||||
query := fmt.Sprintf("%s.%s", reversedIP, rbl)
|
|
||||||
|
|
||||||
// Perform DNS lookup with timeout
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
addrs, err := r.resolver.LookupHost(ctx, query)
|
addrs, err := r.resolver.LookupHost(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Most likely not listed (NXDOMAIN)
|
|
||||||
if dnsErr, ok := err.(*net.DNSError); ok {
|
if dnsErr, ok := err.(*net.DNSError); ok {
|
||||||
if dnsErr.IsNotFound {
|
if dnsErr.IsNotFound {
|
||||||
check.Listed = false
|
check.Listed = false
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Other DNS errors
|
|
||||||
check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
|
check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we got a response, check the return code
|
|
||||||
if len(addrs) > 0 {
|
if len(addrs) > 0 {
|
||||||
check.Response = api.PtrTo(addrs[0]) // Return code (e.g., 127.0.0.2)
|
check.Response = api.PtrTo(addrs[0])
|
||||||
|
|
||||||
// Check for RBL error codes: 127.255.255.253, 127.255.255.254, 127.255.255.255
|
// In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings.
|
||||||
// These indicate RBL operational issues, not actual listings
|
if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") {
|
||||||
if addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255" {
|
|
||||||
check.Listed = false
|
check.Listed = false
|
||||||
check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", rbl, addrs[0]))
|
check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0]))
|
||||||
} else {
|
} else {
|
||||||
// Normal listing response
|
|
||||||
check.Listed = true
|
check.Listed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -250,44 +282,58 @@ func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck {
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
|
|
||||||
// reverseIP reverses an IPv4 address for DNSBL queries
|
// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries
|
||||||
// Example: 192.0.2.1 -> 1.2.0.192
|
// Example: 192.0.2.1 -> 1.2.0.192
|
||||||
func (r *RBLChecker) reverseIP(ipStr string) string {
|
func (r *DNSListChecker) reverseIP(ipStr string) string {
|
||||||
ip := net.ParseIP(ipStr)
|
ip := net.ParseIP(ipStr)
|
||||||
if ip == nil {
|
if ip == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to IPv4
|
|
||||||
ipv4 := ip.To4()
|
ipv4 := ip.To4()
|
||||||
if ipv4 == nil {
|
if ipv4 == nil {
|
||||||
return "" // IPv6 not supported yet
|
return "" // IPv6 not supported yet
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reverse the octets
|
|
||||||
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
|
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateRBLScore calculates the blacklist contribution to deliverability
|
// CalculateScore calculates the list contribution to deliverability.
|
||||||
func (r *RBLChecker) CalculateRBLScore(results *RBLResults) (int, string) {
|
// Informational lists are not counted in the score.
|
||||||
|
func (r *DNSListChecker) CalculateScore(results *DNSListResults, forWhitelist bool) (int, string) {
|
||||||
|
scoringListCount := len(r.Lists) - len(r.informationalSet)
|
||||||
|
|
||||||
|
if forWhitelist {
|
||||||
|
if results.ListedCount >= scoringListCount {
|
||||||
|
return 100, "A++"
|
||||||
|
} else if results.ListedCount > 0 {
|
||||||
|
return 100, "A+"
|
||||||
|
} else {
|
||||||
|
return 95, "A"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if results == nil || len(results.IPsChecked) == 0 {
|
if results == nil || len(results.IPsChecked) == 0 {
|
||||||
// No IPs to check, give benefit of doubt
|
|
||||||
return 100, ""
|
return 100, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
percentage := 100 - results.ListedCount*100/len(r.RBLs)
|
if results.ListedCount <= 0 {
|
||||||
|
return 100, "A+"
|
||||||
|
}
|
||||||
|
|
||||||
|
percentage := 100 - results.RelevantListedCount*100/scoringListCount
|
||||||
return percentage, ScoreToGrade(percentage)
|
return percentage, ScoreToGrade(percentage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL
|
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry
|
||||||
func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
|
func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string {
|
||||||
var listedIPs []string
|
var listedIPs []string
|
||||||
|
|
||||||
for ip, rblChecks := range results.Checks {
|
for ip, checks := range results.Checks {
|
||||||
for _, check := range rblChecks {
|
for _, check := range checks {
|
||||||
if check.Listed {
|
if check.Listed {
|
||||||
listedIPs = append(listedIPs, ip)
|
listedIPs = append(listedIPs, ip)
|
||||||
break // Only add the IP once
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -295,17 +341,17 @@ func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
|
||||||
return listedIPs
|
return listedIPs
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRBLsForIP returns all RBLs that list a specific IP
|
// GetListsForIP returns all lists that match a specific IP
|
||||||
func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
|
func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string {
|
||||||
var rbls []string
|
var lists []string
|
||||||
|
|
||||||
if rblChecks, exists := results.Checks[ip]; exists {
|
if checks, exists := results.Checks[ip]; exists {
|
||||||
for _, check := range rblChecks {
|
for _, check := range checks {
|
||||||
if check.Listed {
|
if check.Listed {
|
||||||
rbls = append(rbls, check.Rbl)
|
lists = append(lists, check.Rbl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return rbls
|
return lists
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,8 @@ func TestNewRBLChecker(t *testing.T) {
|
||||||
if checker.Timeout != tt.expectedTimeout {
|
if checker.Timeout != tt.expectedTimeout {
|
||||||
t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout)
|
t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout)
|
||||||
}
|
}
|
||||||
if len(checker.RBLs) != tt.expectedRBLs {
|
if len(checker.Lists) != tt.expectedRBLs {
|
||||||
t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs)
|
t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs)
|
||||||
}
|
}
|
||||||
if checker.resolver == nil {
|
if checker.resolver == nil {
|
||||||
t.Error("Resolver should not be nil")
|
t.Error("Resolver should not be nil")
|
||||||
|
|
@ -265,7 +265,7 @@ func TestExtractIPs(t *testing.T) {
|
||||||
func TestGetBlacklistScore(t *testing.T) {
|
func TestGetBlacklistScore(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
results *RBLResults
|
results *DNSListResults
|
||||||
expectedScore int
|
expectedScore int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
|
|
@ -275,14 +275,14 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "No IPs checked",
|
name: "No IPs checked",
|
||||||
results: &RBLResults{
|
results: &DNSListResults{
|
||||||
IPsChecked: []string{},
|
IPsChecked: []string{},
|
||||||
},
|
},
|
||||||
expectedScore: 100,
|
expectedScore: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Not listed on any RBL",
|
name: "Not listed on any RBL",
|
||||||
results: &RBLResults{
|
results: &DNSListResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 0,
|
ListedCount: 0,
|
||||||
},
|
},
|
||||||
|
|
@ -290,7 +290,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 1 RBL",
|
name: "Listed on 1 RBL",
|
||||||
results: &RBLResults{
|
results: &DNSListResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 1,
|
ListedCount: 1,
|
||||||
},
|
},
|
||||||
|
|
@ -298,7 +298,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 2 RBLs",
|
name: "Listed on 2 RBLs",
|
||||||
results: &RBLResults{
|
results: &DNSListResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 2,
|
ListedCount: 2,
|
||||||
},
|
},
|
||||||
|
|
@ -306,7 +306,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 3 RBLs",
|
name: "Listed on 3 RBLs",
|
||||||
results: &RBLResults{
|
results: &DNSListResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 3,
|
ListedCount: 3,
|
||||||
},
|
},
|
||||||
|
|
@ -314,7 +314,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 4+ RBLs",
|
name: "Listed on 4+ RBLs",
|
||||||
results: &RBLResults{
|
results: &DNSListResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 4,
|
ListedCount: 4,
|
||||||
},
|
},
|
||||||
|
|
@ -326,7 +326,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
score, _ := checker.CalculateRBLScore(tt.results)
|
score, _ := checker.CalculateScore(tt.results)
|
||||||
if score != tt.expectedScore {
|
if score != tt.expectedScore {
|
||||||
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
|
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
|
||||||
}
|
}
|
||||||
|
|
@ -335,7 +335,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetUniqueListedIPs(t *testing.T) {
|
func TestGetUniqueListedIPs(t *testing.T) {
|
||||||
results := &RBLResults{
|
results := &DNSListResults{
|
||||||
Checks: map[string][]api.BlacklistCheck{
|
Checks: map[string][]api.BlacklistCheck{
|
||||||
"198.51.100.1": {
|
"198.51.100.1": {
|
||||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||||
|
|
@ -363,7 +363,7 @@ func TestGetUniqueListedIPs(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetRBLsForIP(t *testing.T) {
|
func TestGetRBLsForIP(t *testing.T) {
|
||||||
results := &RBLResults{
|
results := &DNSListResults{
|
||||||
Checks: map[string][]api.BlacklistCheck{
|
Checks: map[string][]api.BlacklistCheck{
|
||||||
"198.51.100.1": {
|
"198.51.100.1": {
|
||||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||||
|
|
@ -402,7 +402,7 @@ func TestGetRBLsForIP(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
rbls := checker.GetRBLsForIP(results, tt.ip)
|
rbls := checker.GetListsForIP(results, tt.ip)
|
||||||
|
|
||||||
if len(rbls) != len(tt.expectedRBLs) {
|
if len(rbls) != len(tt.expectedRBLs) {
|
||||||
t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs))
|
t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs))
|
||||||
|
|
|
||||||
|
|
@ -33,24 +33,31 @@ import (
|
||||||
type ReportGenerator struct {
|
type ReportGenerator struct {
|
||||||
authAnalyzer *AuthenticationAnalyzer
|
authAnalyzer *AuthenticationAnalyzer
|
||||||
spamAnalyzer *SpamAssassinAnalyzer
|
spamAnalyzer *SpamAssassinAnalyzer
|
||||||
|
rspamdAnalyzer *RspamdAnalyzer
|
||||||
dnsAnalyzer *DNSAnalyzer
|
dnsAnalyzer *DNSAnalyzer
|
||||||
rblChecker *RBLChecker
|
rblChecker *DNSListChecker
|
||||||
|
dnswlChecker *DNSListChecker
|
||||||
contentAnalyzer *ContentAnalyzer
|
contentAnalyzer *ContentAnalyzer
|
||||||
headerAnalyzer *HeaderAnalyzer
|
headerAnalyzer *HeaderAnalyzer
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewReportGenerator creates a new report generator
|
// NewReportGenerator creates a new report generator
|
||||||
func NewReportGenerator(
|
func NewReportGenerator(
|
||||||
|
receiverHostname string,
|
||||||
dnsTimeout time.Duration,
|
dnsTimeout time.Duration,
|
||||||
httpTimeout time.Duration,
|
httpTimeout time.Duration,
|
||||||
rbls []string,
|
rbls []string,
|
||||||
|
dnswls []string,
|
||||||
checkAllIPs bool,
|
checkAllIPs bool,
|
||||||
|
rspamdAPIURL string,
|
||||||
) *ReportGenerator {
|
) *ReportGenerator {
|
||||||
return &ReportGenerator{
|
return &ReportGenerator{
|
||||||
authAnalyzer: NewAuthenticationAnalyzer(),
|
authAnalyzer: NewAuthenticationAnalyzer(receiverHostname),
|
||||||
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
||||||
|
rspamdAnalyzer: NewRspamdAnalyzer(LoadRspamdSymbols(rspamdAPIURL)),
|
||||||
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
||||||
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
|
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
|
||||||
|
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
|
||||||
contentAnalyzer: NewContentAnalyzer(httpTimeout),
|
contentAnalyzer: NewContentAnalyzer(httpTimeout),
|
||||||
headerAnalyzer: NewHeaderAnalyzer(),
|
headerAnalyzer: NewHeaderAnalyzer(),
|
||||||
}
|
}
|
||||||
|
|
@ -63,8 +70,10 @@ type AnalysisResults struct {
|
||||||
Content *ContentResults
|
Content *ContentResults
|
||||||
DNS *api.DNSResults
|
DNS *api.DNSResults
|
||||||
Headers *api.HeaderAnalysis
|
Headers *api.HeaderAnalysis
|
||||||
RBL *RBLResults
|
RBL *DNSListResults
|
||||||
|
DNSWL *DNSListResults
|
||||||
SpamAssassin *api.SpamAssassinResult
|
SpamAssassin *api.SpamAssassinResult
|
||||||
|
Rspamd *api.RspamdResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeEmail performs complete email analysis
|
// AnalyzeEmail performs complete email analysis
|
||||||
|
|
@ -76,9 +85,11 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
||||||
// Run all analyzers
|
// Run all analyzers
|
||||||
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
||||||
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
||||||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers)
|
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers)
|
||||||
results.RBL = r.rblChecker.CheckEmail(email)
|
results.RBL = r.rblChecker.CheckEmail(email)
|
||||||
|
results.DNSWL = r.dnswlChecker.CheckEmail(email)
|
||||||
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
||||||
|
results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email)
|
||||||
results.Content = r.contentAnalyzer.AnalyzeContent(email)
|
results.Content = r.contentAnalyzer.AnalyzeContent(email)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
@ -130,14 +141,32 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
|
|
||||||
blacklistScore := 0
|
blacklistScore := 0
|
||||||
var blacklistGrade string
|
var blacklistGrade string
|
||||||
|
var whitelistGrade string
|
||||||
if results.RBL != nil {
|
if results.RBL != nil {
|
||||||
blacklistScore, blacklistGrade = r.rblChecker.CalculateRBLScore(results.RBL)
|
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL, false)
|
||||||
|
_, whitelistGrade = r.dnswlChecker.CalculateScore(results.DNSWL, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
spamScore := 0
|
saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
|
||||||
|
rspamdScore, rspamdGrade := r.rspamdAnalyzer.CalculateRspamdScore(results.Rspamd)
|
||||||
|
|
||||||
|
// Combine SpamAssassin and rspamd scores 50/50.
|
||||||
|
// If only one filter ran (the other returns "" grade), use that filter's score alone.
|
||||||
|
var spamScore int
|
||||||
var spamGrade string
|
var spamGrade string
|
||||||
if results.SpamAssassin != nil {
|
switch {
|
||||||
spamScore, spamGrade = r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
|
case saGrade == "" && rspamdGrade == "":
|
||||||
|
spamScore = 0
|
||||||
|
spamGrade = ""
|
||||||
|
case saGrade == "":
|
||||||
|
spamScore = rspamdScore
|
||||||
|
spamGrade = rspamdGrade
|
||||||
|
case rspamdGrade == "":
|
||||||
|
spamScore = saScore
|
||||||
|
spamGrade = saGrade
|
||||||
|
default:
|
||||||
|
spamScore = (saScore + rspamdScore) / 2
|
||||||
|
spamGrade = MinGrade(saGrade, rspamdGrade)
|
||||||
}
|
}
|
||||||
|
|
||||||
report.Summary = &api.ScoreSummary{
|
report.Summary = &api.ScoreSummary{
|
||||||
|
|
@ -146,7 +175,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
AuthenticationScore: authScore,
|
AuthenticationScore: authScore,
|
||||||
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
|
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
|
||||||
BlacklistScore: blacklistScore,
|
BlacklistScore: blacklistScore,
|
||||||
BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade),
|
BlacklistGrade: api.ScoreSummaryBlacklistGrade(MinGrade(blacklistGrade, whitelistGrade)),
|
||||||
ContentScore: contentScore,
|
ContentScore: contentScore,
|
||||||
ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
|
ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
|
||||||
HeaderScore: headerScore,
|
HeaderScore: headerScore,
|
||||||
|
|
@ -177,9 +206,27 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
report.Blacklists = &results.RBL.Checks
|
report.Blacklists = &results.RBL.Checks
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add SpamAssassin result
|
// Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only)
|
||||||
|
if results.DNSWL != nil && len(results.DNSWL.Checks) > 0 {
|
||||||
|
report.Whitelists = &results.DNSWL.Checks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add SpamAssassin result with individual deliverability score
|
||||||
|
if results.SpamAssassin != nil {
|
||||||
|
saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade)
|
||||||
|
results.SpamAssassin.DeliverabilityScore = api.PtrTo(saScore)
|
||||||
|
results.SpamAssassin.DeliverabilityGrade = &saGradeTyped
|
||||||
|
}
|
||||||
report.Spamassassin = results.SpamAssassin
|
report.Spamassassin = results.SpamAssassin
|
||||||
|
|
||||||
|
// Add rspamd result with individual deliverability score
|
||||||
|
if results.Rspamd != nil {
|
||||||
|
rspamdGradeTyped := api.RspamdResultDeliverabilityGrade(rspamdGrade)
|
||||||
|
results.Rspamd.DeliverabilityScore = api.PtrTo(rspamdScore)
|
||||||
|
results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped
|
||||||
|
}
|
||||||
|
report.Rspamd = results.Rspamd
|
||||||
|
|
||||||
// Add raw headers
|
// Add raw headers
|
||||||
if results.Email != nil && results.Email.RawHeaders != "" {
|
if results.Email != nil && results.Email.RawHeaders != "" {
|
||||||
report.RawHeaders = &results.Email.RawHeaders
|
report.RawHeaders = &results.Email.RawHeaders
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewReportGenerator(t *testing.T) {
|
func TestNewReportGenerator(t *testing.T) {
|
||||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
||||||
if gen == nil {
|
if gen == nil {
|
||||||
t.Fatal("Expected report generator, got nil")
|
t.Fatal("Expected report generator, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ func TestNewReportGenerator(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnalyzeEmail(t *testing.T) {
|
func TestAnalyzeEmail(t *testing.T) {
|
||||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
||||||
|
|
||||||
email := createTestEmail()
|
email := createTestEmail()
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ func TestAnalyzeEmail(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateReport(t *testing.T) {
|
func TestGenerateReport(t *testing.T) {
|
||||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
||||||
testID := uuid.New()
|
testID := uuid.New()
|
||||||
|
|
||||||
email := createTestEmail()
|
email := createTestEmail()
|
||||||
|
|
@ -130,7 +130,7 @@ func TestGenerateReport(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
||||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
||||||
testID := uuid.New()
|
testID := uuid.New()
|
||||||
|
|
||||||
email := createTestEmailWithSpamAssassin()
|
email := createTestEmailWithSpamAssassin()
|
||||||
|
|
@ -150,7 +150,7 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateRawEmail(t *testing.T) {
|
func TestGenerateRawEmail(t *testing.T) {
|
||||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
21
pkg/analyzer/rspamd-symbols-README.md
Normal file
21
pkg/analyzer/rspamd-symbols-README.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# rspamd-symbols.json
|
||||||
|
|
||||||
|
This file contains rspamd symbol descriptions, embedded into the binary at compile time as a fallback when no rspamd API URL is configured.
|
||||||
|
|
||||||
|
## How to update
|
||||||
|
|
||||||
|
Fetch the latest symbols from a running rspamd instance:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl http://127.0.0.1:11334/symbols > rspamd-symbols.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with docker:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --rm --name rspamd --pull always rspamd/rspamd
|
||||||
|
docker exec -u 0 rspamd apt install -y curl
|
||||||
|
docker exec rspamd curl http://127.0.0.1:11334/symbols > rspamd-symbols.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Then rebuild the project.
|
||||||
6646
pkg/analyzer/rspamd-symbols.json
Normal file
6646
pkg/analyzer/rspamd-symbols.json
Normal file
File diff suppressed because it is too large
Load diff
174
pkg/analyzer/rspamd.go
Normal file
174
pkg/analyzer/rspamd.go
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
// This file is part of the happyDeliver (R) project.
|
||||||
|
// Copyright (c) 2026 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 <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// 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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package analyzer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default rspamd action thresholds (rspamd built-in defaults)
|
||||||
|
const (
|
||||||
|
rspamdDefaultRejectThreshold float32 = 15
|
||||||
|
rspamdDefaultAddHeaderThreshold float32 = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
// RspamdAnalyzer analyzes rspamd results from email headers
|
||||||
|
type RspamdAnalyzer struct {
|
||||||
|
symbols map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRspamdAnalyzer creates a new rspamd analyzer with optional symbol descriptions
|
||||||
|
func NewRspamdAnalyzer(symbols map[string]string) *RspamdAnalyzer {
|
||||||
|
return &RspamdAnalyzer{symbols: symbols}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
|
||||||
|
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
||||||
|
headers := email.GetRspamdHeaders()
|
||||||
|
if len(headers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require at least X-Spamd-Result or X-Rspamd-Score to produce a meaningful report
|
||||||
|
_, hasSpamdResult := headers["X-Spamd-Result"]
|
||||||
|
_, hasRspamdScore := headers["X-Rspamd-Score"]
|
||||||
|
if !hasSpamdResult && !hasRspamdScore {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &api.RspamdResult{
|
||||||
|
Symbols: make(map[string]api.SpamTestDetail),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
|
||||||
|
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
||||||
|
if spamdResult, ok := headers["X-Spamd-Result"]; ok {
|
||||||
|
report := strings.ReplaceAll(spamdResult, "; ", ";\n")
|
||||||
|
result.Report = &report
|
||||||
|
a.parseSpamdResult(spamdResult, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse X-Rspamd-Score as override/fallback for score
|
||||||
|
if scoreHeader, ok := headers["X-Rspamd-Score"]; ok {
|
||||||
|
if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil {
|
||||||
|
result.Score = float32(score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse X-Rspamd-Server
|
||||||
|
if serverHeader, ok := headers["X-Rspamd-Server"]; ok {
|
||||||
|
server := strings.TrimSpace(serverHeader)
|
||||||
|
result.Server = &server
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate symbol descriptions from the lookup map
|
||||||
|
if a.symbols != nil {
|
||||||
|
for name, sym := range result.Symbols {
|
||||||
|
if desc, ok := a.symbols[name]; ok {
|
||||||
|
sym.Description = &desc
|
||||||
|
result.Symbols[name] = sym
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive IsSpam from score vs reject threshold.
|
||||||
|
if result.Threshold > 0 {
|
||||||
|
result.IsSpam = result.Score >= result.Threshold
|
||||||
|
} else {
|
||||||
|
result.IsSpam = result.Score >= rspamdDefaultAddHeaderThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSpamdResult parses the X-Spamd-Result header
|
||||||
|
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
||||||
|
func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResult) {
|
||||||
|
// Extract score and threshold from the first line
|
||||||
|
// e.g. "default: False [-3.91 / 15.00]"
|
||||||
|
scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`)
|
||||||
|
if matches := scoreRe.FindStringSubmatch(header); len(matches) > 2 {
|
||||||
|
if score, err := strconv.ParseFloat(matches[1], 64); err == nil {
|
||||||
|
result.Score = float32(score)
|
||||||
|
}
|
||||||
|
if threshold, err := strconv.ParseFloat(matches[2], 64); err == nil {
|
||||||
|
result.Threshold = float32(threshold)
|
||||||
|
|
||||||
|
// No threshold? use default AddHeaderThreshold
|
||||||
|
if result.Threshold <= 0 {
|
||||||
|
result.Threshold = rspamdDefaultAddHeaderThreshold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse is_spam from header (before we may get action from X-Rspamd-Action)
|
||||||
|
firstLine := strings.SplitN(header, ";", 2)[0]
|
||||||
|
if strings.Contains(firstLine, ": True") || strings.Contains(firstLine, ": true") {
|
||||||
|
result.IsSpam = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse symbols: SYMBOL(score)[params]
|
||||||
|
// Each symbol entry is separated by ";", so within each part we use a
|
||||||
|
// greedy match to capture params that may contain nested brackets.
|
||||||
|
symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[(.*)\])?`)
|
||||||
|
for _, part := range strings.Split(header, ";") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
matches := symbolRe.FindStringSubmatch(part)
|
||||||
|
if len(matches) > 2 {
|
||||||
|
name := matches[1]
|
||||||
|
score, _ := strconv.ParseFloat(matches[2], 64)
|
||||||
|
sym := api.SpamTestDetail{
|
||||||
|
Name: name,
|
||||||
|
Score: float32(score),
|
||||||
|
}
|
||||||
|
if len(matches) > 3 && matches[3] != "" {
|
||||||
|
params := matches[3]
|
||||||
|
sym.Params = ¶ms
|
||||||
|
}
|
||||||
|
result.Symbols[name] = sym
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale)
|
||||||
|
func (a *RspamdAnalyzer) CalculateRspamdScore(result *api.RspamdResult) (int, string) {
|
||||||
|
if result == nil {
|
||||||
|
return 100, "" // rspamd not installed
|
||||||
|
}
|
||||||
|
|
||||||
|
threshold := result.Threshold
|
||||||
|
percentage := 100 - int(math.Round(float64(result.Score*100/(2*threshold))))
|
||||||
|
|
||||||
|
if percentage > 100 {
|
||||||
|
return 100, "A+"
|
||||||
|
} else if percentage < 0 {
|
||||||
|
return 0, "F"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linear scale between 0 and threshold
|
||||||
|
return percentage, ScoreToGrade(percentage)
|
||||||
|
}
|
||||||
105
pkg/analyzer/rspamd_symbols.go
Normal file
105
pkg/analyzer/rspamd_symbols.go
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
// This file is part of the happyDeliver (R) project.
|
||||||
|
// Copyright (c) 2026 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 <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// 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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package analyzer
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed rspamd-symbols.json
|
||||||
|
var embeddedRspamdSymbols []byte
|
||||||
|
|
||||||
|
// rspamdSymbolGroup represents a group of rspamd symbols from the API/embedded JSON.
|
||||||
|
type rspamdSymbolGroup struct {
|
||||||
|
Group string `json:"group"`
|
||||||
|
Rules []rspamdSymbolEntry `json:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// rspamdSymbolEntry represents a single rspamd symbol entry.
|
||||||
|
type rspamdSymbolEntry struct {
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Weight float64 `json:"weight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRspamdSymbolsJSON parses the rspamd symbols JSON into a name->description map.
|
||||||
|
func parseRspamdSymbolsJSON(data []byte) map[string]string {
|
||||||
|
var groups []rspamdSymbolGroup
|
||||||
|
if err := json.Unmarshal(data, &groups); err != nil {
|
||||||
|
log.Printf("Failed to parse rspamd symbols JSON: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
symbols := make(map[string]string, len(groups)*10)
|
||||||
|
for _, g := range groups {
|
||||||
|
for _, r := range g.Rules {
|
||||||
|
if r.Description != "" {
|
||||||
|
symbols[r.Symbol] = r.Description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return symbols
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadRspamdSymbols loads rspamd symbol descriptions.
|
||||||
|
// If apiURL is non-empty, it fetches from the rspamd API first, falling back to the embedded list on error.
|
||||||
|
func LoadRspamdSymbols(apiURL string) map[string]string {
|
||||||
|
if apiURL != "" {
|
||||||
|
if symbols := fetchRspamdSymbols(apiURL); symbols != nil {
|
||||||
|
return symbols
|
||||||
|
}
|
||||||
|
log.Printf("Failed to fetch rspamd symbols from %s, using embedded list", apiURL)
|
||||||
|
}
|
||||||
|
return parseRspamdSymbolsJSON(embeddedRspamdSymbols)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchRspamdSymbols fetches symbol descriptions from the rspamd API.
|
||||||
|
func fetchRspamdSymbols(apiURL string) map[string]string {
|
||||||
|
url := strings.TrimRight(apiURL, "/") + "/symbols"
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error fetching rspamd symbols: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
log.Printf("rspamd API returned status %d", resp.StatusCode)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error reading rspamd symbols response: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseRspamdSymbolsJSON(body)
|
||||||
|
}
|
||||||
414
pkg/analyzer/rspamd_test.go
Normal file
414
pkg/analyzer/rspamd_test.go
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
// This file is part of the happyDeliver (R) project.
|
||||||
|
// Copyright (c) 2026 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 <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// 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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package analyzer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/mail"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
|
||||||
|
analyzer := NewRspamdAnalyzer(nil)
|
||||||
|
email := &EmailMessage{Header: make(mail.Header)}
|
||||||
|
|
||||||
|
result := analyzer.AnalyzeRspamd(email)
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("Expected nil for email without rspamd headers, got %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSpamdResult(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
expectedScore float32
|
||||||
|
expectedThreshold float32
|
||||||
|
expectedIsSpam bool
|
||||||
|
expectedSymbols map[string]float32
|
||||||
|
expectedSymParams map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Clean email negative score",
|
||||||
|
header: "default: False [-3.91 / 15.00];\n\tDATE_IN_PAST(0.10); ALL_TRUSTED(-1.00)[trusted]",
|
||||||
|
expectedScore: -3.91,
|
||||||
|
expectedThreshold: 15.00,
|
||||||
|
expectedIsSpam: false,
|
||||||
|
expectedSymbols: map[string]float32{
|
||||||
|
"DATE_IN_PAST": 0.10,
|
||||||
|
"ALL_TRUSTED": -1.00,
|
||||||
|
},
|
||||||
|
expectedSymParams: map[string]string{
|
||||||
|
"ALL_TRUSTED": "trusted",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Spam email True flag",
|
||||||
|
header: "default: True [16.50 / 15.00];\n\tBAYES_99(5.00)[1.00]; SPOOFED_SENDER(3.50)",
|
||||||
|
expectedScore: 16.50,
|
||||||
|
expectedThreshold: 15.00,
|
||||||
|
expectedIsSpam: true,
|
||||||
|
expectedSymbols: map[string]float32{
|
||||||
|
"BAYES_99": 5.00,
|
||||||
|
"SPOOFED_SENDER": 3.50,
|
||||||
|
},
|
||||||
|
expectedSymParams: map[string]string{
|
||||||
|
"BAYES_99": "1.00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Zero threshold uses default",
|
||||||
|
header: "default: False [1.00 / 0.00]",
|
||||||
|
expectedScore: 1.00,
|
||||||
|
expectedThreshold: rspamdDefaultAddHeaderThreshold,
|
||||||
|
expectedIsSpam: false,
|
||||||
|
expectedSymbols: map[string]float32{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Symbol without params",
|
||||||
|
header: "default: False [2.00 / 15.00];\n\tMISSING_DATE(1.00)",
|
||||||
|
expectedScore: 2.00,
|
||||||
|
expectedThreshold: 15.00,
|
||||||
|
expectedIsSpam: false,
|
||||||
|
expectedSymbols: map[string]float32{
|
||||||
|
"MISSING_DATE": 1.00,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Case-insensitive true flag",
|
||||||
|
header: "default: true [8.00 / 6.00]",
|
||||||
|
expectedScore: 8.00,
|
||||||
|
expectedThreshold: 6.00,
|
||||||
|
expectedIsSpam: true,
|
||||||
|
expectedSymbols: map[string]float32{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Zero threshold with symbols containing nested brackets in params",
|
||||||
|
header: "default: False [0.90 / 0.00];\n" +
|
||||||
|
"\tARC_REJECT(1.00)[cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}];\n" +
|
||||||
|
"\tMIME_GOOD(-0.10)[multipart/alternative,text/plain];\n" +
|
||||||
|
"\tMIME_TRACE(0.00)[0:+,1:+,2:~]",
|
||||||
|
expectedScore: 0.90,
|
||||||
|
expectedThreshold: rspamdDefaultAddHeaderThreshold,
|
||||||
|
expectedIsSpam: false,
|
||||||
|
expectedSymbols: map[string]float32{
|
||||||
|
"ARC_REJECT": 1.00,
|
||||||
|
"MIME_GOOD": -0.10,
|
||||||
|
"MIME_TRACE": 0.00,
|
||||||
|
},
|
||||||
|
expectedSymParams: map[string]string{
|
||||||
|
"ARC_REJECT": "cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}",
|
||||||
|
"MIME_GOOD": "multipart/alternative,text/plain",
|
||||||
|
"MIME_TRACE": "0:+,1:+,2:~",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewRspamdAnalyzer(nil)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := &api.RspamdResult{
|
||||||
|
Symbols: make(map[string]api.SpamTestDetail),
|
||||||
|
}
|
||||||
|
analyzer.parseSpamdResult(tt.header, result)
|
||||||
|
|
||||||
|
if result.Score != tt.expectedScore {
|
||||||
|
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
|
||||||
|
}
|
||||||
|
if result.Threshold != tt.expectedThreshold {
|
||||||
|
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
|
||||||
|
}
|
||||||
|
if result.IsSpam != tt.expectedIsSpam {
|
||||||
|
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
|
||||||
|
}
|
||||||
|
for symName, expectedScore := range tt.expectedSymbols {
|
||||||
|
sym, ok := result.Symbols[symName]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("Symbol %s not found", symName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sym.Score != expectedScore {
|
||||||
|
t.Errorf("Symbol %s score = %v, want %v", symName, sym.Score, expectedScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for symName, expectedParam := range tt.expectedSymParams {
|
||||||
|
sym, ok := result.Symbols[symName]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("Symbol %s not found for params check", symName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sym.Params == nil {
|
||||||
|
t.Errorf("Symbol %s params = nil, want %q", symName, expectedParam)
|
||||||
|
} else if *sym.Params != expectedParam {
|
||||||
|
t.Errorf("Symbol %s params = %q, want %q", symName, *sym.Params, expectedParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeRspamd(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
headers map[string]string
|
||||||
|
expectedScore float32
|
||||||
|
expectedThreshold float32
|
||||||
|
expectedIsSpam bool
|
||||||
|
expectedServer *string
|
||||||
|
expectedSymCount int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Full headers clean email",
|
||||||
|
headers: map[string]string{
|
||||||
|
"X-Spamd-Result": "default: False [-3.91 / 15.00];\n\tALL_TRUSTED(-1.00)[local]",
|
||||||
|
"X-Rspamd-Score": "-3.91",
|
||||||
|
"X-Rspamd-Server": "mail.example.com",
|
||||||
|
},
|
||||||
|
expectedScore: -3.91,
|
||||||
|
expectedThreshold: 15.00,
|
||||||
|
expectedIsSpam: false,
|
||||||
|
expectedServer: func() *string { s := "mail.example.com"; return &s }(),
|
||||||
|
expectedSymCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "X-Rspamd-Score overrides spamd result score",
|
||||||
|
headers: map[string]string{
|
||||||
|
"X-Spamd-Result": "default: False [2.00 / 15.00]",
|
||||||
|
"X-Rspamd-Score": "3.50",
|
||||||
|
},
|
||||||
|
expectedScore: 3.50,
|
||||||
|
expectedThreshold: 15.00,
|
||||||
|
expectedIsSpam: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Spam email above threshold",
|
||||||
|
headers: map[string]string{
|
||||||
|
"X-Spamd-Result": "default: True [16.00 / 15.00];\n\tBAYES_99(5.00)",
|
||||||
|
"X-Rspamd-Score": "16.00",
|
||||||
|
},
|
||||||
|
expectedScore: 16.00,
|
||||||
|
expectedThreshold: 15.00,
|
||||||
|
expectedIsSpam: true,
|
||||||
|
expectedSymCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No X-Spamd-Result, only X-Rspamd-Score below default threshold",
|
||||||
|
headers: map[string]string{
|
||||||
|
"X-Rspamd-Score": "2.00",
|
||||||
|
},
|
||||||
|
expectedScore: 2.00,
|
||||||
|
expectedIsSpam: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No X-Spamd-Result, X-Rspamd-Score above default add-header threshold",
|
||||||
|
headers: map[string]string{
|
||||||
|
"X-Rspamd-Score": "7.00",
|
||||||
|
},
|
||||||
|
expectedScore: 7.00,
|
||||||
|
expectedIsSpam: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Server header is trimmed",
|
||||||
|
headers: map[string]string{
|
||||||
|
"X-Rspamd-Score": "1.00",
|
||||||
|
"X-Rspamd-Server": " rspamd-01 ",
|
||||||
|
},
|
||||||
|
expectedScore: 1.00,
|
||||||
|
expectedServer: func() *string { s := "rspamd-01"; return &s }(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewRspamdAnalyzer(nil)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
email := &EmailMessage{Header: make(mail.Header)}
|
||||||
|
for k, v := range tt.headers {
|
||||||
|
email.Header[k] = []string{v}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := analyzer.AnalyzeRspamd(email)
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Expected non-nil result")
|
||||||
|
}
|
||||||
|
if result.Score != tt.expectedScore {
|
||||||
|
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
|
||||||
|
}
|
||||||
|
if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold {
|
||||||
|
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
|
||||||
|
}
|
||||||
|
if result.IsSpam != tt.expectedIsSpam {
|
||||||
|
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
|
||||||
|
}
|
||||||
|
if tt.expectedServer != nil {
|
||||||
|
if result.Server == nil {
|
||||||
|
t.Errorf("Server = nil, want %q", *tt.expectedServer)
|
||||||
|
} else if *result.Server != *tt.expectedServer {
|
||||||
|
t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount {
|
||||||
|
t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateRspamdScore(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
result *api.RspamdResult
|
||||||
|
expectedScore int
|
||||||
|
expectedGrade string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Nil result (rspamd not installed)",
|
||||||
|
result: nil,
|
||||||
|
expectedScore: 100,
|
||||||
|
expectedGrade: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Score well below threshold",
|
||||||
|
result: &api.RspamdResult{
|
||||||
|
Score: -3.91,
|
||||||
|
Threshold: 15.00,
|
||||||
|
},
|
||||||
|
expectedScore: 100,
|
||||||
|
expectedGrade: "A+",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Score at zero",
|
||||||
|
result: &api.RspamdResult{
|
||||||
|
Score: 0,
|
||||||
|
Threshold: 15.00,
|
||||||
|
},
|
||||||
|
// 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A"
|
||||||
|
expectedScore: 100,
|
||||||
|
expectedGrade: "A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Score at threshold (half of 2*threshold)",
|
||||||
|
result: &api.RspamdResult{
|
||||||
|
Score: 15.00,
|
||||||
|
Threshold: 15.00,
|
||||||
|
},
|
||||||
|
// 100 - round(15*100/(2*15)) = 100 - 50 = 50
|
||||||
|
expectedScore: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Score above 2*threshold",
|
||||||
|
result: &api.RspamdResult{
|
||||||
|
Score: 31.00,
|
||||||
|
Threshold: 15.00,
|
||||||
|
},
|
||||||
|
expectedScore: 0,
|
||||||
|
expectedGrade: "F",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Score exactly at 2*threshold",
|
||||||
|
result: &api.RspamdResult{
|
||||||
|
Score: 30.00,
|
||||||
|
Threshold: 15.00,
|
||||||
|
},
|
||||||
|
// 100 - round(30*100/30) = 100 - 100 = 0
|
||||||
|
expectedScore: 0,
|
||||||
|
expectedGrade: "F",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewRspamdAnalyzer(nil)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
score, grade := analyzer.CalculateRspamdScore(tt.result)
|
||||||
|
|
||||||
|
if score != tt.expectedScore {
|
||||||
|
t.Errorf("Score = %d, want %d", score, tt.expectedScore)
|
||||||
|
}
|
||||||
|
if tt.expectedGrade != "" && grade != tt.expectedGrade {
|
||||||
|
t.Errorf("Grade = %q, want %q", grade, tt.expectedGrade)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleEmailWithRspamdHeaders = `X-Spamd-Result: default: False [-3.91 / 15.00];
|
||||||
|
BAYES_HAM(-3.00)[99%];
|
||||||
|
RCVD_IN_DNSWL_MED(-0.01)[1.2.3.4:from];
|
||||||
|
R_DKIM_ALLOW(-0.20)[example.com:s=dkim];
|
||||||
|
FROM_HAS_DN(0.00)[];
|
||||||
|
MIME_GOOD(-0.10)[text/plain];
|
||||||
|
X-Rspamd-Score: -3.91
|
||||||
|
X-Rspamd-Server: rspamd-01.example.com
|
||||||
|
Date: Mon, 09 Mar 2026 10:00:00 +0000
|
||||||
|
From: sender@example.com
|
||||||
|
To: test@happydomain.org
|
||||||
|
Subject: Test email
|
||||||
|
Message-ID: <test123@example.com>
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
Hello world`
|
||||||
|
|
||||||
|
func TestAnalyzeRspamdRealEmail(t *testing.T) {
|
||||||
|
email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithRspamdHeaders))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse email: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewRspamdAnalyzer(nil)
|
||||||
|
result := analyzer.AnalyzeRspamd(email)
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Expected non-nil result")
|
||||||
|
}
|
||||||
|
if result.IsSpam {
|
||||||
|
t.Error("Expected IsSpam=false")
|
||||||
|
}
|
||||||
|
if result.Score != -3.91 {
|
||||||
|
t.Errorf("Score = %v, want -3.91", result.Score)
|
||||||
|
}
|
||||||
|
if result.Threshold != 15.00 {
|
||||||
|
t.Errorf("Threshold = %v, want 15.00", result.Threshold)
|
||||||
|
}
|
||||||
|
if result.Server == nil || *result.Server != "rspamd-01.example.com" {
|
||||||
|
t.Errorf("Server = %v, want \"rspamd-01.example.com\"", result.Server)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSymbols := []string{"BAYES_HAM", "RCVD_IN_DNSWL_MED", "R_DKIM_ALLOW", "FROM_HAS_DN", "MIME_GOOD"}
|
||||||
|
for _, sym := range expectedSymbols {
|
||||||
|
if _, ok := result.Symbols[sym]; !ok {
|
||||||
|
t.Errorf("Symbol %s not found", sym)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
score, _ := analyzer.CalculateRspamdScore(result)
|
||||||
|
if score != 100 {
|
||||||
|
t.Errorf("CalculateRspamdScore = %d, want 100", score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -69,3 +69,33 @@ func ScoreToGradeKind(score int) string {
|
||||||
func ScoreToReportGrade(score int) api.ReportGrade {
|
func ScoreToReportGrade(score int) api.ReportGrade {
|
||||||
return api.ReportGrade(ScoreToGrade(score))
|
return api.ReportGrade(ScoreToGrade(score))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gradeRank returns a numeric rank for a grade (lower = worse)
|
||||||
|
func gradeRank(grade string) int {
|
||||||
|
switch grade {
|
||||||
|
case "A++":
|
||||||
|
return 7
|
||||||
|
case "A+":
|
||||||
|
return 6
|
||||||
|
case "A":
|
||||||
|
return 5
|
||||||
|
case "B":
|
||||||
|
return 4
|
||||||
|
case "C":
|
||||||
|
return 3
|
||||||
|
case "D":
|
||||||
|
return 2
|
||||||
|
case "E":
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinGrade returns the minimal (worse) grade between the two given grades
|
||||||
|
func MinGrade(a, b string) string {
|
||||||
|
if gradeRank(a) <= gradeRank(b) {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,20 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Require at least X-Spam-Status, X-Spam-Score, or X-Spam-Flag to produce a meaningful report
|
||||||
|
_, hasStatus := headers["X-Spam-Status"]
|
||||||
|
_, hasScore := headers["X-Spam-Score"]
|
||||||
|
_, hasFlag := headers["X-Spam-Flag"]
|
||||||
|
if !hasStatus && !hasScore && !hasFlag {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
result := &api.SpamAssassinResult{
|
result := &api.SpamAssassinResult{
|
||||||
TestDetails: make(map[string]api.SpamTestDetail),
|
TestDetails: make(map[string]api.SpamTestDetail),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse X-Spam-Status header
|
// Parse X-Spam-Status header
|
||||||
if statusHeader, ok := headers["X-Spam-Status"]; ok {
|
if statusHeader, ok := headers["X-Spam-Status"]; ok && statusHeader != "" {
|
||||||
a.parseSpamStatus(statusHeader, result)
|
a.parseSpamStatus(statusHeader, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
2274
web/package-lock.json
generated
2274
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,13 +17,13 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.0.0",
|
"@eslint/compat": "^2.0.0",
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^10.0.0",
|
||||||
"@hey-api/openapi-ts": "0.86.10",
|
"@hey-api/openapi-ts": "0.86.10",
|
||||||
"@sveltejs/adapter-static": "^3.0.9",
|
"@sveltejs/adapter-static": "^3.0.9",
|
||||||
"@sveltejs/kit": "^2.43.2",
|
"@sveltejs/kit": "^2.43.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"eslint": "^9.38.0",
|
"eslint": "^10.0.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.12.4",
|
"eslint-plugin-svelte": "^3.12.4",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
"svelte-check": "^4.3.2",
|
"svelte-check": "^4.3.2",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"typescript-eslint": "^8.44.1",
|
"typescript-eslint": "^8.44.1",
|
||||||
"vite": "^7.1.10",
|
"vite": "^8.0.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,19 @@
|
||||||
|
|
||||||
let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props();
|
let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props();
|
||||||
|
|
||||||
|
let allRequiredMissing = $derived(
|
||||||
|
!authentication.spf &&
|
||||||
|
(!authentication.dkim || authentication.dkim.length === 0) &&
|
||||||
|
!authentication.dmarc,
|
||||||
|
);
|
||||||
|
|
||||||
function getAuthResultClass(result: string, noneIsFail: boolean): string {
|
function getAuthResultClass(result: string, noneIsFail: boolean): string {
|
||||||
switch (result) {
|
switch (result) {
|
||||||
case "pass":
|
case "pass":
|
||||||
case "domain_pass":
|
case "domain_pass":
|
||||||
case "orgdomain_pass":
|
case "orgdomain_pass":
|
||||||
return "text-success";
|
return "text-success";
|
||||||
|
case "permerror":
|
||||||
case "error":
|
case "error":
|
||||||
case "fail":
|
case "fail":
|
||||||
case "missing":
|
case "missing":
|
||||||
|
|
@ -51,6 +58,7 @@
|
||||||
case "neutral":
|
case "neutral":
|
||||||
case "invalid":
|
case "invalid":
|
||||||
case "null":
|
case "null":
|
||||||
|
case "permerror":
|
||||||
case "error":
|
case "error":
|
||||||
case "null_smtp":
|
case "null_smtp":
|
||||||
case "null_header":
|
case "null_header":
|
||||||
|
|
@ -95,6 +103,28 @@
|
||||||
</span>
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
{#if allRequiredMissing}
|
||||||
|
<div class="card-body border-bottom">
|
||||||
|
<div class="alert alert-warning mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
<strong>No authentication results found.</strong>
|
||||||
|
<p class="mb-0 mt-1">
|
||||||
|
This usually means either:
|
||||||
|
</p>
|
||||||
|
<ul class="mb-0 mt-1">
|
||||||
|
<li>
|
||||||
|
The receiving mail server is not configured to verify email authentication
|
||||||
|
(no <code>Authentication-Results</code> header was found in the message).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The <code>Authentication-Results</code> header exists but the receiver
|
||||||
|
hostname does not match the configured
|
||||||
|
<code>--receiver-hostname</code> value.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
<!-- IPREV -->
|
<!-- IPREV -->
|
||||||
{#if authentication.iprev}
|
{#if authentication.iprev}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,21 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { BlacklistCheck, ReceivedHop } from "$lib/api/types.gen";
|
import type { BlacklistCheck } from "$lib/api/types.gen";
|
||||||
import { getScoreColorClass } from "$lib/score";
|
import { getScoreColorClass } from "$lib/score";
|
||||||
import { theme } from "$lib/stores/theme";
|
import { theme } from "$lib/stores/theme";
|
||||||
import EmailPathCard from "./EmailPathCard.svelte";
|
|
||||||
import GradeDisplay from "./GradeDisplay.svelte";
|
import GradeDisplay from "./GradeDisplay.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
blacklists: Record<string, BlacklistCheck[]>;
|
blacklists: Record<string, BlacklistCheck[]>;
|
||||||
blacklistGrade?: string;
|
blacklistGrade?: string;
|
||||||
blacklistScore?: number;
|
blacklistScore?: number;
|
||||||
receivedChain?: ReceivedHop[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { blacklists, blacklistGrade, blacklistScore, receivedChain }: Props = $props();
|
let { blacklists, blacklistGrade, blacklistScore }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card shadow-sm" id="rbl-details">
|
<div class="card shadow-sm" id="rbl-details">
|
||||||
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
||||||
<h4 class="mb-0 d-flex justify-content-between align-items-center">
|
<h4 class="mb-0 d-flex flex-wrap justify-content-between align-items-center">
|
||||||
<span>
|
<span>
|
||||||
<i class="bi bi-shield-exclamation me-2"></i>
|
<i class="bi bi-shield-exclamation me-2"></i>
|
||||||
Blacklist Checks
|
Blacklist Checks
|
||||||
|
|
@ -35,11 +33,7 @@
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{#if receivedChain}
|
<div class="row row-cols-1 row-cols-lg-2 overflow-auto">
|
||||||
<EmailPathCard {receivedChain} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="row row-cols-1 row-cols-lg-2">
|
|
||||||
{#each Object.entries(blacklists) as [ip, checks]}
|
{#each Object.entries(blacklists) as [ip, checks]}
|
||||||
<div class="col mb-3">
|
<div class="col mb-3">
|
||||||
<h5 class="text-muted">
|
<h5 class="text-muted">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ReceivedHop } from "$lib/api/types.gen";
|
import type { ReceivedHop } from "$lib/api/types.gen";
|
||||||
|
import { theme } from "$lib/stores/theme";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
receivedChain: ReceivedHop[];
|
receivedChain: ReceivedHop[];
|
||||||
|
|
@ -9,9 +10,18 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if receivedChain && receivedChain.length > 0}
|
{#if receivedChain && receivedChain.length > 0}
|
||||||
<div class="mb-3" id="email-path">
|
<div class="card shadow-sm" id="email-path">
|
||||||
<h5>Email Path (Received Chain)</h5>
|
<div
|
||||||
<div class="list-group">
|
class="card-header"
|
||||||
|
class:bg-white={$theme === "light"}
|
||||||
|
class:bg-dark={$theme !== "light"}
|
||||||
|
>
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="bi bi-pin-map me-2"></i>
|
||||||
|
Email Path
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
{#each receivedChain as hop, i}
|
{#each receivedChain as hop, i}
|
||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="d-flex w-100 justify-content-between">
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
|
@ -30,7 +40,7 @@
|
||||||
: "-"}
|
: "-"}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
{#if hop.with || hop.id}
|
{#if hop.with || hop.id || hop.from}
|
||||||
<p class="mb-1 small d-flex gap-3">
|
<p class="mb-1 small d-flex gap-3">
|
||||||
{#if hop.with}
|
{#if hop.with}
|
||||||
<span>
|
<span>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,11 @@
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasForwardRecords = $derived(ptrForwardRecords && ptrForwardRecords.length > 0);
|
const hasForwardRecords = $derived(ptrForwardRecords && ptrForwardRecords.length > 0);
|
||||||
|
|
||||||
|
let showDifferent = $state(false);
|
||||||
|
const differentCount = $derived(
|
||||||
|
ptrForwardRecords ? ptrForwardRecords.filter((ip) => ip !== senderIp).length : 0,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if ptrRecords && ptrRecords.length > 0}
|
{#if ptrRecords && ptrRecords.length > 0}
|
||||||
|
|
@ -63,15 +68,31 @@
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<strong>Forward Resolution (A/AAAA):</strong>
|
<strong>Forward Resolution (A/AAAA):</strong>
|
||||||
{#each ptrForwardRecords as ip}
|
{#each ptrForwardRecords as ip}
|
||||||
<div class="d-flex gap-2 align-items-center mt-1">
|
{#if ip === senderIp || !fcrDnsIsValid || showDifferent}
|
||||||
{#if senderIp && ip === senderIp}
|
<div class="d-flex gap-2 align-items-center mt-1">
|
||||||
<span class="badge bg-success">Match</span>
|
{#if senderIp && ip === senderIp}
|
||||||
{:else}
|
<span class="badge bg-success">Match</span>
|
||||||
<span class="badge bg-warning">Different</span>
|
{:else}
|
||||||
{/if}
|
<span class="badge bg-secondary">Different</span>
|
||||||
<code>{ip}</code>
|
{/if}
|
||||||
</div>
|
<code>{ip}</code>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if fcrDnsIsValid && differentCount > 0}
|
||||||
|
<div class="mt-1">
|
||||||
|
<button
|
||||||
|
class="btn btn-link btn-sm p-0 text-muted"
|
||||||
|
onclick={() => (showDifferent = !showDifferent)}
|
||||||
|
>
|
||||||
|
{#if showDifferent}
|
||||||
|
Hide other IPs
|
||||||
|
{:else}
|
||||||
|
Show {differentCount} other IP{differentCount > 1 ? 's' : ''} (not the sender)
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if fcrDnsIsValid}
|
{#if fcrDnsIsValid}
|
||||||
<div class="alert alert-success mb-0 mt-2">
|
<div class="alert alert-success mb-0 mt-2">
|
||||||
|
|
|
||||||
153
web/src/lib/components/RspamdCard.svelte
Normal file
153
web/src/lib/components/RspamdCard.svelte
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { RspamdResult } from "$lib/api/types.gen";
|
||||||
|
import { getScoreColorClass } from "$lib/score";
|
||||||
|
import { theme } from "$lib/stores/theme";
|
||||||
|
import GradeDisplay from "./GradeDisplay.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rspamd: RspamdResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { rspamd }: Props = $props();
|
||||||
|
|
||||||
|
// Derive effective action from score vs known rspamd default thresholds.
|
||||||
|
// The action header is unreliable in milter setups (always "no action").
|
||||||
|
const RSPAMD_GREYLIST_THRESHOLD = 4;
|
||||||
|
const RSPAMD_ADD_HEADER_THRESHOLD = 6;
|
||||||
|
|
||||||
|
const effectiveAction = $derived.by(() => {
|
||||||
|
const rejectThreshold = rspamd.threshold > 0 ? rspamd.threshold : 15;
|
||||||
|
if (rspamd.score >= rejectThreshold) return { label: "Reject", cls: "bg-danger" };
|
||||||
|
if (rspamd.score >= RSPAMD_ADD_HEADER_THRESHOLD)
|
||||||
|
return { label: "Add header", cls: "bg-warning text-dark" };
|
||||||
|
if (rspamd.score >= RSPAMD_GREYLIST_THRESHOLD)
|
||||||
|
return { label: "Greylist", cls: "bg-warning text-dark" };
|
||||||
|
return { label: "No action", cls: "bg-success" };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card shadow-sm" id="rspamd-details">
|
||||||
|
<div class="card-header {$theme === 'light' ? 'bg-white' : 'bg-dark'}">
|
||||||
|
<h4 class="mb-0 d-flex justify-content-between align-items-center">
|
||||||
|
<span>
|
||||||
|
<i class="bi bi-bug me-2"></i>
|
||||||
|
rspamd Analysis
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{#if rspamd.deliverability_score !== undefined}
|
||||||
|
<span class="badge bg-{getScoreColorClass(rspamd.deliverability_score)}">
|
||||||
|
{rspamd.deliverability_score}%
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if rspamd.deliverability_grade !== undefined}
|
||||||
|
<GradeDisplay grade={rspamd.deliverability_grade} size="small" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<strong>Score:</strong>
|
||||||
|
<span class={rspamd.is_spam ? "text-danger" : "text-success"}>
|
||||||
|
{rspamd.score.toFixed(2)} / {rspamd.threshold.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<strong>Classified as:</strong>
|
||||||
|
<span class="badge {rspamd.is_spam ? 'bg-danger' : 'bg-success'} ms-2">
|
||||||
|
{rspamd.is_spam ? "SPAM" : "HAM"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<strong>Action:</strong>
|
||||||
|
<span class="badge {effectiveAction.cls} ms-2">
|
||||||
|
{effectiveAction.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if rspamd.symbols && Object.keys(rspamd.symbols).length > 0}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="table-responsive mt-2">
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Symbol</th>
|
||||||
|
<th class="text-end">Score</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each Object.entries(rspamd.symbols).sort(([, a], [, b]) => b.score - a.score) as [symbolName, symbol]}
|
||||||
|
<tr
|
||||||
|
class={symbol.score > 0
|
||||||
|
? "table-warning"
|
||||||
|
: symbol.score < 0
|
||||||
|
? "table-success"
|
||||||
|
: ""}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span class="font-monospace">{symbolName}</span>
|
||||||
|
{#if symbol.params}
|
||||||
|
<small class="d-block text-muted">
|
||||||
|
{symbol.params}
|
||||||
|
</small>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span
|
||||||
|
class={symbol.score > 0
|
||||||
|
? "text-danger fw-bold"
|
||||||
|
: symbol.score < 0
|
||||||
|
? "text-success fw-bold"
|
||||||
|
: "text-muted"}
|
||||||
|
>
|
||||||
|
{symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted">{symbol.description ?? ""}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if rspamd.report}
|
||||||
|
<details class="mt-3">
|
||||||
|
<summary class="cursor-pointer fw-bold">Raw Report</summary>
|
||||||
|
<pre
|
||||||
|
class="mt-2 small {$theme === 'light'
|
||||||
|
? 'bg-light'
|
||||||
|
: 'bg-secondary'} p-3 rounded">{rspamd.report}</pre>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
details summary {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -6,11 +6,9 @@
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
spamassassin: SpamAssassinResult;
|
spamassassin: SpamAssassinResult;
|
||||||
spamGrade?: string;
|
|
||||||
spamScore?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { spamassassin, spamGrade, spamScore }: Props = $props();
|
let { spamassassin }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card shadow-sm" id="spam-details">
|
<div class="card shadow-sm" id="spam-details">
|
||||||
|
|
@ -21,13 +19,13 @@
|
||||||
SpamAssassin Analysis
|
SpamAssassin Analysis
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{#if spamScore !== undefined}
|
{#if spamassassin.deliverability_score !== undefined}
|
||||||
<span class="badge bg-{getScoreColorClass(spamScore)}">
|
<span class="badge bg-{getScoreColorClass(spamassassin.deliverability_score)}">
|
||||||
{spamScore}%
|
{spamassassin.deliverability_score}%
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if spamGrade !== undefined}
|
{#if spamassassin.deliverability_grade !== undefined}
|
||||||
<GradeDisplay grade={spamGrade} size="small" />
|
<GradeDisplay grade={spamassassin.deliverability_grade} size="small" />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,32 @@
|
||||||
|
|
||||||
// Email sender information
|
// Email sender information
|
||||||
const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender";
|
const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender";
|
||||||
const hasDkim = report.authentication?.dkim && report.authentication?.dkim.length > 0;
|
const hasDkim =
|
||||||
const dkimPassed = hasDkim && report.authentication?.dkim?.some((d) => d.result === "pass");
|
report.dns_results?.dkim_records && report.dns_results?.dkim_records?.length > 0;
|
||||||
|
const dkimPassed =
|
||||||
|
report.authentication?.dkim &&
|
||||||
|
report.authentication?.dkim.length > 0 &&
|
||||||
|
report.authentication?.dkim?.some((d) => d.result === "pass");
|
||||||
|
|
||||||
segments.push({ text: "Received a " });
|
segments.push({ text: "Received a " });
|
||||||
segments.push({
|
segments.push({
|
||||||
text: dkimPassed ? "DKIM-signed" : "non-DKIM-signed",
|
text: hasDkim ? "DKIM-signed" : "non-DKIM-signed",
|
||||||
highlight: { color: dkimPassed ? "good" : "danger", bold: true },
|
highlight: {
|
||||||
link: "#authentication-dkim",
|
color: hasDkim ? (dkimPassed ? "good" : "warning") : "danger",
|
||||||
|
bold: true,
|
||||||
|
},
|
||||||
|
link: hasDkim && dkimPassed ? "#authentication-dkim" : "#dns-details",
|
||||||
});
|
});
|
||||||
segments.push({ text: " email from " });
|
segments.push({ text: " email" });
|
||||||
|
if (hasDkim && !dkimPassed) {
|
||||||
|
segments.push({ text: " with " });
|
||||||
|
segments.push({
|
||||||
|
text: "an invalid signature",
|
||||||
|
highlight: { color: "danger", bold: true },
|
||||||
|
link: "#authentication-dkim",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
segments.push({ text: " from " });
|
||||||
segments.push({
|
segments.push({
|
||||||
text: mailFrom,
|
text: mailFrom,
|
||||||
highlight: { emphasis: true },
|
highlight: { emphasis: true },
|
||||||
|
|
@ -113,7 +129,7 @@
|
||||||
} else if (spfResult === "temperror" || spfResult === "permerror") {
|
} else if (spfResult === "temperror" || spfResult === "permerror") {
|
||||||
segments.push({
|
segments.push({
|
||||||
text: "encountered an error",
|
text: "encountered an error",
|
||||||
highlight: { color: "warning", bold: true },
|
highlight: { color: "danger", bold: true },
|
||||||
link: "#authentication-spf",
|
link: "#authentication-spf",
|
||||||
});
|
});
|
||||||
segments.push({ text: ", check your SPF record configuration" });
|
segments.push({ text: ", check your SPF record configuration" });
|
||||||
|
|
@ -331,7 +347,7 @@
|
||||||
highlight: { color: "good", bold: true },
|
highlight: { color: "good", bold: true },
|
||||||
link: "#dns-bimi",
|
link: "#dns-bimi",
|
||||||
});
|
});
|
||||||
if (bimiResult.details && bimiResult.details.indexOf("declined") == 0) {
|
if (bimiResult?.details && bimiResult.details.indexOf("declined") == 0) {
|
||||||
segments.push({ text: " declined to participate" });
|
segments.push({ text: " declined to participate" });
|
||||||
} else if (bimiResult?.result === "fail") {
|
} else if (bimiResult?.result === "fail") {
|
||||||
segments.push({ text: " but " });
|
segments.push({ text: " but " });
|
||||||
|
|
@ -422,6 +438,17 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-click unsubscribe check
|
||||||
|
const unsubscribeMethods = report.content_analysis?.unsubscribe_methods;
|
||||||
|
if (unsubscribeMethods && unsubscribeMethods.length > 0 && !unsubscribeMethods.includes("one-click")) {
|
||||||
|
segments.push({ text: ". This email could benefit from " });
|
||||||
|
segments.push({
|
||||||
|
text: "one-click unsubscribe",
|
||||||
|
highlight: { color: "warning", bold: true },
|
||||||
|
link: "#content-details",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Content/spam assessment
|
// Content/spam assessment
|
||||||
const spamAssassin = report.spamassassin;
|
const spamAssassin = report.spamassassin;
|
||||||
const contentScore = report.summary?.content_score || 0;
|
const contentScore = report.summary?.content_score || 0;
|
||||||
|
|
|
||||||
62
web/src/lib/components/WhitelistCard.svelte
Normal file
62
web/src/lib/components/WhitelistCard.svelte
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { BlacklistCheck } from "$lib/api/types.gen";
|
||||||
|
import { theme } from "$lib/stores/theme";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
whitelists: Record<string, BlacklistCheck[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { whitelists }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card shadow-sm" id="dnswl-details">
|
||||||
|
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
||||||
|
<h4 class="mb-0 d-flex flex-wrap justify-content-between align-items-center">
|
||||||
|
<span>
|
||||||
|
<i class="bi bi-shield-check me-2"></i>
|
||||||
|
Whitelist Checks
|
||||||
|
</span>
|
||||||
|
<span class="badge bg-info text-white">Informational</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
DNS whitelists identify trusted senders. Being listed here is a positive signal, but has
|
||||||
|
no impact on the overall score.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-lg-2 overflow-auto">
|
||||||
|
{#each Object.entries(whitelists) as [ip, checks]}
|
||||||
|
<div class="col mb-3">
|
||||||
|
<h5 class="text-muted">
|
||||||
|
<i class="bi bi-hdd-network me-1"></i>
|
||||||
|
{ip}
|
||||||
|
</h5>
|
||||||
|
<table class="table table-sm table-striped table-hover mb-0">
|
||||||
|
<tbody>
|
||||||
|
{#each checks as check}
|
||||||
|
<tr>
|
||||||
|
<td title={check.response || "-"}>
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
class:bg-success={check.listed}
|
||||||
|
class:bg-dark={check.error}
|
||||||
|
class:bg-secondary={!check.listed && !check.error}
|
||||||
|
>
|
||||||
|
{check.error
|
||||||
|
? "Error"
|
||||||
|
: check.listed
|
||||||
|
? "Listed"
|
||||||
|
: "Not listed"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><code>{check.rbl}</code></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -19,7 +19,9 @@ export { default as PendingState } from "./PendingState.svelte";
|
||||||
export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte";
|
export { default as PtrForwardRecordsDisplay } from "./PtrForwardRecordsDisplay.svelte";
|
||||||
export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte";
|
export { default as PtrRecordsDisplay } from "./PtrRecordsDisplay.svelte";
|
||||||
export { default as ScoreCard } from "./ScoreCard.svelte";
|
export { default as ScoreCard } from "./ScoreCard.svelte";
|
||||||
|
export { default as RspamdCard } from "./RspamdCard.svelte";
|
||||||
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
|
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
|
||||||
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
|
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
|
||||||
export { default as SummaryCard } from "./SummaryCard.svelte";
|
export { default as SummaryCard } from "./SummaryCard.svelte";
|
||||||
export { default as TinySurvey } from "./TinySurvey.svelte";
|
export { default as TinySurvey } from "./TinySurvey.svelte";
|
||||||
|
export { default as WhitelistCard } from "./WhitelistCard.svelte";
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ interface AppConfig {
|
||||||
report_retention?: number;
|
report_retention?: number;
|
||||||
survey_url?: string;
|
survey_url?: string;
|
||||||
custom_logo_url?: string;
|
custom_logo_url?: string;
|
||||||
|
rbls?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: AppConfig = {
|
const defaultConfig: AppConfig = {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ const getInitialTheme = () => {
|
||||||
if (!browser) return "light";
|
if (!browser) return "light";
|
||||||
|
|
||||||
const stored = localStorage.getItem("theme");
|
const stored = localStorage.getItem("theme");
|
||||||
if (stored) return stored;
|
if (stored === "light" || stored === "dark") return stored;
|
||||||
|
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { checkBlacklist } from "$lib/api";
|
import { checkBlacklist } from "$lib/api";
|
||||||
import type { BlacklistCheckResponse } from "$lib/api/types.gen";
|
import type { BlacklistCheckResponse } from "$lib/api/types.gen";
|
||||||
import { BlacklistCard, GradeDisplay, TinySurvey } from "$lib/components";
|
import { BlacklistCard, GradeDisplay, TinySurvey, WhitelistCard } from "$lib/components";
|
||||||
import { theme } from "$lib/stores/theme";
|
import { theme } from "$lib/stores/theme";
|
||||||
|
|
||||||
let ip = $derived($page.params.ip);
|
let ip = $derived($page.params.ip);
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.response.ok) {
|
if (response.response.ok) {
|
||||||
result = response.data;
|
result = response.data ?? null;
|
||||||
} else if (response.error) {
|
} else if (response.error) {
|
||||||
error = response.error.message || "Failed to check IP address";
|
error = response.error.message || "Failed to check IP address";
|
||||||
}
|
}
|
||||||
|
|
@ -122,8 +122,8 @@
|
||||||
>
|
>
|
||||||
<p class="mb-0 mt-1 small">
|
<p class="mb-0 mt-1 small">
|
||||||
This IP address is listed on {result.listed_count} of
|
This IP address is listed on {result.listed_count} of
|
||||||
{result.checks.length} checked blacklist{result
|
{result.blacklists.length} checked blacklist{result
|
||||||
.checks.length > 1
|
.blacklists.length > 1
|
||||||
? "s"
|
? "s"
|
||||||
: ""}.
|
: ""}.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -150,12 +150,23 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Blacklist Results Card -->
|
<div class="row">
|
||||||
<BlacklistCard
|
<!-- Blacklist Results Card -->
|
||||||
blacklists={{ [result.ip]: result.checks }}
|
<div class="col col-lg-6">
|
||||||
blacklistScore={result.score}
|
<BlacklistCard
|
||||||
blacklistGrade={result.grade}
|
blacklists={{ [result.ip]: result.blacklists }}
|
||||||
/>
|
blacklistScore={result.score}
|
||||||
|
blacklistGrade={result.grade}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Whitelist Results Card -->
|
||||||
|
{#if result.whitelists && result.whitelists.length > 0}
|
||||||
|
<div class="col col-lg-6">
|
||||||
|
<WhitelistCard whitelists={{ [result.ip]: result.whitelists }} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Information Card -->
|
<!-- Information Card -->
|
||||||
<div class="card shadow-sm mt-4">
|
<div class="card shadow-sm mt-4">
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@
|
||||||
<div class="d-flex justify-content-end me-lg-5 mt-3">
|
<div class="d-flex justify-content-end me-lg-5 mt-3">
|
||||||
<TinySurvey
|
<TinySurvey
|
||||||
class="bg-primary-subtle rounded-4 p-3 text-center"
|
class="bg-primary-subtle rounded-4 p-3 text-center"
|
||||||
source={"rbl-" + result.ip}
|
source={"domain-" + result.domain}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,26 @@
|
||||||
import { onDestroy } from "svelte";
|
import { onDestroy } from "svelte";
|
||||||
|
|
||||||
import { getReport, getTest, reanalyzeReport } from "$lib/api";
|
import { getReport, getTest, reanalyzeReport } from "$lib/api";
|
||||||
import type { Report, Test } from "$lib/api/types.gen";
|
import type { BlacklistCheck, Report, Test } from "$lib/api/types.gen";
|
||||||
import {
|
import {
|
||||||
AuthenticationCard,
|
AuthenticationCard,
|
||||||
BlacklistCard,
|
BlacklistCard,
|
||||||
ContentAnalysisCard,
|
ContentAnalysisCard,
|
||||||
DnsRecordsCard,
|
DnsRecordsCard,
|
||||||
|
EmailPathCard,
|
||||||
ErrorDisplay,
|
ErrorDisplay,
|
||||||
HeaderAnalysisCard,
|
HeaderAnalysisCard,
|
||||||
PendingState,
|
PendingState,
|
||||||
|
RspamdCard,
|
||||||
ScoreCard,
|
ScoreCard,
|
||||||
SpamAssassinCard,
|
SpamAssassinCard,
|
||||||
SummaryCard,
|
SummaryCard,
|
||||||
TinySurvey,
|
TinySurvey,
|
||||||
|
WhitelistCard,
|
||||||
} from "$lib/components";
|
} from "$lib/components";
|
||||||
|
|
||||||
|
type BlacklistRecords = Record<string, BlacklistCheck[]>;
|
||||||
|
|
||||||
let testId = $derived(page.params.test);
|
let testId = $derived(page.params.test);
|
||||||
let test = $state<Test | null>(null);
|
let test = $state<Test | null>(null);
|
||||||
let report = $state<Report | null>(null);
|
let report = $state<Report | null>(null);
|
||||||
|
|
@ -290,6 +295,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Received Chain -->
|
||||||
|
{#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0}
|
||||||
|
<div class="row mb-4" id="received-chain">
|
||||||
|
<div class="col-12">
|
||||||
|
<EmailPathCard receivedChain={report.header_analysis.received_chain} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- DNS Records -->
|
<!-- DNS Records -->
|
||||||
{#if report.dns_results}
|
{#if report.dns_results}
|
||||||
<div class="row mb-4" id="dns">
|
<div class="row mb-4" id="dns">
|
||||||
|
|
@ -320,17 +334,45 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Blacklist Checks -->
|
<!-- Blacklist Checks -->
|
||||||
{#if report.blacklists && Object.keys(report.blacklists).length > 0}
|
{#snippet blacklistChecks(blacklists: BlacklistRecords, report: Report)}
|
||||||
<div class="row mb-4" id="blacklist">
|
<BlacklistCard
|
||||||
<div class="col-12">
|
{blacklists}
|
||||||
<BlacklistCard
|
blacklistGrade={report.summary?.blacklist_grade}
|
||||||
blacklists={report.blacklists}
|
blacklistScore={report.summary?.blacklist_score}
|
||||||
blacklistGrade={report.summary?.blacklist_grade}
|
/>
|
||||||
blacklistScore={report.summary?.blacklist_score}
|
{/snippet}
|
||||||
receivedChain={report.header_analysis?.received_chain}
|
|
||||||
/>
|
<!-- Whitelist Checks -->
|
||||||
|
{#snippet whitelistChecks(whitelists: BlacklistRecords)}
|
||||||
|
<WhitelistCard {whitelists} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- Blacklist & Whitelist Checks -->
|
||||||
|
{#if report.blacklists && report.whitelists && Object.keys(report.blacklists).length == 1 && Object.keys(report.whitelists).length == 1}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-6" id="blacklist">
|
||||||
|
{@render blacklistChecks(report.blacklists, report)}
|
||||||
|
</div>
|
||||||
|
<div class="col-6" id="whitelist">
|
||||||
|
{@render whitelistChecks(report.whitelists)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if report.blacklists && Object.keys(report.blacklists).length > 0}
|
||||||
|
<div class="row mb-4" id="blacklist">
|
||||||
|
<div class="col-12">
|
||||||
|
{@render blacklistChecks(report.blacklists, report)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if report.whitelists && Object.keys(report.whitelists).length > 0}
|
||||||
|
<div class="row mb-4" id="whitelist">
|
||||||
|
<div class="col-12">
|
||||||
|
{@render whitelistChecks(report.whitelists)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Header Analysis -->
|
<!-- Header Analysis -->
|
||||||
|
|
@ -347,16 +389,19 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Additional Information -->
|
<!-- Spam filter analysis -->
|
||||||
{#if report.spamassassin}
|
{#if report.spamassassin || report.rspamd}
|
||||||
<div class="row mb-4" id="spam">
|
<div class="row mb-4" id="spam">
|
||||||
<div class="col-12">
|
{#if report.spamassassin}
|
||||||
<SpamAssassinCard
|
<div class={report.rspamd ? "col col-lg-6 mb-4 mb-lg-0" : "col-12"}>
|
||||||
spamassassin={report.spamassassin}
|
<SpamAssassinCard spamassassin={report.spamassassin} />
|
||||||
spamGrade={report.summary?.spam_grade}
|
</div>
|
||||||
spamScore={report.summary?.spam_score}
|
{/if}
|
||||||
/>
|
{#if report.rspamd}
|
||||||
</div>
|
<div class={report.spamassassin ? "col col-lg-6" : "col-12"}>
|
||||||
|
<RspamdCard rspamd={report.rspamd} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue