Skip to content
Chapter 67Lesson 4

Send the email, write the audit log

The page loop is done: the parent counts invoices, walks the pages, and parks a placeholder downloadUrl on metadata. What it never does is finish — the run sits at running forever, no email lands, and the audit log has no record that the export happened. In this lesson you close the run with its two terminal side effects — a transactional email and an append-only audit row — each firing exactly once, even when the parent retries.

When you click Export, the run drives to completion: the export-ready email reaches your inbox within about ten seconds carrying the right org name, row count, and download URL; the inspector’s run panel flips to completed with the downloadUrl rendered; and the audit-log tail gains exactly one export.invoices.completed row.

The finished run — email sent, status flipped, one new audit row.

The whole lesson turns on the word exactly. A durable task can retry: after a crash or transient failure the runtime re-runs the parent body from the top, so any side effect written inline runs again on every attempt. That is fine for an idempotent UPDATE; it is a bug for an email. So the export-ready email is not an inline sendEmail call in the parent — it is its own triggerAndWait child task, keyed by a run-scoped idempotency key. Being a child buys two things an inline call can’t: its own retry policy, so a Resend hiccup retries the child rather than the whole export; and per-step deduplication, so a parent retry re-issues the same key, the platform returns the cached child result, and Resend is never touched twice. This is the most important decision in the lesson.

Like every task in this project, the child has no request context, so it re-derives tenancy by reading tenantDb(organizationId) from its payload. The recipient is requestedBy — whoever clicked Export — looked up through a tenant-scoped memberuser join rather than a bare user lookup, so an id that isn’t a member of this org can never reach a send. The org name comes from the global organization row.

A Resend suppression — the recipient bounced before, or unsubscribed — is an expected outcome, not a crash. The sendEmail adapter reads the suppression list at its edge and returns an err('forbidden', …) Result for a suppressed address, which the child forwards rather than throwing. A suppressed recipient is a deliverability fact about the user, not a fault in the export, so the run should still complete: you record the skip in the audit payload and move on.

That audit row is the second side effect, and where it sits matters. It is written after the email step, inside one tenantDb transaction alongside the exports-row update, so the two writes commit or roll back together — you record the outcome you shipped, not the intent you had. It is written as the system actor, actorUserId: null, because a task has no session; the null is information (“no human did this”), not a value you forgot to fill.

This lesson does not add a failure-email path: a permanently-failed export logs the failure but notifies no one.

A full export run ends at status: completed, the downloadUrl rendered, and one new export.invoices.completed row in the audit-log tail.
tested
Forcing a parent retry after the email step sends no second email — the child returns the cached result and never calls Resend again.
tested
When the recipient is on the suppression list, the run still completes, no email is sent, and the audit payload records emailSuppressed: true.
tested
The export-ready email arrives in your Resend-verified inbox with the right org name, row count, and download URL, within about ten seconds of completion.
untested

Implement the sendExportEmail child in trigger/send-export-email.ts and the parent’s two closing steps in trigger/export-invoices.ts against the brief and tests. Try it before opening the walkthrough.

Reference solution and walkthrough

The decision, side by side: the wrong shape calls sendEmail inline in the parent body; the right shape triggers a child task and waits for its Result.

// ...inside the parent run body, after the page loop:
const result = await sendEmail({
to: recipientEmail,
subject: 'Your invoice export is ready',
react: ExportReadyEmail({ orgName, rowCount: total, downloadUrl }),
idempotencyKey: `export-email:${organizationId}`,
});
const emailSuppressed = !result.ok;

Loses durability and per-step idempotency. The send runs in the parent’s body, so a parent retry re-runs it, and Resend’s idempotency key only dedups within a short window, not across an arbitrary retry gap. A transient Resend failure also takes the whole export down instead of retrying just the email.

trigger/send-export-email.ts in full.

export const sendExportEmail = schemaTask({
id: 'send-export-email',
schema: z.strictObject({
organizationId: z.string().min(1),
recipientUserId: z.string().min(1),
rowCount: z.int(),
downloadUrl: z.string(),
}),
run: async ({
organizationId,
recipientUserId,
rowCount,
downloadUrl,
}): Promise<Result<{ id: string }>> => {
const recipient = await tenantDb(organizationId).query.member.findFirst({
where: eq(member.userId, recipientUserId),
with: { user: true },
});
if (!recipient?.user) {
return err('not_found', 'The export recipient is no longer a member.');
}
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
});
const result = await sendEmail({
to: recipient.user.email,
subject: 'Your invoice export is ready',
react: ExportReadyEmail({
orgName: org?.name ?? 'your organization',
rowCount,
downloadUrl,
}),
idempotencyKey: `export-email:${organizationId}:${recipientUserId}:${rowCount}`,
});
if (!result.ok) {
console.info('send-export-email skipped', {
disposition: result.error.code,
});
return result;
}
console.info('send-export-email sent', {
messageId: result.data.id,
disposition: 'sent',
});
return result;
},
});

A schemaTask with a strict object payload. The ids are z.string().min(1), not z.uuid(), because the seed assigns base62 text ids like org_acme. rowCount and downloadUrl come from the parent, which owns the URL; the child only delivers it.

export const sendExportEmail = schemaTask({
id: 'send-export-email',
schema: z.strictObject({
organizationId: z.string().min(1),
recipientUserId: z.string().min(1),
rowCount: z.int(),
downloadUrl: z.string(),
}),
run: async ({
organizationId,
recipientUserId,
rowCount,
downloadUrl,
}): Promise<Result<{ id: string }>> => {
const recipient = await tenantDb(organizationId).query.member.findFirst({
where: eq(member.userId, recipientUserId),
with: { user: true },
});
if (!recipient?.user) {
return err('not_found', 'The export recipient is no longer a member.');
}
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
});
const result = await sendEmail({
to: recipient.user.email,
subject: 'Your invoice export is ready',
react: ExportReadyEmail({
orgName: org?.name ?? 'your organization',
rowCount,
downloadUrl,
}),
idempotencyKey: `export-email:${organizationId}:${recipientUserId}:${rowCount}`,
});
if (!result.ok) {
console.info('send-export-email skipped', {
disposition: result.error.code,
});
return result;
}
console.info('send-export-email sent', {
messageId: result.data.id,
disposition: 'sent',
});
return result;
},
});

The recipient is read through the tenant-scoped memberuser join. That is the guard: tenantDb(organizationId) scopes the lookup to this org’s members, so an arbitrary user id can never resolve to an email. No member, no send: return err('not_found', …) and stop.

export const sendExportEmail = schemaTask({
id: 'send-export-email',
schema: z.strictObject({
organizationId: z.string().min(1),
recipientUserId: z.string().min(1),
rowCount: z.int(),
downloadUrl: z.string(),
}),
run: async ({
organizationId,
recipientUserId,
rowCount,
downloadUrl,
}): Promise<Result<{ id: string }>> => {
const recipient = await tenantDb(organizationId).query.member.findFirst({
where: eq(member.userId, recipientUserId),
with: { user: true },
});
if (!recipient?.user) {
return err('not_found', 'The export recipient is no longer a member.');
}
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
});
const result = await sendEmail({
to: recipient.user.email,
subject: 'Your invoice export is ready',
react: ExportReadyEmail({
orgName: org?.name ?? 'your organization',
rowCount,
downloadUrl,
}),
idempotencyKey: `export-email:${organizationId}:${recipientUserId}:${rowCount}`,
});
if (!result.ok) {
console.info('send-export-email skipped', {
disposition: result.error.code,
});
return result;
}
console.info('send-export-email sent', {
messageId: result.data.id,
disposition: 'sent',
});
return result;
},
});

The org name comes from the global organization row, not tenant data the join would carry, so it’s a plain db.query. The ?? 'your organization' fallback keeps the email sensible if the row vanishes mid-run.

export const sendExportEmail = schemaTask({
id: 'send-export-email',
schema: z.strictObject({
organizationId: z.string().min(1),
recipientUserId: z.string().min(1),
rowCount: z.int(),
downloadUrl: z.string(),
}),
run: async ({
organizationId,
recipientUserId,
rowCount,
downloadUrl,
}): Promise<Result<{ id: string }>> => {
const recipient = await tenantDb(organizationId).query.member.findFirst({
where: eq(member.userId, recipientUserId),
with: { user: true },
});
if (!recipient?.user) {
return err('not_found', 'The export recipient is no longer a member.');
}
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
});
const result = await sendEmail({
to: recipient.user.email,
subject: 'Your invoice export is ready',
react: ExportReadyEmail({
orgName: org?.name ?? 'your organization',
rowCount,
downloadUrl,
}),
idempotencyKey: `export-email:${organizationId}:${recipientUserId}:${rowCount}`,
});
if (!result.ok) {
console.info('send-export-email skipped', {
disposition: result.error.code,
});
return result;
}
console.info('send-export-email sent', {
messageId: result.data.id,
disposition: 'sent',
});
return result;
},
});

The render goes to sendEmail as react, with a stable per-recipient idempotencyKey built from the org, recipient, and row count. This guards the Resend call against the child retrying itself; the run-scoped key in the parent guards the parent retry.

export const sendExportEmail = schemaTask({
id: 'send-export-email',
schema: z.strictObject({
organizationId: z.string().min(1),
recipientUserId: z.string().min(1),
rowCount: z.int(),
downloadUrl: z.string(),
}),
run: async ({
organizationId,
recipientUserId,
rowCount,
downloadUrl,
}): Promise<Result<{ id: string }>> => {
const recipient = await tenantDb(organizationId).query.member.findFirst({
where: eq(member.userId, recipientUserId),
with: { user: true },
});
if (!recipient?.user) {
return err('not_found', 'The export recipient is no longer a member.');
}
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
});
const result = await sendEmail({
to: recipient.user.email,
subject: 'Your invoice export is ready',
react: ExportReadyEmail({
orgName: org?.name ?? 'your organization',
rowCount,
downloadUrl,
}),
idempotencyKey: `export-email:${organizationId}:${recipientUserId}:${rowCount}`,
});
if (!result.ok) {
console.info('send-export-email skipped', {
disposition: result.error.code,
});
return result;
}
console.info('send-export-email sent', {
messageId: result.data.id,
disposition: 'sent',
});
return result;
},
});

The suppression branch. sendEmail returns err('forbidden', …) for a suppressed address, and the child returns that Result rather than throwing: a throw would fail the run over a deliverability fact about the user. The log records the disposition, never the address.

export const sendExportEmail = schemaTask({
id: 'send-export-email',
schema: z.strictObject({
organizationId: z.string().min(1),
recipientUserId: z.string().min(1),
rowCount: z.int(),
downloadUrl: z.string(),
}),
run: async ({
organizationId,
recipientUserId,
rowCount,
downloadUrl,
}): Promise<Result<{ id: string }>> => {
const recipient = await tenantDb(organizationId).query.member.findFirst({
where: eq(member.userId, recipientUserId),
with: { user: true },
});
if (!recipient?.user) {
return err('not_found', 'The export recipient is no longer a member.');
}
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
});
const result = await sendEmail({
to: recipient.user.email,
subject: 'Your invoice export is ready',
react: ExportReadyEmail({
orgName: org?.name ?? 'your organization',
rowCount,
downloadUrl,
}),
idempotencyKey: `export-email:${organizationId}:${recipientUserId}:${rowCount}`,
});
if (!result.ok) {
console.info('send-export-email skipped', {
disposition: result.error.code,
});
return result;
}
console.info('send-export-email sent', {
messageId: result.data.id,
disposition: 'sent',
});
return result;
},
});

The happy-path log keeps the same discipline: a messageId and disposition: 'sent', no recipient PII. Then return the ok Result for the parent to unwrap.

1 / 1

The sendEmail adapter and ExportReadyEmail template are both provided. sendEmail is the Resend layer from the email chapter; it reads the suppression list and returns the err('forbidden', …) Result the child forwards. You wire them into a task, not re-implement them.

The parent body was built last lesson, through the page loop and metadata.set('downloadUrl', …). Append two steps: the email child, then the close-out transaction.

7 collapsed lines
// Side effect #1 — the ready email, as its own triggerAndWait child keyed by
// [organizationId, 'export-email'] (run-scoped: a parent retry re-issues the same
// key, so the child returns its cached result and Resend is never called twice).
// It is a child task — not an inline sendEmail call — for that durability +
// idempotency. The recipient is `requestedBy` (the user who clicked Export); an
// org-owner override is named-not-built. A suppression returns an err Result from
// the child (the run still completes, the audit note records the skip).
const emailResult = await sendExportEmail
.triggerAndWait(
{
organizationId,
recipientUserId: requestedBy,
rowCount: total,
downloadUrl,
},
{
idempotencyKey: await idempotencyKeys.create([
organizationId,
'export-email',
]),
},
)
.unwrap();
const emailSuppressed = !emailResult.ok;
7 collapsed lines
// Side effect #2 — close the run: update the exports row to `completed` and write
// the export.invoices.completed audit entry in ONE tenantDb transaction (the audit
// INSERT needs the transaction-local app.org_id the facade sets, and the two writes
// commit or roll back together). The audit write comes AFTER the email — we audit
// the outcome we shipped, not the intent. logAudit is called with explicit context
// (organizationId + actorUserId: null): a task has no session, so the system-actor
// null is information, not a missing value.
await tenantDb(organizationId).transaction(async (tx) => {
await tx
.update(exports)
.set({
status: 'completed',
rowCount: total,
completedAt: new Date(),
})
.where(eq(exports.runId, ctx.run.id));
await logAudit(tx, {
action: 'export.invoices.completed',
subjectType: 'export',
subjectId: ctx.run.id,
organizationId,
actorUserId: null,
payload: { rowCount: total, emailSuppressed },
});
});
return { ok: true, runId: ctx.run.id, rowCount: total };

const emailSuppressed = !emailResult.ok derives the flag from the unwrapped Result, so it reads true only when the child skipped, never hard-coded.

The close-out is one transaction on purpose. The audit INSERT needs the transaction-local app.org_id the tenantDb facade sets for row-level security; outside the transaction it has no tenant context to write under. Sharing the transaction with the exports-row update also commits or rolls back both writes as a unit, so no row flips to completed without an audit trail. The email runs first by design: by the time the audit row is written, emailSuppressed reflects what really happened, so the row records the outcome, not the intent.

The logAudit writer, the auditLogs table, and the tenantDb facade’s app.org_id were all built when you wired the audit log and tenancy. The downloadUrl is still the parent’s placeholder; it becomes a real object-storage link next chapter.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 4

These tests run your task bodies in-process and fake only this lesson’s seams, so they never touch the live platform. Expect every test green.

The tests cover the child’s Result and the audit payload, not real delivery or a live run. Confirm the rest by hand against a live worker and a seeded org:

Running a full export against a seeded org drives the progress bar to completion, flips the run panel to completed with the downloadUrl rendered, adds one export.invoices.completed row to the audit tail, and lands the ExportReadyEmail in your inbox within about ten seconds.
untested
Forcing a parent retry (the dashboard’s “Replay run”, or a debug throw right after the email step returns) re-issues the same [organizationId, 'export-email'] key, serves the cached { id }, and lands no second email.
untested
Inserting the seeded recipient’s email into emailSuppressions and running an export makes sendEmail return a forbidden Result, the run still completes, and the audit payload records emailSuppressed: true.
untested

That finishes the export; the project is now feature-complete, with only the wrap-up to go.