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:
@@ -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]
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
pub mod authorization;
|
||||
pub mod email_normalize;
|
||||
pub mod i18n_service;
|
||||
pub mod path_service;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user