Skip to content

✨(integrations) add outgoing webhooks for every incoming email - #691

Merged
sylvinus merged 21 commits into
developmentfrom
outbound_webhooks
Jul 1, 2026
Merged

✨(integrations) add outgoing webhooks for every incoming email#691
sylvinus merged 21 commits into
developmentfrom
outbound_webhooks

Conversation

@sylvinus

@sylvinus sylvinus commented Jun 3, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Added webhook integrations for outbound message delivery, including new trigger options, payload formats, and credential handling.
    • Added support for capturing richer message and event details, including webhook actor display names and one-time secret rotation.
  • Bug Fixes

    • Improved inbound delivery handling, including safer retries, abandoned message recovery, and better spam/safety warnings.
    • Added internal-delivery support for same-instance mail and clearer delivery-status handling.
  • Documentation

    • Expanded webhook, environment, and tiered-storage documentation.
    • Updated localization text for the new webhook and delivery UI.

@coderabbitai

coderabbitai Bot commented Jun 3, 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: c3de9735-7972-4205-8aba-ab1b16e2abe7

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

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds webhook channel support end to end, including secret rotation, API and admin changes, a new outbound webhook dispatch pipeline, inbound message storage and delivery updates for internal mail, and matching frontend, localization, and test coverage.

Changes

Webhook channels, inbound pipeline, and frontend integration

Layer / File(s) Summary
Channel secrets, API surfaces, and configuration
src/backend/core/models.py, src/backend/core/admin.py, src/backend/core/api/serializers.py, src/backend/core/api/viewsets/channel.py, src/backend/core/enums.py, src/backend/core/factories.py, src/backend/core/services/ssrf.py, src/backend/core/services/thread_events.py, src/backend/core/templates/admin/core/channel/..., src/backend/core/api/openapi.json, src/backend/messages/settings.py, env.d/development/backend.defaults
Replaces API-key rotation with rotate_secret(), adds webhook credential derivation, updates channel create/regenerate responses and OpenAPI schemas, validates webhook settings, wires webhook support into admin and feature flags, and adds SSRF-safe POST support.
InboundMessage dual byte-source and blob GC
src/backend/core/models.py, src/backend/core/migrations/0032_inboundmessage_blob_inboundmessage_is_internal_and_more.py, src/backend/core/signals.py, src/backend/core/mda/inbound.py, src/backend/core/mda/outbound.py, src/backend/core/services/exporter/tasks.py, docs/tiered-storage.md, src/backend/core/tests/models/test_blob.py, src/backend/core/tests/api/test_messages_*, src/backend/core/tests/mda/test_outbound.py, src/backend/core/tests/mda/test_outbound_e2e.py, src/backend/core/tests/mda/test_retry.py, src/backend/core/tests/test_signals.py, src/backend/e2e/management/commands/e2e_demo.py
Adds nullable blob storage and is_internal on InboundMessage, enforces one byte source, updates GC and internal delivery handoff, and switches delivery-status expectations to SENT_INTERNAL/SENT_EXTERNAL throughout related code and tests.
Webhook dispatch engine and inbound pipeline
src/backend/core/mda/dispatch_webhooks.py, src/backend/core/mda/webhook_payload.py, src/backend/core/mda/inbound_pipeline.py, src/backend/core/mda/inbound_tasks.py, src/backend/core/mda/autoreply.py, src/backend/core/mda/inbound_create.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/mda/test_autoreply.py, src/backend/core/tests/mda/test_inbound.py
Implements webhook payload construction, SSRF-safe signed delivery, response classification, step-based inbound processing, deferred side-effect application, retry/quarantine handling, and autoreply/draft creation refactoring with matching tests.
Frontend webhook UI and message banners
src/frontend/src/features/layouts/components/mailbox-settings/..., src/frontend/src/features/layouts/components/thread-view/..., src/frontend/src/features/layouts/components/thread-view/components/thread-event/...test.ts, src/frontend/public/locales/common/...
Adds webhook integration UI, thread event author_display handling, processing-failed banner rendering, and translated webhook/credential warning copy across locales.
Webhook specification
docs/webhooks.md
New outbound webhook specification covering phases, scopes, settings, transport, authentication, response actions, payload formats, merge rules, and security notes.

Sequence Diagram

sequenceDiagram
  participant deliver_inbound_message
  participant process_inbound_message_task
  participant build_inbound_pipeline
  participant UserWebhookStep
  participant SSRFSafeSession
  participant WebhookReceiver

  deliver_inbound_message->>process_inbound_message_task: queue InboundMessage (raw_data or blob)
  process_inbound_message_task->>build_inbound_pipeline: build steps from InboundContext
  build_inbound_pipeline-->>process_inbound_message_task: ordered steps
  process_inbound_message_task->>UserWebhookStep: execute phase step
  UserWebhookStep->>SSRFSafeSession: POST signed payload
  SSRFSafeSession->>WebhookReceiver: X-StMsg-* + eml/jmap body
  WebhookReceiver-->>SSRFSafeSession: response
  SSRFSafeSession-->>UserWebhookStep: classified result
  UserWebhookStep-->>process_inbound_message_task: Decision + side effects
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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: adding outbound webhooks for incoming emails/integrations.
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.

@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: 6

Caution

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

⚠️ Outside diff range comments (3)
src/backend/core/admin.py (1)

583-619: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Webhook secret rotation is still blocked in Django admin.

This view now delegates to Channel.rotate_secret(), but Line 602 still rejects every non-api_key channel and the response stays API-key-specific. That leaves webhook channels with no admin path to rotate their shared secret even though this PR exposes them as mailbox-admin integrations.

Suggested direction
-    def regenerate_api_key_view(self, request, object_id):
-        """Regenerate the api_key secret on an api_key channel.
+    def regenerate_secret_view(self, request, object_id):
+        """Regenerate the shared secret on an api_key or webhook channel.
@@
-        if channel.type != ChannelTypes.API_KEY:
+        if channel.type not in {ChannelTypes.API_KEY, ChannelTypes.WEBHOOK}:
             messages.error(
-                request, "Only api_key channels can have their secret regenerated."
+                request, "Only api_key and webhook channels can have their secret regenerated."
             )
             return redirect("..")
@@
-            "title": "New api_key generated",
-            "api_key": plaintext,
+            "title": "New secret generated",
+            "secret": plaintext,
         }
-        return TemplateResponse(
-            request, "admin/core/channel/regenerated_api_key.html", context
-        )
+        return TemplateResponse(request, "admin/core/channel/regenerated_secret.html", context)
🤖 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 583 - 619, The
regenerate_api_key_view currently blocks non-API_KEY channels — update
regenerate_api_key_view to allow rotating secrets for webhook channels as well
by permitting ChannelTypes.WEBHOOK in the type check (use ChannelTypes.API_KEY
or ChannelTypes.WEBHOOK), call channel.rotate_secret() for either type, and
build a context/title that is generic or conditional (e.g., use a type-aware
title like "New secret generated" and include channel.type in the context). Also
update the error message when the type is unsupported and ensure the
TemplateResponse/template ("admin/core/channel/regenerated_api_key.html") or its
context keys (like "api_key") are renamed/kept consistent (e.g., use "secret"
instead of "api_key") so webhook secrets display correctly. Ensure messages and
redirects remain unchanged for missing channel.
src/backend/core/api/serializers.py (1)

2196-2204: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject non-HTTPS webhook targets outside local development.

Allowing http:// here means the dispatcher can send full inbound message payloads and the webhook credential over cleartext transport. Please require HTTPS, with an explicit localhost/DEBUG escape hatch if dev still needs plain HTTP.

🤖 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 2196 - 2204, Change the
webhook URL validation to reject plain HTTP except for a local-dev escape hatch:
in the serializer validation where settings_data and url are checked (the block
validating settings.url), require url.startswith("https://") instead of allowing
"http://", but allow "http://" when Django settings.DEBUG is True or the host is
localhost/127.0.0.1 (and optionally port), and keep raising
serializers.ValidationError with a clear message when non-HTTPS targets are
provided; use Django's settings.DEBUG and parse the URL host to detect localhost
to implement the exception.
src/backend/core/api/viewsets/channel.py (1)

127-149: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Document the one-time credential fields on create().

create() now returns api_key / webhook_secret / webhook_api_key / password, but the 201 schema still resolves to ChannelSerializer. That drops the new fields from generated clients, which is a contract break for the webhook/API-key creation flows.

🤖 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/viewsets/channel.py` around lines 127 - 149, The
create() endpoint surfaces one-time fields (api_key, webhook_secret,
webhook_api_key, password) but the response schema still uses ChannelSerializer
so generated clients miss those fields; update the view to return a
creation-aware serializer/response schema: either add a CreateChannelSerializer
that includes these one-time fields (or mark them appropriately on
ChannelSerializer as create-only/write_only and included in .data), and make
get_serializer_class() return that serializer when action == "create", or
annotate the create() method with an explicit response schema (e.g., via your
OpenAPI helper like `@extend_schema`) that includes api_key, webhook_secret,
webhook_api_key, and password so the 201 response contract matches what create()
actually returns. Ensure you reference create(),
get_serializer_class()/get_serializer(), and ChannelSerializer when applying the
change.
🤖 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/webhooks.md`:
- Line 69: The docs entry for the `blocking` webhook field is inaccurate: update
the description for the `blocking` column in docs/webhooks.md to replace "If
true, non-200 (or any failure) drops the inbound message." with a concise
statement like "If true, the webhook response determines delivery (see Response
contract below)." Also ensure the Response contract section (referenced as the
webhook response decision matrix) clearly lists the behavior for 2xx with
{"action":"drop"} → DROP, 2xx without drop → CONTINUE, 4xx → DROP, 408/429/5xx
and timeouts/connection errors → RETRY so readers can find the nuanced behavior.
- Around line 312-330: The fenced code block that begins with "POST /inbox-hook
HTTP/1.1" is missing a language identifier; update that opening triple-backtick
to include the "http" language (i.e., change ``` to ```http) so the HTTP request
example is rendered with proper syntax highlighting and rendering.
- Line 152: Remove the parenthetical narrative from the table row that reads
"Connection error, timeout, DNS, etc.  | RETRY      | Transient. (This was the
original bug: timeouts used to silently lose mail.)" — edit the third column for
that row to simply "Transient." (keep the first column "Connection error,
timeout, DNS, etc." and the second column "RETRY" unchanged) so the API docs
contain only neutral, permanent documentation text.

In `@src/backend/core/mda/dispatch_webhooks.py`:
- Around line 155-183: The function _classify_response_body ignores an
"action":"retry" directive from webhook JSON; update _classify_response_body to
recognize action strings case-insensitively and map "retry" to result.decision =
Decision.RETRY (similar to how "drop" is handled), returning the _HttpResult
with the RETRY decision so downstream UserWebhookStep/dispatch logic re-queues
the inbound task; keep using _HttpResult and Decision enums already referenced
in the function.

In
`@src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/webhook-integration-form.tsx`:
- Around line 149-176: The code currently only handles created credentials when
newChannel.status === 201, which misses other successful 2xx responses; update
the guard to accept any successful status (e.g., newChannel.status < 300) or
otherwise check the resolved success instead of exact 201 so the branches that
call setCreatedCredential(...) or onSuccess(...) run for all 2xx responses;
locate the block referencing newChannel.status, newChannel.data,
setCreatedCredential, and onSuccess in webhook-integration-form.tsx and replace
the strict equality check with a generalized success check (status < 300) so
credentials are surfaced and onSuccess is invoked for any successful create
response.
- Around line 45-58: The Zod validators in createFormSchema are using the
deprecated `{ message: ... }` option; update those calls to use Zod 4's `{
error: ... }` shape (e.g., replace min(..., { message: t("...") }) with min(...,
{ error: t("...") }) and regex(..., { message: t("...") }) with regex(..., {
error: t("...") })). Search the createFormSchema function for any other
validators using `{ message: ... }` and replace them similarly so all Zod error
objects use `error` instead of `message`.

---

Outside diff comments:
In `@src/backend/core/admin.py`:
- Around line 583-619: The regenerate_api_key_view currently blocks non-API_KEY
channels — update regenerate_api_key_view to allow rotating secrets for webhook
channels as well by permitting ChannelTypes.WEBHOOK in the type check (use
ChannelTypes.API_KEY or ChannelTypes.WEBHOOK), call channel.rotate_secret() for
either type, and build a context/title that is generic or conditional (e.g., use
a type-aware title like "New secret generated" and include channel.type in the
context). Also update the error message when the type is unsupported and ensure
the TemplateResponse/template ("admin/core/channel/regenerated_api_key.html") or
its context keys (like "api_key") are renamed/kept consistent (e.g., use
"secret" instead of "api_key") so webhook secrets display correctly. Ensure
messages and redirects remain unchanged for missing channel.

In `@src/backend/core/api/serializers.py`:
- Around line 2196-2204: Change the webhook URL validation to reject plain HTTP
except for a local-dev escape hatch: in the serializer validation where
settings_data and url are checked (the block validating settings.url), require
url.startswith("https://") instead of allowing "http://", but allow "http://"
when Django settings.DEBUG is True or the host is localhost/127.0.0.1 (and
optionally port), and keep raising serializers.ValidationError with a clear
message when non-HTTPS targets are provided; use Django's settings.DEBUG and
parse the URL host to detect localhost to implement the exception.

In `@src/backend/core/api/viewsets/channel.py`:
- Around line 127-149: The create() endpoint surfaces one-time fields (api_key,
webhook_secret, webhook_api_key, password) but the response schema still uses
ChannelSerializer so generated clients miss those fields; update the view to
return a creation-aware serializer/response schema: either add a
CreateChannelSerializer that includes these one-time fields (or mark them
appropriately on ChannelSerializer as create-only/write_only and included in
.data), and make get_serializer_class() return that serializer when action ==
"create", or annotate the create() method with an explicit response schema
(e.g., via your OpenAPI helper like `@extend_schema`) that includes api_key,
webhook_secret, webhook_api_key, and password so the 201 response contract
matches what create() actually returns. Ensure you reference create(),
get_serializer_class()/get_serializer(), and ChannelSerializer when applying the
change.
🪄 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: 9c8c113f-8e55-44d1-a991-a81cbbe7ad31

📥 Commits

Reviewing files that changed from the base of the PR and between e58a6f3 and f23003a.

📒 Files selected for processing (24)
  • docs/webhooks.md
  • env.d/development/backend.defaults
  • src/backend/core/admin.py
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/channel.py
  • src/backend/core/factories.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/models.py
  • src/backend/core/services/ssrf.py
  • src/backend/core/services/thread_events.py
  • src/backend/core/tests/api/test_channel_scope_level.py
  • src/backend/core/tests/api/test_channels.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/messages/settings.py
  • 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

Comment thread docs/webhooks.md Outdated
Comment thread docs/webhooks.md Outdated
Comment thread docs/webhooks.md Outdated
Comment thread src/backend/core/mda/dispatch_webhooks.py
Comment on lines +149 to +176
if (newChannel.status === 201) {
// Surface the freshly minted credential exactly
// once — the receiver needs this value to verify
// every webhook we send. The backend returns
// ``webhook_secret`` for auth_method=jwt and
// ``webhook_api_key`` for auth_method=api_key.
const payload = newChannel.data as Record<string, unknown>;
const secret = payload.webhook_secret as
| string
| undefined;
const apiKey = payload.webhook_api_key as
| string
| undefined;
if (secret) {
setCreatedCredential({
label: t("Webhook signing secret"),
value: secret,
});
} else if (apiKey) {
setCreatedCredential({
label: t("Webhook API key"),
value: apiKey,
});
} else {
onSuccess(newChannel.data);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

status === 201 guard can strand a successful create.

If the create endpoint returns a 2xx other than 201 (e.g. 200), the success toast at Lines 143-147 still fires but none of the credential branches run and onSuccess is never called — the modal neither surfaces the credential nor advances to edit mode. Consider gating on status < 300 (or relying on the resolved promise) rather than an exact 201.

🤖 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 149 - 176, The code currently only handles created credentials when
newChannel.status === 201, which misses other successful 2xx responses; update
the guard to accept any successful status (e.g., newChannel.status < 300) or
otherwise check the resolved success instead of exact 201 so the branches that
call setCreatedCredential(...) or onSuccess(...) run for all 2xx responses;
locate the block referencing newChannel.status, newChannel.data,
setCreatedCredential, and onSuccess in webhook-integration-form.tsx and replace
the strict equality check with a generalized success check (status < 300) so
credentials are surfaced and onSuccess is invoked for any successful create
response.

Comment thread src/backend/core/mda/inbound_pipeline.py Fixed
Comment thread src/backend/core/models.py Fixed
Comment thread src/backend/core/api/viewsets/channel.py Fixed
@sylvinus
sylvinus force-pushed the outbound_webhooks branch from f23003a to 9c775e6 Compare June 16, 2026 09:07

@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: 8

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/models.py (1)

3262-3267: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Honor the documented user fallback when the contact name is blank.

Line 3262 picks mailbox.contact.name whenever a contact exists, so an empty contact name suppresses user.full_name even though the docstring says the user is the fallback for name.

Proposed fix
         context = {}
+        mailbox_name = (
+            mailbox.contact.name if mailbox and mailbox.contact else None
+        )
+        user_name = getattr(user, "full_name", None) if user else ""
         context["name"] = (
-            mailbox.contact.name
-            if mailbox and mailbox.contact
-            else (getattr(user, "full_name", None) if user else "")
-        ) or ""
-        context["user_name"] = (getattr(user, "full_name", None) or "") if user else ""
+            mailbox_name or user_name or ""
+        )
+        context["user_name"] = user_name or ""
🤖 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 3262 - 3267, The context["name"]
assignment currently checks only if mailbox and mailbox.contact exist, but
doesn't verify that mailbox.contact.name is actually non-empty. This means an
empty or falsy contact name prevents the fallback to user.full_name. Fix this by
also checking that mailbox.contact.name is truthy (not just that the contact
exists) before using it; if the contact name is empty, None, or falsy, fall back
to user.full_name as documented.
src/backend/core/api/serializers.py (1)

2224-2235: ⚠️ Potential issue | 🟠 Major

Validate the parsed webhook URL, including hostname.

https:///missing-host passes the current validation because the scheme is https and the hostname is empty, allowing a malformed URL to be saved and later fail at dispatch time. Additionally, the case-sensitive startswith check rejects valid uppercase schemes like HTTPS:// before urlparse normalizes them.

Replace the scheme validation with a check against the parsed URL's normalized scheme and add explicit hostname validation:

Proposed fix
-        if not url.startswith(("http://", "https://")):
-            raise serializers.ValidationError(
-                {"settings": "webhook settings.url must be http:// or https://"}
-            )
         # Each POST carries the HMAC/JWT signing material in its headers,
         # so plaintext http would leak credentials in transit. Require
         # https except for a local-dev escape hatch: a loopback host, or
         # DEBUG (where operators routinely point at a local receiver).
         parsed_url = urlparse(url)
         host = (parsed_url.hostname or "").lower()
+        if parsed_url.scheme not in ("http", "https") or not host:
+            raise serializers.ValidationError(
+                {
+                    "settings": (
+                        "webhook settings.url must be an absolute http:// "
+                        "or https:// URL with a hostname."
+                    )
+                }
+            )
         is_loopback = host in ("localhost", "127.0.0.1", "::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/api/serializers.py` around lines 2224 - 2235, The webhook
URL validation has two issues: the case-sensitive startswith check rejects valid
uppercase schemes like HTTPS:// that urlparse would normalize, and malformed
URLs like https:///missing-host pass validation because the hostname check only
occurs later and doesn't reject empty hostnames. Replace the startswith scheme
validation with a check against parsed_url.scheme (which is normalized by
urlparse), and add explicit validation that parsed_url.hostname is not empty or
None before proceeding with the loopback and DEBUG checks. This ensures the
hostname exists and the scheme is properly normalized.
🤖 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 `@src/backend/core/mda/autoreply.py`:
- Around line 331-333: The code at line 332 assumes raw_body is always a string
before encoding to UTF-8, but template.raw_body may already be a dict or other
JSON-like object, which causes an error. Before calling .encode("utf-8") on
raw_body, add type checking to serialize it appropriately: if raw_body is a dict
or non-string type, convert it to a JSON string using json.dumps() first; if
it's already a string, use it directly. Then encode the resulting string to
UTF-8 bytes.

In `@src/backend/core/mda/dispatch_webhooks.py`:
- Around line 651-692: The code logs full webhook URLs in multiple logger calls
(logger.warning, logger.exception, and logger.info) which can expose sensitive
receiver tokens embedded in the path, query string, or userinfo. Create a helper
function to sanitize the URL by extracting only the scheme, host, and port
components, then replace all instances where url is logged as url=%s with the
sanitized version throughout the exception handlers and response processing
logic to ensure no secrets are leaked in logs.
- Around line 475-482: The jmap_without_body payload stripping logic is
currently removing multiple body-related fields (textBody, htmlBody, bodyValues,
bodyStructure, attachments) but is missing the preview field, which is derived
from the message body and should also be stripped to maintain the
notification-only privacy contract. Add "preview" to the tuple of keys in the
for loop that is popping body-bearing fields from the email object to ensure
this derived content field is also removed.

In `@src/backend/core/mda/inbound_create.py`:
- Around line 278-280: The logger.error call is logging raw email addresses in
the recipient_email variable, which exposes personally identifiable information
(PII). Replace the direct logging of recipient_email and sender_email variables
with either mailbox IDs, message IDs, or hashed/redacted versions of the email
addresses instead. This same issue appears in multiple locations within the file
where similar logging statements reference raw email variables, so update all
occurrences to remove the sensitive email address data from the logs.
- Around line 81-84: The regex pattern in the subject canonicalization function
requires whitespace after the colon (using \s+), which prevents matching common
subject patterns like "Re:Subject" without a space. Fix this by changing the
`\s+` quantifier that follows the colon in the regex pattern to `\s*` (zero or
more whitespace characters instead of one or more) so that both "Re: Subject"
and "Re:Subject" formats are properly normalized and thread matching works
correctly.

In `@src/backend/core/mda/inbound_pipeline.py`:
- Around line 440-463: The set comprehension used to create target_emails loses
the ordering of input emails, resulting in non-deterministic assignment payload
ordering. Replace the set comprehension that creates target_emails with a
deduplication approach that preserves insertion order (such as using
dict.fromkeys()), ensuring that the order of emails in the input is maintained
when target_emails is used in subsequent filter operations and iterations
through the for loop that processes each email in target_emails.

In `@src/backend/core/tests/api/test_channels.py`:
- Around line 589-599: The test_create_minimal_webhook method does not verify
that webhook credentials are returned in the response, making it possible for
the API contract to break without test detection. Add an assertion to verify
that webhook_secret is present in the response for the JWT auth method case.
Additionally, create a separate test case for auth_method="api_key" that asserts
webhook_api_key is returned in the response. Finally, add test coverage for the
regenerate-secret endpoint (likely via a separate test method) that covers both
auth_method variants (JWT and api_key) to ensure credentials are properly
regenerated for each authentication type.

In `@src/backend/core/tests/mda/test_dispatch_webhooks.py`:
- Around line 1652-1688: The test_response_body_is_capped test does not enforce
that reading stops at the MAX_RESPONSE_BODY limit. Currently, the iter_content
mock can return all three oversize_chunk items without consequence, as the test
only verifies the final decision outcome and that close() was called. Replace
the iter_content mock with a generator that tracks consumed bytes and raises an
exception (such as AssertionError or a custom exception) once the cumulative
bytes read exceed MAX_RESPONSE_BODY, ensuring that any attempt by
dispatch_webhooks to read beyond the cap will cause the test to fail if the
capping logic is not working correctly.

---

Outside diff comments:
In `@src/backend/core/api/serializers.py`:
- Around line 2224-2235: The webhook URL validation has two issues: the
case-sensitive startswith check rejects valid uppercase schemes like HTTPS://
that urlparse would normalize, and malformed URLs like https:///missing-host
pass validation because the hostname check only occurs later and doesn't reject
empty hostnames. Replace the startswith scheme validation with a check against
parsed_url.scheme (which is normalized by urlparse), and add explicit validation
that parsed_url.hostname is not empty or None before proceeding with the
loopback and DEBUG checks. This ensures the hostname exists and the scheme is
properly normalized.

In `@src/backend/core/models.py`:
- Around line 3262-3267: The context["name"] assignment currently checks only if
mailbox and mailbox.contact exist, but doesn't verify that mailbox.contact.name
is actually non-empty. This means an empty or falsy contact name prevents the
fallback to user.full_name. Fix this by also checking that mailbox.contact.name
is truthy (not just that the contact exists) before using it; if the contact
name is empty, None, or falsy, fall back to user.full_name as documented.
🪄 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: 64be5c53-0b29-4f46-96d7-e43edd25d08d

📥 Commits

Reviewing files that changed from the base of the PR and between f23003a and 9c775e6.

📒 Files selected for processing (25)
  • docs/webhooks.md
  • env.d/development/backend.defaults
  • src/backend/core/admin.py
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/channel.py
  • src/backend/core/factories.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/models.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/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/messages/settings.py
  • 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
💤 Files with no reviewable changes (5)
  • src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/integrations-data-grid.tsx
  • src/backend/messages/settings.py
  • 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/backend/core/tests/mda/test_spam_processing.py

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
{}

@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: 2

🤖 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 `@src/backend/core/api/openapi.json`:
- Around line 7602-7617: The required fields array in the ChannelCreateResponse
schema includes all credential fields unconditionally, but the create endpoints
only return the credential matching the specific channel type and authentication
method. Remove the type-specific credential fields (such as webhook_api_key,
webhook_secret, password, api_key, and any others that are mutually exclusive
based on credential type) from the required array in the ChannelCreateResponse
schema definition so that only universally present fields like id, created_at,
updated_at, user, mailbox, maildomain, name, scope_level, and type remain
required. This allows the schema to accurately represent that some credential
fields will intentionally be absent depending on which authentication method was
used for that specific credential.

In `@src/backend/core/tests/mda/test_dispatch_webhooks.py`:
- Around line 265-266: The assertions on lines 265 and 266 for
email["inReplyTo"] and email["references"] use `assert not` which accepts any
falsey value (None, "", [], etc.) instead of explicitly checking for the
JMAP-compliant empty list. Replace `assert not email["inReplyTo"]` with `assert
email["inReplyTo"] == []` and `assert not email["references"]` with `assert
email["references"] == []` to enforce the strict JMAP Id[] empty-list contract.
🪄 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: 6fd187ea-53f6-4dfd-b290-48edbb07461b

📥 Commits

Reviewing files that changed from the base of the PR and between 9c775e6 and 244875e.

⛔ Files ignored due to path filters (5)
  • 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/index.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/**
📒 Files selected for processing (8)
  • src/backend/core/api/openapi.json
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/channel.py
  • src/backend/core/mda/dispatch_webhooks.py
  • src/backend/core/mda/inbound_pipeline.py
  • src/backend/core/models.py
  • src/backend/core/tests/api/test_channels.py
  • src/backend/core/tests/mda/test_dispatch_webhooks.py

Comment thread src/backend/core/api/openapi.json
Comment thread src/backend/core/tests/mda/test_dispatch_webhooks.py Outdated
@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
{"name":"HttpError","status":500,"request":{"method":"PATCH","url":"https://api.github.com/repos/suitenumerique/messages/issues/comments/4615051973","headers":{"accept":"application/vnd.github.v3+json","user-agent":"octokit.js/0.0.0-development octokit-core.js/7.0.6 Node.js/24","authorization":"token [REDACTED]","content-type":"application/json; charset=utf-8"},"body":{"body":"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- review_stack_entry_start -->\n\n[![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/suitenumerique/messages/pull/691?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)\n\n<!-- review_stack_entry_end -->\n<!-- This is an auto-generated comment: review in progress by coderabbit.ai -->\n\n> [!NOTE]\n> Currently processing new changes in this PR. This may take a few minutes, please wait...\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: Organization UI\n> \n> **Review profile**: ASSERTIVE\n> \n> **Plan**: Pro\n> \n> **Run ID**: `4153397b-1a3d-4056-8eb7-ffbbfbaa7ee1`\n> \n> </details>\n> \n> <details>\n> <summary>📥 Commits</summary>\n> \n> Reviewing files that changed from the base of the PR and between 244875e16c79bc72759b99adc57fb4fc280fb85a and 01287fb7ae52a31d43219f5440a02f1f88d24ff0.\n> \n> </details>\n> \n> <details>\n> <summary>⛔ Files ignored due to path filters (1)</summary>\n> \n> * `src/frontend/src/features/api/gen/models/channel_create_response.ts` is excluded by `!**/gen/**`\n> \n> </details>\n> \n> <details>\n> <summary>📒 Files selected for processing (4)</summary>\n> \n> * `src/backend/core/api/openapi.json`\n> * `src/backend/core/api/serializers.py`\n> * `src/backend/core/tests/mda/test_dispatch_webhooks.py`\n> * `src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/webhook-integration-form.tsx`\n> \n> </details>\n> \n> \n\n<!-- end of auto-generated comment: review in progress by coderabbit.ai -->\n\n<!-- walkthrough_start -->\n\n<details>\n<summary>📝 Walkthrough</summary>\n\n## Walkthrough\n\nThis PR implements outbound webhook channels for the Messages platform end-to-end. It introduces generalized channel secret rotation supporting API-key and webhook types; validates webhook configuration (URL, phase, format, auth_method); refactors inbound message processing into a step-based pipeline that executes before-spam and after-spam webhook steps alongside existing spam/auth logic; implements webhook dispatch with SSRF-safe POST, HMAC/JWT signing, and response classification into blocking directives; extracts autoreply helpers for webhook-driven draft materialization; extends the REST API and admin surfaces to manage webhook credentials as one-time fields; adds React UI for webhook creation/editing with schema validation and credential display; updates feature flags to enable webhook channels; and provides comprehensive specification and test coverage.\n\n## Changes\n\n**Webhook Integration and Inbound Processing**\n\n| Layer / File(s) | Summary |\n|---|---|\n| **Channel secrets, API surfaces, and configuration** <br> `src/backend/core/models.py`, `src/backend/core/admin.py`, `src/backend/core/factories.py`, `src/backend/core/api/serializers.py`, `src/backend/core/api/viewsets/channel.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`, `env.d/development/backend.defaults`, `src/backend/messages/settings.py` | `Channel.rotate_secret()` replaces `rotate_api_key()` to generate plaintext roots persisted in type-specific `encrypted_settings` shapes (API-key as SHA-256 digest list, webhook as plaintext root); `get_webhook_api_key()` derives deterministic HMAC-SHA256 presentation. Admin endpoint renamed to `regenerate-secret` with explicit channel-type guard. Factory auto-injects webhook `auth_method: jwt` and placeholder `encrypted_settings`. `ChannelSerializer` validates webhook URL (https except loopback), phase, format, blocking, and auth_method; mints secrets for both types on create via `rotate_secret(save=False)`; persists only `encrypted_settings` to validated_data. `_attach_credential()` surfaces exactly one plaintext field per type/auth_method in responses. `SSRFSafeSession.post()` enables non-redirect webhook POSTs. `assign_users()` accepts optional `channel` for audit attribution. Webhook enabled in feature flags. Template display terminology updated from api_key to secret. |\n| **Webhook dispatch transport, signing, and response parsing** <br> `src/backend/core/mda/dispatch_webhooks.py` | 803 LOC implementing webhook dispatch: discovers channels across mailbox/maildomain/global scopes; resolves request body format (eml/jmap/jmap_without_body with JMAP field projection and parser extension stripping); signs via HMAC-SHA256 or HS256 JWT with body binding; builds X-StMsg-* envelope headers including phase/channel/mailbox/recipient/spam/message-id; performs SSRF-safe streamed POST with 30s timeout and fail-closed auth; classifies JSON 2xx responses into `_HttpResult` decision + side-effect fields (drop/spam-override/labels/assigns/flags/events/drafts) with validation and size caps; maps HTTP/network/timeout failures to RETRY/DROP based on blocking semantics. |\n| **Inbound message processing pipeline and finalization** <br> `src/backend/core/mda/inbound_pipeline.py` | 679 LOC introducing step-based pipeline: `Decision` enum (CONTINUE/RETRY/DROP), mutable `InboundContext` dataclass (spam/auth verdicts, cached rspamd result, deferred side-effect queues, flag toggles); `_check_hardcoded_rules()` evaluates per-domain header matches on trusted relay blocks; `_call_rspamd()` invokes `/checkv2`, caches result, swallows errors; `_make_*_step()` factories build hardcoded, rspamd, and inbound-auth steps; `build_inbound_pipeline()` composes before/after webhook steps with spam/auth steps; `run_inbound_pipeline()` executes and short-circuits; finalization helpers apply labels (mailbox-scoped), idempotent flags, template-lookup drafts, validated IM events, and resolved assigns with race tolerance. |\n| **Autoreply refactoring and inbound task migration** <br> `src/backend/core/mda/autoreply.py`, `src/backend/core/mda/inbound_create.py`, `src/backend/core/mda/inbound_tasks.py` | Extracts `_create_reply_record_from_template()` centralizing DB creation (subject build, signature resolution, message/recipient rows). Adds `create_draft_reply_from_template()` for webhook draft materialization with draft_blob storage. Refactors `process_inbound_message_task` to acquire per-message Redis lock, parse email with error persistence, build `InboundContext`, run full `build_inbound_pipeline()` + `run_inbound_pipeline()`, create message via context flags (`is_spam`, `mark_*`), finalize via isolated `_safe_finalize()` callbacks (labels, assigns, events, drafts, flags), conditionally send autoreplies. Adds `is_trashed`/`is_archived` parameters to `_create_message_from_inbound()`. |\n| **Mailbox settings webhook UI** <br> `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` | Data grid adds webhook type label/icon. Modal extends `ChannelType` union, adds webhook metadata (title/description/icon), and renders `WebhookIntegrationForm`. Form implements Zod schema validation (name, https url with loopback exception, phase/format/auth_method/blocking enums), React Query mutations for create/update with `getMailboxesChannelsListUrl` invalidation, one-time credential extraction on create (webhook_secret for jwt, webhook_api_key for api_key, none otherwise), success toast and `onSuccess` callback, error handling with `handle(err)` and translated banner, pending-state button disabling. |\n| **Webhook specification** <br> `docs/webhooks.md` | 461 LOC specification: webhook phases (before_spam/after_spam), scopes (mailbox/maildomain/global), configuration (Channel.settings fields, validation on create/PATCH), HTTP mechanics (POST, 30s timeout, no redirects, SSRF), authentication (jwt via HMAC-signed timestamp + HS256 JWT, api_key via HMAC-derived header, switching via regenerate without root rotation), envelope headers, blocking response contract (CONTINUE/DROP/RETRY with 7-day max-age), optional action schema (drop/is_spam/labels/assigns/flags/reply_draft/add_event) with validation/idempotency, multi-webhook merge rules, payload formats (eml/jmap/jmap_without_body), Python receiver example, security notes. |\n| **OpenAPI schema** <br> `src/backend/core/api/openapi.json` | Creates `201` response (mailbox/user create) via new `ChannelCreateResponse` schema documenting one-time credentials. Renames secret rotation from `/regenerate-api-key/` to `/regenerate-secret/` (both scopes). Updates `200` response to `RegeneratedSecretResponse` documenting credential variation by type/auth_method. Adds `ChannelCreateResponse` and `RegeneratedSecretResponse` component schemas. |\n| **Comprehensive test coverage** <br> `src/backend/core/tests/mda/test_dispatch_webhooks.py` (2538 LOC), `src/backend/core/tests/api/test_channels.py`, `src/backend/core/tests/api/test_channel_scope_level.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` | Dispatch tests: channel discovery (scope/type/mailbox filtering), JMAP building (strict fields, storage omission, parser extensions), dispatch across phases/formats/blocking with transport error mapping, signing validation (eml bytes vs jmap serialization, jwt vs api_key headers), fail-closed missing config/secret, response classification (action/spam/labels/assigns/flags/events/drafts/size caps), action semantics (drop short-circuit, non-blocking ignoring bodies), retry on transient/max-age exhaustion, antispam spam-override short-circuiting, label attachment with mailbox scoping, assign resolution with editor-right filtering and race tolerance, flag actions and skip_autoreply, draft creation with out-of-scope skipping, finalize isolation. REST API: webhook creation (jwt/api_key variants returning correct credential), regenerate-secret (returning correct credential per type), dispatcher options (phase/format/blocking) persisted, validation (format/phase/blocking type, auth_method required, PATCH settings-only re-validating). Pipeline refactoring: auth/spam/spoofed-sender tests retargeted to `inbound_pipeline` functions. |\n\n## Estimated code review effort\n\n🎯 4 (Complex) | ⏱️ ~60 minutes\n\n## Possibly related PRs\n\n- [suitenumerique/messages#599](https://github.com/suitenumerique/messages/pull/599): Modifies channel credential handling and encrypted settings validation that webhook secret rotation depends on.\n- [suitenumerique/messages#436](https://github.com/suitenumerique/messages/pull/436): Introduces rspamd and hardcoded-rule spam detection that the inbound pipeline refactors and reuses.\n- [suitenumerique/messages#541](https://github.com/suitenumerique/messages/pull/541): Modifies `MessageTemplate.resolve_placeholder_values` placeholder fallback logic for contact names.\n\n## Suggested reviewers\n\n- sdemagny\n\n</details>\n\n<!-- walkthrough_end -->\n<!-- pre_merge_checks_walkthrough_start -->\n\n<details>\n<summary>🚥 Pre-merge checks | ✅ 4 | ❌ 1</summary>\n\n### ❌ Failed checks (1 warning)\n\n|     Check name     | Status     | Explanation                                                                           | Resolution                                                                         |\n| :----------------: | :--------- | :------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------- |\n| Docstring Coverage | ⚠️ Warning | Docstring coverage is 70.51% which is insufficient. The required threshold is 80.00%. | Write docstrings for the functions missing them to satisfy the coverage threshold. |\n\n<details>\n<summary>✅ Passed checks (4 passed)</summary>\n\n|         Check name         | Status   | Explanation                                                                                                                                                          |\n| :------------------------: | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n|      Description Check     | ✅ Passed | Check skipped - CodeRabbit’s high-level summary is enabled.                                                                                                          |\n|         Title check        | ✅ Passed | The title accurately describes the main feature being added: outgoing webhooks for processing incoming emails, which is the primary purpose of this large changeset. |\n|     Linked Issues check    | ✅ Passed | Check skipped because no linked issues were found for this pull request.                                                                                             |\n| Out of Scope Changes check | ✅ Passed | Check skipped because no linked issues were found for this pull request.                                                                                             |\n\n</details>\n\n<sub>✏️ Tip: You can configure your own custom pre-merge checks in the settings.</sub>\n\n</details>\n\n<!-- pre_merge_checks_walkthrough_end -->\n<!-- finishing_touch_checkbox_start -->\n\n<details>\n<summary>✨ Finishing Touches</summary>\n\n<details>\n<summary>📝 Generate docstrings</summary>\n\n- [ ] <!-- {\"checkboxId\": \"7962f53c-55bc-4827-bfbf-6a18da830691\"} --> Create stacked PR\n- [ ] <!-- {\"checkboxId\": \"3e1879ae-f29b-4d0d-8e06-d12b7ba33d98\"} --> Commit on current branch\n\n</details>\n\n</details>\n\n<!-- finishing_touch_checkbox_end -->\n\n<!-- internal state start -->\n\n\n<!-- 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 -->\n\n<!-- internal state end -->"},"request":{"retryCount":3,"signal":{},"retries":3,"retryAfter":16}}}

@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: 2

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/mda/inbound_tasks.py (1)

165-172: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Parse failures lack expiration — row retries indefinitely.

When parse_email fails, the InboundMessage row is preserved for retry but never quarantine-expires. Unlike Decision.RETRY (bounded by QUARANTINE_AFTER), a permanently unparseable message will retry every 5-minute sweep indefinitely, accumulating failed attempts.

Consider either:

  1. Applying similar QUARANTINE_AFTER logic to parse failures (delete after 48h with a log)
  2. Incrementing a retry counter and giving up after N attempts
🤖 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 165 - 172, The
parse_email function failure handling in the InboundMessage processing block
does not implement any expiration or retry limiting logic, causing permanently
unparseable messages to retry indefinitely every 5 minutes. Add expiration
handling by either implementing a QUARANTINE_AFTER timeout similar to
Decision.RETRY logic that removes the row after a set duration (e.g., 48 hours)
with appropriate logging, or add a retry counter field to InboundMessage that
increments on each parse failure and stops retrying after reaching a maximum
attempt threshold. Make sure this logic is applied in the same code block where
parsed_email is None and before saving the InboundMessage with the
error_message.
src/backend/core/enums.py (1)

224-232: ⚠️ Potential issue | 🟠 Major

Breaking change: webhook event names renamed without data migration.

The enum change from MESSAGE_RECEIVED/MESSAGE_SENT to MESSAGE_INBOUND/MESSAGE_OUTBOUND breaks existing webhook channels. The dispatcher in dispatch_webhooks.py checks whether "message.inbound" is in the channel's settings["events"]; channels with the old "message.received" or "message.sent" values will be silently skipped and no longer fire.

If webhook channels with the old event names exist in the database, a data migration should update Channel.settings["events"] to use the new values, or verify this is pre-production with no existing data.

🤖 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 224 - 232, The WebhookEvents enum in
this change renames event identifiers from MESSAGE_RECEIVED/MESSAGE_SENT to
MESSAGE_INBOUND/MESSAGE_OUTBOUND, but existing webhook Channel records in the
database still reference the old event names in their settings["events"] field.
This will cause the dispatcher in dispatch_webhooks.py to silently skip channels
with the old values when checking for message.inbound or message.outbound
events. Create a Django data migration that updates all existing
Channel.settings["events"] to replace the old event name strings
(message.received, message.sent) with the new ones (message.inbound,
message.outbound), or if this is a pre-production environment, verify and
document that no production data with the old event names exists.
♻️ Duplicate comments (1)
src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/webhook-integration-form.tsx (1)

150-178: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

status === 201 still creates a success-path gap.

At Line 150, create success handling is gated to exactly 201, so any other successful 2xx response skips both credential display and onSuccess, leaving the modal flow incomplete.

♻️ Proposed fix
-                if (newChannel.status === 201) {
+                if (newChannel.status < 300) {
                     // Surface the freshly minted credential exactly
                     // once — the receiver needs this value to verify
                     // every webhook we send. The backend returns
                     // ``secret`` for auth_method=jwt and ``api_key`` for
                     // auth_method=api_key. These one-time credentials are
                     // create-only response fields, not part of the
                     // generated ``Channel`` type — read them off an
                     // index-signature view.
                     const payload = newChannel.data as unknown as Record<
                         string,
                         unknown
                     >;
                     const secret = payload.secret as string | undefined;
                     const apiKey = payload.api_key as string | undefined;
                     if (secret) {
                         setCreatedCredential({
                             label: t("Webhook signing secret"),
                             value: secret,
                         });
                     } else if (apiKey) {
                         setCreatedCredential({
                             label: t("Webhook API key"),
                             value: apiKey,
                         });
                     } else {
                         onSuccess(newChannel.data);
                     }
+                } else {
+                    onSuccess(newChannel.data);
                 }

Based on learnings: in TSX files using the centralized fetchAPI wrapper, avoid explicit status-based branching for success/error control flow after API calls and rely on thrown APIError for non-ok responses.

🤖 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 150 - 178, Remove the explicit `status === 201` check on the
newChannel response in the webhook integration form's success handler. Since the
centralized fetchAPI wrapper throws APIError for non-ok responses, any response
that reaches this code path is already successful. Execute the credential
extraction and credential/success handling logic unconditionally for all
successful responses, eliminating the gap where other 2xx status codes would
bypass both credential display and the onSuccess callback.

Source: Learnings

🤖 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 `@src/backend/core/mda/dispatch_webhooks.py`:
- Around line 558-560: The comments in the webhook error handling section
contain outdated references to a 7-day retry budget, but the system has been
updated to use a 48-hour quarantine window model. Update the comment blocks
around line 560 and line 678 by replacing any mention of "7-day budget" with
"48-hour quarantine window" to ensure the documentation accurately reflects the
current retry and quarantine behavior of the pipeline.
- Around line 783-785: The current code at line 783 uses a fallback with `or`
that allows malformed non-list values (like strings) to pass the subsequent `in`
check on line 784, which could cause substring matching instead of list
membership checking and unintentionally enable webhook dispatch. Add an explicit
type validation after retrieving the events value to ensure it is a list, and
skip processing the current row with `continue` if events is not a list type,
ensuring the fail-closed behavior for invalid settings.

---

Outside diff comments:
In `@src/backend/core/enums.py`:
- Around line 224-232: The WebhookEvents enum in this change renames event
identifiers from MESSAGE_RECEIVED/MESSAGE_SENT to
MESSAGE_INBOUND/MESSAGE_OUTBOUND, but existing webhook Channel records in the
database still reference the old event names in their settings["events"] field.
This will cause the dispatcher in dispatch_webhooks.py to silently skip channels
with the old values when checking for message.inbound or message.outbound
events. Create a Django data migration that updates all existing
Channel.settings["events"] to replace the old event name strings
(message.received, message.sent) with the new ones (message.inbound,
message.outbound), or if this is a pre-production environment, verify and
document that no production data with the old event names exists.

In `@src/backend/core/mda/inbound_tasks.py`:
- Around line 165-172: The parse_email function failure handling in the
InboundMessage processing block does not implement any expiration or retry
limiting logic, causing permanently unparseable messages to retry indefinitely
every 5 minutes. Add expiration handling by either implementing a
QUARANTINE_AFTER timeout similar to Decision.RETRY logic that removes the row
after a set duration (e.g., 48 hours) with appropriate logging, or add a retry
counter field to InboundMessage that increments on each parse failure and stops
retrying after reaching a maximum attempt threshold. Make sure this logic is
applied in the same code block where parsed_email is None and before saving the
InboundMessage with the error_message.

---

Duplicate comments:
In
`@src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/webhook-integration-form.tsx`:
- Around line 150-178: Remove the explicit `status === 201` check on the
newChannel response in the webhook integration form's success handler. Since the
centralized fetchAPI wrapper throws APIError for non-ok responses, any response
that reaches this code path is already successful. Execute the credential
extraction and credential/success handling logic unconditionally for all
successful responses, eliminating the gap where other 2xx status codes would
bypass both credential display and the onSuccess callback.
🪄 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: b94a3b55-68da-4f0e-ba22-b6434e22bf3b

📥 Commits

Reviewing files that changed from the base of the PR and between 01287fb and 16604f8.

⛔ Files ignored due to path filters (2)
  • src/frontend/src/features/api/gen/models/channel_create_response.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/regenerated_secret_response.ts is excluded by !**/gen/**
📒 Files selected for processing (20)
  • docs/webhooks.md
  • src/backend/core/api/openapi.json
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/channel.py
  • src/backend/core/enums.py
  • src/backend/core/mda/dispatch_webhooks.py
  • src/backend/core/mda/inbound_pipeline.py
  • src/backend/core/mda/inbound_tasks.py
  • src/backend/core/tests/api/test_channel_scope_level.py
  • src/backend/core/tests/api/test_channels.py
  • src/backend/core/tests/mda/test_dispatch_webhooks.py
  • src/backend/core/tests/mda/test_spam_processing.py
  • src/backend/messages/settings.py
  • 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/src/features/layouts/components/mailbox-settings/modal-compose-integration/webhook-integration-form.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-header.tsx

Comment thread src/backend/core/mda/dispatch_webhooks.py Outdated
Comment thread src/backend/core/mda/dispatch_webhooks.py Outdated
@sylvinus
sylvinus force-pushed the outbound_webhooks branch from 16604f8 to 154e7ab Compare June 19, 2026 16:34
@sylvinus

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 27, 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: 12

Caution

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

⚠️ Outside diff range comments (3)
src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx (1)

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

Use author_display for the avatar color too.

Webhook-authored IM events have author=null but a populated author_display, so line 221 hashes "" while lines 258-259 show the webhook name.

Proposed fix
-        const authorName = event.author?.full_name || event.author?.email || "";
-        const avatarColor = getAvatarColor(authorName);
+        const authorDisplayName =
+            event.author_display ||
+            event.author?.full_name ||
+            event.author?.email ||
+            t("Unknown");
+        const avatarColor = getAvatarColor(authorDisplayName);
...
-                                <UserAvatar fullName={event.author_display || t("Unknown")} size="xsmall" />
-                                {event.author_display || t("Unknown")}
+                                <UserAvatar fullName={authorDisplayName} size="xsmall" />
+                                {authorDisplayName}
🤖 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 221 - 259, The IM thread event avatar color is being derived from
author.full_name/email, which falls back to an empty string for webhook-authored
events; update the ThreadEvent rendering logic to use author_display for the
color input as well. In the thread-view component, adjust the authorName
calculation (or equivalent helper used for getAvatarColor) so it prefers
event.author_display before falling back to author fields, keeping the avatar
color consistent with the displayed webhook name.
src/backend/core/mda/inbound_pipeline.py (1)

728-731: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Avoid logging assignment exception text.

This expected race path can include user-identifying details from assignment validation. Log the exception type instead. As per coding guidelines, “Do not log sensitive information (tokens, passwords, financial/health data, PII)”.

Proposed fix
             logger.warning(
-                "assign_users skipped %d assignee(s) due to race: %s",
+                "assign_users skipped %d assignee(s) due to race (%s)",
                 len(assignees_data),
-                exc,
+                type(exc).__name__,
             )
🤖 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 728 - 731, The warning
in the assign_users race-handling path is logging the full exception text, which
can leak user-identifying details. Update the logger.warning call in the
assign_users flow of inbound_pipeline to log only the exception type name (or
class) instead of the exception message/object, while keeping the existing
count/context in the message.

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 an explicit webhook-actor fixture path.

authorId === null still forces author_display to null, so this helper never constructs the new author === null && author_display shape that buildAssignmentMessage now special-cases for webhook/channel events.

🤖 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 helper in assignment-message.test.ts does
not cover the new webhook actor shape because authorId === null always forces
author_display to null. Update this fixture helper or add a dedicated case so
tests can construct the author === null and author_display set path that
buildAssignmentMessage special-cases for webhook/channel events, and verify the
assignment-message behavior through the existing AssignmentEvent setup.
🤖 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/webhooks.md`:
- Around line 65-66: Update the webhooks documentation entry for the `url` field
to state the real protocol requirement enforced by the serializer: `https://` is
required in normal deployments, and `http://` is only accepted when Django
`DEBUG` is enabled. Keep the wording aligned with the `url` and `events` table
in `docs/webhooks.md` so readers don’t assume plain HTTP is generally valid.

In `@src/backend/core/mda/dispatch_webhooks.py`:
- Around line 610-618: The webhook dispatch task is sending the full email
payload through Celery via the dispatch_webhook_task.delay call in the
dispatch_webhooks flow, which should be replaced with a reference-based handoff.
Update the code around the body_b64/base64 encoding path to persist the snapshot
in encrypted/blob-backed storage first, then pass only the stored object
reference (and any minimal metadata needed) into dispatch_webhook_task instead
of the raw body content.
- Around line 157-158: The warning in dispatch_webhooks around the streamed
response read failure is logging the raw exception message, which can leak
request or URL details. Update the exception handling in the truncated response
body read path to avoid interpolating exc into logger.warning, and use a generic
sanitized message instead. Keep the fix localized to the streamed response read
exception block in dispatch_webhooks and preserve the existing control flow.
- Around line 943-954: The webhook event selection logic in the channel
processing path is treating an explicitly empty events list as missing, so
channels configured with no events still default to MESSAGE_INBOUND. Update the
events handling in dispatch_webhooks so the code distinguishes between a
missing/None value and an empty list, preserving cfg["events"] = [] as-is while
still falling back to the default only when the key is absent or null. Keep the
existing non-list guard and the MESSAGE_INBOUND membership check in place.

In `@src/backend/core/mda/inbound_tasks.py`:
- Around line 249-279: The quarantine-delivery RETRY path in inbound_tasks.py
leaves ctx.is_spam unchanged, so later logic still treats the message as spam
even though _create_message_from_inbound stores it in the inbox. In the
RETRY/quarantine branch around _handle_retry and _stamp_processing_failed,
normalize ctx.is_spam to False when quarantined=True so downstream task results
and try_send_autoreply use the corrected spam state; apply the same change in
the duplicate RETRY handling block referenced by the comment.

In `@src/backend/core/mda/inbound.py`:
- Around line 214-215: `deliver_inbound_message()` currently raises a
`ValueError` for missing `blob` on internal deliveries, which bypasses its
documented bool-return flow and normal logging. Update the
`deliver_inbound_message` path to catch this validation case with try-except,
log the failure through the existing helper logging, and return `False` instead
of throwing so callers always get a boolean result. Use the `is_internal`/`blob`
check in this function as the place to preserve the standard delivery-failure
behavior.

In `@src/backend/core/tasks.py`:
- Around line 1-4: The task-registration import in tasks.py is intentionally
unused, but only flake8 is being silenced right now; add the proper pylint
suppression so the import of dispatch_webhook_task does not trigger W0611.
Update the module-level disable comment near the top of the file to cover the
unused-import warning for this registration pattern, while keeping the import
itself for Celery autodiscovery.

In `@src/backend/core/tests/mda/test_dispatch_webhooks.py`:
- Around line 2135-2140: The backdating logic in test_dispatch_webhooks.py is
using dj_timezone.timedelta, but django.utils.timezone does not provide
timedelta, so the test will break when updating created_at. Update the
quarantine-window backdate in the test that uses
models.InboundMessage.objects.filter(...).update(...) to use timedelta from
datetime instead, and make sure the timedelta import is available alongside
dj_timezone and QUARANTINE_AFTER.

In `@src/backend/core/tests/mda/test_inbound_spoofed_sender.py`:
- Around line 179-182: The dedupe test is currently using the import path, so it
no longer covers the internal queued-delivery behavior described in the
docstring. Update the test around deliver_inbound_message in
test_inbound_spoofed_sender to exercise the internal-delivery path instead,
using is_internal=True so the duplicate MIME-id dedupe is validated through the
production queueing flow. Keep the assertion focused on the same
duplicate-message behavior, but make sure the setup matches the internal
redelivery scenario.

In `@src/backend/core/tests/mda/test_inbound.py`:
- Around line 260-262: The affected tests are now exercising the import flow by
passing is_import=True to deliver_inbound_message, which hides regressions in
the real inbound-delivery path. Update these test cases to go through the
non-import queue/task entrypoint instead, or explicitly rename/scope them as
import-path tests and adjust the assertions to reflect import behavior in
_create_message_from_inbound and its thread/read-state handling.

In `@src/frontend/public/locales/common/en-US.json`:
- Line 442: The locale copy in the auth setup text uses “secret” in a way that
is specific to one method, so update the affected entries in common/en-US.json
to use auth-method-neutral wording like “credential” or branch the text by
auth_method. Check the translation keys around the receiver authentication and
rotation/setup messages, including the related entries noted by the comment, and
keep the copy accurate for both API key and secret-based flows.
- Line 428: The webhook description text is too specific about JSON and should
be made format-neutral. Update the relevant localization strings in the common
locale entries, including the “Forward every incoming message…” copy and the
other referenced occurrence, so they describe forwarding messages to a URL
without promising a JSON POST or JSON payload; keep the wording compatible with
raw .eml and the other supported formats exposed by the webhook channel.

---

Outside diff comments:
In `@src/backend/core/mda/inbound_pipeline.py`:
- Around line 728-731: The warning in the assign_users race-handling path is
logging the full exception text, which can leak user-identifying details. Update
the logger.warning call in the assign_users flow of inbound_pipeline to log only
the exception type name (or class) instead of the exception message/object,
while keeping the existing count/context in the message.

In
`@src/frontend/src/features/layouts/components/thread-view/components/thread-event/assignment-message.test.ts`:
- Around line 18-30: The makeEvent helper in assignment-message.test.ts does not
cover the new webhook actor shape because authorId === null always forces
author_display to null. Update this fixture helper or add a dedicated case so
tests can construct the author === null and author_display set path that
buildAssignmentMessage special-cases for webhook/channel events, and verify the
assignment-message behavior through the existing AssignmentEvent setup.

In
`@src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx`:
- Around line 221-259: The IM thread event avatar color is being derived from
author.full_name/email, which falls back to an empty string for webhook-authored
events; update the ThreadEvent rendering logic to use author_display for the
color input as well. In the thread-view component, adjust the authorName
calculation (or equivalent helper used for getAvatarColor) so it prefers
event.author_display before falling back to author fields, keeping the avatar
color consistent with the displayed webhook name.
🪄 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: ba3d67de-37b5-4e37-b072-3b8aa0fad14c

📥 Commits

Reviewing files that changed from the base of the PR and between 01287fb and bff5a75.

⛔ Files ignored due to path filters (3)
  • src/frontend/src/features/api/gen/models/channel_create_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/**
📒 Files selected for processing (40)
  • docs/tiered-storage.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/enums.py
  • src/backend/core/mda/dispatch_webhooks.py
  • src/backend/core/mda/inbound.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/mda/outbound.py
  • src/backend/core/migrations/0032_inboundmessage_blob_inboundmessage_is_internal_and_more.py
  • src/backend/core/models.py
  • src/backend/core/services/exporter/tasks.py
  • src/backend/core/signals.py
  • src/backend/core/tasks.py
  • src/backend/core/templates/admin/core/channel/change_form.html
  • src/backend/core/templates/admin/core/channel/regenerated_secret.html
  • src/backend/core/tests/api/test_channel_scope_level.py
  • src/backend/core/tests/api/test_channels.py
  • src/backend/core/tests/mda/test_dispatch_webhooks.py
  • src/backend/core/tests/mda/test_inbound.py
  • src/backend/core/tests/mda/test_inbound_spoofed_sender.py
  • src/backend/core/tests/mda/test_spam_processing.py
  • src/backend/core/tests/models/test_blob.py
  • src/backend/messages/settings.py
  • 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/src/features/layouts/components/mailbox-settings/modal-compose-integration/webhook-integration-form.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
💤 Files with no reviewable changes (1)
  • src/backend/core/templates/admin/core/channel/regenerated_secret.html

Comment thread docs/webhooks.md Outdated
Comment thread src/backend/core/mda/dispatch_webhooks.py Outdated
Comment thread src/backend/core/mda/dispatch_webhooks.py Outdated
Comment thread src/backend/core/mda/dispatch_webhooks.py Outdated
Comment thread src/backend/core/mda/inbound_tasks.py Outdated
Comment thread src/backend/core/tests/mda/test_dispatch_webhooks.py Outdated
Comment thread src/backend/core/tests/mda/test_inbound_spoofed_sender.py
Comment thread src/backend/core/tests/mda/test_inbound.py
Comment thread src/frontend/public/locales/common/en-US.json Outdated
Comment thread src/frontend/public/locales/common/en-US.json Outdated
Comment thread src/backend/core/models.py Dismissed
@sylvinus

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 28, 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: 8

Caution

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

⚠️ Outside diff range comments (5)
src/backend/core/mda/dispatch_webhooks.py (1)

953-987: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Run message.delivering hooks before message.delivered hooks.

After-spam steps are emitted in queryset order, but message.delivered captures ctx.is_spam immediately. If a later message.delivering hook overrides the verdict, the queued webhook goes out with stale X-StMsg-Is-Spam data.

Suggested fix
-    steps: List[Step] = []
+    steps: List[Step] = []
+    deferred_non_blocking: List[Step] = []
@@
-        steps.append(UserWebhookStep(channel, phase=phase))
-    return steps
+        step = UserWebhookStep(channel, phase=phase)
+        if (
+            phase == PHASE_AFTER_SPAM
+            and trigger == enums.WebhookTrigger.MESSAGE_DELIVERED
+        ):
+            deferred_non_blocking.append(step)
+        else:
+            steps.append(step)
+    return steps + deferred_non_blocking
🤖 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 953 - 987, In the
webhook step assembly logic that builds the `steps` list for `UserWebhookStep`,
ensure `message.delivering` is queued and executed before any
`message.delivered` hooks so the final spam verdict is settled first. Update the
ordering in the phase-based selection around `runs_at` / `phase` so after-spam
hooks do not capture a stale `ctx.is_spam` value, while keeping the existing
trigger and format validation behavior unchanged.
src/backend/core/tests/api/test_channel_scope_level.py (1)

1021-1037: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the one-time webhook credential on personal-channel creation.

With auth_method: "jwt", this path should now surface secret exactly once, just like the mailbox-scoped webhook create flow. The current test only checks that legacy hmac_secret is absent, so a regression in the /users/me/channels/ response contract would still pass.

Suggested assertion
         assert response.status_code == 201, response.content
-        # No HMAC secret generation yet — that scaffolding lands with the
-        # delivery pipeline. The response carries the row id only.
         assert "hmac_secret" not in response.data
+        assert response.data.get("secret")
         created = models.Channel.objects.get(pk=response.data["id"])
🤖 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 -
1037, The personal webhook creation test in test_channel_scope_level.py only
checks that legacy hmac_secret is absent, so it can miss regressions in the
/users/me/channels/ response contract. Update the assertion in the
personal-channel create flow to verify the one-time webhook credential is
surfaced as secret exactly once when auth_method is jwt, matching the
mailbox-scoped webhook behavior, and keep the existing response checks around
the created webhook payload.
src/backend/core/tests/api/test_messages_delivery_statuses.py (1)

576-600: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Cover SENT_INTERNAL in these transition tests as well.

After splitting delivered into SENT_INTERNAL and SENT_EXTERNAL, these cases now exercise only the external branch. That leaves a hole where the endpoint could reject updates for SENT_EXTERNAL but still allow the same invalid transition for internally delivered recipients.

Suggested pattern
+    `@pytest.mark.parametrize`(
+        "delivered_status",
+        [
+            enums.MessageDeliveryStatusChoices.SENT_INTERNAL,
+            enums.MessageDeliveryStatusChoices.SENT_EXTERNAL,
+        ],
+    )
-    def test_api_messages_delivery_statuses_invalid_transition_sent_to_cancelled(self):
+    def test_api_messages_delivery_statuses_invalid_transition_sent_to_cancelled(
+        self, delivered_status
+    ):
         ...
         recipient = factories.MessageRecipientFactory(
             message=message,
-            delivery_status=enums.MessageDeliveryStatusChoices.SENT_EXTERNAL,
+            delivery_status=delivered_status,
         )

As per coding guidelines, “Unit tests should focus on a single use case, keep assertions minimal, and cover all possible cases.”

Also applies to: 613-692, 698-752, 754-836

🤖 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_messages_delivery_statuses.py` around lines
576 - 600, Add SENT_INTERNAL coverage to the delivery-status transition tests
that currently only exercise SENT_EXTERNAL, since the split left an untested
branch in the same invalid-transition scenarios. Update the affected test cases
in test_api_messages_delivery_statuses_* to parameterize or duplicate the setup
for both MessageDeliveryStatusChoices.SENT_EXTERNAL and
MessageDeliveryStatusChoices.SENT_INTERNAL, keeping the same transition
assertions for each recipient status. Use the existing test method names and the
MessageRecipientFactory setup as the anchor points when expanding coverage
across the listed cases.

Source: Coding guidelines

src/backend/core/services/ssrf.py (1)

266-279: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't replay POST on 303 See Other. The redirect loop preserves the original verb for every status in REDIRECT_STATUS_CODES, so a POST that receives 303 is sent again to the Location URL instead of switching to GET. That can duplicate side effects at the redirected endpoint; special-case 303 before retrying.

🤖 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/ssrf.py` around lines 266 - 279, The redirect
handling in the SSRF helper is replaying the original request verb for every
status in REDIRECT_STATUS_CODES, so a POST followed by 303 See Other is retried
as POST instead of switching to GET. Update the redirect loop in the SSRF
redirect-following logic to special-case 303 before the retry, preserving the
existing behavior for other redirect codes while converting the next request to
GET for 303. Use the redirect flow around current_url, response.status_code, and
REDIRECT_STATUS_CODES to locate the change.
src/backend/core/mda/inbound_tasks.py (1)

203-210: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound parse failures with the same retry/abandon policy.

A malformed RFC-822 payload will keep returning success=False, so the retry sweep can pin the InboundMessage indefinitely. Route this through _retry_or_abandon(...) so deterministic parse failures stop looping after the quarantine window.

Proposed fix
         if parsed_email is None:
             error_msg = "Failed to parse email message"
             logger.error(error_msg)
-            inbound_message.error_message = error_msg
-            inbound_message.save(update_fields=["error_message"])
-            return {"success": False, "error": error_msg}
+            return _retry_or_abandon(inbound_message, error_msg)
🤖 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 203 - 210, The parse
failure path in inbound task handling currently returns a plain failure, which
leaves malformed RFC-822 messages subject to repeated retries. Update the
`parse_email`/`parsed_email is None` branch in `inbound_tasks.py` to route the
failure through `_retry_or_abandon(...)` using the same error message and
`InboundMessage` handling, so deterministic parse errors are abandoned after the
quarantine window instead of looping indefinitely.
♻️ Duplicate comments (1)
src/backend/core/tests/mda/test_inbound.py (1)

506-507: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

These assertions still hit the import-only branch.

is_import=True is not a sync replacement for the inbound path: _create_message_from_inbound() switches thread matching, import flags, and read-state handling when that flag is set. These tests can now pass while real inbound delivery regresses. Please drive the non-import entrypoint here, or explicitly scope/rename them as import-path tests.

Also applies to: 535-536, 564-565, 820-824

🤖 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/mda/test_inbound.py` around lines 506 - 507, The
inbound delivery tests are still exercising the import-only path because
deliver_inbound_message is called with is_import=True, which bypasses the real
inbound behavior in _create_message_from_inbound. Update these cases to use the
non-import inbound entrypoint so thread matching, flags, and read-state handling
follow the normal path, or rename/scope the tests explicitly as import-path
coverage; apply the same fix in the other deliver_inbound_message call sites in
this test module.
🤖 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 `@src/backend/core/admin.py`:
- Around line 612-619: The credential rotation in the admin flow is accepting
unsupported webhook auth methods and then falling back to the newly rotated
root, which can invalidate the old secret. Update the logic around
rotate_secret() and get_webhook_surfaced_credential() to reject webhook channels
with missing/unknown auth_method before rotating, mirroring the DRF pre-rotation
validation. Only keep the root fallback for ChannelTypes.API_KEY channels, and
preserve the existing webhook credential display behavior for supported methods.

In `@src/backend/core/mda/webhook_payload.py`:
- Around line 65-74: build_jmap_email currently strips parser-only fields from
textBody, htmlBody, and attachments, but it leaves bodyStructure untouched, so
multipart emails can still contain non-JMAP keys or raw bytes. Update the
include_body path in build_jmap_email to recursively sanitize
parsed_email["bodyStructure"] using the same _strip_body_part logic (including
nested parts), and ensure the cleaned structure is assigned back to
email["bodyStructure"] before returning.

In `@src/backend/core/templates/admin/core/channel/change_form.html`:
- Line 15: The confirmation prompt in the channel change form is hard-coded
English, creating a mixed-language admin UI. Update the inline confirm text used
by the onsubmit handler in the channel secret regeneration form to use the same
translation approach as the button label, so the message is localized. Keep the
existing confirmation behavior, but move the prompt text into the template’s
i18n flow so it can be translated consistently.

In `@src/backend/core/tests/mda/test_autoreply.py`:
- Around line 821-828: The final gate assertion in the autoreply test is using
an outbound-shaped parsed message with the original sender mailbox, so it can
pass for the wrong reason. Update the assertion around should_send_autoreply()
in test_autoreply to use an inbound-shaped message/mailbox pair that matches the
delivery path being tested, so the check specifically verifies loop prevention
rather than self-sender/address-shape filtering.

In `@src/frontend/public/locales/common/nl-NL.json`:
- Around line 16-17: The Dutch translation for the webhook auth help text
currently hard-codes a secret-only term, which is inaccurate for both
secret-based and API-key webhooks. Update the affected entries in the common
locale JSON to use a neutral term such as credential, inloggegeven, or
toegangsgegeven instead of geheim, and make sure the matching regenerate CTA
copy uses the same neutral wording so both messages stay consistent across
webhook auth types.

In `@src/frontend/public/locales/common/ru-RU.json`:
- Line 847: The Russian locale strings in the credential description are too
specific because they hard-code “секрет” even though this flow can use either a
signing secret or an API key. Update the affected translation entries in the
common ru-RU locale to use credential-neutral wording, and make the same wording
change for the related strings referenced by the comment so the text stays
accurate regardless of credential type.

In `@src/frontend/public/locales/common/uk-UA.json`:
- Around line 846-847: The Ukrainian locale text for the webhook authentication
help and regenerate label is too specific by translating “credential” as
“secret,” which is incorrect for API-key auth. Update the affected strings in
common/uk-UA.json so the wording uses a neutral term for the one-time value
(covering both secret and api_key), and apply the same fix to the matching
entries referenced by the other locale keys.

In
`@src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx`:
- Around line 220-224: The IM event header now uses a different fallback than
the color hash, so stale payloads without author_display can render as Unknown
for real users. In thread-event/index.tsx, update the IM event handling in the
isIMEvent branch to derive one shared author label from event.author_display,
event.author?.full_name, event.author?.email, then reuse that same value for
both the hash input and the rendered header text. Apply the same fix anywhere
else in this file where the IM header label is built separately from the color
fallback, including the other affected block.

---

Outside diff comments:
In `@src/backend/core/mda/dispatch_webhooks.py`:
- Around line 953-987: In the webhook step assembly logic that builds the
`steps` list for `UserWebhookStep`, ensure `message.delivering` is queued and
executed before any `message.delivered` hooks so the final spam verdict is
settled first. Update the ordering in the phase-based selection around `runs_at`
/ `phase` so after-spam hooks do not capture a stale `ctx.is_spam` value, while
keeping the existing trigger and format validation behavior unchanged.

In `@src/backend/core/mda/inbound_tasks.py`:
- Around line 203-210: The parse failure path in inbound task handling currently
returns a plain failure, which leaves malformed RFC-822 messages subject to
repeated retries. Update the `parse_email`/`parsed_email is None` branch in
`inbound_tasks.py` to route the failure through `_retry_or_abandon(...)` using
the same error message and `InboundMessage` handling, so deterministic parse
errors are abandoned after the quarantine window instead of looping
indefinitely.

In `@src/backend/core/services/ssrf.py`:
- Around line 266-279: The redirect handling in the SSRF helper is replaying the
original request verb for every status in REDIRECT_STATUS_CODES, so a POST
followed by 303 See Other is retried as POST instead of switching to GET. Update
the redirect loop in the SSRF redirect-following logic to special-case 303
before the retry, preserving the existing behavior for other redirect codes
while converting the next request to GET for 303. Use the redirect flow around
current_url, response.status_code, and REDIRECT_STATUS_CODES to locate the
change.

In `@src/backend/core/tests/api/test_channel_scope_level.py`:
- Around line 1021-1037: The personal webhook creation test in
test_channel_scope_level.py only checks that legacy hmac_secret is absent, so it
can miss regressions in the /users/me/channels/ response contract. Update the
assertion in the personal-channel create flow to verify the one-time webhook
credential is surfaced as secret exactly once when auth_method is jwt, matching
the mailbox-scoped webhook behavior, and keep the existing response checks
around the created webhook payload.

In `@src/backend/core/tests/api/test_messages_delivery_statuses.py`:
- Around line 576-600: Add SENT_INTERNAL coverage to the delivery-status
transition tests that currently only exercise SENT_EXTERNAL, since the split
left an untested branch in the same invalid-transition scenarios. Update the
affected test cases in test_api_messages_delivery_statuses_* to parameterize or
duplicate the setup for both MessageDeliveryStatusChoices.SENT_EXTERNAL and
MessageDeliveryStatusChoices.SENT_INTERNAL, keeping the same transition
assertions for each recipient status. Use the existing test method names and the
MessageRecipientFactory setup as the anchor points when expanding coverage
across the listed cases.

---

Duplicate comments:
In `@src/backend/core/tests/mda/test_inbound.py`:
- Around line 506-507: The inbound delivery tests are still exercising the
import-only path because deliver_inbound_message is called with is_import=True,
which bypasses the real inbound behavior in _create_message_from_inbound. Update
these cases to use the non-import inbound entrypoint so thread matching, flags,
and read-state handling follow the normal path, or rename/scope the tests
explicitly as import-path coverage; apply the same fix in the other
deliver_inbound_message call sites in this test module.
🪄 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: 48845f12-ba30-42ba-b29c-92c44f02fa21

📥 Commits

Reviewing files that changed from the base of the PR and between bff5a75 and cbc103b.

📒 Files selected for processing (46)
  • docs/webhooks.md
  • src/backend/core/admin.py
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/channel.py
  • src/backend/core/enums.py
  • src/backend/core/factories.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/mda/outbound.py
  • src/backend/core/mda/selfcheck.py
  • src/backend/core/mda/webhook_payload.py
  • src/backend/core/models.py
  • src/backend/core/services/ssrf.py
  • src/backend/core/tasks.py
  • src/backend/core/templates/admin/core/channel/change_form.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_messages_create.py
  • src/backend/core/tests/api/test_messages_delivery_statuses.py
  • src/backend/core/tests/api/test_messages_import.py
  • src/backend/core/tests/api/test_prometheus_metrics.py
  • src/backend/core/tests/api/test_threads_list.py
  • src/backend/core/tests/mda/test_autoreply.py
  • src/backend/core/tests/mda/test_dispatch_webhooks.py
  • src/backend/core/tests/mda/test_inbound.py
  • src/backend/core/tests/mda/test_inbound_spoofed_sender.py
  • src/backend/core/tests/mda/test_outbound.py
  • src/backend/core/tests/mda/test_outbound_e2e.py
  • src/backend/core/tests/mda/test_retry.py
  • src/backend/core/tests/mda/test_spam_processing.py
  • src/backend/core/tests/services/test_ssrf.py
  • src/backend/core/tests/test_signals.py
  • src/backend/e2e/management/commands/e2e_demo.py
  • src/backend/messages/settings.py
  • 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/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/thread-view/components/thread-event/assignment-message.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

Comment thread src/backend/core/admin.py
Comment on lines +65 to +74
if include_body:
email["textBody"] = [
_strip_body_part(p) for p in parsed_email.get("textBody") or []
]
email["htmlBody"] = [
_strip_body_part(p) for p in parsed_email.get("htmlBody") or []
]
email["attachments"] = [
_strip_body_part(p) for p in parsed_email.get("attachments") or []
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Sanitize bodyStructure recursively as well.

build_jmap_email() only cleans textBody / htmlBody / attachments. The copied bodyStructure still carries parser-only fields, so multipart payloads can leak non-JMAP keys or raw attachment bytes and break JSON serialization on send.

Suggested fix
 def _strip_body_part(part: Dict[str, Any]) -> Dict[str, Any]:
@@
-    return {k: v for k, v in part.items() if k not in ("content", "sha256")}
+    clean = {
+        k: v for k, v in part.items() if k not in ("_ext", "content", "sha256")
+    }
+    if isinstance(clean.get("subParts"), list):
+        clean["subParts"] = [_strip_body_part(p) for p in clean["subParts"]]
+    return clean
@@
     if include_body:
         email["textBody"] = [
             _strip_body_part(p) for p in parsed_email.get("textBody") or []
         ]
         email["htmlBody"] = [
             _strip_body_part(p) for p in parsed_email.get("htmlBody") or []
         ]
         email["attachments"] = [
             _strip_body_part(p) for p in parsed_email.get("attachments") or []
         ]
+        if parsed_email.get("bodyStructure"):
+            email["bodyStructure"] = _strip_body_part(parsed_email["bodyStructure"])
📝 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.

Suggested change
if include_body:
email["textBody"] = [
_strip_body_part(p) for p in parsed_email.get("textBody") or []
]
email["htmlBody"] = [
_strip_body_part(p) for p in parsed_email.get("htmlBody") or []
]
email["attachments"] = [
_strip_body_part(p) for p in parsed_email.get("attachments") or []
]
if include_body:
email["textBody"] = [
_strip_body_part(p) for p in parsed_email.get("textBody") or []
]
email["htmlBody"] = [
_strip_body_part(p) for p in parsed_email.get("htmlBody") or []
]
email["attachments"] = [
_strip_body_part(p) for p in parsed_email.get("attachments") or []
]
if parsed_email.get("bodyStructure"):
email["bodyStructure"] = _strip_body_part(parsed_email["bodyStructure"])
🤖 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/webhook_payload.py` around lines 65 - 74,
build_jmap_email currently strips parser-only fields from textBody, htmlBody,
and attachments, but it leaves bodyStructure untouched, so multipart emails can
still contain non-JMAP keys or raw bytes. Update the include_body path in
build_jmap_email to recursively sanitize parsed_email["bodyStructure"] using the
same _strip_body_part logic (including nested parts), and ensure the cleaned
structure is assigned back to email["bodyStructure"] before returning.

Comment thread src/backend/core/templates/admin/core/channel/change_form.html Outdated
Comment thread src/backend/core/tests/mda/test_autoreply.py Outdated
Comment thread src/frontend/public/locales/common/nl-NL.json Outdated
Comment thread src/frontend/public/locales/common/ru-RU.json Outdated
Comment thread src/frontend/public/locales/common/uk-UA.json Outdated
@sylvinus
sylvinus changed the base branch from main to development June 29, 2026 10:14
@sylvinus
sylvinus force-pushed the outbound_webhooks branch from 9331775 to 27222a7 Compare June 29, 2026 12:22

@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: 13

Caution

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

⚠️ Outside diff range comments (4)
src/backend/core/tests/mda/test_retry.py (1)

77-84: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add a SENT_INTERNAL non-retry case.

These updates prove SENT_EXTERNAL is excluded from retry, but the renamed enum now has two terminal delivered states. A sibling assertion for SENT_INTERNAL would guard the retry filter against the exact ==/!= SENT_EXTERNAL footgun documented on MessageDeliveryStatusChoices.

🧪 Suggested coverage
+    `@patch`("core.mda.outbound_tasks.send_message")
+    def test_retry_message_with_sent_internal_recipient_not_retryable(
+        self, mock_send_message, mailbox_sender, thread
+    ):
+        sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
+        message = factories.MessageFactory(
+            thread=thread,
+            sender=sender_contact,
+            is_draft=False,
+            is_sender=True,
+            subject="Internal delivery already completed",
+        )
+        internal_contact = factories.ContactFactory(mailbox=mailbox_sender)
+        factories.MessageRecipientFactory(
+            message=message,
+            contact=internal_contact,
+            type=models.MessageRecipientTypeChoices.TO,
+            delivery_status=enums.MessageDeliveryStatusChoices.SENT_INTERNAL,
+            delivered_at=timezone.now(),
+        )
+
+        result = retry_messages_task.apply(args=[[str(message.id)]]).get()
+
+        assert result["processed_messages"] == 0
+        mock_send_message.assert_not_called()

As per coding guidelines, Unit tests should focus on a single use case, keep assertions minimal, and cover all possible cases.

Also applies to: 408-415, 463-470

🤖 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/mda/test_retry.py` around lines 77 - 84, Add a
parallel non-retry assertion for MessageDeliveryStatusChoices.SENT_INTERNAL in
the retry-related tests, alongside the existing SENT_EXTERNAL case, so the retry
filter is validated against both delivered terminal states. Update the relevant
test setup in test_retry.py and any other matching retry coverage blocks to
create a recipient with SENT_INTERNAL and assert it is excluded from retry,
using the existing test helpers and MessageRecipientFactory for consistency.

Source: Coding guidelines

src/backend/core/services/ssrf.py (1)

240-280: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject HTTPS→HTTP redirect downgrades.

The manual redirect loop will replay the same POST body and caller headers to a plaintext http:// Location. That leaks webhook payloads or auth material in transit even when the configured endpoint started as HTTPS. If plaintext delivery is intended, require it in the initial URL instead of silently downgrading mid-chain.

Proposed fix
             next_url = urljoin(current_url, location)
+            if (
+                urlparse(current_url).scheme == "https"
+                and urlparse(next_url).scheme != "https"
+            ):
+                response.close()
+                raise SSRFValidationError(
+                    "Refusing redirect from HTTPS to insecure HTTP"
+                )
             response.close()
             current_url = next_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/backend/core/services/ssrf.py` around lines 240 - 280, The manual
redirect handling in _request_with_redirects currently follows Location headers
from an HTTPS request to an HTTP target, which can silently downgrade transport
security. Update the redirect validation logic in _request_with_redirects to
reject any HTTPS-to-HTTP redirect before calling _pinned_session or replaying
the request, while still allowing same-scheme or stronger redirects. Make sure
the check is applied per hop using current_url and next_url so redirects are
blocked even when the chain starts on HTTPS.
src/backend/core/models.py (1)

2291-2327: 🚀 Performance & Scalability | 🔵 Trivial

Add an index for the new abandonment cutoff.

abandoned_at is now the retry/purge discriminator, but Meta.indexes still only covers created_at. Once abandoned rows accumulate, the periodic sweeps behind this field will degrade to full scans. Add the supporting index(es) in the model/migration alongside the field. As per coding guidelines, src/backend/**/{models.py,migrations/**/*.py}: Implement database indexing and query optimization (Model Meta indexes, constraints).

🤖 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 2291 - 2327, `abandoned_at` is now
used as the cutoff for retry/purge sweeps, but `Meta.indexes` on
`InboundMessage` still only indexes `created_at`, so the cleanup queries will
degrade as abandoned rows grow. Add the appropriate model index in
`InboundMessage.Meta.indexes` for `abandoned_at`, and mirror it in the matching
migration so `purge_abandoned_inbound_messages_task` and related queries can
filter efficiently.

Source: Coding guidelines

src/backend/core/tests/mda/test_spam_processing.py (1)

192-195: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the stale rspamd error comment.

The helper returns False plus an error sentinel, but the pipeline now uses that error to hold for retry rather than deliver unchecked.

Proposed wording fix
-        # On error, treat as not spam to avoid blocking legitimate messages
+        # Low-level helper returns a false verdict plus an error sentinel;
+        # the rspamd pipeline step uses the error to hold the message for retry.
         assert is_spam is False
🤖 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/mda/test_spam_processing.py` around lines 192 - 195,
Update the stale inline comment in the spam-processing test to match the current
rspamd error handling: the helper now returns False with an error sentinel that
the pipeline uses to hold for retry, not to immediately treat the message as
safe. Keep the assertion block in test_spam_processing aligned with the behavior
of the related helper and pipeline logic, and revise the comment text near
is_spam, error, and rspamd_result accordingly.
♻️ Duplicate comments (2)
src/backend/core/mda/inbound_pipeline.py (1)

528-556: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Remove assignee emails from warning logs.

These webhook-supplied addresses are PII. Keep the skip reason, but don’t emit the raw email value.

Proposed fix
             logger.warning(
-                "Webhook assignee email %s does not resolve to any user — skipping",
-                email,
+                "Webhook assignee email does not resolve to any user — skipping",
             )
@@
             logger.warning(
-                "Webhook assignee email %s is ambiguous (multiple matches) — skipping",
-                email,
+                "Webhook assignee email is ambiguous (multiple matches) — skipping",
             )
@@
             logger.warning(
-                "Webhook assignee %s lacks an assignable role on the thread — skipping",
-                candidate_users[uid].email,
+                "Webhook assignee lacks an assignable role on the thread — skipping",
             )

As per coding guidelines, “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 528 - 556, The warning
logs in the assignee resolution flow still emit webhook-supplied email
addresses, which is sensitive PII. Update the logging in the inbound pipeline’s
assignee lookup/assignment path so the messages in this section keep the skip
reason but do not include the raw email value; use the existing logger.warning
calls around candidate resolution and ThreadAccess checks to reference a
non-sensitive identifier or omit the address entirely.

Source: Coding guidelines

src/backend/core/tests/mda/test_autoreply.py (1)

813-850: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the final gate assertion inbound-shaped.

parsed is still addressed to the original sender, so should_send_autoreply(other_mailbox, parsed) can return None even if _is_auto_reply_message() regresses because _is_recipient_explicit() rejects it first. Point the parsed to/cc at other_mailbox (or build an inbound-shaped fixture) before the last assertion so this actually proves loop prevention.

Possible fix
-        assert should_send_autoreply(other_mailbox, parsed) is None
+        inbound_shaped = {
+            **parsed,
+            "to": [{"email": str(other_mailbox)}],
+            "cc": [],
+        }
+        assert should_send_autoreply(other_mailbox, inbound_shaped) is None
🤖 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/mda/test_autoreply.py` around lines 813 - 850, The
final autoreply gate assertion is not using an inbound-shaped message, so it can
fail for recipient-shape reasons instead of the auto-reply detector. Update the
test around send_autoreply_for_message, _is_auto_reply_message, and
should_send_autoreply so the parsed message is addressed to other_mailbox (or
use an inbound-shaped fixture with matching to/cc) before the last assertion.
Keep the second mailbox/template setup, but ensure the final
should_send_autoreply(other_mailbox, parsed) check can only pass if loop
prevention works.
🤖 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/env.md`:
- Around line 247-249: The ALLOWED_HOSTS documentation entry is inaccurate
because it conflates the Django setting with the separate DJANGO_ALLOWED_HOSTS
variable. Update the wording in the env docs to describe ALLOWED_HOSTS as the
Django allow-list setting from settings.py, and clarify that
DJANGO_ALLOWED_HOSTS is only used by development defaults/tooling rather than
saying the Development class sets ALLOWED_HOSTS from it.

In `@docs/tiered-storage.md`:
- Line 55: Update the fast-path GC section so the blob deletion paragraph
matches the liveness graph change for InboundMessage.blob. In the tiered-storage
docs near the liveness graph and deletion description, either add InboundMessage
to the list of entities whose deletions enqueue blob candidates, or explicitly
state that only mode="full" can reclaim InboundMessage blobs. Reference the
InboundMessage.blob and mode="full" wording consistently with the existing
Message, Attachment, and MessageTemplate descriptions.

In `@docs/webhooks.md`:
- Around line 106-108: The regenerate-secret example is scope-specific and
currently implies the unscoped channel route, which is misleading for
mailbox-scoped webhooks. Update the webhook docs near the root secret
description to explain that the regenerate-secret path prefix depends on scope,
and for mailbox-scoped channels it should be reached through the mailbox route
using both mailbox_id and pk. Keep the generic `regenerate-secret` wording tied
to the relevant channel scope so readers are directed to the correct endpoint.

In `@src/backend/core/admin.py`:
- Around line 1603-1608: The replay loop in the admin action mutates each
inbound message before publishing the Celery task, so a broker failure can erase
the abandoned/error state without re-queueing. Update the logic around the
queryset iteration in the admin handler to call
process_inbound_message_task.delay inside a try-except, and only clear
abandoned_at and error_message after a successful publish; if delay fails, leave
the row unchanged and avoid incrementing replayed for that item.

In `@src/backend/core/mda/autoreply.py`:
- Around line 290-291: The async send enqueue in the autoreply flow is still
happening inside the inner savepoint, so it can race an uncommitted message when
an outer transaction exists. Update the send trigger in the autoreply logic
around the `send_message_task.delay(str(message.id))` call to register the
enqueue with `transaction.on_commit()` instead, so the task is queued only after
the message row is fully committed.

In `@src/backend/core/mda/dispatch_webhooks.py`:
- Around line 302-319: _make `_sanitize_url()` resilient to malformed ports_:
`urlparse()` can succeed while accessing `parsed.port` raises `ValueError`, so
update the helper to safely handle that case and still return a fallback value
instead of failing during logging. Use the existing `_sanitize_url` function and
keep the current `<unparseable-url>` / `<no-host>` behavior, but wrap port
access so invalid port strings do not propagate an exception.
- Around line 1055-1068: Revalidate the channel before dispatching the queued
webhook payload in the webhook task around the channel/message lookup logic in
dispatch_webhooks; after re-fetching the Channel and Mailbox, ensure the channel
is still a webhook, still has message.delivered enabled, and still belongs to
the same mailbox as the Message being posted. If any of those checks fail, skip
sending and return early, using the existing channel_id/mailbox_id/message_id
context in the task.

In `@src/backend/core/mda/inbound_pipeline.py`:
- Around line 265-275: The rspamd error path in inbound_pipeline should stop
returning raw exception text because it can expose endpoint details and gets
logged again downstream. Update the exception handling around the rspamd call in
the rspamd-check helper so both the RequestException/ValueError and generic
Exception branches return a sanitized error token instead of str(exc), and make
the same change in the other rspamd error path noted in the review so callers
only receive non-sensitive, stable error identifiers.

In `@src/backend/core/mda/inbound_tasks.py`:
- Around line 95-103: The broad exception handlers in the inbound task flow are
swallowing Celery’s soft-timeout exception before the task-level timeout
handling can run. Update the `except Exception` blocks in the finalize-step path
and the task-level handler in `inbound_tasks.py` to detect
`SoftTimeLimitExceeded` and re-raise it immediately, while keeping the existing
logging for other exceptions. Use the surrounding `fn()` execution and the
inbound-message task handler as the places to make this change.

In `@src/backend/core/mda/inbound.py`:
- Around line 228-232: The info log in the inbound message queueing path is
exposing recipient email PII; update the logger call in the inbound message
handling flow to avoid printing recipient_email and use a non-user identifier
such as mailbox.id or inbound_message.id instead. Locate the log statement in
the inbound message queueing code and keep the message useful for debugging by
reporting internal/non-sensitive identifiers only.

In `@src/backend/core/templates/admin/core/channel/change_form.html`:
- Line 15: The inline confirm prompt in the channel change form is using
translated text directly inside JavaScript, which can break when a locale
includes quotes or backslashes. Update the `onsubmit` handler in the
`change_form.html` template so the `{% blocktranslate %}` output is escaped for
JavaScript before being passed to `confirm(...)`, keeping the prompt safe across
translations and preserving the secret regeneration flow.

In `@src/backend/core/tests/api/test_threads_list.py`:
- Around line 1521-1524: The flag-preservation test in test_threads_list
currently only covers the SENT_EXTERNAL terminal delivery case, so it can miss
regressions in the same-instance path. Update the test around
MessageRecipientFactory and the related status assertions to include
SENT_INTERNAL alongside SENT_EXTERNAL, ensuring both delivered-class statuses
are treated identically by the thread-list flag logic.

In
`@src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/webhook-integration-form.tsx`:
- Around line 67-74: The URL validation in webhook-integration-form.tsx only
checks the scheme prefix, so host-less values like https:///no-host slip through
client-side validation and fail later on save. Update the zod schema for the url
field to also require a valid host/authority (not just http(s)://), keeping the
existing allowInsecureUrl behavior and error messages in the same validation
block so the user gets an inline field error before submit.

---

Outside diff comments:
In `@src/backend/core/models.py`:
- Around line 2291-2327: `abandoned_at` is now used as the cutoff for
retry/purge sweeps, but `Meta.indexes` on `InboundMessage` still only indexes
`created_at`, so the cleanup queries will degrade as abandoned rows grow. Add
the appropriate model index in `InboundMessage.Meta.indexes` for `abandoned_at`,
and mirror it in the matching migration so
`purge_abandoned_inbound_messages_task` and related queries can filter
efficiently.

In `@src/backend/core/services/ssrf.py`:
- Around line 240-280: The manual redirect handling in _request_with_redirects
currently follows Location headers from an HTTPS request to an HTTP target,
which can silently downgrade transport security. Update the redirect validation
logic in _request_with_redirects to reject any HTTPS-to-HTTP redirect before
calling _pinned_session or replaying the request, while still allowing
same-scheme or stronger redirects. Make sure the check is applied per hop using
current_url and next_url so redirects are blocked even when the chain starts on
HTTPS.

In `@src/backend/core/tests/mda/test_retry.py`:
- Around line 77-84: Add a parallel non-retry assertion for
MessageDeliveryStatusChoices.SENT_INTERNAL in the retry-related tests, alongside
the existing SENT_EXTERNAL case, so the retry filter is validated against both
delivered terminal states. Update the relevant test setup in test_retry.py and
any other matching retry coverage blocks to create a recipient with
SENT_INTERNAL and assert it is excluded from retry, using the existing test
helpers and MessageRecipientFactory for consistency.

In `@src/backend/core/tests/mda/test_spam_processing.py`:
- Around line 192-195: Update the stale inline comment in the spam-processing
test to match the current rspamd error handling: the helper now returns False
with an error sentinel that the pipeline uses to hold for retry, not to
immediately treat the message as safe. Keep the assertion block in
test_spam_processing aligned with the behavior of the related helper and
pipeline logic, and revise the comment text near is_spam, error, and
rspamd_result accordingly.

---

Duplicate comments:
In `@src/backend/core/mda/inbound_pipeline.py`:
- Around line 528-556: The warning logs in the assignee resolution flow still
emit webhook-supplied email addresses, which is sensitive PII. Update the
logging in the inbound pipeline’s assignee lookup/assignment path so the
messages in this section keep the skip reason but do not include the raw email
value; use the existing logger.warning calls around candidate resolution and
ThreadAccess checks to reference a non-sensitive identifier or omit the address
entirely.

In `@src/backend/core/tests/mda/test_autoreply.py`:
- Around line 813-850: The final autoreply gate assertion is not using an
inbound-shaped message, so it can fail for recipient-shape reasons instead of
the auto-reply detector. Update the test around send_autoreply_for_message,
_is_auto_reply_message, and should_send_autoreply so the parsed message is
addressed to other_mailbox (or use an inbound-shaped fixture with matching
to/cc) before the last assertion. Keep the second mailbox/template setup, but
ensure the final should_send_autoreply(other_mailbox, parsed) check can only
pass if loop prevention works.
🪄 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: 368ad8b0-573b-4500-8ca7-9f4f521a7dc0

📥 Commits

Reviewing files that changed from the base of the PR and between cbc103b and e542788.

⛔ Files ignored due to path filters (7)
  • src/frontend/package-lock.json is excluded by !**/package-lock.json
  • 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/index.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/**
📒 Files selected for processing (64)
  • docs/env.md
  • docs/tiered-storage.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/enums.py
  • src/backend/core/factories.py
  • src/backend/core/mda/autoreply.py
  • src/backend/core/mda/dispatch_webhooks.py
  • src/backend/core/mda/inbound.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/mda/outbound.py
  • src/backend/core/mda/selfcheck.py
  • src/backend/core/mda/webhook_payload.py
  • src/backend/core/migrations/0032_inboundmessage_blob_inboundmessage_is_internal_and_more.py
  • src/backend/core/models.py
  • src/backend/core/services/exporter/tasks.py
  • src/backend/core/services/ssrf.py
  • src/backend/core/services/thread_events.py
  • src/backend/core/signals.py
  • src/backend/core/tasks.py
  • src/backend/core/templates/admin/core/channel/change_form.html
  • src/backend/core/templates/admin/core/channel/regenerated_secret.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_messages_create.py
  • src/backend/core/tests/api/test_messages_delivery_statuses.py
  • src/backend/core/tests/api/test_messages_import.py
  • src/backend/core/tests/api/test_prometheus_metrics.py
  • src/backend/core/tests/api/test_threads_list.py
  • src/backend/core/tests/mda/test_autoreply.py
  • src/backend/core/tests/mda/test_dispatch_webhooks.py
  • src/backend/core/tests/mda/test_inbound.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_outbound.py
  • src/backend/core/tests/mda/test_outbound_e2e.py
  • src/backend/core/tests/mda/test_retry.py
  • src/backend/core/tests/mda/test_spam_processing.py
  • src/backend/core/tests/models/test_blob.py
  • src/backend/core/tests/services/test_ssrf.py
  • src/backend/core/tests/test_signals.py
  • src/backend/e2e/management/commands/e2e_demo.py
  • src/backend/messages/celery_app.py
  • src/backend/messages/settings.py
  • 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/src/features/layouts/components/mailbox-settings/integrations-view/integrations-data-grid.tsx
  • src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/_index.scss
  • 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/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

Comment thread docs/env.md Outdated
Comment thread docs/tiered-storage.md
Comment thread docs/webhooks.md Outdated
Comment thread src/backend/core/admin.py
Comment on lines +290 to 291
# Trigger async send (outside transaction to avoid sending before commit)
send_message_task.delay(str(message.id))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Use transaction.on_commit() for the send enqueue.

Leaving this inner atomic() only releases the savepoint. If a caller already has an outer transaction open, delay() can still run before the message row commits and the worker can race a missing message. Register the enqueue on commit instead.

Possible fix
-    send_message_task.delay(str(message.id))
+    transaction.on_commit(lambda: send_message_task.delay(str(message.id)))
📝 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.

Suggested change
# Trigger async send (outside transaction to avoid sending before commit)
send_message_task.delay(str(message.id))
# Trigger async send (outside transaction to avoid sending before commit)
transaction.on_commit(lambda: send_message_task.delay(str(message.id)))
🤖 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/autoreply.py` around lines 290 - 291, The async send
enqueue in the autoreply flow is still happening inside the inner savepoint, so
it can race an uncommitted message when an outer transaction exists. Update the
send trigger in the autoreply logic around the
`send_message_task.delay(str(message.id))` call to register the enqueue with
`transaction.on_commit()` instead, so the task is queued only after the message
row is fully committed.

Comment on lines 95 to 103
try:
headers = {"Content-Type": "message/rfc822"}
spam_auth = spam_config.get("rspamd_auth")
if spam_auth:
headers["Authorization"] = spam_auth

response = requests.post(
f"{spam_url}/checkv2",
data=raw_data,
headers=headers,
timeout=10,
fn()
except Exception as exc: # pylint: disable=broad-exception-caught
logger.exception(
"Finalize step %r failed on inbound message %s: %s",
label,
inbound_message_id,
exc,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

python - <<'PY'
from celery.exceptions import SoftTimeLimitExceeded
print(issubclass(SoftTimeLimitExceeded, Exception))
PY

Repository: suitenumerique/messages

Length of output: 280


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the target file and inspect its structure.
git ls-files 'src/backend/core/mda/inbound_tasks.py'
echo '--- outline ---'
ast-grep outline src/backend/core/mda/inbound_tasks.py --view expanded || true
echo '--- soft timelimit search ---'
rg -n 'SoftTimeLimitExceeded|soft time|soft_time_limit|time limit' src/backend/core/mda/inbound_tasks.py src/backend -g '*.py' || true
echo '--- relevant slices ---'
sed -n '1,180p' src/backend/core/mda/inbound_tasks.py
echo '--- later slice ---'
sed -n '430,540p' src/backend/core/mda/inbound_tasks.py

Repository: suitenumerique/messages

Length of output: 14814


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('src/backend/core/mda/inbound_tasks.py')
print('exists', p.exists())
if p.exists():
    txt = p.read_text()
    for needle in ['SoftTimeLimitExceeded', 'except Exception', 'Finalize step', 'Autoreply failed', 'soft_time_limit']:
        print('---', needle, '---')
        for i, line in enumerate(txt.splitlines(), 1):
            if needle in line:
                print(f'{i}: {line}')
PY

Repository: suitenumerique/messages

Length of output: 982


🌐 Web query:

Celery SoftTimeLimitExceeded inherits Exception documentation

💡 Result:

The SoftTimeLimitExceeded exception in Celery inherits directly from the Python built-in Exception class [1][2]. As defined in the source code of billiard (the multiprocessing pool implementation used by Celery), the class definition is: class SoftTimeLimitExceeded(Exception): """The soft time limit has been exceeded. This exception is raised to give the task a chance to clean up.""" Because it inherits from Exception rather than BaseException, it can be inadvertently caught by broad "except Exception:" blocks in user code [3]. This behavior has been noted in the Celery community as a potential issue, as it may prevent the exception from propagating correctly and effectively terminating or cleaning up a task as intended [3]. Users are generally advised to ensure their exception handling logic explicitly re-raises SoftTimeLimitExceeded or excludes it from generic catch-all blocks [3].

Citations:


Re-raise SoftTimeLimitExceeded in these broad catches.
Both except Exception blocks here can swallow Celery’s soft-timeout exception before the task-level handler runs, so the inbound message won’t be parked cleanly on timeout. src/backend/core/mda/inbound_tasks.py:95-103, 468-471

🤖 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 95 - 103, The broad
exception handlers in the inbound task flow are swallowing Celery’s soft-timeout
exception before the task-level timeout handling can run. Update the `except
Exception` blocks in the finalize-step path and the task-level handler in
`inbound_tasks.py` to detect `SoftTimeLimitExceeded` and re-raise it
immediately, while keeping the existing logging for other exceptions. Use the
surrounding `fn()` execution and the inbound-message task handler as the places
to make this change.

Comment thread src/backend/core/mda/inbound.py Outdated
Comment thread src/backend/core/templates/admin/core/channel/change_form.html Outdated
Comment thread src/backend/core/tests/api/test_threads_list.py Outdated
Comment on lines +67 to +74
url: z
.string()
.min(1, { error: t("URL is required.") })
.regex(allowInsecureUrl ? /^https?:\/\//i : /^https:\/\//i, {
error: allowInsecureUrl
? t("URL must start with http:// or https://")
: t("URL must start with https://"),
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject host-less URLs in the form too.

This schema only checks the scheme prefix, so values like https:///no-host pass client-side validation even though the backend rejects them. Users then fall through to the generic save error path instead of getting an inline field error.

Suggested fix
         url: z
             .string()
             .min(1, { error: t("URL is required.") })
-            .regex(allowInsecureUrl ? /^https?:\/\//i : /^https:\/\//i, {
+            .refine((value) => {
+                try {
+                    const parsed = new URL(value);
+                    const protocolOk = allowInsecureUrl
+                        ? ["http:", "https:"].includes(parsed.protocol)
+                        : parsed.protocol === "https:";
+                    return protocolOk && !!parsed.hostname;
+                } catch {
+                    return false;
+                }
+            }, {
                 error: allowInsecureUrl
                     ? t("URL must start with http:// or https://")
                     : t("URL must start with https://"),
             }),
📝 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.

Suggested change
url: z
.string()
.min(1, { error: t("URL is required.") })
.regex(allowInsecureUrl ? /^https?:\/\//i : /^https:\/\//i, {
error: allowInsecureUrl
? t("URL must start with http:// or https://")
: t("URL must start with https://"),
}),
url: z
.string()
.min(1, { error: t("URL is required.") })
.refine((value) => {
try {
const parsed = new URL(value);
const protocolOk = allowInsecureUrl
? ["http:", "https:"].includes(parsed.protocol)
: parsed.protocol === "https:";
return protocolOk && !!parsed.hostname;
} catch {
return false;
}
}, {
error: allowInsecureUrl
? t("URL must start with http:// or https://")
: t("URL must start with 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/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/webhook-integration-form.tsx`
around lines 67 - 74, The URL validation in webhook-integration-form.tsx only
checks the scheme prefix, so host-less values like https:///no-host slip through
client-side validation and fail later on save. Update the zod schema for the url
field to also require a valid host/authority (not just http(s)://), keeping the
existing allowInsecureUrl behavior and error messages in the same validation
block so the user gets an inline field error before submit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Message model has a post_save signal which trigger a thread reindexation. That means that at each save call here, one opensearch request will be done... furthermore to update data that opensearch doesn't care.

Maybe we could use bulk_update ?

take = min(batch_size, limit - scanned)
# Processed rows leave the ``postmark__isnull=True`` set, so the next
# slice is always fresh work — no cursor/offset needed.
batch = list(qs.order_by("created_at")[:take])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In dry mode this will not work as the qs content does not change as we don't populate postmark field. We never reach if not batch statement so if there is only one message to update, we will loop on the same message until scanned equals to limit.

@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: 9

Caution

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

⚠️ Outside diff range comments (4)
src/backend/core/tests/mda/test_dispatch_webhooks.py (1)

1118-1129: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remove auth_method from this misconfiguration test.

The fixture still sets auth_method="jwt", so this never exercises the missing-auth-method fail-closed path the test name describes. Drop the field (or make it invalid if you want the unknown-method branch) so the assertion matches the scenario.

Suggested fix
         models.Channel.objects.create(
             name="no-auth-method",
             type=enums.ChannelTypes.WEBHOOK,
             scope_level=enums.ChannelScopeLevel.MAILBOX,
             mailbox=mailbox,
             settings={
                 "url": "https://hook.example.com",
                 "trigger": "message.delivered",
-                "auth_method": "jwt",
             },
             encrypted_settings={"secret": "whsec_test"},
         )
🤖 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/mda/test_dispatch_webhooks.py` around lines 1118 -
1129, The misconfiguration test for the webhook channel is still setting
auth_method in the Channel.objects.create fixture, so it does not cover the
missing-auth-method fail-closed path it is meant to verify. Update the test
setup in test_dispatch_webhooks to remove auth_method from settings for this
case, or change it to an invalid value only if you intend to exercise the
unknown-method branch, and keep the assertion aligned with the scenario being
tested.
src/backend/core/mda/dispatch_webhooks.py (1)

1129-1138: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Verify the message still belongs to the mailbox before posting it.

The task revalidates the channel scope, but message_id is fetched independently. If task args are stale or mismatched, this can send another mailbox’s message blob to a still-valid webhook for mailbox_id.

Suggested fix
-        message = models.Message.objects.filter(id=message_id).first()
+        message = models.Message.objects.filter(
+            id=message_id,
+            thread__accesses__mailbox=mailbox,
+        ).first()
         if message is None or message.blob_id is None:
             logger.warning(
                 "Webhook source message %s missing — skipping dispatch (channel=%s)",
🤖 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 1129 - 1138, The
webhook dispatch path in dispatch_webhooks.py re-fetches Message by message_id
but never confirms it still belongs to the current mailbox before building the
body. Update the logic around the message lookup in the dispatch flow to
validate message.mailbox_id matches the mailbox_id for this task, and skip with
a warning if it does not. Keep the check near the existing message/blob_id guard
and use the same symbols (message_id, mailbox_id, channel_id,
_resolve_body_from_message) so stale or mismatched task args cannot post another
mailbox’s content.
src/backend/core/mda/inbound_pipeline.py (1)

415-417: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the user lookup case-insensitive. email__in=target_emails can miss users whose stored User.email casing differs from the webhook payload, so valid assignees get skipped. Use a case-insensitive lookup here instead.

🤖 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 415 - 417, The user
matching logic in inbound_pipeline should use a case-insensitive email lookup
instead of a direct email__in match, since webhook payload casing may differ
from stored User.email values and cause valid assignees to be skipped. Update
the lookup in the user-fetching path that builds target_emails so it compares
emails case-insensitively, while keeping the existing ambiguity handling and
skip behavior in place.
src/backend/core/signals.py (1)

168-176: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Schedule blob GC after the delete commits.

post_delete can fire inside a transaction. Queueing GC immediately can race with uncommitted state or survive a rollback; mirror the index handlers and defer the side effect with transaction.on_commit.

🐛 Proposed fix
-    schedule_for_gc(instance.blob_id)
+    blob_id = instance.blob_id
+    if blob_id is not None:
+        transaction.on_commit(lambda: schedule_for_gc(blob_id))
🤖 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/signals.py` around lines 168 - 176, The blob GC scheduling
in schedule_inbound_message_blob_for_gc is happening directly in post_delete,
which can run before the surrounding transaction commits. Update this receiver
to defer the schedule_for_gc(instance.blob_id) call with transaction.on_commit,
matching the existing index handler pattern so the side effect only runs after a
successful commit and is skipped on rollback.
♻️ Duplicate comments (1)
docs/webhooks.md (1)

203-229: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Split the blocking failure matrix into DROP vs RETRY.

This still says every non-2xx retries, but the dispatcher drops 4xx responses and only retries transient 408/429/5xx, timeouts, and connection errors. Please update the prose/table to match that contract.

Suggested fix
- * A **transient** failure — a `4xx`/`5xx`, a timeout, a connection error, a receiver that is temporarily down — is held for **RETRY** and re-fired by the sweep until it recovers (bounded by the deferral window).
+ * A **client** failure (`4xx`) is **DROP**.
+ * A **transient** failure — `408`/`429`/`5xx`, a timeout, or a connection error — is held for **RETRY** and re-fired by the sweep until it recovers (bounded by the deferral window).
@@
-| Any non-2xx (`4xx`/`5xx`/`3xx`), timeout, conn. | RETRY           | Transient — held, re-fired by the sweep, bounded by the deferral window.   |
+| HTTP `4xx`                                      | DROP            | Client error — the receiver rejected the message.                         |
+| HTTP `408`/`429`/`5xx`, timeout, conn.          | RETRY           | Transient — held, re-fired by the sweep, bounded by the deferral window.  |
🤖 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 203 - 229, Update the webhook failure matrix
in docs/webhooks.md so the dispatcher contract is split correctly: keep the
existing DROP path tied to HTTP 2xx with {"action":"drop"}, but change the
non-2xx handling in the prose and table to distinguish DROP-worthy 4xx responses
from RETRY-only transient cases. Reference the webhook outcome table and
surrounding retry description, and revise the entries to say only 408/429/5xx,
timeouts, and connection errors are retried while 4xx responses are dropped per
the dispatcher behavior.
🤖 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 `@src/backend/core/admin.py`:
- Around line 1603-1617: The re-queue flow in the abandoned-message loop leaves
abandoned_at set until after process_inbound_message_task.delay() returns, which
lets a fast worker observe the stale abandoned state and skip processing. Update
the admin re-queue logic around the abandoned_message loop in admin.py to clear
abandoned_at and error_message before publishing the task, or otherwise ensure
the worker cannot see the old marker when process_inbound_message_task runs,
while keeping the failure path logged and leaving the row abandoned if publish
fails.

In `@src/backend/core/api/viewsets/inbound/mta.py`:
- Around line 210-217: The Received header construction in the mta metadata
branch can still raise KeyError because it only checks client_helo before
indexing client_hostname and client_address. Update the logic in the
Received-header path to use safe access via mta_metadata.get(...) for
client_helo, client_hostname, and client_address, and only append the header
when all required values are present; otherwise skip adding that header.

In `@src/backend/core/management/commands/backfill_postmark.py`:
- Around line 96-121: The current batching logic in
backfill_postmark.Command.run relies on rows leaving the postmark__isnull
queryset only after a save, so dry-run and exception paths can reprocess the
same records forever. Update the loop in the backfill_postmark management
command to page with a stable cursor based on created_at plus a tie-breaker
(instead of repeatedly slicing qs.order_by("created_at")[:take]), and advance
that cursor after every scanned message regardless of whether dry_run is set or
_postmark_from_stmsg fails. Keep the existing logging and postmark save
behavior, but make forward progress independent of writes so the command remains
bounded and resumable.

In `@src/backend/core/mda/inbound_create.py`:
- Around line 457-462: The queued inbound flow is passing the canonical mailbox
address into _create_message_from_inbound instead of the actual SMTP RCPT TO,
which breaks alias/catch-all handling. Update the task caller that builds the
inbound message context to prefer (inbound_message.envelope or
{}).get("rcpt_to") over ctx.recipient_email, and keep the fallback behavior only
if the envelope value is missing. Use the relevant inbound task / message
construction code path and _create_message_from_inbound as the anchors when
making the change.

In `@src/backend/core/mda/spam.py`:
- Around line 44-47: In the spam rule matching logic around
header_match_regex/header_match, stop lowercasing the regex pattern itself
because it changes regex semantics; only normalize the input header text or do
case-insensitive matching via the regex flags/search call. Also wrap regex
compilation/matching in the spam processing path so malformed configured rules
are caught and skipped with a warning instead of raising and aborting
processing, including the duplicated logic in the later block noted in the
review.

In `@src/backend/core/migrations/0032_inboundmessage_blob_envelope.py`:
- Around line 17-26: The migration currently imports core.models.Blob and calls
Blob.objects.create_blob, which is not migration-safe because BlobManager is not
guaranteed to be available or stable during migrations. Update the
0032_inboundmessage_blob_envelope migration to avoid live model imports and
instead use a migration-safe approach in the same function that processes
InboundMessage rows, either by inlining the minimal blob creation/dedup logic or
by using a helper that is safe to run in migrations. Ensure the logic still
creates the blob content for each inbound message without depending on
Blob.objects.

In `@src/backend/core/models.py`:
- Around line 2196-2209: The STMsg header projection in get_stmsg_headers() is
missing postmark["rcpt_to"], so divergent RCPT/BCC/alias data recorded by
_record_divergent_rcpt() gets dropped. Update the existing postmark overlay in
src/backend/core/models.py to include rcpt_to alongside sender-auth,
processing-failed, and spam, using the same postmark dict handling pattern and
preserving the structured value surfaced by the model.

In `@src/backend/core/services/ssrf.py`:
- Around line 253-260: The redirect handling in ssrf.py currently forwards
Authorization headers and POST bodies verbatim across hosts in the webhook
dispatch path, which should not happen by default. Update the redirect logic
around the SSRF/session flow to strip sensitive headers and avoid resending the
original body when the redirect target changes host, while preserving same-host
behavior only if explicitly intended. Use the existing SSRF validation and
redirect/session methods in the relevant webhook request path to locate and
adjust the cross-host redirect forwarding behavior.

In `@src/backend/core/tests/commands/test_backfill_postmark.py`:
- Around line 44-51: The dry-run test in test_dry_run_writes_nothing only
verifies that Message.postmark stays unset, so it misses the batching bug in
backfill_postmark.handle() where the same row can be reprocessed repeatedly.
Update this test to capture stdout and assert the reported scanned count is 1
for a single MessageFactory input, using call_command("backfill_postmark",
"--dry-run", stdout=...). Also add a separate case around the unreadable-blob
path to assert the errors counter is incremented as expected.

---

Outside diff comments:
In `@src/backend/core/mda/dispatch_webhooks.py`:
- Around line 1129-1138: The webhook dispatch path in dispatch_webhooks.py
re-fetches Message by message_id but never confirms it still belongs to the
current mailbox before building the body. Update the logic around the message
lookup in the dispatch flow to validate message.mailbox_id matches the
mailbox_id for this task, and skip with a warning if it does not. Keep the check
near the existing message/blob_id guard and use the same symbols (message_id,
mailbox_id, channel_id, _resolve_body_from_message) so stale or mismatched task
args cannot post another mailbox’s content.

In `@src/backend/core/mda/inbound_pipeline.py`:
- Around line 415-417: The user matching logic in inbound_pipeline should use a
case-insensitive email lookup instead of a direct email__in match, since webhook
payload casing may differ from stored User.email values and cause valid
assignees to be skipped. Update the lookup in the user-fetching path that builds
target_emails so it compares emails case-insensitively, while keeping the
existing ambiguity handling and skip behavior in place.

In `@src/backend/core/signals.py`:
- Around line 168-176: The blob GC scheduling in
schedule_inbound_message_blob_for_gc is happening directly in post_delete, which
can run before the surrounding transaction commits. Update this receiver to
defer the schedule_for_gc(instance.blob_id) call with transaction.on_commit,
matching the existing index handler pattern so the side effect only runs after a
successful commit and is skipped on rollback.

In `@src/backend/core/tests/mda/test_dispatch_webhooks.py`:
- Around line 1118-1129: The misconfiguration test for the webhook channel is
still setting auth_method in the Channel.objects.create fixture, so it does not
cover the missing-auth-method fail-closed path it is meant to verify. Update the
test setup in test_dispatch_webhooks to remove auth_method from settings for
this case, or change it to an invalid value only if you intend to exercise the
unknown-method branch, and keep the assertion aligned with the scenario being
tested.

---

Duplicate comments:
In `@docs/webhooks.md`:
- Around line 203-229: Update the webhook failure matrix in docs/webhooks.md so
the dispatcher contract is split correctly: keep the existing DROP path tied to
HTTP 2xx with {"action":"drop"}, but change the non-2xx handling in the prose
and table to distinguish DROP-worthy 4xx responses from RETRY-only transient
cases. Reference the webhook outcome table and surrounding retry description,
and revise the entries to say only 408/429/5xx, timeouts, and connection errors
are retried while 4xx responses are dropped per the dispatcher behavior.
🪄 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: 00b6ae79-1515-4954-8fe3-fd66beced24d

📥 Commits

Reviewing files that changed from the base of the PR and between e542788 and 897b35a.

⛔ Files ignored due to path filters (2)
  • src/frontend/src/features/api/gen/models/channel_create_response.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/regenerated_secret_response.ts is excluded by !**/gen/**
📒 Files selected for processing (52)
  • docs/env.md
  • docs/tiered-storage.md
  • docs/webhooks.md
  • 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/inbound/mta.py
  • src/backend/core/api/viewsets/inbound/widget.py
  • src/backend/core/enums.py
  • src/backend/core/management/commands/backfill_postmark.py
  • src/backend/core/mda/autoreply.py
  • src/backend/core/mda/dispatch_webhooks.py
  • src/backend/core/mda/inbound.py
  • src/backend/core/mda/inbound_auth.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/mda/outbound.py
  • src/backend/core/mda/selfcheck.py
  • src/backend/core/mda/spam.py
  • src/backend/core/mda/webhook_payload.py
  • src/backend/core/migrations/0032_inboundmessage_blob_envelope.py
  • src/backend/core/models.py
  • src/backend/core/services/ssrf.py
  • src/backend/core/signals.py
  • src/backend/core/templates/admin/core/channel/change_form.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_inbound_mta.py
  • src/backend/core/tests/api/test_threads_list.py
  • src/backend/core/tests/commands/test_backfill_postmark.py
  • src/backend/core/tests/mda/test_autoreply.py
  • src/backend/core/tests/mda/test_dispatch_webhooks.py
  • src/backend/core/tests/mda/test_inbound.py
  • src/backend/core/tests/mda/test_inbound_auth.py
  • src/backend/core/tests/mda/test_inbound_e2e.py
  • src/backend/core/tests/mda/test_inbound_spoofed_sender.py
  • src/backend/core/tests/mda/test_retry.py
  • src/backend/core/tests/mda/test_spam_processing.py
  • src/backend/core/tests/models/test_blob.py
  • src/backend/core/tests/models/test_message_get_parsed_data.py
  • src/backend/core/tests/services/test_ssrf.py
  • src/backend/messages/settings.py
  • 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/src/features/layouts/components/mailbox-settings/modal-compose-integration/webhook-integration-form.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/assignment-message.ts
  • src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-header.tsx

Comment thread src/backend/core/admin.py Outdated
Comment on lines +1603 to +1617
for inbound_message in queryset.filter(abandoned_at__isnull=False):
# Publish to the broker *before* clearing the terminal markers: if
# the publish fails, leave the row abandoned (with its error) so the
# operator can retry, rather than erasing the state without a re-queue.
try:
process_inbound_message_task.delay(str(inbound_message.id))
except Exception: # pylint: disable=broad-except
logging.exception(
"Failed to re-queue abandoned inbound message %s",
inbound_message.id,
)
continue
inbound_message.abandoned_at = None
inbound_message.error_message = ""
inbound_message.save(update_fields=["abandoned_at", "error_message"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Clear the abandoned marker before the worker can observe the row.

Line 1608 publishes the Celery task while abandoned_at is still set. A fast worker can hit process_inbound_message_task’s abandoned guard and exit before Lines 1615-1617 clear the marker, leaving the row live but not actually replayed.

Proposed fix
         replayed = 0
         for inbound_message in queryset.filter(abandoned_at__isnull=False):
-            # Publish to the broker *before* clearing the terminal markers: if
-            # the publish fails, leave the row abandoned (with its error) so the
-            # operator can retry, rather than erasing the state without a re-queue.
+            previous_abandoned_at = inbound_message.abandoned_at
+            previous_error_message = inbound_message.error_message
+            inbound_message.abandoned_at = None
+            inbound_message.error_message = ""
+            inbound_message.save(update_fields=["abandoned_at", "error_message"])
             try:
                 process_inbound_message_task.delay(str(inbound_message.id))
             except Exception:  # pylint: disable=broad-except
+                inbound_message.abandoned_at = previous_abandoned_at
+                inbound_message.error_message = previous_error_message
+                inbound_message.save(update_fields=["abandoned_at", "error_message"])
                 logging.exception(
                     "Failed to re-queue abandoned inbound message %s",
                     inbound_message.id,
                 )
                 continue
-            inbound_message.abandoned_at = None
-            inbound_message.error_message = ""
-            inbound_message.save(update_fields=["abandoned_at", "error_message"])
             replayed += 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.

Suggested change
for inbound_message in queryset.filter(abandoned_at__isnull=False):
# Publish to the broker *before* clearing the terminal markers: if
# the publish fails, leave the row abandoned (with its error) so the
# operator can retry, rather than erasing the state without a re-queue.
try:
process_inbound_message_task.delay(str(inbound_message.id))
except Exception: # pylint: disable=broad-except
logging.exception(
"Failed to re-queue abandoned inbound message %s",
inbound_message.id,
)
continue
inbound_message.abandoned_at = None
inbound_message.error_message = ""
inbound_message.save(update_fields=["abandoned_at", "error_message"])
for inbound_message in queryset.filter(abandoned_at__isnull=False):
previous_abandoned_at = inbound_message.abandoned_at
previous_error_message = inbound_message.error_message
inbound_message.abandoned_at = None
inbound_message.error_message = ""
inbound_message.save(update_fields=["abandoned_at", "error_message"])
try:
process_inbound_message_task.delay(str(inbound_message.id))
except Exception: # pylint: disable=broad-except
inbound_message.abandoned_at = previous_abandoned_at
inbound_message.error_message = previous_error_message
inbound_message.save(update_fields=["abandoned_at", "error_message"])
logging.exception(
"Failed to re-queue abandoned inbound message %s",
inbound_message.id,
)
continue
replayed += 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/admin.py` around lines 1603 - 1617, The re-queue flow in the
abandoned-message loop leaves abandoned_at set until after
process_inbound_message_task.delay() returns, which lets a fast worker observe
the stale abandoned state and skip processing. Update the admin re-queue logic
around the abandoned_message loop in admin.py to clear abandoned_at and
error_message before publishing the task, or otherwise ensure the worker cannot
see the old marker when process_inbound_message_task runs, while keeping the
failure path logged and leaving the row abandoned if publish fails.

Comment on lines +210 to +217
if "client_helo" in mta_metadata:
prepend_headers = [
prepend_headers.append(
(
"Received",
f"from {mta_metadata['client_helo']} ("
+ f"{mta_metadata['client_hostname']} [{mta_metadata['client_address']}]);",
),
]
)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid KeyError on partial MTA metadata.

This branch only checks client_helo, but then indexes client_hostname and client_address. Use .get() and omit the Received header unless the required parts are present.

🐛 Proposed fix
-        if "client_helo" in mta_metadata:
+        client_helo = mta_metadata.get("client_helo")
+        client_hostname = mta_metadata.get("client_hostname")
+        client_address = mta_metadata.get("client_address")
+        if client_helo and client_address:
             prepend_headers.append(
                 (
                     "Received",
-                    f"from {mta_metadata['client_helo']} ("
-                    + f"{mta_metadata['client_hostname']} [{mta_metadata['client_address']}]);",
+                    f"from {client_helo} ({client_hostname or 'unknown'} [{client_address}]);",
                 )
             )
📝 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.

Suggested change
if "client_helo" in mta_metadata:
prepend_headers = [
prepend_headers.append(
(
"Received",
f"from {mta_metadata['client_helo']} ("
+ f"{mta_metadata['client_hostname']} [{mta_metadata['client_address']}]);",
),
]
)
)
client_helo = mta_metadata.get("client_helo")
client_hostname = mta_metadata.get("client_hostname")
client_address = mta_metadata.get("client_address")
if client_helo and client_address:
prepend_headers.append(
(
"Received",
f"from {client_helo} ({client_hostname or 'unknown'} [{client_address}]);",
)
)
🤖 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/viewsets/inbound/mta.py` around lines 210 - 217, The
Received header construction in the mta metadata branch can still raise KeyError
because it only checks client_helo before indexing client_hostname and
client_address. Update the logic in the Received-header path to use safe access
via mta_metadata.get(...) for client_helo, client_hostname, and client_address,
and only append the header when all required values are present; otherwise skip
adding that header.

Comment on lines +96 to +121
while scanned < limit:
take = min(batch_size, limit - scanned)
# Processed rows leave the ``postmark__isnull=True`` set, so the next
# slice is always fresh work — no cursor/offset needed.
batch = list(qs.order_by("created_at")[:take])
if not batch:
break

for message in batch:
scanned += 1
try:
postmark = _postmark_from_stmsg(message.get_stmsg_headers())
except Exception: # pylint: disable=broad-exception-caught
# A single unreadable/corrupt blob must not abort the run.
errors += 1
logger.exception("backfill_postmark: failed to read %s", message.id)
continue

if postmark:
populated += 1
if dry_run:
continue
# ``{}`` marks the row scanned (won't be re-read) and reads back
# the same as NULL through ``get_stmsg_headers``.
message.postmark = postmark
message.save(update_fields=["postmark"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Batching invariant breaks for --dry-run and error rows — no real forward progress.

The loop assumes every processed row leaves the postmark__isnull=True set (per the comment on lines 98-99), but that's only true when a write actually happens:

  • --dry-run: line 116-117 never persists, so qs.order_by("created_at")[:take] returns the identical top batch_size rows every iteration. The command spins up to --limit times reprocessing the same small window instead of surveying the backlog, and reports a scanned count that vastly overstates what was actually examined. This is exactly what happens in test_dry_run_writes_nothing (see src/backend/core/tests/commands/test_backfill_postmark.py lines 44-51): with one message and default limit=10000, the same row gets read and parsed 10000 times.
  • Errors: line 108-112 also skips the write on exception, so a message whose blob is corrupt/unreadable will remain the oldest postmark IS NULL row forever — it will keep occupying one batch slot and re-triggering logger.exception on every future run until someone manually intervenes.

Both defeat the "bounded, resumable, run-from-cron" design goal stated in the module docstring.

🐛 Proposed fix: paginate with a stable cursor instead of relying on writes to shrink the queryset
+from django.db.models import Q
+
 ...
-        while scanned < limit:
-            take = min(batch_size, limit - scanned)
-            # Processed rows leave the ``postmark__isnull=True`` set, so the next
-            # slice is always fresh work — no cursor/offset needed.
-            batch = list(qs.order_by("created_at")[:take])
-            if not batch:
-                break
-
-            for message in batch:
+        cursor = None  # (created_at, id) of the last row examined this run
+        while scanned < limit:
+            take = min(batch_size, limit - scanned)
+            batch_qs = qs.order_by("created_at", "id")
+            if cursor is not None:
+                batch_qs = batch_qs.filter(
+                    Q(created_at__gt=cursor[0])
+                    | Q(created_at=cursor[0], id__gt=cursor[1])
+                )
+            batch = list(batch_qs[:take])
+            if not batch:
+                break
+            cursor = (batch[-1].created_at, batch[-1].id)
+
+            for message in batch:

This makes forward progress independent of whether a write happened, and also fixes tie-breaking for rows sharing the same created_at.

📝 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.

Suggested change
while scanned < limit:
take = min(batch_size, limit - scanned)
# Processed rows leave the ``postmark__isnull=True`` set, so the next
# slice is always fresh work — no cursor/offset needed.
batch = list(qs.order_by("created_at")[:take])
if not batch:
break
for message in batch:
scanned += 1
try:
postmark = _postmark_from_stmsg(message.get_stmsg_headers())
except Exception: # pylint: disable=broad-exception-caught
# A single unreadable/corrupt blob must not abort the run.
errors += 1
logger.exception("backfill_postmark: failed to read %s", message.id)
continue
if postmark:
populated += 1
if dry_run:
continue
# ``{}`` marks the row scanned (won't be re-read) and reads back
# the same as NULL through ``get_stmsg_headers``.
message.postmark = postmark
message.save(update_fields=["postmark"])
cursor = None # (created_at, id) of the last row examined this run
while scanned < limit:
take = min(batch_size, limit - scanned)
batch_qs = qs.order_by("created_at", "id")
if cursor is not None:
batch_qs = batch_qs.filter(
Q(created_at__gt=cursor[0])
| Q(created_at=cursor[0], id__gt=cursor[1])
)
batch = list(batch_qs[:take])
if not batch:
break
cursor = (batch[-1].created_at, batch[-1].id)
for message in batch:
scanned += 1
try:
postmark = _postmark_from_stmsg(message.get_stmsg_headers())
except Exception: # pylint: disable=broad-exception-caught
# A single unreadable/corrupt blob must not abort the run.
errors += 1
logger.exception("backfill_postmark: failed to read %s", message.id)
continue
if postmark:
populated += 1
if dry_run:
continue
# ``{}`` marks the row scanned (won't be re-read) and reads back
# the same as NULL through ``get_stmsg_headers``.
message.postmark = postmark
message.save(update_fields=["postmark"])
🤖 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/backfill_postmark.py` around lines 96 -
121, The current batching logic in backfill_postmark.Command.run relies on rows
leaving the postmark__isnull queryset only after a save, so dry-run and
exception paths can reprocess the same records forever. Update the loop in the
backfill_postmark management command to page with a stable cursor based on
created_at plus a tie-breaker (instead of repeatedly slicing
qs.order_by("created_at")[:take]), and advance that cursor after every scanned
message regardless of whether dry_run is set or _postmark_from_stmsg fails. Keep
the existing logging and postmark save behavior, but make forward progress
independent of writes so the command remains bounded and resumable.

Comment thread src/backend/core/mda/inbound_create.py Outdated
Comment on lines +457 to +462
# Per-recipient postmark: record the envelope RCPT TO only when it
# diverges from the MIME To/Cc (BCC / alias / catch-all) — the
# "you were BCC'd / reached via <alias>" signal. Structured, never
# in the bytes, so it can't break blob dedup across recipients.
postmark = dict(postmark or {})
_record_divergent_rcpt(postmark, recipient_email, parsed_email)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use the queued envelope RCPT, not the canonical mailbox address.

The queued inbound path passes ctx.recipient_email from str(mailbox) into _create_message_from_inbound, so alias/catch-all deliveries can record the mailbox address instead of the real SMTP rcpt_to. Prefer (inbound_message.envelope or {}).get("rcpt_to") when calling this function from the task.

🐛 Proposed fix in the task caller
+actual_recipient = (inbound_message.envelope or {}).get("rcpt_to") or ctx.recipient_email
 inbound_msg = _create_message_from_inbound(
-    recipient_email=ctx.recipient_email,
+    recipient_email=actual_recipient,
     parsed_email=ctx.parsed_email,
     raw_data=ctx.raw_data,
🤖 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_create.py` around lines 457 - 462, The queued
inbound flow is passing the canonical mailbox address into
_create_message_from_inbound instead of the actual SMTP RCPT TO, which breaks
alias/catch-all handling. Update the task caller that builds the inbound message
context to prefer (inbound_message.envelope or {}).get("rcpt_to") over
ctx.recipient_email, and keep the fallback behavior only if the envelope value
is missing. Use the relevant inbound task / message construction code path and
_create_message_from_inbound as the anchors when making the change.

Comment thread src/backend/core/mda/spam.py Outdated
Comment on lines +44 to +47
key, value = header_match.split(":", 1)
key = key.lower().strip()
value = value.lower().strip()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve regex semantics and handle invalid regex rules.

Lowercasing header_match_regex changes regex tokens and character classes before matching. Also, a malformed configured regex currently raises and can abort spam processing instead of skipping the bad rule.

Suggested fix
         key, value = header_match.split(":", 1)
         key = key.lower().strip()
-        value = value.lower().strip()
+        value = value.strip()
@@
         header_value = (
             found_value.lower().strip()
             if isinstance(found_value, str)
             else str(found_value).lower().strip()
         )
         if rule.get("header_match"):
-            is_match = header_value == value
+            is_match = header_value == value.lower()
         else:  # header_match_regex
-            is_match = re.fullmatch(value, header_value) is not None
+            try:
+                is_match = re.fullmatch(value, header_value, re.IGNORECASE) is not None
+            except re.error:
+                logger.warning("Invalid header_match_regex in spam rule #%d", idx)
+                continue

Also applies to: 88-91

🤖 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/spam.py` around lines 44 - 47, In the spam rule matching
logic around header_match_regex/header_match, stop lowercasing the regex pattern
itself because it changes regex semantics; only normalize the input header text
or do case-insensitive matching via the regex flags/search call. Also wrap regex
compilation/matching in the spam processing path so malformed configured rules
are caught and skipped with a warning instead of raising and aborting
processing, including the duplicated logic in the later block noted in the
review.

Comment on lines +17 to +26
# pylint: disable=import-outside-toplevel
from core.models import Blob

InboundMessage = apps.get_model("core", "inboundmessage")
rows = InboundMessage.objects.filter(blob__isnull=True).exclude(raw_data=None)
for inbound in rows.iterator():
raw = bytes(inbound.raw_data) if inbound.raw_data else b""
if not raw:
continue
blob = Blob.objects.create_blob(content=raw, content_type="message/rfc822")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether Blob's manager is migration-safe and find current-model imports in migrations.
rg -n "class .*Blob.*Manager|use_in_migrations|def create_blob|from core.models import Blob|core.models import" src/backend/core

Repository: suitenumerique/messages

Length of output: 5962


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect BlobManager, Blob, and the migration in question.
sed -n '2360,2465p' src/backend/core/models.py
printf '\n---- MIGRATION ----\n'
sed -n '1,120p' src/backend/core/migrations/0032_inboundmessage_blob_envelope.py

printf '\n---- SEARCH use_in_migrations / migrations-safe manager ----\n'
rg -n "use_in_migrations|class BlobManager|objects = BlobManager|create_blob\(" src/backend/core/models.py src/backend/core/migrations

Repository: suitenumerique/messages

Length of output: 7964


Avoid importing core.models.Blob in this migration
BlobManager isn’t marked use_in_migrations, so Blob.objects.create_blob() still depends on the live model/manager shape. Use a migration-safe helper or inline the minimal blob insert/dedup logic here.

🤖 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/migrations/0032_inboundmessage_blob_envelope.py` around
lines 17 - 26, The migration currently imports core.models.Blob and calls
Blob.objects.create_blob, which is not migration-safe because BlobManager is not
guaranteed to be available or stable during migrations. Update the
0032_inboundmessage_blob_envelope migration to avoid live model imports and
instead use a migration-safe approach in the same function that processes
InboundMessage rows, either by inlining the minimal blob creation/dedup logic or
by using a helper that is safe to run in migrations. Ensure the logic still
creates the blob content for each inbound message without depending on
Blob.objects.

Comment on lines +2196 to +2209
postmark = self.postmark or {}
if postmark.get("auth"):
# ``sender-auth`` carries the verdict value verbatim ("none"/"fail"),
# matching the legacy header.
result["sender-auth"] = postmark["auth"]
if postmark.get("processing"):
# ``processing-failed`` is a boolean flag; normalise to the legacy
# header value ("true") regardless of the structured reason.
result["processing-failed"] = "true"
if postmark.get("spam"):
# rspamd flagged probable spam but below the Junk threshold — the
# UI renders a "suspected spam" marker while the message stays in
# the inbox.
result["spam"] = postmark["spam"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Project postmark["rcpt_to"] into STMsg headers.

Line 2094 documents rcpt_to as structured postmark data surfaced through get_stmsg_headers(), but this overlay only emits sender-auth, processing-failed, and spam. Divergent RCPT/BCC/alias information recorded by _record_divergent_rcpt() is therefore dropped at the model boundary.

Proposed fix
         if postmark.get("spam"):
             # rspamd flagged probable spam but below the Junk threshold — the
             # UI renders a "suspected spam" marker while the message stays in
             # the inbox.
             result["spam"] = postmark["spam"]
+        if postmark.get("rcpt_to"):
+            result["rcpt-to"] = postmark["rcpt_to"]
         return 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.

Suggested change
postmark = self.postmark or {}
if postmark.get("auth"):
# ``sender-auth`` carries the verdict value verbatim ("none"/"fail"),
# matching the legacy header.
result["sender-auth"] = postmark["auth"]
if postmark.get("processing"):
# ``processing-failed`` is a boolean flag; normalise to the legacy
# header value ("true") regardless of the structured reason.
result["processing-failed"] = "true"
if postmark.get("spam"):
# rspamd flagged probable spam but below the Junk threshold — the
# UI renders a "suspected spam" marker while the message stays in
# the inbox.
result["spam"] = postmark["spam"]
postmark = self.postmark or {}
if postmark.get("auth"):
# ``sender-auth`` carries the verdict value verbatim ("none"/"fail"),
# matching the legacy header.
result["sender-auth"] = postmark["auth"]
if postmark.get("processing"):
# ``processing-failed`` is a boolean flag; normalise to the legacy
# header value ("true") regardless of the structured reason.
result["processing-failed"] = "true"
if postmark.get("spam"):
# rspamd flagged probable spam but below the Junk threshold — the
# UI renders a "suspected spam" marker while the message stays in
# the inbox.
result["spam"] = postmark["spam"]
if postmark.get("rcpt_to"):
result["rcpt-to"] = postmark["rcpt_to"]
🤖 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 2196 - 2209, The STMsg header
projection in get_stmsg_headers() is missing postmark["rcpt_to"], so divergent
RCPT/BCC/alias data recorded by _record_divergent_rcpt() gets dropped. Update
the existing postmark overlay in src/backend/core/models.py to include rcpt_to
alongside sender-auth, processing-failed, and spam, using the same postmark dict
handling pattern and preserving the structured value surfaced by the model.

Comment on lines +253 to +260
``kwargs`` (including any ``Authorization`` header and the POST body)
are re-sent verbatim on every hop, so a cross-host redirect forwards
the caller's credentials + payload to the redirect target. This is
intentional for webhook delivery — a receiver that 3xx-redirects (LB /
canonicaliser) must still get the signed body and its auth — and safe
because every hop is SSRF-validated to a public host and an HTTPS→HTTP
downgrade is refused. Callers that must not leak credentials across
hosts should not send a bearer credential through this session.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not forward webhook credentials and email bodies across hosts by default.

Line 253 documents that Authorization and the POST body are re-sent verbatim on every redirect. For webhook dispatch, that means a receiver-side open redirect can exfiltrate the bearer token plus full email payload to any public host; SSRF validation does not mitigate that confidentiality leak.

Suggested fix
             next_url = urljoin(current_url, location)
+            current_parsed = urlparse(current_url)
+            next_parsed = urlparse(next_url)
             # Refuse an HTTPS→HTTP downgrade: a redirect must not silently drop
             # the connection from TLS to cleartext. Same-scheme or an HTTP→HTTPS
             # upgrade is fine.
             if (
-                urlparse(current_url).scheme == "https"
-                and urlparse(next_url).scheme == "http"
+                current_parsed.scheme == "https"
+                and next_parsed.scheme == "http"
             ):
                 response.close()
                 raise SSRFValidationError(
                     "Refusing to follow HTTPS→HTTP redirect downgrade"
                 )
+            if current_parsed.hostname != next_parsed.hostname and (
+                kwargs.get("data") is not None
+                or kwargs.get("json") is not None
+                or (kwargs.get("headers") or {}).get("Authorization")
+            ):
+                response.close()
+                raise SSRFValidationError(
+                    "Refusing to forward credentials/body across hosts"
+                )
             response.close()
             current_url = next_url

Also applies to: 275-277

🤖 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/ssrf.py` around lines 253 - 260, The redirect
handling in ssrf.py currently forwards Authorization headers and POST bodies
verbatim across hosts in the webhook dispatch path, which should not happen by
default. Update the redirect logic around the SSRF/session flow to strip
sensitive headers and avoid resending the original body when the redirect target
changes host, while preserving same-host behavior only if explicitly intended.
Use the existing SSRF validation and redirect/session methods in the relevant
webhook request path to locate and adjust the cross-host redirect forwarding
behavior.

Comment on lines +44 to +51
def test_dry_run_writes_nothing(self):
"""--dry-run reports but does not persist."""
message = factories.MessageFactory(raw_mime=_WITH_VERDICTS)

call_command("backfill_postmark", "--dry-run", stdout=StringIO())

message.refresh_from_db()
assert message.postmark is None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Coverage gap masks the dry-run batching bug.

This test only checks message.postmark is None; it doesn't assert on the command's reported scanned count or iteration behavior, so it passes even though (per the root-cause issue flagged on backfill_postmark.py's handle()) the command actually reprocesses this single row up to --limit (10000) times in dry-run mode. Once the batching fix is applied, consider also capturing stdout and asserting scanned == 1 here to guard against regressions, and adding a case for the errors counter (a message whose blob is unreadable).

🤖 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/commands/test_backfill_postmark.py` around lines 44 -
51, The dry-run test in test_dry_run_writes_nothing only verifies that
Message.postmark stays unset, so it misses the batching bug in
backfill_postmark.handle() where the same row can be reprocessed repeatedly.
Update this test to capture stdout and assert the reported scanned count is 1
for a single MessageFactory input, using call_command("backfill_postmark",
"--dry-run", stdout=...). Also add a separate case around the unreadable-blob
path to assert the errors counter is incremented as expected.

@sylvinus
sylvinus merged commit 26d8411 into development Jul 1, 2026
This was referenced Jul 2, 2026
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.

3 participants