Skip to content

Handle Postmark InactiveRecipientsError gracefully#466

Merged
SvenVw merged 10 commits into
developmentfrom
FDM454
Feb 20, 2026
Merged

Handle Postmark InactiveRecipientsError gracefully#466
SvenVw merged 10 commits into
developmentfrom
FDM454

Conversation

@BoraIneviNMI
Copy link
Copy Markdown
Collaborator

@BoraIneviNMI BoraIneviNMI commented Feb 18, 2026

Enhancements

  • While logging in or sending an organization invitation, if an email can't be send due to an inactive recipient error from the Postmark API, the user will see a toast with a message that is specific to this error.

Closes #454

Summary by CodeRabbit

  • New Features
    • Shows a specific localized (Dutch) message when an email can't be sent because the recipient is marked inactive (during sign-in or when inviting someone).
  • Bug Fixes
    • Cancels pending organization invitations if email delivery fails, preventing dangling invites and confusing states.
  • Documentation
    • Added a changelog entry for the package reflecting this minor version.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Feb 18, 2026

🦋 Changeset detected

Latest commit: d3fbaa6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@svenvw/fdm-app Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 18, 2026

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

Adds detection and handling for Postmark "inactive recipient" errors in magic-link sign-in and organization-invite flows: catches the specific error, returns a localized Dutch user-facing message, and cancels created invitations when email delivery fails due to an inactive recipient. (≤50 words)

Changes

Cohort / File(s) Summary
Changelog Entry
.changeset/strong-symbols-beam.md
Adds a changelog entry documenting the new user-facing behavior for Postmark inactive-recipient errors.
Organization Invite Flow
fdm-app/app/routes/organization.$slug.tsx
Imports isInactiveRecipientError; wraps invitation creation and email send in try/catch; stores invitation for cancellation; cancels via auth.api.cancelInvitation on failure; returns localized inactive-recipient or generic support message; removes previous unconditional final success return.
Sign-in (Magic Link)
fdm-app/app/routes/signin._index.tsx
Imports isInactiveRecipientError and dataWithError; sign-in action catch now detects inactive-recipient errors and returns a localized Dutch error message, otherwise returns a generic support error message.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant AppServer
    participant AuthAPI
    participant EmailLib
    participant Postmark

    Client->>AppServer: Request invite or magic-link
    AppServer->>AuthAPI: createInvitation / requestMagicLink
    AuthAPI-->>AppServer: returns (invitation.id / ok)
    AppServer->>EmailLib: send email (invitation or magic link)
    EmailLib->>Postmark: deliver email
    alt Postmark OK
        Postmark-->>EmailLib: 200 OK
        EmailLib-->>AppServer: success
        AppServer-->>Client: success message / toast
    else Postmark InactiveRecipientsError
        Postmark-->>EmailLib: 406 InactiveRecipientsError
        EmailLib-->>AppServer: throws isInactiveRecipientError
        AppServer->>AuthAPI: cancelInvitation (if invitation.id)
        AppServer-->>Client: localized inactive-recipient error message
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • gerardhros

Poem

🐰 I hopped through code with twitchy nose,
Found the bounces where the bad mail goes,
Canceled invites that couldn't fly,
Gave users a message clear and spry,
Now magic links can leap the sky ✨

🚥 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 handles inactive recipient errors in sign-in and invitation flows but does not modify sendEmail in email.server.ts to catch and transform Postmark errors to EMAIL_INACTIVE as specified in issue #454. Verify whether the isInactiveRecipientError utility function already exists and handles the error detection per issue #454 requirements, or complete the sendEmail modification to catch Postmark errors.
✅ 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 accurately and concisely summarizes the main objective: handling Postmark InactiveRecipientsError gracefully, which is the core focus of the PR.
Out of Scope Changes check ✅ Passed All changes directly address handling InactiveRecipientsError in sign-in and invitation flows, with no unrelated modifications present.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch FDM454

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.

@codecov
Copy link
Copy Markdown

codecov Bot commented Feb 18, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.10%. Comparing base (af74b27) to head (d3fbaa6).
⚠️ Report is 11 commits behind head on development.

Additional details and impacted files
@@             Coverage Diff              @@
##           development     #466   +/-   ##
============================================
  Coverage        88.10%   88.10%           
============================================
  Files               93       93           
  Lines             4675     4675           
  Branches          1498     1498           
============================================
  Hits              4119     4119           
  Misses             556      556           
Flag Coverage Δ
fdm-calculator 88.86% <ø> (ø)
fdm-core 86.69% <ø> (ø)
fdm-data 92.12% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot added enhancement New feature or request fdm-app labels Feb 18, 2026
Copy link
Copy Markdown
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: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.changeset/strong-symbols-beam.md:
- Line 5: Fix the grammar in the changelog sentence by replacing the incorrect
phrase "can't be send" with "can't be sent" in the changelog entry text (the
sentence beginning "While logging in or sending an organization invitation, if
an email can't be send due to an inactive recipient error from the Postmark
API..."). Ensure the updated sentence reads "...if an email can't be sent due to
an inactive recipient error from the Postmark API..." without changing other
wording.

In `@fdm-app/app/routes/organization`.$slug.tsx:
- Around line 435-452: The generic email failure branch currently returns an
error without cancelling the created invitation, leaving it stuck in pending;
modify the catch handler so that when sendEmail throws any error (i.e., in the
non-isInactiveRecipientError branch) you call auth.api.cancelInvitation with the
same headers and body (invitationId: invitation.id) before returning
dataWithError, similar to the isInactiveRecipientError branch; reference
sendEmail, isInactiveRecipientError, auth.api.cancelInvitation, invitation.id,
dataWithError, invitationEmail and formValues.email when making the change.
- Around line 438-448: When an inactive-recipient error is detected, add a
warning log that includes the affected email before the error is propagated;
update the detection within email.server.ts (where isInactiveRecipientError is
defined) to call console.warn(`Inactive recipient: ${email}`) (or similar) when
it returns true, or alternatively add the same console.warn in the error
handlers that call it (the handlers inside organization.$slug.tsx around the
branch using isInactiveRecipientError and in signin._index.tsx). Ensure the
warning runs prior to returning dataWithError or rethrowing so the email address
is recorded for debugging.

In `@fdm-app/app/routes/signin._index.tsx`:
- Around line 1672-1681: The catch block currently handles inactive-recipient
via isInactiveRecipientError and returns dataWithError, but for all other errors
it calls handleActionError(error) without throwing, which swallows the result;
prepend throw to the call (i.e., throw handleActionError(error)) so the action
returns the expected error flow; also update the console.error("Error sending
magic link") line to include the error details (e.g., console.error("Error
sending magic link", error)) to preserve useful logs; modify the catch around
isInactiveRecipientError, dataWithError, console.error, and handleActionError
accordingly.

Comment thread .changeset/strong-symbols-beam.md Outdated
Comment thread fdm-app/app/routes/organization.$slug.tsx Outdated
Comment thread fdm-app/app/routes/organization.$slug.tsx Outdated
Comment thread fdm-app/app/routes/signin._index.tsx
@coderabbitai coderabbitai Bot added the branch:development Issue only affecting development, not the main branch (yet) label Feb 19, 2026
Copy link
Copy Markdown
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: 1

🧹 Nitpick comments (1)
fdm-app/app/routes/organization.$slug.tsx (1)

444-462: Line 445 has an inconsistent error-handling pattern, not unreachable code.

The handleActionError(e) call on line 445 returns a Response object—it does not throw. The function always returns across all code paths with no throw statements. This means lines 446–462 are reachable and will execute correctly; the invitation cancellation and error responses function as intended.

However, the pattern of calling handleActionError(e) without throwing or returning its result is inconsistent with the rest of the codebase (lines 385, 401, 414, etc.), where the return value is always used. Additionally, there is no specific logging for inactive recipient errors as suggested in the previous review.

Consider restructuring to handle specific errors before generic ones, capturing the logged response flow more explicitly:

Suggested pattern
             } catch (e) {
-                handleActionError(e)
                 if (invitation?.id) {
                     await auth.api.cancelInvitation({
                         headers: request.headers,
                         body: {
                             invitationId: invitation.id,
                         },
                     })
                 }
                 if (isInactiveRecipientError(e)) {
+                    console.warn(
+                        `Inactive recipient error for ${formValues.email}`,
+                    )
                     return dataWithError(null, {
                         message: `We kunnen geen e-mails naar ${formValues.email} sturen omdat het als inactief is gemarkeerd. Neem contact op met de ondersteuning voor hulp.`,
                     })
                 }
+                return handleActionError(e)
-                return dataWithError(null, {
-                    message: `We kunnen geen e-mails naar ${formValues.email} sturen. Neem contact op met de ondersteuning voor hulp.`,
-                })
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/routes/organization`.$slug.tsx around lines 444 - 462, Handle the
specific inactive-recipient case before calling the generic error handler and
make sure to return the handler's response; check isInactiveRecipientError(e)
first and return dataWithError(...) for that case (include formValues.email),
then if invitation?.id perform auth.api.cancelInvitation(...) before returning
the generic error response, and finally return the result of
handleActionError(e) when you want the centralized handling/logging behavior;
reference handleActionError, isInactiveRecipientError,
auth.api.cancelInvitation, dataWithError, invitation, and formValues.email to
locate the changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@fdm-app/app/routes/organization`.$slug.tsx:
- Around line 446-452: The call to auth.api.cancelInvitation when invitation?.id
exists can throw and currently bubbles out into the outer catch, hiding the
original user-facing error; wrap the auth.api.cancelInvitation({...}) call in
its own try/catch so any cancellation failure is caught locally, log the
cancellation error (including context: invitation.id and request.headers) but do
not rethrow, ensuring the original upstream error handling (the outer catch that
returns the inactive-recipient or email-failure response) still executes; keep
the rest of the flow unchanged.

---

Nitpick comments:
In `@fdm-app/app/routes/organization`.$slug.tsx:
- Around line 444-462: Handle the specific inactive-recipient case before
calling the generic error handler and make sure to return the handler's
response; check isInactiveRecipientError(e) first and return dataWithError(...)
for that case (include formValues.email), then if invitation?.id perform
auth.api.cancelInvitation(...) before returning the generic error response, and
finally return the result of handleActionError(e) when you want the centralized
handling/logging behavior; reference handleActionError,
isInactiveRecipientError, auth.api.cancelInvitation, dataWithError, invitation,
and formValues.email to locate the changes.

Comment thread fdm-app/app/routes/organization.$slug.tsx Outdated
@BoraIneviNMI
Copy link
Copy Markdown
Collaborator Author

@coderabbitai we sometimes call handleActionError without awaiting it to be able to just report errors to Sentry. For example I think it is called like that on the signin page so that the user sees a toast instead of a full page error, and doesn't get a bad first impression of the application.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 19, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine the signin page to see the handleActionError usage pattern
cat fdm-app/app/routes/signin._index.tsx

Length of output: 97741


🏁 Script executed:

#!/bin/bash
# Find and examine the handleActionError function definition
fd -e ts -e tsx handleActionError --exec cat {}

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for other usages of handleActionError without await
rg -n "handleActionError\(" --type=ts --type=tsx -A2 -B2

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Find the handleActionError function definition
fd -e ts -e tsx . | xargs grep -l "handleActionError" | head -5

Length of output: 374


🏁 Script executed:

#!/bin/bash
# Search for handleActionError usages with ripgrep
rg -n "handleActionError\(" --type typescript -A2 -B2

Length of output: 34


🏁 Script executed:

#!/bin/bash
# Let's also try with simpler type flag
rg -n "handleActionError" -g "*.ts" -g "*.tsx" -A2 -B2

Length of output: 48312


🏁 Script executed:

#!/bin/bash
# Find and read the handleActionError function definition
fd -e ts -e tsx . | xargs grep -l "export.*handleActionError\|function handleActionError" | head -1 | xargs cat

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check the organization.$slug.tsx file for the similar pattern
sed -n '440,460p' fdm-app/app/routes/organization.$slug.tsx

Length of output: 79


🏁 Script executed:

#!/bin/bash
# Find the error handling module
fd "error" -e ts -e tsx | grep -E "lib|util" | head -5

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Read the error handling file
cat fdm-app/app/lib/error.ts 2>/dev/null || cat fdm-app/app/lib/error.server.ts 2>/dev/null || cat fdm-app/app/utils/error.ts 2>/dev/null

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check organization file with correct escaping
cat fdm-app/app/routes/organization.\$slug.tsx | sed -n '440,460p'

Length of output: 954


🏁 Script executed:

#!/bin/bash
# Find all files with "error" in the name
fd . fdm-app/app/lib | grep -i error

Length of output: 102


🏁 Script executed:

#!/bin/bash
# List all files in lib directory
ls -la fdm-app/app/lib/ | grep -E "\.ts|\.tsx"

Length of output: 63


🏁 Script executed:

#!/bin/bash
# Try to find where handleActionError is defined
rg "function handleActionError|export.*handleActionError" -g "*.ts" -g "*.tsx" -A5 | head -30

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check the current directory
pwd
ls -la

Length of output: 1472


🏁 Script executed:

#!/bin/bash
# Find any TypeScript files
find . -name "*.ts" -o -name "*.tsx" | head -10

Length of output: 603


🏁 Script executed:

#!/bin/bash
# Find the error handling module
find fdm-app/app -name "*error*" -type f

Length of output: 39


🏁 Script executed:

#!/bin/bash
# Search for handleActionError definition more broadly
rg "handleActionError" fdm-app/app -l | head -3 | xargs cat | grep -A10 -B5 "handleActionError"

Length of output: 3780


@BoraIneviNMI Thank you for clarifying this! That makes perfect sense from a UX perspective. I can see this pattern in action:

  • Line 1674 in signin._index.tsx: handleActionError(error) is called without await, then the code continues to check if it's an inactive recipient error and returns a user-friendly toast message
  • Lines 445 and 455 in organization.$slug.tsx: Similar pattern where handleActionError(e) is called to log to Sentry, but then the code proceeds to handle the error gracefully (e.g., canceling the invitation and showing a specific error message)

This is a smart approach—you get error monitoring via Sentry while the user sees a friendly toast notification instead of a jarring 500 error page. Perfect for maintaining a good first impression during critical flows like sign-in and invitations!


✏️ Learnings added
Learnt from: BoraIneviNMI
URL: https://github.com/SvenVw/fdm/pull/466

Timestamp: 2026-02-19T12:35:08.944Z
Learning: In the fdm-app, `handleActionError` is sometimes called without `await` intentionally. This pattern allows the error to be reported to Sentry for monitoring while the code continues execution to return user-friendly toast messages instead of full page errors. This is particularly used in user-facing flows like sign-in (fdm-app/app/routes/signin._index.tsx) and organization invitations (fdm-app/app/routes/organization.$slug.tsx) to maintain a better user experience.

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.


🧠 Learnings used
Learnt from: SvenVw
Repo: SvenVw/fdm PR: 64
File: fdm-core/src/farm.ts:25-48
Timestamp: 2025-01-30T08:51:44.741Z
Learning: The `handleError` function always throws an error, but for better code clarity and to satisfy TypeScript's control flow analysis, it's recommended to add a return statement after it, even though it will never be reached.

Learnt from: SvenVw
Repo: SvenVw/fdm PR: 84
File: fdm-app/app/root.tsx:89-145
Timestamp: 2025-02-24T10:49:54.523Z
Learning: In the ErrorBoundary component of fdm-app/app/root.tsx, all client errors (400, 401, 403, 404) are intentionally displayed with a 404 status code for security purposes.

Copy link
Copy Markdown
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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@fdm-app/app/routes/organization`.$slug.tsx:
- Around line 444-466: The catch block conflates invitation-creation failures
with email-send failures and shadows the outer error variable; update the error
handling so email-specific messages only run when an invitation was actually
created (i.e., invitation?.id is truthy) and distinguish a separate
invitation-creation failure message when createInvitation throws and invitation
is undefined; also rename the inner catch parameter (e.g., from "e" to
"emailErr" or "cancelErr") in the auth.api.cancelInvitation block to avoid
shadowing the outer "e", and keep using handleActionError for both errors and
return appropriate dataWithError messages using formValues.email or a generic
invitation failure message depending on whether invitation exists.

---

Duplicate comments:
In `@fdm-app/app/routes/organization`.$slug.tsx:
- Line 445: The call to handleActionError(e) runs for every exception (including
inactive-recipient cases) causing Sentry to record full errors; modify the flow
in the action handler so you first check isInactiveRecipientError(e) and for
that case emit a warning (use console.warn with the recipient address where
available) and skip calling handleActionError; only call handleActionError(e)
for non-inactive-recipient errors so they continue to be treated as full errors.
Ensure you reference the isInactiveRecipientError(...) check and the
handleActionError(...) call and add the console.warn(...) with the recipient
identifier in the inactive branch.

Comment thread fdm-app/app/routes/organization.$slug.tsx Outdated
Copy link
Copy Markdown
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.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@fdm-app/app/routes/organization`.$slug.tsx:
- Around line 458-465: The error message for email delivery is shown even when
createInvitation fails; update the try/catch in the invitation flow: rename the
catch parameter sendingError to a neutral inviteError, ensure email-specific
checks (isInactiveRecipientError(inviteError) and the "We kunnen geen
e-mails..." messages) only run when invitation?.id is present (i.e., an invite
was created and an email was attempted), and add a separate error path/message
for invitation creation/rendering failures (errors from createInvitation or
renderInvitationEmail) to return a distinct, accurate error. Also keep
references to createInvitation, renderInvitationEmail, sendEmail and
isInactiveRecipientError to locate and scope the changes.

@SvenVw SvenVw self-requested a review February 20, 2026 14:17
Copy link
Copy Markdown
Collaborator

@SvenVw SvenVw left a comment

Choose a reason for hiding this comment

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

Thanks!

@SvenVw SvenVw merged commit a3899d4 into development Feb 20, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

branch:development Issue only affecting development, not the main branch (yet) enhancement New feature or request fdm-app

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Handle Postmark InactiveRecipientsError gracefully

2 participants