@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,835 @@
|
|
|
1
|
+
import { Workpool } from "@convex-dev/workpool";
|
|
2
|
+
import type { FunctionHandle } from "convex/server";
|
|
3
|
+
import { v } from "convex/values";
|
|
4
|
+
import { api, components, internal } from "./_generated/api.js";
|
|
5
|
+
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
6
|
+
import {
|
|
7
|
+
action,
|
|
8
|
+
internalAction,
|
|
9
|
+
internalMutation,
|
|
10
|
+
internalQuery,
|
|
11
|
+
type MutationCtx,
|
|
12
|
+
mutation,
|
|
13
|
+
query,
|
|
14
|
+
} from "./_generated/server.js";
|
|
15
|
+
import schema from "./schema.js";
|
|
16
|
+
import {
|
|
17
|
+
classifyEvent,
|
|
18
|
+
type DeliveryStatus,
|
|
19
|
+
type SweegoEvent,
|
|
20
|
+
vChannel,
|
|
21
|
+
vDeliveryStatus,
|
|
22
|
+
vMessageInput,
|
|
23
|
+
vOptions,
|
|
24
|
+
vSendStatus,
|
|
25
|
+
} from "./shared.js";
|
|
26
|
+
import {
|
|
27
|
+
buildSendRequest,
|
|
28
|
+
fetchLogStatus,
|
|
29
|
+
fetchSmsEstimate,
|
|
30
|
+
parseSendResponse,
|
|
31
|
+
PERMANENT_ERROR_CODES,
|
|
32
|
+
sanitizeSweegoError,
|
|
33
|
+
sweegoFetch,
|
|
34
|
+
} from "./sweego.js";
|
|
35
|
+
import { pickString } from "./utils.js";
|
|
36
|
+
|
|
37
|
+
/* -------------------------------------------------------------------------- */
|
|
38
|
+
/* Constants */
|
|
39
|
+
/* -------------------------------------------------------------------------- */
|
|
40
|
+
|
|
41
|
+
const SEND_POOL_SIZE = 5;
|
|
42
|
+
const CALLBACK_POOL_SIZE = 4;
|
|
43
|
+
const FINALIZED_EPOCH = Number.MAX_SAFE_INTEGER;
|
|
44
|
+
const FINALIZED_RETENTION_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
|
|
45
|
+
const ABANDONED_RETENTION_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
|
|
46
|
+
const EVENT_RETENTION_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
|
|
47
|
+
const CLEANUP_BATCH = 100;
|
|
48
|
+
// Upper bound on per-message deliveries read into a status view / polled, to
|
|
49
|
+
// keep reads bounded for very large bulk sends.
|
|
50
|
+
const MAX_DELIVERIES = 1024;
|
|
51
|
+
|
|
52
|
+
const sendPool = new Workpool(components.sendWorkpool, {
|
|
53
|
+
maxParallelism: SEND_POOL_SIZE,
|
|
54
|
+
});
|
|
55
|
+
const callbackPool = new Workpool(components.callbackWorkpool, {
|
|
56
|
+
maxParallelism: CALLBACK_POOL_SIZE,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/* -------------------------------------------------------------------------- */
|
|
60
|
+
/* Return validators */
|
|
61
|
+
/* -------------------------------------------------------------------------- */
|
|
62
|
+
|
|
63
|
+
const vDeliveryView = v.object({
|
|
64
|
+
swgUid: v.string(),
|
|
65
|
+
recipientKey: v.string(),
|
|
66
|
+
channel: vChannel,
|
|
67
|
+
status: vDeliveryStatus,
|
|
68
|
+
lastEventType: v.union(v.string(), v.null()),
|
|
69
|
+
delivered: v.boolean(),
|
|
70
|
+
bounced: v.boolean(),
|
|
71
|
+
softBounced: v.boolean(),
|
|
72
|
+
complained: v.boolean(),
|
|
73
|
+
unsubscribed: v.boolean(),
|
|
74
|
+
opened: v.boolean(),
|
|
75
|
+
clicked: v.boolean(),
|
|
76
|
+
stopped: v.boolean(),
|
|
77
|
+
errorMessage: v.union(v.string(), v.null()),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const vStatusView = v.object({
|
|
81
|
+
status: vSendStatus,
|
|
82
|
+
channel: vChannel,
|
|
83
|
+
transactionId: v.union(v.string(), v.null()),
|
|
84
|
+
errorMessage: v.union(v.string(), v.null()),
|
|
85
|
+
creditLeft: v.union(v.string(), v.null()),
|
|
86
|
+
deliveries: v.array(vDeliveryView),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const vSendActionResult = v.union(
|
|
90
|
+
v.null(),
|
|
91
|
+
v.object({
|
|
92
|
+
swgUids: v.record(v.string(), v.string()),
|
|
93
|
+
transactionId: v.optional(v.string()),
|
|
94
|
+
creditLeft: v.optional(v.string()),
|
|
95
|
+
channel: v.optional(v.string()),
|
|
96
|
+
provider: v.optional(v.string()),
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
function deliveryView(d: Doc<"deliveries">) {
|
|
101
|
+
return {
|
|
102
|
+
swgUid: d.swgUid,
|
|
103
|
+
recipientKey: d.recipientKey,
|
|
104
|
+
channel: d.channel,
|
|
105
|
+
status: d.status,
|
|
106
|
+
lastEventType: d.lastEventType ?? null,
|
|
107
|
+
delivered: d.delivered,
|
|
108
|
+
bounced: d.bounced,
|
|
109
|
+
softBounced: d.softBounced,
|
|
110
|
+
complained: d.complained,
|
|
111
|
+
unsubscribed: d.unsubscribed,
|
|
112
|
+
opened: d.opened,
|
|
113
|
+
clicked: d.clicked,
|
|
114
|
+
stopped: d.stopped,
|
|
115
|
+
errorMessage: d.errorMessage ?? null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* -------------------------------------------------------------------------- */
|
|
120
|
+
/* Enqueue */
|
|
121
|
+
/* -------------------------------------------------------------------------- */
|
|
122
|
+
|
|
123
|
+
// Enqueue a message to be sent durably by the send workpool.
|
|
124
|
+
export const enqueueMessage = mutation({
|
|
125
|
+
args: {
|
|
126
|
+
options: vOptions,
|
|
127
|
+
message: vMessageInput,
|
|
128
|
+
},
|
|
129
|
+
returns: v.id("messages"),
|
|
130
|
+
handler: async (ctx, args) => {
|
|
131
|
+
const { message, options } = args;
|
|
132
|
+
|
|
133
|
+
// Treat empty strings as absent so an empty body field can't slip through.
|
|
134
|
+
const present = (s: string | undefined) =>
|
|
135
|
+
typeof s === "string" && s.length > 0;
|
|
136
|
+
const hasHtml = present(message.html);
|
|
137
|
+
const hasText = present(message.text);
|
|
138
|
+
const hasTemplate = present(message.templateId);
|
|
139
|
+
|
|
140
|
+
if (hasHtml && hasTemplate) {
|
|
141
|
+
throw new Error("Provide either html or templateId, not both.");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (message.channel === "email") {
|
|
145
|
+
if (!message.from) throw new Error("Email requires a `from` address.");
|
|
146
|
+
if (!message.emailRecipients?.length) {
|
|
147
|
+
throw new Error("Email requires at least one recipient.");
|
|
148
|
+
}
|
|
149
|
+
if (message.bulk && message.emailRecipients.length < 2) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
"Bulk email (/send/bulk/email) requires at least 2 recipients.",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
// Sweego requires a text part or a template; `html` is supplementary and
|
|
155
|
+
// is rejected on its own ("Either 'message-txt' or 'template-id' is
|
|
156
|
+
// required"). Fail fast locally rather than after a permanent 422.
|
|
157
|
+
if (!hasText && !hasTemplate) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
"Email requires `text` or a `templateId`; `html` alone is rejected by Sweego (html is supplementary to a text/template body).",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (!hasTemplate && message.subject === undefined) {
|
|
163
|
+
throw new Error("Email requires a subject when not using a template.");
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
if (!message.smsRecipients?.length) {
|
|
167
|
+
throw new Error("SMS requires at least one recipient.");
|
|
168
|
+
}
|
|
169
|
+
if (!message.campaignType) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
"SMS requires a campaignType ('transac' or 'market').",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (hasHtml) {
|
|
175
|
+
throw new Error("SMS does not support html; use text or templateId.");
|
|
176
|
+
}
|
|
177
|
+
if (hasText && hasTemplate) {
|
|
178
|
+
throw new Error("Provide either text or templateId for SMS, not both.");
|
|
179
|
+
}
|
|
180
|
+
if (!hasText && !hasTemplate) {
|
|
181
|
+
throw new Error("SMS requires text or templateId.");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const messageId = await ctx.db.insert("messages", {
|
|
186
|
+
...message,
|
|
187
|
+
// Normalize empty body fields to absent so they aren't sent to Sweego.
|
|
188
|
+
html: hasHtml ? message.html : undefined,
|
|
189
|
+
text: hasText ? message.text : undefined,
|
|
190
|
+
templateId: hasTemplate ? message.templateId : undefined,
|
|
191
|
+
provider: options.provider,
|
|
192
|
+
status: "queued",
|
|
193
|
+
finalizedAt: FINALIZED_EPOCH,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Remember the latest event callback so the webhook handler can dispatch it.
|
|
197
|
+
await upsertConfig(ctx, options.onEvent);
|
|
198
|
+
|
|
199
|
+
await sendPool.enqueueAction(
|
|
200
|
+
ctx,
|
|
201
|
+
internal.lib.sendMessage,
|
|
202
|
+
{ messageId, options },
|
|
203
|
+
{
|
|
204
|
+
retry: {
|
|
205
|
+
maxAttempts: options.retryAttempts,
|
|
206
|
+
initialBackoffMs: options.initialBackoffMs,
|
|
207
|
+
base: 2,
|
|
208
|
+
},
|
|
209
|
+
context: { messageId },
|
|
210
|
+
onComplete: internal.lib.onSendComplete,
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
return messageId;
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
async function upsertConfig(
|
|
219
|
+
ctx: MutationCtx,
|
|
220
|
+
onEvent: { fnHandle: string } | undefined,
|
|
221
|
+
) {
|
|
222
|
+
// Only register/replace when a handle is actually supplied. A send that
|
|
223
|
+
// omits onEvent must never clear a previously-registered handle — the
|
|
224
|
+
// webhook callback is global and arrives independently of any send.
|
|
225
|
+
if (!onEvent) return;
|
|
226
|
+
const existing = await ctx.db.query("config").unique();
|
|
227
|
+
if (!existing) {
|
|
228
|
+
await ctx.db.insert("config", { onEvent });
|
|
229
|
+
} else if (existing.onEvent?.fnHandle !== onEvent.fnHandle) {
|
|
230
|
+
await ctx.db.patch("config", existing._id, { onEvent });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* -------------------------------------------------------------------------- */
|
|
235
|
+
/* Send (durable action) */
|
|
236
|
+
/* -------------------------------------------------------------------------- */
|
|
237
|
+
|
|
238
|
+
export const sendMessage = internalAction({
|
|
239
|
+
args: { messageId: v.id("messages"), options: vOptions },
|
|
240
|
+
returns: vSendActionResult,
|
|
241
|
+
handler: async (ctx, args) => {
|
|
242
|
+
const message = await ctx.runQuery(internal.lib.getMessage, {
|
|
243
|
+
messageId: args.messageId,
|
|
244
|
+
});
|
|
245
|
+
if (!message || message.status !== "queued") {
|
|
246
|
+
// Cancelled, already sent, or cleaned up — nothing to do.
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const { path, body } = buildSendRequest(message);
|
|
251
|
+
|
|
252
|
+
const response = await sweegoFetch(args.options.apiKey, path, {
|
|
253
|
+
method: "POST",
|
|
254
|
+
json: body,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
if (!response.ok) {
|
|
258
|
+
const errorText = await response.text().catch(() => "");
|
|
259
|
+
if (PERMANENT_ERROR_CODES.has(response.status)) {
|
|
260
|
+
const errorMessage = sanitizeSweegoError(response.status, errorText);
|
|
261
|
+
await ctx.runMutation(internal.lib.recordFailed, {
|
|
262
|
+
messageId: args.messageId,
|
|
263
|
+
errorMessage,
|
|
264
|
+
});
|
|
265
|
+
// Returning (not throwing) prevents the workpool from retrying.
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
// Transient (429, 5xx, 409, …): throw so the workpool retries.
|
|
269
|
+
throw new Error(sanitizeSweegoError(response.status, errorText));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The request succeeded. Never throw past this point: Sweego has no
|
|
273
|
+
// idempotency key, so a retry here would re-send the message. If the body
|
|
274
|
+
// is unparseable we still treat the send as accepted (with no swg_uids).
|
|
275
|
+
const data: unknown = await response.json().catch(() => null);
|
|
276
|
+
const result = parseSendResponse(data ?? {});
|
|
277
|
+
return {
|
|
278
|
+
swgUids: result.swgUids,
|
|
279
|
+
transactionId: result.transactionId,
|
|
280
|
+
creditLeft: result.creditLeft,
|
|
281
|
+
channel: result.channel,
|
|
282
|
+
provider: result.provider,
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
export const onSendComplete = sendPool.defineOnComplete({
|
|
288
|
+
context: v.object({ messageId: v.id("messages") }),
|
|
289
|
+
handler: async (ctx, args) => {
|
|
290
|
+
const { messageId } = args.context;
|
|
291
|
+
if (args.result.kind === "success") {
|
|
292
|
+
const value = args.result.returnValue as {
|
|
293
|
+
swgUids?: Record<string, string>;
|
|
294
|
+
transactionId?: string;
|
|
295
|
+
creditLeft?: string;
|
|
296
|
+
} | null;
|
|
297
|
+
if (!value) return; // permanent failure already recorded, or no-op
|
|
298
|
+
await recordSentHandler(ctx, {
|
|
299
|
+
messageId,
|
|
300
|
+
swgUids: value.swgUids ?? {},
|
|
301
|
+
transactionId: value.transactionId,
|
|
302
|
+
creditLeft: value.creditLeft,
|
|
303
|
+
});
|
|
304
|
+
} else if (args.result.kind === "failed") {
|
|
305
|
+
await recordFailedHandler(ctx, {
|
|
306
|
+
messageId,
|
|
307
|
+
errorMessage: args.result.error,
|
|
308
|
+
});
|
|
309
|
+
} else if (args.result.kind === "canceled") {
|
|
310
|
+
await recordCancelledHandler(ctx, messageId);
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
/* -------------------------------------------------------------------------- */
|
|
316
|
+
/* Recording outcomes */
|
|
317
|
+
/* -------------------------------------------------------------------------- */
|
|
318
|
+
|
|
319
|
+
async function recordSentHandler(
|
|
320
|
+
ctx: MutationCtx,
|
|
321
|
+
args: {
|
|
322
|
+
messageId: Id<"messages">;
|
|
323
|
+
swgUids: Record<string, string>;
|
|
324
|
+
transactionId?: string;
|
|
325
|
+
creditLeft?: string;
|
|
326
|
+
},
|
|
327
|
+
) {
|
|
328
|
+
const message = await ctx.db.get("messages", args.messageId);
|
|
329
|
+
// Only the initial queued -> sent transition records deliveries; this guards
|
|
330
|
+
// against cancellation and any (unexpected) repeated completion.
|
|
331
|
+
if (!message || message.status !== "queued") return;
|
|
332
|
+
|
|
333
|
+
await ctx.db.patch("messages", args.messageId, {
|
|
334
|
+
status: "sent",
|
|
335
|
+
transactionId: args.transactionId,
|
|
336
|
+
creditLeft: args.creditLeft,
|
|
337
|
+
finalizedAt: Date.now(),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
for (const [recipientKey, swgUid] of Object.entries(args.swgUids)) {
|
|
341
|
+
await ctx.db.insert("deliveries", {
|
|
342
|
+
messageId: args.messageId,
|
|
343
|
+
swgUid,
|
|
344
|
+
recipientKey,
|
|
345
|
+
channel: message.channel,
|
|
346
|
+
status: "sent",
|
|
347
|
+
delivered: false,
|
|
348
|
+
bounced: false,
|
|
349
|
+
softBounced: false,
|
|
350
|
+
complained: false,
|
|
351
|
+
unsubscribed: false,
|
|
352
|
+
opened: false,
|
|
353
|
+
clicked: false,
|
|
354
|
+
stopped: false,
|
|
355
|
+
finalizedAt: FINALIZED_EPOCH,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function recordFailedHandler(
|
|
361
|
+
ctx: MutationCtx,
|
|
362
|
+
args: { messageId: Id<"messages">; errorMessage: string },
|
|
363
|
+
) {
|
|
364
|
+
const message = await ctx.db.get("messages", args.messageId);
|
|
365
|
+
if (!message || message.status !== "queued") return;
|
|
366
|
+
await ctx.db.patch("messages", args.messageId, {
|
|
367
|
+
status: "failed",
|
|
368
|
+
errorMessage: args.errorMessage,
|
|
369
|
+
finalizedAt: Date.now(),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function recordCancelledHandler(
|
|
374
|
+
ctx: MutationCtx,
|
|
375
|
+
messageId: Id<"messages">,
|
|
376
|
+
) {
|
|
377
|
+
const message = await ctx.db.get("messages", messageId);
|
|
378
|
+
if (!message || message.status !== "queued") return;
|
|
379
|
+
await ctx.db.patch("messages", messageId, {
|
|
380
|
+
status: "cancelled",
|
|
381
|
+
finalizedAt: Date.now(),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export const recordFailed = internalMutation({
|
|
386
|
+
args: { messageId: v.id("messages"), errorMessage: v.string() },
|
|
387
|
+
returns: v.null(),
|
|
388
|
+
handler: (ctx, args) => recordFailedHandler(ctx, args),
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
/* -------------------------------------------------------------------------- */
|
|
392
|
+
/* Cancel */
|
|
393
|
+
/* -------------------------------------------------------------------------- */
|
|
394
|
+
|
|
395
|
+
// Cancel a message if it has not yet been handed to Sweego.
|
|
396
|
+
export const cancel = mutation({
|
|
397
|
+
args: { messageId: v.id("messages") },
|
|
398
|
+
returns: v.boolean(),
|
|
399
|
+
handler: async (ctx, args) => {
|
|
400
|
+
const message = await ctx.db.get("messages", args.messageId);
|
|
401
|
+
if (!message) throw new Error("Message not found");
|
|
402
|
+
if (message.status !== "queued") return false;
|
|
403
|
+
await ctx.db.patch("messages", args.messageId, {
|
|
404
|
+
status: "cancelled",
|
|
405
|
+
finalizedAt: Date.now(),
|
|
406
|
+
});
|
|
407
|
+
return true;
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
/* -------------------------------------------------------------------------- */
|
|
412
|
+
/* Queries */
|
|
413
|
+
/* -------------------------------------------------------------------------- */
|
|
414
|
+
|
|
415
|
+
export const getMessage = internalQuery({
|
|
416
|
+
args: { messageId: v.id("messages") },
|
|
417
|
+
handler: async (ctx, args) => ctx.db.get("messages", args.messageId),
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Aggregate status of a message plus per-recipient delivery state.
|
|
421
|
+
export const getStatus = query({
|
|
422
|
+
args: { messageId: v.id("messages") },
|
|
423
|
+
returns: v.union(v.null(), vStatusView),
|
|
424
|
+
handler: async (ctx, args) => {
|
|
425
|
+
const message = await ctx.db.get("messages", args.messageId);
|
|
426
|
+
if (!message) return null;
|
|
427
|
+
const deliveries = await ctx.db
|
|
428
|
+
.query("deliveries")
|
|
429
|
+
.withIndex("by_messageId", (q) => q.eq("messageId", args.messageId))
|
|
430
|
+
.take(MAX_DELIVERIES);
|
|
431
|
+
return {
|
|
432
|
+
status: message.status,
|
|
433
|
+
channel: message.channel,
|
|
434
|
+
transactionId: message.transactionId ?? null,
|
|
435
|
+
errorMessage: message.errorMessage ?? null,
|
|
436
|
+
creditLeft: message.creditLeft ?? null,
|
|
437
|
+
deliveries: deliveries.map(deliveryView),
|
|
438
|
+
};
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// The full stored message plus its deliveries.
|
|
443
|
+
export const get = query({
|
|
444
|
+
args: { messageId: v.id("messages") },
|
|
445
|
+
returns: v.union(
|
|
446
|
+
v.null(),
|
|
447
|
+
v.object({
|
|
448
|
+
...schema.tables.messages.validator.fields,
|
|
449
|
+
_id: v.id("messages"),
|
|
450
|
+
_creationTime: v.number(),
|
|
451
|
+
deliveries: v.array(vDeliveryView),
|
|
452
|
+
}),
|
|
453
|
+
),
|
|
454
|
+
handler: async (ctx, args) => {
|
|
455
|
+
const message = await ctx.db.get("messages", args.messageId);
|
|
456
|
+
if (!message) return null;
|
|
457
|
+
const deliveries = await ctx.db
|
|
458
|
+
.query("deliveries")
|
|
459
|
+
.withIndex("by_messageId", (q) => q.eq("messageId", args.messageId))
|
|
460
|
+
.take(MAX_DELIVERIES);
|
|
461
|
+
return { ...message, deliveries: deliveries.map(deliveryView) };
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
/* -------------------------------------------------------------------------- */
|
|
466
|
+
/* Webhook event handling */
|
|
467
|
+
/* -------------------------------------------------------------------------- */
|
|
468
|
+
|
|
469
|
+
const DELIVERY_RANK: Record<DeliveryStatus, number> = {
|
|
470
|
+
pending: 0,
|
|
471
|
+
sent: 1,
|
|
472
|
+
soft_bounced: 2,
|
|
473
|
+
delivered: 3,
|
|
474
|
+
bounced: 4,
|
|
475
|
+
undelivered: 4,
|
|
476
|
+
stopped: 4,
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const TERMINAL_STATUSES = new Set<DeliveryStatus>([
|
|
480
|
+
"delivered",
|
|
481
|
+
"bounced",
|
|
482
|
+
"undelivered",
|
|
483
|
+
"stopped",
|
|
484
|
+
]);
|
|
485
|
+
|
|
486
|
+
// Compute the patch to apply to a delivery for a classified event. Returns null
|
|
487
|
+
// when nothing about the delivery's tracked state changes.
|
|
488
|
+
function computeDeliveryPatch(
|
|
489
|
+
delivery: Doc<"deliveries">,
|
|
490
|
+
): (kind: ReturnType<typeof classifyEvent>) => Partial<Doc<"deliveries">> | null {
|
|
491
|
+
const currentRank = DELIVERY_RANK[delivery.status];
|
|
492
|
+
return (kind) => {
|
|
493
|
+
const patch: Partial<Doc<"deliveries">> = {};
|
|
494
|
+
const upgradeTo = (status: DeliveryStatus) => {
|
|
495
|
+
if (DELIVERY_RANK[status] > currentRank) patch.status = status;
|
|
496
|
+
};
|
|
497
|
+
switch (kind) {
|
|
498
|
+
case "sent":
|
|
499
|
+
case "sms_sent":
|
|
500
|
+
upgradeTo("sent");
|
|
501
|
+
break;
|
|
502
|
+
case "delivered":
|
|
503
|
+
upgradeTo("delivered");
|
|
504
|
+
if (!delivery.delivered) patch.delivered = true;
|
|
505
|
+
break;
|
|
506
|
+
case "soft_bounce":
|
|
507
|
+
upgradeTo("soft_bounced");
|
|
508
|
+
if (!delivery.softBounced) patch.softBounced = true;
|
|
509
|
+
break;
|
|
510
|
+
case "hard_bounce":
|
|
511
|
+
upgradeTo("bounced");
|
|
512
|
+
if (!delivery.bounced) patch.bounced = true;
|
|
513
|
+
break;
|
|
514
|
+
case "sms_undelivered":
|
|
515
|
+
upgradeTo("undelivered");
|
|
516
|
+
break;
|
|
517
|
+
case "sms_stop":
|
|
518
|
+
upgradeTo("stopped");
|
|
519
|
+
if (!delivery.stopped) patch.stopped = true;
|
|
520
|
+
break;
|
|
521
|
+
case "complaint":
|
|
522
|
+
if (!delivery.complained) patch.complained = true;
|
|
523
|
+
break;
|
|
524
|
+
case "unsub":
|
|
525
|
+
if (!delivery.unsubscribed) patch.unsubscribed = true;
|
|
526
|
+
break;
|
|
527
|
+
case "open":
|
|
528
|
+
if (!delivery.opened) patch.opened = true;
|
|
529
|
+
break;
|
|
530
|
+
case "click":
|
|
531
|
+
case "sms_click":
|
|
532
|
+
if (!delivery.clicked) patch.clicked = true;
|
|
533
|
+
break;
|
|
534
|
+
case "inbound":
|
|
535
|
+
case "unknown":
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
if (Object.keys(patch).length === 0) return null;
|
|
539
|
+
if (
|
|
540
|
+
patch.status &&
|
|
541
|
+
TERMINAL_STATUSES.has(patch.status) &&
|
|
542
|
+
delivery.finalizedAt === FINALIZED_EPOCH
|
|
543
|
+
) {
|
|
544
|
+
patch.finalizedAt = Date.now();
|
|
545
|
+
}
|
|
546
|
+
return patch;
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Handle a verified webhook event. The caller (client) verifies the signature
|
|
551
|
+
// before calling this; here we persist the raw event, update delivery state,
|
|
552
|
+
// and dispatch the onEvent callback.
|
|
553
|
+
export const handleEvent = mutation({
|
|
554
|
+
args: { event: v.any(), webhookId: v.optional(v.string()) },
|
|
555
|
+
returns: v.null(),
|
|
556
|
+
handler: async (ctx, args) => {
|
|
557
|
+
const raw = args.event;
|
|
558
|
+
const eventType = pickString(raw, "event_type") ?? "";
|
|
559
|
+
const swgUid = pickString(raw, "swg_uid") ?? "";
|
|
560
|
+
const channelStr = pickString(raw, "channel");
|
|
561
|
+
const channel =
|
|
562
|
+
channelStr === "email" || channelStr === "sms" ? channelStr : undefined;
|
|
563
|
+
const eventId = pickString(raw, "event_id");
|
|
564
|
+
const transactionId = pickString(raw, "transaction_id");
|
|
565
|
+
const timestamp = pickString(raw, "timestamp");
|
|
566
|
+
|
|
567
|
+
if (!eventType || !swgUid) {
|
|
568
|
+
console.warn(
|
|
569
|
+
"[sweego] Webhook event missing event_type or swg_uid; ignoring.",
|
|
570
|
+
);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Idempotency: Sweego webhooks are at-least-once and may be replayed within
|
|
575
|
+
// the tolerance window. Skip anything we've already processed for this
|
|
576
|
+
// webhook-id so the delivery state and onEvent callback fire at most once.
|
|
577
|
+
if (args.webhookId) {
|
|
578
|
+
const seen = await ctx.db
|
|
579
|
+
.query("events")
|
|
580
|
+
.withIndex("by_webhookId", (q) => q.eq("webhookId", args.webhookId))
|
|
581
|
+
.first();
|
|
582
|
+
if (seen) {
|
|
583
|
+
console.info(`[sweego] Duplicate webhook ${args.webhookId} ignored.`);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const delivery = await ctx.db
|
|
589
|
+
.query("deliveries")
|
|
590
|
+
.withIndex("by_swgUid", (q) => q.eq("swgUid", swgUid))
|
|
591
|
+
.unique();
|
|
592
|
+
|
|
593
|
+
// Always record the raw event for auditing.
|
|
594
|
+
await ctx.db.insert("events", {
|
|
595
|
+
swgUid,
|
|
596
|
+
messageId: delivery?.messageId,
|
|
597
|
+
deliveryId: delivery?._id,
|
|
598
|
+
channel: channel ?? delivery?.channel,
|
|
599
|
+
eventType,
|
|
600
|
+
webhookId: args.webhookId,
|
|
601
|
+
eventId,
|
|
602
|
+
transactionId,
|
|
603
|
+
timestamp,
|
|
604
|
+
payload: raw,
|
|
605
|
+
createdAt: Date.now(),
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
if (!delivery) {
|
|
609
|
+
console.info(
|
|
610
|
+
`[sweego] No delivery found for swg_uid ${swgUid} (event ${eventType}); recorded for audit only.`,
|
|
611
|
+
);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Update delivery state from the event.
|
|
616
|
+
const kind = classifyEvent(eventType);
|
|
617
|
+
const patch = computeDeliveryPatch(delivery)(kind) ?? {};
|
|
618
|
+
patch.lastEventType = eventType;
|
|
619
|
+
await ctx.db.patch("deliveries", delivery._id, patch);
|
|
620
|
+
|
|
621
|
+
// Dispatch the host app's onEvent callback (durably, via the callback pool).
|
|
622
|
+
await enqueueCallbackIfExists(ctx, delivery.messageId, {
|
|
623
|
+
eventType,
|
|
624
|
+
channel: channel ?? delivery.channel,
|
|
625
|
+
swgUid,
|
|
626
|
+
eventId,
|
|
627
|
+
transactionId,
|
|
628
|
+
timestamp,
|
|
629
|
+
raw,
|
|
630
|
+
});
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
async function enqueueCallbackIfExists(
|
|
635
|
+
ctx: MutationCtx,
|
|
636
|
+
messageId: Id<"messages">,
|
|
637
|
+
event: SweegoEvent,
|
|
638
|
+
) {
|
|
639
|
+
const config = await ctx.db.query("config").unique();
|
|
640
|
+
if (!config?.onEvent) return;
|
|
641
|
+
const handle = config.onEvent.fnHandle as FunctionHandle<
|
|
642
|
+
"mutation",
|
|
643
|
+
{ messageId: string; swgUid: string; event: SweegoEvent },
|
|
644
|
+
void
|
|
645
|
+
>;
|
|
646
|
+
await callbackPool.enqueueMutation(ctx, handle, {
|
|
647
|
+
messageId,
|
|
648
|
+
swgUid: event.swgUid,
|
|
649
|
+
event,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/* -------------------------------------------------------------------------- */
|
|
654
|
+
/* Estimate (SMS) */
|
|
655
|
+
/* -------------------------------------------------------------------------- */
|
|
656
|
+
|
|
657
|
+
export const estimateSms = action({
|
|
658
|
+
args: { apiKey: v.string(), body: v.any() },
|
|
659
|
+
returns: v.any(),
|
|
660
|
+
handler: async (_ctx, args) => fetchSmsEstimate(args.apiKey, args.body),
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
/* -------------------------------------------------------------------------- */
|
|
664
|
+
/* Refresh status (webhook-free polling fallback) */
|
|
665
|
+
/* -------------------------------------------------------------------------- */
|
|
666
|
+
|
|
667
|
+
function mapLogStatus(status: string): DeliveryStatus | undefined {
|
|
668
|
+
const s = status.toLowerCase();
|
|
669
|
+
if (s.includes("deliver") && !s.includes("undeliver")) return "delivered";
|
|
670
|
+
if (s.includes("undeliver")) return "undelivered";
|
|
671
|
+
if (s.includes("hard") || s.includes("bounce")) return "bounced";
|
|
672
|
+
if (s.includes("soft")) return "soft_bounced";
|
|
673
|
+
if (s.includes("stop")) return "stopped";
|
|
674
|
+
if (s.includes("sent") || s.includes("accept")) return "sent";
|
|
675
|
+
return undefined;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export const applyLogStatus = internalMutation({
|
|
679
|
+
args: {
|
|
680
|
+
deliveryId: v.id("deliveries"),
|
|
681
|
+
remoteStatus: v.string(),
|
|
682
|
+
},
|
|
683
|
+
returns: v.null(),
|
|
684
|
+
handler: async (ctx, args) => {
|
|
685
|
+
const delivery = await ctx.db.get("deliveries", args.deliveryId);
|
|
686
|
+
if (!delivery) return;
|
|
687
|
+
const mapped = mapLogStatus(args.remoteStatus);
|
|
688
|
+
const patch: Partial<Doc<"deliveries">> = {
|
|
689
|
+
lastEventType: `log:${args.remoteStatus}`,
|
|
690
|
+
};
|
|
691
|
+
if (mapped && DELIVERY_RANK[mapped] > DELIVERY_RANK[delivery.status]) {
|
|
692
|
+
patch.status = mapped;
|
|
693
|
+
if (mapped === "delivered") patch.delivered = true;
|
|
694
|
+
if (mapped === "bounced") patch.bounced = true;
|
|
695
|
+
if (mapped === "soft_bounced") patch.softBounced = true;
|
|
696
|
+
if (mapped === "stopped") patch.stopped = true;
|
|
697
|
+
if (
|
|
698
|
+
TERMINAL_STATUSES.has(mapped) &&
|
|
699
|
+
delivery.finalizedAt === FINALIZED_EPOCH
|
|
700
|
+
) {
|
|
701
|
+
patch.finalizedAt = Date.now();
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
await ctx.db.patch("deliveries", args.deliveryId, patch);
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
export const listDeliveries = internalQuery({
|
|
709
|
+
args: { messageId: v.id("messages") },
|
|
710
|
+
handler: async (ctx, args) =>
|
|
711
|
+
ctx.db
|
|
712
|
+
.query("deliveries")
|
|
713
|
+
.withIndex("by_messageId", (q) => q.eq("messageId", args.messageId))
|
|
714
|
+
.take(MAX_DELIVERIES),
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// Poll Sweego's /logs/{swg_uid}/status for (up to MAX_DELIVERIES of) a message's
|
|
718
|
+
// deliveries and update local state. Useful if you haven't configured webhooks.
|
|
719
|
+
// Fetches are done in bounded-concurrency batches to avoid action timeouts.
|
|
720
|
+
export const refreshStatus = action({
|
|
721
|
+
args: { apiKey: v.string(), messageId: v.id("messages") },
|
|
722
|
+
returns: v.number(),
|
|
723
|
+
handler: async (ctx, args) => {
|
|
724
|
+
const deliveries = await ctx.runQuery(internal.lib.listDeliveries, {
|
|
725
|
+
messageId: args.messageId,
|
|
726
|
+
});
|
|
727
|
+
const CONCURRENCY = 10;
|
|
728
|
+
let updated = 0;
|
|
729
|
+
for (let i = 0; i < deliveries.length; i += CONCURRENCY) {
|
|
730
|
+
const batch = deliveries.slice(i, i + CONCURRENCY);
|
|
731
|
+
const results = await Promise.all(
|
|
732
|
+
batch.map((d) =>
|
|
733
|
+
fetchLogStatus(args.apiKey, d.swgUid).then((r) => ({ d, r })),
|
|
734
|
+
),
|
|
735
|
+
);
|
|
736
|
+
for (const { d, r } of results) {
|
|
737
|
+
if (!r) continue;
|
|
738
|
+
await ctx.runMutation(internal.lib.applyLogStatus, {
|
|
739
|
+
deliveryId: d._id,
|
|
740
|
+
remoteStatus: r.status,
|
|
741
|
+
});
|
|
742
|
+
updated++;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return updated;
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
/* -------------------------------------------------------------------------- */
|
|
750
|
+
/* Cleanup (retention) */
|
|
751
|
+
/* -------------------------------------------------------------------------- */
|
|
752
|
+
|
|
753
|
+
async function deleteMessageCascade(ctx: MutationCtx, message: Doc<"messages">) {
|
|
754
|
+
const deliveries = await ctx.db
|
|
755
|
+
.query("deliveries")
|
|
756
|
+
.withIndex("by_messageId", (q) => q.eq("messageId", message._id))
|
|
757
|
+
.collect();
|
|
758
|
+
for (const d of deliveries) await ctx.db.delete("deliveries", d._id);
|
|
759
|
+
const events = await ctx.db
|
|
760
|
+
.query("events")
|
|
761
|
+
.withIndex("by_messageId", (q) => q.eq("messageId", message._id))
|
|
762
|
+
.collect();
|
|
763
|
+
for (const e of events) await ctx.db.delete("events", e._id);
|
|
764
|
+
await ctx.db.delete("messages", message._id);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Delete finalized (sent/failed/cancelled) messages older than `olderThan` ms.
|
|
768
|
+
export const cleanupOldMessages = mutation({
|
|
769
|
+
args: { olderThan: v.optional(v.number()) },
|
|
770
|
+
returns: v.null(),
|
|
771
|
+
handler: async (ctx, args) => {
|
|
772
|
+
const olderThan = args.olderThan ?? FINALIZED_RETENTION_MS;
|
|
773
|
+
const cutoff = Date.now() - olderThan;
|
|
774
|
+
const old = await ctx.db
|
|
775
|
+
.query("messages")
|
|
776
|
+
.withIndex("by_finalizedAt", (q) => q.lt("finalizedAt", cutoff))
|
|
777
|
+
.take(CLEANUP_BATCH);
|
|
778
|
+
for (const message of old) await deleteMessageCascade(ctx, message);
|
|
779
|
+
if (old.length > 0) console.log(`[sweego] Cleaned up ${old.length} messages`);
|
|
780
|
+
if (old.length === CLEANUP_BATCH) {
|
|
781
|
+
await ctx.scheduler.runAfter(0, api.lib.cleanupOldMessages, {
|
|
782
|
+
olderThan,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
// Delete never-finalized messages older than `olderThan` ms (e.g. stuck sends).
|
|
789
|
+
export const cleanupAbandonedMessages = mutation({
|
|
790
|
+
args: { olderThan: v.optional(v.number()) },
|
|
791
|
+
returns: v.null(),
|
|
792
|
+
handler: async (ctx, args) => {
|
|
793
|
+
const olderThan = args.olderThan ?? ABANDONED_RETENTION_MS;
|
|
794
|
+
const cutoff = Date.now() - olderThan;
|
|
795
|
+
// Only never-finalized rows (finalizedAt === FINALIZED_EPOCH); finalized
|
|
796
|
+
// ones are handled by cleanupOldMessages on their earlier finalizedAt. The
|
|
797
|
+
// by_finalizedAt index has _creationTime auto-appended, so we range on it.
|
|
798
|
+
const abandoned = await ctx.db
|
|
799
|
+
.query("messages")
|
|
800
|
+
.withIndex("by_finalizedAt", (q) =>
|
|
801
|
+
q.eq("finalizedAt", FINALIZED_EPOCH).lt("_creationTime", cutoff),
|
|
802
|
+
)
|
|
803
|
+
.take(CLEANUP_BATCH);
|
|
804
|
+
for (const message of abandoned) await deleteMessageCascade(ctx, message);
|
|
805
|
+
if (abandoned.length > 0) {
|
|
806
|
+
console.log(`[sweego] Cleaned up ${abandoned.length} abandoned messages`);
|
|
807
|
+
}
|
|
808
|
+
if (abandoned.length === CLEANUP_BATCH) {
|
|
809
|
+
await ctx.scheduler.runAfter(0, api.lib.cleanupAbandonedMessages, {
|
|
810
|
+
olderThan,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
// Delete raw events older than `olderThan` ms. Events for a message are also
|
|
817
|
+
// removed when that message is cleaned up; this additionally reclaims
|
|
818
|
+
// audit-only events (unmatched swg_uid / inbound) that have no parent message.
|
|
819
|
+
export const cleanupOldEvents = mutation({
|
|
820
|
+
args: { olderThan: v.optional(v.number()) },
|
|
821
|
+
returns: v.null(),
|
|
822
|
+
handler: async (ctx, args) => {
|
|
823
|
+
const olderThan = args.olderThan ?? EVENT_RETENTION_MS;
|
|
824
|
+
const cutoff = Date.now() - olderThan;
|
|
825
|
+
const old = await ctx.db
|
|
826
|
+
.query("events")
|
|
827
|
+
.withIndex("by_createdAt", (q) => q.lt("createdAt", cutoff))
|
|
828
|
+
.take(CLEANUP_BATCH);
|
|
829
|
+
for (const event of old) await ctx.db.delete("events", event._id);
|
|
830
|
+
if (old.length > 0) console.log(`[sweego] Cleaned up ${old.length} events`);
|
|
831
|
+
if (old.length === CLEANUP_BATCH) {
|
|
832
|
+
await ctx.scheduler.runAfter(0, api.lib.cleanupOldEvents, { olderThan });
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
});
|