@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,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 };
|