Skip to content

Commit a8b03bb

Browse files
committed
Admin API to add user emails
1 parent 344794b commit a8b03bb

File tree

4 files changed

+446
-1
lines changed

4 files changed

+446
-1
lines changed

crates/handlers/src/admin/v1/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ where
8282
)
8383
.api_route(
8484
"/user-emails",
85-
get_with(self::user_emails::list, self::user_emails::list_doc),
85+
get_with(self::user_emails::list, self::user_emails::list_doc)
86+
.post_with(self::user_emails::add, self::user_emails::add_doc),
8687
)
8788
.api_route(
8889
"/user-emails/{id}",
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
// Please see LICENSE in the repository root for full details.
5+
6+
use std::str::FromStr as _;
7+
8+
use aide::{transform::TransformOperation, NoApi, OperationIo};
9+
use axum::{response::IntoResponse, Json};
10+
use hyper::StatusCode;
11+
use mas_storage::{
12+
queue::{ProvisionUserJob, QueueJobRepositoryExt as _},
13+
user::UserEmailFilter,
14+
BoxRng,
15+
};
16+
use schemars::JsonSchema;
17+
use serde::Deserialize;
18+
use ulid::Ulid;
19+
20+
use crate::{
21+
admin::{
22+
call_context::CallContext,
23+
model::UserEmail,
24+
response::{ErrorResponse, SingleResponse},
25+
},
26+
impl_from_error_for_route,
27+
};
28+
29+
#[derive(Debug, thiserror::Error, OperationIo)]
30+
#[aide(output_with = "Json<ErrorResponse>")]
31+
pub enum RouteError {
32+
#[error(transparent)]
33+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
34+
35+
#[error("User email {0:?} already in use")]
36+
EmailAlreadyInUse(String),
37+
38+
#[error("Email {email:?} is not valid")]
39+
EmailNotValid {
40+
email: String,
41+
42+
#[source]
43+
source: lettre::address::AddressError,
44+
},
45+
46+
#[error("User ID {0} not found")]
47+
UserNotFound(Ulid),
48+
}
49+
50+
impl_from_error_for_route!(mas_storage::RepositoryError);
51+
52+
impl IntoResponse for RouteError {
53+
fn into_response(self) -> axum::response::Response {
54+
let error = ErrorResponse::from_error(&self);
55+
let status = match self {
56+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
57+
Self::EmailAlreadyInUse(_) => StatusCode::CONFLICT,
58+
Self::EmailNotValid { .. } => StatusCode::BAD_REQUEST,
59+
Self::UserNotFound(_) => StatusCode::NOT_FOUND,
60+
};
61+
(status, Json(error)).into_response()
62+
}
63+
}
64+
65+
/// # JSON payload for the `POST /api/admin/v1/user-emails`
66+
#[derive(Deserialize, JsonSchema)]
67+
#[serde(rename = "AddUserEmailRequest")]
68+
pub struct Request {
69+
/// The ID of the user to which the email should be added.
70+
#[schemars(with = "crate::admin::schema::Ulid")]
71+
user_id: Ulid,
72+
73+
/// The email address of the user to add.
74+
#[schemars(email)]
75+
email: String,
76+
}
77+
78+
pub fn doc(operation: TransformOperation) -> TransformOperation {
79+
operation
80+
.id("addUserEmail")
81+
.summary("Add a user email")
82+
.description(r"Add an email address to a user.
83+
Note that this endpoint ignores any policy which would normally prevent the email from being added.")
84+
.tag("user-email")
85+
.response_with::<201, Json<SingleResponse<UserEmail>>, _>(|t| {
86+
let [sample, ..] = UserEmail::samples();
87+
let response = SingleResponse::new_canonical(sample);
88+
t.description("User email was created").example(response)
89+
})
90+
.response_with::<409, RouteError, _>(|t| {
91+
let response = ErrorResponse::from_error(&RouteError::EmailAlreadyInUse(
92+
"[email protected]".to_owned(),
93+
));
94+
t.description("Email already in use").example(response)
95+
})
96+
.response_with::<400, RouteError, _>(|t| {
97+
let response = ErrorResponse::from_error(&RouteError::EmailNotValid {
98+
email: "not a valid email".to_owned(),
99+
source: lettre::address::AddressError::MissingParts,
100+
});
101+
t.description("Email is not valid").example(response)
102+
})
103+
.response_with::<404, RouteError, _>(|t| {
104+
let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil()));
105+
t.description("User was not found").example(response)
106+
})
107+
}
108+
109+
#[tracing::instrument(name = "handler.admin.v1.user_emails.add", skip_all, err)]
110+
pub async fn handler(
111+
CallContext {
112+
mut repo, clock, ..
113+
}: CallContext,
114+
NoApi(mut rng): NoApi<BoxRng>,
115+
Json(params): Json<Request>,
116+
) -> Result<(StatusCode, Json<SingleResponse<UserEmail>>), RouteError> {
117+
// Find the user
118+
let user = repo
119+
.user()
120+
.lookup(params.user_id)
121+
.await?
122+
.ok_or(RouteError::UserNotFound(params.user_id))?;
123+
124+
// Validate the email
125+
if let Err(source) = lettre::Address::from_str(&params.email) {
126+
return Err(RouteError::EmailNotValid {
127+
email: params.email,
128+
source,
129+
});
130+
}
131+
132+
// Check if the email already exists
133+
let count = repo
134+
.user_email()
135+
.count(UserEmailFilter::new().for_email(&params.email))
136+
.await?;
137+
138+
if count > 0 {
139+
return Err(RouteError::EmailAlreadyInUse(params.email));
140+
}
141+
142+
// Add the email to the user
143+
let user_email = repo
144+
.user_email()
145+
.add(&mut rng, &clock, &user, params.email)
146+
.await?;
147+
148+
// Schedule a job to update the user
149+
repo.queue_job()
150+
.schedule_job(&mut rng, &clock, ProvisionUserJob::new_for_id(user.id))
151+
.await?;
152+
153+
repo.save().await?;
154+
155+
Ok((
156+
StatusCode::CREATED,
157+
Json(SingleResponse::new_canonical(user_email.into())),
158+
))
159+
}
160+
161+
#[cfg(test)]
162+
mod tests {
163+
use hyper::{Request, StatusCode};
164+
use insta::assert_json_snapshot;
165+
use sqlx::PgPool;
166+
use ulid::Ulid;
167+
168+
use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState};
169+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
170+
async fn test_create(pool: PgPool) {
171+
setup();
172+
let mut state = TestState::from_pool(pool).await.unwrap();
173+
let token = state.token_with_scope("urn:mas:admin").await;
174+
let mut rng = state.rng();
175+
176+
// Provision a user
177+
let mut repo = state.repository().await.unwrap();
178+
let alice = repo
179+
.user()
180+
.add(&mut rng, &state.clock, "alice".to_owned())
181+
.await
182+
.unwrap();
183+
repo.save().await.unwrap();
184+
185+
let request = Request::post("/api/admin/v1/user-emails")
186+
.bearer(&token)
187+
.json(serde_json::json!({
188+
"email": "[email protected]",
189+
"user_id": alice.id,
190+
}));
191+
let response = state.request(request).await;
192+
response.assert_status(StatusCode::CREATED);
193+
let body: serde_json::Value = response.json();
194+
assert_json_snapshot!(body, @r###"
195+
{
196+
"data": {
197+
"type": "user-email",
198+
"id": "01FSHN9AG07HNEZXNQM2KNBNF6",
199+
"attributes": {
200+
"created_at": "2022-01-16T14:40:00Z",
201+
"user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
202+
"email": "[email protected]"
203+
},
204+
"links": {
205+
"self": "/api/admin/v1/user-emails/01FSHN9AG07HNEZXNQM2KNBNF6"
206+
}
207+
},
208+
"links": {
209+
"self": "/api/admin/v1/user-emails/01FSHN9AG07HNEZXNQM2KNBNF6"
210+
}
211+
}
212+
"###);
213+
}
214+
215+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
216+
async fn test_user_not_found(pool: PgPool) {
217+
setup();
218+
let mut state = TestState::from_pool(pool).await.unwrap();
219+
let token = state.token_with_scope("urn:mas:admin").await;
220+
221+
let request = Request::post("/api/admin/v1/user-emails")
222+
.bearer(&token)
223+
.json(serde_json::json!({
224+
"email": "[email protected]",
225+
"user_id": Ulid::nil(),
226+
}));
227+
let response = state.request(request).await;
228+
response.assert_status(StatusCode::NOT_FOUND);
229+
let body: serde_json::Value = response.json();
230+
assert_json_snapshot!(body, @r###"
231+
{
232+
"errors": [
233+
{
234+
"title": "User ID 00000000000000000000000000 not found"
235+
}
236+
]
237+
}
238+
"###);
239+
}
240+
241+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
242+
async fn test_email_already_exists(pool: PgPool) {
243+
setup();
244+
let mut state = TestState::from_pool(pool).await.unwrap();
245+
let token = state.token_with_scope("urn:mas:admin").await;
246+
let mut rng = state.rng();
247+
248+
let mut repo = state.repository().await.unwrap();
249+
let alice = repo
250+
.user()
251+
.add(&mut rng, &state.clock, "alice".to_owned())
252+
.await
253+
.unwrap();
254+
repo.user_email()
255+
.add(
256+
&mut rng,
257+
&state.clock,
258+
&alice,
259+
"[email protected]".to_owned(),
260+
)
261+
.await
262+
.unwrap();
263+
repo.save().await.unwrap();
264+
265+
let request = Request::post("/api/admin/v1/user-emails")
266+
.bearer(&token)
267+
.json(serde_json::json!({
268+
"email": "[email protected]",
269+
"user_id": alice.id,
270+
}));
271+
let response = state.request(request).await;
272+
response.assert_status(StatusCode::CONFLICT);
273+
let body: serde_json::Value = response.json();
274+
assert_json_snapshot!(body, @r###"
275+
{
276+
"errors": [
277+
{
278+
"title": "User email \"[email protected]\" already in use"
279+
}
280+
]
281+
}
282+
"###);
283+
}
284+
285+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
286+
async fn test_invalid_email(pool: PgPool) {
287+
setup();
288+
let mut state = TestState::from_pool(pool).await.unwrap();
289+
let token = state.token_with_scope("urn:mas:admin").await;
290+
let mut rng = state.rng();
291+
292+
let mut repo = state.repository().await.unwrap();
293+
let alice = repo
294+
.user()
295+
.add(&mut rng, &state.clock, "alice".to_owned())
296+
.await
297+
.unwrap();
298+
repo.save().await.unwrap();
299+
300+
let request = Request::post("/api/admin/v1/user-emails")
301+
.bearer(&token)
302+
.json(serde_json::json!({
303+
"email": "invalid-email",
304+
"user_id": alice.id,
305+
}));
306+
let response = state.request(request).await;
307+
response.assert_status(StatusCode::BAD_REQUEST);
308+
let body: serde_json::Value = response.json();
309+
assert_json_snapshot!(body, @r###"
310+
{
311+
"errors": [
312+
{
313+
"title": "Email \"invalid-email\" is not valid"
314+
},
315+
{
316+
"title": "Missing domain or user"
317+
}
318+
]
319+
}
320+
"###);
321+
}
322+
}

crates/handlers/src/admin/v1/user_emails/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
// SPDX-License-Identifier: AGPL-3.0-only
44
// Please see LICENSE in the repository root for full details.
55

6+
mod add;
67
mod delete;
78
mod get;
89
mod list;
910

1011
pub use self::{
12+
add::{doc as add_doc, handler as add},
1113
delete::{doc as delete_doc, handler as delete},
1214
get::{doc as get_doc, handler as get},
1315
list::{doc as list_doc, handler as list},

0 commit comments

Comments
 (0)