@bernierllc/email-mitm-masking 1.0.0

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.
@@ -0,0 +1,425 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ import { Pool } from 'pg';
10
+ import type {
11
+ EmailMaskingConfig,
12
+ ProxyAddress,
13
+ RoutingResult,
14
+ RoutingAudit,
15
+ CreateProxyOptions,
16
+ ProxyAddressRow
17
+ } from './types.js';
18
+ import { initializeDatabase } from './database.js';
19
+
20
+ /**
21
+ * Email masking and man-in-the-middle routing service
22
+ */
23
+ export class EmailMaskingService {
24
+ private config: EmailMaskingConfig;
25
+ private db: Pool;
26
+ private cleanupInterval?: NodeJS.Timeout;
27
+
28
+ constructor(config: EmailMaskingConfig) {
29
+ this.config = config;
30
+ this.db = new Pool({
31
+ host: config.database.host,
32
+ port: config.database.port,
33
+ database: config.database.database,
34
+ user: config.database.user,
35
+ password: config.database.password,
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Initialize the masking service
41
+ */
42
+ async initialize(): Promise<void> {
43
+ // Initialize database schema
44
+ await initializeDatabase(this.db);
45
+
46
+ // Start cleanup job for expired proxies
47
+ this.startCleanupJob();
48
+ }
49
+
50
+ /**
51
+ * Cleanup resources
52
+ */
53
+ async shutdown(): Promise<void> {
54
+ if (this.cleanupInterval) {
55
+ clearInterval(this.cleanupInterval);
56
+ }
57
+ await this.db.end();
58
+ }
59
+
60
+ /**
61
+ * Create a new proxy email address
62
+ */
63
+ async createProxy(
64
+ userId: string,
65
+ realEmail: string,
66
+ options?: CreateProxyOptions
67
+ ): Promise<ProxyAddress> {
68
+ // Validate user's proxy quota
69
+ const userProxies = await this.getUserProxyCount(userId);
70
+ const maxProxies = this.config.maxProxiesPerUser || 0;
71
+
72
+ if (maxProxies > 0 && userProxies >= maxProxies) {
73
+ throw new Error(`User ${userId} has reached maximum proxy limit (${maxProxies})`);
74
+ }
75
+
76
+ // Generate secure proxy token
77
+ const proxyToken = this.generateProxyToken();
78
+ const proxyEmail = `${userId}-${proxyToken}@${this.config.proxyDomain}`;
79
+
80
+ // Calculate expiration
81
+ const ttl = options?.ttlDays !== undefined ? options.ttlDays : (this.config.defaultTTL || 0);
82
+ const expiresAt = ttl !== 0
83
+ ? new Date(Date.now() + ttl * 24 * 60 * 60 * 1000)
84
+ : null;
85
+
86
+ // Insert proxy record
87
+ const result = await this.db.query<ProxyAddressRow>(
88
+ `INSERT INTO proxy_addresses
89
+ (proxy_email, real_email, user_id, external_email, conversation_id, status, expires_at, metadata)
90
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
91
+ RETURNING *`,
92
+ [
93
+ proxyEmail,
94
+ realEmail,
95
+ userId,
96
+ options?.externalEmail || null,
97
+ options?.conversationId || null,
98
+ 'active',
99
+ expiresAt,
100
+ JSON.stringify(options?.metadata || {})
101
+ ]
102
+ );
103
+
104
+ return this.mapProxyRow(result.rows[0]);
105
+ }
106
+
107
+ /**
108
+ * Route inbound email (external → real address)
109
+ */
110
+ async routeInbound(
111
+ from: string,
112
+ to: string,
113
+ _subject: string,
114
+ _text?: string,
115
+ _html?: string
116
+ ): Promise<RoutingResult> {
117
+ const toAddress = this.extractProxyAddress(to);
118
+
119
+ if (!toAddress) {
120
+ return {
121
+ success: false,
122
+ direction: 'inbound',
123
+ error: 'No proxy address found in To field'
124
+ };
125
+ }
126
+
127
+ // Look up proxy
128
+ const proxy = await this.getProxyByEmail(toAddress);
129
+
130
+ if (!proxy) {
131
+ return {
132
+ success: false,
133
+ direction: 'inbound',
134
+ error: `Proxy address not found: ${toAddress}`
135
+ };
136
+ }
137
+
138
+ // Validate proxy status
139
+ if (proxy.status !== 'active') {
140
+ return {
141
+ success: false,
142
+ direction: 'inbound',
143
+ error: `Proxy is ${proxy.status}: ${toAddress}`
144
+ };
145
+ }
146
+
147
+ // Check expiration
148
+ if (proxy.expiresAt && proxy.expiresAt < new Date()) {
149
+ await this.expireProxy(proxy.id);
150
+ return {
151
+ success: false,
152
+ direction: 'inbound',
153
+ error: `Proxy expired: ${toAddress}`
154
+ };
155
+ }
156
+
157
+ // Update proxy usage
158
+ await this.updateProxyUsage(proxy.id);
159
+
160
+ // Audit the routing
161
+ const auditId = await this.auditRouting({
162
+ proxyId: proxy.id,
163
+ direction: 'inbound',
164
+ fromAddress: from,
165
+ toAddress: proxy.realEmail,
166
+ success: true
167
+ });
168
+
169
+ return {
170
+ success: true,
171
+ routedTo: proxy.realEmail,
172
+ proxyUsed: toAddress,
173
+ direction: 'inbound',
174
+ auditId
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Route outbound email (real address → external via proxy)
180
+ */
181
+ async routeOutbound(
182
+ userId: string,
183
+ realEmail: string,
184
+ externalEmail: string,
185
+ _emailContent: { subject: string; text?: string; html?: string }
186
+ ): Promise<RoutingResult> {
187
+ // Find or create proxy for this user-external pair
188
+ let proxy = await this.getProxyForContact(userId, realEmail, externalEmail);
189
+
190
+ if (!proxy) {
191
+ // Auto-create proxy for outbound routing
192
+ proxy = await this.createProxy(userId, realEmail, {
193
+ externalEmail,
194
+ metadata: { autoCreated: true, direction: 'outbound' }
195
+ });
196
+ }
197
+
198
+ // Validate proxy status
199
+ if (proxy.status !== 'active') {
200
+ return {
201
+ success: false,
202
+ direction: 'outbound',
203
+ error: `Proxy is ${proxy.status}`
204
+ };
205
+ }
206
+
207
+ // Update proxy usage
208
+ await this.updateProxyUsage(proxy.id);
209
+
210
+ // Audit the routing
211
+ const auditId = await this.auditRouting({
212
+ proxyId: proxy.id,
213
+ direction: 'outbound',
214
+ fromAddress: realEmail,
215
+ toAddress: externalEmail,
216
+ success: true
217
+ });
218
+
219
+ return {
220
+ success: true,
221
+ routedTo: externalEmail,
222
+ proxyUsed: proxy.proxyEmail,
223
+ direction: 'outbound',
224
+ auditId
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Deactivate a proxy address
230
+ */
231
+ async deactivateProxy(proxyId: string): Promise<void> {
232
+ await this.db.query(
233
+ 'UPDATE proxy_addresses SET status = $1 WHERE id = $2',
234
+ ['inactive', proxyId]
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Revoke a proxy address permanently
240
+ */
241
+ async revokeProxy(proxyId: string): Promise<void> {
242
+ await this.db.query(
243
+ 'UPDATE proxy_addresses SET status = $1 WHERE id = $2',
244
+ ['revoked', proxyId]
245
+ );
246
+ }
247
+
248
+ /**
249
+ * Get proxy by ID
250
+ */
251
+ async getProxyById(proxyId: string): Promise<ProxyAddress | null> {
252
+ const result = await this.db.query<ProxyAddressRow>(
253
+ 'SELECT * FROM proxy_addresses WHERE id = $1',
254
+ [proxyId]
255
+ );
256
+
257
+ return result.rows.length > 0 ? this.mapProxyRow(result.rows[0]) : null;
258
+ }
259
+
260
+ /**
261
+ * Get proxy by email address
262
+ */
263
+ private async getProxyByEmail(proxyEmail: string): Promise<ProxyAddress | null> {
264
+ const result = await this.db.query<ProxyAddressRow>(
265
+ 'SELECT * FROM proxy_addresses WHERE proxy_email = $1',
266
+ [proxyEmail]
267
+ );
268
+
269
+ return result.rows.length > 0 ? this.mapProxyRow(result.rows[0]) : null;
270
+ }
271
+
272
+ /**
273
+ * Get proxy for specific user-contact pair
274
+ */
275
+ private async getProxyForContact(
276
+ userId: string,
277
+ realEmail: string,
278
+ externalEmail: string
279
+ ): Promise<ProxyAddress | null> {
280
+ const result = await this.db.query<ProxyAddressRow>(
281
+ `SELECT * FROM proxy_addresses
282
+ WHERE user_id = $1
283
+ AND real_email = $2
284
+ AND external_email = $3
285
+ ORDER BY created_at DESC
286
+ LIMIT 1`,
287
+ [userId, realEmail, externalEmail]
288
+ );
289
+
290
+ return result.rows.length > 0 ? this.mapProxyRow(result.rows[0]) : null;
291
+ }
292
+
293
+ /**
294
+ * Update proxy usage statistics
295
+ */
296
+ private async updateProxyUsage(proxyId: string): Promise<void> {
297
+ await this.db.query(
298
+ `UPDATE proxy_addresses
299
+ SET routing_count = routing_count + 1, last_used_at = NOW()
300
+ WHERE id = $1`,
301
+ [proxyId]
302
+ );
303
+ }
304
+
305
+ /**
306
+ * Audit a routing decision
307
+ */
308
+ private async auditRouting(
309
+ audit: Omit<RoutingAudit, 'id' | 'routedAt'>
310
+ ): Promise<string> {
311
+ if (!this.config.auditEnabled) {
312
+ return '';
313
+ }
314
+
315
+ const result = await this.db.query<{ id: string }>(
316
+ `INSERT INTO routing_audit
317
+ (proxy_id, direction, from_address, to_address, success, error, metadata)
318
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
319
+ RETURNING id`,
320
+ [
321
+ audit.proxyId,
322
+ audit.direction,
323
+ audit.fromAddress,
324
+ audit.toAddress,
325
+ audit.success,
326
+ audit.error || null,
327
+ JSON.stringify(audit.metadata || {})
328
+ ]
329
+ );
330
+
331
+ return result.rows[0].id;
332
+ }
333
+
334
+ /**
335
+ * Map database row to ProxyAddress
336
+ */
337
+ private mapProxyRow(row: ProxyAddressRow): ProxyAddress {
338
+ return {
339
+ id: row.id,
340
+ proxyEmail: row.proxy_email,
341
+ realEmail: row.real_email,
342
+ userId: row.user_id,
343
+ externalEmail: row.external_email || undefined,
344
+ conversationId: row.conversation_id || undefined,
345
+ status: row.status as 'active' | 'inactive' | 'expired' | 'revoked',
346
+ createdAt: new Date(row.created_at),
347
+ activatedAt: row.activated_at ? new Date(row.activated_at) : undefined,
348
+ expiresAt: row.expires_at ? new Date(row.expires_at) : undefined,
349
+ lastUsedAt: row.last_used_at ? new Date(row.last_used_at) : undefined,
350
+ routingCount: parseInt(row.routing_count) || 0,
351
+ metadata: row.metadata ? JSON.parse(row.metadata) as Record<string, unknown> : {}
352
+ };
353
+ }
354
+
355
+ /**
356
+ * Get user's proxy count
357
+ */
358
+ private async getUserProxyCount(userId: string): Promise<number> {
359
+ const result = await this.db.query<{ count: string }>(
360
+ `SELECT COUNT(*) as count FROM proxy_addresses
361
+ WHERE user_id = $1 AND status = 'active'`,
362
+ [userId]
363
+ );
364
+
365
+ return parseInt(result.rows[0].count) || 0;
366
+ }
367
+
368
+ /**
369
+ * Expire a proxy
370
+ */
371
+ private async expireProxy(proxyId: string): Promise<void> {
372
+ await this.db.query(
373
+ 'UPDATE proxy_addresses SET status = $1 WHERE id = $2',
374
+ ['expired', proxyId]
375
+ );
376
+ }
377
+
378
+ /**
379
+ * Start cleanup job for expired proxies
380
+ */
381
+ private startCleanupJob(): void {
382
+ // Run every hour
383
+ this.cleanupInterval = setInterval(() => {
384
+ void this.cleanupExpiredProxies();
385
+ }, 60 * 60 * 1000); // 1 hour
386
+ }
387
+
388
+ /**
389
+ * Cleanup expired proxies
390
+ */
391
+ private async cleanupExpiredProxies(): Promise<void> {
392
+ await this.db.query(
393
+ `UPDATE proxy_addresses
394
+ SET status = 'expired'
395
+ WHERE expires_at < NOW() AND status = 'active'`
396
+ );
397
+ }
398
+
399
+ /**
400
+ * Extract proxy address from To field
401
+ */
402
+ private extractProxyAddress(to: string): string | null {
403
+ const proxyDomain = this.config.proxyDomain;
404
+
405
+ if (to.includes(`@${proxyDomain}`)) {
406
+ // Extract email from "Name <email>" format
407
+ const match = to.match(/<(.+?)>/) || [null, to];
408
+ return match[1];
409
+ }
410
+
411
+ return null;
412
+ }
413
+
414
+ /**
415
+ * Generate secure proxy token
416
+ */
417
+ private generateProxyToken(): string {
418
+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
419
+ let token = '';
420
+ for (let i = 0; i < 16; i++) {
421
+ token += chars.charAt(Math.floor(Math.random() * chars.length));
422
+ }
423
+ return token;
424
+ }
425
+ }
@@ -0,0 +1,69 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ import { Pool } from 'pg';
10
+
11
+ /**
12
+ * Initialize database schema for email masking
13
+ */
14
+ export async function initializeDatabase(pool: Pool): Promise<void> {
15
+ await pool.query(`
16
+ CREATE TABLE IF NOT EXISTS proxy_addresses (
17
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
18
+ proxy_email TEXT UNIQUE NOT NULL,
19
+ real_email TEXT NOT NULL,
20
+ user_id TEXT NOT NULL,
21
+ external_email TEXT,
22
+ conversation_id TEXT,
23
+ status TEXT NOT NULL DEFAULT 'active',
24
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
25
+ activated_at TIMESTAMP,
26
+ expires_at TIMESTAMP,
27
+ last_used_at TIMESTAMP,
28
+ routing_count INTEGER NOT NULL DEFAULT 0,
29
+ metadata JSONB
30
+ );
31
+
32
+ CREATE INDEX IF NOT EXISTS idx_proxy_email ON proxy_addresses(proxy_email);
33
+ CREATE INDEX IF NOT EXISTS idx_user_id ON proxy_addresses(user_id);
34
+ CREATE INDEX IF NOT EXISTS idx_real_email ON proxy_addresses(real_email);
35
+ CREATE INDEX IF NOT EXISTS idx_status ON proxy_addresses(status);
36
+ CREATE INDEX IF NOT EXISTS idx_expires_at ON proxy_addresses(expires_at) WHERE status = 'active';
37
+
38
+ CREATE TABLE IF NOT EXISTS routing_audit (
39
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
40
+ proxy_id UUID NOT NULL,
41
+ direction TEXT NOT NULL,
42
+ from_address TEXT NOT NULL,
43
+ to_address TEXT NOT NULL,
44
+ routed_at TIMESTAMP NOT NULL DEFAULT NOW(),
45
+ success BOOLEAN NOT NULL,
46
+ error TEXT,
47
+ metadata JSONB
48
+ );
49
+
50
+ CREATE INDEX IF NOT EXISTS idx_audit_proxy_id ON routing_audit(proxy_id);
51
+ CREATE INDEX IF NOT EXISTS idx_routed_at ON routing_audit(routed_at);
52
+ CREATE INDEX IF NOT EXISTS idx_direction ON routing_audit(direction);
53
+
54
+ CREATE TABLE IF NOT EXISTS masking_rules (
55
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
56
+ user_id TEXT NOT NULL,
57
+ type TEXT NOT NULL,
58
+ criteria TEXT NOT NULL,
59
+ action TEXT NOT NULL,
60
+ priority INTEGER NOT NULL DEFAULT 0,
61
+ enabled BOOLEAN NOT NULL DEFAULT true,
62
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
63
+ );
64
+
65
+ CREATE INDEX IF NOT EXISTS idx_rules_user_id ON masking_rules(user_id);
66
+ CREATE INDEX IF NOT EXISTS idx_priority ON masking_rules(priority);
67
+ CREATE INDEX IF NOT EXISTS idx_enabled ON masking_rules(enabled);
68
+ `);
69
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ export { EmailMaskingService } from './EmailMaskingService.js';
10
+ export type {
11
+ EmailMaskingConfig,
12
+ DatabaseConfig,
13
+ ProxyAddress,
14
+ MaskingRule,
15
+ RoutingResult,
16
+ RoutingAudit,
17
+ MaskingResult,
18
+ CreateProxyOptions,
19
+ EmailContent
20
+ } from './types.js';
package/src/types.ts ADDED
@@ -0,0 +1,200 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ /**
10
+ * Configuration for email masking service
11
+ */
12
+ export interface EmailMaskingConfig {
13
+ /** Proxy domain for masked addresses (e.g., proxy.example.com) */
14
+ proxyDomain: string;
15
+
16
+ /** Database connection for proxy storage */
17
+ database: DatabaseConfig;
18
+
19
+ /** Default TTL for proxy addresses (in days, 0 = unlimited) */
20
+ defaultTTL?: number;
21
+
22
+ /** Maximum proxies per user (0 = unlimited) */
23
+ maxProxiesPerUser?: number;
24
+
25
+ /** Enable audit logging */
26
+ auditEnabled?: boolean;
27
+
28
+ /** Masking strategy */
29
+ strategy?: 'per-user' | 'per-contact' | 'per-conversation';
30
+ }
31
+
32
+ /**
33
+ * Database configuration
34
+ */
35
+ export interface DatabaseConfig {
36
+ host: string;
37
+ port: number;
38
+ database: string;
39
+ user: string;
40
+ password: string;
41
+ }
42
+
43
+ /**
44
+ * Proxy email address record
45
+ */
46
+ export interface ProxyAddress {
47
+ /** Unique proxy identifier */
48
+ id: string;
49
+
50
+ /** Proxy email address (e.g., user-abc123@proxy.domain.com) */
51
+ proxyEmail: string;
52
+
53
+ /** Real user email address */
54
+ realEmail: string;
55
+
56
+ /** User identifier */
57
+ userId: string;
58
+
59
+ /** Optional external contact email (for per-contact masking) */
60
+ externalEmail?: string;
61
+
62
+ /** Optional conversation identifier (for per-conversation masking) */
63
+ conversationId?: string;
64
+
65
+ /** Proxy status */
66
+ status: 'active' | 'inactive' | 'expired' | 'revoked';
67
+
68
+ /** Creation timestamp */
69
+ createdAt: Date;
70
+
71
+ /** Activation timestamp */
72
+ activatedAt?: Date;
73
+
74
+ /** Expiration timestamp (null = no expiration) */
75
+ expiresAt?: Date;
76
+
77
+ /** Last used timestamp */
78
+ lastUsedAt?: Date;
79
+
80
+ /** Routing count */
81
+ routingCount: number;
82
+
83
+ /** Metadata */
84
+ metadata?: Record<string, unknown>;
85
+ }
86
+
87
+ /**
88
+ * Masking rule configuration
89
+ */
90
+ export interface MaskingRule {
91
+ /** Rule identifier */
92
+ id: string;
93
+
94
+ /** User identifier */
95
+ userId: string;
96
+
97
+ /** Rule type */
98
+ type: 'domain' | 'address' | 'pattern';
99
+
100
+ /** Match criteria */
101
+ criteria: string;
102
+
103
+ /** Action (mask or allow) */
104
+ action: 'mask' | 'allow';
105
+
106
+ /** Priority (higher = evaluated first) */
107
+ priority: number;
108
+
109
+ /** Enabled flag */
110
+ enabled: boolean;
111
+ }
112
+
113
+ /**
114
+ * Routing result
115
+ */
116
+ export interface RoutingResult {
117
+ success: boolean;
118
+ routedTo?: string;
119
+ proxyUsed?: string;
120
+ direction: 'inbound' | 'outbound';
121
+ error?: string;
122
+ auditId?: string;
123
+ }
124
+
125
+ /**
126
+ * Audit entry
127
+ */
128
+ export interface RoutingAudit {
129
+ id: string;
130
+ proxyId: string;
131
+ direction: 'inbound' | 'outbound';
132
+ fromAddress: string;
133
+ toAddress: string;
134
+ routedAt: Date;
135
+ success: boolean;
136
+ error?: string;
137
+ metadata?: Record<string, unknown>;
138
+ }
139
+
140
+ /**
141
+ * Result wrapper for masking operations
142
+ */
143
+ export interface MaskingResult<T = unknown> {
144
+ success: boolean;
145
+ data?: T;
146
+ error?: string;
147
+ }
148
+
149
+ /**
150
+ * Options for creating a proxy
151
+ */
152
+ export interface CreateProxyOptions {
153
+ externalEmail?: string;
154
+ conversationId?: string;
155
+ ttlDays?: number;
156
+ metadata?: Record<string, unknown>;
157
+ }
158
+
159
+ /**
160
+ * Email content for outbound routing
161
+ */
162
+ export interface EmailContent {
163
+ subject: string;
164
+ text?: string;
165
+ html?: string;
166
+ }
167
+
168
+ /**
169
+ * Database row type for proxy addresses
170
+ */
171
+ export interface ProxyAddressRow {
172
+ id: string;
173
+ proxy_email: string;
174
+ real_email: string;
175
+ user_id: string;
176
+ external_email: string | null;
177
+ conversation_id: string | null;
178
+ status: string;
179
+ created_at: string;
180
+ activated_at: string | null;
181
+ expires_at: string | null;
182
+ last_used_at: string | null;
183
+ routing_count: string;
184
+ metadata: string | null;
185
+ }
186
+
187
+ /**
188
+ * Database row type for routing audit
189
+ */
190
+ export interface RoutingAuditRow {
191
+ id: string;
192
+ proxy_id: string;
193
+ direction: string;
194
+ from_address: string;
195
+ to_address: string;
196
+ routed_at: string;
197
+ success: boolean;
198
+ error: string | null;
199
+ metadata: string | null;
200
+ }