argorant 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -252,10 +252,12 @@ npx argorant webhooks rm 3 --yes
252
252
  ```
253
253
 
254
254
  `credits buy` shows the quote first and only buys after a yes: at a terminal it asks, anywhere
255
- else it needs `--yes`. When your workspace saved its card for API and agent purchases and the
256
- purchase fits the limits you set in Settings, that card is charged right away. Otherwise you get a
257
- payment link to pay yourself, and the credits are added once the payment goes through.
258
- `--pay-with link` always gives the link. Bought credits stay valid for 365 days.
255
+ else it needs `--yes`. An agent that runs the CLI for someone passes that person's own words with
256
+ `--confirmation "yes, buy 5,000 credits"`; every purchase records how it was confirmed. When the
257
+ account owner allowed purchases with the saved card in Settings > Billing (up to a monthly limit)
258
+ and the purchase fits, that card is charged right away. Otherwise you get a payment link to pay
259
+ yourself, and the credits are added once the payment goes through. `--pay-with link` always gives
260
+ the link. Bought credits stay valid for 365 days.
259
261
 
260
262
  `lists rm` and `webhooks rm` work the same way: they ask at a terminal and need `--yes` anywhere
261
263
  else. A webhook's signing secret is shown once, when you add it.
@@ -282,6 +284,10 @@ argorant campaigns create --name "Q4 CFO outreach" [--daily-limit 40] [--timezon
282
284
  argorant campaigns emails set <campaign> --step 1 --subject "Quick question, {{first_name}}" --body-file ./step1.txt
283
285
  argorant campaigns emails set <campaign> --step 2 --body "Bumping this." --delay-days 3
284
286
  argorant campaigns emails set <campaign> --file emails.json # whole sequence at once
287
+ argorant campaigns subsequences list <campaign> # reply-keyword follow-ups
288
+ argorant campaigns subsequences set <campaign> --file subsequences.json # up to 5, replaces the set
289
+ # keywords also match in capitals, as a plural, with a small typo or inside a short sentence;
290
+ # "not interested" and replies that ask to stop never start a subsequence
285
291
  argorant campaigns leads add <campaign> --list <list-id> # a saved list
286
292
  argorant campaigns leads add <campaign> --csv leads.csv # email, first_name, last_name, company, title
287
293
  argorant campaigns senders set <campaign> --emails a@x.com,b@y.com # or --all
@@ -302,6 +308,40 @@ argorant inboxes connect-google --admin admin@yourdomain.com # lists mailb
302
308
  argorant inboxes connect-google --admin admin@yourdomain.com --emails a@x.com,b@x.com # or --all
303
309
  ```
304
310
 
311
+ ### Mailboxes from any email provider (SMTP and IMAP)
312
+
313
+ Zoho, Fastmail, Yahoo, iCloud, IONOS, your web host or any other provider connect with their server settings.
314
+ Argorant signs in twice before it saves anything: once to send (SMTP) and once to read replies (IMAP). A mailbox
315
+ connects only when both work. The password is stored safely and never shown again; many providers need an app
316
+ password instead of the normal one. Google and Microsoft mailboxes connect with their own sign-in instead.
317
+
318
+ ```
319
+ argorant inboxes presets # provider keys for --preset
320
+ argorant inboxes test-smtp --email anna@acme.com --preset zoho # checks sending and reading replies, saves nothing
321
+ argorant inboxes connect-smtp --email anna@acme.com --preset zoho --name "Anna Weber"
322
+ argorant inboxes connect-smtp --email anna@acme.com --smtp-host mail.acme.com --smtp-port 587 --imap-host mail.acme.com
323
+ argorant inboxes connect-smtp --csv mailboxes.csv # many at once, a result per row
324
+ ```
325
+
326
+ The password is never taken on the command line. Put it in an environment variable and name it with
327
+ `--password-env NAME`, set `ARGORANT_MAILBOX_PASSWORD`, or type it when asked. `--imap-password-env NAME` sets a
328
+ different password for reading replies. Connecting the same address again replaces its settings, which is how a
329
+ mailbox is reconnected after a password change.
330
+
331
+ CSV columns: `email, sender_name, preset, smtp_host, smtp_port, smtp_security, smtp_username, smtp_password,
332
+ imap_host, imap_port, imap_security, imap_username, imap_password, daily_limit`. Each row needs `email`,
333
+ `smtp_password` (or `password`) and either `preset` or `smtp_host` + `smtp_port` + `imap_host`. Empty columns take
334
+ the defaults: the username is the email address, reading replies uses the sending sign-in, port 993, 25 emails a
335
+ day. Security is `ssl`, `starttls` or `none`. Use port 587 for sending; if you give 465, Argorant tries 587 and
336
+ saves 587 when it works. Rows go up 25 at a time; the command exits 1 when any row did not
337
+ connect.
338
+
339
+ ```
340
+ email,sender_name,preset,smtp_password
341
+ anna@acme.com,Anna Weber,zoho,app-password-1
342
+ ben@acme.com,Ben Hill,zoho,app-password-2
343
+ ```
344
+
305
345
  New mailboxes on their own new domains, ordered from the terminal. The quote shows every domain with its
306
346
  first-year and renewal price, the mailboxes, the total and how it is paid. When your workspace saved its card
307
347
  for API and agent purchases (you ticked that box when you paid an earlier order, and the setting in
package/bin/argorant.js CHANGED
@@ -1460,6 +1460,89 @@ async function campaignsEmails(argv) {
1460
1460
  console.log(green("✓") + ` Saved ${(r.emails || emails).length} email(s) for ${dim(campaignId)}`);
1461
1461
  }
1462
1462
 
1463
+ // subsequences: follow-ups a reply keyword starts (GET / POST /api/v1/campaigns/{id}/subsequences).
1464
+ // The server replaces the whole set: subsequences left out are deleted with anyone still in them,
1465
+ // so `set` asks before it drops one that still has people in progress (-y skips the question).
1466
+ const SUBSEQUENCES_USAGE =
1467
+ "usage: argorant campaigns subsequences list <campaign>\n" +
1468
+ " or: argorant campaigns subsequences set <campaign> --file subsequences.json [-y]\n" +
1469
+ " or: argorant campaigns subsequences clear <campaign> [-y]\n" +
1470
+ ' subsequences.json: [{"name":"Interested","keywords":["interested","yes"],"emails":[{"body":"...","delay_days":0},{"body":"...","delay_days":3}]}]\n' +
1471
+ " Up to 5 subsequences, 1 to 10 keywords and 1 to 8 emails each. Email 1 defaults to the subject 'Re: {{thread_subject}}'.\n" +
1472
+ " Keywords also count in capitals, as a plural, with a small typo or inside a short sentence.\n" +
1473
+ " 'not interested' and replies that ask to stop never start one.";
1474
+
1475
+ function readSubsequencesFile(text, source) {
1476
+ let data;
1477
+ try { data = JSON.parse(text); } catch (e) { die(`${source}: not valid JSON (${e.message})`); }
1478
+ const items = Array.isArray(data) ? data : data && Array.isArray(data.subsequences) ? data.subsequences : null;
1479
+ if (!items) die(`${source}: expected a list of subsequences (or {"subsequences": [...]})`);
1480
+ if (items.length > 5) die(`${source}: a campaign can have up to 5 subsequences (got ${items.length})`);
1481
+ return items.map((item, i) => {
1482
+ if (!item || typeof item !== "object" || Array.isArray(item)) die(`${source}: subsequence ${i + 1} is not an object`);
1483
+ const keywords = typeof item.keywords === "string" ? item.keywords.split(",") : item.keywords;
1484
+ if (!Array.isArray(keywords) || !keywords.some((k) => String(k || "").trim())) die(`${source}: subsequence ${i + 1} needs at least one keyword`);
1485
+ const emails = item.emails || item.steps;
1486
+ if (!Array.isArray(emails) || !emails.length) die(`${source}: subsequence ${i + 1} needs at least one email`);
1487
+ const out = { name: item.name, keywords: keywords.map((k) => String(k).trim()).filter(Boolean), emails };
1488
+ if (item.id) out.id = item.id;
1489
+ return out;
1490
+ });
1491
+ }
1492
+
1493
+ function printSubsequences(r) {
1494
+ const subs = r.subsequences || [];
1495
+ console.log(dim(`Stop on reply: ${r.stop_on_reply === false ? "off" : "on"}`));
1496
+ if (!subs.length) return console.log(dim("No subsequences. Add them with `argorant campaigns subsequences set <campaign> --file subsequences.json`."));
1497
+ subs.forEach((s, i) => {
1498
+ const st = s.stats || {};
1499
+ console.log(`${bold(`${i + 1}. ${s.name}`)} ${dim(s.id || "")} ${s.status || ""}`);
1500
+ console.log(` keywords: ${(s.keywords || []).join(", ")}`);
1501
+ console.log(` emails: ${(s.steps || []).length} ${dim(`in progress ${st.in_progress || 0} replied ${st.replied || 0} finished ${st.finished || 0} sent ${st.emails_sent || 0}`)}`);
1502
+ for (const step of s.steps || []) {
1503
+ const preview = String(step.body || "").replace(/\s+/g, " ").slice(0, 70);
1504
+ console.log(dim(` ${step.step_number}. +${step.delay_days || 0}d ${step.subject || "(same thread)"} ${preview}`));
1505
+ }
1506
+ });
1507
+ }
1508
+
1509
+ async function campaignsSubsequences(argv) {
1510
+ const sub = argv[0];
1511
+ if (!["list", "get", "set", "clear"].includes(sub)) die(SUBSEQUENCES_USAGE);
1512
+ const key = requireKey();
1513
+ const args = readFlags(argv.slice(1), { "--file": "file" });
1514
+ const identifier = args._[0];
1515
+ if (!identifier) die(SUBSEQUENCES_USAGE);
1516
+ const campaignId = await resolveCampaign(args.base, key, identifier);
1517
+ const route = `/api/v1/campaigns/${campaignId}/subsequences`;
1518
+ if (sub === "list" || sub === "get") {
1519
+ const r = need(await request("GET", args.base, route, { key }), "campaigns subsequences");
1520
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
1521
+ return printSubsequences(r);
1522
+ }
1523
+ let items = [];
1524
+ if (sub === "set") {
1525
+ if (!args.file) die(SUBSEQUENCES_USAGE);
1526
+ const text = args.file === "-" ? await readStdin() : (() => { try { return fs.readFileSync(args.file, "utf8"); } catch { die(`cannot read file: ${args.file}`); } })();
1527
+ items = readSubsequencesFile(text, args.file === "-" ? "stdin" : args.file);
1528
+ }
1529
+ // What the replace would drop, and who is still inside it.
1530
+ const current = need(await request("GET", args.base, route, { key }), "campaigns subsequences");
1531
+ const keptIds = new Set(items.map((i) => i.id).filter(Boolean));
1532
+ const dropped = (current.subsequences || []).filter((s) => !keptIds.has(s.id));
1533
+ const busy = dropped.filter((s) => ((s.stats || {}).in_progress || 0) > 0);
1534
+ if (dropped.length && !args.yes) {
1535
+ const who = busy.length ? ` (${busy.map((s) => `${s.name}: ${s.stats.in_progress} in progress`).join(", ")})` : "";
1536
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1537
+ const ok = await new Promise((r) => rl.question(`This removes ${dropped.length} subsequence(s)${who}, with anyone still in them. Continue? [y/N] `, (a) => { rl.close(); r(/^y/i.test(a)); }));
1538
+ if (!ok) return console.log(dim("aborted"));
1539
+ }
1540
+ const r = need(await request("POST", args.base, route, { key, body: { subsequences: items } }), "campaigns subsequences set");
1541
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
1542
+ console.log(green("✓") + ` Saved ${(r.subsequences || []).length} subsequence(s) for ${dim(campaignId)}`);
1543
+ printSubsequences(r);
1544
+ }
1545
+
1463
1546
  // senders: which connected mailboxes send this campaign.
1464
1547
  async function campaignsSenders(argv) {
1465
1548
  const sub = argv[0];
@@ -1912,6 +1995,9 @@ ${bold("USAGE")}
1912
1995
  --gap 60-120 --unsubscribe|--no-unsubscribe --start YYYY-MM-DD --tags a,b --name "<n>"
1913
1996
  ${p} emails set <campaign> --file emails.json
1914
1997
  ${p} emails set <campaign> --step <n> --subject "..." (--body-file <path> | --body <text|->) [--delay-days <d>]
1998
+ ${p} subsequences list <campaign>
1999
+ ${p} subsequences set <campaign> --file subsequences.json [-y] ${dim("reply keywords start follow-ups in the same thread")}
2000
+ ${p} subsequences clear <campaign> [-y]
1915
2001
  ${p} leads add <campaign> --list <list-id> | --csv <file> [--include-catch-all]
1916
2002
  ${p} leads list <campaign> [--status replied] [--q text] [-n 50]
1917
2003
  ${p} leads remove <campaign> <email>
@@ -1929,6 +2015,10 @@ ${bold("USAGE")}
1929
2015
  ${bold("argorant inboxes")} set <mailbox> [--daily-limit 40] [--warmup|--no-warmup] [--active|--inactive] [--timezone <tz>]
1930
2016
  ${bold("argorant inboxes")} disconnect <mailbox>
1931
2017
  ${bold("argorant inboxes")} connect-google --admin <admin@yourdomain.com> [--emails a,b | --all]
2018
+ ${bold("argorant inboxes")} connect-smtp --email <address> --preset <key> [--name "Sender"] [--password-env NAME] ${dim("any provider; --smtp-host/--imap-host without a preset")}
2019
+ ${bold("argorant inboxes")} connect-smtp --csv mailboxes.csv ${dim("many at once, 25 per request, a result per row")}
2020
+ ${bold("argorant inboxes")} test-smtp --email <address> --preset <key> ${dim("checks sending and reading replies, saves nothing")}
2021
+ ${bold("argorant inboxes")} presets ${dim("provider keys for --preset (zoho, fastmail, yahoo, icloud, ...)")}
1932
2022
  ${bold("argorant inboxes")} endings ${dim("the domain endings on offer (com, net, org, co, sbs, biz, info)")}
1933
2023
  ${bold("argorant inboxes")} suggest <website|brand> [--ending com] [-n 20] ${dim("free domain names with prices")}
1934
2024
  ${bold("argorant inboxes")} quote --mailboxes 100 [--company acme.com] [--domains a.com,b.com] [--currency usd|eur] [--billing month|quarter]
@@ -1976,6 +2066,8 @@ async function cmdCampaigns(argv) {
1976
2066
  replies: campaignsReplies,
1977
2067
  analytics: campaignsAnalytics,
1978
2068
  emails: campaignsEmails,
2069
+ subsequences: campaignsSubsequences,
2070
+ subsequence: campaignsSubsequences,
1979
2071
  steps: campaignsSteps,
1980
2072
  senders: campaignsSenders,
1981
2073
  inboxes: campaignsInboxes,
@@ -2000,7 +2092,7 @@ async function cmdInboxes(argv) {
2000
2092
  const r = await fetchPages(args.base, key, "/api/v1/inboxes", { limit: 100 }, "inboxes", "inboxes list", true);
2001
2093
  if (args.json) return console.log(JSON.stringify(r, null, 2));
2002
2094
  const rows = r.inboxes || [];
2003
- if (!rows.length) return console.log(dim("No mailboxes connected yet. Connect Google Workspace with `argorant inboxes connect-google --admin ...` or Microsoft 365 in the app."));
2095
+ if (!rows.length) return console.log(dim("No mailboxes connected yet. Connect Google Workspace with `argorant inboxes connect-google --admin ...`, any other provider with `argorant inboxes connect-smtp --email ... --preset ...`, or Microsoft 365 in the app."));
2004
2096
  for (const i of rows) {
2005
2097
  console.log(`${bold(i.email)} ${dim(i.name || "")}`);
2006
2098
  console.log(dim(` ${i.provider} · ${i.status}${i.health ? ` · ${i.health}` : ""} · today ${i.sent_today || 0}/${i.daily_limit || "?"}`));
@@ -2207,7 +2299,267 @@ async function cmdInboxes(argv) {
2207
2299
  if (args.json) return console.log(JSON.stringify(r, null, 2));
2208
2300
  return console.log(green("✓") + ` Bundle ${target} cancelled${r.existing ? dim(" (already)") : ""}`);
2209
2301
  }
2210
- die(`unknown inboxes subcommand: ${sub} (list, set, disconnect, connect-google, endings, suggest, quote, order, status, renewal, cancel-bundle)`);
2302
+ if (sub === "presets") {
2303
+ const args = readFlags(argv.slice(1));
2304
+ const r = need(await request("GET", args.base, "/api/v1/inboxes/smtp/presets", { key }), "inboxes presets");
2305
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
2306
+ for (const p of r.presets || []) {
2307
+ const out = p.smtp_host ? `${p.smtp_host}:${p.smtp_port} ${p.smtp_secure}` : "you enter the servers";
2308
+ const inn = p.imap_host ? `${p.imap_host}:${p.imap_port} ${p.imap_secure}` : "";
2309
+ console.log(`${bold(p.key.padEnd(14))} ${p.label}`);
2310
+ console.log(dim(` sending ${out}${inn ? ` · reading replies ${inn}` : ""}`));
2311
+ if (p.hint) console.log(dim(` ${p.hint}`));
2312
+ }
2313
+ return;
2314
+ }
2315
+ if (sub === "test-smtp" || sub === "connect-smtp") return cmdSmtpMailbox(sub, argv.slice(1), key);
2316
+ die(`unknown inboxes subcommand: ${sub} (list, set, disconnect, connect-google, connect-smtp, test-smtp, presets, endings, suggest, quote, order, status, renewal, cancel-bundle)`);
2317
+ }
2318
+
2319
+ // ---- mailboxes from any email provider (SMTP sending + IMAP replies), 0.14.0 ----
2320
+ // The password is never taken on the command line (shell history): --password-env NAME, the
2321
+ // ARGORANT_MAILBOX_PASSWORD variable, a hidden prompt, or the CSV file for many mailboxes.
2322
+ const SMTP_FLAGS = {
2323
+ "--email": "email", "--name": "name", "--preset": "preset",
2324
+ "--smtp-host": "smtpHost", "--smtp-port": "smtpPort", "--smtp-security": "smtpSecurity", "--smtp-user": "smtpUser",
2325
+ "--imap-host": "imapHost", "--imap-port": "imapPort", "--imap-security": "imapSecurity", "--imap-user": "imapUser",
2326
+ "--daily-limit": "dailyLimit", "--password-env": "passwordEnv", "--imap-password-env": "imapPasswordEnv", "--csv": "csv",
2327
+ };
2328
+ const SMTP_BULK_CHUNK = 25;
2329
+ const SMTP_USAGE = `usage: argorant inboxes connect-smtp --email <address> (--preset <key> | --smtp-host <h> --smtp-port <p> --imap-host <h>)
2330
+ [--name "Sender Name"] [--smtp-security starttls|ssl|none] [--smtp-user <u>] [--imap-port 993]
2331
+ [--imap-security ssl|starttls|none] [--imap-user <u>] [--daily-limit 25] [--password-env NAME] [--imap-password-env NAME]
2332
+ argorant inboxes connect-smtp --csv mailboxes.csv
2333
+ argorant inboxes test-smtp (same flags as connect-smtp for one mailbox; saves nothing)
2334
+ argorant inboxes presets (the provider keys for --preset)`;
2335
+ // CSV header -> field (lower case, spaces and dashes as underscores).
2336
+ const SMTP_CSV_COLUMNS = {
2337
+ email: "email", sender_name: "sender_name", name: "sender_name", display_name: "sender_name", preset: "preset",
2338
+ smtp_host: "smtp_host", smtp_port: "smtp_port", smtp_security: "smtp_security", smtp_secure: "smtp_security",
2339
+ smtp_username: "smtp_username", smtp_user: "smtp_username", smtp_password: "smtp_password", password: "smtp_password",
2340
+ imap_host: "imap_host", imap_port: "imap_port", imap_security: "imap_security", imap_secure: "imap_security",
2341
+ imap_username: "imap_username", imap_user: "imap_username", imap_password: "imap_password", daily_limit: "daily_limit",
2342
+ };
2343
+
2344
+ function normalizeSecurity(value, what) {
2345
+ if (value === undefined || value === null || String(value).trim() === "") return undefined;
2346
+ const v = String(value).trim().toLowerCase().replace(/[\s_-]+/g, "");
2347
+ if (["ssl", "ssl/tls", "tls", "ssltls", "implicit"].includes(v)) return "ssl";
2348
+ if (["starttls", "start"].includes(v)) return "starttls";
2349
+ if (["none", "no", "off", "plain"].includes(v)) return "none";
2350
+ throw new Error(`${what} must be ssl, starttls or none`);
2351
+ }
2352
+ function parsePort(value, what) {
2353
+ if (value === undefined || value === null || String(value).trim() === "") return undefined;
2354
+ const n = Number(String(value).trim());
2355
+ if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`${what} must be a port number from 1 to 65535`);
2356
+ return n;
2357
+ }
2358
+ function parseDailyLimit(value) {
2359
+ if (value === undefined || value === null || String(value).trim() === "") return undefined;
2360
+ const n = Number(String(value).trim());
2361
+ if (!Number.isInteger(n) || n < 1 || n > 10000) throw new Error("daily limit must be a whole number from 1 to 10000");
2362
+ return n;
2363
+ }
2364
+ // One mailbox in the API's field names; empty values are left out (the server fills the defaults).
2365
+ function mailboxBody(f) {
2366
+ const body = {};
2367
+ const put = (k, v) => { if (v !== undefined && v !== null && String(v) !== "") body[k] = v; };
2368
+ put("email", f.email ? String(f.email).trim().toLowerCase() : undefined);
2369
+ put("display_name", f.sender_name ? String(f.sender_name).trim() : undefined);
2370
+ put("preset", f.preset ? String(f.preset).trim().toLowerCase() : undefined);
2371
+ put("smtp_host", f.smtp_host ? String(f.smtp_host).trim() : undefined);
2372
+ put("smtp_port", parsePort(f.smtp_port, "smtp port"));
2373
+ put("smtp_secure", normalizeSecurity(f.smtp_security, "smtp security"));
2374
+ put("smtp_username", f.smtp_username ? String(f.smtp_username).trim() : undefined);
2375
+ put("smtp_password", f.smtp_password);
2376
+ put("imap_host", f.imap_host ? String(f.imap_host).trim() : undefined);
2377
+ put("imap_port", parsePort(f.imap_port, "imap port"));
2378
+ put("imap_secure", normalizeSecurity(f.imap_security, "imap security"));
2379
+ put("imap_username", f.imap_username ? String(f.imap_username).trim() : undefined);
2380
+ put("imap_password", f.imap_password);
2381
+ put("daily_limit", parseDailyLimit(f.daily_limit));
2382
+ if (!body.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) throw new Error("needs a valid email address");
2383
+ if (!body.smtp_password) throw new Error("needs a password");
2384
+ if (!body.preset && !(body.smtp_host && body.smtp_port && body.imap_host)) {
2385
+ throw new Error("needs a preset, or the sending server, its port and the server for reading replies");
2386
+ }
2387
+ return body;
2388
+ }
2389
+
2390
+ // RFC 4180 CSV: quoted fields, doubled quotes, commas and line breaks inside quotes, CRLF, a BOM.
2391
+ function parseCsv(text) {
2392
+ const rows = [];
2393
+ let row = [], field = "", quoted = false, i = 0;
2394
+ const src = String(text || "").replace(/^\uFEFF/, "");
2395
+ while (i < src.length) {
2396
+ const ch = src[i];
2397
+ if (quoted) {
2398
+ if (ch === '"') {
2399
+ if (src[i + 1] === '"') { field += '"'; i += 2; continue; }
2400
+ quoted = false; i += 1; continue;
2401
+ }
2402
+ field += ch; i += 1; continue;
2403
+ }
2404
+ if (ch === '"' && field === "") { quoted = true; i += 1; continue; }
2405
+ if (ch === ",") { row.push(field); field = ""; i += 1; continue; }
2406
+ if (ch === "\r" && src[i + 1] === "\n") { i += 1; continue; }
2407
+ if (ch === "\n" || ch === "\r") { row.push(field); rows.push(row); row = []; field = ""; i += 1; continue; }
2408
+ field += ch; i += 1;
2409
+ }
2410
+ if (field !== "" || row.length) { row.push(field); rows.push(row); }
2411
+ return rows.filter((r) => r.some((v) => String(v).trim() !== ""));
2412
+ }
2413
+
2414
+ // CSV text -> { rows: [{ line, body }], invalid: [{ line, email, message }], ignored: [column] }
2415
+ function mailboxesFromCsv(text) {
2416
+ const table = parseCsv(text);
2417
+ if (!table.length) throw new Error("the file is empty");
2418
+ const header = table[0].map((h) => String(h).trim().toLowerCase().replace(/[\s-]+/g, "_"));
2419
+ const fields = header.map((h) => SMTP_CSV_COLUMNS[h] || null);
2420
+ if (!fields.includes("email")) throw new Error("the file needs an email column (see: argorant inboxes connect-smtp --help)");
2421
+ const ignored = header.filter((h, i) => h && !fields[i]);
2422
+ const rows = [], invalid = [];
2423
+ table.slice(1).forEach((cells, index) => {
2424
+ const line = index + 2;
2425
+ const f = {};
2426
+ fields.forEach((name, i) => { if (name && cells[i] !== undefined && String(cells[i]).trim() !== "") f[name] = name.endsWith("password") ? String(cells[i]) : String(cells[i]).trim(); });
2427
+ try {
2428
+ rows.push({ line, body: mailboxBody(f) });
2429
+ } catch (e) {
2430
+ invalid.push({ line, email: f.email || "", status: "invalid", message: e.message });
2431
+ }
2432
+ });
2433
+ return { rows, invalid, ignored };
2434
+ }
2435
+
2436
+ function promptSecret(question) {
2437
+ return new Promise((resolve) => {
2438
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
2439
+ let muted = false;
2440
+ rl._writeToOutput = (str) => { if (!muted) rl.output.write(str); };
2441
+ rl.question(question, (answer) => { rl.close(); process.stdout.write("\n"); resolve(answer); });
2442
+ muted = true;
2443
+ });
2444
+ }
2445
+ async function mailboxPassword(args, which) {
2446
+ const envFlag = which === "imap" ? args.imapPasswordEnv : args.passwordEnv;
2447
+ if (envFlag) {
2448
+ const v = process.env[envFlag];
2449
+ if (!v) die(`the environment variable ${envFlag} is empty or not set.`);
2450
+ return v;
2451
+ }
2452
+ if (which === "imap") return undefined; // the server uses the sending password
2453
+ if (process.env.ARGORANT_MAILBOX_PASSWORD) return process.env.ARGORANT_MAILBOX_PASSWORD;
2454
+ if (process.stdin.isTTY) {
2455
+ const v = await promptSecret("Mailbox password or app password (not shown): ");
2456
+ if (!v) die("no password given.");
2457
+ return v;
2458
+ }
2459
+ die("give the mailbox password with --password-env NAME or the ARGORANT_MAILBOX_PASSWORD variable.");
2460
+ }
2461
+
2462
+ function printSignInTest(t) {
2463
+ if (!t || typeof t !== "object") return;
2464
+ for (const [leg, label] of [["smtp", "sending"], ["imap", "reading replies"]]) {
2465
+ const r = t[leg];
2466
+ if (!r) continue;
2467
+ console.log(` ${r.works ? green("✓") : red("✗")} ${label.padEnd(16)}${r.message || r.result || ""}`);
2468
+ }
2469
+ }
2470
+ function printBulkResults(results) {
2471
+ const w = Math.min(40, Math.max(5, ...results.map((r) => String(r.email || "").length)));
2472
+ for (const r of results) {
2473
+ const mark = r.status === "connected" ? green("✓ connected") : r.status === "skipped" ? yellow("- skipped ") : red("✗ " + (r.status === "invalid" ? "invalid " : "failed "));
2474
+ console.log(`${String(r.line).padStart(4)} ${String(r.email || "").padEnd(w)} ${mark} ${r.status === "connected" ? "" : (r.message || "")}`);
2475
+ }
2476
+ }
2477
+
2478
+ async function cmdSmtpMailbox(sub, argv, key) {
2479
+ if (argv.includes("--password") || argv.some((a) => a.startsWith("--password="))) {
2480
+ die("for safety the password is never taken on the command line. Use --password-env NAME, the ARGORANT_MAILBOX_PASSWORD variable, or type it when asked.");
2481
+ }
2482
+ if (argv.includes("--help") || argv.includes("-h")) return console.log(SMTP_USAGE + `
2483
+
2484
+ CSV columns: email, sender_name, preset, smtp_host, smtp_port, smtp_security, smtp_username, smtp_password,
2485
+ imap_host, imap_port, imap_security, imap_username, imap_password, daily_limit
2486
+ Each row needs email, smtp_password (or password), and a preset or smtp_host + smtp_port + imap_host.
2487
+ Empty columns take the defaults: username = email, reading replies uses the sending sign-in, port 993, 25 a day.
2488
+ Send on port 587 (starttls). If you give 465, Argorant tries 587 and saves 587 when it works.
2489
+ Rows go up 25 at a time; each row says whether it connected or what to fix. Exit 1 when any row did not connect.`);
2490
+ const args = readFlags(argv, SMTP_FLAGS);
2491
+ if (args.csv) {
2492
+ if (sub !== "connect-smtp") die("--csv works with connect-smtp.");
2493
+ let text;
2494
+ try { text = fs.readFileSync(args.csv, "utf8"); } catch (e) { die(`cannot read ${args.csv}: ${e.message}`); }
2495
+ let parsed;
2496
+ try { parsed = mailboxesFromCsv(text); } catch (e) { die(`${args.csv}: ${e.message}`); }
2497
+ if (parsed.ignored.length && !args.json) warn(`ignoring unknown column${parsed.ignored.length === 1 ? "" : "s"}: ${parsed.ignored.join(", ")}`);
2498
+ const results = [...parsed.invalid];
2499
+ const totals = { connected: 0, failed: 0, skipped: 0, invalid: parsed.invalid.length };
2500
+ for (let i = 0; i < parsed.rows.length; i += SMTP_BULK_CHUNK) {
2501
+ const chunk = parsed.rows.slice(i, i + SMTP_BULK_CHUNK);
2502
+ if (!args.json) process.stderr.write(dim(`checking ${i + 1} to ${i + chunk.length} of ${parsed.rows.length}...\n`));
2503
+ const res = await request("POST", args.base, "/api/v1/inboxes/smtp/bulk", { key, body: { mailboxes: chunk.map((r) => r.body) } });
2504
+ if (res.status >= 400) {
2505
+ if (results.length && !args.json) printBulkResults(results.sort((a, b) => a.line - b.line));
2506
+ need(res, "inboxes connect-smtp --csv");
2507
+ }
2508
+ const r = res.json || {};
2509
+ for (const item of r.results || []) {
2510
+ const source = chunk[Number(item.row) - 1];
2511
+ results.push({ line: source ? source.line : null, email: item.email || (source && source.body.email), status: item.status, result: item.result, message: item.message });
2512
+ if (item.status in totals) totals[item.status] += 1;
2513
+ }
2514
+ }
2515
+ results.sort((a, b) => (a.line || 0) - (b.line || 0));
2516
+ if (totals.failed || totals.invalid || totals.skipped) process.exitCode = 1;
2517
+ if (args.json) return console.log(JSON.stringify({ ...totals, results }, null, 2));
2518
+ printBulkResults(results);
2519
+ const parts = [`${totals.connected} connected`];
2520
+ if (totals.failed) parts.push(`${totals.failed} could not sign in`);
2521
+ if (totals.skipped) parts.push(`${totals.skipped} skipped`);
2522
+ if (totals.invalid) parts.push(`${totals.invalid} with missing or wrong columns`);
2523
+ return console.log(`\n${parts.join(", ")}.`);
2524
+ }
2525
+ if (!args.email) die(SMTP_USAGE);
2526
+ const password = await mailboxPassword(args, "smtp");
2527
+ const imapPassword = await mailboxPassword(args, "imap");
2528
+ let body;
2529
+ try {
2530
+ body = mailboxBody({
2531
+ email: args.email, sender_name: args.name, preset: args.preset, smtp_host: args.smtpHost, smtp_port: args.smtpPort,
2532
+ smtp_security: args.smtpSecurity, smtp_username: args.smtpUser, smtp_password: password, imap_host: args.imapHost,
2533
+ imap_port: args.imapPort, imap_security: args.imapSecurity, imap_username: args.imapUser, imap_password: imapPassword,
2534
+ daily_limit: args.dailyLimit,
2535
+ });
2536
+ } catch (e) {
2537
+ die(`${e.message}.\n${SMTP_USAGE}`);
2538
+ }
2539
+ if (sub === "test-smtp") {
2540
+ delete body.display_name; delete body.daily_limit;
2541
+ const r = need(await request("POST", args.base, "/api/v1/inboxes/smtp/test", { key, body }), "inboxes test-smtp");
2542
+ if (!r.works) process.exitCode = 1;
2543
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
2544
+ console.log(`${r.works ? green("✓") : red("✗")} ${body.email} ${r.message || ""}`);
2545
+ printSignInTest(r);
2546
+ if (r.works) console.log(dim(`Connect it: argorant inboxes connect-smtp ${process.argv.slice(4).join(" ")}`.trim()));
2547
+ return;
2548
+ }
2549
+ const res = await request("POST", args.base, "/api/v1/inboxes/smtp", { key, body });
2550
+ if (res.status === 422 && errorCode(res.json) === "sign_in_failed") {
2551
+ const details = (res.json && res.json.error && res.json.error.details) || {};
2552
+ process.exitCode = 1;
2553
+ if (args.json) return console.log(JSON.stringify({ connected: false, message: errorMessage(res.json), test: details.test || null }, null, 2));
2554
+ console.log(`${red("✗")} ${body.email} was not connected. ${errorMessage(res.json) || ""}`);
2555
+ printSignInTest(details.test);
2556
+ return;
2557
+ }
2558
+ const r = need(res, "inboxes connect-smtp");
2559
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
2560
+ const i = r.inbox || {};
2561
+ console.log(green("✓") + ` Connected ${bold(i.email || body.email)}${i.daily_limit ? dim(` ${i.daily_limit} a day`) : ""}`);
2562
+ console.log(dim("It is in `argorant inboxes list` now and can be picked as a sender for any campaign."));
2211
2563
  }
2212
2564
 
2213
2565
  // =============================================================================
@@ -3082,8 +3434,9 @@ async function cmdCredits(argv) {
3082
3434
  return console.log(`
3083
3435
  ${bold("argorant credits")} packs ${dim("the credit packs and their prices")}
3084
3436
  ${bold("argorant credits")} quote <credits> ${dim("a price quote for one pack; nothing is bought")}
3085
- ${bold("argorant credits")} buy <credits> [--pay-with link] [--yes]
3086
- ${dim("shows the quote, asks, then charges the saved card (when your workspace allows it) or gives you a payment link")}
3437
+ ${bold("argorant credits")} buy <credits> [--pay-with link] [--yes] [--confirmation "<the user's yes>"]
3438
+ ${dim("shows the quote, asks, then charges the saved card (when the account owner allowed it, up to a monthly limit) or gives you a payment link")}
3439
+ ${dim("every purchase records its confirmation: your answer at the prompt, or the words passed with --confirmation")}
3087
3440
  ${bold("argorant credits")} purchases [<id>] ${dim("purchases made with this account")}
3088
3441
  ${dim("1 credit = 1 valid contact; 0.5 credit = 1 email check. Bought credits stay valid for 365 days.")}
3089
3442
  `);
@@ -3100,7 +3453,7 @@ ${dim("1 credit = 1 valid contact; 0.5 credit = 1 email check. Bought credits st
3100
3453
  return;
3101
3454
  }
3102
3455
  if (sub === "quote" || sub === "buy") {
3103
- const args = readFlags(rest, { "--currency": "currency", "--pay-with": "payWith" });
3456
+ const args = readFlags(rest, { "--currency": "currency", "--pay-with": "payWith", "--confirmation": "confirmation" });
3104
3457
  const credits = parseLimit(String(args._[0] || "").replace(/[,_]/g, "") || "x", "credits");
3105
3458
  const body = { credits };
3106
3459
  if (args.currency) body.currency = String(args.currency).toLowerCase();
@@ -3116,9 +3469,14 @@ ${dim("1 credit = 1 valid contact; 0.5 credit = 1 email check. Bought credits st
3116
3469
  if (!args.json) for (const l of quoteLines(q)) console.log(l);
3117
3470
  const card = q.payment && q.payment.saved_card;
3118
3471
  const how = payWith !== "link" && card && card.usable ? `The saved ${card.card || "card"} is charged now.` : "You get a payment link; nothing is charged before you pay it.";
3119
- if (!(await confirmOrDie(args, `Buy ${credits.toLocaleString()} credits for ${fmtPrice(q.price_cents !== undefined || q.amount_cents !== undefined ? q : q.pack || q, q.currency)}? ${how}`, "Buying credits needs a confirmation."))) return;
3472
+ const question = `Buy ${credits.toLocaleString()} credits for ${fmtPrice(q.price_cents !== undefined || q.amount_cents !== undefined ? q : q.pack || q, q.currency)}? ${how}`;
3473
+ if (!(await confirmOrDie(args, question, "Buying credits needs a confirmation."))) return;
3120
3474
  const pbody = { quote_id: q.quote_id, confirmed: true };
3121
3475
  if (payWith !== "auto") pbody.pay_with = payWith;
3476
+ // The purchase records how it was confirmed (2026-10-07): the user's own words when an agent passes them,
3477
+ // the answer at the prompt, or nothing extra with --yes (the record then says confirmed=true).
3478
+ const said = args.confirmation ? String(args.confirmation) : !args.yes ? `Answered yes at the terminal to: ${question}` : "";
3479
+ if (said.trim()) pbody.user_confirmation = said.replace(/\s+/g, " ").trim().slice(0, 1000);
3122
3480
  const r = need(await request("POST", args.base, `${V1}/credits/purchases`, { key, body: pbody }), "credit purchase");
3123
3481
  if (args.json) return console.log(JSON.stringify({ quote: q, purchase: r }, null, 2));
3124
3482
  const p = r.purchase && typeof r.purchase === "object" ? { ...r.purchase, ...r } : r;
@@ -3369,7 +3727,7 @@ ${bold("COMMANDS")}
3369
3727
  ${cyan("find")} status | download | resume | cancel <job_id> Manage a find job ${dim("(free)")}
3370
3728
  ${cyan("find pricing")} Finder prices, balance, limits ${dim("(free)")}
3371
3729
  ${cyan("campaigns")} ... Email outreach: create, write, enroll, launch ${dim("(argorant campaigns help)")}
3372
- ${cyan("inboxes")} list | connect-google Connected mailboxes; connect a Google Workspace
3730
+ ${cyan("inboxes")} list | connect-google | connect-smtp | test-smtp Connected mailboxes; connect Google Workspace or any provider
3373
3731
  ${cyan("inboxes")} quote | order | status | renewal New mailboxes on new domains ${dim("(saved card or payment link)")}
3374
3732
  ${cyan("inbox")} list | read | reply | forward | classify Replies across campaigns; answer in-thread or forward
3375
3733
  ${cyan("blocklist")} list | add | remove Addresses and domains never contacted
@@ -3524,5 +3882,6 @@ if (require.main === module) {
3524
3882
  parseArgs, readFlags, flagValue, parseLimit, takeIdempotencyFlag, nextIdempotencyKey, isV1Write, IDEM,
3525
3883
  errorMessage, errorCode, requestIdOf, detailMsg, need, EXIT, request, pageQuery, pickItems, fetchPages, retryWaitSeconds, RATE_RETRY,
3526
3884
  companyFilters, numericId, fmtPrice, packLine, quoteLines, LOOKUP_KINDS, OWN_FLAG_COMMANDS, VERSION,
3885
+ parseCsv, mailboxesFromCsv, mailboxBody, normalizeSecurity, SMTP_BULK_CHUNK,
3527
3886
  };
3528
3887
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argorant",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Search, count, reveal, export, and verify B2B contacts from the Argorant database, and find work emails by name and company domain, from your terminal, scripts, or coding agent.",
5
5
  "bin": {
6
6
  "argorant": "bin/argorant.js"
@@ -34,6 +34,7 @@
34
34
  "url": "https://argorant.com/docs/cli"
35
35
  },
36
36
  "scripts": {
37
- "prepublishOnly": "node scripts/prepublish-guard.js"
37
+ "prepublishOnly": "node scripts/prepublish-guard.js",
38
+ "test": "node --test test/*.test.js"
38
39
  }
39
40
  }