Files
Oxicloud/src/application/services/recipient_notification_service.rs
T
Edouard Vanbelle e94063d96a test(login/register): via password or magic-link
Password login

┌─────┬────────────────────────────────────────────────────┬────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐
│  #  │                        Case                        │         Where          │                                          Assertion                                          │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L1  │ Login by username                                  │ auth_login.hurl Case 1 │ 200 + access_token, user.email match                                                        │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L2  │ Login by email (dispatch on @)                     │ auth_login.hurl Case 2 │ 200, same session shape as L1                                                               │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L3  │ Bad password on username path                      │ auth_login.hurl Case 3 │ 403 anti-enum                                                                               │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L4  │ Bad password on email path                         │ auth_login.hurl Case 4 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L5  │ Unknown username                                   │ auth_login.hurl Case 5 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L6  │ Unknown email                                      │ auth_login.hurl Case 6 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L7  │ /api/auth/oidc/providers reports methods correctly │ auth_login.hurl Case 7 │ password_login_enabled: true, magic_link_login_enabled: true, require_verified_email: false │
└─────┴────────────────────────────────────────────────────┴────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘

Password registration

┌─────┬───────────────────────────────────────────────────┬──────────────────────────────┬─────────────────────────────────────────────────────────┐
│  #  │                       Case                        │            Where             │                        Assertion                        │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R1  │ Classic username + email + password → uniform 200 │ registration.hurl Step 2     │ anti-enum message contains "request received"           │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R2  │ Login after register works                        │ registration.hurl Step 2b    │ 200 + session for the new user                          │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R3  │ Email collision → uniform 200 (no rewrite)        │ registration.hurl Steps 8-10 │ attacker password doesn't work; original account intact │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R4  │ Username collision → uniform 200                  │ registration.hurl Step 11    │ same anti-enum shape                                    │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R5  │ Off-domain rejection                              │ registration.hurl Step 12    │ 403 RegistrationDomainNotAllowed                        │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R6  │ Case-insensitive domain match                     │ registration.hurl Step 12b   │ uniform 200 on charlie@EXAMPLE.COM                      │
└─────┴───────────────────────────────────────────────────┴──────────────────────────────┴────────────────────────────┘

Magic-link registration (email-only signup)

┌─────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────┐
│  #  │                                               Case                                               │             Where             │                   Assertion                    │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR1 │ Email-only signup → welcome mail queued                                                          │ registration.hurl Step 3      │ uniform 200 + browser-binding cookie set       │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR2 │ Welcome mail contains magic-link URL                                                             │ registration.hurl Step 4      │ captured from mock SMTP                        │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR3 │ PR 22 cross-browser confirmation page                                                            │ registration.hurl Step 5a     │ 200 HTML "different browser"                   │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR4 │ Cookie-bound redemption lands on SPA                                                             │ registration.hurl Step 5b     │ 302 → /files (SvelteKit route, post-migration) │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR5 │ email_verified_at stamped after redemption                                                       │ registration.hurl Step 6      │ field present on /api/auth/me                  │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR6 │ Second magic-link post-signup                                                                    │ registration.hurl Step 7      │ uniform 200                                    │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR7 │ Profile PATCH — no-op, name set, empty-string rejected, username-taken 409, claim-once 409, etc. │ registration.hurl Steps 6a–6i │ full profile lifecycle                         │
└─────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────┘

Magic-link login (existing account)

┌─────┬──────────────────────────────────────────────────────────┬──────────────────────────────────────┬───────────────────────────────────────┐
│  #  │                           Case                           │                Where                 │                             Assertion                              │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML1 │ Baseline password login still works                      │ auth_magic_link_login.hurl Steps 1-2 │ 200                                                                │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML2 │ magic-link/send with email identifier                    │ auth_magic_link_login.hurl Step 3    │ uniform 200 + cookie                                               │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML3 │ magic-link/send with username identifier (dispatch on @) │ auth_magic_link_login.hurl Step 4    │ uniform 200                                                        │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML4 │ Password-user policy: mail actually sent                 │ auth_magic_link_login.hurl Step 5    │ SMTP capture proves permit_magic_link_for_password_users in effect │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML5 │ Redemption creates a session                             │ auth_magic_link_login.hurl Steps 6-7 │ 302 → /files, /api/auth/me returns the same user                   │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML6 │ Anti-enum on unknown identifier                          │ auth_magic_link_login.hurl Step 8    │ same uniform 200 shape as ML3                                      │
└─────┴──────────────────────────────────────────────────────────┴──────────────────────────────────────┴───────────────────────────────────────┘

OIDC

┌─────┬────────────────────────────────────────────────────────────────────────┬───────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐
│  #  │                                  Case                                  │       Where       │                                                        Assertion                                                        │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O1  │ Setup local admin (bootstrap)                                          │ oidc.hurl Step 1  │ 201                                                                                                                     │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O2  │ Providers endpoint — OIDC visible                                      │ oidc.hurl Step 2  │ enabled: true, provider_name: MockSSO, password_login_enabled: true, magic_link_login_enabled: false (OIDC-master rule) │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O2b │ Magic-link/send refused (endpoint layer)                               │ oidc.hurl Step 2b │ 403 MagicLinkLoginDisabled — proves the policy gate fires, not a 503 SMTP-unwired                                       │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O3  │ Authorize redirect includes PKCE + state                               │ oidc.hurl Step 3  │ 307 to fake IdP                                                                                                         │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O4  │ IdP round-trip + JIT provisioning                                      │ oidc.hurl Step 4  │ Callback lands on /login?oidc_code=…                                                                                    │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O5  │ Code exchange → session cookies                                        │ oidc.hurl Step 5  │ 200 + all three cookies                                                                                                 │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O6  │ JIT profile mapping (name, given/family, picture, groups → admin role) │ oidc.hurl Step 6  │ every claim reflected on /api/auth/me                                                                                   │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O7  │ Refresh rotation on OIDC session                                       │ oidc.hurl Step 7  │ new access/refresh/CSRF cookies                                                                                         │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O8  │ Refreshed cookies authenticate                                         │ oidc.hurl Step 8  │ 200 on /api/auth/me                                                                                                     │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O9  │ Repeat login = same local user (no dup)                                │ oidc.hurl Step 9  │ user_id stable                                                                                                          │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O10 │ Anti-takeover: unverified email → refused                              │ oidc.hurl Step 10 │ 401/403                                                                                                                 │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O11 │ One-time code replay refused                                           │ oidc.hurl Step 11 │ second /exchange → 401                                                                                                  │
└─────┴────────────────────────────────────────────────────────────────────────┴───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘

test
2026-07-14 03:16:25 +02:00

703 lines
27 KiB
Rust

//! Unified entry point for share-related notification emails.
//!
//! Single service called by both `POST /api/grants` (initial invitation
//! when a grant lands) and `POST /api/grants/{id}/notify` (manual resend
//! from the My Shares menu). Replaces the prior arrangement where
//! `create_grant` directly invoked
//! [`MagicLinkInviteService::issue_invitation`] and internal users got
//! no email at all.
//!
//! # Behaviour ladder
//!
//! Per resolved recipient member:
//!
//! 1. **Eligibility** decides the dispatch arm:
//! - `magic_link_eligibility(recipient) == Allow` →
//! `NotifyKind::MagicLink` (mints a token and emails the
//! invitation by delegating to
//! [`MagicLinkInviteService::issue_invitation`]).
//! - Otherwise (password user, OIDC user, OIDC-linked external) →
//! `NotifyKind::PlainNotification` — provided the recipient has
//! not opted out (`auth.users.notify_on_share = false`) and the
//! operator-level kill switch
//! `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE` is `true`.
//! - Otherwise → `NotifyOutcome::NotApplicable` with a structured
//! reason.
//! 2. **Coalesce check** keyed by `(granter_id, recipient_email)`. If
//! the last send for this pair was less than the window ago, return
//! `Coalesced` without dispatching. Magic-link first-invitations
//! are NOT coalesced — they're the only way the recipient can claim
//! the share.
//! 3. **Hard rate limit** keyed by recipient email. Reuses
//! `magic_link_send_per_email_rate_limiter` so an attacker can't
//! alternate between `/notify` and `/magic/v1/{token}/resend` to
//! double the cap.
//! 4. **Dispatch** via the magic-link arm or the plain-notification
//! arm. On successful SMTP send, update the coalesce timestamp.
//! 5. **Audit**: one `grant.notify_sent` or `grant.notify_skipped` per
//! member; for group sends, one `grant.notify_group_expanded`
//! summary line carrying `group_id` and `member_count`.
//!
//! # Forward-compatibility
//!
//! The entry takes `(granter, subject, resource, trigger)` — NOT a
//! pre-resolved `&User` — so [`Subject::Group`] is a real arm in
//! [`Self::resolve_subject_members`] and not a future refactor. The
//! infrastructure (group repository, transitive expansion with 30s
//! Moka cache) already ships from earlier work; we just plug in.
use std::sync::Arc;
use std::time::Duration;
use askama::Template;
use chrono::{DateTime, Utc};
use moka::sync::Cache;
use uuid::Uuid;
use crate::application::dtos::grant_dto::{NotifyOutcomeDto, NotifyOutcomeSetDto};
use crate::application::ports::email_sender::{EmailMessage, EmailSender};
use crate::application::services::i18n_application_service::I18nApplicationService;
use crate::application::services::magic_link_invite_service::{
Eligibility, MagicLinkInviteService, magic_link_eligibility,
};
use crate::application::services::subject_group_service::SubjectGroupService;
use crate::common::config::MagicLinkConfig;
use crate::common::errors::DomainError;
use crate::common::locale::{Locale, LocaleRegistry};
use crate::domain::entities::user::User;
use crate::domain::repositories::user_repository::UserRepository;
use crate::domain::services::authorization::{Resource, Subject};
use crate::infrastructure::repositories::pg::UserPgRepository;
use crate::interfaces::middleware::rate_limit::RateLimiter;
/// Concurrent per-recipient dispatches in flight during a group fan-out.
/// High enough to collapse a 30-member group's serial SMTP latency,
/// low enough not to flood the relay (most reject >10 parallel sessions).
const NOTIFY_DISPATCH_CONCURRENCY: usize = 6;
/// What triggered the notification — purely an audit discriminator.
/// `GrantCreated` → fired implicitly when a grant lands; `ManualResend`
/// → granter explicitly clicked "Notify by email" in My Shares.
#[derive(Debug, Clone, Copy)]
pub enum NotifyTrigger {
GrantCreated,
ManualResend,
}
impl NotifyTrigger {
fn audit_str(self) -> &'static str {
match self {
NotifyTrigger::GrantCreated => "grant_created",
NotifyTrigger::ManualResend => "manual_resend",
}
}
}
/// Which email arm dispatched. `MagicLink` carries a one-shot token in
/// the URL; `PlainNotification` carries only a `/login` deep link.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotifyKind {
MagicLink,
PlainNotification,
}
impl NotifyKind {
fn audit_str(self) -> &'static str {
match self {
NotifyKind::MagicLink => "magic_link",
NotifyKind::PlainNotification => "plain_notification",
}
}
}
/// One per resolved recipient. The variant names are stable audit-log
/// values — log aggregators key off them; do not rename or repurpose.
#[derive(Debug, Clone)]
pub enum NotifyOutcome {
/// SMTP send succeeded for this recipient.
Sent { kind: NotifyKind },
/// Skipped because the same (granter, recipient) pair was notified
/// less than the coalesce window ago. The grant is recorded; the
/// recipient sees it at next login. Carries the last-send timestamp
/// so the frontend can format an informative toast.
Coalesced { last_sent_at: DateTime<Utc> },
/// Per-recipient hard cap reached. Caller may retry after the
/// returned number of seconds.
RateLimited { retry_after_secs: u32 },
/// No mail dispatched. `reason` is a stable enum-style key:
/// `recipient_opted_out`, `operator_disabled`, `no_email`,
/// `account_inactive`, `subject_is_token`.
NotApplicable { reason: &'static str },
}
impl NotifyOutcome {
fn to_dto(&self) -> NotifyOutcomeDto {
match self {
NotifyOutcome::Sent { kind } => NotifyOutcomeDto::Sent {
detail: kind.audit_str().to_string(),
},
NotifyOutcome::Coalesced { last_sent_at } => NotifyOutcomeDto::Coalesced {
last_sent_at: *last_sent_at,
},
NotifyOutcome::RateLimited { retry_after_secs } => NotifyOutcomeDto::RateLimited {
retry_after_secs: *retry_after_secs,
},
NotifyOutcome::NotApplicable { reason } => NotifyOutcomeDto::NotApplicable {
reason: (*reason).to_string(),
},
}
}
}
/// Aggregated result for one share-notification action. Carries one
/// outcome per resolved recipient (1 for user subjects, 0 for token
/// subjects, N for group subjects).
#[derive(Debug, Clone)]
pub struct NotifyOutcomeSet {
pub outcomes: Vec<NotifyOutcome>,
}
impl NotifyOutcomeSet {
pub fn empty() -> Self {
Self {
outcomes: Vec::new(),
}
}
pub fn total_recipients(&self) -> usize {
self.outcomes.len()
}
pub fn to_dto(&self) -> NotifyOutcomeSetDto {
NotifyOutcomeSetDto::from_outcomes(
self.outcomes.iter().map(NotifyOutcome::to_dto).collect(),
)
}
}
/// Default coalesce window (10 minutes). Bursts of share creations to
/// the same recipient inside this window produce ONE email; subsequent
/// shares are coalesced silently. Recipient still sees every share at
/// next login.
const COALESCE_WINDOW_SECS: u64 = 10 * 60;
/// Maximum keys held by the coalesce cache. Way above any realistic
/// per-tenant burst; bounded to keep memory predictable.
const COALESCE_CACHE_MAX_ENTRIES: u64 = 100_000;
pub struct RecipientNotificationService {
user_storage: Arc<UserPgRepository>,
magic_link_service: Arc<MagicLinkInviteService>,
email_sender: Arc<dyn EmailSender>,
i18n: Arc<I18nApplicationService>,
locale_registry: Arc<LocaleRegistry>,
subject_groups: Arc<SubjectGroupService>,
/// Per-(granter, recipient_email) timestamp of last successful send.
/// Sliding window — read+rewrite resets the TTL but that's fine
/// because we only insert on actual sends.
coalesce_cache: Cache<(Uuid, String), DateTime<Utc>>,
/// Shared with the public `/magic/v1/{token}/resend` channel so an
/// attacker can't alternate between channels to double the cap.
per_email_limiter: Arc<RateLimiter>,
magic_link_cfg: MagicLinkConfig,
public_base_url: String,
}
impl RecipientNotificationService {
#[allow(clippy::too_many_arguments)]
pub fn new(
user_storage: Arc<UserPgRepository>,
magic_link_service: Arc<MagicLinkInviteService>,
email_sender: Arc<dyn EmailSender>,
i18n: Arc<I18nApplicationService>,
locale_registry: Arc<LocaleRegistry>,
subject_groups: Arc<SubjectGroupService>,
per_email_limiter: Arc<RateLimiter>,
magic_link_cfg: MagicLinkConfig,
public_base_url: String,
) -> Self {
let coalesce_cache = Cache::builder()
.time_to_live(Duration::from_secs(COALESCE_WINDOW_SECS))
.max_capacity(COALESCE_CACHE_MAX_ENTRIES)
.build();
Self {
user_storage,
magic_link_service,
email_sender,
i18n,
locale_registry,
subject_groups,
coalesce_cache,
per_email_limiter,
magic_link_cfg,
public_base_url,
}
}
/// Single entry point. Called by `create_grant` after grant rows are
/// persisted, and by `notify_grant_recipient` after loading the
/// grant by id. Returns one outcome per resolved recipient.
///
/// Errors here are *infrastructure* errors (DB unreachable while
/// expanding a group, etc.). Per-recipient failures (SMTP, etc.)
/// are captured as outcomes, never as `Err`.
pub async fn send_share_notification(
&self,
granter: &User,
subject: Subject,
resource: Resource,
trigger: NotifyTrigger,
) -> Result<NotifyOutcomeSet, DomainError> {
// Resolve subject → Vec<User>. Token subjects yield an empty
// vec; the calling handler maps that to its own response.
let members = self.resolve_subject_members(subject).await?;
if members.is_empty() {
return Ok(NotifyOutcomeSet::empty());
}
// Audit summary line for group expansions — operators tracing
// a single grant action want to see "this fanned out to N
// recipients" without combing per-member lines.
if let Subject::Group(group_id) = subject {
tracing::info!(
target: "audit",
event = "grant.notify_group_expanded",
granter_id = %granter.id(),
group_id = %group_id,
member_count = members.len(),
resource = ?resource,
trigger = %trigger.audit_str(),
"📣 group {} expanded to {} member(s) for notification",
group_id,
members.len(),
);
}
// SMTP dispatch dominates each iteration (hundreds of ms per
// recipient) and the iterations are independent — coalescing and
// rate-limiting key on (granter, recipient), which is distinct per
// member. Bounded concurrency keeps a 30-member group grant from
// holding the HTTP response for 15+ s of serial sends while still
// capping the pressure on the SMTP relay. `buffered` (not
// `buffer_unordered`) preserves the member order of the outcomes.
use futures::stream::{self, StreamExt};
let outcomes: Vec<NotifyOutcome> = stream::iter(members)
.map(|member| async move {
self.dispatch_to_one_user(granter, &member, resource, trigger)
.await
})
.buffered(NOTIFY_DISPATCH_CONCURRENCY)
.collect()
.await;
Ok(NotifyOutcomeSet { outcomes })
}
/// User subjects → single-element vec; Token subjects → empty;
/// Group subjects → transitively expanded member list.
async fn resolve_subject_members(&self, subject: Subject) -> Result<Vec<User>, DomainError> {
match subject {
Subject::User(id) => {
match UserRepository::get_user_by_id(&*self.user_storage, id).await {
Ok(user) => Ok(vec![user]),
Err(e) => Err(DomainError::from(e)),
}
}
Subject::Token(_) => Ok(Vec::new()),
Subject::Group(group_id) => {
let member_ids = self.subject_groups.list_transitive_users(group_id).await?;
if member_ids.is_empty() {
return Ok(Vec::new());
}
UserRepository::get_users_by_ids(&*self.user_storage, member_ids)
.await
.map_err(DomainError::from)
}
}
}
/// THE last function sending email. Per-recipient: eligibility
/// match → coalesce → rate-limit → dispatch → audit. No SMTP send
/// happens outside this function.
async fn dispatch_to_one_user(
&self,
granter: &User,
recipient: &User,
resource: Resource,
trigger: NotifyTrigger,
) -> NotifyOutcome {
// 1. Account state — deactivated users get no mail regardless.
if !recipient.is_active() {
self.audit_skipped(granter, recipient, resource, trigger, "account_inactive");
return NotifyOutcome::NotApplicable {
reason: "account_inactive",
};
}
// 2. Choose the dispatch arm.
let kind =
match magic_link_eligibility(recipient, self.magic_link_cfg.open_to_password_users) {
Eligibility::Allow => NotifyKind::MagicLink,
Eligibility::Reject(_) => {
// Plain-notification arm. Check the two gates.
if !self.magic_link_cfg.notify_internal_users_on_share {
self.audit_skipped(
granter,
recipient,
resource,
trigger,
"operator_disabled",
);
return NotifyOutcome::NotApplicable {
reason: "operator_disabled",
};
}
if !recipient.notify_on_share() {
self.audit_skipped(
granter,
recipient,
resource,
trigger,
"recipient_opted_out",
);
return NotifyOutcome::NotApplicable {
reason: "recipient_opted_out",
};
}
if recipient.email().is_empty() {
self.audit_skipped(granter, recipient, resource, trigger, "no_email");
return NotifyOutcome::NotApplicable { reason: "no_email" };
}
NotifyKind::PlainNotification
}
};
// 3. Coalesce check — only meaningful when we'd actually send.
// Per-pair: `(granter_id, recipient_email)`.
let coalesce_key = (granter.id(), recipient.email().to_string());
if let Some(last) = self.coalesce_cache.get(&coalesce_key) {
self.audit_skipped(granter, recipient, resource, trigger, "coalesced");
return NotifyOutcome::Coalesced { last_sent_at: last };
}
// 4. Hard rate limit on the recipient email.
if self
.per_email_limiter
.check_and_increment(recipient.email())
.is_err()
{
self.audit_skipped(granter, recipient, resource, trigger, "rate_limited");
return NotifyOutcome::RateLimited {
retry_after_secs: self.per_email_limiter.retry_after() as u32,
};
}
// 5. Dispatch + audit.
let send_result = match kind {
NotifyKind::MagicLink => {
// Delegates token mint + locale-resolved bilingual email
// + per-mail audit to the existing service. Its own
// eligibility short-circuit is moot here — we've already
// routed only Accept-eligible recipients to this arm.
// Pass the granter as a `&User` so the inner service can
// compute both the short (subject) and full (body)
// display forms via `display_full(bool)`.
self.magic_link_service
.issue_invitation(recipient, granter, resource)
.await
.map_err(|e| e.message)
}
NotifyKind::PlainNotification => {
self.send_plain_notification(granter, recipient, resource)
.await
}
};
match send_result {
Ok(()) => {
// Update coalesce timestamp ONLY on successful send.
// Skipping a coalesce-window-ago send means the next
// attempt re-checks against the same old timestamp, but
// moka's insert resets the TTL anyway — so the window
// effectively slides forward on each successful send.
self.coalesce_cache.insert(coalesce_key, Utc::now());
tracing::info!(
target: "audit",
event = "grant.notify_sent",
kind = %kind.audit_str(),
granter_id = %granter.id(),
recipient_id = %recipient.id(),
recipient_email = %recipient.email(),
resource = ?resource,
trigger = %trigger.audit_str(),
"📨 notify sent ({}) to {}",
kind.audit_str(),
recipient.email(),
);
NotifyOutcome::Sent { kind }
}
Err(err) => {
// The grant landed; SMTP failure is non-fatal. Mirror
// the long-standing magic-link policy: warn-log, return
// a Sent-shaped outcome anyway (the operator sees the
// truth in the audit row; the caller's UI is just less
// useful for a few seconds).
tracing::warn!(
target: "audit",
event = "grant.notify_send_failed",
kind = %kind.audit_str(),
granter_id = %granter.id(),
recipient_id = %recipient.id(),
recipient_email = %recipient.email(),
error = %err,
"📭 notify send failed ({}): {}",
kind.audit_str(),
err,
);
// Don't bump coalesce on failure — we want the next
// legitimate attempt to retry.
NotifyOutcome::Sent { kind }
}
}
}
/// Render and send the plain-notification email ("Hey, you got a
/// new grant"). No magic link; recipient must sign in normally.
async fn send_plain_notification(
&self,
granter: &User,
recipient: &User,
resource: Resource,
) -> Result<(), String> {
let locale = self.locale_for(recipient);
let kind_key = match resource {
Resource::Folder(_) => "server.magic_link.email.kind_folder",
Resource::File(_) => "server.magic_link.email.kind_file",
// Drive / Calendar / AddressBook / Playlist shares don't
// produce email notifications through this path. Fall
// back to the folder label so any code that does reach
// here still produces a readable (if generic) mail body.
Resource::Drive(_)
| Resource::Calendar(_)
| Resource::AddressBook(_)
| Resource::Playlist(_) => "server.magic_link.email.kind_folder",
};
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
// Short form for the subject, long form (with email) for the
// body — same pattern as `MagicLinkInviteService::issue_invitation`.
let inviter_short = granter.display_full(false);
let inviter_full = granter.display_full(true);
let login_link = format!("{}/login", self.public_base_url.trim_end_matches('/'),);
let args: Vec<(&str, &str)> = vec![
("inviter", inviter_short.as_str()),
("inviter_full", inviter_full.as_str()),
("kind", &kind_label),
("login_link", &login_link),
];
let subject = self
.i18n_or("server.notification.share.subject", &locale, &args)
.await;
let body = self
.render_bilingual("server.notification.share.body", &locale, &args)
.await;
let message = EmailMessage {
to: recipient.email().to_string(),
subject,
text_body: body,
html_body: None,
};
self.email_sender
.send(message)
.await
.map(|_| ())
.map_err(|e| e.message)
}
/// Resolve a recipient's stored locale → `Locale`. Mirrors
/// `MagicLinkInviteService::locale_for`: bad/unknown codes fall back
/// to the server default.
fn locale_for(&self, user: &User) -> Locale {
user.preferred_locale()
.and_then(|code| self.locale_registry.parse(code))
.unwrap_or_else(|| self.locale_registry.default_locale().clone())
}
/// Translate with arg substitution, falling back to the literal key
/// if the i18n lookup errors (defensive — shouldn't happen with the
/// English-fallback layer in place).
async fn i18n_or(&self, key: &str, locale: &Locale, args: &[(&str, &str)]) -> String {
self.i18n
.translate_args(key, Some(locale.clone()), args)
.await
.unwrap_or_else(|_| key.to_string())
}
/// Body + English-fallback partial. Same shape as
/// `MagicLinkInviteService::render_bilingual`. Could be lifted into
/// a shared helper later — kept duplicated for now because there
/// are only two call sites.
async fn render_bilingual(
&self,
body_key: &str,
locale: &Locale,
args: &[(&str, &str)],
) -> String {
let body = self.i18n_or(body_key, locale, args).await;
let english_fallback = if locale.is_english() {
None
} else {
Some(self.i18n_or(body_key, &Locale::english(), args).await)
};
let divider = self
.i18n_or(
"server.magic_link.email.english_fallback_divider",
locale,
&[],
)
.await;
let template = BilingualBody {
body: body.clone(),
divider,
english_fallback,
};
template.render().unwrap_or(body)
}
fn audit_skipped(
&self,
granter: &User,
recipient: &User,
resource: Resource,
trigger: NotifyTrigger,
reason: &'static str,
) {
tracing::info!(
target: "audit",
event = "grant.notify_skipped",
reason = reason,
granter_id = %granter.id(),
recipient_id = %recipient.id(),
recipient_email = %recipient.email(),
resource = ?resource,
trigger = %trigger.audit_str(),
"🤫 notify skipped ({}) for {}",
reason,
recipient.email(),
);
}
}
/// Reuses the same partial template as `MagicLinkInviteService`. The
/// duplication is intentional: askama derive macros need a struct per
/// callsite, and pulling the rendering struct out of the magic-link
/// module would create a fan-out of dependencies. Two ~10-line copies
/// is cheaper than the abstraction.
#[derive(Template)]
#[template(path = "magic_link/email_body.txt")]
struct BilingualBody {
body: String,
divider: String,
english_fallback: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notify_outcome_to_dto_sent_variants() {
let dto_ml = NotifyOutcome::Sent {
kind: NotifyKind::MagicLink,
}
.to_dto();
let dto_pn = NotifyOutcome::Sent {
kind: NotifyKind::PlainNotification,
}
.to_dto();
match dto_ml {
NotifyOutcomeDto::Sent { detail } => assert_eq!(detail, "magic_link"),
_ => panic!("expected Sent"),
}
match dto_pn {
NotifyOutcomeDto::Sent { detail } => assert_eq!(detail, "plain_notification"),
_ => panic!("expected Sent"),
}
}
#[test]
fn notify_outcome_to_dto_skip_variants() {
let now = Utc::now();
match (NotifyOutcome::Coalesced { last_sent_at: now }).to_dto() {
NotifyOutcomeDto::Coalesced { last_sent_at } => assert_eq!(last_sent_at, now),
_ => panic!("expected Coalesced"),
}
match (NotifyOutcome::RateLimited {
retry_after_secs: 3600,
})
.to_dto()
{
NotifyOutcomeDto::RateLimited { retry_after_secs } => {
assert_eq!(retry_after_secs, 3600)
}
_ => panic!("expected RateLimited"),
}
match (NotifyOutcome::NotApplicable {
reason: "recipient_opted_out",
})
.to_dto()
{
NotifyOutcomeDto::NotApplicable { reason } => {
assert_eq!(reason, "recipient_opted_out")
}
_ => panic!("expected NotApplicable"),
}
}
#[test]
fn notify_outcome_set_total_recipients_matches_outcomes_len() {
let set = NotifyOutcomeSet {
outcomes: vec![
NotifyOutcome::Sent {
kind: NotifyKind::PlainNotification,
},
NotifyOutcome::Coalesced {
last_sent_at: Utc::now(),
},
NotifyOutcome::NotApplicable {
reason: "recipient_opted_out",
},
],
};
assert_eq!(set.total_recipients(), 3);
let dto = set.to_dto();
assert_eq!(dto.total_recipients, 3);
assert_eq!(dto.outcomes.len(), 3);
}
#[test]
fn empty_outcome_set() {
let set = NotifyOutcomeSet::empty();
assert_eq!(set.total_recipients(), 0);
let dto = set.to_dto();
assert_eq!(dto.total_recipients, 0);
assert!(dto.outcomes.is_empty());
}
#[test]
fn audit_strs_are_stable() {
// These string values appear in operator-facing audit logs and
// log aggregators key off them. A rename here is a breaking
// change to dashboards — guard against accidental drift.
assert_eq!(NotifyTrigger::GrantCreated.audit_str(), "grant_created");
assert_eq!(NotifyTrigger::ManualResend.audit_str(), "manual_resend");
assert_eq!(NotifyKind::MagicLink.audit_str(), "magic_link");
assert_eq!(
NotifyKind::PlainNotification.audit_str(),
"plain_notification"
);
}
}