@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.
- package/.eslintrc.js +21 -0
- package/README.md +405 -0
- package/TESTING.md +237 -0
- package/__tests__/EmailMaskingService.test.ts +470 -0
- package/__tests__/__fixtures__/test-config.ts +24 -0
- package/__tests__/__mocks__/database.ts +252 -0
- package/__tests__/setup.ts +16 -0
- package/dist/EmailMaskingService.d.ts +90 -0
- package/dist/EmailMaskingService.js +316 -0
- package/dist/database.d.ts +5 -0
- package/dist/database.js +69 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +12 -0
- package/dist/types.d.ts +159 -0
- package/dist/types.js +9 -0
- package/jest.config.cjs +28 -0
- package/package.json +56 -0
- package/src/EmailMaskingService.ts +425 -0
- package/src/database.ts +69 -0
- package/src/index.ts +20 -0
- package/src/types.ts +200 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,252 @@
|
|
|
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 type { ProxyAddressRow, RoutingAuditRow } from '../../src/types.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* In-memory database mock for testing email masking service
|
|
13
|
+
*/
|
|
14
|
+
class MockDatabase {
|
|
15
|
+
private proxyAddresses: Map<string, ProxyAddressRow> = new Map();
|
|
16
|
+
private routingAudits: Map<string, RoutingAuditRow> = new Map();
|
|
17
|
+
private proxyEmailIndex: Map<string, string> = new Map(); // proxy_email -> id
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reset all data (called between tests)
|
|
21
|
+
*/
|
|
22
|
+
reset(): void {
|
|
23
|
+
this.proxyAddresses.clear();
|
|
24
|
+
this.routingAudits.clear();
|
|
25
|
+
this.proxyEmailIndex.clear();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Execute a SQL query (mocked)
|
|
30
|
+
*/
|
|
31
|
+
async query<T = any>(sql: string, params: any[] = []): Promise<{ rows: T[] }> {
|
|
32
|
+
const normalizedSql = sql.trim().toLowerCase();
|
|
33
|
+
|
|
34
|
+
// CREATE TABLE statements (schema initialization)
|
|
35
|
+
if (normalizedSql.includes('create table') || normalizedSql.includes('create index')) {
|
|
36
|
+
return { rows: [] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// INSERT INTO proxy_addresses
|
|
40
|
+
if (normalizedSql.includes('insert into proxy_addresses')) {
|
|
41
|
+
const [
|
|
42
|
+
proxyEmail,
|
|
43
|
+
realEmail,
|
|
44
|
+
userId,
|
|
45
|
+
externalEmail,
|
|
46
|
+
conversationId,
|
|
47
|
+
status,
|
|
48
|
+
expiresAt,
|
|
49
|
+
metadata
|
|
50
|
+
] = params;
|
|
51
|
+
|
|
52
|
+
const id = this.generateUUID();
|
|
53
|
+
const now = new Date().toISOString();
|
|
54
|
+
|
|
55
|
+
const row: ProxyAddressRow = {
|
|
56
|
+
id,
|
|
57
|
+
proxy_email: proxyEmail,
|
|
58
|
+
real_email: realEmail,
|
|
59
|
+
user_id: userId,
|
|
60
|
+
external_email: externalEmail,
|
|
61
|
+
conversation_id: conversationId,
|
|
62
|
+
status,
|
|
63
|
+
created_at: now,
|
|
64
|
+
activated_at: null,
|
|
65
|
+
expires_at: expiresAt ? new Date(expiresAt).toISOString() : null,
|
|
66
|
+
last_used_at: null,
|
|
67
|
+
routing_count: '0',
|
|
68
|
+
metadata: metadata || '{}'
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
this.proxyAddresses.set(id, row);
|
|
72
|
+
this.proxyEmailIndex.set(proxyEmail, id);
|
|
73
|
+
|
|
74
|
+
return { rows: [row] as T[] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// INSERT INTO routing_audit
|
|
78
|
+
if (normalizedSql.includes('insert into routing_audit')) {
|
|
79
|
+
const [
|
|
80
|
+
proxyId,
|
|
81
|
+
direction,
|
|
82
|
+
fromAddress,
|
|
83
|
+
toAddress,
|
|
84
|
+
success,
|
|
85
|
+
error,
|
|
86
|
+
metadata
|
|
87
|
+
] = params;
|
|
88
|
+
|
|
89
|
+
const id = this.generateUUID();
|
|
90
|
+
const now = new Date().toISOString();
|
|
91
|
+
|
|
92
|
+
const row: RoutingAuditRow = {
|
|
93
|
+
id,
|
|
94
|
+
proxy_id: proxyId,
|
|
95
|
+
direction,
|
|
96
|
+
from_address: fromAddress,
|
|
97
|
+
to_address: toAddress,
|
|
98
|
+
routed_at: now,
|
|
99
|
+
success,
|
|
100
|
+
error: error || null,
|
|
101
|
+
metadata: metadata || '{}'
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
this.routingAudits.set(id, row);
|
|
105
|
+
|
|
106
|
+
return { rows: [{ id }] as T[] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// SELECT * FROM proxy_addresses WHERE id = $1
|
|
110
|
+
if (normalizedSql.includes('select * from proxy_addresses') && normalizedSql.includes('where id')) {
|
|
111
|
+
const [id] = params;
|
|
112
|
+
const row = this.proxyAddresses.get(id);
|
|
113
|
+
return { rows: row ? [row] as T[] : [] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// SELECT * FROM proxy_addresses WHERE proxy_email = $1
|
|
117
|
+
if (normalizedSql.includes('select * from proxy_addresses') && normalizedSql.includes('where proxy_email')) {
|
|
118
|
+
const [proxyEmail] = params;
|
|
119
|
+
const id = this.proxyEmailIndex.get(proxyEmail);
|
|
120
|
+
const row = id ? this.proxyAddresses.get(id) : undefined;
|
|
121
|
+
return { rows: row ? [row] as T[] : [] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// SELECT * FROM proxy_addresses WHERE user_id = $1 AND real_email = $2 AND external_email = $3
|
|
125
|
+
if (normalizedSql.includes('select * from proxy_addresses') &&
|
|
126
|
+
normalizedSql.includes('where user_id') &&
|
|
127
|
+
normalizedSql.includes('real_email') &&
|
|
128
|
+
normalizedSql.includes('external_email')) {
|
|
129
|
+
const [userId, realEmail, externalEmail] = params;
|
|
130
|
+
|
|
131
|
+
let matches = Array.from(this.proxyAddresses.values()).filter(row =>
|
|
132
|
+
row.user_id === userId &&
|
|
133
|
+
row.real_email === realEmail &&
|
|
134
|
+
row.external_email === externalEmail
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Sort by created_at DESC if ORDER BY is present
|
|
138
|
+
if (normalizedSql.includes('order by created_at desc')) {
|
|
139
|
+
matches.sort((a, b) => {
|
|
140
|
+
const dateA = new Date(a.created_at).getTime();
|
|
141
|
+
const dateB = new Date(b.created_at).getTime();
|
|
142
|
+
return dateB - dateA; // DESC order
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { rows: matches.slice(0, 1) as T[] }; // LIMIT 1
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// SELECT COUNT(*) FROM proxy_addresses WHERE user_id = $1 AND status = 'active'
|
|
150
|
+
if (normalizedSql.includes('select count(*)') && normalizedSql.includes('from proxy_addresses')) {
|
|
151
|
+
const [userId] = params;
|
|
152
|
+
|
|
153
|
+
const count = Array.from(this.proxyAddresses.values()).filter(row =>
|
|
154
|
+
row.user_id === userId && row.status === 'active'
|
|
155
|
+
).length;
|
|
156
|
+
|
|
157
|
+
return { rows: [{ count: count.toString() }] as T[] };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// UPDATE proxy_addresses SET status = $1 WHERE id = $2
|
|
161
|
+
if (normalizedSql.includes('update proxy_addresses') &&
|
|
162
|
+
normalizedSql.includes('set status') &&
|
|
163
|
+
!normalizedSql.includes('routing_count')) {
|
|
164
|
+
const [status, id] = params;
|
|
165
|
+
const row = this.proxyAddresses.get(id);
|
|
166
|
+
|
|
167
|
+
if (row) {
|
|
168
|
+
row.status = status;
|
|
169
|
+
this.proxyAddresses.set(id, row);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { rows: [] };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// UPDATE proxy_addresses SET routing_count = routing_count + 1, last_used_at = NOW() WHERE id = $1
|
|
176
|
+
if (normalizedSql.includes('update proxy_addresses') && normalizedSql.includes('routing_count')) {
|
|
177
|
+
const [id] = params;
|
|
178
|
+
const row = this.proxyAddresses.get(id);
|
|
179
|
+
|
|
180
|
+
if (row) {
|
|
181
|
+
row.routing_count = (parseInt(row.routing_count) + 1).toString();
|
|
182
|
+
row.last_used_at = new Date().toISOString();
|
|
183
|
+
this.proxyAddresses.set(id, row);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { rows: [] };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// UPDATE proxy_addresses SET status = 'expired' WHERE expires_at < NOW() AND status = 'active'
|
|
190
|
+
if (normalizedSql.includes('update proxy_addresses') && normalizedSql.includes('expires_at < now()')) {
|
|
191
|
+
const now = new Date();
|
|
192
|
+
|
|
193
|
+
Array.from(this.proxyAddresses.values()).forEach(row => {
|
|
194
|
+
if (row.status === 'active' && row.expires_at) {
|
|
195
|
+
const expiresAt = new Date(row.expires_at);
|
|
196
|
+
if (expiresAt < now) {
|
|
197
|
+
row.status = 'expired';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return { rows: [] };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Fallback for unhandled queries
|
|
206
|
+
console.warn('Unhandled mock query:', normalizedSql, params);
|
|
207
|
+
return { rows: [] };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Generate a UUID v4
|
|
212
|
+
*/
|
|
213
|
+
private generateUUID(): string {
|
|
214
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
215
|
+
const r = Math.random() * 16 | 0;
|
|
216
|
+
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
217
|
+
return v.toString(16);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Singleton instance
|
|
223
|
+
const mockDb = new MockDatabase();
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Mock Pool class that mimics pg.Pool
|
|
227
|
+
*/
|
|
228
|
+
export class Pool {
|
|
229
|
+
async query<T = any>(sql: string, params: any[] = []): Promise<{ rows: T[] }> {
|
|
230
|
+
return mockDb.query<T>(sql, params);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async end(): Promise<void> {
|
|
234
|
+
// No-op for mock
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Initialize database schema (no-op in mock)
|
|
240
|
+
*/
|
|
241
|
+
export async function initializeDatabase(_pool: Pool): Promise<void> {
|
|
242
|
+
// Schema is implicitly created in memory
|
|
243
|
+
await mockDb.query('CREATE TABLE proxy_addresses');
|
|
244
|
+
await mockDb.query('CREATE TABLE routing_audit');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Reset mock database between tests
|
|
249
|
+
*/
|
|
250
|
+
export function resetMockDatabase(): void {
|
|
251
|
+
mockDb.reset();
|
|
252
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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 { resetMockDatabase } from './__mocks__/database.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reset mock database before each test
|
|
13
|
+
*/
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
resetMockDatabase();
|
|
16
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { EmailMaskingConfig, ProxyAddress, RoutingResult, CreateProxyOptions } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Email masking and man-in-the-middle routing service
|
|
4
|
+
*/
|
|
5
|
+
export declare class EmailMaskingService {
|
|
6
|
+
private config;
|
|
7
|
+
private db;
|
|
8
|
+
private cleanupInterval?;
|
|
9
|
+
constructor(config: EmailMaskingConfig);
|
|
10
|
+
/**
|
|
11
|
+
* Initialize the masking service
|
|
12
|
+
*/
|
|
13
|
+
initialize(): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Cleanup resources
|
|
16
|
+
*/
|
|
17
|
+
shutdown(): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Create a new proxy email address
|
|
20
|
+
*/
|
|
21
|
+
createProxy(userId: string, realEmail: string, options?: CreateProxyOptions): Promise<ProxyAddress>;
|
|
22
|
+
/**
|
|
23
|
+
* Route inbound email (external → real address)
|
|
24
|
+
*/
|
|
25
|
+
routeInbound(from: string, to: string, _subject: string, _text?: string, _html?: string): Promise<RoutingResult>;
|
|
26
|
+
/**
|
|
27
|
+
* Route outbound email (real address → external via proxy)
|
|
28
|
+
*/
|
|
29
|
+
routeOutbound(userId: string, realEmail: string, externalEmail: string, _emailContent: {
|
|
30
|
+
subject: string;
|
|
31
|
+
text?: string;
|
|
32
|
+
html?: string;
|
|
33
|
+
}): Promise<RoutingResult>;
|
|
34
|
+
/**
|
|
35
|
+
* Deactivate a proxy address
|
|
36
|
+
*/
|
|
37
|
+
deactivateProxy(proxyId: string): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Revoke a proxy address permanently
|
|
40
|
+
*/
|
|
41
|
+
revokeProxy(proxyId: string): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Get proxy by ID
|
|
44
|
+
*/
|
|
45
|
+
getProxyById(proxyId: string): Promise<ProxyAddress | null>;
|
|
46
|
+
/**
|
|
47
|
+
* Get proxy by email address
|
|
48
|
+
*/
|
|
49
|
+
private getProxyByEmail;
|
|
50
|
+
/**
|
|
51
|
+
* Get proxy for specific user-contact pair
|
|
52
|
+
*/
|
|
53
|
+
private getProxyForContact;
|
|
54
|
+
/**
|
|
55
|
+
* Update proxy usage statistics
|
|
56
|
+
*/
|
|
57
|
+
private updateProxyUsage;
|
|
58
|
+
/**
|
|
59
|
+
* Audit a routing decision
|
|
60
|
+
*/
|
|
61
|
+
private auditRouting;
|
|
62
|
+
/**
|
|
63
|
+
* Map database row to ProxyAddress
|
|
64
|
+
*/
|
|
65
|
+
private mapProxyRow;
|
|
66
|
+
/**
|
|
67
|
+
* Get user's proxy count
|
|
68
|
+
*/
|
|
69
|
+
private getUserProxyCount;
|
|
70
|
+
/**
|
|
71
|
+
* Expire a proxy
|
|
72
|
+
*/
|
|
73
|
+
private expireProxy;
|
|
74
|
+
/**
|
|
75
|
+
* Start cleanup job for expired proxies
|
|
76
|
+
*/
|
|
77
|
+
private startCleanupJob;
|
|
78
|
+
/**
|
|
79
|
+
* Cleanup expired proxies
|
|
80
|
+
*/
|
|
81
|
+
private cleanupExpiredProxies;
|
|
82
|
+
/**
|
|
83
|
+
* Extract proxy address from To field
|
|
84
|
+
*/
|
|
85
|
+
private extractProxyAddress;
|
|
86
|
+
/**
|
|
87
|
+
* Generate secure proxy token
|
|
88
|
+
*/
|
|
89
|
+
private generateProxyToken;
|
|
90
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
Copyright (c) 2025 Bernier LLC
|
|
4
|
+
|
|
5
|
+
This file is licensed to the client under a limited-use license.
|
|
6
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
7
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.EmailMaskingService = void 0;
|
|
11
|
+
const pg_1 = require("pg");
|
|
12
|
+
const database_js_1 = require("./database.js");
|
|
13
|
+
/**
|
|
14
|
+
* Email masking and man-in-the-middle routing service
|
|
15
|
+
*/
|
|
16
|
+
class EmailMaskingService {
|
|
17
|
+
constructor(config) {
|
|
18
|
+
this.config = config;
|
|
19
|
+
this.db = new pg_1.Pool({
|
|
20
|
+
host: config.database.host,
|
|
21
|
+
port: config.database.port,
|
|
22
|
+
database: config.database.database,
|
|
23
|
+
user: config.database.user,
|
|
24
|
+
password: config.database.password,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Initialize the masking service
|
|
29
|
+
*/
|
|
30
|
+
async initialize() {
|
|
31
|
+
// Initialize database schema
|
|
32
|
+
await (0, database_js_1.initializeDatabase)(this.db);
|
|
33
|
+
// Start cleanup job for expired proxies
|
|
34
|
+
this.startCleanupJob();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Cleanup resources
|
|
38
|
+
*/
|
|
39
|
+
async shutdown() {
|
|
40
|
+
if (this.cleanupInterval) {
|
|
41
|
+
clearInterval(this.cleanupInterval);
|
|
42
|
+
}
|
|
43
|
+
await this.db.end();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Create a new proxy email address
|
|
47
|
+
*/
|
|
48
|
+
async createProxy(userId, realEmail, options) {
|
|
49
|
+
// Validate user's proxy quota
|
|
50
|
+
const userProxies = await this.getUserProxyCount(userId);
|
|
51
|
+
const maxProxies = this.config.maxProxiesPerUser || 0;
|
|
52
|
+
if (maxProxies > 0 && userProxies >= maxProxies) {
|
|
53
|
+
throw new Error(`User ${userId} has reached maximum proxy limit (${maxProxies})`);
|
|
54
|
+
}
|
|
55
|
+
// Generate secure proxy token
|
|
56
|
+
const proxyToken = this.generateProxyToken();
|
|
57
|
+
const proxyEmail = `${userId}-${proxyToken}@${this.config.proxyDomain}`;
|
|
58
|
+
// Calculate expiration
|
|
59
|
+
const ttl = options?.ttlDays !== undefined ? options.ttlDays : (this.config.defaultTTL || 0);
|
|
60
|
+
const expiresAt = ttl !== 0
|
|
61
|
+
? new Date(Date.now() + ttl * 24 * 60 * 60 * 1000)
|
|
62
|
+
: null;
|
|
63
|
+
// Insert proxy record
|
|
64
|
+
const result = await this.db.query(`INSERT INTO proxy_addresses
|
|
65
|
+
(proxy_email, real_email, user_id, external_email, conversation_id, status, expires_at, metadata)
|
|
66
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
67
|
+
RETURNING *`, [
|
|
68
|
+
proxyEmail,
|
|
69
|
+
realEmail,
|
|
70
|
+
userId,
|
|
71
|
+
options?.externalEmail || null,
|
|
72
|
+
options?.conversationId || null,
|
|
73
|
+
'active',
|
|
74
|
+
expiresAt,
|
|
75
|
+
JSON.stringify(options?.metadata || {})
|
|
76
|
+
]);
|
|
77
|
+
return this.mapProxyRow(result.rows[0]);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Route inbound email (external → real address)
|
|
81
|
+
*/
|
|
82
|
+
async routeInbound(from, to, _subject, _text, _html) {
|
|
83
|
+
const toAddress = this.extractProxyAddress(to);
|
|
84
|
+
if (!toAddress) {
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
direction: 'inbound',
|
|
88
|
+
error: 'No proxy address found in To field'
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// Look up proxy
|
|
92
|
+
const proxy = await this.getProxyByEmail(toAddress);
|
|
93
|
+
if (!proxy) {
|
|
94
|
+
return {
|
|
95
|
+
success: false,
|
|
96
|
+
direction: 'inbound',
|
|
97
|
+
error: `Proxy address not found: ${toAddress}`
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// Validate proxy status
|
|
101
|
+
if (proxy.status !== 'active') {
|
|
102
|
+
return {
|
|
103
|
+
success: false,
|
|
104
|
+
direction: 'inbound',
|
|
105
|
+
error: `Proxy is ${proxy.status}: ${toAddress}`
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// Check expiration
|
|
109
|
+
if (proxy.expiresAt && proxy.expiresAt < new Date()) {
|
|
110
|
+
await this.expireProxy(proxy.id);
|
|
111
|
+
return {
|
|
112
|
+
success: false,
|
|
113
|
+
direction: 'inbound',
|
|
114
|
+
error: `Proxy expired: ${toAddress}`
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// Update proxy usage
|
|
118
|
+
await this.updateProxyUsage(proxy.id);
|
|
119
|
+
// Audit the routing
|
|
120
|
+
const auditId = await this.auditRouting({
|
|
121
|
+
proxyId: proxy.id,
|
|
122
|
+
direction: 'inbound',
|
|
123
|
+
fromAddress: from,
|
|
124
|
+
toAddress: proxy.realEmail,
|
|
125
|
+
success: true
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
success: true,
|
|
129
|
+
routedTo: proxy.realEmail,
|
|
130
|
+
proxyUsed: toAddress,
|
|
131
|
+
direction: 'inbound',
|
|
132
|
+
auditId
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Route outbound email (real address → external via proxy)
|
|
137
|
+
*/
|
|
138
|
+
async routeOutbound(userId, realEmail, externalEmail, _emailContent) {
|
|
139
|
+
// Find or create proxy for this user-external pair
|
|
140
|
+
let proxy = await this.getProxyForContact(userId, realEmail, externalEmail);
|
|
141
|
+
if (!proxy) {
|
|
142
|
+
// Auto-create proxy for outbound routing
|
|
143
|
+
proxy = await this.createProxy(userId, realEmail, {
|
|
144
|
+
externalEmail,
|
|
145
|
+
metadata: { autoCreated: true, direction: 'outbound' }
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
// Validate proxy status
|
|
149
|
+
if (proxy.status !== 'active') {
|
|
150
|
+
return {
|
|
151
|
+
success: false,
|
|
152
|
+
direction: 'outbound',
|
|
153
|
+
error: `Proxy is ${proxy.status}`
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// Update proxy usage
|
|
157
|
+
await this.updateProxyUsage(proxy.id);
|
|
158
|
+
// Audit the routing
|
|
159
|
+
const auditId = await this.auditRouting({
|
|
160
|
+
proxyId: proxy.id,
|
|
161
|
+
direction: 'outbound',
|
|
162
|
+
fromAddress: realEmail,
|
|
163
|
+
toAddress: externalEmail,
|
|
164
|
+
success: true
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
success: true,
|
|
168
|
+
routedTo: externalEmail,
|
|
169
|
+
proxyUsed: proxy.proxyEmail,
|
|
170
|
+
direction: 'outbound',
|
|
171
|
+
auditId
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Deactivate a proxy address
|
|
176
|
+
*/
|
|
177
|
+
async deactivateProxy(proxyId) {
|
|
178
|
+
await this.db.query('UPDATE proxy_addresses SET status = $1 WHERE id = $2', ['inactive', proxyId]);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Revoke a proxy address permanently
|
|
182
|
+
*/
|
|
183
|
+
async revokeProxy(proxyId) {
|
|
184
|
+
await this.db.query('UPDATE proxy_addresses SET status = $1 WHERE id = $2', ['revoked', proxyId]);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Get proxy by ID
|
|
188
|
+
*/
|
|
189
|
+
async getProxyById(proxyId) {
|
|
190
|
+
const result = await this.db.query('SELECT * FROM proxy_addresses WHERE id = $1', [proxyId]);
|
|
191
|
+
return result.rows.length > 0 ? this.mapProxyRow(result.rows[0]) : null;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Get proxy by email address
|
|
195
|
+
*/
|
|
196
|
+
async getProxyByEmail(proxyEmail) {
|
|
197
|
+
const result = await this.db.query('SELECT * FROM proxy_addresses WHERE proxy_email = $1', [proxyEmail]);
|
|
198
|
+
return result.rows.length > 0 ? this.mapProxyRow(result.rows[0]) : null;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Get proxy for specific user-contact pair
|
|
202
|
+
*/
|
|
203
|
+
async getProxyForContact(userId, realEmail, externalEmail) {
|
|
204
|
+
const result = await this.db.query(`SELECT * FROM proxy_addresses
|
|
205
|
+
WHERE user_id = $1
|
|
206
|
+
AND real_email = $2
|
|
207
|
+
AND external_email = $3
|
|
208
|
+
ORDER BY created_at DESC
|
|
209
|
+
LIMIT 1`, [userId, realEmail, externalEmail]);
|
|
210
|
+
return result.rows.length > 0 ? this.mapProxyRow(result.rows[0]) : null;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Update proxy usage statistics
|
|
214
|
+
*/
|
|
215
|
+
async updateProxyUsage(proxyId) {
|
|
216
|
+
await this.db.query(`UPDATE proxy_addresses
|
|
217
|
+
SET routing_count = routing_count + 1, last_used_at = NOW()
|
|
218
|
+
WHERE id = $1`, [proxyId]);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Audit a routing decision
|
|
222
|
+
*/
|
|
223
|
+
async auditRouting(audit) {
|
|
224
|
+
if (!this.config.auditEnabled) {
|
|
225
|
+
return '';
|
|
226
|
+
}
|
|
227
|
+
const result = await this.db.query(`INSERT INTO routing_audit
|
|
228
|
+
(proxy_id, direction, from_address, to_address, success, error, metadata)
|
|
229
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
230
|
+
RETURNING id`, [
|
|
231
|
+
audit.proxyId,
|
|
232
|
+
audit.direction,
|
|
233
|
+
audit.fromAddress,
|
|
234
|
+
audit.toAddress,
|
|
235
|
+
audit.success,
|
|
236
|
+
audit.error || null,
|
|
237
|
+
JSON.stringify(audit.metadata || {})
|
|
238
|
+
]);
|
|
239
|
+
return result.rows[0].id;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Map database row to ProxyAddress
|
|
243
|
+
*/
|
|
244
|
+
mapProxyRow(row) {
|
|
245
|
+
return {
|
|
246
|
+
id: row.id,
|
|
247
|
+
proxyEmail: row.proxy_email,
|
|
248
|
+
realEmail: row.real_email,
|
|
249
|
+
userId: row.user_id,
|
|
250
|
+
externalEmail: row.external_email || undefined,
|
|
251
|
+
conversationId: row.conversation_id || undefined,
|
|
252
|
+
status: row.status,
|
|
253
|
+
createdAt: new Date(row.created_at),
|
|
254
|
+
activatedAt: row.activated_at ? new Date(row.activated_at) : undefined,
|
|
255
|
+
expiresAt: row.expires_at ? new Date(row.expires_at) : undefined,
|
|
256
|
+
lastUsedAt: row.last_used_at ? new Date(row.last_used_at) : undefined,
|
|
257
|
+
routingCount: parseInt(row.routing_count) || 0,
|
|
258
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : {}
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Get user's proxy count
|
|
263
|
+
*/
|
|
264
|
+
async getUserProxyCount(userId) {
|
|
265
|
+
const result = await this.db.query(`SELECT COUNT(*) as count FROM proxy_addresses
|
|
266
|
+
WHERE user_id = $1 AND status = 'active'`, [userId]);
|
|
267
|
+
return parseInt(result.rows[0].count) || 0;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Expire a proxy
|
|
271
|
+
*/
|
|
272
|
+
async expireProxy(proxyId) {
|
|
273
|
+
await this.db.query('UPDATE proxy_addresses SET status = $1 WHERE id = $2', ['expired', proxyId]);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Start cleanup job for expired proxies
|
|
277
|
+
*/
|
|
278
|
+
startCleanupJob() {
|
|
279
|
+
// Run every hour
|
|
280
|
+
this.cleanupInterval = setInterval(() => {
|
|
281
|
+
void this.cleanupExpiredProxies();
|
|
282
|
+
}, 60 * 60 * 1000); // 1 hour
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Cleanup expired proxies
|
|
286
|
+
*/
|
|
287
|
+
async cleanupExpiredProxies() {
|
|
288
|
+
await this.db.query(`UPDATE proxy_addresses
|
|
289
|
+
SET status = 'expired'
|
|
290
|
+
WHERE expires_at < NOW() AND status = 'active'`);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Extract proxy address from To field
|
|
294
|
+
*/
|
|
295
|
+
extractProxyAddress(to) {
|
|
296
|
+
const proxyDomain = this.config.proxyDomain;
|
|
297
|
+
if (to.includes(`@${proxyDomain}`)) {
|
|
298
|
+
// Extract email from "Name <email>" format
|
|
299
|
+
const match = to.match(/<(.+?)>/) || [null, to];
|
|
300
|
+
return match[1];
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Generate secure proxy token
|
|
306
|
+
*/
|
|
307
|
+
generateProxyToken() {
|
|
308
|
+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
309
|
+
let token = '';
|
|
310
|
+
for (let i = 0; i < 16; i++) {
|
|
311
|
+
token += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
312
|
+
}
|
|
313
|
+
return token;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
exports.EmailMaskingService = EmailMaskingService;
|