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.cjs CHANGED
@@ -61,7 +61,7 @@ function toAguMailError(cause) {
61
61
  }
62
62
 
63
63
  // src/runtime.ts
64
- var VERSION = "0.1.0";
64
+ var VERSION = "0.2.0";
65
65
  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.";
66
66
  var API_KEY_ENV = "AGUMAIL_API_KEY";
67
67
  var MAX_DELAY_MS = 2147483647;
@@ -122,6 +122,7 @@ var UNIQUE_ID_MAX = 120;
122
122
  var EVENT_PROPERTIES_MAX = 30;
123
123
  var EVENT_PROPERTIES_CHARS = 8e3;
124
124
  var STATUS_EMAIL_MAX = 320;
125
+ var USER_ID_MAX = 200;
125
126
  var SIGNUP_EMAIL_MAX = 254;
126
127
  var FORM_ID_MAX = 100;
127
128
  var FORM_NAME_MAX = 200;
@@ -132,8 +133,8 @@ var EMAIL = /^(?:[A-Za-z0-9_'+\-]+\.)*[A-Za-z0-9_'+\-]*[A-Za-z0-9_+-]@(?:[A-Za-z
132
133
  var SIGNUP_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
133
134
  var BUILDER = /^[a-z0-9][a-z0-9_-]{0,39}$/;
134
135
  var PROFILE_FIELDS = ["firstName", "lastName", "phone", "timezone", "tags", "properties", "customerSince"];
135
- var CONTACT_FIELDS = ["email", "subscribed", ...PROFILE_FIELDS];
136
- var EVENT_FIELDS = ["email", "event", "properties", "value", "occurredAt", "uniqueId", "contact"];
136
+ var CONTACT_FIELDS = ["email", "userId", "subscribed", ...PROFILE_FIELDS];
137
+ var EVENT_FIELDS = ["email", "userId", "event", "properties", "value", "occurredAt", "uniqueId", "contact"];
137
138
  var SIGNUP_FIELDS = [
138
139
  "email",
139
140
  "doubleOptIn",
@@ -150,8 +151,12 @@ var SIGNUP_FIELDS = [
150
151
  var FORM_FIELDS = ["id", "name", "builder"];
151
152
  var CONTACT_HINTS = {
152
153
  name: "Use firstName and lastName.",
153
- customFields: "Use properties.",
154
- userId: "AguMail identifies contacts by email for now."
154
+ customFields: "Use properties."
155
+ };
156
+ var PROFILE_HINTS = {
157
+ ...CONTACT_HINTS,
158
+ email: "Put it on the event itself.",
159
+ userId: "Put it on the event itself."
155
160
  };
156
161
  var EVENT_HINTS = {
157
162
  metric: "Use event.",
@@ -167,20 +172,27 @@ function contactRecord(input, at) {
167
172
  throw invalidInput(`${at}.subscribed must be true or false.`);
168
173
  }
169
174
  return {
170
- email: email(c.email, `${at}.email`),
175
+ ...identity(c, at),
171
176
  ...profileFields(c, at),
172
177
  consent: { optedIn: c.subscribed !== false, source: "api" }
173
178
  };
174
179
  }
175
- function profileRecord(address, input, at) {
180
+ function profileRecord(who, input, at) {
176
181
  const c = object(input, at);
177
- knownKeys(c, PROFILE_FIELDS, at, CONTACT_HINTS, "Put your own fields in properties.");
178
- return { email: address, ...profileFields(c, at) };
182
+ knownKeys(c, PROFILE_FIELDS, at, PROFILE_HINTS, "Put your own fields in properties.");
183
+ return { ...who, ...profileFields(c, at) };
179
184
  }
180
185
  function consentRecord(address, at, change) {
181
186
  const checked = email(address, at);
182
187
  return change === "subscribe" ? { email: checked, consent: { optedIn: true, source: "my_account" } } : { email: checked, unsubscribed: true, consentSource: "my_account" };
183
188
  }
189
+ function identity(r, at) {
190
+ const out = {};
191
+ if (r.email !== void 0 && r.email !== null) out.email = email(r.email, `${at}.email`);
192
+ set(out, "userId", userId(r.userId, `${at}.userId`));
193
+ if (out.email === void 0 && out.userId === void 0) throw invalidInput(`${at}.email is required when there is no userId.`);
194
+ return out;
195
+ }
184
196
  function profileFields(c, at) {
185
197
  const out = {};
186
198
  set(out, "firstName", text(c.firstName, `${at}.firstName`, NAME_MAX));
@@ -236,7 +248,7 @@ function fieldText(value, at) {
236
248
  function eventRecord(input, at) {
237
249
  const e = object(input, at);
238
250
  knownKeys(e, EVENT_FIELDS, at, EVENT_HINTS);
239
- const address = email(e.email, `${at}.email`);
251
+ const who = identity(e, at);
240
252
  const metric = requiredText(e.event, `${at}.event`, METRIC_MAX).trim();
241
253
  if (!/[A-Za-z0-9]/.test(metric)) {
242
254
  throw invalidInput(
@@ -244,7 +256,7 @@ function eventRecord(input, at) {
244
256
  );
245
257
  }
246
258
  const uniqueId = e.uniqueId === void 0 || e.uniqueId === null ? randomId() : requiredText(e.uniqueId, `${at}.uniqueId`, UNIQUE_ID_MAX);
247
- const record = { email: address, metric, uniqueId };
259
+ const record = { ...who, metric, uniqueId };
248
260
  set(record, "occurredAt", date(e.occurredAt, `${at}.occurredAt`));
249
261
  if (e.value !== void 0 && e.value !== null) {
250
262
  if (typeof e.value !== "number" || !Number.isFinite(e.value)) {
@@ -255,7 +267,7 @@ function eventRecord(input, at) {
255
267
  set(record, "properties", eventProperties(e.properties, `${at}.properties`));
256
268
  const checked = { record, uniqueId };
257
269
  if (e.contact !== void 0 && e.contact !== null) {
258
- checked.contact = profileRecord(address, e.contact, `${at}.contact`);
270
+ checked.contact = profileRecord(who, e.contact, `${at}.contact`);
259
271
  }
260
272
  return checked;
261
273
  }
@@ -323,6 +335,11 @@ function statusEmail(value, at) {
323
335
  }
324
336
  return value;
325
337
  }
338
+ function statusUserId(value, at) {
339
+ const id = idText(value, at);
340
+ if (id.length > USER_ID_MAX) throw invalidInput(`${at} can be at most ${USER_ID_MAX} characters (this one has ${id.length}).`);
341
+ return id;
342
+ }
326
343
  function list(value, at) {
327
344
  if (!Array.isArray(value)) throw invalidInput(`${at} must be an array (got ${describe(value)}).`);
328
345
  return value;
@@ -333,6 +350,21 @@ function email(value, at) {
333
350
  if (!EMAIL.test(trimmed)) throw invalidInput(`${at} is not an email address AguMail accepts: "${value}".`);
334
351
  return trimmed;
335
352
  }
353
+ function userId(value, at) {
354
+ if (value === void 0 || value === null) return void 0;
355
+ const id = idText(value, at);
356
+ if (id.trim() === "") throw invalidInput(`${at} cannot be blank.`);
357
+ if (id.length > USER_ID_MAX) throw invalidInput(`${at} can be at most ${USER_ID_MAX} characters (this one has ${id.length}).`);
358
+ return id;
359
+ }
360
+ function idText(value, at) {
361
+ if (typeof value === "string") return value;
362
+ if (typeof value !== "number" || !Number.isInteger(value)) {
363
+ throw invalidInput(`${at} must be text or a whole number (got ${describe(value)}).`);
364
+ }
365
+ if (!Number.isSafeInteger(value)) throw invalidInput(`${at} is too big to be exact as a number (got ${value}): send it as text.`);
366
+ return String(value);
367
+ }
336
368
  function signupEmail(value, at) {
337
369
  if (typeof value !== "string") throw invalidInput(`${at} must be an email address (got ${describe(value)}).`);
338
370
  const trimmed = value.trim();
@@ -604,7 +636,14 @@ function messageOf(cause) {
604
636
  // src/client.ts
605
637
  var DEFAULT_MAX_RETRIES = 3;
606
638
  var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
607
- var WARNING_CODES = ["unknown_contacts", "over_capacity", "signups_limited", "history"];
639
+ var WARNING_CODES = [
640
+ "unknown_contacts",
641
+ "over_capacity",
642
+ "signups_limited",
643
+ "history",
644
+ "unknown_user_ids",
645
+ "conflicts"
646
+ ];
608
647
  var noThrow = Object.freeze({ throwOnError: false });
609
648
  var AguMail = class {
610
649
  contacts;
@@ -649,9 +688,12 @@ var AguMail = class {
649
688
  /**
650
689
  * Adds or updates a contact, and subscribes them unless you pass `subscribed: false`.
651
690
  * It never brings back someone who unsubscribed or bounced (`suppressedSkipped`).
691
+ * Pass your app's `userId` and AguMail knows them by it, so a changed email moves
692
+ * the same contact. A record that would mix two people up is refused without an
693
+ * error, and listed in `conflicted`.
652
694
  *
653
695
  * @example
654
- * await agumail.identify({ email: 'sam@example.com', firstName: 'Sam' });
696
+ * await agumail.identify({ email: 'sam@example.com', userId: user.id, firstName: 'Sam' });
655
697
  */
656
698
  identify(contact, options) {
657
699
  const core = this.#core;
@@ -663,6 +705,7 @@ var AguMail = class {
663
705
  /**
664
706
  * identify for any number of contacts, 500 to a request. Every record is checked
665
707
  * first, and nothing is sent if one is wrong. Pass `backfill: true` for an import.
708
+ * Each entry of `conflicted` gives its contact's place in your array as `index`.
666
709
  *
667
710
  * @example
668
711
  * await agumail.identifyMany(users.map((u) => ({ email: u.email, subscribed: u.marketingOptIn })), { backfill: true });
@@ -675,8 +718,9 @@ var AguMail = class {
675
718
  });
676
719
  }
677
720
  /**
678
- * Records something a contact did. AguMail skips events for people it doesn't have,
679
- * so identify them first, or pass `contact` to upsert them (never subscribed) first.
721
+ * Records something a contact did, named by email or by your app's `userId`. AguMail
722
+ * skips events for people it doesn't have, so identify them first, or pass `contact`
723
+ * to upsert them (never subscribed) first.
680
724
  *
681
725
  * @example
682
726
  * await agumail.track({ email: 'sam@example.com', event: 'Created project', uniqueId: `project:${id}` });
@@ -691,7 +735,8 @@ var AguMail = class {
691
735
  }
692
736
  /**
693
737
  * track for any number of events, 500 to a request, each batch's contacts first.
694
- * On failure, `error.uniqueIds` holds the id each event was sent with.
738
+ * On failure, `error.uniqueIds` holds the id each event was sent with. A conflict
739
+ * in `contacts.conflicted` gives its event's place in your array as `index`.
695
740
  *
696
741
  * @example
697
742
  * const { uniqueIds } = await agumail.trackMany(rows.map((r) => ({ email: r.email, event: 'Imported' })));
@@ -739,19 +784,23 @@ var AguMail = class {
739
784
  }
740
785
  };
741
786
  function contactsApi(core) {
742
- const status = (emails, options) => core.run(options, async (call) => {
743
- const one = typeof emails === "string";
744
- const asked = one ? [statusEmail(emails, "email")] : list(emails, "emails").map((e, i) => statusEmail(e, `emails[${i}]`));
787
+ const lookup = (by, check) => (given, options) => core.run(options, async (call) => {
788
+ const key = `${by}s`;
789
+ const one = typeof given === "string" || by === "userId" && typeof given === "number";
790
+ const asked = one ? [check(given, by)] : list(given, key).map((v, i) => check(v, `${key}[${i}]`));
745
791
  const answers = [];
746
792
  for (let i = 0; i < asked.length; i += BATCH_SIZE) {
747
793
  const batch = asked.slice(i, i + BATCH_SIZE);
748
- answers.push(...statuses(await core.post("/api/v1/contacts/status", { emails: batch }, batch.length, call)));
794
+ answers.push(...statuses(await core.post("/api/v1/contacts/status", { [key]: batch }, batch.length, call)));
749
795
  }
750
796
  if (!one) return answers;
751
- return answers[0] ?? { email: asked[0], status: null, subscribed: false };
797
+ const first = asked[0];
798
+ const unknown = { email: by === "email" ? first : null, userId: by === "userId" ? first : null };
799
+ return answers[0] ?? { ...unknown, status: null, subscribed: false };
752
800
  });
753
801
  return {
754
- status,
802
+ status: lookup("email", statusEmail),
803
+ statusByUserId: lookup("userId", statusUserId),
755
804
  subscribe: (emails, options) => core.run(options, (call) => core.consentChange(emails, "subscribe", call)),
756
805
  unsubscribe: (emails, options) => core.run(options, (call) => core.consentChange(emails, "unsubscribe", call))
757
806
  };
@@ -795,7 +844,7 @@ var Core = class {
795
844
  async run(options, work) {
796
845
  const given = options;
797
846
  const quiet = given?.throwOnError === false;
798
- const call = { options: given, tally: emptyTally() };
847
+ const call = { options: given, tally: emptyTally(), conflicted: [] };
799
848
  try {
800
849
  if (this.#configProblem) throw invalidInput(this.#configProblem);
801
850
  const problem = callProblem(given);
@@ -808,7 +857,7 @@ var Core = class {
808
857
  this.#report(error);
809
858
  return { data: null, error };
810
859
  } finally {
811
- this.#warn(call.tally);
860
+ this.#warn(call);
812
861
  }
813
862
  }
814
863
  post(path2, body, records, call) {
@@ -821,14 +870,19 @@ var Core = class {
821
870
  const { timeoutMs, signal } = call.options ?? {};
822
871
  return request(this.#transportNow(), { method: "GET", path: path2, records: 0, timeoutMs, signal });
823
872
  }
824
- /** Sends records in batches, one after another, adding up what each batch reports. */
825
- async countBatches(sender, records, extra, call) {
873
+ /**
874
+ * Sends records in batches, one after another, adding up what each batch reports.
875
+ * `indexOf` turns a record's place in `records` into its place in the caller's array.
876
+ */
877
+ async countBatches(sender, records, extra, call, indexOf = (i) => i) {
826
878
  let total;
827
879
  let sent = 0;
828
880
  for (const batch of batches(records, (r) => r)) {
829
881
  try {
830
- const counts = sender.read(await this.post(sender.path, { [sender.key]: batch, ...extra }, batch.length, call));
831
- sender.tally(call.tally, counts);
882
+ const offset = sent;
883
+ const body = await this.post(sender.path, { [sender.key]: batch, ...extra }, batch.length, call);
884
+ const counts = sender.read(body, (i) => indexOf(offset + i));
885
+ sender.tally(call, counts);
832
886
  total = total ? sender.add(total, counts) : counts;
833
887
  sent += batch.length;
834
888
  } catch (err) {
@@ -836,7 +890,7 @@ var Core = class {
836
890
  throw err;
837
891
  }
838
892
  }
839
- return total ?? sender.read({});
893
+ return total ?? sender.read({}, indexOf);
840
894
  }
841
895
  /** Sends events in batches, upserting the contacts a batch carries just before it. */
842
896
  async sendEvents(checked, extra, call) {
@@ -846,8 +900,9 @@ var Core = class {
846
900
  try {
847
901
  for (const batch of batches(checked, (c) => c.record)) {
848
902
  const people = batch.flatMap((c) => c.contact ? [c.contact] : []);
903
+ const owners = batch.flatMap((c, i) => c.contact ? [sent + i] : []);
849
904
  if (people.length > 0) {
850
- const landed = await this.countBatches(CONTACTS, people, extra, call);
905
+ const landed = await this.countBatches(CONTACTS, people, extra, call, (k) => owners[k] ?? k);
851
906
  contacts = contacts ? addContactCounts(contacts, landed) : landed;
852
907
  }
853
908
  const history = extra.backfill === true ? 0 : batch.filter((c) => isHistory(c.record)).length;
@@ -915,7 +970,7 @@ var Core = class {
915
970
  }
916
971
  async #flushQueued(options) {
917
972
  const result = { contacts: contactCounts({}), events: eventCounts({}), error: null };
918
- const call = { options, tally: emptyTally() };
973
+ const call = { options, tally: emptyTally(), conflicted: [] };
919
974
  let events = [];
920
975
  let sending = "contacts";
921
976
  try {
@@ -938,7 +993,7 @@ var Core = class {
938
993
  result.error = error;
939
994
  this.#report(error);
940
995
  }
941
- this.#warn(call.tally);
996
+ this.#warn(call);
942
997
  return result;
943
998
  }
944
999
  #transportNow() {
@@ -949,11 +1004,14 @@ var Core = class {
949
1004
  #report(error) {
950
1005
  callQuietly(this.#onError, error);
951
1006
  }
952
- #warn(tally) {
1007
+ #warn(call) {
953
1008
  if (this.#disabled || !this.#onWarning) return;
954
1009
  for (const code of WARNING_CODES) {
955
- const count2 = tally[code];
956
- if (count2 > 0) callQuietly(this.#onWarning, { code, message: warningMessage(code, count2), count: count2 });
1010
+ const count2 = call.tally[code];
1011
+ if (count2 <= 0) continue;
1012
+ const warning = { code, message: warningMessage(code, count2), count: count2 };
1013
+ if (code === "conflicts") warning.conflicted = call.conflicted;
1014
+ callQuietly(this.#onWarning, warning);
957
1015
  }
958
1016
  }
959
1017
  };
@@ -978,15 +1036,25 @@ function warningMessage(code, n) {
978
1036
  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.`;
979
1037
  case "history":
980
1038
  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.`;
1039
+ case "unknown_user_ids":
1040
+ 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}.`;
1041
+ case "conflicts":
1042
+ 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.`;
981
1043
  }
982
1044
  }
983
1045
  function emptyTally() {
984
- return { unknown_contacts: 0, over_capacity: 0, signups_limited: 0, history: 0 };
1046
+ return { unknown_contacts: 0, over_capacity: 0, signups_limited: 0, history: 0, unknown_user_ids: 0, conflicts: 0 };
985
1047
  }
986
1048
  function disabledAnswer(path2, body) {
987
1049
  if (path2 !== "/api/v1/contacts/status") return {};
988
1050
  const emails = body?.emails ?? [];
989
- return { contacts: emails.map((email2) => ({ email: email2, status: null, subscribed: false })) };
1051
+ const userIds = body?.userIds ?? [];
1052
+ return {
1053
+ contacts: [
1054
+ ...emails.map((email2) => ({ email: email2, userId: null, status: null, subscribed: false })),
1055
+ ...userIds.map((userId2) => ({ email: null, userId: userId2, status: null, subscribed: false }))
1056
+ ]
1057
+ };
990
1058
  }
991
1059
  function batches(items, recordOf) {
992
1060
  const out = [];
@@ -1049,14 +1117,19 @@ function backfillOf(options) {
1049
1117
  function count(value) {
1050
1118
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
1051
1119
  }
1052
- function contactCounts(body) {
1120
+ var LATER_CONTACT_COUNTS = ["confirmationsSent", "unknownContacts", "conflicts"];
1121
+ function contactCounts(body, indexOf = (i) => i) {
1053
1122
  const counts = {
1054
1123
  created: count(body.created),
1055
1124
  updated: count(body.updated),
1056
1125
  suppressedSkipped: count(body.suppressedSkipped),
1057
1126
  overCapacity: count(body.overCapacity)
1058
1127
  };
1059
- if (typeof body.confirmationsSent === "number") counts.confirmationsSent = body.confirmationsSent;
1128
+ for (const key of LATER_CONTACT_COUNTS) {
1129
+ const value = body[key];
1130
+ if (typeof value === "number") counts[key] = value;
1131
+ }
1132
+ if (Array.isArray(body.conflicted)) counts.conflicted = body.conflicted.map((row) => conflict(row, indexOf));
1060
1133
  return counts;
1061
1134
  }
1062
1135
  function addContactCounts(a, b) {
@@ -1066,11 +1139,20 @@ function addContactCounts(a, b) {
1066
1139
  suppressedSkipped: a.suppressedSkipped + b.suppressedSkipped,
1067
1140
  overCapacity: a.overCapacity + b.overCapacity
1068
1141
  };
1069
- if (a.confirmationsSent !== void 0 || b.confirmationsSent !== void 0) {
1070
- sum.confirmationsSent = (a.confirmationsSent ?? 0) + (b.confirmationsSent ?? 0);
1142
+ for (const key of LATER_CONTACT_COUNTS) {
1143
+ if (a[key] !== void 0 || b[key] !== void 0) sum[key] = (a[key] ?? 0) + (b[key] ?? 0);
1071
1144
  }
1145
+ if (a.conflicted || b.conflicted) sum.conflicted = [...a.conflicted ?? [], ...b.conflicted ?? []];
1072
1146
  return sum;
1073
1147
  }
1148
+ function conflict(row, indexOf) {
1149
+ return {
1150
+ index: indexOf(count(row.index)),
1151
+ userId: typeof row.userId === "string" ? row.userId : null,
1152
+ email: typeof row.email === "string" ? row.email : null,
1153
+ reason: String(row.reason)
1154
+ };
1155
+ }
1074
1156
  function eventCounts(body) {
1075
1157
  return { created: count(body.created), duplicates: count(body.duplicates), unknownContacts: count(body.unknownContacts) };
1076
1158
  }
@@ -1092,8 +1174,11 @@ var CONTACTS = {
1092
1174
  key: "contacts",
1093
1175
  read: contactCounts,
1094
1176
  add: addContactCounts,
1095
- tally: (tally, counts) => {
1096
- tally.over_capacity += counts.overCapacity;
1177
+ tally: (call, counts) => {
1178
+ call.tally.over_capacity += counts.overCapacity;
1179
+ call.tally.unknown_user_ids += counts.unknownContacts ?? 0;
1180
+ call.tally.conflicts += counts.conflicts ?? 0;
1181
+ if (counts.conflicted) call.conflicted.push(...counts.conflicted);
1097
1182
  }
1098
1183
  };
1099
1184
  var SIGNUPS = {
@@ -1101,15 +1186,16 @@ var SIGNUPS = {
1101
1186
  key: "signups",
1102
1187
  read: signupCounts,
1103
1188
  add: addSignupCounts,
1104
- tally: (tally, counts) => {
1105
- tally.over_capacity += counts.overCapacity;
1106
- tally.signups_limited += counts.limited;
1189
+ tally: (call, counts) => {
1190
+ call.tally.over_capacity += counts.overCapacity;
1191
+ call.tally.signups_limited += counts.limited;
1107
1192
  }
1108
1193
  };
1109
1194
  function statuses(body) {
1110
1195
  const rows = Array.isArray(body.contacts) ? body.contacts : [];
1111
1196
  return rows.map((row) => ({
1112
- email: String(row.email ?? ""),
1197
+ email: typeof row.email === "string" ? row.email : null,
1198
+ userId: typeof row.userId === "string" ? row.userId : null,
1113
1199
  status: typeof row.status === "string" ? row.status : null,
1114
1200
  subscribed: row.subscribed === true
1115
1201
  }));
package/dist/index.d.cts 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 };