@eusend_dev/better-auth 0.1.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/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/index.d.mts +287 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +430 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eusend
|
|
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
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# @eusend_dev/better-auth
|
|
2
|
+
|
|
3
|
+
[Better Auth](https://better-auth.com) plugin for [eusend](https://eusend.dev), the
|
|
4
|
+
EU-native transactional email API.
|
|
5
|
+
|
|
6
|
+
- **Auth email:** verification, password reset and the "your password was changed" notice
|
|
7
|
+
are set up by one plugin block, with clean default templates you can brand or replace. Senders for
|
|
8
|
+
email OTP, magic links and organization invitations are one line each.
|
|
9
|
+
- **Contact sync (optional):** users are added to an eusend audience once they have verified
|
|
10
|
+
their email address, and never before.
|
|
11
|
+
|
|
12
|
+
Every message is sent and stored on infrastructure in the EU.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @eusend_dev/better-auth
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Add your domain under [Domains](https://eusend.dev/domains), create an API key under
|
|
21
|
+
[API keys](https://eusend.dev/api-keys), and set it as `EUSEND_API_KEY`.
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// auth.ts
|
|
27
|
+
import { betterAuth } from 'better-auth'
|
|
28
|
+
import { eusend } from '@eusend_dev/better-auth'
|
|
29
|
+
|
|
30
|
+
export const auth = betterAuth({
|
|
31
|
+
emailAndPassword: { enabled: true, requireEmailVerification: true },
|
|
32
|
+
emailVerification: { sendOnSignUp: true },
|
|
33
|
+
plugins: [
|
|
34
|
+
eusend({
|
|
35
|
+
email: {
|
|
36
|
+
from: 'Acme <auth@acme.com>',
|
|
37
|
+
appName: 'Acme',
|
|
38
|
+
brand: {
|
|
39
|
+
logoUrl: 'https://acme.com/logo.png',
|
|
40
|
+
primaryColor: '#4f46e5',
|
|
41
|
+
supportEmail: 'help@acme.com',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
],
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
That sets `sendVerificationEmail`, `sendResetPassword` and `onPasswordReset`.
|
|
50
|
+
|
|
51
|
+
### Your config always wins
|
|
52
|
+
|
|
53
|
+
Better Auth merges plugin options *underneath* your own. If you already have a
|
|
54
|
+
`sendVerificationEmail`, it keeps working and the plugin only fills in what is missing.
|
|
55
|
+
The plugin never turns on password sign-in either: `emailAndPassword.enabled` stays whatever
|
|
56
|
+
you set it to.
|
|
57
|
+
|
|
58
|
+
## Other plugins' emails
|
|
59
|
+
|
|
60
|
+
The OTP, magic-link and invitation senders belong to other Better Auth plugins, so you build
|
|
61
|
+
them once and pass them in:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { eusend, eusendAuthEmails } from '@eusend_dev/better-auth'
|
|
65
|
+
import { emailOTP, magicLink, organization } from 'better-auth/plugins'
|
|
66
|
+
|
|
67
|
+
const emails = eusendAuthEmails({
|
|
68
|
+
from: 'Acme <auth@acme.com>',
|
|
69
|
+
appName: 'Acme',
|
|
70
|
+
appUrl: 'https://app.acme.com', // for the invitation link
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
betterAuth({
|
|
74
|
+
plugins: [
|
|
75
|
+
eusend({ email: { from: 'Acme <auth@acme.com>', appName: 'Acme' } }),
|
|
76
|
+
emailOTP({ sendVerificationOTP: emails.otp }),
|
|
77
|
+
magicLink({ sendMagicLink: emails.magicLink }),
|
|
78
|
+
organization({ sendInvitationEmail: emails.invitation }),
|
|
79
|
+
],
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The six senders are `verification`, `resetPassword`, `passwordChanged`, `magicLink`, `otp` and
|
|
84
|
+
`invitation`. The invitation link defaults to `${appUrl}/accept-invitation/${id}`; pass
|
|
85
|
+
`invitationUrl: ({ id }) => ...` for a different route.
|
|
86
|
+
|
|
87
|
+
## Sends don't block the response
|
|
88
|
+
|
|
89
|
+
No sender waits for eusend to answer. That is what Better Auth recommends: an endpoint that
|
|
90
|
+
waits for the send responds faster when an address has no account, and that difference tells
|
|
91
|
+
an attacker which addresses are registered.
|
|
92
|
+
|
|
93
|
+
On a long-running server nothing else is needed. On serverless, the function can be frozen as
|
|
94
|
+
soon as the response goes out, so hand the send to the platform:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { after } from 'next/server'
|
|
98
|
+
|
|
99
|
+
eusend({ waitUntil: after, email: { ... } })
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`waitUntil` from `@vercel/functions` and a Worker's `ctx.waitUntil` work the same way.
|
|
103
|
+
|
|
104
|
+
## Contact sync
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
eusend({
|
|
108
|
+
email: { from: 'Acme <auth@acme.com>', appName: 'Acme' },
|
|
109
|
+
sync: {
|
|
110
|
+
audienceId: process.env.EUSEND_AUDIENCE_ID!,
|
|
111
|
+
properties: (user) => ({ user_id: user.id }),
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- **Verified addresses only.** A user who signs up with email and password is added once
|
|
117
|
+
they verify. A user whose provider already verified them (Google, GitHub) is added at
|
|
118
|
+
creation. A signup form accepts typos and other people's addresses, and mailing those is
|
|
119
|
+
how a sending domain gets its reputation damaged.
|
|
120
|
+
- **Nothing is overwritten.** The sync merges into an existing contact. It never clears
|
|
121
|
+
properties or names you set elsewhere, and it never removes an opt-out: someone who
|
|
122
|
+
unsubscribed from your newsletter and later signs up stays unsubscribed.
|
|
123
|
+
- **Every sign-up path.** It runs on Better Auth's database hooks, so email and password,
|
|
124
|
+
OAuth, magic link, OTP and admin-created users all go through it, alongside any database
|
|
125
|
+
hooks of your own.
|
|
126
|
+
|
|
127
|
+
## Templates
|
|
128
|
+
|
|
129
|
+
The built-in templates are plain HTML with a text alternative, no React, and no eusend
|
|
130
|
+
branding — they read as coming from your app. Everything you pass in is HTML-escaped, and a
|
|
131
|
+
link that isn't `http(s)` is refused rather than rendered.
|
|
132
|
+
|
|
133
|
+
Replace any of them:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
eusend({
|
|
137
|
+
email: {
|
|
138
|
+
from: 'auth@acme.com',
|
|
139
|
+
appName: 'Acme',
|
|
140
|
+
templates: {
|
|
141
|
+
verification: ({ user, url }, { appName }) => ({
|
|
142
|
+
subject: `Confirm your ${appName} account`,
|
|
143
|
+
html: renderMyEmail({ user, url }),
|
|
144
|
+
text: `Confirm: ${url}`,
|
|
145
|
+
}),
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Errors
|
|
152
|
+
|
|
153
|
+
A failed send never throws into Better Auth; a sign-up still succeeds if its email could not
|
|
154
|
+
be sent. Failures are logged with `console.error`, or passed to your own handler:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
eusend({
|
|
158
|
+
onError: ({ category, to, code, message }) => logger.warn({ category, to, code }, message),
|
|
159
|
+
email: { ... },
|
|
160
|
+
})
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`code` is the eusend [error code](https://eusend.dev/docs/reference/error-codes), such as
|
|
164
|
+
`DOMAIN_NOT_VERIFIED` or `ALL_SUPPRESSED`.
|
|
165
|
+
|
|
166
|
+
Every message is tagged `source: better-auth` and `category: verification` (or
|
|
167
|
+
`reset-password`, `otp`, …), so you can filter auth mail in the eusend email log. Click
|
|
168
|
+
tracking is off for auth mail: link scanners that follow tracked links can use up a
|
|
169
|
+
single-use sign-in link before the user clicks it.
|
|
170
|
+
|
|
171
|
+
## Options
|
|
172
|
+
|
|
173
|
+
| Option | Where | |
|
|
174
|
+
|---|---|---|
|
|
175
|
+
| `apiKey` | top level | Defaults to `EUSEND_API_KEY`. |
|
|
176
|
+
| `waitUntil` | top level | Keeps sends alive after the response on serverless. |
|
|
177
|
+
| `onError` | top level | Receives `{ category, to, code, message }`. |
|
|
178
|
+
| `baseUrl` | top level | API host override, for staging. |
|
|
179
|
+
| `from` | `email` | Sender; its domain must be verified on eusend. |
|
|
180
|
+
| `appName` | `email` | Used in subjects and copy. |
|
|
181
|
+
| `brand` | `email` | `logoUrl`, `primaryColor` (hex), `supportEmail`. |
|
|
182
|
+
| `replyTo` | `email` | Reply-to address. |
|
|
183
|
+
| `templates` | `email` | Replace any of the six templates. |
|
|
184
|
+
| `tags` | `email` | Extra tags on every send, up to 8. |
|
|
185
|
+
| `appUrl` / `invitationUrl` | `email` | How invitation links are built. |
|
|
186
|
+
| `audienceId` | `sync` | Audience verified users are added to. |
|
|
187
|
+
| `properties` | `sync` | Custom contact properties from the user. |
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { Eusend } from "@eusend_dev/sdk";
|
|
2
|
+
//#region src/templates.d.ts
|
|
3
|
+
type Brand = {
|
|
4
|
+
/** Shown above every email. Use an absolute https URL; mail clients cannot load relative ones. */
|
|
5
|
+
logoUrl?: string;
|
|
6
|
+
/** Button and link colour, as a hex value. Defaults to a neutral near-black. */
|
|
7
|
+
primaryColor?: string;
|
|
8
|
+
/** Printed in the footer as the place to reply to. */
|
|
9
|
+
supportEmail?: string;
|
|
10
|
+
};
|
|
11
|
+
type RenderedEmail = {
|
|
12
|
+
subject: string;
|
|
13
|
+
html: string;
|
|
14
|
+
text: string;
|
|
15
|
+
};
|
|
16
|
+
type TemplateContext = {
|
|
17
|
+
appName: string;
|
|
18
|
+
brand: Brand;
|
|
19
|
+
};
|
|
20
|
+
type OtpType = 'sign-in' | 'email-verification' | 'forget-password' | 'change-email';
|
|
21
|
+
type Templates = {
|
|
22
|
+
verification: (data: {
|
|
23
|
+
user: {
|
|
24
|
+
email: string;
|
|
25
|
+
name?: string | null;
|
|
26
|
+
};
|
|
27
|
+
url: string;
|
|
28
|
+
}, context: TemplateContext) => RenderedEmail;
|
|
29
|
+
resetPassword: (data: {
|
|
30
|
+
user: {
|
|
31
|
+
email: string;
|
|
32
|
+
name?: string | null;
|
|
33
|
+
};
|
|
34
|
+
url: string;
|
|
35
|
+
}, context: TemplateContext) => RenderedEmail;
|
|
36
|
+
passwordChanged: (data: {
|
|
37
|
+
user: {
|
|
38
|
+
email: string;
|
|
39
|
+
name?: string | null;
|
|
40
|
+
};
|
|
41
|
+
}, context: TemplateContext) => RenderedEmail;
|
|
42
|
+
magicLink: (data: {
|
|
43
|
+
email: string;
|
|
44
|
+
url: string;
|
|
45
|
+
}, context: TemplateContext) => RenderedEmail;
|
|
46
|
+
otp: (data: {
|
|
47
|
+
email: string;
|
|
48
|
+
otp: string;
|
|
49
|
+
type: OtpType;
|
|
50
|
+
}, context: TemplateContext) => RenderedEmail;
|
|
51
|
+
invitation: (data: {
|
|
52
|
+
email: string;
|
|
53
|
+
url: string;
|
|
54
|
+
role: string;
|
|
55
|
+
organizationName: string;
|
|
56
|
+
inviterName: string;
|
|
57
|
+
inviterEmail: string;
|
|
58
|
+
}, context: TemplateContext) => RenderedEmail;
|
|
59
|
+
};
|
|
60
|
+
declare function escapeHtml(value: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Only http(s) URLs make it into an href. Better Auth builds these links itself, but the
|
|
63
|
+
* base URL comes from config and request headers, and a `javascript:` link in an auth email
|
|
64
|
+
* is the one mistake that must be impossible rather than unlikely.
|
|
65
|
+
*/
|
|
66
|
+
declare function safeUrl(value: string): string;
|
|
67
|
+
type Block = {
|
|
68
|
+
kind: 'p';
|
|
69
|
+
text: string;
|
|
70
|
+
} | {
|
|
71
|
+
kind: 'button';
|
|
72
|
+
label: string;
|
|
73
|
+
url: string;
|
|
74
|
+
} | {
|
|
75
|
+
kind: 'code';
|
|
76
|
+
code: string;
|
|
77
|
+
};
|
|
78
|
+
declare function renderLayout({ appName, brand }: TemplateContext, { preheader, blocks, footnote }: {
|
|
79
|
+
preheader: string;
|
|
80
|
+
blocks: Block[];
|
|
81
|
+
footnote: string;
|
|
82
|
+
}): {
|
|
83
|
+
html: string;
|
|
84
|
+
text: string;
|
|
85
|
+
};
|
|
86
|
+
declare const defaultTemplates: Templates;
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/emails.d.ts
|
|
89
|
+
type EmailCategory = keyof Templates;
|
|
90
|
+
type EusendSendError = {
|
|
91
|
+
category: EmailCategory | 'contactSync';
|
|
92
|
+
to: string;
|
|
93
|
+
/** The eusend error code, e.g. `DOMAIN_NOT_VERIFIED`, or `NETWORK_ERROR` when the request never got an answer. */
|
|
94
|
+
code: string;
|
|
95
|
+
message: string;
|
|
96
|
+
};
|
|
97
|
+
type EusendClientOptions = {
|
|
98
|
+
/** Defaults to the `EUSEND_API_KEY` environment variable. */
|
|
99
|
+
apiKey?: string;
|
|
100
|
+
/** Override the API host. Only useful against a staging deployment. */
|
|
101
|
+
baseUrl?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Keeps the send alive after the response has gone out, on platforms that freeze the
|
|
104
|
+
* function when it returns — `after` from `next/server`, `waitUntil` from
|
|
105
|
+
* `@vercel/functions`, or a Worker's `ctx.waitUntil`. Without it the send is started and
|
|
106
|
+
* not awaited, which is right for a long-running server.
|
|
107
|
+
*/
|
|
108
|
+
waitUntil?: (promise: Promise<unknown>) => void;
|
|
109
|
+
/** Called when a send or a contact sync fails. Defaults to `console.error`. */
|
|
110
|
+
onError?: (error: EusendSendError) => void;
|
|
111
|
+
};
|
|
112
|
+
type EusendAuthEmailOptions = EusendClientOptions & {
|
|
113
|
+
/** Sender for every auth email, e.g. `Acme <auth@acme.com>`. Its domain must be verified on eusend. */
|
|
114
|
+
from: string;
|
|
115
|
+
/** Your product name, used in subjects and copy. */
|
|
116
|
+
appName: string;
|
|
117
|
+
replyTo?: string;
|
|
118
|
+
brand?: Brand;
|
|
119
|
+
/** Replace any of the built-in templates. Each receives the same data as the default. */
|
|
120
|
+
templates?: Partial<Templates>;
|
|
121
|
+
/** Added to every send alongside `source: better-auth` and `category`. Up to 8. */
|
|
122
|
+
tags?: Record<string, string>;
|
|
123
|
+
/**
|
|
124
|
+
* Builds the link in organization invitation emails. Defaults to
|
|
125
|
+
* `${appUrl}/accept-invitation/${id}`, the route Better Auth's docs use.
|
|
126
|
+
*/
|
|
127
|
+
invitationUrl?: (invitation: {
|
|
128
|
+
id: string;
|
|
129
|
+
email: string;
|
|
130
|
+
}) => string;
|
|
131
|
+
/** Your app's public URL. Only needed for invitation emails, when `invitationUrl` is not set. */
|
|
132
|
+
appUrl?: string;
|
|
133
|
+
};
|
|
134
|
+
type AuthUser = {
|
|
135
|
+
email: string;
|
|
136
|
+
name?: string | null;
|
|
137
|
+
};
|
|
138
|
+
type InvitationData = {
|
|
139
|
+
id: string;
|
|
140
|
+
email: string;
|
|
141
|
+
role: string;
|
|
142
|
+
organization: {
|
|
143
|
+
name: string;
|
|
144
|
+
};
|
|
145
|
+
inviter: {
|
|
146
|
+
user: {
|
|
147
|
+
name: string;
|
|
148
|
+
email: string;
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
type EusendAuthEmails = {
|
|
153
|
+
/** For `emailVerification.sendVerificationEmail`. */
|
|
154
|
+
verification: (data: {
|
|
155
|
+
user: AuthUser;
|
|
156
|
+
url: string;
|
|
157
|
+
}) => Promise<void>;
|
|
158
|
+
/** For `emailAndPassword.sendResetPassword`. */
|
|
159
|
+
resetPassword: (data: {
|
|
160
|
+
user: AuthUser;
|
|
161
|
+
url: string;
|
|
162
|
+
}) => Promise<void>;
|
|
163
|
+
/** For `emailAndPassword.onPasswordReset` — the "was this you?" notice after a reset. */
|
|
164
|
+
passwordChanged: (data: {
|
|
165
|
+
user: AuthUser;
|
|
166
|
+
}) => Promise<void>;
|
|
167
|
+
/** For `magicLink({ sendMagicLink })`. */
|
|
168
|
+
magicLink: (data: {
|
|
169
|
+
email: string;
|
|
170
|
+
url: string;
|
|
171
|
+
}) => Promise<void>;
|
|
172
|
+
/** For `emailOTP({ sendVerificationOTP })`. */
|
|
173
|
+
otp: (data: {
|
|
174
|
+
email: string;
|
|
175
|
+
otp: string;
|
|
176
|
+
type: OtpType;
|
|
177
|
+
}) => Promise<void>;
|
|
178
|
+
/** For `organization({ sendInvitationEmail })`. */
|
|
179
|
+
invitation: (data: InvitationData) => Promise<void>;
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* The six senders Better Auth's email hooks need, each rendering a template and sending it
|
|
183
|
+
* through eusend. Pass them to the plugins the `eusend()` plugin cannot reach:
|
|
184
|
+
*
|
|
185
|
+
* ```ts
|
|
186
|
+
* const emails = eusendAuthEmails({ from: 'Acme <auth@acme.com>', appName: 'Acme' })
|
|
187
|
+
* emailOTP({ sendVerificationOTP: emails.otp })
|
|
188
|
+
* ```
|
|
189
|
+
*
|
|
190
|
+
* Every sender resolves straight away and never rejects — failures go to `onError`.
|
|
191
|
+
*/
|
|
192
|
+
declare function eusendAuthEmails(options: EusendAuthEmailOptions): EusendAuthEmails;
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/sync.d.ts
|
|
195
|
+
type SyncedUser = {
|
|
196
|
+
id: string;
|
|
197
|
+
email: string;
|
|
198
|
+
name?: string | null;
|
|
199
|
+
emailVerified?: boolean | null;
|
|
200
|
+
};
|
|
201
|
+
type ContactSyncOptions = EusendClientOptions & {
|
|
202
|
+
/** The eusend audience verified users are added to. */
|
|
203
|
+
audienceId: string;
|
|
204
|
+
/**
|
|
205
|
+
* Custom contact properties to set from the user, e.g. `(user) => ({ plan: user.plan })`.
|
|
206
|
+
* Merged into what the contact already has, never replacing it.
|
|
207
|
+
*/
|
|
208
|
+
properties?: (user: SyncedUser) => Record<string, string>;
|
|
209
|
+
};
|
|
210
|
+
/** Users carry one `name`; contacts carry two. "Ada Lovelace King" gives a last name of "Lovelace King". */
|
|
211
|
+
declare function splitName(name: string | null | undefined): {
|
|
212
|
+
firstName?: string;
|
|
213
|
+
lastName?: string;
|
|
214
|
+
};
|
|
215
|
+
/**
|
|
216
|
+
* Adds verified users to an audience.
|
|
217
|
+
*
|
|
218
|
+
* Unverified addresses are never synced. A signup form accepts typos and addresses that are
|
|
219
|
+
* not the signer's own, and broadcasting to those is the bounce and complaint rate that gets
|
|
220
|
+
* a sending domain blocked.
|
|
221
|
+
*
|
|
222
|
+
* The write goes through the batch endpoint, which merges instead of replacing: it cannot
|
|
223
|
+
* clear an existing contact's properties or names, and it can add an opt-out but never
|
|
224
|
+
* remove one. Someone who unsubscribed and then signs up stays unsubscribed.
|
|
225
|
+
*/
|
|
226
|
+
declare function createContactSync(options: ContactSyncOptions): (user: SyncedUser) => Promise<void>;
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/index.d.ts
|
|
229
|
+
type EusendPluginOptions = EusendClientOptions & {
|
|
230
|
+
/** Send Better Auth's verification, password-reset and password-changed emails. Omit to leave them to you. */
|
|
231
|
+
email?: Omit<EusendAuthEmailOptions, keyof EusendClientOptions>;
|
|
232
|
+
/** Add users to an eusend audience once their email is verified. Omit to turn syncing off. */
|
|
233
|
+
sync?: Omit<ContactSyncOptions, keyof EusendClientOptions>;
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* Better Auth plugin for [eusend](https://eusend.dev), EU-native transactional email.
|
|
237
|
+
*
|
|
238
|
+
* Everything it sets is a default. Better Auth merges plugin options underneath the app's
|
|
239
|
+
* own, so a `sendVerificationEmail` you already wrote keeps working and this plugin only
|
|
240
|
+
* fills the gaps. Adding it cannot switch on password sign-in.
|
|
241
|
+
*/
|
|
242
|
+
declare const eusend: (options?: EusendPluginOptions) => {
|
|
243
|
+
id: "eusend";
|
|
244
|
+
init(): {
|
|
245
|
+
options: {
|
|
246
|
+
databaseHooks?: {
|
|
247
|
+
user: {
|
|
248
|
+
create: {
|
|
249
|
+
after: (user: SyncedUser) => Promise<void>;
|
|
250
|
+
};
|
|
251
|
+
update: {
|
|
252
|
+
before: (data: Partial<SyncedUser>, context: object | null) => Promise<void>;
|
|
253
|
+
after: (user: SyncedUser, context: object | null) => Promise<void>;
|
|
254
|
+
};
|
|
255
|
+
};
|
|
256
|
+
} | undefined;
|
|
257
|
+
emailVerification?: {
|
|
258
|
+
sendVerificationEmail: (data: {
|
|
259
|
+
user: {
|
|
260
|
+
email: string;
|
|
261
|
+
name?: string | null;
|
|
262
|
+
};
|
|
263
|
+
url: string;
|
|
264
|
+
}) => Promise<void>;
|
|
265
|
+
} | undefined;
|
|
266
|
+
emailAndPassword?: {
|
|
267
|
+
enabled: false;
|
|
268
|
+
sendResetPassword: (data: {
|
|
269
|
+
user: {
|
|
270
|
+
email: string;
|
|
271
|
+
name?: string | null;
|
|
272
|
+
};
|
|
273
|
+
url: string;
|
|
274
|
+
}) => Promise<void>;
|
|
275
|
+
onPasswordReset: (data: {
|
|
276
|
+
user: {
|
|
277
|
+
email: string;
|
|
278
|
+
name?: string | null;
|
|
279
|
+
};
|
|
280
|
+
}) => Promise<void>;
|
|
281
|
+
} | undefined;
|
|
282
|
+
};
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
//#endregion
|
|
286
|
+
export { type Brand, type ContactSyncOptions, type EmailCategory, type EusendAuthEmailOptions, type EusendAuthEmails, type EusendClientOptions, EusendPluginOptions, type EusendSendError, type InvitationData, type OtpType, type RenderedEmail, type SyncedUser, type TemplateContext, type Templates, createContactSync, defaultTemplates, escapeHtml, eusend, eusendAuthEmails, renderLayout, safeUrl, splitName };
|
|
287
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/templates.ts","../src/emails.ts","../src/sync.ts","../src/index.ts"],"mappings":";;KAAY;;EAEV;;EAEA;;EAEA;;KAGU;EAAkB;EAAiB;EAAc;;KAEjD;EAAoB;EAAiB,OAAO;;KAE5C;KAEA;EACV,eACE;IAAQ;MAAQ;MAAe;;IAAwB;KACvD,SAAS,oBACN;EACL,gBACE;IAAQ;MAAQ;MAAe;;IAAwB;KACvD,SAAS,oBACN;EACL,kBACE;IAAQ;MAAQ;MAAe;;KAC/B,SAAS,oBACN;EACL,YAAY;IAAQ;IAAe;KAAe,SAAS,oBAAoB;EAC/E,MAAM;IAAQ;IAAe;IAAa,MAAM;KAAW,SAAS,oBAAoB;EACxF,aACE;IACE;IACA;IACA;IACA;IACA;IACA;KAEF,SAAS,oBACN;;iBAKS,WAAW;;;;;;iBAcX,QAAQ;KAkBnB;EAAU;EAAW;;EAAmB;EAAgB;EAAe;;EAAkB;EAAc;;iBAE5F,eACZ,SAAS,SAAS,mBAClB,WAAW,QAAQ;EAAc;EAAmB,QAAQ;EAAS;;EACpE;EAAc;;cAgEN,kBAAkB;;;KC5InB,sBAAsB;KAEtB;EACV,UAAU;EACV;;EAEA;EACA;;KAGU;;EAEV;;EAEA;;;;;;;EAOA,aAAa,SAAS;;EAEtB,WAAW,OAAO;;KAGR,yBAAyB;;EAEnC;;EAEA;EACA;EACA,QAAQ;;EAER,YAAY,QAAQ;;EAEpB,OAAO;;;;;EAKP,iBAAiB;IAAc;IAAY;;;EAE3C;;KAGG;EAAa;EAAe;;KAErB;EACV;EACA;EACA;EACA;IAAgB;;EAChB;IAAW;MAAQ;MAAc;;;;KAGvB;;EAEV,eAAe;IAAQ,MAAM;IAAU;QAAkB;;EAEzD,gBAAgB;IAAQ,MAAM;IAAU;QAAkB;;EAE1D,kBAAkB;IAAQ,MAAM;QAAe;;EAE/C,YAAY;IAAQ;IAAe;QAAkB;;EAErD,MAAM;IAAQ;IAAe;IAAa,MAAM;QAAc;;EAE9D,aAAa,MAAM,mBAAmB;;;;;;;;;;;;;iBA0CxB,iBAAiB,SAAS,yBAAyB;;;KCjHvD;EAAe;EAAY;EAAe;EAAsB;;KAEhE,qBAAqB;;EAE/B;;;;;EAKA,cAAc,MAAM,eAAe;;;iBAIrB,UAAU;EAAoC;EAAoB;;;;;;;;;;;;;iBAmBlE,kBAAkB,SAAS,sBAGjC,MAAM,eAAU;;;KCjBd,sBAAsB;;EAEhC,QAAQ,KAAK,8BAA8B;;EAE3C,OAAO,KAAK,0BAA0B;;;;;;;;;cAU3B,SAAU,UAAS;;;;;;;YAmCU,QAAA,MAAA,eAAU;;;YAKT,SAAA,MAAA,QAAQ,aAAW,2BAAwB;YAG5C,QAAA,MAAA,YAAU,2BAAwB"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { Eusend } from "@eusend_dev/sdk";
|
|
2
|
+
//#region src/templates.ts
|
|
3
|
+
const DEFAULT_COLOR = "#18181b";
|
|
4
|
+
function escapeHtml(value) {
|
|
5
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Only http(s) URLs make it into an href. Better Auth builds these links itself, but the
|
|
9
|
+
* base URL comes from config and request headers, and a `javascript:` link in an auth email
|
|
10
|
+
* is the one mistake that must be impossible rather than unlikely.
|
|
11
|
+
*/
|
|
12
|
+
function safeUrl(value) {
|
|
13
|
+
try {
|
|
14
|
+
const url = new URL(value);
|
|
15
|
+
if (url.protocol === "https:" || url.protocol === "http:") return url.toString();
|
|
16
|
+
} catch {}
|
|
17
|
+
throw new Error(`Refusing to put a non-http(s) URL in an email: ${value}`);
|
|
18
|
+
}
|
|
19
|
+
function color(brand) {
|
|
20
|
+
const value = brand.primaryColor?.trim();
|
|
21
|
+
return value && /^#[0-9a-f]{3,8}$/i.test(value) ? value : DEFAULT_COLOR;
|
|
22
|
+
}
|
|
23
|
+
function greeting(name) {
|
|
24
|
+
const first = name?.trim().split(/\s+/)[0];
|
|
25
|
+
return first ? `Hi ${first},` : "Hi,";
|
|
26
|
+
}
|
|
27
|
+
function renderLayout({ appName, brand }, { preheader, blocks, footnote }) {
|
|
28
|
+
const accent = color(brand);
|
|
29
|
+
const logo = brand.logoUrl ? `<img src="${escapeHtml(safeUrl(brand.logoUrl))}" alt="${escapeHtml(appName)}" height="32" style="display:block;height:32px;width:auto;border:0;margin:0 0 24px">` : `<div style="font-size:18px;font-weight:600;color:#18181b;margin:0 0 24px">${escapeHtml(appName)}</div>`;
|
|
30
|
+
const body = blocks.map((block) => {
|
|
31
|
+
if (block.kind === "p") return `<p style="margin:0 0 16px;font-size:15px;line-height:24px;color:#3f3f46">${escapeHtml(block.text)}</p>`;
|
|
32
|
+
if (block.kind === "code") return `<p style="margin:8px 0 24px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:28px;letter-spacing:6px;font-weight:600;color:#18181b">${escapeHtml(block.code)}</p>`;
|
|
33
|
+
const href = escapeHtml(safeUrl(block.url));
|
|
34
|
+
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:8px 0 24px"><tr><td style="border-radius:6px;background:${accent}"><a href="${href}" style="display:inline-block;padding:12px 20px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:6px">${escapeHtml(block.label)}</a></td></tr></table>
|
|
35
|
+
<p style="margin:0 0 16px;font-size:13px;line-height:20px;color:#71717a">Or paste this link into your browser:<br><a href="${href}" style="color:${accent};word-break:break-all">${href}</a></p>`;
|
|
36
|
+
}).join("\n");
|
|
37
|
+
const support = brand.supportEmail ? ` Questions? Write to <a href="mailto:${escapeHtml(brand.supportEmail)}" style="color:#71717a">${escapeHtml(brand.supportEmail)}</a>.` : "";
|
|
38
|
+
return {
|
|
39
|
+
html: `<!doctype html>
|
|
40
|
+
<html lang="en">
|
|
41
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"></head>
|
|
42
|
+
<body style="margin:0;padding:0;background:#f4f4f5">
|
|
43
|
+
<div style="display:none;max-height:0;overflow:hidden">${escapeHtml(preheader)}</div>
|
|
44
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5">
|
|
45
|
+
<tr><td align="center" style="padding:32px 16px">
|
|
46
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:520px;background:#ffffff;border-radius:8px;border:1px solid #e4e4e7">
|
|
47
|
+
<tr><td style="padding:32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">
|
|
48
|
+
${logo}
|
|
49
|
+
${body}
|
|
50
|
+
</td></tr>
|
|
51
|
+
</table>
|
|
52
|
+
<p style="max-width:520px;margin:16px auto 0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:12px;line-height:18px;color:#71717a">${escapeHtml(footnote)}${support}</p>
|
|
53
|
+
</td></tr>
|
|
54
|
+
</table>
|
|
55
|
+
</body>
|
|
56
|
+
</html>`,
|
|
57
|
+
text: [
|
|
58
|
+
...blocks.map((block) => {
|
|
59
|
+
if (block.kind === "p") return block.text;
|
|
60
|
+
if (block.kind === "code") return block.code;
|
|
61
|
+
return `${block.label}: ${safeUrl(block.url)}`;
|
|
62
|
+
}),
|
|
63
|
+
"",
|
|
64
|
+
"—",
|
|
65
|
+
footnote + (brand.supportEmail ? ` Questions? Write to ${brand.supportEmail}.` : "")
|
|
66
|
+
].join("\n\n")
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const OTP_COPY = {
|
|
70
|
+
"sign-in": {
|
|
71
|
+
subject: "sign-in code",
|
|
72
|
+
lead: "Use this code to sign in to"
|
|
73
|
+
},
|
|
74
|
+
"email-verification": {
|
|
75
|
+
subject: "verification code",
|
|
76
|
+
lead: "Use this code to verify your email address for"
|
|
77
|
+
},
|
|
78
|
+
"forget-password": {
|
|
79
|
+
subject: "password reset code",
|
|
80
|
+
lead: "Use this code to reset your password for"
|
|
81
|
+
},
|
|
82
|
+
"change-email": {
|
|
83
|
+
subject: "code to confirm your new email",
|
|
84
|
+
lead: "Use this code to confirm your new email address for"
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const defaultTemplates = {
|
|
88
|
+
verification: ({ user, url }, context) => ({
|
|
89
|
+
subject: `Verify your email for ${context.appName}`,
|
|
90
|
+
...renderLayout(context, {
|
|
91
|
+
preheader: `Confirm your email address to finish setting up ${context.appName}.`,
|
|
92
|
+
blocks: [
|
|
93
|
+
{
|
|
94
|
+
kind: "p",
|
|
95
|
+
text: greeting(user.name)
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
kind: "p",
|
|
99
|
+
text: `Confirm that ${user.email} is your email address to finish setting up your ${context.appName} account.`
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
kind: "button",
|
|
103
|
+
label: "Verify email",
|
|
104
|
+
url
|
|
105
|
+
}
|
|
106
|
+
],
|
|
107
|
+
footnote: `If you didn't create a ${context.appName} account, you can ignore this email.`
|
|
108
|
+
})
|
|
109
|
+
}),
|
|
110
|
+
resetPassword: ({ user, url }, context) => ({
|
|
111
|
+
subject: `Reset your ${context.appName} password`,
|
|
112
|
+
...renderLayout(context, {
|
|
113
|
+
preheader: `Someone asked to reset the password for ${user.email}.`,
|
|
114
|
+
blocks: [
|
|
115
|
+
{
|
|
116
|
+
kind: "p",
|
|
117
|
+
text: greeting(user.name)
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
kind: "p",
|
|
121
|
+
text: `Someone asked to reset the password for your ${context.appName} account. Choose a new one with the button below.`
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
kind: "button",
|
|
125
|
+
label: "Reset password",
|
|
126
|
+
url
|
|
127
|
+
}
|
|
128
|
+
],
|
|
129
|
+
footnote: "If you didn't ask for this, you can ignore this email — your password stays the same."
|
|
130
|
+
})
|
|
131
|
+
}),
|
|
132
|
+
passwordChanged: ({ user }, context) => ({
|
|
133
|
+
subject: `Your ${context.appName} password was changed`,
|
|
134
|
+
...renderLayout(context, {
|
|
135
|
+
preheader: `The password for ${user.email} was just changed.`,
|
|
136
|
+
blocks: [
|
|
137
|
+
{
|
|
138
|
+
kind: "p",
|
|
139
|
+
text: greeting(user.name)
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
kind: "p",
|
|
143
|
+
text: `The password for your ${context.appName} account (${user.email}) was just changed.`
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
kind: "p",
|
|
147
|
+
text: "If this was you, there's nothing else to do."
|
|
148
|
+
}
|
|
149
|
+
],
|
|
150
|
+
footnote: `If you didn't change it, reset your password straight away and contact ${context.appName} support.`
|
|
151
|
+
})
|
|
152
|
+
}),
|
|
153
|
+
magicLink: ({ url }, context) => ({
|
|
154
|
+
subject: `Sign in to ${context.appName}`,
|
|
155
|
+
...renderLayout(context, {
|
|
156
|
+
preheader: `Your sign-in link for ${context.appName}.`,
|
|
157
|
+
blocks: [{
|
|
158
|
+
kind: "p",
|
|
159
|
+
text: `Use the button below to sign in to ${context.appName}. The link works once and expires shortly.`
|
|
160
|
+
}, {
|
|
161
|
+
kind: "button",
|
|
162
|
+
label: `Sign in to ${context.appName}`,
|
|
163
|
+
url
|
|
164
|
+
}],
|
|
165
|
+
footnote: "If you didn't try to sign in, you can ignore this email."
|
|
166
|
+
})
|
|
167
|
+
}),
|
|
168
|
+
otp: ({ otp, type }, context) => {
|
|
169
|
+
const copy = OTP_COPY[type] ?? OTP_COPY["sign-in"];
|
|
170
|
+
return {
|
|
171
|
+
subject: `Your ${context.appName} ${copy.subject}: ${otp}`,
|
|
172
|
+
...renderLayout(context, {
|
|
173
|
+
preheader: `${copy.lead} ${context.appName}.`,
|
|
174
|
+
blocks: [
|
|
175
|
+
{
|
|
176
|
+
kind: "p",
|
|
177
|
+
text: `${copy.lead} ${context.appName}:`
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
kind: "code",
|
|
181
|
+
code: otp
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
kind: "p",
|
|
185
|
+
text: "The code expires shortly. Never share it with anyone."
|
|
186
|
+
}
|
|
187
|
+
],
|
|
188
|
+
footnote: "If you didn't ask for this code, you can ignore this email."
|
|
189
|
+
})
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
invitation: ({ url, organizationName, inviterName, inviterEmail, role }, context) => ({
|
|
193
|
+
subject: `${inviterName} invited you to ${organizationName} on ${context.appName}`,
|
|
194
|
+
...renderLayout(context, {
|
|
195
|
+
preheader: `Join ${organizationName} on ${context.appName}.`,
|
|
196
|
+
blocks: [{
|
|
197
|
+
kind: "p",
|
|
198
|
+
text: `${inviterName} (${inviterEmail}) invited you to join ${organizationName} on ${context.appName} as ${role}.`
|
|
199
|
+
}, {
|
|
200
|
+
kind: "button",
|
|
201
|
+
label: "Accept invitation",
|
|
202
|
+
url
|
|
203
|
+
}],
|
|
204
|
+
footnote: "If you weren't expecting this invitation, you can ignore this email."
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
};
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/emails.ts
|
|
210
|
+
function reportError(options, error) {
|
|
211
|
+
if (options.onError) {
|
|
212
|
+
try {
|
|
213
|
+
options.onError(error);
|
|
214
|
+
} catch {}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
console.error(`[eusend] ${error.category} for ${error.to} failed: ${error.code} — ${error.message}`);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Start `work` without making the caller wait for it. Better Auth asks for exactly this:
|
|
221
|
+
* a reset endpoint that waits for the send answers faster for addresses with no account,
|
|
222
|
+
* and that difference is how an attacker learns which addresses are registered.
|
|
223
|
+
*/
|
|
224
|
+
function detach(options, work) {
|
|
225
|
+
const running = work();
|
|
226
|
+
if (options.waitUntil) options.waitUntil(running);
|
|
227
|
+
return Promise.resolve();
|
|
228
|
+
}
|
|
229
|
+
function lazyClient(options) {
|
|
230
|
+
let client;
|
|
231
|
+
return () => client ??= new Eusend(options.apiKey, options.baseUrl ? { baseUrl: options.baseUrl } : void 0);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The six senders Better Auth's email hooks need, each rendering a template and sending it
|
|
235
|
+
* through eusend. Pass them to the plugins the `eusend()` plugin cannot reach:
|
|
236
|
+
*
|
|
237
|
+
* ```ts
|
|
238
|
+
* const emails = eusendAuthEmails({ from: 'Acme <auth@acme.com>', appName: 'Acme' })
|
|
239
|
+
* emailOTP({ sendVerificationOTP: emails.otp })
|
|
240
|
+
* ```
|
|
241
|
+
*
|
|
242
|
+
* Every sender resolves straight away and never rejects — failures go to `onError`.
|
|
243
|
+
*/
|
|
244
|
+
function eusendAuthEmails(options) {
|
|
245
|
+
const templates = {
|
|
246
|
+
...defaultTemplates,
|
|
247
|
+
...options.templates
|
|
248
|
+
};
|
|
249
|
+
const context = {
|
|
250
|
+
appName: options.appName,
|
|
251
|
+
brand: options.brand ?? {}
|
|
252
|
+
};
|
|
253
|
+
const client = lazyClient(options);
|
|
254
|
+
const send = (category, to, render) => detach(options, async () => {
|
|
255
|
+
try {
|
|
256
|
+
const email = render();
|
|
257
|
+
const message = {
|
|
258
|
+
from: options.from,
|
|
259
|
+
to,
|
|
260
|
+
replyTo: options.replyTo,
|
|
261
|
+
subject: email.subject,
|
|
262
|
+
html: email.html,
|
|
263
|
+
text: email.text,
|
|
264
|
+
tags: {
|
|
265
|
+
...options.tags,
|
|
266
|
+
source: "better-auth",
|
|
267
|
+
category: toTagValue(category)
|
|
268
|
+
},
|
|
269
|
+
trackClicks: false
|
|
270
|
+
};
|
|
271
|
+
const { error } = await client().emails.send(message);
|
|
272
|
+
if (error) reportError(options, {
|
|
273
|
+
category,
|
|
274
|
+
to,
|
|
275
|
+
code: error.name,
|
|
276
|
+
message: error.message
|
|
277
|
+
});
|
|
278
|
+
} catch (error) {
|
|
279
|
+
reportError(options, {
|
|
280
|
+
category,
|
|
281
|
+
to,
|
|
282
|
+
code: "NETWORK_ERROR",
|
|
283
|
+
message: error instanceof Error ? error.message : String(error)
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
return {
|
|
288
|
+
verification: ({ user, url }) => send("verification", user.email, () => templates.verification({
|
|
289
|
+
user,
|
|
290
|
+
url
|
|
291
|
+
}, context)),
|
|
292
|
+
resetPassword: ({ user, url }) => send("resetPassword", user.email, () => templates.resetPassword({
|
|
293
|
+
user,
|
|
294
|
+
url
|
|
295
|
+
}, context)),
|
|
296
|
+
passwordChanged: ({ user }) => send("passwordChanged", user.email, () => templates.passwordChanged({ user }, context)),
|
|
297
|
+
magicLink: ({ email, url }) => send("magicLink", email, () => templates.magicLink({
|
|
298
|
+
email,
|
|
299
|
+
url
|
|
300
|
+
}, context)),
|
|
301
|
+
otp: ({ email, otp, type }) => send("otp", email, () => templates.otp({
|
|
302
|
+
email,
|
|
303
|
+
otp,
|
|
304
|
+
type
|
|
305
|
+
}, context)),
|
|
306
|
+
invitation: (data) => send("invitation", data.email, () => templates.invitation({
|
|
307
|
+
email: data.email,
|
|
308
|
+
url: invitationLink(options, data),
|
|
309
|
+
role: data.role,
|
|
310
|
+
organizationName: data.organization.name,
|
|
311
|
+
inviterName: data.inviter.user.name,
|
|
312
|
+
inviterEmail: data.inviter.user.email
|
|
313
|
+
}, context))
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function invitationLink(options, data) {
|
|
317
|
+
if (options.invitationUrl) return options.invitationUrl({
|
|
318
|
+
id: data.id,
|
|
319
|
+
email: data.email
|
|
320
|
+
});
|
|
321
|
+
if (!options.appUrl) throw new Error("Set `appUrl` or `invitationUrl` to send organization invitation emails.");
|
|
322
|
+
return safeUrl(`${options.appUrl.replace(/\/+$/, "")}/accept-invitation/${encodeURIComponent(data.id)}`);
|
|
323
|
+
}
|
|
324
|
+
/** Tag values allow letters, digits, `_` and `-`, so `resetPassword` goes on the wire as `reset-password`. */
|
|
325
|
+
function toTagValue(category) {
|
|
326
|
+
return category.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
327
|
+
}
|
|
328
|
+
//#endregion
|
|
329
|
+
//#region src/sync.ts
|
|
330
|
+
/** Users carry one `name`; contacts carry two. "Ada Lovelace King" gives a last name of "Lovelace King". */
|
|
331
|
+
function splitName(name) {
|
|
332
|
+
const trimmed = name?.trim() ?? "";
|
|
333
|
+
if (!trimmed) return {};
|
|
334
|
+
const gap = trimmed.indexOf(" ");
|
|
335
|
+
if (gap === -1) return { firstName: trimmed };
|
|
336
|
+
return {
|
|
337
|
+
firstName: trimmed.slice(0, gap),
|
|
338
|
+
lastName: trimmed.slice(gap + 1)
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Adds verified users to an audience.
|
|
343
|
+
*
|
|
344
|
+
* Unverified addresses are never synced. A signup form accepts typos and addresses that are
|
|
345
|
+
* not the signer's own, and broadcasting to those is the bounce and complaint rate that gets
|
|
346
|
+
* a sending domain blocked.
|
|
347
|
+
*
|
|
348
|
+
* The write goes through the batch endpoint, which merges instead of replacing: it cannot
|
|
349
|
+
* clear an existing contact's properties or names, and it can add an opt-out but never
|
|
350
|
+
* remove one. Someone who unsubscribed and then signs up stays unsubscribed.
|
|
351
|
+
*/
|
|
352
|
+
function createContactSync(options) {
|
|
353
|
+
const client = lazyClient(options);
|
|
354
|
+
return (user) => detach(options, async () => {
|
|
355
|
+
if (!user.emailVerified || !user.email) return;
|
|
356
|
+
try {
|
|
357
|
+
const { error } = await client().audiences.batchCreateContacts(options.audienceId, { contacts: [{
|
|
358
|
+
email: user.email,
|
|
359
|
+
...splitName(user.name),
|
|
360
|
+
properties: options.properties?.(user)
|
|
361
|
+
}] });
|
|
362
|
+
if (error) reportError(options, {
|
|
363
|
+
category: "contactSync",
|
|
364
|
+
to: user.email,
|
|
365
|
+
code: error.name,
|
|
366
|
+
message: error.message
|
|
367
|
+
});
|
|
368
|
+
} catch (error) {
|
|
369
|
+
reportError(options, {
|
|
370
|
+
category: "contactSync",
|
|
371
|
+
to: user.email,
|
|
372
|
+
code: "NETWORK_ERROR",
|
|
373
|
+
message: error instanceof Error ? error.message : String(error)
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
//#endregion
|
|
379
|
+
//#region src/index.ts
|
|
380
|
+
/**
|
|
381
|
+
* Better Auth plugin for [eusend](https://eusend.dev), EU-native transactional email.
|
|
382
|
+
*
|
|
383
|
+
* Everything it sets is a default. Better Auth merges plugin options underneath the app's
|
|
384
|
+
* own, so a `sendVerificationEmail` you already wrote keeps working and this plugin only
|
|
385
|
+
* fills the gaps. Adding it cannot switch on password sign-in.
|
|
386
|
+
*/
|
|
387
|
+
const eusend = (options = {}) => {
|
|
388
|
+
const { email, sync: syncOptions, ...client } = options;
|
|
389
|
+
const emails = email ? eusendAuthEmails({
|
|
390
|
+
...client,
|
|
391
|
+
...email
|
|
392
|
+
}) : null;
|
|
393
|
+
const sync = syncOptions ? createContactSync({
|
|
394
|
+
...client,
|
|
395
|
+
...syncOptions
|
|
396
|
+
}) : null;
|
|
397
|
+
const verifying = /* @__PURE__ */ new WeakSet();
|
|
398
|
+
return {
|
|
399
|
+
id: "eusend",
|
|
400
|
+
init() {
|
|
401
|
+
return { options: {
|
|
402
|
+
...emails ? {
|
|
403
|
+
emailVerification: { sendVerificationEmail: emails.verification },
|
|
404
|
+
emailAndPassword: {
|
|
405
|
+
enabled: false,
|
|
406
|
+
sendResetPassword: emails.resetPassword,
|
|
407
|
+
onPasswordReset: emails.passwordChanged
|
|
408
|
+
}
|
|
409
|
+
} : {},
|
|
410
|
+
...sync ? { databaseHooks: { user: {
|
|
411
|
+
create: { after: async (user) => {
|
|
412
|
+
await sync(user);
|
|
413
|
+
} },
|
|
414
|
+
update: {
|
|
415
|
+
before: async (data, context) => {
|
|
416
|
+
if (data.emailVerified === true && context) verifying.add(context);
|
|
417
|
+
},
|
|
418
|
+
after: async (user, context) => {
|
|
419
|
+
if (context && verifying.delete(context)) await sync(user);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
} } } : {}
|
|
423
|
+
} };
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
};
|
|
427
|
+
//#endregion
|
|
428
|
+
export { createContactSync, defaultTemplates, escapeHtml, eusend, eusendAuthEmails, renderLayout, safeUrl, splitName };
|
|
429
|
+
|
|
430
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/templates.ts","../src/emails.ts","../src/sync.ts","../src/index.ts"],"sourcesContent":["export type Brand = {\r\n /** Shown above every email. Use an absolute https URL; mail clients cannot load relative ones. */\r\n logoUrl?: string\r\n /** Button and link colour, as a hex value. Defaults to a neutral near-black. */\r\n primaryColor?: string\r\n /** Printed in the footer as the place to reply to. */\r\n supportEmail?: string\r\n}\r\n\r\nexport type RenderedEmail = { subject: string; html: string; text: string }\r\n\r\nexport type TemplateContext = { appName: string; brand: Brand }\r\n\r\nexport type OtpType = 'sign-in' | 'email-verification' | 'forget-password' | 'change-email'\r\n\r\nexport type Templates = {\r\n verification: (\r\n data: { user: { email: string; name?: string | null }; url: string },\r\n context: TemplateContext,\r\n ) => RenderedEmail\r\n resetPassword: (\r\n data: { user: { email: string; name?: string | null }; url: string },\r\n context: TemplateContext,\r\n ) => RenderedEmail\r\n passwordChanged: (\r\n data: { user: { email: string; name?: string | null } },\r\n context: TemplateContext,\r\n ) => RenderedEmail\r\n magicLink: (data: { email: string; url: string }, context: TemplateContext) => RenderedEmail\r\n otp: (data: { email: string; otp: string; type: OtpType }, context: TemplateContext) => RenderedEmail\r\n invitation: (\r\n data: {\r\n email: string\r\n url: string\r\n role: string\r\n organizationName: string\r\n inviterName: string\r\n inviterEmail: string\r\n },\r\n context: TemplateContext,\r\n ) => RenderedEmail\r\n}\r\n\r\nconst DEFAULT_COLOR = '#18181b'\r\n\r\nexport function escapeHtml(value: string): string {\r\n return value\r\n .replace(/&/g, '&')\r\n .replace(/</g, '<')\r\n .replace(/>/g, '>')\r\n .replace(/\"/g, '"')\r\n .replace(/'/g, ''')\r\n}\r\n\r\n/**\r\n * Only http(s) URLs make it into an href. Better Auth builds these links itself, but the\r\n * base URL comes from config and request headers, and a `javascript:` link in an auth email\r\n * is the one mistake that must be impossible rather than unlikely.\r\n */\r\nexport function safeUrl(value: string): string {\r\n try {\r\n const url = new URL(value)\r\n if (url.protocol === 'https:' || url.protocol === 'http:') return url.toString()\r\n } catch {}\r\n throw new Error(`Refusing to put a non-http(s) URL in an email: ${value}`)\r\n}\r\n\r\nfunction color(brand: Brand): string {\r\n const value = brand.primaryColor?.trim()\r\n return value && /^#[0-9a-f]{3,8}$/i.test(value) ? value : DEFAULT_COLOR\r\n}\r\n\r\nfunction greeting(name: string | null | undefined): string {\r\n const first = name?.trim().split(/\\s+/)[0]\r\n return first ? `Hi ${first},` : 'Hi,'\r\n}\r\n\r\ntype Block = { kind: 'p'; text: string } | { kind: 'button'; label: string; url: string } | { kind: 'code'; code: string }\r\n\r\nexport function renderLayout(\r\n { appName, brand }: TemplateContext,\r\n { preheader, blocks, footnote }: { preheader: string; blocks: Block[]; footnote: string },\r\n): { html: string; text: string } {\r\n const accent = color(brand)\r\n const logo = brand.logoUrl\r\n ? `<img src=\"${escapeHtml(safeUrl(brand.logoUrl))}\" alt=\"${escapeHtml(appName)}\" height=\"32\" style=\"display:block;height:32px;width:auto;border:0;margin:0 0 24px\">`\r\n : `<div style=\"font-size:18px;font-weight:600;color:#18181b;margin:0 0 24px\">${escapeHtml(appName)}</div>`\r\n\r\n const body = blocks\r\n .map((block) => {\r\n if (block.kind === 'p') {\r\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:24px;color:#3f3f46\">${escapeHtml(block.text)}</p>`\r\n }\r\n if (block.kind === 'code') {\r\n return `<p style=\"margin:8px 0 24px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:28px;letter-spacing:6px;font-weight:600;color:#18181b\">${escapeHtml(block.code)}</p>`\r\n }\r\n const href = escapeHtml(safeUrl(block.url))\r\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:8px 0 24px\"><tr><td style=\"border-radius:6px;background:${accent}\"><a href=\"${href}\" style=\"display:inline-block;padding:12px 20px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:6px\">${escapeHtml(block.label)}</a></td></tr></table>\r\n<p style=\"margin:0 0 16px;font-size:13px;line-height:20px;color:#71717a\">Or paste this link into your browser:<br><a href=\"${href}\" style=\"color:${accent};word-break:break-all\">${href}</a></p>`\r\n })\r\n .join('\\n')\r\n\r\n const support = brand.supportEmail\r\n ? ` Questions? Write to <a href=\"mailto:${escapeHtml(brand.supportEmail)}\" style=\"color:#71717a\">${escapeHtml(brand.supportEmail)}</a>.`\r\n : ''\r\n\r\n const html = `<!doctype html>\r\n<html lang=\"en\">\r\n<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><meta name=\"color-scheme\" content=\"light\"></head>\r\n<body style=\"margin:0;padding:0;background:#f4f4f5\">\r\n<div style=\"display:none;max-height:0;overflow:hidden\">${escapeHtml(preheader)}</div>\r\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background:#f4f4f5\">\r\n<tr><td align=\"center\" style=\"padding:32px 16px\">\r\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"max-width:520px;background:#ffffff;border-radius:8px;border:1px solid #e4e4e7\">\r\n<tr><td style=\"padding:32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif\">\r\n${logo}\r\n${body}\r\n</td></tr>\r\n</table>\r\n<p style=\"max-width:520px;margin:16px auto 0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:12px;line-height:18px;color:#71717a\">${escapeHtml(footnote)}${support}</p>\r\n</td></tr>\r\n</table>\r\n</body>\r\n</html>`\r\n\r\n const text = [\r\n ...blocks.map((block) => {\r\n if (block.kind === 'p') return block.text\r\n if (block.kind === 'code') return block.code\r\n return `${block.label}: ${safeUrl(block.url)}`\r\n }),\r\n '',\r\n '—',\r\n footnote + (brand.supportEmail ? ` Questions? Write to ${brand.supportEmail}.` : ''),\r\n ].join('\\n\\n')\r\n\r\n return { html, text }\r\n}\r\n\r\nconst OTP_COPY: Record<OtpType, { subject: string; lead: string }> = {\r\n 'sign-in': { subject: 'sign-in code', lead: 'Use this code to sign in to' },\r\n 'email-verification': { subject: 'verification code', lead: 'Use this code to verify your email address for' },\r\n 'forget-password': { subject: 'password reset code', lead: 'Use this code to reset your password for' },\r\n 'change-email': { subject: 'code to confirm your new email', lead: 'Use this code to confirm your new email address for' },\r\n}\r\n\r\nexport const defaultTemplates: Templates = {\r\n verification: ({ user, url }, context) => ({\r\n subject: `Verify your email for ${context.appName}`,\r\n ...renderLayout(context, {\r\n preheader: `Confirm your email address to finish setting up ${context.appName}.`,\r\n blocks: [\r\n { kind: 'p', text: greeting(user.name) },\r\n { kind: 'p', text: `Confirm that ${user.email} is your email address to finish setting up your ${context.appName} account.` },\r\n { kind: 'button', label: 'Verify email', url },\r\n ],\r\n footnote: `If you didn't create a ${context.appName} account, you can ignore this email.`,\r\n }),\r\n }),\r\n\r\n resetPassword: ({ user, url }, context) => ({\r\n subject: `Reset your ${context.appName} password`,\r\n ...renderLayout(context, {\r\n preheader: `Someone asked to reset the password for ${user.email}.`,\r\n blocks: [\r\n { kind: 'p', text: greeting(user.name) },\r\n { kind: 'p', text: `Someone asked to reset the password for your ${context.appName} account. Choose a new one with the button below.` },\r\n { kind: 'button', label: 'Reset password', url },\r\n ],\r\n footnote: \"If you didn't ask for this, you can ignore this email — your password stays the same.\",\r\n }),\r\n }),\r\n\r\n passwordChanged: ({ user }, context) => ({\r\n subject: `Your ${context.appName} password was changed`,\r\n ...renderLayout(context, {\r\n preheader: `The password for ${user.email} was just changed.`,\r\n blocks: [\r\n { kind: 'p', text: greeting(user.name) },\r\n { kind: 'p', text: `The password for your ${context.appName} account (${user.email}) was just changed.` },\r\n { kind: 'p', text: \"If this was you, there's nothing else to do.\" },\r\n ],\r\n footnote: `If you didn't change it, reset your password straight away and contact ${context.appName} support.`,\r\n }),\r\n }),\r\n\r\n magicLink: ({ url }, context) => ({\r\n subject: `Sign in to ${context.appName}`,\r\n ...renderLayout(context, {\r\n preheader: `Your sign-in link for ${context.appName}.`,\r\n blocks: [\r\n { kind: 'p', text: `Use the button below to sign in to ${context.appName}. The link works once and expires shortly.` },\r\n { kind: 'button', label: `Sign in to ${context.appName}`, url },\r\n ],\r\n footnote: \"If you didn't try to sign in, you can ignore this email.\",\r\n }),\r\n }),\r\n\r\n otp: ({ otp, type }, context) => {\r\n const copy = OTP_COPY[type] ?? OTP_COPY['sign-in']\r\n return {\r\n subject: `Your ${context.appName} ${copy.subject}: ${otp}`,\r\n ...renderLayout(context, {\r\n preheader: `${copy.lead} ${context.appName}.`,\r\n blocks: [\r\n { kind: 'p', text: `${copy.lead} ${context.appName}:` },\r\n { kind: 'code', code: otp },\r\n { kind: 'p', text: 'The code expires shortly. Never share it with anyone.' },\r\n ],\r\n footnote: \"If you didn't ask for this code, you can ignore this email.\",\r\n }),\r\n }\r\n },\r\n\r\n invitation: ({ url, organizationName, inviterName, inviterEmail, role }, context) => ({\r\n subject: `${inviterName} invited you to ${organizationName} on ${context.appName}`,\r\n ...renderLayout(context, {\r\n preheader: `Join ${organizationName} on ${context.appName}.`,\r\n blocks: [\r\n { kind: 'p', text: `${inviterName} (${inviterEmail}) invited you to join ${organizationName} on ${context.appName} as ${role}.` },\r\n { kind: 'button', label: 'Accept invitation', url },\r\n ],\r\n footnote: \"If you weren't expecting this invitation, you can ignore this email.\",\r\n }),\r\n }),\r\n}\r\n","import { Eusend } from '@eusend_dev/sdk'\r\nimport type { SendEmailOptions } from '@eusend_dev/sdk'\r\n\r\nimport { defaultTemplates, safeUrl } from './templates'\r\nimport type { Brand, OtpType, RenderedEmail, TemplateContext, Templates } from './templates'\r\n\r\nexport type EmailCategory = keyof Templates\r\n\r\nexport type EusendSendError = {\r\n category: EmailCategory | 'contactSync'\r\n to: string\r\n /** The eusend error code, e.g. `DOMAIN_NOT_VERIFIED`, or `NETWORK_ERROR` when the request never got an answer. */\r\n code: string\r\n message: string\r\n}\r\n\r\nexport type EusendClientOptions = {\r\n /** Defaults to the `EUSEND_API_KEY` environment variable. */\r\n apiKey?: string\r\n /** Override the API host. Only useful against a staging deployment. */\r\n baseUrl?: string\r\n /**\r\n * Keeps the send alive after the response has gone out, on platforms that freeze the\r\n * function when it returns — `after` from `next/server`, `waitUntil` from\r\n * `@vercel/functions`, or a Worker's `ctx.waitUntil`. Without it the send is started and\r\n * not awaited, which is right for a long-running server.\r\n */\r\n waitUntil?: (promise: Promise<unknown>) => void\r\n /** Called when a send or a contact sync fails. Defaults to `console.error`. */\r\n onError?: (error: EusendSendError) => void\r\n}\r\n\r\nexport type EusendAuthEmailOptions = EusendClientOptions & {\r\n /** Sender for every auth email, e.g. `Acme <auth@acme.com>`. Its domain must be verified on eusend. */\r\n from: string\r\n /** Your product name, used in subjects and copy. */\r\n appName: string\r\n replyTo?: string\r\n brand?: Brand\r\n /** Replace any of the built-in templates. Each receives the same data as the default. */\r\n templates?: Partial<Templates>\r\n /** Added to every send alongside `source: better-auth` and `category`. Up to 8. */\r\n tags?: Record<string, string>\r\n /**\r\n * Builds the link in organization invitation emails. Defaults to\r\n * `${appUrl}/accept-invitation/${id}`, the route Better Auth's docs use.\r\n */\r\n invitationUrl?: (invitation: { id: string; email: string }) => string\r\n /** Your app's public URL. Only needed for invitation emails, when `invitationUrl` is not set. */\r\n appUrl?: string\r\n}\r\n\r\ntype AuthUser = { email: string; name?: string | null }\r\n\r\nexport type InvitationData = {\r\n id: string\r\n email: string\r\n role: string\r\n organization: { name: string }\r\n inviter: { user: { name: string; email: string } }\r\n}\r\n\r\nexport type EusendAuthEmails = {\r\n /** For `emailVerification.sendVerificationEmail`. */\r\n verification: (data: { user: AuthUser; url: string }) => Promise<void>\r\n /** For `emailAndPassword.sendResetPassword`. */\r\n resetPassword: (data: { user: AuthUser; url: string }) => Promise<void>\r\n /** For `emailAndPassword.onPasswordReset` — the \"was this you?\" notice after a reset. */\r\n passwordChanged: (data: { user: AuthUser }) => Promise<void>\r\n /** For `magicLink({ sendMagicLink })`. */\r\n magicLink: (data: { email: string; url: string }) => Promise<void>\r\n /** For `emailOTP({ sendVerificationOTP })`. */\r\n otp: (data: { email: string; otp: string; type: OtpType }) => Promise<void>\r\n /** For `organization({ sendInvitationEmail })`. */\r\n invitation: (data: InvitationData) => Promise<void>\r\n}\r\n\r\nexport function reportError(options: EusendClientOptions, error: EusendSendError): void {\r\n if (options.onError) {\r\n try {\r\n options.onError(error)\r\n } catch {}\r\n return\r\n }\r\n console.error(`[eusend] ${error.category} for ${error.to} failed: ${error.code} — ${error.message}`)\r\n}\r\n\r\n/**\r\n * Start `work` without making the caller wait for it. Better Auth asks for exactly this:\r\n * a reset endpoint that waits for the send answers faster for addresses with no account,\r\n * and that difference is how an attacker learns which addresses are registered.\r\n */\r\nexport function detach(options: EusendClientOptions, work: () => Promise<void>): Promise<void> {\r\n const running = work()\r\n if (options.waitUntil) options.waitUntil(running)\r\n return Promise.resolve()\r\n}\r\n\r\nexport function lazyClient(options: EusendClientOptions): () => Eusend {\r\n let client: Eusend | undefined\r\n // Built on first use rather than at import, so a build step that loads auth.ts without\r\n // production env vars does not crash on the missing key.\r\n return () => (client ??= new Eusend(options.apiKey, options.baseUrl ? { baseUrl: options.baseUrl } : undefined))\r\n}\r\n\r\n/**\r\n * The six senders Better Auth's email hooks need, each rendering a template and sending it\r\n * through eusend. Pass them to the plugins the `eusend()` plugin cannot reach:\r\n *\r\n * ```ts\r\n * const emails = eusendAuthEmails({ from: 'Acme <auth@acme.com>', appName: 'Acme' })\r\n * emailOTP({ sendVerificationOTP: emails.otp })\r\n * ```\r\n *\r\n * Every sender resolves straight away and never rejects — failures go to `onError`.\r\n */\r\nexport function eusendAuthEmails(options: EusendAuthEmailOptions): EusendAuthEmails {\r\n const templates: Templates = { ...defaultTemplates, ...options.templates }\r\n const context: TemplateContext = { appName: options.appName, brand: options.brand ?? {} }\r\n const client = lazyClient(options)\r\n\r\n const send = (category: EmailCategory, to: string, render: () => RenderedEmail) =>\r\n detach(options, async () => {\r\n try {\r\n const email = render()\r\n const message: SendEmailOptions = {\r\n from: options.from,\r\n to,\r\n replyTo: options.replyTo,\r\n subject: email.subject,\r\n html: email.html,\r\n text: email.text,\r\n tags: { ...options.tags, source: 'better-auth', category: toTagValue(category) },\r\n // A tracked link is rewritten through a redirect, and some security scanners\r\n // follow every link in a message — which would burn a single-use sign-in or\r\n // reset link before the user ever clicks it.\r\n trackClicks: false,\r\n }\r\n const { error } = await client().emails.send(message)\r\n if (error) reportError(options, { category, to, code: error.name, message: error.message })\r\n } catch (error) {\r\n reportError(options, {\r\n category,\r\n to,\r\n code: 'NETWORK_ERROR',\r\n message: error instanceof Error ? error.message : String(error),\r\n })\r\n }\r\n })\r\n\r\n return {\r\n verification: ({ user, url }) => send('verification', user.email, () => templates.verification({ user, url }, context)),\r\n resetPassword: ({ user, url }) =>\r\n send('resetPassword', user.email, () => templates.resetPassword({ user, url }, context)),\r\n passwordChanged: ({ user }) =>\r\n send('passwordChanged', user.email, () => templates.passwordChanged({ user }, context)),\r\n magicLink: ({ email, url }) => send('magicLink', email, () => templates.magicLink({ email, url }, context)),\r\n otp: ({ email, otp, type }) => send('otp', email, () => templates.otp({ email, otp, type }, context)),\r\n invitation: (data) =>\r\n send('invitation', data.email, () =>\r\n templates.invitation(\r\n {\r\n email: data.email,\r\n url: invitationLink(options, data),\r\n role: data.role,\r\n organizationName: data.organization.name,\r\n inviterName: data.inviter.user.name,\r\n inviterEmail: data.inviter.user.email,\r\n },\r\n context,\r\n ),\r\n ),\r\n }\r\n}\r\n\r\nfunction invitationLink(options: EusendAuthEmailOptions, data: InvitationData): string {\r\n if (options.invitationUrl) return options.invitationUrl({ id: data.id, email: data.email })\r\n if (!options.appUrl) {\r\n throw new Error('Set `appUrl` or `invitationUrl` to send organization invitation emails.')\r\n }\r\n return safeUrl(`${options.appUrl.replace(/\\/+$/, '')}/accept-invitation/${encodeURIComponent(data.id)}`)\r\n}\r\n\r\n/** Tag values allow letters, digits, `_` and `-`, so `resetPassword` goes on the wire as `reset-password`. */\r\nfunction toTagValue(category: EmailCategory): string {\r\n return category.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)\r\n}\r\n","import { detach, lazyClient, reportError } from './emails'\r\nimport type { EusendClientOptions } from './emails'\r\n\r\nexport type SyncedUser = { id: string; email: string; name?: string | null; emailVerified?: boolean | null }\r\n\r\nexport type ContactSyncOptions = EusendClientOptions & {\r\n /** The eusend audience verified users are added to. */\r\n audienceId: string\r\n /**\r\n * Custom contact properties to set from the user, e.g. `(user) => ({ plan: user.plan })`.\r\n * Merged into what the contact already has, never replacing it.\r\n */\r\n properties?: (user: SyncedUser) => Record<string, string>\r\n}\r\n\r\n/** Users carry one `name`; contacts carry two. \"Ada Lovelace King\" gives a last name of \"Lovelace King\". */\r\nexport function splitName(name: string | null | undefined): { firstName?: string; lastName?: string } {\r\n const trimmed = name?.trim() ?? ''\r\n if (!trimmed) return {}\r\n const gap = trimmed.indexOf(' ')\r\n if (gap === -1) return { firstName: trimmed }\r\n return { firstName: trimmed.slice(0, gap), lastName: trimmed.slice(gap + 1) }\r\n}\r\n\r\n/**\r\n * Adds verified users to an audience.\r\n *\r\n * Unverified addresses are never synced. A signup form accepts typos and addresses that are\r\n * not the signer's own, and broadcasting to those is the bounce and complaint rate that gets\r\n * a sending domain blocked.\r\n *\r\n * The write goes through the batch endpoint, which merges instead of replacing: it cannot\r\n * clear an existing contact's properties or names, and it can add an opt-out but never\r\n * remove one. Someone who unsubscribed and then signs up stays unsubscribed.\r\n */\r\nexport function createContactSync(options: ContactSyncOptions) {\r\n const client = lazyClient(options)\r\n\r\n return (user: SyncedUser) =>\r\n detach(options, async () => {\r\n if (!user.emailVerified || !user.email) return\r\n try {\r\n const { error } = await client().audiences.batchCreateContacts(options.audienceId, {\r\n contacts: [{ email: user.email, ...splitName(user.name), properties: options.properties?.(user) }],\r\n })\r\n if (error) {\r\n reportError(options, { category: 'contactSync', to: user.email, code: error.name, message: error.message })\r\n }\r\n } catch (error) {\r\n reportError(options, {\r\n category: 'contactSync',\r\n to: user.email,\r\n code: 'NETWORK_ERROR',\r\n message: error instanceof Error ? error.message : String(error),\r\n })\r\n }\r\n })\r\n}\r\n","import type { BetterAuthPlugin } from 'better-auth'\r\n\r\nimport { eusendAuthEmails } from './emails'\r\nimport type { EusendAuthEmailOptions, EusendClientOptions } from './emails'\r\nimport { createContactSync } from './sync'\r\nimport type { ContactSyncOptions, SyncedUser } from './sync'\r\n\r\nexport { eusendAuthEmails } from './emails'\r\nexport type {\r\n EmailCategory,\r\n EusendAuthEmailOptions,\r\n EusendAuthEmails,\r\n EusendClientOptions,\r\n EusendSendError,\r\n InvitationData,\r\n} from './emails'\r\nexport { createContactSync, splitName } from './sync'\r\nexport type { ContactSyncOptions, SyncedUser } from './sync'\r\nexport { defaultTemplates, escapeHtml, renderLayout, safeUrl } from './templates'\r\nexport type { Brand, OtpType, RenderedEmail, TemplateContext, Templates } from './templates'\r\n\r\nexport type EusendPluginOptions = EusendClientOptions & {\r\n /** Send Better Auth's verification, password-reset and password-changed emails. Omit to leave them to you. */\r\n email?: Omit<EusendAuthEmailOptions, keyof EusendClientOptions>\r\n /** Add users to an eusend audience once their email is verified. Omit to turn syncing off. */\r\n sync?: Omit<ContactSyncOptions, keyof EusendClientOptions>\r\n}\r\n\r\n/**\r\n * Better Auth plugin for [eusend](https://eusend.dev), EU-native transactional email.\r\n *\r\n * Everything it sets is a default. Better Auth merges plugin options underneath the app's\r\n * own, so a `sendVerificationEmail` you already wrote keeps working and this plugin only\r\n * fills the gaps. Adding it cannot switch on password sign-in.\r\n */\r\nexport const eusend = (options: EusendPluginOptions = {}) => {\r\n const { email, sync: syncOptions, ...client } = options\r\n const emails = email ? eusendAuthEmails({ ...client, ...email }) : null\r\n const sync = syncOptions ? createContactSync({ ...client, ...syncOptions }) : null\r\n\r\n // Better Auth hands the same context object to an update's `before` and `after` hooks,\r\n // which is how the update that sets `emailVerified` is told apart from a later name change.\r\n const verifying = new WeakSet<object>()\r\n\r\n return {\r\n id: 'eusend',\r\n init() {\r\n return {\r\n options: {\r\n ...(emails\r\n ? {\r\n emailVerification: { sendVerificationEmail: emails.verification },\r\n emailAndPassword: {\r\n // Required by the type. `false` is Better Auth's own default, and the app's\r\n // `enabled: true` wins the merge, so this can never switch password sign-in on.\r\n enabled: false,\r\n sendResetPassword: emails.resetPassword,\r\n onPasswordReset: emails.passwordChanged,\r\n },\r\n }\r\n : {}),\r\n // Database hooks, not endpoint hooks: they fire for every way a user is created or\r\n // verified — email and password, OAuth, magic link, OTP, admin — and they run\r\n // alongside the app's own database hooks rather than replacing them.\r\n ...(sync\r\n ? {\r\n databaseHooks: {\r\n user: {\r\n create: {\r\n // OAuth users arrive already verified; everyone else waits for the update below.\r\n after: async (user: SyncedUser) => {\r\n await sync(user)\r\n },\r\n },\r\n update: {\r\n before: async (data: Partial<SyncedUser>, context: object | null) => {\r\n if (data.emailVerified === true && context) verifying.add(context)\r\n },\r\n after: async (user: SyncedUser, context: object | null) => {\r\n // Without a request context the two hooks cannot be paired, so a bare update\r\n // is never treated as a verification. Mailing someone who never confirmed\r\n // their address is a worse mistake than missing a sync.\r\n if (context && verifying.delete(context)) await sync(user)\r\n },\r\n },\r\n },\r\n },\r\n }\r\n : {}),\r\n },\r\n }\r\n },\r\n } satisfies BetterAuthPlugin\r\n}\r\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@eusend_dev/better-auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Better Auth plugin for eusend — verification, password-reset, OTP, magic-link and invitation email from the EU, plus contact sync for verified users.",
|
|
5
|
+
"author": "Eusend",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/eusend-dev/eusend-better-auth.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://eusend.dev/docs/integrations/better-auth",
|
|
12
|
+
"bugs": "https://github.com/eusend-dev/eusend-better-auth/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"better-auth",
|
|
15
|
+
"better-auth-plugin",
|
|
16
|
+
"auth",
|
|
17
|
+
"email",
|
|
18
|
+
"eusend",
|
|
19
|
+
"transactional-email",
|
|
20
|
+
"gdpr"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": {
|
|
26
|
+
"types": "./dist/index.d.mts",
|
|
27
|
+
"default": "./dist/index.mjs"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsdown src/index.ts --format esm --dts",
|
|
42
|
+
"dev": "tsdown src/index.ts --format esm --dts --watch",
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"test": "bun test"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@eusend_dev/sdk": "^0.18.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"better-auth": ">=1.6.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/bun": "^1.4.2",
|
|
54
|
+
"@types/node": "^26.5.0",
|
|
55
|
+
"better-auth": "^1.6.23",
|
|
56
|
+
"tsdown": "^0.22.14",
|
|
57
|
+
"typescript": "^5.9.3"
|
|
58
|
+
}
|
|
59
|
+
}
|