Skip to content

Commit ee984ce

Browse files
authored
Merge pull request #2 from vintasoftware/feat/attachments
Implement Attachments
2 parents f1d82ac + d38ea3f commit ee984ce

File tree

5 files changed

+358
-11
lines changed

5 files changed

+358
-11
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "vintasend-nodemailer",
3-
"version": "0.3.0",
3+
"version": "0.4.0",
44
"description": "",
55
"main": "dist/index.js",
66
"scripts": {
@@ -15,7 +15,7 @@
1515
"license": "MIT",
1616
"dependencies": {
1717
"nodemailer": "^6.10.0",
18-
"vintasend": "^0.3.0"
18+
"vintasend": "^0.4.0"
1919
},
2020
"devDependencies": {
2121
"@types/jest": "^29.5.14",
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import nodemailer from 'nodemailer';
2+
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
3+
import type { BaseNotificationBackend } from 'vintasend/dist/services/notification-backends/base-notification-backend';
4+
import type { BaseEmailTemplateRenderer } from 'vintasend/dist/services/notification-template-renderers/base-email-template-renderer';
5+
import type { DatabaseNotification } from 'vintasend/dist/types/notification';
6+
import type { StoredAttachment, AttachmentFile } from 'vintasend/dist/types/attachment';
7+
import { NodemailerNotificationAdapter, NodemailerNotificationAdapterFactory } from '../index';
8+
9+
jest.mock('nodemailer');
10+
11+
describe('NodemailerNotificationAdapter - Attachments', () => {
12+
const mockTransporter = {
13+
sendMail: jest.fn(),
14+
};
15+
16+
const mockTemplateRenderer = {
17+
render: jest.fn(),
18+
// biome-ignore lint/suspicious/noExplicitAny: any just for testing
19+
} as jest.Mocked<BaseEmailTemplateRenderer<any>>;
20+
21+
// biome-ignore lint/suspicious/noExplicitAny: any just for testing
22+
const mockBackend: jest.Mocked<BaseNotificationBackend<any>> = {
23+
persistNotification: jest.fn(),
24+
persistNotificationUpdate: jest.fn(),
25+
getAllFutureNotifications: jest.fn(),
26+
getAllFutureNotificationsFromUser: jest.fn(),
27+
getFutureNotificationsFromUser: jest.fn(),
28+
getFutureNotifications: jest.fn(),
29+
getAllPendingNotifications: jest.fn(),
30+
getPendingNotifications: jest.fn(),
31+
getNotification: jest.fn(),
32+
markAsRead: jest.fn(),
33+
filterAllInAppUnreadNotifications: jest.fn(),
34+
cancelNotification: jest.fn(),
35+
markAsSent: jest.fn(),
36+
markAsFailed: jest.fn(),
37+
storeContextUsed: jest.fn(),
38+
getUserEmailFromNotification: jest.fn(),
39+
filterInAppUnreadNotifications: jest.fn(),
40+
bulkPersistNotifications: jest.fn(),
41+
getAllNotifications: jest.fn(),
42+
getNotifications: jest.fn(),
43+
persistOneOffNotification: jest.fn(),
44+
persistOneOffNotificationUpdate: jest.fn(),
45+
getOneOffNotification: jest.fn(),
46+
getAllOneOffNotifications: jest.fn(),
47+
getOneOffNotifications: jest.fn(),
48+
getAttachmentFile: jest.fn(),
49+
deleteAttachmentFile: jest.fn(),
50+
getOrphanedAttachmentFiles: jest.fn(),
51+
getAttachments: jest.fn(),
52+
deleteNotificationAttachment: jest.fn(),
53+
findAttachmentFileByChecksum: jest.fn(),
54+
};
55+
56+
// biome-ignore lint/suspicious/noExplicitAny: any just for testing
57+
let mockNotification: DatabaseNotification<any>;
58+
59+
beforeEach(() => {
60+
jest.clearAllMocks();
61+
(nodemailer.createTransport as jest.Mock).mockReturnValue(mockTransporter);
62+
mockNotification = {
63+
id: '123',
64+
notificationType: 'EMAIL' as const,
65+
contextName: 'testContext',
66+
contextParameters: {},
67+
userId: '456',
68+
title: 'Test Notification',
69+
bodyTemplate: '/path/to/template',
70+
subjectTemplate: '/path/to/subject',
71+
extraParams: {},
72+
contextUsed: null,
73+
adapterUsed: null,
74+
status: 'PENDING_SEND' as const,
75+
sentAt: null,
76+
readAt: null,
77+
sendAfter: new Date(),
78+
};
79+
});
80+
81+
it('should report that it supports attachments', () => {
82+
const adapter = new NodemailerNotificationAdapterFactory().create(mockTemplateRenderer, false, {
83+
host: 'smtp.example.com',
84+
port: 587,
85+
secure: false,
86+
auth: {
87+
user: 'username',
88+
pass: 'password',
89+
},
90+
} as SMTPTransport.Options);
91+
92+
expect(adapter.supportsAttachments).toBe(true);
93+
});
94+
95+
it('should send email with single attachment', async () => {
96+
const adapter = new NodemailerNotificationAdapterFactory().create(mockTemplateRenderer, false, {
97+
host: 'smtp.example.com',
98+
port: 587,
99+
secure: false,
100+
auth: {
101+
user: 'username',
102+
pass: 'password',
103+
},
104+
} as SMTPTransport.Options);
105+
adapter.injectBackend(mockBackend);
106+
107+
const fileBuffer = Buffer.from('test file content');
108+
const mockFile: AttachmentFile = {
109+
read: jest.fn().mockResolvedValue(fileBuffer),
110+
stream: jest.fn(),
111+
url: jest.fn(),
112+
delete: jest.fn(),
113+
};
114+
115+
const attachment: StoredAttachment = {
116+
id: 'att-1',
117+
fileId: 'file-1',
118+
filename: 'test.pdf',
119+
contentType: 'application/pdf',
120+
size: fileBuffer.length,
121+
checksum: 'abc123',
122+
description: 'Test file',
123+
file: mockFile,
124+
createdAt: new Date(),
125+
storageMetadata: {},
126+
};
127+
128+
mockNotification.attachments = [attachment];
129+
130+
const context = { foo: 'bar' };
131+
const renderedTemplate = {
132+
subject: 'Test Subject',
133+
body: '<p>Test Body</p>',
134+
};
135+
const userEmail = 'user@example.com';
136+
137+
mockTemplateRenderer.render.mockResolvedValue(renderedTemplate);
138+
mockBackend.getUserEmailFromNotification.mockResolvedValue(userEmail);
139+
140+
await adapter.send(mockNotification, context);
141+
142+
expect(mockFile.read).toHaveBeenCalled();
143+
expect(mockTransporter.sendMail).toHaveBeenCalledWith({
144+
to: userEmail,
145+
subject: renderedTemplate.subject,
146+
html: renderedTemplate.body,
147+
attachments: [
148+
{
149+
filename: 'test.pdf',
150+
content: fileBuffer,
151+
contentType: 'application/pdf',
152+
},
153+
],
154+
});
155+
});
156+
157+
it('should send email with multiple attachments', async () => {
158+
const adapter = new NodemailerNotificationAdapterFactory().create(mockTemplateRenderer, false, {
159+
host: 'smtp.example.com',
160+
port: 587,
161+
secure: false,
162+
auth: {
163+
user: 'username',
164+
pass: 'password',
165+
},
166+
} as SMTPTransport.Options);
167+
adapter.injectBackend(mockBackend);
168+
169+
const fileBuffer1 = Buffer.from('file 1 content');
170+
const fileBuffer2 = Buffer.from('file 2 content');
171+
172+
const mockFile1: AttachmentFile = {
173+
read: jest.fn().mockResolvedValue(fileBuffer1),
174+
stream: jest.fn(),
175+
url: jest.fn(),
176+
delete: jest.fn(),
177+
};
178+
179+
const mockFile2: AttachmentFile = {
180+
read: jest.fn().mockResolvedValue(fileBuffer2),
181+
stream: jest.fn(),
182+
url: jest.fn(),
183+
delete: jest.fn(),
184+
};
185+
186+
const attachment1: StoredAttachment = {
187+
id: 'att-1',
188+
fileId: 'file-1',
189+
filename: 'document.pdf',
190+
contentType: 'application/pdf',
191+
size: fileBuffer1.length,
192+
checksum: 'abc123',
193+
description: 'PDF document',
194+
file: mockFile1,
195+
createdAt: new Date(),
196+
storageMetadata: {},
197+
};
198+
199+
const attachment2: StoredAttachment = {
200+
id: 'att-2',
201+
fileId: 'file-2',
202+
filename: 'image.png',
203+
contentType: 'image/png',
204+
size: fileBuffer2.length,
205+
checksum: 'def456',
206+
description: 'Image file',
207+
file: mockFile2,
208+
createdAt: new Date(),
209+
storageMetadata: {},
210+
};
211+
212+
mockNotification.attachments = [attachment1, attachment2];
213+
214+
const context = { foo: 'bar' };
215+
const renderedTemplate = {
216+
subject: 'Test Subject',
217+
body: '<p>Test Body</p>',
218+
};
219+
const userEmail = 'user@example.com';
220+
221+
mockTemplateRenderer.render.mockResolvedValue(renderedTemplate);
222+
mockBackend.getUserEmailFromNotification.mockResolvedValue(userEmail);
223+
224+
await adapter.send(mockNotification, context);
225+
226+
expect(mockFile1.read).toHaveBeenCalled();
227+
expect(mockFile2.read).toHaveBeenCalled();
228+
expect(mockTransporter.sendMail).toHaveBeenCalledWith({
229+
to: userEmail,
230+
subject: renderedTemplate.subject,
231+
html: renderedTemplate.body,
232+
attachments: [
233+
{
234+
filename: 'document.pdf',
235+
content: fileBuffer1,
236+
contentType: 'application/pdf',
237+
},
238+
{
239+
filename: 'image.png',
240+
content: fileBuffer2,
241+
contentType: 'image/png',
242+
},
243+
],
244+
});
245+
});
246+
247+
it('should send email without attachments when attachments array is empty', async () => {
248+
const adapter = new NodemailerNotificationAdapterFactory().create(mockTemplateRenderer, false, {
249+
host: 'smtp.example.com',
250+
port: 587,
251+
secure: false,
252+
auth: {
253+
user: 'username',
254+
pass: 'password',
255+
},
256+
} as SMTPTransport.Options);
257+
adapter.injectBackend(mockBackend);
258+
259+
mockNotification.attachments = [];
260+
261+
const context = { foo: 'bar' };
262+
const renderedTemplate = {
263+
subject: 'Test Subject',
264+
body: '<p>Test Body</p>',
265+
};
266+
const userEmail = 'user@example.com';
267+
268+
mockTemplateRenderer.render.mockResolvedValue(renderedTemplate);
269+
mockBackend.getUserEmailFromNotification.mockResolvedValue(userEmail);
270+
271+
await adapter.send(mockNotification, context);
272+
273+
expect(mockTransporter.sendMail).toHaveBeenCalledWith({
274+
to: userEmail,
275+
subject: renderedTemplate.subject,
276+
html: renderedTemplate.body,
277+
});
278+
});
279+
280+
it('should send email without attachments when attachments is undefined', async () => {
281+
const adapter = new NodemailerNotificationAdapterFactory().create(mockTemplateRenderer, false, {
282+
host: 'smtp.example.com',
283+
port: 587,
284+
secure: false,
285+
auth: {
286+
user: 'username',
287+
pass: 'password',
288+
},
289+
} as SMTPTransport.Options);
290+
adapter.injectBackend(mockBackend);
291+
292+
mockNotification.attachments = undefined;
293+
294+
const context = { foo: 'bar' };
295+
const renderedTemplate = {
296+
subject: 'Test Subject',
297+
body: '<p>Test Body</p>',
298+
};
299+
const userEmail = 'user@example.com';
300+
301+
mockTemplateRenderer.render.mockResolvedValue(renderedTemplate);
302+
mockBackend.getUserEmailFromNotification.mockResolvedValue(userEmail);
303+
304+
await adapter.send(mockNotification, context);
305+
306+
expect(mockTransporter.sendMail).toHaveBeenCalledWith({
307+
to: userEmail,
308+
subject: renderedTemplate.subject,
309+
html: renderedTemplate.body,
310+
});
311+
});
312+
});

src/__tests__/nodemailer-adapter-one-off.test.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ describe('NodemailerNotificationAdapter - One-Off Notifications', () => {
1818

1919
const mockTemplateRenderer = {
2020
render: jest.fn(),
21-
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
21+
// biome-ignore lint/suspicious/noExplicitAny: any just for testing
2222
} as jest.Mocked<BaseEmailTemplateRenderer<any>>;
2323

24-
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
24+
// biome-ignore lint/suspicious/noExplicitAny: any just for testing
2525
const mockBackend: jest.Mocked<BaseNotificationBackend<any>> = {
2626
persistNotification: jest.fn(),
2727
persistNotificationUpdate: jest.fn(),
@@ -48,13 +48,19 @@ describe('NodemailerNotificationAdapter - One-Off Notifications', () => {
4848
getOneOffNotification: jest.fn(),
4949
getAllOneOffNotifications: jest.fn(),
5050
getOneOffNotifications: jest.fn(),
51+
getAttachmentFile: jest.fn(),
52+
deleteAttachmentFile: jest.fn(),
53+
getOrphanedAttachmentFiles: jest.fn(),
54+
getAttachments: jest.fn(),
55+
deleteNotificationAttachment: jest.fn(),
56+
findAttachmentFileByChecksum: jest.fn(),
5157
};
5258

53-
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
59+
// biome-ignore lint/suspicious/noExplicitAny: any just for testing
5460
let mockOneOffNotification: DatabaseOneOffNotification<any>;
55-
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
61+
// biome-ignore lint/suspicious/noExplicitAny: any just for testing
5662
let mockRegularNotification: DatabaseNotification<any>;
57-
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
63+
// biome-ignore lint/suspicious/noExplicitAny: any just for testing
5864
let adapter: NodemailerNotificationAdapter<typeof mockTemplateRenderer, any>;
5965

6066
beforeEach(() => {
@@ -177,7 +183,7 @@ describe('NodemailerNotificationAdapter - One-Off Notifications', () => {
177183
const notificationWithoutId = {
178184
...mockOneOffNotification,
179185
id: null,
180-
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
186+
// biome-ignore lint/suspicious/noExplicitAny: any just for testing
181187
} as any;
182188

183189
await expect(adapter.send(notificationWithoutId, {})).rejects.toThrow(

0 commit comments

Comments
 (0)