@@ -70,7 +70,7 @@ jest.mock('@urbackend/common', () => {
7070 } ;
7171} ) ;
7272
73- const { Project, decrypt, redis, publicEmailQueue, MailLog } = require ( '@urbackend/common' ) ;
73+ const { Project, decrypt, redis, publicEmailQueue, MailTemplate , MailLog } = require ( '@urbackend/common' ) ;
7474const mailController = require ( '../controllers/mail.controller' ) ;
7575const originalResendApiKey2 = process . env . RESEND_API_KEY_2 ;
7676
@@ -129,6 +129,18 @@ describe('mail.controller', () => {
129129 success : true ,
130130 data : expect . objectContaining ( { provider : 'byok' , monthlyUsage : 1 } ) ,
131131 } ) ) ;
132+ expect ( publicEmailQueue . add ) . toHaveBeenCalledWith ( "send-public-email" , expect . objectContaining ( {
133+ projectId : 'proj_1' ,
134+ usingByok : true ,
135+ payload : expect . objectContaining ( {
136+ to : 'user@example.com' ,
137+ subject : 'Hello' ,
138+ text : 'This is a message.'
139+ } )
140+ } ) , expect . objectContaining ( {
141+ attempts : 3 ,
142+ backoff : expect . objectContaining ( { type : 'exponential' , delay : 5000 } )
143+ } ) ) ;
132144 } ) ;
133145
134146 test ( 'falls back to default key when BYOK missing' , async ( ) => {
@@ -145,6 +157,18 @@ describe('mail.controller', () => {
145157 expect ( res . json ) . toHaveBeenCalledWith ( expect . objectContaining ( {
146158 data : expect . objectContaining ( { provider : 'default' , monthlyUsage : 2 } ) ,
147159 } ) ) ;
160+ expect ( publicEmailQueue . add ) . toHaveBeenCalledWith ( "send-public-email" , expect . objectContaining ( {
161+ projectId : 'proj_1' ,
162+ usingByok : false ,
163+ payload : expect . objectContaining ( {
164+ to : 'user@example.com' ,
165+ subject : 'Hello' ,
166+ text : 'This is a message.'
167+ } )
168+ } ) , expect . objectContaining ( {
169+ attempts : 3 ,
170+ backoff : expect . objectContaining ( { type : 'exponential' , delay : 5000 } )
171+ } ) ) ;
148172 } ) ;
149173
150174 test ( 'enforces monthly limit' , async ( ) => {
@@ -208,6 +232,131 @@ describe('mail.controller', () => {
208232 } ) , expect . objectContaining ( { attempts : expect . any ( Number ) } ) ) ;
209233 } ) ;
210234
235+ test ( 'renders and sends a project-scoped mail template from DB' , async ( ) => {
236+ const req = makeReq ( ) ;
237+ req . body = {
238+ to : 'user@example.com' ,
239+ templateName : 'welcome' ,
240+ variables : { name : 'Yash' } ,
241+ } ;
242+ const res = makeRes ( ) ;
243+
244+ mockProjectConfig ( {
245+ _id : 'proj_1' ,
246+ resendApiKey : null ,
247+ } ) ;
248+
249+ MailTemplate . findOne . mockReturnValueOnce ( {
250+ lean : jest . fn ( ) . mockResolvedValue ( {
251+ _id : 'tpl_db_1' ,
252+ name : 'welcome' ,
253+ subject : 'Hello {{name}}' ,
254+ text : 'Welcome to project!' ,
255+ html : '<p>Welcome to project!</p>' ,
256+ projectId : 'proj_1'
257+ } )
258+ } ) ;
259+
260+ decrypt . mockReturnValue ( null ) ;
261+ redis . eval . mockResolvedValue ( 1 ) ;
262+
263+ await mailController . sendMail ( req , res ) ;
264+
265+ // Assert project-scope query was made first
266+ expect ( MailTemplate . findOne ) . toHaveBeenCalledWith ( expect . objectContaining ( {
267+ projectId : 'proj_1' ,
268+ } ) ) ;
269+
270+ expect ( res . status ) . toHaveBeenCalledWith ( 200 ) ;
271+ expect ( res . json ) . toHaveBeenCalledWith ( expect . objectContaining ( {
272+ success : true ,
273+ data : expect . objectContaining ( {
274+ templateUsed : expect . objectContaining ( { name : 'welcome' , id : 'tpl_db_1' , scope : 'project' } ) ,
275+ } ) ,
276+ } ) ) ;
277+ expect ( publicEmailQueue . add ) . toHaveBeenCalledWith ( "send-public-email" , expect . objectContaining ( {
278+ projectId : 'proj_1' ,
279+ usingByok : false ,
280+ payload : expect . objectContaining ( {
281+ to : 'user@example.com' ,
282+ subject : 'Hello Yash' ,
283+ text : 'Welcome to project!' ,
284+ html : '<p>Welcome to project!</p>'
285+ } )
286+ } ) , expect . objectContaining ( {
287+ attempts : 3 ,
288+ backoff : expect . objectContaining ( { type : 'exponential' , delay : 5000 } )
289+ } ) ) ;
290+ } ) ;
291+
292+ test ( 'renders and sends a global mail template from DB when no project template exists' , async ( ) => {
293+ const req = makeReq ( ) ;
294+ req . body = {
295+ to : 'user@example.com' ,
296+ templateName : 'welcome' ,
297+ variables : { name : 'Yash' } ,
298+ } ;
299+ const res = makeRes ( ) ;
300+
301+ mockProjectConfig ( {
302+ _id : 'proj_1' ,
303+ resendApiKey : null ,
304+ } ) ;
305+
306+ // First call: project scope (returns null)
307+ MailTemplate . findOne . mockReturnValueOnce ( {
308+ lean : jest . fn ( ) . mockResolvedValue ( null )
309+ } ) ;
310+
311+ // Second call: global scope
312+ MailTemplate . findOne . mockReturnValueOnce ( {
313+ lean : jest . fn ( ) . mockResolvedValue ( {
314+ _id : 'tpl_global_1' ,
315+ name : 'welcome' ,
316+ subject : 'Global Hello {{name}}' ,
317+ text : 'Global welcome!' ,
318+ html : '<p>Global welcome!</p>' ,
319+ projectId : null ,
320+ isSystem : true
321+ } )
322+ } ) ;
323+
324+ decrypt . mockReturnValue ( null ) ;
325+ redis . eval . mockResolvedValue ( 1 ) ;
326+
327+ await mailController . sendMail ( req , res ) ;
328+
329+ // Assert project-scope was queried first, then global fallback
330+ expect ( MailTemplate . findOne ) . toHaveBeenNthCalledWith ( 1 , expect . objectContaining ( {
331+ projectId : 'proj_1' ,
332+ } ) ) ;
333+ expect ( MailTemplate . findOne ) . toHaveBeenNthCalledWith ( 2 , expect . objectContaining ( {
334+ projectId : null ,
335+ isSystem : true ,
336+ } ) ) ;
337+
338+ expect ( res . status ) . toHaveBeenCalledWith ( 200 ) ;
339+ expect ( res . json ) . toHaveBeenCalledWith ( expect . objectContaining ( {
340+ success : true ,
341+ data : expect . objectContaining ( {
342+ templateUsed : expect . objectContaining ( { name : 'welcome' , id : 'tpl_global_1' , scope : 'global' } ) ,
343+ } ) ,
344+ } ) ) ;
345+ expect ( publicEmailQueue . add ) . toHaveBeenCalledWith ( "send-public-email" , expect . objectContaining ( {
346+ projectId : 'proj_1' ,
347+ usingByok : false ,
348+ payload : expect . objectContaining ( {
349+ to : 'user@example.com' ,
350+ subject : 'Global Hello Yash' ,
351+ text : 'Global welcome!' ,
352+ html : '<p>Global welcome!</p>'
353+ } )
354+ } ) , expect . objectContaining ( {
355+ attempts : 3 ,
356+ backoff : expect . objectContaining ( { type : 'exponential' , delay : 5000 } )
357+ } ) ) ;
358+ } ) ;
359+
211360 test ( 'refunds quota on terminal async worker failure' , async ( ) => {
212361 let failedHandler ;
213362 jest . resetModules ( ) ;
0 commit comments