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 CHANGED
@@ -36,7 +36,8 @@ Both calls resolve with what AguMail counted:
36
36
 
37
37
  ```ts
38
38
  const added = await agumail.identify({ email: 'sam@example.com', firstName: 'Sam' });
39
- // { created: 1, updated: 0, suppressedSkipped: 0, overCapacity: 0, confirmationsSent: 0 }
39
+ // { created: 1, updated: 0, suppressedSkipped: 0, overCapacity: 0, confirmationsSent: 0,
40
+ // unknownContacts: 0, conflicts: 0, conflicted: [] }
40
41
 
41
42
  const recorded = await agumail.track({ email: 'sam@example.com', event: 'Created first project' });
42
43
  // { created: 1, duplicates: 0, unknownContacts: 0, uniqueId: '0b6f9d3e-...' }
@@ -47,7 +48,8 @@ const recorded = await agumail.track({ email: 'sam@example.com', event: 'Created
47
48
  - `overCapacity`: your plan has no room for another contact, so they weren't added.
48
49
  - `confirmationsSent`: confirmation emails AguMail sent, for someone coming back after unsubscribing (see [Email settings in your app](#email-settings-in-your-app)).
49
50
  - `duplicates`: an event with this `uniqueId` was already recorded, so this one wasn't.
50
- - `unknownContacts`: AguMail doesn't have this person, so the event was skipped.
51
+ - `unknownContacts`: AguMail doesn't have this person, so the event was skipped. For a contact, it counts those sent with only a `userId` that nobody has.
52
+ - `conflicts` and `conflicted`: contacts refused because their email belongs to someone else (see [Identify by your app's user id](#identify-by-your-apps-user-id)).
51
53
  - `uniqueId`: the id the event was sent with: yours, or one the package made up.
52
54
 
53
55
  ## Never break a signup
@@ -100,6 +102,8 @@ const agumail = new AguMail(process.env.AGUMAIL_API_KEY, {
100
102
  | `over_capacity` | Contacts or signups weren't added because your plan has no room for more contacts. |
101
103
  | `signups_limited` | Signups over the limit of 10 new addresses per visitor IP a minute were dropped. |
102
104
  | `history` | Events dated more than 72 hours ago, sent without `backfill: true`, were stored but start no flows. |
105
+ | `unknown_user_ids` | Contacts sent with only a `userId` that nobody has were skipped. Send the email too, so AguMail can add them. |
106
+ | `conflicts` | Contacts whose email belongs to someone else were refused. `warning.conflicted` lists them. |
103
107
 
104
108
  Each warning fires at most once per call (or flush), and `warning.count` adds up the records it covers. Warnings fire for calls that throw and calls that don't. Duplicates and `suppressedSkipped` are normal, so they never warn. Both callbacks may be async, and neither can make a call fail.
105
109
 
@@ -163,6 +167,46 @@ if (result.error?.uniqueIds) {
163
167
  }
164
168
  ```
165
169
 
170
+ ## Identify by your app's user id
171
+
172
+ If your app gives each user an id, send it as `userId` beside the email. AguMail then knows the person by that id: when they change their email in your app, your next `identify` moves the same contact to the new address. Their status, tags, history and flows stay with them, and they are not counted twice.
173
+
174
+ ```ts
175
+ await agumail.identify({ userId: user.id, email: user.email, firstName: user.firstName });
176
+ ```
177
+
178
+ - `userId` is your own id for the person: text up to 200 characters, sent exactly as you give it, or a whole number, which is sent as text. A contact keeps the first id it is sent with.
179
+ - A contact AguMail already has by email takes the id the first time you send both.
180
+ - With a `userId`, the email is optional: `identify({ userId: user.id, ... })` updates that contact. If nobody has that id, the record is skipped and counted in `unknownContacts`, because a new contact needs an email.
181
+ - `track` takes `userId` in place of `email`, or beside it. With a `userId`, AguMail finds the person by the id alone, and an event never changes anyone's email or id.
182
+ - The `contact` you pass to `track` goes with the event's `userId` and `email`. That upsert works like `identify` (without touching their subscription), so with both it can move the contact to that email.
183
+ - A new address that is on your suppression list stays blocked: the contact keeps its status, and nothing is sent to that address.
184
+
185
+ AguMail never merges two contacts. A record that would mix two people up is refused without an error, and the rest of the call still lands:
186
+
187
+ - `email_taken`: the new email already belongs to another contact.
188
+ - `user_id_mismatch`: the contact with that email already has a different `userId`.
189
+
190
+ `conflicts` counts them and `conflicted` lists each one. `index` is its place in the array you passed, from 0, and for `trackMany`, the place of the event that carried the `contact`:
191
+
192
+ ```ts
193
+ const result = await agumail.identifyMany(users.map((u) => ({ userId: u.id, email: u.email })));
194
+ // { ..., conflicts: 1, conflicted: [{ index: 1, userId: 'user_77', email: 'alex@example.com', reason: 'email_taken' }] }
195
+ ```
196
+
197
+ `onWarning` gets a `conflicts` warning with the same list in `warning.conflicted`, and an `unknown_user_ids` warning for ids nobody has. For [the queue](#batching-with-the-queue), these warnings are how you hear about them, since nobody reads the result of a flush the queue starts itself. In a flush, `index` is the place among the contacts that flush sent, in the order you queued them.
198
+
199
+ To ask where someone stands by their id, use `contacts.statusByUserId`. It takes one id or an array, like `contacts.status`:
200
+
201
+ ```ts
202
+ const sam = await agumail.contacts.statusByUserId(user.id);
203
+ // { email: 'sam@example.com', userId: 'user_8412', status: 'active', subscribed: true }
204
+ ```
205
+
206
+ For an id nobody has, `email` and `status` are `null`.
207
+
208
+ `ping()` returns a `shop.userId` too. That one is your AguMail account's id, not one of your users' ids.
209
+
166
210
  ## Contact properties
167
211
 
168
212
  `properties` become the contact's custom fields in AguMail. AguMail keeps them as text, so the package converts for you:
@@ -187,7 +231,7 @@ await agumail.contacts.unsubscribe(user.email); // they switched it off
187
231
  await agumail.contacts.subscribe(user.email); // they switched it on
188
232
  ```
189
233
 
190
- Call these only when the signed-in person changes the setting themselves, never from a sync. When they switch it back on:
234
+ Call these only when the signed-in person changes the setting themselves, never from a sync. They take email addresses, not user ids, so a switch-off holds even for someone AguMail doesn't have yet. When they switch it back on:
191
235
 
192
236
  - If they switched it off in your app, they are subscribed again straight away.
193
237
  - If they clicked unsubscribe in one of your emails, or marked one as spam, AguMail sends them one confirmation email (at most one a day), and they are back when they click it. `confirmationsSent` in the result counts these, so you can tell them to check their inbox.
@@ -199,10 +243,10 @@ To show the switch in the right position, ask AguMail. `subscribed` is the value
199
243
 
200
244
  ```ts
201
245
  const sam = await agumail.contacts.status('sam@example.com');
202
- // { email: 'sam@example.com', status: 'active', subscribed: true }
246
+ // { email: 'sam@example.com', userId: 'user_8412', status: 'active', subscribed: true }
203
247
  ```
204
248
 
205
- `status` is `null` for an address that isn't on your list. Pass an array to ask about as many addresses as you like: the package asks 500 at a time and keeps your order.
249
+ `status` is `null` for an address that isn't on your list, and `userId` is `null` when the contact has none. Pass an array to ask about as many addresses as you like: the package asks 500 at a time and keeps your order. To ask by your app's user ids, use [`contacts.statusByUserId`](#identify-by-your-apps-user-id).
206
250
 
207
251
  ## Examples
208
252
 
@@ -305,6 +349,7 @@ export const addToAguMail: OnAfterSignupHook = async ({ user }) => {
305
349
  // Not awaited, and never throws: AguMail can't hold up or fail a signup.
306
350
  void agumail.identify(
307
351
  {
352
+ userId: user.id,
308
353
  email: user.email,
309
354
  firstName: user.firstName,
310
355
  lastName: user.lastName,
@@ -323,7 +368,7 @@ import { addToAguMail } from './src/auth/agumail' with { type: 'ref' };
323
368
  onAfterSignup: addToAguMail,
324
369
  ```
325
370
 
326
- Then record what they do from your actions:
371
+ Then record what they do from your actions. AguMail knows them by their Wasp user id, so that is all an event needs:
327
372
 
328
373
  ```ts
329
374
  // src/campaigns/operations.ts
@@ -332,18 +377,17 @@ import { noThrow } from 'agumail-js';
332
377
  import { agumail } from '../server/agumail';
333
378
 
334
379
  export const createCampaign: CreateCampaign<{ name: string }, { id: string }> = async ({ name }, context) => {
335
- const campaign = await context.entities.Campaign.create({ data: { name, userId: context.user!.id } });
336
- if (context.user?.email) {
337
- void agumail.track(
338
- {
339
- email: context.user.email,
340
- event: 'Created campaign',
341
- properties: { campaignId: campaign.id },
342
- uniqueId: `campaign_created:${campaign.id}`,
343
- },
344
- noThrow
345
- );
346
- }
380
+ const user = context.user!;
381
+ const campaign = await context.entities.Campaign.create({ data: { name, userId: user.id } });
382
+ void agumail.track(
383
+ {
384
+ userId: user.id,
385
+ event: 'Created campaign',
386
+ properties: { campaignId: campaign.id },
387
+ uniqueId: `campaign_created:${campaign.id}`,
388
+ },
389
+ noThrow
390
+ );
347
391
  return campaign;
348
392
  };
349
393
  ```
@@ -361,6 +405,7 @@ agumail.queue.track({ email: user.email, event: 'Signed up' });
361
405
  - The queue sends itself (a flush) once 500 records are waiting, or 5 seconds after the first one was queued. `flushAt` and `flushIntervalMs` change both.
362
406
  - A flush sends every queued contact first, 500 to a request, then every event, so an event never arrives before its contact. The `contact` you pass to `queue.track` goes out with the contacts.
363
407
  - A flush that still fails after its retries goes to `onError`, and its records are dropped, not queued again.
408
+ - A queued contact AguMail refuses because its email belongs to someone else is a `conflicts` warning, and a `userId` nobody has is an `unknown_user_ids` warning (see [Identify by your app's user id](#identify-by-your-apps-user-id)).
364
409
  - The queue lives in memory, and its timer never keeps a Node process running. Records still queued when the process stops are lost, so flush first.
365
410
 
366
411
  `flush()` sends everything queued now, and resolves when that's done. It never rejects:
@@ -443,7 +488,7 @@ const { loaderUrl, forms } = await agumail.forms.list();
443
488
  Check these in order:
444
489
 
445
490
  1. **Is the person Subscribed?** Flows started by an event only email Subscribed people. Add them with `identify`, which subscribes them, not with `subscribed: false` and not only through `track`'s `contact`. `agumail.contacts.status(email)` tells you where they stand.
446
- 2. **Did AguMail have them before the event?** An event for someone AguMail doesn't have is skipped and counted in `unknownContacts`, and `onWarning` gets `unknown_contacts`. Identify them first and wait for it, or pass `contact`, or use the queue.
491
+ 2. **Did AguMail have them before the event?** An event for someone AguMail doesn't have is skipped and counted in `unknownContacts`, and `onWarning` gets `unknown_contacts`. Identify them first and wait for it, or pass `contact`, or use the queue. An event sent by `userId` needs a contact with that id, so identify them with both their `userId` and their email.
447
492
  3. **Did it happen in the last 72 hours?** An `occurredAt` older than that counts as history: stored, but no flow starts. `onWarning` gets `history`.
448
493
  4. **Was the uniqueId new?** An id AguMail has seen before, for any event, counts in `duplicates` and starts nothing.
449
494
  5. **Does the flow's trigger name the same event?** "Upgraded plan" and "upgraded_plan" match, but "Upgraded" doesn't.
@@ -460,6 +505,7 @@ Check these in order:
460
505
  | Names | first and last name up to 100 characters, phone up to 40 |
461
506
  | Event name | up to 60 characters |
462
507
  | `uniqueId` | up to 120 characters |
508
+ | `userId` | up to 200 characters |
463
509
 
464
510
  Every `identify` or `track` is one request (`track` with `contact` is two), so an app sending more than about 2 a second will be slowed down. Use `identifyMany` and `trackMany` for bulk work, and [the queue](#batching-with-the-queue) for a busy app.
465
511
 
@@ -506,7 +552,7 @@ Retried errors are tried 3 more times, with growing waits. If AguMail asks for a
506
552
 
507
553
  The package refuses to run in a browser, and that includes test setups that fake one, such as jsdom. Run tests that use it in a Node environment, or mock the package.
508
554
 
509
- For tests and local development, `disabled: true` sends nothing and needs no key. Calls still check their input and options, so a bad record still fails. They resolve with empty results: zero counts, a generated `uniqueId`, and `status: null` for every address. No warnings fire.
555
+ For tests and local development, `disabled: true` sends nothing and needs no key. Calls still check their input and options, so a bad record still fails. They resolve with empty results: zero counts, a generated `uniqueId`, and `status: null` for every address and user id. No warnings fire.
510
556
 
511
557
  ```ts
512
558
  const agumail = new AguMail(process.env.AGUMAIL_API_KEY, {
@@ -550,32 +596,36 @@ Every call also takes `{ throwOnError, timeoutMs, signal }`, and `identifyMany`
550
596
  ## API
551
597
 
552
598
  ```ts
553
- agumail.identify(contact, options?) // Promise<ContactCounts>
554
- agumail.identifyMany(contacts, options?) // Promise<ContactCounts>
555
- agumail.track(event, options?) // Promise<TrackResult>
556
- agumail.trackMany(events, options?) // Promise<TrackManyResult>
557
- agumail.contacts.status(email, options?) // Promise<ContactStatus>
558
- agumail.contacts.status(emails, options?) // Promise<ContactStatus[]>
559
- agumail.contacts.subscribe(emails, options?) // Promise<ContactCounts>
560
- agumail.contacts.unsubscribe(emails, options?) // Promise<ContactCounts>
561
- agumail.forms.list(options?) // Promise<FormList>
562
- agumail.signups.create(signups, options?) // Promise<SignupCounts>
563
- agumail.ping(options?) // Promise<PingResult>
564
- agumail.queue.identify(contact) // void
565
- agumail.queue.track(event) // void
566
- agumail.flush(options?) // Promise<FlushResult>
599
+ agumail.identify(contact, options?) // Promise<ContactCounts>
600
+ agumail.identifyMany(contacts, options?) // Promise<ContactCounts>
601
+ agumail.track(event, options?) // Promise<TrackResult>
602
+ agumail.trackMany(events, options?) // Promise<TrackManyResult>
603
+ agumail.contacts.status(email, options?) // Promise<ContactStatus>
604
+ agumail.contacts.status(emails, options?) // Promise<ContactStatus[]>
605
+ agumail.contacts.statusByUserId(userId, options?) // Promise<ContactStatus>
606
+ agumail.contacts.statusByUserId(userIds, options?) // Promise<ContactStatus[]>
607
+ agumail.contacts.subscribe(emails, options?) // Promise<ContactCounts>
608
+ agumail.contacts.unsubscribe(emails, options?) // Promise<ContactCounts>
609
+ agumail.forms.list(options?) // Promise<FormList>
610
+ agumail.signups.create(signups, options?) // Promise<SignupCounts>
611
+ agumail.ping(options?) // Promise<PingResult>
612
+ agumail.queue.identify(contact) // void
613
+ agumail.queue.track(event) // void
614
+ agumail.flush(options?) // Promise<FlushResult>
567
615
  ```
568
616
 
569
617
  With `throwOnError: false` (or `noThrow`), a call that resolves to `T` resolves to `{ data: T, error: null } | { data: null, error: AguMailError }` instead. The methods work taken off the client too: `const { identify, track } = agumail`.
570
618
 
571
619
  ```ts
572
- ContactCounts // { created, updated, suppressedSkipped, overCapacity, confirmationsSent? }
620
+ ContactCounts // { created, updated, suppressedSkipped, overCapacity, confirmationsSent?,
621
+ // unknownContacts?, conflicts?, conflicted?: ContactConflict[] }
622
+ ContactConflict // { index, userId, email, reason: 'email_taken' | 'user_id_mismatch' }
573
623
  TrackResult // { created, duplicates, unknownContacts, uniqueId, contact?: ContactCounts }
574
624
  TrackManyResult // { created, duplicates, unknownContacts, uniqueIds, contacts?: ContactCounts }
575
- ContactStatus // { email, status, subscribed }
625
+ ContactStatus // { email, userId, status, subscribed }
576
626
  FormList // { loaderUrl, forms: { slug, name, doubleOptIn, publishedAt }[] }
577
627
  SignupCounts // { received, overCapacity, limited }
578
- PingResult // { shop: { userId, shopName } }
628
+ PingResult // { shop: { userId, shopName } }: userId is your AguMail account's id
579
629
  FlushResult // { contacts: ContactCounts, events: EventCounts, error: AguMailError | null }
580
630
  ```
581
631
 
@@ -583,7 +633,6 @@ These types, and `Contact`, `TrackEvent`, `Signup` and the rest, are exported, a
583
633
 
584
634
  ## Coming
585
635
 
586
- - Identifying contacts by your own user id, not only by email.
587
636
  - Deleting a contact.
588
637
  - Verifying AguMail's webhooks, for unsubscribes and bounces.
589
638
  - Syncing customers from Stripe and users from auth providers.