Skip to content

Commit 7e4b9f8

Browse files
committed
worker/jobs/sync_admins: Send notification emails
1 parent e580a6d commit 7e4b9f8

File tree

5 files changed

+119
-17
lines changed

5 files changed

+119
-17
lines changed

Cargo.lock

Lines changed: 1 addition & 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 & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,5 @@ crates_io_test_db = { path = "crates_io_test_db" }
129129
claims = "=0.7.1"
130130
googletest = "=0.10.0"
131131
insta = { version = "=1.34.0", features = ["json", "redactions"] }
132+
regex = "=1.10.2"
132133
tokio = "=1.35.1"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
source: src/tests/worker/sync_admins.rs
3+
expression: emails
4+
---
5+
[
6+
"To: existing-admin@crates.io\r\nFrom: noreply@crates.io\r\nSubject: crates.io: Admin account changes\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nNew admins have been added:\r\n\r\n- new-admin (github_id: 3)\r\n\r\nAdmin access has been revoked for:\r\n- obsolete-admin (github_id: 2)\r\n",
7+
"To: obsolete-admin@crates.io\r\nFrom: noreply@crates.io\r\nSubject: crates.io: Admin account changes\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nNew admins have been added:\r\n\r\n- new-admin (github_id: 3)\r\n\r\nAdmin access has been revoked for:\r\n- obsolete-admin (github_id: 2)\r\n",
8+
]

src/tests/worker/sync_admins.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
use crate::util::TestApp;
2-
use crates_io::schema::users;
2+
use crates_io::schema::{emails, users};
33
use crates_io::team_repo::{Member, MockTeamRepo, Team};
44
use crates_io::worker::jobs::SyncAdmins;
55
use crates_io_worker::BackgroundJob;
66
use diesel::prelude::*;
77
use diesel::{PgConnection, QueryResult, RunQueryDsl};
8+
use insta::assert_debug_snapshot;
9+
use regex::Regex;
810

911
#[test]
1012
fn test_sync_admins_job() {
@@ -39,6 +41,15 @@ fn test_sync_admins_job() {
3941
let admins = app.db(|conn| get_admins(conn).unwrap());
4042
let expected_admins = vec![("existing-admin".into(), 1), ("new-admin".into(), 3)];
4143
assert_eq!(admins, expected_admins);
44+
45+
let email_header_regex = Regex::new(r"(Message-ID|Date): [^\r\n]+\r\n").unwrap();
46+
let emails = app.as_inner().emails.mails_in_memory().unwrap();
47+
let emails = emails
48+
.iter()
49+
.map(|(_, email)| email_header_regex.replace_all(email, ""))
50+
.collect::<Vec<_>>();
51+
52+
assert_debug_snapshot!(emails);
4253
}
4354

4455
fn mock_team(name: impl Into<String>, members: Vec<Member>) -> Team {
@@ -61,14 +72,23 @@ fn mock_member(name: impl Into<String>, github_id: i32) -> Member {
6172
}
6273

6374
fn create_user(name: &str, gh_id: i32, is_admin: bool, conn: &mut PgConnection) -> QueryResult<()> {
64-
diesel::insert_into(users::table)
75+
let user_id = diesel::insert_into(users::table)
6576
.values((
6677
users::name.eq(name),
6778
users::gh_login.eq(name),
6879
users::gh_id.eq(gh_id),
6980
users::gh_access_token.eq("some random token"),
7081
users::is_admin.eq(is_admin),
7182
))
83+
.returning(users::id)
84+
.get_result::<i32>(conn)?;
85+
86+
diesel::insert_into(emails::table)
87+
.values((
88+
emails::user_id.eq(user_id),
89+
emails::email.eq(format!("{}@crates.io", name)),
90+
emails::verified.eq(true),
91+
))
7292
.execute(conn)?;
7393

7494
Ok(())

src/worker/jobs/sync_admins.rs

Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
use crate::schema::users;
1+
use crate::email::Email;
2+
use crate::schema::{emails, users};
23
use crate::tasks::spawn_blocking;
34
use crate::worker::Environment;
45
use crates_io_worker::BackgroundJob;
56
use diesel::prelude::*;
67
use diesel::RunQueryDsl;
78
use std::collections::HashSet;
9+
use std::fmt::{Display, Formatter};
810
use std::sync::Arc;
911

1012
/// See <https://github.com/rust-lang/team/blob/master/teams/crates-io-admins.toml>.
@@ -31,57 +33,83 @@ impl BackgroundJob for SyncAdmins {
3133
let mut conn = ctx.connection_pool.get()?;
3234

3335
let database_admins = users::table
34-
.select((users::gh_id, users::gh_login))
36+
.left_join(emails::table)
37+
.select((users::gh_id, users::gh_login, emails::email.nullable()))
3538
.filter(users::is_admin.eq(true))
36-
.get_results::<(i32, String)>(&mut conn)?;
39+
.get_results::<(i32, String, Option<String>)>(&mut conn)?;
3740

3841
let database_admin_ids = database_admins
3942
.iter()
40-
.map(|(gh_id, _)| *gh_id)
43+
.map(|(gh_id, _, _)| *gh_id)
4144
.collect::<HashSet<_>>();
4245

4346
let new_admin_ids = repo_admin_ids
4447
.difference(&database_admin_ids)
4548
.collect::<HashSet<_>>();
4649

47-
if new_admin_ids.is_empty() {
50+
let new_admins = if new_admin_ids.is_empty() {
4851
debug!("No new admins to add");
52+
vec![]
4953
} else {
5054
let new_admins = repo_admins
5155
.iter()
5256
.filter(|m| new_admin_ids.contains(&&m.github_id))
5357
.map(|m| format!("{} (github_id: {})", m.github, m.github_id))
54-
.collect::<Vec<_>>()
55-
.join(", ");
58+
.collect::<Vec<_>>();
5659

57-
info!("Adding new admins: {}", new_admins);
60+
info!("Adding new admins: {}", new_admins.join(", "));
5861

5962
diesel::update(users::table)
6063
.filter(users::gh_id.eq_any(new_admin_ids))
6164
.set(users::is_admin.eq(true))
6265
.execute(&mut conn)?;
63-
}
66+
67+
new_admins
68+
};
6469

6570
let obsolete_admin_ids = database_admin_ids
6671
.difference(&repo_admin_ids)
6772
.collect::<HashSet<_>>();
6873

69-
if obsolete_admin_ids.is_empty() {
74+
let obsolete_admins = if obsolete_admin_ids.is_empty() {
7075
debug!("No obsolete admins to remove");
76+
vec![]
7177
} else {
7278
let obsolete_admins = database_admins
7379
.iter()
74-
.filter(|(gh_id, _)| obsolete_admin_ids.contains(&gh_id))
75-
.map(|(gh_id, login)| format!("{} (github_id: {})", login, gh_id))
76-
.collect::<Vec<_>>()
77-
.join(", ");
80+
.filter(|(gh_id, _, _)| obsolete_admin_ids.contains(&gh_id))
81+
.map(|(gh_id, login, _)| format!("{} (github_id: {})", login, gh_id))
82+
.collect::<Vec<_>>();
7883

79-
info!("Removing obsolete admins: {}", obsolete_admins);
84+
info!("Removing obsolete admins: {}", obsolete_admins.join(", "));
8085

8186
diesel::update(users::table)
8287
.filter(users::gh_id.eq_any(obsolete_admin_ids))
8388
.set(users::is_admin.eq(false))
8489
.execute(&mut conn)?;
90+
91+
obsolete_admins
92+
};
93+
94+
if !new_admins.is_empty() || !obsolete_admins.is_empty() {
95+
let email = AdminAccountEmail::new(new_admins, obsolete_admins);
96+
97+
for database_admin in &database_admins {
98+
let (_, _, email_address) = database_admin;
99+
if let Some(email_address) = email_address {
100+
if let Err(error) = ctx.emails.send(email_address, email.clone()) {
101+
warn!(
102+
"Failed to send email to admin {} ({}, github_id: {}): {}",
103+
database_admin.1, email_address, database_admin.0, error
104+
);
105+
}
106+
} else {
107+
warn!(
108+
"No email address found for admin {} (github_id: {})",
109+
database_admin.1, database_admin.0
110+
);
111+
}
112+
}
85113
}
86114

87115
Ok(())
@@ -91,3 +119,47 @@ impl BackgroundJob for SyncAdmins {
91119
Ok(())
92120
}
93121
}
122+
123+
#[derive(Debug, Clone)]
124+
struct AdminAccountEmail {
125+
new_admins: Vec<String>,
126+
obsolete_admins: Vec<String>,
127+
}
128+
129+
impl AdminAccountEmail {
130+
fn new(new_admins: Vec<String>, obsolete_admins: Vec<String>) -> Self {
131+
Self {
132+
new_admins,
133+
obsolete_admins,
134+
}
135+
}
136+
}
137+
138+
impl Email for AdminAccountEmail {
139+
const SUBJECT: &'static str = "crates.io: Admin account changes";
140+
141+
fn body(&self) -> String {
142+
self.to_string()
143+
}
144+
}
145+
146+
impl Display for AdminAccountEmail {
147+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
148+
if !self.new_admins.is_empty() {
149+
writeln!(f, "New admins have been added:\n")?;
150+
for new_admin in &self.new_admins {
151+
writeln!(f, "- {}", new_admin)?;
152+
}
153+
writeln!(f)?;
154+
}
155+
156+
if !self.obsolete_admins.is_empty() {
157+
writeln!(f, "Admin access has been revoked for:")?;
158+
for obsolete_admin in &self.obsolete_admins {
159+
writeln!(f, "- {}", obsolete_admin)?;
160+
}
161+
}
162+
163+
Ok(())
164+
}
165+
}

0 commit comments

Comments
 (0)