use anyhow::Context as _;
use async_graphql::{Context, Description, Enum, InputObject, Object, ID};
use mas_storage::{
job::{JobRepositoryExt, ProvisionUserJob, VerifyEmailJob},
user::{UserEmailRepository, UserRepository},
RepositoryAccess,
};
use crate::graphql::{
model::{NodeType, User, UserEmail},
state::ContextExt,
UserId,
};
#[derive(Default)]
pub struct UserEmailMutations {
_private: (),
}
#[derive(InputObject)]
struct AddEmailInput {
email: String,
user_id: ID,
skip_verification: Option<bool>,
skip_policy_check: Option<bool>,
}
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
pub enum AddEmailStatus {
Added,
Exists,
Invalid,
Denied,
}
#[derive(Description)]
enum AddEmailPayload {
Added(mas_data_model::UserEmail),
Exists(mas_data_model::UserEmail),
Invalid,
Denied {
violations: Vec<mas_policy::Violation>,
},
}
#[Object(use_type_description)]
impl AddEmailPayload {
async fn status(&self) -> AddEmailStatus {
match self {
AddEmailPayload::Added(_) => AddEmailStatus::Added,
AddEmailPayload::Exists(_) => AddEmailStatus::Exists,
AddEmailPayload::Invalid => AddEmailStatus::Invalid,
AddEmailPayload::Denied { .. } => AddEmailStatus::Denied,
}
}
async fn email(&self) -> Option<UserEmail> {
match self {
AddEmailPayload::Added(email) | AddEmailPayload::Exists(email) => {
Some(UserEmail(email.clone()))
}
AddEmailPayload::Invalid | AddEmailPayload::Denied { .. } => None,
}
}
async fn user(&self, ctx: &Context<'_>) -> Result<Option<User>, async_graphql::Error> {
let state = ctx.state();
let mut repo = state.repository().await?;
let user_id = match self {
AddEmailPayload::Added(email) | AddEmailPayload::Exists(email) => email.user_id,
AddEmailPayload::Invalid | AddEmailPayload::Denied { .. } => return Ok(None),
};
let user = repo
.user()
.lookup(user_id)
.await?
.context("User not found")?;
Ok(Some(User(user)))
}
async fn violations(&self) -> Option<Vec<String>> {
let AddEmailPayload::Denied { violations } = self else {
return None;
};
let messages = violations.iter().map(|v| v.msg.clone()).collect();
Some(messages)
}
}
#[derive(InputObject)]
struct SendVerificationEmailInput {
user_email_id: ID,
}
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
enum SendVerificationEmailStatus {
Sent,
AlreadyVerified,
}
#[derive(Description)]
enum SendVerificationEmailPayload {
Sent(mas_data_model::UserEmail),
AlreadyVerified(mas_data_model::UserEmail),
}
#[Object(use_type_description)]
impl SendVerificationEmailPayload {
async fn status(&self) -> SendVerificationEmailStatus {
match self {
SendVerificationEmailPayload::Sent(_) => SendVerificationEmailStatus::Sent,
SendVerificationEmailPayload::AlreadyVerified(_) => {
SendVerificationEmailStatus::AlreadyVerified
}
}
}
async fn email(&self) -> UserEmail {
match self {
SendVerificationEmailPayload::Sent(email)
| SendVerificationEmailPayload::AlreadyVerified(email) => UserEmail(email.clone()),
}
}
async fn user(&self, ctx: &Context<'_>) -> Result<User, async_graphql::Error> {
let state = ctx.state();
let mut repo = state.repository().await?;
let user_id = match self {
SendVerificationEmailPayload::Sent(email)
| SendVerificationEmailPayload::AlreadyVerified(email) => email.user_id,
};
let user = repo
.user()
.lookup(user_id)
.await?
.context("User not found")?;
Ok(User(user))
}
}
#[derive(InputObject)]
struct VerifyEmailInput {
user_email_id: ID,
code: String,
}
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
enum VerifyEmailStatus {
Verified,
AlreadyVerified,
InvalidCode,
}
#[derive(Description)]
enum VerifyEmailPayload {
Verified(mas_data_model::UserEmail),
AlreadyVerified(mas_data_model::UserEmail),
InvalidCode,
}
#[Object(use_type_description)]
impl VerifyEmailPayload {
async fn status(&self) -> VerifyEmailStatus {
match self {
VerifyEmailPayload::Verified(_) => VerifyEmailStatus::Verified,
VerifyEmailPayload::AlreadyVerified(_) => VerifyEmailStatus::AlreadyVerified,
VerifyEmailPayload::InvalidCode => VerifyEmailStatus::InvalidCode,
}
}
async fn email(&self) -> Option<UserEmail> {
match self {
VerifyEmailPayload::Verified(email) | VerifyEmailPayload::AlreadyVerified(email) => {
Some(UserEmail(email.clone()))
}
VerifyEmailPayload::InvalidCode => None,
}
}
async fn user(&self, ctx: &Context<'_>) -> Result<Option<User>, async_graphql::Error> {
let state = ctx.state();
let mut repo = state.repository().await?;
let user_id = match self {
VerifyEmailPayload::Verified(email) | VerifyEmailPayload::AlreadyVerified(email) => {
email.user_id
}
VerifyEmailPayload::InvalidCode => return Ok(None),
};
let user = repo
.user()
.lookup(user_id)
.await?
.context("User not found")?;
Ok(Some(User(user)))
}
}
#[derive(InputObject)]
struct RemoveEmailInput {
user_email_id: ID,
}
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
enum RemoveEmailStatus {
Removed,
Primary,
NotFound,
}
#[derive(Description)]
enum RemoveEmailPayload {
Removed(mas_data_model::UserEmail),
Primary(mas_data_model::UserEmail),
NotFound,
}
#[Object(use_type_description)]
impl RemoveEmailPayload {
async fn status(&self) -> RemoveEmailStatus {
match self {
RemoveEmailPayload::Removed(_) => RemoveEmailStatus::Removed,
RemoveEmailPayload::Primary(_) => RemoveEmailStatus::Primary,
RemoveEmailPayload::NotFound => RemoveEmailStatus::NotFound,
}
}
async fn email(&self) -> Option<UserEmail> {
match self {
RemoveEmailPayload::Removed(email) | RemoveEmailPayload::Primary(email) => {
Some(UserEmail(email.clone()))
}
RemoveEmailPayload::NotFound => None,
}
}
async fn user(&self, ctx: &Context<'_>) -> Result<Option<User>, async_graphql::Error> {
let state = ctx.state();
let mut repo = state.repository().await?;
let user_id = match self {
RemoveEmailPayload::Removed(email) | RemoveEmailPayload::Primary(email) => {
email.user_id
}
RemoveEmailPayload::NotFound => return Ok(None),
};
let user = repo
.user()
.lookup(user_id)
.await?
.context("User not found")?;
Ok(Some(User(user)))
}
}
#[derive(InputObject)]
struct SetPrimaryEmailInput {
user_email_id: ID,
}
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
enum SetPrimaryEmailStatus {
Set,
NotFound,
Unverified,
}
#[derive(Description)]
enum SetPrimaryEmailPayload {
Set(mas_data_model::User),
NotFound,
Unverified,
}
#[Object(use_type_description)]
impl SetPrimaryEmailPayload {
async fn status(&self) -> SetPrimaryEmailStatus {
match self {
SetPrimaryEmailPayload::Set(_) => SetPrimaryEmailStatus::Set,
SetPrimaryEmailPayload::NotFound => SetPrimaryEmailStatus::NotFound,
SetPrimaryEmailPayload::Unverified => SetPrimaryEmailStatus::Unverified,
}
}
async fn user(&self) -> Option<User> {
match self {
SetPrimaryEmailPayload::Set(user) => Some(User(user.clone())),
SetPrimaryEmailPayload::NotFound | SetPrimaryEmailPayload::Unverified => None,
}
}
}
#[Object]
impl UserEmailMutations {
async fn add_email(
&self,
ctx: &Context<'_>,
input: AddEmailInput,
) -> Result<AddEmailPayload, async_graphql::Error> {
let state = ctx.state();
let id = NodeType::User.extract_ulid(&input.user_id)?;
let requester = ctx.requester();
if !requester.is_owner_or_admin(&UserId(id)) {
return Err(async_graphql::Error::new("Unauthorized"));
}
if !requester.is_admin() && !state.site_config().email_change_allowed {
return Err(async_graphql::Error::new("Unauthorized"));
}
if (input.skip_verification.is_some() || input.skip_policy_check.is_some())
&& !requester.is_admin()
{
return Err(async_graphql::Error::new("Unauthorized"));
}
let skip_verification = input.skip_verification.unwrap_or(false);
let skip_policy_check = input.skip_policy_check.unwrap_or(false);
let mut repo = state.repository().await?;
let user = repo
.user()
.lookup(id)
.await?
.context("Failed to load user")?;
if input.email.parse::<lettre::Address>().is_err() {
return Ok(AddEmailPayload::Invalid);
}
if !skip_policy_check {
let mut policy = state.policy().await?;
let res = policy.evaluate_email(&input.email).await?;
if !res.valid() {
return Ok(AddEmailPayload::Denied {
violations: res.violations,
});
}
}
let existing_user_email = repo.user_email().find(&user, &input.email).await?;
let (added, mut user_email) = if let Some(user_email) = existing_user_email {
(false, user_email)
} else {
let clock = state.clock();
let mut rng = state.rng();
let user_email = repo
.user_email()
.add(&mut rng, &clock, &user, input.email)
.await?;
(true, user_email)
};
if user_email.confirmed_at.is_none() {
if skip_verification {
user_email = repo
.user_email()
.mark_as_verified(&state.clock(), user_email)
.await?;
} else {
repo.job()
.schedule_job(VerifyEmailJob::new(&user_email))
.await?;
}
}
repo.save().await?;
let payload = if added {
AddEmailPayload::Added(user_email)
} else {
AddEmailPayload::Exists(user_email)
};
Ok(payload)
}
async fn send_verification_email(
&self,
ctx: &Context<'_>,
input: SendVerificationEmailInput,
) -> Result<SendVerificationEmailPayload, async_graphql::Error> {
let state = ctx.state();
let user_email_id = NodeType::UserEmail.extract_ulid(&input.user_email_id)?;
let requester = ctx.requester();
let mut repo = state.repository().await?;
let user_email = repo
.user_email()
.lookup(user_email_id)
.await?
.context("User email not found")?;
if !requester.is_owner_or_admin(&user_email) {
return Err(async_graphql::Error::new("User email not found"));
}
let needs_verification = user_email.confirmed_at.is_none();
if needs_verification {
repo.job()
.schedule_job(VerifyEmailJob::new(&user_email))
.await?;
}
repo.save().await?;
let payload = if needs_verification {
SendVerificationEmailPayload::Sent(user_email)
} else {
SendVerificationEmailPayload::AlreadyVerified(user_email)
};
Ok(payload)
}
async fn verify_email(
&self,
ctx: &Context<'_>,
input: VerifyEmailInput,
) -> Result<VerifyEmailPayload, async_graphql::Error> {
let state = ctx.state();
let user_email_id = NodeType::UserEmail.extract_ulid(&input.user_email_id)?;
let requester = ctx.requester();
let clock = state.clock();
let mut repo = state.repository().await?;
let user_email = repo
.user_email()
.lookup(user_email_id)
.await?
.context("User email not found")?;
if !requester.is_owner_or_admin(&user_email) {
return Err(async_graphql::Error::new("User email not found"));
}
if user_email.confirmed_at.is_some() {
return Ok(VerifyEmailPayload::AlreadyVerified(user_email));
}
let verification = repo
.user_email()
.find_verification_code(&clock, &user_email, &input.code)
.await?
.filter(|v| v.is_valid());
let Some(verification) = verification else {
return Ok(VerifyEmailPayload::InvalidCode);
};
repo.user_email()
.consume_verification_code(&clock, verification)
.await?;
let user = repo
.user()
.lookup(user_email.user_id)
.await?
.context("Failed to load user")?;
if user.primary_user_email_id.is_none() {
repo.user_email().set_as_primary(&user_email).await?;
}
let user_email = repo
.user_email()
.mark_as_verified(&clock, user_email)
.await?;
repo.job()
.schedule_job(ProvisionUserJob::new(&user))
.await?;
repo.save().await?;
Ok(VerifyEmailPayload::Verified(user_email))
}
async fn remove_email(
&self,
ctx: &Context<'_>,
input: RemoveEmailInput,
) -> Result<RemoveEmailPayload, async_graphql::Error> {
let state = ctx.state();
let user_email_id = NodeType::UserEmail.extract_ulid(&input.user_email_id)?;
let requester = ctx.requester();
let mut repo = state.repository().await?;
let user_email = repo.user_email().lookup(user_email_id).await?;
let Some(user_email) = user_email else {
return Ok(RemoveEmailPayload::NotFound);
};
if !requester.is_owner_or_admin(&user_email) {
return Ok(RemoveEmailPayload::NotFound);
}
if !requester.is_admin() && !state.site_config().email_change_allowed {
return Err(async_graphql::Error::new("Unauthorized"));
}
let user = repo
.user()
.lookup(user_email.user_id)
.await?
.context("Failed to load user")?;
if user.primary_user_email_id == Some(user_email.id) {
return Ok(RemoveEmailPayload::Primary(user_email));
}
repo.user_email().remove(user_email.clone()).await?;
repo.job()
.schedule_job(ProvisionUserJob::new(&user))
.await?;
repo.save().await?;
Ok(RemoveEmailPayload::Removed(user_email))
}
async fn set_primary_email(
&self,
ctx: &Context<'_>,
input: SetPrimaryEmailInput,
) -> Result<SetPrimaryEmailPayload, async_graphql::Error> {
let state = ctx.state();
let user_email_id = NodeType::UserEmail.extract_ulid(&input.user_email_id)?;
let requester = ctx.requester();
let mut repo = state.repository().await?;
let user_email = repo.user_email().lookup(user_email_id).await?;
let Some(user_email) = user_email else {
return Ok(SetPrimaryEmailPayload::NotFound);
};
if !requester.is_owner_or_admin(&user_email) {
return Err(async_graphql::Error::new("Unauthorized"));
}
if !requester.is_admin() && !state.site_config().email_change_allowed {
return Err(async_graphql::Error::new("Unauthorized"));
}
if user_email.confirmed_at.is_none() {
return Ok(SetPrimaryEmailPayload::Unverified);
}
repo.user_email().set_as_primary(&user_email).await?;
let user = repo
.user()
.lookup(user_email.user_id)
.await?
.context("Failed to load user")?;
repo.save().await?;
Ok(SetPrimaryEmailPayload::Set(user))
}
}