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)]