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
Generated
+1
View File
@@ -3729,6 +3729,7 @@ dependencies = [
"http-body-util",
"http-range-header",
"id3",
"idna",
"image",
"infer 0.19.0",
"jsonwebtoken",
+1
View File
@@ -70,6 +70,7 @@ lru = "0.16.4"
fastcdc = "4.0.0"
memmap2 = "0.9.10"
lettre = { version = "0.11.18", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "rustls-native-certs", "builder"] }
idna = "1.1"
[features]
default = []
+2
View File
@@ -185,6 +185,8 @@ Used by the magic-link invitation flow and the login-via-email flow. When `OXICL
| `OXICLOUD_SMTP_FROM` | — | `From:` mailbox; bare address or RFC 5322 name-address (`OxiCloud <noreply@example.com>`) |
| `OXICLOUD_SMTP_TLS` | `starttls` | Transport encryption: `starttls`, `tls`, or `none` (emits startup WARN) |
There is also `OXICLOUD_SMTP_MOCK` (false by default), this is for test purpose only, do not activate it
### Reliability and retries
OxiCloud does **not** spool mail. Each `send()` is a single attempt: if the remote SMTP server is unreachable, slow, or temporarily refusing the message, the send fails and the error is logged — there is no in-process retry, queue, or dead-letter handling. This keeps the HTTP path fast and the binary small at the cost of durability guarantees during a relay outage.
@@ -0,0 +1,35 @@
-- ════════════════════════════════════════════════════════════════════════════
-- Drop `external` from `storage.access_grants.subject_type` CHECK
-- ════════════════════════════════════════════════════════════════════════════
-- The federated-identity case is now folded into `Subject::User(uuid)` with
-- `auth.users.is_external = TRUE` (PR 2 of the external-users work). Nothing
-- in code ever minted a grant row with subject_type='external', so there
-- are no live rows to migrate — this migration just tightens the CHECK
-- so a stale piece of code can't accidentally start producing them.
--
-- The original CHECK in `20260520000000_rebac_access_grants.sql` was an
-- inline anonymous constraint, so we discover its auto-generated name via
-- pg_constraint before dropping it.
DO $BODY$
DECLARE
cname TEXT;
BEGIN
SELECT c.conname INTO cname
FROM pg_constraint c
JOIN pg_namespace n ON n.oid = c.connamespace
JOIN pg_class t ON t.oid = c.conrelid
WHERE n.nspname = 'storage'
AND t.relname = 'access_grants'
AND c.contype = 'c'
AND pg_get_constraintdef(c.oid) ILIKE '%subject_type%external%';
IF cname IS NOT NULL THEN
EXECUTE 'ALTER TABLE storage.access_grants DROP CONSTRAINT '
|| quote_ident(cname);
END IF;
END $BODY$;
ALTER TABLE storage.access_grants
ADD CONSTRAINT access_grants_subject_type_check
CHECK (subject_type IN ('user', 'group', 'token'));
+29 -4
View File
@@ -23,7 +23,6 @@ pub enum SubjectTypeDto {
User,
Group,
Token,
External,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -39,7 +38,6 @@ impl From<SubjectDto> for Subject {
SubjectTypeDto::User => Subject::User(dto.id),
SubjectTypeDto::Group => Subject::Group(dto.id),
SubjectTypeDto::Token => Subject::Token(dto.id),
SubjectTypeDto::External => Subject::External(dto.id),
}
}
}
@@ -50,7 +48,6 @@ impl From<Subject> for SubjectDto {
Subject::User(id) => (SubjectTypeDto::User, id),
Subject::Group(id) => (SubjectTypeDto::Group, id),
Subject::Token(id) => (SubjectTypeDto::Token, id),
Subject::External(id) => (SubjectTypeDto::External, id),
};
SubjectDto { kind, id }
}
@@ -181,11 +178,39 @@ impl Role {
// Request DTOs
// ════════════════════════════════════════════════════════════════════════════
/// Subject shape accepted by `POST /api/grants`. Internally-tagged enum
/// so the existing `{type:"user", id:"..."}` payload keeps working
/// alongside the new `{type:"email", email:"..."}` variant that feeds
/// the invite-by-email flow. The response-side [`SubjectDto`] stays
/// unchanged — externals resolve to `Subject::User(uuid)` with
/// `is_external = TRUE` on the user row, never a distinct subject type.
#[derive(Debug, Clone, Deserialize, ToSchema)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum SubjectInputDto {
User {
id: Uuid,
},
Group {
id: Uuid,
},
Token {
id: Uuid,
},
/// Invite-by-email. Lazily provisions an external user with the
/// normalised address as both username and email when no match
/// exists; otherwise reuses the existing user. Triggers a magic-link
/// invitation email when the resolved user has no other login
/// credential.
Email {
email: String,
},
}
/// `POST /api/grants` — accepts either `permissions` (explicit) or `role`.
/// Server-side validation requires exactly one of the two to be present.
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateGrantDto {
pub subject: SubjectDto,
pub subject: SubjectInputDto,
pub resource: ResourceDto,
#[serde(default)]
pub permissions: Option<Vec<PermissionDto>>,
@@ -0,0 +1,264 @@
//! Invite-by-email orchestration for `POST /api/grants` with
//! `subject.type = "email"`.
//!
//! Two-step API (kept separate so the handler can interleave the standard
//! grant-creation step in between):
//!
//! 1. [`resolve_or_create_recipient`] — normalise the email, apply the
//! allowlist + kill-switch checks, then look up or lazily provision
//! an external user. Returns the resolved [`User`] entity.
//! 2. [`issue_invitation`] — mint a magic-link token targeting the
//! shared resource, build the `/magic/v1/{token}` URL, and send the
//! invitation email through the wired `EmailSender`.
//!
//! Step 2 is only called when the resolved user has no other login
//! credential (`!user.has_login_credential()`) — internal users with
//! passwords / OIDC see the grant appear in their normal
//! "Shared with me" view and do not get a clickable magic link.
//!
//! # Enumeration defense
//!
//! v1 awaits the SMTP send synchronously. A malicious caller can in
//! theory measure response times to distinguish "new external user
//! provisioned + mail sent" from "existing internal user, no mail" —
//! a single-bit oracle. The plan defers full constant-time defense
//! (fire-and-forget spawn, dummy SMTP latency on no-op paths) to PR 12.
use std::sync::Arc;
use chrono::Utc;
use crate::application::ports::email_sender::{EmailMessage, EmailSender};
use crate::application::services::user_lifecycle_service::UserLifecycleService;
use crate::common::config::MagicLinkConfig;
use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkToken};
use crate::domain::entities::user::User;
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
use crate::domain::repositories::user_repository::{UserRepository, UserRepositoryError};
use crate::domain::services::authorization::{Resource, ResourceKind};
use crate::domain::services::email_normalize::normalize_email;
use crate::infrastructure::repositories::pg::UserPgRepository;
pub struct MagicLinkInviteService {
user_storage: Arc<UserPgRepository>,
magic_link_repo: Arc<dyn MagicLinkTokenRepository>,
email_sender: Arc<dyn EmailSender>,
user_lifecycle: Arc<UserLifecycleService>,
magic_link_cfg: MagicLinkConfig,
/// Public base URL of this OxiCloud instance — used to build the
/// `/magic/v1/{token}` invitation link. Sourced from
/// `AppConfig::base_url()` at DI time.
public_base_url: String,
}
impl MagicLinkInviteService {
pub fn new(
user_storage: Arc<UserPgRepository>,
magic_link_repo: Arc<dyn MagicLinkTokenRepository>,
email_sender: Arc<dyn EmailSender>,
user_lifecycle: Arc<UserLifecycleService>,
magic_link_cfg: MagicLinkConfig,
public_base_url: String,
) -> Self {
Self {
user_storage,
magic_link_repo,
email_sender,
user_lifecycle,
magic_link_cfg,
public_base_url,
}
}
/// Resolve the email to an existing user, or lazily provision a new
/// external user. Returns the resolved [`User`].
///
/// Errors:
/// - `InvalidInput` — email failed normalisation (malformed / too long).
/// - `AccessDenied` — email-grant kill switch is off
/// (`OXICLOUD_ALLOW_EXTERNAL_USERS=false`) and no matching user
/// exists, OR the email's domain isn't in the allowlist.
/// - any propagated repo error.
pub async fn resolve_or_create_recipient(&self, raw_email: &str) -> Result<User, DomainError> {
let normalised = normalize_email(raw_email).map_err(|e| {
DomainError::new(ErrorKind::InvalidInput, "MagicLinkInvite", format!("{}", e))
})?;
// Fast path: existing user with this email — works for both
// internal (was previously created via normal registration) and
// external (previous invitation re-sharing) cases.
match UserRepository::get_user_by_email(&*self.user_storage, &normalised).await {
Ok(user) => Ok(user),
Err(UserRepositoryError::NotFound(_)) => self.create_external_user(&normalised).await,
Err(e) => Err(DomainError::from(e)),
}
}
/// Lazy provisioning path. Runs the two policy guards (kill switch
/// and per-domain allowlist) before touching the DB.
async fn create_external_user(&self, normalised_email: &str) -> Result<User, DomainError> {
if !self.magic_link_cfg.allow_external_users {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"MagicLinkInvite",
"Creating external users is disabled on this server \
(OXICLOUD_ALLOW_EXTERNAL_USERS=false)"
.to_string(),
));
}
if !self.magic_link_cfg.is_email_allowed(normalised_email) {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"MagicLinkInvite",
format!(
"Email domain is not in the allowlist (OXICLOUD_EXTERNAL_EMAIL_DOMAINS); \
refusing to invite {}",
normalised_email,
),
));
}
// username == normalised email for external users. The user
// entity's `validate_username` was widened to 254 chars + email
// shape in PR 6 specifically to allow this.
let user = User::new_external(normalised_email.to_string(), normalised_email.to_string())
.map_err(|e| {
DomainError::new(
ErrorKind::InvalidInput,
"MagicLinkInvite",
format!("invalid external user data: {}", e),
)
})?;
let saved = UserRepository::create_user(&*self.user_storage, user.clone())
.await
.map_err(DomainError::from)?;
// Fire the user-lifecycle dispatcher — `on_user_created` lights
// up audit + future external-identity provenance bookkeeping.
// Errors are logged-and-continued by the dispatcher's
// `dispatch_created` per the lifecycle contract.
self.user_lifecycle.dispatch_created(&saved).await;
Ok(saved)
}
/// Mint a magic-link token targeting the resource and email the
/// invitation link. Caller is expected to have already created the
/// grant rows.
///
/// `inviter_username` is interpolated into the subject line as a
/// trust signal ("Alice shared with you on OxiCloud"). The message
/// body is plain text only in v1; HTML templating is out of scope
/// (see plan "Out of scope" → "Email template engine").
pub async fn issue_invitation(
&self,
recipient: &User,
inviter_username: &str,
resource: Resource,
) -> Result<(), DomainError> {
// The grant is in place either way; only mint a magic link when
// the recipient has no other way to authenticate (the auto-auth
// mailbox-as-2FA-bypass is only acceptable when the recipient
// has nothing else). Internal users with passwords / OIDC
// simply see the grant in their normal "Shared with me" view.
if recipient.has_login_credential() {
return Ok(());
}
let (kind, resource_id) = match resource {
Resource::Folder(id) => (MagicLinkResourceKind::Folder, id),
Resource::File(id) => (MagicLinkResourceKind::File, id),
};
let token = MagicLinkToken::new(
recipient.id(),
self.magic_link_cfg.ttl_hours,
Some((kind, resource_id)),
);
self.magic_link_repo.create(&token).await?;
let link = format!(
"{}/magic/v1/{}",
self.public_base_url.trim_end_matches('/'),
token.token(),
);
let kind_label = match resource {
Resource::Folder(_) => "folder",
Resource::File(_) => "file",
};
let subject = format!(
"{} shared a {} with you on OxiCloud",
inviter_username, kind_label
);
let text_body = format!(
"{inviter} shared a {kind} with you on OxiCloud.\n\
\n\
Open it by clicking the link below:\n\
{link}\n\
\n\
The link works once and expires in {ttl} hours.\n\
If you didn't expect this invitation, you can safely ignore this message.\n\
\n\
— OxiCloud, {now}\n",
inviter = inviter_username,
kind = kind_label,
link = link,
ttl = self.magic_link_cfg.ttl_hours,
now = Utc::now().to_rfc3339(),
);
let message = EmailMessage {
to: recipient.email().to_string(),
subject,
text_body,
html_body: None,
};
// Synchronous send — see module docs for the enumeration-defense
// trade-off. PR 12 promotes this to fire-and-forget when the
// hardening pass lands.
match self.email_sender.send(message).await {
Ok(outcome) => {
tracing::info!(
target: "audit",
event = "magic_link.invitation_sent",
recipient_user_id = %recipient.id(),
recipient_email = %recipient.email(),
resource = ?resource,
smtp_code = outcome.code,
smtp_message = %outcome.message,
);
Ok(())
}
Err(e) => {
// The grant already exists — log the SMTP failure but
// don't propagate it as a fatal error, so the API client
// still gets `201 Created` with the GrantDto. Recipient
// can re-trigger via the future `POST /api/auth/magic-link/send`
// endpoint once login-via-email lands.
tracing::warn!(
target: "audit",
event = "magic_link.invitation_send_failed",
recipient_user_id = %recipient.id(),
recipient_email = %recipient.email(),
error = %e.message,
);
Ok(())
}
}
}
}
/// Lightweight conversion so the grant handler can derive a
/// [`MagicLinkResourceKind`] from the already-parsed [`ResourceKind`]
/// without re-importing match arms.
impl From<ResourceKind> for MagicLinkResourceKind {
fn from(kind: ResourceKind) -> Self {
match kind {
ResourceKind::Folder => Self::Folder,
ResourceKind::File => Self::File,
}
}
}
+1
View File
@@ -15,6 +15,7 @@ pub mod file_upload_service;
pub mod file_use_case_factory;
pub mod folder_service;
pub mod i18n_application_service;
pub mod magic_link_invite_service;
pub mod music_service;
pub mod nextcloud_file_id_service;
pub mod nextcloud_login_flow_service;
+102 -7
View File
@@ -678,6 +678,15 @@ impl AppServiceFactory {
let storage_usage_service: Option<Arc<StorageUsageService>>;
let mut auth_services: Option<crate::common::di::AuthServices> = None;
let mut nextcloud_services: Option<NextcloudServices> = None;
// Lifted out of the database-services block so PR 9's invite
// orchestrator (built at AppState-assembly time below) can share
// the same lifecycle dispatcher. The inner block at line ~682
// is unconditional and always assigns; the `#[allow]` silences
// the rustc warning that the `None` initialiser is never read.
#[allow(unused_assignments)]
let mut user_lifecycle_handle: Option<
Arc<crate::application::services::user_lifecycle_service::UserLifecycleService>,
> = None;
{
let favs = self.create_favorites_service(&pool);
@@ -792,6 +801,8 @@ impl AppServiceFactory {
tracing::info!("Authentication services initialized successfully");
auth_services = Some(services);
}
user_lifecycle_handle = Some(user_lifecycle);
}
// Shared App Password service — created once, used by both NC routes and native API
@@ -911,8 +922,41 @@ impl AppServiceFactory {
),
),
)),
email_sender: build_email_sender(&self.config.smtp),
email_sender: None, // populated below
mock_email_sender: None, // populated below
magic_link_invite_service: None, // populated below
};
let email_bundle = build_email_sender(&self.config.smtp);
app_state.email_sender = email_bundle.sender;
app_state.mock_email_sender = email_bundle.mock;
// Magic-link invite orchestrator: only when SMTP wired AND the
// user-lifecycle dispatcher exists (i.e. auth is enabled).
if let (Some(email_sender), Some(lifecycle)) = (
app_state.email_sender.clone(),
user_lifecycle_handle.clone(),
) {
let invite_user_storage = Arc::new(
crate::infrastructure::repositories::pg::UserPgRepository::new(pool.clone()),
);
let invite_magic_link_repo: Arc<
dyn crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository,
> = Arc::new(
crate::infrastructure::repositories::pg::MagicLinkTokenPgRepository::new(
pool.clone(),
),
);
app_state.magic_link_invite_service = Some(Arc::new(
crate::application::services::magic_link_invite_service::MagicLinkInviteService::new(
invite_user_storage,
invite_magic_link_repo,
email_sender,
lifecycle,
self.config.magic_link.clone(),
self.config.base_url(),
),
));
}
// 9b. Wire admin settings service when auth is available
if let Some(auth_svc) = &app_state.auth_service {
@@ -1246,6 +1290,18 @@ pub struct AppState {
/// must return 503 when this is `None` rather than silently dropping
/// the message.
pub email_sender: Option<Arc<dyn crate::application::ports::email_sender::EmailSender>>,
/// Set alongside `email_sender` when the test harness flag
/// `OXICLOUD_SMTP_MOCK=true` is on. Used by the
/// `GET /api/admin/smtp/test/captured` test-only endpoint to look up
/// recently captured messages. Always `None` in production.
pub mock_email_sender:
Option<Arc<crate::infrastructure::services::mock_email_sender::MockEmailSender>>,
/// Invite-by-email orchestrator — `None` when SMTP isn't configured
/// (no `email_sender`). `POST /api/grants` with `subject.type=email`
/// returns 503 when this is `None`.
pub magic_link_invite_service: Option<
Arc<crate::application::services::magic_link_invite_service::MagicLinkInviteService>,
>,
}
// All AppState construction is done via struct literal in build_app_state().
@@ -1276,19 +1332,52 @@ fn build_authorization_engine(
Arc::new(PgAclEngine::new(pool, folder_repo, file_repo, group_repo))
}
/// Pair returned by [`build_email_sender`] when wiring DI: the
/// `EmailSender` trait object used by the rest of the application, plus
/// (in mock mode only) a typed handle to the same `MockEmailSender` so
/// the test-only capture endpoint can introspect it without downcasting.
struct EmailSenderBundle {
sender: Option<Arc<dyn crate::application::ports::email_sender::EmailSender>>,
mock: Option<Arc<crate::infrastructure::services::mock_email_sender::MockEmailSender>>,
}
/// Construct the SMTP email sender from config, or return `None` when
/// SMTP is disabled (`OXICLOUD_SMTP_HOST` empty). Construction errors
/// (unparseable `From:` mailbox, bad TLS settings) downgrade to `None`
/// with a `WARN` log — the server still starts, but every magic-link
/// endpoint will return 503 until the operator fixes the config.
fn build_email_sender(
cfg: &crate::common::config::SmtpConfig,
) -> Option<Arc<dyn crate::application::ports::email_sender::EmailSender>> {
///
/// When `OXICLOUD_SMTP_MOCK=true` (test harness only — never in
/// production), construction returns an in-process `MockEmailSender`
/// that captures every message instead of sending it. The harness
/// retrieves captured messages via `GET /api/admin/smtp/test/captured`.
fn build_email_sender(cfg: &crate::common::config::SmtpConfig) -> EmailSenderBundle {
if std::env::var("OXICLOUD_SMTP_MOCK")
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
{
tracing::warn!(
target: "oxicloud",
event = "smtp.mock_enabled",
"OXICLOUD_SMTP_MOCK=true — outbound mail is being captured in-process. \
Test harness only; never set this in production.",
);
let mock =
Arc::new(crate::infrastructure::services::mock_email_sender::MockEmailSender::new());
return EmailSenderBundle {
sender: Some(mock.clone()),
mock: Some(mock),
};
}
if !cfg.is_enabled() {
tracing::info!(
"SMTP disabled (OXICLOUD_SMTP_HOST empty); magic-link endpoints will return 503"
);
return None;
return EmailSenderBundle {
sender: None,
mock: None,
};
}
match crate::infrastructure::services::smtp_email_sender::SmtpEmailSender::new(cfg) {
Ok(sender) => {
@@ -1302,7 +1391,10 @@ fn build_email_sender(
user = if cfg.user.is_empty() { "<anon>" } else { "<set>" },
"SMTP sender configured",
);
Some(Arc::new(sender))
EmailSenderBundle {
sender: Some(Arc::new(sender)),
mock: None,
}
}
Err(e) => {
tracing::warn!(
@@ -1311,7 +1403,10 @@ fn build_email_sender(
error = %e,
"SMTP configuration is invalid; magic-link endpoints will return 503",
);
None
EmailSenderBundle {
sender: None,
mock: None,
}
}
}
}
+9 -14
View File
@@ -25,9 +25,6 @@ pub enum Subject {
Group(Uuid),
/// An anonymous share token (`storage.shares.id`).
Token(Uuid),
/// A federated identity from another server — Open Cloud Mesh, external
/// OIDC, etc. Refers to `auth.external_subjects.id` (future table).
External(Uuid),
}
impl Subject {
@@ -37,26 +34,27 @@ impl Subject {
Subject::User(_) => "user",
Subject::Group(_) => "group",
Subject::Token(_) => "token",
Subject::External(_) => "external",
}
}
/// The raw UUID regardless of variant.
pub fn id(&self) -> Uuid {
match self {
Subject::User(id) | Subject::Group(id) | Subject::Token(id) | Subject::External(id) => {
*id
}
Subject::User(id) | Subject::Group(id) | Subject::Token(id) => *id,
}
}
/// Reconstruct from a SQL row's `(subject_type, subject_id)` pair.
///
/// `"external"` is no longer accepted: PR-2 of the external-users
/// work folded the federated-identity case into `Subject::User(uuid)`
/// with `auth.users.is_external = TRUE`. The DB CHECK constraint
/// on `storage.access_grants.subject_type` was narrowed to match.
pub fn from_parts(subject_type: &str, id: Uuid) -> Option<Self> {
match subject_type {
"user" => Some(Subject::User(id)),
"group" => Some(Subject::Group(id)),
"token" => Some(Subject::Token(id)),
"external" => Some(Subject::External(id)),
_ => None,
}
}
@@ -350,17 +348,14 @@ mod tests {
#[test]
fn subject_roundtrip() {
let id = Uuid::new_v4();
let cases = [
Subject::User(id),
Subject::Group(id),
Subject::Token(id),
Subject::External(id),
];
let cases = [Subject::User(id), Subject::Group(id), Subject::Token(id)];
for s in cases {
let back = Subject::from_parts(s.type_str(), s.id()).unwrap();
assert_eq!(s, back);
}
assert!(Subject::from_parts("unknown", id).is_none());
// `external` is no longer a valid subject_type — folded into `user`.
assert!(Subject::from_parts("external", id).is_none());
}
#[test]
+174
View File
@@ -0,0 +1,174 @@
//! Email address normalization.
//!
//! Every email coming through the magic-link invitation path is funneled
//! through [`normalize_email`] before it is compared against existing
//! users or persisted in `auth.users.email`. Two addresses that differ
//! only in case or in the IDN encoding of the domain MUST collapse to
//! the same stored form — otherwise the same recipient would be invited
//! twice and end up with two `is_external` accounts.
//!
//! # Rules
//!
//! - The local-part (before the `@`) is lower-cased. We treat the local
//! part as opaque: Gmail-style `+tag` aliases and dot-insensitivity are
//! NOT special-cased. Each `alice+invoices@example.com` and
//! `alice@example.com` is a distinct identity from our perspective.
//! - The domain (after the `@`) is lower-cased, then run through
//! `idna::domain_to_ascii` so internationalised domains land as
//! punycode (`münchen.de` → `xn--mnchen-3ya.de`). This keeps the
//! stored form ASCII; UIs that want to display the unicode original
//! can reverse it with `idna::domain_to_unicode`.
//! - Exactly one `@` separator is required. Whitespace around the input
//! is trimmed. Empty local-part or empty domain is rejected. Overall
//! length must fit in the 254-char RFC 5321 envelope cap.
//!
//! # What this is NOT
//!
//! - Not a deliverability check. No MX lookup, no syntax validation
//! beyond the basics above. A normalized string that comes out of
//! here can still fail at SMTP-send time.
//! - Not a sanitiser against XSS / SQL injection. Callers must still
//! treat the output as untrusted text.
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EmailNormalizeError {
/// Missing `@` separator, or more than one (we require exactly one
/// post-trim, splitting on the last `@`).
Malformed,
/// Local-part is empty after lowercasing / trimming.
EmptyLocal,
/// Domain is empty or punycode conversion failed.
InvalidDomain,
/// Normalised form exceeds RFC 5321's 254-char ceiling.
TooLong,
}
impl fmt::Display for EmailNormalizeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Malformed => write!(f, "email is missing '@' separator"),
Self::EmptyLocal => write!(f, "email local-part is empty"),
Self::InvalidDomain => write!(f, "email domain is empty or invalid"),
Self::TooLong => write!(f, "email exceeds 254 characters"),
}
}
}
impl std::error::Error for EmailNormalizeError {}
/// Normalise a raw email address. See module docs for the rules.
pub fn normalize_email(raw: &str) -> Result<String, EmailNormalizeError> {
let trimmed = raw.trim();
let (local, domain) = trimmed
.rsplit_once('@')
.ok_or(EmailNormalizeError::Malformed)?;
let local_lc = local.to_ascii_lowercase();
if local_lc.is_empty() {
return Err(EmailNormalizeError::EmptyLocal);
}
let domain_lc = domain.to_ascii_lowercase();
if domain_lc.is_empty() {
return Err(EmailNormalizeError::InvalidDomain);
}
let domain_ascii =
idna::domain_to_ascii(&domain_lc).map_err(|_| EmailNormalizeError::InvalidDomain)?;
if domain_ascii.is_empty() {
return Err(EmailNormalizeError::InvalidDomain);
}
let out = format!("{}@{}", local_lc, domain_ascii);
if out.len() > 254 {
return Err(EmailNormalizeError::TooLong);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_passthrough_lowercases_local_and_domain() {
assert_eq!(
normalize_email("Alice@Example.COM").unwrap(),
"alice@example.com"
);
}
#[test]
fn trims_surrounding_whitespace() {
assert_eq!(
normalize_email(" bob@example.com \n").unwrap(),
"bob@example.com"
);
}
#[test]
fn idn_domain_is_punycoded() {
// u-umlaut in München.
assert_eq!(
normalize_email("user@münchen.de").unwrap(),
"user@xn--mnchen-3ya.de"
);
}
#[test]
fn plus_tag_local_part_is_preserved() {
// No Gmail-style folding.
assert_eq!(
normalize_email("alice+invoices@example.com").unwrap(),
"alice+invoices@example.com"
);
}
#[test]
fn missing_at_is_rejected() {
assert_eq!(
normalize_email("not-an-email").unwrap_err(),
EmailNormalizeError::Malformed
);
}
#[test]
fn empty_local_is_rejected() {
assert_eq!(
normalize_email("@example.com").unwrap_err(),
EmailNormalizeError::EmptyLocal
);
}
#[test]
fn empty_domain_is_rejected() {
assert_eq!(
normalize_email("alice@").unwrap_err(),
EmailNormalizeError::InvalidDomain
);
}
#[test]
fn multi_at_uses_last_separator() {
// `rsplit_once('@')` splits at the rightmost `@`. The local-part
// can legally contain `@` if quoted; we don't fully parse
// RFC 5321, so we let the resulting local-part through and rely
// on the caller's email regex / SMTP server to reject malformed
// local-parts the relay will refuse.
assert_eq!(
normalize_email("WeIrD@local@example.com").unwrap(),
"weird@local@example.com"
);
}
#[test]
fn over_254_chars_is_rejected() {
let long_local = "a".repeat(250);
let raw = format!("{}@x.io", long_local);
assert_eq!(
normalize_email(&raw).unwrap_err(),
EmailNormalizeError::TooLong
);
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod authorization;
pub mod email_normalize;
pub mod i18n_service;
pub mod path_service;
@@ -0,0 +1,97 @@
//! In-process `EmailSender` for integration tests.
//!
//! Captures every sent message in a moka cache keyed by normalised
//! recipient address. The test harness retrieves the captured payload
//! via the `GET /api/admin/smtp/test/captured` endpoint (only mounted
//! when `OXICLOUD_SMTP_MOCK=true`), parses the magic-link URL out of
//! the body, and follows it.
//!
//! # NOT for production
//!
//! The capture endpoint is admin-only AND only mounted in mock mode —
//! but even then, exposing inbox-style storage over HTTP is a leak
//! waiting to happen. The mock sender refuses to construct unless the
//! `OXICLOUD_SMTP_MOCK` env var is `true` at startup, so a misconfigured
//! prod deployment can't silently end up here.
use std::sync::Arc;
use async_trait::async_trait;
use moka::future::Cache;
use std::time::Duration;
use crate::application::ports::email_sender::{EmailMessage, EmailSendOutcome, EmailSender};
use crate::common::errors::DomainError;
/// Snapshot of one captured outbound message. Hands a copy to the
/// capture endpoint so the test runner can extract the magic-link URL,
/// verify subject, etc.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CapturedEmail {
pub to: String,
pub subject: String,
pub text_body: String,
pub html_body: Option<String>,
pub captured_at: chrono::DateTime<chrono::Utc>,
}
pub struct MockEmailSender {
/// Keyed by lowercased recipient address; only the most-recent
/// message is kept. Tests that need a full history can extend
/// this — for the magic-link flow one-per-recipient is enough.
captured: Cache<String, Arc<CapturedEmail>>,
}
impl MockEmailSender {
/// Construct a sender with a generous 10-minute capture TTL — long
/// enough for a Hurl test suite to retrieve the message at leisure
/// without the entry getting evicted out from under it.
pub fn new() -> Self {
Self {
captured: Cache::builder()
.max_capacity(10_000)
.time_to_live(Duration::from_secs(600))
.build(),
}
}
/// Fetch the most recent captured message for `recipient` (matched
/// case-insensitively on the address). Returns `None` if no message
/// was ever sent to that recipient (or it expired).
pub async fn last_for(&self, recipient: &str) -> Option<Arc<CapturedEmail>> {
self.captured.get(&recipient.to_ascii_lowercase()).await
}
/// Clear every captured message. Intended for between-test isolation.
pub async fn clear(&self) {
self.captured.invalidate_all();
}
}
impl Default for MockEmailSender {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl EmailSender for MockEmailSender {
async fn send(&self, message: EmailMessage) -> Result<EmailSendOutcome, DomainError> {
let key = message.to.to_ascii_lowercase();
let entry = CapturedEmail {
to: message.to.clone(),
subject: message.subject,
text_body: message.text_body,
html_body: message.html_body,
captured_at: chrono::Utc::now(),
};
self.captured.insert(key, Arc::new(entry)).await;
// Mimic a healthy relay's response so callers that surface the
// SMTP code (admin "test email" page) see a realistic value.
Ok(EmailSendOutcome {
code: 250,
message: "2.0.0 Mock OK".to_string(),
})
}
}
+1
View File
@@ -14,6 +14,7 @@ pub mod local_blob_backend;
pub mod login_lockout_service;
pub mod migration_blob_backend;
pub mod migration_job;
pub mod mock_email_sender;
pub mod nextcloud_chunked_upload_service;
pub mod oidc_service;
pub mod password_hasher;
@@ -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()
}
+217
View File
@@ -0,0 +1,217 @@
# =============================================================
# OxiCloud — invite-by-email + magic-link redemption (PR 9)
# =============================================================
# End-to-end: Alice shares a folder with bob@externalcompany.com,
# the server lazily provisions bob as an external user, sends the
# invitation through MockEmailSender, and bob redeems the magic
# link to land authenticated on the resource.
#
# Requires `OXICLOUD_SMTP_MOCK=true` in tests/common/server.env so
# the in-process capture endpoint at /api/admin/smtp/test/captured
# is mounted. The .hurl file would error on a real SMTP setup
# because the magic link wouldn't be retrievable.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 — Alice logs in (admin) and grabs her home folder id.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
alice_token: jsonpath "$.access_token"
GET {{base_url}}/api/folders
Authorization: Bearer {{alice_token}}
HTTP 200
[Captures]
alice_home_id: jsonpath "$[0].id"
# ─────────────────────────────────────────────────────────────
# Step 2 — Alice creates a folder she's about to share by email.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "name": "ext-share", "parent_id": "{{alice_home_id}}" }
HTTP 201
[Captures]
ext_folder_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 3 — Alice shares with bob@externalcompany.com via the new
# subject.type=email payload. Server lazily provisions
# bob as an external user.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/grants
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "email", "email": "bob@externalcompany.com" },
"resource": { "type": "folder", "id": "{{ext_folder_id}}" },
"role": "viewer"
}
HTTP 201
# The response carries the resolved subject as a regular user UUID —
# externals never surface as a distinct subject_type post-PR-9.3a.
[Asserts]
jsonpath "$[0].subject.type" == "user"
jsonpath "$[0].resource.id" == "{{ext_folder_id}}"
[Captures]
bob_user_id: jsonpath "$[0].subject.id"
# ─────────────────────────────────────────────────────────────
# Step 4 — Alice's /grants/outgoing lists bob as a grantee.
# The endpoint groups by resource and exposes the
# subject display string (here: bob's email-as-username).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/grants/outgoing/resources
Authorization: Bearer {{alice_token}}
HTTP 200
# Hurl's `includes` predicate type-mismatches when JSONPath returns a
# scalar (single-grantee case) instead of an array, so we assert on
# the raw body — robust regardless of result count + ordering.
[Asserts]
body contains "bob@externalcompany.com"
body contains "{{bob_user_id}}"
# ─────────────────────────────────────────────────────────────
# Step 5 — Defense gap #2: bob must NOT appear in the system
# address book. The contacts handler filters externals
# via `include_external = false` (PR 6).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books/system/contacts
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
body not contains "bob@externalcompany.com"
body not contains "{{bob_user_id}}"
# ─────────────────────────────────────────────────────────────
# Step 6 — Retrieve the invitation email captured by the mock
# sender BEFORE issuing any further mail (the mock only
# remembers the latest message per recipient), then
# extract the magic-link URL out of the plain-text body.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/smtp/test/captured?to=bob@externalcompany.com
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$.to" == "bob@externalcompany.com"
jsonpath "$.subject" contains "shared a folder with you"
jsonpath "$.text_body" matches "/magic/v1/[A-Za-z0-9_-]+"
[Captures]
magic_url: jsonpath "$.text_body" regex "(https?://[^\\s]+/magic/v1/[A-Za-z0-9_-]+)"
# ─────────────────────────────────────────────────────────────
# Step 7 — Re-sharing the same email reuses bob — no second
# external user gets created. The response carries the
# same user_id captured in Step 3.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "name": "ext-share-2", "parent_id": "{{alice_home_id}}" }
HTTP 201
[Captures]
ext_folder_id_2: jsonpath "$.id"
POST {{base_url}}/api/grants
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "email", "email": "bob@externalcompany.com" },
"resource": { "type": "folder", "id": "{{ext_folder_id_2}}" },
"role": "viewer"
}
HTTP 201
[Asserts]
jsonpath "$[0].subject.id" == "{{bob_user_id}}"
# ─────────────────────────────────────────────────────────────
# Step 8 — Redeem the magic link. The handler 302s to the SPA
# hash-route for the shared folder and sets the auth
# cookies. Hurl follows-mode is OFF by default; we want
# to inspect the Location header AND the Set-Cookie.
# ─────────────────────────────────────────────────────────────
GET {{magic_url}}
HTTP 302
[Asserts]
header "Location" == "/#/files/folder/{{ext_folder_id}}"
[Captures]
bob_access_token: cookie "oxicloud_access"
# ─────────────────────────────────────────────────────────────
# Step 9 — Bob (now carrying the cookie-issued JWT as bearer)
# can read the shared folder. Without the magic-link
# grant this would be 404 anti-enumeration.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders/{{ext_folder_id}}
Authorization: Bearer {{bob_access_token}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{ext_folder_id}}"
jsonpath "$.name" == "ext-share"
# ─────────────────────────────────────────────────────────────
# Step 10 — Bob sees the shared folder in his /grants/incoming.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/grants/incoming/resources
Authorization: Bearer {{bob_access_token}}
HTTP 200
[Asserts]
body contains "{{ext_folder_id}}"
# ─────────────────────────────────────────────────────────────
# Step 11 — Second redemption of the same token is rejected.
# single-use is enforced by the SQL UPDATE in
# magic_link_token_pg_repository::mark_used.
# ─────────────────────────────────────────────────────────────
GET {{magic_url}}
HTTP 410
# ─────────────────────────────────────────────────────────────
# Step 12 — Cleanup. Alice trashes the two test folders and
# deletes bob via the admin API so the suite's
# storage-check sweep at run.sh end sees a clean DB.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/folders/{{ext_folder_id_2}}
Authorization: Bearer {{alice_token}}
HTTP 204
DELETE {{base_url}}/api/folders/{{ext_folder_id}}
Authorization: Bearer {{alice_token}}
HTTP 204
DELETE {{base_url}}/api/admin/users/{{bob_user_id}}
Authorization: Bearer {{alice_token}}
HTTP *
+2 -1
View File
@@ -101,7 +101,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/permissions.hurl" \
"$API_DIR/grants.hurl" \
"$API_DIR/subject_groups.hurl" \
"$API_DIR/grants_nested_groups.hurl"
"$API_DIR/grants_nested_groups.hurl" \
"$API_DIR/external_users.hurl"
#bash "$API_DIR/dedup_bulk_upload.sh"
+12
View File
@@ -22,3 +22,15 @@ RUST_LOG=warn
# grow up limits for tests
OXICLOUD_RATE_LIMIT_REFRESH_MAX=360
OXICLOUD_RATE_LIMIT_LOGIN_MAX=360
# Magic-link / external-users flow (PR 9). The mock SMTP captures every
# outbound message in-process so external_users.hurl can retrieve the
# invitation body and follow the magic-link URL. The `SMTP_FROM` value
# is required so the mock can build a valid Message; host/port are
# irrelevant in mock mode but kept set for completeness.
OXICLOUD_SMTP_MOCK=true
OXICLOUD_SMTP_HOST=localhost
OXICLOUD_SMTP_PORT=25
OXICLOUD_SMTP_FROM='OxiCloud Tests <test@oxicloud.local>'
OXICLOUD_SMTP_TLS=none
OXICLOUD_ALLOW_EXTERNAL_USERS=true