diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index a7e22ac1a..dd071e004 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -82,11 +82,13 @@ where ) .api_route( "/user-emails", - get_with(self::user_emails::list, self::user_emails::list_doc), + get_with(self::user_emails::list, self::user_emails::list_doc) + .post_with(self::user_emails::add, self::user_emails::add_doc), ) .api_route( "/user-emails/{id}", - get_with(self::user_emails::get, self::user_emails::get_doc), + get_with(self::user_emails::get, self::user_emails::get_doc) + .delete_with(self::user_emails::delete, self::user_emails::delete_doc), ) .api_route( "/user-sessions", diff --git a/crates/handlers/src/admin/v1/user_emails/add.rs b/crates/handlers/src/admin/v1/user_emails/add.rs new file mode 100644 index 000000000..98da87063 --- /dev/null +++ b/crates/handlers/src/admin/v1/user_emails/add.rs @@ -0,0 +1,322 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::str::FromStr as _; + +use aide::{transform::TransformOperation, NoApi, OperationIo}; +use axum::{response::IntoResponse, Json}; +use hyper::StatusCode; +use mas_storage::{ + queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, + user::UserEmailFilter, + BoxRng, +}; +use schemars::JsonSchema; +use serde::Deserialize; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::UserEmail, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User email {0:?} already in use")] + EmailAlreadyInUse(String), + + #[error("Email {email:?} is not valid")] + EmailNotValid { + email: String, + + #[source] + source: lettre::address::AddressError, + }, + + #[error("User ID {0} not found")] + UserNotFound(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::EmailAlreadyInUse(_) => StatusCode::CONFLICT, + Self::EmailNotValid { .. } => StatusCode::BAD_REQUEST, + Self::UserNotFound(_) => StatusCode::NOT_FOUND, + }; + (status, Json(error)).into_response() + } +} + +/// # JSON payload for the `POST /api/admin/v1/user-emails` +#[derive(Deserialize, JsonSchema)] +#[serde(rename = "AddUserEmailRequest")] +pub struct Request { + /// The ID of the user to which the email should be added. + #[schemars(with = "crate::admin::schema::Ulid")] + user_id: Ulid, + + /// The email address of the user to add. + #[schemars(email)] + email: String, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("addUserEmail") + .summary("Add a user email") + .description(r"Add an email address to a user. +Note that this endpoint ignores any policy which would normally prevent the email from being added.") + .tag("user-email") + .response_with::<201, Json>, _>(|t| { + let [sample, ..] = UserEmail::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("User email was created").example(response) + }) + .response_with::<409, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::EmailAlreadyInUse( + "alice@example.com".to_owned(), + )); + t.description("Email already in use").example(response) + }) + .response_with::<400, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::EmailNotValid { + email: "not a valid email".to_owned(), + source: lettre::address::AddressError::MissingParts, + }); + t.description("Email is not valid").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil())); + t.description("User was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.user_emails.add", skip_all, err)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + NoApi(mut rng): NoApi, + Json(params): Json, +) -> Result<(StatusCode, Json>), RouteError> { + // Find the user + let user = repo + .user() + .lookup(params.user_id) + .await? + .ok_or(RouteError::UserNotFound(params.user_id))?; + + // Validate the email + if let Err(source) = lettre::Address::from_str(¶ms.email) { + return Err(RouteError::EmailNotValid { + email: params.email, + source, + }); + } + + // Check if the email already exists + let count = repo + .user_email() + .count(UserEmailFilter::new().for_email(¶ms.email)) + .await?; + + if count > 0 { + return Err(RouteError::EmailAlreadyInUse(params.email)); + } + + // Add the email to the user + let user_email = repo + .user_email() + .add(&mut rng, &clock, &user, params.email) + .await?; + + // Schedule a job to update the user + repo.queue_job() + .schedule_job(&mut rng, &clock, ProvisionUserJob::new_for_id(user.id)) + .await?; + + repo.save().await?; + + Ok(( + StatusCode::CREATED, + Json(SingleResponse::new_canonical(user_email.into())), + )) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use sqlx::PgPool; + use ulid::Ulid; + + use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState}; + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision a user + let mut repo = state.repository().await.unwrap(); + let alice = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::post("/api/admin/v1/user-emails") + .bearer(&token) + .json(serde_json::json!({ + "email": "alice@example.com", + "user_id": alice.id, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "data": { + "type": "user-email", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "email": "alice@example.com" + }, + "links": { + "self": "/api/admin/v1/user-emails/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + "links": { + "self": "/api/admin/v1/user-emails/01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + "###); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_user_not_found(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::post("/api/admin/v1/user-emails") + .bearer(&token) + .json(serde_json::json!({ + "email": "alice@example.com", + "user_id": Ulid::nil(), + })); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + "###); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_email_already_exists(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + let mut repo = state.repository().await.unwrap(); + let alice = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + repo.user_email() + .add( + &mut rng, + &state.clock, + &alice, + "alice@example.com".to_owned(), + ) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::post("/api/admin/v1/user-emails") + .bearer(&token) + .json(serde_json::json!({ + "email": "alice@example.com", + "user_id": alice.id, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CONFLICT); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "errors": [ + { + "title": "User email \"alice@example.com\" already in use" + } + ] + } + "###); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_invalid_email(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + let mut repo = state.repository().await.unwrap(); + let alice = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::post("/api/admin/v1/user-emails") + .bearer(&token) + .json(serde_json::json!({ + "email": "invalid-email", + "user_id": alice.id, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "errors": [ + { + "title": "Email \"invalid-email\" is not valid" + }, + { + "title": "Missing domain or user" + } + ] + } + "###); + } +} diff --git a/crates/handlers/src/admin/v1/user_emails/delete.rs b/crates/handlers/src/admin/v1/user_emails/delete.rs new file mode 100644 index 000000000..8ea584be3 --- /dev/null +++ b/crates/handlers/src/admin/v1/user_emails/delete.rs @@ -0,0 +1,141 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use aide::{transform::TransformOperation, NoApi, OperationIo}; +use axum::{response::IntoResponse, Json}; +use hyper::StatusCode; +use mas_storage::{ + queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, + BoxRng, +}; +use ulid::Ulid; + +use crate::{ + admin::{call_context::CallContext, params::UlidPathParam, response::ErrorResponse}, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User email ID {0} not found")] + NotFound(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + }; + (status, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("deleteUserEmail") + .summary("Delete a user email") + .tag("user-email") + .response_with::<204, (), _>(|t| t.description("User email was found")) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("User email was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.user_emails.delete", skip_all, err)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + NoApi(mut rng): NoApi, + id: UlidPathParam, +) -> Result { + let email = repo + .user_email() + .lookup(*id) + .await? + .ok_or(RouteError::NotFound(*id))?; + + let job = ProvisionUserJob::new_for_id(email.user_id); + repo.user_email().remove(email).await?; + + // Schedule a job to update the user + repo.queue_job().schedule_job(&mut rng, &clock, job).await?; + + repo.save().await?; + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use sqlx::PgPool; + use ulid::Ulid; + + use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState}; + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_delete(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision a user and an email + let mut repo = state.repository().await.unwrap(); + let alice = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let mas_data_model::UserEmail { id, .. } = repo + .user_email() + .add( + &mut rng, + &state.clock, + &alice, + "alice@example.com".to_owned(), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::delete(format!("/api/admin/v1/user-emails/{id}")) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NO_CONTENT); + + // Verify that the email was deleted + let request = Request::get(format!("/api/admin/v1/user-emails/{id}")) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_not_found(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let email_id = Ulid::nil(); + let request = Request::delete(format!("/api/admin/v1/user-emails/{email_id}")) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/crates/handlers/src/admin/v1/user_emails/get.rs b/crates/handlers/src/admin/v1/user_emails/get.rs index f3b85d35e..e3c4a9a4c 100644 --- a/crates/handlers/src/admin/v1/user_emails/get.rs +++ b/crates/handlers/src/admin/v1/user_emails/get.rs @@ -24,7 +24,7 @@ pub enum RouteError { #[error(transparent)] Internal(Box), - #[error("OAuth 2.0 session ID {0} not found")] + #[error("User email ID {0} not found")] NotFound(Ulid), } @@ -62,15 +62,13 @@ pub async fn handler( CallContext { mut repo, .. }: CallContext, id: UlidPathParam, ) -> Result>, RouteError> { - let session = repo + let email = repo .user_email() .lookup(*id) .await? .ok_or(RouteError::NotFound(*id))?; - Ok(Json(SingleResponse::new_canonical(UserEmail::from( - session, - )))) + Ok(Json(SingleResponse::new_canonical(UserEmail::from(email)))) } #[cfg(test)] @@ -142,8 +140,8 @@ mod tests { let mut state = TestState::from_pool(pool).await.unwrap(); let token = state.token_with_scope("urn:mas:admin").await; - let session_id = Ulid::nil(); - let request = Request::get(format!("/api/admin/v1/user-emails/{session_id}")) + let email_id = Ulid::nil(); + let request = Request::get(format!("/api/admin/v1/user-emails/{email_id}")) .bearer(&token) .empty(); let response = state.request(request).await; diff --git a/crates/handlers/src/admin/v1/user_emails/mod.rs b/crates/handlers/src/admin/v1/user_emails/mod.rs index 23c05c416..136c132ba 100644 --- a/crates/handlers/src/admin/v1/user_emails/mod.rs +++ b/crates/handlers/src/admin/v1/user_emails/mod.rs @@ -3,10 +3,14 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +mod add; +mod delete; mod get; mod list; pub use self::{ + add::{doc as add_doc, handler as add}, + delete::{doc as delete_doc, handler as delete}, get::{doc as get_doc, handler as get}, list::{doc as list_doc, handler as list}, }; diff --git a/docs/api/spec.json b/docs/api/spec.json index 100bff25a..07743afcb 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -1462,6 +1462,107 @@ } } } + }, + "post": { + "tags": [ + "user-email" + ], + "summary": "Add a user email", + "description": "Add an email address to a user.\nNote that this endpoint ignores any policy which would normally prevent the email from being added.", + "operationId": "addUserEmail", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddUserEmailRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "User email was created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UserEmail" + }, + "example": { + "data": { + "type": "user-email", + "id": "01040G2081040G2081040G2081", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "user_id": "02081040G2081040G2081040G2", + "email": "alice@example.com" + }, + "links": { + "self": "/api/admin/v1/user-emails/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/user-emails/01040G2081040G2081040G2081" + } + } + } + } + }, + "409": { + "description": "Email already in use", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User email \"alice@example.com\" already in use" + } + ] + } + } + } + }, + "400": { + "description": "Email is not valid", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Email \"not a valid email\" is not valid" + }, + { + "title": "Missing domain or user" + } + ] + } + } + } + }, + "404": { + "description": "User was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } } }, "/api/admin/v1/user-emails/{id}": { @@ -1521,7 +1622,48 @@ "example": { "errors": [ { - "title": "OAuth 2.0 session ID 00000000000000000000000000 not found" + "title": "User email ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + }, + "delete": { + "tags": [ + "user-email" + ], + "summary": "Delete a user email", + "operationId": "deleteUserEmail", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "204": { + "description": "User email was found" + }, + "404": { + "description": "User email was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User email ID 00000000000000000000000000 not found" } ] } @@ -2803,6 +2945,25 @@ } } }, + "AddUserEmailRequest": { + "title": "JSON payload for the `POST /api/admin/v1/user-emails`", + "type": "object", + "required": [ + "email", + "user_id" + ], + "properties": { + "user_id": { + "description": "The ID of the user to which the email should be added.", + "$ref": "#/components/schemas/ULID" + }, + "email": { + "description": "The email address of the user to add.", + "type": "string", + "format": "email" + } + } + }, "SingleResponse_for_UserEmail": { "description": "A top-level response with a single resource", "type": "object",