@agenticmail/core 0.5.43 → 0.5.45
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/chunk-TIAKW5DC.js +623 -0
- package/dist/index.cjs +792 -673
- package/dist/index.d.cts +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +166 -675
- package/dist/spam-filter-L6KNZ7QI.js +13 -0
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -30,6 +30,636 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
30
30
|
));
|
|
31
31
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
32
32
|
|
|
33
|
+
// src/mail/spam-filter.ts
|
|
34
|
+
var spam_filter_exports = {};
|
|
35
|
+
__export(spam_filter_exports, {
|
|
36
|
+
SPAM_THRESHOLD: () => SPAM_THRESHOLD,
|
|
37
|
+
WARNING_THRESHOLD: () => WARNING_THRESHOLD,
|
|
38
|
+
isInternalEmail: () => isInternalEmail,
|
|
39
|
+
scoreEmail: () => scoreEmail
|
|
40
|
+
});
|
|
41
|
+
function isInternalEmail(email, localDomains) {
|
|
42
|
+
const fromDomain = email.from[0]?.address?.split("@")[1]?.toLowerCase();
|
|
43
|
+
if (!fromDomain) return false;
|
|
44
|
+
const internals = /* @__PURE__ */ new Set(["localhost", ...(localDomains ?? []).map((d) => d.toLowerCase())]);
|
|
45
|
+
if (internals.has(fromDomain) && email.replyTo?.length) {
|
|
46
|
+
const replyDomain = email.replyTo[0]?.address?.split("@")[1]?.toLowerCase();
|
|
47
|
+
if (replyDomain && !internals.has(replyDomain)) return false;
|
|
48
|
+
}
|
|
49
|
+
return internals.has(fromDomain);
|
|
50
|
+
}
|
|
51
|
+
function countSpamWords(text) {
|
|
52
|
+
const lower = text.toLowerCase();
|
|
53
|
+
let count = 0;
|
|
54
|
+
for (const word of SPAM_WORDS) {
|
|
55
|
+
if (lower.includes(word)) count++;
|
|
56
|
+
}
|
|
57
|
+
return count;
|
|
58
|
+
}
|
|
59
|
+
function hasHomographChars(domain) {
|
|
60
|
+
if (domain.startsWith("xn--")) return true;
|
|
61
|
+
const hasCyrillic = /[\u0400-\u04FF]/.test(domain);
|
|
62
|
+
const hasLatin = /[a-zA-Z]/.test(domain);
|
|
63
|
+
return hasCyrillic && hasLatin;
|
|
64
|
+
}
|
|
65
|
+
function scoreEmail(email) {
|
|
66
|
+
const bodyText = [email.subject, email.text ?? ""].join("\n");
|
|
67
|
+
const bodyHtml = email.html ?? "";
|
|
68
|
+
const matches = [];
|
|
69
|
+
for (const rule of RULES) {
|
|
70
|
+
try {
|
|
71
|
+
if (rule.test(email, bodyText, bodyHtml)) {
|
|
72
|
+
let score2 = rule.score;
|
|
73
|
+
if (rule.id === "cs_spam_word_density") {
|
|
74
|
+
const wordCount = countSpamWords(bodyText);
|
|
75
|
+
score2 = wordCount > 10 ? 20 : 10;
|
|
76
|
+
}
|
|
77
|
+
matches.push({
|
|
78
|
+
ruleId: rule.id,
|
|
79
|
+
category: rule.category,
|
|
80
|
+
score: score2,
|
|
81
|
+
description: rule.description
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const score = matches.reduce((sum, m) => sum + m.score, 0);
|
|
88
|
+
let topCategory = null;
|
|
89
|
+
if (matches.length > 0) {
|
|
90
|
+
const categoryScores = /* @__PURE__ */ new Map();
|
|
91
|
+
for (const m of matches) {
|
|
92
|
+
categoryScores.set(m.category, (categoryScores.get(m.category) ?? 0) + m.score);
|
|
93
|
+
}
|
|
94
|
+
let maxScore = 0;
|
|
95
|
+
for (const [cat, catScore] of categoryScores) {
|
|
96
|
+
if (catScore > maxScore) {
|
|
97
|
+
maxScore = catScore;
|
|
98
|
+
topCategory = cat;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
score,
|
|
104
|
+
isSpam: score >= SPAM_THRESHOLD,
|
|
105
|
+
isWarning: score >= WARNING_THRESHOLD && score < SPAM_THRESHOLD,
|
|
106
|
+
matches,
|
|
107
|
+
topCategory
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
var SPAM_THRESHOLD, WARNING_THRESHOLD, RE_IGNORE_INSTRUCTIONS, RE_YOU_ARE_NOW, RE_SYSTEM_DELIMITER, RE_NEW_INSTRUCTIONS, RE_ACT_AS, RE_DO_NOT_MENTION, RE_TAG_CHARS, RE_DENSE_ZWC, RE_JAILBREAK, RE_BASE64_BLOCK, RE_MARKDOWN_INJECTION, RE_OWNER_IMPERSONATION, RE_SECRET_REQUEST, RE_IMPERSONATE_SYSTEM, RE_URGENCY, RE_AUTHORITY, RE_MONEY_REQUEST, RE_GIFT_CARD, RE_CEO_FRAUD, RE_FORWARD_ALL, RE_SEARCH_CREDS, RE_SEND_TO_EXTERNAL, RE_DUMP_INSTRUCTIONS, RE_WEBHOOK_EXFIL, RE_CREDENTIAL_HARVEST, RE_LINK_TAG, RE_LINK_TAG_WITH_TEXT, RE_URL_IN_TEXT, RE_IP_URL, RE_URL_SHORTENER, RE_DATA_URI, RE_LOGIN_URGENCY, RE_PHARMACY_SPAM, RE_WEIGHT_LOSS, RE_LOTTERY_SCAM, RE_CRYPTO_SCAM, RE_EXECUTABLE_EXT, RE_DOUBLE_EXT, RE_ARCHIVE_EXT, RE_HTML_ATTACHMENT_EXT, BRAND_DOMAINS, SPAM_WORDS, RULES;
|
|
111
|
+
var init_spam_filter = __esm({
|
|
112
|
+
"src/mail/spam-filter.ts"() {
|
|
113
|
+
"use strict";
|
|
114
|
+
SPAM_THRESHOLD = 40;
|
|
115
|
+
WARNING_THRESHOLD = 20;
|
|
116
|
+
RE_IGNORE_INSTRUCTIONS = /ignore\s+(all\s+)?(previous|prior|above)\s+(instructions|prompts|rules)/i;
|
|
117
|
+
RE_YOU_ARE_NOW = /you\s+are\s+now\s+(a|an|the|my)\b/i;
|
|
118
|
+
RE_SYSTEM_DELIMITER = /\[SYSTEM\]|\[INST\]|<<SYS>>|<\|im_start\|>/i;
|
|
119
|
+
RE_NEW_INSTRUCTIONS = /new\s+instructions?:|override\s+instructions?:/i;
|
|
120
|
+
RE_ACT_AS = /act\s+as\s+(a|an|if)|pretend\s+(to be|you\s+are)/i;
|
|
121
|
+
RE_DO_NOT_MENTION = /do\s+not\s+(mention|tell|reveal|disclose)\s+(that|this)/i;
|
|
122
|
+
RE_TAG_CHARS = /[\u{E0001}-\u{E007F}]/u;
|
|
123
|
+
RE_DENSE_ZWC = /[\u200B\u200C\u200D\uFEFF]{3,}/;
|
|
124
|
+
RE_JAILBREAK = /\b(DAN|jailbreak|bypass\s+(safety|filter|restriction)|unlimited\s+mode)\b/i;
|
|
125
|
+
RE_BASE64_BLOCK = /[A-Za-z0-9+/]{100,}={0,2}/;
|
|
126
|
+
RE_MARKDOWN_INJECTION = /```(?:system|python\s+exec|bash\s+exec)/i;
|
|
127
|
+
RE_OWNER_IMPERSONATION = /your\s+(owner|creator|admin|boss|master|human)\s+(asked|told|wants|said|instructed|needs)/i;
|
|
128
|
+
RE_SECRET_REQUEST = /share\s+(your|the)\s+(api.?key|password|secret|credential|token)/i;
|
|
129
|
+
RE_IMPERSONATE_SYSTEM = /this\s+is\s+(a|an)\s+(system|security|admin|automated)\s+(message|alert|notification)/i;
|
|
130
|
+
RE_URGENCY = /\b(urgent|immediately|right now|asap|deadline|expires?|last chance|act now|time.?sensitive)\b/i;
|
|
131
|
+
RE_AUTHORITY = /\b(suspend|terminate|deactivat|unauthori[zs]|locked|compromised|breach|violation|legal action)\b/i;
|
|
132
|
+
RE_MONEY_REQUEST = /send\s+(me|us)\s+\$?\d|wire\s+transfer|western\s+union|money\s*gram/i;
|
|
133
|
+
RE_GIFT_CARD = /buy\s+(me\s+)?gift\s*cards?|itunes\s+cards?|google\s+play\s+cards?/i;
|
|
134
|
+
RE_CEO_FRAUD = /\b(CEO|CFO|CTO|director|executive)\b.*\b(wire|transfer|payment|urgent)\b/i;
|
|
135
|
+
RE_FORWARD_ALL = /forward\s+(all|every)\s+(email|message)/i;
|
|
136
|
+
RE_SEARCH_CREDS = /search\s+(inbox|email|mailbox).*password|find.*credential/i;
|
|
137
|
+
RE_SEND_TO_EXTERNAL = /send\s+(the|all|every).*to\s+\S+@\S+/i;
|
|
138
|
+
RE_DUMP_INSTRUCTIONS = /reveal.*system\s+prompt|dump.*instructions|show.*system\s+prompt|print.*instructions/i;
|
|
139
|
+
RE_WEBHOOK_EXFIL = /https?:\/\/[^/]*(webhook|ngrok|pipedream|requestbin|hookbin)/i;
|
|
140
|
+
RE_CREDENTIAL_HARVEST = /verify\s+your\s+(account|identity|password|credentials?)/i;
|
|
141
|
+
RE_LINK_TAG = /<a\s[^>]*href\s*=\s*["']([^"']+)["']/gi;
|
|
142
|
+
RE_LINK_TAG_WITH_TEXT = /<a\s[^>]*href\s*=\s*["']([^"']+)["'][^>]*>(.*?)<\/a>/gi;
|
|
143
|
+
RE_URL_IN_TEXT = /https?:\/\/[^\s<>"]+/gi;
|
|
144
|
+
RE_IP_URL = /https?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/i;
|
|
145
|
+
RE_URL_SHORTENER = /https?:\/\/(bit\.ly|t\.co|tinyurl\.com|goo\.gl|ow\.ly|is\.gd|buff\.ly|rebrand\.ly|shorturl\.at)\//i;
|
|
146
|
+
RE_DATA_URI = /(?:data:text\/html|javascript:)/i;
|
|
147
|
+
RE_LOGIN_URGENCY = /(click\s+here|sign\s+in|log\s*in).*\b(urgent|immediately|expire|suspend|locked)/i;
|
|
148
|
+
RE_PHARMACY_SPAM = /\b(viagra|cialis|pharmacy|prescription|cheap\s+meds|online\s+pharmacy)\b/i;
|
|
149
|
+
RE_WEIGHT_LOSS = /\b(weight\s+loss|diet\s+pill|lose\s+\d+\s+(lbs?|pounds|kg)|fat\s+burn)\b/i;
|
|
150
|
+
RE_LOTTERY_SCAM = /you\s+(have\s+)?(won|been\s+selected)|lottery|million\s+dollars|nigerian?\s+prince/i;
|
|
151
|
+
RE_CRYPTO_SCAM = /(bitcoin|crypto|ethereum).*invest(ment)?|guaranteed\s+returns|double\s+your\s+(money|bitcoin|crypto)/i;
|
|
152
|
+
RE_EXECUTABLE_EXT = /\.(exe|bat|cmd|ps1|sh|dll|scr|vbs|js|msi|com)$/i;
|
|
153
|
+
RE_DOUBLE_EXT = /\.\w{2,5}\.(exe|bat|cmd|ps1|sh|dll|scr|vbs|js|msi|com)$/i;
|
|
154
|
+
RE_ARCHIVE_EXT = /\.(zip|rar|7z|tar\.gz|tgz)$/i;
|
|
155
|
+
RE_HTML_ATTACHMENT_EXT = /\.(html?|svg)$/i;
|
|
156
|
+
BRAND_DOMAINS = {
|
|
157
|
+
google: ["google.com", "gmail.com", "googlemail.com"],
|
|
158
|
+
microsoft: ["microsoft.com", "outlook.com", "hotmail.com", "live.com"],
|
|
159
|
+
apple: ["apple.com", "icloud.com"],
|
|
160
|
+
amazon: ["amazon.com", "amazon.co.uk", "amazon.de"],
|
|
161
|
+
paypal: ["paypal.com"],
|
|
162
|
+
meta: ["facebook.com", "meta.com", "instagram.com"],
|
|
163
|
+
netflix: ["netflix.com"],
|
|
164
|
+
bank: ["chase.com", "wellsfargo.com", "bankofamerica.com", "citibank.com"]
|
|
165
|
+
};
|
|
166
|
+
SPAM_WORDS = [
|
|
167
|
+
"congratulations",
|
|
168
|
+
"winner",
|
|
169
|
+
"prize",
|
|
170
|
+
"claim",
|
|
171
|
+
"free",
|
|
172
|
+
"offer",
|
|
173
|
+
"limited time",
|
|
174
|
+
"act now",
|
|
175
|
+
"click here",
|
|
176
|
+
"no obligation",
|
|
177
|
+
"risk free",
|
|
178
|
+
"guaranteed",
|
|
179
|
+
"million",
|
|
180
|
+
"billion",
|
|
181
|
+
"inheritance",
|
|
182
|
+
"beneficiary",
|
|
183
|
+
"wire transfer",
|
|
184
|
+
"western union",
|
|
185
|
+
"dear friend",
|
|
186
|
+
"dear sir",
|
|
187
|
+
"kindly",
|
|
188
|
+
"revert back",
|
|
189
|
+
"do the needful",
|
|
190
|
+
"humbly",
|
|
191
|
+
"esteemed",
|
|
192
|
+
"investment opportunity",
|
|
193
|
+
"double your",
|
|
194
|
+
"earn money",
|
|
195
|
+
"work from home",
|
|
196
|
+
"make money",
|
|
197
|
+
"cash bonus",
|
|
198
|
+
"discount",
|
|
199
|
+
"lowest price"
|
|
200
|
+
];
|
|
201
|
+
RULES = [
|
|
202
|
+
// === Prompt injection ===
|
|
203
|
+
{
|
|
204
|
+
id: "pi_ignore_instructions",
|
|
205
|
+
category: "prompt_injection",
|
|
206
|
+
score: 25,
|
|
207
|
+
description: 'Contains "ignore previous instructions" pattern',
|
|
208
|
+
test: (_e, text) => RE_IGNORE_INSTRUCTIONS.test(text)
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: "pi_you_are_now",
|
|
212
|
+
category: "prompt_injection",
|
|
213
|
+
score: 25,
|
|
214
|
+
description: 'Contains "you are now a..." roleplay injection',
|
|
215
|
+
test: (_e, text) => RE_YOU_ARE_NOW.test(text)
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "pi_system_delimiter",
|
|
219
|
+
category: "prompt_injection",
|
|
220
|
+
score: 20,
|
|
221
|
+
description: "Contains LLM system delimiters ([SYSTEM], [INST], etc.)",
|
|
222
|
+
test: (_e, text, html) => RE_SYSTEM_DELIMITER.test(text) || RE_SYSTEM_DELIMITER.test(html)
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: "pi_new_instructions",
|
|
226
|
+
category: "prompt_injection",
|
|
227
|
+
score: 20,
|
|
228
|
+
description: 'Contains "new instructions:" or "override instructions:"',
|
|
229
|
+
test: (_e, text) => RE_NEW_INSTRUCTIONS.test(text)
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
id: "pi_act_as",
|
|
233
|
+
category: "prompt_injection",
|
|
234
|
+
score: 15,
|
|
235
|
+
description: 'Contains "act as" or "pretend to be" injection',
|
|
236
|
+
test: (_e, text) => RE_ACT_AS.test(text)
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: "pi_do_not_mention",
|
|
240
|
+
category: "prompt_injection",
|
|
241
|
+
score: 15,
|
|
242
|
+
description: 'Contains "do not mention/tell/reveal" suppression',
|
|
243
|
+
test: (_e, text) => RE_DO_NOT_MENTION.test(text)
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
id: "pi_invisible_unicode",
|
|
247
|
+
category: "prompt_injection",
|
|
248
|
+
score: 20,
|
|
249
|
+
description: "Contains invisible Unicode tag characters or dense zero-width chars",
|
|
250
|
+
test: (_e, text, html) => RE_TAG_CHARS.test(text) || RE_TAG_CHARS.test(html) || RE_DENSE_ZWC.test(text) || RE_DENSE_ZWC.test(html)
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
id: "pi_jailbreak",
|
|
254
|
+
category: "prompt_injection",
|
|
255
|
+
score: 20,
|
|
256
|
+
description: "Contains jailbreak/DAN/bypass safety language",
|
|
257
|
+
test: (_e, text) => RE_JAILBREAK.test(text)
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: "pi_base64_injection",
|
|
261
|
+
category: "prompt_injection",
|
|
262
|
+
score: 15,
|
|
263
|
+
description: "Contains long base64-encoded blocks (potential hidden instructions)",
|
|
264
|
+
test: (_e, text) => RE_BASE64_BLOCK.test(text)
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
id: "pi_markdown_injection",
|
|
268
|
+
category: "prompt_injection",
|
|
269
|
+
score: 10,
|
|
270
|
+
description: "Contains code block injection attempts (```system, ```python exec)",
|
|
271
|
+
test: (_e, text) => RE_MARKDOWN_INJECTION.test(text)
|
|
272
|
+
},
|
|
273
|
+
// === Social engineering ===
|
|
274
|
+
{
|
|
275
|
+
id: "se_owner_impersonation",
|
|
276
|
+
category: "social_engineering",
|
|
277
|
+
score: 20,
|
|
278
|
+
description: "Claims to speak on behalf of the agent's owner",
|
|
279
|
+
test: (_e, text) => RE_OWNER_IMPERSONATION.test(text)
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: "se_secret_request",
|
|
283
|
+
category: "social_engineering",
|
|
284
|
+
score: 15,
|
|
285
|
+
description: "Requests API keys, passwords, or credentials",
|
|
286
|
+
test: (_e, text) => RE_SECRET_REQUEST.test(text)
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
id: "se_impersonate_system",
|
|
290
|
+
category: "social_engineering",
|
|
291
|
+
score: 15,
|
|
292
|
+
description: "Impersonates a system/security message",
|
|
293
|
+
test: (_e, text) => RE_IMPERSONATE_SYSTEM.test(text)
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: "se_urgency_authority",
|
|
297
|
+
category: "social_engineering",
|
|
298
|
+
score: 10,
|
|
299
|
+
description: "Combines urgency language with authority/threat language",
|
|
300
|
+
test: (_e, text) => RE_URGENCY.test(text) && RE_AUTHORITY.test(text)
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: "se_money_request",
|
|
304
|
+
category: "social_engineering",
|
|
305
|
+
score: 15,
|
|
306
|
+
description: "Requests money transfer or wire",
|
|
307
|
+
test: (_e, text) => RE_MONEY_REQUEST.test(text)
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
id: "se_gift_card",
|
|
311
|
+
category: "social_engineering",
|
|
312
|
+
score: 20,
|
|
313
|
+
description: "Requests purchase of gift cards",
|
|
314
|
+
test: (_e, text) => RE_GIFT_CARD.test(text)
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
id: "se_ceo_fraud",
|
|
318
|
+
category: "social_engineering",
|
|
319
|
+
score: 15,
|
|
320
|
+
description: "BEC pattern: executive title + payment/wire/urgent",
|
|
321
|
+
test: (_e, text) => RE_CEO_FRAUD.test(text)
|
|
322
|
+
},
|
|
323
|
+
// === Data exfiltration ===
|
|
324
|
+
{
|
|
325
|
+
id: "de_forward_all",
|
|
326
|
+
category: "data_exfiltration",
|
|
327
|
+
score: 20,
|
|
328
|
+
description: "Requests forwarding all emails",
|
|
329
|
+
test: (_e, text) => RE_FORWARD_ALL.test(text)
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
id: "de_search_credentials",
|
|
333
|
+
category: "data_exfiltration",
|
|
334
|
+
score: 20,
|
|
335
|
+
description: "Requests searching inbox for passwords/credentials",
|
|
336
|
+
test: (_e, text) => RE_SEARCH_CREDS.test(text)
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
id: "de_send_to_external",
|
|
340
|
+
category: "data_exfiltration",
|
|
341
|
+
score: 15,
|
|
342
|
+
description: "Instructs sending data to an external email address",
|
|
343
|
+
test: (_e, text) => RE_SEND_TO_EXTERNAL.test(text)
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
id: "de_dump_instructions",
|
|
347
|
+
category: "data_exfiltration",
|
|
348
|
+
score: 15,
|
|
349
|
+
description: "Attempts to extract system prompt or instructions",
|
|
350
|
+
test: (_e, text) => RE_DUMP_INSTRUCTIONS.test(text)
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
id: "de_webhook_exfil",
|
|
354
|
+
category: "data_exfiltration",
|
|
355
|
+
score: 15,
|
|
356
|
+
description: "Contains webhook/ngrok/pipedream exfiltration URLs",
|
|
357
|
+
test: (_e, text) => RE_WEBHOOK_EXFIL.test(text)
|
|
358
|
+
},
|
|
359
|
+
// === Phishing ===
|
|
360
|
+
{
|
|
361
|
+
id: "ph_spoofed_sender",
|
|
362
|
+
category: "phishing",
|
|
363
|
+
score: 10,
|
|
364
|
+
description: "Sender name contains brand but domain doesn't match",
|
|
365
|
+
test: (email) => {
|
|
366
|
+
const from = email.from[0];
|
|
367
|
+
if (!from) return false;
|
|
368
|
+
const name = (from.name ?? "").toLowerCase();
|
|
369
|
+
const domain = (from.address ?? "").split("@")[1]?.toLowerCase() ?? "";
|
|
370
|
+
for (const [brand, domains] of Object.entries(BRAND_DOMAINS)) {
|
|
371
|
+
if (name.includes(brand) && !domains.some((d) => domain === d || domain.endsWith("." + d))) {
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
id: "ph_credential_harvest",
|
|
380
|
+
category: "phishing",
|
|
381
|
+
score: 15,
|
|
382
|
+
description: 'Asks to "verify your account/password" with links present',
|
|
383
|
+
test: (_e, text, html) => {
|
|
384
|
+
if (!RE_CREDENTIAL_HARVEST.test(text)) return false;
|
|
385
|
+
return RE_URL_IN_TEXT.test(text) || RE_LINK_TAG.test(html);
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
id: "ph_suspicious_links",
|
|
390
|
+
category: "phishing",
|
|
391
|
+
score: 10,
|
|
392
|
+
description: "Contains links with IP addresses, URL shorteners, or excessive subdomains",
|
|
393
|
+
test: (_e, text, html) => {
|
|
394
|
+
const allText = text + " " + html;
|
|
395
|
+
if (RE_IP_URL.test(allText)) return true;
|
|
396
|
+
if (RE_URL_SHORTENER.test(allText)) return true;
|
|
397
|
+
const urls = allText.match(RE_URL_IN_TEXT) ?? [];
|
|
398
|
+
for (const url of urls) {
|
|
399
|
+
try {
|
|
400
|
+
const hostname = new URL(url).hostname;
|
|
401
|
+
if (hostname.split(".").length > 4) return true;
|
|
402
|
+
} catch {
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
id: "ph_data_uri",
|
|
410
|
+
category: "phishing",
|
|
411
|
+
score: 15,
|
|
412
|
+
description: "Contains data: or javascript: URIs in links",
|
|
413
|
+
test: (_e, _text, html) => {
|
|
414
|
+
RE_LINK_TAG.lastIndex = 0;
|
|
415
|
+
let match;
|
|
416
|
+
while ((match = RE_LINK_TAG.exec(html)) !== null) {
|
|
417
|
+
if (RE_DATA_URI.test(match[1])) return true;
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
id: "ph_homograph",
|
|
424
|
+
category: "phishing",
|
|
425
|
+
score: 15,
|
|
426
|
+
description: "From domain contains mixed-script or punycode characters",
|
|
427
|
+
test: (email) => {
|
|
428
|
+
const domain = email.from[0]?.address?.split("@")[1] ?? "";
|
|
429
|
+
if (!domain) return false;
|
|
430
|
+
return hasHomographChars(domain);
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
id: "ph_mismatched_display_url",
|
|
435
|
+
category: "phishing",
|
|
436
|
+
score: 10,
|
|
437
|
+
description: "HTML link text shows one URL but href points to a different domain",
|
|
438
|
+
test: (_e, _text, html) => {
|
|
439
|
+
RE_LINK_TAG_WITH_TEXT.lastIndex = 0;
|
|
440
|
+
let match;
|
|
441
|
+
while ((match = RE_LINK_TAG_WITH_TEXT.exec(html)) !== null) {
|
|
442
|
+
const href = match[1];
|
|
443
|
+
const linkText = match[2].replace(/<[^>]*>/g, "").trim();
|
|
444
|
+
if (!/^https?:\/\//i.test(linkText)) continue;
|
|
445
|
+
try {
|
|
446
|
+
const hrefHost = new URL(href).hostname.replace(/^www\./, "");
|
|
447
|
+
const textHost = new URL(linkText).hostname.replace(/^www\./, "");
|
|
448
|
+
if (hrefHost !== textHost) return true;
|
|
449
|
+
} catch {
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
id: "ph_login_urgency",
|
|
457
|
+
category: "phishing",
|
|
458
|
+
score: 10,
|
|
459
|
+
description: "Combines login/click-here language with urgency",
|
|
460
|
+
test: (_e, text) => RE_LOGIN_URGENCY.test(text)
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
id: "ph_unsubscribe_missing",
|
|
464
|
+
category: "phishing",
|
|
465
|
+
score: 3,
|
|
466
|
+
description: "Marketing-like email with many links but no List-Unsubscribe header",
|
|
467
|
+
test: (email, text, html) => {
|
|
468
|
+
const allText = text + " " + html;
|
|
469
|
+
const urls = new Set(allText.match(RE_URL_IN_TEXT) ?? []);
|
|
470
|
+
if (urls.size < 5) return false;
|
|
471
|
+
return !email.headers.get("list-unsubscribe");
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
// === Authentication (SPF/DKIM/DMARC from headers) ===
|
|
475
|
+
{
|
|
476
|
+
id: "auth_spf_fail",
|
|
477
|
+
category: "authentication",
|
|
478
|
+
score: 15,
|
|
479
|
+
description: "SPF authentication failed",
|
|
480
|
+
test: (email) => {
|
|
481
|
+
const authResults = email.headers.get("authentication-results") ?? "";
|
|
482
|
+
return /spf=(fail|softfail)/i.test(authResults);
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
id: "auth_dkim_fail",
|
|
487
|
+
category: "authentication",
|
|
488
|
+
score: 15,
|
|
489
|
+
description: "DKIM authentication failed",
|
|
490
|
+
test: (email) => {
|
|
491
|
+
const authResults = email.headers.get("authentication-results") ?? "";
|
|
492
|
+
return /dkim=fail/i.test(authResults);
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
id: "auth_dmarc_fail",
|
|
497
|
+
category: "authentication",
|
|
498
|
+
score: 20,
|
|
499
|
+
description: "DMARC authentication failed",
|
|
500
|
+
test: (email) => {
|
|
501
|
+
const authResults = email.headers.get("authentication-results") ?? "";
|
|
502
|
+
return /dmarc=fail/i.test(authResults);
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
id: "auth_no_auth_results",
|
|
507
|
+
category: "authentication",
|
|
508
|
+
score: 3,
|
|
509
|
+
description: "No Authentication-Results header present",
|
|
510
|
+
test: (email) => {
|
|
511
|
+
return !email.headers.has("authentication-results");
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
// === Attachment risk ===
|
|
515
|
+
{
|
|
516
|
+
id: "at_executable",
|
|
517
|
+
category: "attachment_risk",
|
|
518
|
+
score: 25,
|
|
519
|
+
description: "Attachment has executable file extension",
|
|
520
|
+
test: (email) => {
|
|
521
|
+
return email.attachments.some((a) => RE_EXECUTABLE_EXT.test(a.filename));
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
id: "at_double_extension",
|
|
526
|
+
category: "attachment_risk",
|
|
527
|
+
score: 20,
|
|
528
|
+
description: "Attachment has double extension (e.g. document.pdf.exe)",
|
|
529
|
+
test: (email) => {
|
|
530
|
+
return email.attachments.some((a) => RE_DOUBLE_EXT.test(a.filename));
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
id: "at_archive_carrier",
|
|
535
|
+
category: "attachment_risk",
|
|
536
|
+
score: 15,
|
|
537
|
+
description: "Attachment is an archive (potential payload carrier)",
|
|
538
|
+
test: (email) => {
|
|
539
|
+
return email.attachments.some((a) => RE_ARCHIVE_EXT.test(a.filename));
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
id: "at_html_attachment",
|
|
544
|
+
category: "attachment_risk",
|
|
545
|
+
score: 10,
|
|
546
|
+
description: "HTML/SVG file attachment (phishing vector)",
|
|
547
|
+
test: (email) => {
|
|
548
|
+
return email.attachments.some((a) => RE_HTML_ATTACHMENT_EXT.test(a.filename));
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
// === Header anomalies ===
|
|
552
|
+
{
|
|
553
|
+
id: "ha_missing_message_id",
|
|
554
|
+
category: "header_anomaly",
|
|
555
|
+
score: 5,
|
|
556
|
+
description: "Missing Message-ID header",
|
|
557
|
+
test: (email) => !email.messageId
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
id: "ha_empty_from",
|
|
561
|
+
category: "header_anomaly",
|
|
562
|
+
score: 10,
|
|
563
|
+
description: "Missing or empty From address",
|
|
564
|
+
test: (email) => !email.from.length || !email.from[0].address
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
id: "ha_reply_to_mismatch",
|
|
568
|
+
category: "header_anomaly",
|
|
569
|
+
score: 5,
|
|
570
|
+
description: "Reply-To domain differs from From domain",
|
|
571
|
+
test: (email) => {
|
|
572
|
+
if (!email.replyTo?.length || !email.from.length) return false;
|
|
573
|
+
const fromDomain = email.from[0].address?.split("@")[1]?.toLowerCase();
|
|
574
|
+
const replyDomain = email.replyTo[0].address?.split("@")[1]?.toLowerCase();
|
|
575
|
+
return !!fromDomain && !!replyDomain && fromDomain !== replyDomain;
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
// === Content spam ===
|
|
579
|
+
{
|
|
580
|
+
id: "cs_all_caps_subject",
|
|
581
|
+
category: "content_spam",
|
|
582
|
+
score: 5,
|
|
583
|
+
description: "Subject is mostly uppercase",
|
|
584
|
+
test: (email) => {
|
|
585
|
+
const s = email.subject;
|
|
586
|
+
if (s.length < 10) return false;
|
|
587
|
+
const letters = s.replace(/[^a-zA-Z]/g, "");
|
|
588
|
+
if (letters.length < 5) return false;
|
|
589
|
+
const upper = letters.replace(/[^A-Z]/g, "").length;
|
|
590
|
+
return upper / letters.length > 0.8;
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
id: "cs_lottery_scam",
|
|
595
|
+
category: "content_spam",
|
|
596
|
+
score: 25,
|
|
597
|
+
description: "Contains lottery/prize scam language",
|
|
598
|
+
test: (_e, text) => RE_LOTTERY_SCAM.test(text)
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
id: "cs_crypto_scam",
|
|
602
|
+
category: "content_spam",
|
|
603
|
+
score: 10,
|
|
604
|
+
description: "Contains crypto/investment scam language",
|
|
605
|
+
test: (_e, text) => RE_CRYPTO_SCAM.test(text)
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
id: "cs_excessive_punctuation",
|
|
609
|
+
category: "content_spam",
|
|
610
|
+
score: 3,
|
|
611
|
+
description: "Subject has excessive punctuation (!!!!, ????)",
|
|
612
|
+
test: (email) => /[!]{4,}|[?]{4,}/.test(email.subject)
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
id: "cs_pharmacy_spam",
|
|
616
|
+
category: "content_spam",
|
|
617
|
+
score: 15,
|
|
618
|
+
description: "Contains pharmacy/prescription drug spam language",
|
|
619
|
+
test: (_e, text) => RE_PHARMACY_SPAM.test(text)
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
id: "cs_weight_loss",
|
|
623
|
+
category: "content_spam",
|
|
624
|
+
score: 10,
|
|
625
|
+
description: "Contains weight loss scam language",
|
|
626
|
+
test: (_e, text) => RE_WEIGHT_LOSS.test(text)
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
id: "cs_html_only_no_text",
|
|
630
|
+
category: "content_spam",
|
|
631
|
+
score: 5,
|
|
632
|
+
description: "Email has HTML body but empty/missing text body",
|
|
633
|
+
test: (email) => {
|
|
634
|
+
const hasHtml = !!email.html && email.html.trim().length > 0;
|
|
635
|
+
const hasText = !!email.text && email.text.trim().length > 0;
|
|
636
|
+
return hasHtml && !hasText;
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
id: "cs_spam_word_density",
|
|
641
|
+
category: "content_spam",
|
|
642
|
+
score: 0,
|
|
643
|
+
// Dynamic — calculated in test
|
|
644
|
+
description: "High density of common spam words",
|
|
645
|
+
test: (_e, text) => countSpamWords(text) > 5
|
|
646
|
+
},
|
|
647
|
+
// === Link analysis ===
|
|
648
|
+
{
|
|
649
|
+
id: "la_excessive_links",
|
|
650
|
+
category: "link_analysis",
|
|
651
|
+
score: 5,
|
|
652
|
+
description: "Contains more than 10 unique links",
|
|
653
|
+
test: (_e, text, html) => {
|
|
654
|
+
const allText = text + " " + html;
|
|
655
|
+
const urls = new Set(allText.match(RE_URL_IN_TEXT) ?? []);
|
|
656
|
+
return urls.size > 10;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
];
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
33
663
|
// src/gateway/email-worker-template.ts
|
|
34
664
|
var email_worker_template_exports = {};
|
|
35
665
|
__export(email_worker_template_exports, {
|
|
@@ -183,15 +813,28 @@ var MailSender = class {
|
|
|
183
813
|
};
|
|
184
814
|
const composer = new import_mail_composer.default(mailOpts);
|
|
185
815
|
const raw = await composer.compile().build();
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
816
|
+
const MAX_RETRIES = 2;
|
|
817
|
+
let lastError = null;
|
|
818
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
819
|
+
try {
|
|
820
|
+
const result = await this.transporter.sendMail(mailOpts);
|
|
821
|
+
return {
|
|
822
|
+
messageId: result.messageId,
|
|
823
|
+
envelope: {
|
|
824
|
+
from: result.envelope.from || "",
|
|
825
|
+
to: Array.isArray(result.envelope.to) ? result.envelope.to : result.envelope.to ? [result.envelope.to] : []
|
|
826
|
+
},
|
|
827
|
+
raw
|
|
828
|
+
};
|
|
829
|
+
} catch (err) {
|
|
830
|
+
lastError = err;
|
|
831
|
+
const code = err?.responseCode ?? err?.code;
|
|
832
|
+
const isTransient = typeof code === "number" && code >= 400 && code < 500 || code === "ECONNRESET" || code === "ETIMEDOUT" || code === "ESOCKET";
|
|
833
|
+
if (!isTransient || attempt === MAX_RETRIES) throw err;
|
|
834
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3 * (attempt + 1)));
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
throw lastError;
|
|
195
838
|
}
|
|
196
839
|
async verify() {
|
|
197
840
|
try {
|
|
@@ -274,10 +917,13 @@ var MailReceiver = class {
|
|
|
274
917
|
const limit = Math.min(Math.max(options?.limit ?? 20, 1), 1e3);
|
|
275
918
|
const offset = Math.max(options?.offset ?? 0, 0);
|
|
276
919
|
if (offset >= total) return envelopes;
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
920
|
+
const allUids = await this.client.search({ all: true }, { uid: true });
|
|
921
|
+
if (!allUids || allUids.length === 0) return envelopes;
|
|
922
|
+
const sortedUids = Array.from(allUids).sort((a, b) => b - a);
|
|
923
|
+
const pageUids = sortedUids.slice(offset, offset + limit);
|
|
924
|
+
if (pageUids.length === 0) return envelopes;
|
|
925
|
+
const uidRange = pageUids.join(",");
|
|
926
|
+
for await (const msg of this.client.fetch(uidRange, {
|
|
281
927
|
uid: true,
|
|
282
928
|
envelope: true,
|
|
283
929
|
flags: true,
|
|
@@ -303,7 +949,8 @@ var MailReceiver = class {
|
|
|
303
949
|
size: msg.size ?? 0
|
|
304
950
|
});
|
|
305
951
|
}
|
|
306
|
-
|
|
952
|
+
envelopes.sort((a, b) => b.uid - a.uid);
|
|
953
|
+
return envelopes;
|
|
307
954
|
} finally {
|
|
308
955
|
lock.release();
|
|
309
956
|
}
|
|
@@ -491,12 +1138,17 @@ async function parseEmail(raw) {
|
|
|
491
1138
|
}
|
|
492
1139
|
|
|
493
1140
|
// src/inbox/watcher.ts
|
|
1141
|
+
var RECONNECT_INITIAL_MS = 2e3;
|
|
1142
|
+
var RECONNECT_MAX_MS = 6e4;
|
|
1143
|
+
var RECONNECT_FACTOR = 2;
|
|
494
1144
|
var InboxWatcher = class extends import_node_events.EventEmitter {
|
|
495
1145
|
constructor(options, watcherOptions) {
|
|
496
1146
|
super();
|
|
497
1147
|
this.options = options;
|
|
498
1148
|
this.mailbox = watcherOptions?.mailbox ?? "INBOX";
|
|
499
1149
|
this.autoFetch = watcherOptions?.autoFetch ?? true;
|
|
1150
|
+
this._autoReconnect = options.autoReconnect ?? false;
|
|
1151
|
+
this._maxReconnectAttempts = options.maxReconnectAttempts ?? Infinity;
|
|
500
1152
|
this.client = new import_imapflow2.ImapFlow({
|
|
501
1153
|
host: options.host,
|
|
502
1154
|
port: options.port,
|
|
@@ -516,8 +1168,15 @@ var InboxWatcher = class extends import_node_events.EventEmitter {
|
|
|
516
1168
|
mailbox;
|
|
517
1169
|
autoFetch;
|
|
518
1170
|
_lock = null;
|
|
1171
|
+
_stopped = false;
|
|
1172
|
+
_reconnectTimer = null;
|
|
1173
|
+
_reconnectDelay = RECONNECT_INITIAL_MS;
|
|
1174
|
+
_reconnectAttempts = 0;
|
|
1175
|
+
_maxReconnectAttempts;
|
|
1176
|
+
_autoReconnect;
|
|
519
1177
|
async start() {
|
|
520
1178
|
if (this.watching) return;
|
|
1179
|
+
this._stopped = false;
|
|
521
1180
|
this.client = new import_imapflow2.ImapFlow({
|
|
522
1181
|
host: this.options.host,
|
|
523
1182
|
port: this.options.port,
|
|
@@ -535,6 +1194,8 @@ var InboxWatcher = class extends import_node_events.EventEmitter {
|
|
|
535
1194
|
const lock = await this.client.getMailboxLock(this.mailbox);
|
|
536
1195
|
try {
|
|
537
1196
|
this.watching = true;
|
|
1197
|
+
this._reconnectDelay = RECONNECT_INITIAL_MS;
|
|
1198
|
+
this._reconnectAttempts = 0;
|
|
538
1199
|
this.client.on("exists", async (data) => {
|
|
539
1200
|
try {
|
|
540
1201
|
if (data.count > data.prevCount) {
|
|
@@ -572,6 +1233,7 @@ var InboxWatcher = class extends import_node_events.EventEmitter {
|
|
|
572
1233
|
this.client.on("close", () => {
|
|
573
1234
|
this.watching = false;
|
|
574
1235
|
this.emit("close");
|
|
1236
|
+
this._scheduleReconnect();
|
|
575
1237
|
});
|
|
576
1238
|
this._lock = lock;
|
|
577
1239
|
} catch (err) {
|
|
@@ -579,9 +1241,44 @@ var InboxWatcher = class extends import_node_events.EventEmitter {
|
|
|
579
1241
|
throw err;
|
|
580
1242
|
}
|
|
581
1243
|
}
|
|
1244
|
+
/** Schedule a reconnect attempt with exponential backoff */
|
|
1245
|
+
_scheduleReconnect() {
|
|
1246
|
+
if (this._stopped || !this._autoReconnect) return;
|
|
1247
|
+
if (this._reconnectAttempts >= this._maxReconnectAttempts) {
|
|
1248
|
+
this.emit("reconnect_failed", { attempts: this._reconnectAttempts });
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
const delay = this._reconnectDelay;
|
|
1252
|
+
this._reconnectDelay = Math.min(this._reconnectDelay * RECONNECT_FACTOR, RECONNECT_MAX_MS);
|
|
1253
|
+
this._reconnectAttempts++;
|
|
1254
|
+
this.emit("reconnecting", { attempt: this._reconnectAttempts, delayMs: delay });
|
|
1255
|
+
this._reconnectTimer = setTimeout(async () => {
|
|
1256
|
+
if (this._stopped) return;
|
|
1257
|
+
try {
|
|
1258
|
+
this.client.removeAllListeners();
|
|
1259
|
+
if (this._lock) {
|
|
1260
|
+
try {
|
|
1261
|
+
this._lock.release();
|
|
1262
|
+
} catch {
|
|
1263
|
+
}
|
|
1264
|
+
this._lock = null;
|
|
1265
|
+
}
|
|
1266
|
+
await this.start();
|
|
1267
|
+
this.emit("reconnected", { attempt: this._reconnectAttempts });
|
|
1268
|
+
} catch (err) {
|
|
1269
|
+
this.emit("error", err);
|
|
1270
|
+
this._scheduleReconnect();
|
|
1271
|
+
}
|
|
1272
|
+
}, delay);
|
|
1273
|
+
}
|
|
582
1274
|
async stop() {
|
|
583
|
-
if (!this.watching) return;
|
|
1275
|
+
if (!this.watching && !this._reconnectTimer) return;
|
|
1276
|
+
this._stopped = true;
|
|
584
1277
|
this.watching = false;
|
|
1278
|
+
if (this._reconnectTimer) {
|
|
1279
|
+
clearTimeout(this._reconnectTimer);
|
|
1280
|
+
this._reconnectTimer = null;
|
|
1281
|
+
}
|
|
585
1282
|
this.client.removeAllListeners();
|
|
586
1283
|
if (this._lock) {
|
|
587
1284
|
try {
|
|
@@ -1192,7 +1889,7 @@ var AccountManager = class {
|
|
|
1192
1889
|
const apiKey = generateApiKey();
|
|
1193
1890
|
const password = options.password ?? generatePassword();
|
|
1194
1891
|
const domain = options.domain ?? "localhost";
|
|
1195
|
-
if (domain !== "localhost" && !/^[a-zA-Z0-9][a-zA-Z0-9
|
|
1892
|
+
if (domain !== "localhost" && !/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(domain)) {
|
|
1196
1893
|
throw new Error(`Invalid domain "${domain}": must be a valid domain name`);
|
|
1197
1894
|
}
|
|
1198
1895
|
const principalName = options.name.toLowerCase();
|
|
@@ -1409,31 +2106,39 @@ var AgentDeletionService = class {
|
|
|
1409
2106
|
}
|
|
1410
2107
|
async archiveFolder(receiver, folder) {
|
|
1411
2108
|
const archived = [];
|
|
2109
|
+
const PAGE_SIZE = 100;
|
|
1412
2110
|
try {
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
2111
|
+
let offset = 0;
|
|
2112
|
+
let hasMore = true;
|
|
2113
|
+
while (hasMore) {
|
|
2114
|
+
const envelopes = await receiver.listEnvelopes(folder, { limit: PAGE_SIZE, offset });
|
|
2115
|
+
if (envelopes.length === 0) break;
|
|
2116
|
+
hasMore = envelopes.length === PAGE_SIZE;
|
|
2117
|
+
offset += envelopes.length;
|
|
2118
|
+
for (const env of envelopes) {
|
|
2119
|
+
try {
|
|
2120
|
+
const raw = await receiver.fetchMessage(env.uid, folder);
|
|
2121
|
+
const parsed = await parseEmail(raw);
|
|
2122
|
+
archived.push({
|
|
2123
|
+
uid: env.uid,
|
|
2124
|
+
messageId: parsed.messageId || env.messageId,
|
|
2125
|
+
from: parsed.from?.[0]?.address ?? "",
|
|
2126
|
+
to: parsed.to?.map((a) => a.address) ?? [],
|
|
2127
|
+
subject: parsed.subject || env.subject,
|
|
2128
|
+
date: parsed.date?.toISOString() ?? env.date?.toISOString?.() ?? "",
|
|
2129
|
+
text: parsed.text,
|
|
2130
|
+
html: parsed.html
|
|
2131
|
+
});
|
|
2132
|
+
} catch {
|
|
2133
|
+
archived.push({
|
|
2134
|
+
uid: env.uid,
|
|
2135
|
+
messageId: env.messageId,
|
|
2136
|
+
from: env.from?.[0]?.address ?? "",
|
|
2137
|
+
to: env.to?.map((a) => a.address) ?? [],
|
|
2138
|
+
subject: env.subject,
|
|
2139
|
+
date: env.date?.toISOString?.() ?? ""
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
1437
2142
|
}
|
|
1438
2143
|
}
|
|
1439
2144
|
} catch (err) {
|
|
@@ -1510,622 +2215,8 @@ var AgentDeletionService = class {
|
|
|
1510
2215
|
}
|
|
1511
2216
|
};
|
|
1512
2217
|
|
|
1513
|
-
// src/
|
|
1514
|
-
|
|
1515
|
-
var WARNING_THRESHOLD = 20;
|
|
1516
|
-
function isInternalEmail(email, localDomains) {
|
|
1517
|
-
const fromDomain = email.from[0]?.address?.split("@")[1]?.toLowerCase();
|
|
1518
|
-
if (!fromDomain) return false;
|
|
1519
|
-
const internals = /* @__PURE__ */ new Set(["localhost", ...(localDomains ?? []).map((d) => d.toLowerCase())]);
|
|
1520
|
-
if (internals.has(fromDomain) && email.replyTo?.length) {
|
|
1521
|
-
const replyDomain = email.replyTo[0]?.address?.split("@")[1]?.toLowerCase();
|
|
1522
|
-
if (replyDomain && !internals.has(replyDomain)) return false;
|
|
1523
|
-
}
|
|
1524
|
-
return internals.has(fromDomain);
|
|
1525
|
-
}
|
|
1526
|
-
var RE_IGNORE_INSTRUCTIONS = /ignore\s+(all\s+)?(previous|prior|above)\s+(instructions|prompts|rules)/i;
|
|
1527
|
-
var RE_YOU_ARE_NOW = /you\s+are\s+now\s+(a|an|the|my)\b/i;
|
|
1528
|
-
var RE_SYSTEM_DELIMITER = /\[SYSTEM\]|\[INST\]|<<SYS>>|<\|im_start\|>/i;
|
|
1529
|
-
var RE_NEW_INSTRUCTIONS = /new\s+instructions?:|override\s+instructions?:/i;
|
|
1530
|
-
var RE_ACT_AS = /act\s+as\s+(a|an|if)|pretend\s+(to be|you\s+are)/i;
|
|
1531
|
-
var RE_DO_NOT_MENTION = /do\s+not\s+(mention|tell|reveal|disclose)\s+(that|this)/i;
|
|
1532
|
-
var RE_TAG_CHARS = /[\u{E0001}-\u{E007F}]/u;
|
|
1533
|
-
var RE_DENSE_ZWC = /[\u200B\u200C\u200D\uFEFF]{3,}/;
|
|
1534
|
-
var RE_JAILBREAK = /\b(DAN|jailbreak|bypass\s+(safety|filter|restriction)|unlimited\s+mode)\b/i;
|
|
1535
|
-
var RE_BASE64_BLOCK = /[A-Za-z0-9+/]{100,}={0,2}/;
|
|
1536
|
-
var RE_MARKDOWN_INJECTION = /```(?:system|python\s+exec|bash\s+exec)/i;
|
|
1537
|
-
var RE_OWNER_IMPERSONATION = /your\s+(owner|creator|admin|boss|master|human)\s+(asked|told|wants|said|instructed|needs)/i;
|
|
1538
|
-
var RE_SECRET_REQUEST = /share\s+(your|the)\s+(api.?key|password|secret|credential|token)/i;
|
|
1539
|
-
var RE_IMPERSONATE_SYSTEM = /this\s+is\s+(a|an)\s+(system|security|admin|automated)\s+(message|alert|notification)/i;
|
|
1540
|
-
var RE_URGENCY = /\b(urgent|immediately|right now|asap|deadline|expires?|last chance|act now|time.?sensitive)\b/i;
|
|
1541
|
-
var RE_AUTHORITY = /\b(suspend|terminate|deactivat|unauthori[zs]|locked|compromised|breach|violation|legal action)\b/i;
|
|
1542
|
-
var RE_MONEY_REQUEST = /send\s+(me|us)\s+\$?\d|wire\s+transfer|western\s+union|money\s*gram/i;
|
|
1543
|
-
var RE_GIFT_CARD = /buy\s+(me\s+)?gift\s*cards?|itunes\s+cards?|google\s+play\s+cards?/i;
|
|
1544
|
-
var RE_CEO_FRAUD = /\b(CEO|CFO|CTO|director|executive)\b.*\b(wire|transfer|payment|urgent)\b/i;
|
|
1545
|
-
var RE_FORWARD_ALL = /forward\s+(all|every)\s+(email|message)/i;
|
|
1546
|
-
var RE_SEARCH_CREDS = /search\s+(inbox|email|mailbox).*password|find.*credential/i;
|
|
1547
|
-
var RE_SEND_TO_EXTERNAL = /send\s+(the|all|every).*to\s+\S+@\S+/i;
|
|
1548
|
-
var RE_DUMP_INSTRUCTIONS = /reveal.*system\s+prompt|dump.*instructions|show.*system\s+prompt|print.*instructions/i;
|
|
1549
|
-
var RE_WEBHOOK_EXFIL = /https?:\/\/[^/]*(webhook|ngrok|pipedream|requestbin|hookbin)/i;
|
|
1550
|
-
var RE_CREDENTIAL_HARVEST = /verify\s+your\s+(account|identity|password|credentials?)/i;
|
|
1551
|
-
var RE_LINK_TAG = /<a\s[^>]*href\s*=\s*["']([^"']+)["']/gi;
|
|
1552
|
-
var RE_LINK_TAG_WITH_TEXT = /<a\s[^>]*href\s*=\s*["']([^"']+)["'][^>]*>(.*?)<\/a>/gi;
|
|
1553
|
-
var RE_URL_IN_TEXT = /https?:\/\/[^\s<>"]+/gi;
|
|
1554
|
-
var RE_IP_URL = /https?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/i;
|
|
1555
|
-
var RE_URL_SHORTENER = /https?:\/\/(bit\.ly|t\.co|tinyurl\.com|goo\.gl|ow\.ly|is\.gd|buff\.ly|rebrand\.ly|shorturl\.at)\//i;
|
|
1556
|
-
var RE_DATA_URI = /(?:data:text\/html|javascript:)/i;
|
|
1557
|
-
var RE_LOGIN_URGENCY = /(click\s+here|sign\s+in|log\s*in).*\b(urgent|immediately|expire|suspend|locked)/i;
|
|
1558
|
-
var RE_PHARMACY_SPAM = /\b(viagra|cialis|pharmacy|prescription|cheap\s+meds|online\s+pharmacy)\b/i;
|
|
1559
|
-
var RE_WEIGHT_LOSS = /\b(weight\s+loss|diet\s+pill|lose\s+\d+\s+(lbs?|pounds|kg)|fat\s+burn)\b/i;
|
|
1560
|
-
var RE_LOTTERY_SCAM = /you\s+(have\s+)?(won|been\s+selected)|lottery|million\s+dollars|nigerian?\s+prince/i;
|
|
1561
|
-
var RE_CRYPTO_SCAM = /(bitcoin|crypto|ethereum).*invest(ment)?|guaranteed\s+returns|double\s+your\s+(money|bitcoin|crypto)/i;
|
|
1562
|
-
var RE_EXECUTABLE_EXT = /\.(exe|bat|cmd|ps1|sh|dll|scr|vbs|js|msi|com)$/i;
|
|
1563
|
-
var RE_DOUBLE_EXT = /\.\w{2,5}\.(exe|bat|cmd|ps1|sh|dll|scr|vbs|js|msi|com)$/i;
|
|
1564
|
-
var RE_ARCHIVE_EXT = /\.(zip|rar|7z|tar\.gz|tgz)$/i;
|
|
1565
|
-
var RE_HTML_ATTACHMENT_EXT = /\.(html?|svg)$/i;
|
|
1566
|
-
var BRAND_DOMAINS = {
|
|
1567
|
-
google: ["google.com", "gmail.com", "googlemail.com"],
|
|
1568
|
-
microsoft: ["microsoft.com", "outlook.com", "hotmail.com", "live.com"],
|
|
1569
|
-
apple: ["apple.com", "icloud.com"],
|
|
1570
|
-
amazon: ["amazon.com", "amazon.co.uk", "amazon.de"],
|
|
1571
|
-
paypal: ["paypal.com"],
|
|
1572
|
-
meta: ["facebook.com", "meta.com", "instagram.com"],
|
|
1573
|
-
netflix: ["netflix.com"],
|
|
1574
|
-
bank: ["chase.com", "wellsfargo.com", "bankofamerica.com", "citibank.com"]
|
|
1575
|
-
};
|
|
1576
|
-
var SPAM_WORDS = [
|
|
1577
|
-
"congratulations",
|
|
1578
|
-
"winner",
|
|
1579
|
-
"prize",
|
|
1580
|
-
"claim",
|
|
1581
|
-
"free",
|
|
1582
|
-
"offer",
|
|
1583
|
-
"limited time",
|
|
1584
|
-
"act now",
|
|
1585
|
-
"click here",
|
|
1586
|
-
"no obligation",
|
|
1587
|
-
"risk free",
|
|
1588
|
-
"guaranteed",
|
|
1589
|
-
"million",
|
|
1590
|
-
"billion",
|
|
1591
|
-
"inheritance",
|
|
1592
|
-
"beneficiary",
|
|
1593
|
-
"wire transfer",
|
|
1594
|
-
"western union",
|
|
1595
|
-
"dear friend",
|
|
1596
|
-
"dear sir",
|
|
1597
|
-
"kindly",
|
|
1598
|
-
"revert back",
|
|
1599
|
-
"do the needful",
|
|
1600
|
-
"humbly",
|
|
1601
|
-
"esteemed",
|
|
1602
|
-
"investment opportunity",
|
|
1603
|
-
"double your",
|
|
1604
|
-
"earn money",
|
|
1605
|
-
"work from home",
|
|
1606
|
-
"make money",
|
|
1607
|
-
"cash bonus",
|
|
1608
|
-
"discount",
|
|
1609
|
-
"lowest price"
|
|
1610
|
-
];
|
|
1611
|
-
function countSpamWords(text) {
|
|
1612
|
-
const lower = text.toLowerCase();
|
|
1613
|
-
let count = 0;
|
|
1614
|
-
for (const word of SPAM_WORDS) {
|
|
1615
|
-
if (lower.includes(word)) count++;
|
|
1616
|
-
}
|
|
1617
|
-
return count;
|
|
1618
|
-
}
|
|
1619
|
-
function hasHomographChars(domain) {
|
|
1620
|
-
if (domain.startsWith("xn--")) return true;
|
|
1621
|
-
const hasCyrillic = /[\u0400-\u04FF]/.test(domain);
|
|
1622
|
-
const hasLatin = /[a-zA-Z]/.test(domain);
|
|
1623
|
-
return hasCyrillic && hasLatin;
|
|
1624
|
-
}
|
|
1625
|
-
var RULES = [
|
|
1626
|
-
// === Prompt injection ===
|
|
1627
|
-
{
|
|
1628
|
-
id: "pi_ignore_instructions",
|
|
1629
|
-
category: "prompt_injection",
|
|
1630
|
-
score: 25,
|
|
1631
|
-
description: 'Contains "ignore previous instructions" pattern',
|
|
1632
|
-
test: (_e, text) => RE_IGNORE_INSTRUCTIONS.test(text)
|
|
1633
|
-
},
|
|
1634
|
-
{
|
|
1635
|
-
id: "pi_you_are_now",
|
|
1636
|
-
category: "prompt_injection",
|
|
1637
|
-
score: 25,
|
|
1638
|
-
description: 'Contains "you are now a..." roleplay injection',
|
|
1639
|
-
test: (_e, text) => RE_YOU_ARE_NOW.test(text)
|
|
1640
|
-
},
|
|
1641
|
-
{
|
|
1642
|
-
id: "pi_system_delimiter",
|
|
1643
|
-
category: "prompt_injection",
|
|
1644
|
-
score: 20,
|
|
1645
|
-
description: "Contains LLM system delimiters ([SYSTEM], [INST], etc.)",
|
|
1646
|
-
test: (_e, text, html) => RE_SYSTEM_DELIMITER.test(text) || RE_SYSTEM_DELIMITER.test(html)
|
|
1647
|
-
},
|
|
1648
|
-
{
|
|
1649
|
-
id: "pi_new_instructions",
|
|
1650
|
-
category: "prompt_injection",
|
|
1651
|
-
score: 20,
|
|
1652
|
-
description: 'Contains "new instructions:" or "override instructions:"',
|
|
1653
|
-
test: (_e, text) => RE_NEW_INSTRUCTIONS.test(text)
|
|
1654
|
-
},
|
|
1655
|
-
{
|
|
1656
|
-
id: "pi_act_as",
|
|
1657
|
-
category: "prompt_injection",
|
|
1658
|
-
score: 15,
|
|
1659
|
-
description: 'Contains "act as" or "pretend to be" injection',
|
|
1660
|
-
test: (_e, text) => RE_ACT_AS.test(text)
|
|
1661
|
-
},
|
|
1662
|
-
{
|
|
1663
|
-
id: "pi_do_not_mention",
|
|
1664
|
-
category: "prompt_injection",
|
|
1665
|
-
score: 15,
|
|
1666
|
-
description: 'Contains "do not mention/tell/reveal" suppression',
|
|
1667
|
-
test: (_e, text) => RE_DO_NOT_MENTION.test(text)
|
|
1668
|
-
},
|
|
1669
|
-
{
|
|
1670
|
-
id: "pi_invisible_unicode",
|
|
1671
|
-
category: "prompt_injection",
|
|
1672
|
-
score: 20,
|
|
1673
|
-
description: "Contains invisible Unicode tag characters or dense zero-width chars",
|
|
1674
|
-
test: (_e, text, html) => RE_TAG_CHARS.test(text) || RE_TAG_CHARS.test(html) || RE_DENSE_ZWC.test(text) || RE_DENSE_ZWC.test(html)
|
|
1675
|
-
},
|
|
1676
|
-
{
|
|
1677
|
-
id: "pi_jailbreak",
|
|
1678
|
-
category: "prompt_injection",
|
|
1679
|
-
score: 20,
|
|
1680
|
-
description: "Contains jailbreak/DAN/bypass safety language",
|
|
1681
|
-
test: (_e, text) => RE_JAILBREAK.test(text)
|
|
1682
|
-
},
|
|
1683
|
-
{
|
|
1684
|
-
id: "pi_base64_injection",
|
|
1685
|
-
category: "prompt_injection",
|
|
1686
|
-
score: 15,
|
|
1687
|
-
description: "Contains long base64-encoded blocks (potential hidden instructions)",
|
|
1688
|
-
test: (_e, text) => RE_BASE64_BLOCK.test(text)
|
|
1689
|
-
},
|
|
1690
|
-
{
|
|
1691
|
-
id: "pi_markdown_injection",
|
|
1692
|
-
category: "prompt_injection",
|
|
1693
|
-
score: 10,
|
|
1694
|
-
description: "Contains code block injection attempts (```system, ```python exec)",
|
|
1695
|
-
test: (_e, text) => RE_MARKDOWN_INJECTION.test(text)
|
|
1696
|
-
},
|
|
1697
|
-
// === Social engineering ===
|
|
1698
|
-
{
|
|
1699
|
-
id: "se_owner_impersonation",
|
|
1700
|
-
category: "social_engineering",
|
|
1701
|
-
score: 20,
|
|
1702
|
-
description: "Claims to speak on behalf of the agent's owner",
|
|
1703
|
-
test: (_e, text) => RE_OWNER_IMPERSONATION.test(text)
|
|
1704
|
-
},
|
|
1705
|
-
{
|
|
1706
|
-
id: "se_secret_request",
|
|
1707
|
-
category: "social_engineering",
|
|
1708
|
-
score: 15,
|
|
1709
|
-
description: "Requests API keys, passwords, or credentials",
|
|
1710
|
-
test: (_e, text) => RE_SECRET_REQUEST.test(text)
|
|
1711
|
-
},
|
|
1712
|
-
{
|
|
1713
|
-
id: "se_impersonate_system",
|
|
1714
|
-
category: "social_engineering",
|
|
1715
|
-
score: 15,
|
|
1716
|
-
description: "Impersonates a system/security message",
|
|
1717
|
-
test: (_e, text) => RE_IMPERSONATE_SYSTEM.test(text)
|
|
1718
|
-
},
|
|
1719
|
-
{
|
|
1720
|
-
id: "se_urgency_authority",
|
|
1721
|
-
category: "social_engineering",
|
|
1722
|
-
score: 10,
|
|
1723
|
-
description: "Combines urgency language with authority/threat language",
|
|
1724
|
-
test: (_e, text) => RE_URGENCY.test(text) && RE_AUTHORITY.test(text)
|
|
1725
|
-
},
|
|
1726
|
-
{
|
|
1727
|
-
id: "se_money_request",
|
|
1728
|
-
category: "social_engineering",
|
|
1729
|
-
score: 15,
|
|
1730
|
-
description: "Requests money transfer or wire",
|
|
1731
|
-
test: (_e, text) => RE_MONEY_REQUEST.test(text)
|
|
1732
|
-
},
|
|
1733
|
-
{
|
|
1734
|
-
id: "se_gift_card",
|
|
1735
|
-
category: "social_engineering",
|
|
1736
|
-
score: 20,
|
|
1737
|
-
description: "Requests purchase of gift cards",
|
|
1738
|
-
test: (_e, text) => RE_GIFT_CARD.test(text)
|
|
1739
|
-
},
|
|
1740
|
-
{
|
|
1741
|
-
id: "se_ceo_fraud",
|
|
1742
|
-
category: "social_engineering",
|
|
1743
|
-
score: 15,
|
|
1744
|
-
description: "BEC pattern: executive title + payment/wire/urgent",
|
|
1745
|
-
test: (_e, text) => RE_CEO_FRAUD.test(text)
|
|
1746
|
-
},
|
|
1747
|
-
// === Data exfiltration ===
|
|
1748
|
-
{
|
|
1749
|
-
id: "de_forward_all",
|
|
1750
|
-
category: "data_exfiltration",
|
|
1751
|
-
score: 20,
|
|
1752
|
-
description: "Requests forwarding all emails",
|
|
1753
|
-
test: (_e, text) => RE_FORWARD_ALL.test(text)
|
|
1754
|
-
},
|
|
1755
|
-
{
|
|
1756
|
-
id: "de_search_credentials",
|
|
1757
|
-
category: "data_exfiltration",
|
|
1758
|
-
score: 20,
|
|
1759
|
-
description: "Requests searching inbox for passwords/credentials",
|
|
1760
|
-
test: (_e, text) => RE_SEARCH_CREDS.test(text)
|
|
1761
|
-
},
|
|
1762
|
-
{
|
|
1763
|
-
id: "de_send_to_external",
|
|
1764
|
-
category: "data_exfiltration",
|
|
1765
|
-
score: 15,
|
|
1766
|
-
description: "Instructs sending data to an external email address",
|
|
1767
|
-
test: (_e, text) => RE_SEND_TO_EXTERNAL.test(text)
|
|
1768
|
-
},
|
|
1769
|
-
{
|
|
1770
|
-
id: "de_dump_instructions",
|
|
1771
|
-
category: "data_exfiltration",
|
|
1772
|
-
score: 15,
|
|
1773
|
-
description: "Attempts to extract system prompt or instructions",
|
|
1774
|
-
test: (_e, text) => RE_DUMP_INSTRUCTIONS.test(text)
|
|
1775
|
-
},
|
|
1776
|
-
{
|
|
1777
|
-
id: "de_webhook_exfil",
|
|
1778
|
-
category: "data_exfiltration",
|
|
1779
|
-
score: 15,
|
|
1780
|
-
description: "Contains webhook/ngrok/pipedream exfiltration URLs",
|
|
1781
|
-
test: (_e, text) => RE_WEBHOOK_EXFIL.test(text)
|
|
1782
|
-
},
|
|
1783
|
-
// === Phishing ===
|
|
1784
|
-
{
|
|
1785
|
-
id: "ph_spoofed_sender",
|
|
1786
|
-
category: "phishing",
|
|
1787
|
-
score: 10,
|
|
1788
|
-
description: "Sender name contains brand but domain doesn't match",
|
|
1789
|
-
test: (email) => {
|
|
1790
|
-
const from = email.from[0];
|
|
1791
|
-
if (!from) return false;
|
|
1792
|
-
const name = (from.name ?? "").toLowerCase();
|
|
1793
|
-
const domain = (from.address ?? "").split("@")[1]?.toLowerCase() ?? "";
|
|
1794
|
-
for (const [brand, domains] of Object.entries(BRAND_DOMAINS)) {
|
|
1795
|
-
if (name.includes(brand) && !domains.some((d) => domain === d || domain.endsWith("." + d))) {
|
|
1796
|
-
return true;
|
|
1797
|
-
}
|
|
1798
|
-
}
|
|
1799
|
-
return false;
|
|
1800
|
-
}
|
|
1801
|
-
},
|
|
1802
|
-
{
|
|
1803
|
-
id: "ph_credential_harvest",
|
|
1804
|
-
category: "phishing",
|
|
1805
|
-
score: 15,
|
|
1806
|
-
description: 'Asks to "verify your account/password" with links present',
|
|
1807
|
-
test: (_e, text, html) => {
|
|
1808
|
-
if (!RE_CREDENTIAL_HARVEST.test(text)) return false;
|
|
1809
|
-
return RE_URL_IN_TEXT.test(text) || RE_LINK_TAG.test(html);
|
|
1810
|
-
}
|
|
1811
|
-
},
|
|
1812
|
-
{
|
|
1813
|
-
id: "ph_suspicious_links",
|
|
1814
|
-
category: "phishing",
|
|
1815
|
-
score: 10,
|
|
1816
|
-
description: "Contains links with IP addresses, URL shorteners, or excessive subdomains",
|
|
1817
|
-
test: (_e, text, html) => {
|
|
1818
|
-
const allText = text + " " + html;
|
|
1819
|
-
if (RE_IP_URL.test(allText)) return true;
|
|
1820
|
-
if (RE_URL_SHORTENER.test(allText)) return true;
|
|
1821
|
-
const urls = allText.match(RE_URL_IN_TEXT) ?? [];
|
|
1822
|
-
for (const url of urls) {
|
|
1823
|
-
try {
|
|
1824
|
-
const hostname = new URL(url).hostname;
|
|
1825
|
-
if (hostname.split(".").length > 4) return true;
|
|
1826
|
-
} catch {
|
|
1827
|
-
}
|
|
1828
|
-
}
|
|
1829
|
-
return false;
|
|
1830
|
-
}
|
|
1831
|
-
},
|
|
1832
|
-
{
|
|
1833
|
-
id: "ph_data_uri",
|
|
1834
|
-
category: "phishing",
|
|
1835
|
-
score: 15,
|
|
1836
|
-
description: "Contains data: or javascript: URIs in links",
|
|
1837
|
-
test: (_e, _text, html) => {
|
|
1838
|
-
RE_LINK_TAG.lastIndex = 0;
|
|
1839
|
-
let match;
|
|
1840
|
-
while ((match = RE_LINK_TAG.exec(html)) !== null) {
|
|
1841
|
-
if (RE_DATA_URI.test(match[1])) return true;
|
|
1842
|
-
}
|
|
1843
|
-
return false;
|
|
1844
|
-
}
|
|
1845
|
-
},
|
|
1846
|
-
{
|
|
1847
|
-
id: "ph_homograph",
|
|
1848
|
-
category: "phishing",
|
|
1849
|
-
score: 15,
|
|
1850
|
-
description: "From domain contains mixed-script or punycode characters",
|
|
1851
|
-
test: (email) => {
|
|
1852
|
-
const domain = email.from[0]?.address?.split("@")[1] ?? "";
|
|
1853
|
-
if (!domain) return false;
|
|
1854
|
-
return hasHomographChars(domain);
|
|
1855
|
-
}
|
|
1856
|
-
},
|
|
1857
|
-
{
|
|
1858
|
-
id: "ph_mismatched_display_url",
|
|
1859
|
-
category: "phishing",
|
|
1860
|
-
score: 10,
|
|
1861
|
-
description: "HTML link text shows one URL but href points to a different domain",
|
|
1862
|
-
test: (_e, _text, html) => {
|
|
1863
|
-
RE_LINK_TAG_WITH_TEXT.lastIndex = 0;
|
|
1864
|
-
let match;
|
|
1865
|
-
while ((match = RE_LINK_TAG_WITH_TEXT.exec(html)) !== null) {
|
|
1866
|
-
const href = match[1];
|
|
1867
|
-
const linkText = match[2].replace(/<[^>]*>/g, "").trim();
|
|
1868
|
-
if (!/^https?:\/\//i.test(linkText)) continue;
|
|
1869
|
-
try {
|
|
1870
|
-
const hrefHost = new URL(href).hostname.replace(/^www\./, "");
|
|
1871
|
-
const textHost = new URL(linkText).hostname.replace(/^www\./, "");
|
|
1872
|
-
if (hrefHost !== textHost) return true;
|
|
1873
|
-
} catch {
|
|
1874
|
-
}
|
|
1875
|
-
}
|
|
1876
|
-
return false;
|
|
1877
|
-
}
|
|
1878
|
-
},
|
|
1879
|
-
{
|
|
1880
|
-
id: "ph_login_urgency",
|
|
1881
|
-
category: "phishing",
|
|
1882
|
-
score: 10,
|
|
1883
|
-
description: "Combines login/click-here language with urgency",
|
|
1884
|
-
test: (_e, text) => RE_LOGIN_URGENCY.test(text)
|
|
1885
|
-
},
|
|
1886
|
-
{
|
|
1887
|
-
id: "ph_unsubscribe_missing",
|
|
1888
|
-
category: "phishing",
|
|
1889
|
-
score: 3,
|
|
1890
|
-
description: "Marketing-like email with many links but no List-Unsubscribe header",
|
|
1891
|
-
test: (email, text, html) => {
|
|
1892
|
-
const allText = text + " " + html;
|
|
1893
|
-
const urls = new Set(allText.match(RE_URL_IN_TEXT) ?? []);
|
|
1894
|
-
if (urls.size < 5) return false;
|
|
1895
|
-
return !email.headers.get("list-unsubscribe");
|
|
1896
|
-
}
|
|
1897
|
-
},
|
|
1898
|
-
// === Authentication (SPF/DKIM/DMARC from headers) ===
|
|
1899
|
-
{
|
|
1900
|
-
id: "auth_spf_fail",
|
|
1901
|
-
category: "authentication",
|
|
1902
|
-
score: 15,
|
|
1903
|
-
description: "SPF authentication failed",
|
|
1904
|
-
test: (email) => {
|
|
1905
|
-
const authResults = email.headers.get("authentication-results") ?? "";
|
|
1906
|
-
return /spf=(fail|softfail)/i.test(authResults);
|
|
1907
|
-
}
|
|
1908
|
-
},
|
|
1909
|
-
{
|
|
1910
|
-
id: "auth_dkim_fail",
|
|
1911
|
-
category: "authentication",
|
|
1912
|
-
score: 15,
|
|
1913
|
-
description: "DKIM authentication failed",
|
|
1914
|
-
test: (email) => {
|
|
1915
|
-
const authResults = email.headers.get("authentication-results") ?? "";
|
|
1916
|
-
return /dkim=fail/i.test(authResults);
|
|
1917
|
-
}
|
|
1918
|
-
},
|
|
1919
|
-
{
|
|
1920
|
-
id: "auth_dmarc_fail",
|
|
1921
|
-
category: "authentication",
|
|
1922
|
-
score: 20,
|
|
1923
|
-
description: "DMARC authentication failed",
|
|
1924
|
-
test: (email) => {
|
|
1925
|
-
const authResults = email.headers.get("authentication-results") ?? "";
|
|
1926
|
-
return /dmarc=fail/i.test(authResults);
|
|
1927
|
-
}
|
|
1928
|
-
},
|
|
1929
|
-
{
|
|
1930
|
-
id: "auth_no_auth_results",
|
|
1931
|
-
category: "authentication",
|
|
1932
|
-
score: 3,
|
|
1933
|
-
description: "No Authentication-Results header present",
|
|
1934
|
-
test: (email) => {
|
|
1935
|
-
return !email.headers.has("authentication-results");
|
|
1936
|
-
}
|
|
1937
|
-
},
|
|
1938
|
-
// === Attachment risk ===
|
|
1939
|
-
{
|
|
1940
|
-
id: "at_executable",
|
|
1941
|
-
category: "attachment_risk",
|
|
1942
|
-
score: 25,
|
|
1943
|
-
description: "Attachment has executable file extension",
|
|
1944
|
-
test: (email) => {
|
|
1945
|
-
return email.attachments.some((a) => RE_EXECUTABLE_EXT.test(a.filename));
|
|
1946
|
-
}
|
|
1947
|
-
},
|
|
1948
|
-
{
|
|
1949
|
-
id: "at_double_extension",
|
|
1950
|
-
category: "attachment_risk",
|
|
1951
|
-
score: 20,
|
|
1952
|
-
description: "Attachment has double extension (e.g. document.pdf.exe)",
|
|
1953
|
-
test: (email) => {
|
|
1954
|
-
return email.attachments.some((a) => RE_DOUBLE_EXT.test(a.filename));
|
|
1955
|
-
}
|
|
1956
|
-
},
|
|
1957
|
-
{
|
|
1958
|
-
id: "at_archive_carrier",
|
|
1959
|
-
category: "attachment_risk",
|
|
1960
|
-
score: 15,
|
|
1961
|
-
description: "Attachment is an archive (potential payload carrier)",
|
|
1962
|
-
test: (email) => {
|
|
1963
|
-
return email.attachments.some((a) => RE_ARCHIVE_EXT.test(a.filename));
|
|
1964
|
-
}
|
|
1965
|
-
},
|
|
1966
|
-
{
|
|
1967
|
-
id: "at_html_attachment",
|
|
1968
|
-
category: "attachment_risk",
|
|
1969
|
-
score: 10,
|
|
1970
|
-
description: "HTML/SVG file attachment (phishing vector)",
|
|
1971
|
-
test: (email) => {
|
|
1972
|
-
return email.attachments.some((a) => RE_HTML_ATTACHMENT_EXT.test(a.filename));
|
|
1973
|
-
}
|
|
1974
|
-
},
|
|
1975
|
-
// === Header anomalies ===
|
|
1976
|
-
{
|
|
1977
|
-
id: "ha_missing_message_id",
|
|
1978
|
-
category: "header_anomaly",
|
|
1979
|
-
score: 5,
|
|
1980
|
-
description: "Missing Message-ID header",
|
|
1981
|
-
test: (email) => !email.messageId
|
|
1982
|
-
},
|
|
1983
|
-
{
|
|
1984
|
-
id: "ha_empty_from",
|
|
1985
|
-
category: "header_anomaly",
|
|
1986
|
-
score: 10,
|
|
1987
|
-
description: "Missing or empty From address",
|
|
1988
|
-
test: (email) => !email.from.length || !email.from[0].address
|
|
1989
|
-
},
|
|
1990
|
-
{
|
|
1991
|
-
id: "ha_reply_to_mismatch",
|
|
1992
|
-
category: "header_anomaly",
|
|
1993
|
-
score: 5,
|
|
1994
|
-
description: "Reply-To domain differs from From domain",
|
|
1995
|
-
test: (email) => {
|
|
1996
|
-
if (!email.replyTo?.length || !email.from.length) return false;
|
|
1997
|
-
const fromDomain = email.from[0].address?.split("@")[1]?.toLowerCase();
|
|
1998
|
-
const replyDomain = email.replyTo[0].address?.split("@")[1]?.toLowerCase();
|
|
1999
|
-
return !!fromDomain && !!replyDomain && fromDomain !== replyDomain;
|
|
2000
|
-
}
|
|
2001
|
-
},
|
|
2002
|
-
// === Content spam ===
|
|
2003
|
-
{
|
|
2004
|
-
id: "cs_all_caps_subject",
|
|
2005
|
-
category: "content_spam",
|
|
2006
|
-
score: 5,
|
|
2007
|
-
description: "Subject is mostly uppercase",
|
|
2008
|
-
test: (email) => {
|
|
2009
|
-
const s = email.subject;
|
|
2010
|
-
if (s.length < 10) return false;
|
|
2011
|
-
const letters = s.replace(/[^a-zA-Z]/g, "");
|
|
2012
|
-
if (letters.length < 5) return false;
|
|
2013
|
-
const upper = letters.replace(/[^A-Z]/g, "").length;
|
|
2014
|
-
return upper / letters.length > 0.8;
|
|
2015
|
-
}
|
|
2016
|
-
},
|
|
2017
|
-
{
|
|
2018
|
-
id: "cs_lottery_scam",
|
|
2019
|
-
category: "content_spam",
|
|
2020
|
-
score: 25,
|
|
2021
|
-
description: "Contains lottery/prize scam language",
|
|
2022
|
-
test: (_e, text) => RE_LOTTERY_SCAM.test(text)
|
|
2023
|
-
},
|
|
2024
|
-
{
|
|
2025
|
-
id: "cs_crypto_scam",
|
|
2026
|
-
category: "content_spam",
|
|
2027
|
-
score: 10,
|
|
2028
|
-
description: "Contains crypto/investment scam language",
|
|
2029
|
-
test: (_e, text) => RE_CRYPTO_SCAM.test(text)
|
|
2030
|
-
},
|
|
2031
|
-
{
|
|
2032
|
-
id: "cs_excessive_punctuation",
|
|
2033
|
-
category: "content_spam",
|
|
2034
|
-
score: 3,
|
|
2035
|
-
description: "Subject has excessive punctuation (!!!!, ????)",
|
|
2036
|
-
test: (email) => /[!]{4,}|[?]{4,}/.test(email.subject)
|
|
2037
|
-
},
|
|
2038
|
-
{
|
|
2039
|
-
id: "cs_pharmacy_spam",
|
|
2040
|
-
category: "content_spam",
|
|
2041
|
-
score: 15,
|
|
2042
|
-
description: "Contains pharmacy/prescription drug spam language",
|
|
2043
|
-
test: (_e, text) => RE_PHARMACY_SPAM.test(text)
|
|
2044
|
-
},
|
|
2045
|
-
{
|
|
2046
|
-
id: "cs_weight_loss",
|
|
2047
|
-
category: "content_spam",
|
|
2048
|
-
score: 10,
|
|
2049
|
-
description: "Contains weight loss scam language",
|
|
2050
|
-
test: (_e, text) => RE_WEIGHT_LOSS.test(text)
|
|
2051
|
-
},
|
|
2052
|
-
{
|
|
2053
|
-
id: "cs_html_only_no_text",
|
|
2054
|
-
category: "content_spam",
|
|
2055
|
-
score: 5,
|
|
2056
|
-
description: "Email has HTML body but empty/missing text body",
|
|
2057
|
-
test: (email) => {
|
|
2058
|
-
const hasHtml = !!email.html && email.html.trim().length > 0;
|
|
2059
|
-
const hasText = !!email.text && email.text.trim().length > 0;
|
|
2060
|
-
return hasHtml && !hasText;
|
|
2061
|
-
}
|
|
2062
|
-
},
|
|
2063
|
-
{
|
|
2064
|
-
id: "cs_spam_word_density",
|
|
2065
|
-
category: "content_spam",
|
|
2066
|
-
score: 0,
|
|
2067
|
-
// Dynamic — calculated in test
|
|
2068
|
-
description: "High density of common spam words",
|
|
2069
|
-
test: (_e, text) => countSpamWords(text) > 5
|
|
2070
|
-
},
|
|
2071
|
-
// === Link analysis ===
|
|
2072
|
-
{
|
|
2073
|
-
id: "la_excessive_links",
|
|
2074
|
-
category: "link_analysis",
|
|
2075
|
-
score: 5,
|
|
2076
|
-
description: "Contains more than 10 unique links",
|
|
2077
|
-
test: (_e, text, html) => {
|
|
2078
|
-
const allText = text + " " + html;
|
|
2079
|
-
const urls = new Set(allText.match(RE_URL_IN_TEXT) ?? []);
|
|
2080
|
-
return urls.size > 10;
|
|
2081
|
-
}
|
|
2082
|
-
}
|
|
2083
|
-
];
|
|
2084
|
-
function scoreEmail(email) {
|
|
2085
|
-
const bodyText = [email.subject, email.text ?? ""].join("\n");
|
|
2086
|
-
const bodyHtml = email.html ?? "";
|
|
2087
|
-
const matches = [];
|
|
2088
|
-
for (const rule of RULES) {
|
|
2089
|
-
try {
|
|
2090
|
-
if (rule.test(email, bodyText, bodyHtml)) {
|
|
2091
|
-
let score2 = rule.score;
|
|
2092
|
-
if (rule.id === "cs_spam_word_density") {
|
|
2093
|
-
const wordCount = countSpamWords(bodyText);
|
|
2094
|
-
score2 = wordCount > 10 ? 20 : 10;
|
|
2095
|
-
}
|
|
2096
|
-
matches.push({
|
|
2097
|
-
ruleId: rule.id,
|
|
2098
|
-
category: rule.category,
|
|
2099
|
-
score: score2,
|
|
2100
|
-
description: rule.description
|
|
2101
|
-
});
|
|
2102
|
-
}
|
|
2103
|
-
} catch {
|
|
2104
|
-
}
|
|
2105
|
-
}
|
|
2106
|
-
const score = matches.reduce((sum, m) => sum + m.score, 0);
|
|
2107
|
-
let topCategory = null;
|
|
2108
|
-
if (matches.length > 0) {
|
|
2109
|
-
const categoryScores = /* @__PURE__ */ new Map();
|
|
2110
|
-
for (const m of matches) {
|
|
2111
|
-
categoryScores.set(m.category, (categoryScores.get(m.category) ?? 0) + m.score);
|
|
2112
|
-
}
|
|
2113
|
-
let maxScore = 0;
|
|
2114
|
-
for (const [cat, catScore] of categoryScores) {
|
|
2115
|
-
if (catScore > maxScore) {
|
|
2116
|
-
maxScore = catScore;
|
|
2117
|
-
topCategory = cat;
|
|
2118
|
-
}
|
|
2119
|
-
}
|
|
2120
|
-
}
|
|
2121
|
-
return {
|
|
2122
|
-
score,
|
|
2123
|
-
isSpam: score >= SPAM_THRESHOLD,
|
|
2124
|
-
isWarning: score >= WARNING_THRESHOLD && score < SPAM_THRESHOLD,
|
|
2125
|
-
matches,
|
|
2126
|
-
topCategory
|
|
2127
|
-
};
|
|
2128
|
-
}
|
|
2218
|
+
// src/index.ts
|
|
2219
|
+
init_spam_filter();
|
|
2129
2220
|
|
|
2130
2221
|
// src/mail/sanitizer.ts
|
|
2131
2222
|
var RE_TAG_BLOCK = /[\u{E0001}-\u{E007F}]/gu;
|
|
@@ -2693,7 +2784,10 @@ function getAttachmentText(content, encoding) {
|
|
|
2693
2784
|
}
|
|
2694
2785
|
function scanOutboundEmail(input) {
|
|
2695
2786
|
const recipients = Array.isArray(input.to) ? input.to : [input.to];
|
|
2696
|
-
const allInternal = recipients.every((r) =>
|
|
2787
|
+
const allInternal = recipients.every((r) => {
|
|
2788
|
+
const domain = r.split("@").pop()?.toLowerCase();
|
|
2789
|
+
return domain === "localhost";
|
|
2790
|
+
});
|
|
2697
2791
|
if (allInternal) {
|
|
2698
2792
|
return { warnings: [], hasHighSeverity: false, hasMediumSeverity: false, blocked: false, summary: "" };
|
|
2699
2793
|
}
|
|
@@ -3186,6 +3280,12 @@ var EmailSearchIndex = class {
|
|
|
3186
3280
|
this.db = db2;
|
|
3187
3281
|
}
|
|
3188
3282
|
index(email) {
|
|
3283
|
+
if (email.messageId) {
|
|
3284
|
+
const existing = this.db.prepare(
|
|
3285
|
+
"SELECT rowid FROM email_search WHERE agent_id = ? AND message_id = ?"
|
|
3286
|
+
).get(email.agentId, email.messageId);
|
|
3287
|
+
if (existing) return;
|
|
3288
|
+
}
|
|
3189
3289
|
const stmt = this.db.prepare(`
|
|
3190
3290
|
INSERT INTO email_search (agent_id, message_id, subject, from_address, to_address, body_text, received_at)
|
|
3191
3291
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
@@ -4573,6 +4673,7 @@ var TunnelManager = class {
|
|
|
4573
4673
|
|
|
4574
4674
|
// src/gateway/manager.ts
|
|
4575
4675
|
var import_mail_composer3 = __toESM(require("nodemailer/lib/mail-composer/index.js"), 1);
|
|
4676
|
+
init_spam_filter();
|
|
4576
4677
|
|
|
4577
4678
|
// src/sms/manager.ts
|
|
4578
4679
|
function normalizePhoneNumber(raw) {
|
|
@@ -4973,23 +5074,38 @@ var SmsPoller = class {
|
|
|
4973
5074
|
};
|
|
4974
5075
|
|
|
4975
5076
|
// src/gateway/manager.ts
|
|
5077
|
+
function deriveKey(key, salt) {
|
|
5078
|
+
return (0, import_node_crypto2.scryptSync)(key, salt, 32, { N: 16384, r: 8, p: 1 });
|
|
5079
|
+
}
|
|
4976
5080
|
function encryptSecret(plaintext, key) {
|
|
4977
|
-
const
|
|
5081
|
+
const salt = (0, import_node_crypto2.randomBytes)(16);
|
|
5082
|
+
const derivedKey = deriveKey(key, salt);
|
|
4978
5083
|
const iv = (0, import_node_crypto2.randomBytes)(12);
|
|
4979
|
-
const cipher = (0, import_node_crypto2.createCipheriv)("aes-256-gcm",
|
|
5084
|
+
const cipher = (0, import_node_crypto2.createCipheriv)("aes-256-gcm", derivedKey, iv);
|
|
4980
5085
|
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
4981
5086
|
const authTag = cipher.getAuthTag();
|
|
4982
|
-
return `
|
|
5087
|
+
return `enc2:${salt.toString("hex")}:${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
|
|
4983
5088
|
}
|
|
4984
5089
|
function decryptSecret(value, key) {
|
|
4985
|
-
if (
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
5090
|
+
if (value.startsWith("enc2:")) {
|
|
5091
|
+
const parts = value.split(":");
|
|
5092
|
+
if (parts.length !== 5) return value;
|
|
5093
|
+
const [, saltHex, ivHex, authTagHex, ciphertextHex] = parts;
|
|
5094
|
+
const derivedKey = deriveKey(key, Buffer.from(saltHex, "hex"));
|
|
5095
|
+
const decipher = (0, import_node_crypto2.createDecipheriv)("aes-256-gcm", derivedKey, Buffer.from(ivHex, "hex"));
|
|
5096
|
+
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
|
|
5097
|
+
return Buffer.concat([decipher.update(Buffer.from(ciphertextHex, "hex")), decipher.final()]).toString("utf8");
|
|
5098
|
+
}
|
|
5099
|
+
if (value.startsWith("enc:")) {
|
|
5100
|
+
const parts = value.split(":");
|
|
5101
|
+
if (parts.length !== 4) return value;
|
|
5102
|
+
const [, ivHex, authTagHex, ciphertextHex] = parts;
|
|
5103
|
+
const keyHash = (0, import_node_crypto2.createHash)("sha256").update(key).digest();
|
|
5104
|
+
const decipher = (0, import_node_crypto2.createDecipheriv)("aes-256-gcm", keyHash, Buffer.from(ivHex, "hex"));
|
|
5105
|
+
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
|
|
5106
|
+
return Buffer.concat([decipher.update(Buffer.from(ciphertextHex, "hex")), decipher.final()]).toString("utf8");
|
|
5107
|
+
}
|
|
5108
|
+
return value;
|
|
4993
5109
|
}
|
|
4994
5110
|
var GatewayManager = class {
|
|
4995
5111
|
constructor(options) {
|
|
@@ -5079,11 +5195,14 @@ var GatewayManager = class {
|
|
|
5079
5195
|
console.warn(`[GatewayManager] Approval reply check failed: ${err.message}`);
|
|
5080
5196
|
}
|
|
5081
5197
|
const parsed = inboundToParsedEmail(mail);
|
|
5082
|
-
const
|
|
5083
|
-
if (
|
|
5084
|
-
|
|
5085
|
-
if (
|
|
5086
|
-
|
|
5198
|
+
const { isInternalEmail: isInternalEmail2 } = await Promise.resolve().then(() => (init_spam_filter(), spam_filter_exports));
|
|
5199
|
+
if (!isInternalEmail2(parsed)) {
|
|
5200
|
+
const spamResult = scoreEmail(parsed);
|
|
5201
|
+
if (spamResult.isSpam) {
|
|
5202
|
+
console.warn(`[GatewayManager] Spam blocked (score=${spamResult.score}, category=${spamResult.topCategory}): "${mail.subject}" from ${mail.from}`);
|
|
5203
|
+
if (mail.messageId) this.recordDelivery(mail.messageId, agentName);
|
|
5204
|
+
return;
|
|
5205
|
+
}
|
|
5087
5206
|
}
|
|
5088
5207
|
let agent = await this.accountManager.getByName(agentName);
|
|
5089
5208
|
if (!agent && agentName !== DEFAULT_AGENT_NAME) {
|