@agenticmail/core 0.9.19 → 0.9.21

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.js CHANGED
@@ -5137,1605 +5137,1944 @@ var SmsPoller = class {
5137
5137
  }
5138
5138
  };
5139
5139
 
5140
- // src/gateway/manager.ts
5141
- var GatewayManager = class {
5142
- constructor(options) {
5143
- this.options = options;
5144
- this.db = options.db;
5145
- this.stalwart = options.stalwart;
5146
- this.accountManager = options.accountManager ?? null;
5147
- this.encryptionKey = options.encryptionKey ?? process.env.AGENTICMAIL_MASTER_KEY ?? null;
5148
- const inboundHandler = options.onInboundMail ?? (this.accountManager && options.localSmtp ? this.deliverInboundLocally.bind(this) : void 0);
5149
- this.relay = new RelayGateway({
5150
- onInboundMail: inboundHandler,
5151
- defaultAgentName: DEFAULT_AGENT_NAME
5140
+ // src/telegram/client.ts
5141
+ var TELEGRAM_API_BASE = "https://api.telegram.org";
5142
+ var TELEGRAM_MESSAGE_LIMIT = 4096;
5143
+ var TELEGRAM_CHUNK_SIZE = 4e3;
5144
+ var TelegramApiError = class extends Error {
5145
+ isTelegramApiError = true;
5146
+ description;
5147
+ errorCode;
5148
+ constructor(method, description, errorCode) {
5149
+ super(`Telegram ${method} failed: ${description}${errorCode ? ` (code ${errorCode})` : ""}`);
5150
+ this.name = "TelegramApiError";
5151
+ this.description = description;
5152
+ this.errorCode = errorCode;
5153
+ }
5154
+ };
5155
+ function redactBotToken(text, token) {
5156
+ let out = typeof text === "string" ? text : String(text);
5157
+ if (token) out = out.split(token).join("bot***");
5158
+ return out.replace(/\d{6,}:[A-Za-z0-9_-]{30,}/g, "bot***");
5159
+ }
5160
+ async function callTelegramApi(token, method, body, options = {}) {
5161
+ if (!token || typeof token !== "string") {
5162
+ throw new TelegramApiError(method, "bot token is required");
5163
+ }
5164
+ const pollTimeout = typeof body?.timeout === "number" ? body.timeout : 0;
5165
+ const timeoutMs = options.longPoll && pollTimeout > 0 ? (pollTimeout + 15) * 1e3 : 3e4;
5166
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
5167
+ const signal = options.signal ? AbortSignal.any([timeoutSignal, options.signal]) : timeoutSignal;
5168
+ let response;
5169
+ try {
5170
+ response = await fetch(`${TELEGRAM_API_BASE}/bot${token}/${method}`, {
5171
+ method: "POST",
5172
+ headers: { "Content-Type": "application/json" },
5173
+ body: body ? JSON.stringify(body) : void 0,
5174
+ signal
5152
5175
  });
5153
- try {
5154
- this.loadConfig();
5155
- } catch {
5156
- this.config = { mode: "none" };
5157
- }
5158
- try {
5159
- this.smsManager = new SmsManager(options.db);
5160
- } catch {
5176
+ } catch (err) {
5177
+ throw new TelegramApiError(method, redactBotToken(err?.message ?? String(err), token));
5178
+ }
5179
+ let json;
5180
+ try {
5181
+ json = await response.json();
5182
+ } catch {
5183
+ throw new TelegramApiError(method, `non-JSON response (HTTP ${response.status})`);
5184
+ }
5185
+ if (!json || json.ok !== true) {
5186
+ throw new TelegramApiError(
5187
+ method,
5188
+ redactBotToken(String(json?.description || `HTTP ${response.status}`), token),
5189
+ typeof json?.error_code === "number" ? json.error_code : void 0
5190
+ );
5191
+ }
5192
+ return json.result;
5193
+ }
5194
+ function stripTelegramMarkdown(text) {
5195
+ if (!text) return text;
5196
+ return text.replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1").replace(/__(.+?)__/g, "$1").replace(/~~(.+?)~~/g, "$1").replace(/^#{1,6}\s+/gm, "").replace(/```[\s\S]*?```/g, (m) => m.replace(/```\w*\n?/g, "").trim()).replace(/`([^`]+)`/g, "$1").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim();
5197
+ }
5198
+ function splitTelegramMessage(text, maxLen = TELEGRAM_CHUNK_SIZE) {
5199
+ const chunks = [];
5200
+ let rest = text || "";
5201
+ while (rest.length > maxLen) {
5202
+ let cut = rest.lastIndexOf("\n", maxLen);
5203
+ if (cut < maxLen / 2) cut = maxLen;
5204
+ chunks.push(rest.slice(0, cut));
5205
+ rest = rest.slice(cut).replace(/^\n+/, "");
5206
+ }
5207
+ if (rest) chunks.push(rest);
5208
+ return chunks;
5209
+ }
5210
+ async function sendTelegramMessage(token, chatId, text, options = {}) {
5211
+ const clean = stripTelegramMarkdown(text);
5212
+ const chunks = splitTelegramMessage(clean);
5213
+ if (chunks.length === 0) chunks.push("");
5214
+ const messageIds = [];
5215
+ for (let i = 0; i < chunks.length; i++) {
5216
+ const body = { chat_id: String(chatId), text: chunks[i] };
5217
+ if (i === 0 && options.replyToMessageId) {
5218
+ body.reply_parameters = { message_id: options.replyToMessageId };
5161
5219
  }
5220
+ if (options.disableNotification) body.disable_notification = true;
5221
+ const result = await callTelegramApi(token, "sendMessage", body);
5222
+ messageIds.push(result.message_id);
5162
5223
  }
5163
- db;
5164
- stalwart;
5165
- accountManager;
5166
- relay;
5167
- config = { mode: "none" };
5168
- cfClient = null;
5169
- tunnel = null;
5170
- dnsConfigurator = null;
5171
- domainPurchaser = null;
5172
- smsManager = null;
5173
- smsPollers = /* @__PURE__ */ new Map();
5174
- encryptionKey = null;
5175
- /**
5176
- * Check if a message has already been delivered to an agent (deduplication).
5177
- */
5178
- isAlreadyDelivered(messageId, agentName) {
5179
- if (!messageId) return false;
5180
- const row = this.db.prepare("SELECT 1 FROM delivered_messages WHERE message_id = ? AND agent_name = ?").get(messageId, agentName);
5181
- return !!row;
5224
+ return { messageIds, chunks: chunks.length };
5225
+ }
5226
+ function getTelegramMe(token) {
5227
+ return callTelegramApi(token, "getMe");
5228
+ }
5229
+ function getTelegramChat(token, chatId) {
5230
+ return callTelegramApi(token, "getChat", { chat_id: String(chatId) });
5231
+ }
5232
+ function getTelegramUpdates(token, offset, options = {}) {
5233
+ const timeoutSec = Math.max(options.timeoutSec ?? 0, 0);
5234
+ return callTelegramApi(token, "getUpdates", {
5235
+ offset,
5236
+ limit: Math.min(Math.max(options.limit ?? 100, 1), 100),
5237
+ timeout: timeoutSec,
5238
+ allowed_updates: ["message"]
5239
+ }, { longPoll: timeoutSec > 0, signal: options.signal });
5240
+ }
5241
+ function setTelegramWebhook(token, url, options = {}) {
5242
+ return callTelegramApi(token, "setWebhook", {
5243
+ url,
5244
+ secret_token: options.secretToken,
5245
+ allowed_updates: ["message"],
5246
+ drop_pending_updates: options.dropPendingUpdates ?? false
5247
+ });
5248
+ }
5249
+ function deleteTelegramWebhook(token) {
5250
+ return callTelegramApi(token, "deleteWebhook", {});
5251
+ }
5252
+ function getTelegramWebhookInfo(token) {
5253
+ return callTelegramApi(token, "getWebhookInfo");
5254
+ }
5255
+
5256
+ // src/telegram/update.ts
5257
+ function asTrimmed(value) {
5258
+ return typeof value === "string" ? value.trim() : "";
5259
+ }
5260
+ function normalizeChatType(type) {
5261
+ return type === "private" || type === "group" || type === "supergroup" || type === "channel" ? type : "unknown";
5262
+ }
5263
+ function parseTelegramUpdate(update) {
5264
+ if (!update || typeof update !== "object") return null;
5265
+ const u = update;
5266
+ if (typeof u.update_id !== "number") return null;
5267
+ const msg = u.message || u.channel_post;
5268
+ if (!msg || typeof msg !== "object") return null;
5269
+ if (typeof msg.message_id !== "number") return null;
5270
+ const chat = msg.chat || {};
5271
+ if (typeof chat.id !== "number" && typeof chat.id !== "string") return null;
5272
+ const text = asTrimmed(msg.text) || asTrimmed(msg.caption);
5273
+ if (!text) return null;
5274
+ const from = msg.from || {};
5275
+ const fromName = [from.first_name, from.last_name].filter((p) => typeof p === "string" && p).join(" ") || asTrimmed(from.username) || asTrimmed(chat.title) || "User";
5276
+ const replyTo = msg.reply_to_message;
5277
+ return {
5278
+ updateId: u.update_id,
5279
+ messageId: msg.message_id,
5280
+ chatId: String(chat.id),
5281
+ chatType: normalizeChatType(chat.type),
5282
+ chatTitle: asTrimmed(chat.title) || void 0,
5283
+ fromId: from.id != null ? String(from.id) : String(chat.id),
5284
+ fromName,
5285
+ fromUsername: asTrimmed(from.username) || void 0,
5286
+ text,
5287
+ replyToMessageId: replyTo && typeof replyTo.message_id === "number" ? replyTo.message_id : void 0,
5288
+ replyToText: replyTo ? asTrimmed(replyTo.text) || asTrimmed(replyTo.caption) || void 0 : void 0,
5289
+ date: typeof msg.date === "number" ? new Date(msg.date * 1e3).toISOString() : (/* @__PURE__ */ new Date()).toISOString()
5290
+ };
5291
+ }
5292
+ var TELEGRAM_STOP_WORDS = /* @__PURE__ */ new Set([
5293
+ "stop",
5294
+ "abort",
5295
+ "kill",
5296
+ "cancel",
5297
+ "halt"
5298
+ ]);
5299
+ function isTelegramStopCommand(text) {
5300
+ if (!text) return false;
5301
+ const cleaned = text.trim().toLowerCase().replace(/[!.?]+$/, "");
5302
+ return TELEGRAM_STOP_WORDS.has(cleaned);
5303
+ }
5304
+ function nextTelegramOffset(currentOffset, updates) {
5305
+ let next = currentOffset;
5306
+ for (const u of updates) {
5307
+ if (u && typeof u.update_id === "number" && u.update_id >= next) {
5308
+ next = u.update_id + 1;
5309
+ }
5182
5310
  }
5311
+ return next;
5312
+ }
5313
+
5314
+ // src/telegram/manager.ts
5315
+ import { timingSafeEqual } from "crypto";
5316
+ var TELEGRAM_WEBHOOK_SECRET_RE = /^[A-Za-z0-9_-]+$/;
5317
+ var TELEGRAM_MIN_WEBHOOK_SECRET_LENGTH = 16;
5318
+ var TELEGRAM_SECRET_FIELDS = ["botToken", "webhookSecret"];
5319
+ function redactTelegramConfig(config) {
5320
+ return {
5321
+ ...config,
5322
+ botToken: config.botToken ? "***" : config.botToken,
5323
+ webhookSecret: config.webhookSecret ? "***" : void 0
5324
+ };
5325
+ }
5326
+ function isTelegramChatAllowed(config, chatId) {
5327
+ const id = String(chatId ?? "").trim();
5328
+ if (!id) return false;
5329
+ if (config.operatorChatId && String(config.operatorChatId).trim() === id) return true;
5330
+ return Array.isArray(config.allowedChatIds) && config.allowedChatIds.some((c) => String(c).trim() === id);
5331
+ }
5332
+ function safeEqual(a, b) {
5333
+ const bufA = Buffer.from(a, "utf8");
5334
+ const bufB = Buffer.from(b, "utf8");
5335
+ if (bufA.length !== bufB.length) return false;
5336
+ return timingSafeEqual(bufA, bufB);
5337
+ }
5338
+ var TelegramManager = class {
5183
5339
  /**
5184
- * Record that a message was delivered to an agent.
5340
+ * Optional master key used to encrypt Telegram credentials at rest
5341
+ * (the same AES-256-GCM scheme SMS/phone use). When absent (tests, or
5342
+ * a deployment with no master key) configs are stored as-is and reads
5343
+ * tolerate plaintext — upgrades and downgrades both stay safe.
5185
5344
  */
5186
- recordDelivery(messageId, agentName) {
5187
- if (!messageId) return;
5188
- this.db.prepare("INSERT OR IGNORE INTO delivered_messages (message_id, agent_name) VALUES (?, ?)").run(messageId, agentName);
5345
+ constructor(db2, encryptionKey) {
5346
+ this.db = db2;
5347
+ this.encryptionKey = encryptionKey;
5348
+ this.ensureTable();
5189
5349
  }
5190
- /**
5191
- * Built-in inbound mail handler: delivers relay inbound mail to agent's local Stalwart mailbox.
5192
- * Authenticates as the agent to send to their own mailbox (Stalwart requires sender = auth user).
5193
- *
5194
- * Also intercepts owner replies to approval notification emails — if the reply says
5195
- * "approve" or "reject", the pending outbound email is automatically processed.
5196
- */
5197
- async deliverInboundLocally(agentName, mail) {
5198
- if (!this.accountManager || !this.options.localSmtp) {
5199
- console.warn("[GatewayManager] Cannot deliver inbound: no accountManager or localSmtp config");
5200
- return;
5201
- }
5202
- if (mail.messageId && this.isAlreadyDelivered(mail.messageId, agentName)) return;
5203
- if (this.smsManager) {
5350
+ initialized = false;
5351
+ ensureTable() {
5352
+ if (this.initialized) return;
5353
+ try {
5354
+ this.db.exec(`
5355
+ CREATE TABLE IF NOT EXISTS telegram_messages (
5356
+ id TEXT PRIMARY KEY,
5357
+ agent_id TEXT NOT NULL,
5358
+ direction TEXT NOT NULL CHECK(direction IN ('inbound', 'outbound')),
5359
+ chat_id TEXT NOT NULL,
5360
+ telegram_message_id INTEGER,
5361
+ from_id TEXT,
5362
+ text TEXT NOT NULL,
5363
+ status TEXT NOT NULL DEFAULT 'pending',
5364
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
5365
+ metadata TEXT DEFAULT '{}'
5366
+ )
5367
+ `);
5204
5368
  try {
5205
- const smsBody = mail.text || mail.html || "";
5206
- const parsedSms = parseGoogleVoiceSms(smsBody, mail.from);
5207
- if (parsedSms) {
5208
- const agent2 = this.accountManager ? await this.accountManager.getByName(agentName) : null;
5209
- const agentId = agent2?.id;
5210
- if (agentId) {
5211
- const smsConfig = this.smsManager.getSmsConfig(agentId);
5212
- if (smsConfig?.enabled && smsConfig.sameAsRelay) {
5213
- this.smsManager.recordInbound(agentId, parsedSms);
5214
- console.log(`[GatewayManager] SMS received from ${parsedSms.from}: "${parsedSms.body.slice(0, 50)}..." \u2192 agent ${agentName}`);
5215
- if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
5216
- }
5217
- }
5218
- }
5219
- } catch (err) {
5220
- debug("GatewayManager", `SMS detection error: ${err.message}`);
5369
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_telegram_agent ON telegram_messages(agent_id)");
5370
+ } catch {
5221
5371
  }
5222
- }
5223
- try {
5224
- await this.tryProcessApprovalReply(mail);
5225
- } catch (err) {
5226
- console.warn(`[GatewayManager] Approval reply check failed: ${err.message}`);
5227
- }
5228
- const parsed = inboundToParsedEmail(mail);
5229
- const { isInternalEmail: isInternalEmail2 } = await import("./spam-filter-L6KNZ7QI.js");
5230
- if (!isInternalEmail2(parsed)) {
5231
- const spamResult = scoreEmail(parsed);
5232
- if (spamResult.isSpam) {
5233
- console.warn(`[GatewayManager] Spam blocked (score=${spamResult.score}, category=${spamResult.topCategory}): "${mail.subject}" from ${mail.from}`);
5234
- if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
5235
- return;
5372
+ try {
5373
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_telegram_chat ON telegram_messages(chat_id)");
5374
+ } catch {
5236
5375
  }
5237
- }
5238
- let agent = await this.accountManager.getByName(agentName);
5239
- if (!agent && agentName !== DEFAULT_AGENT_NAME) {
5240
- agent = await this.accountManager.getByName(DEFAULT_AGENT_NAME);
5241
- if (agent) {
5242
- console.warn(`[GatewayManager] Agent "${agentName}" not found, delivering to default agent "${DEFAULT_AGENT_NAME}"`);
5376
+ try {
5377
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_telegram_created ON telegram_messages(created_at)");
5378
+ } catch {
5243
5379
  }
5380
+ this.initialized = true;
5381
+ } catch {
5382
+ this.initialized = true;
5244
5383
  }
5245
- if (!agent) {
5246
- console.warn(`[GatewayManager] No agent to deliver inbound mail (target: "${agentName}")`);
5247
- return;
5248
- }
5249
- const agentPassword = agent.metadata?._password;
5250
- if (!agentPassword) {
5251
- console.warn(`[GatewayManager] No password for agent "${agentName}", cannot deliver`);
5252
- return;
5384
+ }
5385
+ /** Encrypt the credential fields of a config before persisting. */
5386
+ encryptConfig(config) {
5387
+ if (!this.encryptionKey) return config;
5388
+ const out = { ...config };
5389
+ for (const field of TELEGRAM_SECRET_FIELDS) {
5390
+ const value = out[field];
5391
+ if (typeof value === "string" && value && !isEncryptedSecret(value)) {
5392
+ out[field] = encryptSecret(value, this.encryptionKey);
5393
+ }
5253
5394
  }
5254
- const transport = nodemailer3.createTransport({
5255
- host: this.options.localSmtp.host,
5256
- port: this.options.localSmtp.port,
5257
- secure: false,
5258
- auth: {
5259
- user: agent.stalwartPrincipal,
5260
- pass: agentPassword
5261
- },
5262
- tls: { rejectUnauthorized: false },
5263
- connectionTimeout: 1e4,
5264
- greetingTimeout: 1e4,
5265
- socketTimeout: 15e3
5266
- });
5267
- try {
5268
- await transport.sendMail({
5269
- from: `${mail.from} <${agent.email}>`,
5270
- to: agent.email,
5271
- subject: mail.subject,
5272
- text: mail.text,
5273
- html: mail.html || void 0,
5274
- replyTo: mail.from,
5275
- inReplyTo: mail.inReplyTo,
5276
- references: Array.isArray(mail.references) ? mail.references.join(" ") : mail.references,
5277
- headers: {
5278
- "X-AgenticMail-Relay": "inbound",
5279
- "X-Original-From": mail.from,
5280
- ...mail.messageId ? { "X-Original-Message-Id": mail.messageId } : {}
5281
- },
5282
- attachments: mail.attachments?.map((a) => ({
5283
- filename: a.filename,
5284
- content: a.content,
5285
- contentType: a.contentType
5286
- }))
5287
- });
5288
- if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
5289
- } catch (err) {
5290
- console.error(`[GatewayManager] Failed to deliver to ${agent.email}: ${err.message}`);
5291
- throw err;
5292
- } finally {
5293
- transport.close();
5395
+ return out;
5396
+ }
5397
+ /** Decrypt the credential fields of a config after loading. */
5398
+ decryptConfig(config) {
5399
+ if (!this.encryptionKey) return config;
5400
+ const out = { ...config };
5401
+ for (const field of TELEGRAM_SECRET_FIELDS) {
5402
+ const value = out[field];
5403
+ if (typeof value === "string" && isEncryptedSecret(value)) {
5404
+ try {
5405
+ out[field] = decryptSecret(value, this.encryptionKey);
5406
+ } catch {
5407
+ }
5408
+ }
5294
5409
  }
5410
+ return out;
5411
+ }
5412
+ /** Normalize a stored/loaded config object, defaulting missing fields. */
5413
+ normalizeConfig(raw) {
5414
+ return {
5415
+ enabled: raw.enabled === true,
5416
+ botToken: typeof raw.botToken === "string" ? raw.botToken : "",
5417
+ botUsername: typeof raw.botUsername === "string" ? raw.botUsername : void 0,
5418
+ botId: typeof raw.botId === "number" ? raw.botId : void 0,
5419
+ allowedChatIds: Array.isArray(raw.allowedChatIds) ? raw.allowedChatIds.map((c) => String(c).trim()).filter(Boolean) : [],
5420
+ operatorChatId: typeof raw.operatorChatId === "string" && raw.operatorChatId.trim() ? raw.operatorChatId.trim() : void 0,
5421
+ mode: raw.mode === "webhook" ? "webhook" : "poll",
5422
+ webhookUrl: typeof raw.webhookUrl === "string" ? raw.webhookUrl : void 0,
5423
+ webhookSecret: typeof raw.webhookSecret === "string" ? raw.webhookSecret : void 0,
5424
+ pollOffset: typeof raw.pollOffset === "number" ? raw.pollOffset : 0,
5425
+ configuredAt: typeof raw.configuredAt === "string" ? raw.configuredAt : (/* @__PURE__ */ new Date()).toISOString()
5426
+ };
5295
5427
  }
5296
- /**
5297
- * Check if an inbound email is a reply to a pending approval notification.
5298
- * If the reply body starts with "approve"/"yes" or "reject"/"no", automatically
5299
- * process the pending email (send it or discard it) and confirm to the owner.
5300
- */
5301
- async tryProcessApprovalReply(mail) {
5302
- const candidateIds = [];
5303
- if (mail.inReplyTo) candidateIds.push(mail.inReplyTo);
5304
- if (mail.references) candidateIds.push(...mail.references);
5305
- if (candidateIds.length === 0) return false;
5306
- let row = null;
5307
- for (const id of candidateIds) {
5308
- row = this.db.prepare(
5309
- `SELECT * FROM pending_outbound WHERE notification_message_id = ? AND status = 'pending'`
5310
- ).get(id);
5311
- if (row) break;
5428
+ /** Get the Telegram config from agent metadata (credentials decrypted). */
5429
+ getConfig(agentId) {
5430
+ const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
5431
+ if (!row) return null;
5432
+ try {
5433
+ const meta = JSON.parse(row.metadata || "{}");
5434
+ if (!meta.telegram || typeof meta.telegram !== "object") return null;
5435
+ return this.decryptConfig(this.normalizeConfig(meta.telegram));
5436
+ } catch {
5437
+ return null;
5312
5438
  }
5313
- if (!row) return false;
5314
- const body = (mail.text || "").trim();
5315
- const lines = body.split("\n").filter((l) => !l.startsWith(">") && l.trim().length > 0);
5316
- const firstLine = (lines[0] || "").trim().toLowerCase();
5317
- const approvePattern = /^(approve[d]?|yes|send\s*it|send|go\s*ahead|lgtm|ok(?:ay)?)\b/;
5318
- const rejectPattern = /^(reject(?:ed)?|no|den(?:y|ied)|don'?t\s*send|do\s*not\s*send|cancel|block(?:ed)?)\b/;
5319
- let action = null;
5320
- if (approvePattern.test(firstLine)) {
5321
- action = "approve";
5322
- } else if (rejectPattern.test(firstLine)) {
5323
- action = "reject";
5439
+ }
5440
+ /** Save the Telegram config to agent metadata (credentials encrypted). */
5441
+ saveConfig(agentId, config) {
5442
+ const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
5443
+ if (!row) throw new Error(`Agent ${agentId} not found`);
5444
+ let meta;
5445
+ try {
5446
+ meta = JSON.parse(row.metadata || "{}");
5447
+ } catch {
5448
+ meta = {};
5324
5449
  }
5325
- if (!action) return false;
5450
+ meta.telegram = this.encryptConfig(config);
5451
+ this.db.prepare("UPDATE agents SET metadata = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(meta), agentId);
5452
+ }
5453
+ /** Remove the Telegram config from agent metadata. */
5454
+ removeConfig(agentId) {
5455
+ const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
5456
+ if (!row) return;
5457
+ let meta;
5326
5458
  try {
5327
- if (action === "approve") {
5328
- await this.executeApproval(row);
5329
- } else {
5330
- this.db.prepare(
5331
- `UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = 'owner-reply' WHERE id = ?`
5332
- ).run(row.id);
5333
- }
5334
- await this.sendApprovalConfirmation(row, action, mail.messageId);
5335
- } catch (err) {
5336
- console.error(`[GatewayManager] Failed to process approval reply for ${row.id}:`, err);
5459
+ meta = JSON.parse(row.metadata || "{}");
5460
+ } catch {
5461
+ meta = {};
5337
5462
  }
5338
- return true;
5463
+ delete meta.telegram;
5464
+ this.db.prepare("UPDATE agents SET metadata = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(meta), agentId);
5465
+ }
5466
+ /** Persist a new poll offset without touching the rest of the config. */
5467
+ updatePollOffset(agentId, offset) {
5468
+ const config = this.getConfig(agentId);
5469
+ if (!config) return;
5470
+ config.pollOffset = offset;
5471
+ this.saveConfig(agentId, config);
5339
5472
  }
5340
5473
  /**
5341
- * Execute approval of a pending outbound email: look up the agent, reconstitute
5342
- * attachments, and send the email via gateway routing or local SMTP.
5474
+ * Resolve the agent that owns a webhook secret. Used to authenticate +
5475
+ * route an inbound Telegram webhook delivery: a webhook carries no bot
5476
+ * identity, so the `X-Telegram-Bot-Api-Secret-Token` header is the
5477
+ * routing key. The comparison is constant-time, and a non-match
5478
+ * returns `null` so the route can answer with a single uniform 403
5479
+ * (no enumeration oracle — same posture as the SMS webhook).
5343
5480
  */
5344
- async executeApproval(row) {
5345
- if (!this.accountManager) {
5346
- throw new Error("AccountManager required for approval processing");
5347
- }
5348
- const agent = await this.accountManager.getById(row.agent_id);
5349
- if (!agent) {
5350
- console.warn(`[GatewayManager] Cannot approve pending ${row.id}: agent ${row.agent_id} no longer exists`);
5351
- this.db.prepare(
5352
- `UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = 'owner-reply', error = 'Agent no longer exists' WHERE id = ?`
5353
- ).run(row.id);
5354
- return;
5355
- }
5356
- const mailOpts = JSON.parse(row.mail_options);
5357
- const ownerName = agent.metadata?.ownerName;
5358
- mailOpts.fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
5359
- if (Array.isArray(mailOpts.attachments)) {
5360
- for (const att of mailOpts.attachments) {
5361
- if (att.content && typeof att.content === "object" && att.content.type === "Buffer" && Array.isArray(att.content.data)) {
5362
- att.content = Buffer.from(att.content.data);
5481
+ findAgentByWebhookSecret(secret) {
5482
+ const provided = String(secret ?? "");
5483
+ if (!provided) return null;
5484
+ const rows = this.db.prepare("SELECT id, metadata FROM agents").all();
5485
+ for (const row of rows) {
5486
+ try {
5487
+ const meta = JSON.parse(row.metadata || "{}");
5488
+ if (!meta.telegram || typeof meta.telegram !== "object") continue;
5489
+ const config = this.decryptConfig(this.normalizeConfig(meta.telegram));
5490
+ if (!config.enabled || !config.webhookSecret) continue;
5491
+ if (safeEqual(provided, config.webhookSecret)) {
5492
+ return { agentId: row.id, config };
5363
5493
  }
5494
+ } catch {
5364
5495
  }
5365
5496
  }
5366
- const gatewayResult = await this.routeOutbound(agent.name, mailOpts);
5367
- if (!gatewayResult && this.options.localSmtp) {
5368
- const agentPassword = agent.metadata?._password;
5369
- if (!agentPassword) {
5370
- throw new Error(`No password for agent "${agent.name}"`);
5371
- }
5372
- const transport = nodemailer3.createTransport({
5373
- host: this.options.localSmtp.host,
5374
- port: this.options.localSmtp.port,
5375
- secure: false,
5376
- auth: {
5377
- user: agent.stalwartPrincipal,
5378
- pass: agentPassword
5379
- },
5380
- tls: { rejectUnauthorized: false }
5381
- });
5382
- try {
5383
- await transport.sendMail({
5384
- from: mailOpts.fromName ? `${mailOpts.fromName} <${agent.email}>` : agent.email,
5385
- to: Array.isArray(mailOpts.to) ? mailOpts.to.join(", ") : mailOpts.to,
5386
- subject: mailOpts.subject,
5387
- text: mailOpts.text || void 0,
5388
- html: mailOpts.html || void 0,
5389
- cc: mailOpts.cc || void 0,
5390
- bcc: mailOpts.bcc || void 0,
5391
- replyTo: mailOpts.replyTo || void 0,
5392
- inReplyTo: mailOpts.inReplyTo || void 0,
5393
- references: Array.isArray(mailOpts.references) ? mailOpts.references.join(" ") : mailOpts.references || void 0,
5394
- attachments: mailOpts.attachments?.map((a) => ({
5395
- filename: a.filename,
5396
- content: a.content,
5397
- contentType: a.contentType,
5398
- encoding: a.encoding
5399
- }))
5400
- });
5401
- } finally {
5402
- transport.close();
5403
- }
5497
+ return null;
5498
+ }
5499
+ /** True if an inbound message with this Telegram id is already stored. */
5500
+ inboundMessageExists(agentId, chatId, telegramMessageId) {
5501
+ const row = this.db.prepare(
5502
+ "SELECT 1 FROM telegram_messages WHERE agent_id = ? AND direction = ? AND chat_id = ? AND telegram_message_id = ? LIMIT 1"
5503
+ ).get(agentId, "inbound", String(chatId), telegramMessageId);
5504
+ return !!row;
5505
+ }
5506
+ /** Record an inbound Telegram message. */
5507
+ recordInbound(agentId, input, metadata) {
5508
+ const id = `tg_in_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
5509
+ const createdAt = input.createdAt || (/* @__PURE__ */ new Date()).toISOString();
5510
+ this.db.prepare(
5511
+ "INSERT INTO telegram_messages (id, agent_id, direction, chat_id, telegram_message_id, from_id, text, status, created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
5512
+ ).run(
5513
+ id,
5514
+ agentId,
5515
+ "inbound",
5516
+ String(input.chatId),
5517
+ input.telegramMessageId,
5518
+ input.fromId ?? null,
5519
+ input.text,
5520
+ "received",
5521
+ createdAt,
5522
+ JSON.stringify(metadata ?? {})
5523
+ );
5524
+ return {
5525
+ id,
5526
+ agentId,
5527
+ direction: "inbound",
5528
+ chatId: String(input.chatId),
5529
+ telegramMessageId: input.telegramMessageId,
5530
+ fromId: input.fromId,
5531
+ text: input.text,
5532
+ status: "received",
5533
+ createdAt,
5534
+ metadata
5535
+ };
5536
+ }
5537
+ /** Record an outbound Telegram message attempt. */
5538
+ recordOutbound(agentId, input, metadata) {
5539
+ const id = `tg_out_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
5540
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
5541
+ const status = input.status ?? "sent";
5542
+ this.db.prepare(
5543
+ "INSERT INTO telegram_messages (id, agent_id, direction, chat_id, telegram_message_id, from_id, text, status, created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
5544
+ ).run(
5545
+ id,
5546
+ agentId,
5547
+ "outbound",
5548
+ String(input.chatId),
5549
+ input.telegramMessageId ?? null,
5550
+ null,
5551
+ input.text,
5552
+ status,
5553
+ createdAt,
5554
+ JSON.stringify(metadata ?? {})
5555
+ );
5556
+ return {
5557
+ id,
5558
+ agentId,
5559
+ direction: "outbound",
5560
+ chatId: String(input.chatId),
5561
+ telegramMessageId: input.telegramMessageId,
5562
+ text: input.text,
5563
+ status,
5564
+ createdAt,
5565
+ metadata
5566
+ };
5567
+ }
5568
+ /** Update the status (+ optional metadata) of a stored message. */
5569
+ updateStatus(id, status, metadata) {
5570
+ if (metadata) {
5571
+ this.db.prepare("UPDATE telegram_messages SET status = ?, metadata = ? WHERE id = ?").run(status, JSON.stringify(metadata), id);
5572
+ return;
5573
+ }
5574
+ this.db.prepare("UPDATE telegram_messages SET status = ? WHERE id = ?").run(status, id);
5575
+ }
5576
+ /** List stored Telegram messages for an agent, newest first. */
5577
+ listMessages(agentId, opts) {
5578
+ const limit = Math.min(Math.max(opts?.limit ?? 20, 1), 100);
5579
+ const offset = Math.max(opts?.offset ?? 0, 0);
5580
+ let query = "SELECT * FROM telegram_messages WHERE agent_id = ?";
5581
+ const params = [agentId];
5582
+ if (opts?.direction === "inbound" || opts?.direction === "outbound") {
5583
+ query += " AND direction = ?";
5584
+ params.push(opts.direction);
5404
5585
  }
5405
- this.db.prepare(
5406
- `UPDATE pending_outbound SET status = 'approved', resolved_at = datetime('now'), resolved_by = 'owner-reply' WHERE id = ?`
5407
- ).run(row.id);
5586
+ if (opts?.chatId) {
5587
+ query += " AND chat_id = ?";
5588
+ params.push(String(opts.chatId));
5589
+ }
5590
+ query += " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?";
5591
+ params.push(limit, offset);
5592
+ return this.db.prepare(query).all(...params).map((row) => ({
5593
+ id: row.id,
5594
+ agentId: row.agent_id,
5595
+ direction: row.direction,
5596
+ chatId: row.chat_id,
5597
+ telegramMessageId: row.telegram_message_id ?? void 0,
5598
+ fromId: row.from_id ?? void 0,
5599
+ text: row.text,
5600
+ status: row.status,
5601
+ createdAt: row.created_at,
5602
+ metadata: row.metadata ? JSON.parse(row.metadata) : void 0
5603
+ }));
5408
5604
  }
5605
+ };
5606
+
5607
+ // src/telegram/poller.ts
5608
+ var TELEGRAM_LONG_POLL_TIMEOUT_SEC = 25;
5609
+ var ERROR_BACKOFF_MAX_MS = 6e4;
5610
+ var ERROR_BACKOFF_BASE_MS = 2e3;
5611
+ var TelegramPoller = class {
5612
+ constructor(telegramManager, agentId, options = {}) {
5613
+ this.telegramManager = telegramManager;
5614
+ this.agentId = agentId;
5615
+ this.options = options;
5616
+ }
5617
+ running = false;
5618
+ currentAbort = null;
5619
+ /** Wakes a sleeping backoff so `stop()` returns quickly. */
5620
+ wakeStop = null;
5621
+ lastErrorLogAt = 0;
5622
+ lastErrorMessage = "";
5409
5623
  /**
5410
- * Send a confirmation email back to the owner after processing an approval reply.
5624
+ * Set by the caller. Fired for every new inbound message that isn't a
5625
+ * duplicate and is in the allow-list. The callback's return value
5626
+ * gates record-as-handled (errors propagate as failures the loop
5627
+ * tolerates — the poll offset is STILL advanced so a single bad
5628
+ * message doesn't wedge the agent forever).
5411
5629
  */
5412
- async sendApprovalConfirmation(row, action, replyMessageId) {
5413
- const ownerEmail = this.config.relay?.email;
5414
- if (!ownerEmail || !this.accountManager) return;
5415
- const mailOpts = JSON.parse(row.mail_options);
5416
- const agent = await this.accountManager.getById(row.agent_id);
5417
- const agentName = agent?.name || "unknown agent";
5418
- const statusText = action === "approve" ? "APPROVED and sent" : "REJECTED and discarded";
5419
- this.routeOutbound(agentName, {
5420
- to: ownerEmail,
5421
- subject: `Re: [Approval Required] Blocked email from "${agentName}" \u2014 ${statusText}`,
5422
- text: [
5423
- `The blocked email has been ${statusText}.`,
5424
- "",
5425
- ` To: ${Array.isArray(mailOpts.to) ? mailOpts.to.join(", ") : mailOpts.to}`,
5426
- ` Subject: ${mailOpts.subject}`,
5427
- "",
5428
- action === "approve" ? "The email has been delivered to the recipient." : "The email has been discarded and will not be sent."
5429
- ].join("\n"),
5430
- fromName: "Agentic Mail",
5431
- inReplyTo: replyMessageId
5432
- }).catch((err) => {
5433
- console.warn(`[GatewayManager] Failed to send approval confirmation: ${err.message}`);
5630
+ onInbound = null;
5631
+ /** Has `start()` been called and is the loop still running? */
5632
+ get isRunning() {
5633
+ return this.running;
5634
+ }
5635
+ /** Resolves when the background loop has fully exited. */
5636
+ loopPromise = null;
5637
+ async start() {
5638
+ if (this.running) return;
5639
+ this.running = true;
5640
+ this.loopPromise = this.loop().catch((err) => {
5641
+ this.running = false;
5642
+ console.warn(`[TelegramPoller:${this.agentId.slice(0, 8)}] loop crashed: ${err?.message ?? err}`);
5434
5643
  });
5435
5644
  }
5436
- // --- Relay Mode ---
5437
- async setupRelay(config, options) {
5438
- await this.relay.setup(config);
5439
- this.config = { mode: "relay", relay: config };
5440
- this.saveConfig();
5441
- let agent;
5442
- if (!options?.skipDefaultAgent && this.accountManager) {
5443
- const agentName = options?.defaultAgentName ?? DEFAULT_AGENT_NAME;
5444
- const agentRole = options?.defaultAgentRole ?? DEFAULT_AGENT_ROLE;
5445
- const existing = await this.accountManager.getByName(agentName);
5446
- if (existing) {
5447
- agent = existing;
5448
- } else {
5449
- agent = await this.accountManager.create({
5450
- name: agentName,
5451
- role: agentRole,
5452
- gateway: "relay"
5453
- });
5454
- }
5645
+ /** Cancel the in-flight long-poll and wait for the loop to exit. */
5646
+ async stop() {
5647
+ this.running = false;
5648
+ try {
5649
+ this.currentAbort?.abort();
5650
+ } catch {
5455
5651
  }
5456
- this.relay.onUidAdvance = (uid) => this.saveLastSeenUid(uid);
5457
- await this.relay.startPolling();
5458
- return { agent };
5459
- }
5460
- // --- Domain Mode ---
5461
- async setupDomain(options) {
5462
- this.cfClient = new CloudflareClient(options.cloudflareToken, options.cloudflareAccountId);
5463
- this.dnsConfigurator = new DNSConfigurator(this.cfClient);
5464
- this.tunnel = new TunnelManager(this.cfClient);
5465
- this.domainPurchaser = new DomainPurchaser(this.cfClient);
5466
- let domain = options.domain;
5467
- if (!domain && options.purchase) {
5468
- const available = await this.domainPurchaser.searchAvailable(
5469
- options.purchase.keywords,
5470
- options.purchase.tld ? [options.purchase.tld] : void 0
5471
- );
5472
- const first = available.find((d) => d.available && !d.premium);
5473
- if (!first) {
5474
- throw new Error("No available domains found for the given keywords");
5652
+ if (this.wakeStop) {
5653
+ try {
5654
+ this.wakeStop();
5655
+ } catch {
5475
5656
  }
5476
- await this.domainPurchaser.purchase(first.domain);
5477
- domain = first.domain;
5478
- this.db.prepare(`
5479
- INSERT OR REPLACE INTO purchased_domains (domain, registrar) VALUES (?, ?)
5480
- `).run(domain, "cloudflare");
5481
- }
5482
- if (!domain) {
5483
- throw new Error("No domain specified and no purchase keywords provided");
5484
- }
5485
- let zone = await this.cfClient.getZone(domain);
5486
- if (!zone) {
5487
- zone = await this.cfClient.createZone(domain);
5488
5657
  }
5489
- const existingRecords = await this.cfClient.listDnsRecords(zone.id);
5490
- const { homedir: homedir13 } = await import("os");
5491
- const backupDir = join4(homedir13(), ".agenticmail");
5492
- const backupPath = join4(backupDir, `dns-backup-${domain}-${Date.now()}.json`);
5493
- const { writeFileSync: writeFileSync11, mkdirSync: mkdirSync12 } = await import("fs");
5494
- mkdirSync12(backupDir, { recursive: true });
5495
- writeFileSync11(backupPath, JSON.stringify({
5496
- domain,
5497
- zoneId: zone.id,
5498
- backedUpAt: (/* @__PURE__ */ new Date()).toISOString(),
5499
- records: existingRecords
5500
- }, null, 2));
5501
- console.log(`[GatewayManager] DNS backup saved to ${backupPath} (${existingRecords.length} records)`);
5502
- const rootRecords = existingRecords.filter(
5503
- (r) => r.name === domain && (r.type === "A" || r.type === "AAAA" || r.type === "CNAME" || r.type === "MX")
5504
- );
5505
- if (rootRecords.length > 0) {
5506
- console.warn(`[GatewayManager] \u26A0\uFE0F WARNING: ${rootRecords.length} existing root DNS record(s) for ${domain} will be modified:`);
5507
- for (const r of rootRecords) {
5508
- console.warn(`[GatewayManager] ${r.type} ${r.name} \u2192 ${r.content}`);
5658
+ if (this.loopPromise) {
5659
+ try {
5660
+ await this.loopPromise;
5661
+ } catch {
5509
5662
  }
5510
- console.warn(`[GatewayManager] Backup saved at: ${backupPath}`);
5511
- }
5512
- const tunnelConfig = await this.tunnel.create(`agenticmail-${domain}`);
5513
- console.log(`[GatewayManager] Configuring mail server hostname: ${domain}`);
5514
- try {
5515
- await this.stalwart.setHostname(domain);
5516
- console.log(`[GatewayManager] Mail server hostname set to ${domain}`);
5517
- } catch (err) {
5518
- console.warn(`[GatewayManager] Failed to set hostname (EHLO may show "localhost"): ${err.message}`);
5519
- }
5520
- console.log("[GatewayManager] Setting up DKIM signing...");
5521
- let dkimPublicKey;
5522
- let dkimSelector = "agenticmail";
5523
- try {
5524
- const dkim = await this.stalwart.createDkimSignature(domain, dkimSelector);
5525
- dkimPublicKey = dkim.publicKey;
5526
- console.log(`[GatewayManager] DKIM signature created (selector: ${dkimSelector})`);
5527
- } catch (err) {
5528
- console.warn(`[GatewayManager] DKIM setup failed (email may land in spam): ${err.message}`);
5663
+ this.loopPromise = null;
5529
5664
  }
5530
- const tunnelRemoved = await this.dnsConfigurator.configureForTunnel(domain, zone.id, tunnelConfig.tunnelId);
5531
- if (tunnelRemoved.length > 0) {
5532
- console.log(`[GatewayManager] Removed ${tunnelRemoved.length} conflicting DNS record(s) for tunnel`);
5665
+ }
5666
+ async loop() {
5667
+ const timeoutSec = Math.max(1, this.options.timeoutSec ?? TELEGRAM_LONG_POLL_TIMEOUT_SEC);
5668
+ let backoff = ERROR_BACKOFF_BASE_MS;
5669
+ while (this.running) {
5670
+ const config = this.telegramManager.getConfig(this.agentId);
5671
+ if (!config?.enabled || config.mode !== "poll" || !config.botToken) {
5672
+ this.running = false;
5673
+ return;
5674
+ }
5675
+ const offset = config.pollOffset ?? 0;
5676
+ const controller = new AbortController();
5677
+ this.currentAbort = controller;
5678
+ try {
5679
+ const updates = await getTelegramUpdates(config.botToken, offset, {
5680
+ timeoutSec,
5681
+ signal: controller.signal
5682
+ });
5683
+ backoff = ERROR_BACKOFF_BASE_MS;
5684
+ if (updates.length === 0 && timeoutSec > 0) {
5685
+ await new Promise((r) => setImmediate(r));
5686
+ }
5687
+ for (const update of updates) {
5688
+ if (!this.running) break;
5689
+ const parsed = parseTelegramUpdate(update);
5690
+ if (!parsed) continue;
5691
+ if (!isTelegramChatAllowed(config, parsed.chatId)) continue;
5692
+ if (this.telegramManager.inboundMessageExists(this.agentId, parsed.chatId, parsed.messageId)) {
5693
+ continue;
5694
+ }
5695
+ this.telegramManager.recordInbound(this.agentId, {
5696
+ chatId: parsed.chatId,
5697
+ telegramMessageId: parsed.messageId,
5698
+ fromId: parsed.fromId,
5699
+ text: parsed.text,
5700
+ createdAt: parsed.date
5701
+ }, {
5702
+ chatType: parsed.chatType,
5703
+ fromName: parsed.fromName,
5704
+ fromUsername: parsed.fromUsername,
5705
+ updateId: parsed.updateId
5706
+ });
5707
+ if (this.onInbound) {
5708
+ try {
5709
+ await this.onInbound({ agentId: this.agentId, message: parsed, config });
5710
+ } catch (err) {
5711
+ this.logError("inbound bridge failed", err);
5712
+ }
5713
+ }
5714
+ }
5715
+ const newOffset = nextTelegramOffset(offset, updates);
5716
+ if (newOffset !== offset) {
5717
+ this.telegramManager.updatePollOffset(this.agentId, newOffset);
5718
+ }
5719
+ } catch (err) {
5720
+ if (!this.running) return;
5721
+ if (controller.signal.aborted) return;
5722
+ if (err instanceof TelegramApiError && (err.errorCode === 401 || err.errorCode === 404)) {
5723
+ this.logError("bot token rejected \u2014 stopping poller", err);
5724
+ this.running = false;
5725
+ return;
5726
+ }
5727
+ this.logError("getUpdates failed", err);
5728
+ await this.backoff(backoff);
5729
+ backoff = Math.min(backoff * 2, ERROR_BACKOFF_MAX_MS);
5730
+ } finally {
5731
+ if (this.currentAbort === controller) this.currentAbort = null;
5732
+ }
5533
5733
  }
5534
- const emailDns = await this.dnsConfigurator.configureForEmail(domain, zone.id, {
5535
- dkimSelector,
5536
- dkimPublicKey
5734
+ }
5735
+ /** Sleep that returns early on `stop()`. */
5736
+ backoff(ms) {
5737
+ return new Promise((resolve2) => {
5738
+ const t = setTimeout(() => {
5739
+ this.wakeStop = null;
5740
+ resolve2();
5741
+ }, ms);
5742
+ this.wakeStop = () => {
5743
+ clearTimeout(t);
5744
+ this.wakeStop = null;
5745
+ resolve2();
5746
+ };
5747
+ });
5748
+ }
5749
+ /** Collapse identical errors fired in close succession to one log line. */
5750
+ logError(prefix, err) {
5751
+ const msg = err instanceof Error ? err.message : String(err);
5752
+ const suppressMs = this.options.suppressDuplicateLogsMs ?? 3e4;
5753
+ const now = Date.now();
5754
+ if (msg === this.lastErrorMessage && now - this.lastErrorLogAt < suppressMs) return;
5755
+ this.lastErrorLogAt = now;
5756
+ this.lastErrorMessage = msg;
5757
+ console.warn(`[TelegramPoller:${this.agentId.slice(0, 8)}] ${prefix}: ${msg}`);
5758
+ }
5759
+ };
5760
+
5761
+ // src/telegram/operator-query.ts
5762
+ var TELEGRAM_OPERATOR_QUERY_TAG = "AMQ";
5763
+ var QUERY_ID_RE = /(oq_[A-Za-z0-9-]+)/;
5764
+ var QUERY_TAG_RE = new RegExp(`\\[${TELEGRAM_OPERATOR_QUERY_TAG}\\s+(oq_[A-Za-z0-9-]+)\\]`);
5765
+ function formatOperatorQueryTelegramMessage(input) {
5766
+ const lines = [];
5767
+ lines.push(input.urgency === "high" ? "\u{1F534} Your agent needs an answer to continue a live call (URGENT)." : "\u{1F7E1} Your agent needs an answer to continue a live call.");
5768
+ lines.push("");
5769
+ lines.push(`Question: ${input.question}`);
5770
+ if (input.callContext) lines.push(`Context: ${input.callContext}`);
5771
+ lines.push("");
5772
+ lines.push("Reply to this message with your answer. You can also send:");
5773
+ lines.push(` /answer ${input.queryId} <your answer>`);
5774
+ lines.push(` /approve ${input.queryId} \xB7 /deny ${input.queryId}`);
5775
+ lines.push("");
5776
+ lines.push(`[${TELEGRAM_OPERATOR_QUERY_TAG} ${input.queryId}]`);
5777
+ return lines.join("\n");
5778
+ }
5779
+ function parseTelegramOperatorReply(input) {
5780
+ const text = (input.text ?? "").trim();
5781
+ if (!text) return null;
5782
+ const quotedTag = input.replyToText ? QUERY_TAG_RE.exec(input.replyToText) : null;
5783
+ const quotedQueryId = quotedTag?.[1];
5784
+ const answerCmd = /^\/answer(?:@\w+)?\s+(oq_[A-Za-z0-9-]+)\s+([\s\S]+)$/i.exec(text);
5785
+ if (answerCmd) {
5786
+ return { queryId: answerCmd[1], answer: answerCmd[2].trim(), kind: "answer" };
5787
+ }
5788
+ const decisionCmd = /^\/(approve|deny)(?:@\w+)?\b([\s\S]*)$/i.exec(text);
5789
+ if (decisionCmd) {
5790
+ const kind = decisionCmd[1].toLowerCase() === "approve" ? "approve" : "deny";
5791
+ const rest = decisionCmd[2].trim();
5792
+ const inlineId2 = QUERY_ID_RE.exec(rest)?.[1];
5793
+ const note = rest.replace(QUERY_ID_RE, "").trim();
5794
+ const answer2 = (kind === "approve" ? "Approved" : "Denied") + (note ? `: ${note}` : ".");
5795
+ return { queryId: inlineId2 ?? quotedQueryId, answer: answer2, kind };
5796
+ }
5797
+ const inlineId = QUERY_TAG_RE.exec(text)?.[1] ?? QUERY_ID_RE.exec(text)?.[1];
5798
+ const answer = text.replace(QUERY_TAG_RE, "").trim();
5799
+ if (!answer) return null;
5800
+ return { queryId: quotedQueryId ?? inlineId, answer, kind: "answer" };
5801
+ }
5802
+
5803
+ // src/gateway/manager.ts
5804
+ var GatewayManager = class {
5805
+ constructor(options) {
5806
+ this.options = options;
5807
+ this.db = options.db;
5808
+ this.stalwart = options.stalwart;
5809
+ this.accountManager = options.accountManager ?? null;
5810
+ this.encryptionKey = options.encryptionKey ?? process.env.AGENTICMAIL_MASTER_KEY ?? null;
5811
+ const inboundHandler = options.onInboundMail ?? (this.accountManager && options.localSmtp ? this.deliverInboundLocally.bind(this) : void 0);
5812
+ this.relay = new RelayGateway({
5813
+ onInboundMail: inboundHandler,
5814
+ defaultAgentName: DEFAULT_AGENT_NAME
5537
5815
  });
5538
- if (emailDns.removed.length > 0) {
5539
- console.log(`[GatewayManager] Replaced ${emailDns.removed.length} old DNS record(s) for email`);
5540
- }
5541
- await this.tunnel.start(tunnelConfig.tunnelToken);
5542
- await this.tunnel.createIngress(tunnelConfig.tunnelId, domain);
5543
- console.log("[GatewayManager] Enabling Cloudflare Email Routing...");
5544
- try {
5545
- await this.cfClient.enableEmailRouting(zone.id);
5546
- console.log("[GatewayManager] Email Routing enabled");
5547
- } catch (err) {
5548
- console.warn(`[GatewayManager] Email Routing enable failed (may already be active): ${err.message}`);
5549
- }
5550
- const workerName = `agenticmail-inbound-${domain.replace(/\./g, "-")}`;
5551
- const inboundUrl = `https://${domain}/api/agenticmail/mail/inbound`;
5552
- const inboundSecret = options.outboundSecret || crypto.randomUUID();
5553
- console.log(`[GatewayManager] Deploying Email Worker "${workerName}"...`);
5554
- console.log(`[GatewayManager] Set AGENTICMAIL_INBOUND_SECRET="${inboundSecret}" in your environment to match the worker`);
5555
5816
  try {
5556
- const { EMAIL_WORKER_SCRIPT } = await import("./email-worker-template-BOJPKCVB.js");
5557
- await this.cfClient.deployEmailWorker(workerName, EMAIL_WORKER_SCRIPT, {
5558
- INBOUND_URL: inboundUrl,
5559
- INBOUND_SECRET: inboundSecret
5560
- });
5561
- console.log(`[GatewayManager] Email Worker deployed: ${workerName}`);
5562
- } catch (err) {
5563
- console.warn(`[GatewayManager] Email Worker deployment failed: ${err.message}`);
5564
- console.warn("[GatewayManager] You may need to deploy the worker manually or check Workers permissions on your API token");
5817
+ this.loadConfig();
5818
+ } catch {
5819
+ this.config = { mode: "none" };
5565
5820
  }
5566
- console.log("[GatewayManager] Configuring catch-all Email Routing rule...");
5567
5821
  try {
5568
- await this.cfClient.setCatchAllWorkerRule(zone.id, workerName);
5569
- console.log("[GatewayManager] Catch-all rule set: all emails \u2192 Worker \u2192 AgenticMail");
5570
- } catch (err) {
5571
- console.warn(`[GatewayManager] Catch-all rule failed: ${err.message}`);
5822
+ this.smsManager = new SmsManager(options.db);
5823
+ } catch {
5572
5824
  }
5573
5825
  try {
5574
- await this.stalwart.createPrincipal({
5575
- type: "domain",
5576
- name: domain,
5577
- description: `AgenticMail gateway domain: ${domain}`
5578
- });
5826
+ this.telegramManager = new TelegramManager(options.db, this.encryptionKey ?? void 0);
5579
5827
  } catch {
5828
+ this.telegramManager = null;
5580
5829
  }
5581
- if (this.accountManager) {
5582
- try {
5583
- const agents = await this.accountManager.list();
5584
- for (const agent of agents) {
5585
- const domainEmail = `${agent.name.toLowerCase()}@${domain}`;
5586
- try {
5587
- const principal = await this.stalwart.getPrincipal(agent.stalwartPrincipal);
5588
- const emails = principal.emails ?? [];
5589
- if (!emails.includes(domainEmail)) {
5590
- await this.stalwart.addEmailAlias(agent.stalwartPrincipal, domainEmail);
5591
- console.log(`[GatewayManager] Added ${domainEmail} to Stalwart principal "${agent.stalwartPrincipal}"`);
5592
- }
5593
- } catch (err) {
5594
- console.warn(`[GatewayManager] Could not update principal for ${agent.name}: ${err.message}`);
5595
- }
5596
- }
5597
- } catch (err) {
5598
- console.warn(`[GatewayManager] Could not update agent email aliases: ${err.message}`);
5599
- }
5600
- }
5601
- const domainConfig = {
5602
- domain,
5603
- cloudflareApiToken: options.cloudflareToken,
5604
- cloudflareAccountId: options.cloudflareAccountId,
5605
- tunnelId: tunnelConfig.tunnelId,
5606
- tunnelToken: tunnelConfig.tunnelToken,
5607
- outboundWorkerUrl: options.outboundWorkerUrl,
5608
- outboundSecret: options.outboundSecret,
5609
- inboundSecret,
5610
- emailWorkerName: workerName
5611
- };
5612
- this.config = { mode: "domain", domain: domainConfig };
5613
- this.saveConfig();
5614
- this.db.prepare(`
5615
- INSERT OR REPLACE INTO purchased_domains (domain, registrar, cloudflare_zone_id, tunnel_id, dns_configured, tunnel_active)
5616
- VALUES (?, 'cloudflare', ?, ?, 1, 1)
5617
- `).run(domain, zone.id, tunnelConfig.tunnelId);
5618
- let outboundRelay;
5619
- const nextSteps = [];
5620
- if (options.gmailRelay) {
5621
- console.log("[GatewayManager] Configuring outbound relay through Gmail SMTP...");
5622
- try {
5623
- await this.stalwart.configureOutboundRelay({
5624
- smtpHost: "smtp.gmail.com",
5625
- smtpPort: 465,
5626
- username: options.gmailRelay.email,
5627
- password: options.gmailRelay.appPassword
5628
- });
5629
- outboundRelay = { configured: true, provider: "gmail" };
5630
- console.log("[GatewayManager] Outbound relay configured: all external mail routes through Gmail SMTP");
5631
- const gmailSettingsUrl = "https://mail.google.com/mail/u/0/#settings/accounts";
5632
- nextSteps.push(
5633
- `IMPORTANT: To send emails showing your domain (not ${options.gmailRelay.email}), add each agent email as a "Send mail as" alias in Gmail:`,
5634
- `1. Open: ${gmailSettingsUrl}`,
5635
- `2. Under "Send mail as" click "Add another email address"`,
5636
- `3. Enter agent name and email (e.g. "Secretary" / secretary@${domain}), uncheck "Treat as alias"`,
5637
- `4. On the SMTP screen, Gmail will auto-fill WRONG values. You MUST change them to:`,
5638
- ` SMTP Server: smtp.gmail.com | Port: 465 | Username: ${options.gmailRelay.email} | Password: [your app password] | Select "Secured connection using SSL"`,
5639
- `5. Click "Add Account". Gmail sends a verification email to the agent's @${domain} address`,
5640
- `6. Check AgenticMail inbox for the code/link from gmail-noreply@google.com, then confirm`,
5641
- `7. Repeat for each agent. Or ask your OpenClaw agent to automate this via the browser tool.`
5642
- );
5643
- } catch (err) {
5644
- outboundRelay = { configured: false, provider: "gmail" };
5645
- console.warn(`[GatewayManager] Outbound relay setup failed: ${err.message}`);
5646
- nextSteps.push(`Outbound relay setup failed: ${err.message}. You can configure it manually later.`);
5647
- }
5648
- } else {
5649
- nextSteps.push(
5650
- "Outbound email: Your server sends directly from your IP. If your IP lacks a PTR record (common for residential connections), emails may be rejected.",
5651
- 'To fix this, re-run setup with gmailRelay: { email: "you@gmail.com", appPassword: "xxxx xxxx xxxx xxxx" } to relay outbound mail through Gmail SMTP.',
5652
- "You will need a Gmail app password: https://myaccount.google.com/apppasswords"
5653
- );
5654
- }
5655
- return {
5656
- domain,
5657
- dnsConfigured: true,
5658
- tunnelId: tunnelConfig.tunnelId,
5659
- outboundRelay,
5660
- nextSteps: nextSteps.length > 0 ? nextSteps : void 0
5661
- };
5662
5830
  }
5663
- // --- Test ---
5831
+ db;
5832
+ stalwart;
5833
+ accountManager;
5834
+ relay;
5835
+ config = { mode: "none" };
5836
+ cfClient = null;
5837
+ tunnel = null;
5838
+ dnsConfigurator = null;
5839
+ domainPurchaser = null;
5840
+ smsManager = null;
5841
+ smsPollers = /* @__PURE__ */ new Map();
5842
+ telegramManager = null;
5843
+ telegramPollers = /* @__PURE__ */ new Map();
5844
+ encryptionKey = null;
5664
5845
  /**
5665
- * Send a test email through the gateway without requiring a real agent.
5666
- * In relay mode, uses "test" as the sub-address.
5667
- * In domain mode, uses the first available agent (Stalwart needs real credentials).
5846
+ * Check if a message has already been delivered to an agent (deduplication).
5668
5847
  */
5669
- async sendTestEmail(to) {
5670
- const mail = {
5671
- to,
5672
- subject: "AgenticMail Gateway Test",
5673
- text: "This is a test email sent via the AgenticMail gateway to verify your configuration is working."
5674
- };
5675
- if (this.config.mode === "relay") {
5676
- return this.routeOutbound("test", mail);
5677
- }
5678
- if (this.config.mode === "domain" && this.accountManager) {
5679
- const agents = await this.accountManager.list();
5680
- if (agents.length === 0) {
5681
- throw new Error("No agents exist yet. Create an agent first, then send a test email.");
5682
- }
5683
- const primary = agents.find((a) => a.metadata?.persistent) ?? agents[0];
5684
- return this.routeOutbound(primary.name, mail);
5685
- }
5686
- return null;
5848
+ isAlreadyDelivered(messageId, agentName) {
5849
+ if (!messageId) return false;
5850
+ const row = this.db.prepare("SELECT 1 FROM delivered_messages WHERE message_id = ? AND agent_name = ?").get(messageId, agentName);
5851
+ return !!row;
5687
5852
  }
5688
- // --- Routing ---
5689
5853
  /**
5690
- * Route an outbound email. If the destination is external and a gateway
5691
- * is configured, send via the appropriate channel.
5692
- * Returns null if the mail should be sent via local Stalwart.
5854
+ * Record that a message was delivered to an agent.
5693
5855
  */
5694
- async routeOutbound(agentName, mail) {
5695
- if (this.config.mode === "none") return null;
5696
- const collect = (field) => {
5697
- if (!field) return [];
5698
- if (Array.isArray(field)) return field;
5699
- return field.split(",").map((s) => s.trim()).filter(Boolean);
5700
- };
5701
- const allRecipients = [
5702
- ...collect(mail.to),
5703
- ...collect(mail.cc),
5704
- ...collect(mail.bcc)
5705
- ];
5706
- const localDomain = this.config.domain?.domain?.toLowerCase();
5707
- const isExternal = allRecipients.some((addr) => {
5708
- const domain = (addr.split("@")[1] ?? "localhost").toLowerCase();
5709
- return domain !== "localhost" && domain !== localDomain;
5710
- });
5711
- if (!isExternal) return null;
5712
- if (this.config.mode === "relay") {
5713
- return this.relay.sendViaRelay(agentName, mail);
5714
- }
5715
- if (this.config.mode === "domain" && this.config.domain) {
5716
- return this.sendViaStalwart(agentName, mail);
5717
- }
5718
- return null;
5856
+ recordDelivery(messageId, agentName) {
5857
+ if (!messageId) return;
5858
+ this.db.prepare("INSERT OR IGNORE INTO delivered_messages (message_id, agent_name) VALUES (?, ?)").run(messageId, agentName);
5719
5859
  }
5720
5860
  /**
5721
- * Send email by submitting to local Stalwart via SMTP (port 587).
5722
- * Stalwart handles DKIM signing and delivery (direct or via relay).
5723
- * Reply-To is set to the agent's domain email so replies come back
5724
- * to the domain (handled by Cloudflare Email Routing inbound Worker).
5861
+ * Built-in inbound mail handler: delivers relay inbound mail to agent's local Stalwart mailbox.
5862
+ * Authenticates as the agent to send to their own mailbox (Stalwart requires sender = auth user).
5863
+ *
5864
+ * Also intercepts owner replies to approval notification emails if the reply says
5865
+ * "approve" or "reject", the pending outbound email is automatically processed.
5725
5866
  */
5726
- async sendViaStalwart(agentName, mail) {
5727
- if (!this.accountManager) {
5728
- throw new Error("AccountManager required for domain mode outbound");
5729
- }
5730
- const agent = await this.accountManager.getByName(agentName);
5731
- if (!agent) {
5732
- throw new Error(`Agent "${agentName}" not found`);
5733
- }
5734
- const agentPassword = agent.metadata?._password;
5735
- if (!agentPassword) {
5736
- throw new Error(`No password for agent "${agentName}"`);
5867
+ async deliverInboundLocally(agentName, mail) {
5868
+ if (!this.accountManager || !this.options.localSmtp) {
5869
+ console.warn("[GatewayManager] Cannot deliver inbound: no accountManager or localSmtp config");
5870
+ return;
5737
5871
  }
5738
- const domainName = this.config.domain?.domain;
5739
- const fromAddr = domainName ? agent.email.replace(/@localhost$/, `@${domainName}`) : agent.email;
5740
- const displayName = mail.fromName || agentName;
5741
- const from = `${displayName} <${fromAddr}>`;
5742
- if (domainName && fromAddr !== agent.email) {
5872
+ if (mail.messageId && this.isAlreadyDelivered(mail.messageId, agentName)) return;
5873
+ if (this.smsManager) {
5743
5874
  try {
5744
- const principal = await this.stalwart.getPrincipal(agent.stalwartPrincipal);
5745
- const emails = principal.emails ?? [];
5746
- if (!emails.includes(fromAddr)) {
5747
- await this.stalwart.addEmailAlias(agent.stalwartPrincipal, fromAddr);
5748
- console.log(`[GatewayManager] Auto-added ${fromAddr} to Stalwart principal "${agent.stalwartPrincipal}"`);
5875
+ const smsBody = mail.text || mail.html || "";
5876
+ const parsedSms = parseGoogleVoiceSms(smsBody, mail.from);
5877
+ if (parsedSms) {
5878
+ const agent2 = this.accountManager ? await this.accountManager.getByName(agentName) : null;
5879
+ const agentId = agent2?.id;
5880
+ if (agentId) {
5881
+ const smsConfig = this.smsManager.getSmsConfig(agentId);
5882
+ if (smsConfig?.enabled && smsConfig.sameAsRelay) {
5883
+ this.smsManager.recordInbound(agentId, parsedSms);
5884
+ console.log(`[GatewayManager] SMS received from ${parsedSms.from}: "${parsedSms.body.slice(0, 50)}..." \u2192 agent ${agentName}`);
5885
+ if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
5886
+ }
5887
+ }
5749
5888
  }
5750
5889
  } catch (err) {
5751
- console.warn(`[GatewayManager] Could not auto-add domain email alias: ${err.message}`);
5890
+ debug("GatewayManager", `SMS detection error: ${err.message}`);
5891
+ }
5892
+ }
5893
+ try {
5894
+ await this.tryProcessApprovalReply(mail);
5895
+ } catch (err) {
5896
+ console.warn(`[GatewayManager] Approval reply check failed: ${err.message}`);
5897
+ }
5898
+ const parsed = inboundToParsedEmail(mail);
5899
+ const { isInternalEmail: isInternalEmail2 } = await import("./spam-filter-L6KNZ7QI.js");
5900
+ if (!isInternalEmail2(parsed)) {
5901
+ const spamResult = scoreEmail(parsed);
5902
+ if (spamResult.isSpam) {
5903
+ console.warn(`[GatewayManager] Spam blocked (score=${spamResult.score}, category=${spamResult.topCategory}): "${mail.subject}" from ${mail.from}`);
5904
+ if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
5905
+ return;
5906
+ }
5907
+ }
5908
+ let agent = await this.accountManager.getByName(agentName);
5909
+ if (!agent && agentName !== DEFAULT_AGENT_NAME) {
5910
+ agent = await this.accountManager.getByName(DEFAULT_AGENT_NAME);
5911
+ if (agent) {
5912
+ console.warn(`[GatewayManager] Agent "${agentName}" not found, delivering to default agent "${DEFAULT_AGENT_NAME}"`);
5752
5913
  }
5753
5914
  }
5754
- const recipients = Array.isArray(mail.to) ? mail.to : [mail.to];
5755
- const mailOpts = {
5756
- from,
5757
- to: recipients.join(", "),
5758
- cc: mail.cc ? Array.isArray(mail.cc) ? mail.cc.join(", ") : mail.cc : void 0,
5759
- bcc: mail.bcc ? Array.isArray(mail.bcc) ? mail.bcc.join(", ") : mail.bcc : void 0,
5760
- subject: mail.subject,
5761
- text: mail.text || void 0,
5762
- // The `html` field is the literal HTML body of the outbound
5763
- // mail — by design it is whatever the sender chose to compose.
5764
- // CodeQL `js/xss` flags this because the value flows from user
5765
- // input, but nodemailer is the SMTP serializer, not an HTML
5766
- // renderer; XSS would only occur if the recipient's MUA
5767
- // executed the body, which is outside our trust boundary.
5768
- // The outbound-guard (packages/core/src/mail/outbound-guard.ts)
5769
- // already scores HTML bodies for suspicious patterns at the
5770
- // pre-send step. lgtm[js/xss]
5771
- html: mail.html || void 0,
5772
- replyTo: mail.replyTo || from,
5773
- inReplyTo: mail.inReplyTo || void 0,
5774
- references: Array.isArray(mail.references) ? mail.references.join(" ") : mail.references || void 0,
5775
- headers: {
5776
- "X-Mailer": "AgenticMail/1.0"
5777
- },
5778
- attachments: mail.attachments?.map((a) => ({
5779
- filename: a.filename,
5780
- content: a.content,
5781
- contentType: a.contentType,
5782
- encoding: a.encoding
5783
- }))
5784
- };
5785
- const composer = new MailComposer3(mailOpts);
5786
- const raw = await composer.compile().build();
5787
- const smtpHost = this.options.localSmtp?.host ?? "127.0.0.1";
5788
- const smtpPort = this.options.localSmtp?.port ?? 587;
5915
+ if (!agent) {
5916
+ console.warn(`[GatewayManager] No agent to deliver inbound mail (target: "${agentName}")`);
5917
+ return;
5918
+ }
5919
+ const agentPassword = agent.metadata?._password;
5920
+ if (!agentPassword) {
5921
+ console.warn(`[GatewayManager] No password for agent "${agentName}", cannot deliver`);
5922
+ return;
5923
+ }
5789
5924
  const transport = nodemailer3.createTransport({
5790
- host: smtpHost,
5791
- port: smtpPort,
5925
+ host: this.options.localSmtp.host,
5926
+ port: this.options.localSmtp.port,
5792
5927
  secure: false,
5793
5928
  auth: {
5794
5929
  user: agent.stalwartPrincipal,
5795
5930
  pass: agentPassword
5796
5931
  },
5797
- tls: { rejectUnauthorized: false }
5932
+ tls: { rejectUnauthorized: false },
5933
+ connectionTimeout: 1e4,
5934
+ greetingTimeout: 1e4,
5935
+ socketTimeout: 15e3
5798
5936
  });
5799
5937
  try {
5800
- const info = await transport.sendMail(mailOpts);
5801
- debug("GatewayManager", `Sent via Stalwart: ${info.messageId} \u2192 ${info.response}`);
5802
- return {
5803
- messageId: info.messageId,
5804
- envelope: { from, to: recipients },
5805
- raw
5806
- };
5938
+ await transport.sendMail({
5939
+ from: `${mail.from} <${agent.email}>`,
5940
+ to: agent.email,
5941
+ subject: mail.subject,
5942
+ text: mail.text,
5943
+ html: mail.html || void 0,
5944
+ replyTo: mail.from,
5945
+ inReplyTo: mail.inReplyTo,
5946
+ references: Array.isArray(mail.references) ? mail.references.join(" ") : mail.references,
5947
+ headers: {
5948
+ "X-AgenticMail-Relay": "inbound",
5949
+ "X-Original-From": mail.from,
5950
+ ...mail.messageId ? { "X-Original-Message-Id": mail.messageId } : {}
5951
+ },
5952
+ attachments: mail.attachments?.map((a) => ({
5953
+ filename: a.filename,
5954
+ content: a.content,
5955
+ contentType: a.contentType
5956
+ }))
5957
+ });
5958
+ if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
5959
+ } catch (err) {
5960
+ console.error(`[GatewayManager] Failed to deliver to ${agent.email}: ${err.message}`);
5961
+ throw err;
5807
5962
  } finally {
5808
5963
  transport.close();
5809
5964
  }
5810
5965
  }
5811
- // --- Status ---
5812
- getStatus() {
5813
- const status = {
5814
- mode: this.config.mode,
5815
- healthy: false
5816
- };
5817
- if (this.config.mode === "relay" && this.config.relay) {
5818
- status.relay = {
5819
- provider: this.config.relay.provider,
5820
- email: this.config.relay.email,
5821
- polling: this.relay.isPolling()
5822
- };
5823
- status.healthy = this.relay.isConfigured();
5824
- }
5825
- if (this.config.mode === "domain" && this.config.domain) {
5826
- const tunnelStatus = this.tunnel?.status();
5827
- status.domain = {
5828
- domain: this.config.domain.domain,
5829
- dnsConfigured: true,
5830
- tunnelActive: tunnelStatus?.running ?? false
5831
- };
5832
- status.healthy = tunnelStatus?.running ?? false;
5833
- }
5834
- if (this.config.mode === "none") {
5835
- status.healthy = true;
5836
- }
5837
- return status;
5838
- }
5839
- getMode() {
5840
- return this.config.mode;
5841
- }
5842
- getConfig() {
5843
- return this.config;
5844
- }
5845
- // --- Domain Purchase ---
5846
- getStalwart() {
5847
- return this.stalwart;
5848
- }
5849
- getDomainPurchaser() {
5850
- return this.domainPurchaser;
5851
- }
5852
- getDNSConfigurator() {
5853
- return this.dnsConfigurator;
5854
- }
5855
- getTunnelManager() {
5856
- return this.tunnel;
5857
- }
5858
- getRelay() {
5859
- return this.relay;
5860
- }
5861
- /**
5862
- * Search the connected relay account (Gmail/Outlook) for emails matching criteria.
5863
- * Returns empty array if relay is not configured.
5864
- */
5865
- async searchRelay(criteria, maxResults = 50) {
5866
- if (this.config.mode !== "relay" || !this.relay.isConfigured()) return [];
5867
- return this.relay.searchRelay(criteria, maxResults);
5868
- }
5869
5966
  /**
5870
- * Import an email from the connected relay account into an agent's local inbox.
5871
- * Fetches the full message from relay IMAP and delivers it locally, preserving
5872
- * all headers (Message-ID, In-Reply-To, References) for thread continuity.
5967
+ * Check if an inbound email is a reply to a pending approval notification.
5968
+ * If the reply body starts with "approve"/"yes" or "reject"/"no", automatically
5969
+ * process the pending email (send it or discard it) and confirm to the owner.
5873
5970
  */
5874
- async importRelayMessage(relayUid, agentName) {
5875
- if (this.config.mode !== "relay" || !this.relay.isConfigured()) {
5876
- return { success: false, error: "Relay not configured" };
5971
+ async tryProcessApprovalReply(mail) {
5972
+ const candidateIds = [];
5973
+ if (mail.inReplyTo) candidateIds.push(mail.inReplyTo);
5974
+ if (mail.references) candidateIds.push(...mail.references);
5975
+ if (candidateIds.length === 0) return false;
5976
+ let row = null;
5977
+ for (const id of candidateIds) {
5978
+ row = this.db.prepare(
5979
+ `SELECT * FROM pending_outbound WHERE notification_message_id = ? AND status = 'pending'`
5980
+ ).get(id);
5981
+ if (row) break;
5877
5982
  }
5878
- const mail = await this.relay.fetchRelayMessage(relayUid);
5879
- if (!mail) {
5880
- return { success: false, error: "Could not fetch message from relay account" };
5983
+ if (!row) return false;
5984
+ const body = (mail.text || "").trim();
5985
+ const lines = body.split("\n").filter((l) => !l.startsWith(">") && l.trim().length > 0);
5986
+ const firstLine = (lines[0] || "").trim().toLowerCase();
5987
+ const approvePattern = /^(approve[d]?|yes|send\s*it|send|go\s*ahead|lgtm|ok(?:ay)?)\b/;
5988
+ const rejectPattern = /^(reject(?:ed)?|no|den(?:y|ied)|don'?t\s*send|do\s*not\s*send|cancel|block(?:ed)?)\b/;
5989
+ let action = null;
5990
+ if (approvePattern.test(firstLine)) {
5991
+ action = "approve";
5992
+ } else if (rejectPattern.test(firstLine)) {
5993
+ action = "reject";
5881
5994
  }
5995
+ if (!action) return false;
5882
5996
  try {
5883
- await this.deliverInboundLocally(agentName, mail);
5884
- return { success: true };
5997
+ if (action === "approve") {
5998
+ await this.executeApproval(row);
5999
+ } else {
6000
+ this.db.prepare(
6001
+ `UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = 'owner-reply' WHERE id = ?`
6002
+ ).run(row.id);
6003
+ }
6004
+ await this.sendApprovalConfirmation(row, action, mail.messageId);
5885
6005
  } catch (err) {
5886
- return { success: false, error: err instanceof Error ? err.message : String(err) };
6006
+ console.error(`[GatewayManager] Failed to process approval reply for ${row.id}:`, err);
5887
6007
  }
6008
+ return true;
5888
6009
  }
5889
- // --- SMS Polling ---
5890
6010
  /**
5891
- * Start SMS pollers for all agents that have separate GV Gmail credentials.
5892
- * Agents with sameAsRelay=true are handled in deliverInboundLocally.
6011
+ * Execute approval of a pending outbound email: look up the agent, reconstitute
6012
+ * attachments, and send the email via gateway routing or local SMTP.
5893
6013
  */
5894
- async startSmsPollers() {
5895
- if (!this.smsManager || !this.accountManager) return;
5896
- const agents = this.db.prepare("SELECT id, name, metadata FROM agents").all();
5897
- for (const agent of agents) {
6014
+ async executeApproval(row) {
6015
+ if (!this.accountManager) {
6016
+ throw new Error("AccountManager required for approval processing");
6017
+ }
6018
+ const agent = await this.accountManager.getById(row.agent_id);
6019
+ if (!agent) {
6020
+ console.warn(`[GatewayManager] Cannot approve pending ${row.id}: agent ${row.agent_id} no longer exists`);
6021
+ this.db.prepare(
6022
+ `UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = 'owner-reply', error = 'Agent no longer exists' WHERE id = ?`
6023
+ ).run(row.id);
6024
+ return;
6025
+ }
6026
+ const mailOpts = JSON.parse(row.mail_options);
6027
+ const ownerName = agent.metadata?.ownerName;
6028
+ mailOpts.fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
6029
+ if (Array.isArray(mailOpts.attachments)) {
6030
+ for (const att of mailOpts.attachments) {
6031
+ if (att.content && typeof att.content === "object" && att.content.type === "Buffer" && Array.isArray(att.content.data)) {
6032
+ att.content = Buffer.from(att.content.data);
6033
+ }
6034
+ }
6035
+ }
6036
+ const gatewayResult = await this.routeOutbound(agent.name, mailOpts);
6037
+ if (!gatewayResult && this.options.localSmtp) {
6038
+ const agentPassword = agent.metadata?._password;
6039
+ if (!agentPassword) {
6040
+ throw new Error(`No password for agent "${agent.name}"`);
6041
+ }
6042
+ const transport = nodemailer3.createTransport({
6043
+ host: this.options.localSmtp.host,
6044
+ port: this.options.localSmtp.port,
6045
+ secure: false,
6046
+ auth: {
6047
+ user: agent.stalwartPrincipal,
6048
+ pass: agentPassword
6049
+ },
6050
+ tls: { rejectUnauthorized: false }
6051
+ });
5898
6052
  try {
5899
- const meta = JSON.parse(agent.metadata || "{}");
5900
- const smsConfig = meta.sms;
5901
- if (!smsConfig?.enabled || !smsConfig.forwardingPassword || smsConfig.sameAsRelay) continue;
5902
- const poller = new SmsPoller(this.smsManager, agent.id, smsConfig);
5903
- poller.onSmsReceived = (agentId, sms) => {
5904
- console.log(`[SmsPoller] SMS received for agent ${agent.name}: from ${sms.from}, body="${sms.body.slice(0, 50)}..."`);
5905
- };
5906
- this.smsPollers.set(agent.id, poller);
5907
- await poller.startPolling();
5908
- console.log(`[GatewayManager] SMS poller started for agent "${agent.name}" (${smsConfig.forwardingEmail})`);
5909
- } catch {
6053
+ await transport.sendMail({
6054
+ from: mailOpts.fromName ? `${mailOpts.fromName} <${agent.email}>` : agent.email,
6055
+ to: Array.isArray(mailOpts.to) ? mailOpts.to.join(", ") : mailOpts.to,
6056
+ subject: mailOpts.subject,
6057
+ text: mailOpts.text || void 0,
6058
+ html: mailOpts.html || void 0,
6059
+ cc: mailOpts.cc || void 0,
6060
+ bcc: mailOpts.bcc || void 0,
6061
+ replyTo: mailOpts.replyTo || void 0,
6062
+ inReplyTo: mailOpts.inReplyTo || void 0,
6063
+ references: Array.isArray(mailOpts.references) ? mailOpts.references.join(" ") : mailOpts.references || void 0,
6064
+ attachments: mailOpts.attachments?.map((a) => ({
6065
+ filename: a.filename,
6066
+ content: a.content,
6067
+ contentType: a.contentType,
6068
+ encoding: a.encoding
6069
+ }))
6070
+ });
6071
+ } finally {
6072
+ transport.close();
5910
6073
  }
5911
6074
  }
5912
- }
5913
- // --- Lifecycle ---
5914
- async shutdown() {
5915
- await this.relay.shutdown();
5916
- this.tunnel?.stop();
5917
- for (const poller of this.smsPollers.values()) {
5918
- poller.stopPolling();
5919
- }
5920
- this.smsPollers.clear();
6075
+ this.db.prepare(
6076
+ `UPDATE pending_outbound SET status = 'approved', resolved_at = datetime('now'), resolved_by = 'owner-reply' WHERE id = ?`
6077
+ ).run(row.id);
5921
6078
  }
5922
6079
  /**
5923
- * Resume gateway from saved config (e.g., after server restart).
5924
- *
5925
- * Issue #31 — On a Docker container restart the API can come up
5926
- * before Stalwart / Gmail IMAP / DNS is reachable, so the very first
5927
- * setup() can fail with a transient network error. Previously that
5928
- * single failure was logged and never retried, leaving polling
5929
- * permanently dead until someone noticed and manually revived the
5930
- * relay. We now schedule background retries with exponential backoff
5931
- * (5s, 10s, 20s, 40s, 60s cap, indefinite) so the relay
5932
- * self-recovers as soon as the dependency is reachable again.
6080
+ * Send a confirmation email back to the owner after processing an approval reply.
5933
6081
  */
5934
- async resume() {
5935
- if (this.config.mode === "relay" && this.config.relay) {
5936
- try {
5937
- await this._resumeRelayOnce();
5938
- } catch (err) {
5939
- console.error("[GatewayManager] Initial relay resume failed; scheduling retries:", formatPollError(err));
5940
- this._scheduleRelayResumeRetry();
6082
+ async sendApprovalConfirmation(row, action, replyMessageId) {
6083
+ const ownerEmail = this.config.relay?.email;
6084
+ if (!ownerEmail || !this.accountManager) return;
6085
+ const mailOpts = JSON.parse(row.mail_options);
6086
+ const agent = await this.accountManager.getById(row.agent_id);
6087
+ const agentName = agent?.name || "unknown agent";
6088
+ const statusText = action === "approve" ? "APPROVED and sent" : "REJECTED and discarded";
6089
+ this.routeOutbound(agentName, {
6090
+ to: ownerEmail,
6091
+ subject: `Re: [Approval Required] Blocked email from "${agentName}" \u2014 ${statusText}`,
6092
+ text: [
6093
+ `The blocked email has been ${statusText}.`,
6094
+ "",
6095
+ ` To: ${Array.isArray(mailOpts.to) ? mailOpts.to.join(", ") : mailOpts.to}`,
6096
+ ` Subject: ${mailOpts.subject}`,
6097
+ "",
6098
+ action === "approve" ? "The email has been delivered to the recipient." : "The email has been discarded and will not be sent."
6099
+ ].join("\n"),
6100
+ fromName: "Agentic Mail",
6101
+ inReplyTo: replyMessageId
6102
+ }).catch((err) => {
6103
+ console.warn(`[GatewayManager] Failed to send approval confirmation: ${err.message}`);
6104
+ });
6105
+ }
6106
+ // --- Relay Mode ---
6107
+ async setupRelay(config, options) {
6108
+ await this.relay.setup(config);
6109
+ this.config = { mode: "relay", relay: config };
6110
+ this.saveConfig();
6111
+ let agent;
6112
+ if (!options?.skipDefaultAgent && this.accountManager) {
6113
+ const agentName = options?.defaultAgentName ?? DEFAULT_AGENT_NAME;
6114
+ const agentRole = options?.defaultAgentRole ?? DEFAULT_AGENT_ROLE;
6115
+ const existing = await this.accountManager.getByName(agentName);
6116
+ if (existing) {
6117
+ agent = existing;
6118
+ } else {
6119
+ agent = await this.accountManager.create({
6120
+ name: agentName,
6121
+ role: agentRole,
6122
+ gateway: "relay"
6123
+ });
5941
6124
  }
5942
6125
  }
5943
- if (this.smsManager && this.accountManager) {
5944
- try {
5945
- await this.startSmsPollers();
5946
- } catch (err) {
5947
- console.error("[GatewayManager] Failed to start SMS pollers:", err);
6126
+ this.relay.onUidAdvance = (uid) => this.saveLastSeenUid(uid);
6127
+ await this.relay.startPolling();
6128
+ return { agent };
6129
+ }
6130
+ // --- Domain Mode ---
6131
+ async setupDomain(options) {
6132
+ this.cfClient = new CloudflareClient(options.cloudflareToken, options.cloudflareAccountId);
6133
+ this.dnsConfigurator = new DNSConfigurator(this.cfClient);
6134
+ this.tunnel = new TunnelManager(this.cfClient);
6135
+ this.domainPurchaser = new DomainPurchaser(this.cfClient);
6136
+ let domain = options.domain;
6137
+ if (!domain && options.purchase) {
6138
+ const available = await this.domainPurchaser.searchAvailable(
6139
+ options.purchase.keywords,
6140
+ options.purchase.tld ? [options.purchase.tld] : void 0
6141
+ );
6142
+ const first = available.find((d) => d.available && !d.premium);
6143
+ if (!first) {
6144
+ throw new Error("No available domains found for the given keywords");
5948
6145
  }
6146
+ await this.domainPurchaser.purchase(first.domain);
6147
+ domain = first.domain;
6148
+ this.db.prepare(`
6149
+ INSERT OR REPLACE INTO purchased_domains (domain, registrar) VALUES (?, ?)
6150
+ `).run(domain, "cloudflare");
5949
6151
  }
5950
- if (this.config.mode === "domain" && this.config.domain) {
5951
- try {
5952
- this.cfClient = new CloudflareClient(
5953
- this.config.domain.cloudflareApiToken,
5954
- this.config.domain.cloudflareAccountId
5955
- );
5956
- this.dnsConfigurator = new DNSConfigurator(this.cfClient);
5957
- this.tunnel = new TunnelManager(this.cfClient);
5958
- this.domainPurchaser = new DomainPurchaser(this.cfClient);
5959
- if (this.config.domain.tunnelToken) {
5960
- await this.tunnel.start(this.config.domain.tunnelToken);
5961
- }
5962
- } catch (err) {
5963
- console.error("[GatewayManager] Failed to resume domain mode:", err);
6152
+ if (!domain) {
6153
+ throw new Error("No domain specified and no purchase keywords provided");
6154
+ }
6155
+ let zone = await this.cfClient.getZone(domain);
6156
+ if (!zone) {
6157
+ zone = await this.cfClient.createZone(domain);
6158
+ }
6159
+ const existingRecords = await this.cfClient.listDnsRecords(zone.id);
6160
+ const { homedir: homedir13 } = await import("os");
6161
+ const backupDir = join4(homedir13(), ".agenticmail");
6162
+ const backupPath = join4(backupDir, `dns-backup-${domain}-${Date.now()}.json`);
6163
+ const { writeFileSync: writeFileSync11, mkdirSync: mkdirSync12 } = await import("fs");
6164
+ mkdirSync12(backupDir, { recursive: true });
6165
+ writeFileSync11(backupPath, JSON.stringify({
6166
+ domain,
6167
+ zoneId: zone.id,
6168
+ backedUpAt: (/* @__PURE__ */ new Date()).toISOString(),
6169
+ records: existingRecords
6170
+ }, null, 2));
6171
+ console.log(`[GatewayManager] DNS backup saved to ${backupPath} (${existingRecords.length} records)`);
6172
+ const rootRecords = existingRecords.filter(
6173
+ (r) => r.name === domain && (r.type === "A" || r.type === "AAAA" || r.type === "CNAME" || r.type === "MX")
6174
+ );
6175
+ if (rootRecords.length > 0) {
6176
+ console.warn(`[GatewayManager] \u26A0\uFE0F WARNING: ${rootRecords.length} existing root DNS record(s) for ${domain} will be modified:`);
6177
+ for (const r of rootRecords) {
6178
+ console.warn(`[GatewayManager] ${r.type} ${r.name} \u2192 ${r.content}`);
5964
6179
  }
6180
+ console.warn(`[GatewayManager] Backup saved at: ${backupPath}`);
5965
6181
  }
5966
- }
5967
- // ─── Issue #31 helpers — resume retry with backoff ───
5968
- _resumeRetryTimer = null;
5969
- _resumeRetryAttempt = 0;
5970
- async _resumeRelayOnce() {
5971
- if (!this.config.relay) throw new Error("No relay config to resume");
5972
- await this.relay.setup(this.config.relay);
5973
- const savedUid = this.loadLastSeenUid();
5974
- if (savedUid > 0) {
5975
- this.relay.setLastSeenUid(savedUid);
5976
- console.log(`[GatewayManager] Restored lastSeenUid=${savedUid} from database`);
6182
+ const tunnelConfig = await this.tunnel.create(`agenticmail-${domain}`);
6183
+ console.log(`[GatewayManager] Configuring mail server hostname: ${domain}`);
6184
+ try {
6185
+ await this.stalwart.setHostname(domain);
6186
+ console.log(`[GatewayManager] Mail server hostname set to ${domain}`);
6187
+ } catch (err) {
6188
+ console.warn(`[GatewayManager] Failed to set hostname (EHLO may show "localhost"): ${err.message}`);
5977
6189
  }
5978
- this.relay.onUidAdvance = (uid) => this.saveLastSeenUid(uid);
5979
- await this.relay.startPolling();
5980
- if (this._resumeRetryAttempt > 0) {
5981
- console.log(`[GatewayManager] Relay polling resumed after ${this._resumeRetryAttempt} retry attempt${this._resumeRetryAttempt !== 1 ? "s" : ""}`);
6190
+ console.log("[GatewayManager] Setting up DKIM signing...");
6191
+ let dkimPublicKey;
6192
+ let dkimSelector = "agenticmail";
6193
+ try {
6194
+ const dkim = await this.stalwart.createDkimSignature(domain, dkimSelector);
6195
+ dkimPublicKey = dkim.publicKey;
6196
+ console.log(`[GatewayManager] DKIM signature created (selector: ${dkimSelector})`);
6197
+ } catch (err) {
6198
+ console.warn(`[GatewayManager] DKIM setup failed (email may land in spam): ${err.message}`);
5982
6199
  }
5983
- this._resumeRetryAttempt = 0;
5984
- }
5985
- _scheduleRelayResumeRetry() {
5986
- if (this._resumeRetryTimer) return;
5987
- this._resumeRetryAttempt++;
5988
- const base = Math.min(5e3 * Math.pow(2, this._resumeRetryAttempt - 1), 6e4);
5989
- const jitter = base * (0.8 + Math.random() * 0.4);
5990
- const delay = Math.round(jitter);
5991
- console.log(`[GatewayManager] Will retry relay resume in ${(delay / 1e3).toFixed(1)}s (attempt ${this._resumeRetryAttempt + 1})`);
5992
- this._resumeRetryTimer = setTimeout(async () => {
5993
- this._resumeRetryTimer = null;
5994
- if (this.config.mode !== "relay" || !this.config.relay) return;
5995
- try {
5996
- await this._resumeRelayOnce();
5997
- } catch (err) {
5998
- console.error(`[GatewayManager] Relay resume retry ${this._resumeRetryAttempt} failed:`, formatPollError(err));
5999
- this._scheduleRelayResumeRetry();
6000
- }
6001
- }, delay);
6002
- }
6003
- // --- Persistence ---
6004
- loadConfig() {
6005
- const row = this.db.prepare("SELECT * FROM gateway_config WHERE id = ?").get("default");
6006
- if (row) {
6200
+ const tunnelRemoved = await this.dnsConfigurator.configureForTunnel(domain, zone.id, tunnelConfig.tunnelId);
6201
+ if (tunnelRemoved.length > 0) {
6202
+ console.log(`[GatewayManager] Removed ${tunnelRemoved.length} conflicting DNS record(s) for tunnel`);
6203
+ }
6204
+ const emailDns = await this.dnsConfigurator.configureForEmail(domain, zone.id, {
6205
+ dkimSelector,
6206
+ dkimPublicKey
6207
+ });
6208
+ if (emailDns.removed.length > 0) {
6209
+ console.log(`[GatewayManager] Replaced ${emailDns.removed.length} old DNS record(s) for email`);
6210
+ }
6211
+ await this.tunnel.start(tunnelConfig.tunnelToken);
6212
+ await this.tunnel.createIngress(tunnelConfig.tunnelId, domain);
6213
+ console.log("[GatewayManager] Enabling Cloudflare Email Routing...");
6214
+ try {
6215
+ await this.cfClient.enableEmailRouting(zone.id);
6216
+ console.log("[GatewayManager] Email Routing enabled");
6217
+ } catch (err) {
6218
+ console.warn(`[GatewayManager] Email Routing enable failed (may already be active): ${err.message}`);
6219
+ }
6220
+ const workerName = `agenticmail-inbound-${domain.replace(/\./g, "-")}`;
6221
+ const inboundUrl = `https://${domain}/api/agenticmail/mail/inbound`;
6222
+ const inboundSecret = options.outboundSecret || crypto.randomUUID();
6223
+ console.log(`[GatewayManager] Deploying Email Worker "${workerName}"...`);
6224
+ console.log(`[GatewayManager] Set AGENTICMAIL_INBOUND_SECRET="${inboundSecret}" in your environment to match the worker`);
6225
+ try {
6226
+ const { EMAIL_WORKER_SCRIPT } = await import("./email-worker-template-BOJPKCVB.js");
6227
+ await this.cfClient.deployEmailWorker(workerName, EMAIL_WORKER_SCRIPT, {
6228
+ INBOUND_URL: inboundUrl,
6229
+ INBOUND_SECRET: inboundSecret
6230
+ });
6231
+ console.log(`[GatewayManager] Email Worker deployed: ${workerName}`);
6232
+ } catch (err) {
6233
+ console.warn(`[GatewayManager] Email Worker deployment failed: ${err.message}`);
6234
+ console.warn("[GatewayManager] You may need to deploy the worker manually or check Workers permissions on your API token");
6235
+ }
6236
+ console.log("[GatewayManager] Configuring catch-all Email Routing rule...");
6237
+ try {
6238
+ await this.cfClient.setCatchAllWorkerRule(zone.id, workerName);
6239
+ console.log("[GatewayManager] Catch-all rule set: all emails \u2192 Worker \u2192 AgenticMail");
6240
+ } catch (err) {
6241
+ console.warn(`[GatewayManager] Catch-all rule failed: ${err.message}`);
6242
+ }
6243
+ try {
6244
+ await this.stalwart.createPrincipal({
6245
+ type: "domain",
6246
+ name: domain,
6247
+ description: `AgenticMail gateway domain: ${domain}`
6248
+ });
6249
+ } catch {
6250
+ }
6251
+ if (this.accountManager) {
6007
6252
  try {
6008
- const parsed = JSON.parse(row.config);
6009
- if (this.encryptionKey) {
6010
- if (parsed.relay?.password) {
6011
- try {
6012
- parsed.relay.password = decryptSecret(parsed.relay.password, this.encryptionKey);
6013
- } catch {
6014
- }
6015
- }
6016
- if (parsed.relay?.appPassword) {
6017
- try {
6018
- parsed.relay.appPassword = decryptSecret(parsed.relay.appPassword, this.encryptionKey);
6019
- } catch {
6020
- }
6021
- }
6022
- if (parsed.domain?.cloudflareApiToken) {
6023
- try {
6024
- parsed.domain.cloudflareApiToken = decryptSecret(parsed.domain.cloudflareApiToken, this.encryptionKey);
6025
- } catch {
6026
- }
6027
- }
6028
- if (parsed.domain?.tunnelToken) {
6029
- try {
6030
- parsed.domain.tunnelToken = decryptSecret(parsed.domain.tunnelToken, this.encryptionKey);
6031
- } catch {
6032
- }
6033
- }
6034
- if (parsed.domain?.inboundSecret) {
6035
- try {
6036
- parsed.domain.inboundSecret = decryptSecret(parsed.domain.inboundSecret, this.encryptionKey);
6037
- } catch {
6038
- }
6039
- }
6040
- if (parsed.domain?.outboundSecret) {
6041
- try {
6042
- parsed.domain.outboundSecret = decryptSecret(parsed.domain.outboundSecret, this.encryptionKey);
6043
- } catch {
6253
+ const agents = await this.accountManager.list();
6254
+ for (const agent of agents) {
6255
+ const domainEmail = `${agent.name.toLowerCase()}@${domain}`;
6256
+ try {
6257
+ const principal = await this.stalwart.getPrincipal(agent.stalwartPrincipal);
6258
+ const emails = principal.emails ?? [];
6259
+ if (!emails.includes(domainEmail)) {
6260
+ await this.stalwart.addEmailAlias(agent.stalwartPrincipal, domainEmail);
6261
+ console.log(`[GatewayManager] Added ${domainEmail} to Stalwart principal "${agent.stalwartPrincipal}"`);
6044
6262
  }
6263
+ } catch (err) {
6264
+ console.warn(`[GatewayManager] Could not update principal for ${agent.name}: ${err.message}`);
6045
6265
  }
6046
6266
  }
6047
- this.config = {
6048
- mode: row.mode,
6049
- ...parsed
6050
- };
6051
- } catch {
6052
- this.config = { mode: "none" };
6267
+ } catch (err) {
6268
+ console.warn(`[GatewayManager] Could not update agent email aliases: ${err.message}`);
6053
6269
  }
6054
6270
  }
6055
- }
6056
- saveConfig() {
6057
- const { mode, ...rest } = this.config;
6058
- const toStore = JSON.parse(JSON.stringify(rest));
6059
- if (this.encryptionKey) {
6060
- if (toStore.relay?.password) {
6061
- toStore.relay.password = encryptSecret(toStore.relay.password, this.encryptionKey);
6062
- }
6063
- if (toStore.relay?.appPassword) {
6064
- toStore.relay.appPassword = encryptSecret(toStore.relay.appPassword, this.encryptionKey);
6065
- }
6066
- if (toStore.domain?.cloudflareApiToken) {
6067
- toStore.domain.cloudflareApiToken = encryptSecret(toStore.domain.cloudflareApiToken, this.encryptionKey);
6068
- }
6069
- if (toStore.domain?.tunnelToken) {
6070
- toStore.domain.tunnelToken = encryptSecret(toStore.domain.tunnelToken, this.encryptionKey);
6071
- }
6072
- if (toStore.domain?.inboundSecret) {
6073
- toStore.domain.inboundSecret = encryptSecret(toStore.domain.inboundSecret, this.encryptionKey);
6074
- }
6075
- if (toStore.domain?.outboundSecret) {
6076
- toStore.domain.outboundSecret = encryptSecret(toStore.domain.outboundSecret, this.encryptionKey);
6271
+ const domainConfig = {
6272
+ domain,
6273
+ cloudflareApiToken: options.cloudflareToken,
6274
+ cloudflareAccountId: options.cloudflareAccountId,
6275
+ tunnelId: tunnelConfig.tunnelId,
6276
+ tunnelToken: tunnelConfig.tunnelToken,
6277
+ outboundWorkerUrl: options.outboundWorkerUrl,
6278
+ outboundSecret: options.outboundSecret,
6279
+ inboundSecret,
6280
+ emailWorkerName: workerName
6281
+ };
6282
+ this.config = { mode: "domain", domain: domainConfig };
6283
+ this.saveConfig();
6284
+ this.db.prepare(`
6285
+ INSERT OR REPLACE INTO purchased_domains (domain, registrar, cloudflare_zone_id, tunnel_id, dns_configured, tunnel_active)
6286
+ VALUES (?, 'cloudflare', ?, ?, 1, 1)
6287
+ `).run(domain, zone.id, tunnelConfig.tunnelId);
6288
+ let outboundRelay;
6289
+ const nextSteps = [];
6290
+ if (options.gmailRelay) {
6291
+ console.log("[GatewayManager] Configuring outbound relay through Gmail SMTP...");
6292
+ try {
6293
+ await this.stalwart.configureOutboundRelay({
6294
+ smtpHost: "smtp.gmail.com",
6295
+ smtpPort: 465,
6296
+ username: options.gmailRelay.email,
6297
+ password: options.gmailRelay.appPassword
6298
+ });
6299
+ outboundRelay = { configured: true, provider: "gmail" };
6300
+ console.log("[GatewayManager] Outbound relay configured: all external mail routes through Gmail SMTP");
6301
+ const gmailSettingsUrl = "https://mail.google.com/mail/u/0/#settings/accounts";
6302
+ nextSteps.push(
6303
+ `IMPORTANT: To send emails showing your domain (not ${options.gmailRelay.email}), add each agent email as a "Send mail as" alias in Gmail:`,
6304
+ `1. Open: ${gmailSettingsUrl}`,
6305
+ `2. Under "Send mail as" click "Add another email address"`,
6306
+ `3. Enter agent name and email (e.g. "Secretary" / secretary@${domain}), uncheck "Treat as alias"`,
6307
+ `4. On the SMTP screen, Gmail will auto-fill WRONG values. You MUST change them to:`,
6308
+ ` SMTP Server: smtp.gmail.com | Port: 465 | Username: ${options.gmailRelay.email} | Password: [your app password] | Select "Secured connection using SSL"`,
6309
+ `5. Click "Add Account". Gmail sends a verification email to the agent's @${domain} address`,
6310
+ `6. Check AgenticMail inbox for the code/link from gmail-noreply@google.com, then confirm`,
6311
+ `7. Repeat for each agent. Or ask your OpenClaw agent to automate this via the browser tool.`
6312
+ );
6313
+ } catch (err) {
6314
+ outboundRelay = { configured: false, provider: "gmail" };
6315
+ console.warn(`[GatewayManager] Outbound relay setup failed: ${err.message}`);
6316
+ nextSteps.push(`Outbound relay setup failed: ${err.message}. You can configure it manually later.`);
6077
6317
  }
6318
+ } else {
6319
+ nextSteps.push(
6320
+ "Outbound email: Your server sends directly from your IP. If your IP lacks a PTR record (common for residential connections), emails may be rejected.",
6321
+ 'To fix this, re-run setup with gmailRelay: { email: "you@gmail.com", appPassword: "xxxx xxxx xxxx xxxx" } to relay outbound mail through Gmail SMTP.',
6322
+ "You will need a Gmail app password: https://myaccount.google.com/apppasswords"
6323
+ );
6078
6324
  }
6079
- this.db.prepare(`
6080
- INSERT OR REPLACE INTO gateway_config (id, mode, config)
6081
- VALUES ('default', ?, ?)
6082
- `).run(mode, JSON.stringify(toStore));
6083
- }
6084
- saveLastSeenUid(uid) {
6085
- this.db.prepare(`
6086
- INSERT OR REPLACE INTO config (key, value) VALUES ('relay_last_seen_uid', ?)
6087
- `).run(String(uid));
6088
- }
6089
- loadLastSeenUid() {
6090
- const row = this.db.prepare("SELECT value FROM config WHERE key = ?").get("relay_last_seen_uid");
6091
- return row ? parseInt(row.value, 10) || 0 : 0;
6092
- }
6093
- };
6094
- function parseAddressString(addr) {
6095
- const match = addr.match(/^(.+?)\s*<([^>]+)>$/);
6096
- if (match) {
6097
- return { name: match[1].trim(), address: match[2].trim() };
6325
+ return {
6326
+ domain,
6327
+ dnsConfigured: true,
6328
+ tunnelId: tunnelConfig.tunnelId,
6329
+ outboundRelay,
6330
+ nextSteps: nextSteps.length > 0 ? nextSteps : void 0
6331
+ };
6098
6332
  }
6099
- return { address: addr.trim() };
6100
- }
6101
- function inboundToParsedEmail(mail) {
6102
- return {
6103
- messageId: mail.messageId || "",
6104
- subject: mail.subject || "",
6105
- from: [parseAddressString(mail.from)],
6106
- to: [parseAddressString(mail.to)],
6107
- date: mail.date || /* @__PURE__ */ new Date(),
6108
- text: mail.text,
6109
- html: mail.html,
6110
- inReplyTo: mail.inReplyTo,
6111
- references: mail.references,
6112
- attachments: (mail.attachments ?? []).map((a) => ({
6113
- filename: a.filename,
6114
- contentType: a.contentType,
6115
- size: a.size,
6116
- content: a.content
6117
- })),
6118
- headers: /* @__PURE__ */ new Map()
6119
- };
6120
- }
6121
-
6122
- // src/gateway/relay-bridge.ts
6123
- import { createServer } from "http";
6124
- import { createTransport } from "nodemailer";
6125
- var RelayBridge = class {
6126
- server = null;
6127
- options;
6128
- constructor(options) {
6129
- this.options = options;
6333
+ // --- Test ---
6334
+ /**
6335
+ * Send a test email through the gateway without requiring a real agent.
6336
+ * In relay mode, uses "test" as the sub-address.
6337
+ * In domain mode, uses the first available agent (Stalwart needs real credentials).
6338
+ */
6339
+ async sendTestEmail(to) {
6340
+ const mail = {
6341
+ to,
6342
+ subject: "AgenticMail Gateway Test",
6343
+ text: "This is a test email sent via the AgenticMail gateway to verify your configuration is working."
6344
+ };
6345
+ if (this.config.mode === "relay") {
6346
+ return this.routeOutbound("test", mail);
6347
+ }
6348
+ if (this.config.mode === "domain" && this.accountManager) {
6349
+ const agents = await this.accountManager.list();
6350
+ if (agents.length === 0) {
6351
+ throw new Error("No agents exist yet. Create an agent first, then send a test email.");
6352
+ }
6353
+ const primary = agents.find((a) => a.metadata?.persistent) ?? agents[0];
6354
+ return this.routeOutbound(primary.name, mail);
6355
+ }
6356
+ return null;
6130
6357
  }
6131
- async start() {
6132
- return new Promise((resolve2, reject) => {
6133
- this.server = createServer((req, res) => this.handleRequest(req, res));
6134
- this.server.listen(this.options.port, "127.0.0.1", () => {
6135
- console.log(`[RelayBridge] Listening on 127.0.0.1:${this.options.port}`);
6136
- resolve2();
6137
- });
6138
- this.server.on("error", reject);
6358
+ // --- Routing ---
6359
+ /**
6360
+ * Route an outbound email. If the destination is external and a gateway
6361
+ * is configured, send via the appropriate channel.
6362
+ * Returns null if the mail should be sent via local Stalwart.
6363
+ */
6364
+ async routeOutbound(agentName, mail) {
6365
+ if (this.config.mode === "none") return null;
6366
+ const collect = (field) => {
6367
+ if (!field) return [];
6368
+ if (Array.isArray(field)) return field;
6369
+ return field.split(",").map((s) => s.trim()).filter(Boolean);
6370
+ };
6371
+ const allRecipients = [
6372
+ ...collect(mail.to),
6373
+ ...collect(mail.cc),
6374
+ ...collect(mail.bcc)
6375
+ ];
6376
+ const localDomain = this.config.domain?.domain?.toLowerCase();
6377
+ const isExternal = allRecipients.some((addr) => {
6378
+ const domain = (addr.split("@")[1] ?? "localhost").toLowerCase();
6379
+ return domain !== "localhost" && domain !== localDomain;
6139
6380
  });
6381
+ if (!isExternal) return null;
6382
+ if (this.config.mode === "relay") {
6383
+ return this.relay.sendViaRelay(agentName, mail);
6384
+ }
6385
+ if (this.config.mode === "domain" && this.config.domain) {
6386
+ return this.sendViaStalwart(agentName, mail);
6387
+ }
6388
+ return null;
6140
6389
  }
6141
- stop() {
6142
- this.server?.close();
6143
- this.server = null;
6144
- }
6145
- async handleRequest(req, res) {
6146
- if (req.method !== "POST" || req.url !== "/send") {
6147
- res.writeHead(404, { "Content-Type": "application/json" });
6148
- res.end(JSON.stringify({ error: "Not found" }));
6149
- return;
6390
+ /**
6391
+ * Send email by submitting to local Stalwart via SMTP (port 587).
6392
+ * Stalwart handles DKIM signing and delivery (direct or via relay).
6393
+ * Reply-To is set to the agent's domain email so replies come back
6394
+ * to the domain (handled by Cloudflare Email Routing → inbound Worker).
6395
+ */
6396
+ async sendViaStalwart(agentName, mail) {
6397
+ if (!this.accountManager) {
6398
+ throw new Error("AccountManager required for domain mode outbound");
6150
6399
  }
6151
- const secret = req.headers["x-relay-secret"];
6152
- if (secret !== this.options.secret) {
6153
- res.writeHead(401, { "Content-Type": "application/json" });
6154
- res.end(JSON.stringify({ error: "Unauthorized" }));
6155
- return;
6400
+ const agent = await this.accountManager.getByName(agentName);
6401
+ if (!agent) {
6402
+ throw new Error(`Agent "${agentName}" not found`);
6156
6403
  }
6157
- let body = "";
6158
- for await (const chunk of req) body += chunk;
6159
- try {
6160
- const payload = JSON.parse(body);
6161
- const result = await this.submitToStalwart(payload);
6162
- res.writeHead(200, { "Content-Type": "application/json" });
6163
- res.end(JSON.stringify(result));
6164
- } catch (err) {
6165
- console.error("[RelayBridge] Delivery failed:", err.message);
6166
- res.writeHead(500, { "Content-Type": "application/json" });
6167
- res.end(JSON.stringify({ error: err.message }));
6404
+ const agentPassword = agent.metadata?._password;
6405
+ if (!agentPassword) {
6406
+ throw new Error(`No password for agent "${agentName}"`);
6168
6407
  }
6169
- }
6170
- async submitToStalwart(payload) {
6171
- const { from, to, subject, text, html, replyTo, inReplyTo, references } = payload;
6172
- const recipients = Array.isArray(to) ? to : [to];
6173
- debug("RelayBridge", `Submitting to Stalwart: ${from} \u2192 ${recipients.join(", ")}`);
6174
- const transport = createTransport({
6175
- host: this.options.smtpHost ?? "127.0.0.1",
6176
- port: this.options.smtpPort ?? 587,
6408
+ const domainName = this.config.domain?.domain;
6409
+ const fromAddr = domainName ? agent.email.replace(/@localhost$/, `@${domainName}`) : agent.email;
6410
+ const displayName = mail.fromName || agentName;
6411
+ const from = `${displayName} <${fromAddr}>`;
6412
+ if (domainName && fromAddr !== agent.email) {
6413
+ try {
6414
+ const principal = await this.stalwart.getPrincipal(agent.stalwartPrincipal);
6415
+ const emails = principal.emails ?? [];
6416
+ if (!emails.includes(fromAddr)) {
6417
+ await this.stalwart.addEmailAlias(agent.stalwartPrincipal, fromAddr);
6418
+ console.log(`[GatewayManager] Auto-added ${fromAddr} to Stalwart principal "${agent.stalwartPrincipal}"`);
6419
+ }
6420
+ } catch (err) {
6421
+ console.warn(`[GatewayManager] Could not auto-add domain email alias: ${err.message}`);
6422
+ }
6423
+ }
6424
+ const recipients = Array.isArray(mail.to) ? mail.to : [mail.to];
6425
+ const mailOpts = {
6426
+ from,
6427
+ to: recipients.join(", "),
6428
+ cc: mail.cc ? Array.isArray(mail.cc) ? mail.cc.join(", ") : mail.cc : void 0,
6429
+ bcc: mail.bcc ? Array.isArray(mail.bcc) ? mail.bcc.join(", ") : mail.bcc : void 0,
6430
+ subject: mail.subject,
6431
+ text: mail.text || void 0,
6432
+ // The `html` field is the literal HTML body of the outbound
6433
+ // mail — by design it is whatever the sender chose to compose.
6434
+ // CodeQL `js/xss` flags this because the value flows from user
6435
+ // input, but nodemailer is the SMTP serializer, not an HTML
6436
+ // renderer; XSS would only occur if the recipient's MUA
6437
+ // executed the body, which is outside our trust boundary.
6438
+ // The outbound-guard (packages/core/src/mail/outbound-guard.ts)
6439
+ // already scores HTML bodies for suspicious patterns at the
6440
+ // pre-send step. lgtm[js/xss]
6441
+ html: mail.html || void 0,
6442
+ replyTo: mail.replyTo || from,
6443
+ inReplyTo: mail.inReplyTo || void 0,
6444
+ references: Array.isArray(mail.references) ? mail.references.join(" ") : mail.references || void 0,
6445
+ headers: {
6446
+ "X-Mailer": "AgenticMail/1.0"
6447
+ },
6448
+ attachments: mail.attachments?.map((a) => ({
6449
+ filename: a.filename,
6450
+ content: a.content,
6451
+ contentType: a.contentType,
6452
+ encoding: a.encoding
6453
+ }))
6454
+ };
6455
+ const composer = new MailComposer3(mailOpts);
6456
+ const raw = await composer.compile().build();
6457
+ const smtpHost = this.options.localSmtp?.host ?? "127.0.0.1";
6458
+ const smtpPort = this.options.localSmtp?.port ?? 587;
6459
+ const transport = nodemailer3.createTransport({
6460
+ host: smtpHost,
6461
+ port: smtpPort,
6177
6462
  secure: false,
6178
6463
  auth: {
6179
- user: this.options.smtpUser,
6180
- pass: this.options.smtpPass
6464
+ user: agent.stalwartPrincipal,
6465
+ pass: agentPassword
6181
6466
  },
6182
6467
  tls: { rejectUnauthorized: false }
6183
6468
  });
6184
6469
  try {
6185
- const info = await transport.sendMail({
6186
- from,
6187
- to: recipients.join(", "),
6188
- subject,
6189
- text: text || void 0,
6190
- html: html || void 0,
6191
- replyTo: replyTo || void 0,
6192
- inReplyTo: inReplyTo || void 0,
6193
- references: references || void 0,
6194
- headers: {
6195
- "X-Mailer": "AgenticMail/1.0"
6196
- }
6197
- });
6198
- debug("RelayBridge", `Queued: ${info.messageId} \u2192 ${info.response}`);
6470
+ const info = await transport.sendMail(mailOpts);
6471
+ debug("GatewayManager", `Sent via Stalwart: ${info.messageId} \u2192 ${info.response}`);
6199
6472
  return {
6200
- ok: true,
6201
6473
  messageId: info.messageId,
6202
- response: info.response
6474
+ envelope: { from, to: recipients },
6475
+ raw
6203
6476
  };
6204
6477
  } finally {
6205
6478
  transport.close();
6206
6479
  }
6207
6480
  }
6208
- };
6209
- function startRelayBridge(options) {
6210
- const bridge = new RelayBridge(options);
6211
- bridge.start().catch((err) => {
6212
- console.error("[RelayBridge] Failed to start:", err);
6213
- });
6214
- return bridge;
6215
- }
6216
-
6217
- // src/gateway/types.ts
6218
- var RELAY_PRESETS = {
6219
- gmail: {
6220
- smtpHost: "smtp.gmail.com",
6221
- smtpPort: 587,
6222
- imapHost: "imap.gmail.com",
6223
- imapPort: 993
6224
- },
6225
- outlook: {
6226
- smtpHost: "smtp.office365.com",
6227
- smtpPort: 587,
6228
- imapHost: "outlook.office365.com",
6229
- imapPort: 993
6481
+ // --- Status ---
6482
+ getStatus() {
6483
+ const status = {
6484
+ mode: this.config.mode,
6485
+ healthy: false
6486
+ };
6487
+ if (this.config.mode === "relay" && this.config.relay) {
6488
+ status.relay = {
6489
+ provider: this.config.relay.provider,
6490
+ email: this.config.relay.email,
6491
+ polling: this.relay.isPolling()
6492
+ };
6493
+ status.healthy = this.relay.isConfigured();
6494
+ }
6495
+ if (this.config.mode === "domain" && this.config.domain) {
6496
+ const tunnelStatus = this.tunnel?.status();
6497
+ status.domain = {
6498
+ domain: this.config.domain.domain,
6499
+ dnsConfigured: true,
6500
+ tunnelActive: tunnelStatus?.running ?? false
6501
+ };
6502
+ status.healthy = tunnelStatus?.running ?? false;
6503
+ }
6504
+ if (this.config.mode === "none") {
6505
+ status.healthy = true;
6506
+ }
6507
+ return status;
6230
6508
  }
6231
- };
6232
-
6233
- // src/telegram/client.ts
6234
- var TELEGRAM_API_BASE = "https://api.telegram.org";
6235
- var TELEGRAM_MESSAGE_LIMIT = 4096;
6236
- var TELEGRAM_CHUNK_SIZE = 4e3;
6237
- var TelegramApiError = class extends Error {
6238
- isTelegramApiError = true;
6239
- description;
6240
- errorCode;
6241
- constructor(method, description, errorCode) {
6242
- super(`Telegram ${method} failed: ${description}${errorCode ? ` (code ${errorCode})` : ""}`);
6243
- this.name = "TelegramApiError";
6244
- this.description = description;
6245
- this.errorCode = errorCode;
6509
+ getMode() {
6510
+ return this.config.mode;
6246
6511
  }
6247
- };
6248
- function redactBotToken(text, token) {
6249
- let out = typeof text === "string" ? text : String(text);
6250
- if (token) out = out.split(token).join("bot***");
6251
- return out.replace(/\d{6,}:[A-Za-z0-9_-]{30,}/g, "bot***");
6252
- }
6253
- async function callTelegramApi(token, method, body, options = {}) {
6254
- if (!token || typeof token !== "string") {
6255
- throw new TelegramApiError(method, "bot token is required");
6512
+ getConfig() {
6513
+ return this.config;
6256
6514
  }
6257
- const pollTimeout = typeof body?.timeout === "number" ? body.timeout : 0;
6258
- const timeoutMs = options.longPoll && pollTimeout > 0 ? (pollTimeout + 15) * 1e3 : 3e4;
6259
- let response;
6260
- try {
6261
- response = await fetch(`${TELEGRAM_API_BASE}/bot${token}/${method}`, {
6262
- method: "POST",
6263
- headers: { "Content-Type": "application/json" },
6264
- body: body ? JSON.stringify(body) : void 0,
6265
- signal: AbortSignal.timeout(timeoutMs)
6266
- });
6267
- } catch (err) {
6268
- throw new TelegramApiError(method, redactBotToken(err?.message ?? String(err), token));
6515
+ // --- Domain Purchase ---
6516
+ getStalwart() {
6517
+ return this.stalwart;
6269
6518
  }
6270
- let json;
6271
- try {
6272
- json = await response.json();
6273
- } catch {
6274
- throw new TelegramApiError(method, `non-JSON response (HTTP ${response.status})`);
6519
+ getDomainPurchaser() {
6520
+ return this.domainPurchaser;
6521
+ }
6522
+ getDNSConfigurator() {
6523
+ return this.dnsConfigurator;
6524
+ }
6525
+ getTunnelManager() {
6526
+ return this.tunnel;
6527
+ }
6528
+ getRelay() {
6529
+ return this.relay;
6530
+ }
6531
+ /**
6532
+ * Search the connected relay account (Gmail/Outlook) for emails matching criteria.
6533
+ * Returns empty array if relay is not configured.
6534
+ */
6535
+ async searchRelay(criteria, maxResults = 50) {
6536
+ if (this.config.mode !== "relay" || !this.relay.isConfigured()) return [];
6537
+ return this.relay.searchRelay(criteria, maxResults);
6538
+ }
6539
+ /**
6540
+ * Import an email from the connected relay account into an agent's local inbox.
6541
+ * Fetches the full message from relay IMAP and delivers it locally, preserving
6542
+ * all headers (Message-ID, In-Reply-To, References) for thread continuity.
6543
+ */
6544
+ async importRelayMessage(relayUid, agentName) {
6545
+ if (this.config.mode !== "relay" || !this.relay.isConfigured()) {
6546
+ return { success: false, error: "Relay not configured" };
6547
+ }
6548
+ const mail = await this.relay.fetchRelayMessage(relayUid);
6549
+ if (!mail) {
6550
+ return { success: false, error: "Could not fetch message from relay account" };
6551
+ }
6552
+ try {
6553
+ await this.deliverInboundLocally(agentName, mail);
6554
+ return { success: true };
6555
+ } catch (err) {
6556
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
6557
+ }
6275
6558
  }
6276
- if (!json || json.ok !== true) {
6277
- throw new TelegramApiError(
6278
- method,
6279
- redactBotToken(String(json?.description || `HTTP ${response.status}`), token),
6280
- typeof json?.error_code === "number" ? json.error_code : void 0
6281
- );
6559
+ // --- SMS Polling ---
6560
+ /**
6561
+ * Start SMS pollers for all agents that have separate GV Gmail credentials.
6562
+ * Agents with sameAsRelay=true are handled in deliverInboundLocally.
6563
+ */
6564
+ async startSmsPollers() {
6565
+ if (!this.smsManager || !this.accountManager) return;
6566
+ const agents = this.db.prepare("SELECT id, name, metadata FROM agents").all();
6567
+ for (const agent of agents) {
6568
+ try {
6569
+ const meta = JSON.parse(agent.metadata || "{}");
6570
+ const smsConfig = meta.sms;
6571
+ if (!smsConfig?.enabled || !smsConfig.forwardingPassword || smsConfig.sameAsRelay) continue;
6572
+ const poller = new SmsPoller(this.smsManager, agent.id, smsConfig);
6573
+ poller.onSmsReceived = (agentId, sms) => {
6574
+ console.log(`[SmsPoller] SMS received for agent ${agent.name}: from ${sms.from}, body="${sms.body.slice(0, 50)}..."`);
6575
+ };
6576
+ this.smsPollers.set(agent.id, poller);
6577
+ await poller.startPolling();
6578
+ console.log(`[GatewayManager] SMS poller started for agent "${agent.name}" (${smsConfig.forwardingEmail})`);
6579
+ } catch {
6580
+ }
6581
+ }
6282
6582
  }
6283
- return json.result;
6284
- }
6285
- function stripTelegramMarkdown(text) {
6286
- if (!text) return text;
6287
- return text.replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1").replace(/__(.+?)__/g, "$1").replace(/~~(.+?)~~/g, "$1").replace(/^#{1,6}\s+/gm, "").replace(/```[\s\S]*?```/g, (m) => m.replace(/```\w*\n?/g, "").trim()).replace(/`([^`]+)`/g, "$1").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim();
6288
- }
6289
- function splitTelegramMessage(text, maxLen = TELEGRAM_CHUNK_SIZE) {
6290
- const chunks = [];
6291
- let rest = text || "";
6292
- while (rest.length > maxLen) {
6293
- let cut = rest.lastIndexOf("\n", maxLen);
6294
- if (cut < maxLen / 2) cut = maxLen;
6295
- chunks.push(rest.slice(0, cut));
6296
- rest = rest.slice(cut).replace(/^\n+/, "");
6583
+ // --- Telegram Polling ---
6584
+ /**
6585
+ * Start a long-poll loop for every agent whose Telegram channel is
6586
+ * configured + enabled + in poll mode. Webhook-mode agents skip — the
6587
+ * webhook route already calls back into the same agent-wake bridge.
6588
+ *
6589
+ * Each new inbound Telegram message (that isn't an operator-query
6590
+ * reply) is converted to a synthetic email and delivered into the
6591
+ * agent's INBOX via the existing local-SMTP path — the very same
6592
+ * delivery the relay uses for real email. This makes the existing
6593
+ * IMAP IDLE claudecode dispatcher path light up exactly as it
6594
+ * would for a real inbound mail, so the agent gets a Claude turn
6595
+ * without any new dispatcher plumbing. The body of the synthetic
6596
+ * mail tells the agent the message came from Telegram and that it
6597
+ * MUST reply via the `telegram_send` MCP tool, not via email.
6598
+ */
6599
+ async startTelegramPollers() {
6600
+ if (!this.telegramManager || !this.accountManager) return;
6601
+ const agents = this.db.prepare("SELECT id, name FROM agents").all();
6602
+ for (const agent of agents) {
6603
+ try {
6604
+ const config = this.telegramManager.getConfig(agent.id);
6605
+ if (!config?.enabled || config.mode !== "poll" || !config.botToken) continue;
6606
+ await this.startTelegramPollerForAgent(agent.id, agent.name);
6607
+ } catch (err) {
6608
+ console.warn(`[GatewayManager] Could not start Telegram poller for ${agent.name}: ${err.message}`);
6609
+ }
6610
+ }
6297
6611
  }
6298
- if (rest) chunks.push(rest);
6299
- return chunks;
6300
- }
6301
- async function sendTelegramMessage(token, chatId, text, options = {}) {
6302
- const clean = stripTelegramMarkdown(text);
6303
- const chunks = splitTelegramMessage(clean);
6304
- if (chunks.length === 0) chunks.push("");
6305
- const messageIds = [];
6306
- for (let i = 0; i < chunks.length; i++) {
6307
- const body = { chat_id: String(chatId), text: chunks[i] };
6308
- if (i === 0 && options.replyToMessageId) {
6309
- body.reply_parameters = { message_id: options.replyToMessageId };
6612
+ /**
6613
+ * Start (or restart) the Telegram poller for one agent. Idempotent —
6614
+ * a prior poller is stopped first so re-running `/telegram/setup`
6615
+ * picks up the new token / allow-list cleanly.
6616
+ *
6617
+ * Public so the API layer can poke the gateway after a successful
6618
+ * `/telegram/setup` without waiting for the next server restart.
6619
+ */
6620
+ async startTelegramPollerForAgent(agentId, agentName) {
6621
+ if (!this.telegramManager) return;
6622
+ const existing = this.telegramPollers.get(agentId);
6623
+ if (existing) {
6624
+ try {
6625
+ await existing.stop();
6626
+ } catch {
6627
+ }
6628
+ this.telegramPollers.delete(agentId);
6310
6629
  }
6311
- if (options.disableNotification) body.disable_notification = true;
6312
- const result = await callTelegramApi(token, "sendMessage", body);
6313
- messageIds.push(result.message_id);
6630
+ const config = this.telegramManager.getConfig(agentId);
6631
+ if (!config?.enabled || config.mode !== "poll" || !config.botToken) return;
6632
+ const poller = new TelegramPoller(this.telegramManager, agentId);
6633
+ poller.onInbound = async (event) => {
6634
+ await this.bridgeInboundTelegram(event.agentId, event.message, event.config, agentName);
6635
+ };
6636
+ this.telegramPollers.set(agentId, poller);
6637
+ await poller.start();
6638
+ const botName = config.botUsername ? `@${config.botUsername}` : `bot ${config.botId ?? "(unknown)"}`;
6639
+ console.log(`[GatewayManager] Telegram poller started for agent "${agentName ?? agentId.slice(0, 8)}" (${botName})`);
6314
6640
  }
6315
- return { messageIds, chunks: chunks.length };
6316
- }
6317
- function getTelegramMe(token) {
6318
- return callTelegramApi(token, "getMe");
6319
- }
6320
- function getTelegramChat(token, chatId) {
6321
- return callTelegramApi(token, "getChat", { chat_id: String(chatId) });
6322
- }
6323
- function getTelegramUpdates(token, offset, options = {}) {
6324
- const timeoutSec = Math.max(options.timeoutSec ?? 0, 0);
6325
- return callTelegramApi(token, "getUpdates", {
6326
- offset,
6327
- limit: Math.min(Math.max(options.limit ?? 100, 1), 100),
6328
- timeout: timeoutSec,
6329
- allowed_updates: ["message"]
6330
- }, { longPoll: timeoutSec > 0 });
6331
- }
6332
- function setTelegramWebhook(token, url, options = {}) {
6333
- return callTelegramApi(token, "setWebhook", {
6334
- url,
6335
- secret_token: options.secretToken,
6336
- allowed_updates: ["message"],
6337
- drop_pending_updates: options.dropPendingUpdates ?? false
6338
- });
6339
- }
6340
- function deleteTelegramWebhook(token) {
6341
- return callTelegramApi(token, "deleteWebhook", {});
6342
- }
6343
- function getTelegramWebhookInfo(token) {
6344
- return callTelegramApi(token, "getWebhookInfo");
6345
- }
6346
-
6347
- // src/telegram/update.ts
6348
- function asTrimmed(value) {
6349
- return typeof value === "string" ? value.trim() : "";
6350
- }
6351
- function normalizeChatType(type) {
6352
- return type === "private" || type === "group" || type === "supergroup" || type === "channel" ? type : "unknown";
6353
- }
6354
- function parseTelegramUpdate(update) {
6355
- if (!update || typeof update !== "object") return null;
6356
- const u = update;
6357
- if (typeof u.update_id !== "number") return null;
6358
- const msg = u.message || u.channel_post;
6359
- if (!msg || typeof msg !== "object") return null;
6360
- if (typeof msg.message_id !== "number") return null;
6361
- const chat = msg.chat || {};
6362
- if (typeof chat.id !== "number" && typeof chat.id !== "string") return null;
6363
- const text = asTrimmed(msg.text) || asTrimmed(msg.caption);
6364
- if (!text) return null;
6365
- const from = msg.from || {};
6366
- const fromName = [from.first_name, from.last_name].filter((p) => typeof p === "string" && p).join(" ") || asTrimmed(from.username) || asTrimmed(chat.title) || "User";
6367
- const replyTo = msg.reply_to_message;
6368
- return {
6369
- updateId: u.update_id,
6370
- messageId: msg.message_id,
6371
- chatId: String(chat.id),
6372
- chatType: normalizeChatType(chat.type),
6373
- chatTitle: asTrimmed(chat.title) || void 0,
6374
- fromId: from.id != null ? String(from.id) : String(chat.id),
6375
- fromName,
6376
- fromUsername: asTrimmed(from.username) || void 0,
6377
- text,
6378
- replyToMessageId: replyTo && typeof replyTo.message_id === "number" ? replyTo.message_id : void 0,
6379
- replyToText: replyTo ? asTrimmed(replyTo.text) || asTrimmed(replyTo.caption) || void 0 : void 0,
6380
- date: typeof msg.date === "number" ? new Date(msg.date * 1e3).toISOString() : (/* @__PURE__ */ new Date()).toISOString()
6381
- };
6382
- }
6383
- var TELEGRAM_STOP_WORDS = /* @__PURE__ */ new Set([
6384
- "stop",
6385
- "abort",
6386
- "kill",
6387
- "cancel",
6388
- "halt"
6389
- ]);
6390
- function isTelegramStopCommand(text) {
6391
- if (!text) return false;
6392
- const cleaned = text.trim().toLowerCase().replace(/[!.?]+$/, "");
6393
- return TELEGRAM_STOP_WORDS.has(cleaned);
6394
- }
6395
- function nextTelegramOffset(currentOffset, updates) {
6396
- let next = currentOffset;
6397
- for (const u of updates) {
6398
- if (u && typeof u.update_id === "number" && u.update_id >= next) {
6399
- next = u.update_id + 1;
6641
+ /** Stop a single agent's Telegram poller (called on disable). */
6642
+ async stopTelegramPollerForAgent(agentId) {
6643
+ const poller = this.telegramPollers.get(agentId);
6644
+ if (!poller) return;
6645
+ try {
6646
+ await poller.stop();
6647
+ } catch {
6400
6648
  }
6649
+ this.telegramPollers.delete(agentId);
6401
6650
  }
6402
- return next;
6403
- }
6404
-
6405
- // src/telegram/manager.ts
6406
- import { timingSafeEqual } from "crypto";
6407
- var TELEGRAM_WEBHOOK_SECRET_RE = /^[A-Za-z0-9_-]+$/;
6408
- var TELEGRAM_MIN_WEBHOOK_SECRET_LENGTH = 16;
6409
- var TELEGRAM_SECRET_FIELDS = ["botToken", "webhookSecret"];
6410
- function redactTelegramConfig(config) {
6411
- return {
6412
- ...config,
6413
- botToken: config.botToken ? "***" : config.botToken,
6414
- webhookSecret: config.webhookSecret ? "***" : void 0
6415
- };
6416
- }
6417
- function isTelegramChatAllowed(config, chatId) {
6418
- const id = String(chatId ?? "").trim();
6419
- if (!id) return false;
6420
- if (config.operatorChatId && String(config.operatorChatId).trim() === id) return true;
6421
- return Array.isArray(config.allowedChatIds) && config.allowedChatIds.some((c) => String(c).trim() === id);
6422
- }
6423
- function safeEqual(a, b) {
6424
- const bufA = Buffer.from(a, "utf8");
6425
- const bufB = Buffer.from(b, "utf8");
6426
- if (bufA.length !== bufB.length) return false;
6427
- return timingSafeEqual(bufA, bufB);
6428
- }
6429
- var TelegramManager = class {
6430
6651
  /**
6431
- * Optional master key used to encrypt Telegram credentials at rest
6432
- * (the same AES-256-GCM scheme SMS/phone use). When absent (tests, or
6433
- * a deployment with no master key) configs are stored as-is and reads
6434
- * tolerate plaintext upgrades and downgrades both stay safe.
6652
+ * Convert one new inbound Telegram message into a synthetic email
6653
+ * landing in the agent's INBOX, so the dispatcher wakes the agent.
6654
+ *
6655
+ * Two short-circuits before delivery:
6656
+ *
6657
+ * 1. If the message is from the configured operator's chat AND
6658
+ * looks like an operator-query reply (parsed by
6659
+ * `parseTelegramOperatorReply`), it's an answer to an in-flight
6660
+ * voice mission, not free-form chat — the HTTP webhook/poll
6661
+ * route already handles those by calling into the phone
6662
+ * manager, and the route does NOT need an agent turn. The poller
6663
+ * hands them off the same way: we just skip the wake here.
6664
+ *
6665
+ * 2. Plain `/start` (BotFather's default first DM) is a Telegram
6666
+ * housekeeping nudge — replying with an LLM turn for "/start"
6667
+ * would be embarrassing. Skip it.
6668
+ *
6669
+ * Everything else: synthesise the email and deliver.
6435
6670
  */
6436
- constructor(db2, encryptionKey) {
6437
- this.db = db2;
6438
- this.encryptionKey = encryptionKey;
6439
- this.ensureTable();
6671
+ /**
6672
+ * Public wrapper around the bridge — the Telegram webhook route calls
6673
+ * this directly so push-mode and poll-mode share the wake path.
6674
+ */
6675
+ async bridgeTelegramInbound(agentId, parsed, config) {
6676
+ return this.bridgeInboundTelegram(agentId, parsed, config);
6440
6677
  }
6441
- initialized = false;
6442
- ensureTable() {
6443
- if (this.initialized) return;
6678
+ async bridgeInboundTelegram(agentId, parsed, config, agentNameHint) {
6679
+ const operatorChatId = config.operatorChatId?.toString().trim() || "";
6680
+ if (operatorChatId && parsed.chatId === operatorChatId) {
6681
+ const reply = parseTelegramOperatorReply({ text: parsed.text, replyToText: parsed.replyToText });
6682
+ if (reply) return;
6683
+ }
6684
+ const trimmedText = (parsed.text ?? "").trim();
6685
+ if (trimmedText === "/start" || trimmedText === "/help" || trimmedText === "/stop") {
6686
+ return;
6687
+ }
6688
+ if (!trimmedText) return;
6689
+ if (!this.accountManager) return;
6690
+ const agent = await this.accountManager.getById(agentId);
6691
+ if (!agent) return;
6692
+ const agentName = agentNameHint ?? agent.name;
6693
+ const fromLabel = parsed.fromName ? `${parsed.fromName} (Telegram chat ${parsed.chatId})` : `Telegram chat ${parsed.chatId}`;
6694
+ const senderName = parsed.fromName || parsed.fromUsername || "User";
6695
+ const subject = `[Telegram] ${trimmedText.slice(0, 80)}${trimmedText.length > 80 ? "\u2026" : ""}`;
6696
+ const body = [
6697
+ `[Incoming Telegram message \u2014 via AgenticMail Telegram bridge]`,
6698
+ `from_name: ${senderName}`,
6699
+ parsed.fromId ? `from_id: ${parsed.fromId}` : null,
6700
+ `chat_id: ${parsed.chatId}`,
6701
+ `chat_type: ${parsed.chatType}`,
6702
+ `telegram_message_id: ${parsed.messageId}`,
6703
+ `received_at: ${parsed.date}`,
6704
+ ``,
6705
+ `=== REPLY ROUTING (important, read before responding) ===`,
6706
+ `This message arrived via Telegram, NOT email. To reply to ${senderName}`,
6707
+ `you must use the telegram_send MCP tool \u2014 replying by email will go`,
6708
+ `nowhere they can see it.`,
6709
+ ``,
6710
+ ` telegram_send({ chatId: "${parsed.chatId}", text: "<your reply>" })`,
6711
+ ``,
6712
+ `Send EXACTLY ONE telegram_send call per response \u2014 do not also narrate`,
6713
+ `or summarise what you sent in a separate reply / email, that just shows`,
6714
+ `up to the user as a duplicate. Keep replies concise and plain text`,
6715
+ `(Telegram strips markdown formatting in transit). No preamble like`,
6716
+ `"sure, here you go" \u2014 just answer the question.`,
6717
+ ``,
6718
+ `If the user is asking you to do an errand (call someone, look up info,`,
6719
+ `send something), do the work FIRST, then telegram_send a single clear`,
6720
+ `update back to chat_id ${parsed.chatId} when done \u2014 that becomes the`,
6721
+ `whole reply.`,
6722
+ `=== END REPLY ROUTING ===`,
6723
+ ``,
6724
+ `--- User's message ---`,
6725
+ trimmedText,
6726
+ `---`
6727
+ ].filter((l) => l !== null).join("\n");
6728
+ const inbound = {
6729
+ from: `telegram-bridge@telegram.local`,
6730
+ to: agent.email,
6731
+ subject,
6732
+ text: body,
6733
+ html: void 0,
6734
+ // Use the Telegram-provided send time so the inbox ordering matches
6735
+ // when the user actually pressed Send, not when the bridge ran.
6736
+ date: parsed.date ? new Date(parsed.date) : /* @__PURE__ */ new Date(),
6737
+ messageId: `<tg-${parsed.chatId}-${parsed.messageId}@telegram.local>`
6738
+ };
6444
6739
  try {
6445
- this.db.exec(`
6446
- CREATE TABLE IF NOT EXISTS telegram_messages (
6447
- id TEXT PRIMARY KEY,
6448
- agent_id TEXT NOT NULL,
6449
- direction TEXT NOT NULL CHECK(direction IN ('inbound', 'outbound')),
6450
- chat_id TEXT NOT NULL,
6451
- telegram_message_id INTEGER,
6452
- from_id TEXT,
6453
- text TEXT NOT NULL,
6454
- status TEXT NOT NULL DEFAULT 'pending',
6455
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
6456
- metadata TEXT DEFAULT '{}'
6457
- )
6458
- `);
6740
+ await this.deliverInboundLocally(agentName, inbound);
6741
+ } catch (err) {
6742
+ console.warn(`[GatewayManager] Telegram \u2192 inbox bridge failed for ${agentName}: ${err.message}`);
6743
+ }
6744
+ }
6745
+ // --- Lifecycle ---
6746
+ async shutdown() {
6747
+ await this.relay.shutdown();
6748
+ this.tunnel?.stop();
6749
+ for (const poller of this.smsPollers.values()) {
6750
+ poller.stopPolling();
6751
+ }
6752
+ this.smsPollers.clear();
6753
+ for (const poller of this.telegramPollers.values()) {
6459
6754
  try {
6460
- this.db.exec("CREATE INDEX IF NOT EXISTS idx_telegram_agent ON telegram_messages(agent_id)");
6755
+ void poller.stop();
6461
6756
  } catch {
6462
6757
  }
6758
+ }
6759
+ this.telegramPollers.clear();
6760
+ }
6761
+ /**
6762
+ * Resume gateway from saved config (e.g., after server restart).
6763
+ *
6764
+ * Issue #31 — On a Docker container restart the API can come up
6765
+ * before Stalwart / Gmail IMAP / DNS is reachable, so the very first
6766
+ * setup() can fail with a transient network error. Previously that
6767
+ * single failure was logged and never retried, leaving polling
6768
+ * permanently dead until someone noticed and manually revived the
6769
+ * relay. We now schedule background retries with exponential backoff
6770
+ * (5s, 10s, 20s, 40s, 60s cap, indefinite) so the relay
6771
+ * self-recovers as soon as the dependency is reachable again.
6772
+ */
6773
+ async resume() {
6774
+ if (this.config.mode === "relay" && this.config.relay) {
6463
6775
  try {
6464
- this.db.exec("CREATE INDEX IF NOT EXISTS idx_telegram_chat ON telegram_messages(chat_id)");
6465
- } catch {
6776
+ await this._resumeRelayOnce();
6777
+ } catch (err) {
6778
+ console.error("[GatewayManager] Initial relay resume failed; scheduling retries:", formatPollError(err));
6779
+ this._scheduleRelayResumeRetry();
6466
6780
  }
6781
+ }
6782
+ if (this.smsManager && this.accountManager) {
6467
6783
  try {
6468
- this.db.exec("CREATE INDEX IF NOT EXISTS idx_telegram_created ON telegram_messages(created_at)");
6469
- } catch {
6784
+ await this.startSmsPollers();
6785
+ } catch (err) {
6786
+ console.error("[GatewayManager] Failed to start SMS pollers:", err);
6470
6787
  }
6471
- this.initialized = true;
6472
- } catch {
6473
- this.initialized = true;
6474
6788
  }
6475
- }
6476
- /** Encrypt the credential fields of a config before persisting. */
6477
- encryptConfig(config) {
6478
- if (!this.encryptionKey) return config;
6479
- const out = { ...config };
6480
- for (const field of TELEGRAM_SECRET_FIELDS) {
6481
- const value = out[field];
6482
- if (typeof value === "string" && value && !isEncryptedSecret(value)) {
6483
- out[field] = encryptSecret(value, this.encryptionKey);
6789
+ if (this.telegramManager && this.accountManager) {
6790
+ try {
6791
+ await this.startTelegramPollers();
6792
+ } catch (err) {
6793
+ console.error("[GatewayManager] Failed to start Telegram pollers:", err);
6484
6794
  }
6485
6795
  }
6486
- return out;
6487
- }
6488
- /** Decrypt the credential fields of a config after loading. */
6489
- decryptConfig(config) {
6490
- if (!this.encryptionKey) return config;
6491
- const out = { ...config };
6492
- for (const field of TELEGRAM_SECRET_FIELDS) {
6493
- const value = out[field];
6494
- if (typeof value === "string" && isEncryptedSecret(value)) {
6495
- try {
6496
- out[field] = decryptSecret(value, this.encryptionKey);
6497
- } catch {
6796
+ if (this.config.mode === "domain" && this.config.domain) {
6797
+ try {
6798
+ this.cfClient = new CloudflareClient(
6799
+ this.config.domain.cloudflareApiToken,
6800
+ this.config.domain.cloudflareAccountId
6801
+ );
6802
+ this.dnsConfigurator = new DNSConfigurator(this.cfClient);
6803
+ this.tunnel = new TunnelManager(this.cfClient);
6804
+ this.domainPurchaser = new DomainPurchaser(this.cfClient);
6805
+ if (this.config.domain.tunnelToken) {
6806
+ await this.tunnel.start(this.config.domain.tunnelToken);
6498
6807
  }
6808
+ } catch (err) {
6809
+ console.error("[GatewayManager] Failed to resume domain mode:", err);
6499
6810
  }
6500
6811
  }
6501
- return out;
6502
- }
6503
- /** Normalize a stored/loaded config object, defaulting missing fields. */
6504
- normalizeConfig(raw) {
6505
- return {
6506
- enabled: raw.enabled === true,
6507
- botToken: typeof raw.botToken === "string" ? raw.botToken : "",
6508
- botUsername: typeof raw.botUsername === "string" ? raw.botUsername : void 0,
6509
- botId: typeof raw.botId === "number" ? raw.botId : void 0,
6510
- allowedChatIds: Array.isArray(raw.allowedChatIds) ? raw.allowedChatIds.map((c) => String(c).trim()).filter(Boolean) : [],
6511
- operatorChatId: typeof raw.operatorChatId === "string" && raw.operatorChatId.trim() ? raw.operatorChatId.trim() : void 0,
6512
- mode: raw.mode === "webhook" ? "webhook" : "poll",
6513
- webhookUrl: typeof raw.webhookUrl === "string" ? raw.webhookUrl : void 0,
6514
- webhookSecret: typeof raw.webhookSecret === "string" ? raw.webhookSecret : void 0,
6515
- pollOffset: typeof raw.pollOffset === "number" ? raw.pollOffset : 0,
6516
- configuredAt: typeof raw.configuredAt === "string" ? raw.configuredAt : (/* @__PURE__ */ new Date()).toISOString()
6517
- };
6518
- }
6519
- /** Get the Telegram config from agent metadata (credentials decrypted). */
6520
- getConfig(agentId) {
6521
- const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
6522
- if (!row) return null;
6523
- try {
6524
- const meta = JSON.parse(row.metadata || "{}");
6525
- if (!meta.telegram || typeof meta.telegram !== "object") return null;
6526
- return this.decryptConfig(this.normalizeConfig(meta.telegram));
6527
- } catch {
6528
- return null;
6529
- }
6530
6812
  }
6531
- /** Save the Telegram config to agent metadata (credentials encrypted). */
6532
- saveConfig(agentId, config) {
6533
- const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
6534
- if (!row) throw new Error(`Agent ${agentId} not found`);
6535
- let meta;
6536
- try {
6537
- meta = JSON.parse(row.metadata || "{}");
6538
- } catch {
6539
- meta = {};
6813
+ // ─── Issue #31 helpers resume retry with backoff ───
6814
+ _resumeRetryTimer = null;
6815
+ _resumeRetryAttempt = 0;
6816
+ async _resumeRelayOnce() {
6817
+ if (!this.config.relay) throw new Error("No relay config to resume");
6818
+ await this.relay.setup(this.config.relay);
6819
+ const savedUid = this.loadLastSeenUid();
6820
+ if (savedUid > 0) {
6821
+ this.relay.setLastSeenUid(savedUid);
6822
+ console.log(`[GatewayManager] Restored lastSeenUid=${savedUid} from database`);
6540
6823
  }
6541
- meta.telegram = this.encryptConfig(config);
6542
- this.db.prepare("UPDATE agents SET metadata = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(meta), agentId);
6543
- }
6544
- /** Remove the Telegram config from agent metadata. */
6545
- removeConfig(agentId) {
6546
- const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
6547
- if (!row) return;
6548
- let meta;
6549
- try {
6550
- meta = JSON.parse(row.metadata || "{}");
6551
- } catch {
6552
- meta = {};
6824
+ this.relay.onUidAdvance = (uid) => this.saveLastSeenUid(uid);
6825
+ await this.relay.startPolling();
6826
+ if (this._resumeRetryAttempt > 0) {
6827
+ console.log(`[GatewayManager] Relay polling resumed after ${this._resumeRetryAttempt} retry attempt${this._resumeRetryAttempt !== 1 ? "s" : ""}`);
6553
6828
  }
6554
- delete meta.telegram;
6555
- this.db.prepare("UPDATE agents SET metadata = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(meta), agentId);
6829
+ this._resumeRetryAttempt = 0;
6556
6830
  }
6557
- /** Persist a new poll offset without touching the rest of the config. */
6558
- updatePollOffset(agentId, offset) {
6559
- const config = this.getConfig(agentId);
6560
- if (!config) return;
6561
- config.pollOffset = offset;
6562
- this.saveConfig(agentId, config);
6831
+ _scheduleRelayResumeRetry() {
6832
+ if (this._resumeRetryTimer) return;
6833
+ this._resumeRetryAttempt++;
6834
+ const base = Math.min(5e3 * Math.pow(2, this._resumeRetryAttempt - 1), 6e4);
6835
+ const jitter = base * (0.8 + Math.random() * 0.4);
6836
+ const delay = Math.round(jitter);
6837
+ console.log(`[GatewayManager] Will retry relay resume in ${(delay / 1e3).toFixed(1)}s (attempt ${this._resumeRetryAttempt + 1})`);
6838
+ this._resumeRetryTimer = setTimeout(async () => {
6839
+ this._resumeRetryTimer = null;
6840
+ if (this.config.mode !== "relay" || !this.config.relay) return;
6841
+ try {
6842
+ await this._resumeRelayOnce();
6843
+ } catch (err) {
6844
+ console.error(`[GatewayManager] Relay resume retry ${this._resumeRetryAttempt} failed:`, formatPollError(err));
6845
+ this._scheduleRelayResumeRetry();
6846
+ }
6847
+ }, delay);
6563
6848
  }
6564
- /**
6565
- * Resolve the agent that owns a webhook secret. Used to authenticate +
6566
- * route an inbound Telegram webhook delivery: a webhook carries no bot
6567
- * identity, so the `X-Telegram-Bot-Api-Secret-Token` header is the
6568
- * routing key. The comparison is constant-time, and a non-match
6569
- * returns `null` so the route can answer with a single uniform 403
6570
- * (no enumeration oracle — same posture as the SMS webhook).
6571
- */
6572
- findAgentByWebhookSecret(secret) {
6573
- const provided = String(secret ?? "");
6574
- if (!provided) return null;
6575
- const rows = this.db.prepare("SELECT id, metadata FROM agents").all();
6576
- for (const row of rows) {
6849
+ // --- Persistence ---
6850
+ loadConfig() {
6851
+ const row = this.db.prepare("SELECT * FROM gateway_config WHERE id = ?").get("default");
6852
+ if (row) {
6577
6853
  try {
6578
- const meta = JSON.parse(row.metadata || "{}");
6579
- if (!meta.telegram || typeof meta.telegram !== "object") continue;
6580
- const config = this.decryptConfig(this.normalizeConfig(meta.telegram));
6581
- if (!config.enabled || !config.webhookSecret) continue;
6582
- if (safeEqual(provided, config.webhookSecret)) {
6583
- return { agentId: row.id, config };
6854
+ const parsed = JSON.parse(row.config);
6855
+ if (this.encryptionKey) {
6856
+ if (parsed.relay?.password) {
6857
+ try {
6858
+ parsed.relay.password = decryptSecret(parsed.relay.password, this.encryptionKey);
6859
+ } catch {
6860
+ }
6861
+ }
6862
+ if (parsed.relay?.appPassword) {
6863
+ try {
6864
+ parsed.relay.appPassword = decryptSecret(parsed.relay.appPassword, this.encryptionKey);
6865
+ } catch {
6866
+ }
6867
+ }
6868
+ if (parsed.domain?.cloudflareApiToken) {
6869
+ try {
6870
+ parsed.domain.cloudflareApiToken = decryptSecret(parsed.domain.cloudflareApiToken, this.encryptionKey);
6871
+ } catch {
6872
+ }
6873
+ }
6874
+ if (parsed.domain?.tunnelToken) {
6875
+ try {
6876
+ parsed.domain.tunnelToken = decryptSecret(parsed.domain.tunnelToken, this.encryptionKey);
6877
+ } catch {
6878
+ }
6879
+ }
6880
+ if (parsed.domain?.inboundSecret) {
6881
+ try {
6882
+ parsed.domain.inboundSecret = decryptSecret(parsed.domain.inboundSecret, this.encryptionKey);
6883
+ } catch {
6884
+ }
6885
+ }
6886
+ if (parsed.domain?.outboundSecret) {
6887
+ try {
6888
+ parsed.domain.outboundSecret = decryptSecret(parsed.domain.outboundSecret, this.encryptionKey);
6889
+ } catch {
6890
+ }
6891
+ }
6584
6892
  }
6893
+ this.config = {
6894
+ mode: row.mode,
6895
+ ...parsed
6896
+ };
6585
6897
  } catch {
6898
+ this.config = { mode: "none" };
6586
6899
  }
6587
6900
  }
6588
- return null;
6589
6901
  }
6590
- /** True if an inbound message with this Telegram id is already stored. */
6591
- inboundMessageExists(agentId, chatId, telegramMessageId) {
6592
- const row = this.db.prepare(
6593
- "SELECT 1 FROM telegram_messages WHERE agent_id = ? AND direction = ? AND chat_id = ? AND telegram_message_id = ? LIMIT 1"
6594
- ).get(agentId, "inbound", String(chatId), telegramMessageId);
6595
- return !!row;
6902
+ saveConfig() {
6903
+ const { mode, ...rest } = this.config;
6904
+ const toStore = JSON.parse(JSON.stringify(rest));
6905
+ if (this.encryptionKey) {
6906
+ if (toStore.relay?.password) {
6907
+ toStore.relay.password = encryptSecret(toStore.relay.password, this.encryptionKey);
6908
+ }
6909
+ if (toStore.relay?.appPassword) {
6910
+ toStore.relay.appPassword = encryptSecret(toStore.relay.appPassword, this.encryptionKey);
6911
+ }
6912
+ if (toStore.domain?.cloudflareApiToken) {
6913
+ toStore.domain.cloudflareApiToken = encryptSecret(toStore.domain.cloudflareApiToken, this.encryptionKey);
6914
+ }
6915
+ if (toStore.domain?.tunnelToken) {
6916
+ toStore.domain.tunnelToken = encryptSecret(toStore.domain.tunnelToken, this.encryptionKey);
6917
+ }
6918
+ if (toStore.domain?.inboundSecret) {
6919
+ toStore.domain.inboundSecret = encryptSecret(toStore.domain.inboundSecret, this.encryptionKey);
6920
+ }
6921
+ if (toStore.domain?.outboundSecret) {
6922
+ toStore.domain.outboundSecret = encryptSecret(toStore.domain.outboundSecret, this.encryptionKey);
6923
+ }
6924
+ }
6925
+ this.db.prepare(`
6926
+ INSERT OR REPLACE INTO gateway_config (id, mode, config)
6927
+ VALUES ('default', ?, ?)
6928
+ `).run(mode, JSON.stringify(toStore));
6929
+ }
6930
+ saveLastSeenUid(uid) {
6931
+ this.db.prepare(`
6932
+ INSERT OR REPLACE INTO config (key, value) VALUES ('relay_last_seen_uid', ?)
6933
+ `).run(String(uid));
6934
+ }
6935
+ loadLastSeenUid() {
6936
+ const row = this.db.prepare("SELECT value FROM config WHERE key = ?").get("relay_last_seen_uid");
6937
+ return row ? parseInt(row.value, 10) || 0 : 0;
6938
+ }
6939
+ };
6940
+ function parseAddressString(addr) {
6941
+ const match = addr.match(/^(.+?)\s*<([^>]+)>$/);
6942
+ if (match) {
6943
+ return { name: match[1].trim(), address: match[2].trim() };
6944
+ }
6945
+ return { address: addr.trim() };
6946
+ }
6947
+ function inboundToParsedEmail(mail) {
6948
+ return {
6949
+ messageId: mail.messageId || "",
6950
+ subject: mail.subject || "",
6951
+ from: [parseAddressString(mail.from)],
6952
+ to: [parseAddressString(mail.to)],
6953
+ date: mail.date || /* @__PURE__ */ new Date(),
6954
+ text: mail.text,
6955
+ html: mail.html,
6956
+ inReplyTo: mail.inReplyTo,
6957
+ references: mail.references,
6958
+ attachments: (mail.attachments ?? []).map((a) => ({
6959
+ filename: a.filename,
6960
+ contentType: a.contentType,
6961
+ size: a.size,
6962
+ content: a.content
6963
+ })),
6964
+ headers: /* @__PURE__ */ new Map()
6965
+ };
6966
+ }
6967
+
6968
+ // src/gateway/relay-bridge.ts
6969
+ import { createServer } from "http";
6970
+ import { createTransport } from "nodemailer";
6971
+ var RelayBridge = class {
6972
+ server = null;
6973
+ options;
6974
+ constructor(options) {
6975
+ this.options = options;
6596
6976
  }
6597
- /** Record an inbound Telegram message. */
6598
- recordInbound(agentId, input, metadata) {
6599
- const id = `tg_in_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
6600
- const createdAt = input.createdAt || (/* @__PURE__ */ new Date()).toISOString();
6601
- this.db.prepare(
6602
- "INSERT INTO telegram_messages (id, agent_id, direction, chat_id, telegram_message_id, from_id, text, status, created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
6603
- ).run(
6604
- id,
6605
- agentId,
6606
- "inbound",
6607
- String(input.chatId),
6608
- input.telegramMessageId,
6609
- input.fromId ?? null,
6610
- input.text,
6611
- "received",
6612
- createdAt,
6613
- JSON.stringify(metadata ?? {})
6614
- );
6615
- return {
6616
- id,
6617
- agentId,
6618
- direction: "inbound",
6619
- chatId: String(input.chatId),
6620
- telegramMessageId: input.telegramMessageId,
6621
- fromId: input.fromId,
6622
- text: input.text,
6623
- status: "received",
6624
- createdAt,
6625
- metadata
6626
- };
6977
+ async start() {
6978
+ return new Promise((resolve2, reject) => {
6979
+ this.server = createServer((req, res) => this.handleRequest(req, res));
6980
+ this.server.listen(this.options.port, "127.0.0.1", () => {
6981
+ console.log(`[RelayBridge] Listening on 127.0.0.1:${this.options.port}`);
6982
+ resolve2();
6983
+ });
6984
+ this.server.on("error", reject);
6985
+ });
6627
6986
  }
6628
- /** Record an outbound Telegram message attempt. */
6629
- recordOutbound(agentId, input, metadata) {
6630
- const id = `tg_out_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
6631
- const createdAt = (/* @__PURE__ */ new Date()).toISOString();
6632
- const status = input.status ?? "sent";
6633
- this.db.prepare(
6634
- "INSERT INTO telegram_messages (id, agent_id, direction, chat_id, telegram_message_id, from_id, text, status, created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
6635
- ).run(
6636
- id,
6637
- agentId,
6638
- "outbound",
6639
- String(input.chatId),
6640
- input.telegramMessageId ?? null,
6641
- null,
6642
- input.text,
6643
- status,
6644
- createdAt,
6645
- JSON.stringify(metadata ?? {})
6646
- );
6647
- return {
6648
- id,
6649
- agentId,
6650
- direction: "outbound",
6651
- chatId: String(input.chatId),
6652
- telegramMessageId: input.telegramMessageId,
6653
- text: input.text,
6654
- status,
6655
- createdAt,
6656
- metadata
6657
- };
6987
+ stop() {
6988
+ this.server?.close();
6989
+ this.server = null;
6658
6990
  }
6659
- /** Update the status (+ optional metadata) of a stored message. */
6660
- updateStatus(id, status, metadata) {
6661
- if (metadata) {
6662
- this.db.prepare("UPDATE telegram_messages SET status = ?, metadata = ? WHERE id = ?").run(status, JSON.stringify(metadata), id);
6991
+ async handleRequest(req, res) {
6992
+ if (req.method !== "POST" || req.url !== "/send") {
6993
+ res.writeHead(404, { "Content-Type": "application/json" });
6994
+ res.end(JSON.stringify({ error: "Not found" }));
6663
6995
  return;
6664
6996
  }
6665
- this.db.prepare("UPDATE telegram_messages SET status = ? WHERE id = ?").run(status, id);
6666
- }
6667
- /** List stored Telegram messages for an agent, newest first. */
6668
- listMessages(agentId, opts) {
6669
- const limit = Math.min(Math.max(opts?.limit ?? 20, 1), 100);
6670
- const offset = Math.max(opts?.offset ?? 0, 0);
6671
- let query = "SELECT * FROM telegram_messages WHERE agent_id = ?";
6672
- const params = [agentId];
6673
- if (opts?.direction === "inbound" || opts?.direction === "outbound") {
6674
- query += " AND direction = ?";
6675
- params.push(opts.direction);
6997
+ const secret = req.headers["x-relay-secret"];
6998
+ if (secret !== this.options.secret) {
6999
+ res.writeHead(401, { "Content-Type": "application/json" });
7000
+ res.end(JSON.stringify({ error: "Unauthorized" }));
7001
+ return;
6676
7002
  }
6677
- if (opts?.chatId) {
6678
- query += " AND chat_id = ?";
6679
- params.push(String(opts.chatId));
7003
+ let body = "";
7004
+ for await (const chunk of req) body += chunk;
7005
+ try {
7006
+ const payload = JSON.parse(body);
7007
+ const result = await this.submitToStalwart(payload);
7008
+ res.writeHead(200, { "Content-Type": "application/json" });
7009
+ res.end(JSON.stringify(result));
7010
+ } catch (err) {
7011
+ console.error("[RelayBridge] Delivery failed:", err.message);
7012
+ res.writeHead(500, { "Content-Type": "application/json" });
7013
+ res.end(JSON.stringify({ error: err.message }));
7014
+ }
7015
+ }
7016
+ async submitToStalwart(payload) {
7017
+ const { from, to, subject, text, html, replyTo, inReplyTo, references } = payload;
7018
+ const recipients = Array.isArray(to) ? to : [to];
7019
+ debug("RelayBridge", `Submitting to Stalwart: ${from} \u2192 ${recipients.join(", ")}`);
7020
+ const transport = createTransport({
7021
+ host: this.options.smtpHost ?? "127.0.0.1",
7022
+ port: this.options.smtpPort ?? 587,
7023
+ secure: false,
7024
+ auth: {
7025
+ user: this.options.smtpUser,
7026
+ pass: this.options.smtpPass
7027
+ },
7028
+ tls: { rejectUnauthorized: false }
7029
+ });
7030
+ try {
7031
+ const info = await transport.sendMail({
7032
+ from,
7033
+ to: recipients.join(", "),
7034
+ subject,
7035
+ text: text || void 0,
7036
+ html: html || void 0,
7037
+ replyTo: replyTo || void 0,
7038
+ inReplyTo: inReplyTo || void 0,
7039
+ references: references || void 0,
7040
+ headers: {
7041
+ "X-Mailer": "AgenticMail/1.0"
7042
+ }
7043
+ });
7044
+ debug("RelayBridge", `Queued: ${info.messageId} \u2192 ${info.response}`);
7045
+ return {
7046
+ ok: true,
7047
+ messageId: info.messageId,
7048
+ response: info.response
7049
+ };
7050
+ } finally {
7051
+ transport.close();
6680
7052
  }
6681
- query += " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?";
6682
- params.push(limit, offset);
6683
- return this.db.prepare(query).all(...params).map((row) => ({
6684
- id: row.id,
6685
- agentId: row.agent_id,
6686
- direction: row.direction,
6687
- chatId: row.chat_id,
6688
- telegramMessageId: row.telegram_message_id ?? void 0,
6689
- fromId: row.from_id ?? void 0,
6690
- text: row.text,
6691
- status: row.status,
6692
- createdAt: row.created_at,
6693
- metadata: row.metadata ? JSON.parse(row.metadata) : void 0
6694
- }));
6695
7053
  }
6696
7054
  };
6697
-
6698
- // src/telegram/operator-query.ts
6699
- var TELEGRAM_OPERATOR_QUERY_TAG = "AMQ";
6700
- var QUERY_ID_RE = /(oq_[A-Za-z0-9-]+)/;
6701
- var QUERY_TAG_RE = new RegExp(`\\[${TELEGRAM_OPERATOR_QUERY_TAG}\\s+(oq_[A-Za-z0-9-]+)\\]`);
6702
- function formatOperatorQueryTelegramMessage(input) {
6703
- const lines = [];
6704
- lines.push(input.urgency === "high" ? "\u{1F534} Your agent needs an answer to continue a live call (URGENT)." : "\u{1F7E1} Your agent needs an answer to continue a live call.");
6705
- lines.push("");
6706
- lines.push(`Question: ${input.question}`);
6707
- if (input.callContext) lines.push(`Context: ${input.callContext}`);
6708
- lines.push("");
6709
- lines.push("Reply to this message with your answer. You can also send:");
6710
- lines.push(` /answer ${input.queryId} <your answer>`);
6711
- lines.push(` /approve ${input.queryId} \xB7 /deny ${input.queryId}`);
6712
- lines.push("");
6713
- lines.push(`[${TELEGRAM_OPERATOR_QUERY_TAG} ${input.queryId}]`);
6714
- return lines.join("\n");
7055
+ function startRelayBridge(options) {
7056
+ const bridge = new RelayBridge(options);
7057
+ bridge.start().catch((err) => {
7058
+ console.error("[RelayBridge] Failed to start:", err);
7059
+ });
7060
+ return bridge;
6715
7061
  }
6716
- function parseTelegramOperatorReply(input) {
6717
- const text = (input.text ?? "").trim();
6718
- if (!text) return null;
6719
- const quotedTag = input.replyToText ? QUERY_TAG_RE.exec(input.replyToText) : null;
6720
- const quotedQueryId = quotedTag?.[1];
6721
- const answerCmd = /^\/answer(?:@\w+)?\s+(oq_[A-Za-z0-9-]+)\s+([\s\S]+)$/i.exec(text);
6722
- if (answerCmd) {
6723
- return { queryId: answerCmd[1], answer: answerCmd[2].trim(), kind: "answer" };
6724
- }
6725
- const decisionCmd = /^\/(approve|deny)(?:@\w+)?\b([\s\S]*)$/i.exec(text);
6726
- if (decisionCmd) {
6727
- const kind = decisionCmd[1].toLowerCase() === "approve" ? "approve" : "deny";
6728
- const rest = decisionCmd[2].trim();
6729
- const inlineId2 = QUERY_ID_RE.exec(rest)?.[1];
6730
- const note = rest.replace(QUERY_ID_RE, "").trim();
6731
- const answer2 = (kind === "approve" ? "Approved" : "Denied") + (note ? `: ${note}` : ".");
6732
- return { queryId: inlineId2 ?? quotedQueryId, answer: answer2, kind };
7062
+
7063
+ // src/gateway/types.ts
7064
+ var RELAY_PRESETS = {
7065
+ gmail: {
7066
+ smtpHost: "smtp.gmail.com",
7067
+ smtpPort: 587,
7068
+ imapHost: "imap.gmail.com",
7069
+ imapPort: 993
7070
+ },
7071
+ outlook: {
7072
+ smtpHost: "smtp.office365.com",
7073
+ smtpPort: 587,
7074
+ imapHost: "outlook.office365.com",
7075
+ imapPort: 993
6733
7076
  }
6734
- const inlineId = QUERY_TAG_RE.exec(text)?.[1] ?? QUERY_ID_RE.exec(text)?.[1];
6735
- const answer = text.replace(QUERY_TAG_RE, "").trim();
6736
- if (!answer) return null;
6737
- return { queryId: quotedQueryId ?? inlineId, answer, kind: "answer" };
6738
- }
7077
+ };
6739
7078
 
6740
7079
  // src/phone/realtime.ts
6741
7080
  var ELKS_REALTIME_AUDIO_FORMATS = ["ulaw", "pcm_16000", "pcm_24000", "wav"];
@@ -6942,7 +7281,9 @@ var ElksRealtimeTransport = class {
6942
7281
  // Historical prefix — `elks-bye` / `elks-closed` etc. are matched by
6943
7282
  // long-standing call sites and tests; do not change.
6944
7283
  endReasonPrefix = "elks";
6945
- openaiAudioFormat = { type: "audio/pcm", rate: 24e3 };
7284
+ // OpenAI rejects `format.rate` as an unknown parameter — `audio/pcm` is
7285
+ // implicitly 24 kHz mono PCM16 in the current Realtime API.
7286
+ openaiAudioFormat = { type: "audio/pcm" };
6946
7287
  parseInbound(raw) {
6947
7288
  const msg = parseElksRealtimeMessage(raw);
6948
7289
  if (msg.t === "hello") {
@@ -6973,9 +7314,9 @@ var TwilioRealtimeTransport = class {
6973
7314
  provider = "twilio";
6974
7315
  endReasonPrefix = "twilio";
6975
7316
  // µ-law @ 8 kHz — Twilio's native format; no transcode end to end.
6976
- // > `audio/pcmu` is the OpenAI GA Realtime µ-law format token; verify
6977
- // > against current OpenAI docs before the live smoke-test.
6978
- openaiAudioFormat = { type: "audio/pcmu", rate: 8e3 };
7317
+ // OpenAI rejects `format.rate` as an unknown parameter; `audio/pcmu` is
7318
+ // implicitly 8 kHz G.711 µ-law in the current Realtime API.
7319
+ openaiAudioFormat = { type: "audio/pcmu" };
6979
7320
  /** Latched from the Twilio `start` frame; required on every outbound. */
6980
7321
  streamSid = "";
6981
7322
  /** The active `streamSid`, once the `start` frame has been seen. */
@@ -7335,7 +7676,7 @@ ${task}`);
7335
7676
  }
7336
7677
  return sections.join("\n\n");
7337
7678
  }
7338
- var DEFAULT_REALTIME_AUDIO_FORMAT = { type: "audio/pcm", rate: REALTIME_AUDIO_SAMPLE_RATE };
7679
+ var DEFAULT_REALTIME_AUDIO_FORMAT = { type: "audio/pcm" };
7339
7680
  function buildRealtimeSessionConfig(opts) {
7340
7681
  const tools = opts.tools ?? [];
7341
7682
  const instructions = opts.instructions?.trim() || buildRealtimeInstructions({
@@ -7438,6 +7779,7 @@ var RealtimeVoiceBridge = class {
7438
7779
  if (this.ended || this.openaiReady) return;
7439
7780
  this.openaiReady = true;
7440
7781
  this.safeSend(this.openai, this.sessionConfig);
7782
+ this.safeSend(this.openai, { type: "response.create" });
7441
7783
  for (const audio of this.pendingAudio.splice(0)) {
7442
7784
  this.safeSend(this.openai, { type: "input_audio_buffer.append", audio });
7443
7785
  }
@@ -8170,7 +8512,8 @@ function buildWebhookUrl(config, path2, missionId) {
8170
8512
  return url.toString();
8171
8513
  }
8172
8514
  function buildRealtimeStreamUrl(webhookBaseUrl, missionId, token) {
8173
- const url = new URL(`${apiBaseUrl(webhookBaseUrl)}${TWILIO_REALTIME_WS_PATH}`);
8515
+ const root = webhookBaseUrl.replace(/\/+$/, "");
8516
+ const url = new URL(`${root}${TWILIO_REALTIME_WS_PATH}`);
8174
8517
  url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
8175
8518
  url.searchParams.set("missionId", missionId);
8176
8519
  url.searchParams.set("token", token);