Repository navigation
Expand file tree
/
Copy pathnotification.transport.ts
More file actions
131 lines (119 loc) · 5.81 KB
/
Copy pathnotification.transport.ts
File metadata and controls
131 lines (119 loc) · 5.81 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import { EmailPayload, WebPayload } from '../types/notification.types';
import { WebhookService } from './webhook.service';
import { logger } from '../logger';
import * as crypto from 'crypto';
import {
assertSafeEmailHeaders,
EmailMessage,
EmailTransport,
isValidRecipientEmail,
redactEmailAddress,
SendGridEmailTransport,
SesEmailTransport,
SmtpEmailTransport,
} from '../queue/processors/email.transport';
/** Result returned by notification transports. */
export interface NotificationResult {
success: boolean;
message?: string;
}
/** Pluggable transport interface for notification delivery. */
export interface NotificationTransport {
sendEmail?: (payload: EmailPayload) => Promise<NotificationResult>;
sendWebNotification?: (payload: WebPayload) => Promise<NotificationResult>;
}
/** Explicit no-network transport for local development and tests. */
export const ConsoleTransport: NotificationTransport = {
async sendEmail(payload: EmailPayload): Promise<NotificationResult> {
if (!isValidRecipientEmail(payload.to)) return { success: false, message: 'Invalid email address' };
try {
assertSafeEmailHeaders(payload);
} catch (error) {
return { success: false, message: (error as Error).message };
}
logger.debug('[ConsoleTransport:Email] Sending', { toRedacted: redactEmailAddress(payload.to) });
return { success: true };
},
async sendWebNotification(payload: WebPayload): Promise<NotificationResult> {
logger.debug('[ConsoleTransport:Web] Sending', { userId: payload.userId });
return { success: true };
},
};
/** Webhook transport reuses WebhookService signing and retry behaviour. */
export class WebhookTransport implements NotificationTransport {
constructor(private readonly webhookService: WebhookService, private readonly url: string, private readonly secret?: string) {}
/**
* Generates a collision-resistant delivery id for a notification.
*
* The id is constructed as `${userId}:${crypto.randomUUID()}` so it remains
* human-readable (the userId prefix aids log correlation) while being unique
* under rapid successive sends — even multiple sends for the same user within
* the same millisecond produce distinct ids. The random component uses
* `crypto.randomUUID()` which provides 122 bits of entropy (version 4 UUID).
*
* @param userId - The recipient's platform identifier.
* @returns A unique delivery id string (e.g. `user-abc:550e8400-e29b-41d4-a716-446655440000`).
*/
private buildDeliveryId(userId: string): string {
return `${userId}:${crypto.randomUUID()}`;
}
/**
* Sends a web notification via the configured webhook endpoint.
*
* The delivery id is generated once per call (via {@link buildDeliveryId}) and
* passed to `webhookService.send`, which handles its own bounded retry cycle
* using that same id — so the id is stable for a single logical send.
*
* @param payload - The web notification payload (userId, title, message).
* @returns A result indicating success or failure with an optional message.
*/
async sendWebNotification(payload: WebPayload): Promise<NotificationResult> {
try {
await this.webhookService.send({ id: this.buildDeliveryId(payload.userId), url: this.url, data: payload, retryCount: 0, webhookSecret: this.secret });
return { success: true };
} catch (error) {
return { success: false, message: (error as Error).message };
}
}
}
type Provider = Pick<EmailTransport, 'send'>;
/**
* Adapts a real queue email provider to the NotificationTransport contract.
* It validates a single recipient and rejects CR/LF header injection before
* dispatch. Provider errors are converted to a failed result for caller retry.
*/
class ProviderEmailNotificationTransport implements NotificationTransport {
constructor(private readonly provider: Provider, private readonly name: string) {}
async sendEmail(payload: EmailPayload): Promise<NotificationResult> {
if (!isValidRecipientEmail(payload.to)) return { success: false, message: 'Invalid email address' };
try {
assertSafeEmailHeaders(payload);
await this.provider.send(payload as EmailMessage, logger);
return { success: true };
} catch (error) {
logger.error(`[${this.name}] Email delivery failed`, { err: error, toRedacted: redactEmailAddress(payload.to) });
return { success: false, message: (error as Error).message };
}
}
}
/** Real SMTP notification transport. A provider may be injected for tests. */
export class SMTPTransport extends ProviderEmailNotificationTransport {
constructor(config: { host: string; port: number; user?: string; password?: string; from: string; secure?: boolean }, timeoutMs = 10_000, provider?: Provider) {
if (!config.host || !config.port || !config.from) throw new Error('SMTP_HOST, SMTP_PORT, and SMTP_FROM are required');
super(provider ?? new SmtpEmailTransport(config, timeoutMs), 'SMTPTransport');
}
}
/** Real AWS SES notification transport. A provider may be injected for tests. */
export class SESTransport extends ProviderEmailNotificationTransport {
constructor(config: { accessKeyId?: string; secretAccessKey?: string; region: string; from: string }, timeoutMs = 10_000, provider?: Provider) {
if (!config.region || !config.from) throw new Error('AWS_REGION and SMTP_FROM are required');
super(provider ?? new SesEmailTransport(config, timeoutMs), 'SESTransport');
}
}
/** Real SendGrid notification transport. A provider may be injected for tests. */
export class SendGridTransport extends ProviderEmailNotificationTransport {
constructor(config: { apiKey: string; from: string }, timeoutMs = 10_000, provider?: Provider) {
if (!config.apiKey || !config.from) throw new Error('SENDGRID_API_KEY and SMTP_FROM are required');
super(provider ?? new SendGridEmailTransport(config, timeoutMs), 'SendGridTransport');
}
}