@wopr-network/platform-core 1.50.1 → 1.51.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/dist/auth/better-auth.js +3 -0
- package/dist/email/client.d.ts +13 -8
- package/dist/email/client.js +80 -32
- package/dist/email/ses-transport.d.ts +26 -0
- package/dist/email/ses-transport.js +63 -0
- package/package.json +2 -1
- package/src/auth/better-auth.ts +3 -0
- package/src/email/client.ts +89 -33
- package/src/email/ses-transport.ts +80 -0
package/dist/auth/better-auth.js
CHANGED
|
@@ -218,6 +218,9 @@ function authOptions(cfg) {
|
|
|
218
218
|
session_token: {
|
|
219
219
|
attributes: cookieDomain ? { domain: cookieDomain } : {},
|
|
220
220
|
},
|
|
221
|
+
session_data: {
|
|
222
|
+
attributes: cookieDomain ? { domain: cookieDomain } : {},
|
|
223
|
+
},
|
|
221
224
|
},
|
|
222
225
|
},
|
|
223
226
|
plugins: cfg.twoFactor !== false ? [twoFactor()] : [],
|
package/dist/email/client.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Email Client — Template-based transactional email sender.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Supports two backends:
|
|
5
|
+
* - **Resend**: Set RESEND_API_KEY env var
|
|
6
|
+
* - **AWS SES**: Set AWS_SES_REGION env var (+ AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
|
|
7
|
+
*
|
|
8
|
+
* SES takes priority when both are configured.
|
|
6
9
|
*/
|
|
7
10
|
export interface EmailClientConfig {
|
|
8
11
|
apiKey: string;
|
|
@@ -23,22 +26,24 @@ export interface EmailSendResult {
|
|
|
23
26
|
id: string;
|
|
24
27
|
success: boolean;
|
|
25
28
|
}
|
|
29
|
+
/** Transport abstraction — any backend that can send an email. */
|
|
30
|
+
export interface EmailTransport {
|
|
31
|
+
send(opts: SendTemplateEmailOpts): Promise<EmailSendResult>;
|
|
32
|
+
}
|
|
26
33
|
/**
|
|
27
|
-
* Transactional email client
|
|
34
|
+
* Transactional email client with pluggable transport.
|
|
28
35
|
*
|
|
29
36
|
* Usage:
|
|
30
37
|
* ```ts
|
|
31
|
-
* const client = new EmailClient({ apiKey: "re_xxx", from: "noreply@
|
|
38
|
+
* const client = new EmailClient({ apiKey: "re_xxx", from: "noreply@example.com" });
|
|
32
39
|
* const template = verifyEmailTemplate(url, email);
|
|
33
40
|
* await client.send({ to: email, ...template, userId: "user-123", templateName: "verify-email" });
|
|
34
41
|
* ```
|
|
35
42
|
*/
|
|
36
43
|
export declare class EmailClient {
|
|
37
|
-
private
|
|
38
|
-
private from;
|
|
39
|
-
private replyTo;
|
|
44
|
+
private transport;
|
|
40
45
|
private onSend;
|
|
41
|
-
constructor(
|
|
46
|
+
constructor(configOrTransport: EmailClientConfig | EmailTransport);
|
|
42
47
|
/** Register a callback invoked after each successful send (for audit logging). */
|
|
43
48
|
onEmailSent(callback: (opts: SendTemplateEmailOpts, result: EmailSendResult) => void): void;
|
|
44
49
|
/** Send a transactional email. */
|
package/dist/email/client.js
CHANGED
|
@@ -1,36 +1,64 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Email Client — Template-based transactional email sender.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Supports two backends:
|
|
5
|
+
* - **Resend**: Set RESEND_API_KEY env var
|
|
6
|
+
* - **AWS SES**: Set AWS_SES_REGION env var (+ AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
|
|
7
|
+
*
|
|
8
|
+
* SES takes priority when both are configured.
|
|
6
9
|
*/
|
|
7
10
|
import { Resend } from "resend";
|
|
8
11
|
import { logger } from "../config/logger.js";
|
|
12
|
+
import { SesTransport } from "./ses-transport.js";
|
|
9
13
|
/**
|
|
10
|
-
* Transactional email client
|
|
14
|
+
* Transactional email client with pluggable transport.
|
|
11
15
|
*
|
|
12
16
|
* Usage:
|
|
13
17
|
* ```ts
|
|
14
|
-
* const client = new EmailClient({ apiKey: "re_xxx", from: "noreply@
|
|
18
|
+
* const client = new EmailClient({ apiKey: "re_xxx", from: "noreply@example.com" });
|
|
15
19
|
* const template = verifyEmailTemplate(url, email);
|
|
16
20
|
* await client.send({ to: email, ...template, userId: "user-123", templateName: "verify-email" });
|
|
17
21
|
* ```
|
|
18
22
|
*/
|
|
19
23
|
export class EmailClient {
|
|
24
|
+
transport;
|
|
25
|
+
onSend = null;
|
|
26
|
+
constructor(configOrTransport) {
|
|
27
|
+
if ("send" in configOrTransport) {
|
|
28
|
+
this.transport = configOrTransport;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
this.transport = new ResendTransport(configOrTransport);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Register a callback invoked after each successful send (for audit logging). */
|
|
35
|
+
onEmailSent(callback) {
|
|
36
|
+
this.onSend = callback;
|
|
37
|
+
}
|
|
38
|
+
/** Send a transactional email. */
|
|
39
|
+
async send(opts) {
|
|
40
|
+
const result = await this.transport.send(opts);
|
|
41
|
+
if (this.onSend) {
|
|
42
|
+
try {
|
|
43
|
+
this.onSend(opts, result);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Audit callback failure should not break email sending
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Resend-backed transport (original implementation). */
|
|
53
|
+
class ResendTransport {
|
|
20
54
|
resend;
|
|
21
55
|
from;
|
|
22
56
|
replyTo;
|
|
23
|
-
onSend = null;
|
|
24
57
|
constructor(config) {
|
|
25
58
|
this.resend = new Resend(config.apiKey);
|
|
26
59
|
this.from = config.from;
|
|
27
60
|
this.replyTo = config.replyTo;
|
|
28
61
|
}
|
|
29
|
-
/** Register a callback invoked after each successful send (for audit logging). */
|
|
30
|
-
onEmailSent(callback) {
|
|
31
|
-
this.onSend = callback;
|
|
32
|
-
}
|
|
33
|
-
/** Send a transactional email. */
|
|
34
62
|
async send(opts) {
|
|
35
63
|
const { data, error } = await this.resend.emails.send({
|
|
36
64
|
from: this.from,
|
|
@@ -41,7 +69,7 @@ export class EmailClient {
|
|
|
41
69
|
text: opts.text,
|
|
42
70
|
});
|
|
43
71
|
if (error) {
|
|
44
|
-
logger.error("Failed to send email", {
|
|
72
|
+
logger.error("Failed to send email via Resend", {
|
|
45
73
|
to: opts.to,
|
|
46
74
|
template: opts.templateName,
|
|
47
75
|
error: error.message,
|
|
@@ -52,43 +80,63 @@ export class EmailClient {
|
|
|
52
80
|
id: data?.id || "",
|
|
53
81
|
success: true,
|
|
54
82
|
};
|
|
55
|
-
logger.info("Email sent", {
|
|
83
|
+
logger.info("Email sent via Resend", {
|
|
56
84
|
emailId: result.id,
|
|
57
85
|
to: opts.to,
|
|
58
86
|
template: opts.templateName,
|
|
59
87
|
userId: opts.userId,
|
|
60
88
|
});
|
|
61
|
-
if (this.onSend) {
|
|
62
|
-
try {
|
|
63
|
-
this.onSend(opts, result);
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
// Audit callback failure should not break email sending
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
89
|
return result;
|
|
70
90
|
}
|
|
71
91
|
}
|
|
72
92
|
/**
|
|
73
93
|
* Create a lazily-initialized singleton EmailClient from environment variables.
|
|
74
94
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
95
|
+
* Backend selection (first match wins):
|
|
96
|
+
* 1. AWS SES — AWS_SES_REGION is set
|
|
97
|
+
* 2. Resend — RESEND_API_KEY is set
|
|
98
|
+
*
|
|
99
|
+
* Common env vars:
|
|
100
|
+
* - EMAIL_FROM (default: "noreply@wopr.bot") — sender address
|
|
101
|
+
* - EMAIL_REPLY_TO (default: "support@wopr.bot") — reply-to address
|
|
102
|
+
*
|
|
103
|
+
* SES env vars:
|
|
104
|
+
* - AWS_SES_REGION (e.g. "us-east-1")
|
|
105
|
+
* - AWS_ACCESS_KEY_ID
|
|
106
|
+
* - AWS_SECRET_ACCESS_KEY
|
|
107
|
+
*
|
|
108
|
+
* Resend env vars:
|
|
109
|
+
* - RESEND_API_KEY
|
|
110
|
+
*
|
|
111
|
+
* Legacy env vars (still supported):
|
|
112
|
+
* - RESEND_FROM → falls back if EMAIL_FROM is not set
|
|
113
|
+
* - RESEND_REPLY_TO → falls back if EMAIL_REPLY_TO is not set
|
|
79
114
|
*/
|
|
80
115
|
let _client = null;
|
|
81
116
|
export function getEmailClient() {
|
|
82
117
|
if (!_client) {
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
118
|
+
const from = process.env.EMAIL_FROM || process.env.RESEND_FROM || "noreply@wopr.bot";
|
|
119
|
+
const replyTo = process.env.EMAIL_REPLY_TO || process.env.RESEND_REPLY_TO || "support@wopr.bot";
|
|
120
|
+
const sesRegion = process.env.AWS_SES_REGION;
|
|
121
|
+
if (sesRegion) {
|
|
122
|
+
const transport = new SesTransport({
|
|
123
|
+
region: sesRegion,
|
|
124
|
+
from,
|
|
125
|
+
replyTo,
|
|
126
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
127
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
128
|
+
});
|
|
129
|
+
_client = new EmailClient(transport);
|
|
130
|
+
logger.info("Email client initialized with AWS SES", { region: sesRegion, from });
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const apiKey = process.env.RESEND_API_KEY;
|
|
134
|
+
if (!apiKey) {
|
|
135
|
+
throw new Error("Either AWS_SES_REGION or RESEND_API_KEY environment variable is required");
|
|
136
|
+
}
|
|
137
|
+
_client = new EmailClient({ apiKey, from, replyTo });
|
|
138
|
+
logger.info("Email client initialized with Resend", { from });
|
|
86
139
|
}
|
|
87
|
-
_client = new EmailClient({
|
|
88
|
-
apiKey,
|
|
89
|
-
from: process.env.RESEND_FROM || "noreply@wopr.bot",
|
|
90
|
-
replyTo: process.env.RESEND_REPLY_TO || "support@wopr.bot",
|
|
91
|
-
});
|
|
92
140
|
}
|
|
93
141
|
return _client;
|
|
94
142
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS SES Email Transport.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in alternative to Resend for transactional email.
|
|
5
|
+
* Activated when AWS_SES_REGION env var is set.
|
|
6
|
+
*
|
|
7
|
+
* Required env vars:
|
|
8
|
+
* - AWS_SES_REGION (e.g. "us-east-1")
|
|
9
|
+
* - AWS_ACCESS_KEY_ID
|
|
10
|
+
* - AWS_SECRET_ACCESS_KEY
|
|
11
|
+
*/
|
|
12
|
+
import type { EmailSendResult, EmailTransport, SendTemplateEmailOpts } from "./client.js";
|
|
13
|
+
export interface SesTransportConfig {
|
|
14
|
+
region: string;
|
|
15
|
+
from: string;
|
|
16
|
+
replyTo?: string;
|
|
17
|
+
accessKeyId?: string;
|
|
18
|
+
secretAccessKey?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare class SesTransport implements EmailTransport {
|
|
21
|
+
private client;
|
|
22
|
+
private from;
|
|
23
|
+
private replyTo;
|
|
24
|
+
constructor(config: SesTransportConfig);
|
|
25
|
+
send(opts: SendTemplateEmailOpts): Promise<EmailSendResult>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS SES Email Transport.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in alternative to Resend for transactional email.
|
|
5
|
+
* Activated when AWS_SES_REGION env var is set.
|
|
6
|
+
*
|
|
7
|
+
* Required env vars:
|
|
8
|
+
* - AWS_SES_REGION (e.g. "us-east-1")
|
|
9
|
+
* - AWS_ACCESS_KEY_ID
|
|
10
|
+
* - AWS_SECRET_ACCESS_KEY
|
|
11
|
+
*/
|
|
12
|
+
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
|
|
13
|
+
import { logger } from "../config/logger.js";
|
|
14
|
+
export class SesTransport {
|
|
15
|
+
client;
|
|
16
|
+
from;
|
|
17
|
+
replyTo;
|
|
18
|
+
constructor(config) {
|
|
19
|
+
const credentials = config.accessKeyId && config.secretAccessKey
|
|
20
|
+
? { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }
|
|
21
|
+
: undefined;
|
|
22
|
+
this.client = new SESClient({
|
|
23
|
+
region: config.region,
|
|
24
|
+
...(credentials ? { credentials } : {}),
|
|
25
|
+
});
|
|
26
|
+
this.from = config.from;
|
|
27
|
+
this.replyTo = config.replyTo;
|
|
28
|
+
}
|
|
29
|
+
async send(opts) {
|
|
30
|
+
const command = new SendEmailCommand({
|
|
31
|
+
Source: this.from,
|
|
32
|
+
ReplyToAddresses: this.replyTo ? [this.replyTo] : undefined,
|
|
33
|
+
Destination: { ToAddresses: [opts.to] },
|
|
34
|
+
Message: {
|
|
35
|
+
Subject: { Data: opts.subject, Charset: "UTF-8" },
|
|
36
|
+
Body: {
|
|
37
|
+
Html: { Data: opts.html, Charset: "UTF-8" },
|
|
38
|
+
...(opts.text ? { Text: { Data: opts.text, Charset: "UTF-8" } } : {}),
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
try {
|
|
43
|
+
const response = await this.client.send(command);
|
|
44
|
+
const messageId = response.MessageId || "";
|
|
45
|
+
logger.info("Email sent via SES", {
|
|
46
|
+
messageId,
|
|
47
|
+
to: opts.to,
|
|
48
|
+
template: opts.templateName,
|
|
49
|
+
userId: opts.userId,
|
|
50
|
+
});
|
|
51
|
+
return { id: messageId, success: true };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
logger.error("Failed to send email via SES", {
|
|
55
|
+
to: opts.to,
|
|
56
|
+
template: opts.templateName,
|
|
57
|
+
userId: opts.userId,
|
|
58
|
+
error: error instanceof Error ? error.message : String(error),
|
|
59
|
+
});
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wopr-network/platform-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.51.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -128,6 +128,7 @@
|
|
|
128
128
|
},
|
|
129
129
|
"packageManager": "pnpm@10.31.0",
|
|
130
130
|
"dependencies": {
|
|
131
|
+
"@aws-sdk/client-ses": "^3.1014.0",
|
|
131
132
|
"@hono/node-server": "^1.19.11",
|
|
132
133
|
"@noble/hashes": "^2.0.1",
|
|
133
134
|
"@scure/base": "^2.0.0",
|
package/src/auth/better-auth.ts
CHANGED
|
@@ -284,6 +284,9 @@ function authOptions(cfg: BetterAuthConfig): BetterAuthOptions {
|
|
|
284
284
|
session_token: {
|
|
285
285
|
attributes: cookieDomain ? { domain: cookieDomain } : {},
|
|
286
286
|
},
|
|
287
|
+
session_data: {
|
|
288
|
+
attributes: cookieDomain ? { domain: cookieDomain } : {},
|
|
289
|
+
},
|
|
287
290
|
},
|
|
288
291
|
},
|
|
289
292
|
plugins: cfg.twoFactor !== false ? [twoFactor()] : [],
|
package/src/email/client.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Email Client — Template-based transactional email sender.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Supports two backends:
|
|
5
|
+
* - **Resend**: Set RESEND_API_KEY env var
|
|
6
|
+
* - **AWS SES**: Set AWS_SES_REGION env var (+ AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
|
|
7
|
+
*
|
|
8
|
+
* SES takes priority when both are configured.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import { Resend } from "resend";
|
|
9
12
|
import { logger } from "../config/logger.js";
|
|
13
|
+
import { SesTransport } from "./ses-transport.js";
|
|
10
14
|
|
|
11
15
|
export interface EmailClientConfig {
|
|
12
16
|
apiKey: string;
|
|
@@ -30,26 +34,31 @@ export interface EmailSendResult {
|
|
|
30
34
|
success: boolean;
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
/** Transport abstraction — any backend that can send an email. */
|
|
38
|
+
export interface EmailTransport {
|
|
39
|
+
send(opts: SendTemplateEmailOpts): Promise<EmailSendResult>;
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
/**
|
|
34
|
-
* Transactional email client
|
|
43
|
+
* Transactional email client with pluggable transport.
|
|
35
44
|
*
|
|
36
45
|
* Usage:
|
|
37
46
|
* ```ts
|
|
38
|
-
* const client = new EmailClient({ apiKey: "re_xxx", from: "noreply@
|
|
47
|
+
* const client = new EmailClient({ apiKey: "re_xxx", from: "noreply@example.com" });
|
|
39
48
|
* const template = verifyEmailTemplate(url, email);
|
|
40
49
|
* await client.send({ to: email, ...template, userId: "user-123", templateName: "verify-email" });
|
|
41
50
|
* ```
|
|
42
51
|
*/
|
|
43
52
|
export class EmailClient {
|
|
44
|
-
private
|
|
45
|
-
private from: string;
|
|
46
|
-
private replyTo: string | undefined;
|
|
53
|
+
private transport: EmailTransport;
|
|
47
54
|
private onSend: ((opts: SendTemplateEmailOpts, result: EmailSendResult) => void) | null = null;
|
|
48
55
|
|
|
49
|
-
constructor(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
constructor(configOrTransport: EmailClientConfig | EmailTransport) {
|
|
57
|
+
if ("send" in configOrTransport) {
|
|
58
|
+
this.transport = configOrTransport;
|
|
59
|
+
} else {
|
|
60
|
+
this.transport = new ResendTransport(configOrTransport);
|
|
61
|
+
}
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
/** Register a callback invoked after each successful send (for audit logging). */
|
|
@@ -58,6 +67,33 @@ export class EmailClient {
|
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
/** Send a transactional email. */
|
|
70
|
+
async send(opts: SendTemplateEmailOpts): Promise<EmailSendResult> {
|
|
71
|
+
const result = await this.transport.send(opts);
|
|
72
|
+
|
|
73
|
+
if (this.onSend) {
|
|
74
|
+
try {
|
|
75
|
+
this.onSend(opts, result);
|
|
76
|
+
} catch {
|
|
77
|
+
// Audit callback failure should not break email sending
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Resend-backed transport (original implementation). */
|
|
86
|
+
class ResendTransport implements EmailTransport {
|
|
87
|
+
private resend: Resend;
|
|
88
|
+
private from: string;
|
|
89
|
+
private replyTo: string | undefined;
|
|
90
|
+
|
|
91
|
+
constructor(config: EmailClientConfig) {
|
|
92
|
+
this.resend = new Resend(config.apiKey);
|
|
93
|
+
this.from = config.from;
|
|
94
|
+
this.replyTo = config.replyTo;
|
|
95
|
+
}
|
|
96
|
+
|
|
61
97
|
async send(opts: SendTemplateEmailOpts): Promise<EmailSendResult> {
|
|
62
98
|
const { data, error } = await this.resend.emails.send({
|
|
63
99
|
from: this.from,
|
|
@@ -69,7 +105,7 @@ export class EmailClient {
|
|
|
69
105
|
});
|
|
70
106
|
|
|
71
107
|
if (error) {
|
|
72
|
-
logger.error("Failed to send email", {
|
|
108
|
+
logger.error("Failed to send email via Resend", {
|
|
73
109
|
to: opts.to,
|
|
74
110
|
template: opts.templateName,
|
|
75
111
|
error: error.message,
|
|
@@ -82,21 +118,13 @@ export class EmailClient {
|
|
|
82
118
|
success: true,
|
|
83
119
|
};
|
|
84
120
|
|
|
85
|
-
logger.info("Email sent", {
|
|
121
|
+
logger.info("Email sent via Resend", {
|
|
86
122
|
emailId: result.id,
|
|
87
123
|
to: opts.to,
|
|
88
124
|
template: opts.templateName,
|
|
89
125
|
userId: opts.userId,
|
|
90
126
|
});
|
|
91
127
|
|
|
92
|
-
if (this.onSend) {
|
|
93
|
-
try {
|
|
94
|
-
this.onSend(opts, result);
|
|
95
|
-
} catch {
|
|
96
|
-
// Audit callback failure should not break email sending
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
128
|
return result;
|
|
101
129
|
}
|
|
102
130
|
}
|
|
@@ -104,24 +132,52 @@ export class EmailClient {
|
|
|
104
132
|
/**
|
|
105
133
|
* Create a lazily-initialized singleton EmailClient from environment variables.
|
|
106
134
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
135
|
+
* Backend selection (first match wins):
|
|
136
|
+
* 1. AWS SES — AWS_SES_REGION is set
|
|
137
|
+
* 2. Resend — RESEND_API_KEY is set
|
|
138
|
+
*
|
|
139
|
+
* Common env vars:
|
|
140
|
+
* - EMAIL_FROM (default: "noreply@wopr.bot") — sender address
|
|
141
|
+
* - EMAIL_REPLY_TO (default: "support@wopr.bot") — reply-to address
|
|
142
|
+
*
|
|
143
|
+
* SES env vars:
|
|
144
|
+
* - AWS_SES_REGION (e.g. "us-east-1")
|
|
145
|
+
* - AWS_ACCESS_KEY_ID
|
|
146
|
+
* - AWS_SECRET_ACCESS_KEY
|
|
147
|
+
*
|
|
148
|
+
* Resend env vars:
|
|
149
|
+
* - RESEND_API_KEY
|
|
150
|
+
*
|
|
151
|
+
* Legacy env vars (still supported):
|
|
152
|
+
* - RESEND_FROM → falls back if EMAIL_FROM is not set
|
|
153
|
+
* - RESEND_REPLY_TO → falls back if EMAIL_REPLY_TO is not set
|
|
111
154
|
*/
|
|
112
155
|
let _client: EmailClient | null = null;
|
|
113
156
|
|
|
114
157
|
export function getEmailClient(): EmailClient {
|
|
115
158
|
if (!_client) {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
159
|
+
const from = process.env.EMAIL_FROM || process.env.RESEND_FROM || "noreply@wopr.bot";
|
|
160
|
+
const replyTo = process.env.EMAIL_REPLY_TO || process.env.RESEND_REPLY_TO || "support@wopr.bot";
|
|
161
|
+
|
|
162
|
+
const sesRegion = process.env.AWS_SES_REGION;
|
|
163
|
+
if (sesRegion) {
|
|
164
|
+
const transport = new SesTransport({
|
|
165
|
+
region: sesRegion,
|
|
166
|
+
from,
|
|
167
|
+
replyTo,
|
|
168
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
169
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
170
|
+
});
|
|
171
|
+
_client = new EmailClient(transport);
|
|
172
|
+
logger.info("Email client initialized with AWS SES", { region: sesRegion, from });
|
|
173
|
+
} else {
|
|
174
|
+
const apiKey = process.env.RESEND_API_KEY;
|
|
175
|
+
if (!apiKey) {
|
|
176
|
+
throw new Error("Either AWS_SES_REGION or RESEND_API_KEY environment variable is required");
|
|
177
|
+
}
|
|
178
|
+
_client = new EmailClient({ apiKey, from, replyTo });
|
|
179
|
+
logger.info("Email client initialized with Resend", { from });
|
|
119
180
|
}
|
|
120
|
-
_client = new EmailClient({
|
|
121
|
-
apiKey,
|
|
122
|
-
from: process.env.RESEND_FROM || "noreply@wopr.bot",
|
|
123
|
-
replyTo: process.env.RESEND_REPLY_TO || "support@wopr.bot",
|
|
124
|
-
});
|
|
125
181
|
}
|
|
126
182
|
return _client;
|
|
127
183
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS SES Email Transport.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in alternative to Resend for transactional email.
|
|
5
|
+
* Activated when AWS_SES_REGION env var is set.
|
|
6
|
+
*
|
|
7
|
+
* Required env vars:
|
|
8
|
+
* - AWS_SES_REGION (e.g. "us-east-1")
|
|
9
|
+
* - AWS_ACCESS_KEY_ID
|
|
10
|
+
* - AWS_SECRET_ACCESS_KEY
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
|
|
14
|
+
import { logger } from "../config/logger.js";
|
|
15
|
+
import type { EmailSendResult, EmailTransport, SendTemplateEmailOpts } from "./client.js";
|
|
16
|
+
|
|
17
|
+
export interface SesTransportConfig {
|
|
18
|
+
region: string;
|
|
19
|
+
from: string;
|
|
20
|
+
replyTo?: string;
|
|
21
|
+
accessKeyId?: string;
|
|
22
|
+
secretAccessKey?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class SesTransport implements EmailTransport {
|
|
26
|
+
private client: SESClient;
|
|
27
|
+
private from: string;
|
|
28
|
+
private replyTo: string | undefined;
|
|
29
|
+
|
|
30
|
+
constructor(config: SesTransportConfig) {
|
|
31
|
+
const credentials =
|
|
32
|
+
config.accessKeyId && config.secretAccessKey
|
|
33
|
+
? { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }
|
|
34
|
+
: undefined;
|
|
35
|
+
|
|
36
|
+
this.client = new SESClient({
|
|
37
|
+
region: config.region,
|
|
38
|
+
...(credentials ? { credentials } : {}),
|
|
39
|
+
});
|
|
40
|
+
this.from = config.from;
|
|
41
|
+
this.replyTo = config.replyTo;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async send(opts: SendTemplateEmailOpts): Promise<EmailSendResult> {
|
|
45
|
+
const command = new SendEmailCommand({
|
|
46
|
+
Source: this.from,
|
|
47
|
+
ReplyToAddresses: this.replyTo ? [this.replyTo] : undefined,
|
|
48
|
+
Destination: { ToAddresses: [opts.to] },
|
|
49
|
+
Message: {
|
|
50
|
+
Subject: { Data: opts.subject, Charset: "UTF-8" },
|
|
51
|
+
Body: {
|
|
52
|
+
Html: { Data: opts.html, Charset: "UTF-8" },
|
|
53
|
+
...(opts.text ? { Text: { Data: opts.text, Charset: "UTF-8" } } : {}),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const response = await this.client.send(command);
|
|
60
|
+
const messageId = response.MessageId || "";
|
|
61
|
+
|
|
62
|
+
logger.info("Email sent via SES", {
|
|
63
|
+
messageId,
|
|
64
|
+
to: opts.to,
|
|
65
|
+
template: opts.templateName,
|
|
66
|
+
userId: opts.userId,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return { id: messageId, success: true };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
logger.error("Failed to send email via SES", {
|
|
72
|
+
to: opts.to,
|
|
73
|
+
template: opts.templateName,
|
|
74
|
+
userId: opts.userId,
|
|
75
|
+
error: error instanceof Error ? error.message : String(error),
|
|
76
|
+
});
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|