Skip to content

Commit a7dd17c

Browse files
feat(email-resend): add Custom headers for the Resend adapter (#15645)
After some trying (and failing) to enable unsubscribe headers, I found that the Resend adapter does not pass custom headers from the Payload sendEmail options to the Resend API. When attempting to send an email with custom headers, they are ignored because the `mapPayloadEmailToResendEmail` function creates a new object that excludes the headers property. ``` await payload.sendEmail({ from: "Test <test@domain.com>", to: "jimmybillbob@example.com", subject: "Resend adapter does not allow custom headers :(", html: html, headers: { "List-Unsubscribe": "<https://domain.com/unsubscribe>", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", }, }); ``` I have recitified this by adding `headers: message.headers,` to the `mapPayloadEmailToResendEmail` in `packages/email-resend/src/index.ts`, as below. ``` function mapPayloadEmailToResendEmail( message: SendEmailOptions, defaultFromAddress: string, defaultFromName: string, ): ResendSendEmailOptions { return { // Required from: mapFromAddress(message.from, defaultFromName, defaultFromAddress), subject: message.subject ?? '', to: mapAddresses(message.to), // Other To fields bcc: mapAddresses(message.bcc), cc: mapAddresses(message.cc), reply_to: mapAddresses(message.replyTo), // Optional attachments: mapAttachments(message.attachments), html: message.html?.toString() || '', text: message.text?.toString() || '', headers: message.headers, // Added this line. } as ResendSendEmailOptions } ``` --------- Co-authored-by: Paul Popus <paul@payloadcms.com>
1 parent 4179bf3 commit a7dd17c

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

packages/email-resend/src/email-resend.spec.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,88 @@ describe('email-resend', () => {
152152
})
153153
})
154154

155+
describe('headers', () => {
156+
beforeEach(() => {
157+
global.fetch = vitest.spyOn(global, 'fetch').mockImplementation(
158+
vitest.fn(() =>
159+
Promise.resolve({
160+
json: () => ({ id: 'test-id' }),
161+
}),
162+
) as Mock,
163+
) as Mock
164+
})
165+
166+
const adapter = () =>
167+
resendAdapter({ apiKey, defaultFromAddress, defaultFromName })({ payload: mockPayload })
168+
169+
it('should pass simple string headers through as-is', async () => {
170+
await adapter().sendEmail({
171+
from,
172+
to,
173+
subject,
174+
headers: { 'List-Unsubscribe': '<mailto:unsub@example.com>' },
175+
})
176+
177+
// @ts-expect-error
178+
const body = JSON.parse(global.fetch.mock.calls[0][1].body)
179+
expect(body.headers).toStrictEqual({ 'List-Unsubscribe': '<mailto:unsub@example.com>' })
180+
})
181+
182+
it('should join array string values with a comma', async () => {
183+
await adapter().sendEmail({
184+
from,
185+
to,
186+
subject,
187+
headers: { 'X-Custom': ['val1', 'val2'] },
188+
})
189+
190+
// @ts-expect-error
191+
const body = JSON.parse(global.fetch.mock.calls[0][1].body)
192+
expect(body.headers).toStrictEqual({ 'X-Custom': 'val1, val2' })
193+
})
194+
195+
it('should extract the value from prepared-object header values', async () => {
196+
await adapter().sendEmail({
197+
from,
198+
to,
199+
subject,
200+
headers: { 'X-Prepared': { prepared: true, value: 'prepared-value' } },
201+
})
202+
203+
// @ts-expect-error
204+
const body = JSON.parse(global.fetch.mock.calls[0][1].body)
205+
expect(body.headers).toStrictEqual({ 'X-Prepared': 'prepared-value' })
206+
})
207+
208+
it('should convert array-of-objects header form to a plain object', async () => {
209+
await adapter().sendEmail({
210+
from,
211+
to,
212+
subject,
213+
headers: [
214+
{ key: 'X-First', value: 'first' },
215+
{ key: 'X-Second', value: 'second' },
216+
],
217+
})
218+
219+
// @ts-expect-error
220+
const body = JSON.parse(global.fetch.mock.calls[0][1].body)
221+
expect(body.headers).toStrictEqual({ 'X-First': 'first', 'X-Second': 'second' })
222+
})
223+
224+
it('should omit the headers field when headers are undefined', async () => {
225+
await adapter().sendEmail({
226+
from,
227+
to,
228+
subject,
229+
})
230+
231+
// @ts-expect-error
232+
const body = JSON.parse(global.fetch.mock.calls[0][1].body)
233+
expect(body).not.toHaveProperty('headers')
234+
})
235+
})
236+
155237
it('should throw an error if the email fails to send', async () => {
156238
const errorResponse = {
157239
name: 'validation_error',

packages/email-resend/src/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ function mapPayloadEmailToResendEmail(
8282

8383
// Optional
8484
attachments: mapAttachments(message.attachments),
85+
headers: mapHeaders(message.headers),
8586
html: message.html?.toString() || '',
8687
text: message.text?.toString() || '',
8788
} as ResendSendEmailOptions
@@ -162,6 +163,32 @@ function mapAttachments(
162163
})
163164
}
164165

166+
function mapHeaders(headers: SendEmailOptions['headers']): Record<string, string> | undefined {
167+
if (!headers) {
168+
return undefined
169+
}
170+
171+
// Array-of-objects form: [{ key: string; value: string }, ...]
172+
if (Array.isArray(headers)) {
173+
return headers.reduce<Record<string, string>>((acc, { key, value }) => {
174+
acc[key] = value
175+
return acc
176+
}, {})
177+
}
178+
179+
// Object form: { [key: string]: string | string[] | { prepared: boolean; value: string } }
180+
return Object.entries(headers).reduce<Record<string, string>>((acc, [key, value]) => {
181+
if (typeof value === 'string') {
182+
acc[key] = value
183+
} else if (Array.isArray(value)) {
184+
acc[key] = value.join(', ')
185+
} else {
186+
acc[key] = value.value
187+
}
188+
return acc
189+
}, {})
190+
}
191+
165192
type ResendSendEmailOptions = {
166193
/**
167194
* Filename and content of attachments (max 40mb per email)

0 commit comments

Comments
 (0)