@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,310 @@
1
+ import { DEFAULT_PROVIDER, SWEEGO_API_BASE_URL } from "./shared.js";
2
+
3
+ /* -------------------------------------------------------------------------- */
4
+ /* HTTP */
5
+ /* -------------------------------------------------------------------------- */
6
+
7
+ /**
8
+ * HTTP status codes from which retrying will never help. Everything else
9
+ * (429 rate limits, 409 conflicts, 5xx) is treated as transient and retried
10
+ * by the workpool. Derived from Sweego's documented status codes.
11
+ */
12
+ export const PERMANENT_ERROR_CODES = new Set([
13
+ 400, // malformed request
14
+ 401, // bad / missing API key
15
+ 403, // forbidden / restricted account
16
+ 404, // resource not found
17
+ 405, // method not allowed
18
+ 406, // not acceptable
19
+ 410, // route is gone
20
+ 413, // request too large
21
+ 418, // I'm a teapot
22
+ 422, // validation error (missing / wrong-type field)
23
+ 501, // route / channel not implemented
24
+ ]);
25
+
26
+ export type SweegoRequestInit = Omit<RequestInit, "body"> & { json?: unknown };
27
+
28
+ /** Low-level fetch against the Sweego API with the `Api-Key` auth header. */
29
+ export async function sweegoFetch(
30
+ apiKey: string,
31
+ path: string,
32
+ init: SweegoRequestInit = {},
33
+ ): Promise<Response> {
34
+ const { json, ...rest } = init;
35
+ const headers = new Headers(rest.headers ?? {});
36
+ headers.set("Api-Key", apiKey);
37
+ if (json !== undefined && !headers.has("Content-Type")) {
38
+ headers.set("Content-Type", "application/json");
39
+ }
40
+ return fetch(`${SWEEGO_API_BASE_URL}${path}`, {
41
+ ...rest,
42
+ headers,
43
+ body: json !== undefined ? JSON.stringify(json) : (rest as RequestInit).body,
44
+ });
45
+ }
46
+
47
+ /** Turn a non-OK Sweego response into a concise, non-leaky Error message. */
48
+ export function sanitizeSweegoError(status: number, errorText: string): string {
49
+ if (status === 401 || status === 403) {
50
+ return "Sweego authentication failed. Check your SWEEGO_API_KEY.";
51
+ }
52
+ if (status === 422) {
53
+ return `Sweego rejected the request (422): ${truncate(errorText, 500)}`;
54
+ }
55
+ if (status === 404) return "Sweego resource not found (404).";
56
+ if (status === 413) return "Sweego request too large (413).";
57
+ if (status === 429) return "Sweego rate limit exceeded (429).";
58
+ if (status >= 500) return `Sweego service error (${status}).`;
59
+ return `Sweego API error (${status}): ${truncate(errorText, 300)}`;
60
+ }
61
+
62
+ function truncate(text: string, max: number): string {
63
+ return text.length > max ? `${text.slice(0, max)}…` : text;
64
+ }
65
+
66
+ /* -------------------------------------------------------------------------- */
67
+ /* Request payload builders */
68
+ /* -------------------------------------------------------------------------- */
69
+
70
+ // Structural shape of a stored message that the payload builders need.
71
+ // `Doc<"messages">` is assignable to this.
72
+ export interface BuildableMessage {
73
+ channel: "email" | "sms";
74
+ provider: string;
75
+ bulk: boolean;
76
+ emailRecipients?: Array<{
77
+ email: string;
78
+ name?: string;
79
+ variables?: Record<string, string | number | boolean>;
80
+ }>;
81
+ smsRecipients?: Array<{ num: string; region: string }>;
82
+ from?: { email: string; name?: string };
83
+ subject?: string;
84
+ cc?: Array<{ email: string; name?: string }>;
85
+ bcc?: Array<{ email: string; name?: string }>;
86
+ replyTo?: { email: string; name?: string };
87
+ html?: string;
88
+ text?: string;
89
+ templateId?: string;
90
+ variables?: Record<string, string | number | boolean>;
91
+ attachments?: Array<{
92
+ content: string;
93
+ filename: string;
94
+ contentId?: string;
95
+ disposition?: "attachment" | "inline";
96
+ isRelated?: boolean;
97
+ }>;
98
+ headers?: Record<string, string>;
99
+ listUnsub?: { method?: "mailto" | "one-click"; value: string };
100
+ expires?: string;
101
+ campaignId?: string;
102
+ campaignTags?: string[];
103
+ campaignType?: string;
104
+ compressStyle?: boolean;
105
+ forceInlineStyle?: boolean;
106
+ dryRun?: boolean;
107
+ senderId?: string;
108
+ shortenUrls?: boolean;
109
+ shortenWithProtocol?: boolean;
110
+ bat?: boolean;
111
+ }
112
+
113
+ type Json = Record<string, unknown>;
114
+
115
+ /** Drop keys whose value is `undefined`. */
116
+ function compact(obj: Json): Json {
117
+ const out: Json = {};
118
+ for (const [key, value] of Object.entries(obj)) {
119
+ if (value !== undefined) out[key] = value;
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function mapAttachments(message: BuildableMessage): Json[] | undefined {
125
+ if (!message.attachments?.length) return undefined;
126
+ return message.attachments.map((a) =>
127
+ compact({
128
+ content: a.content,
129
+ filename: a.filename,
130
+ content_id: a.contentId,
131
+ disposition: a.disposition,
132
+ is_related: a.isRelated,
133
+ }),
134
+ );
135
+ }
136
+
137
+ /** Fields common to single and bulk email payloads. */
138
+ function commonEmailFields(message: BuildableMessage): Json {
139
+ return {
140
+ channel: "email",
141
+ provider: message.provider || DEFAULT_PROVIDER,
142
+ from: message.from,
143
+ subject: message.subject,
144
+ "message-html": message.html,
145
+ "message-txt": message.text,
146
+ "template-id": message.templateId,
147
+ attachments: mapAttachments(message),
148
+ headers: message.headers,
149
+ "list-unsub": message.listUnsub
150
+ ? compact({
151
+ method: message.listUnsub.method,
152
+ value: message.listUnsub.value,
153
+ })
154
+ : undefined,
155
+ expires: message.expires,
156
+ "campaign-id": message.campaignId,
157
+ "campaign-tags": message.campaignTags,
158
+ "campaign-type": message.campaignType,
159
+ compress_style: message.compressStyle,
160
+ force_inline_style: message.forceInlineStyle,
161
+ "dry-run": message.dryRun,
162
+ };
163
+ }
164
+
165
+ function buildEmailPayload(message: BuildableMessage): Json {
166
+ return compact({
167
+ ...commonEmailFields(message),
168
+ recipients: (message.emailRecipients ?? []).map((r) =>
169
+ compact({ email: r.email, name: r.name }),
170
+ ),
171
+ cc: message.cc?.map((r) => compact({ email: r.email, name: r.name })),
172
+ bcc: message.bcc?.map((r) => compact({ email: r.email, name: r.name })),
173
+ "reply-to": message.replyTo
174
+ ? compact({ email: message.replyTo.email, name: message.replyTo.name })
175
+ : undefined,
176
+ // Sweego only honors root-level `variables` for a single recipient on /send.
177
+ variables: message.variables,
178
+ });
179
+ }
180
+
181
+ function buildBulkEmailPayload(message: BuildableMessage): Json {
182
+ return compact({
183
+ ...commonEmailFields(message),
184
+ // Bulk: per-recipient variables; no cc/bcc/reply-to.
185
+ recipients: (message.emailRecipients ?? []).map((r) =>
186
+ compact({ email: r.email, name: r.name, variables: r.variables }),
187
+ ),
188
+ });
189
+ }
190
+
191
+ function buildSmsPayload(message: BuildableMessage): Json {
192
+ return compact({
193
+ channel: "sms",
194
+ provider: message.provider || DEFAULT_PROVIDER,
195
+ "campaign-type": message.campaignType,
196
+ recipients: (message.smsRecipients ?? []).map((r) => ({
197
+ num: r.num,
198
+ region: r.region.toUpperCase(),
199
+ })),
200
+ "message-txt": message.text,
201
+ "template-id": message.templateId,
202
+ variables: message.variables,
203
+ "sender-id": message.senderId,
204
+ "shorten-urls": message.shortenUrls,
205
+ "shorten-with-protocol": message.shortenWithProtocol,
206
+ bat: message.bat,
207
+ "campaign-id": message.campaignId,
208
+ });
209
+ }
210
+
211
+ /** Build the `{ path, body }` for a stored message. */
212
+ export function buildSendRequest(message: BuildableMessage): {
213
+ path: string;
214
+ body: Json;
215
+ } {
216
+ if (message.channel === "sms") {
217
+ return { path: "/send", body: buildSmsPayload(message) };
218
+ }
219
+ if (message.bulk) {
220
+ return { path: "/send/bulk/email", body: buildBulkEmailPayload(message) };
221
+ }
222
+ return { path: "/send", body: buildEmailPayload(message) };
223
+ }
224
+
225
+ /* -------------------------------------------------------------------------- */
226
+ /* Response parsing */
227
+ /* -------------------------------------------------------------------------- */
228
+
229
+ export interface SendResult {
230
+ channel?: string;
231
+ provider?: string;
232
+ swgUids: Record<string, string>;
233
+ transactionId?: string;
234
+ creditLeft?: string;
235
+ }
236
+
237
+ /** Parse a Sweego `ModelOutSend` body into a normalized result. */
238
+ export function parseSendResponse(data: unknown): SendResult {
239
+ const obj = (typeof data === "object" && data !== null ? data : {}) as Record<
240
+ string,
241
+ unknown
242
+ >;
243
+ const rawUids = obj["swg_uids"];
244
+ const swgUids: Record<string, string> = {};
245
+ if (typeof rawUids === "object" && rawUids !== null) {
246
+ for (const [key, value] of Object.entries(rawUids as Record<string, unknown>)) {
247
+ if (typeof value === "string") swgUids[key] = value;
248
+ }
249
+ }
250
+ return {
251
+ channel: typeof obj["channel"] === "string" ? (obj["channel"] as string) : undefined,
252
+ provider:
253
+ typeof obj["provider"] === "string" ? (obj["provider"] as string) : undefined,
254
+ swgUids,
255
+ transactionId:
256
+ typeof obj["transaction_id"] === "string"
257
+ ? (obj["transaction_id"] as string)
258
+ : undefined,
259
+ // Sweego documents credit_left as a string, but coerce a number defensively
260
+ // so a numeric value isn't silently dropped.
261
+ creditLeft:
262
+ typeof obj["credit_left"] === "string"
263
+ ? (obj["credit_left"] as string)
264
+ : typeof obj["credit_left"] === "number"
265
+ ? String(obj["credit_left"])
266
+ : undefined,
267
+ };
268
+ }
269
+
270
+ /* -------------------------------------------------------------------------- */
271
+ /* Logs (webhook-free status polling) */
272
+ /* -------------------------------------------------------------------------- */
273
+
274
+ /** GET /logs/{swg_uid}/status — current remote status for one message. */
275
+ export async function fetchLogStatus(
276
+ apiKey: string,
277
+ swgUid: string,
278
+ ): Promise<{ status: string; channel?: string } | null> {
279
+ const response = await sweegoFetch(
280
+ apiKey,
281
+ `/logs/${encodeURIComponent(swgUid)}/status`,
282
+ { method: "GET", headers: { Accept: "application/json" } },
283
+ );
284
+ if (!response.ok) return null;
285
+ const data = (await response.json()) as Record<string, unknown>;
286
+ const status = data["status"];
287
+ if (typeof status !== "string") return null;
288
+ return {
289
+ status,
290
+ channel: typeof data["channel"] === "string" ? (data["channel"] as string) : undefined,
291
+ };
292
+ }
293
+
294
+ /** POST /sms/estimate — estimate the cost/segments of an SMS send. */
295
+ export async function fetchSmsEstimate(
296
+ apiKey: string,
297
+ body: Record<string, unknown>,
298
+ ): Promise<unknown> {
299
+ const response = await sweegoFetch(apiKey, "/sms/estimate", {
300
+ method: "POST",
301
+ json: body,
302
+ });
303
+ const data = await response.json().catch(() => null);
304
+ if (!response.ok) {
305
+ throw new Error(
306
+ sanitizeSweegoError(response.status, JSON.stringify(data ?? {})),
307
+ );
308
+ }
309
+ return data;
310
+ }
@@ -0,0 +1,35 @@
1
+ import { parse } from "convex-helpers/validators";
2
+ import type { Infer, Validator } from "convex/values";
3
+
4
+ export const assertExhaustive = (value: never): never => {
5
+ throw new Error(`Unhandled case: ${value as string}`);
6
+ };
7
+
8
+ /**
9
+ * Attempt to parse an unknown value against a validator, with TypeScript type
10
+ * narrowing. Strips out anything not declared by the validator.
11
+ */
12
+ export function attemptToParse<T extends Validator<any, any, any>>(
13
+ validator: T,
14
+ value: unknown,
15
+ ): { kind: "success"; data: Infer<T> } | { kind: "error"; error: unknown } {
16
+ try {
17
+ return { kind: "success", data: parse(validator, value) };
18
+ } catch (error) {
19
+ return { kind: "error", error };
20
+ }
21
+ }
22
+
23
+ /** Pull a string field from an unknown object, if present. */
24
+ export function pickString(
25
+ obj: unknown,
26
+ ...keys: string[]
27
+ ): string | undefined {
28
+ if (typeof obj !== "object" || obj === null) return undefined;
29
+ const record = obj as Record<string, unknown>;
30
+ for (const key of keys) {
31
+ const value = record[key];
32
+ if (typeof value === "string" && value.length > 0) return value;
33
+ }
34
+ return undefined;
35
+ }
package/src/test.ts ADDED
@@ -0,0 +1,20 @@
1
+ /// <reference types="vite/client" />
2
+ import type { TestConvex } from "convex-test";
3
+ import type { GenericSchema, SchemaDefinition } from "convex/server";
4
+ import schema from "./component/schema.js";
5
+
6
+ const modules = import.meta.glob("./component/**/*.ts");
7
+
8
+ /**
9
+ * Register the Sweego component with a `convexTest` instance.
10
+ * @param t - The test convex instance, e.g. from calling `convexTest`.
11
+ * @param name - The component name, as registered in convex.config.ts.
12
+ */
13
+ export function register(
14
+ t: TestConvex<SchemaDefinition<GenericSchema, boolean>>,
15
+ name: string = "sweego",
16
+ ) {
17
+ t.registerComponent(name, schema, modules);
18
+ }
19
+
20
+ export default { register, schema, modules };