@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.cjs CHANGED
@@ -6049,1605 +6049,1944 @@ var SmsPoller = class {
6049
6049
  }
6050
6050
  };
6051
6051
 
6052
- // src/gateway/manager.ts
6053
- var GatewayManager = class {
6054
- constructor(options) {
6055
- this.options = options;
6056
- this.db = options.db;
6057
- this.stalwart = options.stalwart;
6058
- this.accountManager = options.accountManager ?? null;
6059
- this.encryptionKey = options.encryptionKey ?? process.env.AGENTICMAIL_MASTER_KEY ?? null;
6060
- const inboundHandler = options.onInboundMail ?? (this.accountManager && options.localSmtp ? this.deliverInboundLocally.bind(this) : void 0);
6061
- this.relay = new RelayGateway({
6062
- onInboundMail: inboundHandler,
6063
- defaultAgentName: DEFAULT_AGENT_NAME
6052
+ // src/telegram/client.ts
6053
+ var TELEGRAM_API_BASE = "https://api.telegram.org";
6054
+ var TELEGRAM_MESSAGE_LIMIT = 4096;
6055
+ var TELEGRAM_CHUNK_SIZE = 4e3;
6056
+ var TelegramApiError = class extends Error {
6057
+ isTelegramApiError = true;
6058
+ description;
6059
+ errorCode;
6060
+ constructor(method, description, errorCode) {
6061
+ super(`Telegram ${method} failed: ${description}${errorCode ? ` (code ${errorCode})` : ""}`);
6062
+ this.name = "TelegramApiError";
6063
+ this.description = description;
6064
+ this.errorCode = errorCode;
6065
+ }
6066
+ };
6067
+ function redactBotToken(text, token) {
6068
+ let out = typeof text === "string" ? text : String(text);
6069
+ if (token) out = out.split(token).join("bot***");
6070
+ return out.replace(/\d{6,}:[A-Za-z0-9_-]{30,}/g, "bot***");
6071
+ }
6072
+ async function callTelegramApi(token, method, body, options = {}) {
6073
+ if (!token || typeof token !== "string") {
6074
+ throw new TelegramApiError(method, "bot token is required");
6075
+ }
6076
+ const pollTimeout = typeof body?.timeout === "number" ? body.timeout : 0;
6077
+ const timeoutMs = options.longPoll && pollTimeout > 0 ? (pollTimeout + 15) * 1e3 : 3e4;
6078
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
6079
+ const signal = options.signal ? AbortSignal.any([timeoutSignal, options.signal]) : timeoutSignal;
6080
+ let response;
6081
+ try {
6082
+ response = await fetch(`${TELEGRAM_API_BASE}/bot${token}/${method}`, {
6083
+ method: "POST",
6084
+ headers: { "Content-Type": "application/json" },
6085
+ body: body ? JSON.stringify(body) : void 0,
6086
+ signal
6064
6087
  });
6065
- try {
6066
- this.loadConfig();
6067
- } catch {
6068
- this.config = { mode: "none" };
6069
- }
6070
- try {
6071
- this.smsManager = new SmsManager(options.db);
6072
- } catch {
6088
+ } catch (err) {
6089
+ throw new TelegramApiError(method, redactBotToken(err?.message ?? String(err), token));
6090
+ }
6091
+ let json;
6092
+ try {
6093
+ json = await response.json();
6094
+ } catch {
6095
+ throw new TelegramApiError(method, `non-JSON response (HTTP ${response.status})`);
6096
+ }
6097
+ if (!json || json.ok !== true) {
6098
+ throw new TelegramApiError(
6099
+ method,
6100
+ redactBotToken(String(json?.description || `HTTP ${response.status}`), token),
6101
+ typeof json?.error_code === "number" ? json.error_code : void 0
6102
+ );
6103
+ }
6104
+ return json.result;
6105
+ }
6106
+ function stripTelegramMarkdown(text) {
6107
+ if (!text) return text;
6108
+ 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();
6109
+ }
6110
+ function splitTelegramMessage(text, maxLen = TELEGRAM_CHUNK_SIZE) {
6111
+ const chunks = [];
6112
+ let rest = text || "";
6113
+ while (rest.length > maxLen) {
6114
+ let cut = rest.lastIndexOf("\n", maxLen);
6115
+ if (cut < maxLen / 2) cut = maxLen;
6116
+ chunks.push(rest.slice(0, cut));
6117
+ rest = rest.slice(cut).replace(/^\n+/, "");
6118
+ }
6119
+ if (rest) chunks.push(rest);
6120
+ return chunks;
6121
+ }
6122
+ async function sendTelegramMessage(token, chatId, text, options = {}) {
6123
+ const clean = stripTelegramMarkdown(text);
6124
+ const chunks = splitTelegramMessage(clean);
6125
+ if (chunks.length === 0) chunks.push("");
6126
+ const messageIds = [];
6127
+ for (let i = 0; i < chunks.length; i++) {
6128
+ const body = { chat_id: String(chatId), text: chunks[i] };
6129
+ if (i === 0 && options.replyToMessageId) {
6130
+ body.reply_parameters = { message_id: options.replyToMessageId };
6073
6131
  }
6132
+ if (options.disableNotification) body.disable_notification = true;
6133
+ const result = await callTelegramApi(token, "sendMessage", body);
6134
+ messageIds.push(result.message_id);
6074
6135
  }
6075
- db;
6076
- stalwart;
6077
- accountManager;
6078
- relay;
6079
- config = { mode: "none" };
6080
- cfClient = null;
6081
- tunnel = null;
6082
- dnsConfigurator = null;
6083
- domainPurchaser = null;
6084
- smsManager = null;
6085
- smsPollers = /* @__PURE__ */ new Map();
6086
- encryptionKey = null;
6087
- /**
6088
- * Check if a message has already been delivered to an agent (deduplication).
6089
- */
6090
- isAlreadyDelivered(messageId, agentName) {
6091
- if (!messageId) return false;
6092
- const row = this.db.prepare("SELECT 1 FROM delivered_messages WHERE message_id = ? AND agent_name = ?").get(messageId, agentName);
6093
- return !!row;
6136
+ return { messageIds, chunks: chunks.length };
6137
+ }
6138
+ function getTelegramMe(token) {
6139
+ return callTelegramApi(token, "getMe");
6140
+ }
6141
+ function getTelegramChat(token, chatId) {
6142
+ return callTelegramApi(token, "getChat", { chat_id: String(chatId) });
6143
+ }
6144
+ function getTelegramUpdates(token, offset, options = {}) {
6145
+ const timeoutSec = Math.max(options.timeoutSec ?? 0, 0);
6146
+ return callTelegramApi(token, "getUpdates", {
6147
+ offset,
6148
+ limit: Math.min(Math.max(options.limit ?? 100, 1), 100),
6149
+ timeout: timeoutSec,
6150
+ allowed_updates: ["message"]
6151
+ }, { longPoll: timeoutSec > 0, signal: options.signal });
6152
+ }
6153
+ function setTelegramWebhook(token, url, options = {}) {
6154
+ return callTelegramApi(token, "setWebhook", {
6155
+ url,
6156
+ secret_token: options.secretToken,
6157
+ allowed_updates: ["message"],
6158
+ drop_pending_updates: options.dropPendingUpdates ?? false
6159
+ });
6160
+ }
6161
+ function deleteTelegramWebhook(token) {
6162
+ return callTelegramApi(token, "deleteWebhook", {});
6163
+ }
6164
+ function getTelegramWebhookInfo(token) {
6165
+ return callTelegramApi(token, "getWebhookInfo");
6166
+ }
6167
+
6168
+ // src/telegram/update.ts
6169
+ function asTrimmed(value) {
6170
+ return typeof value === "string" ? value.trim() : "";
6171
+ }
6172
+ function normalizeChatType(type) {
6173
+ return type === "private" || type === "group" || type === "supergroup" || type === "channel" ? type : "unknown";
6174
+ }
6175
+ function parseTelegramUpdate(update) {
6176
+ if (!update || typeof update !== "object") return null;
6177
+ const u = update;
6178
+ if (typeof u.update_id !== "number") return null;
6179
+ const msg = u.message || u.channel_post;
6180
+ if (!msg || typeof msg !== "object") return null;
6181
+ if (typeof msg.message_id !== "number") return null;
6182
+ const chat = msg.chat || {};
6183
+ if (typeof chat.id !== "number" && typeof chat.id !== "string") return null;
6184
+ const text = asTrimmed(msg.text) || asTrimmed(msg.caption);
6185
+ if (!text) return null;
6186
+ const from = msg.from || {};
6187
+ const fromName = [from.first_name, from.last_name].filter((p) => typeof p === "string" && p).join(" ") || asTrimmed(from.username) || asTrimmed(chat.title) || "User";
6188
+ const replyTo = msg.reply_to_message;
6189
+ return {
6190
+ updateId: u.update_id,
6191
+ messageId: msg.message_id,
6192
+ chatId: String(chat.id),
6193
+ chatType: normalizeChatType(chat.type),
6194
+ chatTitle: asTrimmed(chat.title) || void 0,
6195
+ fromId: from.id != null ? String(from.id) : String(chat.id),
6196
+ fromName,
6197
+ fromUsername: asTrimmed(from.username) || void 0,
6198
+ text,
6199
+ replyToMessageId: replyTo && typeof replyTo.message_id === "number" ? replyTo.message_id : void 0,
6200
+ replyToText: replyTo ? asTrimmed(replyTo.text) || asTrimmed(replyTo.caption) || void 0 : void 0,
6201
+ date: typeof msg.date === "number" ? new Date(msg.date * 1e3).toISOString() : (/* @__PURE__ */ new Date()).toISOString()
6202
+ };
6203
+ }
6204
+ var TELEGRAM_STOP_WORDS = /* @__PURE__ */ new Set([
6205
+ "stop",
6206
+ "abort",
6207
+ "kill",
6208
+ "cancel",
6209
+ "halt"
6210
+ ]);
6211
+ function isTelegramStopCommand(text) {
6212
+ if (!text) return false;
6213
+ const cleaned = text.trim().toLowerCase().replace(/[!.?]+$/, "");
6214
+ return TELEGRAM_STOP_WORDS.has(cleaned);
6215
+ }
6216
+ function nextTelegramOffset(currentOffset, updates) {
6217
+ let next = currentOffset;
6218
+ for (const u of updates) {
6219
+ if (u && typeof u.update_id === "number" && u.update_id >= next) {
6220
+ next = u.update_id + 1;
6221
+ }
6094
6222
  }
6223
+ return next;
6224
+ }
6225
+
6226
+ // src/telegram/manager.ts
6227
+ var import_node_crypto3 = require("crypto");
6228
+ var TELEGRAM_WEBHOOK_SECRET_RE = /^[A-Za-z0-9_-]+$/;
6229
+ var TELEGRAM_MIN_WEBHOOK_SECRET_LENGTH = 16;
6230
+ var TELEGRAM_SECRET_FIELDS = ["botToken", "webhookSecret"];
6231
+ function redactTelegramConfig(config) {
6232
+ return {
6233
+ ...config,
6234
+ botToken: config.botToken ? "***" : config.botToken,
6235
+ webhookSecret: config.webhookSecret ? "***" : void 0
6236
+ };
6237
+ }
6238
+ function isTelegramChatAllowed(config, chatId) {
6239
+ const id = String(chatId ?? "").trim();
6240
+ if (!id) return false;
6241
+ if (config.operatorChatId && String(config.operatorChatId).trim() === id) return true;
6242
+ return Array.isArray(config.allowedChatIds) && config.allowedChatIds.some((c) => String(c).trim() === id);
6243
+ }
6244
+ function safeEqual(a, b) {
6245
+ const bufA = Buffer.from(a, "utf8");
6246
+ const bufB = Buffer.from(b, "utf8");
6247
+ if (bufA.length !== bufB.length) return false;
6248
+ return (0, import_node_crypto3.timingSafeEqual)(bufA, bufB);
6249
+ }
6250
+ var TelegramManager = class {
6095
6251
  /**
6096
- * Record that a message was delivered to an agent.
6252
+ * Optional master key used to encrypt Telegram credentials at rest
6253
+ * (the same AES-256-GCM scheme SMS/phone use). When absent (tests, or
6254
+ * a deployment with no master key) configs are stored as-is and reads
6255
+ * tolerate plaintext — upgrades and downgrades both stay safe.
6097
6256
  */
6098
- recordDelivery(messageId, agentName) {
6099
- if (!messageId) return;
6100
- this.db.prepare("INSERT OR IGNORE INTO delivered_messages (message_id, agent_name) VALUES (?, ?)").run(messageId, agentName);
6257
+ constructor(db2, encryptionKey) {
6258
+ this.db = db2;
6259
+ this.encryptionKey = encryptionKey;
6260
+ this.ensureTable();
6101
6261
  }
6102
- /**
6103
- * Built-in inbound mail handler: delivers relay inbound mail to agent's local Stalwart mailbox.
6104
- * Authenticates as the agent to send to their own mailbox (Stalwart requires sender = auth user).
6105
- *
6106
- * Also intercepts owner replies to approval notification emails — if the reply says
6107
- * "approve" or "reject", the pending outbound email is automatically processed.
6108
- */
6109
- async deliverInboundLocally(agentName, mail) {
6110
- if (!this.accountManager || !this.options.localSmtp) {
6111
- console.warn("[GatewayManager] Cannot deliver inbound: no accountManager or localSmtp config");
6112
- return;
6113
- }
6114
- if (mail.messageId && this.isAlreadyDelivered(mail.messageId, agentName)) return;
6115
- if (this.smsManager) {
6262
+ initialized = false;
6263
+ ensureTable() {
6264
+ if (this.initialized) return;
6265
+ try {
6266
+ this.db.exec(`
6267
+ CREATE TABLE IF NOT EXISTS telegram_messages (
6268
+ id TEXT PRIMARY KEY,
6269
+ agent_id TEXT NOT NULL,
6270
+ direction TEXT NOT NULL CHECK(direction IN ('inbound', 'outbound')),
6271
+ chat_id TEXT NOT NULL,
6272
+ telegram_message_id INTEGER,
6273
+ from_id TEXT,
6274
+ text TEXT NOT NULL,
6275
+ status TEXT NOT NULL DEFAULT 'pending',
6276
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
6277
+ metadata TEXT DEFAULT '{}'
6278
+ )
6279
+ `);
6116
6280
  try {
6117
- const smsBody = mail.text || mail.html || "";
6118
- const parsedSms = parseGoogleVoiceSms(smsBody, mail.from);
6119
- if (parsedSms) {
6120
- const agent2 = this.accountManager ? await this.accountManager.getByName(agentName) : null;
6121
- const agentId = agent2?.id;
6122
- if (agentId) {
6123
- const smsConfig = this.smsManager.getSmsConfig(agentId);
6124
- if (smsConfig?.enabled && smsConfig.sameAsRelay) {
6125
- this.smsManager.recordInbound(agentId, parsedSms);
6126
- console.log(`[GatewayManager] SMS received from ${parsedSms.from}: "${parsedSms.body.slice(0, 50)}..." \u2192 agent ${agentName}`);
6127
- if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
6128
- }
6129
- }
6130
- }
6131
- } catch (err) {
6132
- debug("GatewayManager", `SMS detection error: ${err.message}`);
6281
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_telegram_agent ON telegram_messages(agent_id)");
6282
+ } catch {
6133
6283
  }
6134
- }
6135
- try {
6136
- await this.tryProcessApprovalReply(mail);
6137
- } catch (err) {
6138
- console.warn(`[GatewayManager] Approval reply check failed: ${err.message}`);
6139
- }
6140
- const parsed = inboundToParsedEmail(mail);
6141
- const { isInternalEmail: isInternalEmail2 } = await Promise.resolve().then(() => (init_spam_filter(), spam_filter_exports));
6142
- if (!isInternalEmail2(parsed)) {
6143
- const spamResult = scoreEmail(parsed);
6144
- if (spamResult.isSpam) {
6145
- console.warn(`[GatewayManager] Spam blocked (score=${spamResult.score}, category=${spamResult.topCategory}): "${mail.subject}" from ${mail.from}`);
6146
- if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
6147
- return;
6284
+ try {
6285
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_telegram_chat ON telegram_messages(chat_id)");
6286
+ } catch {
6148
6287
  }
6149
- }
6150
- let agent = await this.accountManager.getByName(agentName);
6151
- if (!agent && agentName !== DEFAULT_AGENT_NAME) {
6152
- agent = await this.accountManager.getByName(DEFAULT_AGENT_NAME);
6153
- if (agent) {
6154
- console.warn(`[GatewayManager] Agent "${agentName}" not found, delivering to default agent "${DEFAULT_AGENT_NAME}"`);
6288
+ try {
6289
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_telegram_created ON telegram_messages(created_at)");
6290
+ } catch {
6155
6291
  }
6292
+ this.initialized = true;
6293
+ } catch {
6294
+ this.initialized = true;
6156
6295
  }
6157
- if (!agent) {
6158
- console.warn(`[GatewayManager] No agent to deliver inbound mail (target: "${agentName}")`);
6159
- return;
6160
- }
6161
- const agentPassword = agent.metadata?._password;
6162
- if (!agentPassword) {
6163
- console.warn(`[GatewayManager] No password for agent "${agentName}", cannot deliver`);
6164
- return;
6296
+ }
6297
+ /** Encrypt the credential fields of a config before persisting. */
6298
+ encryptConfig(config) {
6299
+ if (!this.encryptionKey) return config;
6300
+ const out = { ...config };
6301
+ for (const field of TELEGRAM_SECRET_FIELDS) {
6302
+ const value = out[field];
6303
+ if (typeof value === "string" && value && !isEncryptedSecret(value)) {
6304
+ out[field] = encryptSecret(value, this.encryptionKey);
6305
+ }
6165
6306
  }
6166
- const transport = import_nodemailer3.default.createTransport({
6167
- host: this.options.localSmtp.host,
6168
- port: this.options.localSmtp.port,
6169
- secure: false,
6170
- auth: {
6171
- user: agent.stalwartPrincipal,
6172
- pass: agentPassword
6173
- },
6174
- tls: { rejectUnauthorized: false },
6175
- connectionTimeout: 1e4,
6176
- greetingTimeout: 1e4,
6177
- socketTimeout: 15e3
6178
- });
6179
- try {
6180
- await transport.sendMail({
6181
- from: `${mail.from} <${agent.email}>`,
6182
- to: agent.email,
6183
- subject: mail.subject,
6184
- text: mail.text,
6185
- html: mail.html || void 0,
6186
- replyTo: mail.from,
6187
- inReplyTo: mail.inReplyTo,
6188
- references: Array.isArray(mail.references) ? mail.references.join(" ") : mail.references,
6189
- headers: {
6190
- "X-AgenticMail-Relay": "inbound",
6191
- "X-Original-From": mail.from,
6192
- ...mail.messageId ? { "X-Original-Message-Id": mail.messageId } : {}
6193
- },
6194
- attachments: mail.attachments?.map((a) => ({
6195
- filename: a.filename,
6196
- content: a.content,
6197
- contentType: a.contentType
6198
- }))
6199
- });
6200
- if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
6201
- } catch (err) {
6202
- console.error(`[GatewayManager] Failed to deliver to ${agent.email}: ${err.message}`);
6203
- throw err;
6204
- } finally {
6205
- transport.close();
6307
+ return out;
6308
+ }
6309
+ /** Decrypt the credential fields of a config after loading. */
6310
+ decryptConfig(config) {
6311
+ if (!this.encryptionKey) return config;
6312
+ const out = { ...config };
6313
+ for (const field of TELEGRAM_SECRET_FIELDS) {
6314
+ const value = out[field];
6315
+ if (typeof value === "string" && isEncryptedSecret(value)) {
6316
+ try {
6317
+ out[field] = decryptSecret(value, this.encryptionKey);
6318
+ } catch {
6319
+ }
6320
+ }
6206
6321
  }
6322
+ return out;
6323
+ }
6324
+ /** Normalize a stored/loaded config object, defaulting missing fields. */
6325
+ normalizeConfig(raw) {
6326
+ return {
6327
+ enabled: raw.enabled === true,
6328
+ botToken: typeof raw.botToken === "string" ? raw.botToken : "",
6329
+ botUsername: typeof raw.botUsername === "string" ? raw.botUsername : void 0,
6330
+ botId: typeof raw.botId === "number" ? raw.botId : void 0,
6331
+ allowedChatIds: Array.isArray(raw.allowedChatIds) ? raw.allowedChatIds.map((c) => String(c).trim()).filter(Boolean) : [],
6332
+ operatorChatId: typeof raw.operatorChatId === "string" && raw.operatorChatId.trim() ? raw.operatorChatId.trim() : void 0,
6333
+ mode: raw.mode === "webhook" ? "webhook" : "poll",
6334
+ webhookUrl: typeof raw.webhookUrl === "string" ? raw.webhookUrl : void 0,
6335
+ webhookSecret: typeof raw.webhookSecret === "string" ? raw.webhookSecret : void 0,
6336
+ pollOffset: typeof raw.pollOffset === "number" ? raw.pollOffset : 0,
6337
+ configuredAt: typeof raw.configuredAt === "string" ? raw.configuredAt : (/* @__PURE__ */ new Date()).toISOString()
6338
+ };
6207
6339
  }
6208
- /**
6209
- * Check if an inbound email is a reply to a pending approval notification.
6210
- * If the reply body starts with "approve"/"yes" or "reject"/"no", automatically
6211
- * process the pending email (send it or discard it) and confirm to the owner.
6212
- */
6213
- async tryProcessApprovalReply(mail) {
6214
- const candidateIds = [];
6215
- if (mail.inReplyTo) candidateIds.push(mail.inReplyTo);
6216
- if (mail.references) candidateIds.push(...mail.references);
6217
- if (candidateIds.length === 0) return false;
6218
- let row = null;
6219
- for (const id of candidateIds) {
6220
- row = this.db.prepare(
6221
- `SELECT * FROM pending_outbound WHERE notification_message_id = ? AND status = 'pending'`
6222
- ).get(id);
6223
- if (row) break;
6340
+ /** Get the Telegram config from agent metadata (credentials decrypted). */
6341
+ getConfig(agentId) {
6342
+ const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
6343
+ if (!row) return null;
6344
+ try {
6345
+ const meta = JSON.parse(row.metadata || "{}");
6346
+ if (!meta.telegram || typeof meta.telegram !== "object") return null;
6347
+ return this.decryptConfig(this.normalizeConfig(meta.telegram));
6348
+ } catch {
6349
+ return null;
6224
6350
  }
6225
- if (!row) return false;
6226
- const body = (mail.text || "").trim();
6227
- const lines = body.split("\n").filter((l) => !l.startsWith(">") && l.trim().length > 0);
6228
- const firstLine = (lines[0] || "").trim().toLowerCase();
6229
- const approvePattern = /^(approve[d]?|yes|send\s*it|send|go\s*ahead|lgtm|ok(?:ay)?)\b/;
6230
- const rejectPattern = /^(reject(?:ed)?|no|den(?:y|ied)|don'?t\s*send|do\s*not\s*send|cancel|block(?:ed)?)\b/;
6231
- let action = null;
6232
- if (approvePattern.test(firstLine)) {
6233
- action = "approve";
6234
- } else if (rejectPattern.test(firstLine)) {
6235
- action = "reject";
6351
+ }
6352
+ /** Save the Telegram config to agent metadata (credentials encrypted). */
6353
+ saveConfig(agentId, config) {
6354
+ const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
6355
+ if (!row) throw new Error(`Agent ${agentId} not found`);
6356
+ let meta;
6357
+ try {
6358
+ meta = JSON.parse(row.metadata || "{}");
6359
+ } catch {
6360
+ meta = {};
6236
6361
  }
6237
- if (!action) return false;
6362
+ meta.telegram = this.encryptConfig(config);
6363
+ this.db.prepare("UPDATE agents SET metadata = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(meta), agentId);
6364
+ }
6365
+ /** Remove the Telegram config from agent metadata. */
6366
+ removeConfig(agentId) {
6367
+ const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
6368
+ if (!row) return;
6369
+ let meta;
6238
6370
  try {
6239
- if (action === "approve") {
6240
- await this.executeApproval(row);
6241
- } else {
6242
- this.db.prepare(
6243
- `UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = 'owner-reply' WHERE id = ?`
6244
- ).run(row.id);
6245
- }
6246
- await this.sendApprovalConfirmation(row, action, mail.messageId);
6247
- } catch (err) {
6248
- console.error(`[GatewayManager] Failed to process approval reply for ${row.id}:`, err);
6371
+ meta = JSON.parse(row.metadata || "{}");
6372
+ } catch {
6373
+ meta = {};
6249
6374
  }
6250
- return true;
6375
+ delete meta.telegram;
6376
+ this.db.prepare("UPDATE agents SET metadata = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(meta), agentId);
6377
+ }
6378
+ /** Persist a new poll offset without touching the rest of the config. */
6379
+ updatePollOffset(agentId, offset) {
6380
+ const config = this.getConfig(agentId);
6381
+ if (!config) return;
6382
+ config.pollOffset = offset;
6383
+ this.saveConfig(agentId, config);
6251
6384
  }
6252
6385
  /**
6253
- * Execute approval of a pending outbound email: look up the agent, reconstitute
6254
- * attachments, and send the email via gateway routing or local SMTP.
6386
+ * Resolve the agent that owns a webhook secret. Used to authenticate +
6387
+ * route an inbound Telegram webhook delivery: a webhook carries no bot
6388
+ * identity, so the `X-Telegram-Bot-Api-Secret-Token` header is the
6389
+ * routing key. The comparison is constant-time, and a non-match
6390
+ * returns `null` so the route can answer with a single uniform 403
6391
+ * (no enumeration oracle — same posture as the SMS webhook).
6255
6392
  */
6256
- async executeApproval(row) {
6257
- if (!this.accountManager) {
6258
- throw new Error("AccountManager required for approval processing");
6259
- }
6260
- const agent = await this.accountManager.getById(row.agent_id);
6261
- if (!agent) {
6262
- console.warn(`[GatewayManager] Cannot approve pending ${row.id}: agent ${row.agent_id} no longer exists`);
6263
- this.db.prepare(
6264
- `UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = 'owner-reply', error = 'Agent no longer exists' WHERE id = ?`
6265
- ).run(row.id);
6266
- return;
6267
- }
6268
- const mailOpts = JSON.parse(row.mail_options);
6269
- const ownerName = agent.metadata?.ownerName;
6270
- mailOpts.fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
6271
- if (Array.isArray(mailOpts.attachments)) {
6272
- for (const att of mailOpts.attachments) {
6273
- if (att.content && typeof att.content === "object" && att.content.type === "Buffer" && Array.isArray(att.content.data)) {
6274
- att.content = Buffer.from(att.content.data);
6393
+ findAgentByWebhookSecret(secret) {
6394
+ const provided = String(secret ?? "");
6395
+ if (!provided) return null;
6396
+ const rows = this.db.prepare("SELECT id, metadata FROM agents").all();
6397
+ for (const row of rows) {
6398
+ try {
6399
+ const meta = JSON.parse(row.metadata || "{}");
6400
+ if (!meta.telegram || typeof meta.telegram !== "object") continue;
6401
+ const config = this.decryptConfig(this.normalizeConfig(meta.telegram));
6402
+ if (!config.enabled || !config.webhookSecret) continue;
6403
+ if (safeEqual(provided, config.webhookSecret)) {
6404
+ return { agentId: row.id, config };
6275
6405
  }
6406
+ } catch {
6276
6407
  }
6277
6408
  }
6278
- const gatewayResult = await this.routeOutbound(agent.name, mailOpts);
6279
- if (!gatewayResult && this.options.localSmtp) {
6280
- const agentPassword = agent.metadata?._password;
6281
- if (!agentPassword) {
6282
- throw new Error(`No password for agent "${agent.name}"`);
6283
- }
6284
- const transport = import_nodemailer3.default.createTransport({
6285
- host: this.options.localSmtp.host,
6286
- port: this.options.localSmtp.port,
6287
- secure: false,
6288
- auth: {
6289
- user: agent.stalwartPrincipal,
6290
- pass: agentPassword
6291
- },
6292
- tls: { rejectUnauthorized: false }
6293
- });
6294
- try {
6295
- await transport.sendMail({
6296
- from: mailOpts.fromName ? `${mailOpts.fromName} <${agent.email}>` : agent.email,
6297
- to: Array.isArray(mailOpts.to) ? mailOpts.to.join(", ") : mailOpts.to,
6298
- subject: mailOpts.subject,
6299
- text: mailOpts.text || void 0,
6300
- html: mailOpts.html || void 0,
6301
- cc: mailOpts.cc || void 0,
6302
- bcc: mailOpts.bcc || void 0,
6303
- replyTo: mailOpts.replyTo || void 0,
6304
- inReplyTo: mailOpts.inReplyTo || void 0,
6305
- references: Array.isArray(mailOpts.references) ? mailOpts.references.join(" ") : mailOpts.references || void 0,
6306
- attachments: mailOpts.attachments?.map((a) => ({
6307
- filename: a.filename,
6308
- content: a.content,
6309
- contentType: a.contentType,
6310
- encoding: a.encoding
6311
- }))
6312
- });
6313
- } finally {
6314
- transport.close();
6315
- }
6409
+ return null;
6410
+ }
6411
+ /** True if an inbound message with this Telegram id is already stored. */
6412
+ inboundMessageExists(agentId, chatId, telegramMessageId) {
6413
+ const row = this.db.prepare(
6414
+ "SELECT 1 FROM telegram_messages WHERE agent_id = ? AND direction = ? AND chat_id = ? AND telegram_message_id = ? LIMIT 1"
6415
+ ).get(agentId, "inbound", String(chatId), telegramMessageId);
6416
+ return !!row;
6417
+ }
6418
+ /** Record an inbound Telegram message. */
6419
+ recordInbound(agentId, input, metadata) {
6420
+ const id = `tg_in_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
6421
+ const createdAt = input.createdAt || (/* @__PURE__ */ new Date()).toISOString();
6422
+ this.db.prepare(
6423
+ "INSERT INTO telegram_messages (id, agent_id, direction, chat_id, telegram_message_id, from_id, text, status, created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
6424
+ ).run(
6425
+ id,
6426
+ agentId,
6427
+ "inbound",
6428
+ String(input.chatId),
6429
+ input.telegramMessageId,
6430
+ input.fromId ?? null,
6431
+ input.text,
6432
+ "received",
6433
+ createdAt,
6434
+ JSON.stringify(metadata ?? {})
6435
+ );
6436
+ return {
6437
+ id,
6438
+ agentId,
6439
+ direction: "inbound",
6440
+ chatId: String(input.chatId),
6441
+ telegramMessageId: input.telegramMessageId,
6442
+ fromId: input.fromId,
6443
+ text: input.text,
6444
+ status: "received",
6445
+ createdAt,
6446
+ metadata
6447
+ };
6448
+ }
6449
+ /** Record an outbound Telegram message attempt. */
6450
+ recordOutbound(agentId, input, metadata) {
6451
+ const id = `tg_out_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
6452
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
6453
+ const status = input.status ?? "sent";
6454
+ this.db.prepare(
6455
+ "INSERT INTO telegram_messages (id, agent_id, direction, chat_id, telegram_message_id, from_id, text, status, created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
6456
+ ).run(
6457
+ id,
6458
+ agentId,
6459
+ "outbound",
6460
+ String(input.chatId),
6461
+ input.telegramMessageId ?? null,
6462
+ null,
6463
+ input.text,
6464
+ status,
6465
+ createdAt,
6466
+ JSON.stringify(metadata ?? {})
6467
+ );
6468
+ return {
6469
+ id,
6470
+ agentId,
6471
+ direction: "outbound",
6472
+ chatId: String(input.chatId),
6473
+ telegramMessageId: input.telegramMessageId,
6474
+ text: input.text,
6475
+ status,
6476
+ createdAt,
6477
+ metadata
6478
+ };
6479
+ }
6480
+ /** Update the status (+ optional metadata) of a stored message. */
6481
+ updateStatus(id, status, metadata) {
6482
+ if (metadata) {
6483
+ this.db.prepare("UPDATE telegram_messages SET status = ?, metadata = ? WHERE id = ?").run(status, JSON.stringify(metadata), id);
6484
+ return;
6485
+ }
6486
+ this.db.prepare("UPDATE telegram_messages SET status = ? WHERE id = ?").run(status, id);
6487
+ }
6488
+ /** List stored Telegram messages for an agent, newest first. */
6489
+ listMessages(agentId, opts) {
6490
+ const limit = Math.min(Math.max(opts?.limit ?? 20, 1), 100);
6491
+ const offset = Math.max(opts?.offset ?? 0, 0);
6492
+ let query = "SELECT * FROM telegram_messages WHERE agent_id = ?";
6493
+ const params = [agentId];
6494
+ if (opts?.direction === "inbound" || opts?.direction === "outbound") {
6495
+ query += " AND direction = ?";
6496
+ params.push(opts.direction);
6316
6497
  }
6317
- this.db.prepare(
6318
- `UPDATE pending_outbound SET status = 'approved', resolved_at = datetime('now'), resolved_by = 'owner-reply' WHERE id = ?`
6319
- ).run(row.id);
6498
+ if (opts?.chatId) {
6499
+ query += " AND chat_id = ?";
6500
+ params.push(String(opts.chatId));
6501
+ }
6502
+ query += " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?";
6503
+ params.push(limit, offset);
6504
+ return this.db.prepare(query).all(...params).map((row) => ({
6505
+ id: row.id,
6506
+ agentId: row.agent_id,
6507
+ direction: row.direction,
6508
+ chatId: row.chat_id,
6509
+ telegramMessageId: row.telegram_message_id ?? void 0,
6510
+ fromId: row.from_id ?? void 0,
6511
+ text: row.text,
6512
+ status: row.status,
6513
+ createdAt: row.created_at,
6514
+ metadata: row.metadata ? JSON.parse(row.metadata) : void 0
6515
+ }));
6320
6516
  }
6517
+ };
6518
+
6519
+ // src/telegram/poller.ts
6520
+ var TELEGRAM_LONG_POLL_TIMEOUT_SEC = 25;
6521
+ var ERROR_BACKOFF_MAX_MS = 6e4;
6522
+ var ERROR_BACKOFF_BASE_MS = 2e3;
6523
+ var TelegramPoller = class {
6524
+ constructor(telegramManager, agentId, options = {}) {
6525
+ this.telegramManager = telegramManager;
6526
+ this.agentId = agentId;
6527
+ this.options = options;
6528
+ }
6529
+ running = false;
6530
+ currentAbort = null;
6531
+ /** Wakes a sleeping backoff so `stop()` returns quickly. */
6532
+ wakeStop = null;
6533
+ lastErrorLogAt = 0;
6534
+ lastErrorMessage = "";
6321
6535
  /**
6322
- * Send a confirmation email back to the owner after processing an approval reply.
6536
+ * Set by the caller. Fired for every new inbound message that isn't a
6537
+ * duplicate and is in the allow-list. The callback's return value
6538
+ * gates record-as-handled (errors propagate as failures the loop
6539
+ * tolerates — the poll offset is STILL advanced so a single bad
6540
+ * message doesn't wedge the agent forever).
6323
6541
  */
6324
- async sendApprovalConfirmation(row, action, replyMessageId) {
6325
- const ownerEmail = this.config.relay?.email;
6326
- if (!ownerEmail || !this.accountManager) return;
6327
- const mailOpts = JSON.parse(row.mail_options);
6328
- const agent = await this.accountManager.getById(row.agent_id);
6329
- const agentName = agent?.name || "unknown agent";
6330
- const statusText = action === "approve" ? "APPROVED and sent" : "REJECTED and discarded";
6331
- this.routeOutbound(agentName, {
6332
- to: ownerEmail,
6333
- subject: `Re: [Approval Required] Blocked email from "${agentName}" \u2014 ${statusText}`,
6334
- text: [
6335
- `The blocked email has been ${statusText}.`,
6336
- "",
6337
- ` To: ${Array.isArray(mailOpts.to) ? mailOpts.to.join(", ") : mailOpts.to}`,
6338
- ` Subject: ${mailOpts.subject}`,
6339
- "",
6340
- action === "approve" ? "The email has been delivered to the recipient." : "The email has been discarded and will not be sent."
6341
- ].join("\n"),
6342
- fromName: "Agentic Mail",
6343
- inReplyTo: replyMessageId
6344
- }).catch((err) => {
6345
- console.warn(`[GatewayManager] Failed to send approval confirmation: ${err.message}`);
6542
+ onInbound = null;
6543
+ /** Has `start()` been called and is the loop still running? */
6544
+ get isRunning() {
6545
+ return this.running;
6546
+ }
6547
+ /** Resolves when the background loop has fully exited. */
6548
+ loopPromise = null;
6549
+ async start() {
6550
+ if (this.running) return;
6551
+ this.running = true;
6552
+ this.loopPromise = this.loop().catch((err) => {
6553
+ this.running = false;
6554
+ console.warn(`[TelegramPoller:${this.agentId.slice(0, 8)}] loop crashed: ${err?.message ?? err}`);
6346
6555
  });
6347
6556
  }
6348
- // --- Relay Mode ---
6349
- async setupRelay(config, options) {
6350
- await this.relay.setup(config);
6351
- this.config = { mode: "relay", relay: config };
6352
- this.saveConfig();
6353
- let agent;
6354
- if (!options?.skipDefaultAgent && this.accountManager) {
6355
- const agentName = options?.defaultAgentName ?? DEFAULT_AGENT_NAME;
6356
- const agentRole = options?.defaultAgentRole ?? DEFAULT_AGENT_ROLE;
6357
- const existing = await this.accountManager.getByName(agentName);
6358
- if (existing) {
6359
- agent = existing;
6360
- } else {
6361
- agent = await this.accountManager.create({
6362
- name: agentName,
6363
- role: agentRole,
6364
- gateway: "relay"
6365
- });
6366
- }
6557
+ /** Cancel the in-flight long-poll and wait for the loop to exit. */
6558
+ async stop() {
6559
+ this.running = false;
6560
+ try {
6561
+ this.currentAbort?.abort();
6562
+ } catch {
6367
6563
  }
6368
- this.relay.onUidAdvance = (uid) => this.saveLastSeenUid(uid);
6369
- await this.relay.startPolling();
6370
- return { agent };
6371
- }
6372
- // --- Domain Mode ---
6373
- async setupDomain(options) {
6374
- this.cfClient = new CloudflareClient(options.cloudflareToken, options.cloudflareAccountId);
6375
- this.dnsConfigurator = new DNSConfigurator(this.cfClient);
6376
- this.tunnel = new TunnelManager(this.cfClient);
6377
- this.domainPurchaser = new DomainPurchaser(this.cfClient);
6378
- let domain = options.domain;
6379
- if (!domain && options.purchase) {
6380
- const available = await this.domainPurchaser.searchAvailable(
6381
- options.purchase.keywords,
6382
- options.purchase.tld ? [options.purchase.tld] : void 0
6383
- );
6384
- const first = available.find((d) => d.available && !d.premium);
6385
- if (!first) {
6386
- throw new Error("No available domains found for the given keywords");
6564
+ if (this.wakeStop) {
6565
+ try {
6566
+ this.wakeStop();
6567
+ } catch {
6387
6568
  }
6388
- await this.domainPurchaser.purchase(first.domain);
6389
- domain = first.domain;
6390
- this.db.prepare(`
6391
- INSERT OR REPLACE INTO purchased_domains (domain, registrar) VALUES (?, ?)
6392
- `).run(domain, "cloudflare");
6393
- }
6394
- if (!domain) {
6395
- throw new Error("No domain specified and no purchase keywords provided");
6396
- }
6397
- let zone = await this.cfClient.getZone(domain);
6398
- if (!zone) {
6399
- zone = await this.cfClient.createZone(domain);
6400
6569
  }
6401
- const existingRecords = await this.cfClient.listDnsRecords(zone.id);
6402
- const { homedir: homedir13 } = await import("os");
6403
- const backupDir = (0, import_node_path4.join)(homedir13(), ".agenticmail");
6404
- const backupPath = (0, import_node_path4.join)(backupDir, `dns-backup-${domain}-${Date.now()}.json`);
6405
- const { writeFileSync: writeFileSync11, mkdirSync: mkdirSync12 } = await import("fs");
6406
- mkdirSync12(backupDir, { recursive: true });
6407
- writeFileSync11(backupPath, JSON.stringify({
6408
- domain,
6409
- zoneId: zone.id,
6410
- backedUpAt: (/* @__PURE__ */ new Date()).toISOString(),
6411
- records: existingRecords
6412
- }, null, 2));
6413
- console.log(`[GatewayManager] DNS backup saved to ${backupPath} (${existingRecords.length} records)`);
6414
- const rootRecords = existingRecords.filter(
6415
- (r) => r.name === domain && (r.type === "A" || r.type === "AAAA" || r.type === "CNAME" || r.type === "MX")
6416
- );
6417
- if (rootRecords.length > 0) {
6418
- console.warn(`[GatewayManager] \u26A0\uFE0F WARNING: ${rootRecords.length} existing root DNS record(s) for ${domain} will be modified:`);
6419
- for (const r of rootRecords) {
6420
- console.warn(`[GatewayManager] ${r.type} ${r.name} \u2192 ${r.content}`);
6570
+ if (this.loopPromise) {
6571
+ try {
6572
+ await this.loopPromise;
6573
+ } catch {
6421
6574
  }
6422
- console.warn(`[GatewayManager] Backup saved at: ${backupPath}`);
6423
- }
6424
- const tunnelConfig = await this.tunnel.create(`agenticmail-${domain}`);
6425
- console.log(`[GatewayManager] Configuring mail server hostname: ${domain}`);
6426
- try {
6427
- await this.stalwart.setHostname(domain);
6428
- console.log(`[GatewayManager] Mail server hostname set to ${domain}`);
6429
- } catch (err) {
6430
- console.warn(`[GatewayManager] Failed to set hostname (EHLO may show "localhost"): ${err.message}`);
6431
- }
6432
- console.log("[GatewayManager] Setting up DKIM signing...");
6433
- let dkimPublicKey;
6434
- let dkimSelector = "agenticmail";
6435
- try {
6436
- const dkim = await this.stalwart.createDkimSignature(domain, dkimSelector);
6437
- dkimPublicKey = dkim.publicKey;
6438
- console.log(`[GatewayManager] DKIM signature created (selector: ${dkimSelector})`);
6439
- } catch (err) {
6440
- console.warn(`[GatewayManager] DKIM setup failed (email may land in spam): ${err.message}`);
6575
+ this.loopPromise = null;
6441
6576
  }
6442
- const tunnelRemoved = await this.dnsConfigurator.configureForTunnel(domain, zone.id, tunnelConfig.tunnelId);
6443
- if (tunnelRemoved.length > 0) {
6444
- console.log(`[GatewayManager] Removed ${tunnelRemoved.length} conflicting DNS record(s) for tunnel`);
6577
+ }
6578
+ async loop() {
6579
+ const timeoutSec = Math.max(1, this.options.timeoutSec ?? TELEGRAM_LONG_POLL_TIMEOUT_SEC);
6580
+ let backoff = ERROR_BACKOFF_BASE_MS;
6581
+ while (this.running) {
6582
+ const config = this.telegramManager.getConfig(this.agentId);
6583
+ if (!config?.enabled || config.mode !== "poll" || !config.botToken) {
6584
+ this.running = false;
6585
+ return;
6586
+ }
6587
+ const offset = config.pollOffset ?? 0;
6588
+ const controller = new AbortController();
6589
+ this.currentAbort = controller;
6590
+ try {
6591
+ const updates = await getTelegramUpdates(config.botToken, offset, {
6592
+ timeoutSec,
6593
+ signal: controller.signal
6594
+ });
6595
+ backoff = ERROR_BACKOFF_BASE_MS;
6596
+ if (updates.length === 0 && timeoutSec > 0) {
6597
+ await new Promise((r) => setImmediate(r));
6598
+ }
6599
+ for (const update of updates) {
6600
+ if (!this.running) break;
6601
+ const parsed = parseTelegramUpdate(update);
6602
+ if (!parsed) continue;
6603
+ if (!isTelegramChatAllowed(config, parsed.chatId)) continue;
6604
+ if (this.telegramManager.inboundMessageExists(this.agentId, parsed.chatId, parsed.messageId)) {
6605
+ continue;
6606
+ }
6607
+ this.telegramManager.recordInbound(this.agentId, {
6608
+ chatId: parsed.chatId,
6609
+ telegramMessageId: parsed.messageId,
6610
+ fromId: parsed.fromId,
6611
+ text: parsed.text,
6612
+ createdAt: parsed.date
6613
+ }, {
6614
+ chatType: parsed.chatType,
6615
+ fromName: parsed.fromName,
6616
+ fromUsername: parsed.fromUsername,
6617
+ updateId: parsed.updateId
6618
+ });
6619
+ if (this.onInbound) {
6620
+ try {
6621
+ await this.onInbound({ agentId: this.agentId, message: parsed, config });
6622
+ } catch (err) {
6623
+ this.logError("inbound bridge failed", err);
6624
+ }
6625
+ }
6626
+ }
6627
+ const newOffset = nextTelegramOffset(offset, updates);
6628
+ if (newOffset !== offset) {
6629
+ this.telegramManager.updatePollOffset(this.agentId, newOffset);
6630
+ }
6631
+ } catch (err) {
6632
+ if (!this.running) return;
6633
+ if (controller.signal.aborted) return;
6634
+ if (err instanceof TelegramApiError && (err.errorCode === 401 || err.errorCode === 404)) {
6635
+ this.logError("bot token rejected \u2014 stopping poller", err);
6636
+ this.running = false;
6637
+ return;
6638
+ }
6639
+ this.logError("getUpdates failed", err);
6640
+ await this.backoff(backoff);
6641
+ backoff = Math.min(backoff * 2, ERROR_BACKOFF_MAX_MS);
6642
+ } finally {
6643
+ if (this.currentAbort === controller) this.currentAbort = null;
6644
+ }
6445
6645
  }
6446
- const emailDns = await this.dnsConfigurator.configureForEmail(domain, zone.id, {
6447
- dkimSelector,
6448
- dkimPublicKey
6646
+ }
6647
+ /** Sleep that returns early on `stop()`. */
6648
+ backoff(ms) {
6649
+ return new Promise((resolve2) => {
6650
+ const t = setTimeout(() => {
6651
+ this.wakeStop = null;
6652
+ resolve2();
6653
+ }, ms);
6654
+ this.wakeStop = () => {
6655
+ clearTimeout(t);
6656
+ this.wakeStop = null;
6657
+ resolve2();
6658
+ };
6659
+ });
6660
+ }
6661
+ /** Collapse identical errors fired in close succession to one log line. */
6662
+ logError(prefix, err) {
6663
+ const msg = err instanceof Error ? err.message : String(err);
6664
+ const suppressMs = this.options.suppressDuplicateLogsMs ?? 3e4;
6665
+ const now = Date.now();
6666
+ if (msg === this.lastErrorMessage && now - this.lastErrorLogAt < suppressMs) return;
6667
+ this.lastErrorLogAt = now;
6668
+ this.lastErrorMessage = msg;
6669
+ console.warn(`[TelegramPoller:${this.agentId.slice(0, 8)}] ${prefix}: ${msg}`);
6670
+ }
6671
+ };
6672
+
6673
+ // src/telegram/operator-query.ts
6674
+ var TELEGRAM_OPERATOR_QUERY_TAG = "AMQ";
6675
+ var QUERY_ID_RE = /(oq_[A-Za-z0-9-]+)/;
6676
+ var QUERY_TAG_RE = new RegExp(`\\[${TELEGRAM_OPERATOR_QUERY_TAG}\\s+(oq_[A-Za-z0-9-]+)\\]`);
6677
+ function formatOperatorQueryTelegramMessage(input) {
6678
+ const lines = [];
6679
+ 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.");
6680
+ lines.push("");
6681
+ lines.push(`Question: ${input.question}`);
6682
+ if (input.callContext) lines.push(`Context: ${input.callContext}`);
6683
+ lines.push("");
6684
+ lines.push("Reply to this message with your answer. You can also send:");
6685
+ lines.push(` /answer ${input.queryId} <your answer>`);
6686
+ lines.push(` /approve ${input.queryId} \xB7 /deny ${input.queryId}`);
6687
+ lines.push("");
6688
+ lines.push(`[${TELEGRAM_OPERATOR_QUERY_TAG} ${input.queryId}]`);
6689
+ return lines.join("\n");
6690
+ }
6691
+ function parseTelegramOperatorReply(input) {
6692
+ const text = (input.text ?? "").trim();
6693
+ if (!text) return null;
6694
+ const quotedTag = input.replyToText ? QUERY_TAG_RE.exec(input.replyToText) : null;
6695
+ const quotedQueryId = quotedTag?.[1];
6696
+ const answerCmd = /^\/answer(?:@\w+)?\s+(oq_[A-Za-z0-9-]+)\s+([\s\S]+)$/i.exec(text);
6697
+ if (answerCmd) {
6698
+ return { queryId: answerCmd[1], answer: answerCmd[2].trim(), kind: "answer" };
6699
+ }
6700
+ const decisionCmd = /^\/(approve|deny)(?:@\w+)?\b([\s\S]*)$/i.exec(text);
6701
+ if (decisionCmd) {
6702
+ const kind = decisionCmd[1].toLowerCase() === "approve" ? "approve" : "deny";
6703
+ const rest = decisionCmd[2].trim();
6704
+ const inlineId2 = QUERY_ID_RE.exec(rest)?.[1];
6705
+ const note = rest.replace(QUERY_ID_RE, "").trim();
6706
+ const answer2 = (kind === "approve" ? "Approved" : "Denied") + (note ? `: ${note}` : ".");
6707
+ return { queryId: inlineId2 ?? quotedQueryId, answer: answer2, kind };
6708
+ }
6709
+ const inlineId = QUERY_TAG_RE.exec(text)?.[1] ?? QUERY_ID_RE.exec(text)?.[1];
6710
+ const answer = text.replace(QUERY_TAG_RE, "").trim();
6711
+ if (!answer) return null;
6712
+ return { queryId: quotedQueryId ?? inlineId, answer, kind: "answer" };
6713
+ }
6714
+
6715
+ // src/gateway/manager.ts
6716
+ var GatewayManager = class {
6717
+ constructor(options) {
6718
+ this.options = options;
6719
+ this.db = options.db;
6720
+ this.stalwart = options.stalwart;
6721
+ this.accountManager = options.accountManager ?? null;
6722
+ this.encryptionKey = options.encryptionKey ?? process.env.AGENTICMAIL_MASTER_KEY ?? null;
6723
+ const inboundHandler = options.onInboundMail ?? (this.accountManager && options.localSmtp ? this.deliverInboundLocally.bind(this) : void 0);
6724
+ this.relay = new RelayGateway({
6725
+ onInboundMail: inboundHandler,
6726
+ defaultAgentName: DEFAULT_AGENT_NAME
6449
6727
  });
6450
- if (emailDns.removed.length > 0) {
6451
- console.log(`[GatewayManager] Replaced ${emailDns.removed.length} old DNS record(s) for email`);
6452
- }
6453
- await this.tunnel.start(tunnelConfig.tunnelToken);
6454
- await this.tunnel.createIngress(tunnelConfig.tunnelId, domain);
6455
- console.log("[GatewayManager] Enabling Cloudflare Email Routing...");
6456
- try {
6457
- await this.cfClient.enableEmailRouting(zone.id);
6458
- console.log("[GatewayManager] Email Routing enabled");
6459
- } catch (err) {
6460
- console.warn(`[GatewayManager] Email Routing enable failed (may already be active): ${err.message}`);
6461
- }
6462
- const workerName = `agenticmail-inbound-${domain.replace(/\./g, "-")}`;
6463
- const inboundUrl = `https://${domain}/api/agenticmail/mail/inbound`;
6464
- const inboundSecret = options.outboundSecret || crypto.randomUUID();
6465
- console.log(`[GatewayManager] Deploying Email Worker "${workerName}"...`);
6466
- console.log(`[GatewayManager] Set AGENTICMAIL_INBOUND_SECRET="${inboundSecret}" in your environment to match the worker`);
6467
6728
  try {
6468
- const { EMAIL_WORKER_SCRIPT: EMAIL_WORKER_SCRIPT2 } = await Promise.resolve().then(() => (init_email_worker_template(), email_worker_template_exports));
6469
- await this.cfClient.deployEmailWorker(workerName, EMAIL_WORKER_SCRIPT2, {
6470
- INBOUND_URL: inboundUrl,
6471
- INBOUND_SECRET: inboundSecret
6472
- });
6473
- console.log(`[GatewayManager] Email Worker deployed: ${workerName}`);
6474
- } catch (err) {
6475
- console.warn(`[GatewayManager] Email Worker deployment failed: ${err.message}`);
6476
- console.warn("[GatewayManager] You may need to deploy the worker manually or check Workers permissions on your API token");
6729
+ this.loadConfig();
6730
+ } catch {
6731
+ this.config = { mode: "none" };
6477
6732
  }
6478
- console.log("[GatewayManager] Configuring catch-all Email Routing rule...");
6479
6733
  try {
6480
- await this.cfClient.setCatchAllWorkerRule(zone.id, workerName);
6481
- console.log("[GatewayManager] Catch-all rule set: all emails \u2192 Worker \u2192 AgenticMail");
6482
- } catch (err) {
6483
- console.warn(`[GatewayManager] Catch-all rule failed: ${err.message}`);
6734
+ this.smsManager = new SmsManager(options.db);
6735
+ } catch {
6484
6736
  }
6485
6737
  try {
6486
- await this.stalwart.createPrincipal({
6487
- type: "domain",
6488
- name: domain,
6489
- description: `AgenticMail gateway domain: ${domain}`
6490
- });
6738
+ this.telegramManager = new TelegramManager(options.db, this.encryptionKey ?? void 0);
6491
6739
  } catch {
6740
+ this.telegramManager = null;
6492
6741
  }
6493
- if (this.accountManager) {
6494
- try {
6495
- const agents = await this.accountManager.list();
6496
- for (const agent of agents) {
6497
- const domainEmail = `${agent.name.toLowerCase()}@${domain}`;
6498
- try {
6499
- const principal = await this.stalwart.getPrincipal(agent.stalwartPrincipal);
6500
- const emails = principal.emails ?? [];
6501
- if (!emails.includes(domainEmail)) {
6502
- await this.stalwart.addEmailAlias(agent.stalwartPrincipal, domainEmail);
6503
- console.log(`[GatewayManager] Added ${domainEmail} to Stalwart principal "${agent.stalwartPrincipal}"`);
6504
- }
6505
- } catch (err) {
6506
- console.warn(`[GatewayManager] Could not update principal for ${agent.name}: ${err.message}`);
6507
- }
6508
- }
6509
- } catch (err) {
6510
- console.warn(`[GatewayManager] Could not update agent email aliases: ${err.message}`);
6511
- }
6512
- }
6513
- const domainConfig = {
6514
- domain,
6515
- cloudflareApiToken: options.cloudflareToken,
6516
- cloudflareAccountId: options.cloudflareAccountId,
6517
- tunnelId: tunnelConfig.tunnelId,
6518
- tunnelToken: tunnelConfig.tunnelToken,
6519
- outboundWorkerUrl: options.outboundWorkerUrl,
6520
- outboundSecret: options.outboundSecret,
6521
- inboundSecret,
6522
- emailWorkerName: workerName
6523
- };
6524
- this.config = { mode: "domain", domain: domainConfig };
6525
- this.saveConfig();
6526
- this.db.prepare(`
6527
- INSERT OR REPLACE INTO purchased_domains (domain, registrar, cloudflare_zone_id, tunnel_id, dns_configured, tunnel_active)
6528
- VALUES (?, 'cloudflare', ?, ?, 1, 1)
6529
- `).run(domain, zone.id, tunnelConfig.tunnelId);
6530
- let outboundRelay;
6531
- const nextSteps = [];
6532
- if (options.gmailRelay) {
6533
- console.log("[GatewayManager] Configuring outbound relay through Gmail SMTP...");
6534
- try {
6535
- await this.stalwart.configureOutboundRelay({
6536
- smtpHost: "smtp.gmail.com",
6537
- smtpPort: 465,
6538
- username: options.gmailRelay.email,
6539
- password: options.gmailRelay.appPassword
6540
- });
6541
- outboundRelay = { configured: true, provider: "gmail" };
6542
- console.log("[GatewayManager] Outbound relay configured: all external mail routes through Gmail SMTP");
6543
- const gmailSettingsUrl = "https://mail.google.com/mail/u/0/#settings/accounts";
6544
- nextSteps.push(
6545
- `IMPORTANT: To send emails showing your domain (not ${options.gmailRelay.email}), add each agent email as a "Send mail as" alias in Gmail:`,
6546
- `1. Open: ${gmailSettingsUrl}`,
6547
- `2. Under "Send mail as" click "Add another email address"`,
6548
- `3. Enter agent name and email (e.g. "Secretary" / secretary@${domain}), uncheck "Treat as alias"`,
6549
- `4. On the SMTP screen, Gmail will auto-fill WRONG values. You MUST change them to:`,
6550
- ` SMTP Server: smtp.gmail.com | Port: 465 | Username: ${options.gmailRelay.email} | Password: [your app password] | Select "Secured connection using SSL"`,
6551
- `5. Click "Add Account". Gmail sends a verification email to the agent's @${domain} address`,
6552
- `6. Check AgenticMail inbox for the code/link from gmail-noreply@google.com, then confirm`,
6553
- `7. Repeat for each agent. Or ask your OpenClaw agent to automate this via the browser tool.`
6554
- );
6555
- } catch (err) {
6556
- outboundRelay = { configured: false, provider: "gmail" };
6557
- console.warn(`[GatewayManager] Outbound relay setup failed: ${err.message}`);
6558
- nextSteps.push(`Outbound relay setup failed: ${err.message}. You can configure it manually later.`);
6559
- }
6560
- } else {
6561
- nextSteps.push(
6562
- "Outbound email: Your server sends directly from your IP. If your IP lacks a PTR record (common for residential connections), emails may be rejected.",
6563
- 'To fix this, re-run setup with gmailRelay: { email: "you@gmail.com", appPassword: "xxxx xxxx xxxx xxxx" } to relay outbound mail through Gmail SMTP.',
6564
- "You will need a Gmail app password: https://myaccount.google.com/apppasswords"
6565
- );
6566
- }
6567
- return {
6568
- domain,
6569
- dnsConfigured: true,
6570
- tunnelId: tunnelConfig.tunnelId,
6571
- outboundRelay,
6572
- nextSteps: nextSteps.length > 0 ? nextSteps : void 0
6573
- };
6574
6742
  }
6575
- // --- Test ---
6743
+ db;
6744
+ stalwart;
6745
+ accountManager;
6746
+ relay;
6747
+ config = { mode: "none" };
6748
+ cfClient = null;
6749
+ tunnel = null;
6750
+ dnsConfigurator = null;
6751
+ domainPurchaser = null;
6752
+ smsManager = null;
6753
+ smsPollers = /* @__PURE__ */ new Map();
6754
+ telegramManager = null;
6755
+ telegramPollers = /* @__PURE__ */ new Map();
6756
+ encryptionKey = null;
6576
6757
  /**
6577
- * Send a test email through the gateway without requiring a real agent.
6578
- * In relay mode, uses "test" as the sub-address.
6579
- * In domain mode, uses the first available agent (Stalwart needs real credentials).
6758
+ * Check if a message has already been delivered to an agent (deduplication).
6580
6759
  */
6581
- async sendTestEmail(to) {
6582
- const mail = {
6583
- to,
6584
- subject: "AgenticMail Gateway Test",
6585
- text: "This is a test email sent via the AgenticMail gateway to verify your configuration is working."
6586
- };
6587
- if (this.config.mode === "relay") {
6588
- return this.routeOutbound("test", mail);
6589
- }
6590
- if (this.config.mode === "domain" && this.accountManager) {
6591
- const agents = await this.accountManager.list();
6592
- if (agents.length === 0) {
6593
- throw new Error("No agents exist yet. Create an agent first, then send a test email.");
6594
- }
6595
- const primary = agents.find((a) => a.metadata?.persistent) ?? agents[0];
6596
- return this.routeOutbound(primary.name, mail);
6597
- }
6598
- return null;
6760
+ isAlreadyDelivered(messageId, agentName) {
6761
+ if (!messageId) return false;
6762
+ const row = this.db.prepare("SELECT 1 FROM delivered_messages WHERE message_id = ? AND agent_name = ?").get(messageId, agentName);
6763
+ return !!row;
6599
6764
  }
6600
- // --- Routing ---
6601
6765
  /**
6602
- * Route an outbound email. If the destination is external and a gateway
6603
- * is configured, send via the appropriate channel.
6604
- * Returns null if the mail should be sent via local Stalwart.
6766
+ * Record that a message was delivered to an agent.
6605
6767
  */
6606
- async routeOutbound(agentName, mail) {
6607
- if (this.config.mode === "none") return null;
6608
- const collect = (field) => {
6609
- if (!field) return [];
6610
- if (Array.isArray(field)) return field;
6611
- return field.split(",").map((s) => s.trim()).filter(Boolean);
6612
- };
6613
- const allRecipients = [
6614
- ...collect(mail.to),
6615
- ...collect(mail.cc),
6616
- ...collect(mail.bcc)
6617
- ];
6618
- const localDomain = this.config.domain?.domain?.toLowerCase();
6619
- const isExternal = allRecipients.some((addr) => {
6620
- const domain = (addr.split("@")[1] ?? "localhost").toLowerCase();
6621
- return domain !== "localhost" && domain !== localDomain;
6622
- });
6623
- if (!isExternal) return null;
6624
- if (this.config.mode === "relay") {
6625
- return this.relay.sendViaRelay(agentName, mail);
6626
- }
6627
- if (this.config.mode === "domain" && this.config.domain) {
6628
- return this.sendViaStalwart(agentName, mail);
6629
- }
6630
- return null;
6768
+ recordDelivery(messageId, agentName) {
6769
+ if (!messageId) return;
6770
+ this.db.prepare("INSERT OR IGNORE INTO delivered_messages (message_id, agent_name) VALUES (?, ?)").run(messageId, agentName);
6631
6771
  }
6632
6772
  /**
6633
- * Send email by submitting to local Stalwart via SMTP (port 587).
6634
- * Stalwart handles DKIM signing and delivery (direct or via relay).
6635
- * Reply-To is set to the agent's domain email so replies come back
6636
- * to the domain (handled by Cloudflare Email Routing inbound Worker).
6773
+ * Built-in inbound mail handler: delivers relay inbound mail to agent's local Stalwart mailbox.
6774
+ * Authenticates as the agent to send to their own mailbox (Stalwart requires sender = auth user).
6775
+ *
6776
+ * Also intercepts owner replies to approval notification emails if the reply says
6777
+ * "approve" or "reject", the pending outbound email is automatically processed.
6637
6778
  */
6638
- async sendViaStalwart(agentName, mail) {
6639
- if (!this.accountManager) {
6640
- throw new Error("AccountManager required for domain mode outbound");
6641
- }
6642
- const agent = await this.accountManager.getByName(agentName);
6643
- if (!agent) {
6644
- throw new Error(`Agent "${agentName}" not found`);
6645
- }
6646
- const agentPassword = agent.metadata?._password;
6647
- if (!agentPassword) {
6648
- throw new Error(`No password for agent "${agentName}"`);
6779
+ async deliverInboundLocally(agentName, mail) {
6780
+ if (!this.accountManager || !this.options.localSmtp) {
6781
+ console.warn("[GatewayManager] Cannot deliver inbound: no accountManager or localSmtp config");
6782
+ return;
6649
6783
  }
6650
- const domainName = this.config.domain?.domain;
6651
- const fromAddr = domainName ? agent.email.replace(/@localhost$/, `@${domainName}`) : agent.email;
6652
- const displayName = mail.fromName || agentName;
6653
- const from = `${displayName} <${fromAddr}>`;
6654
- if (domainName && fromAddr !== agent.email) {
6784
+ if (mail.messageId && this.isAlreadyDelivered(mail.messageId, agentName)) return;
6785
+ if (this.smsManager) {
6655
6786
  try {
6656
- const principal = await this.stalwart.getPrincipal(agent.stalwartPrincipal);
6657
- const emails = principal.emails ?? [];
6658
- if (!emails.includes(fromAddr)) {
6659
- await this.stalwart.addEmailAlias(agent.stalwartPrincipal, fromAddr);
6660
- console.log(`[GatewayManager] Auto-added ${fromAddr} to Stalwart principal "${agent.stalwartPrincipal}"`);
6787
+ const smsBody = mail.text || mail.html || "";
6788
+ const parsedSms = parseGoogleVoiceSms(smsBody, mail.from);
6789
+ if (parsedSms) {
6790
+ const agent2 = this.accountManager ? await this.accountManager.getByName(agentName) : null;
6791
+ const agentId = agent2?.id;
6792
+ if (agentId) {
6793
+ const smsConfig = this.smsManager.getSmsConfig(agentId);
6794
+ if (smsConfig?.enabled && smsConfig.sameAsRelay) {
6795
+ this.smsManager.recordInbound(agentId, parsedSms);
6796
+ console.log(`[GatewayManager] SMS received from ${parsedSms.from}: "${parsedSms.body.slice(0, 50)}..." \u2192 agent ${agentName}`);
6797
+ if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
6798
+ }
6799
+ }
6661
6800
  }
6662
6801
  } catch (err) {
6663
- console.warn(`[GatewayManager] Could not auto-add domain email alias: ${err.message}`);
6802
+ debug("GatewayManager", `SMS detection error: ${err.message}`);
6803
+ }
6804
+ }
6805
+ try {
6806
+ await this.tryProcessApprovalReply(mail);
6807
+ } catch (err) {
6808
+ console.warn(`[GatewayManager] Approval reply check failed: ${err.message}`);
6809
+ }
6810
+ const parsed = inboundToParsedEmail(mail);
6811
+ const { isInternalEmail: isInternalEmail2 } = await Promise.resolve().then(() => (init_spam_filter(), spam_filter_exports));
6812
+ if (!isInternalEmail2(parsed)) {
6813
+ const spamResult = scoreEmail(parsed);
6814
+ if (spamResult.isSpam) {
6815
+ console.warn(`[GatewayManager] Spam blocked (score=${spamResult.score}, category=${spamResult.topCategory}): "${mail.subject}" from ${mail.from}`);
6816
+ if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
6817
+ return;
6818
+ }
6819
+ }
6820
+ let agent = await this.accountManager.getByName(agentName);
6821
+ if (!agent && agentName !== DEFAULT_AGENT_NAME) {
6822
+ agent = await this.accountManager.getByName(DEFAULT_AGENT_NAME);
6823
+ if (agent) {
6824
+ console.warn(`[GatewayManager] Agent "${agentName}" not found, delivering to default agent "${DEFAULT_AGENT_NAME}"`);
6664
6825
  }
6665
6826
  }
6666
- const recipients = Array.isArray(mail.to) ? mail.to : [mail.to];
6667
- const mailOpts = {
6668
- from,
6669
- to: recipients.join(", "),
6670
- cc: mail.cc ? Array.isArray(mail.cc) ? mail.cc.join(", ") : mail.cc : void 0,
6671
- bcc: mail.bcc ? Array.isArray(mail.bcc) ? mail.bcc.join(", ") : mail.bcc : void 0,
6672
- subject: mail.subject,
6673
- text: mail.text || void 0,
6674
- // The `html` field is the literal HTML body of the outbound
6675
- // mail — by design it is whatever the sender chose to compose.
6676
- // CodeQL `js/xss` flags this because the value flows from user
6677
- // input, but nodemailer is the SMTP serializer, not an HTML
6678
- // renderer; XSS would only occur if the recipient's MUA
6679
- // executed the body, which is outside our trust boundary.
6680
- // The outbound-guard (packages/core/src/mail/outbound-guard.ts)
6681
- // already scores HTML bodies for suspicious patterns at the
6682
- // pre-send step. lgtm[js/xss]
6683
- html: mail.html || void 0,
6684
- replyTo: mail.replyTo || from,
6685
- inReplyTo: mail.inReplyTo || void 0,
6686
- references: Array.isArray(mail.references) ? mail.references.join(" ") : mail.references || void 0,
6687
- headers: {
6688
- "X-Mailer": "AgenticMail/1.0"
6689
- },
6690
- attachments: mail.attachments?.map((a) => ({
6691
- filename: a.filename,
6692
- content: a.content,
6693
- contentType: a.contentType,
6694
- encoding: a.encoding
6695
- }))
6696
- };
6697
- const composer = new import_mail_composer3.default(mailOpts);
6698
- const raw = await composer.compile().build();
6699
- const smtpHost = this.options.localSmtp?.host ?? "127.0.0.1";
6700
- const smtpPort = this.options.localSmtp?.port ?? 587;
6827
+ if (!agent) {
6828
+ console.warn(`[GatewayManager] No agent to deliver inbound mail (target: "${agentName}")`);
6829
+ return;
6830
+ }
6831
+ const agentPassword = agent.metadata?._password;
6832
+ if (!agentPassword) {
6833
+ console.warn(`[GatewayManager] No password for agent "${agentName}", cannot deliver`);
6834
+ return;
6835
+ }
6701
6836
  const transport = import_nodemailer3.default.createTransport({
6702
- host: smtpHost,
6703
- port: smtpPort,
6837
+ host: this.options.localSmtp.host,
6838
+ port: this.options.localSmtp.port,
6704
6839
  secure: false,
6705
6840
  auth: {
6706
6841
  user: agent.stalwartPrincipal,
6707
6842
  pass: agentPassword
6708
6843
  },
6709
- tls: { rejectUnauthorized: false }
6844
+ tls: { rejectUnauthorized: false },
6845
+ connectionTimeout: 1e4,
6846
+ greetingTimeout: 1e4,
6847
+ socketTimeout: 15e3
6710
6848
  });
6711
6849
  try {
6712
- const info = await transport.sendMail(mailOpts);
6713
- debug("GatewayManager", `Sent via Stalwart: ${info.messageId} \u2192 ${info.response}`);
6714
- return {
6715
- messageId: info.messageId,
6716
- envelope: { from, to: recipients },
6717
- raw
6718
- };
6850
+ await transport.sendMail({
6851
+ from: `${mail.from} <${agent.email}>`,
6852
+ to: agent.email,
6853
+ subject: mail.subject,
6854
+ text: mail.text,
6855
+ html: mail.html || void 0,
6856
+ replyTo: mail.from,
6857
+ inReplyTo: mail.inReplyTo,
6858
+ references: Array.isArray(mail.references) ? mail.references.join(" ") : mail.references,
6859
+ headers: {
6860
+ "X-AgenticMail-Relay": "inbound",
6861
+ "X-Original-From": mail.from,
6862
+ ...mail.messageId ? { "X-Original-Message-Id": mail.messageId } : {}
6863
+ },
6864
+ attachments: mail.attachments?.map((a) => ({
6865
+ filename: a.filename,
6866
+ content: a.content,
6867
+ contentType: a.contentType
6868
+ }))
6869
+ });
6870
+ if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
6871
+ } catch (err) {
6872
+ console.error(`[GatewayManager] Failed to deliver to ${agent.email}: ${err.message}`);
6873
+ throw err;
6719
6874
  } finally {
6720
6875
  transport.close();
6721
6876
  }
6722
6877
  }
6723
- // --- Status ---
6724
- getStatus() {
6725
- const status = {
6726
- mode: this.config.mode,
6727
- healthy: false
6728
- };
6729
- if (this.config.mode === "relay" && this.config.relay) {
6730
- status.relay = {
6731
- provider: this.config.relay.provider,
6732
- email: this.config.relay.email,
6733
- polling: this.relay.isPolling()
6734
- };
6735
- status.healthy = this.relay.isConfigured();
6736
- }
6737
- if (this.config.mode === "domain" && this.config.domain) {
6738
- const tunnelStatus = this.tunnel?.status();
6739
- status.domain = {
6740
- domain: this.config.domain.domain,
6741
- dnsConfigured: true,
6742
- tunnelActive: tunnelStatus?.running ?? false
6743
- };
6744
- status.healthy = tunnelStatus?.running ?? false;
6745
- }
6746
- if (this.config.mode === "none") {
6747
- status.healthy = true;
6748
- }
6749
- return status;
6750
- }
6751
- getMode() {
6752
- return this.config.mode;
6753
- }
6754
- getConfig() {
6755
- return this.config;
6756
- }
6757
- // --- Domain Purchase ---
6758
- getStalwart() {
6759
- return this.stalwart;
6760
- }
6761
- getDomainPurchaser() {
6762
- return this.domainPurchaser;
6763
- }
6764
- getDNSConfigurator() {
6765
- return this.dnsConfigurator;
6766
- }
6767
- getTunnelManager() {
6768
- return this.tunnel;
6769
- }
6770
- getRelay() {
6771
- return this.relay;
6772
- }
6773
- /**
6774
- * Search the connected relay account (Gmail/Outlook) for emails matching criteria.
6775
- * Returns empty array if relay is not configured.
6776
- */
6777
- async searchRelay(criteria, maxResults = 50) {
6778
- if (this.config.mode !== "relay" || !this.relay.isConfigured()) return [];
6779
- return this.relay.searchRelay(criteria, maxResults);
6780
- }
6781
6878
  /**
6782
- * Import an email from the connected relay account into an agent's local inbox.
6783
- * Fetches the full message from relay IMAP and delivers it locally, preserving
6784
- * all headers (Message-ID, In-Reply-To, References) for thread continuity.
6879
+ * Check if an inbound email is a reply to a pending approval notification.
6880
+ * If the reply body starts with "approve"/"yes" or "reject"/"no", automatically
6881
+ * process the pending email (send it or discard it) and confirm to the owner.
6785
6882
  */
6786
- async importRelayMessage(relayUid, agentName) {
6787
- if (this.config.mode !== "relay" || !this.relay.isConfigured()) {
6788
- return { success: false, error: "Relay not configured" };
6883
+ async tryProcessApprovalReply(mail) {
6884
+ const candidateIds = [];
6885
+ if (mail.inReplyTo) candidateIds.push(mail.inReplyTo);
6886
+ if (mail.references) candidateIds.push(...mail.references);
6887
+ if (candidateIds.length === 0) return false;
6888
+ let row = null;
6889
+ for (const id of candidateIds) {
6890
+ row = this.db.prepare(
6891
+ `SELECT * FROM pending_outbound WHERE notification_message_id = ? AND status = 'pending'`
6892
+ ).get(id);
6893
+ if (row) break;
6789
6894
  }
6790
- const mail = await this.relay.fetchRelayMessage(relayUid);
6791
- if (!mail) {
6792
- return { success: false, error: "Could not fetch message from relay account" };
6895
+ if (!row) return false;
6896
+ const body = (mail.text || "").trim();
6897
+ const lines = body.split("\n").filter((l) => !l.startsWith(">") && l.trim().length > 0);
6898
+ const firstLine = (lines[0] || "").trim().toLowerCase();
6899
+ const approvePattern = /^(approve[d]?|yes|send\s*it|send|go\s*ahead|lgtm|ok(?:ay)?)\b/;
6900
+ const rejectPattern = /^(reject(?:ed)?|no|den(?:y|ied)|don'?t\s*send|do\s*not\s*send|cancel|block(?:ed)?)\b/;
6901
+ let action = null;
6902
+ if (approvePattern.test(firstLine)) {
6903
+ action = "approve";
6904
+ } else if (rejectPattern.test(firstLine)) {
6905
+ action = "reject";
6793
6906
  }
6907
+ if (!action) return false;
6794
6908
  try {
6795
- await this.deliverInboundLocally(agentName, mail);
6796
- return { success: true };
6909
+ if (action === "approve") {
6910
+ await this.executeApproval(row);
6911
+ } else {
6912
+ this.db.prepare(
6913
+ `UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = 'owner-reply' WHERE id = ?`
6914
+ ).run(row.id);
6915
+ }
6916
+ await this.sendApprovalConfirmation(row, action, mail.messageId);
6797
6917
  } catch (err) {
6798
- return { success: false, error: err instanceof Error ? err.message : String(err) };
6918
+ console.error(`[GatewayManager] Failed to process approval reply for ${row.id}:`, err);
6799
6919
  }
6920
+ return true;
6800
6921
  }
6801
- // --- SMS Polling ---
6802
6922
  /**
6803
- * Start SMS pollers for all agents that have separate GV Gmail credentials.
6804
- * Agents with sameAsRelay=true are handled in deliverInboundLocally.
6923
+ * Execute approval of a pending outbound email: look up the agent, reconstitute
6924
+ * attachments, and send the email via gateway routing or local SMTP.
6805
6925
  */
6806
- async startSmsPollers() {
6807
- if (!this.smsManager || !this.accountManager) return;
6808
- const agents = this.db.prepare("SELECT id, name, metadata FROM agents").all();
6809
- for (const agent of agents) {
6926
+ async executeApproval(row) {
6927
+ if (!this.accountManager) {
6928
+ throw new Error("AccountManager required for approval processing");
6929
+ }
6930
+ const agent = await this.accountManager.getById(row.agent_id);
6931
+ if (!agent) {
6932
+ console.warn(`[GatewayManager] Cannot approve pending ${row.id}: agent ${row.agent_id} no longer exists`);
6933
+ this.db.prepare(
6934
+ `UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = 'owner-reply', error = 'Agent no longer exists' WHERE id = ?`
6935
+ ).run(row.id);
6936
+ return;
6937
+ }
6938
+ const mailOpts = JSON.parse(row.mail_options);
6939
+ const ownerName = agent.metadata?.ownerName;
6940
+ mailOpts.fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
6941
+ if (Array.isArray(mailOpts.attachments)) {
6942
+ for (const att of mailOpts.attachments) {
6943
+ if (att.content && typeof att.content === "object" && att.content.type === "Buffer" && Array.isArray(att.content.data)) {
6944
+ att.content = Buffer.from(att.content.data);
6945
+ }
6946
+ }
6947
+ }
6948
+ const gatewayResult = await this.routeOutbound(agent.name, mailOpts);
6949
+ if (!gatewayResult && this.options.localSmtp) {
6950
+ const agentPassword = agent.metadata?._password;
6951
+ if (!agentPassword) {
6952
+ throw new Error(`No password for agent "${agent.name}"`);
6953
+ }
6954
+ const transport = import_nodemailer3.default.createTransport({
6955
+ host: this.options.localSmtp.host,
6956
+ port: this.options.localSmtp.port,
6957
+ secure: false,
6958
+ auth: {
6959
+ user: agent.stalwartPrincipal,
6960
+ pass: agentPassword
6961
+ },
6962
+ tls: { rejectUnauthorized: false }
6963
+ });
6810
6964
  try {
6811
- const meta = JSON.parse(agent.metadata || "{}");
6812
- const smsConfig = meta.sms;
6813
- if (!smsConfig?.enabled || !smsConfig.forwardingPassword || smsConfig.sameAsRelay) continue;
6814
- const poller = new SmsPoller(this.smsManager, agent.id, smsConfig);
6815
- poller.onSmsReceived = (agentId, sms) => {
6816
- console.log(`[SmsPoller] SMS received for agent ${agent.name}: from ${sms.from}, body="${sms.body.slice(0, 50)}..."`);
6817
- };
6818
- this.smsPollers.set(agent.id, poller);
6819
- await poller.startPolling();
6820
- console.log(`[GatewayManager] SMS poller started for agent "${agent.name}" (${smsConfig.forwardingEmail})`);
6821
- } catch {
6965
+ await transport.sendMail({
6966
+ from: mailOpts.fromName ? `${mailOpts.fromName} <${agent.email}>` : agent.email,
6967
+ to: Array.isArray(mailOpts.to) ? mailOpts.to.join(", ") : mailOpts.to,
6968
+ subject: mailOpts.subject,
6969
+ text: mailOpts.text || void 0,
6970
+ html: mailOpts.html || void 0,
6971
+ cc: mailOpts.cc || void 0,
6972
+ bcc: mailOpts.bcc || void 0,
6973
+ replyTo: mailOpts.replyTo || void 0,
6974
+ inReplyTo: mailOpts.inReplyTo || void 0,
6975
+ references: Array.isArray(mailOpts.references) ? mailOpts.references.join(" ") : mailOpts.references || void 0,
6976
+ attachments: mailOpts.attachments?.map((a) => ({
6977
+ filename: a.filename,
6978
+ content: a.content,
6979
+ contentType: a.contentType,
6980
+ encoding: a.encoding
6981
+ }))
6982
+ });
6983
+ } finally {
6984
+ transport.close();
6822
6985
  }
6823
6986
  }
6824
- }
6825
- // --- Lifecycle ---
6826
- async shutdown() {
6827
- await this.relay.shutdown();
6828
- this.tunnel?.stop();
6829
- for (const poller of this.smsPollers.values()) {
6830
- poller.stopPolling();
6831
- }
6832
- this.smsPollers.clear();
6987
+ this.db.prepare(
6988
+ `UPDATE pending_outbound SET status = 'approved', resolved_at = datetime('now'), resolved_by = 'owner-reply' WHERE id = ?`
6989
+ ).run(row.id);
6833
6990
  }
6834
6991
  /**
6835
- * Resume gateway from saved config (e.g., after server restart).
6836
- *
6837
- * Issue #31 — On a Docker container restart the API can come up
6838
- * before Stalwart / Gmail IMAP / DNS is reachable, so the very first
6839
- * setup() can fail with a transient network error. Previously that
6840
- * single failure was logged and never retried, leaving polling
6841
- * permanently dead until someone noticed and manually revived the
6842
- * relay. We now schedule background retries with exponential backoff
6843
- * (5s, 10s, 20s, 40s, 60s cap, indefinite) so the relay
6844
- * self-recovers as soon as the dependency is reachable again.
6992
+ * Send a confirmation email back to the owner after processing an approval reply.
6845
6993
  */
6846
- async resume() {
6847
- if (this.config.mode === "relay" && this.config.relay) {
6848
- try {
6849
- await this._resumeRelayOnce();
6850
- } catch (err) {
6851
- console.error("[GatewayManager] Initial relay resume failed; scheduling retries:", formatPollError(err));
6852
- this._scheduleRelayResumeRetry();
6994
+ async sendApprovalConfirmation(row, action, replyMessageId) {
6995
+ const ownerEmail = this.config.relay?.email;
6996
+ if (!ownerEmail || !this.accountManager) return;
6997
+ const mailOpts = JSON.parse(row.mail_options);
6998
+ const agent = await this.accountManager.getById(row.agent_id);
6999
+ const agentName = agent?.name || "unknown agent";
7000
+ const statusText = action === "approve" ? "APPROVED and sent" : "REJECTED and discarded";
7001
+ this.routeOutbound(agentName, {
7002
+ to: ownerEmail,
7003
+ subject: `Re: [Approval Required] Blocked email from "${agentName}" \u2014 ${statusText}`,
7004
+ text: [
7005
+ `The blocked email has been ${statusText}.`,
7006
+ "",
7007
+ ` To: ${Array.isArray(mailOpts.to) ? mailOpts.to.join(", ") : mailOpts.to}`,
7008
+ ` Subject: ${mailOpts.subject}`,
7009
+ "",
7010
+ action === "approve" ? "The email has been delivered to the recipient." : "The email has been discarded and will not be sent."
7011
+ ].join("\n"),
7012
+ fromName: "Agentic Mail",
7013
+ inReplyTo: replyMessageId
7014
+ }).catch((err) => {
7015
+ console.warn(`[GatewayManager] Failed to send approval confirmation: ${err.message}`);
7016
+ });
7017
+ }
7018
+ // --- Relay Mode ---
7019
+ async setupRelay(config, options) {
7020
+ await this.relay.setup(config);
7021
+ this.config = { mode: "relay", relay: config };
7022
+ this.saveConfig();
7023
+ let agent;
7024
+ if (!options?.skipDefaultAgent && this.accountManager) {
7025
+ const agentName = options?.defaultAgentName ?? DEFAULT_AGENT_NAME;
7026
+ const agentRole = options?.defaultAgentRole ?? DEFAULT_AGENT_ROLE;
7027
+ const existing = await this.accountManager.getByName(agentName);
7028
+ if (existing) {
7029
+ agent = existing;
7030
+ } else {
7031
+ agent = await this.accountManager.create({
7032
+ name: agentName,
7033
+ role: agentRole,
7034
+ gateway: "relay"
7035
+ });
6853
7036
  }
6854
7037
  }
6855
- if (this.smsManager && this.accountManager) {
6856
- try {
6857
- await this.startSmsPollers();
6858
- } catch (err) {
6859
- console.error("[GatewayManager] Failed to start SMS pollers:", err);
7038
+ this.relay.onUidAdvance = (uid) => this.saveLastSeenUid(uid);
7039
+ await this.relay.startPolling();
7040
+ return { agent };
7041
+ }
7042
+ // --- Domain Mode ---
7043
+ async setupDomain(options) {
7044
+ this.cfClient = new CloudflareClient(options.cloudflareToken, options.cloudflareAccountId);
7045
+ this.dnsConfigurator = new DNSConfigurator(this.cfClient);
7046
+ this.tunnel = new TunnelManager(this.cfClient);
7047
+ this.domainPurchaser = new DomainPurchaser(this.cfClient);
7048
+ let domain = options.domain;
7049
+ if (!domain && options.purchase) {
7050
+ const available = await this.domainPurchaser.searchAvailable(
7051
+ options.purchase.keywords,
7052
+ options.purchase.tld ? [options.purchase.tld] : void 0
7053
+ );
7054
+ const first = available.find((d) => d.available && !d.premium);
7055
+ if (!first) {
7056
+ throw new Error("No available domains found for the given keywords");
6860
7057
  }
7058
+ await this.domainPurchaser.purchase(first.domain);
7059
+ domain = first.domain;
7060
+ this.db.prepare(`
7061
+ INSERT OR REPLACE INTO purchased_domains (domain, registrar) VALUES (?, ?)
7062
+ `).run(domain, "cloudflare");
6861
7063
  }
6862
- if (this.config.mode === "domain" && this.config.domain) {
6863
- try {
6864
- this.cfClient = new CloudflareClient(
6865
- this.config.domain.cloudflareApiToken,
6866
- this.config.domain.cloudflareAccountId
6867
- );
6868
- this.dnsConfigurator = new DNSConfigurator(this.cfClient);
6869
- this.tunnel = new TunnelManager(this.cfClient);
6870
- this.domainPurchaser = new DomainPurchaser(this.cfClient);
6871
- if (this.config.domain.tunnelToken) {
6872
- await this.tunnel.start(this.config.domain.tunnelToken);
6873
- }
6874
- } catch (err) {
6875
- console.error("[GatewayManager] Failed to resume domain mode:", err);
7064
+ if (!domain) {
7065
+ throw new Error("No domain specified and no purchase keywords provided");
7066
+ }
7067
+ let zone = await this.cfClient.getZone(domain);
7068
+ if (!zone) {
7069
+ zone = await this.cfClient.createZone(domain);
7070
+ }
7071
+ const existingRecords = await this.cfClient.listDnsRecords(zone.id);
7072
+ const { homedir: homedir13 } = await import("os");
7073
+ const backupDir = (0, import_node_path4.join)(homedir13(), ".agenticmail");
7074
+ const backupPath = (0, import_node_path4.join)(backupDir, `dns-backup-${domain}-${Date.now()}.json`);
7075
+ const { writeFileSync: writeFileSync11, mkdirSync: mkdirSync12 } = await import("fs");
7076
+ mkdirSync12(backupDir, { recursive: true });
7077
+ writeFileSync11(backupPath, JSON.stringify({
7078
+ domain,
7079
+ zoneId: zone.id,
7080
+ backedUpAt: (/* @__PURE__ */ new Date()).toISOString(),
7081
+ records: existingRecords
7082
+ }, null, 2));
7083
+ console.log(`[GatewayManager] DNS backup saved to ${backupPath} (${existingRecords.length} records)`);
7084
+ const rootRecords = existingRecords.filter(
7085
+ (r) => r.name === domain && (r.type === "A" || r.type === "AAAA" || r.type === "CNAME" || r.type === "MX")
7086
+ );
7087
+ if (rootRecords.length > 0) {
7088
+ console.warn(`[GatewayManager] \u26A0\uFE0F WARNING: ${rootRecords.length} existing root DNS record(s) for ${domain} will be modified:`);
7089
+ for (const r of rootRecords) {
7090
+ console.warn(`[GatewayManager] ${r.type} ${r.name} \u2192 ${r.content}`);
6876
7091
  }
7092
+ console.warn(`[GatewayManager] Backup saved at: ${backupPath}`);
6877
7093
  }
6878
- }
6879
- // ─── Issue #31 helpers — resume retry with backoff ───
6880
- _resumeRetryTimer = null;
6881
- _resumeRetryAttempt = 0;
6882
- async _resumeRelayOnce() {
6883
- if (!this.config.relay) throw new Error("No relay config to resume");
6884
- await this.relay.setup(this.config.relay);
6885
- const savedUid = this.loadLastSeenUid();
6886
- if (savedUid > 0) {
6887
- this.relay.setLastSeenUid(savedUid);
6888
- console.log(`[GatewayManager] Restored lastSeenUid=${savedUid} from database`);
7094
+ const tunnelConfig = await this.tunnel.create(`agenticmail-${domain}`);
7095
+ console.log(`[GatewayManager] Configuring mail server hostname: ${domain}`);
7096
+ try {
7097
+ await this.stalwart.setHostname(domain);
7098
+ console.log(`[GatewayManager] Mail server hostname set to ${domain}`);
7099
+ } catch (err) {
7100
+ console.warn(`[GatewayManager] Failed to set hostname (EHLO may show "localhost"): ${err.message}`);
6889
7101
  }
6890
- this.relay.onUidAdvance = (uid) => this.saveLastSeenUid(uid);
6891
- await this.relay.startPolling();
6892
- if (this._resumeRetryAttempt > 0) {
6893
- console.log(`[GatewayManager] Relay polling resumed after ${this._resumeRetryAttempt} retry attempt${this._resumeRetryAttempt !== 1 ? "s" : ""}`);
7102
+ console.log("[GatewayManager] Setting up DKIM signing...");
7103
+ let dkimPublicKey;
7104
+ let dkimSelector = "agenticmail";
7105
+ try {
7106
+ const dkim = await this.stalwart.createDkimSignature(domain, dkimSelector);
7107
+ dkimPublicKey = dkim.publicKey;
7108
+ console.log(`[GatewayManager] DKIM signature created (selector: ${dkimSelector})`);
7109
+ } catch (err) {
7110
+ console.warn(`[GatewayManager] DKIM setup failed (email may land in spam): ${err.message}`);
6894
7111
  }
6895
- this._resumeRetryAttempt = 0;
6896
- }
6897
- _scheduleRelayResumeRetry() {
6898
- if (this._resumeRetryTimer) return;
6899
- this._resumeRetryAttempt++;
6900
- const base = Math.min(5e3 * Math.pow(2, this._resumeRetryAttempt - 1), 6e4);
6901
- const jitter = base * (0.8 + Math.random() * 0.4);
6902
- const delay = Math.round(jitter);
6903
- console.log(`[GatewayManager] Will retry relay resume in ${(delay / 1e3).toFixed(1)}s (attempt ${this._resumeRetryAttempt + 1})`);
6904
- this._resumeRetryTimer = setTimeout(async () => {
6905
- this._resumeRetryTimer = null;
6906
- if (this.config.mode !== "relay" || !this.config.relay) return;
6907
- try {
6908
- await this._resumeRelayOnce();
6909
- } catch (err) {
6910
- console.error(`[GatewayManager] Relay resume retry ${this._resumeRetryAttempt} failed:`, formatPollError(err));
6911
- this._scheduleRelayResumeRetry();
6912
- }
6913
- }, delay);
6914
- }
6915
- // --- Persistence ---
6916
- loadConfig() {
6917
- const row = this.db.prepare("SELECT * FROM gateway_config WHERE id = ?").get("default");
6918
- if (row) {
7112
+ const tunnelRemoved = await this.dnsConfigurator.configureForTunnel(domain, zone.id, tunnelConfig.tunnelId);
7113
+ if (tunnelRemoved.length > 0) {
7114
+ console.log(`[GatewayManager] Removed ${tunnelRemoved.length} conflicting DNS record(s) for tunnel`);
7115
+ }
7116
+ const emailDns = await this.dnsConfigurator.configureForEmail(domain, zone.id, {
7117
+ dkimSelector,
7118
+ dkimPublicKey
7119
+ });
7120
+ if (emailDns.removed.length > 0) {
7121
+ console.log(`[GatewayManager] Replaced ${emailDns.removed.length} old DNS record(s) for email`);
7122
+ }
7123
+ await this.tunnel.start(tunnelConfig.tunnelToken);
7124
+ await this.tunnel.createIngress(tunnelConfig.tunnelId, domain);
7125
+ console.log("[GatewayManager] Enabling Cloudflare Email Routing...");
7126
+ try {
7127
+ await this.cfClient.enableEmailRouting(zone.id);
7128
+ console.log("[GatewayManager] Email Routing enabled");
7129
+ } catch (err) {
7130
+ console.warn(`[GatewayManager] Email Routing enable failed (may already be active): ${err.message}`);
7131
+ }
7132
+ const workerName = `agenticmail-inbound-${domain.replace(/\./g, "-")}`;
7133
+ const inboundUrl = `https://${domain}/api/agenticmail/mail/inbound`;
7134
+ const inboundSecret = options.outboundSecret || crypto.randomUUID();
7135
+ console.log(`[GatewayManager] Deploying Email Worker "${workerName}"...`);
7136
+ console.log(`[GatewayManager] Set AGENTICMAIL_INBOUND_SECRET="${inboundSecret}" in your environment to match the worker`);
7137
+ try {
7138
+ const { EMAIL_WORKER_SCRIPT: EMAIL_WORKER_SCRIPT2 } = await Promise.resolve().then(() => (init_email_worker_template(), email_worker_template_exports));
7139
+ await this.cfClient.deployEmailWorker(workerName, EMAIL_WORKER_SCRIPT2, {
7140
+ INBOUND_URL: inboundUrl,
7141
+ INBOUND_SECRET: inboundSecret
7142
+ });
7143
+ console.log(`[GatewayManager] Email Worker deployed: ${workerName}`);
7144
+ } catch (err) {
7145
+ console.warn(`[GatewayManager] Email Worker deployment failed: ${err.message}`);
7146
+ console.warn("[GatewayManager] You may need to deploy the worker manually or check Workers permissions on your API token");
7147
+ }
7148
+ console.log("[GatewayManager] Configuring catch-all Email Routing rule...");
7149
+ try {
7150
+ await this.cfClient.setCatchAllWorkerRule(zone.id, workerName);
7151
+ console.log("[GatewayManager] Catch-all rule set: all emails \u2192 Worker \u2192 AgenticMail");
7152
+ } catch (err) {
7153
+ console.warn(`[GatewayManager] Catch-all rule failed: ${err.message}`);
7154
+ }
7155
+ try {
7156
+ await this.stalwart.createPrincipal({
7157
+ type: "domain",
7158
+ name: domain,
7159
+ description: `AgenticMail gateway domain: ${domain}`
7160
+ });
7161
+ } catch {
7162
+ }
7163
+ if (this.accountManager) {
6919
7164
  try {
6920
- const parsed = JSON.parse(row.config);
6921
- if (this.encryptionKey) {
6922
- if (parsed.relay?.password) {
6923
- try {
6924
- parsed.relay.password = decryptSecret(parsed.relay.password, this.encryptionKey);
6925
- } catch {
6926
- }
6927
- }
6928
- if (parsed.relay?.appPassword) {
6929
- try {
6930
- parsed.relay.appPassword = decryptSecret(parsed.relay.appPassword, this.encryptionKey);
6931
- } catch {
6932
- }
6933
- }
6934
- if (parsed.domain?.cloudflareApiToken) {
6935
- try {
6936
- parsed.domain.cloudflareApiToken = decryptSecret(parsed.domain.cloudflareApiToken, this.encryptionKey);
6937
- } catch {
6938
- }
6939
- }
6940
- if (parsed.domain?.tunnelToken) {
6941
- try {
6942
- parsed.domain.tunnelToken = decryptSecret(parsed.domain.tunnelToken, this.encryptionKey);
6943
- } catch {
6944
- }
6945
- }
6946
- if (parsed.domain?.inboundSecret) {
6947
- try {
6948
- parsed.domain.inboundSecret = decryptSecret(parsed.domain.inboundSecret, this.encryptionKey);
6949
- } catch {
6950
- }
6951
- }
6952
- if (parsed.domain?.outboundSecret) {
6953
- try {
6954
- parsed.domain.outboundSecret = decryptSecret(parsed.domain.outboundSecret, this.encryptionKey);
6955
- } catch {
7165
+ const agents = await this.accountManager.list();
7166
+ for (const agent of agents) {
7167
+ const domainEmail = `${agent.name.toLowerCase()}@${domain}`;
7168
+ try {
7169
+ const principal = await this.stalwart.getPrincipal(agent.stalwartPrincipal);
7170
+ const emails = principal.emails ?? [];
7171
+ if (!emails.includes(domainEmail)) {
7172
+ await this.stalwart.addEmailAlias(agent.stalwartPrincipal, domainEmail);
7173
+ console.log(`[GatewayManager] Added ${domainEmail} to Stalwart principal "${agent.stalwartPrincipal}"`);
6956
7174
  }
7175
+ } catch (err) {
7176
+ console.warn(`[GatewayManager] Could not update principal for ${agent.name}: ${err.message}`);
6957
7177
  }
6958
7178
  }
6959
- this.config = {
6960
- mode: row.mode,
6961
- ...parsed
6962
- };
6963
- } catch {
6964
- this.config = { mode: "none" };
7179
+ } catch (err) {
7180
+ console.warn(`[GatewayManager] Could not update agent email aliases: ${err.message}`);
6965
7181
  }
6966
7182
  }
6967
- }
6968
- saveConfig() {
6969
- const { mode, ...rest } = this.config;
6970
- const toStore = JSON.parse(JSON.stringify(rest));
6971
- if (this.encryptionKey) {
6972
- if (toStore.relay?.password) {
6973
- toStore.relay.password = encryptSecret(toStore.relay.password, this.encryptionKey);
6974
- }
6975
- if (toStore.relay?.appPassword) {
6976
- toStore.relay.appPassword = encryptSecret(toStore.relay.appPassword, this.encryptionKey);
6977
- }
6978
- if (toStore.domain?.cloudflareApiToken) {
6979
- toStore.domain.cloudflareApiToken = encryptSecret(toStore.domain.cloudflareApiToken, this.encryptionKey);
6980
- }
6981
- if (toStore.domain?.tunnelToken) {
6982
- toStore.domain.tunnelToken = encryptSecret(toStore.domain.tunnelToken, this.encryptionKey);
6983
- }
6984
- if (toStore.domain?.inboundSecret) {
6985
- toStore.domain.inboundSecret = encryptSecret(toStore.domain.inboundSecret, this.encryptionKey);
6986
- }
6987
- if (toStore.domain?.outboundSecret) {
6988
- toStore.domain.outboundSecret = encryptSecret(toStore.domain.outboundSecret, this.encryptionKey);
7183
+ const domainConfig = {
7184
+ domain,
7185
+ cloudflareApiToken: options.cloudflareToken,
7186
+ cloudflareAccountId: options.cloudflareAccountId,
7187
+ tunnelId: tunnelConfig.tunnelId,
7188
+ tunnelToken: tunnelConfig.tunnelToken,
7189
+ outboundWorkerUrl: options.outboundWorkerUrl,
7190
+ outboundSecret: options.outboundSecret,
7191
+ inboundSecret,
7192
+ emailWorkerName: workerName
7193
+ };
7194
+ this.config = { mode: "domain", domain: domainConfig };
7195
+ this.saveConfig();
7196
+ this.db.prepare(`
7197
+ INSERT OR REPLACE INTO purchased_domains (domain, registrar, cloudflare_zone_id, tunnel_id, dns_configured, tunnel_active)
7198
+ VALUES (?, 'cloudflare', ?, ?, 1, 1)
7199
+ `).run(domain, zone.id, tunnelConfig.tunnelId);
7200
+ let outboundRelay;
7201
+ const nextSteps = [];
7202
+ if (options.gmailRelay) {
7203
+ console.log("[GatewayManager] Configuring outbound relay through Gmail SMTP...");
7204
+ try {
7205
+ await this.stalwart.configureOutboundRelay({
7206
+ smtpHost: "smtp.gmail.com",
7207
+ smtpPort: 465,
7208
+ username: options.gmailRelay.email,
7209
+ password: options.gmailRelay.appPassword
7210
+ });
7211
+ outboundRelay = { configured: true, provider: "gmail" };
7212
+ console.log("[GatewayManager] Outbound relay configured: all external mail routes through Gmail SMTP");
7213
+ const gmailSettingsUrl = "https://mail.google.com/mail/u/0/#settings/accounts";
7214
+ nextSteps.push(
7215
+ `IMPORTANT: To send emails showing your domain (not ${options.gmailRelay.email}), add each agent email as a "Send mail as" alias in Gmail:`,
7216
+ `1. Open: ${gmailSettingsUrl}`,
7217
+ `2. Under "Send mail as" click "Add another email address"`,
7218
+ `3. Enter agent name and email (e.g. "Secretary" / secretary@${domain}), uncheck "Treat as alias"`,
7219
+ `4. On the SMTP screen, Gmail will auto-fill WRONG values. You MUST change them to:`,
7220
+ ` SMTP Server: smtp.gmail.com | Port: 465 | Username: ${options.gmailRelay.email} | Password: [your app password] | Select "Secured connection using SSL"`,
7221
+ `5. Click "Add Account". Gmail sends a verification email to the agent's @${domain} address`,
7222
+ `6. Check AgenticMail inbox for the code/link from gmail-noreply@google.com, then confirm`,
7223
+ `7. Repeat for each agent. Or ask your OpenClaw agent to automate this via the browser tool.`
7224
+ );
7225
+ } catch (err) {
7226
+ outboundRelay = { configured: false, provider: "gmail" };
7227
+ console.warn(`[GatewayManager] Outbound relay setup failed: ${err.message}`);
7228
+ nextSteps.push(`Outbound relay setup failed: ${err.message}. You can configure it manually later.`);
6989
7229
  }
7230
+ } else {
7231
+ nextSteps.push(
7232
+ "Outbound email: Your server sends directly from your IP. If your IP lacks a PTR record (common for residential connections), emails may be rejected.",
7233
+ 'To fix this, re-run setup with gmailRelay: { email: "you@gmail.com", appPassword: "xxxx xxxx xxxx xxxx" } to relay outbound mail through Gmail SMTP.',
7234
+ "You will need a Gmail app password: https://myaccount.google.com/apppasswords"
7235
+ );
6990
7236
  }
6991
- this.db.prepare(`
6992
- INSERT OR REPLACE INTO gateway_config (id, mode, config)
6993
- VALUES ('default', ?, ?)
6994
- `).run(mode, JSON.stringify(toStore));
6995
- }
6996
- saveLastSeenUid(uid) {
6997
- this.db.prepare(`
6998
- INSERT OR REPLACE INTO config (key, value) VALUES ('relay_last_seen_uid', ?)
6999
- `).run(String(uid));
7000
- }
7001
- loadLastSeenUid() {
7002
- const row = this.db.prepare("SELECT value FROM config WHERE key = ?").get("relay_last_seen_uid");
7003
- return row ? parseInt(row.value, 10) || 0 : 0;
7004
- }
7005
- };
7006
- function parseAddressString(addr) {
7007
- const match = addr.match(/^(.+?)\s*<([^>]+)>$/);
7008
- if (match) {
7009
- return { name: match[1].trim(), address: match[2].trim() };
7237
+ return {
7238
+ domain,
7239
+ dnsConfigured: true,
7240
+ tunnelId: tunnelConfig.tunnelId,
7241
+ outboundRelay,
7242
+ nextSteps: nextSteps.length > 0 ? nextSteps : void 0
7243
+ };
7010
7244
  }
7011
- return { address: addr.trim() };
7012
- }
7013
- function inboundToParsedEmail(mail) {
7014
- return {
7015
- messageId: mail.messageId || "",
7016
- subject: mail.subject || "",
7017
- from: [parseAddressString(mail.from)],
7018
- to: [parseAddressString(mail.to)],
7019
- date: mail.date || /* @__PURE__ */ new Date(),
7020
- text: mail.text,
7021
- html: mail.html,
7022
- inReplyTo: mail.inReplyTo,
7023
- references: mail.references,
7024
- attachments: (mail.attachments ?? []).map((a) => ({
7025
- filename: a.filename,
7026
- contentType: a.contentType,
7027
- size: a.size,
7028
- content: a.content
7029
- })),
7030
- headers: /* @__PURE__ */ new Map()
7031
- };
7032
- }
7033
-
7034
- // src/gateway/relay-bridge.ts
7035
- var import_node_http = require("http");
7036
- var import_nodemailer4 = require("nodemailer");
7037
- var RelayBridge = class {
7038
- server = null;
7039
- options;
7040
- constructor(options) {
7041
- this.options = options;
7245
+ // --- Test ---
7246
+ /**
7247
+ * Send a test email through the gateway without requiring a real agent.
7248
+ * In relay mode, uses "test" as the sub-address.
7249
+ * In domain mode, uses the first available agent (Stalwart needs real credentials).
7250
+ */
7251
+ async sendTestEmail(to) {
7252
+ const mail = {
7253
+ to,
7254
+ subject: "AgenticMail Gateway Test",
7255
+ text: "This is a test email sent via the AgenticMail gateway to verify your configuration is working."
7256
+ };
7257
+ if (this.config.mode === "relay") {
7258
+ return this.routeOutbound("test", mail);
7259
+ }
7260
+ if (this.config.mode === "domain" && this.accountManager) {
7261
+ const agents = await this.accountManager.list();
7262
+ if (agents.length === 0) {
7263
+ throw new Error("No agents exist yet. Create an agent first, then send a test email.");
7264
+ }
7265
+ const primary = agents.find((a) => a.metadata?.persistent) ?? agents[0];
7266
+ return this.routeOutbound(primary.name, mail);
7267
+ }
7268
+ return null;
7042
7269
  }
7043
- async start() {
7044
- return new Promise((resolve2, reject) => {
7045
- this.server = (0, import_node_http.createServer)((req, res) => this.handleRequest(req, res));
7046
- this.server.listen(this.options.port, "127.0.0.1", () => {
7047
- console.log(`[RelayBridge] Listening on 127.0.0.1:${this.options.port}`);
7048
- resolve2();
7049
- });
7050
- this.server.on("error", reject);
7270
+ // --- Routing ---
7271
+ /**
7272
+ * Route an outbound email. If the destination is external and a gateway
7273
+ * is configured, send via the appropriate channel.
7274
+ * Returns null if the mail should be sent via local Stalwart.
7275
+ */
7276
+ async routeOutbound(agentName, mail) {
7277
+ if (this.config.mode === "none") return null;
7278
+ const collect = (field) => {
7279
+ if (!field) return [];
7280
+ if (Array.isArray(field)) return field;
7281
+ return field.split(",").map((s) => s.trim()).filter(Boolean);
7282
+ };
7283
+ const allRecipients = [
7284
+ ...collect(mail.to),
7285
+ ...collect(mail.cc),
7286
+ ...collect(mail.bcc)
7287
+ ];
7288
+ const localDomain = this.config.domain?.domain?.toLowerCase();
7289
+ const isExternal = allRecipients.some((addr) => {
7290
+ const domain = (addr.split("@")[1] ?? "localhost").toLowerCase();
7291
+ return domain !== "localhost" && domain !== localDomain;
7051
7292
  });
7293
+ if (!isExternal) return null;
7294
+ if (this.config.mode === "relay") {
7295
+ return this.relay.sendViaRelay(agentName, mail);
7296
+ }
7297
+ if (this.config.mode === "domain" && this.config.domain) {
7298
+ return this.sendViaStalwart(agentName, mail);
7299
+ }
7300
+ return null;
7052
7301
  }
7053
- stop() {
7054
- this.server?.close();
7055
- this.server = null;
7056
- }
7057
- async handleRequest(req, res) {
7058
- if (req.method !== "POST" || req.url !== "/send") {
7059
- res.writeHead(404, { "Content-Type": "application/json" });
7060
- res.end(JSON.stringify({ error: "Not found" }));
7061
- return;
7302
+ /**
7303
+ * Send email by submitting to local Stalwart via SMTP (port 587).
7304
+ * Stalwart handles DKIM signing and delivery (direct or via relay).
7305
+ * Reply-To is set to the agent's domain email so replies come back
7306
+ * to the domain (handled by Cloudflare Email Routing → inbound Worker).
7307
+ */
7308
+ async sendViaStalwart(agentName, mail) {
7309
+ if (!this.accountManager) {
7310
+ throw new Error("AccountManager required for domain mode outbound");
7062
7311
  }
7063
- const secret = req.headers["x-relay-secret"];
7064
- if (secret !== this.options.secret) {
7065
- res.writeHead(401, { "Content-Type": "application/json" });
7066
- res.end(JSON.stringify({ error: "Unauthorized" }));
7067
- return;
7312
+ const agent = await this.accountManager.getByName(agentName);
7313
+ if (!agent) {
7314
+ throw new Error(`Agent "${agentName}" not found`);
7068
7315
  }
7069
- let body = "";
7070
- for await (const chunk of req) body += chunk;
7071
- try {
7072
- const payload = JSON.parse(body);
7073
- const result = await this.submitToStalwart(payload);
7074
- res.writeHead(200, { "Content-Type": "application/json" });
7075
- res.end(JSON.stringify(result));
7076
- } catch (err) {
7077
- console.error("[RelayBridge] Delivery failed:", err.message);
7078
- res.writeHead(500, { "Content-Type": "application/json" });
7079
- res.end(JSON.stringify({ error: err.message }));
7316
+ const agentPassword = agent.metadata?._password;
7317
+ if (!agentPassword) {
7318
+ throw new Error(`No password for agent "${agentName}"`);
7080
7319
  }
7081
- }
7082
- async submitToStalwart(payload) {
7083
- const { from, to, subject, text, html, replyTo, inReplyTo, references } = payload;
7084
- const recipients = Array.isArray(to) ? to : [to];
7085
- debug("RelayBridge", `Submitting to Stalwart: ${from} \u2192 ${recipients.join(", ")}`);
7086
- const transport = (0, import_nodemailer4.createTransport)({
7087
- host: this.options.smtpHost ?? "127.0.0.1",
7088
- port: this.options.smtpPort ?? 587,
7320
+ const domainName = this.config.domain?.domain;
7321
+ const fromAddr = domainName ? agent.email.replace(/@localhost$/, `@${domainName}`) : agent.email;
7322
+ const displayName = mail.fromName || agentName;
7323
+ const from = `${displayName} <${fromAddr}>`;
7324
+ if (domainName && fromAddr !== agent.email) {
7325
+ try {
7326
+ const principal = await this.stalwart.getPrincipal(agent.stalwartPrincipal);
7327
+ const emails = principal.emails ?? [];
7328
+ if (!emails.includes(fromAddr)) {
7329
+ await this.stalwart.addEmailAlias(agent.stalwartPrincipal, fromAddr);
7330
+ console.log(`[GatewayManager] Auto-added ${fromAddr} to Stalwart principal "${agent.stalwartPrincipal}"`);
7331
+ }
7332
+ } catch (err) {
7333
+ console.warn(`[GatewayManager] Could not auto-add domain email alias: ${err.message}`);
7334
+ }
7335
+ }
7336
+ const recipients = Array.isArray(mail.to) ? mail.to : [mail.to];
7337
+ const mailOpts = {
7338
+ from,
7339
+ to: recipients.join(", "),
7340
+ cc: mail.cc ? Array.isArray(mail.cc) ? mail.cc.join(", ") : mail.cc : void 0,
7341
+ bcc: mail.bcc ? Array.isArray(mail.bcc) ? mail.bcc.join(", ") : mail.bcc : void 0,
7342
+ subject: mail.subject,
7343
+ text: mail.text || void 0,
7344
+ // The `html` field is the literal HTML body of the outbound
7345
+ // mail — by design it is whatever the sender chose to compose.
7346
+ // CodeQL `js/xss` flags this because the value flows from user
7347
+ // input, but nodemailer is the SMTP serializer, not an HTML
7348
+ // renderer; XSS would only occur if the recipient's MUA
7349
+ // executed the body, which is outside our trust boundary.
7350
+ // The outbound-guard (packages/core/src/mail/outbound-guard.ts)
7351
+ // already scores HTML bodies for suspicious patterns at the
7352
+ // pre-send step. lgtm[js/xss]
7353
+ html: mail.html || void 0,
7354
+ replyTo: mail.replyTo || from,
7355
+ inReplyTo: mail.inReplyTo || void 0,
7356
+ references: Array.isArray(mail.references) ? mail.references.join(" ") : mail.references || void 0,
7357
+ headers: {
7358
+ "X-Mailer": "AgenticMail/1.0"
7359
+ },
7360
+ attachments: mail.attachments?.map((a) => ({
7361
+ filename: a.filename,
7362
+ content: a.content,
7363
+ contentType: a.contentType,
7364
+ encoding: a.encoding
7365
+ }))
7366
+ };
7367
+ const composer = new import_mail_composer3.default(mailOpts);
7368
+ const raw = await composer.compile().build();
7369
+ const smtpHost = this.options.localSmtp?.host ?? "127.0.0.1";
7370
+ const smtpPort = this.options.localSmtp?.port ?? 587;
7371
+ const transport = import_nodemailer3.default.createTransport({
7372
+ host: smtpHost,
7373
+ port: smtpPort,
7089
7374
  secure: false,
7090
7375
  auth: {
7091
- user: this.options.smtpUser,
7092
- pass: this.options.smtpPass
7376
+ user: agent.stalwartPrincipal,
7377
+ pass: agentPassword
7093
7378
  },
7094
7379
  tls: { rejectUnauthorized: false }
7095
7380
  });
7096
7381
  try {
7097
- const info = await transport.sendMail({
7098
- from,
7099
- to: recipients.join(", "),
7100
- subject,
7101
- text: text || void 0,
7102
- html: html || void 0,
7103
- replyTo: replyTo || void 0,
7104
- inReplyTo: inReplyTo || void 0,
7105
- references: references || void 0,
7106
- headers: {
7107
- "X-Mailer": "AgenticMail/1.0"
7108
- }
7109
- });
7110
- debug("RelayBridge", `Queued: ${info.messageId} \u2192 ${info.response}`);
7382
+ const info = await transport.sendMail(mailOpts);
7383
+ debug("GatewayManager", `Sent via Stalwart: ${info.messageId} \u2192 ${info.response}`);
7111
7384
  return {
7112
- ok: true,
7113
7385
  messageId: info.messageId,
7114
- response: info.response
7386
+ envelope: { from, to: recipients },
7387
+ raw
7115
7388
  };
7116
7389
  } finally {
7117
7390
  transport.close();
7118
7391
  }
7119
7392
  }
7120
- };
7121
- function startRelayBridge(options) {
7122
- const bridge = new RelayBridge(options);
7123
- bridge.start().catch((err) => {
7124
- console.error("[RelayBridge] Failed to start:", err);
7125
- });
7126
- return bridge;
7127
- }
7128
-
7129
- // src/gateway/types.ts
7130
- var RELAY_PRESETS = {
7131
- gmail: {
7132
- smtpHost: "smtp.gmail.com",
7133
- smtpPort: 587,
7134
- imapHost: "imap.gmail.com",
7135
- imapPort: 993
7136
- },
7137
- outlook: {
7138
- smtpHost: "smtp.office365.com",
7139
- smtpPort: 587,
7140
- imapHost: "outlook.office365.com",
7141
- imapPort: 993
7393
+ // --- Status ---
7394
+ getStatus() {
7395
+ const status = {
7396
+ mode: this.config.mode,
7397
+ healthy: false
7398
+ };
7399
+ if (this.config.mode === "relay" && this.config.relay) {
7400
+ status.relay = {
7401
+ provider: this.config.relay.provider,
7402
+ email: this.config.relay.email,
7403
+ polling: this.relay.isPolling()
7404
+ };
7405
+ status.healthy = this.relay.isConfigured();
7406
+ }
7407
+ if (this.config.mode === "domain" && this.config.domain) {
7408
+ const tunnelStatus = this.tunnel?.status();
7409
+ status.domain = {
7410
+ domain: this.config.domain.domain,
7411
+ dnsConfigured: true,
7412
+ tunnelActive: tunnelStatus?.running ?? false
7413
+ };
7414
+ status.healthy = tunnelStatus?.running ?? false;
7415
+ }
7416
+ if (this.config.mode === "none") {
7417
+ status.healthy = true;
7418
+ }
7419
+ return status;
7142
7420
  }
7143
- };
7144
-
7145
- // src/telegram/client.ts
7146
- var TELEGRAM_API_BASE = "https://api.telegram.org";
7147
- var TELEGRAM_MESSAGE_LIMIT = 4096;
7148
- var TELEGRAM_CHUNK_SIZE = 4e3;
7149
- var TelegramApiError = class extends Error {
7150
- isTelegramApiError = true;
7151
- description;
7152
- errorCode;
7153
- constructor(method, description, errorCode) {
7154
- super(`Telegram ${method} failed: ${description}${errorCode ? ` (code ${errorCode})` : ""}`);
7155
- this.name = "TelegramApiError";
7156
- this.description = description;
7157
- this.errorCode = errorCode;
7421
+ getMode() {
7422
+ return this.config.mode;
7158
7423
  }
7159
- };
7160
- function redactBotToken(text, token) {
7161
- let out = typeof text === "string" ? text : String(text);
7162
- if (token) out = out.split(token).join("bot***");
7163
- return out.replace(/\d{6,}:[A-Za-z0-9_-]{30,}/g, "bot***");
7164
- }
7165
- async function callTelegramApi(token, method, body, options = {}) {
7166
- if (!token || typeof token !== "string") {
7167
- throw new TelegramApiError(method, "bot token is required");
7424
+ getConfig() {
7425
+ return this.config;
7168
7426
  }
7169
- const pollTimeout = typeof body?.timeout === "number" ? body.timeout : 0;
7170
- const timeoutMs = options.longPoll && pollTimeout > 0 ? (pollTimeout + 15) * 1e3 : 3e4;
7171
- let response;
7172
- try {
7173
- response = await fetch(`${TELEGRAM_API_BASE}/bot${token}/${method}`, {
7174
- method: "POST",
7175
- headers: { "Content-Type": "application/json" },
7176
- body: body ? JSON.stringify(body) : void 0,
7177
- signal: AbortSignal.timeout(timeoutMs)
7178
- });
7179
- } catch (err) {
7180
- throw new TelegramApiError(method, redactBotToken(err?.message ?? String(err), token));
7427
+ // --- Domain Purchase ---
7428
+ getStalwart() {
7429
+ return this.stalwart;
7181
7430
  }
7182
- let json;
7183
- try {
7184
- json = await response.json();
7185
- } catch {
7186
- throw new TelegramApiError(method, `non-JSON response (HTTP ${response.status})`);
7431
+ getDomainPurchaser() {
7432
+ return this.domainPurchaser;
7433
+ }
7434
+ getDNSConfigurator() {
7435
+ return this.dnsConfigurator;
7436
+ }
7437
+ getTunnelManager() {
7438
+ return this.tunnel;
7439
+ }
7440
+ getRelay() {
7441
+ return this.relay;
7442
+ }
7443
+ /**
7444
+ * Search the connected relay account (Gmail/Outlook) for emails matching criteria.
7445
+ * Returns empty array if relay is not configured.
7446
+ */
7447
+ async searchRelay(criteria, maxResults = 50) {
7448
+ if (this.config.mode !== "relay" || !this.relay.isConfigured()) return [];
7449
+ return this.relay.searchRelay(criteria, maxResults);
7450
+ }
7451
+ /**
7452
+ * Import an email from the connected relay account into an agent's local inbox.
7453
+ * Fetches the full message from relay IMAP and delivers it locally, preserving
7454
+ * all headers (Message-ID, In-Reply-To, References) for thread continuity.
7455
+ */
7456
+ async importRelayMessage(relayUid, agentName) {
7457
+ if (this.config.mode !== "relay" || !this.relay.isConfigured()) {
7458
+ return { success: false, error: "Relay not configured" };
7459
+ }
7460
+ const mail = await this.relay.fetchRelayMessage(relayUid);
7461
+ if (!mail) {
7462
+ return { success: false, error: "Could not fetch message from relay account" };
7463
+ }
7464
+ try {
7465
+ await this.deliverInboundLocally(agentName, mail);
7466
+ return { success: true };
7467
+ } catch (err) {
7468
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
7469
+ }
7187
7470
  }
7188
- if (!json || json.ok !== true) {
7189
- throw new TelegramApiError(
7190
- method,
7191
- redactBotToken(String(json?.description || `HTTP ${response.status}`), token),
7192
- typeof json?.error_code === "number" ? json.error_code : void 0
7193
- );
7471
+ // --- SMS Polling ---
7472
+ /**
7473
+ * Start SMS pollers for all agents that have separate GV Gmail credentials.
7474
+ * Agents with sameAsRelay=true are handled in deliverInboundLocally.
7475
+ */
7476
+ async startSmsPollers() {
7477
+ if (!this.smsManager || !this.accountManager) return;
7478
+ const agents = this.db.prepare("SELECT id, name, metadata FROM agents").all();
7479
+ for (const agent of agents) {
7480
+ try {
7481
+ const meta = JSON.parse(agent.metadata || "{}");
7482
+ const smsConfig = meta.sms;
7483
+ if (!smsConfig?.enabled || !smsConfig.forwardingPassword || smsConfig.sameAsRelay) continue;
7484
+ const poller = new SmsPoller(this.smsManager, agent.id, smsConfig);
7485
+ poller.onSmsReceived = (agentId, sms) => {
7486
+ console.log(`[SmsPoller] SMS received for agent ${agent.name}: from ${sms.from}, body="${sms.body.slice(0, 50)}..."`);
7487
+ };
7488
+ this.smsPollers.set(agent.id, poller);
7489
+ await poller.startPolling();
7490
+ console.log(`[GatewayManager] SMS poller started for agent "${agent.name}" (${smsConfig.forwardingEmail})`);
7491
+ } catch {
7492
+ }
7493
+ }
7194
7494
  }
7195
- return json.result;
7196
- }
7197
- function stripTelegramMarkdown(text) {
7198
- if (!text) return text;
7199
- 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();
7200
- }
7201
- function splitTelegramMessage(text, maxLen = TELEGRAM_CHUNK_SIZE) {
7202
- const chunks = [];
7203
- let rest = text || "";
7204
- while (rest.length > maxLen) {
7205
- let cut = rest.lastIndexOf("\n", maxLen);
7206
- if (cut < maxLen / 2) cut = maxLen;
7207
- chunks.push(rest.slice(0, cut));
7208
- rest = rest.slice(cut).replace(/^\n+/, "");
7495
+ // --- Telegram Polling ---
7496
+ /**
7497
+ * Start a long-poll loop for every agent whose Telegram channel is
7498
+ * configured + enabled + in poll mode. Webhook-mode agents skip — the
7499
+ * webhook route already calls back into the same agent-wake bridge.
7500
+ *
7501
+ * Each new inbound Telegram message (that isn't an operator-query
7502
+ * reply) is converted to a synthetic email and delivered into the
7503
+ * agent's INBOX via the existing local-SMTP path — the very same
7504
+ * delivery the relay uses for real email. This makes the existing
7505
+ * IMAP IDLE claudecode dispatcher path light up exactly as it
7506
+ * would for a real inbound mail, so the agent gets a Claude turn
7507
+ * without any new dispatcher plumbing. The body of the synthetic
7508
+ * mail tells the agent the message came from Telegram and that it
7509
+ * MUST reply via the `telegram_send` MCP tool, not via email.
7510
+ */
7511
+ async startTelegramPollers() {
7512
+ if (!this.telegramManager || !this.accountManager) return;
7513
+ const agents = this.db.prepare("SELECT id, name FROM agents").all();
7514
+ for (const agent of agents) {
7515
+ try {
7516
+ const config = this.telegramManager.getConfig(agent.id);
7517
+ if (!config?.enabled || config.mode !== "poll" || !config.botToken) continue;
7518
+ await this.startTelegramPollerForAgent(agent.id, agent.name);
7519
+ } catch (err) {
7520
+ console.warn(`[GatewayManager] Could not start Telegram poller for ${agent.name}: ${err.message}`);
7521
+ }
7522
+ }
7209
7523
  }
7210
- if (rest) chunks.push(rest);
7211
- return chunks;
7212
- }
7213
- async function sendTelegramMessage(token, chatId, text, options = {}) {
7214
- const clean = stripTelegramMarkdown(text);
7215
- const chunks = splitTelegramMessage(clean);
7216
- if (chunks.length === 0) chunks.push("");
7217
- const messageIds = [];
7218
- for (let i = 0; i < chunks.length; i++) {
7219
- const body = { chat_id: String(chatId), text: chunks[i] };
7220
- if (i === 0 && options.replyToMessageId) {
7221
- body.reply_parameters = { message_id: options.replyToMessageId };
7524
+ /**
7525
+ * Start (or restart) the Telegram poller for one agent. Idempotent —
7526
+ * a prior poller is stopped first so re-running `/telegram/setup`
7527
+ * picks up the new token / allow-list cleanly.
7528
+ *
7529
+ * Public so the API layer can poke the gateway after a successful
7530
+ * `/telegram/setup` without waiting for the next server restart.
7531
+ */
7532
+ async startTelegramPollerForAgent(agentId, agentName) {
7533
+ if (!this.telegramManager) return;
7534
+ const existing = this.telegramPollers.get(agentId);
7535
+ if (existing) {
7536
+ try {
7537
+ await existing.stop();
7538
+ } catch {
7539
+ }
7540
+ this.telegramPollers.delete(agentId);
7222
7541
  }
7223
- if (options.disableNotification) body.disable_notification = true;
7224
- const result = await callTelegramApi(token, "sendMessage", body);
7225
- messageIds.push(result.message_id);
7542
+ const config = this.telegramManager.getConfig(agentId);
7543
+ if (!config?.enabled || config.mode !== "poll" || !config.botToken) return;
7544
+ const poller = new TelegramPoller(this.telegramManager, agentId);
7545
+ poller.onInbound = async (event) => {
7546
+ await this.bridgeInboundTelegram(event.agentId, event.message, event.config, agentName);
7547
+ };
7548
+ this.telegramPollers.set(agentId, poller);
7549
+ await poller.start();
7550
+ const botName = config.botUsername ? `@${config.botUsername}` : `bot ${config.botId ?? "(unknown)"}`;
7551
+ console.log(`[GatewayManager] Telegram poller started for agent "${agentName ?? agentId.slice(0, 8)}" (${botName})`);
7226
7552
  }
7227
- return { messageIds, chunks: chunks.length };
7228
- }
7229
- function getTelegramMe(token) {
7230
- return callTelegramApi(token, "getMe");
7231
- }
7232
- function getTelegramChat(token, chatId) {
7233
- return callTelegramApi(token, "getChat", { chat_id: String(chatId) });
7234
- }
7235
- function getTelegramUpdates(token, offset, options = {}) {
7236
- const timeoutSec = Math.max(options.timeoutSec ?? 0, 0);
7237
- return callTelegramApi(token, "getUpdates", {
7238
- offset,
7239
- limit: Math.min(Math.max(options.limit ?? 100, 1), 100),
7240
- timeout: timeoutSec,
7241
- allowed_updates: ["message"]
7242
- }, { longPoll: timeoutSec > 0 });
7243
- }
7244
- function setTelegramWebhook(token, url, options = {}) {
7245
- return callTelegramApi(token, "setWebhook", {
7246
- url,
7247
- secret_token: options.secretToken,
7248
- allowed_updates: ["message"],
7249
- drop_pending_updates: options.dropPendingUpdates ?? false
7250
- });
7251
- }
7252
- function deleteTelegramWebhook(token) {
7253
- return callTelegramApi(token, "deleteWebhook", {});
7254
- }
7255
- function getTelegramWebhookInfo(token) {
7256
- return callTelegramApi(token, "getWebhookInfo");
7257
- }
7258
-
7259
- // src/telegram/update.ts
7260
- function asTrimmed(value) {
7261
- return typeof value === "string" ? value.trim() : "";
7262
- }
7263
- function normalizeChatType(type) {
7264
- return type === "private" || type === "group" || type === "supergroup" || type === "channel" ? type : "unknown";
7265
- }
7266
- function parseTelegramUpdate(update) {
7267
- if (!update || typeof update !== "object") return null;
7268
- const u = update;
7269
- if (typeof u.update_id !== "number") return null;
7270
- const msg = u.message || u.channel_post;
7271
- if (!msg || typeof msg !== "object") return null;
7272
- if (typeof msg.message_id !== "number") return null;
7273
- const chat = msg.chat || {};
7274
- if (typeof chat.id !== "number" && typeof chat.id !== "string") return null;
7275
- const text = asTrimmed(msg.text) || asTrimmed(msg.caption);
7276
- if (!text) return null;
7277
- const from = msg.from || {};
7278
- const fromName = [from.first_name, from.last_name].filter((p) => typeof p === "string" && p).join(" ") || asTrimmed(from.username) || asTrimmed(chat.title) || "User";
7279
- const replyTo = msg.reply_to_message;
7280
- return {
7281
- updateId: u.update_id,
7282
- messageId: msg.message_id,
7283
- chatId: String(chat.id),
7284
- chatType: normalizeChatType(chat.type),
7285
- chatTitle: asTrimmed(chat.title) || void 0,
7286
- fromId: from.id != null ? String(from.id) : String(chat.id),
7287
- fromName,
7288
- fromUsername: asTrimmed(from.username) || void 0,
7289
- text,
7290
- replyToMessageId: replyTo && typeof replyTo.message_id === "number" ? replyTo.message_id : void 0,
7291
- replyToText: replyTo ? asTrimmed(replyTo.text) || asTrimmed(replyTo.caption) || void 0 : void 0,
7292
- date: typeof msg.date === "number" ? new Date(msg.date * 1e3).toISOString() : (/* @__PURE__ */ new Date()).toISOString()
7293
- };
7294
- }
7295
- var TELEGRAM_STOP_WORDS = /* @__PURE__ */ new Set([
7296
- "stop",
7297
- "abort",
7298
- "kill",
7299
- "cancel",
7300
- "halt"
7301
- ]);
7302
- function isTelegramStopCommand(text) {
7303
- if (!text) return false;
7304
- const cleaned = text.trim().toLowerCase().replace(/[!.?]+$/, "");
7305
- return TELEGRAM_STOP_WORDS.has(cleaned);
7306
- }
7307
- function nextTelegramOffset(currentOffset, updates) {
7308
- let next = currentOffset;
7309
- for (const u of updates) {
7310
- if (u && typeof u.update_id === "number" && u.update_id >= next) {
7311
- next = u.update_id + 1;
7553
+ /** Stop a single agent's Telegram poller (called on disable). */
7554
+ async stopTelegramPollerForAgent(agentId) {
7555
+ const poller = this.telegramPollers.get(agentId);
7556
+ if (!poller) return;
7557
+ try {
7558
+ await poller.stop();
7559
+ } catch {
7312
7560
  }
7561
+ this.telegramPollers.delete(agentId);
7313
7562
  }
7314
- return next;
7315
- }
7316
-
7317
- // src/telegram/manager.ts
7318
- var import_node_crypto3 = require("crypto");
7319
- var TELEGRAM_WEBHOOK_SECRET_RE = /^[A-Za-z0-9_-]+$/;
7320
- var TELEGRAM_MIN_WEBHOOK_SECRET_LENGTH = 16;
7321
- var TELEGRAM_SECRET_FIELDS = ["botToken", "webhookSecret"];
7322
- function redactTelegramConfig(config) {
7323
- return {
7324
- ...config,
7325
- botToken: config.botToken ? "***" : config.botToken,
7326
- webhookSecret: config.webhookSecret ? "***" : void 0
7327
- };
7328
- }
7329
- function isTelegramChatAllowed(config, chatId) {
7330
- const id = String(chatId ?? "").trim();
7331
- if (!id) return false;
7332
- if (config.operatorChatId && String(config.operatorChatId).trim() === id) return true;
7333
- return Array.isArray(config.allowedChatIds) && config.allowedChatIds.some((c) => String(c).trim() === id);
7334
- }
7335
- function safeEqual(a, b) {
7336
- const bufA = Buffer.from(a, "utf8");
7337
- const bufB = Buffer.from(b, "utf8");
7338
- if (bufA.length !== bufB.length) return false;
7339
- return (0, import_node_crypto3.timingSafeEqual)(bufA, bufB);
7340
- }
7341
- var TelegramManager = class {
7342
7563
  /**
7343
- * Optional master key used to encrypt Telegram credentials at rest
7344
- * (the same AES-256-GCM scheme SMS/phone use). When absent (tests, or
7345
- * a deployment with no master key) configs are stored as-is and reads
7346
- * tolerate plaintext upgrades and downgrades both stay safe.
7564
+ * Convert one new inbound Telegram message into a synthetic email
7565
+ * landing in the agent's INBOX, so the dispatcher wakes the agent.
7566
+ *
7567
+ * Two short-circuits before delivery:
7568
+ *
7569
+ * 1. If the message is from the configured operator's chat AND
7570
+ * looks like an operator-query reply (parsed by
7571
+ * `parseTelegramOperatorReply`), it's an answer to an in-flight
7572
+ * voice mission, not free-form chat — the HTTP webhook/poll
7573
+ * route already handles those by calling into the phone
7574
+ * manager, and the route does NOT need an agent turn. The poller
7575
+ * hands them off the same way: we just skip the wake here.
7576
+ *
7577
+ * 2. Plain `/start` (BotFather's default first DM) is a Telegram
7578
+ * housekeeping nudge — replying with an LLM turn for "/start"
7579
+ * would be embarrassing. Skip it.
7580
+ *
7581
+ * Everything else: synthesise the email and deliver.
7347
7582
  */
7348
- constructor(db2, encryptionKey) {
7349
- this.db = db2;
7350
- this.encryptionKey = encryptionKey;
7351
- this.ensureTable();
7583
+ /**
7584
+ * Public wrapper around the bridge — the Telegram webhook route calls
7585
+ * this directly so push-mode and poll-mode share the wake path.
7586
+ */
7587
+ async bridgeTelegramInbound(agentId, parsed, config) {
7588
+ return this.bridgeInboundTelegram(agentId, parsed, config);
7352
7589
  }
7353
- initialized = false;
7354
- ensureTable() {
7355
- if (this.initialized) return;
7590
+ async bridgeInboundTelegram(agentId, parsed, config, agentNameHint) {
7591
+ const operatorChatId = config.operatorChatId?.toString().trim() || "";
7592
+ if (operatorChatId && parsed.chatId === operatorChatId) {
7593
+ const reply = parseTelegramOperatorReply({ text: parsed.text, replyToText: parsed.replyToText });
7594
+ if (reply) return;
7595
+ }
7596
+ const trimmedText = (parsed.text ?? "").trim();
7597
+ if (trimmedText === "/start" || trimmedText === "/help" || trimmedText === "/stop") {
7598
+ return;
7599
+ }
7600
+ if (!trimmedText) return;
7601
+ if (!this.accountManager) return;
7602
+ const agent = await this.accountManager.getById(agentId);
7603
+ if (!agent) return;
7604
+ const agentName = agentNameHint ?? agent.name;
7605
+ const fromLabel = parsed.fromName ? `${parsed.fromName} (Telegram chat ${parsed.chatId})` : `Telegram chat ${parsed.chatId}`;
7606
+ const senderName = parsed.fromName || parsed.fromUsername || "User";
7607
+ const subject = `[Telegram] ${trimmedText.slice(0, 80)}${trimmedText.length > 80 ? "\u2026" : ""}`;
7608
+ const body = [
7609
+ `[Incoming Telegram message \u2014 via AgenticMail Telegram bridge]`,
7610
+ `from_name: ${senderName}`,
7611
+ parsed.fromId ? `from_id: ${parsed.fromId}` : null,
7612
+ `chat_id: ${parsed.chatId}`,
7613
+ `chat_type: ${parsed.chatType}`,
7614
+ `telegram_message_id: ${parsed.messageId}`,
7615
+ `received_at: ${parsed.date}`,
7616
+ ``,
7617
+ `=== REPLY ROUTING (important, read before responding) ===`,
7618
+ `This message arrived via Telegram, NOT email. To reply to ${senderName}`,
7619
+ `you must use the telegram_send MCP tool \u2014 replying by email will go`,
7620
+ `nowhere they can see it.`,
7621
+ ``,
7622
+ ` telegram_send({ chatId: "${parsed.chatId}", text: "<your reply>" })`,
7623
+ ``,
7624
+ `Send EXACTLY ONE telegram_send call per response \u2014 do not also narrate`,
7625
+ `or summarise what you sent in a separate reply / email, that just shows`,
7626
+ `up to the user as a duplicate. Keep replies concise and plain text`,
7627
+ `(Telegram strips markdown formatting in transit). No preamble like`,
7628
+ `"sure, here you go" \u2014 just answer the question.`,
7629
+ ``,
7630
+ `If the user is asking you to do an errand (call someone, look up info,`,
7631
+ `send something), do the work FIRST, then telegram_send a single clear`,
7632
+ `update back to chat_id ${parsed.chatId} when done \u2014 that becomes the`,
7633
+ `whole reply.`,
7634
+ `=== END REPLY ROUTING ===`,
7635
+ ``,
7636
+ `--- User's message ---`,
7637
+ trimmedText,
7638
+ `---`
7639
+ ].filter((l) => l !== null).join("\n");
7640
+ const inbound = {
7641
+ from: `telegram-bridge@telegram.local`,
7642
+ to: agent.email,
7643
+ subject,
7644
+ text: body,
7645
+ html: void 0,
7646
+ // Use the Telegram-provided send time so the inbox ordering matches
7647
+ // when the user actually pressed Send, not when the bridge ran.
7648
+ date: parsed.date ? new Date(parsed.date) : /* @__PURE__ */ new Date(),
7649
+ messageId: `<tg-${parsed.chatId}-${parsed.messageId}@telegram.local>`
7650
+ };
7356
7651
  try {
7357
- this.db.exec(`
7358
- CREATE TABLE IF NOT EXISTS telegram_messages (
7359
- id TEXT PRIMARY KEY,
7360
- agent_id TEXT NOT NULL,
7361
- direction TEXT NOT NULL CHECK(direction IN ('inbound', 'outbound')),
7362
- chat_id TEXT NOT NULL,
7363
- telegram_message_id INTEGER,
7364
- from_id TEXT,
7365
- text TEXT NOT NULL,
7366
- status TEXT NOT NULL DEFAULT 'pending',
7367
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
7368
- metadata TEXT DEFAULT '{}'
7369
- )
7370
- `);
7652
+ await this.deliverInboundLocally(agentName, inbound);
7653
+ } catch (err) {
7654
+ console.warn(`[GatewayManager] Telegram \u2192 inbox bridge failed for ${agentName}: ${err.message}`);
7655
+ }
7656
+ }
7657
+ // --- Lifecycle ---
7658
+ async shutdown() {
7659
+ await this.relay.shutdown();
7660
+ this.tunnel?.stop();
7661
+ for (const poller of this.smsPollers.values()) {
7662
+ poller.stopPolling();
7663
+ }
7664
+ this.smsPollers.clear();
7665
+ for (const poller of this.telegramPollers.values()) {
7371
7666
  try {
7372
- this.db.exec("CREATE INDEX IF NOT EXISTS idx_telegram_agent ON telegram_messages(agent_id)");
7667
+ void poller.stop();
7373
7668
  } catch {
7374
7669
  }
7670
+ }
7671
+ this.telegramPollers.clear();
7672
+ }
7673
+ /**
7674
+ * Resume gateway from saved config (e.g., after server restart).
7675
+ *
7676
+ * Issue #31 — On a Docker container restart the API can come up
7677
+ * before Stalwart / Gmail IMAP / DNS is reachable, so the very first
7678
+ * setup() can fail with a transient network error. Previously that
7679
+ * single failure was logged and never retried, leaving polling
7680
+ * permanently dead until someone noticed and manually revived the
7681
+ * relay. We now schedule background retries with exponential backoff
7682
+ * (5s, 10s, 20s, 40s, 60s cap, indefinite) so the relay
7683
+ * self-recovers as soon as the dependency is reachable again.
7684
+ */
7685
+ async resume() {
7686
+ if (this.config.mode === "relay" && this.config.relay) {
7375
7687
  try {
7376
- this.db.exec("CREATE INDEX IF NOT EXISTS idx_telegram_chat ON telegram_messages(chat_id)");
7377
- } catch {
7688
+ await this._resumeRelayOnce();
7689
+ } catch (err) {
7690
+ console.error("[GatewayManager] Initial relay resume failed; scheduling retries:", formatPollError(err));
7691
+ this._scheduleRelayResumeRetry();
7378
7692
  }
7693
+ }
7694
+ if (this.smsManager && this.accountManager) {
7379
7695
  try {
7380
- this.db.exec("CREATE INDEX IF NOT EXISTS idx_telegram_created ON telegram_messages(created_at)");
7381
- } catch {
7696
+ await this.startSmsPollers();
7697
+ } catch (err) {
7698
+ console.error("[GatewayManager] Failed to start SMS pollers:", err);
7382
7699
  }
7383
- this.initialized = true;
7384
- } catch {
7385
- this.initialized = true;
7386
7700
  }
7387
- }
7388
- /** Encrypt the credential fields of a config before persisting. */
7389
- encryptConfig(config) {
7390
- if (!this.encryptionKey) return config;
7391
- const out = { ...config };
7392
- for (const field of TELEGRAM_SECRET_FIELDS) {
7393
- const value = out[field];
7394
- if (typeof value === "string" && value && !isEncryptedSecret(value)) {
7395
- out[field] = encryptSecret(value, this.encryptionKey);
7701
+ if (this.telegramManager && this.accountManager) {
7702
+ try {
7703
+ await this.startTelegramPollers();
7704
+ } catch (err) {
7705
+ console.error("[GatewayManager] Failed to start Telegram pollers:", err);
7396
7706
  }
7397
7707
  }
7398
- return out;
7399
- }
7400
- /** Decrypt the credential fields of a config after loading. */
7401
- decryptConfig(config) {
7402
- if (!this.encryptionKey) return config;
7403
- const out = { ...config };
7404
- for (const field of TELEGRAM_SECRET_FIELDS) {
7405
- const value = out[field];
7406
- if (typeof value === "string" && isEncryptedSecret(value)) {
7407
- try {
7408
- out[field] = decryptSecret(value, this.encryptionKey);
7409
- } catch {
7708
+ if (this.config.mode === "domain" && this.config.domain) {
7709
+ try {
7710
+ this.cfClient = new CloudflareClient(
7711
+ this.config.domain.cloudflareApiToken,
7712
+ this.config.domain.cloudflareAccountId
7713
+ );
7714
+ this.dnsConfigurator = new DNSConfigurator(this.cfClient);
7715
+ this.tunnel = new TunnelManager(this.cfClient);
7716
+ this.domainPurchaser = new DomainPurchaser(this.cfClient);
7717
+ if (this.config.domain.tunnelToken) {
7718
+ await this.tunnel.start(this.config.domain.tunnelToken);
7410
7719
  }
7720
+ } catch (err) {
7721
+ console.error("[GatewayManager] Failed to resume domain mode:", err);
7411
7722
  }
7412
7723
  }
7413
- return out;
7414
- }
7415
- /** Normalize a stored/loaded config object, defaulting missing fields. */
7416
- normalizeConfig(raw) {
7417
- return {
7418
- enabled: raw.enabled === true,
7419
- botToken: typeof raw.botToken === "string" ? raw.botToken : "",
7420
- botUsername: typeof raw.botUsername === "string" ? raw.botUsername : void 0,
7421
- botId: typeof raw.botId === "number" ? raw.botId : void 0,
7422
- allowedChatIds: Array.isArray(raw.allowedChatIds) ? raw.allowedChatIds.map((c) => String(c).trim()).filter(Boolean) : [],
7423
- operatorChatId: typeof raw.operatorChatId === "string" && raw.operatorChatId.trim() ? raw.operatorChatId.trim() : void 0,
7424
- mode: raw.mode === "webhook" ? "webhook" : "poll",
7425
- webhookUrl: typeof raw.webhookUrl === "string" ? raw.webhookUrl : void 0,
7426
- webhookSecret: typeof raw.webhookSecret === "string" ? raw.webhookSecret : void 0,
7427
- pollOffset: typeof raw.pollOffset === "number" ? raw.pollOffset : 0,
7428
- configuredAt: typeof raw.configuredAt === "string" ? raw.configuredAt : (/* @__PURE__ */ new Date()).toISOString()
7429
- };
7430
- }
7431
- /** Get the Telegram config from agent metadata (credentials decrypted). */
7432
- getConfig(agentId) {
7433
- const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
7434
- if (!row) return null;
7435
- try {
7436
- const meta = JSON.parse(row.metadata || "{}");
7437
- if (!meta.telegram || typeof meta.telegram !== "object") return null;
7438
- return this.decryptConfig(this.normalizeConfig(meta.telegram));
7439
- } catch {
7440
- return null;
7441
- }
7442
7724
  }
7443
- /** Save the Telegram config to agent metadata (credentials encrypted). */
7444
- saveConfig(agentId, config) {
7445
- const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
7446
- if (!row) throw new Error(`Agent ${agentId} not found`);
7447
- let meta;
7448
- try {
7449
- meta = JSON.parse(row.metadata || "{}");
7450
- } catch {
7451
- meta = {};
7725
+ // ─── Issue #31 helpers resume retry with backoff ───
7726
+ _resumeRetryTimer = null;
7727
+ _resumeRetryAttempt = 0;
7728
+ async _resumeRelayOnce() {
7729
+ if (!this.config.relay) throw new Error("No relay config to resume");
7730
+ await this.relay.setup(this.config.relay);
7731
+ const savedUid = this.loadLastSeenUid();
7732
+ if (savedUid > 0) {
7733
+ this.relay.setLastSeenUid(savedUid);
7734
+ console.log(`[GatewayManager] Restored lastSeenUid=${savedUid} from database`);
7452
7735
  }
7453
- meta.telegram = this.encryptConfig(config);
7454
- this.db.prepare("UPDATE agents SET metadata = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(meta), agentId);
7455
- }
7456
- /** Remove the Telegram config from agent metadata. */
7457
- removeConfig(agentId) {
7458
- const row = this.db.prepare("SELECT metadata FROM agents WHERE id = ?").get(agentId);
7459
- if (!row) return;
7460
- let meta;
7461
- try {
7462
- meta = JSON.parse(row.metadata || "{}");
7463
- } catch {
7464
- meta = {};
7736
+ this.relay.onUidAdvance = (uid) => this.saveLastSeenUid(uid);
7737
+ await this.relay.startPolling();
7738
+ if (this._resumeRetryAttempt > 0) {
7739
+ console.log(`[GatewayManager] Relay polling resumed after ${this._resumeRetryAttempt} retry attempt${this._resumeRetryAttempt !== 1 ? "s" : ""}`);
7465
7740
  }
7466
- delete meta.telegram;
7467
- this.db.prepare("UPDATE agents SET metadata = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(meta), agentId);
7741
+ this._resumeRetryAttempt = 0;
7468
7742
  }
7469
- /** Persist a new poll offset without touching the rest of the config. */
7470
- updatePollOffset(agentId, offset) {
7471
- const config = this.getConfig(agentId);
7472
- if (!config) return;
7473
- config.pollOffset = offset;
7474
- this.saveConfig(agentId, config);
7743
+ _scheduleRelayResumeRetry() {
7744
+ if (this._resumeRetryTimer) return;
7745
+ this._resumeRetryAttempt++;
7746
+ const base = Math.min(5e3 * Math.pow(2, this._resumeRetryAttempt - 1), 6e4);
7747
+ const jitter = base * (0.8 + Math.random() * 0.4);
7748
+ const delay = Math.round(jitter);
7749
+ console.log(`[GatewayManager] Will retry relay resume in ${(delay / 1e3).toFixed(1)}s (attempt ${this._resumeRetryAttempt + 1})`);
7750
+ this._resumeRetryTimer = setTimeout(async () => {
7751
+ this._resumeRetryTimer = null;
7752
+ if (this.config.mode !== "relay" || !this.config.relay) return;
7753
+ try {
7754
+ await this._resumeRelayOnce();
7755
+ } catch (err) {
7756
+ console.error(`[GatewayManager] Relay resume retry ${this._resumeRetryAttempt} failed:`, formatPollError(err));
7757
+ this._scheduleRelayResumeRetry();
7758
+ }
7759
+ }, delay);
7475
7760
  }
7476
- /**
7477
- * Resolve the agent that owns a webhook secret. Used to authenticate +
7478
- * route an inbound Telegram webhook delivery: a webhook carries no bot
7479
- * identity, so the `X-Telegram-Bot-Api-Secret-Token` header is the
7480
- * routing key. The comparison is constant-time, and a non-match
7481
- * returns `null` so the route can answer with a single uniform 403
7482
- * (no enumeration oracle — same posture as the SMS webhook).
7483
- */
7484
- findAgentByWebhookSecret(secret) {
7485
- const provided = String(secret ?? "");
7486
- if (!provided) return null;
7487
- const rows = this.db.prepare("SELECT id, metadata FROM agents").all();
7488
- for (const row of rows) {
7761
+ // --- Persistence ---
7762
+ loadConfig() {
7763
+ const row = this.db.prepare("SELECT * FROM gateway_config WHERE id = ?").get("default");
7764
+ if (row) {
7489
7765
  try {
7490
- const meta = JSON.parse(row.metadata || "{}");
7491
- if (!meta.telegram || typeof meta.telegram !== "object") continue;
7492
- const config = this.decryptConfig(this.normalizeConfig(meta.telegram));
7493
- if (!config.enabled || !config.webhookSecret) continue;
7494
- if (safeEqual(provided, config.webhookSecret)) {
7495
- return { agentId: row.id, config };
7766
+ const parsed = JSON.parse(row.config);
7767
+ if (this.encryptionKey) {
7768
+ if (parsed.relay?.password) {
7769
+ try {
7770
+ parsed.relay.password = decryptSecret(parsed.relay.password, this.encryptionKey);
7771
+ } catch {
7772
+ }
7773
+ }
7774
+ if (parsed.relay?.appPassword) {
7775
+ try {
7776
+ parsed.relay.appPassword = decryptSecret(parsed.relay.appPassword, this.encryptionKey);
7777
+ } catch {
7778
+ }
7779
+ }
7780
+ if (parsed.domain?.cloudflareApiToken) {
7781
+ try {
7782
+ parsed.domain.cloudflareApiToken = decryptSecret(parsed.domain.cloudflareApiToken, this.encryptionKey);
7783
+ } catch {
7784
+ }
7785
+ }
7786
+ if (parsed.domain?.tunnelToken) {
7787
+ try {
7788
+ parsed.domain.tunnelToken = decryptSecret(parsed.domain.tunnelToken, this.encryptionKey);
7789
+ } catch {
7790
+ }
7791
+ }
7792
+ if (parsed.domain?.inboundSecret) {
7793
+ try {
7794
+ parsed.domain.inboundSecret = decryptSecret(parsed.domain.inboundSecret, this.encryptionKey);
7795
+ } catch {
7796
+ }
7797
+ }
7798
+ if (parsed.domain?.outboundSecret) {
7799
+ try {
7800
+ parsed.domain.outboundSecret = decryptSecret(parsed.domain.outboundSecret, this.encryptionKey);
7801
+ } catch {
7802
+ }
7803
+ }
7496
7804
  }
7805
+ this.config = {
7806
+ mode: row.mode,
7807
+ ...parsed
7808
+ };
7497
7809
  } catch {
7810
+ this.config = { mode: "none" };
7498
7811
  }
7499
7812
  }
7500
- return null;
7501
7813
  }
7502
- /** True if an inbound message with this Telegram id is already stored. */
7503
- inboundMessageExists(agentId, chatId, telegramMessageId) {
7504
- const row = this.db.prepare(
7505
- "SELECT 1 FROM telegram_messages WHERE agent_id = ? AND direction = ? AND chat_id = ? AND telegram_message_id = ? LIMIT 1"
7506
- ).get(agentId, "inbound", String(chatId), telegramMessageId);
7507
- return !!row;
7814
+ saveConfig() {
7815
+ const { mode, ...rest } = this.config;
7816
+ const toStore = JSON.parse(JSON.stringify(rest));
7817
+ if (this.encryptionKey) {
7818
+ if (toStore.relay?.password) {
7819
+ toStore.relay.password = encryptSecret(toStore.relay.password, this.encryptionKey);
7820
+ }
7821
+ if (toStore.relay?.appPassword) {
7822
+ toStore.relay.appPassword = encryptSecret(toStore.relay.appPassword, this.encryptionKey);
7823
+ }
7824
+ if (toStore.domain?.cloudflareApiToken) {
7825
+ toStore.domain.cloudflareApiToken = encryptSecret(toStore.domain.cloudflareApiToken, this.encryptionKey);
7826
+ }
7827
+ if (toStore.domain?.tunnelToken) {
7828
+ toStore.domain.tunnelToken = encryptSecret(toStore.domain.tunnelToken, this.encryptionKey);
7829
+ }
7830
+ if (toStore.domain?.inboundSecret) {
7831
+ toStore.domain.inboundSecret = encryptSecret(toStore.domain.inboundSecret, this.encryptionKey);
7832
+ }
7833
+ if (toStore.domain?.outboundSecret) {
7834
+ toStore.domain.outboundSecret = encryptSecret(toStore.domain.outboundSecret, this.encryptionKey);
7835
+ }
7836
+ }
7837
+ this.db.prepare(`
7838
+ INSERT OR REPLACE INTO gateway_config (id, mode, config)
7839
+ VALUES ('default', ?, ?)
7840
+ `).run(mode, JSON.stringify(toStore));
7841
+ }
7842
+ saveLastSeenUid(uid) {
7843
+ this.db.prepare(`
7844
+ INSERT OR REPLACE INTO config (key, value) VALUES ('relay_last_seen_uid', ?)
7845
+ `).run(String(uid));
7846
+ }
7847
+ loadLastSeenUid() {
7848
+ const row = this.db.prepare("SELECT value FROM config WHERE key = ?").get("relay_last_seen_uid");
7849
+ return row ? parseInt(row.value, 10) || 0 : 0;
7850
+ }
7851
+ };
7852
+ function parseAddressString(addr) {
7853
+ const match = addr.match(/^(.+?)\s*<([^>]+)>$/);
7854
+ if (match) {
7855
+ return { name: match[1].trim(), address: match[2].trim() };
7856
+ }
7857
+ return { address: addr.trim() };
7858
+ }
7859
+ function inboundToParsedEmail(mail) {
7860
+ return {
7861
+ messageId: mail.messageId || "",
7862
+ subject: mail.subject || "",
7863
+ from: [parseAddressString(mail.from)],
7864
+ to: [parseAddressString(mail.to)],
7865
+ date: mail.date || /* @__PURE__ */ new Date(),
7866
+ text: mail.text,
7867
+ html: mail.html,
7868
+ inReplyTo: mail.inReplyTo,
7869
+ references: mail.references,
7870
+ attachments: (mail.attachments ?? []).map((a) => ({
7871
+ filename: a.filename,
7872
+ contentType: a.contentType,
7873
+ size: a.size,
7874
+ content: a.content
7875
+ })),
7876
+ headers: /* @__PURE__ */ new Map()
7877
+ };
7878
+ }
7879
+
7880
+ // src/gateway/relay-bridge.ts
7881
+ var import_node_http = require("http");
7882
+ var import_nodemailer4 = require("nodemailer");
7883
+ var RelayBridge = class {
7884
+ server = null;
7885
+ options;
7886
+ constructor(options) {
7887
+ this.options = options;
7508
7888
  }
7509
- /** Record an inbound Telegram message. */
7510
- recordInbound(agentId, input, metadata) {
7511
- const id = `tg_in_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
7512
- const createdAt = input.createdAt || (/* @__PURE__ */ new Date()).toISOString();
7513
- this.db.prepare(
7514
- "INSERT INTO telegram_messages (id, agent_id, direction, chat_id, telegram_message_id, from_id, text, status, created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
7515
- ).run(
7516
- id,
7517
- agentId,
7518
- "inbound",
7519
- String(input.chatId),
7520
- input.telegramMessageId,
7521
- input.fromId ?? null,
7522
- input.text,
7523
- "received",
7524
- createdAt,
7525
- JSON.stringify(metadata ?? {})
7526
- );
7527
- return {
7528
- id,
7529
- agentId,
7530
- direction: "inbound",
7531
- chatId: String(input.chatId),
7532
- telegramMessageId: input.telegramMessageId,
7533
- fromId: input.fromId,
7534
- text: input.text,
7535
- status: "received",
7536
- createdAt,
7537
- metadata
7538
- };
7889
+ async start() {
7890
+ return new Promise((resolve2, reject) => {
7891
+ this.server = (0, import_node_http.createServer)((req, res) => this.handleRequest(req, res));
7892
+ this.server.listen(this.options.port, "127.0.0.1", () => {
7893
+ console.log(`[RelayBridge] Listening on 127.0.0.1:${this.options.port}`);
7894
+ resolve2();
7895
+ });
7896
+ this.server.on("error", reject);
7897
+ });
7539
7898
  }
7540
- /** Record an outbound Telegram message attempt. */
7541
- recordOutbound(agentId, input, metadata) {
7542
- const id = `tg_out_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
7543
- const createdAt = (/* @__PURE__ */ new Date()).toISOString();
7544
- const status = input.status ?? "sent";
7545
- this.db.prepare(
7546
- "INSERT INTO telegram_messages (id, agent_id, direction, chat_id, telegram_message_id, from_id, text, status, created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
7547
- ).run(
7548
- id,
7549
- agentId,
7550
- "outbound",
7551
- String(input.chatId),
7552
- input.telegramMessageId ?? null,
7553
- null,
7554
- input.text,
7555
- status,
7556
- createdAt,
7557
- JSON.stringify(metadata ?? {})
7558
- );
7559
- return {
7560
- id,
7561
- agentId,
7562
- direction: "outbound",
7563
- chatId: String(input.chatId),
7564
- telegramMessageId: input.telegramMessageId,
7565
- text: input.text,
7566
- status,
7567
- createdAt,
7568
- metadata
7569
- };
7899
+ stop() {
7900
+ this.server?.close();
7901
+ this.server = null;
7570
7902
  }
7571
- /** Update the status (+ optional metadata) of a stored message. */
7572
- updateStatus(id, status, metadata) {
7573
- if (metadata) {
7574
- this.db.prepare("UPDATE telegram_messages SET status = ?, metadata = ? WHERE id = ?").run(status, JSON.stringify(metadata), id);
7903
+ async handleRequest(req, res) {
7904
+ if (req.method !== "POST" || req.url !== "/send") {
7905
+ res.writeHead(404, { "Content-Type": "application/json" });
7906
+ res.end(JSON.stringify({ error: "Not found" }));
7575
7907
  return;
7576
7908
  }
7577
- this.db.prepare("UPDATE telegram_messages SET status = ? WHERE id = ?").run(status, id);
7578
- }
7579
- /** List stored Telegram messages for an agent, newest first. */
7580
- listMessages(agentId, opts) {
7581
- const limit = Math.min(Math.max(opts?.limit ?? 20, 1), 100);
7582
- const offset = Math.max(opts?.offset ?? 0, 0);
7583
- let query = "SELECT * FROM telegram_messages WHERE agent_id = ?";
7584
- const params = [agentId];
7585
- if (opts?.direction === "inbound" || opts?.direction === "outbound") {
7586
- query += " AND direction = ?";
7587
- params.push(opts.direction);
7909
+ const secret = req.headers["x-relay-secret"];
7910
+ if (secret !== this.options.secret) {
7911
+ res.writeHead(401, { "Content-Type": "application/json" });
7912
+ res.end(JSON.stringify({ error: "Unauthorized" }));
7913
+ return;
7588
7914
  }
7589
- if (opts?.chatId) {
7590
- query += " AND chat_id = ?";
7591
- params.push(String(opts.chatId));
7915
+ let body = "";
7916
+ for await (const chunk of req) body += chunk;
7917
+ try {
7918
+ const payload = JSON.parse(body);
7919
+ const result = await this.submitToStalwart(payload);
7920
+ res.writeHead(200, { "Content-Type": "application/json" });
7921
+ res.end(JSON.stringify(result));
7922
+ } catch (err) {
7923
+ console.error("[RelayBridge] Delivery failed:", err.message);
7924
+ res.writeHead(500, { "Content-Type": "application/json" });
7925
+ res.end(JSON.stringify({ error: err.message }));
7926
+ }
7927
+ }
7928
+ async submitToStalwart(payload) {
7929
+ const { from, to, subject, text, html, replyTo, inReplyTo, references } = payload;
7930
+ const recipients = Array.isArray(to) ? to : [to];
7931
+ debug("RelayBridge", `Submitting to Stalwart: ${from} \u2192 ${recipients.join(", ")}`);
7932
+ const transport = (0, import_nodemailer4.createTransport)({
7933
+ host: this.options.smtpHost ?? "127.0.0.1",
7934
+ port: this.options.smtpPort ?? 587,
7935
+ secure: false,
7936
+ auth: {
7937
+ user: this.options.smtpUser,
7938
+ pass: this.options.smtpPass
7939
+ },
7940
+ tls: { rejectUnauthorized: false }
7941
+ });
7942
+ try {
7943
+ const info = await transport.sendMail({
7944
+ from,
7945
+ to: recipients.join(", "),
7946
+ subject,
7947
+ text: text || void 0,
7948
+ html: html || void 0,
7949
+ replyTo: replyTo || void 0,
7950
+ inReplyTo: inReplyTo || void 0,
7951
+ references: references || void 0,
7952
+ headers: {
7953
+ "X-Mailer": "AgenticMail/1.0"
7954
+ }
7955
+ });
7956
+ debug("RelayBridge", `Queued: ${info.messageId} \u2192 ${info.response}`);
7957
+ return {
7958
+ ok: true,
7959
+ messageId: info.messageId,
7960
+ response: info.response
7961
+ };
7962
+ } finally {
7963
+ transport.close();
7592
7964
  }
7593
- query += " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?";
7594
- params.push(limit, offset);
7595
- return this.db.prepare(query).all(...params).map((row) => ({
7596
- id: row.id,
7597
- agentId: row.agent_id,
7598
- direction: row.direction,
7599
- chatId: row.chat_id,
7600
- telegramMessageId: row.telegram_message_id ?? void 0,
7601
- fromId: row.from_id ?? void 0,
7602
- text: row.text,
7603
- status: row.status,
7604
- createdAt: row.created_at,
7605
- metadata: row.metadata ? JSON.parse(row.metadata) : void 0
7606
- }));
7607
7965
  }
7608
7966
  };
7609
-
7610
- // src/telegram/operator-query.ts
7611
- var TELEGRAM_OPERATOR_QUERY_TAG = "AMQ";
7612
- var QUERY_ID_RE = /(oq_[A-Za-z0-9-]+)/;
7613
- var QUERY_TAG_RE = new RegExp(`\\[${TELEGRAM_OPERATOR_QUERY_TAG}\\s+(oq_[A-Za-z0-9-]+)\\]`);
7614
- function formatOperatorQueryTelegramMessage(input) {
7615
- const lines = [];
7616
- 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.");
7617
- lines.push("");
7618
- lines.push(`Question: ${input.question}`);
7619
- if (input.callContext) lines.push(`Context: ${input.callContext}`);
7620
- lines.push("");
7621
- lines.push("Reply to this message with your answer. You can also send:");
7622
- lines.push(` /answer ${input.queryId} <your answer>`);
7623
- lines.push(` /approve ${input.queryId} \xB7 /deny ${input.queryId}`);
7624
- lines.push("");
7625
- lines.push(`[${TELEGRAM_OPERATOR_QUERY_TAG} ${input.queryId}]`);
7626
- return lines.join("\n");
7967
+ function startRelayBridge(options) {
7968
+ const bridge = new RelayBridge(options);
7969
+ bridge.start().catch((err) => {
7970
+ console.error("[RelayBridge] Failed to start:", err);
7971
+ });
7972
+ return bridge;
7627
7973
  }
7628
- function parseTelegramOperatorReply(input) {
7629
- const text = (input.text ?? "").trim();
7630
- if (!text) return null;
7631
- const quotedTag = input.replyToText ? QUERY_TAG_RE.exec(input.replyToText) : null;
7632
- const quotedQueryId = quotedTag?.[1];
7633
- const answerCmd = /^\/answer(?:@\w+)?\s+(oq_[A-Za-z0-9-]+)\s+([\s\S]+)$/i.exec(text);
7634
- if (answerCmd) {
7635
- return { queryId: answerCmd[1], answer: answerCmd[2].trim(), kind: "answer" };
7636
- }
7637
- const decisionCmd = /^\/(approve|deny)(?:@\w+)?\b([\s\S]*)$/i.exec(text);
7638
- if (decisionCmd) {
7639
- const kind = decisionCmd[1].toLowerCase() === "approve" ? "approve" : "deny";
7640
- const rest = decisionCmd[2].trim();
7641
- const inlineId2 = QUERY_ID_RE.exec(rest)?.[1];
7642
- const note = rest.replace(QUERY_ID_RE, "").trim();
7643
- const answer2 = (kind === "approve" ? "Approved" : "Denied") + (note ? `: ${note}` : ".");
7644
- return { queryId: inlineId2 ?? quotedQueryId, answer: answer2, kind };
7974
+
7975
+ // src/gateway/types.ts
7976
+ var RELAY_PRESETS = {
7977
+ gmail: {
7978
+ smtpHost: "smtp.gmail.com",
7979
+ smtpPort: 587,
7980
+ imapHost: "imap.gmail.com",
7981
+ imapPort: 993
7982
+ },
7983
+ outlook: {
7984
+ smtpHost: "smtp.office365.com",
7985
+ smtpPort: 587,
7986
+ imapHost: "outlook.office365.com",
7987
+ imapPort: 993
7645
7988
  }
7646
- const inlineId = QUERY_TAG_RE.exec(text)?.[1] ?? QUERY_ID_RE.exec(text)?.[1];
7647
- const answer = text.replace(QUERY_TAG_RE, "").trim();
7648
- if (!answer) return null;
7649
- return { queryId: quotedQueryId ?? inlineId, answer, kind: "answer" };
7650
- }
7989
+ };
7651
7990
 
7652
7991
  // src/phone/realtime.ts
7653
7992
  var ELKS_REALTIME_AUDIO_FORMATS = ["ulaw", "pcm_16000", "pcm_24000", "wav"];
@@ -7854,7 +8193,9 @@ var ElksRealtimeTransport = class {
7854
8193
  // Historical prefix — `elks-bye` / `elks-closed` etc. are matched by
7855
8194
  // long-standing call sites and tests; do not change.
7856
8195
  endReasonPrefix = "elks";
7857
- openaiAudioFormat = { type: "audio/pcm", rate: 24e3 };
8196
+ // OpenAI rejects `format.rate` as an unknown parameter — `audio/pcm` is
8197
+ // implicitly 24 kHz mono PCM16 in the current Realtime API.
8198
+ openaiAudioFormat = { type: "audio/pcm" };
7858
8199
  parseInbound(raw) {
7859
8200
  const msg = parseElksRealtimeMessage(raw);
7860
8201
  if (msg.t === "hello") {
@@ -7885,9 +8226,9 @@ var TwilioRealtimeTransport = class {
7885
8226
  provider = "twilio";
7886
8227
  endReasonPrefix = "twilio";
7887
8228
  // µ-law @ 8 kHz — Twilio's native format; no transcode end to end.
7888
- // > `audio/pcmu` is the OpenAI GA Realtime µ-law format token; verify
7889
- // > against current OpenAI docs before the live smoke-test.
7890
- openaiAudioFormat = { type: "audio/pcmu", rate: 8e3 };
8229
+ // OpenAI rejects `format.rate` as an unknown parameter; `audio/pcmu` is
8230
+ // implicitly 8 kHz G.711 µ-law in the current Realtime API.
8231
+ openaiAudioFormat = { type: "audio/pcmu" };
7891
8232
  /** Latched from the Twilio `start` frame; required on every outbound. */
7892
8233
  streamSid = "";
7893
8234
  /** The active `streamSid`, once the `start` frame has been seen. */
@@ -8247,7 +8588,7 @@ ${task}`);
8247
8588
  }
8248
8589
  return sections.join("\n\n");
8249
8590
  }
8250
- var DEFAULT_REALTIME_AUDIO_FORMAT = { type: "audio/pcm", rate: REALTIME_AUDIO_SAMPLE_RATE };
8591
+ var DEFAULT_REALTIME_AUDIO_FORMAT = { type: "audio/pcm" };
8251
8592
  function buildRealtimeSessionConfig(opts) {
8252
8593
  const tools = opts.tools ?? [];
8253
8594
  const instructions = opts.instructions?.trim() || buildRealtimeInstructions({
@@ -8350,6 +8691,7 @@ var RealtimeVoiceBridge = class {
8350
8691
  if (this.ended || this.openaiReady) return;
8351
8692
  this.openaiReady = true;
8352
8693
  this.safeSend(this.openai, this.sessionConfig);
8694
+ this.safeSend(this.openai, { type: "response.create" });
8353
8695
  for (const audio of this.pendingAudio.splice(0)) {
8354
8696
  this.safeSend(this.openai, { type: "input_audio_buffer.append", audio });
8355
8697
  }
@@ -9082,7 +9424,8 @@ function buildWebhookUrl(config, path2, missionId) {
9082
9424
  return url.toString();
9083
9425
  }
9084
9426
  function buildRealtimeStreamUrl(webhookBaseUrl, missionId, token) {
9085
- const url = new URL(`${apiBaseUrl(webhookBaseUrl)}${TWILIO_REALTIME_WS_PATH}`);
9427
+ const root = webhookBaseUrl.replace(/\/+$/, "");
9428
+ const url = new URL(`${root}${TWILIO_REALTIME_WS_PATH}`);
9086
9429
  url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
9087
9430
  url.searchParams.set("missionId", missionId);
9088
9431
  url.searchParams.set("token", token);