feat(email): per-weekday business hours + manual queue flush
Move the scheduled-email business-hours floor onto the business profile
as Google-style per-weekday opening blocks (multiple blocks/day for lunch
breaks). Migration 114 adds business_profile.business_hours (JSON) +
scheduled_email_floor_enabled; emailProcessor snaps a queued email to the
next open block, read in the profile timezone. Editor lives under
Settings → Business profile.
Add an admin "Send queued emails now" flush (POST /admin/email/flush-queue)
that drains the queue immediately, ignoring the business-hours floor — the
escape hatch before maintenance/updates. processEmailQueue now takes
{ignoreSchedule, limit} and returns send counts; the scheduled interval
run is unchanged.
This commit is contained in:
@@ -2,6 +2,11 @@ const nodemailer = require('nodemailer');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
const {
|
||||
snapToBusinessHours,
|
||||
normaliseSchedule,
|
||||
} = require('../utils/businessHours');
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
|
||||
let transporter = null;
|
||||
let lastConfigHash = null;
|
||||
@@ -735,10 +740,22 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
}
|
||||
}
|
||||
|
||||
// Process email queue
|
||||
async function processEmailQueue() {
|
||||
// Process email queue.
|
||||
//
|
||||
// Options:
|
||||
// ignoreSchedule when true, send every pending email regardless of its
|
||||
// `scheduled_at` floor (used by the admin "send now" flush
|
||||
// before maintenance/updates). The scheduled interval run
|
||||
// leaves it false so future-dated emails keep waiting.
|
||||
// limit max emails per pass. The flush raises this to drain the
|
||||
// whole queue in a single pass (no re-query, so a failing
|
||||
// email isn't retried in a tight loop within one flush).
|
||||
//
|
||||
// Returns { processed, sent, failed }.
|
||||
async function processEmailQueue({ ignoreSchedule = false, limit = 10 } = {}) {
|
||||
logger.info('Email queue processor: Checking for pending emails...');
|
||||
|
||||
const result = { processed: 0, sent: 0, failed: 0 };
|
||||
|
||||
try {
|
||||
// Try to initialize transporter if it's null (in case it failed at startup)
|
||||
if (!transporter) {
|
||||
@@ -746,37 +763,41 @@ async function processEmailQueue() {
|
||||
transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
logger.warn('Email transporter could not be initialized, skipping queue processing');
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let pendingEmails = [];
|
||||
try {
|
||||
// Pick up emails that are pending AND either have no `scheduled_at`
|
||||
// or whose scheduled_at is in the past. Used by CRM invoices to
|
||||
// queue split-payment emails relative to the event date.
|
||||
const now = new Date();
|
||||
pendingEmails = await db('email_queue')
|
||||
const query = db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.andWhere(function() {
|
||||
.where('retry_count', '<', 3);
|
||||
if (!ignoreSchedule) {
|
||||
query.andWhere(function() {
|
||||
this.whereNull('scheduled_at').orWhere('scheduled_at', '<=', now);
|
||||
})
|
||||
});
|
||||
}
|
||||
pendingEmails = await query
|
||||
.orderBy('scheduled_at', 'asc')
|
||||
.orderBy('created_at', 'asc')
|
||||
.limit(10);
|
||||
.limit(limit);
|
||||
} catch (dbError) {
|
||||
logger.error('Failed to query email queue:', dbError);
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
if (pendingEmails.length === 0) {
|
||||
logger.info('Email queue processor: No pending emails found');
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
|
||||
logger.info(`Processing ${pendingEmails.length} emails from queue`);
|
||||
|
||||
result.processed = pendingEmails.length;
|
||||
|
||||
for (const email of pendingEmails) {
|
||||
try {
|
||||
const emailData = typeof email.email_data === 'string'
|
||||
@@ -796,9 +817,11 @@ async function processEmailQueue() {
|
||||
status: 'sent',
|
||||
sent_at: new Date()
|
||||
});
|
||||
|
||||
|
||||
result.sent += 1;
|
||||
logger.info(`Email ${email.id} sent successfully`);
|
||||
} catch (error) {
|
||||
result.failed += 1;
|
||||
// Increment retry count
|
||||
try {
|
||||
await db('email_queue')
|
||||
@@ -825,6 +848,52 @@ async function processEmailQueue() {
|
||||
} catch (error) {
|
||||
logger.error('Error processing email queue:', error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Load + normalise the business-hours config used by queueEmail. The
|
||||
// definition lives on the singleton business_profile row (migration 114):
|
||||
// business_hours JSON, per-ISO-weekday opening blocks
|
||||
// scheduled_email_floor_enabled master on/off switch
|
||||
// timezone IANA zone the blocks are read in
|
||||
// Any failure (column missing on a half-migrated install, bad data) or an
|
||||
// unconfigured schedule degrades to `enabled: false` so a queued email is
|
||||
// never lost — it just sends at its original time.
|
||||
async function getScheduledEmailConfig() {
|
||||
try {
|
||||
if (!(await hasColumnCached('business_profile', 'business_hours'))) {
|
||||
return { enabled: false };
|
||||
}
|
||||
const hasToggle = await hasColumnCached('business_profile', 'scheduled_email_floor_enabled');
|
||||
const cols = ['business_hours', 'timezone'];
|
||||
if (hasToggle) cols.push('scheduled_email_floor_enabled');
|
||||
const profile = await db('business_profile').where({ id: 1 }).first(cols);
|
||||
if (!profile) return { enabled: false };
|
||||
|
||||
const enabled = hasToggle
|
||||
? (profile.scheduled_email_floor_enabled === true
|
||||
|| profile.scheduled_email_floor_enabled === 1
|
||||
|| profile.scheduled_email_floor_enabled === '1')
|
||||
: true;
|
||||
if (!enabled) return { enabled: false };
|
||||
|
||||
const schedule = normaliseSchedule(profile.business_hours);
|
||||
|
||||
let timezone = (profile.timezone || '').trim();
|
||||
if (!timezone) timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
// Reject a bogus tz before it reaches Intl in the snap helper.
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
|
||||
} catch (_) {
|
||||
timezone = 'UTC';
|
||||
}
|
||||
|
||||
return { enabled: true, timezone, schedule };
|
||||
} catch (err) {
|
||||
logger.warn(`Business-hours config unavailable, skipping email floor: ${err.message}`);
|
||||
return { enabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Queue an email for sending. Optionally takes a 5th `options` arg:
|
||||
@@ -846,15 +915,25 @@ async function queueEmail(eventId, recipientEmail, emailType, emailData, options
|
||||
retry_count: 0,
|
||||
created_at: new Date(),
|
||||
};
|
||||
let snappedFrom = null;
|
||||
if (options.scheduledAt) {
|
||||
row.scheduled_at = options.scheduledAt instanceof Date
|
||||
const requested = options.scheduledAt instanceof Date
|
||||
? options.scheduledAt
|
||||
: new Date(options.scheduledAt);
|
||||
// Floor to the configured business-hours window so a "send in N
|
||||
// days" click at 02:11 doesn't deliver at 02:11. No-op when the
|
||||
// floor is disabled or the instant already lands inside the window.
|
||||
const cfg = await getScheduledEmailConfig();
|
||||
const snapped = snapToBusinessHours(requested, cfg);
|
||||
if (snapped.getTime() !== requested.getTime()) snappedFrom = requested;
|
||||
row.scheduled_at = snapped;
|
||||
}
|
||||
await db('email_queue').insert(row);
|
||||
|
||||
logger.info(`Email queued: ${emailType} to ${recipientEmail}${
|
||||
options.scheduledAt ? ` (scheduled ${row.scheduled_at.toISOString()})` : ''
|
||||
options.scheduledAt ? ` (scheduled ${row.scheduled_at.toISOString()}${
|
||||
snappedFrom ? `, floored from ${snappedFrom.toISOString()}` : ''
|
||||
})` : ''
|
||||
}`);
|
||||
} catch (error) {
|
||||
logger.error('Error queueing email:', error);
|
||||
|
||||
Reference in New Issue
Block a user