Skip to content

Commit 4c00c3c

Browse files
committed
Implement publish notification emails
1 parent afc48ee commit 4c00c3c

File tree

8 files changed

+254
-1
lines changed

8 files changed

+254
-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/snapshots/all__krate__publish__basics__new_krate-4.snap

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,16 @@
22
source: src/tests/krate/publish/basics.rs
33
expression: app.emails_snapshot()
44
---
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
510

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/snapshots/all__owners__new_crate_owner-2.snap

Lines changed: 45 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: noreply@crates.io
722
Subject: crates.io: Ownership invitation for "foo_owner"
@@ -14,3 +29,33 @@ Visit https://crates.io/accept-invite/[invite-token] to accept =
1429
this invitation,
1530
or go to https://crates.io/me/pending-invites to manage all of your crate o=
1631
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: noreply@crates.io
722
Subject: crates.io: Ownership invitation for "foo_owner"

src/tests/snapshots/all__team__publish_owned.snap

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,16 @@
22
source: src/tests/team.rs
33
expression: app.emails_snapshot()
44
---
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
510

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/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+
}

src/worker/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ impl RunnerExt for Runner<Arc<Environment>> {
3636
.register_job_type::<jobs::UpdateDownloads>()
3737
.register_job_type::<jobs::UpdateDefaultVersion>()
3838
.register_job_type::<jobs::SendTokenExpiryNotifications>()
39+
.register_job_type::<jobs::SendPublishNotificationsJob>()
3940
.register_job_type::<jobs::rss::SyncCrateFeed>()
4041
.register_job_type::<jobs::rss::SyncCratesFeed>()
4142
.register_job_type::<jobs::rss::SyncUpdatesFeed>()

0 commit comments

Comments
 (0)