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
+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;