← back to Email Deliverability Agent

canary.js

232 lines

#!/usr/bin/env node
'use strict';

/**
 * canary.js — email deliverability canary.
 *
 * Sends ONE test email with a unique token in the subject, then polls the
 * destination mailbox (IMAP) to confirm the message actually arrived. This
 * proves the *receive + route* path end-to-end for one domain.
 *
 * Intended cadence: every 3 days (cron / launchd). This script does NOT
 * register any schedule itself — see README "Scheduling" for the crontab /
 * launchd plist you can install manually.
 *
 * SAFETY: defaults to --dry-run. It will NOT send live email or open any
 * network connection unless you pass --live AND provide SMTP/IMAP creds.
 *
 *   node canary.js                 # dry-run: prints the plan, sends nothing
 *   node canary.js --live          # actually send + verify (needs creds)
 *   node canary.js --live --to addr@example.com
 *
 * Env (only read in --live mode):
 *   CANARY_SMTP_HOST   default smtp.purelymail.com
 *   CANARY_SMTP_PORT   default 465
 *   CANARY_SMTP_USER   sending mailbox login
 *   CANARY_SMTP_PASS   sending mailbox password
 *   CANARY_FROM        From: address (default = SMTP_USER)
 *   CANARY_TO          destination address to verify
 *   CANARY_IMAP_HOST   default imap.purelymail.com
 *   CANARY_IMAP_PORT   default 993
 *   CANARY_IMAP_USER   mailbox to poll for the test message
 *   CANARY_IMAP_PASS   password for the IMAP mailbox
 *   CANARY_TIMEOUT_MS  receipt poll timeout (default 180000 = 3 min)
 *
 * Optional dependencies for --live mode (kept out of package.json deps so
 * the dry-run path has zero install footprint):
 *   npm install nodemailer imapflow
 */

const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { ensureOutputDir } = require('./lib/data');

function parseArgs(argv) {
  const args = { live: false, to: null, from: null };
  for (let i = 2; i < argv.length; i++) {
    const a = argv[i];
    if (a === '--live') args.live = true;
    else if (a === '--dry-run') args.live = false;
    else if (a === '--to') args.to = argv[++i];
    else if (a === '--from') args.from = argv[++i];
  }
  return args;
}

function cfg(args) {
  return {
    smtpHost: process.env.CANARY_SMTP_HOST || 'smtp.purelymail.com',
    smtpPort: Number(process.env.CANARY_SMTP_PORT || 465),
    smtpUser: process.env.CANARY_SMTP_USER || '',
    smtpPass: process.env.CANARY_SMTP_PASS || '',
    from: args.from || process.env.CANARY_FROM || process.env.CANARY_SMTP_USER || '',
    to: args.to || process.env.CANARY_TO || '',
    imapHost: process.env.CANARY_IMAP_HOST || 'imap.purelymail.com',
    imapPort: Number(process.env.CANARY_IMAP_PORT || 993),
    imapUser: process.env.CANARY_IMAP_USER || '',
    imapPass: process.env.CANARY_IMAP_PASS || '',
    timeoutMs: Number(process.env.CANARY_TIMEOUT_MS || 180000),
  };
}

function makeToken() {
  return `CANARY-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
}

function logResult(entry) {
  const outDir = ensureOutputDir();
  const file = path.join(outDir, 'canary-log.jsonl');
  fs.appendFileSync(file, JSON.stringify(entry) + '\n');
  return file;
}

async function runDryRun(c, token) {
  console.log('\n=== Deliverability Canary (DRY RUN) ===');
  console.log('No email will be sent. No network connection will be opened.');
  console.log('Planned send:');
  console.log(`  from     : ${c.from || '(unset — set CANARY_FROM)'}`);
  console.log(`  to       : ${c.to || '(unset — set CANARY_TO or pass --to)'}`);
  console.log(`  subject  : [Deliverability Canary] ${token}`);
  console.log(`  via SMTP : ${c.smtpHost}:${c.smtpPort}`);
  console.log('Planned verification:');
  console.log(`  poll IMAP: ${c.imapHost}:${c.imapPort} as ${c.imapUser || '(unset)'}`);
  console.log(`  match    : subject contains "${token}"`);
  console.log(`  timeout  : ${c.timeoutMs} ms`);
  console.log('\nTo run for real: node canary.js --live  (requires CANARY_* env creds)');
  const entry = {
    ts: new Date().toISOString(),
    mode: 'dry-run',
    token,
    from: c.from || null,
    to: c.to || null,
    result: 'SKIPPED',
  };
  const file = logResult(entry);
  console.log(`Logged dry-run to ${file}\n`);
  return entry;
}

async function runLive(c, token) {
  // Lazy-require so dry-run never needs these installed.
  let nodemailer, ImapFlow;
  try {
    nodemailer = require('nodemailer');
    ({ ImapFlow } = require('imapflow'));
  } catch (err) {
    throw new Error(
      'Live mode needs optional deps. Run: npm install nodemailer imapflow\n' +
      `(${err.message})`
    );
  }
  if (!c.smtpUser || !c.smtpPass || !c.from || !c.to) {
    throw new Error(
      'Live mode requires CANARY_SMTP_USER, CANARY_SMTP_PASS, CANARY_FROM, CANARY_TO.'
    );
  }
  if (!c.imapUser || !c.imapPass) {
    throw new Error('Live mode requires CANARY_IMAP_USER and CANARY_IMAP_PASS to verify receipt.');
  }

  console.log('\n=== Deliverability Canary (LIVE) ===');
  const sentAt = Date.now();
  const subject = `[Deliverability Canary] ${token}`;

  // 1. Send
  const transport = nodemailer.createTransport({
    host: c.smtpHost,
    port: c.smtpPort,
    secure: c.smtpPort === 465,
    auth: { user: c.smtpUser, pass: c.smtpPass },
  });
  console.log(`Sending ${c.from} -> ${c.to} ...`);
  const info = await transport.sendMail({
    from: c.from,
    to: c.to,
    subject,
    text:
      `Automated deliverability canary.\nToken: ${token}\n` +
      `Sent: ${new Date(sentAt).toISOString()}\n` +
      `If you received this, the receive+route path for ${c.to.split('@')[1]} works.`,
  });
  console.log(`  accepted by SMTP: ${info.messageId || '(no id)'}`);

  // 2. Verify receipt by polling IMAP for the token in the subject.
  const imap = new ImapFlow({
    host: c.imapHost,
    port: c.imapPort,
    secure: true,
    auth: { user: c.imapUser, pass: c.imapPass },
    logger: false,
  });
  let received = false;
  let receivedAt = null;
  await imap.connect();
  try {
    const deadline = sentAt + c.timeoutMs;
    while (Date.now() < deadline && !received) {
      const lock = await imap.getMailboxLock('INBOX');
      try {
        for await (const msg of imap.fetch(
          { since: new Date(sentAt - 60000) },
          { envelope: true }
        )) {
          const subj = (msg.envelope && msg.envelope.subject) || '';
          if (subj.includes(token)) {
            received = true;
            receivedAt = Date.now();
            break;
          }
        }
      } finally {
        lock.release();
      }
      if (!received) await new Promise((r) => setTimeout(r, 10000));
    }
  } finally {
    await imap.logout();
  }

  const latencyMs = receivedAt ? receivedAt - sentAt : null;
  const result = received ? 'DELIVERED' : 'NOT_RECEIVED';
  console.log(
    received
      ? `  VERIFIED: message arrived in ${(latencyMs / 1000).toFixed(1)}s`
      : `  FAILED: message not seen within ${c.timeoutMs / 1000}s — deliverability problem`
  );

  const entry = {
    ts: new Date().toISOString(),
    mode: 'live',
    token,
    from: c.from,
    to: c.to,
    result,
    latencyMs,
  };
  const file = logResult(entry);
  console.log(`Logged result to ${file}\n`);
  return entry;
}

async function main() {
  const args = parseArgs(process.argv);
  const c = cfg(args);
  const token = makeToken();
  if (args.live) {
    const entry = await runLive(c, token);
    if (entry.result !== 'DELIVERED') process.exitCode = 2;
  } else {
    await runDryRun(c, token);
  }
}

if (require.main === module) {
  main().catch((err) => {
    console.error('canary.js failed:', err.message);
    process.exit(1);
  });
}

module.exports = { main, makeToken };