@agenticmail/core 0.3.4 → 0.5.0

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/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from 'node:events';
2
- import Database from 'better-sqlite3';
2
+ import Database, { Database as Database$1 } from 'better-sqlite3';
3
3
  import { ImapFlow } from 'imapflow';
4
4
 
5
5
  interface SendMailOptions {
@@ -181,6 +181,13 @@ interface AgenticMailConfig {
181
181
  mode?: 'relay' | 'domain' | 'none';
182
182
  autoResume?: boolean;
183
183
  };
184
+ sms?: {
185
+ enabled?: boolean;
186
+ phoneNumber?: string;
187
+ forwardingEmail?: string;
188
+ provider?: 'google_voice';
189
+ configuredAt?: string;
190
+ };
184
191
  masterKey: string;
185
192
  dataDir: string;
186
193
  }
@@ -1078,6 +1085,8 @@ declare class GatewayManager {
1078
1085
  private tunnel;
1079
1086
  private dnsConfigurator;
1080
1087
  private domainPurchaser;
1088
+ private smsManager;
1089
+ private smsPollers;
1081
1090
  constructor(options: GatewayManagerOptions);
1082
1091
  /**
1083
1092
  * Check if a message has already been delivered to an agent (deduplication).
@@ -1190,6 +1199,11 @@ declare class GatewayManager {
1190
1199
  success: boolean;
1191
1200
  error?: string;
1192
1201
  }>;
1202
+ /**
1203
+ * Start SMS pollers for all agents that have separate GV Gmail credentials.
1204
+ * Agents with sameAsRelay=true are handled in deliverInboundLocally.
1205
+ */
1206
+ private startSmsPollers;
1193
1207
  shutdown(): Promise<void>;
1194
1208
  /**
1195
1209
  * Resume gateway from saved config (e.g., after server restart).
@@ -1237,6 +1251,144 @@ declare class RelayBridge {
1237
1251
  }
1238
1252
  declare function startRelayBridge(options: RelayBridgeOptions): RelayBridge;
1239
1253
 
1254
+ /**
1255
+ * SMS Manager - Google Voice SMS integration
1256
+ *
1257
+ * How it works:
1258
+ * 1. User sets up Google Voice with SMS-to-email forwarding
1259
+ * 2. Incoming SMS arrives at Google Voice -> forwarded to email -> lands in agent inbox
1260
+ * 3. Agent parses forwarded SMS from email body
1261
+ * 4. Outgoing SMS sent via Google Voice web interface (browser automation)
1262
+ *
1263
+ * SMS config is stored in agent metadata under the "sms" key.
1264
+ */
1265
+
1266
+ interface SmsConfig {
1267
+ /** Whether SMS is enabled for this agent */
1268
+ enabled: boolean;
1269
+ /** Google Voice phone number (e.g. +12125551234) */
1270
+ phoneNumber: string;
1271
+ /** The email address Google Voice forwards SMS to (the Gmail used for GV signup) */
1272
+ forwardingEmail: string;
1273
+ /** App password for forwarding email (only needed if different from relay email) */
1274
+ forwardingPassword?: string;
1275
+ /** Whether the GV Gmail is the same as the relay email */
1276
+ sameAsRelay?: boolean;
1277
+ /** Provider (currently only google_voice) */
1278
+ provider: 'google_voice';
1279
+ /** When SMS was configured */
1280
+ configuredAt: string;
1281
+ }
1282
+ interface ParsedSms {
1283
+ from: string;
1284
+ body: string;
1285
+ timestamp: string;
1286
+ raw?: string;
1287
+ }
1288
+ interface SmsMessage {
1289
+ id: string;
1290
+ agentId: string;
1291
+ direction: 'inbound' | 'outbound';
1292
+ phoneNumber: string;
1293
+ body: string;
1294
+ status: 'pending' | 'sent' | 'delivered' | 'failed' | 'received';
1295
+ createdAt: string;
1296
+ metadata?: Record<string, unknown>;
1297
+ }
1298
+ /** Normalize a phone number to E.164-ish format (+1XXXXXXXXXX) */
1299
+ declare function normalizePhoneNumber(raw: string): string | null;
1300
+ /** Validate a phone number (basic) */
1301
+ declare function isValidPhoneNumber(phone: string): boolean;
1302
+ /**
1303
+ * Parse an SMS forwarded from Google Voice via email.
1304
+ * Google Voice forwards SMS with a specific format.
1305
+ *
1306
+ * Known sender addresses:
1307
+ * - voice-noreply@google.com
1308
+ * - *@txt.voice.google.com
1309
+ * - Google Voice <voice-noreply@google.com>
1310
+ */
1311
+ declare function parseGoogleVoiceSms(emailBody: string, emailFrom: string): ParsedSms | null;
1312
+ /**
1313
+ * Extract verification codes from SMS body.
1314
+ * Supports common formats: 6-digit, 4-digit, alphanumeric codes.
1315
+ */
1316
+ declare function extractVerificationCode(smsBody: string): string | null;
1317
+ declare class SmsManager {
1318
+ private db;
1319
+ private initialized;
1320
+ constructor(db: Database$1);
1321
+ private ensureTable;
1322
+ /** Get SMS config from agent metadata */
1323
+ getSmsConfig(agentId: string): SmsConfig | null;
1324
+ /** Save SMS config to agent metadata */
1325
+ saveSmsConfig(agentId: string, config: SmsConfig): void;
1326
+ /** Remove SMS config from agent metadata */
1327
+ removeSmsConfig(agentId: string): void;
1328
+ /** Record an inbound SMS (parsed from email) */
1329
+ recordInbound(agentId: string, parsed: ParsedSms): SmsMessage;
1330
+ /** Record an outbound SMS attempt */
1331
+ recordOutbound(agentId: string, phoneNumber: string, body: string, status?: 'pending' | 'sent' | 'failed'): SmsMessage;
1332
+ /** Update SMS status */
1333
+ updateStatus(id: string, status: SmsMessage['status']): void;
1334
+ /** List SMS messages for an agent */
1335
+ listMessages(agentId: string, opts?: {
1336
+ direction?: 'inbound' | 'outbound';
1337
+ limit?: number;
1338
+ offset?: number;
1339
+ }): SmsMessage[];
1340
+ /** Check for recent verification codes in inbound SMS */
1341
+ checkForVerificationCode(agentId: string, minutesBack?: number): {
1342
+ code: string;
1343
+ from: string;
1344
+ body: string;
1345
+ receivedAt: string;
1346
+ } | null;
1347
+ }
1348
+ /**
1349
+ * SmsPoller — Polls for Google Voice SMS forwarded emails.
1350
+ *
1351
+ * Two modes:
1352
+ * 1. **Same email** (sameAsRelay=true): Hooks into the relay's onInboundMail callback.
1353
+ * The relay poll already fetches emails; SmsPoller filters for GV forwarded SMS.
1354
+ * 2. **Separate email** (sameAsRelay=false): Runs its own IMAP poll against the GV Gmail
1355
+ * using the separate credentials (forwardingEmail + forwardingPassword).
1356
+ *
1357
+ * Parsed SMS messages are stored in the sms_messages table.
1358
+ */
1359
+ declare class SmsPoller {
1360
+ private smsManager;
1361
+ private agentId;
1362
+ private config;
1363
+ private pollTimer;
1364
+ private polling;
1365
+ private lastSeenUid;
1366
+ private firstPollDone;
1367
+ private consecutiveFailures;
1368
+ private readonly POLL_INTERVAL_MS;
1369
+ private readonly MAX_BACKOFF_MS;
1370
+ private readonly CONNECT_TIMEOUT_MS;
1371
+ /** Callback for new inbound SMS */
1372
+ onSmsReceived: ((agentId: string, sms: ParsedSms) => void | Promise<void>) | null;
1373
+ constructor(smsManager: SmsManager, agentId: string, config: SmsConfig);
1374
+ /** Whether this poller needs its own IMAP connection (separate Gmail) */
1375
+ get needsSeparatePoll(): boolean;
1376
+ /**
1377
+ * Process an email from the relay poll (same-email mode).
1378
+ * Called by the relay gateway's onInboundMail when it detects a GV email.
1379
+ * Returns true if the email was an SMS and was processed.
1380
+ */
1381
+ processRelayEmail(from: string, subject: string, body: string): boolean;
1382
+ /**
1383
+ * Start polling the separate GV Gmail for SMS (separate-email mode).
1384
+ * Only call this if needsSeparatePoll is true.
1385
+ */
1386
+ startPolling(): Promise<void>;
1387
+ stopPolling(): void;
1388
+ private scheduleNext;
1389
+ private pollOnce;
1390
+ }
1391
+
1240
1392
  declare function debug(tag: string, message: string): void;
1241
1393
  declare function debugWarn(tag: string, message: string): void;
1242
1394
 
@@ -1272,14 +1424,15 @@ declare class DependencyInstaller {
1272
1424
  */
1273
1425
  installDocker(): Promise<void>;
1274
1426
  /**
1275
- * Attempt to start the Docker daemon.
1276
- * On macOS: opens Docker Desktop app.
1277
- * On Linux: tries systemctl.
1427
+ * Attempt to start the Docker daemon using multiple strategies.
1428
+ * On macOS: tries Docker Desktop app, then docker CLI commands.
1429
+ * On Linux: tries systemctl, then dockerd direct, then snap.
1278
1430
  */
1279
1431
  private startDockerDaemon;
1280
1432
  /**
1281
- * Wait for Docker daemon to be ready (up to 3 minutes).
1282
- * Docker Desktop can take 1-2+ minutes on first launch.
1433
+ * Wait for Docker daemon to be ready, with automatic retry strategies.
1434
+ * Tries multiple approaches to start Docker if the first one fails.
1435
+ * Reports progress as a percentage (0-100).
1283
1436
  */
1284
1437
  private waitForDocker;
1285
1438
  /**
@@ -1371,4 +1524,4 @@ declare class SetupManager {
1371
1524
  isInitialized(): boolean;
1372
1525
  }
1373
1526
 
1374
- export { AGENT_ROLES, AccountManager, type AddressInfo, type Agent, AgentDeletionService, type AgentRole, AgenticMailClient, type AgenticMailClientOptions, type AgenticMailConfig, type ArchiveAndDeleteOptions, type ArchivedEmail, type Attachment, type AttachmentAdvisory, CloudflareClient, type CreateAgentOptions, DEFAULT_AGENT_NAME, DEFAULT_AGENT_ROLE, DNSConfigurator, type DeletionReport, type DeletionSummary, DependencyChecker, DependencyInstaller, type DependencyStatus, type DnsRecord, type DnsSetupResult, type DomainInfo, DomainManager, type DomainModeConfig, type DomainPurchaseResult, DomainPurchaser, type DomainSearchResult, type DomainSetupResult, type EmailEnvelope, EmailSearchIndex, type FolderInfo, type GatewayConfig, GatewayManager, type GatewayManagerOptions, type GatewayMode, type GatewayStatus, type InboundEmail, type InboxEvent, type InboxExpungeEvent, type InboxFlagsEvent, type InboxNewEvent, InboxWatcher, type InboxWatcherOptions, type InstallProgress, type LinkAdvisory, type LocalSmtpConfig, MailReceiver, type MailReceiverOptions, MailSender, type MailSenderOptions, type MailboxInfo, type OutboundCategory, type OutboundScanInput, type OutboundScanResult, type OutboundWarning, type ParsedAttachment, type ParsedEmail, type PurchasedDomain, RELAY_PRESETS, RelayBridge, type RelayBridgeOptions, type RelayConfig, RelayGateway, type RelayProvider, type RelaySearchResult, SPAM_THRESHOLD, type SanitizeDetection, type SanitizeResult, type SearchCriteria, type SearchableEmail, type SecurityAdvisory, type SendMailOptions, type SendResult, type SendResultWithRaw, type SetupConfig, SetupManager, type SetupResult, type Severity, type SpamCategory, type SpamResult, type SpamRuleMatch, StalwartAdmin, type StalwartAdminOptions, type StalwartPrincipal, type TunnelConfig, TunnelManager, WARNING_THRESHOLD, type WatcherOptions, buildInboundSecurityAdvisory, closeDatabase, createTestDatabase, debug, debugWarn, ensureDataDir, getDatabase, isInternalEmail, parseEmail, resolveConfig, sanitizeEmail, saveConfig, scanOutboundEmail, scoreEmail, startRelayBridge };
1527
+ export { AGENT_ROLES, AccountManager, type AddressInfo, type Agent, AgentDeletionService, type AgentRole, AgenticMailClient, type AgenticMailClientOptions, type AgenticMailConfig, type ArchiveAndDeleteOptions, type ArchivedEmail, type Attachment, type AttachmentAdvisory, CloudflareClient, type CreateAgentOptions, DEFAULT_AGENT_NAME, DEFAULT_AGENT_ROLE, DNSConfigurator, type DeletionReport, type DeletionSummary, DependencyChecker, DependencyInstaller, type DependencyStatus, type DnsRecord, type DnsSetupResult, type DomainInfo, DomainManager, type DomainModeConfig, type DomainPurchaseResult, DomainPurchaser, type DomainSearchResult, type DomainSetupResult, type EmailEnvelope, EmailSearchIndex, type FolderInfo, type GatewayConfig, GatewayManager, type GatewayManagerOptions, type GatewayMode, type GatewayStatus, type InboundEmail, type InboxEvent, type InboxExpungeEvent, type InboxFlagsEvent, type InboxNewEvent, InboxWatcher, type InboxWatcherOptions, type InstallProgress, type LinkAdvisory, type LocalSmtpConfig, MailReceiver, type MailReceiverOptions, MailSender, type MailSenderOptions, type MailboxInfo, type OutboundCategory, type OutboundScanInput, type OutboundScanResult, type OutboundWarning, type ParsedAttachment, type ParsedEmail, type ParsedSms, type PurchasedDomain, RELAY_PRESETS, RelayBridge, type RelayBridgeOptions, type RelayConfig, RelayGateway, type RelayProvider, type RelaySearchResult, SPAM_THRESHOLD, type SanitizeDetection, type SanitizeResult, type SearchCriteria, type SearchableEmail, type SecurityAdvisory, type SendMailOptions, type SendResult, type SendResultWithRaw, type SetupConfig, SetupManager, type SetupResult, type Severity, type SmsConfig, SmsManager, type SmsMessage, SmsPoller, type SpamCategory, type SpamResult, type SpamRuleMatch, StalwartAdmin, type StalwartAdminOptions, type StalwartPrincipal, type TunnelConfig, TunnelManager, WARNING_THRESHOLD, type WatcherOptions, buildInboundSecurityAdvisory, closeDatabase, createTestDatabase, debug, debugWarn, ensureDataDir, extractVerificationCode, getDatabase, isInternalEmail, isValidPhoneNumber, normalizePhoneNumber, parseEmail, parseGoogleVoiceSms, resolveConfig, sanitizeEmail, saveConfig, scanOutboundEmail, scoreEmail, startRelayBridge };
package/dist/index.js CHANGED
@@ -4433,6 +4433,406 @@ var TunnelManager = class {
4433
4433
 
4434
4434
  // src/gateway/manager.ts
4435
4435
  import MailComposer3 from "nodemailer/lib/mail-composer/index.js";
4436
+
4437
+ // src/sms/manager.ts
4438
+ function normalizePhoneNumber(raw) {
4439
+ const cleaned = raw.replace(/[^+\d]/g, "");
4440
+ if (!cleaned) return null;
4441
+ const digits = cleaned.replace(/\D/g, "");
4442
+ if (digits.length === 10) return `+1${digits}`;
4443
+ if (digits.length === 11 && digits.startsWith("1")) return `+${digits}`;
4444
+ if (cleaned.startsWith("+") && digits.length >= 10 && digits.length <= 15) return `+${digits}`;
4445
+ if (digits.length < 10) return null;
4446
+ if (digits.length <= 11) return `+1${digits.slice(-10)}`;
4447
+ return null;
4448
+ }
4449
+ function isValidPhoneNumber(phone) {
4450
+ const normalized = normalizePhoneNumber(phone);
4451
+ if (!normalized) return false;
4452
+ const digits = normalized.replace(/\D/g, "");
4453
+ return digits.length >= 10 && digits.length <= 15;
4454
+ }
4455
+ function parseGoogleVoiceSms(emailBody, emailFrom) {
4456
+ if (!emailBody || typeof emailBody !== "string") return null;
4457
+ if (!emailFrom || typeof emailFrom !== "string") return null;
4458
+ const fromLower = emailFrom.toLowerCase();
4459
+ const isGoogleVoice = fromLower.includes("voice-noreply@google.com") || fromLower.includes("@txt.voice.google.com") || fromLower.includes("voice.google.com") || fromLower.includes("google.com/voice") || fromLower.includes("google") && fromLower.includes("voice");
4460
+ if (!isGoogleVoice) return null;
4461
+ let text = emailBody.replace(/<br\s*\/?>/gi, "\n").replace(/<\/p>/gi, "\n").replace(/<\/div>/gi, "\n").replace(/<[^>]+>/g, "").replace(/&nbsp;/gi, " ").replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, '"').replace(/&#39;/gi, "'").trim();
4462
+ let from = "";
4463
+ const newMsgMatch = text.match(/new\s+(?:text\s+)?message\s+from\s+(\+?[\d\s().-]+)/i);
4464
+ if (newMsgMatch) {
4465
+ from = newMsgMatch[1];
4466
+ }
4467
+ if (!from) {
4468
+ const colonMatch = text.match(/^(\+?[\d\s().-]{10,})\s*:\s*/m);
4469
+ if (colonMatch) {
4470
+ from = colonMatch[1];
4471
+ }
4472
+ }
4473
+ if (!from) {
4474
+ const firstLines = text.split("\n").slice(0, 5).join(" ");
4475
+ const phoneMatch = firstLines.match(/(\+?1?[-.\s]?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4})/);
4476
+ if (phoneMatch) from = phoneMatch[1];
4477
+ }
4478
+ if (from) {
4479
+ const normalized = normalizePhoneNumber(from);
4480
+ from = normalized || from.replace(/[^+\d]/g, "");
4481
+ }
4482
+ const lines = text.split("\n").map((l) => l.trim()).filter((l) => l);
4483
+ const boilerplatePatterns = [
4484
+ /^new\s+(text\s+)?message\s+from/i,
4485
+ /^\+?1?[-.\s]?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\s*$/,
4486
+ /^to\s+respond\s+to\s+this/i,
4487
+ /^your\s+account$/i,
4488
+ /^google\s+voice$/i,
4489
+ /^sent\s+via\s+google\s+voice/i,
4490
+ /^you\s+received\s+this/i,
4491
+ /^to\s+stop\s+receiving/i,
4492
+ /^reply\s+to\s+this\s+email/i,
4493
+ /^https?:\/\/voice\.google\.com/i,
4494
+ /^manage\s+your\s+settings/i,
4495
+ /^google\s+llc/i,
4496
+ /^1600\s+amphitheatre/i
4497
+ ];
4498
+ const messageLines = lines.filter((l) => {
4499
+ for (const p of boilerplatePatterns) {
4500
+ if (p.test(l)) return false;
4501
+ }
4502
+ return true;
4503
+ });
4504
+ let body = messageLines.join("\n").trim();
4505
+ const prefixMatch = body.match(/^(\+?[\d\s().-]{10,})\s*:\s*([\s\S]+)/);
4506
+ if (prefixMatch) {
4507
+ body = prefixMatch[2].trim();
4508
+ }
4509
+ if (!body) return null;
4510
+ return {
4511
+ from: from || "unknown",
4512
+ body,
4513
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4514
+ raw: emailBody
4515
+ };
4516
+ }
4517
+ function extractVerificationCode(smsBody) {
4518
+ if (!smsBody || typeof smsBody !== "string") return null;
4519
+ const patterns = [
4520
+ // "Your code is 123456" / "verification code: 123456" / "code is: 123456"
4521
+ /(?:code|pin|otp|token|password)\s*(?:is|:)\s*(\d{4,8})/i,
4522
+ // "123456 is your code"
4523
+ /(\d{4,8})\s+is\s+your\s+(?:code|pin|otp|verification)/i,
4524
+ // G-123456 (Google style)
4525
+ /[Gg]-(\d{4,8})/,
4526
+ // "Enter 123456 to verify" / "Use 123456 to"
4527
+ /(?:enter|use)\s+(\d{4,8})\s+(?:to|for|as)/i,
4528
+ // Standalone 6-digit on its own line (common pattern)
4529
+ /^\s*(\d{6})\s*$/m,
4530
+ // "Code: ABC-123" style alphanumeric
4531
+ /(?:code|pin)\s*(?:is|:)\s*([A-Z0-9]{3,6}[-][A-Z0-9]{3,6})/i,
4532
+ // Last resort: any 6-digit sequence not part of a longer number
4533
+ /(?<!\d)(\d{6})(?!\d)/
4534
+ ];
4535
+ for (const pattern of patterns) {
4536
+ const match = smsBody.match(pattern);
4537
+ if (match?.[1]) return match[1];
4538
+ }
4539
+ return null;
4540
+ }
4541
+ var SmsManager = class {
4542
+ constructor(db2) {
4543
+ this.db = db2;
4544
+ this.ensureTable();
4545
+ }
4546
+ initialized = false;
4547
+ ensureTable() {
4548
+ if (this.initialized) return;
4549
+ try {
4550
+ this.db.exec(`
4551
+ CREATE TABLE IF NOT EXISTS sms_messages (
4552
+ id TEXT PRIMARY KEY,
4553
+ agent_id TEXT NOT NULL,
4554
+ direction TEXT NOT NULL CHECK(direction IN ('inbound', 'outbound')),
4555
+ phone_number TEXT NOT NULL,
4556
+ body TEXT NOT NULL,
4557
+ status TEXT NOT NULL DEFAULT 'pending',
4558
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
4559
+ metadata TEXT DEFAULT '{}'
4560
+ )
4561
+ `);
4562
+ try {
4563
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_sms_agent ON sms_messages(agent_id)");
4564
+ } catch {
4565
+ }
4566
+ try {
4567
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_sms_direction ON sms_messages(direction)");
4568
+ } catch {
4569
+ }
4570
+ try {
4571
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_sms_created ON sms_messages(created_at)");
4572
+ } catch {
4573
+ }
4574
+ this.initialized = true;
4575
+ } catch (err) {
4576
+ this.initialized = true;
4577
+ }
4578
+ }
4579
+ /** Get SMS config from agent metadata */
4580
+ getSmsConfig(agentId) {
4581
+ const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
4582
+ if (!row) return null;
4583
+ try {
4584
+ const meta = JSON.parse(row.metadata || "{}");
4585
+ return meta.sms && meta.sms.enabled !== void 0 ? meta.sms : null;
4586
+ } catch {
4587
+ return null;
4588
+ }
4589
+ }
4590
+ /** Save SMS config to agent metadata */
4591
+ saveSmsConfig(agentId, config) {
4592
+ const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
4593
+ if (!row) throw new Error(`Agent ${agentId} not found`);
4594
+ let meta;
4595
+ try {
4596
+ meta = JSON.parse(row.metadata || "{}");
4597
+ } catch {
4598
+ meta = {};
4599
+ }
4600
+ meta.sms = config;
4601
+ this.db.prepare("UPDATE agents SET metadata = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(meta), agentId);
4602
+ }
4603
+ /** Remove SMS config from agent metadata */
4604
+ removeSmsConfig(agentId) {
4605
+ const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
4606
+ if (!row) return;
4607
+ let meta;
4608
+ try {
4609
+ meta = JSON.parse(row.metadata || "{}");
4610
+ } catch {
4611
+ meta = {};
4612
+ }
4613
+ delete meta.sms;
4614
+ this.db.prepare("UPDATE agents SET metadata = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(meta), agentId);
4615
+ }
4616
+ /** Record an inbound SMS (parsed from email) */
4617
+ recordInbound(agentId, parsed) {
4618
+ const id = `sms_in_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
4619
+ const createdAt = parsed.timestamp || (/* @__PURE__ */ new Date()).toISOString();
4620
+ this.db.prepare(
4621
+ "INSERT INTO sms_messages (id, agent_id, direction, phone_number, body, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
4622
+ ).run(id, agentId, "inbound", parsed.from, parsed.body, "received", createdAt);
4623
+ return { id, agentId, direction: "inbound", phoneNumber: parsed.from, body: parsed.body, status: "received", createdAt };
4624
+ }
4625
+ /** Record an outbound SMS attempt */
4626
+ recordOutbound(agentId, phoneNumber, body, status = "pending") {
4627
+ const id = `sms_out_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
4628
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4629
+ const normalized = normalizePhoneNumber(phoneNumber) || phoneNumber;
4630
+ this.db.prepare(
4631
+ "INSERT INTO sms_messages (id, agent_id, direction, phone_number, body, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
4632
+ ).run(id, agentId, "outbound", normalized, body, status, now);
4633
+ return { id, agentId, direction: "outbound", phoneNumber: normalized, body, status, createdAt: now };
4634
+ }
4635
+ /** Update SMS status */
4636
+ updateStatus(id, status) {
4637
+ this.db.prepare("UPDATE sms_messages SET status = ? WHERE id = ?").run(status, id);
4638
+ }
4639
+ /** List SMS messages for an agent */
4640
+ listMessages(agentId, opts) {
4641
+ const limit = Math.min(Math.max(opts?.limit ?? 20, 1), 100);
4642
+ const offset = Math.max(opts?.offset ?? 0, 0);
4643
+ let query = "SELECT * FROM sms_messages WHERE agent_id = ?";
4644
+ const params = [agentId];
4645
+ if (opts?.direction && (opts.direction === "inbound" || opts.direction === "outbound")) {
4646
+ query += " AND direction = ?";
4647
+ params.push(opts.direction);
4648
+ }
4649
+ query += " ORDER BY created_at DESC LIMIT ? OFFSET ?";
4650
+ params.push(limit, offset);
4651
+ return this.db.prepare(query).all(...params).map((row) => ({
4652
+ id: row.id,
4653
+ agentId: row.agent_id,
4654
+ direction: row.direction,
4655
+ phoneNumber: row.phone_number,
4656
+ body: row.body,
4657
+ status: row.status,
4658
+ createdAt: row.created_at,
4659
+ metadata: row.metadata ? JSON.parse(row.metadata) : void 0
4660
+ }));
4661
+ }
4662
+ /** Check for recent verification codes in inbound SMS */
4663
+ checkForVerificationCode(agentId, minutesBack = 10) {
4664
+ const safeMins = Math.min(Math.max(minutesBack, 1), 1440);
4665
+ const cutoff = new Date(Date.now() - safeMins * 60 * 1e3).toISOString();
4666
+ const messages = this.db.prepare(
4667
+ "SELECT * FROM sms_messages WHERE agent_id = ? AND direction = ? AND created_at > ? ORDER BY created_at DESC LIMIT 50"
4668
+ ).all(agentId, "inbound", cutoff);
4669
+ for (const msg of messages) {
4670
+ const code = extractVerificationCode(msg.body);
4671
+ if (code) {
4672
+ return { code, from: msg.phone_number, body: msg.body, receivedAt: msg.created_at };
4673
+ }
4674
+ }
4675
+ return null;
4676
+ }
4677
+ };
4678
+ var SmsPoller = class {
4679
+ constructor(smsManager, agentId, config) {
4680
+ this.smsManager = smsManager;
4681
+ this.agentId = agentId;
4682
+ this.config = config;
4683
+ }
4684
+ pollTimer = null;
4685
+ polling = false;
4686
+ lastSeenUid = 0;
4687
+ firstPollDone = false;
4688
+ consecutiveFailures = 0;
4689
+ POLL_INTERVAL_MS = 3e4;
4690
+ MAX_BACKOFF_MS = 5 * 6e4;
4691
+ CONNECT_TIMEOUT_MS = 3e4;
4692
+ /** Callback for new inbound SMS */
4693
+ onSmsReceived = null;
4694
+ /** Whether this poller needs its own IMAP connection (separate Gmail) */
4695
+ get needsSeparatePoll() {
4696
+ return !this.config.sameAsRelay && !!this.config.forwardingPassword;
4697
+ }
4698
+ /**
4699
+ * Process an email from the relay poll (same-email mode).
4700
+ * Called by the relay gateway's onInboundMail when it detects a GV email.
4701
+ * Returns true if the email was an SMS and was processed.
4702
+ */
4703
+ processRelayEmail(from, subject, body) {
4704
+ const parsed = parseGoogleVoiceSms(body, from);
4705
+ if (!parsed) return false;
4706
+ this.smsManager.recordInbound(this.agentId, parsed);
4707
+ this.onSmsReceived?.(this.agentId, parsed);
4708
+ return true;
4709
+ }
4710
+ /**
4711
+ * Start polling the separate GV Gmail for SMS (separate-email mode).
4712
+ * Only call this if needsSeparatePoll is true.
4713
+ */
4714
+ async startPolling() {
4715
+ if (!this.needsSeparatePoll) return;
4716
+ if (this.polling) return;
4717
+ this.polling = true;
4718
+ console.log(`[SmsPoller] Starting SMS poll for ${this.config.phoneNumber} via ${this.config.forwardingEmail}`);
4719
+ await this.pollOnce();
4720
+ this.scheduleNext();
4721
+ }
4722
+ stopPolling() {
4723
+ this.polling = false;
4724
+ if (this.pollTimer) {
4725
+ clearTimeout(this.pollTimer);
4726
+ this.pollTimer = null;
4727
+ }
4728
+ }
4729
+ scheduleNext() {
4730
+ if (!this.polling) return;
4731
+ const backoff = this.consecutiveFailures > 0 ? Math.min(this.POLL_INTERVAL_MS * Math.pow(2, this.consecutiveFailures - 1), this.MAX_BACKOFF_MS) : this.POLL_INTERVAL_MS;
4732
+ this.pollTimer = setTimeout(async () => {
4733
+ await this.pollOnce();
4734
+ this.scheduleNext();
4735
+ }, backoff);
4736
+ }
4737
+ async pollOnce() {
4738
+ if (!this.config.forwardingEmail || !this.config.forwardingPassword) return;
4739
+ let ImapFlow4;
4740
+ try {
4741
+ ImapFlow4 = (await import("imapflow")).ImapFlow;
4742
+ } catch {
4743
+ console.error("[SmsPoller] imapflow not available");
4744
+ return;
4745
+ }
4746
+ let simpleParser3;
4747
+ try {
4748
+ simpleParser3 = (await import("mailparser")).simpleParser;
4749
+ } catch {
4750
+ console.error("[SmsPoller] mailparser not available");
4751
+ return;
4752
+ }
4753
+ const imap = new ImapFlow4({
4754
+ host: "imap.gmail.com",
4755
+ port: 993,
4756
+ secure: true,
4757
+ auth: {
4758
+ user: this.config.forwardingEmail,
4759
+ pass: this.config.forwardingPassword
4760
+ },
4761
+ logger: false,
4762
+ tls: { rejectUnauthorized: true }
4763
+ });
4764
+ const timeout = setTimeout(() => {
4765
+ try {
4766
+ imap.close();
4767
+ } catch {
4768
+ }
4769
+ }, this.CONNECT_TIMEOUT_MS);
4770
+ try {
4771
+ await imap.connect();
4772
+ clearTimeout(timeout);
4773
+ const lock = await imap.getMailboxLock("INBOX");
4774
+ try {
4775
+ if (!this.firstPollDone) {
4776
+ const status = imap.mailbox;
4777
+ const uidNext = status && typeof status === "object" && "uidNext" in status ? status.uidNext : 1;
4778
+ this.lastSeenUid = Math.max(0, uidNext - 21);
4779
+ this.firstPollDone = true;
4780
+ }
4781
+ let searchResult;
4782
+ try {
4783
+ searchResult = await imap.search(
4784
+ { from: "voice-noreply@google.com", uid: `${this.lastSeenUid + 1}:*` },
4785
+ { uid: true }
4786
+ );
4787
+ } catch {
4788
+ try {
4789
+ searchResult = await imap.search({ all: true }, { uid: true });
4790
+ searchResult = searchResult.filter((uid) => uid > this.lastSeenUid);
4791
+ } catch {
4792
+ return;
4793
+ }
4794
+ }
4795
+ if (!searchResult?.length) return;
4796
+ const uids = searchResult.filter((uid) => uid > this.lastSeenUid);
4797
+ for (const uid of uids) {
4798
+ if (uid > this.lastSeenUid) this.lastSeenUid = uid;
4799
+ try {
4800
+ const msg = await imap.fetchOne(String(uid), { source: true }, { uid: true });
4801
+ const source = msg?.source;
4802
+ if (!source) continue;
4803
+ const parsed = await simpleParser3(source);
4804
+ const fromAddr = parsed.from?.value?.[0]?.address ?? "";
4805
+ const body = parsed.text || parsed.html || "";
4806
+ const sms = parseGoogleVoiceSms(body, fromAddr);
4807
+ if (sms) {
4808
+ this.smsManager.recordInbound(this.agentId, sms);
4809
+ this.onSmsReceived?.(this.agentId, sms);
4810
+ }
4811
+ } catch {
4812
+ }
4813
+ }
4814
+ } finally {
4815
+ lock.release();
4816
+ }
4817
+ if (this.consecutiveFailures > 0) {
4818
+ console.log(`[SmsPoller] Recovered after ${this.consecutiveFailures} failures`);
4819
+ }
4820
+ this.consecutiveFailures = 0;
4821
+ await imap.logout();
4822
+ } catch (err) {
4823
+ clearTimeout(timeout);
4824
+ this.consecutiveFailures++;
4825
+ const msg = err instanceof Error ? err.message : String(err);
4826
+ console.error(`[SmsPoller] Poll failed (${this.consecutiveFailures}): ${msg}`);
4827
+ try {
4828
+ await imap.logout();
4829
+ } catch {
4830
+ }
4831
+ }
4832
+ }
4833
+ };
4834
+
4835
+ // src/gateway/manager.ts
4436
4836
  var GatewayManager = class {
4437
4837
  constructor(options) {
4438
4838
  this.options = options;
@@ -4449,6 +4849,10 @@ var GatewayManager = class {
4449
4849
  } catch {
4450
4850
  this.config = { mode: "none" };
4451
4851
  }
4852
+ try {
4853
+ this.smsManager = new SmsManager(options.db);
4854
+ } catch {
4855
+ }
4452
4856
  }
4453
4857
  db;
4454
4858
  stalwart;
@@ -4459,6 +4863,8 @@ var GatewayManager = class {
4459
4863
  tunnel = null;
4460
4864
  dnsConfigurator = null;
4461
4865
  domainPurchaser = null;
4866
+ smsManager = null;
4867
+ smsPollers = /* @__PURE__ */ new Map();
4462
4868
  /**
4463
4869
  * Check if a message has already been delivered to an agent (deduplication).
4464
4870
  */
@@ -4487,6 +4893,26 @@ var GatewayManager = class {
4487
4893
  return;
4488
4894
  }
4489
4895
  if (mail.messageId && this.isAlreadyDelivered(mail.messageId, agentName)) return;
4896
+ if (this.smsManager) {
4897
+ try {
4898
+ const smsBody = mail.text || mail.html || "";
4899
+ const parsedSms = parseGoogleVoiceSms(smsBody, mail.from);
4900
+ if (parsedSms) {
4901
+ const agent2 = this.accountManager ? await this.accountManager.getByName(agentName) : null;
4902
+ const agentId = agent2?.id;
4903
+ if (agentId) {
4904
+ const smsConfig = this.smsManager.getSmsConfig(agentId);
4905
+ if (smsConfig?.enabled && smsConfig.sameAsRelay) {
4906
+ this.smsManager.recordInbound(agentId, parsedSms);
4907
+ console.log(`[GatewayManager] SMS received from ${parsedSms.from}: "${parsedSms.body.slice(0, 50)}..." \u2192 agent ${agentName}`);
4908
+ if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
4909
+ }
4910
+ }
4911
+ }
4912
+ } catch (err) {
4913
+ debug("GatewayManager", `SMS detection error: ${err.message}`);
4914
+ }
4915
+ }
4490
4916
  try {
4491
4917
  await this.tryProcessApprovalReply(mail);
4492
4918
  } catch (err) {
@@ -5139,10 +5565,38 @@ var GatewayManager = class {
5139
5565
  return { success: false, error: err instanceof Error ? err.message : String(err) };
5140
5566
  }
5141
5567
  }
5568
+ // --- SMS Polling ---
5569
+ /**
5570
+ * Start SMS pollers for all agents that have separate GV Gmail credentials.
5571
+ * Agents with sameAsRelay=true are handled in deliverInboundLocally.
5572
+ */
5573
+ async startSmsPollers() {
5574
+ if (!this.smsManager || !this.accountManager) return;
5575
+ const agents = this.db.prepare("SELECT id, name, metadata FROM agents").all();
5576
+ for (const agent of agents) {
5577
+ try {
5578
+ const meta = JSON.parse(agent.metadata || "{}");
5579
+ const smsConfig = meta.sms;
5580
+ if (!smsConfig?.enabled || !smsConfig.forwardingPassword || smsConfig.sameAsRelay) continue;
5581
+ const poller = new SmsPoller(this.smsManager, agent.id, smsConfig);
5582
+ poller.onSmsReceived = (agentId, sms) => {
5583
+ console.log(`[SmsPoller] SMS received for agent ${agent.name}: from ${sms.from}, body="${sms.body.slice(0, 50)}..."`);
5584
+ };
5585
+ this.smsPollers.set(agent.id, poller);
5586
+ await poller.startPolling();
5587
+ console.log(`[GatewayManager] SMS poller started for agent "${agent.name}" (${smsConfig.forwardingEmail})`);
5588
+ } catch {
5589
+ }
5590
+ }
5591
+ }
5142
5592
  // --- Lifecycle ---
5143
5593
  async shutdown() {
5144
5594
  await this.relay.shutdown();
5145
5595
  this.tunnel?.stop();
5596
+ for (const poller of this.smsPollers.values()) {
5597
+ poller.stopPolling();
5598
+ }
5599
+ this.smsPollers.clear();
5146
5600
  }
5147
5601
  /**
5148
5602
  * Resume gateway from saved config (e.g., after server restart).
@@ -5162,6 +5616,13 @@ var GatewayManager = class {
5162
5616
  console.error("[GatewayManager] Failed to resume relay:", err);
5163
5617
  }
5164
5618
  }
5619
+ if (this.smsManager && this.accountManager) {
5620
+ try {
5621
+ await this.startSmsPollers();
5622
+ } catch (err) {
5623
+ console.error("[GatewayManager] Failed to start SMS pollers:", err);
5624
+ }
5625
+ }
5165
5626
  if (this.config.mode === "domain" && this.config.domain) {
5166
5627
  try {
5167
5628
  this.cfClient = new CloudflareClient(
@@ -5432,11 +5893,129 @@ var DependencyChecker = class {
5432
5893
  };
5433
5894
 
5434
5895
  // src/setup/installer.ts
5435
- import { execFileSync as execFileSync2, execSync } from "child_process";
5896
+ import { execFileSync as execFileSync2, execSync, spawn as spawnChild } from "child_process";
5436
5897
  import { existsSync as existsSync4 } from "fs";
5437
5898
  import { writeFile, rename, chmod as chmod2, mkdir as mkdir2, unlink } from "fs/promises";
5438
5899
  import { join as join6 } from "path";
5439
5900
  import { homedir as homedir5, platform as platform2, arch as arch2 } from "os";
5901
+ function runWithRollingOutput(command, args, opts = {}) {
5902
+ const maxLines = opts.maxLines ?? 20;
5903
+ const timeout = opts.timeout ?? 3e5;
5904
+ return new Promise((resolve, reject) => {
5905
+ const child = spawnChild(command, args, {
5906
+ stdio: ["ignore", "pipe", "pipe"],
5907
+ timeout
5908
+ });
5909
+ const lines = [];
5910
+ let displayedCount = 0;
5911
+ let fullOutput = "";
5912
+ const processData = (data) => {
5913
+ const text = data.toString();
5914
+ fullOutput += text;
5915
+ const newLines = text.split("\n");
5916
+ for (const line of newLines) {
5917
+ const trimmed = line.trimEnd();
5918
+ if (!trimmed) continue;
5919
+ lines.push(trimmed);
5920
+ if (displayedCount > 0) {
5921
+ const toClear = Math.min(displayedCount, maxLines);
5922
+ process.stdout.write(`\x1B[${toClear}A`);
5923
+ for (let i = 0; i < toClear; i++) {
5924
+ process.stdout.write("\x1B[2K\n");
5925
+ }
5926
+ process.stdout.write(`\x1B[${toClear}A`);
5927
+ }
5928
+ const visible = lines.slice(-maxLines);
5929
+ for (const vLine of visible) {
5930
+ process.stdout.write(` \x1B[90m${vLine.slice(0, 100)}\x1B[0m
5931
+ `);
5932
+ }
5933
+ displayedCount = visible.length;
5934
+ }
5935
+ };
5936
+ child.stdout?.on("data", processData);
5937
+ child.stderr?.on("data", processData);
5938
+ child.on("close", (code) => {
5939
+ if (displayedCount > 0) {
5940
+ process.stdout.write(`\x1B[${displayedCount}A`);
5941
+ for (let i = 0; i < displayedCount; i++) {
5942
+ process.stdout.write("\x1B[2K\n");
5943
+ }
5944
+ process.stdout.write(`\x1B[${displayedCount}A`);
5945
+ }
5946
+ resolve({ exitCode: code ?? 1, fullOutput });
5947
+ });
5948
+ child.on("error", (err) => {
5949
+ if (displayedCount > 0) {
5950
+ process.stdout.write(`\x1B[${displayedCount}A`);
5951
+ for (let i = 0; i < displayedCount; i++) {
5952
+ process.stdout.write("\x1B[2K\n");
5953
+ }
5954
+ process.stdout.write(`\x1B[${displayedCount}A`);
5955
+ }
5956
+ reject(err);
5957
+ });
5958
+ });
5959
+ }
5960
+ function runShellWithRollingOutput(cmd, opts = {}) {
5961
+ const maxLines = opts.maxLines ?? 20;
5962
+ const timeout = opts.timeout ?? 3e5;
5963
+ return new Promise((resolve, reject) => {
5964
+ const child = spawnChild("sh", ["-c", cmd], {
5965
+ stdio: ["ignore", "pipe", "pipe"],
5966
+ timeout
5967
+ });
5968
+ const lines = [];
5969
+ let displayedCount = 0;
5970
+ let fullOutput = "";
5971
+ const processData = (data) => {
5972
+ const text = data.toString();
5973
+ fullOutput += text;
5974
+ const newLines = text.split("\n");
5975
+ for (const line of newLines) {
5976
+ const trimmed = line.trimEnd();
5977
+ if (!trimmed) continue;
5978
+ lines.push(trimmed);
5979
+ if (displayedCount > 0) {
5980
+ const toClear = Math.min(displayedCount, maxLines);
5981
+ process.stdout.write(`\x1B[${toClear}A`);
5982
+ for (let i = 0; i < toClear; i++) {
5983
+ process.stdout.write("\x1B[2K\n");
5984
+ }
5985
+ process.stdout.write(`\x1B[${toClear}A`);
5986
+ }
5987
+ const visible = lines.slice(-maxLines);
5988
+ for (const vLine of visible) {
5989
+ process.stdout.write(` \x1B[90m${vLine.slice(0, 100)}\x1B[0m
5990
+ `);
5991
+ }
5992
+ displayedCount = visible.length;
5993
+ }
5994
+ };
5995
+ child.stdout?.on("data", processData);
5996
+ child.stderr?.on("data", processData);
5997
+ child.on("close", (code) => {
5998
+ if (displayedCount > 0) {
5999
+ process.stdout.write(`\x1B[${displayedCount}A`);
6000
+ for (let i = 0; i < displayedCount; i++) {
6001
+ process.stdout.write("\x1B[2K\n");
6002
+ }
6003
+ process.stdout.write(`\x1B[${displayedCount}A`);
6004
+ }
6005
+ resolve({ exitCode: code ?? 1, fullOutput });
6006
+ });
6007
+ child.on("error", (err) => {
6008
+ if (displayedCount > 0) {
6009
+ process.stdout.write(`\x1B[${displayedCount}A`);
6010
+ for (let i = 0; i < displayedCount; i++) {
6011
+ process.stdout.write("\x1B[2K\n");
6012
+ }
6013
+ process.stdout.write(`\x1B[${displayedCount}A`);
6014
+ }
6015
+ reject(err);
6016
+ });
6017
+ });
6018
+ }
5440
6019
  var DependencyInstaller = class {
5441
6020
  onProgress;
5442
6021
  constructor(onProgress) {
@@ -5474,14 +6053,20 @@ var DependencyInstaller = class {
5474
6053
  } catch {
5475
6054
  throw new Error("Homebrew is required to install Docker on macOS. Install it from https://brew.sh then try again.");
5476
6055
  }
5477
- execFileSync2("brew", ["install", "--cask", "docker"], { timeout: 3e5, stdio: "inherit" });
6056
+ const brewResult = await runWithRollingOutput("brew", ["install", "--cask", "docker"], { timeout: 3e5 });
6057
+ if (brewResult.exitCode !== 0) {
6058
+ throw new Error("Failed to install Docker via Homebrew. Try: brew install --cask docker");
6059
+ }
5478
6060
  this.onProgress("Docker installed. Starting Docker Desktop...");
5479
6061
  this.startDockerDaemon();
5480
6062
  await this.waitForDocker();
5481
6063
  } else if (os === "linux") {
5482
6064
  this.onProgress("Installing Docker...");
5483
6065
  try {
5484
- execSync("curl -fsSL https://get.docker.com | sh", { timeout: 3e5, stdio: "inherit" });
6066
+ const result = await runShellWithRollingOutput("curl -fsSL https://get.docker.com | sh", { timeout: 3e5 });
6067
+ if (result.exitCode !== 0) {
6068
+ throw new Error("Install script failed");
6069
+ }
5485
6070
  } catch {
5486
6071
  throw new Error("Failed to install Docker. Install it manually: https://docs.docker.com/get-docker/");
5487
6072
  }
@@ -5491,48 +6076,123 @@ var DependencyInstaller = class {
5491
6076
  }
5492
6077
  }
5493
6078
  /**
5494
- * Attempt to start the Docker daemon.
5495
- * On macOS: opens Docker Desktop app.
5496
- * On Linux: tries systemctl.
6079
+ * Attempt to start the Docker daemon using multiple strategies.
6080
+ * On macOS: tries Docker Desktop app, then docker CLI commands.
6081
+ * On Linux: tries systemctl, then dockerd direct, then snap.
5497
6082
  */
5498
- startDockerDaemon() {
6083
+ startDockerDaemon(strategy) {
5499
6084
  const os = platform2();
5500
6085
  if (os === "darwin") {
5501
- try {
5502
- execFileSync2("open", ["-a", "Docker"], { timeout: 1e4, stdio: "ignore" });
5503
- } catch {
6086
+ switch (strategy) {
6087
+ case "cli":
6088
+ try {
6089
+ execSync("docker context use default 2>/dev/null; docker info", { timeout: 5e3, stdio: "ignore" });
6090
+ } catch {
6091
+ }
6092
+ break;
6093
+ case "reopen":
6094
+ try {
6095
+ execSync(`osascript -e 'quit app "Docker"'`, { timeout: 5e3, stdio: "ignore" });
6096
+ } catch {
6097
+ }
6098
+ try {
6099
+ execFileSync2("sleep", ["2"], { timeout: 5e3, stdio: "ignore" });
6100
+ } catch {
6101
+ }
6102
+ try {
6103
+ execFileSync2("open", ["-a", "Docker"], { timeout: 1e4, stdio: "ignore" });
6104
+ } catch {
6105
+ }
6106
+ break;
6107
+ case "background":
6108
+ try {
6109
+ const appBin = "/Applications/Docker.app/Contents/MacOS/Docker";
6110
+ if (existsSync4(appBin)) {
6111
+ execSync(`"${appBin}" &`, { timeout: 5e3, stdio: "ignore", shell: "sh" });
6112
+ }
6113
+ } catch {
6114
+ }
6115
+ break;
6116
+ default:
6117
+ try {
6118
+ execFileSync2("open", ["-a", "Docker"], { timeout: 1e4, stdio: "ignore" });
6119
+ } catch {
6120
+ }
5504
6121
  }
5505
6122
  } else if (os === "linux") {
5506
- try {
5507
- execFileSync2("sudo", ["systemctl", "start", "docker"], { timeout: 15e3, stdio: "ignore" });
5508
- } catch {
6123
+ switch (strategy) {
6124
+ case "snap":
6125
+ try {
6126
+ execFileSync2("sudo", ["snap", "start", "docker"], { timeout: 15e3, stdio: "ignore" });
6127
+ } catch {
6128
+ }
6129
+ break;
6130
+ case "service":
6131
+ try {
6132
+ execFileSync2("sudo", ["service", "docker", "start"], { timeout: 15e3, stdio: "ignore" });
6133
+ } catch {
6134
+ }
6135
+ break;
6136
+ default:
6137
+ try {
6138
+ execFileSync2("sudo", ["systemctl", "start", "docker"], { timeout: 15e3, stdio: "ignore" });
6139
+ } catch {
6140
+ }
5509
6141
  }
5510
6142
  }
5511
6143
  }
5512
6144
  /**
5513
- * Wait for Docker daemon to be ready (up to 3 minutes).
5514
- * Docker Desktop can take 1-2+ minutes on first launch.
6145
+ * Wait for Docker daemon to be ready, with automatic retry strategies.
6146
+ * Tries multiple approaches to start Docker if the first one fails.
6147
+ * Reports progress as a percentage (0-100).
5515
6148
  */
5516
6149
  async waitForDocker() {
5517
- this.onProgress("Waiting for Docker to start (this can take a minute)...");
5518
- const maxWait = 18e4;
6150
+ const os = platform2();
6151
+ const strategies = os === "darwin" ? ["default", "cli", "reopen", "background"] : ["default", "service", "snap"];
6152
+ const totalTime = 24e4;
6153
+ const perStrategyTime = Math.floor(totalTime / strategies.length);
5519
6154
  const start = Date.now();
5520
- let attempts = 0;
5521
- while (Date.now() - start < maxWait) {
6155
+ let strategyIdx = 0;
6156
+ this.onProgress("__progress__:0:Starting Docker...");
6157
+ while (Date.now() - start < totalTime) {
5522
6158
  try {
5523
6159
  execFileSync2("docker", ["info"], { timeout: 5e3, stdio: "ignore" });
6160
+ this.onProgress("__progress__:100:Docker is ready!");
5524
6161
  return;
5525
6162
  } catch {
5526
6163
  }
5527
- attempts++;
5528
- if (attempts % 5 === 0) {
5529
- const elapsed = Math.round((Date.now() - start) / 1e3);
5530
- this.onProgress(`Still waiting for Docker to start (${elapsed}s)...`);
6164
+ const elapsed = Date.now() - start;
6165
+ const pct = Math.min(95, Math.round(elapsed / totalTime * 100));
6166
+ const currentStrategyElapsed = elapsed - strategyIdx * perStrategyTime;
6167
+ if (currentStrategyElapsed >= perStrategyTime && strategyIdx < strategies.length - 1) {
6168
+ strategyIdx++;
6169
+ const strategy = strategies[strategyIdx];
6170
+ const msgs = {
6171
+ cli: "Trying Docker CLI...",
6172
+ reopen: "Restarting Docker Desktop...",
6173
+ background: "Trying direct launch...",
6174
+ service: "Trying service command...",
6175
+ snap: "Trying snap..."
6176
+ };
6177
+ this.onProgress(`__progress__:${pct}:${msgs[strategy] || "Trying another approach..."}`);
6178
+ this.startDockerDaemon(strategy);
6179
+ } else {
6180
+ const msgs = [
6181
+ "Starting Docker...",
6182
+ "Waiting for Docker engine...",
6183
+ "Docker is loading...",
6184
+ "Almost there...",
6185
+ "Still starting up...",
6186
+ "First launch takes a bit longer...",
6187
+ "Hang tight..."
6188
+ ];
6189
+ const msgIdx = Math.floor(elapsed / 1e4) % msgs.length;
6190
+ this.onProgress(`__progress__:${pct}:${msgs[msgIdx]}`);
5531
6191
  }
5532
6192
  await new Promise((r) => setTimeout(r, 3e3));
5533
6193
  }
5534
6194
  throw new Error(
5535
- "Docker daemon did not start in time. Open Docker Desktop manually, wait for it to finish loading, then run this again."
6195
+ "DOCKER_MANUAL_START"
5536
6196
  );
5537
6197
  }
5538
6198
  /**
@@ -5549,14 +6209,17 @@ var DependencyInstaller = class {
5549
6209
  this.startDockerDaemon();
5550
6210
  await this.waitForDocker();
5551
6211
  }
5552
- this.onProgress("Starting Stalwart mail server...");
5553
- execFileSync2("docker", ["compose", "-f", composePath, "up", "-d"], {
5554
- timeout: 12e4,
5555
- stdio: ["ignore", "pipe", "pipe"]
5556
- });
6212
+ this.onProgress("__progress__:10:Pulling mail server image...");
6213
+ const composeResult = await runWithRollingOutput("docker", ["compose", "-f", composePath, "up", "-d"], { timeout: 12e4 });
6214
+ if (composeResult.exitCode !== 0) {
6215
+ throw new Error("Failed to start mail server container. Check Docker is running.");
6216
+ }
6217
+ this.onProgress("__progress__:60:Waiting for mail server to start...");
5557
6218
  const maxWait = 3e4;
5558
6219
  const start = Date.now();
5559
6220
  while (Date.now() - start < maxWait) {
6221
+ const pct = 60 + Math.round((Date.now() - start) / maxWait * 35);
6222
+ this.onProgress(`__progress__:${Math.min(95, pct)}:Starting mail server...`);
5560
6223
  try {
5561
6224
  const output = execFileSync2(
5562
6225
  "docker",
@@ -5564,6 +6227,7 @@ var DependencyInstaller = class {
5564
6227
  { timeout: 5e3, stdio: ["ignore", "pipe", "ignore"] }
5565
6228
  ).toString().trim();
5566
6229
  if (output.toLowerCase().includes("up")) {
6230
+ this.onProgress("__progress__:100:Mail server ready!");
5567
6231
  return;
5568
6232
  }
5569
6233
  } catch {
@@ -5859,6 +6523,8 @@ export {
5859
6523
  RelayGateway,
5860
6524
  SPAM_THRESHOLD,
5861
6525
  SetupManager,
6526
+ SmsManager,
6527
+ SmsPoller,
5862
6528
  StalwartAdmin,
5863
6529
  TunnelManager,
5864
6530
  WARNING_THRESHOLD,
@@ -5868,9 +6534,13 @@ export {
5868
6534
  debug,
5869
6535
  debugWarn,
5870
6536
  ensureDataDir,
6537
+ extractVerificationCode,
5871
6538
  getDatabase,
5872
6539
  isInternalEmail,
6540
+ isValidPhoneNumber,
6541
+ normalizePhoneNumber,
5873
6542
  parseEmail,
6543
+ parseGoogleVoiceSms,
5874
6544
  resolveConfig,
5875
6545
  sanitizeEmail,
5876
6546
  saveConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/core",
3
- "version": "0.3.4",
3
+ "version": "0.5.0",
4
4
  "description": "Core SDK for AgenticMail — programmatic email for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",