agumail-js 0.1.1 → 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.cjs
CHANGED
|
@@ -61,7 +61,7 @@ function toAguMailError(cause) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
// src/runtime.ts
|
|
64
|
-
var VERSION = "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
|
-
|
|
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
|
-
|
|
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(
|
|
180
|
+
function profileRecord(who, input, at) {
|
|
176
181
|
const c = object(input, at);
|
|
177
|
-
knownKeys(c, PROFILE_FIELDS, at,
|
|
178
|
-
return {
|
|
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
|
|
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 = {
|
|
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(
|
|
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 = [
|
|
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
|
|
679
|
-
* so identify them first, or pass `contact`
|
|
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
|
|
743
|
-
const
|
|
744
|
-
const
|
|
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", {
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
825
|
-
|
|
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
|
|
831
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1070
|
-
sum
|
|
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: (
|
|
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: (
|
|
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:
|
|
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
|
-
*
|
|
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 };
|