From 03f63ad103d2b4446a8670d562b7c92a5842da82 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 2 Jun 2026 10:47:37 +0200 Subject: [PATCH] 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 --- Cargo.lock | 1 + Cargo.toml | 1 + docs/config/env.md | 2 + ...60612000005_drop_external_subject_type.sql | 35 +++ src/application/dtos/grant_dto.rs | 33 ++- .../services/magic_link_invite_service.rs | 264 ++++++++++++++++++ src/application/services/mod.rs | 1 + src/common/di.rs | 109 +++++++- src/domain/services/authorization.rs | 23 +- src/domain/services/email_normalize.rs | 174 ++++++++++++ src/domain/services/mod.rs | 1 + .../services/mock_email_sender.rs | 97 +++++++ src/infrastructure/services/mod.rs | 1 + src/interfaces/api/handlers/admin_handler.rs | 50 ++++ src/interfaces/api/handlers/grant_handler.rs | 51 +++- tests/api/external_users.hurl | 217 ++++++++++++++ tests/api/run.sh | 3 +- tests/common/server.env | 12 + 18 files changed, 1047 insertions(+), 28 deletions(-) create mode 100644 migrations/20260612000005_drop_external_subject_type.sql create mode 100644 src/application/services/magic_link_invite_service.rs create mode 100644 src/domain/services/email_normalize.rs create mode 100644 src/infrastructure/services/mock_email_sender.rs create mode 100644 tests/api/external_users.hurl diff --git a/Cargo.lock b/Cargo.lock index 924d331a..f4ed025e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3729,6 +3729,7 @@ dependencies = [ "http-body-util", "http-range-header", "id3", + "idna", "image", "infer 0.19.0", "jsonwebtoken", diff --git a/Cargo.toml b/Cargo.toml index c4b00a0b..9ca8c4d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = [] diff --git a/docs/config/env.md b/docs/config/env.md index ad7a7739..2866a3d7 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -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 `) | | `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. diff --git a/migrations/20260612000005_drop_external_subject_type.sql b/migrations/20260612000005_drop_external_subject_type.sql new file mode 100644 index 00000000..670e56af --- /dev/null +++ b/migrations/20260612000005_drop_external_subject_type.sql @@ -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')); diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs index ec0575c7..4d551c34 100644 --- a/src/application/dtos/grant_dto.rs +++ b/src/application/dtos/grant_dto.rs @@ -23,7 +23,6 @@ pub enum SubjectTypeDto { User, Group, Token, - External, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -39,7 +38,6 @@ impl From 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 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>, diff --git a/src/application/services/magic_link_invite_service.rs b/src/application/services/magic_link_invite_service.rs new file mode 100644 index 00000000..f730f16e --- /dev/null +++ b/src/application/services/magic_link_invite_service.rs @@ -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, + magic_link_repo: Arc, + email_sender: Arc, + user_lifecycle: Arc, + 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, + magic_link_repo: Arc, + email_sender: Arc, + user_lifecycle: Arc, + 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 { + 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 { + 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 for MagicLinkResourceKind { + fn from(kind: ResourceKind) -> Self { + match kind { + ResourceKind::Folder => Self::Folder, + ResourceKind::File => Self::File, + } + } +} diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 2ca0e9bd..59e938f2 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -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; diff --git a/src/common/di.rs b/src/common/di.rs index 0644f82d..a08bb152 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -678,6 +678,15 @@ impl AppServiceFactory { let storage_usage_service: Option>; let mut auth_services: Option = None; let mut nextcloud_services: Option = 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, + > = 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>, + /// 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>, + /// 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, + >, } // 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>, + mock: Option>, +} + /// 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> { +/// +/// 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() { "" } else { "" }, "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, + } } } } diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index b75fb784..ce4ccd77 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -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 { 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] diff --git a/src/domain/services/email_normalize.rs b/src/domain/services/email_normalize.rs new file mode 100644 index 00000000..813d1b35 --- /dev/null +++ b/src/domain/services/email_normalize.rs @@ -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 { + 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 + ); + } +} diff --git a/src/domain/services/mod.rs b/src/domain/services/mod.rs index 93be8d78..42855fe5 100644 --- a/src/domain/services/mod.rs +++ b/src/domain/services/mod.rs @@ -1,4 +1,5 @@ pub mod authorization; +pub mod email_normalize; pub mod i18n_service; pub mod path_service; diff --git a/src/infrastructure/services/mock_email_sender.rs b/src/infrastructure/services/mock_email_sender.rs new file mode 100644 index 00000000..370ae0db --- /dev/null +++ b/src/infrastructure/services/mock_email_sender.rs @@ -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, + pub captured_at: chrono::DateTime, +} + +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>, +} + +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> { + 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 { + 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(), + }) + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 701d62a5..3accf192 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -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; diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index bc7accda..b484af04 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -61,6 +61,10 @@ pub fn admin_routes() -> Router> { // 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= — 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>, + headers: HeaderMap, + Query(params): Query, +) -> Result { + 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 diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index bdd49756..34b88833 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -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 = 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() } diff --git a/tests/api/external_users.hurl b/tests/api/external_users.hurl new file mode 100644 index 00000000..059d72a4 --- /dev/null +++ b/tests/api/external_users.hurl @@ -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 * diff --git a/tests/api/run.sh b/tests/api/run.sh index e582560c..ec4d1427 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -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" diff --git a/tests/common/server.env b/tests/common/server.env index 5b2e0b4d..aea2ebe7 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -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 ' +OXICLOUD_SMTP_TLS=none +OXICLOUD_ALLOW_EXTERNAL_USERS=true