[object Object]

← back to Butlr

wire nodemailer Purelymail SMTP transport for password-reset email

c3e3751889185e48d6d1038741a05ddcb45ec194 · 2026-05-19 12:50:18 -0700 · SteveStudio2

Files touched

Diff

commit c3e3751889185e48d6d1038741a05ddcb45ec194
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Tue May 19 12:50:18 2026 -0700

    wire nodemailer Purelymail SMTP transport for password-reset email
---
 lib/reset-delivery.js | 50 ++++++++++++++++++++++++++++++++++++++------------
 package-lock.json     | 10 ++++++++++
 package.json          |  1 +
 3 files changed, 49 insertions(+), 12 deletions(-)

diff --git a/lib/reset-delivery.js b/lib/reset-delivery.js
index dd7fa9b..566def1 100644
--- a/lib/reset-delivery.js
+++ b/lib/reset-delivery.js
@@ -3,12 +3,13 @@
 // ── 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. Butlr also has NO email-sending infrastructure wired (no
-// nodemailer, no SMTP, no transactional-email API key in .env). It DOES
-// have a working, DNC-gated Twilio SMS sender (lib/twilio.sendSms).
+// 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.
 //
-// So neither channel is turnkey:
-//   - EMAIL  — the natural channel (accounts are email-keyed) but no sender.
+// 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
@@ -41,8 +42,11 @@ function log(...a) { console.log('[reset-delivery]', new Date().toISOString(), .
 // ── EMAIL ──────────────────────────────────────────────────────────────
 // Transport is selected by RESET_EMAIL_TRANSPORT:
 //   'log' (default)  — prints the link to the server log (dev / not-yet-wired)
-//   'smtp' / 'api'   — placeholder; throws so a misconfig fails loud, not
-//                      silently. Wire nodemailer or a provider SDK here.
+//   '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') {
@@ -50,12 +54,34 @@ async function sendEmail({ to, subject, text }) {
     log(`  ${text.split('\n').join('\n  ')}`);
     return { ok: true, transport: 'log' };
   }
-  // A real transport is requested but not implemented in this build.
-  // Fail loud rather than silently dropping a password-reset email.
+  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 no email transport is ` +
-    `wired in lib/reset-delivery.js. Add nodemailer/provider SDK here, or ` +
-    `set RESET_EMAIL_TRANSPORT=log for dev.`
+    `RESET_EMAIL_TRANSPORT=${transport} requested but only 'log' and 'smtp' ` +
+    `are implemented in lib/reset-delivery.js.`
   );
 }
 
diff --git a/package-lock.json b/package-lock.json
index 32b1444..ce73062 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
         "helmet": "^7.1.0",
         "morgan": "^1.10.0",
         "multer": "^2.1.1",
+        "nodemailer": "^8.0.7",
         "ws": "^8.20.1"
       },
       "engines": {
@@ -758,6 +759,15 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/nodemailer": {
+      "version": "8.0.7",
+      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
+      "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
+      "license": "MIT-0",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
     "node_modules/object-inspect": {
       "version": "1.13.4",
       "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
diff --git a/package.json b/package.json
index f4792d8..3c34764 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
     "helmet": "^7.1.0",
     "morgan": "^1.10.0",
     "multer": "^2.1.1",
+    "nodemailer": "^8.0.7",
     "ws": "^8.20.1"
   },
   "engines": {

← 2606c4d Add password-reset test suite and wire into npm test  ·  back to Butlr  ·  Add external call-trigger API for first-party sites (NPH) f709144 →