Skip to content

Commit 2f4ed8a

Browse files
authored
Merge pull request #9961 from Turbo87/async-emails
Use async `lettre` transports for sending emails
2 parents 64c1aa7 + 410afc9 commit 2f4ed8a

File tree

19 files changed

+194
-134
lines changed

19 files changed

+194
-134
lines changed

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ indicatif = "=0.17.9"
8484
ipnetwork = "=0.20.0"
8585
json-subscriber = "=0.2.2"
8686
tikv-jemallocator = { version = "=0.6.0", features = ['unprefixed_malloc_on_supported_platforms', 'profiling'] }
87-
lettre = { version = "=0.11.10", default-features = false, features = ["file-transport", "smtp-transport", "native-tls", "hostname", "builder"] }
87+
lettre = { version = "=0.11.10", default-features = false, features = ["file-transport", "smtp-transport", "hostname", "builder", "tokio1", "tokio1-native-tls"] }
8888
minijinja = "=2.5.0"
8989
mockall = "=0.13.0"
9090
native-tls = "=0.2.12"

src/controllers/user/resend.rs

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ use crate::app::AppState;
33
use crate::auth::AuthCheck;
44
use crate::controllers::helpers::ok_true;
55
use crate::models::Email;
6-
use crate::tasks::spawn_blocking;
76
use crate::util::errors::AppResult;
87
use crate::util::errors::{bad_request, BoxedAppError};
98
use axum::extract::Path;
@@ -38,19 +37,17 @@ pub async fn regenerate_token_and_send(
3837
.optional()?
3938
.ok_or_else(|| bad_request("Email could not be found"))?;
4039

41-
spawn_blocking(move || {
42-
let email1 = UserConfirmEmail {
43-
user_name: &auth.user().gh_login,
44-
domain: &state.emails.domain,
45-
token: email.token,
46-
};
47-
48-
state
49-
.emails
50-
.send(&email.email, email1)
51-
.map_err(BoxedAppError::from)
52-
})
53-
.await
40+
let email1 = UserConfirmEmail {
41+
user_name: &auth.user().gh_login,
42+
domain: &state.emails.domain,
43+
token: email.token,
44+
};
45+
46+
state
47+
.emails
48+
.async_send(&email.email, email1)
49+
.await
50+
.map_err(BoxedAppError::from)
5451
}
5552
.scope_boxed()
5653
})
@@ -74,7 +71,7 @@ mod tests {
7471
assert_eq!(response.status(), StatusCode::FORBIDDEN);
7572
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#);
7673

77-
assert_eq!(app.emails().len(), 0);
74+
assert_eq!(app.emails().await.len(), 0);
7875
}
7976

8077
#[tokio::test(flavor = "multi_thread")]
@@ -87,7 +84,7 @@ mod tests {
8784
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
8885
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"current user does not match requested user"}]}"#);
8986

90-
assert_eq!(app.emails().len(), 0);
87+
assert_eq!(app.emails().await.len(), 0);
9188
}
9289

9390
#[tokio::test(flavor = "multi_thread")]
@@ -99,6 +96,6 @@ mod tests {
9996
assert_eq!(response.status(), StatusCode::OK);
10097
assert_snapshot!(response.text(), @r#"{"ok":true}"#);
10198

102-
assert_snapshot!(app.emails_snapshot());
99+
assert_snapshot!(app.emails_snapshot().await);
103100
}
104101
}

src/email.rs

Lines changed: 47 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ use crate::Env;
33
use lettre::address::Envelope;
44
use lettre::message::header::ContentType;
55
use lettre::message::Mailbox;
6-
use lettre::transport::file::FileTransport;
6+
use lettre::transport::file::AsyncFileTransport;
77
use lettre::transport::smtp::authentication::{Credentials, Mechanism};
8-
use lettre::transport::smtp::SmtpTransport;
9-
use lettre::transport::stub::StubTransport;
10-
use lettre::{Address, Message, Transport};
8+
use lettre::transport::smtp::AsyncSmtpTransport;
9+
use lettre::transport::stub::AsyncStubTransport;
10+
use lettre::{Address, AsyncTransport, Message, Tokio1Executor};
1111
use rand::distributions::{Alphanumeric, DistString};
12+
use std::sync::Arc;
13+
use tokio::runtime::Handle;
1214

1315
pub trait Email {
1416
fn subject(&self) -> String;
@@ -36,7 +38,7 @@ impl Emails {
3638

3739
let backend = match (login, password, server) {
3840
(Ok(login), Ok(password), Ok(server)) => {
39-
let transport = SmtpTransport::relay(&server)
41+
let transport = AsyncSmtpTransport::<Tokio1Executor>::relay(&server)
4042
.unwrap()
4143
.credentials(Credentials::new(login, password))
4244
.authentication(vec![Mechanism::Plain])
@@ -45,8 +47,8 @@ impl Emails {
4547
EmailBackend::Smtp(Box::new(transport))
4648
}
4749
_ => {
48-
let transport = FileTransport::new("/tmp");
49-
EmailBackend::FileSystem(transport)
50+
let transport = AsyncFileTransport::new("/tmp");
51+
EmailBackend::FileSystem(Arc::new(transport))
5052
}
5153
};
5254

@@ -67,23 +69,23 @@ impl Emails {
6769
/// to later assert the mails were sent.
6870
pub fn new_in_memory() -> Self {
6971
Self {
70-
backend: EmailBackend::Memory(StubTransport::new_ok()),
72+
backend: EmailBackend::Memory(AsyncStubTransport::new_ok()),
7173
domain: "crates.io".into(),
7274
from: DEFAULT_FROM.parse().unwrap(),
7375
}
7476
}
7577

7678
/// This is supposed to be used only during tests, to retrieve the messages stored in the
7779
/// "memory" backend. It's not cfg'd away because our integration tests need to access this.
78-
pub fn mails_in_memory(&self) -> Option<Vec<(Envelope, String)>> {
80+
pub async fn mails_in_memory(&self) -> Option<Vec<(Envelope, String)>> {
7981
if let EmailBackend::Memory(transport) = &self.backend {
80-
Some(transport.messages())
82+
Some(transport.messages().await)
8183
} else {
8284
None
8385
}
8486
}
8587

86-
pub fn send<E: Email>(&self, recipient: &str, email: E) -> Result<(), EmailError> {
88+
fn build_message<E: Email>(&self, recipient: &str, email: E) -> Result<Message, EmailError> {
8789
// The message ID is normally generated by the SMTP server, but if we let it generate the
8890
// ID there will be no way for the crates.io application to know the ID of the message it
8991
// just sent, as it's not included in the SMTP response.
@@ -102,15 +104,32 @@ impl Emails {
102104
let subject = email.subject();
103105
let body = email.body();
104106

105-
let email = Message::builder()
107+
let message = Message::builder()
106108
.message_id(Some(message_id.clone()))
107109
.to(recipient.parse()?)
108110
.from(from)
109111
.subject(subject)
110112
.header(ContentType::TEXT_PLAIN)
111113
.body(body)?;
112114

113-
self.backend.send(email).map_err(EmailError::TransportError)
115+
Ok(message)
116+
}
117+
118+
pub fn send<E: Email>(&self, recipient: &str, email: E) -> Result<(), EmailError> {
119+
let email = self.build_message(recipient, email)?;
120+
121+
Handle::current()
122+
.block_on(self.backend.send(email))
123+
.map_err(EmailError::TransportError)
124+
}
125+
126+
pub async fn async_send<E: Email>(&self, recipient: &str, email: E) -> Result<(), EmailError> {
127+
let email = self.build_message(recipient, email)?;
128+
129+
self.backend
130+
.send(email)
131+
.await
132+
.map_err(EmailError::TransportError)
114133
}
115134
}
116135

@@ -129,19 +148,19 @@ enum EmailBackend {
129148
/// Backend used in production to send mails using SMTP.
130149
///
131150
/// This is using `Box` to avoid a large size difference between variants.
132-
Smtp(Box<SmtpTransport>),
151+
Smtp(Box<AsyncSmtpTransport<Tokio1Executor>>),
133152
/// Backend used locally during development, will store the emails in the provided directory.
134-
FileSystem(FileTransport),
153+
FileSystem(Arc<AsyncFileTransport<Tokio1Executor>>),
135154
/// Backend used during tests, will keep messages in memory to allow tests to retrieve them.
136-
Memory(StubTransport),
155+
Memory(AsyncStubTransport),
137156
}
138157

139158
impl EmailBackend {
140-
fn send(&self, message: Message) -> anyhow::Result<()> {
159+
async fn send(&self, message: Message) -> anyhow::Result<()> {
141160
match self {
142-
EmailBackend::Smtp(transport) => transport.send(&message).map(|_| ())?,
143-
EmailBackend::FileSystem(transport) => transport.send(&message).map(|_| ())?,
144-
EmailBackend::Memory(transport) => transport.send(&message).map(|_| ())?,
161+
EmailBackend::Smtp(transport) => transport.send(message).await.map(|_| ())?,
162+
EmailBackend::FileSystem(transport) => transport.send(message).await.map(|_| ())?,
163+
EmailBackend::Memory(transport) => transport.send(message).await.map(|_| ())?,
145164
}
146165

147166
Ok(())
@@ -171,20 +190,19 @@ mod tests {
171190
}
172191
}
173192

174-
#[test]
175-
fn sending_to_invalid_email_fails() {
193+
#[tokio::test]
194+
async fn sending_to_invalid_email_fails() {
176195
let emails = Emails::new_in_memory();
177196

178-
assert_err!(emails.send(
179-
"String.Format(\"{0}.{1}@live.com\", FirstName, LastName)",
180-
TestEmail
181-
));
197+
let address = "String.Format(\"{0}.{1}@live.com\", FirstName, LastName)";
198+
assert_err!(emails.async_send(address, TestEmail).await);
182199
}
183200

184-
#[test]
185-
fn sending_to_valid_email_succeeds() {
201+
#[tokio::test]
202+
async fn sending_to_valid_email_succeeds() {
186203
let emails = Emails::new_in_memory();
187204

188-
assert_ok!(emails.send("[email protected]", TestEmail));
205+
let address = "[email protected]";
206+
assert_ok!(emails.async_send(address, TestEmail).await);
189207
}
190208
}

src/tests/github_secret_scanning.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ async fn github_secret_alert_revokes_token() {
4545
let mut conn = app.async_db_conn().await;
4646

4747
// Ensure no emails were sent up to this point
48-
assert_eq!(app.emails().len(), 0);
48+
assert_eq!(app.emails().await.len(), 0);
4949

5050
// Ensure that the token currently exists in the database
5151
let tokens: Vec<ApiToken> = assert_ok!(
@@ -94,7 +94,7 @@ async fn github_secret_alert_revokes_token() {
9494
assert_that!(tokens, len(eq(1)));
9595

9696
// Ensure exactly one email was sent
97-
assert_snapshot!(app.emails_snapshot());
97+
assert_snapshot!(app.emails_snapshot().await);
9898
}
9999

100100
#[tokio::test(flavor = "multi_thread")]
@@ -103,7 +103,7 @@ async fn github_secret_alert_for_revoked_token() {
103103
let mut conn = app.async_db_conn().await;
104104

105105
// Ensure no emails were sent up to this point
106-
assert_eq!(app.emails().len(), 0);
106+
assert_eq!(app.emails().await.len(), 0);
107107

108108
// Ensure that the token currently exists in the database
109109
let tokens: Vec<ApiToken> = assert_ok!(
@@ -155,7 +155,7 @@ async fn github_secret_alert_for_revoked_token() {
155155
assert_that!(tokens, len(eq(1)));
156156

157157
// Ensure still no emails were sent
158-
assert_eq!(app.emails().len(), 0);
158+
assert_eq!(app.emails().await.len(), 0);
159159
}
160160

161161
#[tokio::test(flavor = "multi_thread")]
@@ -164,7 +164,7 @@ async fn github_secret_alert_for_unknown_token() {
164164
let mut conn = app.async_db_conn().await;
165165

166166
// Ensure no emails were sent up to this point
167-
assert_eq!(app.emails().len(), 0);
167+
assert_eq!(app.emails().await.len(), 0);
168168

169169
// Ensure that the token currently exists in the database
170170
let tokens: Vec<ApiToken> = assert_ok!(
@@ -197,7 +197,7 @@ async fn github_secret_alert_for_unknown_token() {
197197
assert_eq!(tokens[0].name, token.as_model().name);
198198

199199
// Ensure still no emails were sent
200-
assert_eq!(app.emails().len(), 0);
200+
assert_eq!(app.emails().await.len(), 0);
201201
}
202202

203203
#[tokio::test(flavor = "multi_thread")]

src/tests/krate/publish/auth.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ async fn new_wrong_token() {
3030
assert_eq!(response.status(), StatusCode::FORBIDDEN);
3131
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"authentication failed"}]}"#);
3232
assert_that!(app.stored_files().await, empty());
33-
assert_that!(app.emails(), empty());
33+
assert_that!(app.emails().await, empty());
3434
}
3535

3636
#[tokio::test(flavor = "multi_thread")]
@@ -50,5 +50,5 @@ async fn new_krate_wrong_user() {
5050
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this crate exists but you don't seem to be an owner. If you believe this is a mistake, perhaps you need to accept an invitation to be an owner before publishing."}]}"#);
5151

5252
assert_that!(app.stored_files().await, empty());
53-
assert_that!(app.emails(), empty());
53+
assert_that!(app.emails().await, empty());
5454
}

src/tests/krate/publish/basics.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ async fn new_krate() {
3838
.unwrap();
3939
assert_eq!(email, "[email protected]");
4040

41-
assert_snapshot!(app.emails_snapshot());
41+
assert_snapshot!(app.emails_snapshot().await);
4242
}
4343

4444
#[tokio::test(flavor = "multi_thread")]

src/tests/krate/publish/emails.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ async fn new_krate_without_any_email_fails() {
2121
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2222
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"A verified email address is required to publish crates to crates.io. Visit https://crates.io/settings/profile to set and verify your email address."}]}"#);
2323
assert_that!(app.stored_files().await, empty());
24-
assert_that!(app.emails(), empty());
24+
assert_that!(app.emails().await, empty());
2525
}
2626

2727
#[tokio::test(flavor = "multi_thread")]
@@ -41,5 +41,5 @@ async fn new_krate_with_unverified_email_fails() {
4141
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4242
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"A verified email address is required to publish crates to crates.io. Visit https://crates.io/settings/profile to set and verify your email address."}]}"#);
4343
assert_that!(app.stored_files().await, empty());
44-
assert_that!(app.emails(), empty());
44+
assert_that!(app.emails().await, empty());
4545
}

0 commit comments

Comments
 (0)