From 8cc21f17c547f0f38619ed50ef3241c28768efce Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 5 Jun 2026 09:46:51 +0200 Subject: [PATCH] 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 --- docs/config/env.md | 1 + example.env | 20 + .../20260624000000_users_notify_on_share.sql | 38 + src/application/dtos/grant_dto.rs | 99 +++ src/application/dtos/user_dto.rs | 16 + src/application/ports/auth_ports.rs | 6 + .../services/auth_application_service.rs | 24 + .../services/magic_link_invite_service.rs | 23 +- src/application/services/mod.rs | 1 + .../recipient_notification_service.rs | 680 ++++++++++++++++++ src/common/config.rs | 18 + src/common/di.rs | 44 +- src/domain/entities/user.rs | 184 +++++ src/domain/repositories/user_repository.rs | 7 + src/domain/services/authorization.rs | 7 + .../repositories/pg/user_pg_repository.rs | 97 ++- src/infrastructure/services/pg_acl_engine.rs | 47 +- src/interfaces/api/handlers/grant_handler.rs | 269 ++++++- src/interfaces/api/routes.rs | 1 + static/js/components/mySharesList.js | 81 +++ static/js/components/shareModal.js | 74 +- static/js/core/icons.js | 4 + static/js/core/types.js | 2 + static/js/model/grants.js | 20 +- static/js/views/profile/profile.js | 19 +- static/locales/ar.json | 10 +- static/locales/de.json | 10 +- static/locales/en.json | 18 + static/locales/es.json | 10 +- static/locales/fa.json | 10 +- static/locales/fr.json | 10 +- static/locales/hi.json | 10 +- static/locales/it.json | 10 +- static/locales/ja.json | 10 +- static/locales/ko.json | 10 +- static/locales/nl.json | 10 +- static/locales/pl.json | 10 +- static/locales/pt.json | 10 +- static/locales/ru.json | 10 +- static/locales/zh-TW.json | 10 +- static/locales/zh.json | 10 +- static/profile.html | 7 + tests/api/external_users.hurl | 12 +- tests/api/grants.hurl | 8 +- tests/api/grants_nested_groups.hurl | 10 +- 45 files changed, 1906 insertions(+), 81 deletions(-) create mode 100644 migrations/20260624000000_users_notify_on_share.sql create mode 100644 src/application/services/recipient_notification_service.rs diff --git a/docs/config/env.md b/docs/config/env.md index 57579572..e797861f 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -216,6 +216,7 @@ Configures the invite-by-email and login-via-email flows. Both require SMTP to b | `OXICLOUD_MAGIC_LINK_TTL_HOURS` | `24` | Lifetime of a freshly-minted magic-link token, in hours | | `OXICLOUD_ALLOW_EXTERNAL_USERS` | `true` | Kill switch for the whole flow. `false` makes `POST /api/grants` reject `subject.type = "email"` for unknown addresses and `POST /api/auth/magic-link/send` return its uniform stub without issuing a token. | | `OXICLOUD_EXTERNAL_EMAIL_DOMAINS` | — | Comma-separated allowlist of email domains accepted when minting a new external user (case-insensitive, exact match on the post-`@` part). Empty = any domain is allowed, subject to `OXICLOUD_ALLOW_EXTERNAL_USERS`. Subdomains must be listed explicitly: `partner.com` does NOT match `eng.partner.com`. Example: `partner-a.com,partner-b.io`. | +| `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE` | `true` | Operator-level kill switch for the **plain-notification** email arm — the "Alice shared 'Project Alpha' with you" mail that fires when the recipient is a password user or OIDC user (i.e. not magic-link eligible). `false` suppresses the arm entirely; internal users discover new shares only at next login. 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 **first-invitations** are unaffected and always send. | ## Internationalization (server-rendered surfaces) diff --git a/example.env b/example.env index f2e0c99e..bbff9ffb 100644 --- a/example.env +++ b/example.env @@ -416,6 +416,26 @@ OXICLOUD_WOPI_ENABLED=false # IdP is the security boundary and may enforce MFA we shouldn't bypass. #OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=false +# Operator-level kill switch for share-notification emails to internal +# users (the "Alice shared 'Project Alpha' with you" mail that fires when +# `magic_link_eligibility` rejects the recipient — typically password +# users and OIDC users with a known email). When `true` (default), the +# new RecipientNotificationService dispatches plain-notification mail +# on every share. When `false`, internal users discover new shares only +# at next login. +# +# This is a coarser knob than the per-user +# `auth.users.notify_on_share` column (set via the profile "Email me +# when someone shares with me" checkbox): when this env is `false`, +# the per-user opt-in does not matter. +# +# External-user magic-link FIRST-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. Subsequent shares +# to an existing external follow the same plain-notification path and +# are subject to both this knob and the per-user opt-out. +#OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE=true + # ----------------------------------------------------------------------------- # INTERNATIONALIZATION (server-rendered surfaces) # ----------------------------------------------------------------------------- diff --git a/migrations/20260624000000_users_notify_on_share.sql b/migrations/20260624000000_users_notify_on_share.sql new file mode 100644 index 00000000..62c22d15 --- /dev/null +++ b/migrations/20260624000000_users_notify_on_share.sql @@ -0,0 +1,38 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- Per-user opt-out for share-notification emails (PR N1, share-notification +-- pipeline). Pairs with the operator kill switch +-- `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE` — the env flag affects all +-- internal-user sends; this column scopes the decision to one recipient. +-- ════════════════════════════════════════════════════════════════════════════ +-- TRUE = the user wants email when someone shares a resource with them +-- (default for both pre-existing and freshly-created rows). +-- FALSE = the user has unchecked the profile checkbox; the share grant is +-- still created normally, but `RecipientNotificationService` returns +-- `NotApplicable { reason: "recipient_opted_out" }` and no mail is +-- dispatched. The granter sees a clear toast. +-- +-- Applies uniformly to the plain-notification arm — the path that fires for +-- internal users, OIDC users, and password users. Magic-link invitations to +-- newly-provisioned external users always send regardless of this column, +-- because the link is the only way the external user can sign in for the +-- first time; suppressing it would lock them out of the share entirely. +-- (Once they have an account and have opted out, subsequent shares from +-- other granters do honor the flag.) +-- +-- DEFAULT TRUE matches the pre-PR-N1 behaviour for external users (they +-- always received invitations); for internal users it ships the new +-- "you've been shared a folder" notification turned on by default. A +-- noisier inbox is the trade-off; the checkbox is the safety valve. + +ALTER TABLE auth.users + ADD COLUMN notify_on_share BOOLEAN NOT NULL DEFAULT TRUE; + +COMMENT ON COLUMN auth.users.notify_on_share IS + 'Per-user opt-out for share-notification emails. TRUE (default) = + receive an email when someone grants access to a resource; + FALSE = grant still created, but no mail is sent + (RecipientNotificationService returns NotApplicable). The + operator-level kill switch OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE + is a separate, broader knob; this column is the per-user fine + grain. Magic-link first-invitations to externals bypass the + check — see the column comment for the rationale.'; diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs index 4d551c34..ac74a225 100644 --- a/src/application/dtos/grant_dto.rs +++ b/src/application/dtos/grant_dto.rs @@ -262,6 +262,98 @@ impl From 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, + }, + /// 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, +} + +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) -> 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` shape; the frontend share modal is updated in +/// lockstep. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct CreateGrantResponseDto { + pub grants: Vec, + pub notification: NotifyOutcomeSetDto, +} + // ════════════════════════════════════════════════════════════════════════════ // Shared-with-me DTOs (GET /api/grants/incoming/resources) // ════════════════════════════════════════════════════════════════════════════ @@ -372,6 +464,13 @@ pub struct OutgoingResourceGrantDto { pub expires_at: Option>, /// 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. diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index abe91b79..6604f52f 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -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, + /// 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 for UserDto { @@ -76,6 +84,7 @@ impl From 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, + /// 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, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 0672c8a6..57f9782c 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -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; + /// 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) -> Result, DomainError>; + /// Gets a user by username async fn get_user_by_username(&self, username: &str) -> Result; diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 8bae16a0..007626b9 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -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 { + 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 diff --git a/src/application/services/magic_link_invite_service.rs b/src/application/services/magic_link_invite_service.rs index bb7bd816..5c064b32 100644 --- a/src/application/services/magic_link_invite_service.rs +++ b/src/application/services/magic_link_invite_service.rs @@ -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 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), diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 59e938f2..7fa82755 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -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; diff --git a/src/application/services/recipient_notification_service.rs b/src/application/services/recipient_notification_service.rs new file mode 100644 index 00000000..fce62b27 --- /dev/null +++ b/src/application/services/recipient_notification_service.rs @@ -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 }, + /// 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, +} + +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, + magic_link_service: Arc, + email_sender: Arc, + i18n: Arc, + locale_registry: Arc, + subject_groups: Arc, + /// 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>, + /// 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, + magic_link_cfg: MagicLinkConfig, + public_base_url: String, +} + +impl RecipientNotificationService { + #[allow(clippy::too_many_arguments)] + pub fn new( + user_storage: Arc, + magic_link_service: Arc, + email_sender: Arc, + i18n: Arc, + locale_registry: Arc, + subject_groups: Arc, + per_email_limiter: Arc, + 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 { + // Resolve subject → Vec. 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, 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, +} + +#[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" + ); + } +} diff --git a/src/common/config.rs b/src/common/config.rs index 3ce797cb..5a3963f9 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -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(); diff --git a/src/common/di.rs b/src/common/di.rs index bac3b96e..6a7a0e71 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -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, >, + /// 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, + >, /// 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 diff --git a/src/domain/entities/user.rs b/src/domain/entities/user.rs index b4c8806d..22417cd8 100644 --- a/src/domain/entities/user.rs +++ b/src/domain/entities/user.rs @@ -85,6 +85,16 @@ pub struct User { /// application layer is the authoritative gatekeeper against the /// `LocaleRegistry`. preferred_locale: Option, + /// 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, email_verified_at: Option>, preferred_locale: Option, + 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 + /// shared X with you" — anywhere a human is reading the line). + /// + /// `with_email` controls whether the address is appended as + /// `" "` after the name part: + /// - `true` — best for the email **body** ("Alice Smith + /// 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 + /// `` decoration in cases 1 and 3 is omitted when + /// `with_email` is false: + /// + /// 1. `"Given Family"` (+ ` `) — full name; the most + /// informative form. + /// 2. `"username"` (+ ` `) — 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 "); + 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 "); + 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 "; + // 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 "); + 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 " half-name. + let u = build_user(Some("carol"), Some("Carol"), None, "carol@x.com"); + assert_eq!(u.display_full(true), "carol "); + 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"); + } +} diff --git a/src/domain/repositories/user_repository.rs b/src/domain/repositories/user_repository.rs index 81816371..d129f2d1 100644 --- a/src/domain/repositories/user_repository.rs +++ b/src/domain/repositories/user_repository.rs @@ -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; + /// 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) -> UserRepositoryResult>; + /// Gets a user by username async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult; diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index ce4ccd77..2e095e59 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -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, diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index 9dcb1df3..c64c7f9a 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -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) -> UserRepositoryResult> { + 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 = 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 { // 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) -> Result, DomainError> { + UserRepository::get_users_by_ids(self, ids) + .await + .map_err(DomainError::from) + } + async fn get_user_by_username(&self, username: &str) -> Result { UserRepository::get_user_by_username(self, username) .await diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index ca61176c..b86b6357 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -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, 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 // 11 sort_int Option // 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, Option, 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); diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index cbb065b2..adc8c5c3 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -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; path = "/api/grants", request_body = CreateGrantDto, responses( - (status = 201, description = "Grant(s) created", body = Vec), + (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, + auth_user: AuthUser, + Path(id): Path, +) -> 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(); diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 8469e43f..4baff7c7 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -318,6 +318,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .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( diff --git a/static/js/components/mySharesList.js b/static/js/components/mySharesList.js index 1341f08c..9b707434 100644 --- a/static/js/components/mySharesList.js +++ b/static/js/components/mySharesList.js @@ -9,6 +9,7 @@ * 'sharedWith' — lane = user | 'links:public' | 'links:password'; row identity = resource */ +import { getCsrfHeaders } from '../core/csrf.js'; import { formatExpiryChip } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { fileSharing } from '../features/sharing/fileSharing.js'; @@ -432,6 +433,24 @@ class MySharesList { const initialExpiry = grant.expires_at ? String(grant.expires_at).slice(0, 10) : null; if (grant.subject_type === 'user' || grant.subject_type === 'group') { + // PR N2 — "Resend invitation email" / "Notify by email" / + // "Notify group members". First item in the menu; only + // present for user and group subjects (token shares have + // no email channel; the server returns 409 anyway). + const notifyLabel = + grant.subject_type === 'group' + ? i18n.t('myshares.notifyGroupMembers', 'Notify group members') + : grant.is_external + ? i18n.t('myshares.resendInvitation', 'Resend invitation email') + : i18n.t('myshares.notifyByEmail', 'Notify by email'); + menu.appendChild( + this._menuItem('fas fa-paper-plane', notifyLabel, false, async () => { + menu.remove(); + await this._notifyRecipient(grant); + }) + ); + menu.appendChild(this._menuSeparator()); + for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) { const isCurrent = grant.role === role; const mi = this._menuItem(isCurrent ? 'fas fa-check' : '', roleLabel(role), false, async () => { @@ -554,6 +573,68 @@ class MySharesList { return row; } + /** + * PR N2 — manual share-notification resend. Calls + * `POST /api/grants/{grant_id}/notify` and surfaces the aggregated + * outcome to the granter. The endpoint returns: + * - 204 No Content — all recipients sent + * - 200 + NotifyOutcomeSetDto — mixed outcomes (coalesced / + * not-applicable / partial sent) + * - 429 Too Many Requests — per-recipient rate limit hit on every + * recipient + * - 404 Not Found — caller is not the granter, or grant + * doesn't exist (anti-enumeration; the audit log carries the + * truth) + * - 409 Conflict — token subject (UI shouldn't reach this) + * + * @param {OutgoingResourceGrant} grant + */ + async _notifyRecipient(grant) { + try { + const resp = await fetch(`/api/grants/${encodeURIComponent(grant.grant_id)}/notify`, { + method: 'POST', + credentials: 'same-origin', + headers: { ...getCsrfHeaders() } + }); + if (resp.status === 204) { + // All sent — silent success. + console.log('[myshares] notify: all recipients sent', grant.grant_id); + return; + } + if (resp.status === 429) { + // eslint-disable-next-line no-alert -- minimal v1 surface + alert(i18n.t('myshares.notifyRateLimited', 'Too many notifications for this recipient — try again later.')); + return; + } + if (resp.ok) { + /** @type {{ total_recipients: number, outcomes: Array<{kind: string, detail?: string, reason?: string}> }} */ + const body = await resp.json(); + console.log('[myshares] notify outcomes:', body); + const sent = body.outcomes.filter((o) => o.kind === 'sent').length; + const coalesced = body.outcomes.filter((o) => o.kind === 'coalesced').length; + const notApplicable = body.outcomes.filter((o) => o.kind === 'not_applicable').length; + /** @type {string[]} */ + const lines = []; + if (sent > 0) lines.push(`${sent} recipient(s) notified by email.`); + if (coalesced > 0) lines.push(`${coalesced} recipient(s) already notified recently — they'll see the share at next login.`); + if (notApplicable > 0) lines.push(`${notApplicable} recipient(s) skipped (opted out, no email, or operator-disabled).`); + if (lines.length > 0) { + // eslint-disable-next-line no-alert -- minimal v1 surface + alert(lines.join('\n')); + } + return; + } + // 404 / 409 / unexpected + console.error('[myshares] notify failed:', resp.status); + // eslint-disable-next-line no-alert -- minimal v1 surface + alert(i18n.t('myshares.notifyFailed', 'Could not send notification.')); + } catch (err) { + console.error('[myshares] notify error:', err); + // eslint-disable-next-line no-alert -- minimal v1 surface + alert(i18n.t('myshares.notifyFailed', 'Could not send notification.')); + } + } + /** * Non-closing password row embedded in the link context menu. * Saves immediately on confirm (blur / Enter). diff --git a/static/js/components/shareModal.js b/static/js/components/shareModal.js index 4706ec92..2b3febdd 100644 --- a/static/js/components/shareModal.js +++ b/static/js/components/shareModal.js @@ -1004,6 +1004,13 @@ const shareModal = { const item = this._item; const itemType = this._itemType; + // Accumulate notification outcomes across all create-grant calls + // in this apply round so the post-apply summary aggregates ("3 + // recipients notified, 1 already notified recently") rather than + // showing one toast per granted member. + /** @type {Array<{kind: string, detail?: string, last_sent_at?: string, retry_after_secs?: number, reason?: string}>} */ + const notifyOutcomes = []; + try { // ── Grants ───────────────────────────────────────────────────────── for (const m of this._localMembers) { @@ -1028,12 +1035,17 @@ const shareModal = { // user_id in the grant DTO. Until `fetchOutgoingGrants` // refreshes below, the row keeps the pending vignette. const subject = m._invitedEmail ? { type: 'email', email: m._invitedEmail } : { type: m.grant.subject.type, id: m.grant.subject.id }; - await grants.createGrant({ + const result = await grants.createGrant({ subject, resource: { type: itemType, id: item.id }, role: m.role, expires_at: expiresIso }); + // PR N1: collect per-recipient notification outcomes + // so we can show one aggregated summary after the loop. + if (result?.notification?.outcomes) { + notifyOutcomes.push(...result.notification.outcomes); + } } } @@ -1076,6 +1088,16 @@ const shareModal = { Modal.close(true); this._onApplied?.(); + + // PR N1: surface share-notification outcomes. The granter + // needs to know whether the recipient actually got an email + // (or was silently coalesced / rate-limited / opted out). + // Without a project-wide toast component the cheapest + // honest signal is a console log + a one-shot alert() for + // the non-success states. A proper toast surface lands in + // a small follow-up; the backend data is correct, the UI + // is just brief. + _surfaceNotifySummary(notifyOutcomes); } catch (err) { console.error('shareModal._applyAll error:', err); if (Modal.confirmBtn) Modal.confirmBtn.disabled = false; @@ -1083,4 +1105,54 @@ const shareModal = { } }; +/** + * Show a one-shot aggregated summary of share-notification outcomes + * after a batch of create-grant calls. v1 surface is minimal — logs + * everything to the console for traceability and pops a single alert() + * only when at least one recipient was coalesced, rate-limited, or + * landed on the not-applicable arm (i.e. the granter SHOULD know the + * email didn't go). The all-Sent happy path stays silent because the + * modal-close already implies success. + * + * A proper toast component is deferred; this function is the seam to + * upgrade later — replace the alert() body, keep the call site. + * + * @param {Array<{kind: string, detail?: string, last_sent_at?: string, retry_after_secs?: number, reason?: string}>} outcomes + */ +function _surfaceNotifySummary(outcomes) { + if (!outcomes || outcomes.length === 0) return; + + // Always log — useful in dev tools regardless of the alert path. + console.log('[share] notification outcomes:', outcomes); + + const sent = outcomes.filter((o) => o.kind === 'sent').length; + const coalesced = outcomes.filter((o) => o.kind === 'coalesced').length; + const rateLimited = outcomes.filter((o) => o.kind === 'rate_limited').length; + const notApplicable = outcomes.filter((o) => o.kind === 'not_applicable'); + + // Happy path — all sent. Stay silent; the closed modal is the toast. + if (coalesced === 0 && rateLimited === 0 && notApplicable.length === 0) return; + + /** @type {string[]} */ + const lines = []; + if (sent > 0) { + lines.push(`${sent} recipient(s) notified by email.`); + } + if (coalesced > 0) { + lines.push(`${coalesced} recipient(s) already notified recently — they'll see the share at next login.`); + } + if (rateLimited > 0) { + lines.push(`${rateLimited} recipient(s) hit the notification rate limit — try again later.`); + } + if (notApplicable.length > 0) { + const reasons = notApplicable + .map((o) => o.reason) + .filter((r, i, arr) => r && arr.indexOf(r) === i) + .join(', '); + lines.push(`${notApplicable.length} recipient(s) skipped (${reasons || 'unknown'}).`); + } + // eslint-disable-next-line no-alert -- minimal v1 surface; toast component lands as follow-up + alert(lines.join('\n')); +} + export { shareModal }; diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 15b56daf..0bc61c40 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -338,6 +338,10 @@ const OxiIcons = { 576, 'm 360.55,24 v 72 h 64 c 79.5,0 144,64.5 144,144 0,93.4 -82.8,134.8 -100.6,142.6 -2.2,1 -4.6,1.4 -7.1,1.4 h -2.5 c -9.8,0 -17.8,-8 -17.8,-17.8 0,-8.3 5.9,-15.5 12.8,-20.3 8.9,-6.2 19.2,-18.2 19.2,-40.5 0,-45 -36.5,-81.5 -81.5,-81.5 h -30.5 v 72 c 0,9.7 -5.8,18.5 -14.8,22.2 -9,3.7 -19.3,1.7 -26.2,-5.2 l -136,-136 c -9.4,-9.4 -9.4,-24.6 0,-33.9 l 136,-136 c 6.9,-6.9 17.2,-8.9 26.2,-5.2 9,3.7 14.8,12.5 14.8,22.2 z M 112.5,96 c -44.2,0 -80,35.8 -80,80 v 256 c 0,44.2 35.8,80 80,80 h 256 c 44.2,0 80,-35.8 80,-80 v -32 c 0,-17.7 -14.3,-32 -32,-32 -17.7,0 -32,14.3 -32,32 v 32 c 0,8.8 -7.2,16 -16,16 h -256 c -8.8,0 -16,-7.2 -16,-16 V 176 c 0,-8.8 7.2,-16 16,-16 h 16 c 17.7,0 32,-14.3 32,-32 0,-17.7 -14.3,-32 -32,-32 z' ], + 'paper-plane': [ + 576, + 'M290.5 287.7L491.4 86.9 359 456.3 290.5 287.7zM457.4 53L256.6 253.8 88 185.3 457.4 53zM38.1 216.8l205.8 83.6 83.6 205.8c5.3 13.1 18.1 21.7 32.3 21.7 14.7 0 27.8-9.2 32.8-23.1L570.6 8c3.5-9.8 1-20.6-6.3-28s-18.2-9.8-28-6.3L39.4 151.7c-13.9 5-23.1 18.1-23.1 32.8 0 14.2 8.6 27 21.7 32.3z' + ], pause: [ 384, 'M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z' diff --git a/static/js/core/types.js b/static/js/core/types.js index b74c5078..4c16fca2 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -166,6 +166,7 @@ * @property {string} [family_name] Last/family name; set at OIDC JIT or via PATCH /api/auth/me/profile (PR 24) * @property {string} [email_verified_at] ISO 8601 timestamp of the first proof-of-email-control (PR 23). Omitted when unverified. * @property {string} [preferred_locale] User-chosen locale code (e.g. `"fr"`, `"zh-TW"`); omitted when unset. Round-trips via PATCH /api/auth/me/profile. + * @property {boolean} notify_on_share Whether the user wants share-notification emails ("Alice shared X with you"). Default TRUE. Toggled via the profile checkbox; round-trips via PATCH /api/auth/me/profile. */ /** @@ -365,6 +366,7 @@ * @property {string} granted_at - ISO-8601 * @property {string|null} [expires_at] - ISO-8601 or absent. * @property {boolean} has_password - True when a token subject has a password set. + * @property {boolean} [is_external] - True when a user subject is a magic-link-only external user (PR N2). Drives the My Shares menu label ("Resend invitation email" vs "Notify by email"). Always false for token and group subjects. */ /** diff --git a/static/js/model/grants.js b/static/js/model/grants.js index f3b6b7de..a6890ff0 100644 --- a/static/js/model/grants.js +++ b/static/js/model/grants.js @@ -166,8 +166,26 @@ const grants = { * Create a new grant. * Body mirrors `CreateGrantDto`: `{ subject, resource, role }` OR `{ subject, resource, permissions }`. * + * Response shape (PR N1 — `CreateGrantResponseDto`): + * + * ```json + * { + * "grants": [ {Grant}, … ], + * "notification": { + * "total_recipients": 1, + * "outcomes": [{ "kind": "sent", "detail": "plain_notification" }] + * } + * } + * ``` + * + * `notification.outcomes` is empty for token subjects; size 1 for + * user subjects; size N for group subjects (one entry per resolved + * member). Callers that just need the grant rows can `.grants`; + * callers that want to surface "did Carol get my email?" UX read + * `.notification.outcomes[]`. + * * @param {Object} dto - CreateGrantDto shape - * @returns {Promise} + * @returns {Promise<{ grants: Grant[], notification: { total_recipients: number, outcomes: Array<{kind: string, detail?: string, last_sent_at?: string, retry_after_secs?: number, reason?: string}> } }>} */ async createGrant(dto) { const response = await fetch('/api/grants', { diff --git a/static/js/views/profile/profile.js b/static/js/views/profile/profile.js index a14521bd..0f60a4b1 100644 --- a/static/js/views/profile/profile.js +++ b/static/js/views/profile/profile.js @@ -645,6 +645,14 @@ function _renderProfileEdit(user) { } givenInput.value = user.given_name || ''; familyInput.value = user.family_name || ''; + + const notifyInput = /** @type {HTMLInputElement | null} */ (document.getElementById('profile-edit-notify-on-share')); + if (notifyInput) { + // notify_on_share is a boolean on the server; default TRUE for + // pre-existing rows via the column default, so the checkbox is + // ticked unless the user has explicitly opted out. + notifyInput.checked = user.notify_on_share !== false; + } } /** @@ -665,7 +673,7 @@ async function submitProfile(e) { const givenInput = /** @type {HTMLInputElement} */ (document.getElementById('profile-edit-given-name')); const familyInput = /** @type {HTMLInputElement} */ (document.getElementById('profile-edit-family-name')); - /** @type {{ username?: string, given_name?: string, family_name?: string }} */ + /** @type {{ username?: string, given_name?: string, family_name?: string, notify_on_share?: boolean }} */ const body = {}; if (!usernameInput.disabled && usernameInput.value.trim()) { body.username = usernameInput.value.trim(); @@ -675,6 +683,15 @@ async function submitProfile(e) { const family = familyInput.value.trim(); if (family) body.family_name = family; + // Always send the share-notification preference. The backend + // compares against the current value and skips the write if + // unchanged, so this is idempotent — sending it on every save + // simplifies the frontend rather than tracking a dirty bit. + const notifyInput = /** @type {HTMLInputElement | null} */ (document.getElementById('profile-edit-notify-on-share')); + if (notifyInput) { + body.notify_on_share = notifyInput.checked; + } + if (Object.keys(body).length === 0) { statusEl.innerHTML = `
${escapeHtml(i18n.t('profile.profile_no_changes'))}
`; return false; diff --git a/static/locales/ar.json b/static/locales/ar.json index 31f2460d..dec9df29 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "شارك {{inviter}} معك {{kind}} على OxiCloud", - "body": "شارك {{inviter}} معك {{kind}} على OxiCloud.\n\nافتحه بالنقر على الرابط أدناه:\n{{link}}\n\nيعمل الرابط مرة واحدة وتنتهي صلاحيته خلال {{ttl_hours}} ساعة.\nإذا لم تكن تتوقع هذه الدعوة، يمكنك تجاهل هذه الرسالة.\n\n— OxiCloud" + "body": "شارك {{inviter_full}} معك {{kind}} على OxiCloud.\n\nافتحه بالنقر على الرابط أدناه:\n{{link}}\n\nيعمل الرابط مرة واحدة وتنتهي صلاحيته خلال {{ttl_hours}} ساعة.\nإذا لم تكن تتوقع هذه الدعوة، يمكنك تجاهل هذه الرسالة.\n\n— OxiCloud" }, "login": { "subject": "تسجيل الدخول إلى OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "مجلد", "english_fallback_divider": "--- النسخة الإنجليزية أدناه ---" } + }, + "notification": { + "share": { + "subject": "شارك {{inviter}} معك {{kind}} على OxiCloud", + "body": "شارك {{inviter_full}} معك {{kind}} على OxiCloud.\n\nافتح OxiCloud لعرض مشاركتك الجديدة:\n{{login_link}}\n\nقد تكون لديك مشاركات جديدة أخرى من {{inviter}} — سجّل الدخول لرؤية جميع العناصر المشاركة معك.\n\n— OxiCloud\n\nأنت تتلقى هذه الرسالة لأن لديك حسابًا في OxiCloud وتفضيل إشعارات المشاركة مُفعّل. يمكنك تعطيله من ملفك الشخصي (راسلني عندما يشاركني شخص ما)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "اسم المستخدم محدد ولا يمكن تغييره (عملاء DAV/NextCloud يعتمدون عليه).", "given_name": "الاسم الأول", "family_name": "اسم العائلة", + "notify_on_share": "أرسل لي بريدًا إلكترونيًا عندما يشاركني شخص ما", + "notify_on_share_hint": "عند إلغاء التحديد، ستظل المشاركات تظهر في حسابك — لن تتلقى فقط بريدًا إلكترونيًا بشأنها.", "save_profile": "حفظ التغييرات", "profile_saved": "تم تحديث الملف الشخصي", "profile_no_changes": "لا توجد تغييرات لحفظها.", diff --git a/static/locales/de.json b/static/locales/de.json index ac850ddb..f2ad3726 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt", - "body": "{{inviter}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt.\n\nÖffnen Sie ihn, indem Sie auf den folgenden Link klicken:\n{{link}}\n\nDer Link kann nur einmal verwendet werden und läuft in {{ttl_hours}} Stunden ab.\nFalls Sie diese Einladung nicht erwartet haben, können Sie diese Nachricht ignorieren.\n\n— OxiCloud" + "body": "{{inviter_full}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt.\n\nÖffnen Sie ihn, indem Sie auf den folgenden Link klicken:\n{{link}}\n\nDer Link kann nur einmal verwendet werden und läuft in {{ttl_hours}} Stunden ab.\nFalls Sie diese Einladung nicht erwartet haben, können Sie diese Nachricht ignorieren.\n\n— OxiCloud" }, "login": { "subject": "Anmeldung bei OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "Ordner", "english_fallback_divider": "--- Englische Version unten ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt", + "body": "{{inviter_full}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt.\n\nÖffnen Sie OxiCloud, um Ihre neue Freigabe zu sehen:\n{{login_link}}\n\nMöglicherweise gibt es weitere neue Freigaben von {{inviter}} — melden Sie sich an, um alle Ihre freigegebenen Elemente zu sehen.\n\n— OxiCloud\n\nSie erhalten diese Nachricht, weil Sie ein OxiCloud-Konto haben und die Benachrichtigung über Freigaben aktiviert ist. Sie können sie in Ihrem Profil deaktivieren (Per E-Mail benachrichtigen, wenn jemand mit mir teilt)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "Benutzername ist gesetzt und kann nicht geändert werden (DAV/NextCloud-Clients hängen davon ab).", "given_name": "Vorname", "family_name": "Nachname", + "notify_on_share": "Mich per E-Mail benachrichtigen, wenn jemand mit mir teilt", + "notify_on_share_hint": "Wenn deaktiviert, werden Freigaben weiterhin in deinem Konto angezeigt — du erhältst nur keine E-Mail dazu.", "save_profile": "Änderungen speichern", "profile_saved": "Profil aktualisiert", "profile_no_changes": "Keine Änderungen zu speichern.", diff --git a/static/locales/en.json b/static/locales/en.json index baa27ecd..b2636368 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -30,12 +30,28 @@ "kind_folder": "folder", "english_fallback_divider": "--- English version below ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen OxiCloud to see your new share:\n{{login_link}}\n\nYou may have additional new shares from {{inviter}} — sign in to see all your shared items.\n\n— OxiCloud\n\nYou're receiving this message because you have an OxiCloud account and your share-notification preference is on. You can turn it off in your profile (Email me when someone shares with me)." + } } }, "app": { "title": "OxiCloud", "description": "Minimalist cloud storage system" }, + "myshares": { + "resendInvitation": "Resend invitation email", + "notifyByEmail": "Notify by email", + "notifyGroupMembers": "Notify group members", + "notifyRateLimited": "Too many notifications for this recipient — try again later.", + "notifyFailed": "Could not send notification.", + "removeAccess": "Remove access", + "copyLink": "Copy link", + "deleteLink": "Delete link" + }, "nav": { "files": "Files", "shared": "My shares", @@ -759,6 +775,8 @@ "username_already_claimed": "Username is set and can't be changed (DAV/NextCloud clients depend on it).", "given_name": "First name", "family_name": "Last name", + "notify_on_share": "Email me when someone shares with me", + "notify_on_share_hint": "When unchecked, shares still appear in your account — you just won't get an email about them.", "save_profile": "Save changes", "profile_saved": "Profile updated", "profile_no_changes": "No changes to save.", diff --git a/static/locales/es.json b/static/locales/es.json index 13d8aa89..7b459ab3 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} ha compartido un {{kind}} contigo en OxiCloud", - "body": "{{inviter}} ha compartido un {{kind}} contigo en OxiCloud.\n\nÁbrelo haciendo clic en el enlace de abajo:\n{{link}}\n\nEl enlace es de un solo uso y expira en {{ttl_hours}} horas.\nSi no esperabas esta invitación, puedes ignorar este mensaje.\n\n— OxiCloud" + "body": "{{inviter_full}} ha compartido un {{kind}} contigo en OxiCloud.\n\nÁbrelo haciendo clic en el enlace de abajo:\n{{link}}\n\nEl enlace es de un solo uso y expira en {{ttl_hours}} horas.\nSi no esperabas esta invitación, puedes ignorar este mensaje.\n\n— OxiCloud" }, "login": { "subject": "Inicia sesión en OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "carpeta", "english_fallback_divider": "--- Versión en inglés a continuación ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} ha compartido un {{kind}} contigo en OxiCloud", + "body": "{{inviter_full}} ha compartido un {{kind}} contigo en OxiCloud.\n\nAbre OxiCloud para ver tu nuevo recurso compartido:\n{{login_link}}\n\nPuede que tengas más recursos compartidos nuevos de {{inviter}} — inicia sesión para ver todos tus elementos compartidos.\n\n— OxiCloud\n\nRecibes este mensaje porque tienes una cuenta de OxiCloud y la preferencia de notificación de recursos compartidos está activada. Puedes desactivarla en tu perfil (Enviarme un correo cuando alguien comparta conmigo)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "Nombre de usuario fijado y no modificable (los clientes DAV/NextCloud dependen de él).", "given_name": "Nombre", "family_name": "Apellidos", + "notify_on_share": "Enviarme un correo cuando alguien comparta conmigo", + "notify_on_share_hint": "Cuando esté desmarcado, los recursos compartidos seguirán apareciendo en tu cuenta — simplemente no recibirás un correo sobre ellos.", "save_profile": "Guardar cambios", "profile_saved": "Perfil actualizado", "profile_no_changes": "Sin cambios que guardar.", diff --git a/static/locales/fa.json b/static/locales/fa.json index 294249fc..1e7dc968 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت", - "body": "{{inviter}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت.\n\nبا کلیک روی پیوند زیر آن را باز کنید:\n{{link}}\n\nپیوند یک‌بار مصرف است و در {{ttl_hours}} ساعت منقضی می‌شود.\nاگر منتظر این دعوت نبودید، می‌توانید این پیام را نادیده بگیرید.\n\n— OxiCloud" + "body": "{{inviter_full}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت.\n\nبا کلیک روی پیوند زیر آن را باز کنید:\n{{link}}\n\nپیوند یک‌بار مصرف است و در {{ttl_hours}} ساعت منقضی می‌شود.\nاگر منتظر این دعوت نبودید، می‌توانید این پیام را نادیده بگیرید.\n\n— OxiCloud" }, "login": { "subject": "ورود به OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "پوشه", "english_fallback_divider": "--- نسخهٔ انگلیسی در پایین ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت", + "body": "{{inviter_full}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت.\n\nبرای دیدن اشتراک‌گذاری جدید خود، OxiCloud را باز کنید:\n{{login_link}}\n\nممکن است اشتراک‌گذاری‌های جدید دیگری از {{inviter}} داشته باشید — وارد شوید تا همه موارد به اشتراک گذاشته‌شده با خود را ببینید.\n\n— OxiCloud\n\nشما این پیام را دریافت می‌کنید زیرا حساب OxiCloud دارید و گزینه اعلان اشتراک‌گذاری شما روشن است. می‌توانید آن را در پروفایل خود خاموش کنید (وقتی کسی با من چیزی به اشتراک می‌گذارد، به من ایمیل بزن)." + } } }, "app": { @@ -725,6 +731,8 @@ "username_already_claimed": "نام کاربری تنظیم شده و قابل تغییر نیست (کلاینت‌های DAV/NextCloud به آن وابسته‌اند).", "given_name": "نام", "family_name": "نام خانوادگی", + "notify_on_share": "وقتی کسی با من چیزی به اشتراک می‌گذارد، به من ایمیل بزن", + "notify_on_share_hint": "وقتی تیک‌خورده نباشد، اشتراک‌گذاری‌ها همچنان در حساب شما نمایش داده می‌شوند — فقط ایمیلی درباره آنها دریافت نخواهید کرد.", "save_profile": "ذخیره تغییرات", "profile_saved": "نمایه به‌روز شد", "profile_no_changes": "تغییری برای ذخیره وجود ندارد.", diff --git a/static/locales/fr.json b/static/locales/fr.json index 6b15e381..10b4d59e 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} a partagé un {{kind}} avec vous sur OxiCloud", - "body": "{{inviter}} a partagé un {{kind}} avec vous sur OxiCloud.\n\nOuvrez-le en cliquant sur le lien ci-dessous :\n{{link}}\n\nLe lien est à usage unique et expire dans {{ttl_hours}} heures.\nSi vous n'attendiez pas cette invitation, vous pouvez ignorer ce message.\n\n— OxiCloud" + "body": "{{inviter_full}} a partagé un {{kind}} avec vous sur OxiCloud.\n\nOuvrez-le en cliquant sur le lien ci-dessous :\n{{link}}\n\nLe lien est à usage unique et expire dans {{ttl_hours}} heures.\nSi vous n'attendiez pas cette invitation, vous pouvez ignorer ce message.\n\n— OxiCloud" }, "login": { "subject": "Connexion à OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "dossier", "english_fallback_divider": "--- Version anglaise ci-dessous ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} a partagé un {{kind}} avec vous sur OxiCloud", + "body": "{{inviter_full}} a partagé un {{kind}} avec vous sur OxiCloud.\n\nOuvrez OxiCloud pour voir votre nouveau partage :\n{{login_link}}\n\nVous avez peut-être d'autres nouveaux partages de {{inviter}} — connectez-vous pour voir tous vos éléments partagés.\n\n— OxiCloud\n\nVous recevez ce message parce que vous avez un compte OxiCloud et que la préférence de notification de partage est activée. Vous pouvez la désactiver dans votre profil (M'avertir par e-mail quand quelqu'un partage avec moi)." + } } }, "app": { @@ -759,6 +765,8 @@ "username_already_claimed": "Nom d'utilisateur fixé et non modifiable (les clients DAV/NextCloud en dépendent).", "given_name": "Prénom", "family_name": "Nom", + "notify_on_share": "M'avertir par e-mail quand quelqu'un partage avec moi", + "notify_on_share_hint": "Lorsque décoché, les partages apparaissent toujours dans votre compte — vous ne recevrez simplement pas d'e-mail à leur sujet.", "save_profile": "Enregistrer", "profile_saved": "Profil mis à jour", "profile_no_changes": "Aucun changement à enregistrer.", diff --git a/static/locales/hi.json b/static/locales/hi.json index b5c007bc..32d7aee4 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया", - "body": "{{inviter}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया है।\n\nइसे नीचे दिए गए लिंक पर क्लिक करके खोलें:\n{{link}}\n\nलिंक केवल एक बार काम करता है और {{ttl_hours}} घंटों में समाप्त हो जाता है।\nयदि आप इस आमंत्रण की अपेक्षा नहीं कर रहे थे, तो आप इस संदेश को अनदेखा कर सकते हैं।\n\n— OxiCloud" + "body": "{{inviter_full}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया है।\n\nइसे नीचे दिए गए लिंक पर क्लिक करके खोलें:\n{{link}}\n\nलिंक केवल एक बार काम करता है और {{ttl_hours}} घंटों में समाप्त हो जाता है।\nयदि आप इस आमंत्रण की अपेक्षा नहीं कर रहे थे, तो आप इस संदेश को अनदेखा कर सकते हैं।\n\n— OxiCloud" }, "login": { "subject": "OxiCloud में साइन इन करें", @@ -30,6 +30,12 @@ "kind_folder": "फ़ोल्डर", "english_fallback_divider": "--- अंग्रेज़ी संस्करण नीचे ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया", + "body": "{{inviter_full}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया है।\n\nअपना नया साझाकरण देखने के लिए OxiCloud खोलें:\n{{login_link}}\n\nहो सकता है आपके पास {{inviter}} से और भी नए साझाकरण हों — साइन इन करें और अपने सभी साझा किए गए आइटम देखें।\n\n— OxiCloud\n\nआपको यह संदेश इसलिए मिल रहा है क्योंकि आपका OxiCloud खाता है और साझाकरण-सूचना प्राथमिकता चालू है। आप इसे अपनी प्रोफ़ाइल में बंद कर सकते हैं (जब कोई मेरे साथ साझा करे तो मुझे ईमेल भेजें)।" + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "उपयोगकर्ता नाम सेट है और बदला नहीं जा सकता (DAV/NextCloud क्लाइंट इस पर निर्भर करते हैं)।", "given_name": "प्रथम नाम", "family_name": "अंतिम नाम", + "notify_on_share": "जब कोई मेरे साथ साझा करे तो मुझे ईमेल भेजें", + "notify_on_share_hint": "जब अनचेक किया जाए, तो साझाकरण आपके खाते में दिखाई देते रहेंगे — आपको बस उनके बारे में ईमेल नहीं मिलेगा।", "save_profile": "परिवर्तन सहेजें", "profile_saved": "प्रोफ़ाइल अद्यतन की गई", "profile_no_changes": "सहेजने के लिए कोई परिवर्तन नहीं।", diff --git a/static/locales/it.json b/static/locales/it.json index e9d0ee7f..0348bb7f 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} ha condiviso un {{kind}} con te su OxiCloud", - "body": "{{inviter}} ha condiviso un {{kind}} con te su OxiCloud.\n\nAprilo facendo clic sul link sottostante:\n{{link}}\n\nIl link è monouso e scade tra {{ttl_hours}} ore.\nSe non ti aspettavi questo invito, puoi ignorare questo messaggio.\n\n— OxiCloud" + "body": "{{inviter_full}} ha condiviso un {{kind}} con te su OxiCloud.\n\nAprilo facendo clic sul link sottostante:\n{{link}}\n\nIl link è monouso e scade tra {{ttl_hours}} ore.\nSe non ti aspettavi questo invito, puoi ignorare questo messaggio.\n\n— OxiCloud" }, "login": { "subject": "Accedi a OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "cartella", "english_fallback_divider": "--- Versione inglese qui sotto ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} ha condiviso un {{kind}} con te su OxiCloud", + "body": "{{inviter_full}} ha condiviso un {{kind}} con te su OxiCloud.\n\nApri OxiCloud per vedere la tua nuova condivisione:\n{{login_link}}\n\nPotresti avere altre nuove condivisioni da {{inviter}} — accedi per vedere tutti gli elementi condivisi con te.\n\n— OxiCloud\n\nRicevi questo messaggio perché hai un account OxiCloud e la preferenza di notifica delle condivisioni è attiva. Puoi disattivarla dal tuo profilo (Avvisami via email quando qualcuno condivide con me)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "Nome utente impostato e non modificabile (i client DAV/NextCloud dipendono da esso).", "given_name": "Nome", "family_name": "Cognome", + "notify_on_share": "Avvisami via email quando qualcuno condivide con me", + "notify_on_share_hint": "Se deselezionato, le condivisioni continueranno ad apparire nel tuo account — semplicemente non riceverai un'email a riguardo.", "save_profile": "Salva modifiche", "profile_saved": "Profilo aggiornato", "profile_no_changes": "Nessuna modifica da salvare.", diff --git a/static/locales/ja.json b/static/locales/ja.json index fb6fa3c0..7e3951ff 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} さんが OxiCloud で {{kind}} をあなたと共有しました", - "body": "{{inviter}} さんが OxiCloud で {{kind}} をあなたと共有しました。\n\n以下のリンクをクリックして開いてください:\n{{link}}\n\nリンクは一度のみ有効で、{{ttl_hours}} 時間で期限切れになります。\nこの招待に心当たりがない場合は、このメッセージを無視していただいて結構です。\n\n— OxiCloud" + "body": "{{inviter_full}} さんが OxiCloud で {{kind}} をあなたと共有しました。\n\n以下のリンクをクリックして開いてください:\n{{link}}\n\nリンクは一度のみ有効で、{{ttl_hours}} 時間で期限切れになります。\nこの招待に心当たりがない場合は、このメッセージを無視していただいて結構です。\n\n— OxiCloud" }, "login": { "subject": "OxiCloud にサインイン", @@ -30,6 +30,12 @@ "kind_folder": "フォルダー", "english_fallback_divider": "--- 以下は英語版 ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} さんが OxiCloud で {{kind}} をあなたと共有しました", + "body": "{{inviter_full}} さんが OxiCloud で {{kind}} をあなたと共有しました。\n\n新しい共有を確認するには OxiCloud を開いてください:\n{{login_link}}\n\n{{inviter}} さんから他にも新しい共有があるかもしれません — サインインしてあなたと共有されたすべての項目を確認してください。\n\n— OxiCloud\n\nOxiCloud のアカウントをお持ちで、共有通知の設定が有効になっているため、このメッセージが届いています。プロフィールでオフにできます(誰かが共有したときにメールで通知する)。" + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "ユーザー名は設定済みで変更できません(DAV/NextCloudクライアントが依存します)。", "given_name": "名", "family_name": "姓", + "notify_on_share": "誰かが共有したときにメールで通知する", + "notify_on_share_hint": "チェックを外しても、共有はアカウントに表示されますが、メールでの通知は届きません。", "save_profile": "変更を保存", "profile_saved": "プロフィールを更新しました", "profile_no_changes": "保存する変更はありません。", diff --git a/static/locales/ko.json b/static/locales/ko.json index a278f999..fd71c784 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}}님이 OxiCloud에서 {{kind}}을(를) 공유했습니다", - "body": "{{inviter}}님이 OxiCloud에서 {{kind}}을(를) 공유했습니다.\n\n아래 링크를 클릭하여 여세요:\n{{link}}\n\n링크는 한 번만 사용 가능하며 {{ttl_hours}}시간 후에 만료됩니다.\n이 초대를 예상하지 못했다면 이 메시지를 무시하셔도 됩니다.\n\n— OxiCloud" + "body": "{{inviter_full}}님이 OxiCloud에서 {{kind}}을(를) 공유했습니다.\n\n아래 링크를 클릭하여 여세요:\n{{link}}\n\n링크는 한 번만 사용 가능하며 {{ttl_hours}}시간 후에 만료됩니다.\n이 초대를 예상하지 못했다면 이 메시지를 무시하셔도 됩니다.\n\n— OxiCloud" }, "login": { "subject": "OxiCloud 로그인", @@ -30,6 +30,12 @@ "kind_folder": "폴더", "english_fallback_divider": "--- 영어 버전은 아래 ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}}님이 OxiCloud에서 {{kind}}을(를) 공유했습니다", + "body": "{{inviter_full}}님이 OxiCloud에서 {{kind}}을(를) 공유했습니다.\n\n새 공유 항목을 확인하려면 OxiCloud를 여세요:\n{{login_link}}\n\n{{inviter}}님이 추가로 공유한 항목이 있을 수 있습니다 — 로그인하여 공유받은 모든 항목을 확인하세요.\n\n— OxiCloud\n\nOxiCloud 계정이 있고 공유 알림 기본 설정이 켜져 있어 이 메시지를 받았습니다. 프로필에서 끌 수 있습니다(다른 사람이 나에게 공유할 때 이메일로 알림 받기)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "사용자 이름이 설정되어 있어 변경할 수 없습니다(DAV/NextCloud 클라이언트가 이에 의존합니다).", "given_name": "이름", "family_name": "성", + "notify_on_share": "다른 사람이 나에게 공유할 때 이메일로 알림 받기", + "notify_on_share_hint": "선택을 해제해도 공유 항목은 계정에 계속 표시되지만, 이메일 알림은 받지 않습니다.", "save_profile": "변경 사항 저장", "profile_saved": "프로필이 업데이트되었습니다", "profile_no_changes": "저장할 변경 사항이 없습니다.", diff --git a/static/locales/nl.json b/static/locales/nl.json index 87f46fd6..f7de5637 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} heeft een {{kind}} met je gedeeld op OxiCloud", - "body": "{{inviter}} heeft een {{kind}} met je gedeeld op OxiCloud.\n\nOpen het door op de onderstaande link te klikken:\n{{link}}\n\nDe link werkt eenmalig en verloopt over {{ttl_hours}} uur.\nAls je deze uitnodiging niet verwacht, kun je dit bericht negeren.\n\n— OxiCloud" + "body": "{{inviter_full}} heeft een {{kind}} met je gedeeld op OxiCloud.\n\nOpen het door op de onderstaande link te klikken:\n{{link}}\n\nDe link werkt eenmalig en verloopt over {{ttl_hours}} uur.\nAls je deze uitnodiging niet verwacht, kun je dit bericht negeren.\n\n— OxiCloud" }, "login": { "subject": "Aanmelden bij OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "map", "english_fallback_divider": "--- Engelse versie hieronder ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} heeft een {{kind}} met je gedeeld op OxiCloud", + "body": "{{inviter_full}} heeft een {{kind}} met je gedeeld op OxiCloud.\n\nOpen OxiCloud om je nieuwe gedeelde item te bekijken:\n{{login_link}}\n\nMisschien heb je nog meer nieuwe gedeelde items van {{inviter}} — meld je aan om al je gedeelde items te zien.\n\n— OxiCloud\n\nJe ontvangt dit bericht omdat je een OxiCloud-account hebt en je voorkeur voor deelmeldingen aanstaat. Je kunt het uitzetten in je profiel (Stuur me een e-mail wanneer iemand iets met mij deelt)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "Gebruikersnaam ingesteld en niet wijzigbaar (DAV/NextCloud-clients zijn ervan afhankelijk).", "given_name": "Voornaam", "family_name": "Achternaam", + "notify_on_share": "Stuur me een e-mail wanneer iemand iets met mij deelt", + "notify_on_share_hint": "Wanneer uitgevinkt, verschijnen gedeelde items nog steeds in je account — je krijgt er alleen geen e-mail over.", "save_profile": "Wijzigingen opslaan", "profile_saved": "Profiel bijgewerkt", "profile_no_changes": "Geen wijzigingen om op te slaan.", diff --git a/static/locales/pl.json b/static/locales/pl.json index 57478719..f02e6783 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} udostępnił Ci {{kind}} w OxiCloud", - "body": "{{inviter}} udostępnił Ci {{kind}} w OxiCloud.\n\nOtwórz, klikając poniższy link:\n{{link}}\n\nLink działa raz i wygasa za {{ttl_hours}} godzin.\nJeśli nie spodziewałeś się tego zaproszenia, możesz zignorować tę wiadomość.\n\n— OxiCloud" + "body": "{{inviter_full}} udostępnił Ci {{kind}} w OxiCloud.\n\nOtwórz, klikając poniższy link:\n{{link}}\n\nLink działa raz i wygasa za {{ttl_hours}} godzin.\nJeśli nie spodziewałeś się tego zaproszenia, możesz zignorować tę wiadomość.\n\n— OxiCloud" }, "login": { "subject": "Zaloguj się do OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "folder", "english_fallback_divider": "--- Wersja angielska poniżej ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} udostępnił Ci {{kind}} w OxiCloud", + "body": "{{inviter_full}} udostępnił Ci {{kind}} w OxiCloud.\n\nOtwórz OxiCloud, aby zobaczyć nowe udostępnienie:\n{{login_link}}\n\nMożesz mieć dodatkowe nowe udostępnienia od {{inviter}} — zaloguj się, aby zobaczyć wszystkie udostępnione Ci elementy.\n\n— OxiCloud\n\nOtrzymujesz tę wiadomość, ponieważ masz konto OxiCloud i preferencja powiadomień o udostępnieniach jest włączona. Możesz ją wyłączyć w swoim profilu (Wyślij mi e-mail, gdy ktoś coś mi udostępni)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "Nazwa użytkownika jest ustawiona i nie może być zmieniona (klienty DAV/NextCloud są od niej zależne).", "given_name": "Imię", "family_name": "Nazwisko", + "notify_on_share": "Wyślij mi e-mail, gdy ktoś coś mi udostępni", + "notify_on_share_hint": "Gdy odznaczone, udostępnienia nadal pojawiają się na Twoim koncie — po prostu nie otrzymasz o nich e-maila.", "save_profile": "Zapisz zmiany", "profile_saved": "Profil zaktualizowany", "profile_no_changes": "Brak zmian do zapisania.", diff --git a/static/locales/pt.json b/static/locales/pt.json index 56cb37b0..9fd032db 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} partilhou um {{kind}} consigo no OxiCloud", - "body": "{{inviter}} partilhou um {{kind}} consigo no OxiCloud.\n\nAbra-o clicando no link abaixo:\n{{link}}\n\nO link é de uso único e expira em {{ttl_hours}} horas.\nSe não esperava este convite, pode ignorar esta mensagem.\n\n— OxiCloud" + "body": "{{inviter_full}} partilhou um {{kind}} consigo no OxiCloud.\n\nAbra-o clicando no link abaixo:\n{{link}}\n\nO link é de uso único e expira em {{ttl_hours}} horas.\nSe não esperava este convite, pode ignorar esta mensagem.\n\n— OxiCloud" }, "login": { "subject": "Iniciar sessão no OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "pasta", "english_fallback_divider": "--- Versão em inglês abaixo ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} partilhou um {{kind}} consigo no OxiCloud", + "body": "{{inviter_full}} partilhou um {{kind}} consigo no OxiCloud.\n\nAbra o OxiCloud para ver a sua nova partilha:\n{{login_link}}\n\nPode ter mais partilhas novas de {{inviter}} — inicie sessão para ver todos os itens partilhados consigo.\n\n— OxiCloud\n\nRecebeu esta mensagem porque tem uma conta OxiCloud e a preferência de notificação de partilhas está ativada. Pode desativá-la no seu perfil (Avisar-me por e-mail quando alguém compartilhar comigo)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "Nome de usuário definido e não pode ser alterado (clientes DAV/NextCloud dependem dele).", "given_name": "Nome", "family_name": "Sobrenome", + "notify_on_share": "Avisar-me por e-mail quando alguém compartilhar comigo", + "notify_on_share_hint": "Quando desmarcado, os compartilhamentos continuarão aparecendo na sua conta — você apenas não receberá um e-mail sobre eles.", "save_profile": "Salvar alterações", "profile_saved": "Perfil atualizado", "profile_no_changes": "Sem alterações para salvar.", diff --git a/static/locales/ru.json b/static/locales/ru.json index 99c96b82..a5fe8d17 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} поделился(ась) с вами {{kind}} в OxiCloud", - "body": "{{inviter}} поделился(ась) с вами {{kind}} в OxiCloud.\n\nОткройте, нажав на ссылку ниже:\n{{link}}\n\nСсылка работает один раз и истекает через {{ttl_hours}} часов.\nЕсли вы не ожидали этого приглашения, можете спокойно проигнорировать это сообщение.\n\n— OxiCloud" + "body": "{{inviter_full}} поделился(ась) с вами {{kind}} в OxiCloud.\n\nОткройте, нажав на ссылку ниже:\n{{link}}\n\nСсылка работает один раз и истекает через {{ttl_hours}} часов.\nЕсли вы не ожидали этого приглашения, можете спокойно проигнорировать это сообщение.\n\n— OxiCloud" }, "login": { "subject": "Вход в OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "папку", "english_fallback_divider": "--- Английская версия ниже ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} поделился(ась) с вами {{kind}} в OxiCloud", + "body": "{{inviter_full}} поделился(ась) с вами {{kind}} в OxiCloud.\n\nОткройте OxiCloud, чтобы увидеть новый общий ресурс:\n{{login_link}}\n\nВозможно, у вас есть и другие новые общие ресурсы от {{inviter}} — войдите, чтобы увидеть все элементы, которыми с вами поделились.\n\n— OxiCloud\n\nВы получаете это сообщение, потому что у вас есть учётная запись OxiCloud и предпочтение уведомлений об общих ресурсах включено. Вы можете отключить его в своём профиле (Уведомлять меня по электронной почте, когда кто-то делится со мной)." + } } }, "app": { @@ -742,6 +748,8 @@ "username_already_claimed": "Имя пользователя установлено и не может быть изменено (клиенты DAV/NextCloud зависят от него).", "given_name": "Имя", "family_name": "Фамилия", + "notify_on_share": "Уведомлять меня по электронной почте, когда кто-то делится со мной", + "notify_on_share_hint": "Если флажок снят, общие ресурсы по-прежнему будут отображаться в вашей учётной записи — вы просто не будете получать о них письма.", "save_profile": "Сохранить изменения", "profile_saved": "Профиль обновлён", "profile_no_changes": "Нет изменений для сохранения.", diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index ef4a696d..85dbc0bc 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} 在 OxiCloud 上與您分享了一個{{kind}}", - "body": "{{inviter}} 在 OxiCloud 上與您分享了一個{{kind}}。\n\n點擊下方連結開啟:\n{{link}}\n\n該連結僅可使用一次,並將在 {{ttl_hours}} 小時後過期。\n如果您未預期收到此邀請,可以忽略此訊息。\n\n— OxiCloud" + "body": "{{inviter_full}} 在 OxiCloud 上與您分享了一個{{kind}}。\n\n點擊下方連結開啟:\n{{link}}\n\n該連結僅可使用一次,並將在 {{ttl_hours}} 小時後過期。\n如果您未預期收到此邀請,可以忽略此訊息。\n\n— OxiCloud" }, "login": { "subject": "登入 OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "資料夾", "english_fallback_divider": "--- 以下為英文版本 ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} 在 OxiCloud 上與您分享了一個{{kind}}", + "body": "{{inviter_full}} 在 OxiCloud 上與您分享了一個{{kind}}。\n\n開啟 OxiCloud 檢視您的新分享:\n{{login_link}}\n\n您可能還有來自 {{inviter}} 的其他新分享 — 登入以檢視所有與您分享的項目。\n\n— OxiCloud\n\n您收到此訊息是因為您擁有 OxiCloud 帳戶且分享通知偏好已開啟。您可以在個人資料中關閉它(當有人與我分享時透過電子郵件通知我)。" + } } }, "app": { @@ -725,6 +731,8 @@ "username_already_claimed": "使用者名稱已設定,不可更改(DAV/NextCloud 用戶端依賴它)。", "given_name": "名", "family_name": "姓", + "notify_on_share": "當有人與我分享時透過電子郵件通知我", + "notify_on_share_hint": "取消勾選後,分享項目仍會顯示在您的帳戶中 — 只是不會收到相關郵件通知。", "save_profile": "儲存變更", "profile_saved": "個人資料已更新", "profile_no_changes": "沒有變更可儲存。", diff --git a/static/locales/zh.json b/static/locales/zh.json index 432e105e..fb1de088 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} 在 OxiCloud 上与您共享了一个{{kind}}", - "body": "{{inviter}} 在 OxiCloud 上与您共享了一个{{kind}}。\n\n点击下方链接打开:\n{{link}}\n\n该链接仅可使用一次,并将在 {{ttl_hours}} 小时后过期。\n如果您未预期收到此邀请,可以忽略此消息。\n\n— OxiCloud" + "body": "{{inviter_full}} 在 OxiCloud 上与您共享了一个{{kind}}。\n\n点击下方链接打开:\n{{link}}\n\n该链接仅可使用一次,并将在 {{ttl_hours}} 小时后过期。\n如果您未预期收到此邀请,可以忽略此消息。\n\n— OxiCloud" }, "login": { "subject": "登录 OxiCloud", @@ -30,6 +30,12 @@ "kind_folder": "文件夹", "english_fallback_divider": "--- 以下为英文版本 ---" } + }, + "notification": { + "share": { + "subject": "{{inviter}} 在 OxiCloud 上与您共享了一个{{kind}}", + "body": "{{inviter_full}} 在 OxiCloud 上与您共享了一个{{kind}}。\n\n打开 OxiCloud 查看您的新共享:\n{{login_link}}\n\n您可能还有来自 {{inviter}} 的其他新共享 — 登录以查看所有共享给您的项目。\n\n— OxiCloud\n\n您收到此消息是因为您拥有 OxiCloud 账户且共享通知偏好已开启。您可以在个人资料中关闭它(当有人与我共享时通过电子邮件通知我)。" + } } }, "app": { @@ -725,6 +731,8 @@ "username_already_claimed": "用户名已设置,不可更改(DAV/NextCloud 客户端依赖它)。", "given_name": "名", "family_name": "姓", + "notify_on_share": "当有人与我共享时通过电子邮件通知我", + "notify_on_share_hint": "取消勾选后,共享项目仍会显示在您的账户中 — 只是不会收到相关邮件通知。", "save_profile": "保存更改", "profile_saved": "个人资料已更新", "profile_no_changes": "无更改可保存。", diff --git a/static/profile.html b/static/profile.html index 0c3ccdf6..e1d8073b 100644 --- a/static/profile.html +++ b/static/profile.html @@ -139,6 +139,13 @@ +
+ + When unchecked, shares still appear in your account — you just won't get an email about them. +
diff --git a/tests/api/external_users.hurl b/tests/api/external_users.hurl index 3a1ea8e8..b5e2ac1c 100644 --- a/tests/api/external_users.hurl +++ b/tests/api/external_users.hurl @@ -63,11 +63,13 @@ Content-Type: application/json HTTP 201 # The response carries the resolved subject as a regular user UUID — # externals never surface as a distinct subject_type post-PR-9.3a. +# PR N1: POST /api/grants now wraps the array in +# `CreateGrantResponseDto { grants, notification }`. [Asserts] -jsonpath "$[0].subject.type" == "user" -jsonpath "$[0].resource.id" == "{{ext_folder_id}}" +jsonpath "$.grants[0].subject.type" == "user" +jsonpath "$.grants[0].resource.id" == "{{ext_folder_id}}" [Captures] -bob_user_id: jsonpath "$[0].subject.id" +bob_user_id: jsonpath "$.grants[0].subject.id" # ───────────────────────────────────────────────────────────── @@ -144,7 +146,7 @@ Content-Type: application/json HTTP 201 [Asserts] -jsonpath "$[0].subject.id" == "{{bob_user_id}}" +jsonpath "$.grants[0].subject.id" == "{{bob_user_id}}" # ───────────────────────────────────────────────────────────── @@ -447,7 +449,7 @@ Content-Type: application/json HTTP 201 [Captures] -rl_user_1_id: jsonpath "$[0].subject.id" +rl_user_1_id: jsonpath "$.grants[0].subject.id" # 16b — 4th invite (4/3) is rejected with 429 + Retry-After. The # cap is visible because Alice is authenticated and her own diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index 2d2220e4..3c5214ac 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -122,9 +122,11 @@ Content-Type: application/json } HTTP 201 +# PR N1: POST /api/grants now wraps results in +# `CreateGrantResponseDto { grants, notification }`. [Asserts] -jsonpath "$" count == 1 -jsonpath "$[0].permission" == "read" +jsonpath "$.grants" count == 1 +jsonpath "$.grants[0].permission" == "read" # ───────────────────────────────────────────────────────────── @@ -205,7 +207,7 @@ Content-Type: application/json HTTP 201 [Captures] -eve_grant_id: jsonpath "$[0].id" +eve_grant_id: jsonpath "$.grants[0].id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/grants_nested_groups.hurl b/tests/api/grants_nested_groups.hurl index d8b6dee7..0acf11a1 100644 --- a/tests/api/grants_nested_groups.hurl +++ b/tests/api/grants_nested_groups.hurl @@ -281,11 +281,13 @@ Content-Type: application/json } HTTP 201 +# PR N1: POST /api/grants now wraps results in +# `CreateGrantResponseDto { grants, notification }`. [Asserts] -jsonpath "$" count == 1 -jsonpath "$[0].permission" == "read" -jsonpath "$[0].subject.type" == "group" -jsonpath "$[0].subject.id" == "{{group_a_id}}" +jsonpath "$.grants" count == 1 +jsonpath "$.grants[0].permission" == "read" +jsonpath "$.grants[0].subject.type" == "group" +jsonpath "$.grants[0].subject.id" == "{{group_a_id}}" # ── Read endpoints now succeed ────────────────────────────── GET {{base_url}}/api/folders/{{perm_folder_id}}/resources?resource_types=folder