Skip to content

Commit 69d19c4

Browse files
authored
Merge pull request rust-lang#9341 from Turbo87/publish-notifications
Implement publish notification emails
2 parents d83b4d3 + 4c00c3c commit 69d19c4

14 files changed

+295
-1
lines changed

src/controllers/krate/publish.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
33
use crate::app::AppState;
44
use crate::auth::AuthCheck;
5-
use crate::worker::jobs::{self, CheckTyposquat, UpdateDefaultVersion};
5+
use crate::worker::jobs::{
6+
self, CheckTyposquat, SendPublishNotificationsJob, UpdateDefaultVersion,
7+
};
68
use axum::body::Bytes;
79
use axum::Json;
810
use cargo_manifest::{Dependency, DepsSet, TargetDepsSet};
@@ -442,6 +444,8 @@ pub async fn publish(app: AppState, req: BytesRequest) -> AppResult<Json<GoodCra
442444

443445
jobs::enqueue_sync_to_index(&krate.name, conn)?;
444446

447+
SendPublishNotificationsJob::new(version.id).enqueue(conn)?;
448+
445449
// If this is a new version for an existing crate it is sufficient
446450
// to update the default version asynchronously in a background job.
447451
if inserted_default_versions == 0 {

src/tests/krate/publish/auth.rs

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

3435
#[tokio::test(flavor = "multi_thread")]
@@ -49,4 +50,5 @@ async fn new_krate_wrong_user() {
4950
assert_json_snapshot!(response.json());
5051

5152
assert_that!(app.stored_files().await, empty());
53+
assert_that!(app.emails(), empty());
5254
}

src/tests/krate/publish/basics.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ async fn new_krate() {
3636
.unwrap();
3737
assert_eq!(email, "[email protected]");
3838
});
39+
40+
assert_snapshot!(app.emails_snapshot());
3941
}
4042

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

src/tests/krate/publish/emails.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ async fn new_krate_without_any_email_fails() {
2020
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2121
assert_json_snapshot!(response.json());
2222
assert_that!(app.stored_files().await, empty());
23+
assert_that!(app.emails(), empty());
2324
}
2425

2526
#[tokio::test(flavor = "multi_thread")]
@@ -39,4 +40,5 @@ async fn new_krate_with_unverified_email_fails() {
3940
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4041
assert_json_snapshot!(response.json());
4142
assert_that!(app.stored_files().await, empty());
43+
assert_that!(app.emails(), empty());
4244
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
source: src/tests/krate/publish/basics.rs
3+
expression: app.emails_snapshot()
4+
---
5+
To: foo@example.com
6+
From: noreply@crates.io
7+
Subject: crates.io: Successfully published foo_new@1.0.0
8+
Content-Type: text/plain; charset=utf-8
9+
Content-Transfer-Encoding: quoted-printable
10+
11+
Hello foo!
12+
13+
A new version of the package foo_new (1.0.0) was published by your account =
14+
(https://crates.io/users/foo) at [0000-00-00T00:00:00Z].
15+
16+
If you have questions or security concerns, you can contact us at help@crat=
17+
es.io.

src/tests/owners.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ async fn new_crate_owner() {
156156
.publish_crate(crate_to_publish)
157157
.await
158158
.good();
159+
160+
assert_snapshot!(app.emails_snapshot());
159161
}
160162

161163
async fn create_and_add_owner(
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
source: src/tests/owners.rs
3+
expression: app.emails_snapshot()
4+
---
5+
To: foo@example.com
6+
From: noreply@crates.io
7+
Subject: crates.io: Successfully published foo_owner@1.0.0
8+
Content-Type: text/plain; charset=utf-8
9+
Content-Transfer-Encoding: quoted-printable
10+
11+
Hello foo!
12+
13+
A new version of the package foo_owner (1.0.0) was published by your accoun=
14+
t (https://crates.io/users/foo) at [0000-00-00T00:00:00Z].
15+
16+
If you have questions or security concerns, you can contact us at help@crat=
17+
es.io.
18+
----------------------------------------
19+
20+
To: Bar@example.com
21+
From: noreply@crates.io
22+
Subject: crates.io: Ownership invitation for "foo_owner"
23+
Content-Type: text/plain; charset=utf-8
24+
Content-Transfer-Encoding: quoted-printable
25+
26+
foo has invited you to become an owner of the crate foo_owner!
27+
28+
Visit https://crates.io/accept-invite/[invite-token] to accept =
29+
this invitation,
30+
or go to https://crates.io/me/pending-invites to manage all of your crate o=
31+
wnership invitations.
32+
----------------------------------------
33+
34+
To: foo@example.com
35+
From: noreply@crates.io
36+
Subject: crates.io: Successfully published foo_owner@2.0.0
37+
Content-Type: text/plain; charset=utf-8
38+
Content-Transfer-Encoding: quoted-printable
39+
40+
Hello foo!
41+
42+
A new version of the package foo_owner (2.0.0) was published by Bar (https:=
43+
//crates.io/users/Bar) at [0000-00-00T00:00:00Z].
44+
45+
If you have questions or security concerns, you can contact us at help@crat=
46+
es.io.
47+
----------------------------------------
48+
49+
To: Bar@example.com
50+
From: noreply@crates.io
51+
Subject: crates.io: Successfully published foo_owner@2.0.0
52+
Content-Type: text/plain; charset=utf-8
53+
Content-Transfer-Encoding: quoted-printable
54+
55+
Hello Bar!
56+
57+
A new version of the package foo_owner (2.0.0) was published by your accoun=
58+
t (https://crates.io/users/Bar) at [0000-00-00T00:00:00Z].
59+
60+
If you have questions or security concerns, you can contact us at help@crat=
61+
es.io.

src/tests/snapshots/all__owners__new_crate_owner.snap

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22
source: src/tests/owners.rs
33
expression: app.emails_snapshot()
44
---
5+
To: foo@example.com
6+
From: noreply@crates.io
7+
Subject: crates.io: Successfully published foo_owner@1.0.0
8+
Content-Type: text/plain; charset=utf-8
9+
Content-Transfer-Encoding: quoted-printable
10+
11+
Hello foo!
12+
13+
A new version of the package foo_owner (1.0.0) was published by your accoun=
14+
t (https://crates.io/users/foo) at [0000-00-00T00:00:00Z].
15+
16+
If you have questions or security concerns, you can contact us at help@crat=
17+
es.io.
18+
----------------------------------------
19+
520
To: Bar@example.com
621
From: crates.io <noreply@crates.io>
722
Subject: crates.io: Ownership invitation for "foo_owner"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
source: src/tests/team.rs
3+
expression: app.emails_snapshot()
4+
---
5+
To: user-all-teams@example.com
6+
From: noreply@crates.io
7+
Subject: crates.io: Successfully published foo_team_owned@2.0.0
8+
Content-Type: text/plain; charset=utf-8
9+
Content-Transfer-Encoding: quoted-printable
10+
11+
Hello user-all-teams!
12+
13+
A new version of the package foo_team_owned (2.0.0) was published by user-o=
14+
ne-team (https://crates.io/users/user-one-team) at [0000-00-00T00:00:00Z].
15+
16+
If you have questions or security concerns, you can contact us at help@crat=
17+
es.io.

src/tests/team.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use crates_io::{
1010

1111
use diesel::*;
1212
use http::StatusCode;
13+
use insta::assert_snapshot;
1314

1415
impl crate::util::MockAnonymousUser {
1516
/// List the team owners of the specified crate.
@@ -399,6 +400,8 @@ async fn publish_owned() {
399400
.publish_crate(crate_to_publish)
400401
.await
401402
.good();
403+
404+
assert_snapshot!(app.emails_snapshot());
402405
}
403406

404407
/// Test trying to change owners (when only on an owning team)

src/tests/util/test_app.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ impl TestApp {
177177
static EMAIL_HEADER_REGEX: LazyLock<Regex> =
178178
LazyLock::new(|| Regex::new(r"(Message-ID|Date): [^\r\n]+\r\n").unwrap());
179179

180+
static DATE_TIME_REGEX: LazyLock<Regex> =
181+
LazyLock::new(|| Regex::new(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z").unwrap());
182+
180183
static INVITE_TOKEN_REGEX: LazyLock<Regex> =
181184
LazyLock::new(|| Regex::new(r"/accept-invite/\w+").unwrap());
182185

@@ -186,6 +189,7 @@ impl TestApp {
186189
.into_iter()
187190
.map(|email| {
188191
let email = EMAIL_HEADER_REGEX.replace_all(&email, "");
192+
let email = DATE_TIME_REGEX.replace_all(&email, "[0000-00-00T00:00:00Z]");
189193
let email = INVITE_TOKEN_REGEX.replace_all(&email, "/accept-invite/[invite-token]");
190194
email.to_string()
191195
})

src/worker/jobs/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ mod git;
1515
mod index_version_downloads_archive;
1616
mod readmes;
1717
pub mod rss;
18+
mod send_publish_notifications;
1819
mod sync_admins;
1920
mod typosquat;
2021
mod update_default_version;
@@ -29,6 +30,7 @@ pub use self::expiry_notification::SendTokenExpiryNotifications;
2930
pub use self::git::{NormalizeIndex, SquashIndex, SyncToGitIndex, SyncToSparseIndex};
3031
pub use self::index_version_downloads_archive::IndexVersionDownloadsArchive;
3132
pub use self::readmes::RenderAndUploadReadme;
33+
pub use self::send_publish_notifications::SendPublishNotificationsJob;
3234
pub use self::sync_admins::SyncAdmins;
3335
pub use self::typosquat::CheckTyposquat;
3436
pub use self::update_default_version::UpdateDefaultVersion;
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
use crate::email::Email;
2+
use crate::models::OwnerKind;
3+
use crate::schema::{crate_owners, crates, emails, users, versions};
4+
use crate::tasks::spawn_blocking;
5+
use crate::worker::Environment;
6+
use anyhow::anyhow;
7+
use chrono::{NaiveDateTime, SecondsFormat};
8+
use crates_io_worker::BackgroundJob;
9+
use diesel::prelude::*;
10+
use diesel_async::{AsyncPgConnection, RunQueryDsl};
11+
use std::sync::Arc;
12+
13+
/// Background job that sends email notifications to all crate owners when a
14+
/// new crate version is published.
15+
#[derive(Serialize, Deserialize)]
16+
pub struct SendPublishNotificationsJob {
17+
version_id: i32,
18+
}
19+
20+
impl SendPublishNotificationsJob {
21+
pub fn new(version_id: i32) -> Self {
22+
Self { version_id }
23+
}
24+
}
25+
26+
impl BackgroundJob for SendPublishNotificationsJob {
27+
const JOB_NAME: &'static str = "send_publish_notifications";
28+
29+
type Context = Arc<Environment>;
30+
31+
async fn run(&self, ctx: Self::Context) -> anyhow::Result<()> {
32+
let mut conn = ctx.deadpool.get().await?;
33+
34+
// Get crate name, version and other publish details
35+
let publish_details = PublishDetails::for_version(self.version_id, &mut conn).await?;
36+
37+
let publish_time = publish_details
38+
.publish_time
39+
.and_utc()
40+
.to_rfc3339_opts(SecondsFormat::Secs, true);
41+
42+
// Find names and email addresses of all crate owners
43+
let recipients = crate_owners::table
44+
.filter(crate_owners::deleted.eq(false))
45+
.filter(crate_owners::owner_kind.eq(OwnerKind::User))
46+
.filter(crate_owners::crate_id.eq(publish_details.crate_id))
47+
.inner_join(users::table)
48+
.inner_join(emails::table.on(users::id.eq(emails::user_id)))
49+
.filter(emails::verified.eq(true))
50+
.select((users::gh_login, emails::email))
51+
.load::<(String, String)>(&mut conn)
52+
.await?;
53+
54+
// Sending emails is currently a blocking operation, so we have to use
55+
// `spawn_blocking()` to run it in a separate thread.
56+
spawn_blocking(move || {
57+
let results = recipients
58+
.into_iter()
59+
.map(|(ref recipient, email_address)| {
60+
let krate = &publish_details.krate;
61+
let version = &publish_details.version;
62+
63+
let publisher_info = match &publish_details.publisher {
64+
Some(publisher) if publisher == recipient => &format!(
65+
" by your account (https://{domain}/users/{publisher})",
66+
domain = ctx.config.domain_name
67+
),
68+
Some(publisher) => &format!(
69+
" by {publisher} (https://{domain}/users/{publisher})",
70+
domain = ctx.config.domain_name
71+
),
72+
None => "",
73+
};
74+
75+
let email = PublishNotificationEmail {
76+
recipient,
77+
krate,
78+
version,
79+
publish_time: &publish_time,
80+
publisher_info,
81+
};
82+
83+
ctx.emails.send(&email_address, email).inspect_err(|err| {
84+
warn!("Failed to send publish notification for {krate}@{version} to {email_address}: {err}")
85+
})
86+
})
87+
.collect::<Vec<_>>();
88+
89+
// Check if any of the emails succeeded to send, in which case we
90+
// consider the job successful enough and not worth retrying.
91+
match results.iter().any(|result| result.is_ok()) {
92+
true => Ok(()),
93+
false => Err(anyhow!("Failed to send publish notifications")),
94+
}
95+
})
96+
.await?;
97+
98+
Ok(())
99+
}
100+
}
101+
102+
#[derive(Debug, Queryable, Selectable)]
103+
struct PublishDetails {
104+
#[diesel(select_expression = crates::columns::id)]
105+
crate_id: i32,
106+
#[diesel(select_expression = crates::columns::name)]
107+
krate: String,
108+
#[diesel(select_expression = versions::columns::num)]
109+
version: String,
110+
#[diesel(select_expression = versions::columns::created_at)]
111+
publish_time: NaiveDateTime,
112+
#[diesel(select_expression = users::columns::gh_login.nullable())]
113+
publisher: Option<String>,
114+
}
115+
116+
impl PublishDetails {
117+
async fn for_version(version_id: i32, conn: &mut AsyncPgConnection) -> QueryResult<Self> {
118+
versions::table
119+
.find(version_id)
120+
.inner_join(crates::table)
121+
.left_join(users::table)
122+
.select(PublishDetails::as_select())
123+
.first(conn)
124+
.await
125+
}
126+
}
127+
128+
/// Email template for notifying crate owners about a new crate version
129+
/// being published.
130+
#[derive(Debug, Clone)]
131+
struct PublishNotificationEmail<'a> {
132+
recipient: &'a str,
133+
krate: &'a str,
134+
version: &'a str,
135+
publish_time: &'a str,
136+
publisher_info: &'a str,
137+
}
138+
139+
impl Email for PublishNotificationEmail<'_> {
140+
fn subject(&self) -> String {
141+
let Self { krate, version, .. } = self;
142+
format!("crates.io: Successfully published {krate}@{version}")
143+
}
144+
145+
fn body(&self) -> String {
146+
let Self {
147+
recipient,
148+
krate,
149+
version,
150+
publish_time,
151+
publisher_info,
152+
} = self;
153+
154+
format!(
155+
"Hello {recipient}!
156+
157+
A new version of the package {krate} ({version}) was published{publisher_info} at {publish_time}.
158+
159+
If you have questions or security concerns, you can contact us at [email protected]."
160+
)
161+
}
162+
}

0 commit comments

Comments
 (0)