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's `content` is a Buffer, a Uint8Array or a Base64 string. A Resend attachment with a `path` returns an `invalid_attachment` error, because PostHog can't fetch files. 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.
99 changes: 97 additions & 2 deletions packages/node/references/posthog-node-references-latest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2039,11 +2039,30 @@
"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": "Bare addresses, such as `'finance@example.com'`, that get a copy.",
"type": "string | string[]",
"name": "cc"
},
{
"description": "A verified sender, such as `'Acme <security@acme.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 sets the standard ones itself.",
"type": "Record<string, string>",
"name": "headers"
},
{
"type": "string | string[]",
"name": "replyTo"
Expand All @@ -2052,6 +2071,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 when PostHog does not hold its content yet.",
"type": "Record<string, unknown>",
Expand Down Expand Up @@ -2145,12 +2169,44 @@
"properties": [],
"path": "../core/src/cookie.ts"
},
{
"id": "EmailAttachment",
"name": "EmailAttachment",
"properties": [
{
"description": "The file's bytes, or its content already Base64 encoded.",
"type": "Buffer | 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.ts"
},
{
"id": "EmailError",
"name": "EmailError",
"properties": [
{
"description": "PostHog's error code, such as `invalid_recipient`, or one of the SDK's own: `missing_secret_key`, `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` for an answer without a code, `unexpected_error`, and `invalid_form`, `invalid_template_id`, `invalid_template_key` and `invalid_idempotency_key` for a send the SDK refused before any request.",
"description": "PostHog's error code, such as `invalid_recipient`, or one of the SDK's own: `missing_secret_key`, `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` for an answer without a code, `unexpected_error`, and `invalid_form`, `invalid_template_id`, `invalid_template_key`, `invalid_attachment` and `invalid_idempotency_key` for a send the SDK refused before any request.",
"type": "string",
"name": "code"
},
Expand All @@ -2160,7 +2216,7 @@
"name": "docsUrl"
},
{
"description": "The request field the error is about.",
"description": "The request field the error is about, such as `cc` or `attachments__0__filename`.",
"type": "string",
"name": "field"
},
Expand Down Expand Up @@ -2204,6 +2260,21 @@
"properties": [],
"path": "src/emails.ts"
},
{
"id": "EmailTag",
"name": "EmailTag",
"properties": [
{
"type": "string",
"name": "name"
},
{
"type": "string",
"name": "value"
}
],
"path": "src/emails.ts"
},
{
"id": "EmailWarning",
"name": "EmailWarning",
Expand Down Expand Up @@ -4487,6 +4558,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 sets the standard ones itself.",
"type": "never",
"name": "headers"
},
{
"description": "Added to the email's engagement events as `$email_tags`.",
"type": "never",
"name": "tags"
},
{
"type": "never",
"name": "attachments"
},
{
"type": "never",
"name": "html"
Expand Down
104 changes: 104 additions & 0 deletions packages/node/src/__tests__/emails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,32 @@ describe('emails', () => {
const welcome = { templateKey: 'welcome', subject: 'Welcome, {{ variables.name }}', html: '<p>Hi</p>' }
const sendLocalWelcome = (posthog: PostHog, content: Partial<typeof welcome> = {}) =>
posthog.emails.send({ to: JANE, distinctId: 'user_42', ...welcome, ...content, templateData: { name: 'Jane' } })
const receipt = { templateKey: 'receipt', subject: 'Your receipt', html: '<p>Thanks</p>' }
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 extrasOnTheWire = {
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') },
],
}

it.each([
[
Expand Down Expand Up @@ -203,6 +229,11 @@ describe('emails', () => {
reply_to: ['a@example.com', 'b@example.com'],
},
],
[
'copies, headers, tags and attachments',
{ ...receipt, ...extras },
{ template_key: 'receipt', subject: 'Your receipt', html: '<p>Thanks</p>', ...extrasOnTheWire },
],
])('sends rendered content with %s as is, without an upload', async (_, content, body) => {
const result = await createClient().emails.send({ to: JANE, distinctId: 'user_42', ...content })

Expand Down Expand Up @@ -238,6 +269,23 @@ describe('emails', () => {
])
})

it('sends copies, headers, tags and attachments with both sends of a local template, and never uploads them', async () => {
const result = await createClient().emails.send({ to: JANE, ...receipt, ...extras, templateData: { total: 42 } })

expect(result.error).toBeNull()
expect(requestLog()).toEqual([SEND, 'PUT /api/projects/@current/messaging_templates/by_key/receipt/', SEND])
const [firstSend, upload, secondSend] = api.received
expect(firstSend.body).toEqual(expect.objectContaining(extrasOnTheWire))
expect(secondSend.body).toEqual(firstSend.body)
expect(Object.keys(upload.body).sort()).toEqual([
'html',
'if_code_managed',
'if_not_superseded',
'name',
'subject',
])
})

it('sends a local template PostHog holds in one request, and uploads it again once it changes', async () => {
const posthog = createClient()
await sendLocalWelcome(posthog)
Expand Down Expand Up @@ -293,6 +341,7 @@ describe('emails', () => {
it.each([
[400, 'invalid_recipient', 'validation_error'],
[400, 'transactional_email_paused', 'validation_error'],
[400, 'invalid_request', 'validation_error'],
[400, 'invalid_idempotency_key', 'validation_error'],
[401, 'authentication_failed', 'missing_api_key'],
[403, 'permission_denied', 'invalid_permission'],
Expand Down Expand Up @@ -393,6 +442,30 @@ describe('emails', () => {
{},
{ templateId: undefined, templateKey: '../welcome', subject: 'Hi', html: '<p>Hi</p>' },
],
[
'invalid_attachment',
'validation_error',
{},
{
templateId: undefined,
templateKey: 'invoice',
subject: 'Hi',
html: '<p>Hi</p>',
attachments: [{ filename: 'invoice.pdf', content: 42 }],
},
],
[
'invalid_attachment',
'validation_error',
{},
{
templateId: undefined,
templateKey: 'invoice',
subject: 'Hi',
html: '<p>Hi</p>',
attachments: { filename: 'invoice.pdf', content: 'aW52b2ljZQ==' },
},
],
])('returns %s as %s without a request', async (code, name, clientOptions: PostHogOptions, sendOptions) => {
const result = await createClient(clientOptions).emails.send({
to: JANE,
Expand Down Expand Up @@ -427,6 +500,15 @@ describe('emails', () => {
['subject on the template form', { templateId: TEMPLATE_ID, subject: 'Hi' }, 'subject'],
['html on the template form', { templateId: TEMPLATE_ID, html: '<p>Hi</p>' }, 'html'],
['text on the template form', { templateId: TEMPLATE_ID, text: 'Hi' }, 'text'],
['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', { templateKey: 'welcome', subject: 'Hi', html: 1 }, 'html'],
Expand Down Expand Up @@ -457,6 +539,28 @@ describe('emails', () => {
expect(api.received).toEqual([])
})

it("refuses a Resend attachment path, which PostHog can't fetch, without a request", async () => {
const resendAttachment = { filename: 'invoice.pdf', path: 'https://example.com/invoice.pdf' }

const result = await createClient().emails.send({
to: JANE,
templateKey: 'invoice',
subject: 'Your invoice',
html: '<p>Attached</p>',
attachments: [{ filename: 'terms.txt', content: 'dGVybXM=' }, resendAttachment],
} as SendEmailOptions)

expect(result.error).toEqual(
expect.objectContaining({
name: 'validation_error',
code: 'invalid_attachment',
field: 'attachments__1__path',
message: expect.stringContaining('content'),
})
)
expect(api.received).toEqual([])
})

it('returns unexpected_error when the template data cannot be encoded', async () => {
const result = await createClient().emails.send({
to: JANE,
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 @@ -7,6 +7,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' },
{ 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 @@ -18,4 +33,16 @@ 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: '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b', cc: 'finance@example.com' },
// @ts-expect-error a template made in PostHog keeps its own attachments
{ to, templateId: '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b', 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' }],
},
]
Loading
Loading