agumail-js 0.1.0 → 0.2.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 +87 -38
- package/dist/index.cjs +134 -48
- package/dist/index.d.cts +84 -13
- package/dist/index.d.ts +84 -13
- package/dist/index.js +134 -48
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -26,13 +26,15 @@ declare class AguMailError extends Error {
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
type AguMailWarningCode = 'unknown_contacts' | 'over_capacity' | 'signups_limited' | 'history';
|
|
29
|
+
type AguMailWarningCode = 'unknown_contacts' | 'over_capacity' | 'signups_limited' | 'history' | 'unknown_user_ids' | 'conflicts';
|
|
30
30
|
/** Records AguMail took without an error, but dropped or held back. */
|
|
31
31
|
interface AguMailWarning {
|
|
32
32
|
code: AguMailWarningCode;
|
|
33
33
|
message: string;
|
|
34
34
|
/** How many records it covers, added up over the call or flush. */
|
|
35
35
|
count: number;
|
|
36
|
+
/** On a `conflicts` warning: the contacts AguMail refused, and why. */
|
|
37
|
+
conflicted?: ContactConflict[];
|
|
36
38
|
}
|
|
37
39
|
interface AguMailOptions {
|
|
38
40
|
/** Where the AguMail API lives. Defaults to https://api.agumail.com. */
|
|
@@ -54,7 +56,8 @@ interface AguMailOptions {
|
|
|
54
56
|
/**
|
|
55
57
|
* Called when AguMail took a call without an error but dropped or held back
|
|
56
58
|
* some of it: events for unknown contacts, a full plan, rate-limited signups,
|
|
57
|
-
*
|
|
59
|
+
* events old enough to count as history, a userId nobody has, or an email
|
|
60
|
+
* that belongs to someone else. It may be async.
|
|
58
61
|
*/
|
|
59
62
|
onWarning?: ((warning: AguMailWarning) => void | Promise<void>) | undefined;
|
|
60
63
|
/**
|
|
@@ -115,7 +118,15 @@ type Outcome<T, O> = O extends {
|
|
|
115
118
|
/** A contact property. Sent to AguMail as text. */
|
|
116
119
|
type PropertyValue = string | number | boolean | Date | null | undefined;
|
|
117
120
|
interface Contact {
|
|
118
|
-
|
|
121
|
+
/** Needed unless you pass userId. With a userId, it is the address the contact should have. */
|
|
122
|
+
email?: string | null | undefined;
|
|
123
|
+
/**
|
|
124
|
+
* Your app's own id for the person, up to 200 characters, sent exactly as you
|
|
125
|
+
* give it (a whole number is sent as text). AguMail then knows them by it: when
|
|
126
|
+
* they change their email in your app, identify moves the same contact to the
|
|
127
|
+
* new address. A contact keeps the first id it is sent with.
|
|
128
|
+
*/
|
|
129
|
+
userId?: string | number | null | undefined;
|
|
119
130
|
firstName?: string | null | undefined;
|
|
120
131
|
lastName?: string | null | undefined;
|
|
121
132
|
phone?: string | null | undefined;
|
|
@@ -132,9 +143,12 @@ interface Contact {
|
|
|
132
143
|
/** When they became your customer. Becomes their "Date added" when AguMail creates them. */
|
|
133
144
|
customerSince?: string | Date | null | undefined;
|
|
134
145
|
}
|
|
135
|
-
type ContactProfile = Omit<Contact, 'email' | 'subscribed'>;
|
|
146
|
+
type ContactProfile = Omit<Contact, 'email' | 'userId' | 'subscribed'>;
|
|
136
147
|
interface TrackEvent {
|
|
137
|
-
|
|
148
|
+
/** Who did it. Needed unless you pass userId. */
|
|
149
|
+
email?: string | null | undefined;
|
|
150
|
+
/** Your app's own id for the person. With one, AguMail finds them by it alone, and reads no email. */
|
|
151
|
+
userId?: string | number | null | undefined;
|
|
138
152
|
/** Your name for what happened, such as "Created first campaign". */
|
|
139
153
|
event: string;
|
|
140
154
|
/** Any plain object, nested or typed by your own interface. Sent as JSON. */
|
|
@@ -148,7 +162,11 @@ interface TrackEvent {
|
|
|
148
162
|
* your whole account. One is generated when you leave it out.
|
|
149
163
|
*/
|
|
150
164
|
uniqueId?: string | null | undefined;
|
|
151
|
-
/**
|
|
165
|
+
/**
|
|
166
|
+
* Upserts the contact first, with these fields and the event's email and userId.
|
|
167
|
+
* That upsert is an identify that never changes their subscription: with both
|
|
168
|
+
* email and userId, it can move the contact to that email.
|
|
169
|
+
*/
|
|
152
170
|
contact?: ContactProfile | null | undefined;
|
|
153
171
|
}
|
|
154
172
|
interface Signup {
|
|
@@ -180,6 +198,32 @@ interface ContactCounts {
|
|
|
180
198
|
overCapacity: number;
|
|
181
199
|
/** Sent by AguMail servers that report confirmation emails. */
|
|
182
200
|
confirmationsSent?: number;
|
|
201
|
+
/**
|
|
202
|
+
* Contacts sent with only a userId that no contact has, so they were skipped:
|
|
203
|
+
* a new contact needs an email. Sent by AguMail servers that know user ids, as
|
|
204
|
+
* are conflicts and conflicted.
|
|
205
|
+
*/
|
|
206
|
+
unknownContacts?: number;
|
|
207
|
+
/** Contacts refused because their email belongs to someone else. */
|
|
208
|
+
conflicts?: number;
|
|
209
|
+
/** Each contact refused, and why. AguMail never merges two contacts. */
|
|
210
|
+
conflicted?: ContactConflict[];
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* - `email_taken`: the userId's contact can't move to this email, because another contact has it.
|
|
214
|
+
* - `user_id_mismatch`: the contact with this email already has a different userId.
|
|
215
|
+
*/
|
|
216
|
+
type ContactConflictReason = 'email_taken' | 'user_id_mismatch';
|
|
217
|
+
interface ContactConflict {
|
|
218
|
+
/**
|
|
219
|
+
* The record's place in the array you passed, from 0: the contact's for
|
|
220
|
+
* identifyMany, the event's for trackMany. In a flush, its place among the
|
|
221
|
+
* contacts that flush sent, in the order they were queued.
|
|
222
|
+
*/
|
|
223
|
+
index: number;
|
|
224
|
+
userId: string | null;
|
|
225
|
+
email: string | null;
|
|
226
|
+
reason: ContactConflictReason;
|
|
183
227
|
}
|
|
184
228
|
interface EventCounts {
|
|
185
229
|
created: number;
|
|
@@ -198,8 +242,11 @@ interface TrackManyResult extends EventCounts {
|
|
|
198
242
|
}
|
|
199
243
|
type ContactStatusValue = 'active' | 'nonsubscribed' | 'unconfirmed' | 'unsubscribed' | 'bounced' | 'complained' | 'cleaned';
|
|
200
244
|
interface ContactStatus {
|
|
201
|
-
|
|
202
|
-
|
|
245
|
+
/** The address asked about, or the contact's own when you asked by userId. null for an id nobody has. */
|
|
246
|
+
email: string | null;
|
|
247
|
+
/** The id asked about, or the contact's own when you asked by email. null when it has none, or nobody has the address. */
|
|
248
|
+
userId: string | null;
|
|
249
|
+
/** null when the address or id is not on your list. */
|
|
203
250
|
status: ContactStatusValue | null;
|
|
204
251
|
/** The one to act on: subscribed, and not unsubscribed or bounced since. */
|
|
205
252
|
subscribed: boolean;
|
|
@@ -222,6 +269,7 @@ interface SignupCounts {
|
|
|
222
269
|
limited: number;
|
|
223
270
|
}
|
|
224
271
|
interface PingResult {
|
|
272
|
+
/** `userId` is your AguMail account's id, not one of your app's user ids. */
|
|
225
273
|
shop: {
|
|
226
274
|
userId: string;
|
|
227
275
|
shopName: string | null;
|
|
@@ -253,6 +301,21 @@ interface ContactsApi {
|
|
|
253
301
|
* const answers = await agumail.contacts.status(['sam@example.com', 'alex@example.com']);
|
|
254
302
|
*/
|
|
255
303
|
status<O extends CallOptions | undefined = undefined>(emails: readonly string[], options?: O): Promise<Outcome<ContactStatus[], O>>;
|
|
304
|
+
/**
|
|
305
|
+
* Where the contact with your app's user id stands, as status answers for an address.
|
|
306
|
+
* `email` is the address that contact has, and `status` is null for an id nobody has.
|
|
307
|
+
*
|
|
308
|
+
* @example
|
|
309
|
+
* const { subscribed } = await agumail.contacts.statusByUserId(user.id);
|
|
310
|
+
*/
|
|
311
|
+
statusByUserId<O extends CallOptions | undefined = undefined>(userId: string | number, options?: O): Promise<Outcome<ContactStatus, O>>;
|
|
312
|
+
/**
|
|
313
|
+
* Where each user id stands, in the order you asked, 500 to a request.
|
|
314
|
+
*
|
|
315
|
+
* @example
|
|
316
|
+
* const answers = await agumail.contacts.statusByUserId(users.map((u) => u.id));
|
|
317
|
+
*/
|
|
318
|
+
statusByUserId<O extends CallOptions | undefined = undefined>(userIds: readonly (string | number)[], options?: O): Promise<Outcome<ContactStatus[], O>>;
|
|
256
319
|
/**
|
|
257
320
|
* The person turned marketing email on, themselves, in your app's settings. Call it
|
|
258
321
|
* only then: someone who left from an email gets one confirmation email instead.
|
|
@@ -288,6 +351,8 @@ interface QueueApi {
|
|
|
288
351
|
/**
|
|
289
352
|
* Queues a contact for the next flush, subscribed unless `subscribed: false`, as
|
|
290
353
|
* with identify. Never throws: a bad record goes to onError and is not queued.
|
|
354
|
+
* A contact AguMail refuses because its email belongs to someone else is a
|
|
355
|
+
* `conflicts` warning, and is listed in the flush's result.
|
|
291
356
|
*
|
|
292
357
|
* @example
|
|
293
358
|
* agumail.queue.identify({ email: user.email, firstName: user.firstName });
|
|
@@ -319,22 +384,27 @@ declare class AguMail {
|
|
|
319
384
|
/**
|
|
320
385
|
* Adds or updates a contact, and subscribes them unless you pass `subscribed: false`.
|
|
321
386
|
* It never brings back someone who unsubscribed or bounced (`suppressedSkipped`).
|
|
387
|
+
* Pass your app's `userId` and AguMail knows them by it, so a changed email moves
|
|
388
|
+
* the same contact. A record that would mix two people up is refused without an
|
|
389
|
+
* error, and listed in `conflicted`.
|
|
322
390
|
*
|
|
323
391
|
* @example
|
|
324
|
-
* await agumail.identify({ email: 'sam@example.com', firstName: 'Sam' });
|
|
392
|
+
* await agumail.identify({ email: 'sam@example.com', userId: user.id, firstName: 'Sam' });
|
|
325
393
|
*/
|
|
326
394
|
identify<O extends CallOptions | undefined = undefined>(contact: Contact, options?: O): Promise<Outcome<ContactCounts, O>>;
|
|
327
395
|
/**
|
|
328
396
|
* identify for any number of contacts, 500 to a request. Every record is checked
|
|
329
397
|
* first, and nothing is sent if one is wrong. Pass `backfill: true` for an import.
|
|
398
|
+
* Each entry of `conflicted` gives its contact's place in your array as `index`.
|
|
330
399
|
*
|
|
331
400
|
* @example
|
|
332
401
|
* await agumail.identifyMany(users.map((u) => ({ email: u.email, subscribed: u.marketingOptIn })), { backfill: true });
|
|
333
402
|
*/
|
|
334
403
|
identifyMany<O extends BatchOptions | undefined = undefined>(contacts: readonly Contact[], options?: O): Promise<Outcome<ContactCounts, O>>;
|
|
335
404
|
/**
|
|
336
|
-
* Records something a contact did
|
|
337
|
-
* so identify them first, or pass `contact`
|
|
405
|
+
* Records something a contact did, named by email or by your app's `userId`. AguMail
|
|
406
|
+
* skips events for people it doesn't have, so identify them first, or pass `contact`
|
|
407
|
+
* to upsert them (never subscribed) first.
|
|
338
408
|
*
|
|
339
409
|
* @example
|
|
340
410
|
* await agumail.track({ email: 'sam@example.com', event: 'Created project', uniqueId: `project:${id}` });
|
|
@@ -342,7 +412,8 @@ declare class AguMail {
|
|
|
342
412
|
track<O extends CallOptions | undefined = undefined>(event: TrackEvent, options?: O): Promise<Outcome<TrackResult, O>>;
|
|
343
413
|
/**
|
|
344
414
|
* track for any number of events, 500 to a request, each batch's contacts first.
|
|
345
|
-
* On failure, `error.uniqueIds` holds the id each event was sent with.
|
|
415
|
+
* On failure, `error.uniqueIds` holds the id each event was sent with. A conflict
|
|
416
|
+
* in `contacts.conflicted` gives its event's place in your array as `index`.
|
|
346
417
|
*
|
|
347
418
|
* @example
|
|
348
419
|
* const { uniqueIds } = await agumail.trackMany(rows.map((r) => ({ email: r.email, event: 'Imported' })));
|
|
@@ -371,4 +442,4 @@ declare class AguMail {
|
|
|
371
442
|
declare const VERSION: string;
|
|
372
443
|
|
|
373
444
|
export { AguMail, AguMailError, VERSION, noThrow };
|
|
374
|
-
export type { AguMailErrorCode, AguMailErrorPartial, AguMailOptions, AguMailWarning, AguMailWarningCode, BatchOptions, CallOptions, Contact, ContactCounts, ContactProfile, ContactStatus, ContactStatusValue, ContactsApi, EventCounts, FlushOptions, FlushResult, Form, FormList, FormsApi, Outcome, PingResult, PropertyValue, QueueApi, Settled, Signup, SignupCounts, SignupsApi, TrackEvent, TrackManyResult, TrackResult };
|
|
445
|
+
export type { AguMailErrorCode, AguMailErrorPartial, AguMailOptions, AguMailWarning, AguMailWarningCode, BatchOptions, CallOptions, Contact, ContactConflict, ContactConflictReason, ContactCounts, ContactProfile, ContactStatus, ContactStatusValue, ContactsApi, EventCounts, FlushOptions, FlushResult, Form, FormList, FormsApi, Outcome, PingResult, PropertyValue, QueueApi, Settled, Signup, SignupCounts, SignupsApi, TrackEvent, TrackManyResult, TrackResult };
|
package/dist/index.js
CHANGED
|
@@ -32,7 +32,7 @@ function toAguMailError(cause) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// src/runtime.ts
|
|
35
|
-
var VERSION = "0.
|
|
35
|
+
var VERSION = "0.2.0";
|
|
36
36
|
var BROWSER_MESSAGE = "agumail-js runs on your server only. Your AguMail API key is secret, and code in a browser hands it to every visitor. Call AguMail from an API route, a server action or your backend instead.";
|
|
37
37
|
var API_KEY_ENV = "AGUMAIL_API_KEY";
|
|
38
38
|
var MAX_DELAY_MS = 2147483647;
|
|
@@ -93,6 +93,7 @@ var UNIQUE_ID_MAX = 120;
|
|
|
93
93
|
var EVENT_PROPERTIES_MAX = 30;
|
|
94
94
|
var EVENT_PROPERTIES_CHARS = 8e3;
|
|
95
95
|
var STATUS_EMAIL_MAX = 320;
|
|
96
|
+
var USER_ID_MAX = 200;
|
|
96
97
|
var SIGNUP_EMAIL_MAX = 254;
|
|
97
98
|
var FORM_ID_MAX = 100;
|
|
98
99
|
var FORM_NAME_MAX = 200;
|
|
@@ -103,8 +104,8 @@ var EMAIL = /^(?:[A-Za-z0-9_'+\-]+\.)*[A-Za-z0-9_'+\-]*[A-Za-z0-9_+-]@(?:[A-Za-z
|
|
|
103
104
|
var SIGNUP_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
104
105
|
var BUILDER = /^[a-z0-9][a-z0-9_-]{0,39}$/;
|
|
105
106
|
var PROFILE_FIELDS = ["firstName", "lastName", "phone", "timezone", "tags", "properties", "customerSince"];
|
|
106
|
-
var CONTACT_FIELDS = ["email", "subscribed", ...PROFILE_FIELDS];
|
|
107
|
-
var EVENT_FIELDS = ["email", "event", "properties", "value", "occurredAt", "uniqueId", "contact"];
|
|
107
|
+
var CONTACT_FIELDS = ["email", "userId", "subscribed", ...PROFILE_FIELDS];
|
|
108
|
+
var EVENT_FIELDS = ["email", "userId", "event", "properties", "value", "occurredAt", "uniqueId", "contact"];
|
|
108
109
|
var SIGNUP_FIELDS = [
|
|
109
110
|
"email",
|
|
110
111
|
"doubleOptIn",
|
|
@@ -121,8 +122,12 @@ var SIGNUP_FIELDS = [
|
|
|
121
122
|
var FORM_FIELDS = ["id", "name", "builder"];
|
|
122
123
|
var CONTACT_HINTS = {
|
|
123
124
|
name: "Use firstName and lastName.",
|
|
124
|
-
customFields: "Use properties."
|
|
125
|
-
|
|
125
|
+
customFields: "Use properties."
|
|
126
|
+
};
|
|
127
|
+
var PROFILE_HINTS = {
|
|
128
|
+
...CONTACT_HINTS,
|
|
129
|
+
email: "Put it on the event itself.",
|
|
130
|
+
userId: "Put it on the event itself."
|
|
126
131
|
};
|
|
127
132
|
var EVENT_HINTS = {
|
|
128
133
|
metric: "Use event.",
|
|
@@ -138,20 +143,27 @@ function contactRecord(input, at) {
|
|
|
138
143
|
throw invalidInput(`${at}.subscribed must be true or false.`);
|
|
139
144
|
}
|
|
140
145
|
return {
|
|
141
|
-
|
|
146
|
+
...identity(c, at),
|
|
142
147
|
...profileFields(c, at),
|
|
143
148
|
consent: { optedIn: c.subscribed !== false, source: "api" }
|
|
144
149
|
};
|
|
145
150
|
}
|
|
146
|
-
function profileRecord(
|
|
151
|
+
function profileRecord(who, input, at) {
|
|
147
152
|
const c = object(input, at);
|
|
148
|
-
knownKeys(c, PROFILE_FIELDS, at,
|
|
149
|
-
return {
|
|
153
|
+
knownKeys(c, PROFILE_FIELDS, at, PROFILE_HINTS, "Put your own fields in properties.");
|
|
154
|
+
return { ...who, ...profileFields(c, at) };
|
|
150
155
|
}
|
|
151
156
|
function consentRecord(address, at, change) {
|
|
152
157
|
const checked = email(address, at);
|
|
153
158
|
return change === "subscribe" ? { email: checked, consent: { optedIn: true, source: "my_account" } } : { email: checked, unsubscribed: true, consentSource: "my_account" };
|
|
154
159
|
}
|
|
160
|
+
function identity(r, at) {
|
|
161
|
+
const out = {};
|
|
162
|
+
if (r.email !== void 0 && r.email !== null) out.email = email(r.email, `${at}.email`);
|
|
163
|
+
set(out, "userId", userId(r.userId, `${at}.userId`));
|
|
164
|
+
if (out.email === void 0 && out.userId === void 0) throw invalidInput(`${at}.email is required when there is no userId.`);
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
155
167
|
function profileFields(c, at) {
|
|
156
168
|
const out = {};
|
|
157
169
|
set(out, "firstName", text(c.firstName, `${at}.firstName`, NAME_MAX));
|
|
@@ -207,7 +219,7 @@ function fieldText(value, at) {
|
|
|
207
219
|
function eventRecord(input, at) {
|
|
208
220
|
const e = object(input, at);
|
|
209
221
|
knownKeys(e, EVENT_FIELDS, at, EVENT_HINTS);
|
|
210
|
-
const
|
|
222
|
+
const who = identity(e, at);
|
|
211
223
|
const metric = requiredText(e.event, `${at}.event`, METRIC_MAX).trim();
|
|
212
224
|
if (!/[A-Za-z0-9]/.test(metric)) {
|
|
213
225
|
throw invalidInput(
|
|
@@ -215,7 +227,7 @@ function eventRecord(input, at) {
|
|
|
215
227
|
);
|
|
216
228
|
}
|
|
217
229
|
const uniqueId = e.uniqueId === void 0 || e.uniqueId === null ? randomId() : requiredText(e.uniqueId, `${at}.uniqueId`, UNIQUE_ID_MAX);
|
|
218
|
-
const record = {
|
|
230
|
+
const record = { ...who, metric, uniqueId };
|
|
219
231
|
set(record, "occurredAt", date(e.occurredAt, `${at}.occurredAt`));
|
|
220
232
|
if (e.value !== void 0 && e.value !== null) {
|
|
221
233
|
if (typeof e.value !== "number" || !Number.isFinite(e.value)) {
|
|
@@ -226,7 +238,7 @@ function eventRecord(input, at) {
|
|
|
226
238
|
set(record, "properties", eventProperties(e.properties, `${at}.properties`));
|
|
227
239
|
const checked = { record, uniqueId };
|
|
228
240
|
if (e.contact !== void 0 && e.contact !== null) {
|
|
229
|
-
checked.contact = profileRecord(
|
|
241
|
+
checked.contact = profileRecord(who, e.contact, `${at}.contact`);
|
|
230
242
|
}
|
|
231
243
|
return checked;
|
|
232
244
|
}
|
|
@@ -294,6 +306,11 @@ function statusEmail(value, at) {
|
|
|
294
306
|
}
|
|
295
307
|
return value;
|
|
296
308
|
}
|
|
309
|
+
function statusUserId(value, at) {
|
|
310
|
+
const id = idText(value, at);
|
|
311
|
+
if (id.length > USER_ID_MAX) throw invalidInput(`${at} can be at most ${USER_ID_MAX} characters (this one has ${id.length}).`);
|
|
312
|
+
return id;
|
|
313
|
+
}
|
|
297
314
|
function list(value, at) {
|
|
298
315
|
if (!Array.isArray(value)) throw invalidInput(`${at} must be an array (got ${describe(value)}).`);
|
|
299
316
|
return value;
|
|
@@ -304,6 +321,21 @@ function email(value, at) {
|
|
|
304
321
|
if (!EMAIL.test(trimmed)) throw invalidInput(`${at} is not an email address AguMail accepts: "${value}".`);
|
|
305
322
|
return trimmed;
|
|
306
323
|
}
|
|
324
|
+
function userId(value, at) {
|
|
325
|
+
if (value === void 0 || value === null) return void 0;
|
|
326
|
+
const id = idText(value, at);
|
|
327
|
+
if (id.trim() === "") throw invalidInput(`${at} cannot be blank.`);
|
|
328
|
+
if (id.length > USER_ID_MAX) throw invalidInput(`${at} can be at most ${USER_ID_MAX} characters (this one has ${id.length}).`);
|
|
329
|
+
return id;
|
|
330
|
+
}
|
|
331
|
+
function idText(value, at) {
|
|
332
|
+
if (typeof value === "string") return value;
|
|
333
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
334
|
+
throw invalidInput(`${at} must be text or a whole number (got ${describe(value)}).`);
|
|
335
|
+
}
|
|
336
|
+
if (!Number.isSafeInteger(value)) throw invalidInput(`${at} is too big to be exact as a number (got ${value}): send it as text.`);
|
|
337
|
+
return String(value);
|
|
338
|
+
}
|
|
307
339
|
function signupEmail(value, at) {
|
|
308
340
|
if (typeof value !== "string") throw invalidInput(`${at} must be an email address (got ${describe(value)}).`);
|
|
309
341
|
const trimmed = value.trim();
|
|
@@ -575,7 +607,14 @@ function messageOf(cause) {
|
|
|
575
607
|
// src/client.ts
|
|
576
608
|
var DEFAULT_MAX_RETRIES = 3;
|
|
577
609
|
var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
|
|
578
|
-
var WARNING_CODES = [
|
|
610
|
+
var WARNING_CODES = [
|
|
611
|
+
"unknown_contacts",
|
|
612
|
+
"over_capacity",
|
|
613
|
+
"signups_limited",
|
|
614
|
+
"history",
|
|
615
|
+
"unknown_user_ids",
|
|
616
|
+
"conflicts"
|
|
617
|
+
];
|
|
579
618
|
var noThrow = Object.freeze({ throwOnError: false });
|
|
580
619
|
var AguMail = class {
|
|
581
620
|
contacts;
|
|
@@ -620,9 +659,12 @@ var AguMail = class {
|
|
|
620
659
|
/**
|
|
621
660
|
* Adds or updates a contact, and subscribes them unless you pass `subscribed: false`.
|
|
622
661
|
* It never brings back someone who unsubscribed or bounced (`suppressedSkipped`).
|
|
662
|
+
* Pass your app's `userId` and AguMail knows them by it, so a changed email moves
|
|
663
|
+
* the same contact. A record that would mix two people up is refused without an
|
|
664
|
+
* error, and listed in `conflicted`.
|
|
623
665
|
*
|
|
624
666
|
* @example
|
|
625
|
-
* await agumail.identify({ email: 'sam@example.com', firstName: 'Sam' });
|
|
667
|
+
* await agumail.identify({ email: 'sam@example.com', userId: user.id, firstName: 'Sam' });
|
|
626
668
|
*/
|
|
627
669
|
identify(contact, options) {
|
|
628
670
|
const core = this.#core;
|
|
@@ -634,6 +676,7 @@ var AguMail = class {
|
|
|
634
676
|
/**
|
|
635
677
|
* identify for any number of contacts, 500 to a request. Every record is checked
|
|
636
678
|
* first, and nothing is sent if one is wrong. Pass `backfill: true` for an import.
|
|
679
|
+
* Each entry of `conflicted` gives its contact's place in your array as `index`.
|
|
637
680
|
*
|
|
638
681
|
* @example
|
|
639
682
|
* await agumail.identifyMany(users.map((u) => ({ email: u.email, subscribed: u.marketingOptIn })), { backfill: true });
|
|
@@ -646,8 +689,9 @@ var AguMail = class {
|
|
|
646
689
|
});
|
|
647
690
|
}
|
|
648
691
|
/**
|
|
649
|
-
* Records something a contact did
|
|
650
|
-
* so identify them first, or pass `contact`
|
|
692
|
+
* Records something a contact did, named by email or by your app's `userId`. AguMail
|
|
693
|
+
* skips events for people it doesn't have, so identify them first, or pass `contact`
|
|
694
|
+
* to upsert them (never subscribed) first.
|
|
651
695
|
*
|
|
652
696
|
* @example
|
|
653
697
|
* await agumail.track({ email: 'sam@example.com', event: 'Created project', uniqueId: `project:${id}` });
|
|
@@ -662,7 +706,8 @@ var AguMail = class {
|
|
|
662
706
|
}
|
|
663
707
|
/**
|
|
664
708
|
* track for any number of events, 500 to a request, each batch's contacts first.
|
|
665
|
-
* On failure, `error.uniqueIds` holds the id each event was sent with.
|
|
709
|
+
* On failure, `error.uniqueIds` holds the id each event was sent with. A conflict
|
|
710
|
+
* in `contacts.conflicted` gives its event's place in your array as `index`.
|
|
666
711
|
*
|
|
667
712
|
* @example
|
|
668
713
|
* const { uniqueIds } = await agumail.trackMany(rows.map((r) => ({ email: r.email, event: 'Imported' })));
|
|
@@ -710,19 +755,23 @@ var AguMail = class {
|
|
|
710
755
|
}
|
|
711
756
|
};
|
|
712
757
|
function contactsApi(core) {
|
|
713
|
-
const
|
|
714
|
-
const
|
|
715
|
-
const
|
|
758
|
+
const lookup = (by, check) => (given, options) => core.run(options, async (call) => {
|
|
759
|
+
const key = `${by}s`;
|
|
760
|
+
const one = typeof given === "string" || by === "userId" && typeof given === "number";
|
|
761
|
+
const asked = one ? [check(given, by)] : list(given, key).map((v, i) => check(v, `${key}[${i}]`));
|
|
716
762
|
const answers = [];
|
|
717
763
|
for (let i = 0; i < asked.length; i += BATCH_SIZE) {
|
|
718
764
|
const batch = asked.slice(i, i + BATCH_SIZE);
|
|
719
|
-
answers.push(...statuses(await core.post("/api/v1/contacts/status", {
|
|
765
|
+
answers.push(...statuses(await core.post("/api/v1/contacts/status", { [key]: batch }, batch.length, call)));
|
|
720
766
|
}
|
|
721
767
|
if (!one) return answers;
|
|
722
|
-
|
|
768
|
+
const first = asked[0];
|
|
769
|
+
const unknown = { email: by === "email" ? first : null, userId: by === "userId" ? first : null };
|
|
770
|
+
return answers[0] ?? { ...unknown, status: null, subscribed: false };
|
|
723
771
|
});
|
|
724
772
|
return {
|
|
725
|
-
status,
|
|
773
|
+
status: lookup("email", statusEmail),
|
|
774
|
+
statusByUserId: lookup("userId", statusUserId),
|
|
726
775
|
subscribe: (emails, options) => core.run(options, (call) => core.consentChange(emails, "subscribe", call)),
|
|
727
776
|
unsubscribe: (emails, options) => core.run(options, (call) => core.consentChange(emails, "unsubscribe", call))
|
|
728
777
|
};
|
|
@@ -766,7 +815,7 @@ var Core = class {
|
|
|
766
815
|
async run(options, work) {
|
|
767
816
|
const given = options;
|
|
768
817
|
const quiet = given?.throwOnError === false;
|
|
769
|
-
const call = { options: given, tally: emptyTally() };
|
|
818
|
+
const call = { options: given, tally: emptyTally(), conflicted: [] };
|
|
770
819
|
try {
|
|
771
820
|
if (this.#configProblem) throw invalidInput(this.#configProblem);
|
|
772
821
|
const problem = callProblem(given);
|
|
@@ -779,7 +828,7 @@ var Core = class {
|
|
|
779
828
|
this.#report(error);
|
|
780
829
|
return { data: null, error };
|
|
781
830
|
} finally {
|
|
782
|
-
this.#warn(call
|
|
831
|
+
this.#warn(call);
|
|
783
832
|
}
|
|
784
833
|
}
|
|
785
834
|
post(path2, body, records, call) {
|
|
@@ -792,14 +841,19 @@ var Core = class {
|
|
|
792
841
|
const { timeoutMs, signal } = call.options ?? {};
|
|
793
842
|
return request(this.#transportNow(), { method: "GET", path: path2, records: 0, timeoutMs, signal });
|
|
794
843
|
}
|
|
795
|
-
/**
|
|
796
|
-
|
|
844
|
+
/**
|
|
845
|
+
* Sends records in batches, one after another, adding up what each batch reports.
|
|
846
|
+
* `indexOf` turns a record's place in `records` into its place in the caller's array.
|
|
847
|
+
*/
|
|
848
|
+
async countBatches(sender, records, extra, call, indexOf = (i) => i) {
|
|
797
849
|
let total;
|
|
798
850
|
let sent = 0;
|
|
799
851
|
for (const batch of batches(records, (r) => r)) {
|
|
800
852
|
try {
|
|
801
|
-
const
|
|
802
|
-
|
|
853
|
+
const offset = sent;
|
|
854
|
+
const body = await this.post(sender.path, { [sender.key]: batch, ...extra }, batch.length, call);
|
|
855
|
+
const counts = sender.read(body, (i) => indexOf(offset + i));
|
|
856
|
+
sender.tally(call, counts);
|
|
803
857
|
total = total ? sender.add(total, counts) : counts;
|
|
804
858
|
sent += batch.length;
|
|
805
859
|
} catch (err) {
|
|
@@ -807,7 +861,7 @@ var Core = class {
|
|
|
807
861
|
throw err;
|
|
808
862
|
}
|
|
809
863
|
}
|
|
810
|
-
return total ?? sender.read({});
|
|
864
|
+
return total ?? sender.read({}, indexOf);
|
|
811
865
|
}
|
|
812
866
|
/** Sends events in batches, upserting the contacts a batch carries just before it. */
|
|
813
867
|
async sendEvents(checked, extra, call) {
|
|
@@ -817,8 +871,9 @@ var Core = class {
|
|
|
817
871
|
try {
|
|
818
872
|
for (const batch of batches(checked, (c) => c.record)) {
|
|
819
873
|
const people = batch.flatMap((c) => c.contact ? [c.contact] : []);
|
|
874
|
+
const owners = batch.flatMap((c, i) => c.contact ? [sent + i] : []);
|
|
820
875
|
if (people.length > 0) {
|
|
821
|
-
const landed = await this.countBatches(CONTACTS, people, extra, call);
|
|
876
|
+
const landed = await this.countBatches(CONTACTS, people, extra, call, (k) => owners[k] ?? k);
|
|
822
877
|
contacts = contacts ? addContactCounts(contacts, landed) : landed;
|
|
823
878
|
}
|
|
824
879
|
const history = extra.backfill === true ? 0 : batch.filter((c) => isHistory(c.record)).length;
|
|
@@ -886,7 +941,7 @@ var Core = class {
|
|
|
886
941
|
}
|
|
887
942
|
async #flushQueued(options) {
|
|
888
943
|
const result = { contacts: contactCounts({}), events: eventCounts({}), error: null };
|
|
889
|
-
const call = { options, tally: emptyTally() };
|
|
944
|
+
const call = { options, tally: emptyTally(), conflicted: [] };
|
|
890
945
|
let events = [];
|
|
891
946
|
let sending = "contacts";
|
|
892
947
|
try {
|
|
@@ -909,7 +964,7 @@ var Core = class {
|
|
|
909
964
|
result.error = error;
|
|
910
965
|
this.#report(error);
|
|
911
966
|
}
|
|
912
|
-
this.#warn(call
|
|
967
|
+
this.#warn(call);
|
|
913
968
|
return result;
|
|
914
969
|
}
|
|
915
970
|
#transportNow() {
|
|
@@ -920,11 +975,14 @@ var Core = class {
|
|
|
920
975
|
#report(error) {
|
|
921
976
|
callQuietly(this.#onError, error);
|
|
922
977
|
}
|
|
923
|
-
#warn(
|
|
978
|
+
#warn(call) {
|
|
924
979
|
if (this.#disabled || !this.#onWarning) return;
|
|
925
980
|
for (const code of WARNING_CODES) {
|
|
926
|
-
const count2 = tally[code];
|
|
927
|
-
if (count2
|
|
981
|
+
const count2 = call.tally[code];
|
|
982
|
+
if (count2 <= 0) continue;
|
|
983
|
+
const warning = { code, message: warningMessage(code, count2), count: count2 };
|
|
984
|
+
if (code === "conflicts") warning.conflicted = call.conflicted;
|
|
985
|
+
callQuietly(this.#onWarning, warning);
|
|
928
986
|
}
|
|
929
987
|
}
|
|
930
988
|
};
|
|
@@ -949,15 +1007,25 @@ function warningMessage(code, n) {
|
|
|
949
1007
|
return `AguMail dropped ${n} ${many ? "signups" : "signup"} over its limit of 10 new addresses per visitor IP a minute. Pass the visitor's IP address as ip, not your server's.`;
|
|
950
1008
|
case "history":
|
|
951
1009
|
return `${n} ${many ? "events" : "event"} happened more than 72 hours ago, so AguMail stores ${them} as history and starts no flows from ${them}. Send events as they happen, or pass backfill: true for an import.`;
|
|
1010
|
+
case "unknown_user_ids":
|
|
1011
|
+
return `AguMail skipped ${n} ${many ? "contacts" : "contact"} sent with only a userId that no contact has. Send the email with the userId, so AguMail can add ${them}.`;
|
|
1012
|
+
case "conflicts":
|
|
1013
|
+
return `AguMail refused ${n} ${many ? "contacts" : "contact"} whose email belongs to someone else, so that two people are never mixed up: AguMail never merges contacts. warning.conflicted lists ${them}, and why.`;
|
|
952
1014
|
}
|
|
953
1015
|
}
|
|
954
1016
|
function emptyTally() {
|
|
955
|
-
return { unknown_contacts: 0, over_capacity: 0, signups_limited: 0, history: 0 };
|
|
1017
|
+
return { unknown_contacts: 0, over_capacity: 0, signups_limited: 0, history: 0, unknown_user_ids: 0, conflicts: 0 };
|
|
956
1018
|
}
|
|
957
1019
|
function disabledAnswer(path2, body) {
|
|
958
1020
|
if (path2 !== "/api/v1/contacts/status") return {};
|
|
959
1021
|
const emails = body?.emails ?? [];
|
|
960
|
-
|
|
1022
|
+
const userIds = body?.userIds ?? [];
|
|
1023
|
+
return {
|
|
1024
|
+
contacts: [
|
|
1025
|
+
...emails.map((email2) => ({ email: email2, userId: null, status: null, subscribed: false })),
|
|
1026
|
+
...userIds.map((userId2) => ({ email: null, userId: userId2, status: null, subscribed: false }))
|
|
1027
|
+
]
|
|
1028
|
+
};
|
|
961
1029
|
}
|
|
962
1030
|
function batches(items, recordOf) {
|
|
963
1031
|
const out = [];
|
|
@@ -1020,14 +1088,19 @@ function backfillOf(options) {
|
|
|
1020
1088
|
function count(value) {
|
|
1021
1089
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
1022
1090
|
}
|
|
1023
|
-
|
|
1091
|
+
var LATER_CONTACT_COUNTS = ["confirmationsSent", "unknownContacts", "conflicts"];
|
|
1092
|
+
function contactCounts(body, indexOf = (i) => i) {
|
|
1024
1093
|
const counts = {
|
|
1025
1094
|
created: count(body.created),
|
|
1026
1095
|
updated: count(body.updated),
|
|
1027
1096
|
suppressedSkipped: count(body.suppressedSkipped),
|
|
1028
1097
|
overCapacity: count(body.overCapacity)
|
|
1029
1098
|
};
|
|
1030
|
-
|
|
1099
|
+
for (const key of LATER_CONTACT_COUNTS) {
|
|
1100
|
+
const value = body[key];
|
|
1101
|
+
if (typeof value === "number") counts[key] = value;
|
|
1102
|
+
}
|
|
1103
|
+
if (Array.isArray(body.conflicted)) counts.conflicted = body.conflicted.map((row) => conflict(row, indexOf));
|
|
1031
1104
|
return counts;
|
|
1032
1105
|
}
|
|
1033
1106
|
function addContactCounts(a, b) {
|
|
@@ -1037,11 +1110,20 @@ function addContactCounts(a, b) {
|
|
|
1037
1110
|
suppressedSkipped: a.suppressedSkipped + b.suppressedSkipped,
|
|
1038
1111
|
overCapacity: a.overCapacity + b.overCapacity
|
|
1039
1112
|
};
|
|
1040
|
-
|
|
1041
|
-
sum
|
|
1113
|
+
for (const key of LATER_CONTACT_COUNTS) {
|
|
1114
|
+
if (a[key] !== void 0 || b[key] !== void 0) sum[key] = (a[key] ?? 0) + (b[key] ?? 0);
|
|
1042
1115
|
}
|
|
1116
|
+
if (a.conflicted || b.conflicted) sum.conflicted = [...a.conflicted ?? [], ...b.conflicted ?? []];
|
|
1043
1117
|
return sum;
|
|
1044
1118
|
}
|
|
1119
|
+
function conflict(row, indexOf) {
|
|
1120
|
+
return {
|
|
1121
|
+
index: indexOf(count(row.index)),
|
|
1122
|
+
userId: typeof row.userId === "string" ? row.userId : null,
|
|
1123
|
+
email: typeof row.email === "string" ? row.email : null,
|
|
1124
|
+
reason: String(row.reason)
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1045
1127
|
function eventCounts(body) {
|
|
1046
1128
|
return { created: count(body.created), duplicates: count(body.duplicates), unknownContacts: count(body.unknownContacts) };
|
|
1047
1129
|
}
|
|
@@ -1063,8 +1145,11 @@ var CONTACTS = {
|
|
|
1063
1145
|
key: "contacts",
|
|
1064
1146
|
read: contactCounts,
|
|
1065
1147
|
add: addContactCounts,
|
|
1066
|
-
tally: (
|
|
1067
|
-
tally.over_capacity += counts.overCapacity;
|
|
1148
|
+
tally: (call, counts) => {
|
|
1149
|
+
call.tally.over_capacity += counts.overCapacity;
|
|
1150
|
+
call.tally.unknown_user_ids += counts.unknownContacts ?? 0;
|
|
1151
|
+
call.tally.conflicts += counts.conflicts ?? 0;
|
|
1152
|
+
if (counts.conflicted) call.conflicted.push(...counts.conflicted);
|
|
1068
1153
|
}
|
|
1069
1154
|
};
|
|
1070
1155
|
var SIGNUPS = {
|
|
@@ -1072,15 +1157,16 @@ var SIGNUPS = {
|
|
|
1072
1157
|
key: "signups",
|
|
1073
1158
|
read: signupCounts,
|
|
1074
1159
|
add: addSignupCounts,
|
|
1075
|
-
tally: (
|
|
1076
|
-
tally.over_capacity += counts.overCapacity;
|
|
1077
|
-
tally.signups_limited += counts.limited;
|
|
1160
|
+
tally: (call, counts) => {
|
|
1161
|
+
call.tally.over_capacity += counts.overCapacity;
|
|
1162
|
+
call.tally.signups_limited += counts.limited;
|
|
1078
1163
|
}
|
|
1079
1164
|
};
|
|
1080
1165
|
function statuses(body) {
|
|
1081
1166
|
const rows = Array.isArray(body.contacts) ? body.contacts : [];
|
|
1082
1167
|
return rows.map((row) => ({
|
|
1083
|
-
email:
|
|
1168
|
+
email: typeof row.email === "string" ? row.email : null,
|
|
1169
|
+
userId: typeof row.userId === "string" ? row.userId : null,
|
|
1084
1170
|
status: typeof row.status === "string" ? row.status : null,
|
|
1085
1171
|
subscribed: row.subscribed === true
|
|
1086
1172
|
}));
|