Merge upstream v0.16.25 #155
No files matched your search
@@ -5,11 +5,6 @@
|
|||||||
|
|
||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "cargo" # See documentation for possible values
|
|
||||||
directory: "/" # Location of package manifests
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
|
|
||||||
# Enable version updates for GitHub Actions
|
# Enable version updates for GitHub Actions
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
# Workflow files stored in the default location of `.github/workflows`
|
# Workflow files stored in the default location of `.github/workflows`
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Close issues from non-allowed authors
|
- name: Close issues from non-allowed authors
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
// Users allowed to open issues directly. All other authors will have
|
// Users allowed to open issues directly. All other authors will have
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
sparse-checkout-cone-mode: false
|
sparse-checkout-cone-mode: false
|
||||||
|
|
||||||
- name: Close PRs from non-allowed authors
|
- name: Close PRs from non-allowed authors
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Post support portal redirect
|
- name: Post support portal redirect
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const discussion = context.payload.discussion;
|
const discussion = context.payload.discussion;
|
||||||
|
|||||||
@@ -73,6 +73,6 @@ jobs:
|
|||||||
# Upload the results to GitHub's code scanning dashboard (optional).
|
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||||
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||||
- name: "Upload to code-scanning"
|
- name: "Upload to code-scanning"
|
||||||
uses: github/codeql-action/[email protected]7.4
|
uses: github/codeql-action/[email protected]8.2
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
@@ -36,6 +36,6 @@ jobs:
|
|||||||
severity: 'CRITICAL,HIGH'
|
severity: 'CRITICAL,HIGH'
|
||||||
|
|
||||||
- name: Upload Trivy scan results to GitHub Security tab
|
- name: Upload Trivy scan results to GitHub Security tab
|
||||||
uses: github/codeql-action/[email protected]7.4
|
uses: github/codeql-action/[email protected]8.2
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-results.sarif'
|
sarif_file: 'trivy-results.sarif'
|
||||||
@@ -2,6 +2,33 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
## [0.16.25] - 2026-10-05
|
||||||
|
|
||||||
|
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
- JMAP: Creating a `MaskedEmail` with `emailDomain` fails with `forbidden` for every domain when the account has addresses on more than one domain.
|
||||||
|
- Autodiscover: Requests for a response schema other than Outlook's, such as ActiveSync (`mobilesync`), are answered with the Outlook settings instead of error 601.
|
||||||
|
- IMAP:
|
||||||
|
- `LOGIN` and `AUTHENTICATE` with a wrong, expired or unknown app password or API key are answered with an untagged `NO`, so clients keep waiting for the command to complete until the connection times out.
|
||||||
|
- The failed login that exceeds the maximum number of authentication failures is answered with an untagged `NO` before the connection is closed.
|
||||||
|
- DKIM:
|
||||||
|
- A rotation moves the active key to retiring even when its successor fails to publish or propagate, so outgoing mail is sent unsigned until a retry publishes the new key. The DNS write failure is also not logged and the task reports success.
|
||||||
|
- Keys created while DNS management was manual, or before DKIM was added to the published records, are never rotated after DNS management becomes automatic. Domains already affected start rotating once a `DkimManagement` task is created for them.
|
||||||
|
- After switching DNS management from automatic to manual, a due rotation activates a new key that was never published in DNS, so signatures fail verification, and retiring the old key is retried forever.
|
||||||
|
- Spam filter:
|
||||||
|
- Messages with no text line long enough for a Pyzor digest are checked with the digest of empty input and tagged `PYZOR`.
|
||||||
|
- DNSBL answers with several return codes, such as a Spamhaus ZEN listing in both SBL and PBL, are scored for only the first code returned.
|
||||||
|
- DNSBL lookups that return "not listed" are cached for 24 hours regardless of the zone's negative TTL.
|
||||||
|
- Removing a duplicate training sample of a message reclassified on the same day clears the blob link of the sample that is kept.
|
||||||
|
- MTA: Queue quotas with an empty `match` expression are never enforced, including the global queue quota created on first start.
|
||||||
|
- RocksDB: The info log (`LOG`, `LOG.old.*`) grows without limit because log rotation and retention are left at RocksDB defaults.
|
||||||
|
- WebUI: A blob store read error at startup, such as an S3 authentication failure, stops the web interface from being downloaded.
|
||||||
|
|
||||||
## [0.16.24] - 2026-09-27
|
## [0.16.24] - 2026-09-27
|
||||||
|
|
||||||
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
|
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
|
||||||
|
|||||||
Generated
+79
-69
@@ -277,9 +277,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.48"
|
version = "0.4.50"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
|
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"compression-codecs",
|
"compression-codecs",
|
||||||
"compression-core",
|
"compression-core",
|
||||||
@@ -1292,7 +1292,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "common"
|
name = "common"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm-siv",
|
"aes-gcm-siv",
|
||||||
"ahash",
|
"ahash",
|
||||||
@@ -1392,9 +1392,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.43"
|
version = "0.4.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
|
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"compression-core",
|
"compression-core",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -1477,7 +1477,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coordinator"
|
name = "coordinator"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-nats",
|
"async-nats",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -1889,7 +1889,7 @@ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dav"
|
name = "dav"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"calcard",
|
"calcard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1912,7 +1912,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dav-proto"
|
name = "dav-proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"calcard",
|
"calcard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -2125,7 +2125,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "directory"
|
name = "directory"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"argon2 0.6.0",
|
"argon2 0.6.0",
|
||||||
@@ -2366,7 +2366,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "email"
|
name = "email"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.9.3",
|
"aes 0.9.3",
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
@@ -2406,6 +2406,16 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encodify"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "798c447647dd23f673748f2b868ef309a01dd86aaae999182559d36f06ac82f0"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"simdutf8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.42"
|
version = "0.8.42"
|
||||||
@@ -2474,7 +2484,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "event_macro"
|
name = "event_macro"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
@@ -3002,7 +3012,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "groupware"
|
name = "groupware"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"calcard",
|
"calcard",
|
||||||
@@ -3289,7 +3299,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3385,7 +3395,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http_proto"
|
name = "http_proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -3872,7 +3882,7 @@ checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "imap"
|
name = "imap"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"common",
|
"common",
|
||||||
@@ -3897,7 +3907,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "imap_proto"
|
name = "imap_proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3912,7 +3922,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inbuxa"
|
name = "inbuxa"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"coordinator",
|
"coordinator",
|
||||||
@@ -3920,7 +3930,7 @@ dependencies = [
|
|||||||
"directory",
|
"directory",
|
||||||
"email",
|
"email",
|
||||||
"groupware",
|
"groupware",
|
||||||
"http 0.16.24",
|
"http 0.16.25",
|
||||||
"http_proto",
|
"http_proto",
|
||||||
"imap",
|
"imap",
|
||||||
"jmap",
|
"jmap",
|
||||||
@@ -4209,7 +4219,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jmap"
|
name = "jmap"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -4258,14 +4268,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jmap-client"
|
name = "jmap-client"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4deab22e057d24e32122f0fc6e2d667a124fdd6a0d8ef3ed4f8a89923c11084f"
|
checksum = "f5b5bc66252cc8e779ef1238f40ab93971d54ad00b5d7afb5c1d447a9647ed62"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"base64 0.22.1",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"encodify",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"maybe-async",
|
"maybe-async",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -4292,7 +4302,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jmap_proto"
|
name = "jmap_proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"calcard",
|
"calcard",
|
||||||
@@ -4502,9 +4512,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"spin 0.9.9",
|
"spin 0.9.9",
|
||||||
]
|
]
|
||||||
@@ -4798,7 +4808,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "managesieve"
|
name = "managesieve"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -4933,7 +4943,7 @@ checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "migration"
|
name = "migration"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"email",
|
"email",
|
||||||
@@ -5183,7 +5193,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nlp"
|
name = "nlp"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"hashify",
|
"hashify",
|
||||||
@@ -5503,8 +5513,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry"
|
name = "opentelemetry"
|
||||||
version = "0.32.0"
|
version = "0.33.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
@@ -5516,8 +5526,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-http"
|
name = "opentelemetry-http"
|
||||||
version = "0.32.0"
|
version = "0.33.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -5528,8 +5538,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-otlp"
|
name = "opentelemetry-otlp"
|
||||||
version = "0.32.0"
|
version = "0.33.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"http 1.5.0",
|
"http 1.5.0",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
@@ -5547,8 +5557,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-proto"
|
name = "opentelemetry-proto"
|
||||||
version = "0.32.0"
|
version = "0.33.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"opentelemetry_sdk",
|
"opentelemetry_sdk",
|
||||||
@@ -5559,13 +5569,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-semantic-conventions"
|
name = "opentelemetry-semantic-conventions"
|
||||||
version = "0.32.1"
|
version = "0.33.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry_sdk"
|
name = "opentelemetry_sdk"
|
||||||
version = "0.32.1"
|
version = "0.33.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-executor",
|
"futures-executor",
|
||||||
@@ -6015,7 +6025,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pop3"
|
name = "pop3"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"directory",
|
"directory",
|
||||||
@@ -6385,9 +6395,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.18"
|
version = "0.11.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
|
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -6410,9 +6420,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-udp"
|
name = "quinn-udp"
|
||||||
version = "0.5.15"
|
version = "0.5.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -6835,7 +6845,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "registry"
|
name = "registry"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"hashify",
|
"hashify",
|
||||||
@@ -7392,7 +7402,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scim"
|
name = "scim"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -7418,7 +7428,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scim-proto"
|
name = "scim-proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashify",
|
"hashify",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -7672,9 +7682,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.23.0"
|
version = "3.24.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4"
|
checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"bs58",
|
"bs58",
|
||||||
@@ -7693,9 +7703,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with_macros"
|
name = "serde_with_macros"
|
||||||
version = "3.23.0"
|
version = "3.24.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc"
|
checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling 0.24.1",
|
"darling 0.24.1",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -7753,7 +7763,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "services"
|
name = "services"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
@@ -8068,7 +8078,7 @@ checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smtp"
|
name = "smtp"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -8107,9 +8117,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smtp-proto"
|
name = "smtp-proto"
|
||||||
version = "0.2.4"
|
version = "0.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "707104487221ff447b5b796b5049e5c09cf52ff9fc1c8abba4695b89ac4b0f37"
|
checksum = "142a5a642c6bd7ffd7e1b525ad6a6e9921ccef992dba4663974f8519209a00c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
@@ -8159,7 +8169,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spam-filter"
|
name = "spam-filter"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -8279,7 +8289,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "store"
|
name = "store"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -8539,7 +8549,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tests"
|
name = "tests"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
@@ -8561,7 +8571,7 @@ dependencies = [
|
|||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"futures",
|
"futures",
|
||||||
"groupware",
|
"groupware",
|
||||||
"http 0.16.24",
|
"http 0.16.25",
|
||||||
"http_proto",
|
"http_proto",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
@@ -8818,9 +8828,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.5"
|
version = "0.26.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
|
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls",
|
"rustls",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -9154,7 +9164,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trc"
|
name = "trc"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -9263,7 +9273,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types"
|
name = "types"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -9432,7 +9442,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utils"
|
name = "utils"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"arcstr",
|
"arcstr",
|
||||||
@@ -10117,9 +10127,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xxhash-rust"
|
name = "xxhash-rust"
|
||||||
version = "0.8.18"
|
version = "0.8.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"
|
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yasna"
|
name = "yasna"
|
||||||
@@ -10144,9 +10154,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke-derive"
|
name = "yoke-derive"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
|
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
# *****************
|
# *****************
|
||||||
# Base image for planner & builder
|
# Base image for planner & builder
|
||||||
# *****************
|
# *****************
|
||||||
FROM --platform=$BUILDPLATFORM rust:slim-trixie AS base
|
FROM --platform=$BUILDPLATFORM rust:1.98.1-slim-trixie AS base
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND="noninteractive" \
|
ENV DEBIAN_FRONTEND="noninteractive" \
|
||||||
BINSTALL_DISABLE_TELEMETRY=true \
|
BINSTALL_DISABLE_TELEMETRY=true \
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "common"
|
name = "common"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ pub struct Caches {
|
|||||||
pub dns_ipv6: CacheWithTtl<Box<str>, RecordSet<Ipv6Addr>>,
|
pub dns_ipv6: CacheWithTtl<Box<str>, RecordSet<Ipv6Addr>>,
|
||||||
pub dns_tlsa: CacheWithTtl<Box<str>, Arc<Tlsa>>,
|
pub dns_tlsa: CacheWithTtl<Box<str>, Arc<Tlsa>>,
|
||||||
pub dns_mta_sts: CacheWithTtl<Box<str>, Arc<Policy>>,
|
pub dns_mta_sts: CacheWithTtl<Box<str>, Arc<Policy>>,
|
||||||
pub dns_rbl: CacheWithTtl<Box<str>, Option<Arc<IpResolver>>>,
|
pub dns_rbl: CacheWithTtl<Box<str>, Option<Arc<[IpResolver]>>>,
|
||||||
|
|
||||||
pub negative_cache_ttl: Duration,
|
pub negative_cache_ttl: Duration,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,10 +227,22 @@ impl WebApplicationManager {
|
|||||||
let cached = if force_refresh {
|
let cached = if force_refresh {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
server
|
match server
|
||||||
.blob_store()
|
.blob_store()
|
||||||
.get_blob(self.blob_key.as_slice(), 0..usize::MAX)
|
.get_blob(self.blob_key.as_slice(), 0..usize::MAX)
|
||||||
.await?
|
.await
|
||||||
|
{
|
||||||
|
Ok(cached) => cached,
|
||||||
|
Err(err) => {
|
||||||
|
trc::event!(
|
||||||
|
Resource(trc::ResourceEvent::Error),
|
||||||
|
Reason = err,
|
||||||
|
Url = self.url.clone(),
|
||||||
|
Details = "Failed to read cached application bundle, downloading it again"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let is_cached = cached.is_some();
|
let is_cached = cached.is_some();
|
||||||
let bundle = match cached {
|
let bundle = match cached {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use quick_xml::Reader;
|
|||||||
use quick_xml::XmlVersion;
|
use quick_xml::XmlVersion;
|
||||||
use quick_xml::events::Event;
|
use quick_xml::events::Event;
|
||||||
use registry::schema::{enums::ServiceProtocol, structs::Service};
|
use registry::schema::{enums::ServiceProtocol, structs::Service};
|
||||||
use std::fmt::Write;
|
use std::{borrow::Cow, fmt::Write};
|
||||||
use utils::map::vec_map::VecMap;
|
use utils::map::vec_map::VecMap;
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
@@ -20,32 +20,78 @@ impl Server {
|
|||||||
body: Option<Vec<u8>>,
|
body: Option<Vec<u8>>,
|
||||||
) -> trc::Result<Resource<Vec<u8>>> {
|
) -> trc::Result<Resource<Vec<u8>>> {
|
||||||
// Obtain parameters
|
// Obtain parameters
|
||||||
let emailaddress = parse_autodiscover_request(body.as_deref().unwrap_or_default())
|
let request =
|
||||||
.map_err(|err| {
|
parse_autodiscover_request(body.as_deref().unwrap_or_default()).map_err(|err| {
|
||||||
trc::ResourceEvent::BadParameters
|
trc::ResourceEvent::BadParameters
|
||||||
.into_err()
|
.into_err()
|
||||||
.details("Failed to parse autodiscover request")
|
.details("Failed to parse autodiscover request")
|
||||||
.ctx(trc::Key::Reason, err)
|
.ctx(trc::Key::Reason, err)
|
||||||
})?;
|
})?;
|
||||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||||
let legacy_off = match emailaddress.rsplit_once('@') {
|
let legacy_off = match request.email.rsplit_once('@') {
|
||||||
Some((_, domain)) => self.legacy_off_for(domain).await?,
|
Some((_, domain)) => self.legacy_off_for(domain).await?,
|
||||||
None => self.legacy_off_for("").await?,
|
None => self.legacy_off_for("").await?,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Resource::new(
|
let response = match request.response_schema {
|
||||||
"application/xml; charset=utf-8",
|
ResponseSchema::Outlook => build_autodiscover_response(
|
||||||
build_autodiscover_response(
|
&request.email,
|
||||||
&emailaddress,
|
|
||||||
&self.core.network.server_name,
|
&self.core.network.server_name,
|
||||||
&self.core.network.info.services,
|
&self.core.network.info.services,
|
||||||
|protocol| legacy_off.service(protocol),
|
|protocol| legacy_off.service(protocol),
|
||||||
)
|
)
|
||||||
.into_bytes(),
|
.into_bytes(),
|
||||||
))
|
ResponseSchema::Unsupported => PROVIDER_NOT_AVAILABLE_RESPONSE.as_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Resource::new("application/xml; charset=utf-8", response))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OUTLOOK_RESPONSE_SCHEMA: &str =
|
||||||
|
"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a";
|
||||||
|
|
||||||
|
const PROVIDER_NOT_AVAILABLE_RESPONSE: &str = concat!(
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
|
||||||
|
"<Autodiscover xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006\">\n",
|
||||||
|
"\t<Response>\n",
|
||||||
|
"\t\t<Error>\n",
|
||||||
|
"\t\t\t<ErrorCode>601</ErrorCode>\n",
|
||||||
|
"\t\t\t<Message>Provider is not available</Message>\n",
|
||||||
|
"\t\t\t<DebugData />\n",
|
||||||
|
"\t\t</Error>\n",
|
||||||
|
"\t</Response>\n",
|
||||||
|
"</Autodiscover>\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ResponseSchema {
|
||||||
|
Outlook,
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseSchema {
|
||||||
|
fn parse(value: &str) -> Self {
|
||||||
|
if value.trim().eq_ignore_ascii_case(OUTLOOK_RESPONSE_SCHEMA) {
|
||||||
|
ResponseSchema::Outlook
|
||||||
|
} else {
|
||||||
|
ResponseSchema::Unsupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
struct AutodiscoverRequest {
|
||||||
|
email: String,
|
||||||
|
response_schema: ResponseSchema,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum RequestField {
|
||||||
|
EmailAddress,
|
||||||
|
ResponseSchema,
|
||||||
|
}
|
||||||
|
|
||||||
fn build_autodiscover_response(
|
fn build_autodiscover_response(
|
||||||
emailaddress: &str,
|
emailaddress: &str,
|
||||||
default_host: &str,
|
default_host: &str,
|
||||||
@@ -124,7 +170,7 @@ fn build_autodiscover_response(
|
|||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
|
fn parse_autodiscover_request(bytes: &[u8]) -> Result<AutodiscoverRequest, String> {
|
||||||
if bytes.is_empty() {
|
if bytes.is_empty() {
|
||||||
return Err("Empty request body".to_string());
|
return Err("Empty request body".to_string());
|
||||||
}
|
}
|
||||||
@@ -132,8 +178,9 @@ fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
|
|||||||
let mut reader = Reader::from_reader(bytes);
|
let mut reader = Reader::from_reader(bytes);
|
||||||
reader.config_mut().trim_text(true);
|
reader.config_mut().trim_text(true);
|
||||||
let mut buf = Vec::with_capacity(128);
|
let mut buf = Vec::with_capacity(128);
|
||||||
|
let mut value_buf = Vec::with_capacity(128);
|
||||||
|
|
||||||
'outer: for tag_name in ["Autodiscover", "Request", "EMailAddress"] {
|
'outer: for tag_name in ["Autodiscover", "Request"] {
|
||||||
loop {
|
loop {
|
||||||
match reader.read_event_into(&mut buf) {
|
match reader.read_event_into(&mut buf) {
|
||||||
Ok(Event::Start(e)) => {
|
Ok(Event::Start(e)) => {
|
||||||
@@ -143,30 +190,6 @@ fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
|
|||||||
.eq_ignore_ascii_case(found_tag_name.as_ref())
|
.eq_ignore_ascii_case(found_tag_name.as_ref())
|
||||||
{
|
{
|
||||||
continue 'outer;
|
continue 'outer;
|
||||||
} else if tag_name == "EMailAddress" {
|
|
||||||
// Skip unsupported tags under Request, such as AcceptableResponseSchema
|
|
||||||
let mut tag_count = 0;
|
|
||||||
loop {
|
|
||||||
match reader.read_event_into(&mut buf) {
|
|
||||||
Ok(Event::End(_)) => {
|
|
||||||
if tag_count == 0 {
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
tag_count -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::Start(_)) => {
|
|
||||||
tag_count += 1;
|
|
||||||
}
|
|
||||||
Ok(Event::Eof) => {
|
|
||||||
return Err(format!(
|
|
||||||
"Expected value, found unexpected EOF at position {}.",
|
|
||||||
reader.buffer_position()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Expected tag {}, found unexpected tag {} at position {}.",
|
"Expected tag {}, found unexpected tag {} at position {}.",
|
||||||
@@ -195,38 +218,172 @@ fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(Event::Text(text)) = reader.read_event_into(&mut buf)
|
let mut email = None;
|
||||||
&& let Ok(text) = text.xml_content(XmlVersion::Implicit1_0)
|
let mut response_schema = ResponseSchema::Outlook;
|
||||||
&& text.contains('@')
|
|
||||||
{
|
loop {
|
||||||
return Ok(text.trim().to_lowercase());
|
match reader.read_event_into(&mut buf) {
|
||||||
|
Ok(Event::Start(e)) => {
|
||||||
|
let local_name = e.local_name();
|
||||||
|
let field = hashify::tiny_map_ignore_case!(local_name.as_ref(),
|
||||||
|
b"EMailAddress" => RequestField::EmailAddress,
|
||||||
|
b"AcceptableResponseSchema" => RequestField::ResponseSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
let value = match reader.read_event_into(&mut value_buf) {
|
||||||
|
Ok(Event::End(_)) => None,
|
||||||
|
Ok(event) => {
|
||||||
|
let value = match event {
|
||||||
|
Event::Text(text) => text
|
||||||
|
.xml_content(XmlVersion::Implicit1_0)
|
||||||
|
.ok()
|
||||||
|
.map(Cow::into_owned),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
reader
|
||||||
|
.read_to_end_into(e.name(), &mut value_buf)
|
||||||
|
.map_err(|err| {
|
||||||
|
format!("Error at position {}: {:?}", reader.buffer_position(), err)
|
||||||
|
})?;
|
||||||
|
value
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Error at position {}: {:?}",
|
||||||
|
reader.buffer_position(),
|
||||||
|
err
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match (field, value) {
|
||||||
|
(Some(RequestField::EmailAddress), Some(value)) => {
|
||||||
|
email = Some(value);
|
||||||
|
}
|
||||||
|
(Some(RequestField::ResponseSchema), Some(value)) => {
|
||||||
|
response_schema = ResponseSchema::parse(&value);
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::End(_) | Event::Eof) => break,
|
||||||
|
Ok(_) => (),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Error at position {}: {:?}",
|
||||||
|
reader.buffer_position(),
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(format!(
|
match email {
|
||||||
|
Some(email) if email.contains('@') => Ok(AutodiscoverRequest {
|
||||||
|
email: email.trim().to_lowercase(),
|
||||||
|
response_schema,
|
||||||
|
}),
|
||||||
|
_ => Err(format!(
|
||||||
"Expected email address, found unexpected value at position {}.",
|
"Expected email address, found unexpected value at position {}.",
|
||||||
reader.buffer_position()
|
reader.buffer_position()
|
||||||
))
|
)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::{AutodiscoverRequest, ResponseSchema, parse_autodiscover_request};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_autodiscover() {
|
fn parse_autodiscover() {
|
||||||
let r = r#"<?xml version="1.0" encoding="utf-8"?>
|
const OUTLOOK: &str =
|
||||||
|
"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a";
|
||||||
|
const MOBILESYNC: &str =
|
||||||
|
"http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006";
|
||||||
|
|
||||||
|
for (request, expected) in [
|
||||||
|
(
|
||||||
|
format!(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
|
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
|
||||||
<Request>
|
<Request>
|
||||||
<EMailAddress>email@example.com</EMailAddress>
|
<EMailAddress>Email@Example.com</EMailAddress>
|
||||||
<AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
|
<AcceptableResponseSchema>{OUTLOOK}</AcceptableResponseSchema>
|
||||||
</Request>
|
</Request>
|
||||||
</Autodiscover>"#;
|
</Autodiscover>"#
|
||||||
|
),
|
||||||
|
ResponseSchema::Outlook,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!(
|
||||||
|
r#"<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
|
||||||
|
<Request>
|
||||||
|
<AcceptableResponseSchema>{OUTLOOK}</AcceptableResponseSchema>
|
||||||
|
<EMailAddress>[email protected]</EMailAddress>
|
||||||
|
</Request>
|
||||||
|
</Autodiscover>"#
|
||||||
|
),
|
||||||
|
ResponseSchema::Outlook,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r#"<Autodiscover>
|
||||||
|
<Request>
|
||||||
|
<EMailAddress>[email protected]</EMailAddress>
|
||||||
|
</Request>
|
||||||
|
</Autodiscover>"#
|
||||||
|
.to_string(),
|
||||||
|
ResponseSchema::Outlook,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/mobilesync/requestschema/2006">
|
||||||
|
<Request>
|
||||||
|
<EMailAddress>[email protected]</EMailAddress>
|
||||||
|
<AcceptableResponseSchema>{MOBILESYNC}</AcceptableResponseSchema>
|
||||||
|
</Request>
|
||||||
|
</Autodiscover>"#
|
||||||
|
),
|
||||||
|
ResponseSchema::Unsupported,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!(
|
||||||
|
r#"<Autodiscover>
|
||||||
|
<Request>
|
||||||
|
<LegacyDN>/o=Example/ou=Users/cn=email</LegacyDN>
|
||||||
|
<Unknown><Nested>value</Nested><Empty/></Unknown>
|
||||||
|
<AcceptableResponseSchema>{MOBILESYNC}</AcceptableResponseSchema>
|
||||||
|
<EMailAddress>[email protected]</EMailAddress>
|
||||||
|
</Request>
|
||||||
|
</Autodiscover>"#
|
||||||
|
),
|
||||||
|
ResponseSchema::Unsupported,
|
||||||
|
),
|
||||||
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
super::parse_autodiscover_request(r.as_bytes()).unwrap(),
|
parse_autodiscover_request(request.as_bytes()).expect("valid request"),
|
||||||
"[email protected]"
|
AutodiscoverRequest {
|
||||||
|
email: "[email protected]".to_string(),
|
||||||
|
response_schema: expected,
|
||||||
|
},
|
||||||
|
"{request}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for request in [
|
||||||
|
"",
|
||||||
|
"<Autodiscover><Request></Request></Autodiscover>",
|
||||||
|
"<Autodiscover><Request><EMailAddress>no-domain</EMailAddress></Request></Autodiscover>",
|
||||||
|
"<Autodiscover><Request><EMailAddress>[email protected]</Request></Autodiscover>",
|
||||||
|
"<Request><EMailAddress>[email protected]</EMailAddress></Request>",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
parse_autodiscover_request(request.as_bytes()).is_err(),
|
||||||
|
"{request}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn autodiscover_encryption() {
|
fn autodiscover_encryption() {
|
||||||
use registry::schema::{enums::ServiceProtocol, structs::Service};
|
use registry::schema::{enums::ServiceProtocol, structs::Service};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "coordinator"
|
name = "coordinator"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "dav-proto"
|
name = "dav-proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "dav"
|
name = "dav"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "directory"
|
name = "directory"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "email"
|
name = "email"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use groupware::{
|
|||||||
scheduling::{ItipError, ItipMessages},
|
scheduling::{ItipError, ItipMessages},
|
||||||
};
|
};
|
||||||
use mail_parser::{
|
use mail_parser::{
|
||||||
DateTime, Header, HeaderName, HeaderValue, Message, MessageParser, MimeHeaders, PartType,
|
Header, HeaderName, HeaderValue, Message, MessageParser, MimeHeaders, PartType,
|
||||||
parsers::fields::thread::thread_name,
|
parsers::fields::thread::thread_name,
|
||||||
};
|
};
|
||||||
use registry::{
|
use registry::{
|
||||||
@@ -924,11 +924,7 @@ impl EmailIngest for Server {
|
|||||||
span_id: u64,
|
span_id: u64,
|
||||||
) {
|
) {
|
||||||
if let Some(config) = &self.core.spam.classifier {
|
if let Some(config) = &self.core.spam.classifier {
|
||||||
let mut dt = DateTime::from_timestamp(now() as i64);
|
let until = now() + config.hold_samples_for;
|
||||||
dt.hour = 0;
|
|
||||||
dt.minute = 0;
|
|
||||||
dt.second = 0;
|
|
||||||
let until = dt.to_timestamp() as u64 + config.hold_samples_for;
|
|
||||||
|
|
||||||
let sample = SpamTrainingSample {
|
let sample = SpamTrainingSample {
|
||||||
account_id: Some(Id::from(account_id)),
|
account_id: Some(Id::from(account_id)),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "groupware"
|
name = "groupware"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "http_proto"
|
name = "http_proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "imap_proto"
|
name = "imap_proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -677,7 +677,13 @@ pub trait SerializeResponse {
|
|||||||
impl SerializeResponse for trc::Error {
|
impl SerializeResponse for trc::Error {
|
||||||
fn serialize(&self) -> Vec<u8> {
|
fn serialize(&self) -> Vec<u8> {
|
||||||
let mut buf = Vec::with_capacity(128);
|
let mut buf = Vec::with_capacity(128);
|
||||||
if let Some(tag) = self.value_as_str(trc::Key::Id) {
|
if let Some(tag) = self
|
||||||
|
.keys()
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find_map(|(key, value)| (*key == trc::Key::Id).then_some(value))
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
{
|
||||||
buf.extend_from_slice(tag.as_bytes());
|
buf.extend_from_slice(tag.as_bytes());
|
||||||
} else {
|
} else {
|
||||||
buf.push(b'*');
|
buf.push(b'*');
|
||||||
@@ -813,9 +819,51 @@ impl Display for Command {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::parser::parse_sequence_set;
|
use crate::parser::parse_sequence_set;
|
||||||
use crate::protocol::ObjectId;
|
use crate::protocol::{ObjectId, SerializeResponse};
|
||||||
use types::id::Id;
|
use types::id::Id;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialize_error_uses_command_tag() {
|
||||||
|
for (error, expected) in [
|
||||||
|
(
|
||||||
|
trc::AuthEvent::Failed.into_err().id("a1"),
|
||||||
|
"a1 NO [AUTHENTICATIONFAILED] ",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
trc::AuthEvent::Failed
|
||||||
|
.into_err()
|
||||||
|
.ctx(trc::Key::Id, 7u32)
|
||||||
|
.id("a1"),
|
||||||
|
"a1 NO [AUTHENTICATIONFAILED] ",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
trc::AuthEvent::Error
|
||||||
|
.into_err()
|
||||||
|
.ctx(trc::Key::Id, "12")
|
||||||
|
.id("a2"),
|
||||||
|
"a2 NO [AUTHENTICATIONFAILED] ",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
trc::AuthEvent::TooManyAttempts
|
||||||
|
.into_err()
|
||||||
|
.caused_by(trc::AuthEvent::Failed.into_err().ctx(trc::Key::Id, 7u32))
|
||||||
|
.id("a3"),
|
||||||
|
"a3 NO [AUTHENTICATIONFAILED] ",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
trc::AuthEvent::Failed.into_err().ctx(trc::Key::Id, 7u32),
|
||||||
|
"* NO [AUTHENTICATIONFAILED] ",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let response = error.serialize();
|
||||||
|
assert!(
|
||||||
|
response.starts_with(expected.as_bytes()),
|
||||||
|
"{:?} does not start with {expected:?}",
|
||||||
|
String::from_utf8_lossy(&response)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn serialize_objectid_compound() {
|
fn serialize_objectid_compound() {
|
||||||
// Empty compound
|
// Empty compound
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "imap"
|
name = "imap"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -92,7 +92,10 @@ impl<T: SessionStream> Session<T> {
|
|||||||
auth_failures: auth_failures + 1,
|
auth_failures: auth_failures + 1,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return trc::AuthEvent::TooManyAttempts.into_err().caused_by(err);
|
return trc::AuthEvent::TooManyAttempts
|
||||||
|
.into_err()
|
||||||
|
.caused_by(err)
|
||||||
|
.id(tag.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jmap_proto"
|
name = "jmap_proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jmap"
|
name = "jmap"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ pub(crate) async fn bootstrap_set(
|
|||||||
mut set: RegistrySetResponse<'_>,
|
mut set: RegistrySetResponse<'_>,
|
||||||
) -> trc::Result<RegistrySetResponse<'_>> {
|
) -> trc::Result<RegistrySetResponse<'_>> {
|
||||||
if !set.server.registry().is_bootstrap_mode() {
|
if !set.server.registry().is_bootstrap_mode() {
|
||||||
set.fail_all_create("This operation is only allowed bootstrap mode");
|
set.fail_all_create("This operation is only allowed in bootstrap mode");
|
||||||
set.fail_all_update("This operation is only allowed bootstrap mode");
|
set.fail_all_update("This operation is only allowed in bootstrap mode");
|
||||||
set.fail_all_destroy("This operation is only allowed bootstrap mode");
|
set.fail_all_destroy("This operation is only allowed in bootstrap mode");
|
||||||
return Ok(set);
|
return Ok(set);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ pub(crate) async fn validate_domain(
|
|||||||
let will_trigger_dkim = matches!(domain.dkim_management, DkimManagement::Automatic(_))
|
let will_trigger_dkim = matches!(domain.dkim_management, DkimManagement::Automatic(_))
|
||||||
&& old_domain
|
&& old_domain
|
||||||
.is_none_or(|old| !matches!(old.dkim_management, DkimManagement::Automatic(_)));
|
.is_none_or(|old| !matches!(old.dkim_management, DkimManagement::Automatic(_)));
|
||||||
|
let will_schedule_dkim = !will_trigger_dkim
|
||||||
|
&& matches!(domain.dkim_management, DkimManagement::Automatic(_))
|
||||||
|
&& publishes_dkim(domain)
|
||||||
|
&& old_domain.is_some_and(|old| !publishes_dkim(old));
|
||||||
let will_trigger_acme = if let DnsManagement::Automatic(details) = &domain.dns_management
|
let will_trigger_acme = if let DnsManagement::Automatic(details) = &domain.dns_management
|
||||||
&& old_domain.is_none_or(|old| !matches!(old.dns_management, DnsManagement::Automatic(_)))
|
&& old_domain.is_none_or(|old| !matches!(old.dns_management, DnsManagement::Automatic(_)))
|
||||||
{
|
{
|
||||||
@@ -125,11 +129,19 @@ pub(crate) async fn validate_domain(
|
|||||||
}));
|
}));
|
||||||
on_success_renew_certificate
|
on_success_renew_certificate
|
||||||
} else {
|
} else {
|
||||||
|
if will_schedule_dkim {
|
||||||
|
tasks.push(Task::DnsManagement(TaskDnsManagement {
|
||||||
|
domain_id: Id::default(),
|
||||||
|
update_records: Map::new(vec![DnsRecordType::Dkim]),
|
||||||
|
on_success_renew_certificate: false,
|
||||||
|
status: TaskStatus::now(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Schedule DKIM key rotation task
|
// Schedule DKIM key rotation task
|
||||||
if will_trigger_dkim {
|
if will_trigger_dkim || will_schedule_dkim {
|
||||||
tasks.push(Task::DkimManagement(TaskDomainManagement {
|
tasks.push(Task::DkimManagement(TaskDomainManagement {
|
||||||
domain_id: Id::default(),
|
domain_id: Id::default(),
|
||||||
status: TaskStatus::now(),
|
status: TaskStatus::now(),
|
||||||
@@ -176,6 +188,13 @@ pub(crate) async fn validate_domain(
|
|||||||
Ok(Ok(response))
|
Ok(Ok(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn publishes_dkim(domain: &Domain) -> bool {
|
||||||
|
matches!(
|
||||||
|
&domain.dns_management,
|
||||||
|
DnsManagement::Automatic(details) if details.publish_records.contains(&DnsRecordType::Dkim)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn validate_dns_server(
|
pub(crate) async fn validate_dns_server(
|
||||||
set: &RegistrySetResponse<'_>,
|
set: &RegistrySetResponse<'_>,
|
||||||
dns: &mut DnsServer,
|
dns: &mut DnsServer,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"]
|
|||||||
categories = ["email"]
|
categories = ["email"]
|
||||||
# Upstream offers AGPL-3.0-only OR LicenseRef-SEL; inbuxa takes the AGPL only.
|
# Upstream offers AGPL-3.0-only OR LicenseRef-SEL; inbuxa takes the AGPL only.
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "managesieve"
|
name = "managesieve"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "migration"
|
name = "migration"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "nlp"
|
name = "nlp"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pop3"
|
name = "pop3"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "registry"
|
name = "registry"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "scim-proto"
|
name = "scim-proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "scim"
|
name = "scim"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "services"
|
name = "services"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -68,12 +68,14 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
"Domain is not set to automatic DKIM management".to_string(),
|
"Domain is not set to automatic DKIM management".to_string(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let mut create_signatures = dkim.algorithms.into_inner();
|
let configured = dkim.algorithms.into_inner();
|
||||||
if create_signatures.is_empty() {
|
if configured.is_empty() {
|
||||||
return Ok(TaskResult::permanent(
|
return Ok(TaskResult::permanent(
|
||||||
"No DKIM algorithms configured for domain".to_string(),
|
"No DKIM algorithms configured for domain".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let mut create_signatures = configured.clone();
|
||||||
|
let mut active_types: Vec<DkimSignatureType> = Vec::with_capacity(configured.len());
|
||||||
|
|
||||||
let dns_updater = match domain.dns_management {
|
let dns_updater = match domain.dns_management {
|
||||||
DnsManagement::Automatic(props) if props.publish_records.contains(&DnsRecordType::Dkim) => {
|
DnsManagement::Automatic(props) if props.publish_records.contains(&DnsRecordType::Dkim) => {
|
||||||
@@ -95,6 +97,7 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
let mut retire_signatures = Vec::new();
|
let mut retire_signatures = Vec::new();
|
||||||
let mut retiring_signatures = Vec::new();
|
let mut retiring_signatures = Vec::new();
|
||||||
let mut delete_signatures = Vec::new();
|
let mut delete_signatures = Vec::new();
|
||||||
|
let mut schedule_signatures = Vec::new();
|
||||||
let mut next_transition = None;
|
let mut next_transition = None;
|
||||||
|
|
||||||
let signature_ids = server
|
let signature_ids = server
|
||||||
@@ -123,16 +126,35 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
create_signatures.retain(|algo| algo != &key_algo);
|
create_signatures.retain(|algo| algo != &key_algo);
|
||||||
publish_signatures.push(key)
|
publish_signatures.push(key)
|
||||||
}
|
}
|
||||||
DkimRotationStage::Active => retiring_signatures.push(key),
|
DkimRotationStage::Active if dns_updater.is_some() => retiring_signatures.push(key),
|
||||||
|
DkimRotationStage::Active => {
|
||||||
|
create_signatures.retain(|algo| algo != &key_algo);
|
||||||
|
active_types.push(key_algo);
|
||||||
|
}
|
||||||
DkimRotationStage::Retiring => retire_signatures.push(key),
|
DkimRotationStage::Retiring => retire_signatures.push(key),
|
||||||
DkimRotationStage::Retired => delete_signatures.push(key),
|
DkimRotationStage::Retired => delete_signatures.push(key),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if key.object.is_active() {
|
let transition = key.object.next_transition();
|
||||||
|
match key.object.stage() {
|
||||||
|
DkimRotationStage::Active => {
|
||||||
create_signatures.retain(|algo| algo != &key_algo);
|
create_signatures.retain(|algo| algo != &key_algo);
|
||||||
|
active_types.push(key_algo);
|
||||||
|
if transition.is_none() && dns_updater.is_some() {
|
||||||
|
schedule_signatures.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DkimRotationStage::Pending => {
|
||||||
|
create_signatures.retain(|algo| algo != &key_algo);
|
||||||
|
if transition.is_none() || dns_updater.is_none() {
|
||||||
|
publish_signatures.push(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DkimRotationStage::Retiring | DkimRotationStage::Retired => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(transition) = key.object.next_transition()
|
if let Some(transition) = transition
|
||||||
&& next_transition.is_none_or(|next| transition < next)
|
&& next_transition.is_none_or(|next| transition < next)
|
||||||
{
|
{
|
||||||
next_transition = Some(transition);
|
next_transition = Some(transition);
|
||||||
@@ -142,6 +164,7 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
|
|
||||||
let now = now();
|
let now = now();
|
||||||
let mut do_refresh = false;
|
let mut do_refresh = false;
|
||||||
|
let mut temporary_errors = String::new();
|
||||||
|
|
||||||
for algorithm in create_signatures {
|
for algorithm in create_signatures {
|
||||||
#[cfg(feature = "test_mode")]
|
#[cfg(feature = "test_mode")]
|
||||||
@@ -221,7 +244,7 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
let propagation_target = txt_value.clone();
|
let propagation_target = txt_value.clone();
|
||||||
let published = updater
|
let published = match updater
|
||||||
.set_rrset(
|
.set_rrset(
|
||||||
origin,
|
origin,
|
||||||
&record.name,
|
&record.name,
|
||||||
@@ -229,12 +252,43 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
vec![record.record.clone()],
|
vec![record.record.clone()],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.is_ok();
|
|
||||||
let signature_transition = if published
|
|
||||||
&& updater
|
|
||||||
.wait_for_txt_propagation(&record.name, origin, &propagation_target)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
let propagated = updater
|
||||||
|
.wait_for_txt_propagation(&record.name, origin, &propagation_target)
|
||||||
|
.await;
|
||||||
|
if !propagated {
|
||||||
|
if !temporary_errors.is_empty() {
|
||||||
|
temporary_errors.push_str("; ");
|
||||||
|
}
|
||||||
|
let _ = write!(
|
||||||
|
&mut temporary_errors,
|
||||||
|
"DKIM record {} did not propagate, will retry.",
|
||||||
|
record.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
propagated
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
if !temporary_errors.is_empty() {
|
||||||
|
temporary_errors.push_str("; ");
|
||||||
|
}
|
||||||
|
let _ = write!(
|
||||||
|
&mut temporary_errors,
|
||||||
|
"Failed to publish DKIM record {}: {err}.",
|
||||||
|
record.name
|
||||||
|
);
|
||||||
|
trc::event!(
|
||||||
|
Dns(DnsEvent::RecordCreationFailed),
|
||||||
|
Hostname = record.name.clone(),
|
||||||
|
Details = origin.clone(),
|
||||||
|
Type = "TXT",
|
||||||
|
Reason = err,
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let signature_transition = if published {
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Dkim(DkimEvent::SignaturePublished),
|
Dkim(DkimEvent::SignaturePublished),
|
||||||
Id = selector.clone(),
|
Id = selector.clone(),
|
||||||
@@ -246,7 +300,7 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
} else {
|
} else {
|
||||||
// Something went wrong, reschedule.
|
// Something went wrong, reschedule.
|
||||||
signature.set_stage(DkimRotationStage::Pending);
|
signature.set_stage(DkimRotationStage::Pending);
|
||||||
UTCDateTime::from_timestamp((now + 60) as i64) // Retry after 1 minute
|
UTCDateTime::from_timestamp(now as i64)
|
||||||
};
|
};
|
||||||
|
|
||||||
if next_transition.is_none_or(|next| signature_transition < next) {
|
if next_transition.is_none_or(|next| signature_transition < next) {
|
||||||
@@ -257,12 +311,16 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write key
|
// Write key
|
||||||
|
let is_active = signature.is_active();
|
||||||
match server
|
match server
|
||||||
.registry()
|
.registry()
|
||||||
.write(RegistryWrite::insert(&signature.into()))
|
.write(RegistryWrite::insert(&signature.into()))
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
RegistryWriteResult::Success(_) => {
|
RegistryWriteResult::Success(_) => {
|
||||||
|
if is_active {
|
||||||
|
active_types.push(algorithm);
|
||||||
|
}
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Dkim(DkimEvent::SignatureCreated),
|
Dkim(DkimEvent::SignatureCreated),
|
||||||
Id = selector,
|
Id = selector,
|
||||||
@@ -277,11 +335,35 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for signature in schedule_signatures {
|
||||||
|
let record = generate_dkim_dns_record_name(&signature.object, &domain.name);
|
||||||
|
let signature_transition =
|
||||||
|
UTCDateTime::from_timestamp((now + dkim.rotate_after.as_secs()) as i64);
|
||||||
|
|
||||||
|
if next_transition.is_none_or(|next| signature_transition < next) {
|
||||||
|
next_transition = Some(signature_transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_signature = signature.object.clone();
|
||||||
|
new_signature.set_next_transition(signature_transition);
|
||||||
|
|
||||||
|
if let SignatureUpdate::Failed(task_result) = update_signature(
|
||||||
|
server,
|
||||||
|
signature,
|
||||||
|
new_signature,
|
||||||
|
&record,
|
||||||
|
&mut temporary_errors,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(task_result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Publish signatures
|
// Publish signatures
|
||||||
let mut temporary_errors = String::new();
|
if let Some((updater, origin)) = &dns_updater {
|
||||||
for signature in publish_signatures {
|
for signature in publish_signatures {
|
||||||
let record = generate_dkim_dns_record(&signature.object, &domain.name).await?;
|
let record = generate_dkim_dns_record(&signature.object, &domain.name).await?;
|
||||||
if let Some((updater, origin)) = &dns_updater {
|
|
||||||
let dns_update::DnsRecord::TXT(txt_value) = &record.record else {
|
let dns_update::DnsRecord::TXT(txt_value) = &record.record else {
|
||||||
return Ok(TaskResult::permanent(
|
return Ok(TaskResult::permanent(
|
||||||
"DKIM record must be a TXT record".to_string(),
|
"DKIM record must be a TXT record".to_string(),
|
||||||
@@ -311,6 +393,7 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
next_transition = Some(signature_transition);
|
next_transition = Some(signature_transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let signature_type = signature.object.object_type();
|
||||||
let mut new_signature = signature.object.clone();
|
let mut new_signature = signature.object.clone();
|
||||||
|
|
||||||
new_signature.set_next_transition(signature_transition);
|
new_signature.set_next_transition(signature_transition);
|
||||||
@@ -323,7 +406,7 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Write key
|
// Write key
|
||||||
if let Some(task_result) = update_signature(
|
match update_signature(
|
||||||
server,
|
server,
|
||||||
signature,
|
signature,
|
||||||
new_signature,
|
new_signature,
|
||||||
@@ -332,7 +415,9 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(task_result);
|
SignatureUpdate::Written => active_types.push(signature_type),
|
||||||
|
SignatureUpdate::Conflict => {}
|
||||||
|
SignatureUpdate::Failed(task_result) => return Ok(task_result),
|
||||||
}
|
}
|
||||||
do_refresh = true;
|
do_refresh = true;
|
||||||
}
|
}
|
||||||
@@ -357,20 +442,52 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if !temporary_errors.is_empty() {
|
|
||||||
temporary_errors.push_str("; ");
|
|
||||||
}
|
}
|
||||||
let _ = write!(
|
} else {
|
||||||
|
for signature in publish_signatures {
|
||||||
|
let signature_type = signature.object.object_type();
|
||||||
|
if active_types.contains(&signature_type) || !configured.contains(&signature_type) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let record = generate_dkim_dns_record_name(&signature.object, &domain.name);
|
||||||
|
let mut new_signature = signature.object.clone();
|
||||||
|
new_signature.set_stage(DkimRotationStage::Active);
|
||||||
|
match &mut new_signature {
|
||||||
|
DkimSignature::Dkim1Ed25519Sha256(sign) | DkimSignature::Dkim1RsaSha256(sign) => {
|
||||||
|
sign.next_transition_at = None
|
||||||
|
}
|
||||||
|
DkimSignature::Dkim2Ed25519Sha256(sign) | DkimSignature::Dkim2RsaSha256(sign) => {
|
||||||
|
sign.next_transition_at = None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match update_signature(
|
||||||
|
server,
|
||||||
|
signature,
|
||||||
|
new_signature,
|
||||||
|
&record,
|
||||||
&mut temporary_errors,
|
&mut temporary_errors,
|
||||||
"No DNS server configured, cannot publish DKIM record {}.",
|
)
|
||||||
record.name
|
.await?
|
||||||
);
|
{
|
||||||
|
SignatureUpdate::Written => {
|
||||||
|
active_types.push(signature_type);
|
||||||
|
do_refresh = true;
|
||||||
|
}
|
||||||
|
SignatureUpdate::Conflict => active_types.push(signature_type),
|
||||||
|
SignatureUpdate::Failed(task_result) => return Ok(task_result),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retiring signatures
|
// Retiring signatures
|
||||||
for signature in retiring_signatures {
|
for signature in retiring_signatures {
|
||||||
|
let signature_type = signature.object.object_type();
|
||||||
|
if configured.contains(&signature_type) && !active_types.contains(&signature_type) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let record = generate_dkim_dns_record_name(&signature.object, &domain.name);
|
let record = generate_dkim_dns_record_name(&signature.object, &domain.name);
|
||||||
let signature_transition =
|
let signature_transition =
|
||||||
UTCDateTime::from_timestamp((now + dkim.retire_after.as_secs()) as i64);
|
UTCDateTime::from_timestamp((now + dkim.retire_after.as_secs()) as i64);
|
||||||
@@ -391,7 +508,7 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Write key
|
// Write key
|
||||||
if let Some(task_result) = update_signature(
|
if let SignatureUpdate::Failed(task_result) = update_signature(
|
||||||
server,
|
server,
|
||||||
signature,
|
signature,
|
||||||
new_signature,
|
new_signature,
|
||||||
@@ -406,9 +523,9 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Retire signatures
|
// Retire signatures
|
||||||
|
if let Some((updater, origin)) = &dns_updater {
|
||||||
for signature in retire_signatures {
|
for signature in retire_signatures {
|
||||||
let record = generate_dkim_dns_record_name(&signature.object, &domain.name);
|
let record = generate_dkim_dns_record_name(&signature.object, &domain.name);
|
||||||
if let Some((updater, origin)) = &dns_updater {
|
|
||||||
match updater
|
match updater
|
||||||
.set_rrset(origin, &record, dns_update::DnsRecordType::TXT, Vec::new())
|
.set_rrset(origin, &record, dns_update::DnsRecordType::TXT, Vec::new())
|
||||||
.await
|
.await
|
||||||
@@ -433,7 +550,7 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Write key
|
// Write key
|
||||||
if let Some(task_result) = update_signature(
|
if let SignatureUpdate::Failed(task_result) = update_signature(
|
||||||
server,
|
server,
|
||||||
signature,
|
signature,
|
||||||
new_signature,
|
new_signature,
|
||||||
@@ -458,15 +575,6 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if !temporary_errors.is_empty() {
|
|
||||||
temporary_errors.push_str("; ");
|
|
||||||
}
|
|
||||||
let _ = write!(
|
|
||||||
&mut temporary_errors,
|
|
||||||
"No DNS server configured, cannot retire DKIM record {}.",
|
|
||||||
record
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,13 +664,19 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SignatureUpdate {
|
||||||
|
Written,
|
||||||
|
Conflict,
|
||||||
|
Failed(TaskResult),
|
||||||
|
}
|
||||||
|
|
||||||
async fn update_signature(
|
async fn update_signature(
|
||||||
server: &Server,
|
server: &Server,
|
||||||
signature: RegistryObject<DkimSignature>,
|
signature: RegistryObject<DkimSignature>,
|
||||||
new_signature: DkimSignature,
|
new_signature: DkimSignature,
|
||||||
name: &str,
|
name: &str,
|
||||||
temporary_errors: &mut String,
|
temporary_errors: &mut String,
|
||||||
) -> trc::Result<Option<TaskResult>> {
|
) -> trc::Result<SignatureUpdate> {
|
||||||
match server
|
match server
|
||||||
.registry()
|
.registry()
|
||||||
.write(RegistryWrite::update(
|
.write(RegistryWrite::update(
|
||||||
@@ -575,8 +689,8 @@ async fn update_signature(
|
|||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(RegistryWriteResult::Success(_)) => Ok(None),
|
Ok(RegistryWriteResult::Success(_)) => Ok(SignatureUpdate::Written),
|
||||||
Ok(err) => Ok(Some(TaskResult::permanent(format!(
|
Ok(err) => Ok(SignatureUpdate::Failed(TaskResult::permanent(format!(
|
||||||
"Failed to write DKIM signature for record {name}: {err}"
|
"Failed to write DKIM signature for record {name}: {err}"
|
||||||
)))),
|
)))),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -588,7 +702,7 @@ async fn update_signature(
|
|||||||
temporary_errors,
|
temporary_errors,
|
||||||
"Failed to write DKIM signature for record {name} due to concurrent modification, will retry.",
|
"Failed to write DKIM signature for record {name} due to concurrent modification, will retry.",
|
||||||
);
|
);
|
||||||
Ok(None)
|
Ok(SignatureUpdate::Conflict)
|
||||||
} else {
|
} else {
|
||||||
Err(err)
|
Err(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ homepage = "https://inbuxa.org"
|
|||||||
keywords = ["smtp", "email", "mail", "server"]
|
keywords = ["smtp", "email", "mail", "server"]
|
||||||
categories = ["email"]
|
categories = ["email"]
|
||||||
license = "AGPL-3.0-only OR LicenseRef-SEL"
|
license = "AGPL-3.0-only OR LicenseRef-SEL"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ impl HasQueueQuota for Server {
|
|||||||
refs: &mut Vec<Metadata>,
|
refs: &mut Vec<Metadata>,
|
||||||
session_id: u64,
|
session_id: u64,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if !quota.expr.is_empty()
|
if quota.expr.is_empty()
|
||||||
&& self
|
|| self
|
||||||
.eval_if("a.expr, envelope, session_id)
|
.eval_if("a.expr, envelope, session_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "spam-filter"
|
name = "spam-filter"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ use std::{
|
|||||||
hash::{Hash, RandomState},
|
hash::{Hash, RandomState},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
use store::ahash::AHashSet;
|
use store::ahash::AHashMap;
|
||||||
use store::rand::seq::SliceRandom;
|
use store::rand::seq::SliceRandom;
|
||||||
use store::write::{BlobLink, RegistryClass, now};
|
use store::write::{BlobLink, RegistryClass, now};
|
||||||
use store::{
|
use store::{
|
||||||
@@ -92,7 +92,14 @@ struct TrainingTask {
|
|||||||
sample: TrainingSample,
|
sample: TrainingSample,
|
||||||
is_spam: bool,
|
is_spam: bool,
|
||||||
is_replay: bool,
|
is_replay: bool,
|
||||||
remove: Option<u64>,
|
remove: SampleRemoval,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum SampleRemoval {
|
||||||
|
Keep,
|
||||||
|
Item,
|
||||||
|
ItemAndLink { until: u64 },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug)]
|
#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug)]
|
||||||
@@ -199,7 +206,7 @@ impl SpamClassifier for Server {
|
|||||||
object_id,
|
object_id,
|
||||||
item_id: u64::MAX,
|
item_id: u64::MAX,
|
||||||
}));
|
}));
|
||||||
let mut seen_samples = AHashSet::new();
|
let mut seen_samples = AHashMap::new();
|
||||||
let mut spam_count = 0;
|
let mut spam_count = 0;
|
||||||
let mut ham_count = 0;
|
let mut ham_count = 0;
|
||||||
self.store()
|
self.store()
|
||||||
@@ -220,7 +227,10 @@ impl SpamClassifier for Server {
|
|||||||
.unwrap_or(u32::MAX),
|
.unwrap_or(u32::MAX),
|
||||||
};
|
};
|
||||||
|
|
||||||
if seen_samples.insert(sample.clone()) {
|
match seen_samples.entry(sample.clone()) {
|
||||||
|
Entry::Vacant(entry) => {
|
||||||
|
entry.insert((!do_remove).then_some(until));
|
||||||
|
|
||||||
// Add to reservoir
|
// Add to reservoir
|
||||||
if !do_remove {
|
if !do_remove {
|
||||||
trainer.reservoir.update_reservoir(
|
trainer.reservoir.update_reservoir(
|
||||||
@@ -237,7 +247,11 @@ impl SpamClassifier for Server {
|
|||||||
sample,
|
sample,
|
||||||
is_spam,
|
is_spam,
|
||||||
is_replay: false,
|
is_replay: false,
|
||||||
remove: do_remove.then_some(until),
|
remove: if do_remove {
|
||||||
|
SampleRemoval::ItemAndLink { until }
|
||||||
|
} else {
|
||||||
|
SampleRemoval::Keep
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
remove_entries |= do_remove;
|
remove_entries |= do_remove;
|
||||||
@@ -248,16 +262,23 @@ impl SpamClassifier for Server {
|
|||||||
} else {
|
} else {
|
||||||
ham_count += 1;
|
ham_count += 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Entry::Occupied(entry) => {
|
||||||
|
let remove = if *entry.get() == Some(until) {
|
||||||
|
SampleRemoval::Item
|
||||||
} else {
|
} else {
|
||||||
|
SampleRemoval::ItemAndLink { until }
|
||||||
|
};
|
||||||
duplicate_samples.push(TrainingTask {
|
duplicate_samples.push(TrainingTask {
|
||||||
id,
|
id,
|
||||||
sample,
|
sample,
|
||||||
is_spam,
|
is_spam,
|
||||||
is_replay: false,
|
is_replay: false,
|
||||||
remove: Some(until),
|
remove,
|
||||||
});
|
});
|
||||||
remove_entries = true;
|
remove_entries = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
trainer.last_id = trainer.last_id.max(id);
|
trainer.last_id = trainer.last_id.max(id);
|
||||||
|
|
||||||
@@ -315,7 +336,7 @@ impl SpamClassifier for Server {
|
|||||||
sample: sample.clone(),
|
sample: sample.clone(),
|
||||||
is_spam: false,
|
is_spam: false,
|
||||||
is_replay: true,
|
is_replay: true,
|
||||||
remove: None,
|
remove: SampleRemoval::Keep,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else if ham_count > spam_count {
|
} else if ham_count > spam_count {
|
||||||
@@ -329,7 +350,7 @@ impl SpamClassifier for Server {
|
|||||||
sample: sample.clone(),
|
sample: sample.clone(),
|
||||||
is_spam: true,
|
is_spam: true,
|
||||||
is_replay: true,
|
is_replay: true,
|
||||||
remove: None,
|
remove: SampleRemoval::Keep,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -884,13 +905,19 @@ async fn delete_samples(
|
|||||||
let object_id = ObjectType::SpamTrainingSample.to_id();
|
let object_id = ObjectType::SpamTrainingSample.to_id();
|
||||||
let mut batch = BatchBuilder::new();
|
let mut batch = BatchBuilder::new();
|
||||||
for sample in samples.into_iter().chain(duplicate_samples) {
|
for sample in samples.into_iter().chain(duplicate_samples) {
|
||||||
if let Some(until) = sample.remove {
|
let until = match sample.remove {
|
||||||
batch
|
SampleRemoval::Keep => continue,
|
||||||
.with_account_id(sample.sample.account_id)
|
SampleRemoval::Item => None,
|
||||||
.clear(BlobOp::Link {
|
SampleRemoval::ItemAndLink { until } => Some(until),
|
||||||
|
};
|
||||||
|
batch.with_account_id(sample.sample.account_id);
|
||||||
|
if let Some(until) = until {
|
||||||
|
batch.clear(BlobOp::Link {
|
||||||
hash: sample.sample.hash,
|
hash: sample.sample.hash,
|
||||||
to: BlobLink::Temporary { until },
|
to: BlobLink::Temporary { until },
|
||||||
})
|
});
|
||||||
|
}
|
||||||
|
batch
|
||||||
.clear(ValueClass::Registry(RegistryClass::Item {
|
.clear(ValueClass::Registry(RegistryClass::Item {
|
||||||
object_id,
|
object_id,
|
||||||
item_id: sample.id,
|
item_id: sample.id,
|
||||||
@@ -912,7 +939,6 @@ async fn delete_samples(
|
|||||||
batch.with_account_id(sample.sample.account_id);
|
batch.with_account_id(sample.sample.account_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if !batch.is_empty() {
|
if !batch.is_empty() {
|
||||||
server
|
server
|
||||||
.store()
|
.store()
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ use common::{
|
|||||||
config::mailstore::spamfilter::{DnsBlServer, Element, IpResolver, Location},
|
config::mailstore::spamfilter::{DnsBlServer, Element, IpResolver, Location},
|
||||||
expr::functions::ResolveVariable,
|
expr::functions::ResolveVariable,
|
||||||
};
|
};
|
||||||
use mail_auth::{Error, common::resolver::ToFqdn};
|
use mail_auth::common::resolver::ToFqdn;
|
||||||
|
#[cfg(not(feature = "test_mode"))]
|
||||||
|
use mail_auth::hickory_resolver::{
|
||||||
|
net::{DnsError, NetError},
|
||||||
|
proto::rr::{Name, RData},
|
||||||
|
};
|
||||||
|
#[cfg(feature = "test_mode")]
|
||||||
|
use mail_auth::{DnsError, Error};
|
||||||
use std::{
|
use std::{
|
||||||
net::Ipv4Addr,
|
net::Ipv4Addr,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
@@ -19,6 +26,18 @@ use std::{
|
|||||||
};
|
};
|
||||||
use trc::SpamEvent;
|
use trc::SpamEvent;
|
||||||
|
|
||||||
|
const MAX_NEGATIVE_TTL: u32 = 3600;
|
||||||
|
|
||||||
|
enum DnsblAnswer {
|
||||||
|
Listed {
|
||||||
|
ips: Vec<Ipv4Addr>,
|
||||||
|
expires: Instant,
|
||||||
|
},
|
||||||
|
NotListed {
|
||||||
|
expires: Option<Instant>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn check_dnsbl(
|
pub(crate) async fn check_dnsbl(
|
||||||
server: &Server,
|
server: &Server,
|
||||||
ctx: &mut SpamFilterContext<'_>,
|
ctx: &mut SpamFilterContext<'_>,
|
||||||
@@ -49,7 +68,7 @@ pub(crate) async fn check_dnsbl(
|
|||||||
for dnsbl in &server.core.spam.dnsbl.servers {
|
for dnsbl in &server.core.spam.dnsbl.servers {
|
||||||
if dnsbl.scope == scope
|
if dnsbl.scope == scope
|
||||||
&& checks < max_checks
|
&& checks < max_checks
|
||||||
&& let Some(tag) = is_dnsbl(
|
&& let Some(codes) = dnsbl_codes(
|
||||||
server,
|
server,
|
||||||
dnsbl,
|
dnsbl,
|
||||||
SpamFilterResolver::new(ctx, resolver, location),
|
SpamFilterResolver::new(ctx, resolver, location),
|
||||||
@@ -58,9 +77,21 @@ pub(crate) async fn check_dnsbl(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
for code in codes.iter() {
|
||||||
|
let tag = server
|
||||||
|
.eval_if::<String, _>(
|
||||||
|
&dnsbl.tags,
|
||||||
|
&SpamFilterResolver::new(ctx, code, location),
|
||||||
|
ctx.input.span_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Some(tag) = tag {
|
||||||
ctx.result.add_tag(tag);
|
ctx.result.add_tag(tag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match scope {
|
match scope {
|
||||||
Element::Email => ctx.result.rbl_email_checks = checks,
|
Element::Email => ctx.result.rbl_email_checks = checks,
|
||||||
@@ -71,13 +102,13 @@ pub(crate) async fn check_dnsbl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn is_dnsbl(
|
async fn dnsbl_codes(
|
||||||
server: &Server,
|
server: &Server,
|
||||||
config: &DnsBlServer,
|
config: &DnsBlServer,
|
||||||
resolver: SpamFilterResolver<'_, impl ResolveVariable>,
|
resolver: SpamFilterResolver<'_, impl ResolveVariable>,
|
||||||
element: Element,
|
element: Element,
|
||||||
checks: &mut usize,
|
checks: &mut usize,
|
||||||
) -> Option<String> {
|
) -> Option<Arc<[IpResolver]>> {
|
||||||
let time = Instant::now();
|
let time = Instant::now();
|
||||||
let zone = server
|
let zone = server
|
||||||
.eval_if::<String, _>(&config.zone, &resolver, resolver.ctx.input.span_id)
|
.eval_if::<String, _>(&config.zone, &resolver, resolver.ctx.input.span_id)
|
||||||
@@ -92,43 +123,25 @@ async fn is_dnsbl(
|
|||||||
{
|
{
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
server
|
Some(Arc::from([IpResolver::new(
|
||||||
.eval_if(
|
|
||||||
&config.tags,
|
|
||||||
&SpamFilterResolver::new(
|
|
||||||
resolver.ctx,
|
|
||||||
&IpResolver::new(
|
|
||||||
format!("127.0.{}.{}", parts[1], parts[0]).parse().unwrap(),
|
format!("127.0.{}.{}", parts[1], parts[0]).parse().unwrap(),
|
||||||
),
|
)]))
|
||||||
resolver.location,
|
|
||||||
),
|
|
||||||
resolver.ctx.input.span_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = match server.inner.cache.dns_rbl.get(zone.as_str()) {
|
if let Some(codes) = server.inner.cache.dns_rbl.get(zone.as_str()) {
|
||||||
Some(Some(result)) => result,
|
return codes;
|
||||||
Some(None) => return None,
|
}
|
||||||
None => {
|
|
||||||
*checks += 1;
|
*checks += 1;
|
||||||
|
|
||||||
match server
|
match resolve_zone(server, zone.to_fqdn().as_ref()).await {
|
||||||
.core
|
Ok(DnsblAnswer::Listed { ips, expires }) => {
|
||||||
.smtp
|
|
||||||
.resolvers
|
|
||||||
.dns
|
|
||||||
.ipv4_lookup_raw(zone.to_fqdn().as_ref())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(result) => {
|
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Spam(SpamEvent::Dnsbl),
|
Spam(SpamEvent::Dnsbl),
|
||||||
Hostname = zone.clone(),
|
Hostname = zone.clone(),
|
||||||
Result = result
|
Result = ips
|
||||||
.entry
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|ip| trc::Value::from(ip.to_string()))
|
.map(|ip| trc::Value::from(ip.to_string()))
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
@@ -136,25 +149,20 @@ async fn is_dnsbl(
|
|||||||
Elapsed = time.elapsed()
|
Elapsed = time.elapsed()
|
||||||
);
|
);
|
||||||
|
|
||||||
let entry = Arc::new(IpResolver::new(
|
let codes: Arc<[IpResolver]> = ips
|
||||||
result
|
.into_iter()
|
||||||
.entry
|
.map(|ip| IpResolver::new(ip.into()))
|
||||||
.iter()
|
.collect();
|
||||||
.copied()
|
|
||||||
.next()
|
|
||||||
.unwrap_or(Ipv4Addr::BROADCAST)
|
|
||||||
.into(),
|
|
||||||
));
|
|
||||||
|
|
||||||
server.inner.cache.dns_rbl.insert_with_expiry(
|
server.inner.cache.dns_rbl.insert_with_expiry(
|
||||||
zone.into(),
|
zone.into(),
|
||||||
Some(entry.clone()),
|
Some(codes.clone()),
|
||||||
result.expires,
|
expires,
|
||||||
);
|
);
|
||||||
|
|
||||||
entry
|
Some(codes)
|
||||||
}
|
}
|
||||||
Err(Error::Dns(mail_auth::DnsError::RecordNotFound(_))) => {
|
Ok(DnsblAnswer::NotListed { expires }) => {
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Spam(SpamEvent::Dnsbl),
|
Spam(SpamEvent::Dnsbl),
|
||||||
Hostname = zone.clone(),
|
Hostname = zone.clone(),
|
||||||
@@ -163,13 +171,15 @@ async fn is_dnsbl(
|
|||||||
Elapsed = time.elapsed()
|
Elapsed = time.elapsed()
|
||||||
);
|
);
|
||||||
|
|
||||||
server.inner.cache.dns_rbl.insert(
|
if let Some(expires) = expires {
|
||||||
zone.into(),
|
server
|
||||||
None,
|
.inner
|
||||||
Duration::from_secs(86400),
|
.cache
|
||||||
);
|
.dns_rbl
|
||||||
|
.insert_with_expiry(zone.into(), None, expires);
|
||||||
|
}
|
||||||
|
|
||||||
return None;
|
None
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
trc::event!(
|
trc::event!(
|
||||||
@@ -177,20 +187,58 @@ async fn is_dnsbl(
|
|||||||
Hostname = zone,
|
Hostname = zone,
|
||||||
Elapsed = time.elapsed(),
|
Elapsed = time.elapsed(),
|
||||||
Details = element.as_str(),
|
Details = element.as_str(),
|
||||||
CausedBy = err.to_string()
|
CausedBy = err
|
||||||
);
|
);
|
||||||
|
|
||||||
return None;
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
server
|
#[cfg(not(feature = "test_mode"))]
|
||||||
.eval_if(
|
async fn resolve_zone(server: &Server, zone: &str) -> Result<DnsblAnswer, String> {
|
||||||
&config.tags,
|
let name = Name::from_str_relaxed(zone).map_err(|err| err.to_string())?;
|
||||||
&SpamFilterResolver::new(resolver.ctx, result.as_ref(), resolver.location),
|
|
||||||
resolver.ctx.input.span_id,
|
match server.core.smtp.resolvers.dns.0.ipv4_lookup(name).await {
|
||||||
)
|
Ok(lookup) => {
|
||||||
.await
|
let expires = lookup.valid_until();
|
||||||
|
let ips = lookup
|
||||||
|
.answers()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|record| match &record.data {
|
||||||
|
RData::A(a) => Some(a.0),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(if !ips.is_empty() {
|
||||||
|
DnsblAnswer::Listed { ips, expires }
|
||||||
|
} else {
|
||||||
|
DnsblAnswer::NotListed {
|
||||||
|
expires: Some(expires),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(NetError::Dns(DnsError::NoRecordsFound(no_records))) => Ok(DnsblAnswer::NotListed {
|
||||||
|
expires: no_records
|
||||||
|
.negative_ttl
|
||||||
|
.filter(|ttl| *ttl > 0)
|
||||||
|
.map(|ttl| Instant::now() + Duration::from_secs(ttl.min(MAX_NEGATIVE_TTL).into())),
|
||||||
|
}),
|
||||||
|
Err(err) => Err(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test_mode")]
|
||||||
|
async fn resolve_zone(server: &Server, zone: &str) -> Result<DnsblAnswer, String> {
|
||||||
|
match server.core.smtp.resolvers.dns.ipv4_lookup_raw(zone).await {
|
||||||
|
Ok(result) => Ok(DnsblAnswer::Listed {
|
||||||
|
ips: result.entry.to_vec(),
|
||||||
|
expires: result.expires,
|
||||||
|
}),
|
||||||
|
Err(Error::Dns(DnsError::RecordNotFound(_))) => Ok(DnsblAnswer::NotListed {
|
||||||
|
expires: Some(Instant::now() + Duration::from_secs(MAX_NEGATIVE_TTL.into())),
|
||||||
|
}),
|
||||||
|
Err(err) => Err(err.to_string()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -33,17 +33,9 @@ pub(crate) async fn pyzor_check(
|
|||||||
message: &Message<'_>,
|
message: &Message<'_>,
|
||||||
config: &PyzorConfig,
|
config: &PyzorConfig,
|
||||||
) -> trc::Result<Option<PyzorResponse>> {
|
) -> trc::Result<Option<PyzorResponse>> {
|
||||||
// Make sure there is at least one text part
|
let Some(request) = message.pyzor_check_message() else {
|
||||||
if !message
|
|
||||||
.parts
|
|
||||||
.iter()
|
|
||||||
.any(|p| matches!(p.body, PartType::Text(_) | PartType::Html(_)))
|
|
||||||
{
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Hash message
|
|
||||||
let request = message.pyzor_check_message();
|
|
||||||
|
|
||||||
// Send message to address. inbuxa: in tests, a fixed table answers
|
// Send message to address. inbuxa: in tests, a fixed table answers
|
||||||
// instead of a public server (test_response).
|
// instead of a public server (test_response).
|
||||||
@@ -167,15 +159,15 @@ impl PyzorWrite for Sha1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
trait PyzorDigest<W: PyzorWrite> {
|
trait PyzorDigest<W: PyzorWrite> {
|
||||||
fn pyzor_digest(&self, writer: W) -> W;
|
fn pyzor_digest(&self, writer: W) -> Option<W>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait PyzorCheck {
|
pub trait PyzorCheck {
|
||||||
fn pyzor_check_message(&self) -> String;
|
fn pyzor_check_message(&self) -> Option<String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<W: PyzorWrite> PyzorDigest<W> for Message<'_> {
|
impl<W: PyzorWrite> PyzorDigest<W> for Message<'_> {
|
||||||
fn pyzor_digest(&self, writer: W) -> W {
|
fn pyzor_digest(&self, writer: W) -> Option<W> {
|
||||||
let parts = self
|
let parts = self
|
||||||
.parts
|
.parts
|
||||||
.iter()
|
.iter()
|
||||||
@@ -191,7 +183,7 @@ impl<W: PyzorWrite> PyzorDigest<W> for Message<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PyzorCheck for Message<'_> {
|
impl PyzorCheck for Message<'_> {
|
||||||
fn pyzor_check_message(&self) -> String {
|
fn pyzor_check_message(&self) -> Option<String> {
|
||||||
let time = SystemTime::now()
|
let time = SystemTime::now()
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
.map_or(0, |d| d.as_secs());
|
.map_or(0, |d| d.as_secs());
|
||||||
@@ -204,9 +196,9 @@ impl PyzorCheck for Message<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pyzor_create_message(message: &Message<'_>, time: u64, thread: u16) -> String {
|
fn pyzor_create_message(message: &Message<'_>, time: u64, thread: u16) -> Option<String> {
|
||||||
// Hash message
|
// Hash message
|
||||||
let hash = message.pyzor_digest(Sha1::new()).finalize().hex_encode();
|
let hash = message.pyzor_digest(Sha1::new())?.finalize().hex_encode();
|
||||||
// Hash key
|
// Hash key
|
||||||
let mut hash_key = Sha1::new();
|
let mut hash_key = Sha1::new();
|
||||||
hash_key.update("anonymous:".as_bytes());
|
hash_key.update("anonymous:".as_bytes());
|
||||||
@@ -226,10 +218,10 @@ fn pyzor_create_message(message: &Message<'_>, time: u64, thread: u16) -> String
|
|||||||
sig.update(format!(":{time}:{hash_key}"));
|
sig.update(format!(":{time}:{hash_key}"));
|
||||||
let sig = sig.finalize().hex_encode();
|
let sig = sig.finalize().hex_encode();
|
||||||
|
|
||||||
format!("{message}\nSig: {sig}\n")
|
Some(format!("{message}\nSig: {sig}\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pyzor_digest<'x, I, W>(mut writer: W, lines: I) -> W
|
fn pyzor_digest<'x, I, W>(mut writer: W, lines: I) -> Option<W>
|
||||||
where
|
where
|
||||||
I: Iterator<Item = &'x str>,
|
I: Iterator<Item = &'x str>,
|
||||||
W: PyzorWrite,
|
W: PyzorWrite,
|
||||||
@@ -291,6 +283,10 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if result.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
if result.len() > ATOMIC_NUM_LINES {
|
if result.len() > ATOMIC_NUM_LINES {
|
||||||
for (offset, length) in DIGEST_SPEC {
|
for (offset, length) in DIGEST_SPEC {
|
||||||
for i in 0..*length {
|
for i in 0..*length {
|
||||||
@@ -305,7 +301,7 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writer
|
Some(writer)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn html_to_text(input: &str) -> String {
|
fn html_to_text(input: &str) -> String {
|
||||||
@@ -489,7 +485,8 @@ mod test {
|
|||||||
&MessageParser::new().parse(HTML_TEXT_STYLE_SCRIPT).unwrap(),
|
&MessageParser::new().parse(HTML_TEXT_STYLE_SCRIPT).unwrap(),
|
||||||
1697468672,
|
1697468672,
|
||||||
49005,
|
49005,
|
||||||
);
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
message,
|
message,
|
||||||
@@ -522,10 +519,9 @@ mod test {
|
|||||||
"http://spammer.com/special-offers?buy=now",
|
"http://spammer.com/special-offers?buy=now",
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
String::from_utf8(pyzor_digest(
|
String::from_utf8(
|
||||||
Vec::new(),
|
pyzor_digest(Vec::new(), format!("Test {strip_me} Test2").lines()).unwrap()
|
||||||
format!("Test {strip_me} Test2").lines(),
|
)
|
||||||
))
|
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
"TestTest2"
|
"TestTest2"
|
||||||
);
|
);
|
||||||
@@ -533,20 +529,26 @@ mod test {
|
|||||||
|
|
||||||
// Test short lines
|
// Test short lines
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
String::from_utf8(pyzor_digest(
|
String::from_utf8(
|
||||||
|
pyzor_digest(
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
concat!("This line is included\n", "not this\n", "This also").lines(),
|
concat!("This line is included\n", "not this\n", "This also").lines(),
|
||||||
))
|
)
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
"ThislineisincludedThisalso"
|
"ThislineisincludedThisalso"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test atomic
|
// Test atomic
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
String::from_utf8(pyzor_digest(
|
String::from_utf8(
|
||||||
|
pyzor_digest(
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
"All this message\nShould be included\nIn the digest".lines(),
|
"All this message\nShould be included\nIn the digest".lines(),
|
||||||
))
|
)
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
"AllthismessageShouldbeincludedInthedigest"
|
"AllthismessageShouldbeincludedInthedigest"
|
||||||
);
|
);
|
||||||
@@ -561,7 +563,7 @@ mod test {
|
|||||||
expected += format!("Line{i}testtesttest").as_str();
|
expected += format!("Line{i}testtesttest").as_str();
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
String::from_utf8(pyzor_digest(Vec::new(), text.lines(),)).unwrap(),
|
String::from_utf8(pyzor_digest(Vec::new(), text.lines()).unwrap()).unwrap(),
|
||||||
expected
|
expected
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -593,7 +595,8 @@ mod test {
|
|||||||
MessageParser::new()
|
MessageParser::new()
|
||||||
.parse(input)
|
.parse(input)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.pyzor_digest(Vec::new(),)
|
.pyzor_digest(Vec::new())
|
||||||
|
.unwrap()
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
expected,
|
expected,
|
||||||
@@ -606,7 +609,8 @@ mod test {
|
|||||||
MessageParser::new()
|
MessageParser::new()
|
||||||
.parse(HTML_TEXT_STYLE_SCRIPT)
|
.parse(HTML_TEXT_STYLE_SCRIPT)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.pyzor_digest(Sha1::new(),)
|
.pyzor_digest(Sha1::new())
|
||||||
|
.unwrap()
|
||||||
.finalize()
|
.finalize()
|
||||||
.hex_encode(),
|
.hex_encode(),
|
||||||
"b2c27325a034c581df0c9ef37e4a0d63208a3e7e",
|
"b2c27325a034c581df0c9ef37e4a0d63208a3e7e",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "store"
|
name = "store"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const CHURN_DELETION_WINDOW: usize = 4096;
|
|||||||
const CHURN_DELETION_TRIGGER: usize = 1024;
|
const CHURN_DELETION_TRIGGER: usize = 1024;
|
||||||
const CHURN_DELETION_RATIO: f64 = 0.5;
|
const CHURN_DELETION_RATIO: f64 = 0.5;
|
||||||
const BYTES_PER_SYNC: u64 = 1024 * 1024;
|
const BYTES_PER_SYNC: u64 = 1024 * 1024;
|
||||||
|
const MAX_LOG_FILE_SIZE: usize = 10 * 1024 * 1024;
|
||||||
|
const KEEP_LOG_FILE_NUM: usize = 5;
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum CfProfile {
|
enum CfProfile {
|
||||||
@@ -118,6 +120,8 @@ impl RocksDbStore {
|
|||||||
.set_db_write_buffer_size((config.buffer_size as usize).max(MIN_DB_WRITE_BUFFER_SIZE));
|
.set_db_write_buffer_size((config.buffer_size as usize).max(MIN_DB_WRITE_BUFFER_SIZE));
|
||||||
db_opts.set_bytes_per_sync(BYTES_PER_SYNC);
|
db_opts.set_bytes_per_sync(BYTES_PER_SYNC);
|
||||||
db_opts.set_wal_bytes_per_sync(BYTES_PER_SYNC);
|
db_opts.set_wal_bytes_per_sync(BYTES_PER_SYNC);
|
||||||
|
db_opts.set_max_log_file_size(MAX_LOG_FILE_SIZE);
|
||||||
|
db_opts.set_keep_log_file_num(KEEP_LOG_FILE_NUM);
|
||||||
|
|
||||||
Ok(Store::RocksDb(Arc::new(RocksDbStore {
|
Ok(Store::RocksDb(Arc::new(RocksDbStore {
|
||||||
db: OptimisticTransactionDB::open_cf_descriptors(&db_opts, idx_path, cfs)
|
db: OptimisticTransactionDB::open_cf_descriptors(&db_opts, idx_path, cfs)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "trc"
|
name = "trc"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "event_macro"
|
name = "event_macro"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "types"
|
name = "types"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "utils"
|
name = "utils"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "proc_macros"
|
name = "proc_macros"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tests"
|
name = "tests"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ spf.result none
|
|||||||
spf_ehlo.result none
|
spf_ehlo.result none
|
||||||
dmarc.result none
|
dmarc.result none
|
||||||
remote_ip 195.210.29.48
|
remote_ip 195.210.29.48
|
||||||
expect_header X-Spam-Result: ARC_NA (0.00), DKIM2_NA (0.00), DKIM_NA (0.00), FROM_EQ_ENV_FROM (0.00), FROM_HAS_DN (0.00), HAS_DATA_URI (0.00), HAS_LINK_TO_LARGE_IMG (0.00), HTML_SHORT_1 (0.00), MID_RHS_MATCH_ENV_FROM (0.00), RCPT_COUNT_ONE (0.00), SPF_NA (0.00), SUBJECT_ENDS_EXCLAIM (0.00), TO_DN_NONE (0.00), TO_MATCH_ENVRCPT_ALL (0.00), RCVD_COUNT_ZERO (0.10), RCVD_NO_TLS_LAST (0.10), MIME_HTML_ONLY (0.20), HELO_NORES_A_OR_MX (0.30), AUTH_NA (1.00), DATE_IN_PAST (1.00), DMARC_NA (1.00), MID_RHS_MATCH_FROM (1.00), FROMHOST_NORES_A_OR_MX (1.50), HTML_SHORT_LINK_IMG_1 (2.00), RDNS_NONE (2.00), PYZOR (3.50)
|
expect_header X-Spam-Result: ARC_NA (0.00), DKIM2_NA (0.00), DKIM_NA (0.00), FROM_EQ_ENV_FROM (0.00), FROM_HAS_DN (0.00), HAS_DATA_URI (0.00), HAS_LINK_TO_LARGE_IMG (0.00), HTML_SHORT_1 (0.00), MID_RHS_MATCH_ENV_FROM (0.00), RCPT_COUNT_ONE (0.00), SPF_NA (0.00), SUBJECT_ENDS_EXCLAIM (0.00), TO_DN_NONE (0.00), TO_MATCH_ENVRCPT_ALL (0.00), RCVD_COUNT_ZERO (0.10), RCVD_NO_TLS_LAST (0.10), MIME_HTML_ONLY (0.20), HELO_NORES_A_OR_MX (0.30), AUTH_NA (1.00), DATE_IN_PAST (1.00), DMARC_NA (1.00), MID_RHS_MATCH_FROM (1.00), FROMHOST_NORES_A_OR_MX (1.50), HTML_SHORT_LINK_IMG_1 (2.00), RDNS_NONE (2.00)
|
||||||
expect_header X-Spam-Score: spam, score=13.70
|
expect_header X-Spam-Score: spam, score=10.20
|
||||||
|
|
||||||
From: Client Services <[email protected]>
|
From: Client Services <[email protected]>
|
||||||
To: [email protected]
|
To: [email protected]
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ My e-mail is [email protected]
|
|||||||
And my website is https://sem-fresh15.com/offers.html
|
And my website is https://sem-fresh15.com/offers.html
|
||||||
Try cheating with a trusted domain [email protected]
|
Try cheating with a trusted domain [email protected]
|
||||||
|
|
||||||
|
<!-- NEXT TEST -->
|
||||||
|
expect DBL_SPAM DBL_PHISH
|
||||||
|
|
||||||
|
From: [email protected]
|
||||||
|
Subject: test
|
||||||
|
|
||||||
|
Our website is https://dbl-multi.com/offers.html
|
||||||
|
|
||||||
<!-- NEXT TEST -->
|
<!-- NEXT TEST -->
|
||||||
expect DBL_MALWARE
|
expect DBL_MALWARE
|
||||||
|
|
||||||
|
|||||||
+658
-55
@@ -10,20 +10,37 @@ use common::{config::smtp::auth::Dkim1Signer, network::dns::update::DNS_RECORDS}
|
|||||||
use dns_update::{DnsRecord, NamedDnsRecord};
|
use dns_update::{DnsRecord, NamedDnsRecord};
|
||||||
use registry::{
|
use registry::{
|
||||||
schema::{
|
schema::{
|
||||||
enums::{DkimRotationStage, DnsRecordType},
|
enums::{DkimRotationStage, DnsRecordType, IpProtocol, TsigAlgorithm},
|
||||||
prelude::ObjectType,
|
prelude::{ObjectType, Property},
|
||||||
structs::{
|
structs::{
|
||||||
CertificateManagement, Dkim1Signature, DkimManagement, DkimManagementProperties,
|
CertificateManagement, Dkim1Signature, DkimManagement, DkimManagementProperties,
|
||||||
DkimSignature, DnsManagement, DnsManagementProperties, DnsServer, DnsServerCloudflare,
|
DkimSignature, DnsManagement, DnsManagementProperties, DnsServer, DnsServerCloudflare,
|
||||||
Domain, SecretKey, SecretKeyValue,
|
DnsServerTsig, Domain, SecretKey, SecretKeyValue, Task, TaskDomainManagement,
|
||||||
|
TaskManager, TaskRetryStrategy, TaskRetryStrategyFixed, TaskStatus,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
types::duration::Duration,
|
types::{duration::Duration, map::Map},
|
||||||
};
|
};
|
||||||
|
use serde_json::json;
|
||||||
use store::write::now;
|
use store::write::now;
|
||||||
use types::id::Id;
|
use types::id::Id;
|
||||||
|
|
||||||
|
const SHORT_ROTATION_MS: u64 = 6_000;
|
||||||
|
const LONG_ROTATION_MS: u64 = 3_600_000;
|
||||||
|
|
||||||
pub async fn test(test: &TestServer) {
|
pub async fn test(test: &TestServer) {
|
||||||
|
fast_retry_tests(test).await;
|
||||||
|
unscheduled_keys_test(test, "dkim-manual.org", |_| DnsManagement::Manual).await;
|
||||||
|
unscheduled_keys_test(test, "dkim-unpublished.org", |dns_server_id| {
|
||||||
|
DnsManagement::Automatic(DnsManagementProperties {
|
||||||
|
dns_server_id,
|
||||||
|
publish_records: Map::new(vec![DnsRecordType::Spf]),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
automatic_to_manual_dns_test(test).await;
|
||||||
|
|
||||||
println!("Running DKIM Management tests...");
|
println!("Running DKIM Management tests...");
|
||||||
let account = test.account("[email protected]");
|
let account = test.account("[email protected]");
|
||||||
DNS_RECORDS.lock().unwrap().clear();
|
DNS_RECORDS.lock().unwrap().clear();
|
||||||
@@ -98,8 +115,8 @@ pub async fn test(test: &TestServer) {
|
|||||||
|
|
||||||
// Make sure the DNS records were created
|
// Make sure the DNS records were created
|
||||||
let records = DNS_RECORDS.lock().unwrap().clone();
|
let records = DNS_RECORDS.lock().unwrap().clone();
|
||||||
assert_key_has_dns_record(&records, &rot1_signatures.v1_rsa[0]);
|
assert_key_has_dns_record(&records, "dkim.org", &rot1_signatures.v1_rsa[0]);
|
||||||
assert_key_has_dns_record(&records, &rot1_signatures.v1_ed25519[0]);
|
assert_key_has_dns_record(&records, "dkim.org", &rot1_signatures.v1_ed25519[0]);
|
||||||
|
|
||||||
// Expect a rotation to happen and new keys to be created
|
// Expect a rotation to happen and new keys to be created
|
||||||
let rot2_signatures = account
|
let rot2_signatures = account
|
||||||
@@ -111,10 +128,10 @@ pub async fn test(test: &TestServer) {
|
|||||||
|
|
||||||
// Make sure both old and new keys have DNS records
|
// Make sure both old and new keys have DNS records
|
||||||
let records = DNS_RECORDS.lock().unwrap().clone();
|
let records = DNS_RECORDS.lock().unwrap().clone();
|
||||||
assert_key_has_dns_record(&records, &rot1_signatures.v1_rsa[0]);
|
assert_key_has_dns_record(&records, "dkim.org", &rot1_signatures.v1_rsa[0]);
|
||||||
assert_key_has_dns_record(&records, &rot1_signatures.v1_ed25519[0]);
|
assert_key_has_dns_record(&records, "dkim.org", &rot1_signatures.v1_ed25519[0]);
|
||||||
assert_key_has_dns_record(&records, &rot2_signatures.v1_rsa[0]);
|
assert_key_has_dns_record(&records, "dkim.org", &rot2_signatures.v1_rsa[0]);
|
||||||
assert_key_has_dns_record(&records, &rot2_signatures.v1_ed25519[0]);
|
assert_key_has_dns_record(&records, "dkim.org", &rot2_signatures.v1_ed25519[0]);
|
||||||
|
|
||||||
// Make sure only the new keys are being used for signing
|
// Make sure only the new keys are being used for signing
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
@@ -145,19 +162,19 @@ pub async fn test(test: &TestServer) {
|
|||||||
|
|
||||||
// Make sure the old records were deleted
|
// Make sure the old records were deleted
|
||||||
let records = DNS_RECORDS.lock().unwrap().clone();
|
let records = DNS_RECORDS.lock().unwrap().clone();
|
||||||
assert_key_has_no_dns_record(&records, &rot1_signatures.v1_rsa[0]);
|
assert_key_has_no_dns_record(&records, "dkim.org", &rot1_signatures.v1_rsa[0]);
|
||||||
assert_key_has_no_dns_record(&records, &rot1_signatures.v1_ed25519[0]);
|
assert_key_has_no_dns_record(&records, "dkim.org", &rot1_signatures.v1_ed25519[0]);
|
||||||
assert_key_has_dns_record(&records, &rot2_signatures.v1_rsa[0]);
|
assert_key_has_dns_record(&records, "dkim.org", &rot2_signatures.v1_rsa[0]);
|
||||||
assert_key_has_dns_record(&records, &rot2_signatures.v1_ed25519[0]);
|
assert_key_has_dns_record(&records, "dkim.org", &rot2_signatures.v1_ed25519[0]);
|
||||||
assert_key_has_dns_record(&records, &rot3_signatures.v1_rsa[0]);
|
assert_key_has_dns_record(&records, "dkim.org", &rot3_signatures.v1_rsa[0]);
|
||||||
assert_key_has_dns_record(&records, &rot3_signatures.v1_ed25519[0]);
|
assert_key_has_dns_record(&records, "dkim.org", &rot3_signatures.v1_ed25519[0]);
|
||||||
|
|
||||||
// Make sure the DNS management task does not republish the retired keys
|
// Make sure the DNS management task does not republish the retired keys
|
||||||
let (published, zone_file) = test.published_dkim_records(domain_id).await;
|
let (published, zone_file) = test.published_dkim_records(domain_id).await;
|
||||||
assert_key_has_no_dns_record(&published, &rot1_signatures.v1_rsa[0]);
|
assert_key_has_no_dns_record(&published, "dkim.org", &rot1_signatures.v1_rsa[0]);
|
||||||
assert_key_has_no_dns_record(&published, &rot1_signatures.v1_ed25519[0]);
|
assert_key_has_no_dns_record(&published, "dkim.org", &rot1_signatures.v1_ed25519[0]);
|
||||||
assert_key_has_dns_record(&published, &rot3_signatures.v1_rsa[0]);
|
assert_key_has_dns_record(&published, "dkim.org", &rot3_signatures.v1_rsa[0]);
|
||||||
assert_key_has_dns_record(&published, &rot3_signatures.v1_ed25519[0]);
|
assert_key_has_dns_record(&published, "dkim.org", &rot3_signatures.v1_ed25519[0]);
|
||||||
assert_zone_file_omits_key(&zone_file, &rot1_signatures.v1_rsa[0]);
|
assert_zone_file_omits_key(&zone_file, &rot1_signatures.v1_rsa[0]);
|
||||||
assert_zone_file_omits_key(&zone_file, &rot1_signatures.v1_ed25519[0]);
|
assert_zone_file_omits_key(&zone_file, &rot1_signatures.v1_ed25519[0]);
|
||||||
|
|
||||||
@@ -192,17 +209,17 @@ pub async fn test(test: &TestServer) {
|
|||||||
|
|
||||||
// Make sure the old records were updated
|
// Make sure the old records were updated
|
||||||
let records = DNS_RECORDS.lock().unwrap().clone();
|
let records = DNS_RECORDS.lock().unwrap().clone();
|
||||||
assert_key_has_dns_record(&records, &rot4_signatures.v1_rsa[0]);
|
assert_key_has_dns_record(&records, "dkim.org", &rot4_signatures.v1_rsa[0]);
|
||||||
assert_key_has_dns_record(&records, &rot4_signatures.v1_ed25519[0]);
|
assert_key_has_dns_record(&records, "dkim.org", &rot4_signatures.v1_ed25519[0]);
|
||||||
assert_key_has_no_dns_record(&records, &rot2_signatures.v1_rsa[0]);
|
assert_key_has_no_dns_record(&records, "dkim.org", &rot2_signatures.v1_rsa[0]);
|
||||||
assert_key_has_no_dns_record(&records, &rot2_signatures.v1_ed25519[0]);
|
assert_key_has_no_dns_record(&records, "dkim.org", &rot2_signatures.v1_ed25519[0]);
|
||||||
|
|
||||||
// Make sure the DNS management task does not republish the retired keys
|
// Make sure the DNS management task does not republish the retired keys
|
||||||
let (published, zone_file) = test.published_dkim_records(domain_id).await;
|
let (published, zone_file) = test.published_dkim_records(domain_id).await;
|
||||||
assert_key_has_no_dns_record(&published, &rot2_signatures.v1_rsa[0]);
|
assert_key_has_no_dns_record(&published, "dkim.org", &rot2_signatures.v1_rsa[0]);
|
||||||
assert_key_has_no_dns_record(&published, &rot2_signatures.v1_ed25519[0]);
|
assert_key_has_no_dns_record(&published, "dkim.org", &rot2_signatures.v1_ed25519[0]);
|
||||||
assert_key_has_dns_record(&published, &rot4_signatures.v1_rsa[0]);
|
assert_key_has_dns_record(&published, "dkim.org", &rot4_signatures.v1_rsa[0]);
|
||||||
assert_key_has_dns_record(&published, &rot4_signatures.v1_ed25519[0]);
|
assert_key_has_dns_record(&published, "dkim.org", &rot4_signatures.v1_ed25519[0]);
|
||||||
assert_zone_file_omits_key(&zone_file, &rot2_signatures.v1_rsa[0]);
|
assert_zone_file_omits_key(&zone_file, &rot2_signatures.v1_rsa[0]);
|
||||||
assert_zone_file_omits_key(&zone_file, &rot2_signatures.v1_ed25519[0]);
|
assert_zone_file_omits_key(&zone_file, &rot2_signatures.v1_ed25519[0]);
|
||||||
|
|
||||||
@@ -235,6 +252,470 @@ pub async fn test(test: &TestServer) {
|
|||||||
account.registry_destroy_all(ObjectType::DnsServer).await;
|
account.registry_destroy_all(ObjectType::DnsServer).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn fast_retry_tests(test: &TestServer) {
|
||||||
|
let account = test.account("[email protected]");
|
||||||
|
|
||||||
|
// Retry failed tasks every second
|
||||||
|
account
|
||||||
|
.registry_update_setting(
|
||||||
|
TaskManager {
|
||||||
|
max_attempts: 100,
|
||||||
|
strategy: TaskRetryStrategy::FixedDelay(TaskRetryStrategyFixed {
|
||||||
|
delay: 1_000u64.into(),
|
||||||
|
}),
|
||||||
|
total_deadline: 86_400_000u64.into(),
|
||||||
|
},
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
account.reload_settings().await;
|
||||||
|
|
||||||
|
failed_publish_test(test).await;
|
||||||
|
manual_dns_pending_test(test).await;
|
||||||
|
|
||||||
|
account
|
||||||
|
.registry_update_setting(TaskManager::default(), &[])
|
||||||
|
.await;
|
||||||
|
account.reload_settings().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn failed_publish_test(test: &TestServer) {
|
||||||
|
println!("Running DKIM failed publish tests...");
|
||||||
|
let account = test.account("[email protected]");
|
||||||
|
DNS_RECORDS.lock().unwrap().clear();
|
||||||
|
account.dkim_signatures().await.assert_total(0, 0);
|
||||||
|
|
||||||
|
// Create an in-memory DNS server and a DNS server that refuses connections
|
||||||
|
let dns_server_id = account.create_memory_dns_server().await;
|
||||||
|
let failing_dns_server_id = account.create_failing_dns_server().await;
|
||||||
|
|
||||||
|
// Create a domain whose initial keys rotate shortly
|
||||||
|
let domain_id = account
|
||||||
|
.registry_create_object(Domain {
|
||||||
|
name: "dkim-retry.org".to_string(),
|
||||||
|
certificate_management: CertificateManagement::Manual,
|
||||||
|
dkim_management: dkim_management(SHORT_ROTATION_MS),
|
||||||
|
dns_management: dns_management(dns_server_id),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let initial = account
|
||||||
|
.wait_for_dkim_stages(&[(DkimRotationStage::Active, 2)])
|
||||||
|
.await
|
||||||
|
.assert_total(1, 1);
|
||||||
|
let old_rsa = initial.v1_rsa[0].selector.clone();
|
||||||
|
let old_ed = initial.v1_ed25519[0].selector.clone();
|
||||||
|
test.assert_has_signers("dkim-retry.org", &[&old_rsa, &old_ed])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Point the domain at the failing DNS server before the rotation is due, and
|
||||||
|
// make the keys created from now on long-lived
|
||||||
|
account
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Domain,
|
||||||
|
domain_id,
|
||||||
|
json!({
|
||||||
|
Property::DnsManagement: dns_management(failing_dns_server_id),
|
||||||
|
Property::DkimManagement: dkim_management(LONG_ROTATION_MS),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
initial.v1_rsa[0].next_transition_at.unwrap().timestamp() > now() as i64,
|
||||||
|
"Rotation was due before the DNS server could be replaced: {:#?}",
|
||||||
|
initial
|
||||||
|
);
|
||||||
|
|
||||||
|
// The new keys cannot be published, so they must stay pending while the
|
||||||
|
// old keys remain active and keep signing
|
||||||
|
let failed = account
|
||||||
|
.wait_for_dkim_stages(&[
|
||||||
|
(DkimRotationStage::Pending, 2),
|
||||||
|
(DkimRotationStage::Active, 2),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.assert_total(2, 2)
|
||||||
|
.assert_selector_stage(&old_rsa, DkimRotationStage::Active)
|
||||||
|
.assert_selector_stage(&old_ed, DkimRotationStage::Active);
|
||||||
|
let new_rsa = failed.v1_rsa[0].selector.clone();
|
||||||
|
let new_ed = failed.v1_ed25519[0].selector.clone();
|
||||||
|
let failed = failed
|
||||||
|
.assert_selector_stage(&new_rsa, DkimRotationStage::Pending)
|
||||||
|
.assert_selector_stage(&new_ed, DkimRotationStage::Pending);
|
||||||
|
test.assert_has_signers("dkim-retry.org", &[&old_rsa, &old_ed])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Several retries must neither create duplicate keys nor retire the old ones
|
||||||
|
let failure_reason = account.wait_for_dkim_task_attempts(domain_id, 4).await;
|
||||||
|
assert!(
|
||||||
|
failure_reason.contains("Failed to publish DKIM record"),
|
||||||
|
"Unexpected failure reason: {failure_reason}"
|
||||||
|
);
|
||||||
|
let retried = account.dkim_signatures().await;
|
||||||
|
assert_eq!(
|
||||||
|
retried, failed,
|
||||||
|
"DKIM signatures changed while the DNS server was failing"
|
||||||
|
);
|
||||||
|
test.assert_has_signers("dkim-retry.org", &[&old_rsa, &old_ed])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Under manual DNS management the pending keys stay pending next to the
|
||||||
|
// active keys, and the task stops retrying
|
||||||
|
account
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Domain,
|
||||||
|
domain_id,
|
||||||
|
json!({
|
||||||
|
Property::DnsManagement: DnsManagement::Manual,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
account.wait_for_no_dkim_tasks(domain_id).await;
|
||||||
|
assert_eq!(
|
||||||
|
account.dkim_signatures().await,
|
||||||
|
failed,
|
||||||
|
"DKIM signatures changed under manual DNS management"
|
||||||
|
);
|
||||||
|
test.assert_has_signers("dkim-retry.org", &[&old_rsa, &old_ed])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Once automatic DNS management uses a working server again, the pending
|
||||||
|
// keys are activated and the old keys start retiring
|
||||||
|
account
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Domain,
|
||||||
|
domain_id,
|
||||||
|
json!({
|
||||||
|
Property::DnsManagement: dns_management(dns_server_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let recovered = account
|
||||||
|
.wait_for_dkim_stages(&[
|
||||||
|
(DkimRotationStage::Active, 2),
|
||||||
|
(DkimRotationStage::Retiring, 2),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.assert_total(2, 2)
|
||||||
|
.assert_selector_stage(&new_rsa, DkimRotationStage::Active)
|
||||||
|
.assert_selector_stage(&new_ed, DkimRotationStage::Active)
|
||||||
|
.assert_selector_stage(&old_rsa, DkimRotationStage::Retiring)
|
||||||
|
.assert_selector_stage(&old_ed, DkimRotationStage::Retiring);
|
||||||
|
test.assert_has_signers("dkim-retry.org", &[&new_rsa, &new_ed])
|
||||||
|
.await;
|
||||||
|
let records = DNS_RECORDS.lock().unwrap().clone();
|
||||||
|
assert_key_has_dns_record(&records, "dkim-retry.org", &recovered.v1_rsa[0]);
|
||||||
|
assert_key_has_dns_record(&records, "dkim-retry.org", &recovered.v1_ed25519[0]);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
account.registry_destroy_all(ObjectType::Task).await;
|
||||||
|
account
|
||||||
|
.registry_destroy_all(ObjectType::DkimSignature)
|
||||||
|
.await;
|
||||||
|
account
|
||||||
|
.registry_destroy(ObjectType::Domain, [domain_id])
|
||||||
|
.await
|
||||||
|
.assert_destroyed(&[domain_id]);
|
||||||
|
account.registry_destroy_all(ObjectType::DnsServer).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unscheduled_keys_test(
|
||||||
|
test: &TestServer,
|
||||||
|
domain: &str,
|
||||||
|
initial_dns_management: fn(Id) -> DnsManagement,
|
||||||
|
) {
|
||||||
|
println!("Running DKIM unscheduled key tests for {domain}...");
|
||||||
|
let account = test.account("[email protected]");
|
||||||
|
DNS_RECORDS.lock().unwrap().clear();
|
||||||
|
account.dkim_signatures().await.assert_total(0, 0);
|
||||||
|
let dns_server_id = account.create_memory_dns_server().await;
|
||||||
|
|
||||||
|
// Keys created while DKIM records are not published automatically are
|
||||||
|
// active, unpublished and have no rotation schedule
|
||||||
|
let domain_id = account
|
||||||
|
.registry_create_object(Domain {
|
||||||
|
name: domain.to_string(),
|
||||||
|
certificate_management: CertificateManagement::Manual,
|
||||||
|
dkim_management: dkim_management(SHORT_ROTATION_MS),
|
||||||
|
dns_management: initial_dns_management(dns_server_id),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let initial = account
|
||||||
|
.wait_for_dkim_stages(&[(DkimRotationStage::Active, 2)])
|
||||||
|
.await
|
||||||
|
.assert_total(1, 1);
|
||||||
|
assert!(
|
||||||
|
initial.keys().all(|key| key.next_transition_at.is_none()),
|
||||||
|
"Unexpected rotation schedule for unpublished keys: {initial:#?}"
|
||||||
|
);
|
||||||
|
let records = DNS_RECORDS.lock().unwrap().clone();
|
||||||
|
assert_key_has_no_dns_record(&records, domain, &initial.v1_rsa[0]);
|
||||||
|
assert_key_has_no_dns_record(&records, domain, &initial.v1_ed25519[0]);
|
||||||
|
let old_rsa = initial.v1_rsa[0].selector.clone();
|
||||||
|
let old_ed = initial.v1_ed25519[0].selector.clone();
|
||||||
|
|
||||||
|
// Publishing DKIM records automatically publishes the keys and schedules
|
||||||
|
// their rotation
|
||||||
|
account
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Domain,
|
||||||
|
domain_id,
|
||||||
|
json!({
|
||||||
|
Property::DnsManagement: dns_management(dns_server_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let scheduled = account
|
||||||
|
.wait_for_dkim("active keys with a rotation schedule", |signatures| {
|
||||||
|
signatures.total() == 2
|
||||||
|
&& signatures.stage_count(DkimRotationStage::Active) == 2
|
||||||
|
&& signatures
|
||||||
|
.keys()
|
||||||
|
.all(|key| key.next_transition_at.is_some())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Make the keys created by the rotation long-lived
|
||||||
|
account
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Domain,
|
||||||
|
domain_id,
|
||||||
|
json!({
|
||||||
|
Property::DkimManagement: dkim_management(LONG_ROTATION_MS),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
scheduled
|
||||||
|
.keys()
|
||||||
|
.all(|key| key.next_transition_at.unwrap().timestamp() > now() as i64),
|
||||||
|
"Rotation was due before the rotation period could be extended: {scheduled:#?}"
|
||||||
|
);
|
||||||
|
wait_for_dns_records(domain, &[&old_rsa, &old_ed]).await;
|
||||||
|
|
||||||
|
// The keys rotate once the schedule elapses
|
||||||
|
let rotated = account
|
||||||
|
.wait_for_dkim_stages(&[
|
||||||
|
(DkimRotationStage::Active, 2),
|
||||||
|
(DkimRotationStage::Retiring, 2),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.assert_total(2, 2)
|
||||||
|
.assert_selector_stage(&old_rsa, DkimRotationStage::Retiring)
|
||||||
|
.assert_selector_stage(&old_ed, DkimRotationStage::Retiring);
|
||||||
|
test.assert_has_signers(
|
||||||
|
domain,
|
||||||
|
&[&rotated.v1_rsa[0].selector, &rotated.v1_ed25519[0].selector],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let records = DNS_RECORDS.lock().unwrap().clone();
|
||||||
|
assert_key_has_dns_record(&records, domain, &rotated.v1_rsa[0]);
|
||||||
|
assert_key_has_dns_record(&records, domain, &rotated.v1_ed25519[0]);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
account.registry_destroy_all(ObjectType::Task).await;
|
||||||
|
account
|
||||||
|
.registry_destroy_all(ObjectType::DkimSignature)
|
||||||
|
.await;
|
||||||
|
account
|
||||||
|
.registry_destroy(ObjectType::Domain, [domain_id])
|
||||||
|
.await
|
||||||
|
.assert_destroyed(&[domain_id]);
|
||||||
|
account.registry_destroy_all(ObjectType::DnsServer).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn automatic_to_manual_dns_test(test: &TestServer) {
|
||||||
|
println!("Running DKIM automatic to manual DNS tests...");
|
||||||
|
let account = test.account("[email protected]");
|
||||||
|
DNS_RECORDS.lock().unwrap().clear();
|
||||||
|
account.dkim_signatures().await.assert_total(0, 0);
|
||||||
|
let dns_server_id = account.create_memory_dns_server().await;
|
||||||
|
|
||||||
|
// Create a domain whose initial keys rotate shortly
|
||||||
|
let domain_id = account
|
||||||
|
.registry_create_object(Domain {
|
||||||
|
name: "dkim-mirror.org".to_string(),
|
||||||
|
certificate_management: CertificateManagement::Manual,
|
||||||
|
dkim_management: dkim_management(SHORT_ROTATION_MS),
|
||||||
|
dns_management: dns_management(dns_server_id),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let initial = account
|
||||||
|
.wait_for_dkim_stages(&[(DkimRotationStage::Active, 2)])
|
||||||
|
.await
|
||||||
|
.assert_total(1, 1);
|
||||||
|
let old_rsa = initial.v1_rsa[0].selector.clone();
|
||||||
|
let old_ed = initial.v1_ed25519[0].selector.clone();
|
||||||
|
|
||||||
|
// Switch to manual DNS management before the rotation is due, and make the
|
||||||
|
// keys created from now on long-lived
|
||||||
|
account
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Domain,
|
||||||
|
domain_id,
|
||||||
|
json!({
|
||||||
|
Property::DnsManagement: DnsManagement::Manual,
|
||||||
|
Property::DkimManagement: dkim_management(LONG_ROTATION_MS),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let due = initial.v1_rsa[0].next_transition_at.unwrap().timestamp();
|
||||||
|
let wait_secs = due - now() as i64;
|
||||||
|
assert!(
|
||||||
|
wait_secs > 0,
|
||||||
|
"Rotation was due before DNS management was switched: {initial:#?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Once the rotation is due, the task must neither rotate the keys nor keep
|
||||||
|
// retrying
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(wait_secs as u64 + 1)).await;
|
||||||
|
account.wait_for_no_dkim_tasks(domain_id).await;
|
||||||
|
assert_eq!(
|
||||||
|
account.dkim_signatures().await,
|
||||||
|
initial,
|
||||||
|
"DKIM signatures changed under manual DNS management"
|
||||||
|
);
|
||||||
|
test.assert_has_signers("dkim-mirror.org", &[&old_rsa, &old_ed])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Switching back to automatic DNS management completes the overdue rotation
|
||||||
|
account
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Domain,
|
||||||
|
domain_id,
|
||||||
|
json!({
|
||||||
|
Property::DnsManagement: dns_management(dns_server_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let rotated = account
|
||||||
|
.wait_for_dkim_stages(&[
|
||||||
|
(DkimRotationStage::Active, 2),
|
||||||
|
(DkimRotationStage::Retiring, 2),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.assert_total(2, 2)
|
||||||
|
.assert_selector_stage(&old_rsa, DkimRotationStage::Retiring)
|
||||||
|
.assert_selector_stage(&old_ed, DkimRotationStage::Retiring);
|
||||||
|
test.assert_has_signers(
|
||||||
|
"dkim-mirror.org",
|
||||||
|
&[&rotated.v1_rsa[0].selector, &rotated.v1_ed25519[0].selector],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let records = DNS_RECORDS.lock().unwrap().clone();
|
||||||
|
assert_key_has_dns_record(&records, "dkim-mirror.org", &rotated.v1_rsa[0]);
|
||||||
|
assert_key_has_dns_record(&records, "dkim-mirror.org", &rotated.v1_ed25519[0]);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
account.registry_destroy_all(ObjectType::Task).await;
|
||||||
|
account
|
||||||
|
.registry_destroy_all(ObjectType::DkimSignature)
|
||||||
|
.await;
|
||||||
|
account
|
||||||
|
.registry_destroy(ObjectType::Domain, [domain_id])
|
||||||
|
.await
|
||||||
|
.assert_destroyed(&[domain_id]);
|
||||||
|
account.registry_destroy_all(ObjectType::DnsServer).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn manual_dns_pending_test(test: &TestServer) {
|
||||||
|
println!("Running DKIM pending key under manual DNS tests...");
|
||||||
|
let account = test.account("[email protected]");
|
||||||
|
DNS_RECORDS.lock().unwrap().clear();
|
||||||
|
account.dkim_signatures().await.assert_total(0, 0);
|
||||||
|
let failing_dns_server_id = account.create_failing_dns_server().await;
|
||||||
|
|
||||||
|
// Keys created while the DNS server is failing stay pending
|
||||||
|
let domain_id = account
|
||||||
|
.registry_create_object(Domain {
|
||||||
|
name: "dkim-pending.org".to_string(),
|
||||||
|
certificate_management: CertificateManagement::Manual,
|
||||||
|
dkim_management: dkim_management(LONG_ROTATION_MS),
|
||||||
|
dns_management: dns_management(failing_dns_server_id),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let pending = account
|
||||||
|
.wait_for_dkim_stages(&[(DkimRotationStage::Pending, 2)])
|
||||||
|
.await
|
||||||
|
.assert_total(1, 1);
|
||||||
|
let rsa = pending.v1_rsa[0].selector.clone();
|
||||||
|
let ed = pending.v1_ed25519[0].selector.clone();
|
||||||
|
|
||||||
|
// Switching to manual DNS management activates the pending keys without a
|
||||||
|
// rotation schedule, and the task stops retrying
|
||||||
|
account
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Domain,
|
||||||
|
domain_id,
|
||||||
|
json!({
|
||||||
|
Property::DnsManagement: DnsManagement::Manual,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let active = account
|
||||||
|
.wait_for_dkim_stages(&[(DkimRotationStage::Active, 2)])
|
||||||
|
.await
|
||||||
|
.assert_total(1, 1)
|
||||||
|
.assert_selector_stage(&rsa, DkimRotationStage::Active)
|
||||||
|
.assert_selector_stage(&ed, DkimRotationStage::Active);
|
||||||
|
assert!(
|
||||||
|
active.keys().all(|key| key.next_transition_at.is_none()),
|
||||||
|
"Unexpected rotation schedule under manual DNS management: {active:#?}"
|
||||||
|
);
|
||||||
|
test.assert_has_signers("dkim-pending.org", &[&rsa, &ed])
|
||||||
|
.await;
|
||||||
|
account.wait_for_no_dkim_tasks(domain_id).await;
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
account.registry_destroy_all(ObjectType::Task).await;
|
||||||
|
account
|
||||||
|
.registry_destroy_all(ObjectType::DkimSignature)
|
||||||
|
.await;
|
||||||
|
account
|
||||||
|
.registry_destroy(ObjectType::Domain, [domain_id])
|
||||||
|
.await
|
||||||
|
.assert_destroyed(&[domain_id]);
|
||||||
|
account.registry_destroy_all(ObjectType::DnsServer).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dns_management(dns_server_id: Id) -> DnsManagement {
|
||||||
|
DnsManagement::Automatic(DnsManagementProperties {
|
||||||
|
dns_server_id,
|
||||||
|
publish_records: Map::new(vec![DnsRecordType::Dkim]),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dkim_management(rotate_after: u64) -> DkimManagement {
|
||||||
|
DkimManagement::Automatic(DkimManagementProperties {
|
||||||
|
delete_after: Duration::from_millis(LONG_ROTATION_MS),
|
||||||
|
retire_after: Duration::from_millis(LONG_ROTATION_MS),
|
||||||
|
rotate_after: Duration::from_millis(rotate_after),
|
||||||
|
selector_template: "dummy-v{version}-{algorithm}-{epoch}".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_dns_records(domain: &str, selectors: &[&str]) {
|
||||||
|
for _ in 0..50 {
|
||||||
|
let records = DNS_RECORDS.lock().unwrap().clone();
|
||||||
|
if selectors
|
||||||
|
.iter()
|
||||||
|
.all(|selector| has_dns_record(&records, domain, selector))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
panic!(
|
||||||
|
"DNS records for selectors {selectors:?} were not published: {:#?}",
|
||||||
|
DNS_RECORDS.lock().unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Default)]
|
#[derive(Debug, PartialEq, Eq, Default)]
|
||||||
struct DkimSignatures {
|
struct DkimSignatures {
|
||||||
v1_rsa: Vec<Dkim1Signature>,
|
v1_rsa: Vec<Dkim1Signature>,
|
||||||
@@ -265,6 +746,108 @@ impl Account {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn wait_for_dkim_stages(
|
||||||
|
&self,
|
||||||
|
expected: &[(DkimRotationStage, usize)],
|
||||||
|
) -> DkimSignatures {
|
||||||
|
let expected_total = expected.iter().map(|(_, count)| count).sum::<usize>();
|
||||||
|
self.wait_for_dkim(&format!("stages {expected:?}"), |signatures| {
|
||||||
|
signatures.total() == expected_total
|
||||||
|
&& expected
|
||||||
|
.iter()
|
||||||
|
.all(|(stage, count)| signatures.stage_count(*stage) == *count)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_dkim(
|
||||||
|
&self,
|
||||||
|
expected: &str,
|
||||||
|
is_expected: impl Fn(&DkimSignatures) -> bool,
|
||||||
|
) -> DkimSignatures {
|
||||||
|
let mut signatures = self.dkim_signatures().await;
|
||||||
|
for _ in 0..50 {
|
||||||
|
if is_expected(&signatures) {
|
||||||
|
return signatures;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
|
signatures = self.dkim_signatures().await;
|
||||||
|
}
|
||||||
|
panic!("DKIM signatures did not reach {expected}: {signatures:#?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_no_dkim_tasks(&self, domain_id: Id) {
|
||||||
|
for _ in 0..60 {
|
||||||
|
if self.tasks().await.is_some_and(|tasks| {
|
||||||
|
!tasks.iter().any(|task| {
|
||||||
|
matches!(&task.task, Task::DkimManagement(task) if task.domain_id == domain_id)
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
panic!(
|
||||||
|
"DKIM management task did not complete: {:#?}",
|
||||||
|
self.tasks()
|
||||||
|
.await
|
||||||
|
.map(|tasks| tasks.into_iter().map(|task| task.task).collect::<Vec<_>>())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_failing_dns_server(&self) -> Id {
|
||||||
|
self.registry_create_object(DnsServer::Tsig(DnsServerTsig {
|
||||||
|
host: "127.0.0.1".parse().unwrap(),
|
||||||
|
port: 1,
|
||||||
|
key_name: "stalwart-update-key".to_string(),
|
||||||
|
key: SecretKey::Value(SecretKeyValue {
|
||||||
|
secret: "c3RhbHdhcnQtdGVzdC10c2lnLXNlY3JldA==".into(),
|
||||||
|
}),
|
||||||
|
protocol: IpProtocol::Tcp,
|
||||||
|
tsig_algorithm: TsigAlgorithm::HmacSha256,
|
||||||
|
description: "Unreachable DNS server".to_string(),
|
||||||
|
timeout: Duration::from_millis(1_000),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_memory_dns_server(&self) -> Id {
|
||||||
|
self.registry_create_object(DnsServer::Cloudflare(DnsServerCloudflare {
|
||||||
|
secret: SecretKey::Value(SecretKeyValue {
|
||||||
|
secret: "[email protected]".into(),
|
||||||
|
}),
|
||||||
|
description: "In-memory DNS server".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_dkim_task_attempts(&self, domain_id: Id, attempts: u64) -> String {
|
||||||
|
for _ in 0..60 {
|
||||||
|
if let Some(tasks) = self.tasks().await
|
||||||
|
&& let Some(failure_reason) = tasks.into_iter().find_map(|task| match task.task {
|
||||||
|
Task::DkimManagement(TaskDomainManagement {
|
||||||
|
domain_id: task_domain_id,
|
||||||
|
status: TaskStatus::Retry(retry),
|
||||||
|
}) if task_domain_id == domain_id && retry.attempt_number >= attempts => {
|
||||||
|
Some(retry.failure_reason)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return failure_reason;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
panic!(
|
||||||
|
"DKIM management task did not reach {attempts} attempts: {:#?}",
|
||||||
|
self.tasks()
|
||||||
|
.await
|
||||||
|
.map(|tasks| tasks.into_iter().map(|task| task.task).collect::<Vec<_>>())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async fn dkim_signatures(&self) -> DkimSignatures {
|
async fn dkim_signatures(&self) -> DkimSignatures {
|
||||||
let signatures = self.registry_get_all::<DkimSignature>().await;
|
let signatures = self.registry_get_all::<DkimSignature>().await;
|
||||||
let mut v1_rsa = Vec::new();
|
let mut v1_rsa = Vec::new();
|
||||||
@@ -286,9 +869,35 @@ impl Account {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DkimSignatures {
|
impl DkimSignatures {
|
||||||
|
fn keys(&self) -> impl Iterator<Item = &Dkim1Signature> {
|
||||||
|
self.v1_rsa.iter().chain(&self.v1_ed25519)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn total(&self) -> usize {
|
||||||
|
self.v1_rsa.len() + self.v1_ed25519.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stage_count(&self, stage: DkimRotationStage) -> usize {
|
||||||
|
self.v1_rsa.iter().filter(|s| s.stage == stage).count()
|
||||||
|
+ self.v1_ed25519.iter().filter(|s| s.stage == stage).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_selector_stage(self, selector: &str, stage: DkimRotationStage) -> Self {
|
||||||
|
assert!(
|
||||||
|
self.v1_rsa
|
||||||
|
.iter()
|
||||||
|
.chain(self.v1_ed25519.iter())
|
||||||
|
.any(|s| s.selector == selector && s.stage == stage),
|
||||||
|
"Expected selector {} in stage {:?}: {:#?}",
|
||||||
|
selector,
|
||||||
|
stage,
|
||||||
|
self
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
fn assert_stage_count(self, stage: DkimRotationStage, count: usize) -> Self {
|
fn assert_stage_count(self, stage: DkimRotationStage, count: usize) -> Self {
|
||||||
let actual_count = self.v1_rsa.iter().filter(|s| s.stage == stage).count()
|
let actual_count = self.stage_count(stage);
|
||||||
+ self.v1_ed25519.iter().filter(|s| s.stage == stage).count();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
actual_count, count,
|
actual_count, count,
|
||||||
"Expected {} signatures in stage {:?}, found {}: {:#?}",
|
"Expected {} signatures in stage {:?}, found {}: {:#?}",
|
||||||
@@ -369,21 +978,23 @@ impl TestServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_key_has_dns_record(records: &[NamedDnsRecord], key: &Dkim1Signature) {
|
fn has_dns_record(records: &[NamedDnsRecord], domain: &str, selector: &str) -> bool {
|
||||||
let expected = format!("{}._domainkey.dkim.org.", key.selector);
|
let expected = format!("{selector}._domainkey.{domain}.");
|
||||||
for record in records {
|
records.iter().any(|record| {
|
||||||
if record.name == expected
|
record.name == expected
|
||||||
&& let DnsRecord::TXT(txt) = &record.record
|
&& matches!(&record.record, DnsRecord::TXT(txt)
|
||||||
&& ((key.selector.contains("rsa") && txt.starts_with("v=DKIM1; k=rsa; h=sha256; p="))
|
if (selector.contains("rsa") && txt.starts_with("v=DKIM1; k=rsa; h=sha256; p="))
|
||||||
|| (key.selector.contains("ed25519")
|
|| (selector.contains("ed25519")
|
||||||
&& txt.starts_with("v=DKIM1; k=ed25519; h=sha256; p=")))
|
&& txt.starts_with("v=DKIM1; k=ed25519; h=sha256; p=")))
|
||||||
{
|
})
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
panic!(
|
fn assert_key_has_dns_record(records: &[NamedDnsRecord], domain: &str, key: &Dkim1Signature) {
|
||||||
|
assert!(
|
||||||
|
has_dns_record(records, domain, &key.selector),
|
||||||
"No DNS record found for DKIM key with selector {}, records: {:#?}",
|
"No DNS record found for DKIM key with selector {}, records: {:#?}",
|
||||||
key.selector, records
|
key.selector,
|
||||||
|
records
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,19 +1007,11 @@ fn assert_zone_file_omits_key(zone_file: &str, key: &Dkim1Signature) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_key_has_no_dns_record(records: &[NamedDnsRecord], key: &Dkim1Signature) {
|
fn assert_key_has_no_dns_record(records: &[NamedDnsRecord], domain: &str, key: &Dkim1Signature) {
|
||||||
let expected = format!("{}._domainkey.dkim.org.", key.selector);
|
assert!(
|
||||||
for record in records {
|
!has_dns_record(records, domain, &key.selector),
|
||||||
if record.name == expected
|
|
||||||
&& let DnsRecord::TXT(txt) = &record.record
|
|
||||||
&& ((key.selector.contains("rsa") && txt.starts_with("v=DKIM1; k=rsa; h=sha256; p="))
|
|
||||||
|| (key.selector.contains("ed25519")
|
|
||||||
&& txt.starts_with("v=DKIM1; k=ed25519; h=sha256; p=")))
|
|
||||||
{
|
|
||||||
panic!(
|
|
||||||
"Unexpected DNS record found for DKIM key with selector {}, records: {:#?}",
|
"Unexpected DNS record found for DKIM key with selector {}, records: {:#?}",
|
||||||
key.selector, records
|
key.selector,
|
||||||
|
records
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -211,6 +211,12 @@ async fn antispam() {
|
|||||||
Instant::now() + Duration::from_secs(100),
|
Instant::now() + Duration::from_secs(100),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// A DNSBL answer with several codes must produce one tag per code
|
||||||
|
test.server.dnsbl_add(
|
||||||
|
"dbl-multi.com.dbl.spamhaus.org",
|
||||||
|
vec!["127.0.1.2".parse().unwrap(), "127.0.1.4".parse().unwrap()],
|
||||||
|
Instant::now() + Duration::from_secs(100),
|
||||||
|
);
|
||||||
for mx in [
|
for mx in [
|
||||||
"domain.org",
|
"domain.org",
|
||||||
"domain.co.uk",
|
"domain.co.uk",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::utils::{jmap::JmapUtils, server::TestServer};
|
use crate::utils::{jmap::JmapUtils, server::TestServer};
|
||||||
@@ -11,8 +13,8 @@ use registry::{
|
|||||||
enums::{Permission, TaskSpamFilterMaintenanceType, TaskStoreMaintenanceType},
|
enums::{Permission, TaskSpamFilterMaintenanceType, TaskStoreMaintenanceType},
|
||||||
prelude::{ObjectType, Property},
|
prelude::{ObjectType, Property},
|
||||||
structs::{
|
structs::{
|
||||||
Permissions, PermissionsList, SpamTrainingSample, Task, TaskSpamFilterMaintenance,
|
Jmap, Permissions, PermissionsList, SpamTrainingSample, Task,
|
||||||
TaskStatus, TaskStoreMaintenance,
|
TaskSpamFilterMaintenance, TaskStatus, TaskStoreMaintenance,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
types::map::Map,
|
types::map::Map,
|
||||||
@@ -26,6 +28,21 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
|
|
||||||
// Create test accounts
|
// Create test accounts
|
||||||
let admin = test.account("[email protected]");
|
let admin = test.account("[email protected]");
|
||||||
|
|
||||||
|
// inbuxa: the quota test leaves uploads expiring after one second, at
|
||||||
|
// most three at a time. This test imports twenty samples, and a debug
|
||||||
|
// build can take longer than that between upload and import.
|
||||||
|
admin
|
||||||
|
.registry_update_setting(
|
||||||
|
Jmap::default(),
|
||||||
|
&[
|
||||||
|
Property::UploadQuota,
|
||||||
|
Property::MaxUploadCount,
|
||||||
|
Property::UploadTtl,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
admin.reload_settings().await;
|
||||||
let account = test
|
let account = test
|
||||||
.create_user_account(
|
.create_user_account(
|
||||||
"[email protected]",
|
"[email protected]",
|
||||||
@@ -209,6 +226,14 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 11);
|
assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 11);
|
||||||
assert_eq!(samples.len(), 20);
|
assert_eq!(samples.len(), 20);
|
||||||
|
|
||||||
|
// Removing the duplicate sample of a reclassified email should not remove the blob of the kept sample
|
||||||
|
for (id, sample) in &samples {
|
||||||
|
assert!(
|
||||||
|
client.download(&sample.blob_id.to_string()).await.is_ok(),
|
||||||
|
"blob of sample {id} is not accessible"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Adding a training sample without permissions should fail
|
// Adding a training sample without permissions should fail
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
account
|
account
|
||||||
|
|||||||
@@ -77,14 +77,12 @@ impl DnsCache for Server {
|
|||||||
fn dnsbl_add(&self, name: &str, value: Vec<Ipv4Addr>, valid_until: std::time::Instant) {
|
fn dnsbl_add(&self, name: &str, value: Vec<Ipv4Addr>, valid_until: std::time::Instant) {
|
||||||
self.inner.cache.dns_rbl.insert_with_expiry(
|
self.inner.cache.dns_rbl.insert_with_expiry(
|
||||||
name.into(),
|
name.into(),
|
||||||
Some(Arc::new(IpResolver::new(
|
Some(
|
||||||
value
|
value
|
||||||
.iter()
|
.into_iter()
|
||||||
.copied()
|
.map(|ip| IpResolver::new(ip.into()))
|
||||||
.next()
|
.collect(),
|
||||||
.unwrap_or(Ipv4Addr::BROADCAST)
|
),
|
||||||
.into(),
|
|
||||||
))),
|
|
||||||
valid_until,
|
valid_until,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user