mas_handlers/admin/v1/user_emails/
add.rs

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