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/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
- * or events old enough to count as history. It may be async.
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
- email: string;
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
- email: string;
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
- /** Upserts the contact first, with these fields. Never changes their subscription. */
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
- email: string;
202
- /** null when the address is not on your list. */
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. AguMail skips events for people it doesn't have,
337
- * so identify them first, or pass `contact` to upsert them (never subscribed) first.
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.1.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
- userId: "AguMail identifies contacts by email for now."
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
- email: email(c.email, `${at}.email`),
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(address, input, at) {
151
+ function profileRecord(who, input, at) {
147
152
  const c = object(input, at);
148
- knownKeys(c, PROFILE_FIELDS, at, CONTACT_HINTS, "Put your own fields in properties.");
149
- return { email: address, ...profileFields(c, at) };
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 address = email(e.email, `${at}.email`);
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 = { email: address, metric, uniqueId };
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(address, e.contact, `${at}.contact`);
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 = ["unknown_contacts", "over_capacity", "signups_limited", "history"];
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. AguMail skips events for people it doesn't have,
650
- * so identify them first, or pass `contact` to upsert them (never subscribed) first.
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 status = (emails, options) => core.run(options, async (call) => {
714
- const one = typeof emails === "string";
715
- const asked = one ? [statusEmail(emails, "email")] : list(emails, "emails").map((e, i) => statusEmail(e, `emails[${i}]`));
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", { emails: batch }, batch.length, call)));
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
- return answers[0] ?? { email: asked[0], status: null, subscribed: false };
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.tally);
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
- /** Sends records in batches, one after another, adding up what each batch reports. */
796
- async countBatches(sender, records, extra, call) {
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 counts = sender.read(await this.post(sender.path, { [sender.key]: batch, ...extra }, batch.length, call));
802
- sender.tally(call.tally, counts);
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.tally);
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(tally) {
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 > 0) callQuietly(this.#onWarning, { code, message: warningMessage(code, count2), count: 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
- return { contacts: emails.map((email2) => ({ email: email2, status: null, subscribed: false })) };
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
- function contactCounts(body) {
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
- if (typeof body.confirmationsSent === "number") counts.confirmationsSent = body.confirmationsSent;
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
- if (a.confirmationsSent !== void 0 || b.confirmationsSent !== void 0) {
1041
- sum.confirmationsSent = (a.confirmationsSent ?? 0) + (b.confirmationsSent ?? 0);
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: (tally, counts) => {
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: (tally, counts) => {
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: String(row.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
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agumail-js",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Add your users to AguMail and record what they do, so your AguMail flows can start from it.",
5
5
  "license": "MIT",
6
6
  "author": "AguMail",