diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index cb36ef41..6b6308d3 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -900,6 +900,14 @@ async function getScheduledEmailConfig() { // options.scheduledAt — Date | ISO string; row only picks up once // this moment has passed (used by CRM split- // payment invoices). NULL = send immediately. +// options.respectBusinessHours — when true, snap the send time to the +// next open business-hours block (from "now"). +// Use for automated/relationship mail (dunning +// reminders, gallery-expiry warnings) so we don't +// ping customers overnight. No-op when the floor +// is off / business hours unconfigured / already +// inside a block. Leave it off for transactional +// + admin-initiated mail so those send instantly. // Attachments + cc travel inside `emailData` (keys: attachments, cc) // so callers don't need a new signature for every email shape. async function queueEmail(eventId, recipientEmail, emailType, emailData, options = {}) { @@ -916,22 +924,33 @@ async function queueEmail(eventId, recipientEmail, emailType, emailData, options created_at: new Date(), }; let snappedFrom = null; - if (options.scheduledAt) { - 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. + // Base time to schedule from: + // - explicit options.scheduledAt (CRM split-payment invoices), OR + // - "now" when the caller opts into the business-hours floor via + // options.respectBusinessHours — automated / relationship mail + // like dunning reminders + gallery-expiry warnings, so we don't + // ping the customer at 02:00. + // Both snap to the next open business-hours block. No-op when the + // floor is disabled, business hours are unconfigured, or the instant + // already lands inside a block. Transactional / admin-initiated mail + // (invoice_sent, storno, invitations, password resets) passes neither + // option and sends immediately. + const baseTime = options.scheduledAt + ? (options.scheduledAt instanceof Date ? options.scheduledAt : new Date(options.scheduledAt)) + : (options.respectBusinessHours ? new Date() : null); + if (baseTime) { const cfg = await getScheduledEmailConfig(); - const snapped = snapToBusinessHours(requested, cfg); - if (snapped.getTime() !== requested.getTime()) snappedFrom = requested; - row.scheduled_at = snapped; + const snapped = snapToBusinessHours(baseTime, cfg); + if (snapped.getTime() !== baseTime.getTime()) snappedFrom = baseTime; + // Persist a future scheduled_at for an explicit scheduledAt always; + // for the respectBusinessHours floor only when it actually moved the + // time forward (inside hours → leave null → processor sends at once). + if (options.scheduledAt || snappedFrom) row.scheduled_at = snapped; } await db('email_queue').insert(row); logger.info(`Email queued: ${emailType} to ${recipientEmail}${ - options.scheduledAt ? ` (scheduled ${row.scheduled_at.toISOString()}${ + row.scheduled_at ? ` (scheduled ${row.scheduled_at.toISOString()}${ snappedFrom ? `, floored from ${snappedFrom.toISOString()}` : '' })` : '' }`); diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js index cdf3f242..1666e1d3 100644 --- a/backend/src/services/expirationChecker.js +++ b/backend/src/services/expirationChecker.js @@ -85,7 +85,8 @@ async function queueExpirationWarning(event) { expiry_date: event.expires_at, gallery_link: shareUrl, gallery_password: '{{password_security_message}}' - }); + // Relationship mail — hold to business hours (no-op unless configured). + }, { respectBusinessHours: true }); logger.info(`Queued expiration warning for event ${event.slug}`); } diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 1e1f09ba..91a92e68 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -2650,7 +2650,9 @@ async function applyReminder(invoice, lineItems, level, adminId) { contentPath: pdfPath, contentType: 'application/pdf', }], - }); + // Dunning reminders are relationship mail — hold to business hours so + // the customer isn't pinged overnight (no-op unless hours configured). + }, { respectBusinessHours: true }); try { await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor },