feat(notify): add notif to internal users when granted

- add coalesced protection to avoid mail bombing if an invited goes many grant in a short period
    - add resentd method in share menu item (work for both internal and external users)
    - user can disable email notification via his properties
    - add env variable from admin to disable notifications
This commit is contained in:
Edouard Vanbelle
2026-06-05 09:46:51 +02:00
parent b8e8fcd7dc
commit 8cc21f17c5
45 changed files with 1906 additions and 81 deletions
+99
View File
@@ -262,6 +262,98 @@ impl From<Grant> for GrantDto {
}
}
// ════════════════════════════════════════════════════════════════════════════
// Notification DTOs (PR N1) — surfaced in the create-grant and /notify
// responses so the frontend can show actionable toasts ("Notified Carol",
// "Carol already notified recently", "Notified 8 of 10 group members").
// ════════════════════════════════════════════════════════════════════════════
/// One per resolved recipient. `kind` discriminates; sibling fields are
/// only meaningful for the matching variant. Tagged JSON shape:
///
/// ```json
/// { "kind": "sent", "detail": "magic_link" }
/// { "kind": "sent", "detail": "plain_notification" }
/// { "kind": "coalesced", "last_sent_at": "2026-06-04T12:00:00Z" }
/// { "kind": "rate_limited", "retry_after_secs": 1800 }
/// { "kind": "not_applicable", "reason": "recipient_opted_out" }
/// ```
#[derive(Debug, Clone, Serialize, ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NotifyOutcomeDto {
/// An email actually went out for this recipient. `detail` is
/// `"magic_link"` (external invitation with a fresh token) or
/// `"plain_notification"` (internal "you got a new grant" mail).
Sent { detail: String },
/// Skipped silently because this (granter, recipient) pair was
/// notified less than the coalesce-window ago. The grant is still
/// recorded; the recipient sees it next time they log in.
Coalesced {
last_sent_at: chrono::DateTime<chrono::Utc>,
},
/// Per-recipient hard cap (5/h) reached. The caller may retry after
/// `retry_after_secs`.
RateLimited { retry_after_secs: u32 },
/// No mail was dispatched for this recipient. `reason` is one of:
/// - `"recipient_opted_out"` — user toggled `notify_on_share = false`
/// - `"operator_disabled"` — `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE=false`
/// - `"no_email"` — user row has no email on file
/// - `"oidc_only_no_email"` — OIDC-only user with no email claim
/// - `"subject_is_token"` — anonymous link share (the surface
/// that creates the grant or the `/notify` endpoint maps this to 409)
NotApplicable { reason: String },
}
/// The aggregated result of dispatching share notifications for ONE grant
/// action (one `create_grant` request OR one `/notify` call). Carries
/// per-recipient outcomes so the frontend can render a single
/// summary-style toast:
///
/// - `total_recipients = 1`, `outcomes[0] = Sent` → "Notified Carol"
/// - `total_recipients = 1`, `outcomes[0] = Coalesced` → "Carol already
/// notified recently"
/// - `total_recipients = N`, all `Sent` → "Notified all N group members"
/// - `total_recipients = N`, mix → "Notified 8 of 10 — 2 opted out"
///
/// `total_recipients` equals `outcomes.len()` after resolution. For
/// token-subject grants it is `0` (no human recipient — no toast).
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct NotifyOutcomeSetDto {
pub total_recipients: usize,
pub outcomes: Vec<NotifyOutcomeDto>,
}
impl NotifyOutcomeSetDto {
/// Construct an empty set (token subjects, no recipients to notify).
pub fn empty() -> Self {
Self {
total_recipients: 0,
outcomes: Vec::new(),
}
}
/// Construct from a list of outcomes, deriving `total_recipients`
/// from the list length. Use this from `RecipientNotificationService`
/// after the per-member loop completes.
pub fn from_outcomes(outcomes: Vec<NotifyOutcomeDto>) -> Self {
Self {
total_recipients: outcomes.len(),
outcomes,
}
}
}
/// Response body for `POST /api/grants`. Wraps the array of created
/// grants (one per `permission` in the request) together with the
/// aggregated notification result. Replaces the previous bare
/// `Vec<GrantDto>` shape; the frontend share modal is updated in
/// lockstep.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct CreateGrantResponseDto {
pub grants: Vec<GrantDto>,
pub notification: NotifyOutcomeSetDto,
}
// ════════════════════════════════════════════════════════════════════════════
// Shared-with-me DTOs (GET /api/grants/incoming/resources)
// ════════════════════════════════════════════════════════════════════════════
@@ -372,6 +464,13 @@ pub struct OutgoingResourceGrantDto {
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
/// Whether the token has a password set. Always `false` for user subjects.
pub has_password: bool,
/// True when the subject is a magic-link-only external user
/// (PR N2). Always `false` for token and group subjects, and for
/// internal users. Used by the My Shares per-row menu to choose
/// between "Resend invitation email" (external) and "Notify by
/// email" (internal).
#[serde(default)]
pub is_external: bool,
}
/// One item in the my-shares list.
+16
View File
@@ -53,6 +53,14 @@ pub struct UserDto {
/// through `/api/auth/me` and `PATCH /api/auth/me/profile`.
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_locale: Option<String>,
/// Whether the user wants an email when someone shares a resource
/// with them. `true` (default) = receive share-notification mails;
/// `false` = grants are still created but no email is sent. Honored
/// only on the plain-notification path — magic-link first-invitations
/// to brand-new external users always send, otherwise the recipient
/// could never claim the share. Round-trips through `/api/auth/me`
/// and `PATCH /api/auth/me/profile`.
pub notify_on_share: bool,
}
impl From<User> for UserDto {
@@ -76,6 +84,7 @@ impl From<User> for UserDto {
family_name: user.family_name().map(str::to_string),
email_verified_at: user.email_verified_at(),
preferred_locale: user.preferred_locale().map(str::to_string),
notify_on_share: user.notify_on_share(),
}
}
}
@@ -169,6 +178,13 @@ pub struct UpdateProfileDto {
/// normalises `""` → `None`).
#[serde(default)]
pub preferred_locale: Option<String>,
/// Whether to receive an email when someone shares a resource with
/// the user. Absent → no change (existing setting preserved). Pass
/// `true` to opt in, `false` to opt out. Honored only on the
/// plain-notification path; magic-link first-invitations to externals
/// always send.
#[serde(default)]
pub notify_on_share: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+6
View File
@@ -75,6 +75,12 @@ pub trait UserStoragePort: Send + Sync + 'static {
/// Gets a user by ID
async fn get_user_by_id(&self, id: Uuid) -> Result<User, DomainError>;
/// Batch-loads users by id. Order is unspecified; missing ids are
/// silently dropped. Used by group-recipient expansion in
/// `RecipientNotificationService` to avoid N+1 lookups when notifying
/// a group of size N.
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> Result<Vec<User>, DomainError>;
/// Gets a user by username
async fn get_user_by_username(&self, username: &str) -> Result<User, DomainError>;
@@ -1298,6 +1298,17 @@ impl AuthApplicationService {
}
}
// ── Share-notification opt-out (PR N1) ───────────────────
// Boolean field; absent → no change. Idempotent — setting the
// same value twice is fine but doesn't re-emit an audit row
// because `changed` won't pick it up.
if let Some(notify) = dto.notify_on_share
&& notify != user.notify_on_share()
{
user.set_notify_on_share(notify);
changed.push("notify_on_share");
}
if changed.is_empty() {
// No-op — return the current user without a DB write.
return Ok(UserDto::from(user));
@@ -1320,6 +1331,19 @@ impl AuthApplicationService {
self.get_user(user_id).await
}
/// Load the full `User` entity for the given id. Unlike
/// `get_user_by_id` this returns the domain entity (not a DTO), so
/// callers can read fields like `notify_on_share()`,
/// `preferred_locale()`, or `is_external()` without round-tripping
/// through the DTO shape. Used by `grant_handler::create_grant` to
/// hand the granter entity to `RecipientNotificationService`.
pub async fn get_user_entity(
&self,
user_id: Uuid,
) -> Result<crate::domain::entities::user::User, DomainError> {
UserStoragePort::get_user_by_id(&*self.user_storage, user_id).await
}
/// Visibility-checked profile lookup for `GET /api/users/{id}`.
///
/// Returns `NotFound` (not `AccessDenied`) when the caller has no
@@ -268,14 +268,17 @@ impl MagicLinkInviteService {
/// 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").
/// `inviter` is interpolated into the subject line ("Alice shared
/// with you on OxiCloud") and body ("Alice <alice@x.com> shared a
/// folder with you. Open it by..."). Two forms are computed via
/// [`User::display_full`] — the short form goes into the subject
/// (keeps inbox-row width sane), the email-decorated form goes
/// into the body where the extra identifier helps the recipient
/// place who's reaching out.
pub async fn issue_invitation(
&self,
recipient: &User,
inviter_username: &str,
inviter: &User,
resource: Resource,
) -> Result<(), DomainError> {
// The grant is in place either way; only mint a magic link when
@@ -335,8 +338,16 @@ impl MagicLinkInviteService {
let locale = self.locale_for(recipient);
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
let ttl_hours = self.magic_link_cfg.invite_ttl_hours.to_string();
// Two display forms: `inviter` (short, no email) flows into the
// subject line; `inviter_full` (with email decoration) flows
// into the body. Templates pick whichever placeholder they
// want — see static/locales/en.json `server.magic_link.email.
// invitation.*` for the canonical references.
let inviter_short = inviter.display_full(false);
let inviter_full = inviter.display_full(true);
let invite_args: Vec<(&str, &str)> = vec![
("inviter", inviter_username),
("inviter", inviter_short.as_str()),
("inviter_full", inviter_full.as_str()),
("kind", &kind_label),
("link", &link),
("ttl_hours", &ttl_hours),
+1
View File
@@ -20,6 +20,7 @@ pub mod music_service;
pub mod nextcloud_file_id_service;
pub mod nextcloud_login_flow_service;
pub mod recent_service;
pub mod recipient_notification_service;
pub mod search_service;
pub mod share_browse_service;
pub mod share_service;
@@ -0,0 +1,680 @@
//! 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;
/// 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(),
);
}
let mut outcomes = Vec::with_capacity(members.len());
for member in &members {
let outcome = self
.dispatch_to_one_user(granter, member, resource, trigger)
.await;
outcomes.push(outcome);
}
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",
};
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"
);
}
}
+18
View File
@@ -761,6 +761,20 @@ pub struct MagicLinkConfig {
/// may enforce MFA we shouldn't bypass. See
/// `magic_link_eligibility()` for the precedence ladder.
pub open_to_password_users: bool,
/// Operator-level kill switch for plain-notification emails to
/// internal users (PR N1). When `true` (default), users who can't
/// receive a magic link (password users, OIDC users) get a "Hey,
/// you got a new grant" mail with a `/login` deep link on every
/// share. When `false`, the plain-notification arm is suppressed
/// entirely — internal users discover shares only on next login.
///
/// This is a coarser knob than the per-user
/// `auth.users.notify_on_share` column: when this is `false`, the
/// user-level opt-in does not matter. External-user magic-link
/// invitations are NOT affected by this flag — those always send,
/// because the link is the only way the recipient can claim the
/// share for the first time.
pub notify_internal_users_on_share: bool,
}
impl Default for MagicLinkConfig {
@@ -774,6 +788,7 @@ impl Default for MagicLinkConfig {
send_per_email_per_hour: 5,
send_per_ip_per_hour: 200,
open_to_password_users: false,
notify_internal_users_on_share: true,
}
}
}
@@ -1475,6 +1490,9 @@ impl AppConfig {
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS") {
config.magic_link.open_to_password_users = v == "true" || v == "1";
}
if let Ok(v) = env::var("OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE") {
config.magic_link.notify_internal_users_on_share = v == "true" || v == "1";
}
if let Ok(v) = env::var("OXICLOUD_DEFAULT_LOCALE") {
let trimmed = v.trim();
+39 -5
View File
@@ -948,9 +948,10 @@ impl AppServiceFactory {
),
),
)),
email_sender: None, // populated below
mock_email_sender: None, // populated below
magic_link_invite_service: None, // populated below
email_sender: None, // populated below
mock_email_sender: None, // populated below
magic_link_invite_service: None, // populated below
recipient_notification_service: None, // populated below alongside magic_link_invite_service
// 60 lookups / minute / caller; cap at 50 000 tracked
// callers to bound memory. The same limiter instance is
// shared by every clone of AppState since it lives in an
@@ -1013,9 +1014,9 @@ impl AppServiceFactory {
);
app_state.magic_link_invite_service = Some(Arc::new(
crate::application::services::magic_link_invite_service::MagicLinkInviteService::new(
invite_user_storage,
invite_user_storage.clone(),
invite_magic_link_repo,
email_sender,
email_sender.clone(),
lifecycle,
app_state.applications.i18n_service.clone(),
app_state.locale_registry.clone(),
@@ -1023,6 +1024,30 @@ impl AppServiceFactory {
self.config.base_url(),
),
));
// PR N1: wire the unified RecipientNotificationService.
// Only constructed when MagicLinkInviteService is also
// available — the magic-link path delegates to it.
// SubjectGroupService is built earlier in this factory; the
// notification service needs it for the Group subject arm.
if let (Some(magic_link_svc), Some(subject_groups)) = (
app_state.magic_link_invite_service.clone(),
app_state.subject_group_service.clone(),
) {
app_state.recipient_notification_service = Some(Arc::new(
crate::application::services::recipient_notification_service::RecipientNotificationService::new(
invite_user_storage,
magic_link_svc,
email_sender,
app_state.applications.i18n_service.clone(),
app_state.locale_registry.clone(),
subject_groups,
app_state.magic_link_send_per_email_rate_limiter.clone(),
self.config.magic_link.clone(),
self.config.base_url(),
),
));
}
}
// 9b. Wire admin settings service when auth is available
@@ -1374,6 +1399,15 @@ pub struct AppState {
pub magic_link_invite_service: Option<
Arc<crate::application::services::magic_link_invite_service::MagicLinkInviteService>,
>,
/// Unified share-notification dispatcher (PR N1) — used by both
/// `create_grant` and the future `POST /api/grants/{id}/notify` to
/// route share emails through coalesce + rate-limit + per-recipient
/// dispatch. `None` when SMTP / magic-link / subject-group services
/// aren't all configured; callers degrade to silent no-op in that
/// case (no mail sent, grant still created).
pub recipient_notification_service: Option<
Arc<crate::application::services::recipient_notification_service::RecipientNotificationService>,
>,
/// Per-caller sliding-window limiter for `GET /api/users/{id}`. The
/// endpoint's primary defense is the visibility check, but a stale
/// JWT could in theory iterate UUIDs against the related-by-grant
+184
View File
@@ -85,6 +85,16 @@ pub struct User {
/// application layer is the authoritative gatekeeper against the
/// `LocaleRegistry`.
preferred_locale: Option<String>,
/// Per-user opt-out for share-notification emails (PR N1). TRUE =
/// receive a mail when someone grants access to a resource (default);
/// FALSE = grant still recorded but `RecipientNotificationService`
/// returns `NotApplicable { recipient_opted_out }` and no mail is
/// sent. Bypassed for magic-link first-invitations to external users
/// — the link is their only way to claim the share, so suppressing
/// it would lock them out. Once an external becomes a real account
/// and opts out, subsequent shares from other granters honor the
/// flag.
notify_on_share: bool,
}
impl User {
@@ -178,6 +188,11 @@ impl User {
// language switcher, or invitation-time inheritance fill
// this in later. NULL resolves to OXICLOUD_DEFAULT_LOCALE.
preferred_locale: None,
// PR N1: default to opted-in. The profile checkbox is the
// user-facing toggle; the column default in
// `users_notify_on_share` mirrors this for rows reconstructed
// from disk without going through `new`.
notify_on_share: true,
})
}
@@ -221,6 +236,7 @@ impl User {
family_name: None,
email_verified_at: None,
preferred_locale: None,
notify_on_share: true,
}
}
@@ -245,6 +261,7 @@ impl User {
family_name: Option<String>,
email_verified_at: Option<DateTime<Utc>>,
preferred_locale: Option<String>,
notify_on_share: bool,
) -> Self {
Self {
id,
@@ -266,6 +283,7 @@ impl User {
family_name,
email_verified_at,
preferred_locale,
notify_on_share,
}
}
@@ -341,6 +359,64 @@ impl User {
}
}
/// Rich, user-facing display label for notification surfaces
/// (transactional emails, share invitations, "Alice <a@x.com>
/// shared X with you" — anywhere a human is reading the line).
///
/// `with_email` controls whether the address is appended as
/// `" <email>"` after the name part:
/// - `true` — best for the email **body** ("Alice Smith
/// <alice@example.com> shared a folder with you"), where the
/// extra identifier is helpful at a glance.
/// - `false` — best for the **subject line** and other compact
/// contexts where dragging the email into a 80-char inbox row
/// would be noise ("Alice Smith shared a folder with you").
///
/// Priority order (mirrors RFC 5322 display-name conventions). The
/// `<email>` decoration in cases 1 and 3 is omitted when
/// `with_email` is false:
///
/// 1. `"Given Family"` (+ ` <email>`) — full name; the most
/// informative form.
/// 2. `"username"` (+ ` <email>`) — handle; the typical case
/// for password / OIDC users without first/last claims.
/// 3. `email` — last-resort fallback. The
/// raw email address is always present for non-OCM users and is
/// the unambiguous identifier. Returned regardless of
/// `with_email` since it IS the label here.
/// 4. shortened UUID — failure mode (no email,
/// no username, no given/family — shouldn't happen with current
/// schema invariants but kept defensive for OCM-federated rows).
///
/// External users provisioned via magic-link typically have only an
/// email and fall through to branch 3. Internal users with OIDC
/// JIT often have given/family from the IdP claims → branch 1.
/// Sister of [`Self::display_for_audit`], which deliberately
/// returns a *less* identifying label for log lines.
pub fn display_full(&self, with_email: bool) -> String {
let g = self.given_name.as_deref();
let f = self.family_name.as_deref();
let u = self.username.as_deref();
let has_email = !self.email.is_empty();
if let (Some(g), Some(f)) = (g, f) {
if with_email && has_email {
return format!("{} {} <{}>", g, f, self.email);
}
return format!("{} {}", g, f);
}
if let Some(u) = u {
if with_email && has_email {
return format!("{} <{}>", u, self.email);
}
return u.to_string();
}
if has_email {
return self.email.clone();
}
format!("{}…", &self.id.to_string()[..8])
}
pub fn oidc_provider(&self) -> Option<&str> {
self.oidc_provider.as_deref()
}
@@ -430,6 +506,24 @@ impl User {
self.updated_at = Utc::now();
}
/// Whether this user wants to receive an email when someone grants
/// them access to a resource. `RecipientNotificationService` checks
/// this on the plain-notification arm; magic-link first-invitations
/// to external users bypass it (otherwise the recipient could never
/// claim the share). Defaults TRUE for both the entity constructor
/// and the schema column.
pub fn notify_on_share(&self) -> bool {
self.notify_on_share
}
/// Flip the share-notification preference. The caller is expected
/// to have already validated input shape (the field is a boolean,
/// so there is no work beyond storage). Bumps `updated_at`.
pub fn set_notify_on_share(&mut self, notify: bool) {
self.notify_on_share = notify;
self.updated_at = Utc::now();
}
/// Claim or change the username. Runs the same validation as the
/// constructor — callers must still ensure uniqueness at the repo
/// level. Bumps `updated_at`. Used by the post-create profile-edit
@@ -584,3 +678,93 @@ impl User {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_user(
username: Option<&str>,
given: Option<&str>,
family: Option<&str>,
email: &str,
) -> User {
User::from_data_full(
Uuid::new_v4(),
username.map(str::to_string),
email.to_string(),
None,
UserRole::User,
0,
0,
Utc::now(),
Utc::now(),
None,
true,
None,
None,
None,
false,
given.map(str::to_string),
family.map(str::to_string),
None,
None,
true,
)
}
#[test]
fn display_full_given_family_with_email() {
let u = build_user(Some("alice"), Some("Alice"), Some("Smith"), "alice@x.com");
assert_eq!(u.display_full(true), "Alice Smith <alice@x.com>");
assert_eq!(u.display_full(false), "Alice Smith");
}
#[test]
fn display_full_given_family_takes_priority_over_username() {
// Even when the username is set, the full name is more informative
// and wins. The username surfaces only as part of the address.
let u = build_user(Some("admin"), Some("Bob"), Some("Jones"), "bob@x.com");
assert_eq!(u.display_full(true), "Bob Jones <bob@x.com>");
assert_eq!(u.display_full(false), "Bob Jones");
}
#[test]
fn display_full_username_only() {
// The "admin" case the user observed: no given/family on the
// bootstrap admin user. With email → "admin <admin@x.com>";
// without → just "admin" (compact form for subject lines).
let u = build_user(Some("admin"), None, None, "admin@x.com");
assert_eq!(u.display_full(true), "admin <admin@x.com>");
assert_eq!(u.display_full(false), "admin");
}
#[test]
fn display_full_partial_name_falls_through_to_username() {
// Given without family (or vice versa) is NOT "rich enough" to
// use; we walk to the next priority instead of producing a
// "First <email>" half-name.
let u = build_user(Some("carol"), Some("Carol"), None, "carol@x.com");
assert_eq!(u.display_full(true), "carol <carol@x.com>");
assert_eq!(u.display_full(false), "carol");
}
#[test]
fn display_full_email_only() {
// External users provisioned via magic-link typically have no
// username and no given/family — only the email is present.
// `with_email` is moot here: the email IS the label.
let u = build_user(None, None, None, "external@x.com");
assert_eq!(u.display_full(true), "external@x.com");
assert_eq!(u.display_full(false), "external@x.com");
}
#[test]
fn display_full_partial_name_no_username_falls_to_email() {
// Lone given_name without family AND without username → falls
// all the way through to the raw email.
let u = build_user(None, Some("Solo"), None, "solo@x.com");
assert_eq!(u.display_full(true), "solo@x.com");
assert_eq!(u.display_full(false), "solo@x.com");
}
}
@@ -48,6 +48,13 @@ pub trait UserRepository: Send + Sync + 'static {
/// Gets a user by ID
async fn get_user_by_id(&self, id: Uuid) -> UserRepositoryResult<User>;
/// Batch-loads a set of users by id, preserving no particular order
/// and silently skipping ids that don't match any row. Caller is
/// responsible for de-duplicating the input vec. Returns an empty
/// vec when given an empty input. Used by group-recipient expansion
/// in `RecipientNotificationService` to avoid N+1 queries.
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> UserRepositoryResult<Vec<User>>;
/// Gets a user by username
async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult<User>;
+7
View File
@@ -279,6 +279,13 @@ pub struct OutgoingGrantEntry {
/// True when the token subject has a password set (`storage.shares.password_hash IS NOT NULL`).
/// Always `false` for `user` subjects.
pub has_password: bool,
/// True when the user subject is a magic-link-only external user
/// (`auth.users.is_external = TRUE`). Always `false` for token and
/// group subjects, and for internal-user subjects. Surfaced on the
/// My Shares DTO so the frontend's per-row menu can label the
/// notify item "Resend invitation email" (external) vs "Notify by
/// email" (internal).
pub is_external: bool,
}
/// All subjects that the current user has shared a single resource with,
@@ -97,10 +97,10 @@ impl UserRepository for UserPgRepository {
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, is_external,
given_name, family_name, email_verified_at,
preferred_locale
preferred_locale, notify_on_share
) VALUES (
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, $17, $18
$12, $13, $14, $15, $16, $17, $18, $19
)
RETURNING *
"#,
@@ -123,6 +123,7 @@ impl UserRepository for UserPgRepository {
.bind(user_clone.family_name())
.bind(user_clone.email_verified_at())
.bind(user_clone.preferred_locale())
.bind(user_clone.notify_on_share())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
@@ -147,7 +148,7 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
FROM auth.users
WHERE id = $1
"#,
@@ -184,6 +185,7 @@ impl UserRepository for UserPgRepository {
row.get("family_name"),
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
))
}
@@ -196,7 +198,7 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
FROM auth.users
WHERE username = $1
"#,
@@ -233,6 +235,7 @@ impl UserRepository for UserPgRepository {
row.get("family_name"),
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
))
}
@@ -245,7 +248,7 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
FROM auth.users
WHERE email = $1
"#,
@@ -282,9 +285,71 @@ impl UserRepository for UserPgRepository {
row.get("family_name"),
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
))
}
/// Batch loads users by id in one query (avoids N+1 for group-
/// recipient expansion). Missing ids are silently skipped — the
/// caller treats absent rows as "no such recipient", same as
/// `get_user_by_id` returning `NotFound` for a single lookup.
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> UserRepositoryResult<Vec<User>> {
if ids.is_empty() {
return Ok(Vec::new());
}
let rows = sqlx::query(
r#"
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
FROM auth.users
WHERE id = ANY($1)
"#,
)
.bind(&ids)
.fetch_all(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|row| {
let role_str: Option<String> = row.try_get("role_text").unwrap_or(None);
let role = match role_str.as_deref() {
Some("admin") => UserRole::Admin,
_ => UserRole::User,
};
User::from_data_full(
row.get("id"),
row.get("username"),
row.get("email"),
row.get("password_hash"),
role,
row.get("storage_quota_bytes"),
row.get("storage_used_bytes"),
row.get("created_at"),
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
)
})
.collect())
}
/// Updates an existing user using a transaction
async fn update_user(&self, user: User) -> UserRepositoryResult<User> {
// Create a copy of the user for the closure
@@ -310,7 +375,8 @@ impl UserRepository for UserPgRepository {
given_name = $12,
family_name = $13,
email_verified_at = $14,
preferred_locale = $15
preferred_locale = $15,
notify_on_share = $16
WHERE id = $1
"#,
)
@@ -329,6 +395,7 @@ impl UserRepository for UserPgRepository {
.bind(user_clone.family_name())
.bind(user_clone.email_verified_at())
.bind(user_clone.preferred_locale())
.bind(user_clone.notify_on_share())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
@@ -401,7 +468,7 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
FROM auth.users
WHERE ($3 OR is_external = FALSE)
ORDER BY created_at DESC
@@ -445,6 +512,7 @@ impl UserRepository for UserPgRepository {
row.get("family_name"),
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
)
})
.collect();
@@ -466,7 +534,7 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
FROM auth.users
WHERE (username ILIKE $1 OR email ILIKE $1)
AND ($3 OR is_external = FALSE)
@@ -510,6 +578,7 @@ impl UserRepository for UserPgRepository {
row.get("family_name"),
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
)
})
.collect();
@@ -597,7 +666,7 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
FROM auth.users
WHERE role::text = $1
ORDER BY created_at DESC
@@ -638,6 +707,7 @@ impl UserRepository for UserPgRepository {
row.get("family_name"),
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
)
})
.collect();
@@ -674,7 +744,7 @@ impl UserRepository for UserPgRepository {
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
FROM auth.users
WHERE oidc_provider = $1 AND oidc_subject = $2
"#,
@@ -711,6 +781,7 @@ impl UserRepository for UserPgRepository {
row.get("family_name"),
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
))
}
@@ -792,6 +863,12 @@ impl UserStoragePort for UserPgRepository {
.map_err(DomainError::from)
}
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> Result<Vec<User>, DomainError> {
UserRepository::get_users_by_ids(self, ids)
.await
.map_err(DomainError::from)
}
async fn get_user_by_username(&self, username: &str) -> Result<User, DomainError> {
UserRepository::get_user_by_username(self, username)
.await
+43 -4
View File
@@ -347,6 +347,32 @@ impl PgAclEngine {
Ok(Some((res, granter)))
}
/// Variant of `find_grant_by_id` that also returns the subject —
/// needed by `POST /api/grants/{id}/notify` to resolve who to email.
/// Returns `(subject, resource, granted_by)` or `None`.
pub async fn find_grant_full_by_id(
&self,
grant_id: Uuid,
) -> Result<Option<(Subject, Resource, Uuid)>, DomainError> {
let row: Option<(String, Uuid, String, Uuid, Uuid)> = sqlx::query_as(
"SELECT subject_type, subject_id, resource_type, resource_id, granted_by \
FROM storage.access_grants WHERE id = $1",
)
.bind(grant_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("find_grant_full_by_id: {e}")))?;
let Some((st, sid, rt, rid, granter)) = row else {
return Ok(None);
};
let subject = Subject::from_parts(&st, sid)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown subject_type"))?;
let resource = Resource::from_parts(&rt, rid)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?;
Ok(Some((subject, resource, granter)))
}
/// Row type for all full-grant SELECT queries:
/// (id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at)
#[allow(clippy::type_complexity)]
@@ -865,6 +891,8 @@ impl AuthorizationEngine for PgAclEngine {
// 10 sort_str Option<String>
// 11 sort_int Option<i64>
// 12 has_password bool — token: shares.password_hash IS NOT NULL
// 13 is_external bool — user: auth.users.is_external (PR N2);
// FALSE for token/group subjects.
type Row = (
String,
Uuid,
@@ -879,6 +907,7 @@ impl AuthorizationEngine for PgAclEngine {
Option<String>,
Option<i64>,
bool,
bool,
);
let cursor_str = cursor.as_ref().and_then(|c| c.resource_name.clone());
@@ -962,7 +991,8 @@ impl AuthorizationEngine for PgAclEngine {
COALESCE(u.username, u.email, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
rp.sort_str, rp.sort_int,
(sh.password_hash IS NOT NULL) AS has_password
(sh.password_hash IS NOT NULL) AS has_password,
COALESCE(u.is_external, FALSE) AS is_external
FROM rp
JOIN storage.access_grants ag
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
@@ -1025,6 +1055,7 @@ impl AuthorizationEngine for PgAclEngine {
ag.subject_id,
MAX(COALESCE(u.username, u.email, sg.name::text, sh.item_name, ag.subject_id::text)) AS subject_display,
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
COALESCE(BOOL_OR(u.is_external), FALSE) AS is_external,
MAX(CASE
WHEN ag.subject_type = 'group' THEN 0
WHEN ag.subject_type = 'user' THEN 1
@@ -1066,7 +1097,8 @@ impl AuthorizationEngine for PgAclEngine {
ag.permission,
LOWER(rp.subject_display) AS sort_str,
rp.sort_int,
rp.has_password
rp.has_password,
rp.is_external
FROM rp
JOIN storage.access_grants ag
ON ag.resource_type = rp.resource_type
@@ -1110,6 +1142,7 @@ impl AuthorizationEngine for PgAclEngine {
ag.subject_id,
MAX(COALESCE(u.username, u.email, sh.item_name, ag.subject_id::text)) AS subject_display,
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
COALESCE(BOOL_OR(u.is_external), FALSE) AS is_external,
CASE
WHEN BOOL_OR(ag.permission = 'delete')
AND BOOL_OR(ag.permission = 'share') THEN 0
@@ -1150,7 +1183,8 @@ impl AuthorizationEngine for PgAclEngine {
ag.permission,
LOWER(rp.subject_display) AS sort_str,
rp.sort_int,
rp.has_password
rp.has_password,
rp.is_external
FROM rp
JOIN storage.access_grants ag
ON ag.resource_type = rp.resource_type
@@ -1199,7 +1233,8 @@ impl AuthorizationEngine for PgAclEngine {
COALESCE(u.username, u.email, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
NULL::text AS sort_str, NULL::bigint AS sort_int,
(sh.password_hash IS NOT NULL) AS has_password
(sh.password_hash IS NOT NULL) AS has_password,
COALESCE(u.is_external, FALSE) AS is_external
FROM rp
JOIN storage.access_grants ag
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
@@ -1283,6 +1318,7 @@ impl AuthorizationEngine for PgAclEngine {
_,
_,
has_password,
is_external,
) = r;
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
continue;
@@ -1303,6 +1339,7 @@ impl AuthorizationEngine for PgAclEngine {
granted_at,
expires_at,
has_password,
is_external,
},
)
});
@@ -1399,6 +1436,7 @@ impl AuthorizationEngine for PgAclEngine {
_,
_,
has_password,
is_external,
) = r;
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
continue;
@@ -1425,6 +1463,7 @@ impl AuthorizationEngine for PgAclEngine {
granted_at,
expires_at,
has_password,
is_external,
});
if !entry.permissions.contains(&perm) {
entry.permissions.push(perm);
+243 -26
View File
@@ -20,14 +20,15 @@ use uuid::Uuid;
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, SubjectInputDto, UpdateRoleDto,
role_from_permissions,
CreateGrantDto, CreateGrantResponseDto, GrantDto, MySharesDto, NotifyOutcomeSetDto,
OutgoingResourceGrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto,
ResourceDto, ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery,
SubjectDto, SubjectInputDto, UpdateRoleDto, role_from_permissions,
};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::services::recipient_notification_service::NotifyTrigger;
use crate::common::di::AppState;
#[allow(unused_imports)]
use crate::common::errors::DomainError;
@@ -50,7 +51,7 @@ type AppStateRef = Arc<AppState>;
path = "/api/grants",
request_body = CreateGrantDto,
responses(
(status = 201, description = "Grant(s) created", body = Vec<GrantDto>),
(status = 201, description = "Grant(s) created", body = CreateGrantResponseDto),
(status = 400, description = "Invalid input (both/neither of permissions+role provided)"),
(status = 404, description = "Resource not found OR caller lacks Share permission"),
),
@@ -172,28 +173,78 @@ pub async fn create_grant(
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
);
}
}
// PR N1 — route the post-grant notification through the unified
// RecipientNotificationService. Handles user/group/token subjects
// uniformly (Token subjects return an empty outcome set); applies
// per-(granter, recipient) coalesce + per-recipient hard rate
// limit; dispatches the magic-link arm (delegating to
// MagicLinkInviteService::issue_invitation) for eligible externals
// and the plain-notification arm for internal users; honours the
// per-user `notify_on_share` opt-out and the operator-level
// `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE` flag. SMTP failures
// remain non-fatal — the grant rows are already in place and the
// service captures every per-recipient result as a NotifyOutcome
// rather than an Err.
//
// For the email-resolved subject variant we already loaded the
// recipient `User` above for the lazy-provision side effect; the
// notification service re-resolves the same id, which is cheap and
// keeps the entry-point signature uniform across subject types.
let _ = invite_recipient; // value used only for its side effect above
(StatusCode::CREATED, Json(results)).into_response()
// Load the granter as a full `User` entity — the notification
// service uses display fields (`username`, `given/family_name`) for
// the inviter label in the email body. Failure here means the JWT
// claims correspond to a user row that has since been deleted; we
// return the grants without a notification rather than rolling back.
let notification = match (
state.recipient_notification_service.as_ref(),
state.auth_service.as_ref(),
) {
(Some(svc), Some(auth_svc)) => {
match auth_svc
.auth_application_service
.get_user_entity(caller_id)
.await
{
Ok(granter) => match svc
.send_share_notification(
&granter,
subject,
resource,
NotifyTrigger::GrantCreated,
)
.await
{
Ok(set) => set.to_dto(),
Err(e) => {
warn!(
"notification dispatch failed for grant action by {}: {}",
caller_id, e
);
NotifyOutcomeSetDto::empty()
}
},
Err(e) => {
warn!(
"granter {} user-row load failed; skipping notification: {}",
caller_id, e
);
NotifyOutcomeSetDto::empty()
}
}
}
_ => NotifyOutcomeSetDto::empty(),
};
(
StatusCode::CREATED,
Json(CreateGrantResponseDto {
grants: results,
notification,
}),
)
.into_response()
}
// ════════════════════════════════════════════════════════════════════════════
@@ -246,6 +297,171 @@ pub async fn revoke_grant(
StatusCode::NO_CONTENT.into_response()
}
// ════════════════════════════════════════════════════════════════════════════
// POST /api/grants/{id}/notify — manual share-notification resend
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
post,
path = "/api/grants/{id}/notify",
params(("id" = String, Path, description = "Grant UUID")),
responses(
(status = 204, description = "Notification(s) dispatched"),
(status = 200, description = "Mixed outcome (some recipients coalesced / not-applicable); body carries the full NotifyOutcomeSet", body = NotifyOutcomeSetDto),
(status = 404, description = "Grant not found OR caller is not the granter"),
(status = 409, description = "Token subject (use the existing /magic/v1/{token}/resend channel)"),
(status = 429, description = "Per-recipient hard rate limit exceeded"),
),
security(("bearerAuth" = [])),
tag = "grants"
)]
pub async fn notify_grant_recipient(
State(state): State<AppStateRef>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
let authz = &state.authorization;
let caller_id = auth_user.id;
let grant_id = match Uuid::parse_str(&id) {
Ok(u) => u,
Err(_) => return AppError::not_found(format!("Grant {id} not found")).into_response(),
};
// Load the grant. Anti-enumeration: missing AND not-owner both
// surface as 404 to the caller; only the audit row carries the
// real reason. Mirrors `revoke_grant`'s precedent.
let (subject, resource, granter_id) = match authz.find_grant_full_by_id(grant_id).await {
Ok(Some(t)) => t,
Ok(None) => {
tracing::info!(
target: "audit",
event = "grant.notify_skipped",
reason = "grant_not_found",
caller_id = %caller_id,
grant_id = %grant_id,
"🤫 manual notify rejected: grant {} not found",
grant_id,
);
return AppError::not_found(format!("Grant {grant_id} not found")).into_response();
}
Err(e) => return AppError::from(e).into_response(),
};
if granter_id != caller_id {
tracing::info!(
target: "audit",
event = "grant.notify_skipped",
reason = "not_owner",
caller_id = %caller_id,
grant_id = %grant_id,
actual_granter = %granter_id,
"🤫 manual notify rejected: caller {} is not the granter of {}",
caller_id,
grant_id,
);
return AppError::not_found(format!("Grant {grant_id} not found")).into_response();
}
// Token subjects can't be notified — the link share has no human
// recipient to email. Map to 409 so the frontend can hide the menu
// item for these as defense-in-depth (the v1 UI already does this
// client-side; this is the server-side enforcement).
if matches!(subject, Subject::Token(_)) {
return AppError::new(
StatusCode::CONFLICT,
"Cannot notify a link-share recipient — token shares have no email channel",
"subject_is_token",
)
.into_response();
}
// Load the granter entity (we are the granter; needed for the
// notification email body's "Alice shared X with you" salutation).
let Some(auth_svc) = state.auth_service.as_ref() else {
return AppError::new(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication subsystem not available",
"ServiceUnavailable",
)
.into_response();
};
let granter = match auth_svc
.auth_application_service
.get_user_entity(caller_id)
.await
{
Ok(u) => u,
Err(e) => return AppError::from(e).into_response(),
};
let Some(svc) = state.recipient_notification_service.as_ref() else {
return AppError::new(
StatusCode::SERVICE_UNAVAILABLE,
"Notification service is not configured on this server \
(set OXICLOUD_SMTP_HOST in .env to enable)",
"ServiceUnavailable",
)
.into_response();
};
let outcome_set = match svc
.send_share_notification(&granter, subject, resource, NotifyTrigger::ManualResend)
.await
{
Ok(s) => s,
Err(e) => return AppError::from(e).into_response(),
};
let dto = outcome_set.to_dto();
// HTTP mapping per the plan:
// - empty outcomes (Token subject — already 409'd above; defense
// in depth) → 409
// - every outcome is Sent → 204 No Content
// - all RateLimited (no Sent) → 429 with the longest Retry-After
// - mixed → 200 with the full body
if dto.outcomes.is_empty() {
return AppError::new(
StatusCode::CONFLICT,
"Grant has no notifiable recipients",
"subject_is_token",
)
.into_response();
}
let any_sent = dto.outcomes.iter().any(|o| {
matches!(
o,
crate::application::dtos::grant_dto::NotifyOutcomeDto::Sent { .. }
)
});
let max_retry_after = dto
.outcomes
.iter()
.filter_map(|o| match o {
crate::application::dtos::grant_dto::NotifyOutcomeDto::RateLimited {
retry_after_secs,
} => Some(*retry_after_secs),
_ => None,
})
.max();
let all_sent = dto.outcomes.iter().all(|o| {
matches!(
o,
crate::application::dtos::grant_dto::NotifyOutcomeDto::Sent { .. }
)
});
if all_sent {
return StatusCode::NO_CONTENT.into_response();
}
if !any_sent && let Some(secs) = max_retry_after {
return crate::interfaces::middleware::rate_limit::too_many_requests(secs as u64);
}
(StatusCode::OK, Json(dto)).into_response()
}
// ════════════════════════════════════════════════════════════════════════════
// PUT /api/grants/role
// ════════════════════════════════════════════════════════════════════════════
@@ -742,6 +958,7 @@ pub async fn list_my_shares(
granted_at: g.granted_at,
expires_at: g.expires_at,
has_password: g.has_password,
is_external: g.is_external,
})
.collect();
+1
View File
@@ -318,6 +318,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
.route("/", post(grant_handler::create_grant))
.route("/", get(grant_handler::list_on_resource))
.route("/{id}", delete(grant_handler::revoke_grant))
.route("/{id}/notify", post(grant_handler::notify_grant_recipient))
.route("/role", put(grant_handler::set_role))
.route("/incoming", get(grant_handler::list_incoming))
.route(