@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 +160 -7
- package/dist/index.js +699 -29
- package/package.json +1 -1
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:
|
|
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
|
|
1282
|
-
*
|
|
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(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, '"').replace(/'/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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
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
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
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
|
|
5514
|
-
*
|
|
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
|
-
|
|
5518
|
-
const
|
|
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
|
|
5521
|
-
|
|
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
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
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
|
-
"
|
|
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("
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
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,
|