-
Notifications
You must be signed in to change notification settings - Fork 641
Implement publish notification emails #9341
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
17 changes: 17 additions & 0 deletions
17
src/tests/krate/publish/snapshots/all__krate__publish__basics__new_krate-4.snap
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
--- | ||
source: src/tests/krate/publish/basics.rs | ||
expression: app.emails_snapshot() | ||
--- | ||
To: foo@example.com | ||
From: noreply@crates.io | ||
Subject: crates.io: Successfully published foo_new@1.0.0 | ||
Content-Type: text/plain; charset=utf-8 | ||
Content-Transfer-Encoding: quoted-printable | ||
|
||
Hello foo! | ||
|
||
A new version of the package foo_new (1.0.0) was published by your account = | ||
(https://crates.io/users/foo) at [0000-00-00T00:00:00Z]. | ||
|
||
If you have questions or security concerns, you can contact us at help@crat= | ||
es.io. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
--- | ||
source: src/tests/owners.rs | ||
expression: app.emails_snapshot() | ||
--- | ||
To: foo@example.com | ||
From: noreply@crates.io | ||
Subject: crates.io: Successfully published foo_owner@1.0.0 | ||
Content-Type: text/plain; charset=utf-8 | ||
Content-Transfer-Encoding: quoted-printable | ||
|
||
Hello foo! | ||
|
||
A new version of the package foo_owner (1.0.0) was published by your accoun= | ||
t (https://crates.io/users/foo) at [0000-00-00T00:00:00Z]. | ||
|
||
If you have questions or security concerns, you can contact us at help@crat= | ||
es.io. | ||
---------------------------------------- | ||
|
||
To: Bar@example.com | ||
From: noreply@crates.io | ||
Subject: crates.io: Ownership invitation for "foo_owner" | ||
Content-Type: text/plain; charset=utf-8 | ||
Content-Transfer-Encoding: quoted-printable | ||
|
||
foo has invited you to become an owner of the crate foo_owner! | ||
|
||
Visit https://crates.io/accept-invite/[invite-token] to accept = | ||
this invitation, | ||
or go to https://crates.io/me/pending-invites to manage all of your crate o= | ||
wnership invitations. | ||
---------------------------------------- | ||
|
||
To: foo@example.com | ||
From: noreply@crates.io | ||
Subject: crates.io: Successfully published foo_owner@2.0.0 | ||
Content-Type: text/plain; charset=utf-8 | ||
Content-Transfer-Encoding: quoted-printable | ||
|
||
Hello foo! | ||
|
||
A new version of the package foo_owner (2.0.0) was published by Bar (https:= | ||
//crates.io/users/Bar) at [0000-00-00T00:00:00Z]. | ||
|
||
If you have questions or security concerns, you can contact us at help@crat= | ||
es.io. | ||
---------------------------------------- | ||
|
||
To: Bar@example.com | ||
From: noreply@crates.io | ||
Subject: crates.io: Successfully published foo_owner@2.0.0 | ||
Content-Type: text/plain; charset=utf-8 | ||
Content-Transfer-Encoding: quoted-printable | ||
|
||
Hello Bar! | ||
|
||
A new version of the package foo_owner (2.0.0) was published by your accoun= | ||
t (https://crates.io/users/Bar) at [0000-00-00T00:00:00Z]. | ||
|
||
If you have questions or security concerns, you can contact us at help@crat= | ||
es.io. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
--- | ||
source: src/tests/team.rs | ||
expression: app.emails_snapshot() | ||
--- | ||
To: user-all-teams@example.com | ||
From: noreply@crates.io | ||
Subject: crates.io: Successfully published foo_team_owned@2.0.0 | ||
Content-Type: text/plain; charset=utf-8 | ||
Content-Transfer-Encoding: quoted-printable | ||
|
||
Hello user-all-teams! | ||
|
||
A new version of the package foo_team_owned (2.0.0) was published by user-o= | ||
ne-team (https://crates.io/users/user-one-team) at [0000-00-00T00:00:00Z]. | ||
|
||
If you have questions or security concerns, you can contact us at help@crat= | ||
es.io. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
use crate::email::Email; | ||
use crate::models::OwnerKind; | ||
use crate::schema::{crate_owners, crates, emails, users, versions}; | ||
use crate::tasks::spawn_blocking; | ||
use crate::worker::Environment; | ||
use anyhow::anyhow; | ||
use chrono::{NaiveDateTime, SecondsFormat}; | ||
use crates_io_worker::BackgroundJob; | ||
use diesel::prelude::*; | ||
use diesel_async::{AsyncPgConnection, RunQueryDsl}; | ||
use std::sync::Arc; | ||
|
||
/// Background job that sends email notifications to all crate owners when a | ||
/// new crate version is published. | ||
#[derive(Serialize, Deserialize)] | ||
pub struct SendPublishNotificationsJob { | ||
version_id: i32, | ||
} | ||
|
||
impl SendPublishNotificationsJob { | ||
pub fn new(version_id: i32) -> Self { | ||
Self { version_id } | ||
} | ||
} | ||
|
||
impl BackgroundJob for SendPublishNotificationsJob { | ||
const JOB_NAME: &'static str = "send_publish_notifications"; | ||
|
||
type Context = Arc<Environment>; | ||
|
||
async fn run(&self, ctx: Self::Context) -> anyhow::Result<()> { | ||
let mut conn = ctx.deadpool.get().await?; | ||
|
||
// Get crate name, version and other publish details | ||
let publish_details = PublishDetails::for_version(self.version_id, &mut conn).await?; | ||
|
||
let publish_time = publish_details | ||
.publish_time | ||
.and_utc() | ||
.to_rfc3339_opts(SecondsFormat::Secs, true); | ||
|
||
// Find names and email addresses of all crate owners | ||
let recipients = crate_owners::table | ||
.filter(crate_owners::deleted.eq(false)) | ||
.filter(crate_owners::owner_kind.eq(OwnerKind::User)) | ||
.filter(crate_owners::crate_id.eq(publish_details.crate_id)) | ||
.inner_join(users::table) | ||
.inner_join(emails::table.on(users::id.eq(emails::user_id))) | ||
.filter(emails::verified.eq(true)) | ||
.select((users::gh_login, emails::email)) | ||
.load::<(String, String)>(&mut conn) | ||
.await?; | ||
|
||
// Sending emails is currently a blocking operation, so we have to use | ||
// `spawn_blocking()` to run it in a separate thread. | ||
spawn_blocking(move || { | ||
let results = recipients | ||
.into_iter() | ||
.map(|(ref recipient, email_address)| { | ||
let krate = &publish_details.krate; | ||
let version = &publish_details.version; | ||
|
||
let publisher_info = match &publish_details.publisher { | ||
Some(publisher) if publisher == recipient => &format!( | ||
" by your account (https://{domain}/users/{publisher})", | ||
domain = ctx.config.domain_name | ||
), | ||
Some(publisher) => &format!( | ||
" by {publisher} (https://{domain}/users/{publisher})", | ||
domain = ctx.config.domain_name | ||
), | ||
None => "", | ||
}; | ||
|
||
let email = PublishNotificationEmail { | ||
recipient, | ||
krate, | ||
version, | ||
publish_time: &publish_time, | ||
publisher_info, | ||
}; | ||
|
||
ctx.emails.send(&email_address, email).inspect_err(|err| { | ||
warn!("Failed to send publish notification for {krate}@{version} to {email_address}: {err}") | ||
}) | ||
}) | ||
.collect::<Vec<_>>(); | ||
|
||
// Check if any of the emails succeeded to send, in which case we | ||
// consider the job successful enough and not worth retrying. | ||
match results.iter().any(|result| result.is_ok()) { | ||
true => Ok(()), | ||
false => Err(anyhow!("Failed to send publish notifications")), | ||
} | ||
}) | ||
.await?; | ||
|
||
Ok(()) | ||
} | ||
} | ||
|
||
#[derive(Debug, Queryable, Selectable)] | ||
struct PublishDetails { | ||
#[diesel(select_expression = crates::columns::id)] | ||
crate_id: i32, | ||
#[diesel(select_expression = crates::columns::name)] | ||
krate: String, | ||
#[diesel(select_expression = versions::columns::num)] | ||
version: String, | ||
#[diesel(select_expression = versions::columns::created_at)] | ||
publish_time: NaiveDateTime, | ||
#[diesel(select_expression = users::columns::gh_login.nullable())] | ||
publisher: Option<String>, | ||
} | ||
|
||
impl PublishDetails { | ||
async fn for_version(version_id: i32, conn: &mut AsyncPgConnection) -> QueryResult<Self> { | ||
versions::table | ||
.find(version_id) | ||
.inner_join(crates::table) | ||
.left_join(users::table) | ||
.select(PublishDetails::as_select()) | ||
.first(conn) | ||
.await | ||
} | ||
} | ||
|
||
/// Email template for notifying crate owners about a new crate version | ||
/// being published. | ||
#[derive(Debug, Clone)] | ||
struct PublishNotificationEmail<'a> { | ||
recipient: &'a str, | ||
krate: &'a str, | ||
version: &'a str, | ||
publish_time: &'a str, | ||
publisher_info: &'a str, | ||
} | ||
|
||
impl Email for PublishNotificationEmail<'_> { | ||
fn subject(&self) -> String { | ||
let Self { krate, version, .. } = self; | ||
format!("crates.io: Successfully published {krate}@{version}") | ||
} | ||
|
||
fn body(&self) -> String { | ||
let Self { | ||
recipient, | ||
krate, | ||
version, | ||
publish_time, | ||
publisher_info, | ||
} = self; | ||
|
||
format!( | ||
"Hello {recipient}! | ||
|
||
A new version of the package {krate} ({version}) was published{publisher_info} at {publish_time}. | ||
|
||
If you have questions or security concerns, you can contact us at help@crates.io." | ||
) | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we also be looking up team owners?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we should, I'm just not sure that we can... :D
our publishing code currently performs a "get all owner teams, iterate through teams and query GitHub API to see if publisher is member of the team" operation, but that does not list the members of these teams. in other words: we'd need new GitHub API calls since we don't "cache" the team memberships on our side. if/when we have non-GitHub teams implemented this will probably become a lot easier.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ugh, I forgot that we don't cache that. Sorry, some Sourcegraph slipped through there.
Not blocking for this PR, but I'm wondering if it's worth the effort to figure that1 out sooner rather than later — given that part of the motivation here (at least from what I see) is supply chain security, not sending notifications for crates that are entirely owned by teams (such as the AWS crate ecosystem) feels problematic.
I'll give this some thought.
Footnotes
For clarity, I'm more thinking just some level of caching here; not so much a full blown decoupled-from-GitHub team design. ↩
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it then becomes a problem of cache invalidation. I think I'd personally rather see a proper teams concept in crates.io with a feature to optionally keep the team memberships in sync with some GitHub team. Pietro mentioned a while ago that this sync might be possible by having people install a GitHub app (?) in their orgs, but I haven't looked at any of this in detail yet.