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

- add coalesced protection to avoid mail bombing if an invited goes many grant in a short period
    - add resentd method in share menu item (work for both internal and external users)
    - user can disable email notification via his properties
    - add env variable from admin to disable notifications
This commit is contained in:
Edouard Vanbelle
2026-06-05 09:46:51 +02:00
parent b8e8fcd7dc
commit 8cc21f17c5
45 changed files with 1906 additions and 81 deletions
+18
View File
@@ -761,6 +761,20 @@ pub struct MagicLinkConfig {
/// may enforce MFA we shouldn't bypass. See
/// `magic_link_eligibility()` for the precedence ladder.
pub open_to_password_users: bool,
/// Operator-level kill switch for plain-notification emails to
/// internal users (PR N1). When `true` (default), users who can't
/// receive a magic link (password users, OIDC users) get a "Hey,
/// you got a new grant" mail with a `/login` deep link on every
/// share. When `false`, the plain-notification arm is suppressed
/// entirely — internal users discover shares only on next login.
///
/// This is a coarser knob than the per-user
/// `auth.users.notify_on_share` column: when this is `false`, the
/// user-level opt-in does not matter. External-user magic-link
/// invitations are NOT affected by this flag — those always send,
/// because the link is the only way the recipient can claim the
/// share for the first time.
pub notify_internal_users_on_share: bool,
}
impl Default for MagicLinkConfig {
@@ -774,6 +788,7 @@ impl Default for MagicLinkConfig {
send_per_email_per_hour: 5,
send_per_ip_per_hour: 200,
open_to_password_users: false,
notify_internal_users_on_share: true,
}
}
}
@@ -1475,6 +1490,9 @@ impl AppConfig {
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS") {
config.magic_link.open_to_password_users = v == "true" || v == "1";
}
if let Ok(v) = env::var("OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE") {
config.magic_link.notify_internal_users_on_share = v == "true" || v == "1";
}
if let Ok(v) = env::var("OXICLOUD_DEFAULT_LOCALE") {
let trimmed = v.trim();
+39 -5
View File
@@ -948,9 +948,10 @@ impl AppServiceFactory {
),
),
)),
email_sender: None, // populated below
mock_email_sender: None, // populated below
magic_link_invite_service: None, // populated below
email_sender: None, // populated below
mock_email_sender: None, // populated below
magic_link_invite_service: None, // populated below
recipient_notification_service: None, // populated below alongside magic_link_invite_service
// 60 lookups / minute / caller; cap at 50 000 tracked
// callers to bound memory. The same limiter instance is
// shared by every clone of AppState since it lives in an
@@ -1013,9 +1014,9 @@ impl AppServiceFactory {
);
app_state.magic_link_invite_service = Some(Arc::new(
crate::application::services::magic_link_invite_service::MagicLinkInviteService::new(
invite_user_storage,
invite_user_storage.clone(),
invite_magic_link_repo,
email_sender,
email_sender.clone(),
lifecycle,
app_state.applications.i18n_service.clone(),
app_state.locale_registry.clone(),
@@ -1023,6 +1024,30 @@ impl AppServiceFactory {
self.config.base_url(),
),
));
// PR N1: wire the unified RecipientNotificationService.
// Only constructed when MagicLinkInviteService is also
// available — the magic-link path delegates to it.
// SubjectGroupService is built earlier in this factory; the
// notification service needs it for the Group subject arm.
if let (Some(magic_link_svc), Some(subject_groups)) = (
app_state.magic_link_invite_service.clone(),
app_state.subject_group_service.clone(),
) {
app_state.recipient_notification_service = Some(Arc::new(
crate::application::services::recipient_notification_service::RecipientNotificationService::new(
invite_user_storage,
magic_link_svc,
email_sender,
app_state.applications.i18n_service.clone(),
app_state.locale_registry.clone(),
subject_groups,
app_state.magic_link_send_per_email_rate_limiter.clone(),
self.config.magic_link.clone(),
self.config.base_url(),
),
));
}
}
// 9b. Wire admin settings service when auth is available
@@ -1374,6 +1399,15 @@ pub struct AppState {
pub magic_link_invite_service: Option<
Arc<crate::application::services::magic_link_invite_service::MagicLinkInviteService>,
>,
/// Unified share-notification dispatcher (PR N1) — used by both
/// `create_grant` and the future `POST /api/grants/{id}/notify` to
/// route share emails through coalesce + rate-limit + per-recipient
/// dispatch. `None` when SMTP / magic-link / subject-group services
/// aren't all configured; callers degrade to silent no-op in that
/// case (no mail sent, grant still created).
pub recipient_notification_service: Option<
Arc<crate::application::services::recipient_notification_service::RecipientNotificationService>,
>,
/// Per-caller sliding-window limiter for `GET /api/users/{id}`. The
/// endpoint's primary defense is the visibility check, but a stale
/// JWT could in theory iterate UUIDs against the related-by-grant