agumail-js 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +592 -2
- package/dist/index.cjs +1128 -0
- package/dist/index.d.cts +374 -0
- package/dist/index.d.ts +374 -0
- package/dist/index.js +1105 -0
- package/package.json +58 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AguMail
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,593 @@
|
|
|
1
|
-
#
|
|
1
|
+
# agumail-js
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Add your users to [AguMail](https://agumail.com) and record what they do, so your AguMail flows (onboarding, trial ending, win-back) can start from it. Built for the server side of a SaaS app.
|
|
4
|
+
|
|
5
|
+
- No dependencies. Works on Node 18+, Bun, Deno, Cloudflare Workers and Vercel Edge.
|
|
6
|
+
- ESM and CommonJS, with TypeScript types included.
|
|
7
|
+
- Retries rate limits, server errors, network errors and timeouts for you, without recording an event twice.
|
|
8
|
+
|
|
9
|
+
This package is for marketing email. AguMail does not send transactional email, so there is no "send this email" call.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install agumail-js
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { AguMail } from 'agumail-js';
|
|
21
|
+
const agumail = new AguMail(); // reads AGUMAIL_API_KEY
|
|
22
|
+
await agumail.identify({ email: 'sam@example.com', firstName: 'Sam' });
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then record what Sam does:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
await agumail.track({ email: 'sam@example.com', event: 'Created first project' });
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Get a key in AguMail under **Settings > Store & API > API keys > Create key**, and choose **Other store or your own code**. The key is secret: keep it in an environment variable on your server. With no key passed, the client reads `AGUMAIL_API_KEY`. You can also pass it yourself: `new AguMail(process.env.MY_KEY)`.
|
|
32
|
+
|
|
33
|
+
The API is on every AguMail plan, the free one included.
|
|
34
|
+
|
|
35
|
+
Both calls resolve with what AguMail counted:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const added = await agumail.identify({ email: 'sam@example.com', firstName: 'Sam' });
|
|
39
|
+
// { created: 1, updated: 0, suppressedSkipped: 0, overCapacity: 0, confirmationsSent: 0 }
|
|
40
|
+
|
|
41
|
+
const recorded = await agumail.track({ email: 'sam@example.com', event: 'Created first project' });
|
|
42
|
+
// { created: 1, duplicates: 0, unknownContacts: 0, uniqueId: '0b6f9d3e-...' }
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `created` and `updated`: a new contact or event, or a contact AguMail already had.
|
|
46
|
+
- `suppressedSkipped`: they unsubscribed, marked an email as spam or bounced, so their subscription was left alone (see [Who gets email](#who-gets-email)).
|
|
47
|
+
- `overCapacity`: your plan has no room for another contact, so they weren't added.
|
|
48
|
+
- `confirmationsSent`: confirmation emails AguMail sent, for someone coming back after unsubscribing (see [Email settings in your app](#email-settings-in-your-app)).
|
|
49
|
+
- `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
|
+
- `uniqueId`: the id the event was sent with: yours, or one the package made up.
|
|
52
|
+
|
|
53
|
+
## Never break a signup
|
|
54
|
+
|
|
55
|
+
By default every call throws an `AguMailError` when it fails. On a signup path you usually want the opposite: AguMail being slow or down should never stop someone from creating an account. Pass `noThrow` and the call never throws or rejects. It resolves to `{ data, error }`:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { AguMail, noThrow } from 'agumail-js';
|
|
59
|
+
|
|
60
|
+
const { data, error } = await agumail.identify({ email }, noThrow);
|
|
61
|
+
if (error) console.warn('AguMail:', error.message);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`noThrow` is `{ throwOnError: false }`. If you write that object yourself, write it in the call, so TypeScript knows the call returns `{ data, error }`. When `throwOnError` is only known at run time (a `boolean`, or a `CallOptions` variable), the result could be either, and TypeScript makes you check.
|
|
65
|
+
|
|
66
|
+
Even better, don't wait for it at all. How depends on where your code runs:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// Long-running servers (Express, Fastify, Wasp): start it and move on.
|
|
70
|
+
void agumail.identify({ email }, noThrow);
|
|
71
|
+
|
|
72
|
+
// Next.js 15.1+: run it after the response is sent.
|
|
73
|
+
import { after } from 'next/server';
|
|
74
|
+
after(() => agumail.identify({ email }, noThrow));
|
|
75
|
+
|
|
76
|
+
// Vercel functions outside Next.js: keep the function alive until it finishes.
|
|
77
|
+
import { waitUntil } from '@vercel/functions';
|
|
78
|
+
waitUntil(agumail.identify({ email }, noThrow));
|
|
79
|
+
|
|
80
|
+
// Cloudflare Workers: ctx is the third argument of your fetch handler.
|
|
81
|
+
ctx.waitUntil(agumail.identify({ email }, noThrow));
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
To add someone and then record an event without waiting, keep both calls in one chain, so the event can't arrive before the contact ([Express](#express) shows how), or use [the queue](#batching-with-the-queue).
|
|
85
|
+
|
|
86
|
+
When nobody reads the result, errors would go unnoticed, so give the client an `onError`. Give it an `onWarning` too: it tells you when AguMail took a call without an error but dropped or held back some of it.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
const agumail = new AguMail(process.env.AGUMAIL_API_KEY, {
|
|
90
|
+
onError: (error) => console.error('AguMail:', error.code, error.message),
|
|
91
|
+
onWarning: (warning) => console.warn('AguMail:', warning.code, warning.message),
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`onError` gets the error of every call made with `throwOnError: false`, every record the queue refused, and every flush that failed. Errors you get yourself, from calls that throw, don't go to it.
|
|
96
|
+
|
|
97
|
+
| `warning.code` | What happened |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `unknown_contacts` | Events for people AguMail doesn't have were skipped. Identify them first, or pass `contact`. |
|
|
100
|
+
| `over_capacity` | Contacts or signups weren't added because your plan has no room for more contacts. |
|
|
101
|
+
| `signups_limited` | Signups over the limit of 10 new addresses per visitor IP a minute were dropped. |
|
|
102
|
+
| `history` | Events dated more than 72 hours ago, sent without `backfill: true`, were stored but start no flows. |
|
|
103
|
+
|
|
104
|
+
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
|
+
|
|
106
|
+
A missing API key does not throw when the client is created either, so it can't take your app down at startup. The first call reports it. Use `await agumail.ping()` if you want to check the key when your server starts.
|
|
107
|
+
|
|
108
|
+
## Who gets email
|
|
109
|
+
|
|
110
|
+
**`identify` subscribes people by default.** It tells AguMail the person agreed to marketing email, so your campaigns and flows can email them.
|
|
111
|
+
|
|
112
|
+
To add someone without subscribing them, pass `subscribed: false`. They are on your list as **Never subscribed**, and campaigns and flows do not email them. If your signup form has a "send me product news" box, pass its value:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
await agumail.identify({ email: user.email, subscribed: user.wantsMarketingEmail });
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Rules to know:
|
|
119
|
+
|
|
120
|
+
1. **`subscribed: false` never unsubscribes anyone.** It means "add them, but don't subscribe them". Someone who is already subscribed stays subscribed. To stop email for someone, use [`contacts.unsubscribe`](#email-settings-in-your-app).
|
|
121
|
+
2. **`identify` never brings back someone who left.** People who unsubscribed, marked an email as spam or bounced stay that way, however often you send them. They are counted in `suppressedSkipped`, and their name, properties and tags are still updated.
|
|
122
|
+
3. **`identify` skips double opt-in.** Someone who signed up through a double opt-in form and hasn't clicked the confirmation email yet becomes Subscribed, without the click, when you identify them. If you use double opt-in, identify those people only after they confirm (`contacts.status` shows `unconfirmed` until then).
|
|
123
|
+
4. **`track` never changes anyone's subscription.**
|
|
124
|
+
|
|
125
|
+
## Record what people do
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
await agumail.track({
|
|
129
|
+
email: user.email,
|
|
130
|
+
event: 'Upgraded plan',
|
|
131
|
+
properties: { plan: 'pro', seats: 5 },
|
|
132
|
+
value: 49,
|
|
133
|
+
uniqueId: `upgrade:${invoice.id}`,
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- `event` is your own name for what happened. Spelling and capitals do not matter: "Upgraded plan" and "upgraded_plan" are one event.
|
|
138
|
+
- `uniqueId` is your id for this occurrence. AguMail never records the same id twice, across your whole account and every event name, so prefix it with the event (`upgrade:in_123`, not `in_123`). See [Sending an event again](#sending-an-event-again).
|
|
139
|
+
- `occurredAt` is when it happened, as a `Date` or an ISO string. It defaults to now. **Anything more than 72 hours old counts as history**: AguMail stores it but starts no flows from it.
|
|
140
|
+
- `properties` can nest, and your emails can print them (`{{event.plan}}`).
|
|
141
|
+
- `value` is a number, such as what the event was worth. AguMail stores no currency with it, so send every amount in the same currency.
|
|
142
|
+
|
|
143
|
+
**AguMail skips events for people it doesn't have** (counted in `unknownContacts`), because an event is not permission to email someone. Call `identify` when someone signs up, then `track` from then on. Or pass `contact` and the package upserts them first:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
await agumail.track({ email: user.email, event: 'Logged in', contact: { firstName: user.firstName } });
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
A contact that `track` creates this way is **Never subscribed**, because `track` never changes subscriptions. **Flows started by an event only email Subscribed people**, so make sure your signup calls `identify`.
|
|
150
|
+
|
|
151
|
+
### Sending an event again
|
|
152
|
+
|
|
153
|
+
Leave `uniqueId` out and the package makes one up and reuses it on every retry, so its own retries never record an event twice. That id is new on every call, though. A webhook delivered twice, or a job that runs again, records the event twice unless you pass your own id, such as the webhook's event id.
|
|
154
|
+
|
|
155
|
+
When `track` or `trackMany` fails, `error.uniqueIds` holds the id each event was sent with, in the order you passed them. Send them again with those ids, and the ones that already landed count as duplicates instead of being recorded twice:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const result = await agumail.trackMany(events, noThrow);
|
|
159
|
+
if (result.error?.uniqueIds) {
|
|
160
|
+
const ids = result.error.uniqueIds;
|
|
161
|
+
// Now or later: the same ids, so nothing is recorded twice.
|
|
162
|
+
await agumail.trackMany(events.map((event, i) => ({ ...event, uniqueId: ids[i] })));
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Contact properties
|
|
167
|
+
|
|
168
|
+
`properties` become the contact's custom fields in AguMail. AguMail keeps them as text, so the package converts for you:
|
|
169
|
+
|
|
170
|
+
| You pass | AguMail stores |
|
|
171
|
+
|---|---|
|
|
172
|
+
| `'pro'` | `pro` |
|
|
173
|
+
| `3`, `9.5` | `3`, `9.5` |
|
|
174
|
+
| `true` | `true` |
|
|
175
|
+
| `new Date(...)` | an ISO date, `2026-11-01T00:00:00.000Z` |
|
|
176
|
+
| `null` | an empty value (this is how to clear a field) |
|
|
177
|
+
| `undefined` | nothing: the field is left as it was |
|
|
178
|
+
|
|
179
|
+
Arrays and objects are refused: use tags for a list, or `JSON.stringify` it yourself. Properties are merged into what AguMail already has, so sending one field never deletes the others. Changing a property can start a flow that waits for that field.
|
|
180
|
+
|
|
181
|
+
## Email settings in your app
|
|
182
|
+
|
|
183
|
+
If your app has a "marketing email" switch in its settings, tell AguMail when the person changes it:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
await agumail.contacts.unsubscribe(user.email); // they switched it off
|
|
187
|
+
await agumail.contacts.subscribe(user.email); // they switched it on
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Call these only when the signed-in person changes the setting themselves, never from a sync. When they switch it back on:
|
|
191
|
+
|
|
192
|
+
- If they switched it off in your app, they are subscribed again straight away.
|
|
193
|
+
- 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.
|
|
194
|
+
- If their address bounced, they can't be subscribed again.
|
|
195
|
+
|
|
196
|
+
`contacts.subscribe` also subscribes someone new, or someone Never subscribed, straight away.
|
|
197
|
+
|
|
198
|
+
To show the switch in the right position, ask AguMail. `subscribed` is the value to use:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
const sam = await agumail.contacts.status('sam@example.com');
|
|
202
|
+
// { email: 'sam@example.com', status: 'active', subscribed: true }
|
|
203
|
+
```
|
|
204
|
+
|
|
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.
|
|
206
|
+
|
|
207
|
+
## Examples
|
|
208
|
+
|
|
209
|
+
### Next.js route handler
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
// app/api/signup/route.ts
|
|
213
|
+
import { after } from 'next/server';
|
|
214
|
+
import { AguMail, noThrow } from 'agumail-js';
|
|
215
|
+
import { createUser } from '@/lib/users';
|
|
216
|
+
|
|
217
|
+
const agumail = new AguMail(process.env.AGUMAIL_API_KEY, {
|
|
218
|
+
onError: (error) => console.error('AguMail:', error.message),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
export async function POST(request: Request) {
|
|
222
|
+
const { email, name, newsletter } = await request.json();
|
|
223
|
+
const user = await createUser({ email, name });
|
|
224
|
+
|
|
225
|
+
// Runs after the response is sent, and Next.js keeps the function alive for it.
|
|
226
|
+
after(async () => {
|
|
227
|
+
await agumail.identify(
|
|
228
|
+
{ email: user.email, firstName: user.name, subscribed: newsletter === true, properties: { plan: 'trial' } },
|
|
229
|
+
noThrow
|
|
230
|
+
);
|
|
231
|
+
// After identify, so AguMail already has them.
|
|
232
|
+
await agumail.track({ email: user.email, event: 'Signed up' }, noThrow);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return Response.json({ id: user.id }, { status: 201 });
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`after` comes with Next.js 15.1 and later. Outside Next.js on Vercel, `waitUntil` from `@vercel/functions` does the same job, and on Cloudflare Workers, `ctx.waitUntil` does.
|
|
240
|
+
|
|
241
|
+
### Express
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import express from 'express';
|
|
245
|
+
import { AguMail, noThrow } from 'agumail-js';
|
|
246
|
+
import { createUser, createProject } from './db';
|
|
247
|
+
|
|
248
|
+
const agumail = new AguMail(process.env.AGUMAIL_API_KEY, {
|
|
249
|
+
onError: (error) => console.error('AguMail:', error.message),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const app = express();
|
|
253
|
+
app.use(express.json());
|
|
254
|
+
|
|
255
|
+
app.post('/signup', async (req, res) => {
|
|
256
|
+
const user = await createUser(req.body);
|
|
257
|
+
// One chain, started and not awaited: the event goes only once the contact is in.
|
|
258
|
+
void (async () => {
|
|
259
|
+
await agumail.identify({ email: user.email, firstName: user.firstName }, noThrow);
|
|
260
|
+
await agumail.track({ email: user.email, event: 'Signed up' }, noThrow);
|
|
261
|
+
})();
|
|
262
|
+
res.status(201).json({ id: user.id });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
app.post('/projects', async (req, res) => {
|
|
266
|
+
const project = await createProject(req.user, req.body);
|
|
267
|
+
void agumail.track(
|
|
268
|
+
{
|
|
269
|
+
email: req.user.email,
|
|
270
|
+
event: 'Created project',
|
|
271
|
+
properties: { projectId: project.id, template: project.template },
|
|
272
|
+
uniqueId: `project_created:${project.id}`,
|
|
273
|
+
},
|
|
274
|
+
noThrow
|
|
275
|
+
);
|
|
276
|
+
res.status(201).json(project);
|
|
277
|
+
});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Calls made with `noThrow` never reject, so the chain never does either.
|
|
281
|
+
|
|
282
|
+
### Wasp
|
|
283
|
+
|
|
284
|
+
One client for the whole server:
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
// src/server/agumail.ts
|
|
288
|
+
import { AguMail } from 'agumail-js';
|
|
289
|
+
|
|
290
|
+
export const agumail = new AguMail(process.env.AGUMAIL_API_KEY, {
|
|
291
|
+
onError: (error) => console.error('[agumail]', error.code, error.message),
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Add every new user from Wasp's `onAfterSignup` hook:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
// src/auth/agumail.ts
|
|
299
|
+
import type { OnAfterSignupHook } from 'wasp/server/auth';
|
|
300
|
+
import { noThrow } from 'agumail-js';
|
|
301
|
+
import { agumail } from '../server/agumail';
|
|
302
|
+
|
|
303
|
+
export const addToAguMail: OnAfterSignupHook = async ({ user }) => {
|
|
304
|
+
if (!user.email) return;
|
|
305
|
+
// Not awaited, and never throws: AguMail can't hold up or fail a signup.
|
|
306
|
+
void agumail.identify(
|
|
307
|
+
{
|
|
308
|
+
email: user.email,
|
|
309
|
+
firstName: user.firstName,
|
|
310
|
+
lastName: user.lastName,
|
|
311
|
+
properties: { plan: user.isFreeTrial ? 'trial' : 'paid', trialEndsAt: user.freeTrialExpiresAt },
|
|
312
|
+
},
|
|
313
|
+
noThrow
|
|
314
|
+
);
|
|
315
|
+
};
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
// main.wasp.ts
|
|
320
|
+
import { addToAguMail } from './src/auth/agumail' with { type: 'ref' };
|
|
321
|
+
|
|
322
|
+
// in app({ ... auth: { ... } }):
|
|
323
|
+
onAfterSignup: addToAguMail,
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Then record what they do from your actions:
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
// src/campaigns/operations.ts
|
|
330
|
+
import type { CreateCampaign } from 'wasp/server/operations';
|
|
331
|
+
import { noThrow } from 'agumail-js';
|
|
332
|
+
import { agumail } from '../server/agumail';
|
|
333
|
+
|
|
334
|
+
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
|
+
}
|
|
347
|
+
return campaign;
|
|
348
|
+
};
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
## Batching with the queue
|
|
352
|
+
|
|
353
|
+
Every `identify` and `track` is its own request, and AguMail takes about 120 a minute per key. If your app records more than about two things a second, or you want a contact and its events to go together, queue them instead:
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
agumail.queue.identify({ email: user.email, firstName: user.firstName });
|
|
357
|
+
agumail.queue.track({ email: user.email, event: 'Signed up' });
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
- Queueing returns nothing and never throws. Each record is checked when you queue it, and a bad one goes to `onError` instead of the queue.
|
|
361
|
+
- 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
|
+
- 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
|
+
- A flush that still fails after its retries goes to `onError`, and its records are dropped, not queued again.
|
|
364
|
+
- 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
|
+
|
|
366
|
+
`flush()` sends everything queued now, and resolves when that's done. It never rejects:
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
const { contacts, events, error } = await agumail.flush();
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
On serverless, a function can be frozen as soon as it responds, before the timer fires. Flush with `after()` in Next.js, or `waitUntil` on Vercel and Cloudflare:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
after(() => agumail.flush());
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
On a long-running server, flush when it shuts down:
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
process.on('SIGTERM', async () => {
|
|
382
|
+
await agumail.flush();
|
|
383
|
+
process.exit(0);
|
|
384
|
+
});
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
## Loading your existing users
|
|
388
|
+
|
|
389
|
+
`identifyMany` and `trackMany` take any number of records and send them 500 at a time. Mark history with `backfill: true`, so nobody gets a welcome email or an event-triggered email for something that happened before AguMail knew about it:
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
await agumail.identifyMany(
|
|
393
|
+
users.map((u) => ({
|
|
394
|
+
email: u.email,
|
|
395
|
+
firstName: u.firstName,
|
|
396
|
+
customerSince: u.createdAt,
|
|
397
|
+
subscribed: u.marketingOptIn,
|
|
398
|
+
})),
|
|
399
|
+
{ backfill: true }
|
|
400
|
+
);
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Pass `subscribed` from what each user agreed to. It defaults to true, so leaving it out would subscribe every user you import, including those who never agreed to marketing email.
|
|
404
|
+
|
|
405
|
+
`customerSince` becomes the contact's **Date added** when AguMail creates them, so flows timed from Date added don't treat old users as new.
|
|
406
|
+
|
|
407
|
+
Every record is checked before anything is sent. If one is wrong, the call throws and names it (`contacts[700].email`), and nothing is sent. If a batch fails partway through, the error's `partial` says how many records landed (`error.partial.sent`) and what AguMail counted for them, so you can send the rest with `records.slice(error.partial.sent)`.
|
|
408
|
+
|
|
409
|
+
## Newsletter forms on your website
|
|
410
|
+
|
|
411
|
+
For a newsletter form on your marketing site, use `signups.create` rather than `identify`. AguMail treats it like a signup on its own forms: it can send a double opt-in email, it keeps proof of the signup (page, IP address, the wording they agreed to), and someone who unsubscribed earlier can come back this way after confirming by email.
|
|
412
|
+
|
|
413
|
+
```ts
|
|
414
|
+
await agumail.signups.create({
|
|
415
|
+
email: form.email,
|
|
416
|
+
doubleOptIn: true,
|
|
417
|
+
form: { id: 'footer', name: 'Footer newsletter' },
|
|
418
|
+
pageUrl: request.headers.get('referer') ?? undefined,
|
|
419
|
+
ip: visitorIp, // the visitor's IP address, not your server's
|
|
420
|
+
consentText: 'Send me product news. Unsubscribe any time.',
|
|
421
|
+
tags: ['Newsletter'],
|
|
422
|
+
});
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
- `form.id` is your own name for the form, and `form.name` is what AguMail shows. Add `form.builder` (such as `'webflow'`) if a site builder made the form.
|
|
426
|
+
- AguMail takes at most 10 new addresses per visitor IP address a minute. Pass the visitor's IP address, or every signup shares your server's limit. Signups over the limit are not stored, and are counted in `limited`.
|
|
427
|
+
|
|
428
|
+
Prefer forms designed in AguMail? `forms.list()` returns your published forms and the script that shows them:
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
const { loaderUrl, forms } = await agumail.forms.list();
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
```html
|
|
435
|
+
<script type="text/plain" data-agumail-form="FORM_SLUG" data-mode="inline"></script>
|
|
436
|
+
<script src="LOADER_URL" async></script>
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
`data-mode` is `inline` or `popup`.
|
|
440
|
+
|
|
441
|
+
## My flow didn't start
|
|
442
|
+
|
|
443
|
+
Check these in order:
|
|
444
|
+
|
|
445
|
+
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.
|
|
447
|
+
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
|
+
4. **Was the uniqueId new?** An id AguMail has seen before, for any event, counts in `duplicates` and starts nothing.
|
|
449
|
+
5. **Does the flow's trigger name the same event?** "Upgraded plan" and "upgraded_plan" match, but "Upgraded" doesn't.
|
|
450
|
+
|
|
451
|
+
## Limits
|
|
452
|
+
|
|
453
|
+
| | Limit |
|
|
454
|
+
|---|---|
|
|
455
|
+
| Requests | About 120 a minute per API key. The package waits and retries when AguMail says to slow down. |
|
|
456
|
+
| Records per request | 500, split for you |
|
|
457
|
+
| Tags | 20 per contact, 60 characters each |
|
|
458
|
+
| Properties (contact) | 30 per contact, names up to 60 characters, values up to 500 |
|
|
459
|
+
| Properties (event) | 30 keys, up to 8,000 characters as JSON |
|
|
460
|
+
| Names | first and last name up to 100 characters, phone up to 40 |
|
|
461
|
+
| Event name | up to 60 characters |
|
|
462
|
+
| `uniqueId` | up to 120 characters |
|
|
463
|
+
|
|
464
|
+
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
|
+
|
|
466
|
+
## Errors
|
|
467
|
+
|
|
468
|
+
Failed calls throw an `AguMailError` (or return it, with `throwOnError: false`):
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
import { AguMailError } from 'agumail-js';
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
await agumail.identify({ email });
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (error instanceof AguMailError) {
|
|
477
|
+
error.status; // the HTTP status, or null when there was no answer
|
|
478
|
+
error.code; // see below
|
|
479
|
+
error.message; // what went wrong, in words
|
|
480
|
+
error.retryAfter; // on a 429, the seconds AguMail asked you to wait
|
|
481
|
+
error.partial; // identifyMany, trackMany, flush: what landed before it failed
|
|
482
|
+
error.uniqueIds; // track, trackMany, flush: the id each event was sent with
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
| `code` | Meaning | Retried |
|
|
488
|
+
|---|---|---|
|
|
489
|
+
| `invalid_input` | A record or option is wrong. Nothing was sent. | No |
|
|
490
|
+
| `missing_api_key` | No key, or not an AguMail key (`agm_` and 40 characters). Nothing was sent. | No |
|
|
491
|
+
| `bad_request` | 400: AguMail refused the request. | No |
|
|
492
|
+
| `unauthorized` | 401: the key is wrong or revoked. | No |
|
|
493
|
+
| `payment_required` | 402. No call in this package returns it today: it belongs to AguMail's store API. | No |
|
|
494
|
+
| `forbidden` | 403: for signups, the account isn't taking signups right now. | No |
|
|
495
|
+
| `not_found` | 404 | No |
|
|
496
|
+
| `rate_limited` | 429: too many requests. | Yes, after `Retry-After` |
|
|
497
|
+
| `server_error` | 5xx | Yes |
|
|
498
|
+
| `timeout` | No answer in time (10 seconds, or 60 for a batch). | Yes |
|
|
499
|
+
| `network_error` | AguMail couldn't be reached. | Yes |
|
|
500
|
+
| `aborted` | Your `signal` aborted the call. | No |
|
|
501
|
+
| `unexpected` | A bug, here or in a custom `fetch`. Please report it. | No |
|
|
502
|
+
|
|
503
|
+
Retried errors are tried 3 more times, with growing waits. If AguMail asks for a wait longer than a minute, the call stops and reports `retryAfter` instead.
|
|
504
|
+
|
|
505
|
+
## Testing
|
|
506
|
+
|
|
507
|
+
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
|
+
|
|
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.
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
const agumail = new AguMail(process.env.AGUMAIL_API_KEY, {
|
|
513
|
+
disabled: process.env.NODE_ENV === 'test',
|
|
514
|
+
});
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
To see what would be sent, pass your own `fetch`:
|
|
518
|
+
|
|
519
|
+
```ts
|
|
520
|
+
const sent: unknown[] = [];
|
|
521
|
+
const agumail = new AguMail(`agm_${'0'.repeat(40)}`, {
|
|
522
|
+
fetch: async (url, init) => {
|
|
523
|
+
if (init?.body) sent.push(JSON.parse(String(init.body)));
|
|
524
|
+
const counts = { created: 1, updated: 0, suppressedSkipped: 0, overCapacity: 0, duplicates: 0, unknownContacts: 0 };
|
|
525
|
+
return new Response(JSON.stringify({ ok: true, ...counts }));
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
## Options
|
|
531
|
+
|
|
532
|
+
```ts
|
|
533
|
+
new AguMail(apiKey, {
|
|
534
|
+
baseUrl: 'https://api.agumail.com', // the default
|
|
535
|
+
timeoutMs: 20_000, // per attempt, for every request; leave it out for 10 seconds, or 60 for a batch
|
|
536
|
+
maxRetries: 3, // the default
|
|
537
|
+
fetch: customFetch, // defaults to the global fetch
|
|
538
|
+
onError: (error) => {}, // errors nobody else sees (see Never break a signup)
|
|
539
|
+
onWarning: (warning) => {}, // records AguMail dropped or held back
|
|
540
|
+
disabled: false, // true sends nothing (see Testing)
|
|
541
|
+
flushAt: 500, // the queue flushes at this many records, 1 to 500
|
|
542
|
+
flushIntervalMs: 5_000, // or this long after the first one; 0 turns the timer off
|
|
543
|
+
});
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
Leave `apiKey` out, or pass `undefined` or `''`, and the client reads `AGUMAIL_API_KEY` from the environment (`process.env`, or `Deno.env` on Deno) when it first needs it. A key you pass always wins.
|
|
547
|
+
|
|
548
|
+
Every call also takes `{ throwOnError, timeoutMs, signal }`, and `identifyMany` and `trackMany` take `backfill`. `flush` takes `{ timeoutMs, signal }`. A `timeoutMs` can be at most 2147483647 (about 24 days), the longest a timer can wait.
|
|
549
|
+
|
|
550
|
+
## API
|
|
551
|
+
|
|
552
|
+
```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>
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
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
|
+
|
|
571
|
+
```ts
|
|
572
|
+
ContactCounts // { created, updated, suppressedSkipped, overCapacity, confirmationsSent? }
|
|
573
|
+
TrackResult // { created, duplicates, unknownContacts, uniqueId, contact?: ContactCounts }
|
|
574
|
+
TrackManyResult // { created, duplicates, unknownContacts, uniqueIds, contacts?: ContactCounts }
|
|
575
|
+
ContactStatus // { email, status, subscribed }
|
|
576
|
+
FormList // { loaderUrl, forms: { slug, name, doubleOptIn, publishedAt }[] }
|
|
577
|
+
SignupCounts // { received, overCapacity, limited }
|
|
578
|
+
PingResult // { shop: { userId, shopName } }
|
|
579
|
+
FlushResult // { contacts: ContactCounts, events: EventCounts, error: AguMailError | null }
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
These types, and `Contact`, `TrackEvent`, `Signup` and the rest, are exported, and your editor shows what each field does.
|
|
583
|
+
|
|
584
|
+
## Coming
|
|
585
|
+
|
|
586
|
+
- Identifying contacts by your own user id, not only by email.
|
|
587
|
+
- Deleting a contact.
|
|
588
|
+
- Verifying AguMail's webhooks, for unsubscribes and bounces.
|
|
589
|
+
- Syncing customers from Stripe and users from auth providers.
|
|
590
|
+
|
|
591
|
+
## License
|
|
592
|
+
|
|
593
|
+
MIT
|