feat(external users): email sanity + mock SMTP
- SMTP has a mock to enable end to end test and validate the whole path
(via OXICLOUD_SMTP_MOCK)
- add email normalisation ( including punicode)
- api to share to external user
This commit is contained in:
@@ -61,6 +61,10 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
// SMTP diagnostics
|
||||
.route("/smtp/info", get(get_smtp_info))
|
||||
.route("/smtp/test", post(send_smtp_test))
|
||||
// Test-only capture endpoint. The handler short-circuits to 404
|
||||
// when `OXICLOUD_SMTP_MOCK` is off, so production deployments
|
||||
// can route the path freely without leaking inboxes.
|
||||
.route("/smtp/test/captured", get(get_captured_email))
|
||||
}
|
||||
|
||||
/// Validate JWT and require admin role. Returns (user_id, role).
|
||||
@@ -1264,6 +1268,52 @@ async fn get_smtp_info(
|
||||
Ok(Json(info))
|
||||
}
|
||||
|
||||
/// GET /api/admin/smtp/test/captured?to=<email> — test-only inbox lookup.
|
||||
///
|
||||
/// Returns the most recently captured outbound message for `to` when
|
||||
/// `OXICLOUD_SMTP_MOCK=true`. In production / non-mock mode this
|
||||
/// returns 404 to keep the endpoint inert.
|
||||
async fn get_captured_email(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<CapturedEmailQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
if !std::env::var("OXICLOUD_SMTP_MOCK")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(AppError::not_found(
|
||||
"Capture endpoint is only available when OXICLOUD_SMTP_MOCK=true",
|
||||
));
|
||||
}
|
||||
|
||||
let recipient = params.to.trim();
|
||||
if recipient.is_empty() {
|
||||
return Err(AppError::bad_request("`to` query parameter is required"));
|
||||
}
|
||||
|
||||
let Some(mock) = state.mock_email_sender.as_ref() else {
|
||||
return Err(AppError::not_found(
|
||||
"Mock sender is not active (set OXICLOUD_SMTP_MOCK=true)",
|
||||
));
|
||||
};
|
||||
|
||||
match mock.last_for(recipient).await {
|
||||
Some(captured) => Ok(Json((*captured).clone())),
|
||||
None => Err(AppError::not_found(format!(
|
||||
"No captured message for '{}'",
|
||||
recipient
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct CapturedEmailQuery {
|
||||
to: String,
|
||||
}
|
||||
|
||||
/// POST /api/admin/smtp/test — send a diagnostic email to `dto.to`.
|
||||
///
|
||||
/// Returns 200 regardless of SMTP outcome; the body's `success` flag
|
||||
|
||||
@@ -22,7 +22,8 @@ use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::grant_dto::{
|
||||
CreateGrantDto, GrantDto, MySharesDto, OutgoingResourceGrantDto, OutgoingResourceItemDto,
|
||||
PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, SharedWithMeDto,
|
||||
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, role_from_permissions,
|
||||
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, SubjectInputDto, UpdateRoleDto,
|
||||
role_from_permissions,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
@@ -86,7 +87,6 @@ pub async fn create_grant(
|
||||
}
|
||||
};
|
||||
|
||||
let subject: Subject = dto.subject.into();
|
||||
let resource: Resource = dto.resource.into();
|
||||
let expires_at = dto.expires_at;
|
||||
|
||||
@@ -98,6 +98,31 @@ pub async fn create_grant(
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
|
||||
// Resolve the subject. For the email variant this lazily provisions
|
||||
// an external user (or reuses an existing match) and remembers the
|
||||
// resolved User so the invitation email can be sent after the grant
|
||||
// rows land.
|
||||
let (subject, invite_recipient) = match dto.subject {
|
||||
SubjectInputDto::User { id } => (Subject::User(id), None),
|
||||
SubjectInputDto::Group { id } => (Subject::Group(id), None),
|
||||
SubjectInputDto::Token { id } => (Subject::Token(id), None),
|
||||
SubjectInputDto::Email { email } => {
|
||||
let Some(invite_svc) = state.magic_link_invite_service.as_ref() else {
|
||||
return AppError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Magic-link invitations are not configured on this server \
|
||||
(set OXICLOUD_SMTP_HOST in .env to enable)",
|
||||
"ServiceUnavailable",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
match invite_svc.resolve_or_create_recipient(&email).await {
|
||||
Ok(user) => (Subject::User(user.id()), Some(user)),
|
||||
Err(e) => return AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
|
||||
for perm in permissions {
|
||||
match authz
|
||||
@@ -118,6 +143,28 @@ pub async fn create_grant(
|
||||
resource,
|
||||
caller_id
|
||||
);
|
||||
|
||||
// Fire the invitation email AFTER the grant rows are in place so a
|
||||
// failed SMTP send can't leave the recipient with mail-but-no-access.
|
||||
// The service swallows SMTP errors (logs only) — the API response
|
||||
// stays 201 Created either way, matching the plan's "201 always
|
||||
// when grants land; mail is best-effort" contract.
|
||||
if let Some(recipient) = invite_recipient
|
||||
&& let Some(invite_svc) = state.magic_link_invite_service.as_ref()
|
||||
{
|
||||
let inviter_name = auth_user.username.clone();
|
||||
if let Err(e) = invite_svc
|
||||
.issue_invitation(&recipient, &inviter_name, resource)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"invitation issuance failed for {} (grants already created): {}",
|
||||
recipient.email(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(StatusCode::CREATED, Json(results)).into_response()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user