Skip to content

feat: add email forwarding infrastructure#362

Closed
KalebJG wants to merge 2 commits intousesend:mainfrom
KalebJG:feat/email-forwarding
Closed

feat: add email forwarding infrastructure#362
KalebJG wants to merge 2 commits intousesend:mainfrom
KalebJG:feat/email-forwarding

Conversation

@KalebJG
Copy link

@KalebJG KalebJG commented Feb 27, 2026

feat: Add email forwarding for inbound emails

Closes #113

Summary

Add inbound email forwarding so users can forward incoming mail from their verified domains to any address (e.g. [email protected][email protected]).

What's Included

  • Dashboard UI — Forwarding tab on the domain page with inbound toggle, rule management, and inbound email log
  • TRPC APIlistRules, createRule, updateRule, deleteRule, enableInbound, disableInbound, listInboundEmails
  • REST APIGET/POST /v1/domains/:id/forwarding-rules, DELETE /v1/domains/:id/forwarding-rules/:ruleId, PUT /v1/domains/:id/inbound
  • Inbound pipeline — SNS callback route, BullMQ worker, header rewriting, loop prevention (max 3 hops), S3 fallback for large emails
  • Bounce handling — Forwarded email bounces do not add destination addresses to the suppression list

Architecture

  • SES Receipt Rules (v1 API) for receiving
  • S3 for raw email storage, SNS for notifications
  • BullMQ for async processing
  • Uses existing Email model and send pipeline for forwarded messages

Migration Required

After pulling this branch, run:
ash
pnpm db:migrate-dev


Summary by cubic

Adds inbound email forwarding so verified domains can forward specific addresses to any destination, with a full SES/SNS/S3 pipeline, dashboard UI, and APIs. Includes safe bounce handling and background cleanup.

  • New Features

    • Forwarding tab in the domain page: enable inbound, manage per-address rules, view inbound email log.
    • TRPC endpoints: list/create/update/delete rules; enable/disable inbound; list inbound emails.
    • REST endpoints: GET/POST/DELETE /v1/domains/:id/forwarding-rules, PUT /v1/domains/:id/inbound.
    • Inbound pipeline: SNS callback route (handles subscription confirmation), BullMQ worker, header rewrite, loop prevention (max 3 hops), S3 fetch for raw/large emails.
    • SES receipt rule management: create/delete per-domain rule in supported receiving regions.
    • Cleanup job: deletes inbound email records after 30 days.
  • Migration

    • Run: pnpm db:migrate-dev.
    • Set env: INBOUND_S3_BUCKET, INBOUND_SNS_TOPIC_ARN, INBOUND_SES_RULE_SET.
    • In AWS SES (receiving region): create/attach a receipt rule set that stores raw emails to S3 and publishes to your SNS topic.
    • Subscribe your SNS topic to POST /api/inbound_callback.

Written for commit 8d7251e. Summary will update on new commits.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added email forwarding rules management for domains
    • Added inbound email receiving toggle and configuration
    • Added forwarding rules creation, editing, and deletion in domain settings
    • Added inbound email logs with status tracking
    • Added MX record display for DNS setup
    • Added public API endpoints for forwarding rule management

@vercel
Copy link

vercel bot commented Feb 27, 2026

Someone is attempting to deploy a commit to the kmkoushik's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 27, 2026

Walkthrough

This PR implements inbound email forwarding functionality for Unsend. It adds database models for forwarding rules and inbound emails, creates API endpoints and TRPC procedures to manage forwarding configurations, implements an SNS callback handler for receiving inbound emails from AWS SES, introduces job workers for email processing and cleanup, adds UI components for managing forwarding rules and viewing inbound email logs, and includes AWS SES and S3 integration utilities for receipt rule management and email retrieval. The implementation tracks forwarded emails through status transitions, rewrites email headers, and integrates with the existing email pipeline.

Possibly related PRs

Suggested labels

codex

Suggested reviewers

  • KMKoushik
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive The PR implements core forwarding functionality (#113: forwarding rules, email routing) but diverges from the linked issue's primary request for webhook/JSON conversion to configured endpoints. Clarify whether webhook delivery is a separate future task or intended for this PR, as the issue specifically requests JSON conversion and webhook posting but the implementation forwards emails through the email pipeline instead.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add email forwarding infrastructure' accurately describes the main change in the PR, which adds comprehensive email forwarding functionality including UI, APIs, and backend infrastructure.
Out of Scope Changes check ✅ Passed All changes are directly related to email forwarding infrastructure. Design system documentation and planning docs are scope-adjacent but support the implementation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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 and usage tips.

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 413f5c329a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}

const hopCount = getHopCount(parsed);
if (hopCount > MAX_FORWARDING_HOPS) {

Choose a reason for hiding this comment

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

P2 Badge Block forwarding when hop count hits the limit

The loop guard in processInboundEmail uses hopCount > MAX_FORWARDING_HOPS, which still forwards messages that are already at the configured cap (3 hops) and then increments the header to 4 on the outgoing message. That means loop prevention allows one extra pass, so a message already at the limit is not dropped as intended. Use >= here so emails at the max hop count are rejected before forwarding.

Useful? React with 👍 / 👎.

Comment on lines +90 to +94
const inboundEmail = await db.inboundEmail.create({
data: {
teamId: domain.teamId,
domainId: domain.id,
from: fromAddress ?? "unknown",

Choose a reason for hiding this comment

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

P1 Badge Deduplicate inbound callbacks before creating queue jobs

This handler always creates a new InboundEmail row and enqueues a job keyed by that new CUID, but it never uses a stable SES message identifier (such as mail.messageId) to enforce idempotency. Because SNS delivery is at-least-once and retries on 5xx/timeouts, the same inbound message can be delivered more than once and get forwarded multiple times to the destination address. Add a unique dedupe key from the SNS/SES payload and short-circuit duplicate notifications before insert/queue.

Useful? React with 👍 / 👎.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (9)
apps/web/src/server/aws/s3-inbound.ts (1)

15-27: Consider caching S3Client instances per region.

A new S3Client is instantiated on every fetchRawEmail call. For high-volume inbound processing, consider caching clients per region to reduce overhead.

♻️ Optional: Cache S3Client per region
+const s3ClientCache = new Map<string, S3Client>();
+
 function getS3Client(region: string) {
+  const cached = s3ClientCache.get(region);
+  if (cached) {
+    return cached;
+  }
+  const client = new S3Client({
-  return new S3Client({
     region,
     credentials: {
       accessKeyId: env.AWS_ACCESS_KEY,
       secretAccessKey: env.AWS_SECRET_KEY,
     },
   });
+  s3ClientCache.set(region, client);
+  return client;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/aws/s3-inbound.ts` around lines 15 - 27, fetchRawEmail
currently calls getS3Client which creates a new S3Client on every invocation;
change this to reuse S3Client instances per region by adding a module-level
cache (e.g., a Map keyed by region) and updating getS3Client (or create a
getOrCreateS3Client) to return the cached client if present, otherwise
instantiate S3Client, store it in the cache, and return it; ensure the cache
keys on the region string and preserve the existing getS3Client/fetchRawEmail
signatures so callers are unaffected.
apps/web/prisma/schema.prisma (1)

541-562: Consider adding an index on teamId for InboundEmail.

The InboundEmail model has indexes on [domainId, status] and createdAt, but team-scoped queries (e.g., listing all inbound emails for a team) would benefit from an index on teamId. The EmailForwardingRule model includes @@index([teamId]) for this purpose.

♻️ Add teamId index
   @@index([domainId, status])
   @@index([createdAt(sort: Desc)])
+  @@index([teamId])
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/prisma/schema.prisma` around lines 541 - 562, Add an index on teamId
in the InboundEmail Prisma model to speed up team-scoped queries: update the
InboundEmail model (where fields like id, teamId, domainId, etc. are defined) to
include an @@index([teamId]) declaration alongside the existing
@@index([domainId, status]) and @@index([createdAt(sort: Desc)]) entries so
queries filtering by teamId use the new index.
docs/plans/2026-02-25-email-forwarding-design.md (1)

41-41: Add language specifier to fenced code block.

The ASCII diagram code block is missing a language specifier. Use text or leave empty with proper configuration to satisfy markdownlint.

-```
+```text
 External Sender
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/plans/2026-02-25-email-forwarding-design.md` at line 41, The fenced code
block containing the ASCII diagram line "External Sender" lacks a language
specifier; update that block to use a language tag such as text (e.g., change
the opening triple backticks to ```text) so markdownlint is satisfied and the
diagram is treated as plain text.
apps/web/src/app/api/inbound_callback/route.unit.test.ts (1)

265-278: Clean up fetch spy after test.

The fetch spy is not restored after the test, which could affect other tests if they rely on the global fetch. Consider using mockRestore() in a cleanup or wrapping with vi.restoreAllMocks() in afterEach.

♻️ Suggested fix
   it("confirms SNS subscription with awaited fetch", async () => {
     const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
       new Response("ok", { status: 200 })
     );

     const res = await POST(makeSubscriptionRequest());
     const body = await res.json();

     expect(body.data).toBe("Subscription confirmed");
     expect(fetchSpy).toHaveBeenCalledWith(
       "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription",
       { method: "GET" }
     );
+
+    fetchSpy.mockRestore();
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/api/inbound_callback/route.unit.test.ts` around lines 265 -
278, The test creates a global fetch spy (fetchSpy) but never restores it; add
cleanup to restore the mock after the test suite so other tests aren't
affected—either call fetchSpy.mockRestore() at the end of this test or add an
afterEach hook that calls vi.restoreAllMocks(); locate the spy in the "confirms
SNS subscription with awaited fetch" test that wraps globalThis.fetch and
restore it after calling POST(makeSubscriptionRequest()) (or globally in
afterEach) to ensure the fetch mock is removed.
apps/web/src/server/public-api/api/domains/list-forwarding-rules.ts (1)

59-65: Response shape may include extra fields.

The spread ...r includes all fields from the database record (id, teamId, domainId, sourceAddress, destinationAddress, enabled, createdAt, updatedAt), but the schema only defines a subset. While extra fields are typically stripped by Zod validation in OpenAPI, consider explicitly mapping only the fields defined in the schema for clarity and to avoid leaking internal fields like teamId and domainId.

♻️ Suggested explicit mapping
     return c.json({
       data: rules.map((r) => ({
-        ...r,
+        id: r.id,
+        sourceAddress: r.sourceAddress,
+        destinationAddress: r.destinationAddress,
+        enabled: r.enabled,
         createdAt: r.createdAt.toISOString(),
         updatedAt: r.updatedAt.toISOString(),
       })),
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/public-api/api/domains/list-forwarding-rules.ts` around
lines 59 - 65, The response currently spreads the entire DB record (using ...r)
which may leak internal fields (teamId, domainId); update the mapping in
list-forwarding-rules.ts where rules.map is used to explicitly return only the
schema-defined fields (e.g., id, sourceAddress, destinationAddress, enabled) and
convert timestamps to strings for createdAt and updatedAt instead of spreading
r, so the JSON response contains only those allowed properties.
apps/web/src/server/public-api/api/domains/update-domain-inbound.ts (1)

89-100: Consider atomicity of SES rule creation and DB update.

If createReceiptRule succeeds but the subsequent db.domain.update fails, an orphan SES receipt rule will exist. This matches the TRPC implementation behavior, but consider wrapping in a try-catch to delete the SES rule on DB failure, or document this as a known edge case for operations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/public-api/api/domains/update-domain-inbound.ts` around
lines 89 - 100, The SES receipt rule creation and DB update must be made atomic:
after calling createReceiptRule(...) which returns ruleName, wrap the subsequent
db.domain.update(...) in a try-catch and if the DB update throws, call the
corresponding SES cleanup function (e.g., deleteReceiptRule or an equivalent SES
API wrapper) to remove the created ruleName from the rule set (passing
domain.name, domain.region, ruleSetName and ruleName), then rethrow or return
the error; ensure you reference createReceiptRule, ruleName and db.domain.update
when adding the try-catch and cleanup call.
apps/web/src/server/aws/ses-receipt-rules.ts (1)

17-25: Consider using AWS_SES_ENDPOINT for consistency with other SES clients.

Other parts of the codebase may use env.AWS_SES_ENDPOINT for local development or testing. If receipt rule operations should be testable locally (e.g., with LocalStack), consider adding the endpoint configuration.

♻️ Suggested fix
 function getSesV1Client(region: string) {
   return new SESClient({
     region,
     credentials: {
       accessKeyId: env.AWS_ACCESS_KEY,
       secretAccessKey: env.AWS_SECRET_KEY,
     },
+    ...(env.AWS_SES_ENDPOINT && { endpoint: env.AWS_SES_ENDPOINT }),
   });
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/aws/ses-receipt-rules.ts` around lines 17 - 25,
getSesV1Client currently constructs an SESClient with region and credentials
only, which prevents using a custom endpoint (e.g., LocalStack) via
env.AWS_SES_ENDPOINT; update getSesV1Client to read env.AWS_SES_ENDPOINT and,
when present, pass an endpoint option into the SESClient configuration so
receipt rule operations can target a local/test SES endpoint while keeping
region/credentials as-is.
apps/web/src/server/api/routers/forwarding.trpc.test.ts (1)

66-68: Minor: Explicit block body for clarity.

The inner forEach callback implicitly returns the result of mockReset(). While harmless, using braces makes the intent clearer and silences the static analysis warning.

♻️ Suggested fix
   beforeEach(() => {
     Object.values(mockDb).forEach((model) => {
-      Object.values(model).forEach((fn) => (fn as ReturnType<typeof vi.fn>).mockReset());
+      Object.values(model).forEach((fn) => {
+        (fn as ReturnType<typeof vi.fn>).mockReset();
+      });
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/api/routers/forwarding.trpc.test.ts` around lines 66 -
68, Update the inner forEach callback so it uses an explicit block body instead
of an implicit return: in the code iterating
Object.values(mockDb).forEach((model) => { Object.values(model).forEach((fn) =>
{ (fn as ReturnType<typeof vi.fn>).mockReset(); }); }); — specifically change
the inner arrow function for the model members to use braces and call
mockReset() as a statement (no implicit return) to make the intent clear and
remove the lint warning.
apps/web/src/app/(dashboard)/domains/[domainId]/forwarding-tab.tsx (1)

305-319: Consider disabling rule actions during mutations.

The rule toggle switch and delete button don't disable during their respective mutations, which could allow rapid double-clicks causing duplicate requests. This is a minor UX polish.

♻️ Suggested improvement
                     <div className="flex items-center justify-end gap-2">
                       <Switch
                         checked={rule.enabled}
                         onCheckedChange={() =>
                           handleToggleRule(rule.id, rule.enabled)
                         }
                         className="data-[state=checked]:bg-success scale-75"
+                        disabled={updateRule.isPending}
                       />
                       <Button
                         variant="ghost"
                         size="sm"
                         onClick={() => handleDeleteRule(rule.id)}
                         className="text-destructive hover:text-destructive"
+                        disabled={deleteRule.isPending}
                       >
                         <Trash2 className="h-4 w-4" />
                       </Button>
                     </div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/`(dashboard)/domains/[domainId]/forwarding-tab.tsx around
lines 305 - 319, The Switch and delete Button must be disabled while their
respective mutations are in flight to prevent duplicate requests; update the
toggle and delete logic (handleToggleRule and handleDeleteRule) to set and clear
a per-rule mutation state (e.g., currentTogglingRuleId/currentDeletingRuleId or
a map of loading rule IDs) and pass a disabled prop to the Switch and Button
(and guard onCheckedChange/onClick to no-op when that rule is loading).
Specifically, modify the Switch and Button rendering in forwarding-tab.tsx to
derive disabled from those mutation flags for the given rule.id and ensure
handleToggleRule/handleDeleteRule check the flag before issuing the mutation and
clear it on success/error.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/app/api/inbound_callback/route.ts`:
- Around line 114-120: The logger.info call is currently emitting raw PII
(fromAddress and recipientAddress); change it to log redacted or non-PII
variants instead by replacing the from/to values with either a hashed value or
domain-only value (e.g., use a helper like redactEmail(email) or
extractDomain(email) or hashEmail(email) and call that inside the logger.info).
Update the logger.info invocation that references inboundEmail.id, domain.id,
fromAddress, recipientAddress to pass the redacted/hash/domain-only results (and
add the helper function in the same module or a util file), so no full email
addresses are logged at info level.
- Around line 90-112: The current logic creates an InboundEmail with
db.inboundEmail.create which allows duplicates when SNS retries; add a nullable
sesMessageId field and a unique constraint on (domainId, sesMessageId) in the
Prisma model, then replace the create call with an upsert (using
db.inboundEmail.upsert or equivalent) keyed on the unique constraint so creating
is idempotent using sesMessageId (populate from snsMessage / messageId), and
ensure the same inboundEmail.id is used when calling inboundEmailQueue.add so
retries reuse the existing row instead of inserting duplicates.
- Around line 159-170: Replace the weak TopicArn check in checkEventValidity
with full AWS SNS signature verification: validate SignatureVersion, download
the SigningCertURL over HTTPS with proper hostname/cert checks, build the
canonical string from message fields per AWS SNS docs, and verify the Signature
using the downloaded certificate (use a crypto/X509 verify flow) while still
allowing a bypass in development; also add an AbortController/timeout to the
fetch of message.SubscribeURL to avoid hangs and redact or avoid logging plain
email addresses (mask local-part or remove PII) where emails are currently
logged to prevent exposing sensitive data.
- Around line 145-147: Replace the bare fetch in the SNS subscription
confirmation block with a timed fetch using an AbortController: create an
AbortController, set a timeout (use setTimeout) to call controller.abort(), pass
controller.signal into fetch(message.SubscribeURL!), await the response and
validate response.ok (or status 200–299) before calling logger.info; clear the
timeout on success and handle AbortError/non-OK responses by logging or throwing
so the handler does not falsely log "Confirmed inbound SNS subscription" for
failed or hung requests (affecting the fetch call and logger.info referenced in
route.ts).

In `@apps/web/src/server/aws/ses-receipt-rules.ts`:
- Around line 67-73: The SES client calls that check
response.$metadata.httpStatusCode (around the client.send(command) calls) must
be wrapped in try-catch so API errors thrown by AWS SDK v3 are caught; modify
the blocks that call client.send(command) (and where response is inspected) to
catch exceptions, detect SES errors by checking instanceof SESServiceException
or error.name, log the error and include error.$metadata.httpStatusCode only as
supplemental info, and rethrow or return a controlled error instead of relying
on a non-2xx response check; update both places that currently inspect
response.$metadata.httpStatusCode to follow this try-catch pattern.

In `@apps/web/src/server/public-api/api/domains/create-forwarding-rule.ts`:
- Around line 83-88: The REST handler in create-forwarding-rule.ts throws an
UnsendApiError with code "BAD_REQUEST" when a duplicate forwarding rule is found
(variable existing); change this to use code "CONFLICT" to match the TRPC
createRule behavior—update the throw to new UnsendApiError({ code: "CONFLICT",
message: `A forwarding rule for ${sourceAddress}@${domain.name} already exists`
}) so both API surfaces return HTTP 409 for duplicates.

In `@apps/web/src/server/service/inbound-email-service.ts`:
- Around line 64-76: The forwarding-loop check currently uses `if (hopCount >
MAX_FORWARDING_HOPS)` which is off-by-one for a 3-hop limit; change the
condition to `if (hopCount >= MAX_FORWARDING_HOPS)` (in the block using
`getHopCount(parsed)`, `hopCount`, and `MAX_FORWARDING_HOPS`) so emails hitting
the maximum allowed hops are treated as a loop, keeping the existing
`logger.warn` and `updateInboundEmailStatus(inboundEmailId,
InboundEmailStatus.FAILED, ...)` behavior intact.
- Around line 122-127: The rewritten From header construction in
inbound-email-service.ts uses originalFromName directly which allows double
quotes or control characters to break the header; sanitize originalFromName
before building rewrittenFrom by removing or escaping double quotes and
stripping control characters (CR, LF, non-printables) and use the sanitized
value in the `"${...} via Unsend" <...>` template (e.g., add a helper like
sanitizeDisplayName and call it where originalFromName is referenced to produce
sanitizedFromName, then use sanitizedFromName when creating rewrittenFrom).
- Line 191: The assignment to const snsData = JSON.parse(snsMessage) can throw
on invalid JSON; wrap the JSON.parse(snsMessage) call in a try-catch inside the
same function (the inbound email processing function in
inbound-email-service.ts) to catch SyntaxError, log a clear error including the
raw snsMessage and error.message via your logger, and then handle the failure
path (return an appropriate error response or throw a wrapped/typed error)
instead of letting the raw exception propagate.

---

Nitpick comments:
In `@apps/web/prisma/schema.prisma`:
- Around line 541-562: Add an index on teamId in the InboundEmail Prisma model
to speed up team-scoped queries: update the InboundEmail model (where fields
like id, teamId, domainId, etc. are defined) to include an @@index([teamId])
declaration alongside the existing @@index([domainId, status]) and
@@index([createdAt(sort: Desc)]) entries so queries filtering by teamId use the
new index.

In `@apps/web/src/app/`(dashboard)/domains/[domainId]/forwarding-tab.tsx:
- Around line 305-319: The Switch and delete Button must be disabled while their
respective mutations are in flight to prevent duplicate requests; update the
toggle and delete logic (handleToggleRule and handleDeleteRule) to set and clear
a per-rule mutation state (e.g., currentTogglingRuleId/currentDeletingRuleId or
a map of loading rule IDs) and pass a disabled prop to the Switch and Button
(and guard onCheckedChange/onClick to no-op when that rule is loading).
Specifically, modify the Switch and Button rendering in forwarding-tab.tsx to
derive disabled from those mutation flags for the given rule.id and ensure
handleToggleRule/handleDeleteRule check the flag before issuing the mutation and
clear it on success/error.

In `@apps/web/src/app/api/inbound_callback/route.unit.test.ts`:
- Around line 265-278: The test creates a global fetch spy (fetchSpy) but never
restores it; add cleanup to restore the mock after the test suite so other tests
aren't affected—either call fetchSpy.mockRestore() at the end of this test or
add an afterEach hook that calls vi.restoreAllMocks(); locate the spy in the
"confirms SNS subscription with awaited fetch" test that wraps globalThis.fetch
and restore it after calling POST(makeSubscriptionRequest()) (or globally in
afterEach) to ensure the fetch mock is removed.

In `@apps/web/src/server/api/routers/forwarding.trpc.test.ts`:
- Around line 66-68: Update the inner forEach callback so it uses an explicit
block body instead of an implicit return: in the code iterating
Object.values(mockDb).forEach((model) => { Object.values(model).forEach((fn) =>
{ (fn as ReturnType<typeof vi.fn>).mockReset(); }); }); — specifically change
the inner arrow function for the model members to use braces and call
mockReset() as a statement (no implicit return) to make the intent clear and
remove the lint warning.

In `@apps/web/src/server/aws/s3-inbound.ts`:
- Around line 15-27: fetchRawEmail currently calls getS3Client which creates a
new S3Client on every invocation; change this to reuse S3Client instances per
region by adding a module-level cache (e.g., a Map keyed by region) and updating
getS3Client (or create a getOrCreateS3Client) to return the cached client if
present, otherwise instantiate S3Client, store it in the cache, and return it;
ensure the cache keys on the region string and preserve the existing
getS3Client/fetchRawEmail signatures so callers are unaffected.

In `@apps/web/src/server/aws/ses-receipt-rules.ts`:
- Around line 17-25: getSesV1Client currently constructs an SESClient with
region and credentials only, which prevents using a custom endpoint (e.g.,
LocalStack) via env.AWS_SES_ENDPOINT; update getSesV1Client to read
env.AWS_SES_ENDPOINT and, when present, pass an endpoint option into the
SESClient configuration so receipt rule operations can target a local/test SES
endpoint while keeping region/credentials as-is.

In `@apps/web/src/server/public-api/api/domains/list-forwarding-rules.ts`:
- Around line 59-65: The response currently spreads the entire DB record (using
...r) which may leak internal fields (teamId, domainId); update the mapping in
list-forwarding-rules.ts where rules.map is used to explicitly return only the
schema-defined fields (e.g., id, sourceAddress, destinationAddress, enabled) and
convert timestamps to strings for createdAt and updatedAt instead of spreading
r, so the JSON response contains only those allowed properties.

In `@apps/web/src/server/public-api/api/domains/update-domain-inbound.ts`:
- Around line 89-100: The SES receipt rule creation and DB update must be made
atomic: after calling createReceiptRule(...) which returns ruleName, wrap the
subsequent db.domain.update(...) in a try-catch and if the DB update throws,
call the corresponding SES cleanup function (e.g., deleteReceiptRule or an
equivalent SES API wrapper) to remove the created ruleName from the rule set
(passing domain.name, domain.region, ruleSetName and ruleName), then rethrow or
return the error; ensure you reference createReceiptRule, ruleName and
db.domain.update when adding the try-catch and cleanup call.

In `@docs/plans/2026-02-25-email-forwarding-design.md`:
- Line 41: The fenced code block containing the ASCII diagram line "External
Sender" lacks a language specifier; update that block to use a language tag such
as text (e.g., change the opening triple backticks to ```text) so markdownlint
is satisfied and the diagram is treated as plain text.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b2ed09e and 413f5c3.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (26)
  • .cursor/rules/design-system.mdc
  • apps/web/package.json
  • apps/web/prisma/schema.prisma
  • apps/web/src/app/(dashboard)/domains/[domainId]/forwarding-tab.tsx
  • apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx
  • apps/web/src/app/api/inbound_callback/route.ts
  • apps/web/src/app/api/inbound_callback/route.unit.test.ts
  • apps/web/src/env.js
  • apps/web/src/server/api/root.ts
  • apps/web/src/server/api/routers/forwarding.trpc.test.ts
  • apps/web/src/server/api/routers/forwarding.ts
  • apps/web/src/server/aws/s3-inbound.ts
  • apps/web/src/server/aws/ses-receipt-rules.ts
  • apps/web/src/server/jobs/inbound-email-cleanup-job.ts
  • apps/web/src/server/jobs/inbound-email-worker.ts
  • apps/web/src/server/public-api/api/domains/create-forwarding-rule.ts
  • apps/web/src/server/public-api/api/domains/delete-forwarding-rule.ts
  • apps/web/src/server/public-api/api/domains/list-forwarding-rules.ts
  • apps/web/src/server/public-api/api/domains/update-domain-inbound.ts
  • apps/web/src/server/public-api/index.ts
  • apps/web/src/server/queue/queue-constants.ts
  • apps/web/src/server/service/inbound-email-service.ts
  • apps/web/src/server/service/inbound-email-service.unit.test.ts
  • apps/web/src/server/service/ses-hook-parser.ts
  • docs/plans/2026-02-25-email-forwarding-design.md
  • docs/plans/2026-02-25-email-forwarding-scoping.md

Comment on lines +90 to +112
const inboundEmail = await db.inboundEmail.create({
data: {
teamId: domain.teamId,
domainId: domain.id,
from: fromAddress ?? "unknown",
to: recipientAddress,
subject,
s3Key,
status: "RECEIVED",
},
});

await inboundEmailQueue.add(
inboundEmail.id,
{
inboundEmailId: inboundEmail.id,
teamId: domain.teamId,
domainId: domain.id,
snsMessage,
s3Key,
},
{ jobId: inboundEmail.id, ...DEFAULT_QUEUE_OPTIONS }
);
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 27, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

fd schema.prisma

Repository: usesend/useSend

Length of output: 88


🏁 Script executed:

rg -n --type=prisma -C3 'model InboundEmail|@@unique|messageId|sesMessageId'

Repository: usesend/useSend

Length of output: 89


🏁 Script executed:

cd apps/web && cat -n src/app/api/inbound_callback/route.ts | sed -n '85,135p'

Repository: usesend/useSend

Length of output: 1685


🏁 Script executed:

cat -n apps/web/prisma/schema.prisma | grep -A 30 'model InboundEmail'

Repository: usesend/useSend

Length of output: 1103


🏁 Script executed:

rg -n 'model InboundEmail' -A 30 apps/web/prisma/schema.prisma

Repository: usesend/useSend

Length of output: 1037


🏁 Script executed:

rg -n 'mail\?.messageId|parsedMessage.mail' apps/web/src/app/api/inbound_callback/route.ts

Repository: usesend/useSend

Length of output: 408


Add idempotency to inbound email creation to prevent duplicates on SNS retries.

Lines 90-112 persist an inboundEmail row before queueing. If the enqueue fails and line 129 returns 500, SNS will retry. Since create() lacks idempotency protection, this causes duplicate rows and duplicate email forwards.

Schema prerequisite: The InboundEmail model currently lacks a sesMessageId field and unique constraint. Add these first:

model InboundEmail {
  // ... existing fields
  sesMessageId String?
  // ... relations

  @@unique([domainId, sesMessageId])
}

Then use upsert with this constraint instead of create:

Suggested fix
+const sesMessageId = parsedMessage.mail?.messageId;
+if (!sesMessageId) {
+  logger.warn("Missing SES messageId");
+  return Response.json({ data: "Missing messageId" });
+}
+
+const inboundEmail = await db.inboundEmail.upsert({
+  where: { domainId_sesMessageId: { domainId: domain.id, sesMessageId } },
+  update: {},
+  create: {
+    teamId: domain.teamId,
+    domainId: domain.id,
+    from: fromAddress ?? "unknown",
+    to: recipientAddress,
+    subject,
+    s3Key,
+    status: "RECEIVED",
+    sesMessageId,
+  },
+});

Also applies to: 125-130

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/api/inbound_callback/route.ts` around lines 90 - 112, The
current logic creates an InboundEmail with db.inboundEmail.create which allows
duplicates when SNS retries; add a nullable sesMessageId field and a unique
constraint on (domainId, sesMessageId) in the Prisma model, then replace the
create call with an upsert (using db.inboundEmail.upsert or equivalent) keyed on
the unique constraint so creating is idempotent using sesMessageId (populate
from snsMessage / messageId), and ensure the same inboundEmail.id is used when
calling inboundEmailQueue.add so retries reuse the existing row instead of
inserting duplicates.

Copy link
Author

Choose a reason for hiding this comment

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

The plan was deliberately simple for MVP. It didn't address SNS retry idempotency because:

  1. SNS retries are relatively rare (they only happen on 5xx or timeouts from the callback)
  2. The callback returns 200 quickly in all success paths, so retries should be uncommon
  3. Duplicate InboundEmail rows are low-impact — the downstream worker processes both, but the actual forwarded email is deduplicated at the SES send level (SES has its own message deduplication)

That said, the feedback is still a valid improvement it's just not a bug that's likely to cause real user-facing issues.

Copy link
Contributor

Choose a reason for hiding this comment

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

@KalebJG, thanks for the context! That makes sense for MVP scope. The SES-level deduplication on the send side is a good safety net, and you're right that with quick 200 responses, SNS retries should be uncommon in practice.

Appreciate you walking through the tradeoffs—sounds like a reasonable line to draw for initial delivery. If you ever want to tighten it up post-MVP (especially if retry patterns change or if you want cleaner audit logs), the sesMessageId + upsert approach is there as an option. 👍

Comment on lines +114 to +120
logger.info(
{
inboundEmailId: inboundEmail.id,
domainId: domain.id,
from: fromAddress,
to: recipientAddress,
},
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 27, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid logging raw sender/recipient addresses at info level.

Lines 118-120 log full email addresses (from, to), which are PII. Prefer redacted/hash/domain-only fields for routine logs.

🧹 Proposed fix
     logger.info(
       {
         inboundEmailId: inboundEmail.id,
         domainId: domain.id,
-        from: fromAddress,
-        to: recipientAddress,
+        fromDomain: fromAddress?.split("@")[1],
+        toDomain: recipientAddress.split("@")[1],
       },
       "Enqueued inbound email for processing"
     );
📝 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
logger.info(
{
inboundEmailId: inboundEmail.id,
domainId: domain.id,
from: fromAddress,
to: recipientAddress,
},
logger.info(
{
inboundEmailId: inboundEmail.id,
domainId: domain.id,
fromDomain: fromAddress?.split("@")[1],
toDomain: recipientAddress.split("@")[1],
},
"Enqueued inbound email for processing"
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/api/inbound_callback/route.ts` around lines 114 - 120, The
logger.info call is currently emitting raw PII (fromAddress and
recipientAddress); change it to log redacted or non-PII variants instead by
replacing the from/to values with either a hashed value or domain-only value
(e.g., use a helper like redactEmail(email) or extractDomain(email) or
hashEmail(email) and call that inside the logger.info). Update the logger.info
invocation that references inboundEmail.id, domain.id, fromAddress,
recipientAddress to pass the redacted/hash/domain-only results (and add the
helper function in the same module or a util file), so no full email addresses
are logged at info level.

Copy link
Author

@KalebJG KalebJG Feb 27, 2026

Choose a reason for hiding this comment

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

The feedback is valid but shouldn't be fixed in isolation. The entire codebase logs full email addresses at info level (including mailer.ts which logs emails with sign-in tokens), so redacting this one log line would be inconsistent and give a false sense of compliance if PII in logs is a real concern, it needs a global solution like a Pino serializer, not a one-off patch.

Copy link
Contributor

Choose a reason for hiding this comment

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

@KalebJG, you make a fair point about consistency—applying redaction to just this line would create a patchwork approach when the logging pattern is used throughout the codebase. A global solution like a Pino serializer (to redact or hash PII fields automatically) would indeed be the right way to handle this systematically if you decide to address it.

If PII in logs becomes a compliance or security requirement down the road, you might want to track this as tech debt and implement a codebase-wide redaction strategy at that time. For now, keeping the logging consistent with the rest of the application makes sense.


✏️ Learnings added
Learnt from: KalebJG
Repo: usesend/useSend PR: 362
File: apps/web/src/app/api/inbound_callback/route.ts:114-120
Timestamp: 2026-02-27T05:51:42.679Z
Learning: The useSend codebase intentionally logs full email addresses at info level throughout (e.g., mailer.ts, inbound_callback route). The team prefers consistency and would address PII redaction globally via a Pino serializer if needed, rather than one-off fixes.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment on lines +145 to +147
try {
await fetch(message.SubscribeURL!, { method: "GET" });
logger.info({ topicArn }, "Confirmed inbound SNS subscription");
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n apps/web/src/app/api/inbound_callback/route.ts | sed -n '140,155p'

Repository: usesend/useSend

Length of output: 616


🏁 Script executed:

rg -n 'fetch\(' apps/web/src/app/api/ -A 2 --type=ts -B 1 | head -40

Repository: usesend/useSend

Length of output: 716


🏁 Script executed:

fd -t f -e ts -e tsx apps/web/src/ | xargs rg -l 'AbortController|fetch.*timeout|fetchWithTimeout' | head -10

Repository: usesend/useSend

Length of output: 480


🏁 Script executed:

rg -n 'AbortController|setTimeout.*abort|fetch.*signal' apps/web/src/server/service/webhook-service.ts -B 2 -A 2

Repository: usesend/useSend

Length of output: 211


🏁 Script executed:

rg -n 'AbortController' apps/web/src/server/service/webhook-service.ts -B 2 -A 10

Repository: usesend/useSend

Length of output: 463


🏁 Script executed:

rg -n 'WEBHOOK_REQUEST_TIMEOUT_MS' apps/web/src/server/service/webhook-service.ts -B 2 -A 2

Repository: usesend/useSend

Length of output: 367


🏁 Script executed:

rg -n 'fetch.*signal|res\\.ok' apps/web/src/server/service/webhook-service.ts -B 2 -A 5

Repository: usesend/useSend

Length of output: 41


🏁 Script executed:

sed -n '673,710p' apps/web/src/server/service/webhook-service.ts

Repository: usesend/useSend

Length of output: 1132


🏁 Script executed:

sed -n '673,730p' apps/web/src/server/service/webhook-service.ts

Repository: usesend/useSend

Length of output: 1630


Add timeout and response validation to SNS subscription confirmation fetch.

Line 146 makes an outbound fetch with no timeout or response validation. A hung upstream request could block handler indefinitely. Add AbortController with timeout and validate the response status.

The codebase establishes this pattern in webhook-service.ts (using AbortController + setTimeout with status check), so applying it here maintains consistency.

⏱️ Proposed fix
   try {
-    await fetch(message.SubscribeURL!, { method: "GET" });
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), 5000);
+    try {
+      const res = await fetch(message.SubscribeURL!, {
+        method: "GET",
+        signal: controller.signal,
+      });
+      if (!res.ok) {
+        throw new Error(`SNS subscription confirmation failed: ${res.status}`);
+      }
+    } finally {
+      clearTimeout(timeout);
+    }
     logger.info({ topicArn }, "Confirmed inbound SNS subscription");
   } catch (err) {
📝 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
try {
await fetch(message.SubscribeURL!, { method: "GET" });
logger.info({ topicArn }, "Confirmed inbound SNS subscription");
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(message.SubscribeURL!, {
method: "GET",
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`SNS subscription confirmation failed: ${res.status}`);
}
} finally {
clearTimeout(timeout);
}
logger.info({ topicArn }, "Confirmed inbound SNS subscription");
} catch (err) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/api/inbound_callback/route.ts` around lines 145 - 147,
Replace the bare fetch in the SNS subscription confirmation block with a timed
fetch using an AbortController: create an AbortController, set a timeout (use
setTimeout) to call controller.abort(), pass controller.signal into
fetch(message.SubscribeURL!), await the response and validate response.ok (or
status 200–299) before calling logger.info; clear the timeout on success and
handle AbortError/non-OK responses by logging or throwing so the handler does
not falsely log "Confirmed inbound SNS subscription" for failed or hung requests
(affecting the fetch call and logger.info referenced in route.ts).

Comment on lines +159 to +170
function checkEventValidity(message: SnsNotification): boolean {
if (env.NODE_ENV === "development") {
return true;
}

const { TopicArn } = message;

if (!env.INBOUND_SNS_TOPIC_ARN) {
return false;
}

return TopicArn === env.INBOUND_SNS_TOPIC_ARN;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n apps/web/src/app/api/inbound_callback/route.ts | head -200

Repository: usesend/useSend

Length of output: 6885


🏁 Script executed:

rg -n --type=ts 'Signature|SigningCertURL|SignatureVersion|verify' apps/web/src/app/api/inbound_callback/route.ts

Repository: usesend/useSend

Length of output: 41


🏁 Script executed:

rg -n --type=ts 'import.*sns|from.*aws|aws-sdk' apps/web/src/app/api/inbound_callback/route.ts

Repository: usesend/useSend

Length of output: 41


🏁 Script executed:

rg -i 'signature|signing.*cert|sns.*verify|verifysnsmessage' --type=ts --type=tsx -l

Repository: usesend/useSend

Length of output: 86


🏁 Script executed:

rg -i 'aws.*sns|sns.*signature' --type=ts --type=tsx -A2 -B2

Repository: usesend/useSend

Length of output: 86


🏁 Script executed:

fd -e ts -e tsx | xargs grep -l 'aws-sdk\|@aws-sdk\|sns' 2>/dev/null | head -20

Repository: usesend/useSend

Length of output: 692


🏁 Script executed:

cat -n apps/web/src/server/aws/sns.ts

Repository: usesend/useSend

Length of output: 1569


🏁 Script executed:

cat -n apps/web/src/app/api/inbound_callback/route.unit.test.ts | head -150

Repository: usesend/useSend

Length of output: 5135


🏁 Script executed:

rg -n 'Signature|SigningCertURL|verify.*sns' apps/web/src/server/aws/sns.ts apps/web/src/app/api/inbound_callback/route.unit.test.ts

Repository: usesend/useSend

Length of output: 41


Implement AWS SNS signature verification instead of TopicArn validation alone.

The endpoint validates only TopicArn against env.INBOUND_SNS_TOPIC_ARN, which is insufficient. AWS SNS messages must be cryptographically verified using the Signature, SigningCertURL, and SignatureVersion fields provided in every SNS message. The current implementation completely omits signature verification, making this endpoint vulnerable to forged SNS payloads that can reach database writes and queue operations.

Additionally, the fetch(message.SubscribeURL!) call at line 146 lacks a timeout and could hang indefinitely, and email addresses are logged as plaintext at lines 118–119 (PII exposure).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/api/inbound_callback/route.ts` around lines 159 - 170,
Replace the weak TopicArn check in checkEventValidity with full AWS SNS
signature verification: validate SignatureVersion, download the SigningCertURL
over HTTPS with proper hostname/cert checks, build the canonical string from
message fields per AWS SNS docs, and verify the Signature using the downloaded
certificate (use a crypto/X509 verify flow) while still allowing a bypass in
development; also add an AbortController/timeout to the fetch of
message.SubscribeURL to avoid hangs and redact or avoid logging plain email
addresses (mask local-part or remove PII) where emails are currently logged to
prevent exposing sensitive data.

Comment on lines +67 to +73
if (response.$metadata.httpStatusCode !== 200) {
logger.error(
{ response, domainName },
"Failed to create SES Receipt Rule"
);
throw new Error("Failed to create SES Receipt Rule");
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's read the file to see the context of both locations
cat -n apps/web/src/server/aws/ses-receipt-rules.ts

Repository: usesend/useSend

Length of output: 3305


🌐 Web query:

AWS SDK v3 SES CreateReceiptRuleCommand error handling exceptions vs status codes

💡 Result:

In AWS SDK for JavaScript v3, sesClient.send(new CreateReceiptRuleCommand(...)) does not return a non-2xx “status code response” for you to check. If the HTTP response indicates failure, the SDK throws an exception instead. AWS’s v3 docs explicitly describe failure as “it throws an exception,” and show branching on the exception type/name. [1]

What to do instead of “check status codes”

Catch the exception and inspect:

  • Modeled SES error type (recommended):
    • Use instanceof SomeSesException (supported since v3.53.0) [2]
    • Or check error.name === "RuleSetDoesNotExist" / etc. [1]
  • HTTP status code (when you really need it):
    • Read error.$metadata.httpStatusCode (it’s in the exception’s metadata) [3]

SES CreateReceiptRule modeled errors (examples)

CreateReceiptRule can throw things like AlreadyExists, InvalidSnsTopic, InvalidS3Configuration, LimitExceeded, RuleDoesNotExist, RuleSetDoesNotExist, etc. (mostly HTTP 400). [4]

Example (TypeScript)

import {
  SESClient,
  CreateReceiptRuleCommand,
  SESServiceException,
  RuleSetDoesNotExist, // if exported by your installed SDK version
} from "@aws-sdk/client-ses";

try {
  await new SESClient({}).send(new CreateReceiptRuleCommand(input));
} catch (e: any) {
  const status = e?.$metadata?.httpStatusCode; // e.g., 400
  if (e instanceof RuleSetDoesNotExist || e?.name === "RuleSetDoesNotExist") {
    // handle missing rule set
  } else if (e instanceof SESServiceException) {
    // other SES service-side errors
  } else {
    // network/credentials/serialization/etc.
  }
  throw e;
}

Rule of thumb: handle by exception type/name first; use e.$metadata.httpStatusCode only as supplemental info, and keep a fallback branch for unmodeled errors. [2][3]

Sources

[1] https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/the-response-object.html
[2] https://aws.amazon.com/blogs/developer/service-error-handling-modular-aws-sdk-js/
[3] https://lightrun.com/answers/aws-aws-sdk-js-v3-client-s3-getobjectcommand-with-etags-throws-error-without-name-or-details
[4] https://docs.aws.amazon.com/ses/latest/APIReference/API_CreateReceiptRule.html


Use try-catch to handle AWS SDK v3 errors; HTTP status code check cannot catch API failures.

AWS SDK v3 throws exceptions on API errors rather than returning non-2xx status codes. The response.$metadata.httpStatusCode check on lines 67–73 and 94–100 cannot catch failures because when the API call fails, an exception is thrown at client.send() before the response is even returned. The current code lacks error handling for SES API failures.

Wrap the client.send(command) calls in try-catch blocks and handle exceptions by type (e.g., instanceof SESServiceException or by error.name). Use error.$metadata.httpStatusCode only for logging supplemental information, not as the primary error detection mechanism.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/aws/ses-receipt-rules.ts` around lines 67 - 73, The SES
client calls that check response.$metadata.httpStatusCode (around the
client.send(command) calls) must be wrapped in try-catch so API errors thrown by
AWS SDK v3 are caught; modify the blocks that call client.send(command) (and
where response is inspected) to catch exceptions, detect SES errors by checking
instanceof SESServiceException or error.name, log the error and include
error.$metadata.httpStatusCode only as supplemental info, and rethrow or return
a controlled error instead of relying on a non-2xx response check; update both
places that currently inspect response.$metadata.httpStatusCode to follow this
try-catch pattern.

Comment on lines +83 to +88
if (existing) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: `A forwarding rule for ${sourceAddress}@${domain.name} already exists`,
});
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent error code for duplicate rule.

The TRPC createRule procedure returns CONFLICT (HTTP 409) for duplicate source addresses, but this REST endpoint returns BAD_REQUEST (HTTP 400). Consider using CONFLICT for consistency across both API surfaces.

🔧 Suggested fix
     if (existing) {
       throw new UnsendApiError({
-        code: "BAD_REQUEST",
+        code: "CONFLICT",
         message: `A forwarding rule for ${sourceAddress}@${domain.name} already exists`,
       });
     }
📝 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 (existing) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: `A forwarding rule for ${sourceAddress}@${domain.name} already exists`,
});
}
if (existing) {
throw new UnsendApiError({
code: "CONFLICT",
message: `A forwarding rule for ${sourceAddress}@${domain.name} already exists`,
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/public-api/api/domains/create-forwarding-rule.ts` around
lines 83 - 88, The REST handler in create-forwarding-rule.ts throws an
UnsendApiError with code "BAD_REQUEST" when a duplicate forwarding rule is found
(variable existing); change this to use code "CONFLICT" to match the TRPC
createRule behavior—update the throw to new UnsendApiError({ code: "CONFLICT",
message: `A forwarding rule for ${sourceAddress}@${domain.name} already exists`
}) so both API surfaces return HTTP 409 for duplicates.

Comment on lines +64 to +76
const hopCount = getHopCount(parsed);
if (hopCount > MAX_FORWARDING_HOPS) {
logger.warn(
{ inboundEmailId, hopCount },
"Forwarding loop detected, dropping email"
);
await updateInboundEmailStatus(
inboundEmailId,
InboundEmailStatus.FAILED,
"Forwarding loop detected"
);
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Off-by-one in forwarding loop detection.

The condition hopCount > MAX_FORWARDING_HOPS allows 4 hops (0, 1, 2, 3) before triggering the loop detection at hop 4. If the intent is to limit forwarding to exactly 3 hops, use >=.

🐛 Proposed fix
-  if (hopCount > MAX_FORWARDING_HOPS) {
+  if (hopCount >= MAX_FORWARDING_HOPS) {
📝 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
const hopCount = getHopCount(parsed);
if (hopCount > MAX_FORWARDING_HOPS) {
logger.warn(
{ inboundEmailId, hopCount },
"Forwarding loop detected, dropping email"
);
await updateInboundEmailStatus(
inboundEmailId,
InboundEmailStatus.FAILED,
"Forwarding loop detected"
);
return;
}
const hopCount = getHopCount(parsed);
if (hopCount >= MAX_FORWARDING_HOPS) {
logger.warn(
{ inboundEmailId, hopCount },
"Forwarding loop detected, dropping email"
);
await updateInboundEmailStatus(
inboundEmailId,
InboundEmailStatus.FAILED,
"Forwarding loop detected"
);
return;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/service/inbound-email-service.ts` around lines 64 - 76,
The forwarding-loop check currently uses `if (hopCount > MAX_FORWARDING_HOPS)`
which is off-by-one for a 3-hop limit; change the condition to `if (hopCount >=
MAX_FORWARDING_HOPS)` (in the block using `getHopCount(parsed)`, `hopCount`, and
`MAX_FORWARDING_HOPS`) so emails hitting the maximum allowed hops are treated as
a loop, keeping the existing `logger.warn` and
`updateInboundEmailStatus(inboundEmailId, InboundEmailStatus.FAILED, ...)`
behavior intact.

Comment on lines +122 to +127
const originalFrom =
parsed.from?.value?.[0]?.address ?? inboundEmail.from;
const originalFromName =
parsed.from?.value?.[0]?.name ?? originalFrom;

const rewrittenFrom = `"${originalFromName} via Unsend" <${forwardingRule.sourceAddress}@${domain.name}>`;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Sanitize originalFromName to prevent header injection.

If the original sender's display name contains double quotes or control characters, it could malform the rewritten From header. Consider escaping or removing problematic characters.

🛡️ Proposed fix
   const originalFromName =
     parsed.from?.value?.[0]?.name ?? originalFrom;

-  const rewrittenFrom = `"${originalFromName} via Unsend" <${forwardingRule.sourceAddress}@${domain.name}>`;
+  // Escape quotes and remove control characters to prevent header injection
+  const sanitizedName = originalFromName
+    .replace(/[\x00-\x1f\x7f]/g, "")
+    .replace(/"/g, '\\"');
+  const rewrittenFrom = `"${sanitizedName} via Unsend" <${forwardingRule.sourceAddress}@${domain.name}>`;
📝 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
const originalFrom =
parsed.from?.value?.[0]?.address ?? inboundEmail.from;
const originalFromName =
parsed.from?.value?.[0]?.name ?? originalFrom;
const rewrittenFrom = `"${originalFromName} via Unsend" <${forwardingRule.sourceAddress}@${domain.name}>`;
const originalFrom =
parsed.from?.value?.[0]?.address ?? inboundEmail.from;
const originalFromName =
parsed.from?.value?.[0]?.name ?? originalFrom;
// Escape quotes and remove control characters to prevent header injection
const sanitizedName = originalFromName
.replace(/[\x00-\x1f\x7f]/g, "")
.replace(/"/g, '\\"');
const rewrittenFrom = `"${sanitizedName} via Unsend" <${forwardingRule.sourceAddress}@${domain.name}>`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/service/inbound-email-service.ts` around lines 122 - 127,
The rewritten From header construction in inbound-email-service.ts uses
originalFromName directly which allows double quotes or control characters to
break the header; sanitize originalFromName before building rewrittenFrom by
removing or escaping double quotes and stripping control characters (CR, LF,
non-printables) and use the sanitized value in the `"${...} via Unsend" <...>`
template (e.g., add a helper like sanitizeDisplayName and call it where
originalFromName is referenced to produce sanitizedFromName, then use
sanitizedFromName when creating rewrittenFrom).

): Promise<ParsedMail> {
let rawContent: string;

const snsData = JSON.parse(snsMessage);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add error handling for JSON.parse.

If snsMessage contains invalid JSON, this will throw an unhandled exception that propagates up, potentially causing confusing error messages. Wrap in try-catch for clearer error handling.

🛡️ Proposed fix
 async function parseEmail(
   snsMessage: string,
   s3Key: string | undefined,
   region: string
 ): Promise<ParsedMail> {
   let rawContent: string;

+  let snsData: { content?: string };
+  try {
-  const snsData = JSON.parse(snsMessage);
+    snsData = JSON.parse(snsMessage);
+  } catch {
+    throw new Error("Invalid SNS message format");
+  }

   if (snsData.content) {
📝 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
const snsData = JSON.parse(snsMessage);
async function parseEmail(
snsMessage: string,
s3Key: string | undefined,
region: string
): Promise<ParsedMail> {
let rawContent: string;
let snsData: { content?: string };
try {
snsData = JSON.parse(snsMessage);
} catch {
throw new Error("Invalid SNS message format");
}
if (snsData.content) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/service/inbound-email-service.ts` at line 191, The
assignment to const snsData = JSON.parse(snsMessage) can throw on invalid JSON;
wrap the JSON.parse(snsMessage) call in a try-catch inside the same function
(the inbound email processing function in inbound-email-service.ts) to catch
SyntaxError, log a clear error including the raw snsMessage and error.message
via your logger, and then handle the failure path (return an appropriate error
response or throw a wrapped/typed error) instead of letting the raw exception
propagate.

@KMKoushik
Copy link
Member

too broader for external folks to handle

@KMKoushik KMKoushik closed this Mar 8, 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.

Feat: Add email forwarding

2 participants