Skip to content

feat(lifecycle): attach the whitepaper PDF to the fulfillment email - #1092

Merged
blove merged 1 commit into
mainfrom
blove/whitepaper-email-attachment
Sep 9, 2026
Merged

feat(lifecycle): attach the whitepaper PDF to the fulfillment email#1092
blove merged 1 commit into
mainfrom
blove/whitepaper-email-attachment

Conversation

@blove

@blove blove commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

What

The whitepaper fulfillment email ("Here is the guide you requested") pointed at a URL. It now carries the guide as an attachment, with the link kept underneath as a fallback.

New body:

Here is the guide you requested, attached to this message.

If the attachment does not come through, it is also here:
https://threadplane.ai/whitepapers/angular.pdf

Read it when you have a quiet hour.
If something in it does not hold up in your own code, reply and tell me.

Subjects stay per-paper. The link is kept because corporate mail gateways do strip PDF attachments from an unfamiliar sender, and a link-free email would leave those recipients with nothing. It is still one link, still an approved one, so campaignDraftViolations passes unchanged.

How

Resend's attachments[].path takes a hosted URL and Resend fetches the bytes itself. So no PDF flows through the lifecycle function, nothing is bundled, and the attachment is always whatever generate-whitepaper.ts last deployed. The alternatives — inlining ~1.4 MB of base64 per send, or shipping the four PDFs inside the lifecycle artifact where they would drift from the website's generated copies — both cost more than they buy.

Attachment filenames match the ones WhitePaperForm.tsx puts on the on-page download.

The guard

Resend performs that fetch under the Threadplane sender, so the attachment is a closed registry, not a free-form field — the same shape as the existing campaignTemplate handling, and validated in the same place, after authorization, because the rule depends on the authorized job.kind:

  • only fulfill mail may carry an attachment (send_step throws)
  • at most one
  • filename must match /^[a-z0-9]+(?:-[a-z0-9]+)*\.pdf$/
  • path must be one of the four approved threadplane.ai deliverables

No code path can talk Resend into fetching an arbitrary URL on our behalf.

Failure behavior

If Resend cannot fetch the PDF it returns an error, and the existing classifyResendProviderError split applies unchanged: 4xx marks the job provider_rejected, 5xx/429 marks it unknown. No new retry path was added — a missing deliverable is something to hear about, not to silently retry.

Cleanup

dispatchRecipient took nine positional parameters and would have taken ten. Its message-shaped arguments collapse into one object. Two call sites, same file.

Verification

  • apps/lifecycle — 313 passed (was 305)
  • libs/growth — 547 passed
  • nx lint lifecycle growth — 0 errors
  • nx check lifecycle (tsc + Dawn) — green
  • nx build growth — green

Every guard was mutation-tested rather than assumed. Seven mutants, all killed:

Mutation Tests failed
path registry check removed 7
job-kind check removed 1
filename pattern removed 5
attachments never reach the provider payload 5
exactly-one-attachment check removed 2
send.ts drops the attachment 5
template attaches the wrong paper 6

Note that this ships behind the lifecycle app's own Vercel project, which ci.yml's promote job does not cover — worth a live send check after deploy.

🤖 Generated with Claude Code

The whitepaper fulfillment email pointed at a URL. It now carries the
guide as an attachment, with the link kept underneath as the fallback
for mail gateways that strip attachments from an unfamiliar sender.

Resend fetches the bytes from the public threadplane.ai path itself, so
no PDF flows through the lifecycle function and the attachment is always
whatever generate-whitepaper.ts last deployed.

Because Resend performs that fetch under the Threadplane sender, the
attachment is a closed registry rather than a free-form field: only
fulfill jobs may carry one, at most one, the filename must be a
lowercase PDF name, and the path must be one of the four approved
deliverables. Every guard is mutation-tested.

dispatchRecipient took nine positional parameters and would have taken
ten, so its message-shaped arguments collapse into one object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@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
threadplane Ready Ready Preview Sep 9, 2026 5:27pm UTC

Request Review

@blove
blove enabled auto-merge (squash) September 9, 2026 17:24

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@blove
blove merged commit 1852fcb into main Sep 9, 2026
355 of 397 checks passed
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.

1 participant