/*********************************************************************************************************************************************************************** * File Name : dispatch_email_broadcasts.cron.js * Type : Cron Job * Description : Sends real, paced SMTP email for queued email_broadcasts — * the actual delivery half of controllers/admin/email_broadcasts * .controller.js's createEmailBroadcast(), which only ever * enqueues rows and returns immediately. * * Every tick, picks up to BATCH_SIZE 'pending' recipient rows * (oldest broadcast first, FIFO within it) and sends them one * at a time with a short delay between each — this is the "one * by one, not a blocking for-loop in the API request" behavior: * it's fine to block *here* because nothing is waiting on an * HTTP response, and the delay keeps us well under Gmail SMTP's * practical sustained-send pacing. * * Resumable by construction — if the process restarts mid- * broadcast, the next tick just keeps consuming whatever rows * are still 'pending'. Single-instance only: this does not use * row-level locking, so running more than one app instance * would let two ticks grab the same batch. Fine for the * current single-process deployment; would need SELECT ... FOR * UPDATE SKIP LOCKED before scaling horizontally. * * Schedule : Every minute ("* * * * *"). Registered by cron/admin.cron.js. * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jul. 3, 2026 ***********************************************************************************************************************************************************************/ const mdl_EmailBroadcast = require('../../models/email_templates/email_broadcast.mdl'); const mdl_EmailBroadcastRecipient = require('../../models/email_templates/email_broadcast_recipient.mdl'); const mdl_EmailTemplate = require('../../models/email_templates/email_templates.mdl'); const { sendEmail } = require('../../services/email.service'); const BATCH_SIZE = 25; const DELAY_MS = 600; // pacing between individual sends — keeps us well under Gmail's throttling threshold const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function run() { try { const recipients = await mdl_EmailBroadcastRecipient.findAll({ where: { status: 'pending' }, include: [{ model: mdl_EmailBroadcast, as: 'broadcast', where: { status: ['queued', 'sending'] }, include: [{ model: mdl_EmailTemplate, as: 'template' }], }], order: [['email_broadcast_id', 'ASC'], ['email_broadcast_recipient_id', 'ASC']], limit: BATCH_SIZE, }); if (!recipients.length) return; const touchedBroadcastIds = new Set(); for (const recipient of recipients) { const broadcast = recipient.broadcast; const template = broadcast?.template; if (!broadcast || !template) { await recipient.update({ status: 'failed', error: 'Broadcast or template no longer exists.', sent_at: new Date() }); continue; } if (broadcast.status === 'queued') { await broadcast.update({ status: 'sending', started_at: broadcast.started_at ?? new Date() }); } touchedBroadcastIds.add(broadcast.email_broadcast_id); try { await sendEmail({ to: recipient.email, type: template.type, data: { name: recipient.name || 'there', email: recipient.email }, }); await recipient.update({ status: 'sent', sent_at: new Date() }); await broadcast.increment('sent_count'); } catch (err) { await recipient.update({ status: 'failed', error: err.message, sent_at: new Date() }); await broadcast.increment('failed_count'); console.error('[CRON][EMAIL BROADCAST] Send failed:', recipient.email, err.message); } await delay(DELAY_MS); } // Close out any broadcast that has no pending recipients left. for (const broadcastId of touchedBroadcastIds) { const remaining = await mdl_EmailBroadcastRecipient.count({ where: { email_broadcast_id: broadcastId, status: 'pending' } }); if (remaining === 0) { await mdl_EmailBroadcast.update( { status: 'completed', completed_at: new Date() }, { where: { email_broadcast_id: broadcastId, status: ['queued', 'sending'] } } ); } } console.log(`[CRON][EMAIL BROADCAST] Processed ${recipients.length} recipient(s) across ${touchedBroadcastIds.size} broadcast(s).`); } catch (err) { console.error('[CRON][EMAIL BROADCAST] Failed:', err); } } module.exports = { name: 'dispatchEmailBroadcasts', schedule: '* * * * *', run, };