Skip to content

Commit 57e71f5

Browse files
fix(email-resend): support path and preserve base64 content in mapAttachments (#16094)
Fixes #16093 ## Problem The `mapAttachments` function in `@payloadcms/email-resend` has two bugs: ### Bug 1: `path` is not supported The function requires `content` to exist and throws if it is missing, but the Payload docs recommend using `path` for cloud storage attachments. The `path` property exists in the `Attachment` type but is never forwarded. ### Bug 2: Base64 content produces corrupt files When `content` is a string (base64), the code wraps it in `Buffer.from(attachment.content)`. Since Resend uses `JSON.stringify()` to serialize the request body, this Buffer becomes `{"type":"Buffer","data":[...]}` instead of the expected base64 string. The attachment arrives corrupt. ## Fix 1. Allow `path` as an alternative to `content` — forward it directly when content is absent 2. Keep string content as-is instead of converting to Buffer, preserving base64 encoding for the Resend REST API --------- Co-authored-by: Paul Popus <paul@payloadcms.com>
1 parent ade4501 commit 57e71f5

2 files changed

Lines changed: 115 additions & 5 deletions

File tree

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

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Payload } from 'payload'
2-
import { describe, afterEach, it, expect, vitest, Mock } from 'vitest'
2+
import { describe, afterEach, beforeEach, it, expect, vitest, Mock } from 'vitest'
33

44
import { resendAdapter } from './index.js'
55

@@ -55,6 +55,103 @@ describe('email-resend', () => {
5555
})
5656
})
5757

58+
describe('attachments', () => {
59+
beforeEach(() => {
60+
global.fetch = vitest.spyOn(global, 'fetch').mockImplementation(
61+
vitest.fn(() =>
62+
Promise.resolve({
63+
json: () => ({ id: 'test-id' }),
64+
}),
65+
) as Mock,
66+
) as Mock
67+
})
68+
69+
const adapter = () =>
70+
resendAdapter({ apiKey, defaultFromAddress, defaultFromName })({ payload: mockPayload })
71+
72+
it('should pass path-only attachments through', async () => {
73+
await adapter().sendEmail({
74+
from,
75+
to,
76+
subject,
77+
attachments: [{ filename: 'file.pdf', path: '/tmp/file.pdf' }],
78+
})
79+
80+
// @ts-expect-error
81+
const body = JSON.parse(global.fetch.mock.calls[0][1].body)
82+
expect(body.attachments).toStrictEqual([{ filename: 'file.pdf', path: '/tmp/file.pdf' }])
83+
})
84+
85+
it('should preserve base64 string content without converting to Buffer', async () => {
86+
const base64 = 'SGVsbG8gV29ybGQ='
87+
88+
await adapter().sendEmail({
89+
from,
90+
to,
91+
subject,
92+
attachments: [{ filename: 'hello.txt', content: base64 }],
93+
})
94+
95+
// @ts-expect-error
96+
const body = JSON.parse(global.fetch.mock.calls[0][1].body)
97+
expect(body.attachments).toStrictEqual([{ filename: 'hello.txt', content: base64 }])
98+
})
99+
100+
it('should pass Buffer content through', async () => {
101+
const buf = Buffer.from('hello')
102+
103+
await adapter().sendEmail({
104+
from,
105+
to,
106+
subject,
107+
attachments: [{ filename: 'hello.txt', content: buf }],
108+
})
109+
110+
// @ts-expect-error
111+
const body = JSON.parse(global.fetch.mock.calls[0][1].body)
112+
// Buffer serializes to { type: 'Buffer', data: [...] } via JSON.stringify
113+
expect(body.attachments[0].filename).toBe('hello.txt')
114+
expect(body.attachments[0].content).toMatchObject({ type: 'Buffer' })
115+
})
116+
117+
it('should throw when filename is missing', async () => {
118+
await expect(() =>
119+
adapter().sendEmail({
120+
from,
121+
to,
122+
subject,
123+
attachments: [{ content: 'data' }],
124+
}),
125+
).rejects.toThrow('Attachment is missing filename')
126+
})
127+
128+
it('should throw when both content and path are missing', async () => {
129+
await expect(() =>
130+
adapter().sendEmail({
131+
from,
132+
to,
133+
subject,
134+
attachments: [{ filename: 'file.txt' }],
135+
}),
136+
).rejects.toThrow('Attachment is missing both content and path')
137+
})
138+
139+
it('should prefer content over path when both are provided', async () => {
140+
const content = 'SGVsbG8='
141+
142+
await adapter().sendEmail({
143+
from,
144+
to,
145+
subject,
146+
attachments: [{ filename: 'file.txt', content, path: '/tmp/file.txt' }],
147+
})
148+
149+
// @ts-expect-error
150+
const body = JSON.parse(global.fetch.mock.calls[0][1].body)
151+
expect(body.attachments).toStrictEqual([{ filename: 'file.txt', content }])
152+
})
153+
})
154+
58155
it('should throw an error if the email fails to send', async () => {
59156
const errorResponse = {
60157
name: 'validation_error',

packages/email-resend/src/index.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,27 @@ function mapAttachments(
126126
return []
127127
}
128128

129-
return attachments.map((attachment) => {
130-
if (!attachment.filename || !attachment.content) {
131-
throw new APIError('Attachment is missing filename or content', 400)
129+
return attachments.map((attachment): Attachment => {
130+
if (!attachment.filename) {
131+
throw new APIError('Attachment is missing filename', 400)
132+
}
133+
134+
if (!attachment.content && !attachment.path) {
135+
throw new APIError('Attachment is missing both content and path', 400)
136+
}
137+
138+
// When both content and path are provided, content takes priority; path is ignored.
139+
if (attachment.path && !attachment.content) {
140+
const path = typeof attachment.path === 'string' ? attachment.path : attachment.path.href
141+
return {
142+
filename: attachment.filename,
143+
path,
144+
}
132145
}
133146

134147
if (typeof attachment.content === 'string') {
135148
return {
136-
content: Buffer.from(attachment.content),
149+
content: attachment.content,
137150
filename: attachment.filename,
138151
}
139152
}

0 commit comments

Comments
 (0)