Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/node-emails-code-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-node': minor
---

The code form of `posthog.emails.send()` takes `cc`, `bcc`, `headers`, `tags` and `attachments`. An attachment is an object whose `content` is a Buffer, a Uint8Array or a Base64 string, and anything else returns an `invalid_attachment` error. A Resend attachment with a `path` returns an `invalid_attachment` error, because PostHog can't fetch files, and so does an attachment key the SDK doesn't send, such as `content_id`. A PostHog server that doesn't take one of these fields yet refuses the send with an `invalid_request` error on that field, instead of sending without it.
95 changes: 95 additions & 0 deletions packages/node/references/posthog-node-references-latest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2039,16 +2039,35 @@
"id": "CodeEmailFields",
"name": "CodeEmailFields",
"properties": [
{
"type": "EmailAttachment[]",
"name": "attachments"
},
{
"description": "Bare addresses that get a copy the other recipients never see.",
"type": "string | string[]",
"name": "bcc"
},
{
"description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as events of the recipient. Defaults to `true`. A project whose admin turned engagement capture off captures nothing, whatever the send asks. A template made in PostHog follows the project's setting.",
"type": "boolean",
"name": "captureEngagement"
},
{
"description": "Bare addresses, such as `'finance@example.com'`, that get a copy.",
"type": "string | string[]",
"name": "cc"
},
{
"description": "A verified sender, such as `'Acme <security@example.com>'`. Without it, the project's default sender.",
"type": "string",
"name": "from"
},
{
"description": "Custom email headers, such as `{ 'X-Entity-Ref-ID': 'order-123' }`. PostHog refuses a header it sets itself, such as `From`, `Subject` or `Content-Type`: use the send's own fields instead.",
"type": "Record<string, string>",
"name": "headers"
},
{
"type": "string | string[]",
"name": "replyTo"
Expand All @@ -2057,6 +2076,11 @@
"type": "string",
"name": "subject"
},
{
"description": "Added to the email's engagement events as `$email_tags`.",
"type": "EmailTag[]",
"name": "tags"
},
{
"description": "Leave it out to send rendered content: the email goes out as is, and PostHog never stores it. Pass it to send a local template: PostHog fills its `{{ variables.* }}`, and the SDK uploads the template whenever PostHog doesn't hold its content yet. A local template needs `html`. Content that PostHog already replaced with newer content isn't uploaded again: the email goes out with the newer content, and the send returns a `template_superseded` warning.",
"type": "Record<string, unknown>",
Expand Down Expand Up @@ -2155,6 +2179,38 @@
"properties": [],
"path": "../core/src/cookie.ts"
},
{
"id": "EmailAttachment",
"name": "EmailAttachment",
"properties": [
{
"description": "The file's bytes, or its content already Base64 encoded.",
"type": "Uint8Array | string",
"name": "content"
},
{
"description": "Shows the file inline where the HTML has `<img src=\"cid:...\">` with this id.",
"type": "string",
"name": "contentId"
},
{
"description": "A MIME type, such as `'application/pdf'`.",
"type": "string",
"name": "contentType"
},
{
"description": "The name the recipient sees, such as `'invoice.pdf'`.",
"type": "string",
"name": "filename"
},
{
"description": "PostHog can't fetch a file from a path or URL: read the file and pass its `content`.",
"type": "never",
"name": "path"
}
],
"path": "src/emails/types.ts"
},
{
"id": "EmailDeliveryStatus",
"name": "EmailDeliveryStatus",
Expand Down Expand Up @@ -2249,6 +2305,21 @@
],
"path": "src/emails/types.ts"
},
{
"id": "EmailTag",
"name": "EmailTag",
"properties": [
{
"type": "string",
"name": "name"
},
{
"type": "string",
"name": "value"
}
],
"path": "src/emails/types.ts"
},
{
"id": "EmailWarning",
"name": "EmailWarning",
Expand Down Expand Up @@ -4540,6 +4611,30 @@
"type": "never",
"name": "replyTo"
},
{
"description": "Bare addresses, such as `'finance@example.com'`, that get a copy.",
"type": "never",
"name": "cc"
},
{
"description": "Bare addresses that get a copy the other recipients never see.",
"type": "never",
"name": "bcc"
},
{
"description": "Custom email headers, such as `{ 'X-Entity-Ref-ID': 'order-123' }`. PostHog refuses a header it\nsets itself, such as `From`, `Subject` or `Content-Type`: use the send's own fields instead.",
"type": "never",
"name": "headers"
},
{
"description": "Added to the email's engagement events as `$email_tags`.",
"type": "never",
"name": "tags"
},
{
"type": "never",
"name": "attachments"
},
{
"description": "Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked,\nbounced) as events of the recipient. Defaults to `true`. A project whose admin turned\nengagement capture off captures nothing, whatever the send asks. A template made in PostHog\nfollows the project's setting.",
"type": "never",
Expand Down
89 changes: 89 additions & 0 deletions packages/node/src/__tests__/emails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,32 @@ const rejected = (status: number, code?: string, headers: Record<string, string>
headers,
})

const REPORT = Buffer.alloc(40_000, 'report')
const EXTRAS = {
cc: 'finance@example.com',
bcc: ['audit@example.com', 'archive@example.com'],
headers: { 'X-Entity-Ref-ID': 'order-123' },
tags: [{ name: 'category', value: 'receipt' }],
attachments: [
{ filename: 'receipt.txt', content: Buffer.from('hello') },
{ filename: 'logo.png', content: new Uint8Array([0, 255, 1]), contentType: 'image/png', contentId: 'logo' },
{ filename: 'invite.ics', content: 'QkVHSU46VkNBTEVOREFS', contentType: 'text/calendar' },
{ filename: 'report.txt', content: REPORT },
],
}
const EXTRAS_ON_THE_WIRE = {
cc: ['finance@example.com'],
bcc: ['audit@example.com', 'archive@example.com'],
headers: { 'X-Entity-Ref-ID': 'order-123' },
tags: [{ name: 'category', value: 'receipt' }],
attachments: [
{ filename: 'receipt.txt', content: 'aGVsbG8=' },
{ filename: 'logo.png', content: 'AP8B', content_type: 'image/png', content_id: 'logo' },
{ filename: 'invite.ics', content: 'QkVHSU46VkNBTEVOREFS', content_type: 'text/calendar' },
{ filename: 'report.txt', content: REPORT.toString('base64') },
],
}

describe('emails', () => {
vi.useFakeTimers()

Expand Down Expand Up @@ -170,6 +196,17 @@ describe('emails', () => {
capture_engagement: true,
},
],
[
'copies, headers, tags and attachments',
{ templateKey: 'receipt', subject: 'Your receipt', html: '<p>Thanks</p>', ...EXTRAS },
{
template_key: 'receipt',
subject: 'Your receipt',
html: '<p>Thanks</p>',
...EXTRAS_ON_THE_WIRE,
capture_engagement: true,
},
],
])('posts content from code with %s to the same send URL', async (_name, content, body) => {
replies = [accepted()]

Expand Down Expand Up @@ -522,6 +559,15 @@ describe('emails', () => {
{ templateId: TEMPLATE_ID, captureEngagement: false },
'captureEngagement',
],
['cc on the template form', { templateId: TEMPLATE_ID, cc: 'finance@example.com' }, 'cc'],
['bcc on the template form', { templateId: TEMPLATE_ID, bcc: ['audit@example.com'] }, 'bcc'],
['headers on the template form', { templateId: TEMPLATE_ID, headers: { 'X-Entity-Ref-ID': '42' } }, 'headers'],
['tags on the template form', { templateId: TEMPLATE_ID, tags: [{ name: 'kind', value: 'invoice' }] }, 'tags'],
[
'attachments on the template form',
{ templateId: TEMPLATE_ID, attachments: [{ filename: 'a.txt', content: 'YQ==' }] },
'attachments',
],
['a code send without a subject', { templateKey: 'welcome', html: '<p>Hi</p>' }, 'subject'],
['a code send without html or text', { templateKey: 'welcome', subject: 'Hi' }, 'html'],
['a code send with html that is not a string', { ...CODE_SEND, html: 1 }, 'html'],
Expand All @@ -539,6 +585,32 @@ describe('emails', () => {
expect(fetch).not.toHaveBeenCalled()
})

it.each([
['attachments that are not a list', { filename: 'a.txt', content: 'YQ==' }, 'attachments'],
['an attachment without content', [{ filename: 'a.txt' }], 'attachments__0__content'],
["a file's bytes in place of an attachment", [Buffer.from('%PDF')], 'attachments__0'],
['a Base64 string in place of an attachment', ['JVBERi0='], 'attachments__0'],
['an attachment whose content is a number', [{ filename: 'a.txt', content: 42 }], 'attachments__0__content'],
[
'an attachment with a key it does not take',
[{ filename: 'logo.png', content: 'AA==', content_id: 'logo' }],
'attachments__0__content_id',
],
[
"a Resend attachment path, which PostHog can't fetch",
[
{ filename: 'terms.txt', content: 'dGVybXM=' },
{ filename: 'a.pdf', path: 'https://example.com/a.pdf' },
],
'attachments__1__path',
],
])('refuses %s before sending', async (_name, attachments, field) => {
const { error } = await sendForm({ ...CODE_SEND, attachments })

expect(error).toMatchObject({ code: 'invalid_attachment', name: 'validation_error', field })
expect(fetch).not.toHaveBeenCalled()
})

it.each([
[
'a sender on the template form',
Expand Down Expand Up @@ -664,6 +736,23 @@ describe('emails', () => {
expect(fetch).toHaveBeenCalledTimes(requestsMade)
})

it('sends copies, headers, tags and attachments with both sends, and never uploads them', async () => {
replies = [rejected(409, 'template_outdated'), uploaded(), accepted()]

await sendWelcome(EXTRAS)

const [firstSend, upload, secondSend] = bodies()
expect(firstSend).toMatchObject(EXTRAS_ON_THE_WIRE)
expect(secondSend).toEqual(firstSend)
expect(Object.keys(upload as object).sort()).toEqual([
'html',
'if_code_managed',
'if_not_superseded',
'name',
'subject',
])
})

it('never uploads content rendered for one recipient', async () => {
replies = [rejected(409, 'template_outdated')]

Expand Down
27 changes: 27 additions & 0 deletions packages/node/src/__tests__/types/emails-forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ export const forms: SendEmailOptions[] = [
{ to, templateKey: 'reset', subject: 'Reset', html: '<p>Reset</p>', replyTo: ['help@example.com'] },
{ to, templateKey: 'reset', subject: 'Reset', text: 'Reset', from: 'security@example.com', captureEngagement: false },
{ to, templateKey: 'welcome', subject: 'Hi {{ variables.name }}', html: '<p>Hi</p>', templateData: { name: 'J' } },
{
to,
templateKey: 'receipt',
subject: 'Receipt',
html: '<p>Thanks</p>',
cc: 'finance@example.com',
bcc: ['audit@example.com'],
headers: { 'X-Entity-Ref-ID': 'order-123' },
tags: [{ name: 'category', value: 'receipt' }],
attachments: [
{ filename: 'receipt.pdf', content: Buffer.from('pdf') },
{ filename: 'logo.png', content: new Uint8Array([1]), contentType: 'image/png', contentId: 'logo' },
{ filename: 'invite.ics', content: 'QkVHSU4=' },
],
},
]

export const wrongCombinations: SendEmailOptions[] = [
Expand All @@ -21,6 +36,18 @@ export const wrongCombinations: SendEmailOptions[] = [
{ to, templateKey: 'reset', subject: 'Reset' },
// @ts-expect-error the code form needs a subject
{ to, templateKey: 'reset', html: '<p>Reset</p>' },
// @ts-expect-error a template made in PostHog keeps its own copies
{ to, templateId, cc: 'finance@example.com' },
// @ts-expect-error a template made in PostHog keeps its own attachments
{ to, templateId, attachments: [{ filename: 'a.txt', content: 'YQ==' }] },
{
to,
templateKey: 'invoice',
subject: 'Invoice',
html: '<p>I</p>',
// @ts-expect-error PostHog can't fetch an attachment from a path
attachments: [{ filename: 'a.pdf', path: '/a.pdf' }],
},
// @ts-expect-error a local template needs html
{ to, templateKey: 'welcome', subject: 'Hi {{ variables.name }}', text: 'Hi', templateData: { name: 'J' } },
]
Loading
Loading