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