← 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 };