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:
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user