Skip to content

Test SMTP loop in rust - #8867

Open
link2xt wants to merge 7 commits into
mainfrom
link2xt/smtp-connector
Open

link2xt wants to merge 7 commits into
mainfrom
link2xt/smtp-connector

Conversation

@link2xt

@link2xt link2xt commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

This PR is a refactoring to make SMTP failover code testable in Rust, as a follow-up to #8806 and preparation for more complicated failover strategies, e.g. sending the same messages over multiple transport.

Mostly repeating the commit message below:


Main change is the introduction of Connector implementing the actor pattern. The idea is to have a separate task for each transport that is responsible for maintaining an SMTP connection to the transport and can respond to two requests sent over the incoming channel: 1) connect to the transport and report back 2) send a message and report back.

The tests can then construct crate::smtp::Smtp with SmtpConnectorHandles (wrappers for the sending side of the actor message channel) manually constructed and reply to messages however they want, e.g. reporting success for the connection request or a "timeout", and then "SMTP temporary error" or "SMTP permanent error" or "network error" for the request to send a message.

Networking code remains untested, but all the logic for recording successes and failures in the database, selecting which transports to send the message over etc. can be covered by offline tests, and timeouts can be emulated without actually waiting 1 minute for the timeout.

@link2xt
link2xt force-pushed the link2xt/smtp-connector branch 6 times, most recently from 7137e11 to e39287e Compare October 27, 2026 23:09
@link2xt link2xt changed the title WIP: factor out SmtpConnector Factor out SMTP "Connector" and test SMTP loop in rust Oct 28, 2026
@link2xt
link2xt marked this pull request as ready for review October 28, 2026 13:53
Comment thread src/scheduler.rs Outdated

let handlers = SmtpConnectionHandlers {
connection: Smtp::new(),
connection: Smtp::new(context).await?,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of the commit messages says:

refactor: load ProxyConfig once, when Smtp is created

Changing proxy settings requires restarting I/O anyway
and at least Delta Chat for Android does it,
so Smtp is recreated from scratch.

TODO: Confirm that other UIs than Android also restart IO when proxy config changes. They should, but it's unclear whether this was communicated correctly.

Comment thread src/smtp.rs
let (sender, receiver) = mpsc::channel(2);
let actor = Connector::new(context.clone(), proxy_config.clone(), lp)?;
let cloned_cancellation_token = cancellation_token.clone();
tokio::spawn(async move {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use JoinSet rather than plain spawn, then we don't need the extra logic of cancellation token and DropGuard

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did it differently, have now moved CancellationToken all the way up to start_io() and moved cancellation out of smtp_loop in this PR already. Scheduler constructs the cancellation token when I/O is started and passes it as an argument to Smtp::new() so the token is cloned into all connection actor tasks started by Smtp::new(). When I/O is stopped, it cancels the token stored in SmtpConnectionState.

In the tests Smtp::with_connection_actors() is used and it does not require the cancellation token. And because there is no DropGuard or JoinSet inside Smtp structure, it does not have to create unused drop guard or empty JoinSet that is useless for the tests because there are no actual actors that can be cancelled.

Comment thread src/smtp/connector.rs Outdated

/// SMTP actor that maintains connection to a single transport.
#[derive(Debug)]
pub(crate) struct Connector {

@Hocuri Hocuri Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The names are quite generic (Connector, Connection, ConnectorMessage), maybe we can come up with a better name? Not sure yet what it might be, though:

  • Connector is an actor that contains a Connection, and has functions run() and handle_message(ConnectorMessage).
  • ConnectorMessage is an enum that is sent via a channel to the Connector. It has the variants Connect and SendMail.
  • Connection contains an async_smtp::SmtpTransport, which contains the actual stream to send to the server.
  • Smtp is the SMTP loop.

After talking to @link2xt, these are some renamings:

  • Connector -> ConnectionActor, then make sure that it's not exported outside of the smtp module because the name doesn't say that it's about smtp
  • ConnectorMessage -> ConnectionActorMessage (that's quite long, but because we're a messenger, just calling it Message would be confusing)
  • In another PR (because this PR is quite big already, and these existed before this PR):
    • Smtp -> SmtpLoop (in another PR); smtp_loop can be moved outside of scheduler and become Smtp::run
    • Connection.transport -> Connection.inner because transport means something different now
    • SmtpConnectionHandlers can be removed, the interrupt can be passed to SmtpLoop::run()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ranamed connector -> connection_actor, Connector -> ConnectionActor, ConnectorMessage -> ConnectionActorMessage.

Also removed SmtpConnectionHandlers already so the test does not have to create it.

Comment thread src/smtp.rs Resolved
Comment thread src/smtp/connector.rs Resolved
@link2xt
link2xt force-pushed the link2xt/smtp-connector branch 3 times, most recently from f18046e to 1ccedad Compare October 30, 2026 18:04
@link2xt link2xt changed the title Factor out SMTP "Connector" and test SMTP loop in rust Test SMTP loop in rust Oct 30, 2026
@link2xt
link2xt force-pushed the link2xt/smtp-connector branch 6 times, most recently from c33778e to 92df802 Compare November 4, 2026 19:50
@link2xt

link2xt commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Added basic test for chunking to the end of smtp_tests.

@link2xt
link2xt force-pushed the link2xt/smtp-connector branch 5 times, most recently from c47bd26 to 79512f8 Compare November 4, 2026 23:32
Comment thread src/smtp/smtp_tests.rs
let callback = expect_connect(&mut alice_actors_rx[0]).await?;
callback.send(Ok(())).unwrap();

if i == 7 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retry limit logic is a bit broken: message is only marked as "retry limit exceeded" after connecting and right before actually trying to send. I made a test for what is currently implemented for now, but we can actually fail the message one step before and don't try to connect just to say that we are not going to send the message right after connecting.

@link2xt
link2xt marked this pull request as draft November 4, 2026 23:55
@link2xt
link2xt force-pushed the link2xt/smtp-connector branch from 79512f8 to ce120c5 Compare November 5, 2026 01:07
@link2xt
link2xt force-pushed the link2xt/smtp-connector branch 3 times, most recently from dee7e97 to 4ff6562 Compare November 5, 2026 02:09

/// Temporary error, e.g. temporary SMTP error
/// or connection failure during message upload.
TemporaryFailure(Error),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This temporary failure includes both SMTP temporary failure and network errors such as lost connections during mail upload.

We have already increased retry counter for network errors before, because we increase retry counter before even trying to send the message. With this PR we however now also call record_failure when connection fails after connecting and before finishing mail upload, so we will change to another transport. I think it's fine that we will change the transport unnecessary. We want to change this logic later anyway, but the main reason to have less error types is that we will have less scenarios to test.

@link2xt link2xt Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will also need to change SendResult later for #8640, to return the list of recipients and status for each one.

@Hocuri

Hocuri commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator
image_2026-11-05_17-47-08

I drew the design that is implemented by this PR. There are many details missing in my drawing, e.g. MDNs are handled separately in the SMTP loop (and then als osent via the channels to the actors), and the actors do use the database a little bit (connection_history, DNS cache).

@link2xt
link2xt marked this pull request as ready for review November 5, 2026 18:33
Changing proxy settings requires restarting I/O anyway
and at least Delta Chat for Android does it,
so `Smtp` is recreated from scratch.
This is a preparation for running SMTP loop in the tests,
to avoid having to create CancellationToken that is not going to be used.
It only contained arguments for smtp_loop(),
now they are passed directly without the structure.
This change introduces `ConnectionActor`
implementing the [actor pattern](https://ryhl.io/blog/actors-with-tokio/).
The idea is to have a separate task for each transport
that is responsible for maintaining an SMTP connection to the transport
and can respond to two requests sent over the incoming channel:
1) connect to the transport and report back 2) send a message and report back.

The tests can then run SMTP loop and reply to messages
intended for `ConnectionActor` however they want and in any order,
e.g. reporting success for the connection request or a "timeout",
and then "SMTP temporary error" or "SMTP permanent error" or "network error" for the request to send a message.
This way all the logic for recording successes and failures in the database,
selecting which transports to send the message over etc. can be covered by offline tests,
and timeouts can be emulated without actually waiting for a 1 minute timeout in the networking code.
@link2xt
link2xt force-pushed the link2xt/smtp-connector branch from 4ff6562 to c044c6f Compare November 5, 2026 18:42
@Hocuri
Hocuri requested a review from hpk42 November 5, 2026 19:27
@link2xt
link2xt force-pushed the link2xt/smtp-connector branch from c044c6f to 285a849 Compare November 5, 2026 19:31
@iequidoo
iequidoo self-requested a review November 6, 2026 15:00

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants