Skip to content

Prevent un-sendable sends before they start, and harden per-recipient failure handling #156

Open

Description

@s3rot0n1n

Follow-up to #154 / #155. #155 makes a permanent render error pause the sequence (reason render_error) and keeps everyone queued. That is the send-time backstop. The usual industry approach is to stop these problems before a send starts, and to handle per-recipient failures without losing anyone. This issue tracks that work plus the existing issues found while reviewing #155.

Prevention (main item)

  • Validate Liquid syntax when content is saved. Sequence emails, broadcasts and templates are only scanned for tokens with a regex (templates/validation.ts). Content like {{ name or {% foo %} is accepted and only fails at send time, where fix(api): sequences stall on wrapped footer address; render failures retry forever #155 now pauses the send. Reject it on save with the line and column from liquidjs.
  • Guard the mailing address. Activation requires one, but it can be cleared later in Settings while broadcasts or sequences are active. Every send then fails with footer_render_context_required. Block blanking it while sends are active, or warn and list what will pause.
  • Pre-send checklist. Consider one activation-time check covering footer, mailing address, unsubscribe link, merge-tag syntax and delivery source, so problems show up before any recipient is enrolled.

Per-recipient failures

  • If a render error depends on one contact's data (for example strict Liquid variables, or filters on contact fields), the standard is to skip that recipient's email and record the reason, not pause the whole send or retry. Today this is unlikely because Liquid runs in non-strict mode, but isPermanentRenderError treats every LiquidError as send-wide.

Existing issues found during review

  • Lost updates to sequences.report. On the send-error path, report.sequence.failed is written read-modify-write from the sequence snapshot loaded when the job started. The sequence worker runs 10 jobs at once, so concurrent failures overwrite each other and can also drop report.health. Use an atomic jsonb append.
  • retry_count never resets after a successful send. Failures add up across all steps of a multi-email drip, so a few unrelated failures months apart can drop a contact.
  • A broadcast never completes if its last rows are dropped at the bounce limit. The row is deleted and the error rethrown, so cleanUpResources and markBroadcastSent never run and the broadcast stays in its sending state.
  • Subscribers who join while a sequence is auto-paused are never enrolled. autoPauseSequence removes the trigger rule, so a "new contact subscribes" welcome sequence silently misses everyone who subscribes until it is resumed. Consider enrolling them as queued.

Smaller items

  • renderErrorReason copy is duplicated in apps/api/src/automation/sending-health.ts and apps/web/components/dashboard/auto-pause-banner.tsx. Share it if more reasons are added.
  • sequenceBounceLimit is used for all send failures, not just bounces; the name is misleading.

Activity

  1. added
    bugSomething isn't working
    severity: mediumNotable gap with a workaround, or competitive parity
    on Oct 29, 2026
  2. s3rot0n1n commented on Oct 29, 2026

    @s3rot0n1n
    ContributorAuthor

    More items from the review of #155:

    • Guarded Liquid errors are skipped silently. A content error inside a condition on contact data, such as {% if subscriber.name %}{% include x %}{% endif %}, only fails for some contacts. fix(api): sequences stall on wrapped footer address; render failures retry forever #155 renders the email once without contact data to tell content errors from data errors, and pauses after AUTO_PAUSE_CONSECUTIVE_FAILURES consecutive skips. If the affected contacts are interleaved with ones that send fine, they are still skipped (recorded in report.sequence.failed) with no pause or notification. include, render and layout can never work in SendLit because no template root is configured. Rejecting them on save, or making them parse errors in the engine, would close this.
    • A mailing address containing Liquid (for example {{ x) passes the footer check, because the check runs before Liquid. Liquid then fails, so the owner is told the email's content couldn't be built. The address should be escaped or kept out of Liquid parsing. Today an address such as {{ subscriber.email }} would also render as template code.
    • Duplicate report.sequence.failed entries. A contact skipped on several emails of a sequence is listed once per email. A crash between recording the skip and advancing the row lists them twice.
    • Resume allows repeated skips. Each resume clears the failure streak, so a guarded error can skip up to N-1 more recipients after every resume.
    • Untested: the skip path for a multi-email sequence, and a check that owners get exactly one pause notification under concurrent workers.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingseverity: mediumNotable gap with a workaround, or competitive parity

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions