Skip to content

Commit d4225c1

Browse files
committed
user/update: Add publish_notification field
This field can be used to un-/resubscribe to publish notifications. When a user unsubscribes they get an email notification.
1 parent 1093110 commit d4225c1

7 files changed

+224
-1
lines changed

src/controllers/user/update.rs

Lines changed: 47 additions & 1 deletion
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::ok_true;
44
use crate::models::{Email, NewEmail};
5-
use crate::schema::emails;
5+
use crate::schema::{emails, users};
66
use crate::tasks::spawn_blocking;
77
use crate::util::errors::{bad_request, server_error, AppResult};
88
use axum::extract::Path;
@@ -23,6 +23,7 @@ pub struct UserUpdate {
2323
#[derive(Deserialize)]
2424
pub struct User {
2525
email: Option<String>,
26+
publish_notifications: Option<bool>,
2627
}
2728

2829
/// Handles the `PUT /users/:user_id` route.
@@ -44,6 +45,29 @@ pub async fn update_user(
4445
return Err(bad_request("current user does not match requested user"));
4546
}
4647

48+
if let Some(publish_notifications) = &user_update.user.publish_notifications {
49+
if user.publish_notifications != *publish_notifications {
50+
diesel::update(user)
51+
.set(users::publish_notifications.eq(*publish_notifications))
52+
.execute(conn)?;
53+
54+
if !publish_notifications {
55+
let email_address = user.verified_email(conn)?;
56+
57+
if let Some(email_address) = email_address {
58+
let email = PublishNotificationsUnsubscribeEmail {
59+
user_name: &user.gh_login,
60+
domain: &state.emails.domain,
61+
};
62+
63+
if let Err(error) = state.emails.send(&email_address, email) {
64+
warn!("Failed to send publish notifications unsubscribe email to {email_address}: {error}");
65+
}
66+
}
67+
}
68+
}
69+
}
70+
4771
if let Some(user_email) = &user_update.user.email {
4872
let user_email = user_email.trim();
4973

@@ -153,3 +177,25 @@ https://{domain}/confirm/{token}",
153177
)
154178
}
155179
}
180+
181+
pub struct PublishNotificationsUnsubscribeEmail<'a> {
182+
pub user_name: &'a str,
183+
pub domain: &'a str,
184+
}
185+
186+
impl crate::email::Email for PublishNotificationsUnsubscribeEmail<'_> {
187+
fn subject(&self) -> String {
188+
"crates.io: Unsubscribed from publish notifications".into()
189+
}
190+
191+
fn body(&self) -> String {
192+
let Self { user_name, domain } = self;
193+
format!(
194+
"Hello {user_name}!
195+
196+
You have been unsubscribed from publish notifications.
197+
198+
If you would like to resubscribe, please visit https://{domain}/settings/profile",
199+
)
200+
}
201+
}

src/tests/routes/users/update.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ use crate::util::{RequestHelper, Response, TestApp};
22
use http::StatusCode;
33
use insta::assert_snapshot;
44

5+
mod publish_notifications;
6+
57
pub trait MockEmailHelper: RequestHelper {
68
// TODO: I don't like the name of this method or `update_email` on the `MockCookieUser` impl;
79
// this is starting to look like a builder might help?
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
use crate::builders::PublishBuilder;
2+
use crate::util::{RequestHelper, TestApp};
3+
use http::StatusCode;
4+
use insta::assert_snapshot;
5+
6+
#[tokio::test(flavor = "multi_thread")]
7+
async fn test_unsubscribe_and_resubscribe() {
8+
let (app, _anon, cookie, token) = TestApp::full().with_token();
9+
10+
let user_url = format!("/api/v1/users/{}", cookie.as_model().id);
11+
12+
// Publish a crate to trigger an initial publish email
13+
let pb = PublishBuilder::new("foo", "1.0.0");
14+
let response = token.publish_crate(pb).await;
15+
assert_eq!(response.status(), StatusCode::OK);
16+
17+
// Assert that the user gets an initial publish email
18+
assert_snapshot!(app.emails_snapshot());
19+
20+
// Unsubscribe from publish notifications
21+
let payload = json!({"user": { "publish_notifications": false }});
22+
let response = cookie.put::<()>(&user_url, payload.to_string()).await;
23+
assert_eq!(response.status(), StatusCode::OK);
24+
assert_snapshot!(response.text(), @r###"{"ok":true}"###);
25+
26+
// Assert that the user gets an unsubscribe email
27+
assert_snapshot!(app.emails_snapshot());
28+
29+
// Publish the same crate again to check that the user doesn't get a publish email
30+
let pb = PublishBuilder::new("foo", "1.1.0");
31+
let response = token.publish_crate(pb).await;
32+
assert_eq!(response.status(), StatusCode::OK);
33+
34+
// Assert that the user did not get a publish email this time
35+
assert_snapshot!(app.emails_snapshot());
36+
37+
// Resubscribe to publish notifications
38+
let payload = json!({"user": { "publish_notifications": true }});
39+
let response = cookie.put::<()>(&user_url, payload.to_string()).await;
40+
assert_eq!(response.status(), StatusCode::OK);
41+
assert_snapshot!(response.text(), @r###"{"ok":true}"###);
42+
43+
// Publish the same crate again to check that the user doesn't get a publish email
44+
let pb = PublishBuilder::new("foo", "1.2.0");
45+
let response = token.publish_crate(pb).await;
46+
assert_eq!(response.status(), StatusCode::OK);
47+
48+
// Assert that the user got a publish email again
49+
assert_snapshot!(app.emails_snapshot());
50+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
source: src/tests/routes/users/update/publish_notifications.rs
3+
expression: app.emails_snapshot()
4+
---
5+
To: foo@example.com
6+
From: crates.io <noreply@crates.io>
7+
Subject: crates.io: Successfully published foo@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 (1.0.0) was published by your account (htt=
14+
ps://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: foo@example.com
21+
From: crates.io <noreply@crates.io>
22+
Subject: crates.io: Unsubscribed from publish notifications
23+
Content-Type: text/plain; charset=utf-8
24+
Content-Transfer-Encoding: quoted-printable
25+
26+
Hello foo!
27+
28+
You have been unsubscribed from publish notifications.
29+
30+
If you would like to resubscribe, please visit https://crates.io/settings/p=
31+
rofile
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
source: src/tests/routes/users/update/publish_notifications.rs
3+
expression: app.emails_snapshot()
4+
---
5+
To: foo@example.com
6+
From: crates.io <noreply@crates.io>
7+
Subject: crates.io: Successfully published foo@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 (1.0.0) was published by your account (htt=
14+
ps://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: foo@example.com
21+
From: crates.io <noreply@crates.io>
22+
Subject: crates.io: Unsubscribed from publish notifications
23+
Content-Type: text/plain; charset=utf-8
24+
Content-Transfer-Encoding: quoted-printable
25+
26+
Hello foo!
27+
28+
You have been unsubscribed from publish notifications.
29+
30+
If you would like to resubscribe, please visit https://crates.io/settings/p=
31+
rofile
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
source: src/tests/routes/users/update/publish_notifications.rs
3+
expression: app.emails_snapshot()
4+
---
5+
To: foo@example.com
6+
From: crates.io <noreply@crates.io>
7+
Subject: crates.io: Successfully published foo@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 (1.0.0) was published by your account (htt=
14+
ps://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: foo@example.com
21+
From: crates.io <noreply@crates.io>
22+
Subject: crates.io: Unsubscribed from publish notifications
23+
Content-Type: text/plain; charset=utf-8
24+
Content-Transfer-Encoding: quoted-printable
25+
26+
Hello foo!
27+
28+
You have been unsubscribed from publish notifications.
29+
30+
If you would like to resubscribe, please visit https://crates.io/settings/p=
31+
rofile
32+
----------------------------------------
33+
34+
To: foo@example.com
35+
From: crates.io <noreply@crates.io>
36+
Subject: crates.io: Successfully published foo@1.2.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 (1.2.0) was published by your account (htt=
43+
ps://crates.io/users/foo) 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.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
source: src/tests/routes/users/update/publish_notifications.rs
3+
expression: app.emails_snapshot()
4+
---
5+
To: foo@example.com
6+
From: crates.io <noreply@crates.io>
7+
Subject: crates.io: Successfully published foo@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 (1.0.0) was published by your account (htt=
14+
ps://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.

0 commit comments

Comments
 (0)