@wopr-network/platform-core 1.50.2 → 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.
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Email Client — Template-based transactional email sender.
3
3
  *
4
- * Wraps Resend SDK and provides a typed interface for sending emails
5
- * using the platform's templates. Every email is logged for audit.
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 backed by Resend.
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@wopr.bot" });
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 resend;
38
- private from;
39
- private replyTo;
44
+ private transport;
40
45
  private onSend;
41
- constructor(config: EmailClientConfig);
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. */
@@ -1,36 +1,64 @@
1
1
  /**
2
2
  * Email Client — Template-based transactional email sender.
3
3
  *
4
- * Wraps Resend SDK and provides a typed interface for sending emails
5
- * using the platform's templates. Every email is logged for audit.
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 backed by Resend.
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@wopr.bot" });
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
- * Env vars:
76
- * - RESEND_API_KEY (required)
77
- * - RESEND_FROM (default: "noreply@wopr.bot")
78
- * - RESEND_REPLY_TO (default: "support@wopr.bot")
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 apiKey = process.env.RESEND_API_KEY;
84
- if (!apiKey) {
85
- throw new Error("RESEND_API_KEY environment variable is required");
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.50.2",
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",
@@ -1,12 +1,16 @@
1
1
  /**
2
2
  * Email Client — Template-based transactional email sender.
3
3
  *
4
- * Wraps Resend SDK and provides a typed interface for sending emails
5
- * using the platform's templates. Every email is logged for audit.
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 backed by Resend.
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@wopr.bot" });
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 resend: Resend;
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(config: EmailClientConfig) {
50
- this.resend = new Resend(config.apiKey);
51
- this.from = config.from;
52
- this.replyTo = config.replyTo;
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
- * Env vars:
108
- * - RESEND_API_KEY (required)
109
- * - RESEND_FROM (default: "noreply@wopr.bot")
110
- * - RESEND_REPLY_TO (default: "support@wopr.bot")
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 apiKey = process.env.RESEND_API_KEY;
117
- if (!apiKey) {
118
- throw new Error("RESEND_API_KEY environment variable is required");
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
+ }