Skip to content

✨(push) add Push Notifications system for iOS, Android, Web#727

Open
sylvinus wants to merge 6 commits into
developmentfrom
push
Open

✨(push) add Push Notifications system for iOS, Android, Web#727
sylvinus wants to merge 6 commits into
developmentfrom
push

Conversation

@sylvinus

@sylvinus sylvinus commented Jun 27, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Added push notifications across web and mobile devices, including device registration, sign-out, and a new notifications settings screen.
    • Added browser push enablement and automatic re-sync for existing subscriptions.
    • Added push-related app settings and localized UI strings.
  • Bug Fixes

    • Prevented duplicate notifications for repeated message processing.
    • Improved notification delivery reliability with better retry, cleanup, and disable handling.
    • Added logout-based device cleanup and better device list refresh behavior.

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f087d1ef-9b2d-4001-aefc-337c51ec4d10

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR implements a complete push notification system: user-scoped push device registration with token reclaim, APNs/FCM/Web Push senders with stale-device circuit breakers, Celery orchestration, VAPID key management commands, config/OpenAPI exposure, session-hash logout cleanup, frontend device management UI with a service worker, plus documentation, tests, and an unrelated channel secret rotation/template placeholder change.

Changes

Push Notifications

Layer / File(s) Summary
Push model, enums, migration, settings, dependencies
src/backend/core/enums.py, src/backend/core/models.py, src/backend/core/migrations/0033_channel_lookup_hash.py, src/backend/messages/settings.py, src/backend/pyproject.toml
Adds PushPlatformChoices, Channel.lookup_hash with conditional unique constraint, push credential/config settings, boot-time VAPID validation, and new HTTP/crypto dependencies.
Shared push infrastructure
src/backend/core/services/push/common.py, src/backend/core/services/push/__init__.py
Implements thin payload/collapse-key builders, device registration with cross-user reclaim and pruning, recipient/unread resolution, shared HTTP clients, and stale-device circuit breakers.
Transport senders and VAPID commands
src/backend/core/services/push/apns.py, .../fcm.py, .../webpush.py, src/backend/core/management/commands/derive_vapid_public_key.py, .../generate_vapid_private_key.py
Implements APNs/FCM/Web Push delivery with stale/transient classification and VAPID key derivation/generation commands.
Celery tasks and inbound trigger
src/backend/core/services/push/tasks.py, src/backend/core/tasks.py, src/backend/core/mda/inbound_tasks.py
Adds enqueue/orchestration/per-device Celery tasks and wires push enqueue into inbound message processing.
Session-hash logout cleanup
src/backend/core/signals.py
Adds a logout signal receiver that deletes PUSH channels tied to the ended session.
Device registration API and config exposure
src/backend/core/api/serializers.py, .../viewsets/channel.py, .../viewsets/config.py, src/backend/core/api/openapi.json
Adds push registration serializers/viewset endpoint, throttle, config fields, and OpenAPI schemas.
Frontend device UI and service worker
src/frontend/public/sw.js, src/frontend/src/features/layouts/components/mailbox-settings/devices-view/*, .../notifications-settings/modal-notifications.tsx, .../main/header/authenticated.tsx, .../controlled-modals/*, .../providers/config.tsx
Adds the push service worker, Web Push enable/refresh flow, device management grid, notifications modal, and menu wiring.
Tests and documentation
src/backend/core/tests/test_push.py, .../tests/test_settings.py, .../tests/api/test_config.py, .../tests/mda/test_dispatch_webhooks.py, docs/push-notifications.md
Adds comprehensive backend tests and design/operations documentation for push notifications.
Localization
src/frontend/public/locales/common/de-DE.json, en-US.json, fr-FR.json, nl-NL.json
Adds push notification/device management UI strings.

Channel Secret Rotation and Template Placeholder Fix

Layer / File(s) Summary
Channel secret rotation and template name resolution
src/backend/core/models.py
Adds Channel.rotate_secret/get_webhook_api_key for type-specific secret storage and derivation, and updates MessageTemplate.resolve_placeholder_values() name precedence.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant ServiceWorker as sw.js
  participant Backend as UserChannelViewSet
  participant PushCommon as register_push_device

  Browser->>Backend: request notification permission, register /sw.js
  Browser->>ServiceWorker: subscribe via pushManager (VAPID key)
  ServiceWorker->>Backend: POST /users/me/channels/ (type=push, endpoint, keys)
  Backend->>PushCommon: register_push_device(user, platform, token, keys)
  PushCommon-->>Backend: Channel (created or refreshed)
  Backend-->>Browser: 201 Created or 200 OK (channel)
Loading
sequenceDiagram
  participant InboundTask as process_inbound_message_task
  participant EnqueueTask as enqueue_push_notifications
  participant OrchestratorTask as send_push_for_message
  participant DeviceTask as send_push_notification
  participant Sender as APNs/FCM/Web Push sender

  InboundTask->>EnqueueTask: enqueue after finalize (not spam, newly created)
  EnqueueTask->>OrchestratorTask: dispatch on transaction commit
  OrchestratorTask->>OrchestratorTask: resolve recipients, badge counts, channels
  OrchestratorTask->>DeviceTask: send_push_notification per device channel
  DeviceTask->>Sender: send(channel, thin payload, collapse_key)
  Sender-->>DeviceTask: PushResult(delivered, transient)
  DeviceTask-->>DeviceTask: raise PushTransientError to retry if transient
Loading

Possibly related PRs

  • suitenumerique/messages#599: Main PR's push-channel serialization and UserChannelViewSet registration flow build on this PR's encrypted/scoped Channel and authorization changes.
  • suitenumerique/messages#691: Both PRs modify src/backend/core/api/serializers.py, touching webhook validation and event serialization alongside the push serializer additions.
  • suitenumerique/messages#703: Both PRs modify MessageTemplate.resolve_placeholder_values() in src/backend/core/models.py, changing placeholder context resolution.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: a new push notifications system for iOS, Android, and Web.
Docstring Coverage ✅ Passed Docstring coverage is 85.20% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sylvinus sylvinus changed the base branch from main to outbound_webhooks June 27, 2026 14:37
@sylvinus sylvinus changed the title ✨(integrations) add outgoing webhooks for every incoming email ✨(push) add Push Notifications system for iOS, Android, Web Jun 27, 2026
@sylvinus sylvinus requested a review from jbpenrath June 27, 2026 14:37

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 24

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/backend/core/tests/api/test_channel_scope_level.py (1)

1021-1029: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the one-time JWT credential on the personal-scope create path.

With auth_method: "jwt" here, this test should also verify that /users/me/channels/ returns the one-time secret (and not api_key). The mailbox-scoped coverage in src/backend/core/tests/api/test_channels.py will not catch a regression in the personal-scope endpoint. As per coding guidelines, Unit tests should focus on a single use case, keep assertions minimal, and cover all possible cases.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/tests/api/test_channel_scope_level.py` around lines 1021 -
1029, The personal webhook create test in test_channel_scope_level should assert
the JWT one-time credential returned by the /users/me/channels/ create path.
Update the test around the api_client.post for the personal-webhook so it
explicitly checks that the response includes secret and does not include api_key
when auth_method is jwt, using the existing test case as the single focused
coverage for this endpoint.

Source: Coding guidelines

src/frontend/src/features/layouts/components/thread-view/components/thread-event/assignment-message.test.ts (1)

18-30: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a fixture path for webhook-authored assignment events.

The new branch in buildAssignmentMessage() only runs when author is null and author_display is non-null, but this helper forces author_display to null whenever authorId is null. That leaves the new "Webhook: … assigned …" wording path unrepresentable in this test file.

Example adjustment
 const makeEvent = (
   type: 'assign' | 'unassign',
   authorId: string | null,
   assignees: { id: string; name: string }[],
+  authorDisplay: string | null = authorId === null ? null : `User ${authorId}`,
 ): AssignmentEvent => ({
@@
-  author_display: authorId === null ? null : `User ${authorId}`,
+  author_display: authorDisplay,
const event = makeEvent('assign', null, [{ id: SELF_ID, name: 'You' }], 'Webhook: GitHub');
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('Webhook: GitHub assigned you');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/frontend/src/features/layouts/components/thread-view/components/thread-event/assignment-message.test.ts`
around lines 18 - 30, The makeEvent test helper in assignment-message.test.ts
cannot represent webhook-authored assignment events because it always sets
author_display to null when authorId is null. Update makeEvent so it can accept
an explicit author_display override for the null-author case, then add a fixture
using buildAssignmentMessage with author null and a non-null display label to
cover the new webhook wording path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/push-notifications.md`:
- Around line 27-28: The push notifications docs incorrectly say push channels
are blocked from create via the generic endpoint, which conflicts with the
documented/tested flow. Update the bullet in the push notifications section to
reflect that push devices are created with POST on users/me/channels using
type="push", and only the generic PATCH/update path is blocked for push
metadata; keep the list/revoke wording tied to GET and DELETE.

In `@docs/webhooks.md`:
- Around line 91-95: The webhook docs use the wrong regenerate-secret path in
the root secret description; update the wording in the relevant webhook sections
to match the actual scope-specific routes used by the API. Refer to the webhook
secret handling text around the `auth_method` and `regenerate-secret`
descriptions, and replace the generic `/channels/{id}/regenerate-secret/` path
with the correct mailbox- and user-scoped endpoints so readers are directed to
real routes.
- Around line 451-480: The minimal receiver example in inbox_hook is currently
unauthenticated and can accept spoofed POSTs, so update the snippet to fail
closed unless request verification is performed. Add explicit JWT/API-key
validation before processing the body and only proceed when the signature/key is
valid, or otherwise show a clearly non-production example that rejects
unauthorized requests by default. Keep the guidance tied to inbox_hook,
request.headers, and request.get_json so readers understand auth must be wired
before using the sample.

In `@src/backend/core/admin.py`:
- Around line 612-619: The admin secret rotation flow in the webhook admin
should display the correct credential for api-key webhooks instead of the raw
secret returned by rotate_secret(). Update the logic around
channel.rotate_secret() in the admin rotation view to branch on
auth_method=api_key and use get_webhook_api_key() for the displayed secret,
while keeping the existing raw secret behavior for other webhook types. Make
sure the context’s "secret" value and the generated "New secret generated" page
both reflect the derived API key for the webhook model/instance being rotated.

In `@src/backend/core/api/serializers.py`:
- Around line 2814-2822: The Web Push key validation in the serializer currently
only checks truthiness, so non-string values like objects or lists can slip
through and get stored in encrypted_settings. Update the validation in this
branch of the serializer method handling attrs["platform"] ==
enums.PushPlatformChoices.WEB to explicitly require both keys["p256dh"] and
keys["auth"] to be non-empty strings before persisting them. Keep the existing
normalization to attrs["keys"], but reject any missing or non-string values with
serializers.ValidationError using the existing web push key error message.
- Around line 2245-2250: The webhook URL validation in the serializer should
return a 400 instead of bubbling a ValueError for malformed netlocs. Update the
URL handling in the serializer method that calls urlparse() and reads
parsed_url.hostname to catch ValueError around that parsing/hostname extraction,
and convert it into serializers.ValidationError with the existing settings error
shape so bad webhook URLs are rejected consistently.

In `@src/backend/core/enums.py`:
- Around line 222-224: The push-notification channel comment is pointing to the
wrong place for the token hash storage. Update the comment near the user-scoped
push target definition to say that the device push token lives in
encrypted_settings, the token hash is stored in Channel.lookup_hash, and
settings holds the platform/client metadata; use Channel and lookup_hash as the
reference points so future callers check the correct fields.

In `@src/backend/core/management/commands/derive_vapid_public_key.py`:
- Around line 30-33: The `derive_vapid_public_key()` command help text is
advertising a private-key format that the implementation does not support.
Update the `--private-key` option help in `derive_vapid_public_key` to describe
only PEM unless you also add base64url parsing before calling
`Vapid02.from_pem(...)`, so the documented format matches the actual accepted
input.

In `@src/backend/core/mda/dispatch_webhooks.py`:
- Around line 593-617: In the webhook result handling inside the dispatch flow,
avoid merging any side effects from blocking responses that do not continue,
because aborting decisions like retry can still propagate flags such as
mark_trashed and mark_archived into ctx. Update the logic around result.decision
so that the method returns immediately for any non-CONTINUE decision before
applying ctx updates, and only apply labels, pending assigns/events/drafts, and
boolean merges when the webhook actually continues. Use the existing decision
handling and the surrounding webhook result processing in dispatch_webhooks to
place the early return.

In `@src/backend/core/mda/inbound_pipeline.py`:
- Around line 497-525: The warnings in inbound_pipeline.py are logging sensitive
assignee email addresses directly, so update the assignee resolution flow to
avoid emitting PII. In the webhook assignee handling and the assignable-role
check, replace email-based log fields in logger.warning calls with a
non-reversible internal identifier or a position/count-based reference tied to
candidate_users/candidate_ids. Keep the existing control flow in the assignee
matching logic, but ensure the messages in the candidate_users loop and the
assignable_ids validation no longer include raw email values.
- Around line 346-347: The fallback rspamd auth path in inbound_pipeline should
handle transient rspamd failures the same way as the spam step instead of
silently ignoring them. In the branch that calls _call_rspamd in the inbound
auth check, capture and inspect the second return value (the exception/error
from _call_rspamd) and apply the same retry/quarantine behavior used by the
rspamd spam logic when the auth check cannot be completed. Keep the existing
ctx.rspamd_result assignment on success, but make sure the sender-auth banner is
not dropped when rspamd is temporarily unavailable.
- Around line 177-210: The header-matching logic in the inbound pipeline is
incorrectly lowercasing regex patterns, which can break `header_match_regex`
rules and may also raise on invalid patterns. Update the normalization in
`inbound_pipeline.py` so exact-match comparisons still use a lowered/trimmed
value, but regex matching in the header rule path keeps the original pattern
text before `re.fullmatch`; use the existing header rule flow around
`header_match`/`header_match_regex` and add `re.error` handling so a bad regex
rule is skipped instead of aborting processing.

In `@src/backend/core/mda/inbound_tasks.py`:
- Around line 145-149: The lock in process_inbound_message is hardcoded to
expire after 300s, which can be shorter than the worst-case webhook pipeline
runtime and allow duplicate processing. Update the locking approach around
cache.add(lock_key, "locked", 300) to either refresh the lock while processing
or compute a TTL based on the webhook count and per-webhook timeout so the lock
stays valid until InboundMessage deletion and finalization are complete.

In `@src/backend/core/models.py`:
- Around line 535-541: The hash-input guidance in the model comment is
inconsistent with the push registration shape, since `lookup_hash` is currently
derived from the bare token and can collide under the global unique constraint.
Update the push registration path and any related lookup/tests to namespace the
hashed input with a type-specific prefix (for example in the push registration
flow using `sha256(...)`), and keep the `uniq_channel_lookup_hash`-related
behavior aligned with that scoped keying.
- Around line 681-692: get_webhook_api_key() may return None for misconfigured
channels, but the webhook dispatch path is still passing that value into
X-StMsg-Api-Key. Update the webhook-building logic in dispatch_webhooks.py to
fail closed before headers are created: either skip auth headers entirely or
raise a controlled error when get_webhook_api_key() returns None. Use
get_webhook_api_key() and the header construction code as the key locations to
patch.

In `@src/backend/core/services/push/common.py`:
- Around line 430-437: The stale-delete limiter in the delete-check logic is
using a fixed TTL bucket instead of a rolling window because cache.incr() does
not refresh expiry. Update the counter handling in the stale-delete path around
the cache_key logic so each delete hit renews the expiration (for example by
touching the key or resetting the TTL after incrementing), keeping the cap
aligned with the last minute rather than the first delete. Ensure the change is
applied in the code that uses cache.add(), cache.incr(), and
STALE_DELETE_WINDOW_LIMIT/STALE_DELETE_WINDOW_SECONDS.

In `@src/backend/core/services/push/tasks.py`:
- Around line 126-130: The fan-out loop in the push dispatch path aborts if a
single send_push_notification.delay call fails, so handle enqueue errors per
channel inside the loop in the dispatch logic. In the task method that iterates
over user_channels, catch exceptions around each delay call, log the failing
channel id with enough context, and continue to the next channel so one bad
device does not stop the rest from being dispatched.

In `@src/backend/core/services/push/webpush.py`:
- Around line 79-83: The warning in webpush.py logs the raw PUSH_VAPID_SUBJECT
value, which can expose sensitive PII in logs. Update the logger.warning call in
the Web Push settings validation path to report only that PUSH_VAPID_SUBJECT is
malformed and that Web Push is disabled, without interpolating
settings.PUSH_VAPID_SUBJECT or any other subject value. Keep the fix localized
to the VAPID subject validation logic so the behavior stays the same except for
redacting the logged content.

In `@src/frontend/public/sw.js`:
- Around line 89-103: The notification click handling in sw.js is reusing the
first same-origin window from clients.matchAll() without checking whether it is
the intended mailbox/thread tab. Update the loop in the notification navigation
path to only focus/navigate a matching client when it already represents the app
target, and otherwise fall back to clients.openWindow(url). Use the existing
clients.matchAll, client.focus, client.navigate, and clients.openWindow flow to
prefer a relevant tab rather than blindly redirecting any same-origin window.
- Around line 40-55: The enrichment fetch in buildNotification can hang
indefinitely and delay the fallback banner, so add a short timeout around the
MESSAGE_URL(payload.message_id) request in src/frontend/public/sw.js. Update the
fetch-to-enrich block to abort/stop waiting after the timeout and then continue
with the generic notification path, keeping the existing catch behavior for
offline/cross-origin/signed-out cases. Use the buildNotification and MESSAGE_URL
symbols to locate the logic.

In
`@src/frontend/src/features/layouts/components/mailbox-settings/devices-view/user-devices-grid.tsx`:
- Around line 78-82: The browser enablement check in user-devices-grid should
also respect the backend push feature flag, not just isWebPushSupported() and
config.PUSH_VAPID_PUBLIC_KEY. Update the canEnableThisBrowser logic (and any
related button visibility/availability in this flow) to require
config.PUSH_ENABLED so the UI never offers push registration when the feature is
disabled and the registration endpoint would 404.

In
`@src/frontend/src/features/layouts/components/mailbox-settings/devices-view/web-push.ts`:
- Around line 107-124: The subscription reuse logic in `webPushSubscription` is
not validating that an existing `PushSubscription` was created with the current
`vapidPublicKey`, which can leave the browser stuck on an old key after
rotation. Update both `webPushSubscription` and `refreshWebPushSubscription` to
inspect the existing subscription’s app server key before reusing it, and if it
does not match the current `vapidPublicKey`, unsubscribe and create a fresh
subscription instead. Keep the existing `AbortError` handling in the
`registration.pushManager.subscribe` path intact.

In
`@src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/webhook-integration-form.tsx`:
- Around line 111-128: When editing an existing webhook in
webhook-integration-form.tsx, don’t allow changing auth_method without also
exposing a usable credential. Update the edit flow around the newSettings
construction and the isEditing/updateMutation path so auth is either read-only,
or changing it triggers a secret rotation/regenerate-secret flow and surfaces
the returned secret/api_key to the user. Use the existing WebhookChannelSettings
shape and the mutateAsync/updateMutation logic to keep auth mode and credential
state consistent.

In
`@src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx`:
- Around line 258-259: The ThreadEvent author rendering is using
event.author_display for the avatar label, but avatarColor still hashes
event.author?.full_name || event.author?.email, which breaks webhook IM events
where author is null and all avatars look the same. Update the avatar color
source to use the same resolved author string as the label in this component, so
UserAvatar and the displayed text in the thread-event view stay consistent for
all event types.

---

Outside diff comments:
In `@src/backend/core/tests/api/test_channel_scope_level.py`:
- Around line 1021-1029: The personal webhook create test in
test_channel_scope_level should assert the JWT one-time credential returned by
the /users/me/channels/ create path. Update the test around the api_client.post
for the personal-webhook so it explicitly checks that the response includes
secret and does not include api_key when auth_method is jwt, using the existing
test case as the single focused coverage for this endpoint.

In
`@src/frontend/src/features/layouts/components/thread-view/components/thread-event/assignment-message.test.ts`:
- Around line 18-30: The makeEvent test helper in assignment-message.test.ts
cannot represent webhook-authored assignment events because it always sets
author_display to null when authorId is null. Update makeEvent so it can accept
an explicit author_display override for the null-author case, then add a fixture
using buildAssignmentMessage with author null and a non-null display label to
cover the new webhook wording path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 371ac34b-0d5d-47b4-bbf4-4e04996f3ec0

📥 Commits

Reviewing files that changed from the base of the PR and between a7cfcea and b23d3e5.

⛔ Files ignored due to path filters (13)
  • src/backend/uv.lock is excluded by !**/*.lock
  • src/frontend/src/features/api/gen/channels/channels.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/channel_create_response.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/config_retrieve200.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/index.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/platform_enum.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/push_channel_create_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/push_channel_create_request_keys.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/push_channel_create_type_enum.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/regenerated_api_key_response.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/regenerated_secret_response.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/thread_event.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/user_channel_create_request_request.ts is excluded by !**/gen/**
📒 Files selected for processing (59)
  • docs/push-notifications.md
  • docs/webhooks.md
  • env.d/development/backend.defaults
  • src/backend/core/admin.py
  • src/backend/core/api/openapi.json
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/channel.py
  • src/backend/core/api/viewsets/config.py
  • src/backend/core/enums.py
  • src/backend/core/factories.py
  • src/backend/core/management/commands/derive_vapid_public_key.py
  • src/backend/core/mda/autoreply.py
  • src/backend/core/mda/dispatch_webhooks.py
  • src/backend/core/mda/inbound_create.py
  • src/backend/core/mda/inbound_pipeline.py
  • src/backend/core/mda/inbound_tasks.py
  • src/backend/core/migrations/0032_channel_lookup_hash.py
  • src/backend/core/models.py
  • src/backend/core/services/push/__init__.py
  • src/backend/core/services/push/apns.py
  • src/backend/core/services/push/common.py
  • src/backend/core/services/push/fcm.py
  • src/backend/core/services/push/tasks.py
  • src/backend/core/services/push/webpush.py
  • src/backend/core/services/ssrf.py
  • src/backend/core/services/thread_events.py
  • src/backend/core/templates/admin/core/channel/regenerated_api_key.html
  • src/backend/core/tests/api/test_channel_scope_level.py
  • src/backend/core/tests/api/test_channels.py
  • src/backend/core/tests/api/test_config.py
  • src/backend/core/tests/mda/test_dispatch_webhooks.py
  • src/backend/core/tests/mda/test_inbound_auth.py
  • src/backend/core/tests/mda/test_inbound_spoofed_sender.py
  • src/backend/core/tests/mda/test_spam_processing.py
  • src/backend/core/tests/test_push.py
  • src/backend/messages/settings.py
  • src/backend/pyproject.toml
  • src/frontend/public/locales/common/de-DE.json
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/public/locales/common/fr-FR.json
  • src/frontend/public/locales/common/nl-NL.json
  • src/frontend/public/locales/common/ru-RU.json
  • src/frontend/public/locales/common/uk-UA.json
  • src/frontend/public/sw.js
  • src/frontend/src/features/controlled-modals/index.ts
  • src/frontend/src/features/controlled-modals/notifications.tsx
  • src/frontend/src/features/layouts/components/mailbox-settings/devices-view/user-devices-grid.tsx
  • src/frontend/src/features/layouts/components/mailbox-settings/devices-view/web-push.ts
  • src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/integrations-data-grid.tsx
  • src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/index.tsx
  • src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/webhook-integration-form.tsx
  • src/frontend/src/features/layouts/components/main/header/authenticated.tsx
  • src/frontend/src/features/layouts/components/notifications-settings/modal-notifications.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/assignment-message.test.ts
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/assignment-message.ts
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/group-system-events.test.ts
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-header.tsx
  • src/frontend/src/features/providers/config.tsx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

🛑 Comments failed to post (24)
docs/push-notifications.md (1)

27-28: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Don't say push channels are blocked from create here.

This contradicts the previous bullet and the tested API flow: push devices are created via POST /api/v1.0/users/me/channels/ with type="push". Only the generic update path appears to be blocked for push metadata, not creation itself.

Suggested doc fix
-- **List / revoke:** the normal `GET`/`DELETE /api/v1.0/users/me/channels/`.
-  Push channels are blocked from create/PATCH through the generic endpoint.
+- **List / revoke:** the normal `GET`/`DELETE /api/v1.0/users/me/channels/`.
+  Push channels register through the collection `POST` above, but their
+  generic `PATCH` update path is blocked.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

- **List / revoke:** the normal `GET`/`DELETE /api/v1.0/users/me/channels/`.
  Push channels register through the collection `POST` above, but their
  generic `PATCH` update path is blocked.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/push-notifications.md` around lines 27 - 28, The push notifications docs
incorrectly say push channels are blocked from create via the generic endpoint,
which conflicts with the documented/tested flow. Update the bullet in the push
notifications section to reflect that push devices are created with POST on
users/me/channels using type="push", and only the generic PATCH/update path is
blocked for push metadata; keep the list/revoke wording tied to GET and DELETE.
docs/webhooks.md (2)

91-95: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document the real regenerate-secret routes.

POST /channels/{id}/regenerate-secret/ does not match the scope-specific endpoints exercised elsewhere in this PR (/mailboxes/{mailbox_id}/channels/{id}/regenerate-secret/ and /users/me/channels/{id}/regenerate-secret/). As written, this will send readers to a non-existent path.

Also applies to: 119-123

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/webhooks.md` around lines 91 - 95, The webhook docs use the wrong
regenerate-secret path in the root secret description; update the wording in the
relevant webhook sections to match the actual scope-specific routes used by the
API. Refer to the webhook secret handling text around the `auth_method` and
`regenerate-secret` descriptions, and replace the generic
`/channels/{id}/regenerate-secret/` path with the correct mailbox- and
user-scoped endpoints so readers are directed to real routes.

451-480: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Don't publish an unauthenticated receiver example.

This sample accepts any POST and ignores the documented JWT / API-key verification, so copying it verbatim produces a spoofable webhook endpoint. Please either verify the signature/key here or make the example fail closed until auth is wired.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/webhooks.md` around lines 451 - 480, The minimal receiver example in
inbox_hook is currently unauthenticated and can accept spoofed POSTs, so update
the snippet to fail closed unless request verification is performed. Add
explicit JWT/API-key validation before processing the body and only proceed when
the signature/key is valid, or otherwise show a clearly non-production example
that rejects unauthorized requests by default. Keep the guidance tied to
inbox_hook, request.headers, and request.get_json so readers understand auth
must be wired before using the sample.
src/backend/core/admin.py (1)

612-619: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Display the derived API key for auth_method=api_key webhooks.

rotate_secret() returns the raw whsec_ root for webhooks, but api-key webhooks authenticate with get_webhook_api_key() (whk_...). The admin rotation page will otherwise show operators a credential the receiver should not configure.

Proposed fix
         plaintext = channel.rotate_secret()
+        displayed_secret = plaintext
+        if (
+            channel.type == ChannelTypes.WEBHOOK
+            and (channel.settings or {}).get("auth_method") == "api_key"
+        ):
+            displayed_secret = channel.get_webhook_api_key()
 
         context = {
             **self.admin_site.each_context(request),
             "opts": self.model._meta,  # noqa: SLF001
             "original": channel,
             "title": "New secret generated",
-            "secret": plaintext,
+            "secret": displayed_secret,
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        plaintext = channel.rotate_secret()
        displayed_secret = plaintext
        if (
            channel.type == ChannelTypes.WEBHOOK
            and (channel.settings or {}).get("auth_method") == "api_key"
        ):
            displayed_secret = channel.get_webhook_api_key()

        context = {
            **self.admin_site.each_context(request),
            "opts": self.model._meta,  # noqa: SLF001
            "original": channel,
            "title": "New secret generated",
            "secret": displayed_secret,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/admin.py` around lines 612 - 619, The admin secret rotation
flow in the webhook admin should display the correct credential for api-key
webhooks instead of the raw secret returned by rotate_secret(). Update the logic
around channel.rotate_secret() in the admin rotation view to branch on
auth_method=api_key and use get_webhook_api_key() for the displayed secret,
while keeping the existing raw secret behavior for other webhook types. Make
sure the context’s "secret" value and the generated "New secret generated" page
both reflect the derived API key for the webhook model/instance being rotated.
src/backend/core/api/serializers.py (2)

2245-2250: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Return a 400 for malformed webhook URLs.

urlparse() / parsed_url.hostname can raise ValueError for malformed netlocs such as invalid bracketed IPv6. Catch it and raise a serializer validation error instead of letting a bad request become a 500.

Proposed fix
-        parsed_url = urlparse(url)
+        try:
+            parsed_url = urlparse(url)
+            host = (parsed_url.hostname or "").lower()
+        except ValueError as exc:
+            raise serializers.ValidationError(
+                {"settings": "webhook settings.url must be a valid URL."}
+            ) from exc
         if parsed_url.scheme not in ("http", "https"):
             raise serializers.ValidationError(
                 {"settings": "webhook settings.url must be http:// or https://"}
             )
-        host = (parsed_url.hostname or "").lower()
         if not host:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        try:
            parsed_url = urlparse(url)
            host = (parsed_url.hostname or "").lower()
        except ValueError as exc:
            raise serializers.ValidationError(
                {"settings": "webhook settings.url must be a valid URL."}
            ) from exc
        if parsed_url.scheme not in ("http", "https"):
            raise serializers.ValidationError(
                {"settings": "webhook settings.url must be http:// or https://"}
            )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/api/serializers.py` around lines 2245 - 2250, The webhook
URL validation in the serializer should return a 400 instead of bubbling a
ValueError for malformed netlocs. Update the URL handling in the serializer
method that calls urlparse() and reads parsed_url.hostname to catch ValueError
around that parsing/hostname extraction, and convert it into
serializers.ValidationError with the existing settings error shape so bad
webhook URLs are rejected consistently.

2814-2822: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Validate Web Push keys are non-empty strings.

The current truthiness check accepts objects/lists for p256dh or auth, then persists them into encrypted_settings; senders later expect subscription key strings.

Proposed fix
         if attrs.get("platform") == enums.PushPlatformChoices.WEB:
             keys = attrs.get("keys") or {}
-            if not keys.get("p256dh") or not keys.get("auth"):
+            p256dh = keys.get("p256dh")
+            auth = keys.get("auth")
+            if (
+                not isinstance(p256dh, str)
+                or not p256dh.strip()
+                or not isinstance(auth, str)
+                or not auth.strip()
+            ):
                 raise serializers.ValidationError(
                     {"keys": "web push requires keys.p256dh and keys.auth."}
                 )
             # Persist only the two subscription keys we actually use, so a
             # client can't make us store arbitrary blobs in encrypted_settings.
-            attrs["keys"] = {"p256dh": keys["p256dh"], "auth": keys["auth"]}
+            attrs["keys"] = {"p256dh": p256dh, "auth": auth}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        if attrs.get("platform") == enums.PushPlatformChoices.WEB:
            keys = attrs.get("keys") or {}
            p256dh = keys.get("p256dh")
            auth = keys.get("auth")
            if (
                not isinstance(p256dh, str)
                or not p256dh.strip()
                or not isinstance(auth, str)
                or not auth.strip()
            ):
                raise serializers.ValidationError(
                    {"keys": "web push requires keys.p256dh and keys.auth."}
                )
            # Persist only the two subscription keys we actually use, so a
            # client can't make us store arbitrary blobs in encrypted_settings.
            attrs["keys"] = {"p256dh": p256dh, "auth": auth}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/api/serializers.py` around lines 2814 - 2822, The Web Push
key validation in the serializer currently only checks truthiness, so non-string
values like objects or lists can slip through and get stored in
encrypted_settings. Update the validation in this branch of the serializer
method handling attrs["platform"] == enums.PushPlatformChoices.WEB to explicitly
require both keys["p256dh"] and keys["auth"] to be non-empty strings before
persisting them. Keep the existing normalization to attrs["keys"], but reject
any missing or non-string values with serializers.ValidationError using the
existing web push key error message.
src/backend/core/enums.py (1)

222-224: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the push channel storage comment.

The token hash is stored in Channel.lookup_hash, while settings carries platform/client metadata. The current comment points future callers at the wrong storage location.

Proposed wording
-    # device's push token lives in ``encrypted_settings``; ``settings`` carries
-    # the platform + a token hash for dedup/reclaim. See core.services.push.
+    # device's push token lives in ``encrypted_settings``; ``lookup_hash``
+    # carries the token hash for dedup/reclaim; ``settings`` carries platform
+    # metadata. See core.services.push.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    # A user-scoped push-notification target (one per mobile device). The
    # device's push token lives in ``encrypted_settings``; ``lookup_hash``
    # carries the token hash for dedup/reclaim; ``settings`` carries platform
    # metadata. See core.services.push.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/enums.py` around lines 222 - 224, The push-notification
channel comment is pointing to the wrong place for the token hash storage.
Update the comment near the user-scoped push target definition to say that the
device push token lives in encrypted_settings, the token hash is stored in
Channel.lookup_hash, and settings holds the platform/client metadata; use
Channel and lookup_hash as the reference points so future callers check the
correct fields.
src/backend/core/management/commands/derive_vapid_public_key.py (1)

30-33: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether py-vapid's from_pem accepts base64url private-key strings.
python - <<'PY'
import inspect
from py_vapid import Vapid02

print(inspect.signature(Vapid02.from_pem))
print(Vapid02.from_pem.__doc__)
PY

Repository: suitenumerique/messages

Length of output: 282


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files first.
git ls-files 'src/backend/core/management/commands/derive_vapid_public_key.py' \
             'src/backend/core/services/push/webpush.py' \
             'src/backend/core/services/push/*' \
             | sed 's#^`#FILE` #'

echo
echo "=== outline: derive_vapid_public_key.py ==="
ast-grep outline src/backend/core/management/commands/derive_vapid_public_key.py --view expanded || true

echo
echo "=== outline: webpush.py ==="
ast-grep outline src/backend/core/services/push/webpush.py --view expanded || true

echo
echo "=== search for Vapid02.from_pem / base64url references ==="
rg -n "from_pem|base64url|PUSH_VAPID_PRIVATE_KEY|derive_vapid_public_key" src/backend/core -S

Repository: suitenumerique/messages

Length of output: 6188


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read only the relevant sections with line numbers.
sed -n '1,220p' src/backend/core/management/commands/derive_vapid_public_key.py | cat -n
echo
sed -n '1,240p' src/backend/core/services/push/webpush.py | cat -n

Repository: suitenumerique/messages

Length of output: 13575


Align the help text with the supported private-key format. derive_vapid_public_key() passes the value straight to Vapid02.from_pem(...), so --private-key should advertise PEM only unless base64url parsing is actually added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/management/commands/derive_vapid_public_key.py` around lines
30 - 33, The `derive_vapid_public_key()` command help text is advertising a
private-key format that the implementation does not support. Update the
`--private-key` option help in `derive_vapid_public_key` to describe only PEM
unless you also add base64url parsing before calling `Vapid02.from_pem(...)`, so
the documented format matches the actual accepted input.
src/backend/core/mda/dispatch_webhooks.py (1)

593-617: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not merge side effects from aborting webhook responses.

A blocking webhook can return {"action":"retry","mark_trashed":true}. The task will eventually quarantine-deliver that message, but these lines have already copied mark_trashed/mark_archived into ctx, so the “delivered to inbox so the warning is seen” path can still hide it. Return immediately on non-CONTINUE decisions before applying side effects.

Proposed fix
         )
+        if result.decision != Decision.CONTINUE:
+            return result.decision
+
         if result.is_spam_override is not None:
             ctx.is_spam = result.is_spam_override
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        if result.decision != Decision.CONTINUE:
            return result.decision

        if result.is_spam_override is not None:
            ctx.is_spam = result.is_spam_override
        ctx.labels |= result.labels
        if result.assign_to:
            # Defer the actual assignment until after the thread
            # exists (post-message-creation). Each blocking webhook
            # that asked gets its own ThreadEvent ASSIGN, attributed
            # to this channel.
            ctx.pending_assigns.append((self.channel.id, result.assign_to))
        # Bool flags OR-merge: any blocking webhook saying true sticks.
        ctx.mark_starred = ctx.mark_starred or result.mark_starred
        ctx.mark_read = ctx.mark_read or result.mark_read
        ctx.mark_trashed = ctx.mark_trashed or result.mark_trashed
        ctx.mark_archived = ctx.mark_archived or result.mark_archived
        ctx.skip_autoreply = ctx.skip_autoreply or result.skip_autoreply
        for event in result.events:
            # Per-event attribution like assigns — one ThreadEvent per
            # add_event entry, with channel set to the firing webhook.
            ctx.pending_events.append((self.channel.id, event))
        if result.reply_draft_template_id:
            # Defer template lookup + draft creation until after the
            # message + thread land. Each blocking webhook that asked
            # produces its own draft, channel-attributed.
            ctx.pending_drafts.append((self.channel.id, result.reply_draft_template_id))
        return result.decision
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/mda/dispatch_webhooks.py` around lines 593 - 617, In the
webhook result handling inside the dispatch flow, avoid merging any side effects
from blocking responses that do not continue, because aborting decisions like
retry can still propagate flags such as mark_trashed and mark_archived into ctx.
Update the logic around result.decision so that the method returns immediately
for any non-CONTINUE decision before applying ctx updates, and only apply
labels, pending assigns/events/drafts, and boolean merges when the webhook
actually continues. Use the existing decision handling and the surrounding
webhook result processing in dispatch_webhooks to place the early return.
src/backend/core/mda/inbound_pipeline.py (3)

177-210: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
import re
assert r"\S+".lower() == r"\s+"
assert re.fullmatch(r"\S+".lower(), "abc") is None
PY

Repository: suitenumerique/messages

Length of output: 161


Don't lowercase header_match_regex values. value.lower() changes regex escapes like \S/\D and can turn a valid pattern into the wrong match; keep exact-match values normalized separately and guard re.fullmatch with re.error so a bad rule doesn't abort the step.

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 209-209: Regex pattern passed to re is built from a non-literal (variable, call, concatenation, or f-string) value. If that value is attacker-controlled it can introduce a malicious pattern with catastrophic backtracking (ReDoS). Use a hardcoded literal pattern, or validate/escape untrusted input with re.escape() and bound the regex complexity before compiling.
Context: re.fullmatch(value, header_value)
Note: [CWE-1333] Inefficient Regular Expression Complexity.

(redos-non-literal-regex-python)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/mda/inbound_pipeline.py` around lines 177 - 210, The
header-matching logic in the inbound pipeline is incorrectly lowercasing regex
patterns, which can break `header_match_regex` rules and may also raise on
invalid patterns. Update the normalization in `inbound_pipeline.py` so
exact-match comparisons still use a lowered/trimmed value, but regex matching in
the header rule path keeps the original pattern text before `re.fullmatch`; use
the existing header rule flow around `header_match`/`header_match_regex` and add
`re.error` handling so a bad regex rule is skipped instead of aborting
processing.

Source: Linters/SAST tools


346-347: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Retry when rspamd-backed inbound auth cannot be checked.

When ctx.is_spam is already set, the rspamd spam step skips and this fallback is the only rspamd call for sender auth. Ignoring _call_rspamd(...)[1] means a transient rspamd outage silently removes the sender-auth banner instead of using the same retry/quarantine behavior.

Proposed fix
     def inbound_auth(ctx: InboundContext) -> Decision:
         if ctx.rspamd_result is None and get_inbound_auth_mode(spam_config) == "rspamd":
-            _, _, ctx.rspamd_result = _call_rspamd(ctx.raw_data, spam_config)
+            _, err, result = _call_rspamd(ctx.raw_data, spam_config)
+            if err:
+                logger.warning(
+                    "rspamd error during inbound auth on message %s: %s",
+                    ctx.inbound_message.id,
+                    err,
+                )
+                return Decision.RETRY
+            ctx.rspamd_result = result
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        if ctx.rspamd_result is None and get_inbound_auth_mode(spam_config) == "rspamd":
            _, err, result = _call_rspamd(ctx.raw_data, spam_config)
            if err:
                logger.warning(
                    "rspamd error during inbound auth on message %s: %s",
                    ctx.inbound_message.id,
                    err,
                )
                return Decision.RETRY
            ctx.rspamd_result = result
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/mda/inbound_pipeline.py` around lines 346 - 347, The
fallback rspamd auth path in inbound_pipeline should handle transient rspamd
failures the same way as the spam step instead of silently ignoring them. In the
branch that calls _call_rspamd in the inbound auth check, capture and inspect
the second return value (the exception/error from _call_rspamd) and apply the
same retry/quarantine behavior used by the rspamd spam logic when the auth check
cannot be completed. Keep the existing ctx.rspamd_result assignment on success,
but make sure the sender-auth banner is not dropped when rspamd is temporarily
unavailable.

497-525: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Remove assignee email addresses from logs.

These warnings log webhook-supplied or user email addresses directly. Log a position/count or non-reversible internal identifier instead. As per coding guidelines, src/backend/**/*.py: “Do not log sensitive information (tokens, passwords, financial/health data, PII)”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/mda/inbound_pipeline.py` around lines 497 - 525, The
warnings in inbound_pipeline.py are logging sensitive assignee email addresses
directly, so update the assignee resolution flow to avoid emitting PII. In the
webhook assignee handling and the assignable-role check, replace email-based log
fields in logger.warning calls with a non-reversible internal identifier or a
position/count-based reference tied to candidate_users/candidate_ids. Keep the
existing control flow in the assignee matching logic, but ensure the messages in
the candidate_users loop and the assignable_ids validation no longer include raw
email values.

Source: Coding guidelines

src/backend/core/mda/inbound_tasks.py (1)

145-149: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make the processing lock cover worst-case webhook runtime.

The lock expires after 300s, but each webhook can block for 30s and the pipeline may include many webhook steps. If the lock expires before the first worker deletes the InboundMessage, the queue sweep can start a second run, duplicating webhook calls and non-idempotent finalize side effects such as IM events or reply drafts. Use a refreshed lock/heartbeat or size the TTL from the webhook count and timeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/mda/inbound_tasks.py` around lines 145 - 149, The lock in
process_inbound_message is hardcoded to expire after 300s, which can be shorter
than the worst-case webhook pipeline runtime and allow duplicate processing.
Update the locking approach around cache.add(lock_key, "locked", 300) to either
refresh the lock while processing or compute a TTL based on the webhook count
and per-webhook timeout so the lock stays valid until InboundMessage deletion
and finalization are complete.
src/backend/core/models.py (2)

535-541: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Namespace lookup_hash inputs before relying on global uniqueness.

The comment says every type should namespace its hash input, but the documented push shape is sha256(token) and the provided push registration context uses the bare token hash. With a global unique constraint, future external-identity channel types can collide on the same raw value. Use a type-scoped input such as sha256(f"push:{token}") and update the registration lookup/tests together.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/models.py` around lines 535 - 541, The hash-input guidance
in the model comment is inconsistent with the push registration shape, since
`lookup_hash` is currently derived from the bare token and can collide under the
global unique constraint. Update the push registration path and any related
lookup/tests to namespace the hashed input with a type-specific prefix (for
example in the push registration flow using `sha256(...)`), and keep the
`uniq_channel_lookup_hash`-related behavior aligned with that scoped keying.

681-692: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Make missing webhook roots fail closed before building headers.

get_webhook_api_key() can return None, but the provided dispatch_webhooks.py context puts that value directly into X-StMsg-Api-Key. Misconfigured rows should return no auth headers or raise a controlled error, not produce a None header value at send time.

Proposed downstream guard
-            return {"X-StMsg-Api-Key": self.channel.get_webhook_api_key()}
+            api_key = self.channel.get_webhook_api_key()
+            if not api_key:
+                logger.warning(
+                    "Webhook channel %s has no root secret — skipping",
+                    self.channel.id,
+                )
+                return None
+            return {"X-StMsg-Api-Key": api_key}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/models.py` around lines 681 - 692, get_webhook_api_key() may
return None for misconfigured channels, but the webhook dispatch path is still
passing that value into X-StMsg-Api-Key. Update the webhook-building logic in
dispatch_webhooks.py to fail closed before headers are created: either skip auth
headers entirely or raise a controlled error when get_webhook_api_key() returns
None. Use get_webhook_api_key() and the header construction code as the key
locations to patch.
src/backend/core/services/push/common.py (1)

430-437: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
echo "Configured cache backend(s):"
rg -n -C3 'CACHES|RedisCache|PyMemcacheCache|Memcached|LocMemCache' src/backend/messages/settings.py

Repository: suitenumerique/messages

Length of output: 3203


🏁 Script executed:

#!/bin/bash
set -e
printf 'common.py around stale-delete helper:\n'
sed -n '410,445p' src/backend/core/services/push/common.py

printf '\nTest coverage around stale-delete behavior:\n'
rg -n -C3 '_stale_delete_within_window|stale-delete|stale_delete' src/backend/core/tests/test_push.py

printf '\nCache backend selection in settings:\n'
rg -n -C2 'CACHES =|LocMemCache|django_redis.cache.RedisCache|REDIS_URL|CACHES_DEFAULT_TIMEOUT' src/backend/messages/settings.py

Repository: suitenumerique/messages

Length of output: 1907


🌐 Web query:

Django cache incr expiry semantics LocMemCache RedisCache django-redis does incr preserve timeout or ttl

💡 Result:

In Django's cache framework, the incr() and decr() methods are intended to update a value without affecting the key's existing Time-To-Live (TTL) or expiration timeout [1][2]. However, the implementation of this behavior has historically varied across backends, often resulting in bugs where the timeout is inadvertently reset to the default value [3][4][2]. LocMemCache The LocMemCache backend explicitly preserves the existing TTL during incr() and decr() operations [1][5]. This was established to match the behavior of production-grade backends like Memcached, ensuring that cache updates do not prematurely expire or extend keys [1][2]. RedisCache (via django-redis) django-redis is designed to leverage native Redis atomic INCR/DECR commands [6], which inherently do not modify a key's TTL [7]. However, because django-redis must handle serialization and potential race conditions (e.g., when a key does not exist), historical implementations have occasionally suffered from bugs where the TTL was lost or reset if the operation failed or fell back to non-atomic paths [8][9]. Modern versions of django-redis use scripts or atomic logic intended to preserve the TTL, but developers should be aware that custom configurations or older versions may exhibit inconsistent behavior regarding expiration [8][6][9]. DatabaseCache The DatabaseCache backend has historically had a known issue where incr() and decr() reset the expiration date to the default timeout instead of preserving the original one [3][10][4]. Recent efforts in the Django project have been directed at fixing this, ensuring that the DatabaseCache backend properly maintains the original expiry value during these operations [10][4]. Summary of Semantics When using incr() in Django, the desired and generally supported behavior is that the TTL remains unchanged [1][2]. If you encounter a scenario where the TTL is reset, it is typically considered a bug within the specific cache backend implementation rather than intended framework semantics [3][4][1]. If you rely heavily on persistent TTLs for counters, it is recommended to verify the behavior with your specific Django and cache backend versions, as fixes for these semantics have been an ongoing maintenance area [10][4][1].

Citations:


Make the stale-delete cap actually rolling
cache.incr() preserves the key’s TTL here, so this is a fixed 60-second bucket from the first delete, not a rolling window. Refresh the expiry on each hit (or touch() the key) if the intent is to cap deletes over the last minute.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/services/push/common.py` around lines 430 - 437, The
stale-delete limiter in the delete-check logic is using a fixed TTL bucket
instead of a rolling window because cache.incr() does not refresh expiry. Update
the counter handling in the stale-delete path around the cache_key logic so each
delete hit renews the expiration (for example by touching the key or resetting
the TTL after incrementing), keeping the cap aligned with the last minute rather
than the first delete. Ensure the change is applied in the code that uses
cache.add(), cache.incr(), and
STALE_DELETE_WINDOW_LIMIT/STALE_DELETE_WINDOW_SECONDS.
src/backend/core/services/push/tasks.py (1)

126-130: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Isolate per-device enqueue failures.

If one send_push_notification.delay(...) raises, the fan-out loop aborts and all remaining devices for this message are skipped. Since push is best-effort, log the failed channel and continue dispatching the rest.

Proposed fix
         )
         for channel in user_channels:
-            send_push_notification.delay(  # pylint: disable=no-member
-                str(channel.id), payload, collapse_key
-            )
-            dispatched += 1
+            try:
+                send_push_notification.delay(  # pylint: disable=no-member
+                    str(channel.id), payload, collapse_key
+                )
+            except Exception as exc:
+                logger.warning(
+                    "Failed to dispatch push task for channel %s: %s",
+                    channel.id,
+                    exc,
+                )
+                continue
+            dispatched += 1
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        for channel in user_channels:
            try:
                send_push_notification.delay(  # pylint: disable=no-member
                    str(channel.id), payload, collapse_key
                )
            except Exception as exc:
                logger.warning(
                    "Failed to dispatch push task for channel %s: %s",
                    channel.id,
                    exc,
                )
                continue
            dispatched += 1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/services/push/tasks.py` around lines 126 - 130, The fan-out
loop in the push dispatch path aborts if a single send_push_notification.delay
call fails, so handle enqueue errors per channel inside the loop in the dispatch
logic. In the task method that iterates over user_channels, catch exceptions
around each delay call, log the failing channel id with enough context, and
continue to the next channel so one bad device does not stop the rest from being
dispatched.
src/backend/core/services/push/webpush.py (1)

79-83: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Avoid logging the VAPID subject value.

PUSH_VAPID_SUBJECT is commonly a mailto: URI, so this can put an email address in logs. Log only that the setting is malformed.

Proposed fix
         logger.warning(
-            "PUSH_VAPID_SUBJECT %r is not a mailto:/https: URI; "
-            "Web Push is disabled until it is fixed.",
-            settings.PUSH_VAPID_SUBJECT,
+            "PUSH_VAPID_SUBJECT is not a mailto:/https: URI; "
+            "Web Push is disabled until it is fixed."
         )

As per coding guidelines, src/backend/**/*.py: Do not log sensitive information, including PII.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        logger.warning(
            "PUSH_VAPID_SUBJECT is not a mailto:/https: URI; "
            "Web Push is disabled until it is fixed."
        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/services/push/webpush.py` around lines 79 - 83, The warning
in webpush.py logs the raw PUSH_VAPID_SUBJECT value, which can expose sensitive
PII in logs. Update the logger.warning call in the Web Push settings validation
path to report only that PUSH_VAPID_SUBJECT is malformed and that Web Push is
disabled, without interpolating settings.PUSH_VAPID_SUBJECT or any other subject
value. Keep the fix localized to the VAPID subject validation logic so the
behavior stays the same except for redacting the logged content.

Source: Coding guidelines

src/frontend/public/sw.js (2)

40-55: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add a timeout to the enrichment fetch.

If /api/v1.0/messages/${id}/ stalls, event.waitUntil(buildNotification(payload)) waits on that request and the generic fallback is delayed too, so a partial API outage can suppress the notification instead of degrading gracefully. This path needs a short timeout and then an immediate fallback to the content-free banner.

Suggested fix
+const fetchWithTimeout = async (url, options, timeoutMs) => {
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), timeoutMs);
+  try {
+    return await fetch(url, { ...options, signal: controller.signal });
+  } finally {
+    clearTimeout(timer);
+  }
+};
+
 async function buildNotification(payload) {
@@
   if (payload.message_id) {
     try {
-      const resp = await fetch(MESSAGE_URL(payload.message_id), {
+      const resp = await fetchWithTimeout(
+        MESSAGE_URL(payload.message_id),
+        {
           credentials: "include",
           headers: { Accept: "application/json" },
-      });
+        },
+        2000,
+      );
       if (resp.ok) {
         const message = await resp.json();
         title = senderLabel(message);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

const fetchWithTimeout = async (url, options, timeoutMs) => {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    return await fetch(url, { ...options, signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
};

  // Fetch-to-enrich: pull sender + subject over the authenticated session.
  if (payload.message_id) {
    try {
      const resp = await fetchWithTimeout(
        MESSAGE_URL(payload.message_id),
        {
        credentials: "include",
        headers: { Accept: "application/json" },
        },
        2000,
      );
      if (resp.ok) {
        const message = await resp.json();
        title = senderLabel(message);
        options.body = message.subject || "";
      }
    } catch {
      // Offline / cross-origin / signed out — keep the generic banner.
    }
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/public/sw.js` around lines 40 - 55, The enrichment fetch in
buildNotification can hang indefinitely and delay the fallback banner, so add a
short timeout around the MESSAGE_URL(payload.message_id) request in
src/frontend/public/sw.js. Update the fetch-to-enrich block to abort/stop
waiting after the timeout and then continue with the generic notification path,
keeping the existing catch behavior for offline/cross-origin/signed-out cases.
Use the buildNotification and MESSAGE_URL symbols to locate the logic.

89-103: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don't navigate the first same-origin tab blindly.

clients.matchAll() returns every controlled window for this origin. The current loop focuses and navigates the first one, so clicking a notification can yank an unrelated tab away from its current page instead of reusing a mailbox tab or opening a new one.

Suggested fix
   event.waitUntil(
     clients
       .matchAll({ type: "window", includeUncontrolled: true })
       .then((windowClients) => {
-        // Reuse an existing tab where possible: focus it and navigate to the
-        // thread (refetches and clears the badge).
-        for (const client of windowClients) {
-          if ("focus" in client) {
-            if ("navigate" in client && url !== "/") {
-              return client.focus().then((c) => (c || client).navigate(url));
-            }
-            return client.focus();
-          }
+        const appClient = windowClients.find((client) => {
+          const pathname = new URL(client.url).pathname;
+          return pathname === "/" || pathname.startsWith("/mailbox/");
+        });
+
+        if (appClient && "focus" in appClient) {
+          if ("navigate" in appClient && url !== "/") {
+            return appClient.focus().then((c) => (c || appClient).navigate(url));
+          }
+          return appClient.focus();
         }
         if (clients.openWindow) {
           return clients.openWindow(url);
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    clients
      .matchAll({ type: "window", includeUncontrolled: true })
      .then((windowClients) => {
        const appClient = windowClients.find((client) => {
          const pathname = new URL(client.url).pathname;
          return pathname === "/" || pathname.startsWith("/mailbox/");
        });

        if (appClient && "focus" in appClient) {
          if ("navigate" in appClient && url !== "/") {
            return appClient.focus().then((c) => (c || appClient).navigate(url));
          }
          return appClient.focus();
        }
        if (clients.openWindow) {
          return clients.openWindow(url);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/public/sw.js` around lines 89 - 103, The notification click
handling in sw.js is reusing the first same-origin window from
clients.matchAll() without checking whether it is the intended mailbox/thread
tab. Update the loop in the notification navigation path to only focus/navigate
a matching client when it already represents the app target, and otherwise fall
back to clients.openWindow(url). Use the existing clients.matchAll,
client.focus, client.navigate, and clients.openWindow flow to prefer a relevant
tab rather than blindly redirecting any same-origin window.
src/frontend/src/features/layouts/components/mailbox-settings/devices-view/user-devices-grid.tsx (1)

78-82: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Gate this flow on PUSH_ENABLED too.

This button only checks browser support plus PUSH_VAPID_PUBLIC_KEY, but the backend now exposes PUSH_ENABLED specifically to hide push registration when the feature is off. In that state the registration endpoint returns 404, so this UI can advertise an action that is guaranteed to fail.

Suggested fix
-    const canEnableThisBrowser =
-        isWebPushSupported() && !!config.PUSH_VAPID_PUBLIC_KEY;
+    const canEnableThisBrowser =
+        config.PUSH_ENABLED &&
+        isWebPushSupported() &&
+        !!config.PUSH_VAPID_PUBLIC_KEY;
@@
-        if (!config.PUSH_VAPID_PUBLIC_KEY) {
+        if (!config.PUSH_ENABLED || !config.PUSH_VAPID_PUBLIC_KEY) {
             return;
         }

Also applies to: 90-93

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/frontend/src/features/layouts/components/mailbox-settings/devices-view/user-devices-grid.tsx`
around lines 78 - 82, The browser enablement check in user-devices-grid should
also respect the backend push feature flag, not just isWebPushSupported() and
config.PUSH_VAPID_PUBLIC_KEY. Update the canEnableThisBrowser logic (and any
related button visibility/availability in this flow) to require
config.PUSH_ENABLED so the UI never offers push registration when the feature is
disabled and the registration endpoint would 404.
src/frontend/src/features/layouts/components/mailbox-settings/devices-view/web-push.ts (1)

107-124: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

For the Web Push Push API, is an existing PushSubscription still valid after the site's VAPID applicationServerKey changes, or should clients unsubscribe and resubscribe with the new key?

💡 Result:

An existing PushSubscription is cryptographically bound to the VAPID applicationServerKey used at the time of subscription [1][2]. If you change your applicationServerKey, existing subscriptions will effectively become invalid because the push service will reject any push messages authenticated with the new private key, as they do not match the key associated with the original subscription [2][3]. To handle a VAPID key change, you must force clients to resubscribe [4][2]. The browser does not automatically update or replace the subscription when the key changes; attempting to subscribe with a new key while an old subscription exists may cause errors in some browsers [5]. The recommended approach to handle this transition is: 1. Detect that the applicationServerKey has changed (e.g., by comparing the current key used by your app with the key stored in the existing subscription's options) [4][6]. 2. If the keys do not match, call unsubscribe on the existing PushSubscription [4][5]. 3. Call subscribe again with the new applicationServerKey to create a fresh subscription [4][5]. 4. Send the new subscription details to your server to update your records [7]. Failure to perform this re-subscription flow will result in push delivery failures, typically manifesting as 401 Unauthorized or 403 Forbidden errors when your server attempts to send notifications [4][2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file='src/frontend/src/features/layouts/components/mailbox-settings/devices-view/web-push.ts'

echo '== file outline =='
ast-grep outline "$file" --view expanded || true

echo
echo '== relevant lines =='
nl -ba "$file" | sed -n '1,260p'

Repository: suitenumerique/messages

Length of output: 585


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file='src/frontend/src/features/layouts/components/mailbox-settings/devices-view/web-push.ts'

echo '== relevant lines 80-240 =='
sed -n '80,240p' "$file" | cat -n

echo
echo '== search for subscription handling =='
rg -n "getSubscription|subscribe\\(|unsubscribe\\(|applicationServerKey|refreshWebPushSubscription|enableWebPush" "$file"

Repository: suitenumerique/messages

Length of output: 5660


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== VAPID / pushsubscription references =='
rg -n "vapid|pushsubscriptionchange|refreshWebPushSubscription|enableWebPush|applicationServerKey" src/frontend src -g '!**/node_modules/**'

Repository: suitenumerique/messages

Length of output: 11641


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file='src/frontend/public/sw.js'

echo '== outline =='
ast-grep outline "$file" --view expanded || true

echo
echo '== relevant lines 120-170 =='
sed -n '120,170p' "$file" | cat -n

Repository: suitenumerique/messages

Length of output: 2605


Resubscribe when the configured VAPID key changes. Both paths reuse an existing PushSubscription without checking whether it was created with the current vapidPublicKey, so a key rotation can leave the browser bound to the old key and keep re-posting a subscription that push delivery can no longer use. Also applies to refreshWebPushSubscription.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/frontend/src/features/layouts/components/mailbox-settings/devices-view/web-push.ts`
around lines 107 - 124, The subscription reuse logic in `webPushSubscription` is
not validating that an existing `PushSubscription` was created with the current
`vapidPublicKey`, which can leave the browser stuck on an old key after
rotation. Update both `webPushSubscription` and `refreshWebPushSubscription` to
inspect the existing subscription’s app server key before reusing it, and if it
does not match the current `vapidPublicKey`, unsubscribe and create a fresh
subscription instead. Keep the existing `AbortError` handling in the
`registration.pushManager.subscribe` path intact.
src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/webhook-integration-form.tsx (1)

111-128: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Avoid changing webhook auth mode without revealing a matching credential.

On edit, switching auth_method changes what receivers must verify, but update responses never include secret/api_key; those are only returned on create or rotation. This can leave an existing webhook configured to require a credential the user cannot see. Either make authentication read-only during edit, trigger regenerate-secret and show the returned credential, or require users to rotate explicitly after changing auth mode.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/webhook-integration-form.tsx`
around lines 111 - 128, When editing an existing webhook in
webhook-integration-form.tsx, don’t allow changing auth_method without also
exposing a usable credential. Update the edit flow around the newSettings
construction and the isEditing/updateMutation path so auth is either read-only,
or changing it triggers a secret rotation/regenerate-secret flow and surfaces
the returned secret/api_key to the user. Use the existing WebhookChannelSettings
shape and the mutateAsync/updateMutation logic to keep auth mode and credential
state consistent.
src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx (1)

258-259: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the same resolved author string for avatar color and label.

These lines render author_display, but avatarColor is still derived from event.author?.full_name || event.author?.email at Line 221. For webhook IM events author is null while author_display is populated, so every integration comment hashes from the empty string and ends up visually indistinguishable.

Proposed fix
-        const authorName = event.author?.full_name || event.author?.email || "";
+        const authorName = event.author_display || event.author?.full_name || event.author?.email || t("Unknown");
         const avatarColor = getAvatarColor(authorName);
...
-                                <UserAvatar fullName={event.author_display || t("Unknown")} size="xsmall" />
-                                {event.author_display || t("Unknown")}
+                                <UserAvatar fullName={authorName} size="xsmall" />
+                                {authorName}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        const authorName = event.author_display || event.author?.full_name || event.author?.email || t("Unknown");
        const avatarColor = getAvatarColor(authorName);
...
                                <UserAvatar fullName={authorName} size="xsmall" />
                                {authorName}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx`
around lines 258 - 259, The ThreadEvent author rendering is using
event.author_display for the avatar label, but avatarColor still hashes
event.author?.full_name || event.author?.email, which breaks webhook IM events
where author is null and all avatars look the same. Update the avatar color
source to use the same resolved author string as the label in this component, so
UserAvatar and the displayed text in the thread-event view stay consistent for
all event types.

@sylvinus sylvinus force-pushed the outbound_webhooks branch from 9331775 to 27222a7 Compare June 29, 2026 12:22
@jbpenrath jbpenrath self-assigned this Jul 8, 2026
@jbpenrath jbpenrath changed the base branch from outbound_webhooks to feat/mobile-application July 8, 2026 15:49
@jbpenrath jbpenrath changed the base branch from feat/mobile-application to development July 8, 2026 16:33
@jbpenrath

Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jbpenrath

Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/push-notifications.md`:
- Line 76: Add a blank line after each flagged level-3 heading in the
documentation so markdownlint MD022 passes. Update every affected `###` section
header in `docs/push-notifications.md` by inserting a separator before the
following content, using the same pattern for all instances around the `push
notifications` section headings.

In `@src/backend/core/api/viewsets/config.py`:
- Around line 234-244: Make PUSH_ENABLED required in the config schema so it
matches the always-present runtime value. Update the ConfigEntry for
PUSH_ENABLED in the config viewset to use required=True, alongside the other
feature-flag entries, so the OpenAPI schema and generated TS client treat it as
non-optional.

In `@src/backend/core/services/push/fcm.py`:
- Around line 108-174: send_fcm currently lets batch-level failures escape,
unlike send_apns, so transient client setup or loop errors are treated as
sender_raised and won’t retry. Wrap the _fcm_client() initialization and the
entire per-channel loop in an outer try/except in send_fcm, mirroring the APNs
pattern, and return a transient PushResult(0, len(items)) (or equivalent) on
failure so send_push_notification can retry through PushTransientError.

In `@src/backend/core/tests/test_push.py`:
- Line 15: The pylint disable directive in the test module contains a duplicate
protected-access entry; clean up the comment by deduplicating the disable list
so each symbol appears only once. Update the top-of-file pylint pragma in
test_push.py and keep the existing disables intact, just remove the repeated
protected-access entry.

In `@src/backend/messages/settings.py`:
- Around line 238-279: Add boot-time partial-configuration validation for the
push settings in post_setup, mirroring the existing VAPID guard. The current
PUSH_APNS_* and PUSH_FCM_* groups can silently no-op when only some fields are
set, so update the settings validation logic to fail fast if APNs is partially
configured (PUSH_APNS_KEY, PUSH_APNS_KEY_ID, PUSH_APNS_TEAM_ID,
PUSH_APNS_BUNDLE_ID must all be present together) or if FCM is partially
configured (PUSH_FCM_CREDENTIALS and PUSH_FCM_PROJECT_ID must both be present).
Locate this in the post_setup validation path alongside the existing VAPID
checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ef58855e-378a-4bf2-830b-88b95ec6296c

📥 Commits

Reviewing files that changed from the base of the PR and between b23d3e5 and ae8c3f1.

⛔ Files ignored due to path filters (6)
  • src/backend/uv.lock is excluded by !**/*.lock
  • src/frontend/public/assets/icons/icon-mono-72.png is excluded by !**/*.png
  • src/frontend/src/features/api/gen/channels/channels.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/channel.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/channel_create_response.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/config_retrieve200.ts is excluded by !**/gen/**
📒 Files selected for processing (30)
  • docs/push-notifications.md
  • src/backend/core/api/openapi.json
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/channel.py
  • src/backend/core/api/viewsets/config.py
  • src/backend/core/enums.py
  • src/backend/core/management/commands/derive_vapid_public_key.py
  • src/backend/core/management/commands/generate_vapid_private_key.py
  • src/backend/core/mda/inbound_tasks.py
  • src/backend/core/migrations/0033_channel_lookup_hash.py
  • src/backend/core/models.py
  • src/backend/core/services/push/__init__.py
  • src/backend/core/services/push/apns.py
  • src/backend/core/services/push/common.py
  • src/backend/core/services/push/fcm.py
  • src/backend/core/services/push/tasks.py
  • src/backend/core/services/push/webpush.py
  • src/backend/core/signals.py
  • src/backend/core/tasks.py
  • src/backend/core/tests/api/test_config.py
  • src/backend/core/tests/mda/test_dispatch_webhooks.py
  • src/backend/core/tests/test_push.py
  • src/backend/core/tests/test_settings.py
  • src/backend/messages/settings.py
  • src/backend/pyproject.toml
  • src/frontend/public/locales/common/de-DE.json
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/public/locales/common/fr-FR.json
  • src/frontend/public/locales/common/nl-NL.json
  • src/frontend/public/sw.js
💤 Files with no reviewable changes (6)
  • src/frontend/public/locales/common/de-DE.json
  • src/backend/core/tests/mda/test_dispatch_webhooks.py
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/public/sw.js
  • src/frontend/public/locales/common/nl-NL.json
  • src/frontend/public/locales/common/fr-FR.json

Comment thread docs/push-notifications.md
Comment thread src/backend/core/api/viewsets/config.py
Comment thread src/backend/core/services/push/fcm.py
Comment thread src/backend/core/tests/test_push.py Outdated
Comment thread src/backend/messages/settings.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants