@agenticmail/core 0.9.20 → 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 +1764 -1425
- package/dist/index.d.cts +766 -702
- package/dist/index.d.ts +766 -702
- package/dist/index.js +1764 -1425
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -6049,1605 +6049,1944 @@ var SmsPoller = class {
|
|
|
6049
6049
|
}
|
|
6050
6050
|
};
|
|
6051
6051
|
|
|
6052
|
-
// src/
|
|
6053
|
-
var
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
|
|
6058
|
-
|
|
6059
|
-
|
|
6060
|
-
|
|
6061
|
-
|
|
6062
|
-
|
|
6063
|
-
|
|
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
|
-
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
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
|
-
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
|
|
6088
|
-
|
|
6089
|
-
|
|
6090
|
-
|
|
6091
|
-
|
|
6092
|
-
|
|
6093
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
6099
|
-
|
|
6100
|
-
this.
|
|
6257
|
+
constructor(db2, encryptionKey) {
|
|
6258
|
+
this.db = db2;
|
|
6259
|
+
this.encryptionKey = encryptionKey;
|
|
6260
|
+
this.ensureTable();
|
|
6101
6261
|
}
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
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
|
-
|
|
6118
|
-
|
|
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
|
-
|
|
6136
|
-
|
|
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
|
-
|
|
6151
|
-
|
|
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
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
|
|
6161
|
-
const
|
|
6162
|
-
|
|
6163
|
-
|
|
6164
|
-
|
|
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
|
-
|
|
6167
|
-
|
|
6168
|
-
|
|
6169
|
-
|
|
6170
|
-
|
|
6171
|
-
|
|
6172
|
-
|
|
6173
|
-
|
|
6174
|
-
|
|
6175
|
-
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
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;
|
|
6207
6323
|
}
|
|
6208
|
-
/**
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
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
|
+
};
|
|
6339
|
+
}
|
|
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
|
-
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
const
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
6233
|
-
|
|
6234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6240
|
-
|
|
6241
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
6254
|
-
*
|
|
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
|
-
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
const
|
|
6261
|
-
|
|
6262
|
-
|
|
6263
|
-
|
|
6264
|
-
|
|
6265
|
-
|
|
6266
|
-
|
|
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
|
-
|
|
6279
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
6283
|
-
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
6297
|
-
|
|
6298
|
-
|
|
6299
|
-
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
|
|
6303
|
-
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
6314
|
-
|
|
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
|
-
|
|
6318
|
-
|
|
6319
|
-
|
|
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
|
+
}));
|
|
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;
|
|
6320
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
|
-
*
|
|
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
|
-
|
|
6325
|
-
|
|
6326
|
-
|
|
6327
|
-
|
|
6328
|
-
|
|
6329
|
-
|
|
6330
|
-
|
|
6331
|
-
|
|
6332
|
-
|
|
6333
|
-
|
|
6334
|
-
|
|
6335
|
-
|
|
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
|
-
|
|
6349
|
-
async
|
|
6350
|
-
|
|
6351
|
-
|
|
6352
|
-
|
|
6353
|
-
|
|
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
|
-
|
|
6369
|
-
|
|
6370
|
-
|
|
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
|
-
|
|
6402
|
-
|
|
6403
|
-
|
|
6404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
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
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
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
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
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
|
-
|
|
6481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
6582
|
-
|
|
6583
|
-
|
|
6584
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
6607
|
-
if (
|
|
6608
|
-
|
|
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
|
-
*
|
|
6634
|
-
*
|
|
6635
|
-
*
|
|
6636
|
-
*
|
|
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
|
|
6639
|
-
if (!this.accountManager) {
|
|
6640
|
-
|
|
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
|
-
|
|
6651
|
-
|
|
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
|
|
6657
|
-
const
|
|
6658
|
-
if (
|
|
6659
|
-
await this.
|
|
6660
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
|
|
6670
|
-
|
|
6671
|
-
|
|
6672
|
-
|
|
6673
|
-
|
|
6674
|
-
|
|
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:
|
|
6703
|
-
port:
|
|
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
|
-
|
|
6713
|
-
|
|
6714
|
-
|
|
6715
|
-
|
|
6716
|
-
|
|
6717
|
-
|
|
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
|
-
*
|
|
6783
|
-
*
|
|
6784
|
-
*
|
|
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
|
|
6787
|
-
|
|
6788
|
-
|
|
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
|
-
|
|
6791
|
-
|
|
6792
|
-
|
|
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
|
-
|
|
6796
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
6804
|
-
*
|
|
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
|
|
6807
|
-
if (!this.
|
|
6808
|
-
|
|
6809
|
-
|
|
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
|
-
|
|
6812
|
-
|
|
6813
|
-
|
|
6814
|
-
|
|
6815
|
-
|
|
6816
|
-
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
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
|
-
|
|
6826
|
-
|
|
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
|
-
*
|
|
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
|
|
6847
|
-
|
|
6848
|
-
|
|
6849
|
-
|
|
6850
|
-
|
|
6851
|
-
|
|
6852
|
-
|
|
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
|
-
|
|
6856
|
-
|
|
6857
|
-
|
|
6858
|
-
|
|
6859
|
-
|
|
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 (
|
|
6863
|
-
|
|
6864
|
-
|
|
6865
|
-
|
|
6866
|
-
|
|
6867
|
-
|
|
6868
|
-
|
|
6869
|
-
|
|
6870
|
-
|
|
6871
|
-
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
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
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
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
|
-
|
|
6891
|
-
|
|
6892
|
-
|
|
6893
|
-
|
|
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.
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
this.
|
|
6900
|
-
|
|
6901
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
6910
|
-
|
|
6911
|
-
|
|
6912
|
-
|
|
6913
|
-
|
|
6914
|
-
|
|
6915
|
-
|
|
6916
|
-
|
|
6917
|
-
const
|
|
6918
|
-
|
|
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
|
|
6921
|
-
|
|
6922
|
-
|
|
6923
|
-
|
|
6924
|
-
|
|
6925
|
-
|
|
6926
|
-
|
|
6927
|
-
|
|
6928
|
-
|
|
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
|
-
|
|
6960
|
-
|
|
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
|
-
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
6972
|
-
|
|
6973
|
-
|
|
6974
|
-
|
|
6975
|
-
|
|
6976
|
-
|
|
6977
|
-
|
|
6978
|
-
|
|
6979
|
-
|
|
6980
|
-
|
|
6981
|
-
|
|
6982
|
-
|
|
6983
|
-
|
|
6984
|
-
|
|
6985
|
-
|
|
6986
|
-
|
|
6987
|
-
|
|
6988
|
-
|
|
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
|
-
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
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
|
-
|
|
7012
|
-
|
|
7013
|
-
|
|
7014
|
-
|
|
7015
|
-
|
|
7016
|
-
|
|
7017
|
-
|
|
7018
|
-
|
|
7019
|
-
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
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
|
-
|
|
7044
|
-
|
|
7045
|
-
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
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
|
-
|
|
7054
|
-
|
|
7055
|
-
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
|
|
7059
|
-
|
|
7060
|
-
|
|
7061
|
-
|
|
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
|
|
7064
|
-
if (
|
|
7065
|
-
|
|
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
|
-
|
|
7070
|
-
|
|
7071
|
-
|
|
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
|
-
|
|
7083
|
-
const
|
|
7084
|
-
const
|
|
7085
|
-
|
|
7086
|
-
|
|
7087
|
-
|
|
7088
|
-
|
|
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:
|
|
7092
|
-
pass:
|
|
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
|
-
|
|
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
|
-
|
|
7386
|
+
envelope: { from, to: recipients },
|
|
7387
|
+
raw
|
|
7115
7388
|
};
|
|
7116
7389
|
} finally {
|
|
7117
7390
|
transport.close();
|
|
7118
7391
|
}
|
|
7119
7392
|
}
|
|
7120
|
-
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
|
|
7129
|
-
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
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
|
-
|
|
7183
|
-
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
|
|
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
|
-
|
|
7189
|
-
|
|
7190
|
-
|
|
7191
|
-
|
|
7192
|
-
|
|
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
|
-
|
|
7196
|
-
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
|
|
7200
|
-
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
|
|
7206
|
-
|
|
7207
|
-
|
|
7208
|
-
|
|
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
|
-
|
|
7211
|
-
|
|
7212
|
-
|
|
7213
|
-
|
|
7214
|
-
|
|
7215
|
-
|
|
7216
|
-
|
|
7217
|
-
|
|
7218
|
-
|
|
7219
|
-
|
|
7220
|
-
|
|
7221
|
-
|
|
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
|
-
|
|
7224
|
-
|
|
7225
|
-
|
|
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
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
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
|
-
*
|
|
7344
|
-
*
|
|
7345
|
-
*
|
|
7346
|
-
*
|
|
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
|
-
|
|
7349
|
-
|
|
7350
|
-
|
|
7351
|
-
|
|
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
|
-
|
|
7354
|
-
|
|
7355
|
-
if (
|
|
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.
|
|
7358
|
-
|
|
7359
|
-
|
|
7360
|
-
|
|
7361
|
-
|
|
7362
|
-
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
7389
|
-
|
|
7390
|
-
|
|
7391
|
-
|
|
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
|
-
|
|
7399
|
-
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
7406
|
-
|
|
7407
|
-
|
|
7408
|
-
|
|
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
|
-
|
|
7444
|
-
|
|
7445
|
-
|
|
7446
|
-
|
|
7447
|
-
|
|
7448
|
-
|
|
7449
|
-
|
|
7450
|
-
|
|
7451
|
-
|
|
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
|
-
|
|
7454
|
-
this.
|
|
7455
|
-
|
|
7456
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
7473
|
-
|
|
7474
|
-
|
|
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
|
-
|
|
7478
|
-
|
|
7479
|
-
|
|
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
|
|
7491
|
-
if (
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
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
|
-
|
|
7503
|
-
|
|
7504
|
-
const
|
|
7505
|
-
|
|
7506
|
-
|
|
7507
|
-
|
|
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() };
|
|
7508
7856
|
}
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
|
|
7514
|
-
|
|
7515
|
-
)
|
|
7516
|
-
|
|
7517
|
-
|
|
7518
|
-
|
|
7519
|
-
|
|
7520
|
-
|
|
7521
|
-
|
|
7522
|
-
|
|
7523
|
-
|
|
7524
|
-
|
|
7525
|
-
|
|
7526
|
-
|
|
7527
|
-
|
|
7528
|
-
|
|
7529
|
-
|
|
7530
|
-
|
|
7531
|
-
|
|
7532
|
-
|
|
7533
|
-
|
|
7534
|
-
|
|
7535
|
-
|
|
7536
|
-
|
|
7537
|
-
|
|
7538
|
-
|
|
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;
|
|
7539
7888
|
}
|
|
7540
|
-
|
|
7541
|
-
|
|
7542
|
-
|
|
7543
|
-
|
|
7544
|
-
|
|
7545
|
-
|
|
7546
|
-
|
|
7547
|
-
|
|
7548
|
-
|
|
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
|
-
};
|
|
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
|
+
});
|
|
7570
7898
|
}
|
|
7571
|
-
|
|
7572
|
-
|
|
7573
|
-
|
|
7574
|
-
|
|
7899
|
+
stop() {
|
|
7900
|
+
this.server?.close();
|
|
7901
|
+
this.server = null;
|
|
7902
|
+
}
|
|
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
|
-
|
|
7578
|
-
|
|
7579
|
-
|
|
7580
|
-
|
|
7581
|
-
|
|
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
|
-
|
|
7590
|
-
|
|
7591
|
-
|
|
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
|
-
|
|
7611
|
-
|
|
7612
|
-
|
|
7613
|
-
|
|
7614
|
-
|
|
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
|
-
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
7636
|
-
}
|
|
7637
|
-
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
|
|
7641
|
-
|
|
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
|
-
|
|
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"];
|