Skip to content

fix(email): unify provider failure classification with canonical bounce categories - #38

Merged
kjxcodez merged 1 commit into
mainfrom
fix/unified-outbound-failure-classification
Sep 9, 2026
Merged

kjxcodez merged 1 commit into
mainfrom
fix/unified-outbound-failure-classification

Conversation

@kjxcodez

@kjxcodez kjxcodez commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Problem

The forensic audit (Issue #24, PR #31) identified that classifyEmailFailure() in apps/api/src/services/email/email.service.ts failed to correctly classify critical provider rejection conditions:

  1. SMTP 554 Spamhaus / spam detection and relay denials fell through to generic PROVIDER category and were recorded against mailbox health as transient NETWORK failures.
  2. DMARC / SPF / authentication policy rejections carrying status code 550 (such as 550 5.7.26) were misclassified as INVALID_RECIPIENT, triggering false-positive recipient suppression of valid contacts.
  3. Outbound failure handling operated independently of the canonical bounce classification logic already present in @leadforge/schema.

Root Cause

  1. classifyEmailFailure() relied on ad-hoc regex and string inspection that evaluated generic 550 before checking for policy or authentication rejection markers, and did not delegate to @leadforge/schema's classifyBounce().
  2. In @leadforge/schema, classifyBounce() evaluated statusCode === 550 under MAILBOX_UNAVAILABLE before checking for 5.7.1 / 5.7.26 / SPF / DMARC under POLICY_REJECTION.
  3. Mailbox health tracking did not account for 'POLICY' failure categories, defaulting to 'NETWORK' on unclassified provider rejections.
  4. EmailService.send() evaluated contact suppression broadly on failure.category === INVALID_RECIPIENT rather than gating strictly on verified hard bounces (isHardBounce: true).

Changes

  1. packages/schema/src/utils/bounce-classifier.ts:
    • Reordered rule precedence so that SPAM_REJECTION and POLICY_REJECTION (including 5.7.1, 5.7.26, SPF/DKIM/DMARC, Spamhaus/blocklists) are evaluated before generic 550 MAILBOX_UNAVAILABLE.
  2. packages/schema/src/utils/bounce-classifier.test.ts:
    • Added unit tests for 550 5.7.26 (DMARC/SPF) and 554 5.7.1 (relay denied) to ensure they categorize as POLICY_REJECTION.
  3. apps/api/src/db/models/email-account.model.ts & apps/api/src/repositories/email-account/email-account.repository.ts:
    • Added 'POLICY' to lastFailureCategory in EmailAccountHealth.
    • Updated recordSendFailure() to handle 'POLICY' failures: places mailbox in a 10-minute cooldown or sets status to BLOCKED with operatorActionRequired: true after 3 consecutive policy rejections.
  4. apps/api/src/services/email/email.service.ts:
    • Refactored classifyEmailFailure() to delegate to canonical classifyBounce().
    • Maps SPAM_REJECTION, POLICY_REJECTION, and AUTHENTICATION_REJECTION to EmailFailureCategory.POLICY (retryable: false, isHardBounce: false).
    • Maps MAILBOX_UNAVAILABLE, DOMAIN_UNAVAILABLE, and HARD_BOUNCE to EmailFailureCategory.INVALID_RECIPIENT (isHardBounce: true).
    • In send(), records 'POLICY' failure against mailbox health.
    • In send(), gates automatic contact suppression on failure.isHardBounce === true, preventing false-positive suppression on policy blocks.
  5. Tests:
    • Added apps/api/src/services/email/unified-failure-classification.test.ts (12 tests).
    • Updated apps/api/src/services/email/outbound-provider-rejection-audit.test.ts Finding 1 tests to verify remediation.

Safety Impact

  • Prevents false-positive suppression of valid contact email addresses when sends are blocked by recipient domain authentication policies or blocklists.
  • Prevents reputation degradation from treating permanent reputation blocks as transient network hiccups.
  • Protects mailboxes by triggering cooldown or blocking upon repeated policy rejections.

Tests

  • pnpm --filter @leadforge/schema test: 125 passed (125)
  • pnpm --filter @leadforge/schema check-types: Clean (0 errors)
  • pnpm --filter api check-types: Clean (0 errors)
  • pnpm --filter api test src/services/email/: 18 test files passed (119 passed)

Verification

  • Automated test suites verify that:
    • 554 5.7.1 Spam detected is classified as POLICY (non-retryable, isHardBounce: false).
    • 550 5.7.26 DMARC/SPF is classified as POLICY (non-retryable, isHardBounce: false).
    • 550 5.1.1 User unknown is classified as INVALID_RECIPIENT (isHardBounce: true).
    • 452 Mailbox full is classified as PROVIDER (retryable: true, isHardBounce: false).
    • Mailbox health transitions to DEGRADED on 1st policy failure and BLOCKED on 3rd consecutive policy failure.
    • Valid contacts are not suppressed on policy rejections.

Scope

Related Issue

…ce categories

Delegate classifyEmailFailure() in EmailService to canonical classifyBounce() in @leadforge/schema. Correctly distinguish provider/network errors from permanent policy/spam rejection, avoiding treating reputation blocks as transient network failures. Update mailbox health repository to track POLICY failures, and ensure valid contacts are not falsely suppressed on policy rejections. Closes #32.
@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
leadforge-os-api Ready Ready Preview Sep 9, 2026 2:19pm UTC

@kjxcodez
kjxcodez merged commit 6f6bae7 into main Sep 9, 2026
7 of 9 checks passed
kjxcodez added a commit that referenced this pull request Sep 11, 2026
…suppression

fix(outreach): enforce company dnc and domain suppression cascade (#38)
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.

fix(email): unify provider failure classification with canonical bounce categories

1 participant