mirror of
https://github.com/chatmail/core.git
synced 2026-10-15 01:21:31 +03:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6af41b7cf | ||
|
|
d766641ce2 | ||
|
|
5510fb5a18 | ||
|
|
036b928a7f | ||
|
|
f3b043f111 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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--
|
||||
Reference in New Issue
Block a user