@troykelly/openclaw-projects 0.0.10 → 0.0.12
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/config.d.ts +30 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +28 -1
- package/dist/config.js.map +1 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +17 -3
- package/dist/hooks.js.map +1 -1
- package/dist/register-openclaw.d.ts +3 -1
- package/dist/register-openclaw.d.ts.map +1 -1
- package/dist/register-openclaw.js +611 -55
- package/dist/register-openclaw.js.map +1 -1
- package/dist/tools/contacts.d.ts +6 -6
- package/dist/tools/contacts.d.ts.map +1 -1
- package/dist/tools/contacts.js +7 -7
- package/dist/tools/contacts.js.map +1 -1
- package/dist/tools/context-search.d.ts +79 -0
- package/dist/tools/context-search.d.ts.map +1 -0
- package/dist/tools/context-search.js +265 -0
- package/dist/tools/context-search.js.map +1 -0
- package/dist/tools/email-send.d.ts.map +1 -1
- package/dist/tools/email-send.js +1 -14
- package/dist/tools/email-send.js.map +1 -1
- package/dist/tools/entity-links.d.ts +117 -0
- package/dist/tools/entity-links.d.ts.map +1 -0
- package/dist/tools/entity-links.js +446 -0
- package/dist/tools/entity-links.js.map +1 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +8 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/memory-forget.js +5 -5
- package/dist/tools/memory-forget.js.map +1 -1
- package/dist/tools/memory-recall.d.ts +28 -0
- package/dist/tools/memory-recall.d.ts.map +1 -1
- package/dist/tools/memory-recall.js +44 -4
- package/dist/tools/memory-recall.js.map +1 -1
- package/dist/tools/memory-store.d.ts +57 -0
- package/dist/tools/memory-store.d.ts.map +1 -1
- package/dist/tools/memory-store.js +29 -2
- package/dist/tools/memory-store.js.map +1 -1
- package/dist/tools/message-search.d.ts +1 -1
- package/dist/tools/message-search.d.ts.map +1 -1
- package/dist/tools/message-search.js +20 -2
- package/dist/tools/message-search.js.map +1 -1
- package/dist/tools/notes.d.ts +2 -2
- package/dist/tools/project-search.d.ts +92 -0
- package/dist/tools/project-search.d.ts.map +1 -0
- package/dist/tools/project-search.js +160 -0
- package/dist/tools/project-search.js.map +1 -0
- package/dist/tools/relationships.js +1 -1
- package/dist/tools/relationships.js.map +1 -1
- package/dist/tools/skill-store.d.ts +12 -12
- package/dist/tools/threads.d.ts +2 -2
- package/dist/tools/threads.d.ts.map +1 -1
- package/dist/tools/threads.js +30 -6
- package/dist/tools/threads.js.map +1 -1
- package/dist/tools/todo-search.d.ts +95 -0
- package/dist/tools/todo-search.d.ts.map +1 -0
- package/dist/tools/todo-search.js +164 -0
- package/dist/tools/todo-search.js.map +1 -0
- package/dist/types/openclaw-api.d.ts +15 -0
- package/dist/types/openclaw-api.d.ts.map +1 -1
- package/dist/utils/auto-linker.d.ts +66 -0
- package/dist/utils/auto-linker.d.ts.map +1 -0
- package/dist/utils/auto-linker.js +354 -0
- package/dist/utils/auto-linker.js.map +1 -0
- package/dist/utils/geo.d.ts +24 -0
- package/dist/utils/geo.d.ts.map +1 -0
- package/dist/utils/geo.js +38 -0
- package/dist/utils/geo.js.map +1 -0
- package/dist/utils/inbound-gate.d.ts +85 -0
- package/dist/utils/inbound-gate.d.ts.map +1 -0
- package/dist/utils/inbound-gate.js +133 -0
- package/dist/utils/inbound-gate.js.map +1 -0
- package/dist/utils/injection-protection.d.ts +81 -0
- package/dist/utils/injection-protection.d.ts.map +1 -0
- package/dist/utils/injection-protection.js +179 -0
- package/dist/utils/injection-protection.js.map +1 -0
- package/dist/utils/nominatim.d.ts +18 -0
- package/dist/utils/nominatim.d.ts.map +1 -0
- package/dist/utils/nominatim.js +56 -0
- package/dist/utils/nominatim.js.map +1 -0
- package/dist/utils/rate-limiter.d.ts +81 -0
- package/dist/utils/rate-limiter.d.ts.map +1 -0
- package/dist/utils/rate-limiter.js +188 -0
- package/dist/utils/rate-limiter.js.map +1 -0
- package/dist/utils/spam-filter.d.ts +79 -0
- package/dist/utils/spam-filter.d.ts.map +1 -0
- package/dist/utils/spam-filter.js +237 -0
- package/dist/utils/spam-filter.js.map +1 -0
- package/dist/utils/token-budget.d.ts +68 -0
- package/dist/utils/token-budget.d.ts.map +1 -0
- package/dist/utils/token-budget.js +142 -0
- package/dist/utils/token-budget.js.map +1 -0
- package/openclaw.plugin.json +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nominatim reverse geocoding client with LRU cache.
|
|
3
|
+
* Resolves lat/lng to human-readable address and place label.
|
|
4
|
+
*/
|
|
5
|
+
const geocodeCache = new Map();
|
|
6
|
+
const MAX_CACHE_SIZE = 500;
|
|
7
|
+
/**
|
|
8
|
+
* Rounds coordinates to ~100m precision for cache key deduplication.
|
|
9
|
+
*/
|
|
10
|
+
function cacheKey(lat, lng) {
|
|
11
|
+
return `${Math.round(lat * 1000) / 1000},${Math.round(lng * 1000) / 1000}`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Reverse geocode a lat/lng pair via Nominatim.
|
|
15
|
+
* Returns null on any failure (timeout, network error, bad response).
|
|
16
|
+
*/
|
|
17
|
+
export async function reverseGeocode(lat, lng, nominatimUrl) {
|
|
18
|
+
const key = cacheKey(lat, lng);
|
|
19
|
+
if (geocodeCache.has(key))
|
|
20
|
+
return geocodeCache.get(key);
|
|
21
|
+
try {
|
|
22
|
+
const url = `${nominatimUrl}/reverse?format=jsonv2&lat=${lat}&lon=${lng}`;
|
|
23
|
+
const response = await fetch(url, {
|
|
24
|
+
headers: { 'User-Agent': 'openclaw-projects/1.0' },
|
|
25
|
+
signal: AbortSignal.timeout(5000),
|
|
26
|
+
});
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
console.warn(`[Nominatim] Reverse geocode failed: HTTP ${response.status} for (${lat}, ${lng})`);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const data = (await response.json());
|
|
32
|
+
const result = {
|
|
33
|
+
address: data.display_name ?? '',
|
|
34
|
+
placeLabel: data.name || data.address?.suburb || data.address?.city || '',
|
|
35
|
+
};
|
|
36
|
+
if (geocodeCache.size >= MAX_CACHE_SIZE) {
|
|
37
|
+
const oldest = geocodeCache.keys().next().value;
|
|
38
|
+
if (oldest !== undefined)
|
|
39
|
+
geocodeCache.delete(oldest);
|
|
40
|
+
}
|
|
41
|
+
geocodeCache.set(key, result);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
46
|
+
console.warn(`[Nominatim] Reverse geocode error for (${lat}, ${lng}): ${msg}`);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Clears the geocode cache (for testing).
|
|
52
|
+
*/
|
|
53
|
+
export function clearGeocodeCache() {
|
|
54
|
+
geocodeCache.clear();
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=nominatim.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nominatim.js","sourceRoot":"","sources":["../../src/utils/nominatim.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAOH,MAAM,YAAY,GAAG,IAAI,GAAG,EAA4B,CAAC;AACzD,MAAM,cAAc,GAAG,GAAG,CAAC;AAE3B;;GAEG;AACH,SAAS,QAAQ,CAAC,GAAW,EAAE,GAAW;IACxC,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,EAAE,CAAC;AAC7E,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,GAAW,EACX,GAAW,EACX,YAAoB;IAEpB,MAAM,GAAG,GAAG,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IAC/B,IAAI,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC;QAAE,OAAO,YAAY,CAAC,GAAG,CAAC,GAAG,CAAE,CAAC;IAEzD,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,GAAG,YAAY,8BAA8B,GAAG,QAAQ,GAAG,EAAE,CAAC;QAC1E,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;YAChC,OAAO,EAAE,EAAE,YAAY,EAAE,uBAAuB,EAAE;YAClD,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC;SAClC,CAAC,CAAC;QACH,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,OAAO,CAAC,IAAI,CAAC,4CAA4C,QAAQ,CAAC,MAAM,SAAS,GAAG,KAAK,GAAG,GAAG,CAAC,CAAC;YACjG,OAAO,IAAI,CAAC;QACd,CAAC;QAED,MAAM,IAAI,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAUlC,CAAC;QAEF,MAAM,MAAM,GAAqB;YAC/B,OAAO,EAAE,IAAI,CAAC,YAAY,IAAI,EAAE;YAChC,UAAU,EAAE,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,OAAO,EAAE,MAAM,IAAI,IAAI,CAAC,OAAO,EAAE,IAAI,IAAI,EAAE;SAC1E,CAAC;QAEF,IAAI,YAAY,CAAC,IAAI,IAAI,cAAc,EAAE,CAAC;YACxC,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC;YAChD,IAAI,MAAM,KAAK,SAAS;gBAAE,YAAY,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACxD,CAAC;QACD,YAAY,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;QAC9B,OAAO,MAAM,CAAC;IAChB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,GAAG,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACnE,OAAO,CAAC,IAAI,CAAC,0CAA0C,GAAG,KAAK,GAAG,MAAM,GAAG,EAAE,CAAC,CAAC;QAC/E,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,iBAAiB;IAC/B,YAAY,CAAC,KAAK,EAAE,CAAC;AACvB,CAAC"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sliding window rate limiter for inbound message processing.
|
|
3
|
+
*
|
|
4
|
+
* Provides per-sender and per-recipient rate limiting with
|
|
5
|
+
* contact-trust-level awareness. Uses in-memory sliding window
|
|
6
|
+
* with automatic cleanup of expired entries.
|
|
7
|
+
*
|
|
8
|
+
* NOTE: State is in-memory and does not persist across process restarts
|
|
9
|
+
* or span multiple instances. For multi-instance deployments, a
|
|
10
|
+
* skill_store-backed implementation would be needed to share rate
|
|
11
|
+
* limit state across processes.
|
|
12
|
+
*
|
|
13
|
+
* Part of Issue #1225 — rate limiting and spam protection.
|
|
14
|
+
*/
|
|
15
|
+
import { type MessageChannel } from './spam-filter.js';
|
|
16
|
+
/** Trust levels for sender classification */
|
|
17
|
+
export type SenderTrust = 'trusted' | 'known' | 'unknown' | 'blocked';
|
|
18
|
+
/** Configuration for the rate limiter */
|
|
19
|
+
export interface RateLimiterConfig {
|
|
20
|
+
/** Max messages per window for trusted senders (active thread, replied) */
|
|
21
|
+
trustedSenderLimit: number;
|
|
22
|
+
/** Max messages per window for known contacts */
|
|
23
|
+
knownSenderLimit: number;
|
|
24
|
+
/** Max messages per window for unknown senders */
|
|
25
|
+
unknownSenderLimit: number;
|
|
26
|
+
/** Global max messages per window for a single recipient */
|
|
27
|
+
recipientGlobalLimit: number;
|
|
28
|
+
/** Sliding window duration in milliseconds */
|
|
29
|
+
windowMs: number;
|
|
30
|
+
/**
|
|
31
|
+
* Maximum number of tracked sender entries before forced eviction.
|
|
32
|
+
* Prevents unbounded memory growth if traffic burst creates many keys
|
|
33
|
+
* and then stops (entries would persist until the next cleanup cycle).
|
|
34
|
+
* When exceeded, the oldest entries are evicted during the next check.
|
|
35
|
+
*/
|
|
36
|
+
maxSenderEntries?: number;
|
|
37
|
+
}
|
|
38
|
+
/** Result of a rate limit check */
|
|
39
|
+
export interface RateLimitResult {
|
|
40
|
+
/** Whether the message is allowed */
|
|
41
|
+
allowed: boolean;
|
|
42
|
+
/** Human-readable reason if denied, null if allowed */
|
|
43
|
+
reason: string | null;
|
|
44
|
+
/** Messages remaining in current window */
|
|
45
|
+
remaining: number;
|
|
46
|
+
/** The limit that applies to this sender */
|
|
47
|
+
limit: number;
|
|
48
|
+
/** Milliseconds until the earliest entry expires (for retry-after) */
|
|
49
|
+
retryAfterMs: number | null;
|
|
50
|
+
}
|
|
51
|
+
/** Statistics about the rate limiter state */
|
|
52
|
+
export interface RateLimiterStats {
|
|
53
|
+
/** Number of currently tracked senders */
|
|
54
|
+
activeSenders: number;
|
|
55
|
+
/** Number of currently tracked recipients */
|
|
56
|
+
activeRecipients: number;
|
|
57
|
+
}
|
|
58
|
+
/** Rate limiter instance */
|
|
59
|
+
export interface RateLimiter {
|
|
60
|
+
/**
|
|
61
|
+
* Check if a message is allowed and record it if so.
|
|
62
|
+
* Sender and recipient are normalized for consistent matching
|
|
63
|
+
* (phone number formatting, email alias stripping).
|
|
64
|
+
*/
|
|
65
|
+
check(sender: string, recipient: string, trust: SenderTrust, channel?: MessageChannel): RateLimitResult;
|
|
66
|
+
/** Get current stats about the limiter */
|
|
67
|
+
getStats(): RateLimiterStats;
|
|
68
|
+
}
|
|
69
|
+
/** Default rate limiter configuration */
|
|
70
|
+
export declare const DEFAULT_RATE_LIMITER_CONFIG: RateLimiterConfig;
|
|
71
|
+
/**
|
|
72
|
+
* Create a rate limiter instance.
|
|
73
|
+
*
|
|
74
|
+
* Uses a sliding window approach: each message records a timestamp,
|
|
75
|
+
* and only timestamps within the current window are counted.
|
|
76
|
+
*
|
|
77
|
+
* @param config - Rate limiter configuration (uses defaults if omitted)
|
|
78
|
+
* @returns RateLimiter instance
|
|
79
|
+
*/
|
|
80
|
+
export declare function createRateLimiter(config?: RateLimiterConfig): RateLimiter;
|
|
81
|
+
//# sourceMappingURL=rate-limiter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limiter.d.ts","sourceRoot":"","sources":["../../src/utils/rate-limiter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAmB,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAExE,6CAA6C;AAC7C,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,GAAG,SAAS,CAAC;AAEtE,yCAAyC;AACzC,MAAM,WAAW,iBAAiB;IAChC,2EAA2E;IAC3E,kBAAkB,EAAE,MAAM,CAAC;IAC3B,iDAAiD;IACjD,gBAAgB,EAAE,MAAM,CAAC;IACzB,kDAAkD;IAClD,kBAAkB,EAAE,MAAM,CAAC;IAC3B,4DAA4D;IAC5D,oBAAoB,EAAE,MAAM,CAAC;IAC7B,8CAA8C;IAC9C,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,mCAAmC;AACnC,MAAM,WAAW,eAAe;IAC9B,qCAAqC;IACrC,OAAO,EAAE,OAAO,CAAC;IACjB,uDAAuD;IACvD,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,2CAA2C;IAC3C,SAAS,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED,8CAA8C;AAC9C,MAAM,WAAW,gBAAgB;IAC/B,0CAA0C;IAC1C,aAAa,EAAE,MAAM,CAAC;IACtB,6CAA6C;IAC7C,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED,4BAA4B;AAC5B,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,eAAe,CAAC;IACxG,0CAA0C;IAC1C,QAAQ,IAAI,gBAAgB,CAAC;CAC9B;AAED,yCAAyC;AACzC,eAAO,MAAM,2BAA2B,EAAE,iBAOzC,CAAC;AAKF;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,GAAE,iBAA+C,GAAG,WAAW,CA0KtG"}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sliding window rate limiter for inbound message processing.
|
|
3
|
+
*
|
|
4
|
+
* Provides per-sender and per-recipient rate limiting with
|
|
5
|
+
* contact-trust-level awareness. Uses in-memory sliding window
|
|
6
|
+
* with automatic cleanup of expired entries.
|
|
7
|
+
*
|
|
8
|
+
* NOTE: State is in-memory and does not persist across process restarts
|
|
9
|
+
* or span multiple instances. For multi-instance deployments, a
|
|
10
|
+
* skill_store-backed implementation would be needed to share rate
|
|
11
|
+
* limit state across processes.
|
|
12
|
+
*
|
|
13
|
+
* Part of Issue #1225 — rate limiting and spam protection.
|
|
14
|
+
*/
|
|
15
|
+
import { normalizeSender } from './spam-filter.js';
|
|
16
|
+
/** Default rate limiter configuration */
|
|
17
|
+
export const DEFAULT_RATE_LIMITER_CONFIG = {
|
|
18
|
+
trustedSenderLimit: 100,
|
|
19
|
+
knownSenderLimit: 50,
|
|
20
|
+
unknownSenderLimit: 5,
|
|
21
|
+
recipientGlobalLimit: 200,
|
|
22
|
+
windowMs: 3_600_000, // 1 hour
|
|
23
|
+
maxSenderEntries: 10_000,
|
|
24
|
+
};
|
|
25
|
+
/** Interval for running cleanup of expired entries */
|
|
26
|
+
const CLEANUP_INTERVAL_CHECKS = 100;
|
|
27
|
+
/**
|
|
28
|
+
* Create a rate limiter instance.
|
|
29
|
+
*
|
|
30
|
+
* Uses a sliding window approach: each message records a timestamp,
|
|
31
|
+
* and only timestamps within the current window are counted.
|
|
32
|
+
*
|
|
33
|
+
* @param config - Rate limiter configuration (uses defaults if omitted)
|
|
34
|
+
* @returns RateLimiter instance
|
|
35
|
+
*/
|
|
36
|
+
export function createRateLimiter(config = DEFAULT_RATE_LIMITER_CONFIG) {
|
|
37
|
+
/** Map of sender -> array of message timestamps */
|
|
38
|
+
const senderWindows = new Map();
|
|
39
|
+
/** Map of recipient -> array of message timestamps */
|
|
40
|
+
const recipientWindows = new Map();
|
|
41
|
+
/** Counter for triggering periodic cleanup */
|
|
42
|
+
let checkCount = 0;
|
|
43
|
+
/**
|
|
44
|
+
* Get the rate limit for a given trust level.
|
|
45
|
+
*/
|
|
46
|
+
function getLimitForTrust(trust) {
|
|
47
|
+
switch (trust) {
|
|
48
|
+
case 'trusted':
|
|
49
|
+
return config.trustedSenderLimit;
|
|
50
|
+
case 'known':
|
|
51
|
+
return config.knownSenderLimit;
|
|
52
|
+
case 'unknown':
|
|
53
|
+
return config.unknownSenderLimit;
|
|
54
|
+
case 'blocked':
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Prune timestamps older than the window from an array.
|
|
60
|
+
* Returns only timestamps within the current window.
|
|
61
|
+
*/
|
|
62
|
+
function pruneWindow(timestamps, now) {
|
|
63
|
+
const cutoff = now - config.windowMs;
|
|
64
|
+
return timestamps.filter((ts) => ts > cutoff);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Clean up expired entries from all windows.
|
|
68
|
+
*/
|
|
69
|
+
function cleanup(now) {
|
|
70
|
+
for (const [key, timestamps] of senderWindows) {
|
|
71
|
+
const pruned = pruneWindow(timestamps, now);
|
|
72
|
+
if (pruned.length === 0) {
|
|
73
|
+
senderWindows.delete(key);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
senderWindows.set(key, pruned);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const [key, timestamps] of recipientWindows) {
|
|
80
|
+
const pruned = pruneWindow(timestamps, now);
|
|
81
|
+
if (pruned.length === 0) {
|
|
82
|
+
recipientWindows.delete(key);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
recipientWindows.set(key, pruned);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Evict oldest sender entries when max capacity is exceeded.
|
|
91
|
+
* Prevents unbounded memory growth from traffic bursts that create
|
|
92
|
+
* many keys and then stop before the next cleanup cycle runs.
|
|
93
|
+
*
|
|
94
|
+
* TODO: This only evicts senderWindows, not recipientWindows. In practice
|
|
95
|
+
* recipientWindows is bounded by the number of distinct recipients (typically
|
|
96
|
+
* small), but a similar cap should be added if this assumption changes.
|
|
97
|
+
*/
|
|
98
|
+
function evictIfOverCapacity() {
|
|
99
|
+
const maxEntries = config.maxSenderEntries ?? 10_000;
|
|
100
|
+
if (senderWindows.size <= maxEntries) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Find entries with the oldest most-recent timestamp and evict them
|
|
104
|
+
const entries = Array.from(senderWindows.entries()).map(([key, timestamps]) => ({
|
|
105
|
+
key,
|
|
106
|
+
newestTs: timestamps.length > 0 ? timestamps[timestamps.length - 1] : 0,
|
|
107
|
+
}));
|
|
108
|
+
entries.sort((a, b) => a.newestTs - b.newestTs);
|
|
109
|
+
const toEvict = senderWindows.size - maxEntries;
|
|
110
|
+
for (let i = 0; i < toEvict; i++) {
|
|
111
|
+
senderWindows.delete(entries[i].key);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
check(sender, recipient, trust, channel) {
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
checkCount++;
|
|
118
|
+
// Periodic cleanup and eviction to prevent memory leaks
|
|
119
|
+
if (checkCount % CLEANUP_INTERVAL_CHECKS === 0) {
|
|
120
|
+
cleanup(now);
|
|
121
|
+
evictIfOverCapacity();
|
|
122
|
+
}
|
|
123
|
+
const limit = getLimitForTrust(trust);
|
|
124
|
+
// Blocked senders are always denied
|
|
125
|
+
if (trust === 'blocked') {
|
|
126
|
+
return {
|
|
127
|
+
allowed: false,
|
|
128
|
+
reason: 'sender is blocked (zero rate limit)',
|
|
129
|
+
remaining: 0,
|
|
130
|
+
limit: 0,
|
|
131
|
+
retryAfterMs: null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// Normalize sender/recipient for consistent tracking across format variants
|
|
135
|
+
const ch = channel ?? (sender.includes('@') ? 'email' : 'sms');
|
|
136
|
+
const senderKey = normalizeSender(sender, ch);
|
|
137
|
+
let senderTimestamps = senderWindows.get(senderKey) ?? [];
|
|
138
|
+
senderTimestamps = pruneWindow(senderTimestamps, now);
|
|
139
|
+
if (senderTimestamps.length >= limit) {
|
|
140
|
+
// Calculate retry-after from the oldest entry in window
|
|
141
|
+
const oldestTs = senderTimestamps[0];
|
|
142
|
+
const retryAfterMs = oldestTs + config.windowMs - now;
|
|
143
|
+
return {
|
|
144
|
+
allowed: false,
|
|
145
|
+
reason: `per-sender rate limit exceeded (${senderTimestamps.length}/${limit} in window)`,
|
|
146
|
+
remaining: 0,
|
|
147
|
+
limit,
|
|
148
|
+
retryAfterMs: Math.max(0, retryAfterMs),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// Check per-recipient global limit
|
|
152
|
+
const recipientCh = channel ?? (recipient.includes('@') ? 'email' : 'sms');
|
|
153
|
+
const recipientKey = normalizeSender(recipient, recipientCh);
|
|
154
|
+
let recipientTimestamps = recipientWindows.get(recipientKey) ?? [];
|
|
155
|
+
recipientTimestamps = pruneWindow(recipientTimestamps, now);
|
|
156
|
+
if (recipientTimestamps.length >= config.recipientGlobalLimit) {
|
|
157
|
+
const oldestTs = recipientTimestamps[0];
|
|
158
|
+
const retryAfterMs = oldestTs + config.windowMs - now;
|
|
159
|
+
return {
|
|
160
|
+
allowed: false,
|
|
161
|
+
reason: `global recipient rate limit exceeded (${recipientTimestamps.length}/${config.recipientGlobalLimit} in window)`,
|
|
162
|
+
remaining: 0,
|
|
163
|
+
limit: config.recipientGlobalLimit,
|
|
164
|
+
retryAfterMs: Math.max(0, retryAfterMs),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// Message is allowed — record it
|
|
168
|
+
senderTimestamps.push(now);
|
|
169
|
+
senderWindows.set(senderKey, senderTimestamps);
|
|
170
|
+
recipientTimestamps.push(now);
|
|
171
|
+
recipientWindows.set(recipientKey, recipientTimestamps);
|
|
172
|
+
return {
|
|
173
|
+
allowed: true,
|
|
174
|
+
reason: null,
|
|
175
|
+
remaining: limit - senderTimestamps.length,
|
|
176
|
+
limit,
|
|
177
|
+
retryAfterMs: null,
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
getStats() {
|
|
181
|
+
return {
|
|
182
|
+
activeSenders: senderWindows.size,
|
|
183
|
+
activeRecipients: recipientWindows.size,
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=rate-limiter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limiter.js","sourceRoot":"","sources":["../../src/utils/rate-limiter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,eAAe,EAAuB,MAAM,kBAAkB,CAAC;AA4DxE,yCAAyC;AACzC,MAAM,CAAC,MAAM,2BAA2B,GAAsB;IAC5D,kBAAkB,EAAE,GAAG;IACvB,gBAAgB,EAAE,EAAE;IACpB,kBAAkB,EAAE,CAAC;IACrB,oBAAoB,EAAE,GAAG;IACzB,QAAQ,EAAE,SAAS,EAAE,SAAS;IAC9B,gBAAgB,EAAE,MAAM;CACzB,CAAC;AAEF,sDAAsD;AACtD,MAAM,uBAAuB,GAAG,GAAG,CAAC;AAEpC;;;;;;;;GAQG;AACH,MAAM,UAAU,iBAAiB,CAAC,SAA4B,2BAA2B;IACvF,mDAAmD;IACnD,MAAM,aAAa,GAAG,IAAI,GAAG,EAAoB,CAAC;IAClD,sDAAsD;IACtD,MAAM,gBAAgB,GAAG,IAAI,GAAG,EAAoB,CAAC;IACrD,8CAA8C;IAC9C,IAAI,UAAU,GAAG,CAAC,CAAC;IAEnB;;OAEG;IACH,SAAS,gBAAgB,CAAC,KAAkB;QAC1C,QAAQ,KAAK,EAAE,CAAC;YACd,KAAK,SAAS;gBACZ,OAAO,MAAM,CAAC,kBAAkB,CAAC;YACnC,KAAK,OAAO;gBACV,OAAO,MAAM,CAAC,gBAAgB,CAAC;YACjC,KAAK,SAAS;gBACZ,OAAO,MAAM,CAAC,kBAAkB,CAAC;YACnC,KAAK,SAAS;gBACZ,OAAO,CAAC,CAAC;QACb,CAAC;IACH,CAAC;IAED;;;OAGG;IACH,SAAS,WAAW,CAAC,UAAoB,EAAE,GAAW;QACpD,MAAM,MAAM,GAAG,GAAG,GAAG,MAAM,CAAC,QAAQ,CAAC;QACrC,OAAO,UAAU,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,GAAG,MAAM,CAAC,CAAC;IAChD,CAAC;IAED;;OAEG;IACH,SAAS,OAAO,CAAC,GAAW;QAC1B,KAAK,MAAM,CAAC,GAAG,EAAE,UAAU,CAAC,IAAI,aAAa,EAAE,CAAC;YAC9C,MAAM,MAAM,GAAG,WAAW,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC;YAC5C,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBACxB,aAAa,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YAC5B,CAAC;iBAAM,CAAC;gBACN,aAAa,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;YACjC,CAAC;QACH,CAAC;QAED,KAAK,MAAM,CAAC,GAAG,EAAE,UAAU,CAAC,IAAI,gBAAgB,EAAE,CAAC;YACjD,MAAM,MAAM,GAAG,WAAW,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC;YAC5C,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBACxB,gBAAgB,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YAC/B,CAAC;iBAAM,CAAC;gBACN,gBAAgB,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;YACpC,CAAC;QACH,CAAC;IACH,CAAC;IAED;;;;;;;;OAQG;IACH,SAAS,mBAAmB;QAC1B,MAAM,UAAU,GAAG,MAAM,CAAC,gBAAgB,IAAI,MAAM,CAAC;QACrD,IAAI,aAAa,CAAC,IAAI,IAAI,UAAU,EAAE,CAAC;YACrC,OAAO;QACT,CAAC;QAED,oEAAoE;QACpE,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,aAAa,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,UAAU,CAAC,EAAE,EAAE,CAAC,CAAC;YAC9E,GAAG;YACH,QAAQ,EAAE,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;SACxE,CAAC,CAAC,CAAC;QACJ,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;QAEhD,MAAM,OAAO,GAAG,aAAa,CAAC,IAAI,GAAG,UAAU,CAAC;QAChD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,OAAO,EAAE,CAAC,EAAE,EAAE,CAAC;YACjC,aAAa,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;QACvC,CAAC;IACH,CAAC;IAED,OAAO;QACL,KAAK,CAAC,MAAc,EAAE,SAAiB,EAAE,KAAkB,EAAE,OAAwB;YACnF,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YACvB,UAAU,EAAE,CAAC;YAEb,wDAAwD;YACxD,IAAI,UAAU,GAAG,uBAAuB,KAAK,CAAC,EAAE,CAAC;gBAC/C,OAAO,CAAC,GAAG,CAAC,CAAC;gBACb,mBAAmB,EAAE,CAAC;YACxB,CAAC;YAED,MAAM,KAAK,GAAG,gBAAgB,CAAC,KAAK,CAAC,CAAC;YAEtC,oCAAoC;YACpC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACxB,OAAO;oBACL,OAAO,EAAE,KAAK;oBACd,MAAM,EAAE,qCAAqC;oBAC7C,SAAS,EAAE,CAAC;oBACZ,KAAK,EAAE,CAAC;oBACR,YAAY,EAAE,IAAI;iBACnB,CAAC;YACJ,CAAC;YAED,4EAA4E;YAC5E,MAAM,EAAE,GAAG,OAAO,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;YAC/D,MAAM,SAAS,GAAG,eAAe,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;YAC9C,IAAI,gBAAgB,GAAG,aAAa,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;YAC1D,gBAAgB,GAAG,WAAW,CAAC,gBAAgB,EAAE,GAAG,CAAC,CAAC;YAEtD,IAAI,gBAAgB,CAAC,MAAM,IAAI,KAAK,EAAE,CAAC;gBACrC,wDAAwD;gBACxD,MAAM,QAAQ,GAAG,gBAAgB,CAAC,CAAC,CAAC,CAAC;gBACrC,MAAM,YAAY,GAAG,QAAQ,GAAG,MAAM,CAAC,QAAQ,GAAG,GAAG,CAAC;gBAEtD,OAAO;oBACL,OAAO,EAAE,KAAK;oBACd,MAAM,EAAE,mCAAmC,gBAAgB,CAAC,MAAM,IAAI,KAAK,aAAa;oBACxF,SAAS,EAAE,CAAC;oBACZ,KAAK;oBACL,YAAY,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,YAAY,CAAC;iBACxC,CAAC;YACJ,CAAC;YAED,mCAAmC;YACnC,MAAM,WAAW,GAAG,OAAO,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;YAC3E,MAAM,YAAY,GAAG,eAAe,CAAC,SAAS,EAAE,WAAW,CAAC,CAAC;YAC7D,IAAI,mBAAmB,GAAG,gBAAgB,CAAC,GAAG,CAAC,YAAY,CAAC,IAAI,EAAE,CAAC;YACnE,mBAAmB,GAAG,WAAW,CAAC,mBAAmB,EAAE,GAAG,CAAC,CAAC;YAE5D,IAAI,mBAAmB,CAAC,MAAM,IAAI,MAAM,CAAC,oBAAoB,EAAE,CAAC;gBAC9D,MAAM,QAAQ,GAAG,mBAAmB,CAAC,CAAC,CAAC,CAAC;gBACxC,MAAM,YAAY,GAAG,QAAQ,GAAG,MAAM,CAAC,QAAQ,GAAG,GAAG,CAAC;gBAEtD,OAAO;oBACL,OAAO,EAAE,KAAK;oBACd,MAAM,EAAE,yCAAyC,mBAAmB,CAAC,MAAM,IAAI,MAAM,CAAC,oBAAoB,aAAa;oBACvH,SAAS,EAAE,CAAC;oBACZ,KAAK,EAAE,MAAM,CAAC,oBAAoB;oBAClC,YAAY,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,YAAY,CAAC;iBACxC,CAAC;YACJ,CAAC;YAED,iCAAiC;YACjC,gBAAgB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAC3B,aAAa,CAAC,GAAG,CAAC,SAAS,EAAE,gBAAgB,CAAC,CAAC;YAE/C,mBAAmB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAC9B,gBAAgB,CAAC,GAAG,CAAC,YAAY,EAAE,mBAAmB,CAAC,CAAC;YAExD,OAAO;gBACL,OAAO,EAAE,IAAI;gBACb,MAAM,EAAE,IAAI;gBACZ,SAAS,EAAE,KAAK,GAAG,gBAAgB,CAAC,MAAM;gBAC1C,KAAK;gBACL,YAAY,EAAE,IAAI;aACnB,CAAC;QACJ,CAAC;QAED,QAAQ;YACN,OAAO;gBACL,aAAa,EAAE,aAAa,CAAC,IAAI;gBACjC,gBAAgB,EAAE,gBAAgB,CAAC,IAAI;aACxC,CAAC;QACJ,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spam filtering utility for inbound message processing.
|
|
3
|
+
*
|
|
4
|
+
* Provides pre-processing gate that detects bulk/marketing email,
|
|
5
|
+
* SMS spam signals, and supports configurable allowlist/blocklist.
|
|
6
|
+
*
|
|
7
|
+
* LIMITATION: Header-based spam detection is inherently best-effort.
|
|
8
|
+
* Sophisticated spammers can omit or forge headers to bypass these checks.
|
|
9
|
+
* This filter catches the majority of bulk/marketing email and common SMS
|
|
10
|
+
* spam patterns, but should be complemented with content-based analysis
|
|
11
|
+
* and external reputation services for production use at scale.
|
|
12
|
+
*
|
|
13
|
+
* Part of Issue #1225 — rate limiting and spam protection.
|
|
14
|
+
*/
|
|
15
|
+
/** Supported inbound message channels */
|
|
16
|
+
export type MessageChannel = 'email' | 'sms';
|
|
17
|
+
/** Inbound message to evaluate for spam */
|
|
18
|
+
export interface InboundMessage {
|
|
19
|
+
/** Message channel (email or sms) */
|
|
20
|
+
channel: MessageChannel;
|
|
21
|
+
/** Sender identifier (email address or phone number) */
|
|
22
|
+
sender: string;
|
|
23
|
+
/** Recipient identifier */
|
|
24
|
+
recipient: string;
|
|
25
|
+
/** Message body text */
|
|
26
|
+
body: string;
|
|
27
|
+
/** Email headers (only present for email channel) */
|
|
28
|
+
headers?: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
/** Result of spam evaluation */
|
|
31
|
+
export interface SpamFilterResult {
|
|
32
|
+
/** Whether the message is classified as spam */
|
|
33
|
+
isSpam: boolean;
|
|
34
|
+
/** Human-readable reason for the classification, null if not spam */
|
|
35
|
+
reason: string | null;
|
|
36
|
+
/** The channel of the evaluated message */
|
|
37
|
+
channel: MessageChannel;
|
|
38
|
+
/** The sender of the evaluated message */
|
|
39
|
+
sender: string;
|
|
40
|
+
}
|
|
41
|
+
/** Configurable spam filter settings */
|
|
42
|
+
export interface SpamFilterConfig {
|
|
43
|
+
/** Senders that are always allowed (bypass all checks) */
|
|
44
|
+
allowlist: string[];
|
|
45
|
+
/** Senders that are always blocked */
|
|
46
|
+
blocklist: string[];
|
|
47
|
+
/** Threshold for X-Spam-Score header (emails scoring above this are spam) */
|
|
48
|
+
spamScoreThreshold: number;
|
|
49
|
+
/** Patterns in X-Mailer header that indicate bulk mailers */
|
|
50
|
+
bulkMailerPatterns: string[];
|
|
51
|
+
/** Patterns in SMS body that indicate spam */
|
|
52
|
+
smsSpamPatterns: string[];
|
|
53
|
+
}
|
|
54
|
+
/** Default spam filter configuration */
|
|
55
|
+
export declare const DEFAULT_SPAM_FILTER_CONFIG: SpamFilterConfig;
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a sender identifier for consistent comparison.
|
|
58
|
+
*
|
|
59
|
+
* - Email: lowercased, with `+` alias portion stripped (e.g. user+tag@gmail.com -> user@gmail.com)
|
|
60
|
+
* - Phone: non-digit characters stripped, leading country code '1' normalized to '+1'
|
|
61
|
+
*
|
|
62
|
+
* This prevents bypass via formatting variants like `+1...` vs `1...`
|
|
63
|
+
* or `user+spam@gmail.com` vs `user@gmail.com`.
|
|
64
|
+
*/
|
|
65
|
+
export declare function normalizeSender(sender: string, channel: MessageChannel): string;
|
|
66
|
+
/**
|
|
67
|
+
* Evaluate whether an inbound message is spam.
|
|
68
|
+
*
|
|
69
|
+
* Checks are applied in this order:
|
|
70
|
+
* 1. Allowlist (always passes)
|
|
71
|
+
* 2. Blocklist (always fails)
|
|
72
|
+
* 3. Channel-specific spam checks (email headers, SMS signals)
|
|
73
|
+
*
|
|
74
|
+
* @param message - The inbound message to evaluate
|
|
75
|
+
* @param config - Optional spam filter configuration (uses defaults if omitted)
|
|
76
|
+
* @returns SpamFilterResult with classification and reason
|
|
77
|
+
*/
|
|
78
|
+
export declare function isSpam(message: InboundMessage, config?: SpamFilterConfig): SpamFilterResult;
|
|
79
|
+
//# sourceMappingURL=spam-filter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spam-filter.d.ts","sourceRoot":"","sources":["../../src/utils/spam-filter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,yCAAyC;AACzC,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,KAAK,CAAC;AAE7C,2CAA2C;AAC3C,MAAM,WAAW,cAAc;IAC7B,qCAAqC;IACrC,OAAO,EAAE,cAAc,CAAC;IACxB,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAC;IACf,2BAA2B;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,wBAAwB;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,gCAAgC;AAChC,MAAM,WAAW,gBAAgB;IAC/B,gDAAgD;IAChD,MAAM,EAAE,OAAO,CAAC;IAChB,qEAAqE;IACrE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,2CAA2C;IAC3C,OAAO,EAAE,cAAc,CAAC;IACxB,0CAA0C;IAC1C,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wCAAwC;AACxC,MAAM,WAAW,gBAAgB;IAC/B,0DAA0D;IAC1D,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,sCAAsC;IACtC,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,6EAA6E;IAC7E,kBAAkB,EAAE,MAAM,CAAC;IAC3B,6DAA6D;IAC7D,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B,8CAA8C;IAC9C,eAAe,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,wCAAwC;AACxC,eAAO,MAAM,0BAA0B,EAAE,gBA4BxC,CAAC;AAWF;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,MAAM,CAK/E;AAwDD;;;;;;;;;;;GAWG;AACH,wBAAgB,MAAM,CAAC,OAAO,EAAE,cAAc,EAAE,MAAM,GAAE,gBAA6C,GAAG,gBAAgB,CAiCvH"}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spam filtering utility for inbound message processing.
|
|
3
|
+
*
|
|
4
|
+
* Provides pre-processing gate that detects bulk/marketing email,
|
|
5
|
+
* SMS spam signals, and supports configurable allowlist/blocklist.
|
|
6
|
+
*
|
|
7
|
+
* LIMITATION: Header-based spam detection is inherently best-effort.
|
|
8
|
+
* Sophisticated spammers can omit or forge headers to bypass these checks.
|
|
9
|
+
* This filter catches the majority of bulk/marketing email and common SMS
|
|
10
|
+
* spam patterns, but should be complemented with content-based analysis
|
|
11
|
+
* and external reputation services for production use at scale.
|
|
12
|
+
*
|
|
13
|
+
* Part of Issue #1225 — rate limiting and spam protection.
|
|
14
|
+
*/
|
|
15
|
+
/** Default spam filter configuration */
|
|
16
|
+
export const DEFAULT_SPAM_FILTER_CONFIG = {
|
|
17
|
+
allowlist: [],
|
|
18
|
+
blocklist: [],
|
|
19
|
+
spamScoreThreshold: 5.0,
|
|
20
|
+
bulkMailerPatterns: [
|
|
21
|
+
'mailchimp',
|
|
22
|
+
'sendgrid',
|
|
23
|
+
'constantcontact',
|
|
24
|
+
'mailgun',
|
|
25
|
+
'campaign monitor',
|
|
26
|
+
'hubspot',
|
|
27
|
+
'marketo',
|
|
28
|
+
'pardot',
|
|
29
|
+
'sendinblue',
|
|
30
|
+
'brevo',
|
|
31
|
+
],
|
|
32
|
+
smsSpamPatterns: [
|
|
33
|
+
'you have won',
|
|
34
|
+
'you have been selected',
|
|
35
|
+
'click here',
|
|
36
|
+
'free gift',
|
|
37
|
+
'act now',
|
|
38
|
+
'limited time',
|
|
39
|
+
'congratulations',
|
|
40
|
+
'claim your',
|
|
41
|
+
'verify now',
|
|
42
|
+
'your account has been',
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
/** Email header values indicating bulk/list mail */
|
|
46
|
+
const BULK_PRECEDENCE_VALUES = new Set(['bulk', 'list', 'junk']);
|
|
47
|
+
/** SMS opt-out keywords that indicate marketing messages */
|
|
48
|
+
const SMS_OPT_OUT_KEYWORDS = ['stop', 'unsubscribe', 'opt-out', 'opt out', 'cancel', 'quit', 'end'];
|
|
49
|
+
/** Maximum length for a short code sender (SMS) */
|
|
50
|
+
const SHORT_CODE_MAX_LENGTH = 6;
|
|
51
|
+
/**
|
|
52
|
+
* Normalize a sender identifier for consistent comparison.
|
|
53
|
+
*
|
|
54
|
+
* - Email: lowercased, with `+` alias portion stripped (e.g. user+tag@gmail.com -> user@gmail.com)
|
|
55
|
+
* - Phone: non-digit characters stripped, leading country code '1' normalized to '+1'
|
|
56
|
+
*
|
|
57
|
+
* This prevents bypass via formatting variants like `+1...` vs `1...`
|
|
58
|
+
* or `user+spam@gmail.com` vs `user@gmail.com`.
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeSender(sender, channel) {
|
|
61
|
+
if (channel === 'email') {
|
|
62
|
+
return normalizeEmail(sender);
|
|
63
|
+
}
|
|
64
|
+
return normalizePhone(sender);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Normalize an email address for consistent comparison.
|
|
68
|
+
* Lowercases and strips `+` alias tags (e.g. user+tag@example.com -> user@example.com).
|
|
69
|
+
*/
|
|
70
|
+
function normalizeEmail(email) {
|
|
71
|
+
const lower = email.toLowerCase().trim();
|
|
72
|
+
const atIndex = lower.indexOf('@');
|
|
73
|
+
if (atIndex === -1) {
|
|
74
|
+
return lower;
|
|
75
|
+
}
|
|
76
|
+
const localPart = lower.slice(0, atIndex);
|
|
77
|
+
const domain = lower.slice(atIndex);
|
|
78
|
+
// Strip + alias
|
|
79
|
+
const plusIndex = localPart.indexOf('+');
|
|
80
|
+
if (plusIndex !== -1) {
|
|
81
|
+
return localPart.slice(0, plusIndex) + domain;
|
|
82
|
+
}
|
|
83
|
+
return lower;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Normalize a phone number for consistent comparison.
|
|
87
|
+
* Strips non-digit characters and normalizes country code.
|
|
88
|
+
*
|
|
89
|
+
* TODO: Non-US phone normalization is best-effort. Numbers like `442079460958`
|
|
90
|
+
* (without +) and `+442079460958` (with +) will normalize differently because
|
|
91
|
+
* we can only reliably detect US/NANP 10/11-digit patterns. A full solution
|
|
92
|
+
* would require a phone number parsing library (e.g. libphonenumber) to handle
|
|
93
|
+
* international formats consistently.
|
|
94
|
+
*/
|
|
95
|
+
function normalizePhone(phone) {
|
|
96
|
+
// Strip everything except digits and leading +
|
|
97
|
+
const hasPlus = phone.startsWith('+');
|
|
98
|
+
const digits = phone.replace(/[^0-9]/g, '');
|
|
99
|
+
if (digits.length === 0) {
|
|
100
|
+
return phone.toLowerCase();
|
|
101
|
+
}
|
|
102
|
+
// Normalize US numbers: 10 digits -> +1XXXXXXXXXX, 11 digits starting with 1 -> +1XXXXXXXXXX
|
|
103
|
+
if (digits.length === 10) {
|
|
104
|
+
return `+1${digits}`;
|
|
105
|
+
}
|
|
106
|
+
if (digits.length === 11 && digits.startsWith('1')) {
|
|
107
|
+
return `+${digits}`;
|
|
108
|
+
}
|
|
109
|
+
// For other formats, preserve the + prefix if it was present
|
|
110
|
+
return hasPlus ? `+${digits}` : digits;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Evaluate whether an inbound message is spam.
|
|
114
|
+
*
|
|
115
|
+
* Checks are applied in this order:
|
|
116
|
+
* 1. Allowlist (always passes)
|
|
117
|
+
* 2. Blocklist (always fails)
|
|
118
|
+
* 3. Channel-specific spam checks (email headers, SMS signals)
|
|
119
|
+
*
|
|
120
|
+
* @param message - The inbound message to evaluate
|
|
121
|
+
* @param config - Optional spam filter configuration (uses defaults if omitted)
|
|
122
|
+
* @returns SpamFilterResult with classification and reason
|
|
123
|
+
*/
|
|
124
|
+
export function isSpam(message, config = DEFAULT_SPAM_FILTER_CONFIG) {
|
|
125
|
+
const normalizedSender = normalizeSender(message.sender, message.channel);
|
|
126
|
+
const result = {
|
|
127
|
+
isSpam: false,
|
|
128
|
+
reason: null,
|
|
129
|
+
channel: message.channel,
|
|
130
|
+
sender: message.sender,
|
|
131
|
+
};
|
|
132
|
+
// Check allowlist first (always passes) — normalize both sides for consistent matching
|
|
133
|
+
if (config.allowlist.some((allowed) => normalizeSender(allowed, message.channel) === normalizedSender)) {
|
|
134
|
+
result.reason = 'allowlisted sender';
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
// Check blocklist (always fails) — normalize both sides for consistent matching
|
|
138
|
+
if (config.blocklist.some((blocked) => normalizeSender(blocked, message.channel) === normalizedSender)) {
|
|
139
|
+
result.isSpam = true;
|
|
140
|
+
result.reason = 'blocklisted sender';
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
// Channel-specific checks
|
|
144
|
+
if (message.channel === 'email') {
|
|
145
|
+
return checkEmailSpam(message, config, result);
|
|
146
|
+
}
|
|
147
|
+
if (message.channel === 'sms') {
|
|
148
|
+
return checkSmsSpam(message, config, result);
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Check email-specific spam signals.
|
|
154
|
+
*/
|
|
155
|
+
function checkEmailSpam(message, config, result) {
|
|
156
|
+
const headers = normalizeHeaders(message.headers);
|
|
157
|
+
// Check Precedence header for bulk/list
|
|
158
|
+
const precedence = headers.precedence;
|
|
159
|
+
if (precedence && BULK_PRECEDENCE_VALUES.has(precedence.toLowerCase())) {
|
|
160
|
+
result.isSpam = true;
|
|
161
|
+
result.reason = 'bulk precedence header detected';
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
// Check List-Unsubscribe header
|
|
165
|
+
if (headers['list-unsubscribe']) {
|
|
166
|
+
result.isSpam = true;
|
|
167
|
+
result.reason = 'list-unsubscribe header detected (mailing list)';
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
// Check X-Spam-Score
|
|
171
|
+
const spamScore = headers['x-spam-score'];
|
|
172
|
+
if (spamScore) {
|
|
173
|
+
const score = Number.parseFloat(spamScore);
|
|
174
|
+
if (!Number.isNaN(score) && score >= config.spamScoreThreshold) {
|
|
175
|
+
result.isSpam = true;
|
|
176
|
+
result.reason = `high spam score (${score} >= ${config.spamScoreThreshold})`;
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Check X-Mailer for known bulk mailers
|
|
181
|
+
const xMailer = headers['x-mailer'];
|
|
182
|
+
if (xMailer) {
|
|
183
|
+
const mailerLower = xMailer.toLowerCase();
|
|
184
|
+
for (const pattern of config.bulkMailerPatterns) {
|
|
185
|
+
if (mailerLower.includes(pattern.toLowerCase())) {
|
|
186
|
+
result.isSpam = true;
|
|
187
|
+
result.reason = `bulk mailer detected: ${pattern}`;
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Check SMS-specific spam signals.
|
|
196
|
+
*/
|
|
197
|
+
function checkSmsSpam(message, config, result) {
|
|
198
|
+
// Check for short code sender
|
|
199
|
+
const senderDigits = message.sender.replace(/[^0-9]/g, '');
|
|
200
|
+
if (senderDigits.length > 0 && senderDigits.length <= SHORT_CODE_MAX_LENGTH) {
|
|
201
|
+
result.isSpam = true;
|
|
202
|
+
result.reason = 'short code sender detected';
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
const bodyLower = message.body.toLowerCase();
|
|
206
|
+
// Check for opt-out keywords (indicates marketing/automated message)
|
|
207
|
+
for (const keyword of SMS_OPT_OUT_KEYWORDS) {
|
|
208
|
+
if (bodyLower.includes(keyword)) {
|
|
209
|
+
result.isSpam = true;
|
|
210
|
+
result.reason = `opt-out keyword detected: ${keyword}`;
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Check for common SMS spam patterns
|
|
215
|
+
for (const pattern of config.smsSpamPatterns) {
|
|
216
|
+
if (bodyLower.includes(pattern.toLowerCase())) {
|
|
217
|
+
result.isSpam = true;
|
|
218
|
+
result.reason = `spam pattern detected: ${pattern}`;
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Normalize email headers to lowercase keys for consistent lookup.
|
|
226
|
+
*/
|
|
227
|
+
function normalizeHeaders(headers) {
|
|
228
|
+
if (!headers) {
|
|
229
|
+
return {};
|
|
230
|
+
}
|
|
231
|
+
const normalized = {};
|
|
232
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
233
|
+
normalized[key.toLowerCase()] = value;
|
|
234
|
+
}
|
|
235
|
+
return normalized;
|
|
236
|
+
}
|
|
237
|
+
//# sourceMappingURL=spam-filter.js.map
|