@agenticmail/core 0.3.4 → 0.4.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
 
@@ -1371,4 +1523,4 @@ declare class SetupManager {
1371
1523
  isInitialized(): boolean;
1372
1524
  }
1373
1525
 
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 };
1526
+ 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(
@@ -5859,6 +6320,8 @@ export {
5859
6320
  RelayGateway,
5860
6321
  SPAM_THRESHOLD,
5861
6322
  SetupManager,
6323
+ SmsManager,
6324
+ SmsPoller,
5862
6325
  StalwartAdmin,
5863
6326
  TunnelManager,
5864
6327
  WARNING_THRESHOLD,
@@ -5868,9 +6331,13 @@ export {
5868
6331
  debug,
5869
6332
  debugWarn,
5870
6333
  ensureDataDir,
6334
+ extractVerificationCode,
5871
6335
  getDatabase,
5872
6336
  isInternalEmail,
6337
+ isValidPhoneNumber,
6338
+ normalizePhoneNumber,
5873
6339
  parseEmail,
6340
+ parseGoogleVoiceSms,
5874
6341
  resolveConfig,
5875
6342
  sanitizeEmail,
5876
6343
  saveConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/core",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Core SDK for AgenticMail — programmatic email for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",