@christian-ek/sweego 0.1.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/README.md +357 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/index.d.ts +282 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +265 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/webhook.d.ts +42 -0
- package/dist/client/webhook.d.ts.map +1 -0
- package/dist/client/webhook.js +89 -0
- package/dist/client/webhook.js.map +1 -0
- package/dist/component/_generated/api.d.ts +43 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +226 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +10 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/lib.d.ts +319 -0
- package/dist/component/lib.d.ts.map +1 -0
- package/dist/component/lib.js +725 -0
- package/dist/component/lib.js.map +1 -0
- package/dist/component/schema.d.ts +259 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +99 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/shared.d.ts +280 -0
- package/dist/component/shared.d.ts.map +1 -0
- package/dist/component/shared.js +213 -0
- package/dist/component/shared.js.map +1 -0
- package/dist/component/sweego.d.ts +95 -0
- package/dist/component/sweego.d.ts.map +1 -0
- package/dist/component/sweego.js +210 -0
- package/dist/component/sweego.js.map +1 -0
- package/dist/component/utils.d.ts +16 -0
- package/dist/component/utils.d.ts.map +1 -0
- package/dist/component/utils.js +29 -0
- package/dist/component/utils.js.map +1 -0
- package/package.json +100 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/index.ts +490 -0
- package/src/client/webhook.test.ts +146 -0
- package/src/client/webhook.ts +130 -0
- package/src/component/_generated/api.ts +59 -0
- package/src/component/_generated/component.ts +244 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/convex.config.ts +12 -0
- package/src/component/lib.test.ts +189 -0
- package/src/component/lib.ts +835 -0
- package/src/component/schema.ts +117 -0
- package/src/component/shared.test.ts +64 -0
- package/src/component/shared.ts +315 -0
- package/src/component/sweego.test.ts +141 -0
- package/src/component/sweego.ts +310 -0
- package/src/component/utils.ts +35 -0
- package/src/test.ts +20 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFunctionHandle,
|
|
3
|
+
type FunctionReference,
|
|
4
|
+
type FunctionVisibility,
|
|
5
|
+
type GenericDataModel,
|
|
6
|
+
type GenericMutationCtx,
|
|
7
|
+
internalMutationGeneric,
|
|
8
|
+
} from "convex/server";
|
|
9
|
+
import { type VString, v } from "convex/values";
|
|
10
|
+
import type { ComponentApi } from "../component/_generated/component.js";
|
|
11
|
+
import {
|
|
12
|
+
type ActionCtx,
|
|
13
|
+
type Attachment,
|
|
14
|
+
DEFAULT_PROVIDER,
|
|
15
|
+
type EmailAddress,
|
|
16
|
+
type ListUnsub,
|
|
17
|
+
type MessageInput,
|
|
18
|
+
type MutationCtx,
|
|
19
|
+
parseEmailAddress,
|
|
20
|
+
parseEmailAddresses,
|
|
21
|
+
type QueryCtx,
|
|
22
|
+
type RuntimeConfig,
|
|
23
|
+
type SmsCampaignType,
|
|
24
|
+
type SmsRecipient,
|
|
25
|
+
type SweegoEvent,
|
|
26
|
+
type Variables,
|
|
27
|
+
vSweegoEvent,
|
|
28
|
+
} from "../component/shared.js";
|
|
29
|
+
import { readWebhookHeaders, verifySweegoSignature } from "./webhook.js";
|
|
30
|
+
|
|
31
|
+
/* -------------------------------------------------------------------------- */
|
|
32
|
+
/* Re-exports for consumers */
|
|
33
|
+
/* -------------------------------------------------------------------------- */
|
|
34
|
+
|
|
35
|
+
export type SweegoComponent = ComponentApi;
|
|
36
|
+
|
|
37
|
+
// A branded id for a message stored in the component.
|
|
38
|
+
export type MessageId = string & { __isSweegoMessageId: true };
|
|
39
|
+
export const vMessageId = v.string() as VString<MessageId>;
|
|
40
|
+
|
|
41
|
+
export { vSweegoEvent } from "../component/shared.js";
|
|
42
|
+
export type {
|
|
43
|
+
Attachment,
|
|
44
|
+
Channel,
|
|
45
|
+
DeliveryStatus,
|
|
46
|
+
EmailAddress,
|
|
47
|
+
ListUnsub,
|
|
48
|
+
SendStatus,
|
|
49
|
+
SmsCampaignType,
|
|
50
|
+
SmsRecipient,
|
|
51
|
+
SweegoEvent,
|
|
52
|
+
Variables,
|
|
53
|
+
} from "../component/shared.js";
|
|
54
|
+
|
|
55
|
+
// Argument validators for an `onEvent` handler defined in the host app, for use
|
|
56
|
+
// directly as a function's `args` — e.g. `internalMutation({ args: vOnEventArgs })`.
|
|
57
|
+
// `event` is validated loosely (the component is the only caller and always
|
|
58
|
+
// sends a valid {@link SweegoEvent}); use {@link Sweego.defineEventHandler} for a
|
|
59
|
+
// fully-typed `event`.
|
|
60
|
+
export const vOnEventArgs = {
|
|
61
|
+
messageId: vMessageId,
|
|
62
|
+
swgUid: v.string(),
|
|
63
|
+
event: v.any(),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type EventHandlerRef = FunctionReference<
|
|
67
|
+
"mutation",
|
|
68
|
+
FunctionVisibility,
|
|
69
|
+
{ messageId: MessageId; swgUid: string; event: SweegoEvent }
|
|
70
|
+
>;
|
|
71
|
+
|
|
72
|
+
/* -------------------------------------------------------------------------- */
|
|
73
|
+
/* Options */
|
|
74
|
+
/* -------------------------------------------------------------------------- */
|
|
75
|
+
|
|
76
|
+
export type SweegoOptions = {
|
|
77
|
+
/** Sweego API key. Defaults to `process.env.SWEEGO_API_KEY`. */
|
|
78
|
+
apiKey?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Webhook signing secret (from the Sweego dashboard). Defaults to
|
|
81
|
+
* `process.env.SWEEGO_WEBHOOK_SECRET`. Required only to verify webhooks.
|
|
82
|
+
*/
|
|
83
|
+
webhookSecret?: string;
|
|
84
|
+
/** The Sweego provider. Defaults to "sweego". */
|
|
85
|
+
provider?: string;
|
|
86
|
+
/**
|
|
87
|
+
* When true, email sends are submitted with `dry-run` (Sweego validates but
|
|
88
|
+
* does not send) and SMS sends use BAT test mode. Defaults to false.
|
|
89
|
+
*/
|
|
90
|
+
testMode?: boolean;
|
|
91
|
+
/** Initial retry backoff in ms for the send workpool. Defaults to 30000. */
|
|
92
|
+
initialBackoffMs?: number;
|
|
93
|
+
/** Max send attempts before giving up. Defaults to 5. */
|
|
94
|
+
retryAttempts?: number;
|
|
95
|
+
/**
|
|
96
|
+
* Reject webhooks whose timestamp differs from now by more than this many
|
|
97
|
+
* seconds (replay protection). Defaults to 300 (5 minutes), per the Standard
|
|
98
|
+
* Webhooks recommendation. Set to 0 to disable the timestamp check (not
|
|
99
|
+
* recommended — webhook-id deduplication still applies either way).
|
|
100
|
+
*/
|
|
101
|
+
webhookToleranceSeconds?: number;
|
|
102
|
+
/** A mutation in your app to run after each delivery event. */
|
|
103
|
+
onEvent?: EventHandlerRef | null;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type ResolvedConfig = {
|
|
107
|
+
provider: string;
|
|
108
|
+
testMode: boolean;
|
|
109
|
+
initialBackoffMs: number;
|
|
110
|
+
retryAttempts: number;
|
|
111
|
+
webhookToleranceSeconds: number;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/* -------------------------------------------------------------------------- */
|
|
115
|
+
/* Send option types */
|
|
116
|
+
/* -------------------------------------------------------------------------- */
|
|
117
|
+
|
|
118
|
+
type AddressInput = string | EmailAddress;
|
|
119
|
+
|
|
120
|
+
type CommonEmailOptions = {
|
|
121
|
+
/** Plain-text body. Required unless `templateId` is set. */
|
|
122
|
+
text?: string;
|
|
123
|
+
/**
|
|
124
|
+
* Supplementary HTML body. Sweego rejects an email sent with `html` but no
|
|
125
|
+
* `text`/`templateId` — always pair `html` with `text` (or use a template).
|
|
126
|
+
*/
|
|
127
|
+
html?: string;
|
|
128
|
+
templateId?: string;
|
|
129
|
+
attachments?: Attachment[];
|
|
130
|
+
headers?: Record<string, string>;
|
|
131
|
+
listUnsub?: ListUnsub;
|
|
132
|
+
expires?: string;
|
|
133
|
+
campaignId?: string;
|
|
134
|
+
campaignTags?: string[];
|
|
135
|
+
campaignType?: "market" | "newsletter" | "transac";
|
|
136
|
+
compressStyle?: boolean;
|
|
137
|
+
forceInlineStyle?: boolean;
|
|
138
|
+
dryRun?: boolean;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export type SendEmailOptions = CommonEmailOptions & {
|
|
142
|
+
from: AddressInput;
|
|
143
|
+
to: AddressInput | AddressInput[];
|
|
144
|
+
cc?: AddressInput | AddressInput[];
|
|
145
|
+
bcc?: AddressInput | AddressInput[];
|
|
146
|
+
replyTo?: AddressInput;
|
|
147
|
+
subject?: string;
|
|
148
|
+
/** Template variables. Only applied for a single recipient on /send. */
|
|
149
|
+
variables?: Variables;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export type BulkEmailRecipient = {
|
|
153
|
+
email: string;
|
|
154
|
+
name?: string;
|
|
155
|
+
variables?: Variables;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export type SendBulkEmailOptions = CommonEmailOptions & {
|
|
159
|
+
from: AddressInput;
|
|
160
|
+
subject?: string;
|
|
161
|
+
/** At least 2 recipients; each may carry its own `variables`. */
|
|
162
|
+
recipients: Array<BulkEmailRecipient | string>;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export type SendSmsOptions = {
|
|
166
|
+
to: SmsRecipient | SmsRecipient[] | string | string[];
|
|
167
|
+
/** Default ISO region (e.g. "FR") for recipients given as bare strings. */
|
|
168
|
+
region?: string;
|
|
169
|
+
text?: string;
|
|
170
|
+
templateId?: string;
|
|
171
|
+
variables?: Variables;
|
|
172
|
+
/** REQUIRED by Sweego for SMS. */
|
|
173
|
+
campaignType: SmsCampaignType;
|
|
174
|
+
senderId?: string;
|
|
175
|
+
shortenUrls?: boolean;
|
|
176
|
+
shortenWithProtocol?: boolean;
|
|
177
|
+
/** BAT test mode. */
|
|
178
|
+
bat?: boolean;
|
|
179
|
+
campaignId?: string;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/* -------------------------------------------------------------------------- */
|
|
183
|
+
/* Client */
|
|
184
|
+
/* -------------------------------------------------------------------------- */
|
|
185
|
+
|
|
186
|
+
export class Sweego {
|
|
187
|
+
public readonly config: ResolvedConfig;
|
|
188
|
+
private readonly _apiKey?: string;
|
|
189
|
+
private readonly _webhookSecret?: string;
|
|
190
|
+
public readonly onEvent?: EventHandlerRef | null;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param component The mounted component, e.g. `components.sweego`.
|
|
194
|
+
* @param options {@link SweegoOptions}.
|
|
195
|
+
*/
|
|
196
|
+
constructor(
|
|
197
|
+
public component: SweegoComponent,
|
|
198
|
+
options?: SweegoOptions,
|
|
199
|
+
) {
|
|
200
|
+
this._apiKey = options?.apiKey;
|
|
201
|
+
this._webhookSecret = options?.webhookSecret;
|
|
202
|
+
this.onEvent = options?.onEvent;
|
|
203
|
+
this.config = {
|
|
204
|
+
provider: options?.provider ?? DEFAULT_PROVIDER,
|
|
205
|
+
testMode: options?.testMode ?? false,
|
|
206
|
+
initialBackoffMs: options?.initialBackoffMs ?? 30000,
|
|
207
|
+
retryAttempts: options?.retryAttempts ?? 5,
|
|
208
|
+
webhookToleranceSeconds: options?.webhookToleranceSeconds ?? 300,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Resolved at call time so the client can be constructed at module load.
|
|
213
|
+
private get apiKey(): string {
|
|
214
|
+
const key = this._apiKey ?? process.env.SWEEGO_API_KEY;
|
|
215
|
+
if (!key) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
"Sweego API key is not set. Pass `apiKey` or set SWEEGO_API_KEY.",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return key;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private get webhookSecret(): string {
|
|
224
|
+
const secret = this._webhookSecret ?? process.env.SWEEGO_WEBHOOK_SECRET;
|
|
225
|
+
if (!secret) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
"Sweego webhook secret is not set. Pass `webhookSecret` or set SWEEGO_WEBHOOK_SECRET.",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return secret;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async runtimeConfig(): Promise<RuntimeConfig> {
|
|
234
|
+
return {
|
|
235
|
+
apiKey: this.apiKey,
|
|
236
|
+
provider: this.config.provider,
|
|
237
|
+
initialBackoffMs: this.config.initialBackoffMs,
|
|
238
|
+
retryAttempts: this.config.retryAttempts,
|
|
239
|
+
testMode: this.config.testMode,
|
|
240
|
+
onEvent: this.onEvent
|
|
241
|
+
? { fnHandle: await createFunctionHandle(this.onEvent) }
|
|
242
|
+
: undefined,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Enqueue an email. It is sent durably (with retries) by the component's
|
|
248
|
+
* workpool. Returns the {@link MessageId} you can use to check status,
|
|
249
|
+
* cancel, or correlate webhook events.
|
|
250
|
+
*/
|
|
251
|
+
async sendEmail(
|
|
252
|
+
ctx: MutationCtx | ActionCtx,
|
|
253
|
+
options: SendEmailOptions,
|
|
254
|
+
): Promise<MessageId> {
|
|
255
|
+
const message: MessageInput = {
|
|
256
|
+
channel: "email",
|
|
257
|
+
bulk: false,
|
|
258
|
+
from: parseEmailAddress(options.from),
|
|
259
|
+
emailRecipients: parseEmailAddresses(options.to).map((a) => ({
|
|
260
|
+
email: a.email,
|
|
261
|
+
name: a.name,
|
|
262
|
+
})),
|
|
263
|
+
cc: options.cc ? parseEmailAddresses(options.cc) : undefined,
|
|
264
|
+
bcc: options.bcc ? parseEmailAddresses(options.bcc) : undefined,
|
|
265
|
+
replyTo: options.replyTo ? parseEmailAddress(options.replyTo) : undefined,
|
|
266
|
+
subject: options.subject,
|
|
267
|
+
html: options.html,
|
|
268
|
+
text: options.text,
|
|
269
|
+
templateId: options.templateId,
|
|
270
|
+
variables: options.variables,
|
|
271
|
+
attachments: options.attachments,
|
|
272
|
+
headers: options.headers,
|
|
273
|
+
listUnsub: options.listUnsub,
|
|
274
|
+
expires: options.expires,
|
|
275
|
+
campaignId: options.campaignId,
|
|
276
|
+
campaignTags: options.campaignTags,
|
|
277
|
+
campaignType: options.campaignType,
|
|
278
|
+
compressStyle: options.compressStyle,
|
|
279
|
+
forceInlineStyle: options.forceInlineStyle,
|
|
280
|
+
dryRun: options.dryRun ?? (this.config.testMode ? true : undefined),
|
|
281
|
+
};
|
|
282
|
+
return this.enqueue(ctx, message);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Enqueue a personalized bulk email (Sweego's `/send/bulk/email`). Each
|
|
287
|
+
* recipient may carry its own `variables`. No cc/bcc/replyTo support.
|
|
288
|
+
*/
|
|
289
|
+
async sendBulkEmail(
|
|
290
|
+
ctx: MutationCtx | ActionCtx,
|
|
291
|
+
options: SendBulkEmailOptions,
|
|
292
|
+
): Promise<MessageId> {
|
|
293
|
+
const message: MessageInput = {
|
|
294
|
+
channel: "email",
|
|
295
|
+
bulk: true,
|
|
296
|
+
from: parseEmailAddress(options.from),
|
|
297
|
+
emailRecipients: options.recipients.map((r) =>
|
|
298
|
+
typeof r === "string"
|
|
299
|
+
? { email: r }
|
|
300
|
+
: { email: r.email, name: r.name, variables: r.variables },
|
|
301
|
+
),
|
|
302
|
+
subject: options.subject,
|
|
303
|
+
html: options.html,
|
|
304
|
+
text: options.text,
|
|
305
|
+
templateId: options.templateId,
|
|
306
|
+
attachments: options.attachments,
|
|
307
|
+
headers: options.headers,
|
|
308
|
+
listUnsub: options.listUnsub,
|
|
309
|
+
expires: options.expires,
|
|
310
|
+
campaignId: options.campaignId,
|
|
311
|
+
campaignTags: options.campaignTags,
|
|
312
|
+
campaignType: options.campaignType,
|
|
313
|
+
compressStyle: options.compressStyle,
|
|
314
|
+
forceInlineStyle: options.forceInlineStyle,
|
|
315
|
+
dryRun: options.dryRun ?? (this.config.testMode ? true : undefined),
|
|
316
|
+
};
|
|
317
|
+
return this.enqueue(ctx, message);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Enqueue an SMS (Sweego's `/send` with `channel: "sms"`). */
|
|
321
|
+
async sendSms(
|
|
322
|
+
ctx: MutationCtx | ActionCtx,
|
|
323
|
+
options: SendSmsOptions,
|
|
324
|
+
): Promise<MessageId> {
|
|
325
|
+
const message: MessageInput = {
|
|
326
|
+
channel: "sms",
|
|
327
|
+
bulk: false,
|
|
328
|
+
smsRecipients: normalizeSmsRecipients(options.to, options.region),
|
|
329
|
+
text: options.text,
|
|
330
|
+
templateId: options.templateId,
|
|
331
|
+
variables: options.variables,
|
|
332
|
+
campaignType: options.campaignType,
|
|
333
|
+
senderId: options.senderId,
|
|
334
|
+
shortenUrls: options.shortenUrls,
|
|
335
|
+
shortenWithProtocol: options.shortenWithProtocol,
|
|
336
|
+
bat: options.bat ?? (this.config.testMode ? true : undefined),
|
|
337
|
+
campaignId: options.campaignId,
|
|
338
|
+
};
|
|
339
|
+
return this.enqueue(ctx, message);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private async enqueue(
|
|
343
|
+
ctx: MutationCtx | ActionCtx,
|
|
344
|
+
message: MessageInput,
|
|
345
|
+
): Promise<MessageId> {
|
|
346
|
+
const options = await this.runtimeConfig();
|
|
347
|
+
const id = await ctx.runMutation(this.component.lib.enqueueMessage, {
|
|
348
|
+
options,
|
|
349
|
+
message,
|
|
350
|
+
});
|
|
351
|
+
return id as MessageId;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Aggregate status of a message plus per-recipient delivery state. */
|
|
355
|
+
async status(ctx: QueryCtx | MutationCtx | ActionCtx, messageId: MessageId) {
|
|
356
|
+
return ctx.runQuery(this.component.lib.getStatus, { messageId });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The full stored message plus its deliveries. */
|
|
360
|
+
async get(ctx: QueryCtx | MutationCtx | ActionCtx, messageId: MessageId) {
|
|
361
|
+
return ctx.runQuery(this.component.lib.get, { messageId });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Cancel a message if it has not yet been handed to Sweego. Returns true if
|
|
366
|
+
* it was cancelled, false if it had already been sent.
|
|
367
|
+
*/
|
|
368
|
+
async cancel(
|
|
369
|
+
ctx: MutationCtx | ActionCtx,
|
|
370
|
+
messageId: MessageId,
|
|
371
|
+
): Promise<boolean> {
|
|
372
|
+
return ctx.runMutation(this.component.lib.cancel, { messageId });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Estimate the cost/segments of an SMS send (Sweego `/sms/estimate`). */
|
|
376
|
+
async estimateSms(
|
|
377
|
+
ctx: ActionCtx,
|
|
378
|
+
body: Record<string, unknown>,
|
|
379
|
+
): Promise<unknown> {
|
|
380
|
+
return ctx.runAction(this.component.lib.estimateSms, {
|
|
381
|
+
apiKey: this.apiKey,
|
|
382
|
+
body,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Poll Sweego's logs to refresh delivery status without webhooks. Returns the
|
|
388
|
+
* number of deliveries updated. Prefer webhooks where possible.
|
|
389
|
+
*/
|
|
390
|
+
async refreshStatus(ctx: ActionCtx, messageId: MessageId): Promise<number> {
|
|
391
|
+
return ctx.runAction(this.component.lib.refreshStatus, {
|
|
392
|
+
apiKey: this.apiKey,
|
|
393
|
+
messageId,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Verify and handle a Sweego webhook. Mount this on an HTTP route. Verifies
|
|
399
|
+
* the HMAC signature against the raw body, then updates delivery state and
|
|
400
|
+
* dispatches your `onEvent` handler.
|
|
401
|
+
*/
|
|
402
|
+
async handleSweegoWebhook(
|
|
403
|
+
ctx: MutationCtx | ActionCtx,
|
|
404
|
+
request: Request,
|
|
405
|
+
): Promise<Response> {
|
|
406
|
+
const secret = this.webhookSecret;
|
|
407
|
+
const rawBody = await request.text();
|
|
408
|
+
const { id, timestamp, signature } = readWebhookHeaders(request.headers);
|
|
409
|
+
|
|
410
|
+
const valid = await verifySweegoSignature({
|
|
411
|
+
secret,
|
|
412
|
+
id: id ?? "",
|
|
413
|
+
timestamp: timestamp ?? "",
|
|
414
|
+
body: rawBody,
|
|
415
|
+
signatureHeader: signature ?? "",
|
|
416
|
+
toleranceSeconds: this.config.webhookToleranceSeconds,
|
|
417
|
+
});
|
|
418
|
+
if (!valid) {
|
|
419
|
+
return new Response("Invalid webhook signature", { status: 401 });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
let event: unknown;
|
|
423
|
+
try {
|
|
424
|
+
event = JSON.parse(rawBody);
|
|
425
|
+
} catch {
|
|
426
|
+
// Acknowledge so Sweego doesn't retry an unparseable body forever.
|
|
427
|
+
return new Response("Invalid JSON body", { status: 200 });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Pass the webhook-id so the component can deduplicate redeliveries/replays.
|
|
431
|
+
await ctx.runMutation(this.component.lib.handleEvent, {
|
|
432
|
+
event,
|
|
433
|
+
webhookId: id ?? undefined,
|
|
434
|
+
});
|
|
435
|
+
return new Response(null, { status: 200 });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Helper to define your `onEvent` mutation with the right argument validator.
|
|
440
|
+
* Equivalent to declaring an `internalMutation` with {@link vOnEventArgs}.
|
|
441
|
+
*/
|
|
442
|
+
defineEventHandler<DataModel extends GenericDataModel>(
|
|
443
|
+
handler: (
|
|
444
|
+
ctx: GenericMutationCtx<DataModel>,
|
|
445
|
+
args: { messageId: MessageId; swgUid: string; event: SweegoEvent },
|
|
446
|
+
) => Promise<void>,
|
|
447
|
+
) {
|
|
448
|
+
return internalMutationGeneric({
|
|
449
|
+
args: {
|
|
450
|
+
messageId: vMessageId,
|
|
451
|
+
swgUid: v.string(),
|
|
452
|
+
event: vSweegoEvent,
|
|
453
|
+
},
|
|
454
|
+
handler,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/* -------------------------------------------------------------------------- */
|
|
460
|
+
/* Helpers */
|
|
461
|
+
/* -------------------------------------------------------------------------- */
|
|
462
|
+
|
|
463
|
+
function normalizeRegion(region: string): string {
|
|
464
|
+
const up = region.trim().toUpperCase();
|
|
465
|
+
// Sweego requires uppercase ISO-3166 alpha-2 codes (or the literal "OTHER").
|
|
466
|
+
if (up !== "OTHER" && !/^[A-Z]{2}$/.test(up)) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
`Invalid SMS region '${region}'. Expected an uppercase ISO-3166 alpha-2 code (e.g. 'FR') or 'OTHER'.`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
return up;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function normalizeSmsRecipients(
|
|
475
|
+
to: SmsRecipient | SmsRecipient[] | string | string[],
|
|
476
|
+
defaultRegion?: string,
|
|
477
|
+
): SmsRecipient[] {
|
|
478
|
+
const list = Array.isArray(to) ? to : [to];
|
|
479
|
+
return list.map((r) => {
|
|
480
|
+
if (typeof r === "string") {
|
|
481
|
+
if (!defaultRegion) {
|
|
482
|
+
throw new Error(
|
|
483
|
+
"SMS recipients given as strings require a `region` option (e.g. region: 'FR').",
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
return { num: r, region: normalizeRegion(defaultRegion) };
|
|
487
|
+
}
|
|
488
|
+
return { num: r.num, region: normalizeRegion(r.region) };
|
|
489
|
+
});
|
|
490
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { timingSafeEqual, verifySweegoSignature } from "./webhook.js";
|
|
3
|
+
|
|
4
|
+
function base64ToBytes(b64: string): Uint8Array {
|
|
5
|
+
const bin = atob(b64);
|
|
6
|
+
const out = new Uint8Array(bin.length);
|
|
7
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
8
|
+
return out;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function bytesToBase64(buf: ArrayBuffer): string {
|
|
12
|
+
const arr = new Uint8Array(buf);
|
|
13
|
+
let s = "";
|
|
14
|
+
for (let i = 0; i < arr.length; i++) s += String.fromCharCode(arr[i]);
|
|
15
|
+
return btoa(s);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Independently compute a Sweego-style signature the way the docs specify.
|
|
19
|
+
async function sign(
|
|
20
|
+
secretB64: string,
|
|
21
|
+
id: string,
|
|
22
|
+
ts: string,
|
|
23
|
+
body: string,
|
|
24
|
+
): Promise<string> {
|
|
25
|
+
const key = base64ToBytes(secretB64);
|
|
26
|
+
const ck = await crypto.subtle.importKey(
|
|
27
|
+
"raw",
|
|
28
|
+
key as unknown as BufferSource,
|
|
29
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
30
|
+
false,
|
|
31
|
+
["sign"],
|
|
32
|
+
);
|
|
33
|
+
const mac = await crypto.subtle.sign(
|
|
34
|
+
"HMAC",
|
|
35
|
+
ck,
|
|
36
|
+
new TextEncoder().encode(`${id}.${ts}.${body}`),
|
|
37
|
+
);
|
|
38
|
+
return bytesToBase64(mac);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SECRET = btoa("super-secret-key-bytes-0123456789");
|
|
42
|
+
const ID = "msg_123";
|
|
43
|
+
const TS = "1769696506";
|
|
44
|
+
const BODY = JSON.stringify({ event_type: "delivered", swg_uid: "abc" });
|
|
45
|
+
|
|
46
|
+
describe("verifySweegoSignature", () => {
|
|
47
|
+
it("accepts a valid signature", async () => {
|
|
48
|
+
const signatureHeader = await sign(SECRET, ID, TS, BODY);
|
|
49
|
+
expect(
|
|
50
|
+
await verifySweegoSignature({
|
|
51
|
+
secret: SECRET,
|
|
52
|
+
id: ID,
|
|
53
|
+
timestamp: TS,
|
|
54
|
+
body: BODY,
|
|
55
|
+
signatureHeader,
|
|
56
|
+
}),
|
|
57
|
+
).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("tolerates a v1,-prefixed signature (svix style)", async () => {
|
|
61
|
+
const sig = await sign(SECRET, ID, TS, BODY);
|
|
62
|
+
expect(
|
|
63
|
+
await verifySweegoSignature({
|
|
64
|
+
secret: SECRET,
|
|
65
|
+
id: ID,
|
|
66
|
+
timestamp: TS,
|
|
67
|
+
body: BODY,
|
|
68
|
+
signatureHeader: `v1,${sig}`,
|
|
69
|
+
}),
|
|
70
|
+
).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects a tampered body", async () => {
|
|
74
|
+
const sig = await sign(SECRET, ID, TS, BODY);
|
|
75
|
+
expect(
|
|
76
|
+
await verifySweegoSignature({
|
|
77
|
+
secret: SECRET,
|
|
78
|
+
id: ID,
|
|
79
|
+
timestamp: TS,
|
|
80
|
+
body: `${BODY} `,
|
|
81
|
+
signatureHeader: sig,
|
|
82
|
+
}),
|
|
83
|
+
).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("rejects a wrong secret", async () => {
|
|
87
|
+
const sig = await sign(SECRET, ID, TS, BODY);
|
|
88
|
+
expect(
|
|
89
|
+
await verifySweegoSignature({
|
|
90
|
+
secret: btoa("a-totally-different-secret-value!"),
|
|
91
|
+
id: ID,
|
|
92
|
+
timestamp: TS,
|
|
93
|
+
body: BODY,
|
|
94
|
+
signatureHeader: sig,
|
|
95
|
+
}),
|
|
96
|
+
).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("rejects missing headers", async () => {
|
|
100
|
+
expect(
|
|
101
|
+
await verifySweegoSignature({
|
|
102
|
+
secret: SECRET,
|
|
103
|
+
id: "",
|
|
104
|
+
timestamp: TS,
|
|
105
|
+
body: BODY,
|
|
106
|
+
signatureHeader: "",
|
|
107
|
+
}),
|
|
108
|
+
).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("enforces the timestamp tolerance window when set", async () => {
|
|
112
|
+
const sig = await sign(SECRET, ID, TS, BODY);
|
|
113
|
+
const ok = await verifySweegoSignature({
|
|
114
|
+
secret: SECRET,
|
|
115
|
+
id: ID,
|
|
116
|
+
timestamp: TS,
|
|
117
|
+
body: BODY,
|
|
118
|
+
signatureHeader: sig,
|
|
119
|
+
toleranceSeconds: 300,
|
|
120
|
+
now: Number(TS) + 10,
|
|
121
|
+
});
|
|
122
|
+
const stale = await verifySweegoSignature({
|
|
123
|
+
secret: SECRET,
|
|
124
|
+
id: ID,
|
|
125
|
+
timestamp: TS,
|
|
126
|
+
body: BODY,
|
|
127
|
+
signatureHeader: sig,
|
|
128
|
+
toleranceSeconds: 300,
|
|
129
|
+
now: Number(TS) + 10_000,
|
|
130
|
+
});
|
|
131
|
+
expect(ok).toBe(true);
|
|
132
|
+
expect(stale).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("timingSafeEqual", () => {
|
|
137
|
+
it("matches equal strings", () => {
|
|
138
|
+
expect(timingSafeEqual("abc", "abc")).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
it("rejects differing strings", () => {
|
|
141
|
+
expect(timingSafeEqual("abc", "abd")).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
it("rejects differing lengths", () => {
|
|
144
|
+
expect(timingSafeEqual("abc", "abcd")).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
});
|