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:
Edouard Vanbelle
2026-06-02 10:47:37 +02:00
parent d03b9474c6
commit 03f63ad103
18 changed files with 1047 additions and 28 deletions
@@ -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
+49 -2
View File
@@ -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()
}