← back to Butlr
lib/reset-delivery.js
138 lines
// Password-reset delivery layer for Butlr.
//
// ── WHY THIS IS PLUGGABLE ──────────────────────────────────────────────
// As of this build Butlr accounts (lib/users.js) are keyed by EMAIL ONLY —
// there is no phone number on the user record, and signup only collects an
// email. EMAIL is therefore the natural reset channel, and it is now wired:
// the 'smtp' transport below sends via nodemailer over Purelymail SMTP
// (mailbox noreply@agentabrams.com). Butlr also has a working, DNC-gated
// Twilio SMS sender (lib/twilio.sendSms) kept as an alternate channel.
//
// Channel notes:
// - EMAIL — wired (smtp transport); the default and recommended channel.
// - SMS — a working sender exists, but no phone is stored on accounts.
//
// This module therefore exposes ONE function, deliverResetLink(), and picks
// the channel from RESET_DELIVERY_CHANNEL env:
//
// RESET_DELIVERY_CHANNEL=email (default)
// Sends the reset LINK by email. Until an email transport is wired,
// the 'log' transport just prints the link to the server log so the
// flow is fully testable and Steve can hand-deliver during dev.
// To go live, set RESET_EMAIL_TRANSPORT=smtp|api and fill the
// matching env (see sendEmail() below) — or drop in nodemailer.
//
// RESET_DELIVERY_CHANNEL=sms
// Sends the reset LINK by SMS via the existing twilio.sendSms().
// Requires the user record to have a `phone` field — accounts created
// before a phone-collection change will NOT have one, and delivery
// returns { ok:false, reason:'no_phone' }. The CALLER must still
// respond identically to the user (anti-enumeration) — see the route.
//
// SECURITY: the raw reset token is contained in the link/URL only. It is
// never logged here in a way that ties it to an account beyond what the
// chosen transport inherently does (the 'log' transport prints it for dev —
// acceptable because dev logs are not user-facing; do NOT use 'log' in prod
// with RESET_DELIVERY_CHANNEL=email expecting privacy).
const CHANNEL = (process.env.RESET_DELIVERY_CHANNEL || 'email').toLowerCase();
function log(...a) { console.log('[reset-delivery]', new Date().toISOString(), ...a); }
// ── EMAIL ──────────────────────────────────────────────────────────────
// Transport is selected by RESET_EMAIL_TRANSPORT:
// 'log' (default) — prints the link to the server log (dev / not-yet-wired)
// 'smtp' — sends via nodemailer over SMTP. Needs RESET_SMTP_HOST,
// RESET_SMTP_PORT, RESET_SMTP_USER, RESET_SMTP_PASS and
// RESET_EMAIL_FROM. Wired for Purelymail SMTP
// (smtp.purelymail.com:465, mailbox noreply@agentabrams.com).
// 'api' — not implemented; throws loud rather than silently drop.
async function sendEmail({ to, subject, text }) {
const transport = (process.env.RESET_EMAIL_TRANSPORT || 'log').toLowerCase();
if (transport === 'log') {
log(`EMAIL[log-transport] to=${to} subject="${subject}"`);
log(` ${text.split('\n').join('\n ')}`);
return { ok: true, transport: 'log' };
}
if (transport === 'smtp') {
const host = process.env.RESET_SMTP_HOST;
const user = process.env.RESET_SMTP_USER;
const pass = process.env.RESET_SMTP_PASS;
if (!host || !user || !pass) {
// Fail loud — never silently drop a password-reset email.
throw new Error(
'RESET_EMAIL_TRANSPORT=smtp but RESET_SMTP_HOST / RESET_SMTP_USER / ' +
'RESET_SMTP_PASS are not all set.'
);
}
const port = parseInt(process.env.RESET_SMTP_PORT, 10) || 465;
const nodemailer = require('nodemailer');
const tx = nodemailer.createTransport({
host,
port,
secure: port === 465, // 465 = implicit TLS; 587 = STARTTLS
auth: { user, pass }
});
const from = process.env.RESET_EMAIL_FROM || user;
const info = await tx.sendMail({ from, to, subject, text });
log(`EMAIL[smtp] sent to=${to} id=${info.messageId}`);
return { ok: true, transport: 'smtp' };
}
// 'api' or any unknown value — not implemented; fail loud.
throw new Error(
`RESET_EMAIL_TRANSPORT=${transport} requested but only 'log' and 'smtp' ` +
`are implemented in lib/reset-delivery.js.`
);
}
// ── SMS ────────────────────────────────────────────────────────────────
async function sendSms({ to, body }) {
const twilio = require('./twilio');
return twilio.sendSms({ to, body });
}
// ── public API ─────────────────────────────────────────────────────────
//
// deliverResetLink({ user, resetUrl, expiresMinutes })
// user — full user record from lib/users (has .email, maybe .phone)
// resetUrl — absolute https URL carrying ?token=… (built by the route)
// expiresMinutes — integer, for the message copy
//
// Returns { ok, channel, reason? }. A false ok with reason 'no_phone' means
// the SMS channel is selected but the account has no phone — the route MUST
// still respond to the user identically (no enumeration leak).
async function deliverResetLink({ user, resetUrl, expiresMinutes }) {
const mins = expiresMinutes || 60;
if (CHANNEL === 'sms') {
if (!user.phone) {
log(`SMS channel selected but user ${user.id} has no phone on record`);
return { ok: false, channel: 'sms', reason: 'no_phone' };
}
const body =
`Butlr password reset: open this link within ${mins} min to set a new ` +
`password. ${resetUrl} If you didn't request this, ignore this message.`;
const r = await sendSms({ to: user.phone, body });
return { ok: !!r.ok, channel: 'sms', reason: r.ok ? undefined : (r.error || 'sms_failed') };
}
// default — email
const subject = 'Reset your Butlr password';
const text =
`Someone (hopefully you) asked to reset the password for your Butlr ` +
`account.\n\n` +
`Open this link within ${mins} minutes to choose a new password:\n` +
`${resetUrl}\n\n` +
`This link can be used once. If you didn't request a reset, you can ` +
`safely ignore this email — your password will not change.`;
try {
const r = await sendEmail({ to: user.email, subject, text });
return { ok: !!r.ok, channel: 'email', reason: r.ok ? undefined : 'email_failed' };
} catch (e) {
log('email delivery threw:', e.message);
return { ok: false, channel: 'email', reason: 'email_transport_unconfigured' };
}
}
module.exports = { deliverResetLink, CHANNEL };