Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
use crate::api::context::ApiContext;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use gmail_client::{Filter, GmailError};
use model::response::ErrorResponse;
use strum_macros::AsRefStr;
use thiserror::Error;
use utoipa::ToSchema;

#[derive(Debug, Error, AsRefStr)]
pub enum BlockSenderError {
#[error("Validation error: {0}")]
Validation(String),

#[error("Sender is already blocked")]
AlreadyBlocked,

#[error("Gmail API error: {0}")]
GmailError(String),

#[error("Internal error")]
InternalError(#[from] anyhow::Error),
}

impl IntoResponse for BlockSenderError {
fn into_response(self) -> Response {
let status_code = match &self {
BlockSenderError::Validation(_) => StatusCode::BAD_REQUEST,
BlockSenderError::AlreadyBlocked => StatusCode::CONFLICT,
BlockSenderError::GmailError(_) | BlockSenderError::InternalError(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
};

(
status_code,
Json(ErrorResponse {
message: self.to_string().as_str(),
}),
)
.into_response()
}
}

impl From<GmailError> for BlockSenderError {
fn from(e: GmailError) -> Self {
match e {
GmailError::Conflict(_) => BlockSenderError::AlreadyBlocked,
_ => BlockSenderError::GmailError(e.to_string()),
}
}
}

/// Request to block a sender.
#[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)]
pub struct BlockSenderRequest {
/// The email address of the sender to block.
pub email_address: String,
}

/// Response after blocking a sender.
#[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)]
pub struct BlockSenderResponse {
/// The ID of the filter created in Gmail.
pub filter_id: String,
}

/// Block a sender by creating a Gmail filter that sends their emails to trash.
#[utoipa::path(
post,
tag = "Contacts",
path = "/email/contacts/block",
operation_id = "block_sender",
request_body = BlockSenderRequest,
responses(
(status = 201, body = BlockSenderResponse),
(status = 400, body = ErrorResponse),
(status = 401, body = ErrorResponse),
(status = 409, body = ErrorResponse),
(status = 500, body = ErrorResponse),
)
)]
#[tracing::instrument(skip(ctx, gmail_token), err)]
pub async fn handler(
State(ctx): State<ApiContext>,
gmail_token: Extension<String>,
Json(req): Json<BlockSenderRequest>,
) -> Result<(StatusCode, Json<BlockSenderResponse>), BlockSenderError> {
validate_email(&req.email_address)?;

// Check if sender is already blocked
let existing_filter = ctx
.gmail_client
.find_block_filter_for_sender(&gmail_token, &req.email_address)
.await?;

if existing_filter.is_some() {
return Err(BlockSenderError::AlreadyBlocked);
}

// Create the block filter
let filter: Filter = ctx
.gmail_client
.block_sender(&gmail_token, &req.email_address)
.await?;

let filter_id = filter.id.unwrap_or_default();

Ok((StatusCode::CREATED, Json(BlockSenderResponse { filter_id })))
}

fn validate_email(email: &str) -> Result<(), BlockSenderError> {
if email.trim().is_empty() {
return Err(BlockSenderError::Validation(
"Email address cannot be empty".to_string(),
));
}
if !email.contains('@') {
return Err(BlockSenderError::Validation(
"Invalid email address format".to_string(),
));
}
if email.len() > 254 {
return Err(BlockSenderError::Validation(
"Email address is too long".to_string(),
));
}
Ok(())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
use crate::api::context::ApiContext;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use gmail_client::GmailError;
use model::response::ErrorResponse;
use strum_macros::AsRefStr;
use thiserror::Error;
use utoipa::ToSchema;

#[derive(Debug, Error, AsRefStr)]
pub enum ListBlockedError {
#[error("Gmail API error: {0}")]
GmailError(String),

#[error("Internal error")]
InternalError(#[from] anyhow::Error),
}

impl IntoResponse for ListBlockedError {
fn into_response(self) -> Response {
let status_code = match &self {
ListBlockedError::GmailError(_) | ListBlockedError::InternalError(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
};

(
status_code,
Json(ErrorResponse {
message: self.to_string().as_str(),
}),
)
.into_response()
}
}

impl From<GmailError> for ListBlockedError {
fn from(e: GmailError) -> Self {
ListBlockedError::GmailError(e.to_string())
}
}

/// Response containing list of blocked email addresses.
#[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)]
pub struct ListBlockedResponse {
/// List of email addresses that are currently blocked.
pub blocked_emails: Vec<String>,
}

/// List all blocked senders for the authenticated user.
#[utoipa::path(
get,
tag = "Contacts",
path = "/email/contacts/blocked",
operation_id = "list_blocked_senders",
responses(
(status = 200, body = ListBlockedResponse),
(status = 401, body = ErrorResponse),
(status = 500, body = ErrorResponse),
)
)]
#[tracing::instrument(skip(ctx, gmail_token), err)]
pub async fn handler(
State(ctx): State<ApiContext>,
gmail_token: Extension<String>,
) -> Result<Json<ListBlockedResponse>, ListBlockedError> {
let blocked_emails = ctx.gmail_client.list_blocked_senders(&gmail_token).await?;

Ok(Json(ListBlockedResponse { blocked_emails }))
}
38 changes: 35 additions & 3 deletions rust/cloud-storage/email_service/src/api/email/contacts/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,41 @@
use crate::api::ApiContext;
use axum::Router;
use axum::routing::get;
use axum::routing::{delete, get, post};
use tower::ServiceBuilder;

pub(crate) mod block_sender;
pub(crate) mod list;
pub(crate) mod list_blocked;
pub(crate) mod unblock_sender;

pub fn router() -> Router<ApiContext> {
Router::new().route("/", get(list::list_contacts_handler))
pub fn router(state: ApiContext) -> Router<ApiContext> {
Router::new()
.route("/", get(list::list_contacts_handler))
.route(
"/block",
post(block_sender::handler).layer(ServiceBuilder::new().layer(
axum::middleware::from_fn_with_state(
state.clone(),
crate::api::middleware::gmail_token::attach_gmail_token,
),
)),
)
.route(
"/block/:email_address",
delete(unblock_sender::handler).layer(ServiceBuilder::new().layer(
axum::middleware::from_fn_with_state(
state.clone(),
crate::api::middleware::gmail_token::attach_gmail_token,
),
)),
)
.route(
"/blocked",
get(list_blocked::handler).layer(ServiceBuilder::new().layer(
axum::middleware::from_fn_with_state(
state,
crate::api::middleware::gmail_token::attach_gmail_token,
),
)),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
use crate::api::context::ApiContext;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use gmail_client::GmailError;
use model::response::ErrorResponse;
use strum_macros::AsRefStr;
use thiserror::Error;
use utoipa::IntoParams;

#[derive(Debug, Error, AsRefStr)]
pub enum UnblockSenderError {
#[error("Validation error: {0}")]
Validation(String),

#[error("Sender is not blocked")]
NotBlocked,

#[error("Gmail API error: {0}")]
GmailError(String),

#[error("Internal error")]
InternalError(#[from] anyhow::Error),
}

impl IntoResponse for UnblockSenderError {
fn into_response(self) -> Response {
let status_code = match &self {
UnblockSenderError::Validation(_) => StatusCode::BAD_REQUEST,
UnblockSenderError::NotBlocked => StatusCode::NOT_FOUND,
UnblockSenderError::GmailError(_) | UnblockSenderError::InternalError(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
};

(
status_code,
Json(ErrorResponse {
message: self.to_string().as_str(),
}),
)
.into_response()
}
}

impl From<GmailError> for UnblockSenderError {
fn from(e: GmailError) -> Self {
match e {
GmailError::NotFound(_) => UnblockSenderError::NotBlocked,
_ => UnblockSenderError::GmailError(e.to_string()),
}
}
}

#[derive(serde::Serialize, serde::Deserialize, Debug, IntoParams)]
pub struct PathParams {
/// The email address of the sender to unblock.
pub email_address: String,
}

/// Unblock a sender by removing their block filter from Gmail.
#[utoipa::path(
delete,
tag = "Contacts",
path = "/email/contacts/block/{email_address}",
operation_id = "unblock_sender",
params(PathParams),
responses(
(status = 204),
(status = 400, body = ErrorResponse),
(status = 401, body = ErrorResponse),
(status = 404, body = ErrorResponse),
(status = 500, body = ErrorResponse),
)
)]
#[tracing::instrument(skip(ctx, gmail_token), err)]
pub async fn handler(
State(ctx): State<ApiContext>,
gmail_token: Extension<String>,
Path(PathParams { email_address }): Path<PathParams>,
) -> Result<impl IntoResponse, UnblockSenderError> {
validate_email(&email_address)?;

// Check if sender is currently blocked
let existing_filter = ctx
.gmail_client
.find_block_filter_for_sender(&gmail_token, &email_address)
.await?;

if existing_filter.is_none() {
return Err(UnblockSenderError::NotBlocked);
}

// Unblock the sender
let was_unblocked = ctx
.gmail_client
.unblock_sender(&gmail_token, &email_address)
.await?;

if !was_unblocked {
return Err(UnblockSenderError::NotBlocked);
}

Ok(StatusCode::NO_CONTENT)
}

fn validate_email(email: &str) -> Result<(), UnblockSenderError> {
if email.trim().is_empty() {
return Err(UnblockSenderError::Validation(
"Email address cannot be empty".to_string(),
));
}
if !email.contains('@') {
return Err(UnblockSenderError::Validation(
"Invalid email address format".to_string(),
));
}
if email.len() > 254 {
return Err(UnblockSenderError::Validation(
"Email address is too long".to_string(),
));
}
Ok(())
}
2 changes: 1 addition & 1 deletion rust/cloud-storage/email_service/src/api/email/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ pub fn router(state: ApiContext) -> Router<ApiContext> {
.nest("/drafts", drafts::router(state.clone()))
.nest("/messages", messages::router(state.clone()))
.nest("/links", links::router())
.nest("/contacts", contacts::router())
.nest("/contacts", contacts::router(state.clone()))
.nest("/backfill", backfill::router(state.clone()))
.nest("/settings", settings::router(state.clone()))
.nest("/sync", sync::router(state.clone()))
Expand Down
Loading
Loading