← back to NationalPaperHangers
lib/email.js
293 lines
// NPH email — direct Purelymail SMTP via nodemailer.
// Mailbox: info@nationalpaperhangers.com (Purelymail). Password lives in /secrets
// as INFO_NATIONALPAPERHANGERS_COM_PASSWORD; lands in NPH .env on sync.
//
// Why not George? George's `info` account is a Gmail label on Steve's DW
// Workspace and sends FROM steve@designerwallcoverings.com. NPH needs From to
// match the apex domain for SPF/DKIM/DMARC alignment + brand integrity.
// In dev, falls back to console logging if SMTP creds are absent.
const nodemailer = require('nodemailer');
const SMTP_HOST = process.env.NPH_SMTP_HOST || 'smtp.purelymail.com';
const SMTP_PORT = parseInt(process.env.NPH_SMTP_PORT || '587', 10);
const SMTP_USER = process.env.NPH_SMTP_USER || process.env.EMAIL_FROM || 'info@nationalpaperhangers.com';
const SMTP_PASS = process.env.NPH_SMTP_PASS || process.env.INFO_NATIONALPAPERHANGERS_COM_PASSWORD || '';
const FROM = process.env.EMAIL_FROM || 'info@nationalpaperhangers.com';
const FROM_NAME = process.env.EMAIL_FROM_NAME || 'National Paper Hangers';
let _transport = null;
function getTransport() {
if (_transport) return _transport;
if (!SMTP_PASS) return null; // mock mode
_transport = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_PORT === 465, // 465 = implicit TLS, 587 = STARTTLS
requireTLS: SMTP_PORT === 587,
auth: { user: SMTP_USER, pass: SMTP_PASS }
});
return _transport;
}
async function sendEmail({ to, subject, html, text, extraHeaders }) {
const transport = getTransport();
if (!transport) {
console.warn('[email] no SMTP creds (NPH_SMTP_PASS/INFO_NATIONALPAPERHANGERS_COM_PASSWORD unset); logging instead');
console.log('--- EMAIL ---\nto:', to, '\nsubject:', subject, '\nbody:\n', (html || text || '').slice(0, 500), '\n-------------');
return { ok: false, mocked: true };
}
try {
const info = await transport.sendMail({
from: { name: FROM_NAME, address: FROM },
to,
subject,
html: html || undefined,
text: text || undefined,
headers: (extraHeaders && typeof extraHeaders === 'object') ? extraHeaders : undefined
});
return { ok: true, messageId: info.messageId, accepted: info.accepted, rejected: info.rejected };
} catch (err) {
console.warn('[email] smtp error:', err.code || err.message);
return { ok: false, error: err.message, code: err.code };
}
}
function bookingConfirmationCustomer({ booking, installer, publicUrl }) {
const when = new Date(booking.scheduled_start).toLocaleString('en-US', {
weekday: 'long', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit'
});
// The /bookings/:uuid page is IDOR-gated: a customer (not the owning
// installer) can only open it with the HMAC ?t= token, else it 404s. The
// token MUST ride along on the link or the confirmation email is a dead end.
const bookingUrl = `${publicUrl}/bookings/${booking.uuid}`
+ (booking.view_token ? `?t=${encodeURIComponent(booking.view_token)}` : '');
return {
subject: `Booking confirmed with ${installer.business_name}`,
html: `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
<h1 style="font-size:24px;margin:0 0 16px;letter-spacing:0.02em">Your booking is confirmed</h1>
<p style="font-size:15px;line-height:1.6">Hello ${escapeHtml(booking.customer_name)},</p>
<p style="font-size:15px;line-height:1.6">${escapeHtml(installer.business_name)} has accepted your request.</p>
<table style="width:100%;border-collapse:collapse;margin:24px 0">
<tr><td style="padding:8px 0;color:#666;width:140px">When</td><td style="padding:8px 0">${when}</td></tr>
<tr><td style="padding:8px 0;color:#666">Type</td><td style="padding:8px 0">${escapeHtml(booking.project_type || 'consultation')}</td></tr>
<tr><td style="padding:8px 0;color:#666">Installer</td><td style="padding:8px 0">${escapeHtml(installer.business_name)}</td></tr>
${installer.phone ? `<tr><td style="padding:8px 0;color:#666">Phone</td><td style="padding:8px 0">${escapeHtml(installer.phone)}</td></tr>` : ''}
</table>
<p style="font-size:13px;color:#666;line-height:1.6">If you need to reschedule or cancel, reply to this email or <a href="${bookingUrl}" style="color:#0e0e0e">view your booking</a>.</p>
<hr style="border:none;border-top:1px solid #ddd;margin:32px 0">
<p style="font-size:12px;color:#999">National Paper Hangers · info@nationalpaperhangers.com</p>
</div>`
};
}
// Enum identifier → human label: 'new_plaster' → 'New plaster'.
function prettyEnum(v) {
if (v === null || v === undefined || v === '') return '';
return String(v).replace(/_/g, ' ').replace(/^./, c => c.toUpperCase());
}
// One label/value <tr>, omitted entirely when the value is empty.
function infoRow(label, value) {
if (value === null || value === undefined || value === '') return '';
return `<tr><td style="padding:6px 0;color:#666;width:150px;vertical-align:top">${escapeHtml(label)}</td>` +
`<td style="padding:6px 0">${escapeHtml(String(value))}</td></tr>`;
}
function bookingNotificationInstaller({ booking, installer, publicUrl }) {
const when = new Date(booking.scheduled_start).toLocaleString('en-US', {
weekday: 'long', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit'
});
// product_sourced arrives as a boolean (DB row) or string (raw req.body).
const ps = booking.product_sourced;
const productSourced = (ps === true || ps === 'true') ? 'Yes — brand & pattern chosen'
: (ps === false || ps === 'false') ? 'Not yet — wants guidance'
: '';
// Structured project brief (UX backlog #6) — surfaced in the email itself so
// the installer can scope tools, ladder height, paste type, and roll count
// before they ever open the dashboard. The whole point of the brief.
const briefRows = [
infoRow('Who is ordering', prettyEnum(booking.customer_role)),
infoRow('Wallpaper selected', productSourced),
infoRow('Material', prettyEnum(booking.material)),
infoRow('Brand', booking.brand),
infoRow('Pattern / SKU', booking.brand_sku),
infoRow('Rooms / surfaces', booking.surfaces),
infoRow('Square feet', booking.square_feet ? booking.square_feet + ' sq ft' : ''),
infoRow('Ceiling height', booking.ceiling_height_ft ? booking.ceiling_height_ft + ' ft' : ''),
infoRow('Estimated rolls', booking.roll_count_estimate),
infoRow('Surface state', prettyEnum(booking.surface_state)),
infoRow('Access', prettyEnum(booking.access_constraints)),
infoRow('Budget band', prettyEnum(booking.budget_band))
].join('');
// Camera-captured rooms (UX #6) — photo/video links + confirmed dimensions
// + any customer-supplied wallpaper. URLs are sanitiser-validated upstream.
const captures = Array.isArray(booking.room_captures) ? booking.room_captures : [];
const captureRows = captures.map(function (rc) {
const dims = (rc.wall_width_ft && rc.wall_height_ft)
? rc.wall_width_ft + ' × ' + rc.wall_height_ft + ' ft' : 'not measured';
const links = ['<a href="' + publicUrl + rc.photo_url + '">wall photo</a>'];
if (rc.video_url) links.push('<a href="' + publicUrl + rc.video_url + '">walkthrough</a>');
const wp = rc.wallpaper
? '<br><span style="color:#666">Wallpaper — ' + escapeHtml(String(rc.wallpaper.width_in))
+ '" panel · ' + escapeHtml(String(rc.wallpaper.repeat_in)) + '" repeat · '
+ escapeHtml(prettyEnum(rc.wallpaper.match_type)) + ' match · '
+ '<a href="' + publicUrl + rc.wallpaper.image_url + '">image</a></span>'
: '';
return '<tr><td style="padding:6px 0;color:#666;width:150px;vertical-align:top">'
+ escapeHtml(rc.room || 'Room') + '</td><td style="padding:6px 0">'
+ dims + ' · ' + links.join(' · ') + wp + '</td></tr>';
}).join('');
return {
subject: `New booking — ${booking.customer_name} · ${when}`,
html: `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
<h1 style="font-size:22px;margin:0 0 16px">New booking on your calendar</h1>
<table style="width:100%;border-collapse:collapse;margin:16px 0">
<tr><td style="padding:6px 0;color:#666;width:150px">Customer</td><td style="padding:6px 0">${escapeHtml(booking.customer_name)} · ${escapeHtml(booking.customer_email)}${booking.customer_phone ? ' · ' + escapeHtml(booking.customer_phone) : ''}</td></tr>
<tr><td style="padding:6px 0;color:#666">When</td><td style="padding:6px 0">${when}</td></tr>
<tr><td style="padding:6px 0;color:#666">Type</td><td style="padding:6px 0">${escapeHtml(booking.project_type || 'consultation')}</td></tr>
<tr><td style="padding:6px 0;color:#666">Address</td><td style="padding:6px 0">${[booking.address_line1, booking.city, booking.state, booking.zip].filter(Boolean).map(escapeHtml).join(', ') || '—'}</td></tr>
</table>
${briefRows ? `<h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin:24px 0 4px">Project brief</h2>
<table style="width:100%;border-collapse:collapse">${briefRows}</table>` : ''}
${captureRows ? `<h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin:24px 0 4px">Room captures (${captures.length})</h2>
<table style="width:100%;border-collapse:collapse">${captureRows}</table>` : ''}
<h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin:24px 0 4px">Customer notes</h2>
<p style="margin:4px 0 0;line-height:1.55">${escapeHtml(booking.customer_notes || '—')}</p>
<p style="margin-top:24px"><a href="${publicUrl}/admin/bookings" style="display:inline-block;padding:12px 20px;background:#0e0e0e;color:#fff;text-decoration:none;border-radius:2px">Open dashboard</a></p>
</div>`
};
}
const { escapeHtml } = require('./utils');
// ──────────────────────────────────────────────────────────────────────────
// COI request emails (UX backlog #5)
// ──────────────────────────────────────────────────────────────────────────
function coiRow(label, value) {
if (!value) return '';
return `<tr><td style="padding:6px 0;color:#666;width:160px;vertical-align:top">${escapeHtml(label)}</td>` +
`<td style="padding:6px 0">${escapeHtml(String(value))}</td></tr>`;
}
function coiRequestInstaller({ request, installer, publicUrl }) {
const ins = installer.insurance || {};
const carrier = ins.carrier || 'your carrier';
return {
subject: `[COI request] ${request.designer_company || request.designer_name} → ${request.project_name || request.additional_insured_name}`,
html: `<div style="font-family:Georgia,serif;max-width:620px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
<h1 style="font-size:22px;margin:0 0 8px;letter-spacing:0.02em">New Certificate of Insurance request</h1>
<p style="font-size:13px;color:#666;margin:0 0 20px">A designer needs you (or ${escapeHtml(carrier)}) to issue a COI naming them as additional-insured for an upcoming installation.</p>
<h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin:24px 0 4px">Designer</h2>
<table style="width:100%;border-collapse:collapse">
${coiRow('Name', request.designer_name)}
${coiRow('Company', request.designer_company)}
${coiRow('Email', request.designer_email)}
${coiRow('Phone', request.designer_phone)}
</table>
<h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin:24px 0 4px">Project</h2>
<table style="width:100%;border-collapse:collapse">
${coiRow('Project', request.project_name)}
${coiRow('Address', request.project_address)}
${coiRow('Start date', request.project_start_date)}
${coiRow('Approx. value', request.project_value_usd ? '$' + Number(request.project_value_usd).toLocaleString() : '')}
</table>
<h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin:24px 0 4px">Name on the certificate</h2>
<table style="width:100%;border-collapse:collapse">
${coiRow('Additional insured', request.additional_insured_name)}
${coiRow('Their address', request.additional_insured_address)}
${request.notes ? coiRow('Notes', request.notes) : ''}
</table>
<p style="font-size:13px;color:#666;line-height:1.6;margin-top:24px">
Forward this to ${escapeHtml(ins.broker_email || 'your broker')} and ask them to issue a fresh COI naming the
Additional Insured as listed above. Reply directly to ${escapeHtml(request.designer_email)} when sending the cert.
</p>
<p style="font-size:13px;color:#666;line-height:1.6">
Manage this request: ${escapeHtml(publicUrl)}/admin/coi-requests/${request.id}
</p>
<hr style="border:none;border-top:1px solid #ddd;margin:32px 0">
<p style="font-size:12px;color:#999">National Paper Hangers · info@nationalpaperhangers.com</p>
</div>`
};
}
function coiRequestBroker({ request, installer }) {
const ins = installer.insurance || {};
return {
subject: `[COI needed] ${installer.business_name} job — ${request.project_name || request.additional_insured_name}`,
html: `<div style="font-family:Georgia,serif;max-width:620px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
<h1 style="font-size:22px;margin:0 0 8px;letter-spacing:0.02em">Certificate of Insurance — please issue</h1>
<p style="font-size:14px;line-height:1.6;margin:0 0 20px">Hi${ins.broker_name ? ' ' + escapeHtml(ins.broker_name) : ''},</p>
<p style="font-size:14px;line-height:1.6;margin:0 0 20px">
${escapeHtml(installer.business_name)} has been booked for a wallcovering installation and needs a
COI naming the project's design firm as additional insured. Details below.
</p>
<table style="width:100%;border-collapse:collapse">
${coiRow('Insured (your client)', installer.business_name)}
${ins.policy_number ? coiRow('Policy number', ins.policy_number) : ''}
${coiRow('Project', request.project_name)}
${coiRow('Project address', request.project_address)}
${coiRow('Start date', request.project_start_date)}
</table>
<h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin:24px 0 4px">Add as additional insured</h2>
<table style="width:100%;border-collapse:collapse">
${coiRow('Name (exact phrasing)', request.additional_insured_name)}
${coiRow('Address', request.additional_insured_address)}
</table>
<p style="font-size:13px;color:#666;line-height:1.6;margin-top:24px">
Please email the cert directly to <strong>${escapeHtml(request.designer_email)}</strong>${request.designer_company ? ' (' + escapeHtml(request.designer_company) + ')' : ''}, with ${escapeHtml(installer.email)} on cc.
</p>
<hr style="border:none;border-top:1px solid #ddd;margin:32px 0">
<p style="font-size:12px;color:#999">Routed via National Paper Hangers · nationalpaperhangers.com</p>
</div>`
};
}
function coiRequestDesigner({ request, installer }) {
return {
subject: `Your COI request to ${installer.business_name} is in flight`,
html: `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
<h1 style="font-size:22px;margin:0 0 16px;letter-spacing:0.02em">COI request received</h1>
<p style="font-size:15px;line-height:1.6">Hi ${escapeHtml(request.designer_name)},</p>
<p style="font-size:15px;line-height:1.6">
We've forwarded your Certificate of Insurance request to ${escapeHtml(installer.business_name)}${
(installer.insurance && installer.insurance.broker_email) ? ' and their broker' : ''
}.
</p>
<p style="font-size:15px;line-height:1.6">
Expect the cert to land in your inbox within 1–3 business days. The
additional-insured party will be:
</p>
<p style="font-size:15px;line-height:1.6;border-left:3px solid #999;padding-left:12px;margin:16px 0">
${escapeHtml(request.additional_insured_name)}<br>
${escapeHtml(request.additional_insured_address || '')}
</p>
<p style="font-size:13px;color:#666;line-height:1.6">
If you don't hear back within 3 business days, reply to this email and we'll nudge.
</p>
<hr style="border:none;border-top:1px solid #ddd;margin:32px 0">
<p style="font-size:12px;color:#999">National Paper Hangers · info@nationalpaperhangers.com</p>
</div>`
};
}
module.exports = {
sendEmail,
bookingConfirmationCustomer,
bookingNotificationInstaller,
coiRequestInstaller,
coiRequestBroker,
coiRequestDesigner
};