Compare commits

..
Author SHA1 Message Date
link2xt e6af41b7cf fix: ignore html/* MIME parts
HTML parts have text/html MIME type.
html/* is not a registered MIME type: https://www.iana.org/assignments/media-types
2026-10-13 19:54:16 +00:00
link2xt d766641ce2 feat: switch to another SMTP transport when current one returns an error
Classic email servers sometimes start rejecting all messages and ask the user to solve web CAPTCHA, but still allow to connect.
In this case we want to switch to another SMTP server by moving the transport to the end of the list returned by smtp::sorted_transports.

We disconnect after any message sending failure, so next message or resent same message will be sent over another other transport.

Permanent SMTP error does not necessarily mean that switching to another server will help.
For example if the user tries to send a large message, any server will reject it, and it is not a failure of the server.
We do not try to distinguish between error messages, so it is expected that we may
sometimes switch to another server when it is unnecessary.
2026-10-13 00:43:51 +00:00
link2xt 5510fb5a18 refactor(smtp): introduce smtp::Connection structure
It is now impossible for SMTP client to be connected
without having a From address and transport_id available.

Follow-up to 70730490b2
2026-10-12 23:35:58 +00:00
link2xt 036b928a7f feat: take key flags and expiration into account when selecting the key
Test keys were generated with GnuPG 2.4.9 from Arch Linux.

The script that was used to generate the key for test_select_pk_for_encryption:
```
export GNUPGHOME="$PWD/gnupghome"
rm -fr "$GNUPGHOME"
mkdir -p "$GNUPGHOME"
chmod 700 "$GNUPGHOME"

# --faked-system-time example is from the gpg man page
GPG="gpg --batch --quiet --faked-system-time 20070924T154812"

USERID='Alice <alice@example.org>'

# Generate the primary key.
# "default" means certification+signing primary key
# "never" to make the key not expire, otherwise gpg generates expiring key by default.
$GPG --passphrase '' --quick-generate-key "$USERID" ed25519 default never

# --quick-add-key requires that keys are referred to by the fingerprint.
# Output of --with-colons is described in /usr/share/doc/gnupg/DETAILS
FINGERPRINT="$($GPG --list-secret-keys --with-colons | awk -F: '$1 == "fpr" { print $10 }')"

# Authentication-only RSA key, should not be used for encryption.
$GPG --passphrase '' --quick-add-key "$FINGERPRINT" rsa auth never

# Expired (considering the fake date) subkey.
$GPG --passphrase '' --quick-add-key "$FINGERPRINT" cv25519 encr 1d

# Signing subkey.
$GPG --passphrase '' --quick-add-key "$FINGERPRINT" ed25519 sign never

# Usable encryption subkey.
$GPG --passphrase '' --quick-add-key "$FINGERPRINT" cv25519 encr never

# Expiring subkey that is not expired in the beginning of 2008.
$GPG --passphrase '' --quick-add-key "$FINGERPRINT" cv25519 encr 1y

# Another encryption subkey that should not be used because the first one is preferred.
$GPG --passphrase '' --quick-add-key "$FINGERPRINT" cv25519 encr never

$GPG --armor --export "$FINGERPRINT" > alice.tpk.asc
```

Key for test_select_only_expired_subkey was generated with:
```
#!/bin/sh
set -e
export GNUPGHOME="$PWD/gnupghome"
rm -fr  "$GNUPGHOME"
mkdir -p "$GNUPGHOME"
chmod 700 "$GNUPGHOME"

GPG="gpg --batch --quiet --faked-system-time 20070924T154812"
USERID='Alice <alice@example.org>'
$GPG --passphrase '' --quick-generate-key "$USERID" ed25519 default never
FINGERPRINT="$($GPG --list-secret-keys --with-colons | awk -F: '$1 == "fpr" { print $10 }')"
$GPG --passphrase '' --quick-add-key "$FINGERPRINT" cv25519 encr 1y
$GPG --armor --export "$FINGERPRINT" > alice.tpk.asc
```

Key for test_expiring_subkey_selection was generated with:
```
#!/bin/sh
set -e
export GNUPGHOME="$PWD/gnupghome"
rm -fr  "$GNUPGHOME"
mkdir -p "$GNUPGHOME"
chmod 700 "$GNUPGHOME"

GPG="gpg --batch --quiet"
USERID='Alice <alice@example.org>'

$GPG --passphrase '' --faked-system-time 20250101T000000 --quick-generate-key "$USERID" ed25519 default never
FINGERPRINT="$($GPG --list-secret-keys --with-colons | awk -F: '$1 == "fpr" { print $10 }')"

$GPG --passphrase '' --faked-system-time 20250101T000000 --quick-add-key "$FINGERPRINT" cv25519 encr never

$GPG --passphrase '' --faked-system-time 20260601T000000 --quick-add-key "$FINGERPRINT" cv25519 encr 1m
$GPG --passphrase '' --faked-system-time 20260601T000000 --quick-add-key "$FINGERPRINT" cv25519 encr 2w
$GPG --passphrase '' --faked-system-time 20260608T000000 --quick-add-key "$FINGERPRINT" cv25519 encr 2w

$GPG --armor --export "$FINGERPRINT" > alice.tpk.asc
```
2026-10-10 21:01:24 +00:00
link2xt f3b043f111 refactor: remove Context argument from secret_key_to_public_key() 2026-10-10 21:01:24 +00:00
14 changed files with 548 additions and 151 deletions
+8 -10
View File
@@ -25,8 +25,6 @@ env:
# Minimum Supported Rust Version
MSRV: 1.89.0
PYTHON_VERSION: 3.15
jobs:
lint_rust:
name: Lint Rust
@@ -270,11 +268,11 @@ jobs:
fail-fast: false
matrix:
include:
# Currently used Python version.
# Currently used Rust version.
- os: ubuntu-latest
python: latest
python: 3.14
- os: macos-latest
python: latest
python: 3.14
# PyPy tests
- os: ubuntu-latest
@@ -305,7 +303,7 @@ jobs:
- name: Install python
uses: actions/setup-python@v7.0.0
with:
python-version: ${{ matrix.python == 'latest' && env.PYTHON_VERSION || matrix.python }}
python-version: ${{ matrix.python }}
- name: Install tox
run: pip install tox
@@ -326,11 +324,11 @@ jobs:
matrix:
include:
- os: ubuntu-latest
python: latest
python: 3.14
- os: macos-latest
python: latest
python: 3.14
- os: windows-latest
python: latest
python: 3.14
# PyPy tests
- os: ubuntu-latest
@@ -353,7 +351,7 @@ jobs:
- name: Install python
uses: actions/setup-python@v7.0.0
with:
python-version: ${{ matrix.python == 'latest' && env.PYTHON_VERSION || matrix.python }}
python-version: ${{ matrix.python }}
- name: Install tox
run: pip install tox
+13
View File
@@ -471,6 +471,19 @@ CREATE TABLE smtp_success (
FOREIGN KEY(transport_id) REFERENCES transports(id) ON DELETE CASCADE
) STRICT;
-- Similar to smtp_success, but records failures.
CREATE TABLE smtp_failure (
-- Sequentially increasing ID of the failure
-- Transport with the highest ID is to be used last.
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
-- ID of the transport that was used to attempt sending a message.
transport_id INTEGER UNIQUE NOT NULL,
-- Delete `smtp_failure` rows when the transport is deleted.
FOREIGN KEY(transport_id) REFERENCES transports(id) ON DELETE CASCADE
) STRICT;
-- Table of "sync items" to be grouped into sync messages
-- and sent to own devices.
CREATE TABLE multi_device_sync (
+3 -1
View File
@@ -513,6 +513,8 @@ pub(crate) async fn configure(
let smtp_password = configured_param.smtp_password.clone();
let smtp_addr = configured_param.addr.clone();
let transport_id = 0;
let proxy_config2 = proxy_config.clone();
let smtp_config_task = task::spawn(async move {
let mut smtp = Smtp::new();
@@ -522,6 +524,7 @@ pub(crate) async fn configure(
&smtp_password,
&proxy_config2,
&smtp_addr,
transport_id,
strict_tls,
)
.await?;
@@ -533,7 +536,6 @@ pub(crate) async fn configure(
// Configure IMAP
let transport_id = 0;
let (_s, r) = async_channel::bounded(1);
let mut imap = Imap::new(ctx, transport_id, configured_param.clone(), r).await?;
let imap_session = match imap.connect(ctx).await {
+1 -4
View File
@@ -125,14 +125,11 @@ pub trait DcKey: Serialize + Deserializable + Clone {
/// Converts secret key to public key.
pub(crate) fn secret_key_to_public_key(
context: &Context,
mut signed_secret_key: SignedSecretKey,
timestamp: u32,
addr: &str,
relay_addrs: &str,
) -> Result<SignedPublicKey> {
info!(context, "Converting secret key to public key.");
// Make sure timestamp of created signatures
// is not in the past compared to the primary key timestamp.
let timestamp = std::cmp::max(
@@ -305,7 +302,7 @@ pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result<Option
let addr = context.get_primary_self_addr().await?;
let all_addrs = context.get_self_addrs().await?.join(",");
let signed_public_key =
secret_key_to_public_key(context, signed_secret_key, timestamp, &addr, &all_addrs)?;
secret_key_to_public_key(signed_secret_key, timestamp, &addr, &all_addrs)?;
*lock = Some(signed_public_key.clone());
Ok(Some(signed_public_key))
-1
View File
@@ -362,7 +362,6 @@ async fn test_mdn_sent_to_all_relays() -> Result<()> {
// Bob's key gets a second relay address and Alice merges the newer key.
let bob_secret_key = load_self_secret_key(bob).await?;
let bob_public_key = secret_key_to_public_key(
bob,
bob_secret_key,
u32::try_from(time())? + 100,
"bob@example.net",
+1 -1
View File
@@ -1367,7 +1367,7 @@ impl MimeMessage {
self.do_add_single_part(part);
return Ok(true);
}
mime::TEXT | mime::HTML => {
mime::TEXT => {
let decoded_data = match mail.get_body() {
Ok(decoded_data) => decoded_data,
Err(err) => {
+26
View File
@@ -2228,3 +2228,29 @@ async fn test_load_shared_secrets_with_legacy_state() -> Result<()> {
Ok(())
}
/// Tests that MIME parts with html/* MIME type are ignored.
///
/// HTML parts should have a text/html MIME type.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ignore_html_mime_parts() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let encrypted_msg = test_utils::encrypt_raw_message(
bob,
&[alice],
include_bytes!("../../test-data/message/html_html_mime.eml"),
)
.await?;
let msg = MimeMessage::from_bytes(alice, encrypted_msg.as_bytes())
.await
.unwrap();
assert_eq!(msg.parts.len(), 1);
assert_eq!(msg.parts[0].typ, Viewtype::Text);
assert_eq!(msg.parts[0].mimetype, Some(mime::TEXT_PLAIN));
assert_eq!(msg.parts[0].msg, "Here is a text part.");
Ok(())
}
+60 -6
View File
@@ -1,5 +1,6 @@
//! OpenPGP helper module using [rPGP facilities](https://github.com/rpgp/rpgp).
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::io::Cursor;
@@ -83,13 +84,64 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result<SignedSecretKey> {
/// Selects a subkey of the public key to use for encryption.
///
/// Returns `None` if the public key cannot be used for encryption.
/// The key is selected according to
/// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-03.html#section-4.3-4>.
/// If multiple keys are available, the one that will expire sooner is selected.
///
/// TODO: take key flags and expiration dates into account
fn select_pk_for_encryption(key: &SignedPublicKey) -> Option<&SignedPublicSubKey> {
/// Returns `None` if the public key cannot be used for encryption.
fn select_pk_for_encryption(now: u32, key: &SignedPublicKey) -> Option<&SignedPublicSubKey> {
key.public_subkeys
.iter()
.find(|subkey| subkey.algorithm().can_encrypt())
.filter(|subkey| subkey.algorithm().can_encrypt())
.filter_map(|subkey| {
// We take arbitrary signature here and don't try to select the newest one.
// Filtering out outdated and invalid signatures should happen during certificate merging.
let signature = subkey.signatures.first()?;
let key_flags = signature.key_flags();
if !key_flags.encrypt_comms() {
return None;
}
if let Some(expiration_duration) = signature
.key_expiration_time()
.filter(|duration| duration.as_secs() != 0)
&& now
> subkey
.created_at()
.as_secs()
.saturating_add(expiration_duration.as_secs())
{
// Key is expired.
return None;
}
Some((subkey, signature))
})
.min_by(|(subkey1, signature1), (subkey2, signature2)| {
match (
signature1
.key_expiration_time()
.filter(|duration| duration.as_secs() != 0),
signature2
.key_expiration_time()
.filter(|duration| duration.as_secs() != 0),
) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(expiration1), Some(expiration2)) => (subkey1
.created_at()
.as_secs()
.saturating_add(expiration1.as_secs()))
.cmp(
&(subkey2
.created_at()
.as_secs()
.saturating_add(expiration2.as_secs())),
),
}
})
.map(|(subkey, _signature)| subkey)
}
/// Version of SEIPD packet to use.
@@ -151,10 +203,11 @@ pub fn pk_encrypt(
) -> Result<String> {
tokio::task::block_in_place(|| {
let mut rng = thread_rng();
let now = pgp::types::Timestamp::now();
let pkeys = public_keys_for_encryption
.iter()
.filter_map(select_pk_for_encryption);
.filter_map(|key| select_pk_for_encryption(now.as_secs(), key));
let msg = MessageBuilder::from_bytes("", plain);
let encoded_msg = match seipd_version {
@@ -485,7 +538,8 @@ pub(crate) fn relay_addrs(public_key: &SignedPublicKey, addr: &str) -> Vec<Strin
/// Returns true if the key can be encrypted to, i.e. has an encryption subkey.
pub(crate) fn pubkey_can_encrypt(public_key: &SignedPublicKey) -> bool {
select_pk_for_encryption(public_key).is_some()
let now = pgp::types::Timestamp::now();
select_pk_for_encryption(now.as_secs(), public_key).is_some()
}
/// Returns true if public key advertises SEIPDv2 feature.
+210
View File
@@ -11,6 +11,7 @@ use crate::{
test_utils::{TestContext, TestContextManager, alice_keypair, bob_keypair},
token,
};
use chrono::{TimeZone as _, Utc};
use pgp::composed::{Esk, Message};
use pgp::packet::PublicKeyEncryptedSessionKey;
@@ -423,3 +424,212 @@ async fn test_securejoin_pqc_joiner() {
tcm.execute_securejoin(bob, pqc).await;
}
/// Tests that public subkey selection for encryption respects key flags and prefers expring subkeys.
///
/// Non-encryption subkeys such as RSA subkey for authentication are ignored.
#[test]
fn test_select_pk_for_encryption() {
// Public key generated with GnuPG 2.4.9 with the following subkeys:
// 1. Auth-only RSA subkey (92E762B9084CA740).
// 2. Expired Curve25519 encryption subkey with 1-day expiration (C8F382BD0F35C49E)
// 3. Ed25519 signing subkey (F177AC3118F923CC).
// 4. Curve25519 encryption subkey with fingerprint (36188C6FFC8E267B)
// 5. Curve25519 encryption subkey with 1 year expiration, valid in the beginning of 2008, with key ID 9223FCEE7546CDE7
// 6. Curve25519 encryption subkey with no expiration (FD2C0567967223D8).
// Primary key is an Ed25519 not expiring key.
// Key 4 is the one that should be selected.
// Subkey 4 fingerprint.
let expected_fallback_fingerprint = "cdeb3ba3999bf7880f0ee1f536188c6ffc8e267b";
// Subkey 5 fingerprint, should be preferred to fallback when not expired.
let expected_expiring_fingerprint = "5133fab157c4a46ca41f6dc39223fcee7546cde7";
let alice_tpk_asc = "
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
=AXAi
-----END PGP PUBLIC KEY BLOCK-----
";
let alice_tpk = SignedPublicKey::from_asc(alice_tpk_asc).unwrap();
let now = pgp::types::Timestamp::now();
let encryption_subkey = select_pk_for_encryption(now.as_secs(), &alice_tpk).unwrap();
assert_eq!(
encryption_subkey.fingerprint().to_string().as_str(),
expected_fallback_fingerprint
);
// If we pass 1199149200 as the `now` argument, which was in the beginning of 2008,
// then the expiring subkey is not yet expired and should be used.
let encryption_subkey = select_pk_for_encryption(1199149200, &alice_tpk).unwrap();
assert_eq!(
encryption_subkey.fingerprint().to_string().as_str(),
expected_expiring_fingerprint
);
}
/// Tests that key selection fails if there is only an expired subkey.
#[test]
fn test_select_only_expired_subkey() {
let alice_tpk_asc = "
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMERvfcPBYJKwYBBAHaRw8BAQdAUz6AZXIRE8T04Vh8RReFiP3tEV8UfSs2EiYs
8b8i4ce0GUFsaWNlIDxhbGljZUBleGFtcGxlLm9yZz6IkAQTFgoAOBYhBNRG3w/A
qWyPitBaqkGLyqDx3CaUBQJG99w8AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheA
AAoJEEGLyqDx3CaUMjIA/Rq9/iORLP360s6EsIDe9qSmlmSCggtivafH+uVBWa0X
AP0Wb+kailDwISq2O9Ef/jceZw7ozyzDLeDskKpCYiL3A7g4BEb33DwSCisGAQQB
l1UBBQEBB0BXhbrks9iskW1GfT3B022W3KhJCgz8gw81z9lAWv7OZgMBCAeIfgQY
FgoAJhYhBNRG3w/AqWyPitBaqkGLyqDx3CaUBQJG99w8AhsMBQkB4TOAAAoJEEGL
yqDx3CaU6/YA/jaYJsZMXvu5grrMq1wq3Z/yzGXd15zeWp+alY/6UYxoAQCmSrBC
SOtE3ODLZN9tC3F7k1N9clme1cHXyUiH3EliBQ==
=zWPq
-----END PGP PUBLIC KEY BLOCK-----
";
let alice_tpk = SignedPublicKey::from_asc(alice_tpk_asc).unwrap();
let now = pgp::types::Timestamp::now();
assert_eq!(select_pk_for_encryption(now.as_secs(), &alice_tpk), None);
}
/// Tests that the key with the closest expiration date is selected.
#[test]
fn test_expiring_subkey_selection() {
// The key has the following keys:
// 1. Primary non-expiring Ed25519 key created on 2025-01-01
// 2. Non-expiring subkey CE5FC3FD479E41F08069DCE6F6CAD4ADB9AF47F1 created on 2025-01-01
// 3. Subkey 10493EF4DBA7EE4D55C826A7634684D9BFEEB890 created on 2026-06-01 and expiring on 2026-07-01
// 4. Subkey 477650C13FA4842BAF2831BC41C2D560BE27B38D created on 2026-06-01 and expiring on 2026-06-15
// 5. Subkey AB92F4AFD46DDD7D31FB05151BE08A8089F67CA2 created on 2026-06-08 and expiring on 2026-06-22
let alice_tpk_asc = "
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEZ3SFgBYJKwYBBAHaRw8BAQdAOrjYsxkYNvVtjbZwvglXk94Rd8S3F0YlIgcQ
SSp45ni0GUFsaWNlIDxhbGljZUBleGFtcGxlLm9yZz6IkAQTFgoAOBYhBGV3iWZU
bA6WJh3MR00wvB0X57PiBQJndIWAAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheA
AAoJEE0wvB0X57PiAr0A/0qzulPRXZR1+D1fUeW6/C3BRP+8qRZTlOvF0XEnxPOV
AQD7oePZ/QI6AuDMFmnJ5HQJ6cQSSOp/FwyucD8yiAoABrg4BGd0hYASCisGAQQB
l1UBBQEBB0DBBk+BdzyTu3Hys6dewzd0L5AFeYMRIOC99XosMBLZYwMBCAeIeAQY
FgoAIBYhBGV3iWZUbA6WJh3MR00wvB0X57PiBQJndIWAAhsMAAoJEE0wvB0X57Pi
BxYA+gOtDVtb9p9vVaKyAuYCG7LdJ63Bqu0OtrVxmZEQvHQbAQDJmJX/yOX5QSkh
cRtM1qoiY7OUjcT8ERnaoAcw2suFBLg4BGocy4ASCisGAQQBl1UBBQEBB0AgEDSA
muuqS2iyi7aZeV608Xx2qdVqYKjREgiC3cu4YQMBCAeIfgQYFgoAJhYhBGV3iWZU
bA6WJh3MR00wvB0X57PiBQJqHMuAAhsMBQkAJ40AAAoJEE0wvB0X57Pi9KgA/2vW
4yNU1EF/WRCKmr87mJtmYR5Xf9Jps54Tv4BexNGgAQDxxx9lKvwW4U9/8UkrSF28
HrGdWES4wAa9N9HvJzuOD7g4BGocy4ASCisGAQQBl1UBBQEBB0CK1TifDqKtFzks
4E8gNmF90OkIYVriZxdY3QFSwowILQMBCAeIfgQYFgoAJhYhBGV3iWZUbA6WJh3M
R00wvB0X57PiBQJqHMuAAhsMBQkAEnUAAAoJEE0wvB0X57PiZ+UA/0+s3fM/8OOb
8Ft21QMFM/7Ce1P/ovKgkxsYWjk8q7zYAP9HYm/vcWBGzQw08mn3X26UFB9UfCF3
qc1yyltDEeERDrg4BGomBgASCisGAQQBl1UBBQEBB0AvGvgG8Wkwx/KklkUHdLuC
UKKSqTp4xsjocWXJs69rSwMBCAeIfgQYFgoAJhYhBGV3iWZUbA6WJh3MR00wvB0X
57PiBQJqJgYAAhsMBQkAEnUAAAoJEE0wvB0X57PibzQA/A0qT23pcdenNJJ5QZN/
ecs08p2pbiipv+adPeojVGZIAP47Pyr8Hj0o3qhrEhhFZkIhTxxDxS/jAsq2v4UG
JGp8Ag==
=RzZZ
-----END PGP PUBLIC KEY BLOCK-----
";
let alice_tpk = SignedPublicKey::from_asc(alice_tpk_asc).unwrap();
// Two-week subkey that was generated earlier is selected
// because it expires earlier, on 2026-06-15.
{
let ts = u32::try_from(
Utc.with_ymd_and_hms(2026, 6, 9, 0, 0, 0)
.unwrap()
.timestamp(),
)
.unwrap();
let subkey = select_pk_for_encryption(ts, &alice_tpk).unwrap();
assert_eq!(
subkey.fingerprint().to_string().as_str(),
"477650c13fa4842baf2831bc41c2d560be27b38d",
);
}
// Now both 477650C13FA4842BAF2831BC41C2D560BE27B38D expires
// and we switch to another subkey that expires on 2026-06-22.
{
let ts = u32::try_from(
Utc.with_ymd_and_hms(2026, 6, 16, 0, 0, 0)
.unwrap()
.timestamp(),
)
.unwrap();
let subkey = select_pk_for_encryption(ts, &alice_tpk).unwrap();
assert_eq!(
subkey.fingerprint().to_string().as_str(),
"ab92f4afd46ddd7d31fb05151be08a8089f67ca2",
);
}
// Now both two-week subkeys have expired and we switch to expiring subkey that expires on 2026-07-01.
{
let ts = u32::try_from(
Utc.with_ymd_and_hms(2026, 6, 23, 0, 0, 0)
.unwrap()
.timestamp(),
)
.unwrap();
let subkey = select_pk_for_encryption(ts, &alice_tpk).unwrap();
assert_eq!(
subkey.fingerprint().to_string().as_str(),
"10493ef4dba7ee4d55c826a7634684d9bfeeb890",
);
}
// All expiring subkeys are expired, switching to non-expiring subkey.
{
let ts = u32::try_from(
Utc.with_ymd_and_hms(2026, 7, 2, 0, 0, 0)
.unwrap()
.timestamp(),
)
.unwrap();
let subkey = select_pk_for_encryption(ts, &alice_tpk).unwrap();
assert_eq!(
subkey.fingerprint().to_string().as_str(),
"ce5fc3fd479e41f08069dce6f6cad4adb9af47f1",
);
}
}
+121 -78
View File
@@ -32,21 +32,26 @@ use crate::transport::{
ConfiguredLoginParam, ConfiguredServerLoginParam, prioritize_server_login_params,
};
/// SMTP connection with information about it.
#[derive(Debug)]
struct Connection {
transport: SmtpTransport<Box<dyn SessionBufStream>>,
/// Email address that should be put into the From field when sending over this connection.
from: EmailAddress,
/// ID of the transport from which SMTP connection configuration comes from.
transport_id: u32,
/// Timestamp of last successful send/receive network interaction
/// (eg connect or send succeeded).
last_success: tools::Time,
}
#[derive(Default)]
pub(crate) struct Smtp {
/// SMTP connection.
transport: Option<SmtpTransport<Box<dyn SessionBufStream>>>,
/// Email address we are sending from.
from: Option<EmailAddress>,
/// Transport we are connected to.
transport_id: Option<u32>,
/// Timestamp of last successful send/receive network interaction
/// (eg connect or send succeeded). On initialization and disconnect
/// it is set to None.
last_success: Option<tools::Time>,
connection: Option<Connection>,
pub(crate) connectivity: ConnectivityStore,
@@ -61,7 +66,10 @@ async fn sorted_transports(context: &Context) -> Result<Vec<(u32, ConfiguredLogi
.query_map_vec(
"SELECT transports.id, configured_param FROM transports
LEFT JOIN smtp_success ON smtp_success.transport_id=transports.id
ORDER BY IFNULL(smtp_success.id, 0) DESC, transports.id ASC",
LEFT JOIN smtp_failure ON smtp_failure.transport_id=transports.id
ORDER BY IFNULL(smtp_success.id, 0) DESC,
IFNULL(smtp_failure.id, 0) ASC,
transports.id ASC",
(),
|row| {
let id: u32 = row.get(0)?;
@@ -79,10 +87,37 @@ async fn record_success(context: &Context, transport_id: u32) -> Result<()> {
// if the row exists already, so it becomes the highest rowid in the table.
context
.sql
.execute(
"INSERT OR REPLACE INTO smtp_success (transport_id) VALUES (?)",
(transport_id,),
)
.transaction(|t| {
t.execute(
"INSERT OR REPLACE INTO smtp_success (transport_id) VALUES (?)",
(transport_id,),
)?;
t.execute(
"DELETE FROM smtp_failure WHERE transport_id = ?",
(transport_id,),
)?;
Ok(())
})
.await?;
Ok(())
}
/// Records a failure and removes last remembered success for
/// SMTP transport so another transport is tried next time.
async fn record_failure(context: &Context, transport_id: u32) -> Result<()> {
context
.sql
.transaction(|t| {
t.execute(
"INSERT OR REPLACE INTO smtp_failure (transport_id) VALUES (?)",
(transport_id,),
)?;
t.execute(
"DELETE FROM smtp_success WHERE transport_id = ?",
(transport_id,),
)?;
Ok(())
})
.await?;
Ok(())
}
@@ -95,30 +130,25 @@ impl Smtp {
/// Disconnect the SMTP transport and drop it entirely.
pub fn disconnect(&mut self) {
if let Some(mut transport) = self.transport.take() {
if let Some(mut connection) = self.connection.take() {
// Closing connection with a QUIT command may take some time, especially if it's a
// stale connection and an attempt to send the command times out. Send a command in a
// separate task to avoid waiting for reply or timeout.
task::spawn(async move { transport.quit().await });
task::spawn(async move { connection.transport.quit().await });
}
self.transport_id = None;
self.from = None;
self.last_success = None;
}
/// Return true if smtp was connected but is not known to
/// have been successfully used the last 60 seconds
pub fn has_maybe_stale_connection(&self) -> bool {
if let Some(last_success) = self.last_success {
time_elapsed(&last_success).as_secs() > 60
} else {
false
}
self.connection
.as_ref()
.is_some_and(|connection| time_elapsed(&connection.last_success).as_secs() > 60)
}
/// Check whether we are connected.
pub fn is_connected(&self) -> bool {
self.transport.is_some()
self.connection.is_some()
}
/// Connect using configured parameters.
@@ -143,14 +173,12 @@ impl Smtp {
&lp.smtp_password,
&proxy_config,
&lp.addr,
transport_id,
lp.strict_tls(proxy_config.is_some())?,
)
.await
{
Ok(()) => {
self.transport_id = Some(transport_id);
return Ok(());
}
Ok(()) => return Ok(()),
Err(err) => {
warn!(
context,
@@ -163,6 +191,7 @@ impl Smtp {
}
/// Connect using the provided login params.
#[expect(clippy::too_many_arguments)]
pub async fn connect(
&mut self,
context: &Context,
@@ -170,6 +199,7 @@ impl Smtp {
password: &str,
proxy_config: &Option<ProxyConfig>,
addr: &str,
transport_id: u32,
strict_tls: bool,
) -> Result<()> {
if self.is_connected() {
@@ -179,7 +209,6 @@ impl Smtp {
let from = EmailAddress::new(addr.to_string())
.with_context(|| format!("Invalid address {addr:?}"))?;
self.from = Some(from);
let login_params =
prioritize_server_login_params(&context.sql, login_params, "smtp").await?;
@@ -204,8 +233,12 @@ impl Smtp {
}
};
self.transport = Some(transport);
self.last_success = Some(tools::Time::now());
self.connection = Some(Connection {
transport,
from,
transport_id,
last_success: tools::Time::now(),
});
context.emit_event(EventType::SmtpConnected(format!(
"SMTP-LOGIN as {} ok",
@@ -230,7 +263,7 @@ pub(crate) enum SendResult {
}
/// Tries to send a message.
pub(crate) async fn smtp_send(
async fn smtp_send(
context: &Context,
recipients: &[async_smtp::EmailAddress],
message: &str,
@@ -246,14 +279,37 @@ pub(crate) async fn smtp_send(
smtp.connectivity.set_working(context);
let send_result = smtp.send(context, recipients, message.as_bytes()).await;
smtp.last_send_error = send_result.as_ref().err().map(|e| e.to_string());
let Some(ref mut connection) = smtp.connection else {
warn!(
context,
"Failed to send a message because SMTP client is not connected."
);
return SendResult::Retry;
};
let transport_id = connection.transport_id;
let send_result = connection
.send(context, recipients, message.as_bytes())
.await;
smtp.last_send_error = send_result.as_ref().err().map(|e| format!("{e:#}"));
let status = match send_result {
Err(crate::smtp::send::Error::SmtpSend(err)) => {
Err(send::Error::SmtpSend(err)) => {
// Remote error, retry later.
info!(context, "SMTP failed to send: {:?}.", &err);
// Record failure on any error returned by the server, but not network errors.
if matches!(
err,
async_smtp::error::Error::Permanent(_) | async_smtp::error::Error::Transient(_)
) && let Err(err) = record_failure(context, transport_id).await
{
warn!(
context,
"Failed to remove success for {transport_id}: {err:#}."
)
}
let res = match err {
async_smtp::error::Error::Permanent(ref response) => {
// Workaround for incorrectly configured servers returning permanent errors
@@ -327,44 +383,28 @@ pub(crate) async fn smtp_send(
}
};
// this clears last_success info
info!(context, "Failed to send message over SMTP, disconnecting.");
smtp.disconnect();
res
}
Err(crate::smtp::send::Error::Envelope(err)) => {
// Local error, job is invalid, do not retry.
smtp.disconnect();
warn!(context, "SMTP job is invalid: {err:#}.");
SendResult::Failure(err)
}
Err(crate::smtp::send::Error::NoTransport) => {
// Should never happen.
// It does not even make sense to disconnect here.
error!(context, "SMTP job failed because SMTP has no transport.");
SendResult::Failure(format_err!("SMTP has not transport"))
}
Err(crate::smtp::send::Error::Other(err)) => {
Err(send::Error::Other(err)) => {
// Local error, job is invalid, do not retry.
smtp.disconnect();
warn!(context, "Unable to load SMTP job: {err:#}.");
SendResult::Failure(err)
}
Ok(()) => SendResult::Success,
};
if matches!(status, SendResult::Success) {
debug_assert!(smtp.transport_id.is_some());
if let Some(transport_id) = smtp.transport_id
&& let Err(err) = record_success(context, transport_id).await
{
warn!(
context,
"Failed to record successful use of transport {transport_id} in smtp_success table: {err:#}."
);
Ok(()) => {
record_success(context, transport_id)
.await
.with_context(|| {
format!("Failed to record successful use of transport {transport_id}")
})
.log_err(context)
.ok();
SendResult::Success
}
}
};
if let SendResult::Failure(err) = &status
&& let Some(msg_id) = msg_id
@@ -407,11 +447,7 @@ pub(crate) async fn insert_into_smtp(
/// Sends message identified by `smtp` table rowid over SMTP connection.
///
/// Removes row if the message should not be retried, otherwise increments retry count.
pub(crate) async fn send_msg_to_smtp(
context: &Context,
smtp: &mut Smtp,
rowid: i64,
) -> anyhow::Result<()> {
pub(crate) async fn send_msg_to_smtp(context: &Context, smtp: &mut Smtp, rowid: i64) -> Result<()> {
if let Err(err) = smtp
.connect_configured(context)
.await
@@ -472,9 +508,10 @@ pub(crate) async fn send_msg_to_smtp(
let mut recipients = queued_mail.recipients.clone();
if queued_mail.bcc_self {
let from_addr = smtp
.from
.connection
.as_ref()
.context("No From address available, likely not connected")?
.context("Not connected")?
.from
.to_string();
add_self_recipients(
context,
@@ -504,12 +541,17 @@ pub(crate) async fn send_msg_to_smtp(
let public_key = key::load_self_public_key(context).await?;
let secret_key = key::load_self_secret_key(context).await?;
let from_addr = smtp
.from
.connection
.as_ref()
.context("No From address available, likely not connected")?
.context("Not connected")?
.from
.to_string();
let transport_id = smtp.transport_id.context("Not connected")?;
let transport_id = smtp
.connection
.as_ref()
.context("Not connected")?
.transport_id;
let chunk_size = context
.get_max_smtp_rcpt_to(transport_id, &from_addr)
.await?
@@ -760,9 +802,10 @@ async fn send_mdn_rfc724_mid(
let public_key = key::load_self_public_key(context).await?;
let secret_key = key::load_self_secret_key(context).await?;
let from = smtp
.from
.connection
.as_ref()
.context("No From address, not connected")?
.context("Not connected")?
.from
.to_string();
let rendered_msg =
mimefactory::render_queued_mail(queued_mdn, &public_key, &secret_key, from.clone())?;
+9 -21
View File
@@ -1,31 +1,27 @@
//! # SMTP message sending
use anyhow::Context as _;
use async_smtp::{EmailAddress, Envelope, SendableEmail};
use super::Smtp;
use super::Connection;
use crate::config::Config;
use crate::context::Context;
use crate::log::warn;
use crate::tools;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Envelope error: {}", _0)]
Envelope(anyhow::Error),
#[error("Send error: {}", _0)]
SmtpSend(async_smtp::error::Error),
#[error("SMTP has no transport")]
NoTransport,
#[error("{}", _0)]
#[error("{:#}", _0)]
Other(#[from] anyhow::Error),
}
impl Smtp {
impl Connection {
/// Send a prepared mail to recipients.
/// On successful send out Ok() is returned.
pub async fn send(
pub(super) async fn send(
&mut self,
context: &Context,
recipients: &[EmailAddress],
@@ -38,21 +34,13 @@ impl Smtp {
context.ratelimit.write().await.send();
}
let envelope =
Envelope::new(self.from.clone(), recipients.to_vec()).map_err(Error::Envelope)?;
let envelope = Envelope::new(Some(self.from.clone()), recipients.to_vec())
.context("Envelope error")?;
let mail = SendableEmail::new(envelope, message);
let Some(ref mut transport) = self.transport else {
warn!(
context,
"Failed to send a message because SMTP client has no SmtpTransport."
);
return Err(Error::NoTransport);
};
self.transport.send(mail).await.map_err(Error::SmtpSend)?;
transport.send(mail).await.map_err(Error::SmtpSend)?;
self.last_success = Some(tools::Time::now());
self.last_success = tools::Time::now();
Ok(())
}
}
+50 -29
View File
@@ -1,8 +1,25 @@
use anyhow::Result;
use crate::test_utils::TestContextManager;
use crate::test_utils::{TestContext, TestContextManager};
use crate::transport;
async fn sorted_transport_addrs(t: &TestContext) -> Result<String> {
let transports = super::sorted_transports(t).await?;
let addrs: Vec<String> = transports
.into_iter()
.map(|(_transport_id, transport)| transport.addr)
.collect();
Ok(addrs.join(" "))
}
async fn sorted_transport_ids(t: &TestContext) -> Result<Vec<u32>> {
let transports = super::sorted_transports(t).await?;
Ok(transports
.into_iter()
.map(|(transport_id, _transport)| transport_id)
.collect())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_smtp_candidates() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -12,38 +29,42 @@ async fn test_smtp_candidates() -> Result<()> {
transport::add_pseudo_transport(t, "bar@example.net").await?;
transport::add_pseudo_transport(t, "baz@example.net").await?;
let transports = super::sorted_transports(t).await?;
let [
(transport_id1, ref transport1),
(transport_id2, ref transport2),
(transport_id3, ref transport3),
] = transports[..]
else {
panic!("Unexpected number of transports");
};
// By default first added transport is used first.
assert_eq!(transport1.addr, "foo@example.net");
assert_eq!(transport2.addr, "bar@example.net");
assert_eq!(transport3.addr, "baz@example.net");
assert_eq!(
sorted_transport_addrs(t).await?,
"foo@example.net bar@example.net baz@example.net"
);
assert_eq!(sorted_transport_ids(t).await?, [1, 2, 3]);
super::record_success(t, transport_id3).await?;
let transports2 = super::sorted_transports(t).await?;
assert_eq!(transports2[0].0, transport_id3);
assert_eq!(transports2[1].0, transport_id1);
assert_eq!(transports2[2].0, transport_id2);
super::record_success(t, 3).await?;
assert_eq!(sorted_transport_ids(t).await?, [3, 1, 2]);
super::record_success(t, transport_id2).await?;
let transports3 = super::sorted_transports(t).await?;
assert_eq!(transports3[0].0, transport_id2);
assert_eq!(transports3[1].0, transport_id3);
assert_eq!(transports3[2].0, transport_id1);
super::record_success(t, 2).await?;
assert_eq!(sorted_transport_ids(t).await?, [2, 3, 1]);
super::record_success(t, transport_id3).await?;
let transports4 = super::sorted_transports(t).await?;
assert_eq!(transports4[0].0, transport_id3);
assert_eq!(transports4[1].0, transport_id2);
assert_eq!(transports4[2].0, transport_id1);
super::record_success(t, 3).await?;
assert_eq!(sorted_transport_ids(t).await?, [3, 2, 1]);
super::record_failure(t, 3).await?;
assert_eq!(sorted_transport_ids(t).await?, [2, 1, 3]);
// New transport is added before all failing transports.
transport::add_pseudo_transport(t, "qux@example.net").await?;
assert_eq!(
sorted_transport_addrs(t).await?,
"bar@example.net foo@example.net qux@example.net baz@example.net"
);
assert_eq!(sorted_transport_ids(t).await?, [2, 1, 4, 3]);
super::record_success(t, 4).await?;
assert_eq!(sorted_transport_ids(t).await?, [4, 2, 1, 3]);
super::record_failure(t, 4).await?;
assert_eq!(sorted_transport_ids(t).await?, [2, 1, 3, 4]);
super::record_failure(t, 3).await?;
assert_eq!(sorted_transport_ids(t).await?, [2, 1, 4, 3]);
super::record_failure(t, 2).await?;
assert_eq!(sorted_transport_ids(t).await?, [1, 4, 3, 2]);
Ok(())
}
+15
View File
@@ -2687,6 +2687,21 @@ CREATE TABLE smtp_success (
.await?;
}
inc_and_check(&mut migration_version, 169)?;
if dbversion < migration_version {
sql.execute_migration(
"
CREATE TABLE smtp_failure (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
transport_id INTEGER UNIQUE NOT NULL,
FOREIGN KEY(transport_id) REFERENCES transports(id) ON DELETE CASCADE
) STRICT;
",
migration_version,
)
.await?;
}
let new_version = sql
.get_raw_config_int(VERSION_CFG)
.await?
+31
View File
@@ -0,0 +1,31 @@
From: Bob <bob@example.net>
Subject: Message with invalid HTML mime types
To: alice@example.org
Message-ID: <html-html-test@localhost>
Date: Thu, 17 Dec 2020 15:38:45 +0100
MIME-Version: 1.0
Content-Type: multipart/mixed;
boundary="------------MESSAGE_BOUNDARY"
Content-Language: en-US
Chat-Version: 1.0
This is a multi-part message in MIME format.
--------------MESSAGE_BOUNDARY
Content-Type: html/plain; charset=utf-8
Content-Transfer-Encoding: 7bit
This should be ignored.
--------------MESSAGE_BOUNDARY
Content-Type: html/html; charset=utf-8
Content-Transfer-Encoding: 7bit
And this should be ignored too.
--------------MESSAGE_BOUNDARY
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: 7bit
Here is a text part.
--------------MESSAGE_BOUNDARY--