Skip to content

Commit 5811485

Browse files
authored
Merge pull request #11420 from Turbo87/jinja-emails
Use `minijinja` templates for emails
2 parents e22c2a9 + 05b41e7 commit 5811485

File tree

42 files changed

+495
-632
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+495
-632
lines changed

src/controllers/github/secret_scanning.rs

Lines changed: 31 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::app::AppState;
2-
use crate::email::Email;
2+
use crate::email::EmailMessage;
33
use crate::models::{ApiToken, User};
44
use crate::schema::{api_tokens, crate_owners, crates, emails};
55
use crate::util::errors::{AppResult, BoxedAppError, bad_request};
@@ -16,6 +16,7 @@ use diesel::prelude::*;
1616
use diesel_async::{AsyncPgConnection, RunQueryDsl};
1717
use futures_util::TryStreamExt;
1818
use http::HeaderMap;
19+
use minijinja::context;
1920
use p256::PublicKey;
2021
use p256::ecdsa::VerifyingKey;
2122
use p256::ecdsa::signature::Verifier;
@@ -25,6 +26,7 @@ use std::str::FromStr;
2526
use std::sync::LazyLock;
2627
use std::time::Duration;
2728
use tokio::sync::Mutex;
29+
use tracing::warn;
2830

2931
// Minimum number of seconds to wait before refreshing cache of GitHub's public keys
3032
const PUBLIC_KEY_CACHE_LIFETIME: Duration = Duration::from_secs(60 * 60 * 24); // 24 hours
@@ -226,13 +228,16 @@ async fn send_notification_email(
226228
return Err(anyhow!("No address found"));
227229
};
228230

229-
let email = TokenExposedEmail {
230-
domain: &state.config.domain_name,
231-
reporter: "GitHub",
232-
source: &alert.source,
233-
token_name: &token.name,
234-
url: &alert.url,
235-
};
231+
let email = EmailMessage::from_template(
232+
"token_exposed",
233+
context! {
234+
domain => state.config.domain_name,
235+
reporter => "GitHub",
236+
source => alert.source,
237+
token_name => token.name,
238+
url => if alert.url.is_empty() { "" } else { &alert.url }
239+
},
240+
)?;
236241

237242
state.emails.send(&recipient, email).await?;
238243

@@ -285,12 +290,24 @@ async fn send_trustpub_notification_emails(
285290

286291
// Send notifications in sorted order by email for consistent testing
287292
for (email, crate_names) in notifications {
288-
let email_template = TrustedPublishingTokenExposedEmail {
289-
domain: &state.config.domain_name,
290-
reporter: "GitHub",
291-
source: &alert.source,
292-
crate_names: &crate_names.iter().cloned().collect::<Vec<_>>(),
293-
url: &alert.url,
293+
let message = EmailMessage::from_template(
294+
"trustpub_token_exposed",
295+
context! {
296+
domain => state.config.domain_name,
297+
reporter => "GitHub",
298+
source => alert.source,
299+
crate_names,
300+
url => alert.url
301+
},
302+
);
303+
304+
let Ok(email_template) = message.inspect_err(|error| {
305+
warn!(
306+
%email, ?crate_names, ?error,
307+
"Failed to create trusted publishing token exposure email template"
308+
);
309+
}) else {
310+
continue;
294311
};
295312

296313
if let Err(error) = state.emails.send(&email, email_template).await {
@@ -304,104 +321,6 @@ async fn send_trustpub_notification_emails(
304321
Ok(())
305322
}
306323

307-
struct TokenExposedEmail<'a> {
308-
domain: &'a str,
309-
reporter: &'a str,
310-
source: &'a str,
311-
token_name: &'a str,
312-
url: &'a str,
313-
}
314-
315-
impl Email for TokenExposedEmail<'_> {
316-
fn subject(&self) -> String {
317-
format!(
318-
"crates.io: Your API token \"{}\" has been revoked",
319-
self.token_name
320-
)
321-
}
322-
323-
fn body(&self) -> String {
324-
let mut body = format!(
325-
"{reporter} has notified us that your crates.io API token {token_name} \
326-
has been exposed publicly. We have revoked this token as a precaution.
327-
328-
Please review your account at https://{domain} to confirm that no \
329-
unexpected changes have been made to your settings or crates.
330-
331-
Source type: {source}",
332-
domain = self.domain,
333-
reporter = self.reporter,
334-
source = self.source,
335-
token_name = self.token_name,
336-
);
337-
if self.url.is_empty() {
338-
body.push_str("\n\nWe were not informed of the URL where the token was found.");
339-
} else {
340-
body.push_str(&format!("\n\nURL where the token was found: {}", self.url));
341-
}
342-
343-
body
344-
}
345-
}
346-
347-
struct TrustedPublishingTokenExposedEmail<'a> {
348-
domain: &'a str,
349-
reporter: &'a str,
350-
source: &'a str,
351-
crate_names: &'a [String],
352-
url: &'a str,
353-
}
354-
355-
impl Email for TrustedPublishingTokenExposedEmail<'_> {
356-
fn subject(&self) -> String {
357-
"crates.io: Your Trusted Publishing token has been revoked".to_string()
358-
}
359-
360-
fn body(&self) -> String {
361-
let authorization = if self.crate_names.len() == 1 {
362-
format!(
363-
"This token was only authorized to publish the \"{}\" crate.",
364-
self.crate_names[0]
365-
)
366-
} else {
367-
format!(
368-
"This token was authorized to publish the following crates: \"{}\".",
369-
self.crate_names.join("\", \"")
370-
)
371-
};
372-
373-
let mut body = format!(
374-
"{reporter} has notified us that one of your crates.io Trusted Publishing tokens \
375-
has been exposed publicly. We have revoked this token as a precaution.
376-
377-
{authorization}
378-
379-
Please review your account at https://{domain} and your GitHub repository \
380-
settings to confirm that no unexpected changes have been made to your crates \
381-
or trusted publishing configurations.
382-
383-
Source type: {source}",
384-
domain = self.domain,
385-
reporter = self.reporter,
386-
source = self.source,
387-
);
388-
389-
if self.url.is_empty() {
390-
body.push_str("\n\nWe were not informed of the URL where the token was found.");
391-
} else {
392-
body.push_str(&format!("\n\nURL where the token was found: {}", self.url));
393-
}
394-
395-
body.push_str(
396-
"\n\nTrusted Publishing tokens are temporary and used for automated \
397-
publishing from GitHub Actions. If this exposure was unexpected, please review \
398-
your repository's workflow files and secrets.",
399-
);
400-
401-
body
402-
}
403-
}
404-
405324
#[derive(Deserialize, Serialize)]
406325
pub struct GitHubSecretAlertFeedback {
407326
pub token_raw: String,

src/controllers/krate/delete.rs

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use crate::app::AppState;
22
use crate::auth::AuthCheck;
33
use crate::controllers::helpers::authorization::Rights;
44
use crate::controllers::krate::CratePath;
5-
use crate::email::Email;
5+
use crate::email::EmailMessage;
66
use crate::models::NewDeletedCrate;
77
use crate::schema::{crate_downloads, crates, dependencies};
88
use crate::util::errors::{AppResult, BoxedAppError, custom};
@@ -18,6 +18,8 @@ use diesel_async::scoped_futures::ScopedFutureExt;
1818
use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl};
1919
use http::StatusCode;
2020
use http::request::Parts;
21+
use minijinja::context;
22+
use tracing::error;
2123

2224
const DOWNLOADS_PER_MONTH_LIMIT: u64 = 500;
2325
const AVAILABLE_AFTER: TimeDelta = TimeDelta::hours(24);
@@ -147,10 +149,13 @@ pub async fn delete_crate(
147149

148150
let email_future = async {
149151
if let Some(recipient) = user.email(&mut conn).await? {
150-
let email = CrateDeletionEmail {
151-
user: &user.gh_login,
152-
krate: &crate_name,
153-
};
152+
let email = EmailMessage::from_template(
153+
"crate_deletion",
154+
context! {
155+
user => user.gh_login,
156+
krate => crate_name
157+
},
158+
)?;
154159

155160
app.emails.send(&recipient, email).await?
156161
}
@@ -193,33 +198,6 @@ async fn has_rev_dep(conn: &mut AsyncPgConnection, crate_id: i32) -> QueryResult
193198
Ok(rev_dep.is_some())
194199
}
195200

196-
/// Email template for notifying a crate owner about a crate being deleted.
197-
///
198-
/// The owner usually should be aware of the deletion since they initiated it,
199-
/// but this email can be helpful in detecting malicious account activity.
200-
#[derive(Debug, Clone)]
201-
struct CrateDeletionEmail<'a> {
202-
user: &'a str,
203-
krate: &'a str,
204-
}
205-
206-
impl Email for CrateDeletionEmail<'_> {
207-
fn subject(&self) -> String {
208-
format!("crates.io: Deleted \"{}\" crate", self.krate)
209-
}
210-
211-
fn body(&self) -> String {
212-
format!(
213-
"Hi {},
214-
215-
Your \"{}\" crate has been deleted, per your request.
216-
217-
If you did not initiate this deletion, your account may have been compromised. Please contact us at help@crates.io.",
218-
self.user, self.krate
219-
)
220-
}
221-
}
222-
223201
#[cfg(test)]
224202
mod tests {
225203
use super::*;

src/controllers/krate/owners.rs

Lines changed: 21 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use crate::models::{
1111
use crate::util::errors::{AppResult, BoxedAppError, bad_request, crate_not_found, custom};
1212
use crate::views::EncodableOwner;
1313
use crate::{App, app::AppState};
14-
use crate::{auth::AuthCheck, email::Email};
14+
use crate::{auth::AuthCheck, email::EmailMessage};
1515
use axum::Json;
1616
use chrono::Utc;
1717
use crates_io_github::{GitHubClient, GitHubError};
@@ -20,9 +20,11 @@ use diesel_async::scoped_futures::ScopedFutureExt;
2020
use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl};
2121
use http::StatusCode;
2222
use http::request::Parts;
23+
use minijinja::context;
2324
use oauth2::AccessToken;
24-
use secrecy::{ExposeSecret, SecretString};
25+
use secrecy::ExposeSecret;
2526
use thiserror::Error;
27+
use tracing::warn;
2628

2729
#[derive(Debug, Serialize, utoipa::ToSchema)]
2830
pub struct UsersResponse {
@@ -240,13 +242,20 @@ async fn modify_owners(
240242
if let Some(recipient) =
241243
invitee.verified_email(conn).await.ok().flatten()
242244
{
243-
emails.push(OwnerInviteEmail {
244-
recipient_email_address: recipient,
245-
inviter: user.gh_login.clone(),
246-
domain: app.emails.domain.clone(),
247-
crate_name: krate.name.clone(),
248-
token,
249-
});
245+
let email = EmailMessage::from_template(
246+
"owner_invite",
247+
context! {
248+
inviter => user.gh_login,
249+
domain => app.emails.domain,
250+
crate_name => krate.name,
251+
token => token.expose_secret()
252+
},
253+
);
254+
255+
match email {
256+
Ok(email_msg) => emails.push((recipient, email_msg)),
257+
Err(error) => warn!("Failed to render owner invite email template: {error}"),
258+
}
250259
}
251260
}
252261

@@ -291,11 +300,9 @@ async fn modify_owners(
291300

292301
// Send the accumulated invite emails now the database state has
293302
// committed.
294-
for email in emails {
295-
let addr = email.recipient_email_address().to_string();
296-
297-
if let Err(e) = app.emails.send(&addr, email).await {
298-
warn!("Failed to send co-owner invite email: {e}");
303+
for (recipient, email) in emails {
304+
if let Err(error) = app.emails.send(&recipient, email).await {
305+
warn!("Failed to send owner invite email to {recipient}: {error}");
299306
}
300307
}
301308

@@ -503,41 +510,3 @@ impl From<OwnerRemoveError> for BoxedAppError {
503510
}
504511
}
505512
}
506-
507-
pub struct OwnerInviteEmail {
508-
/// The destination email address for this email.
509-
recipient_email_address: String,
510-
511-
/// Email body variables.
512-
inviter: String,
513-
domain: String,
514-
crate_name: String,
515-
token: SecretString,
516-
}
517-
518-
impl OwnerInviteEmail {
519-
pub fn recipient_email_address(&self) -> &str {
520-
&self.recipient_email_address
521-
}
522-
}
523-
524-
impl Email for OwnerInviteEmail {
525-
fn subject(&self) -> String {
526-
format!(
527-
"crates.io: Ownership invitation for \"{}\"",
528-
self.crate_name
529-
)
530-
}
531-
532-
fn body(&self) -> String {
533-
format!(
534-
"{user_name} has invited you to become an owner of the crate {crate_name}!\n
535-
Visit https://{domain}/accept-invite/{token} to accept this invitation,
536-
or go to https://{domain}/me/pending-invites to manage all of your crate ownership invitations.",
537-
user_name = self.inviter,
538-
domain = self.domain,
539-
crate_name = self.crate_name,
540-
token = self.token.expose_secret(),
541-
)
542-
}
543-
}

0 commit comments

Comments
 (0)