@wopr-network/platform-core 1.61.1 → 1.62.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.
@@ -284,8 +284,15 @@ export async function startWatchers(opts) {
284
284
  if (!method.rpcUrl)
285
285
  continue;
286
286
  const rpcCall = createRpcCaller(method.rpcUrl);
287
- const latestHex = (await rpcCall("eth_blockNumber", []));
288
- const latestBlock = Number.parseInt(latestHex, 16);
287
+ let latestBlock;
288
+ try {
289
+ const latestHex = (await rpcCall("eth_blockNumber", []));
290
+ latestBlock = Number.parseInt(latestHex, 16);
291
+ }
292
+ catch (err) {
293
+ log("Skipping ETH watcher — RPC unreachable", { chain: method.chain, token: method.token, error: String(err) });
294
+ continue;
295
+ }
289
296
  const backfillStart = Math.max(0, latestBlock - BACKFILL_BLOCKS);
290
297
  const activeAddresses = await chargeStore.listActiveDepositAddresses();
291
298
  // Only watch addresses for native charges on this chain (not ERC20 charges)
@@ -346,8 +353,15 @@ export async function startWatchers(opts) {
346
353
  if (!method.rpcUrl || !method.contractAddress)
347
354
  continue;
348
355
  const rpcCall = createRpcCaller(method.rpcUrl);
349
- const latestHex = (await rpcCall("eth_blockNumber", []));
350
- const latestBlock = Number.parseInt(latestHex, 16);
356
+ let latestBlock;
357
+ try {
358
+ const latestHex = (await rpcCall("eth_blockNumber", []));
359
+ latestBlock = Number.parseInt(latestHex, 16);
360
+ }
361
+ catch (err) {
362
+ log("Skipping EVM watcher — RPC unreachable", { chain: method.chain, token: method.token, error: String(err) });
363
+ continue;
364
+ }
351
365
  const activeAddresses = await chargeStore.listActiveDepositAddresses();
352
366
  const chainAddresses = activeAddresses.filter((a) => a.chain === method.chain).map((a) => a.address);
353
367
  const watcher = new EvmWatcher({
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * Email Client — Template-based transactional email sender.
3
3
  *
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.
4
+ * Supports three backends (first match wins):
5
+ * 1. **AWS SES**: Set AWS_SES_REGION env var (+ AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
6
+ * 2. **Postmark**: Set POSTMARK_API_KEY env var
7
+ * 3. **Resend**: Set RESEND_API_KEY env var
9
8
  */
10
9
  export interface EmailClientConfig {
11
10
  apiKey: string;
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * Email Client — Template-based transactional email sender.
3
3
  *
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.
4
+ * Supports three backends (first match wins):
5
+ * 1. **AWS SES**: Set AWS_SES_REGION env var (+ AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
6
+ * 2. **Postmark**: Set POSTMARK_API_KEY env var
7
+ * 3. **Resend**: Set RESEND_API_KEY env var
9
8
  */
10
9
  import { Resend } from "resend";
11
10
  import { logger } from "../config/logger.js";
11
+ import { PostmarkTransport } from "./postmark-transport.js";
12
12
  import { SesTransport } from "./ses-transport.js";
13
13
  /**
14
14
  * Transactional email client with pluggable transport.
@@ -94,7 +94,8 @@ class ResendTransport {
94
94
  *
95
95
  * Backend selection (first match wins):
96
96
  * 1. AWS SES — AWS_SES_REGION is set
97
- * 2. ResendRESEND_API_KEY is set
97
+ * 2. PostmarkPOSTMARK_API_KEY is set
98
+ * 3. Resend — RESEND_API_KEY is set
98
99
  *
99
100
  * Common env vars:
100
101
  * - EMAIL_FROM (default: "noreply@wopr.bot") — sender address
@@ -105,6 +106,9 @@ class ResendTransport {
105
106
  * - AWS_ACCESS_KEY_ID
106
107
  * - AWS_SECRET_ACCESS_KEY
107
108
  *
109
+ * Postmark env vars:
110
+ * - POSTMARK_API_KEY (server token from Postmark dashboard)
111
+ *
108
112
  * Resend env vars:
109
113
  * - RESEND_API_KEY
110
114
  *
@@ -129,10 +133,19 @@ export function getEmailClient() {
129
133
  _client = new EmailClient(transport);
130
134
  logger.info("Email client initialized with AWS SES", { region: sesRegion, from });
131
135
  }
136
+ else if (process.env.POSTMARK_API_KEY) {
137
+ const transport = new PostmarkTransport({
138
+ apiKey: process.env.POSTMARK_API_KEY,
139
+ from,
140
+ replyTo,
141
+ });
142
+ _client = new EmailClient(transport);
143
+ logger.info("Email client initialized with Postmark", { from });
144
+ }
132
145
  else {
133
146
  const apiKey = process.env.RESEND_API_KEY;
134
147
  if (!apiKey) {
135
- throw new Error("Either AWS_SES_REGION or RESEND_API_KEY environment variable is required");
148
+ throw new Error("Set AWS_SES_REGION, POSTMARK_API_KEY, or RESEND_API_KEY environment variable");
136
149
  }
137
150
  _client = new EmailClient({ apiKey, from, replyTo });
138
151
  logger.info("Email client initialized with Resend", { from });
@@ -91,8 +91,8 @@ describe("getEmailClient / setEmailClient / resetEmailClient", () => {
91
91
  delete process.env.RESEND_FROM;
92
92
  delete process.env.RESEND_REPLY_TO;
93
93
  });
94
- it("should throw if RESEND_API_KEY is not set", () => {
95
- expect(() => getEmailClient()).toThrow("RESEND_API_KEY environment variable is required");
94
+ it("should throw if no email provider is configured", () => {
95
+ expect(() => getEmailClient()).toThrow("Set AWS_SES_REGION, POSTMARK_API_KEY, or RESEND_API_KEY");
96
96
  });
97
97
  it("should create client from env vars", () => {
98
98
  process.env.RESEND_API_KEY = "re_test123";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Postmark email transport.
3
+ *
4
+ * Env vars:
5
+ * POSTMARK_API_KEY — server API token from Postmark
6
+ */
7
+ import type { EmailSendResult, EmailTransport, SendTemplateEmailOpts } from "./client.js";
8
+ export interface PostmarkTransportConfig {
9
+ apiKey: string;
10
+ from: string;
11
+ replyTo?: string;
12
+ }
13
+ export declare class PostmarkTransport implements EmailTransport {
14
+ private client;
15
+ private from;
16
+ private replyTo;
17
+ constructor(config: PostmarkTransportConfig);
18
+ send(opts: SendTemplateEmailOpts): Promise<EmailSendResult>;
19
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Postmark email transport.
3
+ *
4
+ * Env vars:
5
+ * POSTMARK_API_KEY — server API token from Postmark
6
+ */
7
+ import { ServerClient } from "postmark";
8
+ import { logger } from "../config/logger.js";
9
+ export class PostmarkTransport {
10
+ client;
11
+ from;
12
+ replyTo;
13
+ constructor(config) {
14
+ this.client = new ServerClient(config.apiKey);
15
+ this.from = config.from;
16
+ this.replyTo = config.replyTo;
17
+ }
18
+ async send(opts) {
19
+ const result = await this.client.sendEmail({
20
+ From: this.from,
21
+ To: opts.to,
22
+ Subject: opts.subject,
23
+ HtmlBody: opts.html,
24
+ TextBody: opts.text,
25
+ ReplyTo: this.replyTo,
26
+ MessageStream: "outbound",
27
+ });
28
+ if (result.ErrorCode !== 0) {
29
+ logger.error("Failed to send email via Postmark", {
30
+ to: opts.to,
31
+ template: opts.templateName,
32
+ error: result.Message,
33
+ code: result.ErrorCode,
34
+ });
35
+ throw new Error(`Postmark error ${result.ErrorCode}: ${result.Message}`);
36
+ }
37
+ logger.info("Email sent via Postmark", {
38
+ emailId: result.MessageID,
39
+ to: opts.to,
40
+ template: opts.templateName,
41
+ userId: opts.userId,
42
+ });
43
+ return { id: result.MessageID, success: true };
44
+ }
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.61.1",
3
+ "version": "1.62.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -138,6 +138,7 @@
138
138
  "@scure/bip39": "^2.0.1",
139
139
  "handlebars": "^4.7.8",
140
140
  "js-yaml": "^4.1.1",
141
+ "postmark": "^4.0.7",
141
142
  "viem": "^2.47.4",
142
143
  "yaml": "^2.8.2"
143
144
  }
@@ -373,8 +373,14 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
373
373
  if (!method.rpcUrl) continue;
374
374
 
375
375
  const rpcCall = createRpcCaller(method.rpcUrl);
376
- const latestHex = (await rpcCall("eth_blockNumber", [])) as string;
377
- const latestBlock = Number.parseInt(latestHex, 16);
376
+ let latestBlock: number;
377
+ try {
378
+ const latestHex = (await rpcCall("eth_blockNumber", [])) as string;
379
+ latestBlock = Number.parseInt(latestHex, 16);
380
+ } catch (err) {
381
+ log("Skipping ETH watcher — RPC unreachable", { chain: method.chain, token: method.token, error: String(err) });
382
+ continue;
383
+ }
378
384
  const backfillStart = Math.max(0, latestBlock - BACKFILL_BLOCKS);
379
385
 
380
386
  const activeAddresses = await chargeStore.listActiveDepositAddresses();
@@ -446,8 +452,14 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
446
452
  if (!method.rpcUrl || !method.contractAddress) continue;
447
453
 
448
454
  const rpcCall = createRpcCaller(method.rpcUrl);
449
- const latestHex = (await rpcCall("eth_blockNumber", [])) as string;
450
- const latestBlock = Number.parseInt(latestHex, 16);
455
+ let latestBlock: number;
456
+ try {
457
+ const latestHex = (await rpcCall("eth_blockNumber", [])) as string;
458
+ latestBlock = Number.parseInt(latestHex, 16);
459
+ } catch (err) {
460
+ log("Skipping EVM watcher — RPC unreachable", { chain: method.chain, token: method.token, error: String(err) });
461
+ continue;
462
+ }
451
463
 
452
464
  const activeAddresses = await chargeStore.listActiveDepositAddresses();
453
465
  const chainAddresses = activeAddresses.filter((a) => a.chain === method.chain).map((a) => a.address);
@@ -116,8 +116,8 @@ describe("getEmailClient / setEmailClient / resetEmailClient", () => {
116
116
  delete process.env.RESEND_REPLY_TO;
117
117
  });
118
118
 
119
- it("should throw if RESEND_API_KEY is not set", () => {
120
- expect(() => getEmailClient()).toThrow("RESEND_API_KEY environment variable is required");
119
+ it("should throw if no email provider is configured", () => {
120
+ expect(() => getEmailClient()).toThrow("Set AWS_SES_REGION, POSTMARK_API_KEY, or RESEND_API_KEY");
121
121
  });
122
122
 
123
123
  it("should create client from env vars", () => {
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * Email Client — Template-based transactional email sender.
3
3
  *
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.
4
+ * Supports three backends (first match wins):
5
+ * 1. **AWS SES**: Set AWS_SES_REGION env var (+ AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
6
+ * 2. **Postmark**: Set POSTMARK_API_KEY env var
7
+ * 3. **Resend**: Set RESEND_API_KEY env var
9
8
  */
10
9
 
11
10
  import { Resend } from "resend";
12
11
  import { logger } from "../config/logger.js";
12
+ import { PostmarkTransport } from "./postmark-transport.js";
13
13
  import { SesTransport } from "./ses-transport.js";
14
14
 
15
15
  export interface EmailClientConfig {
@@ -134,7 +134,8 @@ class ResendTransport implements EmailTransport {
134
134
  *
135
135
  * Backend selection (first match wins):
136
136
  * 1. AWS SES — AWS_SES_REGION is set
137
- * 2. ResendRESEND_API_KEY is set
137
+ * 2. PostmarkPOSTMARK_API_KEY is set
138
+ * 3. Resend — RESEND_API_KEY is set
138
139
  *
139
140
  * Common env vars:
140
141
  * - EMAIL_FROM (default: "noreply@wopr.bot") — sender address
@@ -145,6 +146,9 @@ class ResendTransport implements EmailTransport {
145
146
  * - AWS_ACCESS_KEY_ID
146
147
  * - AWS_SECRET_ACCESS_KEY
147
148
  *
149
+ * Postmark env vars:
150
+ * - POSTMARK_API_KEY (server token from Postmark dashboard)
151
+ *
148
152
  * Resend env vars:
149
153
  * - RESEND_API_KEY
150
154
  *
@@ -170,10 +174,18 @@ export function getEmailClient(): EmailClient {
170
174
  });
171
175
  _client = new EmailClient(transport);
172
176
  logger.info("Email client initialized with AWS SES", { region: sesRegion, from });
177
+ } else if (process.env.POSTMARK_API_KEY) {
178
+ const transport = new PostmarkTransport({
179
+ apiKey: process.env.POSTMARK_API_KEY,
180
+ from,
181
+ replyTo,
182
+ });
183
+ _client = new EmailClient(transport);
184
+ logger.info("Email client initialized with Postmark", { from });
173
185
  } else {
174
186
  const apiKey = process.env.RESEND_API_KEY;
175
187
  if (!apiKey) {
176
- throw new Error("Either AWS_SES_REGION or RESEND_API_KEY environment variable is required");
188
+ throw new Error("Set AWS_SES_REGION, POSTMARK_API_KEY, or RESEND_API_KEY environment variable");
177
189
  }
178
190
  _client = new EmailClient({ apiKey, from, replyTo });
179
191
  logger.info("Email client initialized with Resend", { from });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Postmark email transport.
3
+ *
4
+ * Env vars:
5
+ * POSTMARK_API_KEY — server API token from Postmark
6
+ */
7
+
8
+ import { ServerClient } from "postmark";
9
+ import { logger } from "../config/logger.js";
10
+ import type { EmailSendResult, EmailTransport, SendTemplateEmailOpts } from "./client.js";
11
+
12
+ export interface PostmarkTransportConfig {
13
+ apiKey: string;
14
+ from: string;
15
+ replyTo?: string;
16
+ }
17
+
18
+ export class PostmarkTransport implements EmailTransport {
19
+ private client: ServerClient;
20
+ private from: string;
21
+ private replyTo: string | undefined;
22
+
23
+ constructor(config: PostmarkTransportConfig) {
24
+ this.client = new ServerClient(config.apiKey);
25
+ this.from = config.from;
26
+ this.replyTo = config.replyTo;
27
+ }
28
+
29
+ async send(opts: SendTemplateEmailOpts): Promise<EmailSendResult> {
30
+ const result = await this.client.sendEmail({
31
+ From: this.from,
32
+ To: opts.to,
33
+ Subject: opts.subject,
34
+ HtmlBody: opts.html,
35
+ TextBody: opts.text,
36
+ ReplyTo: this.replyTo,
37
+ MessageStream: "outbound",
38
+ });
39
+
40
+ if (result.ErrorCode !== 0) {
41
+ logger.error("Failed to send email via Postmark", {
42
+ to: opts.to,
43
+ template: opts.templateName,
44
+ error: result.Message,
45
+ code: result.ErrorCode,
46
+ });
47
+ throw new Error(`Postmark error ${result.ErrorCode}: ${result.Message}`);
48
+ }
49
+
50
+ logger.info("Email sent via Postmark", {
51
+ emailId: result.MessageID,
52
+ to: opts.to,
53
+ template: opts.templateName,
54
+ userId: opts.userId,
55
+ });
56
+
57
+ return { id: result.MessageID, success: true };
58
+ }
59
+ }