@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.
Files changed (72) hide show
  1. package/README.md +357 -0
  2. package/dist/client/_generated/_ignore.d.ts +1 -0
  3. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  4. package/dist/client/_generated/_ignore.js +3 -0
  5. package/dist/client/_generated/_ignore.js.map +1 -0
  6. package/dist/client/index.d.ts +282 -0
  7. package/dist/client/index.d.ts.map +1 -0
  8. package/dist/client/index.js +265 -0
  9. package/dist/client/index.js.map +1 -0
  10. package/dist/client/webhook.d.ts +42 -0
  11. package/dist/client/webhook.d.ts.map +1 -0
  12. package/dist/client/webhook.js +89 -0
  13. package/dist/client/webhook.js.map +1 -0
  14. package/dist/component/_generated/api.d.ts +43 -0
  15. package/dist/component/_generated/api.d.ts.map +1 -0
  16. package/dist/component/_generated/api.js +31 -0
  17. package/dist/component/_generated/api.js.map +1 -0
  18. package/dist/component/_generated/component.d.ts +226 -0
  19. package/dist/component/_generated/component.d.ts.map +1 -0
  20. package/dist/component/_generated/component.js +11 -0
  21. package/dist/component/_generated/component.js.map +1 -0
  22. package/dist/component/_generated/dataModel.d.ts +46 -0
  23. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  24. package/dist/component/_generated/dataModel.js +11 -0
  25. package/dist/component/_generated/dataModel.js.map +1 -0
  26. package/dist/component/_generated/server.d.ts +121 -0
  27. package/dist/component/_generated/server.d.ts.map +1 -0
  28. package/dist/component/_generated/server.js +78 -0
  29. package/dist/component/_generated/server.js.map +1 -0
  30. package/dist/component/convex.config.d.ts +3 -0
  31. package/dist/component/convex.config.d.ts.map +1 -0
  32. package/dist/component/convex.config.js +10 -0
  33. package/dist/component/convex.config.js.map +1 -0
  34. package/dist/component/lib.d.ts +319 -0
  35. package/dist/component/lib.d.ts.map +1 -0
  36. package/dist/component/lib.js +725 -0
  37. package/dist/component/lib.js.map +1 -0
  38. package/dist/component/schema.d.ts +259 -0
  39. package/dist/component/schema.d.ts.map +1 -0
  40. package/dist/component/schema.js +99 -0
  41. package/dist/component/schema.js.map +1 -0
  42. package/dist/component/shared.d.ts +280 -0
  43. package/dist/component/shared.d.ts.map +1 -0
  44. package/dist/component/shared.js +213 -0
  45. package/dist/component/shared.js.map +1 -0
  46. package/dist/component/sweego.d.ts +95 -0
  47. package/dist/component/sweego.d.ts.map +1 -0
  48. package/dist/component/sweego.js +210 -0
  49. package/dist/component/sweego.js.map +1 -0
  50. package/dist/component/utils.d.ts +16 -0
  51. package/dist/component/utils.d.ts.map +1 -0
  52. package/dist/component/utils.js +29 -0
  53. package/dist/component/utils.js.map +1 -0
  54. package/package.json +100 -0
  55. package/src/client/_generated/_ignore.ts +1 -0
  56. package/src/client/index.ts +490 -0
  57. package/src/client/webhook.test.ts +146 -0
  58. package/src/client/webhook.ts +130 -0
  59. package/src/component/_generated/api.ts +59 -0
  60. package/src/component/_generated/component.ts +244 -0
  61. package/src/component/_generated/dataModel.ts +60 -0
  62. package/src/component/_generated/server.ts +156 -0
  63. package/src/component/convex.config.ts +12 -0
  64. package/src/component/lib.test.ts +189 -0
  65. package/src/component/lib.ts +835 -0
  66. package/src/component/schema.ts +117 -0
  67. package/src/component/shared.test.ts +64 -0
  68. package/src/component/shared.ts +315 -0
  69. package/src/component/sweego.test.ts +141 -0
  70. package/src/component/sweego.ts +310 -0
  71. package/src/component/utils.ts +35 -0
  72. package/src/test.ts +20 -0
@@ -0,0 +1,117 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+ import {
4
+ onEvent,
5
+ vAttachment,
6
+ vChannel,
7
+ vDeliveryStatus,
8
+ vEmailAddress,
9
+ vEmailRecipient,
10
+ vListUnsub,
11
+ vSendStatus,
12
+ vSmsRecipient,
13
+ vVariables,
14
+ } from "./shared.js";
15
+
16
+ export default defineSchema({
17
+ // A logical send request. One row per `sendEmail` / `sendSms` / `sendBulkEmail`
18
+ // call. Content is stored inline; the whole document must fit within Convex's
19
+ // ~1 MiB limit (so keep attachments small).
20
+ messages: defineTable({
21
+ channel: vChannel,
22
+ provider: v.string(),
23
+ status: vSendStatus,
24
+ bulk: v.boolean(),
25
+
26
+ // Recipients (per channel).
27
+ emailRecipients: v.optional(v.array(vEmailRecipient)),
28
+ smsRecipients: v.optional(v.array(vSmsRecipient)),
29
+
30
+ // Email fields.
31
+ from: v.optional(vEmailAddress),
32
+ subject: v.optional(v.string()),
33
+ cc: v.optional(v.array(vEmailAddress)),
34
+ bcc: v.optional(v.array(vEmailAddress)),
35
+ replyTo: v.optional(vEmailAddress),
36
+ html: v.optional(v.string()),
37
+ text: v.optional(v.string()),
38
+ templateId: v.optional(v.string()),
39
+ variables: v.optional(vVariables),
40
+ attachments: v.optional(v.array(vAttachment)),
41
+ headers: v.optional(v.record(v.string(), v.string())),
42
+ listUnsub: v.optional(vListUnsub),
43
+ expires: v.optional(v.string()),
44
+ campaignId: v.optional(v.string()),
45
+ campaignTags: v.optional(v.array(v.string())),
46
+ campaignType: v.optional(v.string()),
47
+ compressStyle: v.optional(v.boolean()),
48
+ forceInlineStyle: v.optional(v.boolean()),
49
+ dryRun: v.optional(v.boolean()),
50
+
51
+ // SMS fields.
52
+ senderId: v.optional(v.string()),
53
+ shortenUrls: v.optional(v.boolean()),
54
+ shortenWithProtocol: v.optional(v.boolean()),
55
+ bat: v.optional(v.boolean()),
56
+
57
+ // Lifecycle.
58
+ transactionId: v.optional(v.string()),
59
+ errorMessage: v.optional(v.string()),
60
+ creditLeft: v.optional(v.string()),
61
+ finalizedAt: v.number(),
62
+ })
63
+ .index("by_status", ["status"])
64
+ .index("by_finalizedAt", ["finalizedAt"])
65
+ .index("by_transactionId", ["transactionId"]),
66
+
67
+ // One row per recipient (per swg_uid returned by Sweego). Webhook events are
68
+ // matched to a delivery via `swgUid`.
69
+ deliveries: defineTable({
70
+ messageId: v.id("messages"),
71
+ swgUid: v.string(),
72
+ recipientKey: v.string(),
73
+ channel: vChannel,
74
+ status: vDeliveryStatus,
75
+ lastEventType: v.optional(v.string()),
76
+ delivered: v.boolean(),
77
+ bounced: v.boolean(),
78
+ softBounced: v.boolean(),
79
+ complained: v.boolean(),
80
+ unsubscribed: v.boolean(),
81
+ opened: v.boolean(),
82
+ clicked: v.boolean(),
83
+ stopped: v.boolean(),
84
+ errorMessage: v.optional(v.string()),
85
+ finalizedAt: v.number(),
86
+ })
87
+ .index("by_swgUid", ["swgUid"])
88
+ .index("by_messageId", ["messageId"]),
89
+
90
+ // Raw webhook events, kept for auditing/debugging.
91
+ events: defineTable({
92
+ swgUid: v.string(),
93
+ messageId: v.optional(v.id("messages")),
94
+ deliveryId: v.optional(v.id("deliveries")),
95
+ channel: v.optional(vChannel),
96
+ eventType: v.string(),
97
+ // The `webhook-id` header (Standard-Webhooks message id) — used to
98
+ // deduplicate redelivered/replayed webhooks.
99
+ webhookId: v.optional(v.string()),
100
+ eventId: v.optional(v.string()),
101
+ transactionId: v.optional(v.string()),
102
+ timestamp: v.optional(v.string()),
103
+ payload: v.any(),
104
+ createdAt: v.number(),
105
+ })
106
+ .index("by_swgUid", ["swgUid"])
107
+ .index("by_messageId", ["messageId"])
108
+ .index("by_webhookId", ["webhookId"])
109
+ .index("by_createdAt", ["createdAt"]),
110
+
111
+ // Singleton row holding the latest webhook-event callback handle, refreshed
112
+ // whenever a message is enqueued. The webhook handler reads it to dispatch
113
+ // `onEvent` (which arrives independently of any send).
114
+ config: defineTable({
115
+ onEvent: v.optional(onEvent),
116
+ }),
117
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ classifyEvent,
4
+ normalizeEventType,
5
+ parseEmailAddress,
6
+ parseEmailAddresses,
7
+ } from "./shared.js";
8
+
9
+ describe("classifyEvent", () => {
10
+ it("classifies email events regardless of hyphen/underscore casing", () => {
11
+ expect(classifyEvent("email_sent")).toBe("sent");
12
+ expect(classifyEvent("delivered")).toBe("delivered");
13
+ expect(classifyEvent("soft-bounce")).toBe("soft_bounce");
14
+ expect(classifyEvent("hard_bounce")).toBe("hard_bounce");
15
+ expect(classifyEvent("complaint")).toBe("complaint");
16
+ expect(classifyEvent("list_unsub")).toBe("unsub");
17
+ expect(classifyEvent("email_opened")).toBe("open");
18
+ expect(classifyEvent("email_clicked")).toBe("click");
19
+ expect(classifyEvent("email_inbound")).toBe("inbound");
20
+ });
21
+
22
+ it("classifies SMS events including the stop/sms_stop variants", () => {
23
+ expect(classifyEvent("sms_sent")).toBe("sms_sent");
24
+ expect(classifyEvent("sms_undelivered")).toBe("sms_undelivered");
25
+ expect(classifyEvent("sms_stop")).toBe("sms_stop");
26
+ expect(classifyEvent("stop")).toBe("sms_stop");
27
+ expect(classifyEvent("sms_clicked")).toBe("sms_click");
28
+ });
29
+
30
+ it("returns unknown for unrecognized strings", () => {
31
+ expect(classifyEvent("totally_made_up")).toBe("unknown");
32
+ });
33
+
34
+ it("normalizes case and hyphens", () => {
35
+ expect(normalizeEventType("Soft-Bounce")).toBe("soft_bounce");
36
+ });
37
+ });
38
+
39
+ describe("parseEmailAddress", () => {
40
+ it('parses "Name <email>"', () => {
41
+ expect(parseEmailAddress("Acme <hi@acme.com>")).toEqual({
42
+ email: "hi@acme.com",
43
+ name: "Acme",
44
+ });
45
+ });
46
+
47
+ it("parses a bare email", () => {
48
+ expect(parseEmailAddress("hi@acme.com")).toEqual({ email: "hi@acme.com" });
49
+ });
50
+
51
+ it("passes structured addresses through", () => {
52
+ expect(parseEmailAddress({ email: "x@y.com", name: "X" })).toEqual({
53
+ email: "x@y.com",
54
+ name: "X",
55
+ });
56
+ });
57
+
58
+ it("parses arrays and strips quotes from names", () => {
59
+ expect(parseEmailAddresses(['"Cara" <c@d.com>', "a@b.com"])).toEqual([
60
+ { email: "c@d.com", name: "Cara" },
61
+ { email: "a@b.com" },
62
+ ]);
63
+ });
64
+ });
@@ -0,0 +1,315 @@
1
+ import {
2
+ type GenericActionCtx,
3
+ type GenericDataModel,
4
+ type GenericMutationCtx,
5
+ type GenericQueryCtx,
6
+ } from "convex/server";
7
+ import { type Infer, v } from "convex/values";
8
+
9
+ // The Sweego API base URL. All requests go here.
10
+ export const SWEEGO_API_BASE_URL = "https://api.sweego.io";
11
+
12
+ // The default provider Sweego expects on every send request.
13
+ export const DEFAULT_PROVIDER = "sweego";
14
+
15
+ /* -------------------------------------------------------------------------- */
16
+ /* Channels */
17
+ /* -------------------------------------------------------------------------- */
18
+
19
+ export const vChannel = v.union(v.literal("email"), v.literal("sms"));
20
+ export type Channel = Infer<typeof vChannel>;
21
+
22
+ /* -------------------------------------------------------------------------- */
23
+ /* Addresses / recipients */
24
+ /* -------------------------------------------------------------------------- */
25
+
26
+ // An email address with an optional display name, e.g. { email, name }.
27
+ export const vEmailAddress = v.object({
28
+ email: v.string(),
29
+ name: v.optional(v.string()),
30
+ });
31
+ export type EmailAddress = Infer<typeof vEmailAddress>;
32
+
33
+ // Variables passed to a Sweego template. Values are interpolated into
34
+ // `{{ placeholder }}` tokens; Sweego accepts string/number/boolean values.
35
+ export const vVariables = v.record(
36
+ v.string(),
37
+ v.union(v.string(), v.number(), v.boolean()),
38
+ );
39
+ export type Variables = Infer<typeof vVariables>;
40
+
41
+ // An email recipient. `variables` is only honored on the bulk endpoint
42
+ // (`/send/bulk/email`), where each recipient may be personalized.
43
+ export const vEmailRecipient = v.object({
44
+ email: v.string(),
45
+ name: v.optional(v.string()),
46
+ variables: v.optional(vVariables),
47
+ });
48
+ export type EmailRecipient = Infer<typeof vEmailRecipient>;
49
+
50
+ // An SMS recipient: a phone number plus its ISO-3166 alpha-2 region (e.g. "FR").
51
+ export const vSmsRecipient = v.object({
52
+ num: v.string(),
53
+ region: v.string(),
54
+ });
55
+ export type SmsRecipient = Infer<typeof vSmsRecipient>;
56
+
57
+ /* -------------------------------------------------------------------------- */
58
+ /* Email extras */
59
+ /* -------------------------------------------------------------------------- */
60
+
61
+ // A file attachment. `content` MUST be base64-encoded bytes. The total
62
+ // message (incl. attachments) must fit within a single Convex document
63
+ // (~1 MiB); use small attachments or host large files elsewhere.
64
+ export const vAttachment = v.object({
65
+ content: v.string(),
66
+ filename: v.string(),
67
+ contentId: v.optional(v.string()),
68
+ disposition: v.optional(
69
+ v.union(v.literal("attachment"), v.literal("inline")),
70
+ ),
71
+ isRelated: v.optional(v.boolean()),
72
+ });
73
+ export type Attachment = Infer<typeof vAttachment>;
74
+
75
+ // The List-Unsubscribe header. For "mailto", `value` is an email; for
76
+ // "one-click", `value` is "<mailto:EMAIL>,<URL>".
77
+ export const vListUnsub = v.object({
78
+ method: v.optional(v.union(v.literal("mailto"), v.literal("one-click"))),
79
+ value: v.string(),
80
+ });
81
+ export type ListUnsub = Infer<typeof vListUnsub>;
82
+
83
+ // Email campaign type. Optional for email.
84
+ export const vEmailCampaignType = v.union(
85
+ v.literal("market"),
86
+ v.literal("newsletter"),
87
+ v.literal("transac"),
88
+ );
89
+
90
+ // SMS campaign type. REQUIRED by Sweego for every SMS send.
91
+ export const vSmsCampaignType = v.union(
92
+ v.literal("market"),
93
+ v.literal("transac"),
94
+ );
95
+ export type SmsCampaignType = Infer<typeof vSmsCampaignType>;
96
+
97
+ /* -------------------------------------------------------------------------- */
98
+ /* Statuses */
99
+ /* -------------------------------------------------------------------------- */
100
+
101
+ // Lifecycle of a logical send (one `messages` row).
102
+ export const vSendStatus = v.union(
103
+ v.literal("queued"), // enqueued, not yet handed to Sweego
104
+ v.literal("sent"), // accepted by Sweego (swg_uids issued)
105
+ v.literal("failed"), // permanent failure / retries exhausted
106
+ v.literal("cancelled"), // cancelled before being sent
107
+ );
108
+ export type SendStatus = Infer<typeof vSendStatus>;
109
+
110
+ // Per-recipient delivery outcome (one `deliveries` row per swg_uid).
111
+ export const vDeliveryStatus = v.union(
112
+ v.literal("pending"), // accepted, awaiting delivery events
113
+ v.literal("sent"),
114
+ v.literal("delivered"),
115
+ v.literal("soft_bounced"), // transient bounce, may still deliver
116
+ v.literal("bounced"), // hard bounce (terminal)
117
+ v.literal("undelivered"), // SMS not delivered (terminal)
118
+ v.literal("stopped"), // SMS recipient opted out (terminal)
119
+ );
120
+ export type DeliveryStatus = Infer<typeof vDeliveryStatus>;
121
+
122
+ /* -------------------------------------------------------------------------- */
123
+ /* Webhook events */
124
+ /* -------------------------------------------------------------------------- */
125
+
126
+ // Known Sweego email webhook event_type strings. Casing is inconsistent in
127
+ // Sweego's docs (e.g. "soft-bounce" vs "hard_bounce"), so we match defensively
128
+ // via `classifyEvent` rather than relying on these literally.
129
+ export const KNOWN_EMAIL_EVENT_TYPES = [
130
+ "email_sent",
131
+ "delivered",
132
+ "soft-bounce",
133
+ "hard_bounce",
134
+ "list_unsub",
135
+ "complaint",
136
+ "email_opened",
137
+ "email_clicked",
138
+ "email_inbound",
139
+ ] as const;
140
+
141
+ export const KNOWN_SMS_EVENT_TYPES = [
142
+ "sms_sent",
143
+ "sms_undelivered",
144
+ "sms_stop",
145
+ "sms_clicked",
146
+ ] as const;
147
+
148
+ // A normalized webhook event handed to your `onEvent` handler. The raw,
149
+ // unmodified payload is always available on `raw`.
150
+ export const vSweegoEvent = v.object({
151
+ eventType: v.string(),
152
+ channel: v.optional(vChannel),
153
+ swgUid: v.string(),
154
+ eventId: v.optional(v.string()),
155
+ transactionId: v.optional(v.string()),
156
+ timestamp: v.optional(v.string()),
157
+ raw: v.any(),
158
+ });
159
+ export type SweegoEvent = Infer<typeof vSweegoEvent>;
160
+
161
+ // Coarse classification of a Sweego event_type string, normalizing the
162
+ // documented hyphen/underscore inconsistencies (and the sms_stop/stop variant).
163
+ export type EventKind =
164
+ | "sent"
165
+ | "delivered"
166
+ | "soft_bounce"
167
+ | "hard_bounce"
168
+ | "complaint"
169
+ | "unsub"
170
+ | "open"
171
+ | "click"
172
+ | "sms_sent"
173
+ | "sms_undelivered"
174
+ | "sms_stop"
175
+ | "sms_click"
176
+ | "inbound"
177
+ | "unknown";
178
+
179
+ export function normalizeEventType(raw: string): string {
180
+ return raw.trim().toLowerCase().replace(/-/g, "_");
181
+ }
182
+
183
+ export function classifyEvent(raw: string): EventKind {
184
+ switch (normalizeEventType(raw)) {
185
+ case "email_sent":
186
+ return "sent";
187
+ case "delivered":
188
+ return "delivered";
189
+ case "soft_bounce":
190
+ return "soft_bounce";
191
+ case "hard_bounce":
192
+ case "bounce":
193
+ return "hard_bounce";
194
+ case "complaint":
195
+ return "complaint";
196
+ case "list_unsub":
197
+ return "unsub";
198
+ case "email_opened":
199
+ return "open";
200
+ case "email_clicked":
201
+ return "click";
202
+ case "email_inbound":
203
+ return "inbound";
204
+ case "sms_sent":
205
+ return "sms_sent";
206
+ case "sms_undelivered":
207
+ return "sms_undelivered";
208
+ case "sms_stop":
209
+ case "stop":
210
+ return "sms_stop";
211
+ case "sms_clicked":
212
+ return "sms_click";
213
+ default:
214
+ return "unknown";
215
+ }
216
+ }
217
+
218
+ /* -------------------------------------------------------------------------- */
219
+ /* Runtime configuration */
220
+ /* -------------------------------------------------------------------------- */
221
+
222
+ // A reference to a mutation in the host app that runs after each event.
223
+ export const onEvent = v.object({ fnHandle: v.string() });
224
+
225
+ // Runtime configuration threaded from the client into the component on each
226
+ // send. Note: components cannot read the host app's environment variables, so
227
+ // the API key is passed in explicitly here.
228
+ export const vOptions = v.object({
229
+ apiKey: v.string(),
230
+ provider: v.string(),
231
+ initialBackoffMs: v.number(),
232
+ retryAttempts: v.number(),
233
+ // When true, email sends use Sweego's `dry-run` (validated, never sent).
234
+ testMode: v.boolean(),
235
+ onEvent: v.optional(onEvent),
236
+ });
237
+ export type RuntimeConfig = Infer<typeof vOptions>;
238
+
239
+ /* -------------------------------------------------------------------------- */
240
+ /* Message input (client -> component) */
241
+ /* -------------------------------------------------------------------------- */
242
+
243
+ // The normalized message a client hands to the component's `enqueueMessage`
244
+ // mutation. Lifecycle fields (status, transactionId, finalizedAt, …) are added
245
+ // by the component, not provided here.
246
+ export const vMessageInput = v.object({
247
+ channel: vChannel,
248
+ bulk: v.boolean(),
249
+ emailRecipients: v.optional(v.array(vEmailRecipient)),
250
+ smsRecipients: v.optional(v.array(vSmsRecipient)),
251
+ from: v.optional(vEmailAddress),
252
+ subject: v.optional(v.string()),
253
+ cc: v.optional(v.array(vEmailAddress)),
254
+ bcc: v.optional(v.array(vEmailAddress)),
255
+ replyTo: v.optional(vEmailAddress),
256
+ html: v.optional(v.string()),
257
+ text: v.optional(v.string()),
258
+ templateId: v.optional(v.string()),
259
+ variables: v.optional(vVariables),
260
+ attachments: v.optional(v.array(vAttachment)),
261
+ headers: v.optional(v.record(v.string(), v.string())),
262
+ listUnsub: v.optional(vListUnsub),
263
+ expires: v.optional(v.string()),
264
+ campaignId: v.optional(v.string()),
265
+ campaignTags: v.optional(v.array(v.string())),
266
+ campaignType: v.optional(v.string()),
267
+ compressStyle: v.optional(v.boolean()),
268
+ forceInlineStyle: v.optional(v.boolean()),
269
+ dryRun: v.optional(v.boolean()),
270
+ senderId: v.optional(v.string()),
271
+ shortenUrls: v.optional(v.boolean()),
272
+ shortenWithProtocol: v.optional(v.boolean()),
273
+ bat: v.optional(v.boolean()),
274
+ });
275
+ export type MessageInput = Infer<typeof vMessageInput>;
276
+
277
+ /* -------------------------------------------------------------------------- */
278
+ /* Helpers */
279
+ /* -------------------------------------------------------------------------- */
280
+
281
+ // Accepts either a structured address or an RFC-5322-ish "Name <email>" /
282
+ // "email" string and returns { email, name? }.
283
+ export function parseEmailAddress(
284
+ input: string | EmailAddress,
285
+ ): EmailAddress {
286
+ if (typeof input !== "string") return input;
287
+ const match = input.match(/^\s*(.*?)\s*<\s*([^>]+)\s*>\s*$/);
288
+ if (match) {
289
+ const name = match[1].replace(/^["']|["']$/g, "").trim();
290
+ const email = match[2].trim();
291
+ return name ? { email, name } : { email };
292
+ }
293
+ return { email: input.trim() };
294
+ }
295
+
296
+ export function parseEmailAddresses(
297
+ input: string | EmailAddress | Array<string | EmailAddress>,
298
+ ): EmailAddress[] {
299
+ const list = Array.isArray(input) ? input : [input];
300
+ return list.map(parseEmailAddress);
301
+ }
302
+
303
+ /* -------------------------------------------------------------------------- */
304
+ /* Ctx type utilities */
305
+ /* -------------------------------------------------------------------------- */
306
+
307
+ export type QueryCtx = Pick<GenericQueryCtx<GenericDataModel>, "runQuery">;
308
+ export type MutationCtx = Pick<
309
+ GenericMutationCtx<GenericDataModel>,
310
+ "runQuery" | "runMutation"
311
+ >;
312
+ export type ActionCtx = Pick<
313
+ GenericActionCtx<GenericDataModel>,
314
+ "runQuery" | "runMutation" | "runAction"
315
+ >;
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ type BuildableMessage,
4
+ buildSendRequest,
5
+ PERMANENT_ERROR_CODES,
6
+ parseSendResponse,
7
+ sanitizeSweegoError,
8
+ } from "./sweego.js";
9
+
10
+ const baseEmail: BuildableMessage = {
11
+ channel: "email",
12
+ provider: "sweego",
13
+ bulk: false,
14
+ from: { email: "a@x.com", name: "A" },
15
+ subject: "Hi",
16
+ emailRecipients: [{ email: "b@y.com" }],
17
+ html: "<p>hi</p>",
18
+ };
19
+
20
+ describe("buildSendRequest (email)", () => {
21
+ it("uses /send and hyphenated keys, dropping undefined fields", () => {
22
+ const { path, body } = buildSendRequest(baseEmail);
23
+ expect(path).toBe("/send");
24
+ expect(body.channel).toBe("email");
25
+ expect(body.provider).toBe("sweego");
26
+ expect(body["message-html"]).toBe("<p>hi</p>");
27
+ expect(body.subject).toBe("Hi");
28
+ expect(body.recipients).toEqual([{ email: "b@y.com" }]);
29
+ expect(body.from).toEqual({ email: "a@x.com", name: "A" });
30
+ expect("message-txt" in body).toBe(false);
31
+ expect("dry-run" in body).toBe(false);
32
+ });
33
+
34
+ it("maps attachments to Sweego's snake_case fields", () => {
35
+ const { body } = buildSendRequest({
36
+ ...baseEmail,
37
+ attachments: [
38
+ {
39
+ content: "Zm9v",
40
+ filename: "f.txt",
41
+ contentId: "cid",
42
+ disposition: "inline",
43
+ isRelated: true,
44
+ },
45
+ ],
46
+ });
47
+ expect(body.attachments).toEqual([
48
+ {
49
+ content: "Zm9v",
50
+ filename: "f.txt",
51
+ content_id: "cid",
52
+ disposition: "inline",
53
+ is_related: true,
54
+ },
55
+ ]);
56
+ });
57
+
58
+ it("includes dry-run when set", () => {
59
+ const { body } = buildSendRequest({ ...baseEmail, dryRun: true });
60
+ expect(body["dry-run"]).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe("buildSendRequest (bulk email)", () => {
65
+ it("routes to /send/bulk/email with per-recipient variables and no cc", () => {
66
+ const { path, body } = buildSendRequest({
67
+ ...baseEmail,
68
+ bulk: true,
69
+ html: undefined,
70
+ templateId: "tmpl-1",
71
+ emailRecipients: [
72
+ { email: "b@y.com", variables: { name: "Bob" } },
73
+ { email: "c@y.com", variables: { name: "Cara" } },
74
+ ],
75
+ });
76
+ expect(path).toBe("/send/bulk/email");
77
+ expect(body["template-id"]).toBe("tmpl-1");
78
+ expect((body.recipients as Array<Record<string, unknown>>)[0]).toEqual({
79
+ email: "b@y.com",
80
+ variables: { name: "Bob" },
81
+ });
82
+ expect("cc" in body).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe("buildSendRequest (sms)", () => {
87
+ it("builds an SMS payload with required campaign-type and sender-id", () => {
88
+ const { path, body } = buildSendRequest({
89
+ channel: "sms",
90
+ provider: "sweego",
91
+ bulk: false,
92
+ smsRecipients: [{ num: "+33600000000", region: "FR" }],
93
+ text: "hi",
94
+ campaignType: "transac",
95
+ senderId: "Acme",
96
+ shortenUrls: false,
97
+ });
98
+ expect(path).toBe("/send");
99
+ expect(body.channel).toBe("sms");
100
+ expect(body["campaign-type"]).toBe("transac");
101
+ expect(body["sender-id"]).toBe("Acme");
102
+ expect(body["message-txt"]).toBe("hi");
103
+ expect(body["shorten-urls"]).toBe(false);
104
+ expect(body.recipients).toEqual([{ num: "+33600000000", region: "FR" }]);
105
+ });
106
+ });
107
+
108
+ describe("parseSendResponse", () => {
109
+ it("extracts swg_uids, transaction_id, credit_left", () => {
110
+ const r = parseSendResponse({
111
+ channel: "email",
112
+ provider: "sweego",
113
+ swg_uids: { "b@y.com": "uid1" },
114
+ transaction_id: "tx1",
115
+ credit_left: "100",
116
+ });
117
+ expect(r.swgUids).toEqual({ "b@y.com": "uid1" });
118
+ expect(r.transactionId).toBe("tx1");
119
+ expect(r.creditLeft).toBe("100");
120
+ });
121
+
122
+ it("tolerates a missing/empty body", () => {
123
+ const r = parseSendResponse({});
124
+ expect(r.swgUids).toEqual({});
125
+ expect(r.transactionId).toBeUndefined();
126
+ });
127
+ });
128
+
129
+ describe("error classification", () => {
130
+ it("treats 4xx (422) as permanent, 429/5xx as transient", () => {
131
+ expect(PERMANENT_ERROR_CODES.has(422)).toBe(true);
132
+ expect(PERMANENT_ERROR_CODES.has(401)).toBe(true);
133
+ expect(PERMANENT_ERROR_CODES.has(429)).toBe(false);
134
+ expect(PERMANENT_ERROR_CODES.has(500)).toBe(false);
135
+ });
136
+
137
+ it("returns friendly messages", () => {
138
+ expect(sanitizeSweegoError(401, "x")).toMatch(/authentication/i);
139
+ expect(sanitizeSweegoError(429, "x")).toMatch(/rate limit/i);
140
+ });
141
+ });