@ultimat3/mail 1.0.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 +120 -0
- package/package.json +39 -0
- package/src/base64.ts +9 -0
- package/src/blocks.d.ts +47 -0
- package/src/blocks.d.ts.map +1 -0
- package/src/blocks.js +23 -0
- package/src/blocks.js.map +1 -0
- package/src/blocks.ts +56 -0
- package/src/catalog.d.ts +6 -0
- package/src/catalog.d.ts.map +1 -0
- package/src/catalog.js +78 -0
- package/src/catalog.js.map +1 -0
- package/src/catalog.ts +88 -0
- package/src/driver-env.ts +105 -0
- package/src/driver-resend.ts +184 -0
- package/src/driver-smtp.ts +209 -0
- package/src/driver.d.ts +79 -0
- package/src/driver.d.ts.map +1 -0
- package/src/driver.js +115 -0
- package/src/driver.js.map +1 -0
- package/src/driver.ts +174 -0
- package/src/errors.d.ts +23 -0
- package/src/errors.d.ts.map +1 -0
- package/src/errors.js +82 -0
- package/src/errors.js.map +1 -0
- package/src/errors.ts +177 -0
- package/src/html.d.ts +10 -0
- package/src/html.d.ts.map +1 -0
- package/src/html.js +34 -0
- package/src/html.js.map +1 -0
- package/src/html.ts +37 -0
- package/src/idempotency.ts +61 -0
- package/src/index.d.ts +17 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +13 -0
- package/src/index.js.map +1 -0
- package/src/index.ts +110 -0
- package/src/job.d.ts +17 -0
- package/src/job.d.ts.map +1 -0
- package/src/job.js +79 -0
- package/src/job.js.map +1 -0
- package/src/job.ts +36 -0
- package/src/layout.d.ts +41 -0
- package/src/layout.d.ts.map +1 -0
- package/src/layout.js +160 -0
- package/src/layout.js.map +1 -0
- package/src/layout.ts +221 -0
- package/src/mail.d.ts +67 -0
- package/src/mail.d.ts.map +1 -0
- package/src/mail.js +119 -0
- package/src/mail.js.map +1 -0
- package/src/mail.ts +195 -0
- package/src/mime.ts +233 -0
- package/src/render.d.ts +26 -0
- package/src/render.d.ts.map +1 -0
- package/src/render.js +149 -0
- package/src/render.js.map +1 -0
- package/src/render.ts +190 -0
- package/src/smtp-client.ts +246 -0
- package/src/smtp-protocol.ts +184 -0
- package/src/smtp-socket.ts +266 -0
- package/src/templates/index.d.ts +9 -0
- package/src/templates/index.d.ts.map +1 -0
- package/src/templates/index.js +23 -0
- package/src/templates/index.js.map +1 -0
- package/src/templates/index.ts +39 -0
- package/src/templates/invite.d.ts +15 -0
- package/src/templates/invite.d.ts.map +1 -0
- package/src/templates/invite.js +25 -0
- package/src/templates/invite.js.map +1 -0
- package/src/templates/invite.ts +29 -0
- package/src/templates/mfa-enrolled.d.ts +14 -0
- package/src/templates/mfa-enrolled.d.ts.map +1 -0
- package/src/templates/mfa-enrolled.js +26 -0
- package/src/templates/mfa-enrolled.js.map +1 -0
- package/src/templates/mfa-enrolled.ts +37 -0
- package/src/templates/reset-password.d.ts +13 -0
- package/src/templates/reset-password.d.ts.map +1 -0
- package/src/templates/reset-password.js +23 -0
- package/src/templates/reset-password.js.map +1 -0
- package/src/templates/reset-password.ts +27 -0
- package/src/templates/security-alert.d.ts +16 -0
- package/src/templates/security-alert.d.ts.map +1 -0
- package/src/templates/security-alert.js +29 -0
- package/src/templates/security-alert.js.map +1 -0
- package/src/templates/security-alert.ts +36 -0
- package/src/templates/verify-email.d.ts +13 -0
- package/src/templates/verify-email.d.ts.map +1 -0
- package/src/templates/verify-email.js +23 -0
- package/src/templates/verify-email.js.map +1 -0
- package/src/templates/verify-email.ts +27 -0
- package/src/templates/welcome.d.ts +13 -0
- package/src/templates/welcome.d.ts.map +1 -0
- package/src/templates/welcome.js +20 -0
- package/src/templates/welcome.js.map +1 -0
- package/src/templates/welcome.ts +24 -0
package/src/driver.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Single responsibility: the transport seam. One `MailDriver` interface, the two local
|
|
2
|
+
// implementations (memory + log), and a module-level ambient driver so `send()` never knows
|
|
3
|
+
// which one is installed. The two production transports live in `driver-smtp.ts` and
|
|
4
|
+
// `driver-resend.ts`; swapping one for the other is an `app.config.ts` line and zero template
|
|
5
|
+
// changes.
|
|
6
|
+
|
|
7
|
+
import { nanoid, logger as rootLogger } from '@ultimat3/core';
|
|
8
|
+
import { driverUnavailable } from './errors';
|
|
9
|
+
import { mailIdempotencyKey } from './idempotency';
|
|
10
|
+
|
|
11
|
+
/** The rendered envelope. Everything a transport needs; nothing it does not. */
|
|
12
|
+
export interface MailMessage {
|
|
13
|
+
readonly mailId: string;
|
|
14
|
+
readonly to: readonly string[];
|
|
15
|
+
readonly subject: string;
|
|
16
|
+
readonly html: string;
|
|
17
|
+
readonly text: string;
|
|
18
|
+
readonly locale: string;
|
|
19
|
+
readonly tz: string;
|
|
20
|
+
readonly replyTo?: string | undefined;
|
|
21
|
+
readonly cc?: readonly string[] | undefined;
|
|
22
|
+
readonly bcc?: readonly string[] | undefined;
|
|
23
|
+
readonly unsubscribeUrl?: string | undefined;
|
|
24
|
+
readonly idempotencyKey?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SendResult {
|
|
28
|
+
/** Provider message id, or a local id for the memory/log drivers. */
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly driver: string;
|
|
31
|
+
readonly accepted: readonly string[];
|
|
32
|
+
/** True when the message was handed to the queue instead of a transport. */
|
|
33
|
+
readonly queued: boolean;
|
|
34
|
+
readonly idempotencyKey: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MailDriver {
|
|
38
|
+
readonly name: string;
|
|
39
|
+
send(message: MailMessage): Promise<SendResult>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* RFC 8058 one-click unsubscribe. Gmail and Yahoo require it for bulk senders and
|
|
44
|
+
* reward it for transactional ones, so it is computed here rather than per driver.
|
|
45
|
+
*/
|
|
46
|
+
export function messageHeaders(message: MailMessage): Readonly<Record<string, string>> {
|
|
47
|
+
const headers: Record<string, string> = { 'Auto-Submitted': 'auto-generated' };
|
|
48
|
+
if (message.replyTo !== undefined) headers['Reply-To'] = message.replyTo;
|
|
49
|
+
if (message.unsubscribeUrl !== undefined) {
|
|
50
|
+
headers['List-Unsubscribe'] = `<${message.unsubscribeUrl}>`;
|
|
51
|
+
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
|
|
52
|
+
}
|
|
53
|
+
return headers;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every address the envelope is delivered to. `Bcc` is one of them and never a header —
|
|
58
|
+
* an SMTP `RCPT TO` carries it, and putting it in the message would leak the blind list.
|
|
59
|
+
*/
|
|
60
|
+
export function envelopeRecipients(message: MailMessage): readonly string[] {
|
|
61
|
+
return [...message.to, ...(message.cc ?? []), ...(message.bcc ?? [])];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The shared `SendResult` shape, so a transport reports acceptance identically to every other. */
|
|
65
|
+
export function resultFor(driver: string, message: MailMessage, id: string): SendResult {
|
|
66
|
+
return {
|
|
67
|
+
id,
|
|
68
|
+
driver,
|
|
69
|
+
accepted: envelopeRecipients(message),
|
|
70
|
+
queued: false,
|
|
71
|
+
// The content-derived key, not the provider's id: it is the same across every attempt of the
|
|
72
|
+
// same send, which is what a caller deduping its own retries needs it to be.
|
|
73
|
+
idempotencyKey: mailIdempotencyKey(message),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SentMail {
|
|
78
|
+
readonly at: Date;
|
|
79
|
+
readonly message: MailMessage;
|
|
80
|
+
readonly result: SendResult;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Dev + test driver. Retains every message it accepts and exposes them, which is what the
|
|
85
|
+
* `/_x` mail panel reads to show the last mails rendered by the running app — a real inbox
|
|
86
|
+
* is not part of the local loop.
|
|
87
|
+
*/
|
|
88
|
+
export interface MemoryMailDriver extends MailDriver {
|
|
89
|
+
readonly name: 'memory';
|
|
90
|
+
readonly sent: readonly SentMail[];
|
|
91
|
+
/** Newest first, so the panel and assertions do not index backwards. */
|
|
92
|
+
outbox(): readonly SentMail[];
|
|
93
|
+
lastTo(address: string): SentMail | undefined;
|
|
94
|
+
clear(): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createMemoryDriver(): MemoryMailDriver {
|
|
98
|
+
const sent: SentMail[] = [];
|
|
99
|
+
return {
|
|
100
|
+
name: 'memory',
|
|
101
|
+
sent,
|
|
102
|
+
send(message: MailMessage): Promise<SendResult> {
|
|
103
|
+
const result = resultFor('memory', message, `mem_${nanoid(12)}`);
|
|
104
|
+
sent.push({ at: new Date(), message, result });
|
|
105
|
+
return Promise.resolve(result);
|
|
106
|
+
},
|
|
107
|
+
outbox: () => [...sent].reverse(),
|
|
108
|
+
lastTo: (address) => [...sent].reverse().find((entry) => entry.message.to.includes(address)),
|
|
109
|
+
clear: () => {
|
|
110
|
+
sent.length = 0;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether this driver caught the message instead of sending it. A host asks before reading
|
|
117
|
+
* `outbox()` — the `/_x` mail panel exists only when nothing was actually delivered, and a real
|
|
118
|
+
* transport has no record to show. Every member the interface promises is checked, not just the
|
|
119
|
+
* one a caller happens to reach first: the predicate hands back a `MemoryMailDriver`, so a
|
|
120
|
+
* look-alike that passed on `name` + `outbox()` alone would make `sent`, `lastTo()` and `clear()`
|
|
121
|
+
* a compile-time promise the object cannot keep.
|
|
122
|
+
*/
|
|
123
|
+
export function isMemoryDriver(driver: MailDriver): driver is MemoryMailDriver {
|
|
124
|
+
if (driver.name !== 'memory') return false;
|
|
125
|
+
const candidate = driver as MemoryMailDriver;
|
|
126
|
+
return (
|
|
127
|
+
Array.isArray(candidate.sent) &&
|
|
128
|
+
typeof candidate.outbox === 'function' &&
|
|
129
|
+
typeof candidate.lastTo === 'function' &&
|
|
130
|
+
typeof candidate.clear === 'function'
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Structured log line per message through core's `logger` — the default for a worker that
|
|
136
|
+
* has no credentials yet. Bodies are never logged; a mail body is user data.
|
|
137
|
+
*/
|
|
138
|
+
export function createLogDriver(logger = rootLogger): MailDriver {
|
|
139
|
+
return {
|
|
140
|
+
name: 'log',
|
|
141
|
+
send(message: MailMessage): Promise<SendResult> {
|
|
142
|
+
const result = resultFor('log', message, `log_${nanoid(12)}`);
|
|
143
|
+
logger.info('mail.send', {
|
|
144
|
+
mailId: message.mailId,
|
|
145
|
+
to: message.to.length,
|
|
146
|
+
subject: message.subject,
|
|
147
|
+
locale: message.locale,
|
|
148
|
+
idempotencyKey: result.idempotencyKey,
|
|
149
|
+
});
|
|
150
|
+
return Promise.resolve(result);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let ambient: MailDriver | undefined;
|
|
156
|
+
|
|
157
|
+
/** Set once at boot from `app.config.ts`. One driver per process, like the job driver. */
|
|
158
|
+
export function setMailDriver(driver: MailDriver): void {
|
|
159
|
+
ambient = driver;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function tryMailDriver(): MailDriver | undefined {
|
|
163
|
+
return ambient;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Throws `X_MAIL_DRIVER_UNAVAILABLE` rather than silently dropping mail on the floor. */
|
|
167
|
+
export function mailDriver(): MailDriver {
|
|
168
|
+
if (ambient === undefined) throw driverUnavailable('setMailDriver() was never called');
|
|
169
|
+
return ambient;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function resetMailDriver(): void {
|
|
173
|
+
ambient = undefined;
|
|
174
|
+
}
|
package/src/errors.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { UltimateError } from '@ultimat3/core';
|
|
2
|
+
export declare const MAIL_ERROR_CODES: readonly ['X_MAIL_LOCALE_MISSING', 'X_MAIL_TEMPLATE_UNKNOWN', 'X_MAIL_DUPLICATE', 'X_MAIL_TEXT_MISSING', 'X_MAIL_DRIVER_UNAVAILABLE', 'X_NOT_IMPLEMENTED'];
|
|
3
|
+
export type MailErrorCode = (typeof MAIL_ERROR_CODES)[number];
|
|
4
|
+
export declare const MAIL_ERROR_TITLES: Readonly<Record<MailErrorCode, string>>;
|
|
5
|
+
export interface MailErrorInit {
|
|
6
|
+
readonly code: MailErrorCode;
|
|
7
|
+
readonly cause: string;
|
|
8
|
+
readonly fix: string;
|
|
9
|
+
readonly meta?: Readonly<Record<string, unknown>> | undefined;
|
|
10
|
+
}
|
|
11
|
+
export declare class MailError extends UltimateError {
|
|
12
|
+
readonly name = "MailError";
|
|
13
|
+
constructor(init: MailErrorInit);
|
|
14
|
+
}
|
|
15
|
+
export declare const localeMissing: (mailId: string) => MailError;
|
|
16
|
+
export declare const templateUnknown: (mailId: string, known: readonly string[]) => MailError;
|
|
17
|
+
export declare const layoutUnknown: (mailId: string, layout: string, known: readonly string[]) => MailError;
|
|
18
|
+
export declare const mailDuplicate: (mailId: string) => MailError;
|
|
19
|
+
export declare const textMissing: (mailId: string) => MailError;
|
|
20
|
+
export declare const driverUnavailable: (what: string) => MailError;
|
|
21
|
+
/** The blessed shape for a transport that exists as an interface but has no wire yet. */
|
|
22
|
+
export declare const transportNotImplemented: (driver: string, fix: string) => MailError;
|
|
23
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["errors.ts"],"names":[],"mappings":"AAIA,OAAO,EAAoC,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEjF,eAAO,MAAM,gBAAgB,YAC3B,uBAAuB,EACvB,yBAAyB,EACzB,kBAAkB,EAClB,qBAAqB,EACrB,2BAA2B,EAC3B,mBAAmB,CACX,CAAC;AAEX,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE9D,eAAO,MAAM,iBAAiB,EAAE,QAAQ,CAAC,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,CAOrE,CAAC;AAQF,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC;CAC/D;AAED,qBAAa,SAAU,SAAQ,aAAa;IAC1C,SAAkB,IAAI,eAAe;IAErC,YAAY,IAAI,EAAE,aAAa,EAQ9B;CACF;AAED,eAAO,MAAM,aAAa,WAAY,MAAM,KAAG,SAM3C,CAAC;AAEL,eAAO,MAAM,eAAe,WAAY,MAAM,SAAS,SAAS,MAAM,EAAE,KAAG,SAMvE,CAAC;AAEL,eAAO,MAAM,aAAa,WAChB,MAAM,UACN,MAAM,SACP,SAAS,MAAM,EAAE,KACvB,SAMC,CAAC;AAEL,eAAO,MAAM,aAAa,WAAY,MAAM,KAAG,SAM3C,CAAC;AAEL,eAAO,MAAM,WAAW,WAAY,MAAM,KAAG,SAMzC,CAAC;AAEL,eAAO,MAAM,iBAAiB,SAAU,MAAM,KAAG,SAM7C,CAAC;AAEL,yFAAyF;AACzF,eAAO,MAAM,uBAAuB,WAAY,MAAM,OAAO,MAAM,KAAG,SAMlE,CAAC"}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Single responsibility: the mail package's stable error codes and their `fix:` lines.
|
|
2
|
+
// Mail fails in production, not in tests — a wrong locale, an empty text part or an
|
|
3
|
+
// unconfigured driver must name the exact call site edit that repairs it.
|
|
4
|
+
import { hasErrorCode, registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
5
|
+
export const MAIL_ERROR_CODES = [
|
|
6
|
+
'X_MAIL_LOCALE_MISSING',
|
|
7
|
+
'X_MAIL_TEMPLATE_UNKNOWN',
|
|
8
|
+
'X_MAIL_DUPLICATE',
|
|
9
|
+
'X_MAIL_TEXT_MISSING',
|
|
10
|
+
'X_MAIL_DRIVER_UNAVAILABLE',
|
|
11
|
+
'X_NOT_IMPLEMENTED',
|
|
12
|
+
];
|
|
13
|
+
export const MAIL_ERROR_TITLES = {
|
|
14
|
+
X_MAIL_LOCALE_MISSING: 'send() was called without a locale',
|
|
15
|
+
X_MAIL_TEMPLATE_UNKNOWN: 'no mail is registered under that id',
|
|
16
|
+
X_MAIL_DUPLICATE: 'two mails claim the same id',
|
|
17
|
+
X_MAIL_TEXT_MISSING: 'the rendered mail has no plain-text part',
|
|
18
|
+
X_MAIL_DRIVER_UNAVAILABLE: 'no mail driver is configured',
|
|
19
|
+
X_NOT_IMPLEMENTED: 'this driver has no transport yet',
|
|
20
|
+
};
|
|
21
|
+
// Titles must be registered for `format()` to render the contract's first line, but
|
|
22
|
+
// `X_NOT_IMPLEMENTED` belongs to core — registering a code twice throws X_ERROR_CODE_DUPLICATE.
|
|
23
|
+
for (const [code, title] of Object.entries(MAIL_ERROR_TITLES)) {
|
|
24
|
+
if (!hasErrorCode(code))
|
|
25
|
+
registerErrorCodes({ [code]: { title } });
|
|
26
|
+
}
|
|
27
|
+
export class MailError extends UltimateError {
|
|
28
|
+
name = 'MailError';
|
|
29
|
+
constructor(init) {
|
|
30
|
+
super({
|
|
31
|
+
code: init.code,
|
|
32
|
+
cause: init.cause,
|
|
33
|
+
fix: init.fix,
|
|
34
|
+
docs: `https://ultimate.dev/errors/${init.code}`,
|
|
35
|
+
meta: init.meta,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export const localeMissing = (mailId) => new MailError({
|
|
40
|
+
code: 'X_MAIL_LOCALE_MISSING',
|
|
41
|
+
cause: `send(${mailId}, data, options) got no locale — the recipient's language is unknown`,
|
|
42
|
+
fix: `pass it: send(${mailId}, data, { to, locale: ctx.locale })`,
|
|
43
|
+
meta: { mailId },
|
|
44
|
+
});
|
|
45
|
+
export const templateUnknown = (mailId, known) => new MailError({
|
|
46
|
+
code: 'X_MAIL_TEMPLATE_UNKNOWN',
|
|
47
|
+
cause: `no mail with id "${mailId}" is registered (have: ${known.join(', ') || 'none'})`,
|
|
48
|
+
fix: `x mail list --json # then export defineMail({ id: '${mailId}', ... }) and import it`,
|
|
49
|
+
meta: { mailId, known },
|
|
50
|
+
});
|
|
51
|
+
export const layoutUnknown = (mailId, layout, known) => new MailError({
|
|
52
|
+
code: 'X_MAIL_TEMPLATE_UNKNOWN',
|
|
53
|
+
cause: `mail "${mailId}" wants layout "${layout}" (registered: ${known.join(', ')})`,
|
|
54
|
+
fix: `registerLayout('${layout}', myLayout) at boot, or set layout: 'base' on that mail`,
|
|
55
|
+
meta: { mailId, layout, known },
|
|
56
|
+
});
|
|
57
|
+
export const mailDuplicate = (mailId) => new MailError({
|
|
58
|
+
code: 'X_MAIL_DUPLICATE',
|
|
59
|
+
cause: `mail id "${mailId}" is already registered by another defineMail() call`,
|
|
60
|
+
fix: `x mail list --json # then rename one of the two defineMail({ id }) declarations`,
|
|
61
|
+
meta: { mailId },
|
|
62
|
+
});
|
|
63
|
+
export const textMissing = (mailId) => new MailError({
|
|
64
|
+
code: 'X_MAIL_TEXT_MISSING',
|
|
65
|
+
cause: `mail "${mailId}" rendered an empty text part (HTML-only mail scores as spam)`,
|
|
66
|
+
fix: `add a text block to the "${mailId}" template: blocks.paragraph('mail.${mailId}.body')`,
|
|
67
|
+
meta: { mailId },
|
|
68
|
+
});
|
|
69
|
+
export const driverUnavailable = (what) => new MailError({
|
|
70
|
+
code: 'X_MAIL_DRIVER_UNAVAILABLE',
|
|
71
|
+
cause: `${what} — nothing can deliver the message`,
|
|
72
|
+
fix: 'setMailDriver(createMemoryDriver()) in dev, createSmtpDriver({ url: env.SMTP_URL }) live',
|
|
73
|
+
meta: { what },
|
|
74
|
+
});
|
|
75
|
+
/** The blessed shape for a transport that exists as an interface but has no wire yet. */
|
|
76
|
+
export const transportNotImplemented = (driver, fix) => new MailError({
|
|
77
|
+
code: 'X_NOT_IMPLEMENTED',
|
|
78
|
+
cause: `the ${driver} mail driver has no transport in this build`,
|
|
79
|
+
fix,
|
|
80
|
+
meta: { driver },
|
|
81
|
+
});
|
|
82
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["errors.ts"],"names":[],"mappings":"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"}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Single responsibility: the mail package's stable error codes and their `fix:` lines.
|
|
2
|
+
// Mail fails in production, not in tests — a wrong locale, an empty text part or an
|
|
3
|
+
// unconfigured driver must name the exact call site edit that repairs it.
|
|
4
|
+
|
|
5
|
+
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
export const MAIL_ERROR_CODES = [
|
|
8
|
+
'X_MAIL_LOCALE_MISSING',
|
|
9
|
+
'X_MAIL_TEMPLATE_UNKNOWN',
|
|
10
|
+
'X_MAIL_DUPLICATE',
|
|
11
|
+
'X_MAIL_TEXT_MISSING',
|
|
12
|
+
'X_MAIL_DRIVER_UNAVAILABLE',
|
|
13
|
+
'X_MAIL_HEADER_INVALID',
|
|
14
|
+
'X_MAIL_SEND_FAILED',
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type MailErrorCode = (typeof MAIL_ERROR_CODES)[number];
|
|
18
|
+
|
|
19
|
+
export const MAIL_ERROR_TITLES: Readonly<Record<MailErrorCode, string>> = {
|
|
20
|
+
X_MAIL_LOCALE_MISSING: 'send() was called without a locale',
|
|
21
|
+
X_MAIL_TEMPLATE_UNKNOWN: 'no mail is registered under that id',
|
|
22
|
+
X_MAIL_DUPLICATE: 'two mails claim the same id',
|
|
23
|
+
X_MAIL_TEXT_MISSING: 'the rendered mail has no plain-text part',
|
|
24
|
+
X_MAIL_DRIVER_UNAVAILABLE: 'no mail driver is configured',
|
|
25
|
+
X_MAIL_HEADER_INVALID: 'a header value carries a line break',
|
|
26
|
+
X_MAIL_SEND_FAILED: 'the mail transport refused the message',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
30
|
+
// owned here and none is borrowed, so the call is unconditional: a second package claiming one has
|
|
31
|
+
// to fail as X_ERROR_CODE_DUPLICATE, not quietly keep whichever title was registered first.
|
|
32
|
+
registerErrorCodes(
|
|
33
|
+
Object.fromEntries(Object.entries(MAIL_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export interface MailErrorInit {
|
|
37
|
+
readonly code: MailErrorCode;
|
|
38
|
+
readonly cause: string;
|
|
39
|
+
readonly fix: string;
|
|
40
|
+
readonly meta?: Readonly<Record<string, unknown>> | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class MailError extends UltimateError {
|
|
44
|
+
override readonly name = 'MailError';
|
|
45
|
+
|
|
46
|
+
constructor(init: MailErrorInit) {
|
|
47
|
+
super({
|
|
48
|
+
code: init.code,
|
|
49
|
+
cause: init.cause,
|
|
50
|
+
fix: init.fix,
|
|
51
|
+
docs: `https://ultimate.dev/errors/${init.code}`,
|
|
52
|
+
meta: init.meta,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const localeMissing = (mailId: string): MailError =>
|
|
58
|
+
new MailError({
|
|
59
|
+
code: 'X_MAIL_LOCALE_MISSING',
|
|
60
|
+
cause: `send(${mailId}, data, options) got no locale — the recipient's language is unknown`,
|
|
61
|
+
fix: `pass it: send(${mailId}, data, { to, locale: ctx.locale })`,
|
|
62
|
+
meta: { mailId },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const templateUnknown = (mailId: string, known: readonly string[]): MailError =>
|
|
66
|
+
new MailError({
|
|
67
|
+
code: 'X_MAIL_TEMPLATE_UNKNOWN',
|
|
68
|
+
cause: `no mail with id "${mailId}" is registered (have: ${known.join(', ') || 'none'})`,
|
|
69
|
+
fix: `x mail list --json # then export defineMail({ id: '${mailId}', ... }) and import it`,
|
|
70
|
+
meta: { mailId, known },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export const layoutUnknown = (
|
|
74
|
+
mailId: string,
|
|
75
|
+
layout: string,
|
|
76
|
+
known: readonly string[],
|
|
77
|
+
): MailError =>
|
|
78
|
+
new MailError({
|
|
79
|
+
code: 'X_MAIL_TEMPLATE_UNKNOWN',
|
|
80
|
+
cause: `mail "${mailId}" wants layout "${layout}" (registered: ${known.join(', ')})`,
|
|
81
|
+
fix: `registerLayout('${layout}', myLayout) at boot, or set layout: 'base' on that mail`,
|
|
82
|
+
meta: { mailId, layout, known },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export const mailDuplicate = (mailId: string): MailError =>
|
|
86
|
+
new MailError({
|
|
87
|
+
code: 'X_MAIL_DUPLICATE',
|
|
88
|
+
cause: `mail id "${mailId}" is already registered by another defineMail() call`,
|
|
89
|
+
fix: `x mail list --json # then rename one of the two defineMail({ id }) declarations`,
|
|
90
|
+
meta: { mailId },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export const textMissing = (mailId: string): MailError =>
|
|
94
|
+
new MailError({
|
|
95
|
+
code: 'X_MAIL_TEXT_MISSING',
|
|
96
|
+
cause: `mail "${mailId}" rendered an empty text part (HTML-only mail scores as spam)`,
|
|
97
|
+
fix: `add a text block to the "${mailId}" template: blocks.paragraph('mail.${mailId}.body')`,
|
|
98
|
+
meta: { mailId },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export const driverUnavailable = (what: string): MailError =>
|
|
102
|
+
new MailError({
|
|
103
|
+
code: 'X_MAIL_DRIVER_UNAVAILABLE',
|
|
104
|
+
cause: `${what} — nothing can deliver the message`,
|
|
105
|
+
fix: 'setMailDriver(createMemoryDriver()) in dev, createSmtpDriver({ url: env.SMTP_URL }) live',
|
|
106
|
+
meta: { what },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A CR or LF inside a header value ends the header early and lets whatever follows become new
|
|
111
|
+
* headers — the recipient list, a forged `From`. Interpolated data reaches `Subject`, so this is
|
|
112
|
+
* refused rather than stripped: silently rewriting a subject is its own surprise.
|
|
113
|
+
*/
|
|
114
|
+
export const headerInvalid = (name: string, mailId: string): MailError =>
|
|
115
|
+
new MailError({
|
|
116
|
+
code: 'X_MAIL_HEADER_INVALID',
|
|
117
|
+
cause: `the "${name}" header of mail "${mailId}" contains a CR or LF, which would inject headers`,
|
|
118
|
+
fix:
|
|
119
|
+
`strip line breaks from the value before it reaches the header: ` +
|
|
120
|
+
`t('mail.${mailId}.subject', { ...data, x: String(x).replace(/[\\r\\n]+/g, ' ') })`,
|
|
121
|
+
meta: { header: name, mailId },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Every step a send can die at. A closed union rather than a `string`: the stage is keyed on by
|
|
126
|
+
* the transports' `fix` tables and asserted on in tests, so a typo has to be a compile error
|
|
127
|
+
* instead of an unmapped fix line nobody notices until it reaches an operator.
|
|
128
|
+
*/
|
|
129
|
+
export type SendStage =
|
|
130
|
+
// The SMTP conversation, in the order it happens. `tls` is the implicit handshake `smtps://`
|
|
131
|
+
// opens with, before any SMTP byte; `starttls` is the in-band upgrade of a plaintext connection.
|
|
132
|
+
| 'connect'
|
|
133
|
+
| 'tls'
|
|
134
|
+
| 'greeting'
|
|
135
|
+
| 'ehlo'
|
|
136
|
+
| 'starttls'
|
|
137
|
+
| 'auth'
|
|
138
|
+
| 'from'
|
|
139
|
+
| 'recipient'
|
|
140
|
+
| 'data'
|
|
141
|
+
| 'quit'
|
|
142
|
+
// Reply framing, which can break at any of the steps above: bytes that never complete a reply.
|
|
143
|
+
| 'reply'
|
|
144
|
+
// The HTTPS transports: one request, so one stage.
|
|
145
|
+
| 'request';
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What a transport reports when the remote end did not take the message. `stage` names the
|
|
149
|
+
* exact step of the conversation, and `status` is the provider's own number — an SMTP reply
|
|
150
|
+
* code or an HTTP status — because "mail failed" without either is not a diagnosis.
|
|
151
|
+
*/
|
|
152
|
+
export interface SendFailure {
|
|
153
|
+
readonly driver: 'smtp' | 'resend';
|
|
154
|
+
readonly stage: SendStage;
|
|
155
|
+
readonly detail: string;
|
|
156
|
+
/** SMTP reply code (4xx/5xx) or HTTP status. Absent when the socket never answered. */
|
|
157
|
+
readonly status?: number | undefined;
|
|
158
|
+
/** True for a 4xx greylist/throttle: the job's next attempt can succeed unchanged. */
|
|
159
|
+
readonly retryable: boolean;
|
|
160
|
+
readonly fix: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const sendFailed = (failure: SendFailure): MailError =>
|
|
164
|
+
new MailError({
|
|
165
|
+
code: 'X_MAIL_SEND_FAILED',
|
|
166
|
+
cause:
|
|
167
|
+
`${failure.driver} refused the message at ${failure.stage}` +
|
|
168
|
+
`${failure.status === undefined ? '' : ` (${failure.status})`}: ${failure.detail} — ` +
|
|
169
|
+
`${failure.retryable ? 'transient, the job will retry' : 'permanent, retrying cannot help'}`,
|
|
170
|
+
fix: failure.fix,
|
|
171
|
+
meta: {
|
|
172
|
+
driver: failure.driver,
|
|
173
|
+
stage: failure.stage,
|
|
174
|
+
retryable: failure.retryable,
|
|
175
|
+
...(failure.status === undefined ? {} : { status: failure.status }),
|
|
176
|
+
},
|
|
177
|
+
});
|
package/src/html.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Escape text content AND attribute values — the same set covers both in mail. */
|
|
2
|
+
export declare function escapeHtml(value: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* `javascript:` and `data:` hrefs are stripped rather than escaped: some clients still
|
|
5
|
+
* follow them, and a link the recipient cannot trust is worse than a dead one.
|
|
6
|
+
*/
|
|
7
|
+
export declare function safeUrl(value: string): string;
|
|
8
|
+
/** `style="a:b;c:d"` from declarations, so no call site hand-builds a style attribute. */
|
|
9
|
+
export declare function styleAttr(declarations: readonly string[]): string;
|
|
10
|
+
//# sourceMappingURL=html.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"html.d.ts","sourceRoot":"","sources":["html.ts"],"names":[],"mappings":"AAYA,mFAAmF;AACnF,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhD;AAID;;;GAGG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQ7C;AAED,0FAA0F;AAC1F,wBAAgB,SAAS,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAEjE"}
|
package/src/html.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Single responsibility: turning untrusted values into inert HTML. Mail bodies interpolate
|
|
2
|
+
// user-supplied names, org names and URLs; an unescaped one is a phishing vector that also
|
|
3
|
+
// renders in the recipient's client forever, so escaping lives in exactly one place.
|
|
4
|
+
const ESCAPES = {
|
|
5
|
+
'&': '&',
|
|
6
|
+
'<': '<',
|
|
7
|
+
'>': '>',
|
|
8
|
+
'"': '"',
|
|
9
|
+
"'": ''',
|
|
10
|
+
};
|
|
11
|
+
/** Escape text content AND attribute values — the same set covers both in mail. */
|
|
12
|
+
export function escapeHtml(value) {
|
|
13
|
+
return value.replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
|
|
14
|
+
}
|
|
15
|
+
const SAFE_PROTOCOLS = ['http:', 'https:', 'mailto:'];
|
|
16
|
+
/**
|
|
17
|
+
* `javascript:` and `data:` hrefs are stripped rather than escaped: some clients still
|
|
18
|
+
* follow them, and a link the recipient cannot trust is worse than a dead one.
|
|
19
|
+
*/
|
|
20
|
+
export function safeUrl(value) {
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = new URL(value);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return '#';
|
|
27
|
+
}
|
|
28
|
+
return SAFE_PROTOCOLS.includes(parsed.protocol) ? parsed.href : '#';
|
|
29
|
+
}
|
|
30
|
+
/** `style="a:b;c:d"` from declarations, so no call site hand-builds a style attribute. */
|
|
31
|
+
export function styleAttr(declarations) {
|
|
32
|
+
return `style="${escapeHtml(declarations.join(';'))}"`;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=html.js.map
|
package/src/html.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"html.js","sourceRoot":"","sources":["html.ts"],"names":[],"mappings":"AAAA,2FAA2F;AAC3F,2FAA2F;AAC3F,qFAAqF;AAErF,MAAM,OAAO,GAAqC;IAChD,GAAG,EAAE,OAAO;IACZ,GAAG,EAAE,MAAM;IACX,GAAG,EAAE,MAAM;IACX,GAAG,EAAE,QAAQ;IACb,GAAG,EAAE,OAAO;CACb,CAAC;AAEF,mFAAmF;AACnF,MAAM,UAAU,UAAU,CAAC,KAAa;IACtC,OAAO,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,CAAC;AACpE,CAAC;AAED,MAAM,cAAc,GAAsB,CAAC,OAAO,EAAE,QAAQ,EAAE,SAAS,CAAC,CAAC;AAEzE;;;GAGG;AACH,MAAM,UAAU,OAAO,CAAC,KAAa;IACnC,IAAI,MAAW,CAAC;IAChB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;IAC1B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,GAAG,CAAC;IACb,CAAC;IACD,OAAO,cAAc,CAAC,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC;AACtE,CAAC;AAED,0FAA0F;AAC1F,MAAM,UAAU,SAAS,CAAC,YAA+B;IACvD,OAAO,UAAU,UAAU,CAAC,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC;AACzD,CAAC"}
|
package/src/html.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Single responsibility: turning untrusted values into inert HTML. Mail bodies interpolate
|
|
2
|
+
// user-supplied names, org names and URLs; an unescaped one is a phishing vector that also
|
|
3
|
+
// renders in the recipient's client forever, so escaping lives in exactly one place.
|
|
4
|
+
|
|
5
|
+
const ESCAPES: Readonly<Record<string, string>> = {
|
|
6
|
+
'&': '&',
|
|
7
|
+
'<': '<',
|
|
8
|
+
'>': '>',
|
|
9
|
+
'"': '"',
|
|
10
|
+
"'": ''',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Escape text content AND attribute values — the same set covers both in mail. */
|
|
14
|
+
export function escapeHtml(value: string): string {
|
|
15
|
+
return value.replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SAFE_PROTOCOLS: readonly string[] = ['http:', 'https:', 'mailto:'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `javascript:` and `data:` hrefs are stripped rather than escaped: some clients still
|
|
22
|
+
* follow them, and a link the recipient cannot trust is worse than a dead one.
|
|
23
|
+
*/
|
|
24
|
+
export function safeUrl(value: string): string {
|
|
25
|
+
let parsed: URL;
|
|
26
|
+
try {
|
|
27
|
+
parsed = new URL(value);
|
|
28
|
+
} catch {
|
|
29
|
+
return '#';
|
|
30
|
+
}
|
|
31
|
+
return SAFE_PROTOCOLS.includes(parsed.protocol) ? parsed.href : '#';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `style="a:b;c:d"` from declarations, so no call site hand-builds a style attribute. */
|
|
35
|
+
export function styleAttr(declarations: readonly string[]): string {
|
|
36
|
+
return `style="${escapeHtml(declarations.join(';'))}"`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Single responsibility: the content-derived key that makes at-least-once delivery safe. It lives
|
|
2
|
+
// apart from `job.ts` because the transports need it too: a job retry after a timeout hands the
|
|
3
|
+
// same envelope to the provider again, and without this key on the wire that is a second email.
|
|
4
|
+
|
|
5
|
+
import type { MailMessage } from './driver';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `(mailId, recipients, hash(rendered payload))`, or the caller's key when supplied.
|
|
9
|
+
* Content-derived on purpose: a retry of the same request produces the same key, while an
|
|
10
|
+
* intentional resend with different content produces a different one.
|
|
11
|
+
*/
|
|
12
|
+
export function mailIdempotencyKey(message: MailMessage): string {
|
|
13
|
+
const explicit = message.idempotencyKey;
|
|
14
|
+
if (explicit !== undefined && explicit !== '') return `mail:${explicit}`;
|
|
15
|
+
const recipients = [...message.to].map((address) => address.toLowerCase()).sort();
|
|
16
|
+
// Every field that reaches the wire is hashed, `replyTo` included: it travels as `Reply-To` and
|
|
17
|
+
// as Resend's `reply_to`, so two mails that differ only there are two mails, and a shared key
|
|
18
|
+
// would have the provider drop the second one as a duplicate.
|
|
19
|
+
const digest = contentDigest(
|
|
20
|
+
stableStringify({
|
|
21
|
+
subject: message.subject,
|
|
22
|
+
html: message.html,
|
|
23
|
+
text: message.text,
|
|
24
|
+
cc: message.cc ?? [],
|
|
25
|
+
bcc: message.bcc ?? [],
|
|
26
|
+
locale: message.locale,
|
|
27
|
+
tz: message.tz,
|
|
28
|
+
replyTo: message.replyTo ?? '',
|
|
29
|
+
unsubscribeUrl: message.unsubscribeUrl ?? '',
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
return `mail:${message.mailId}:${recipients.join(',')}:${digest}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Key order is normalised so two structurally equal payloads hash identically. */
|
|
36
|
+
function stableStringify(value: unknown): string {
|
|
37
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
38
|
+
if (value !== null && typeof value === 'object') {
|
|
39
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
40
|
+
.filter(([, entry]) => entry !== undefined)
|
|
41
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
42
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`);
|
|
43
|
+
return `{${entries.join(',')}}`;
|
|
44
|
+
}
|
|
45
|
+
if (value === undefined) return 'null';
|
|
46
|
+
return JSON.stringify(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 128 bits of hex: no collision at any volume a mailer reaches, and short enough for a header. */
|
|
50
|
+
const DIGEST_HEX_CHARS = 32;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* SHA-256, truncated. Two properties matter and only a specified algorithm has both. Width: a
|
|
54
|
+
* 32-bit digest reaches a 1% chance of collision at ~9,300 payloads for one mailId and recipient
|
|
55
|
+
* set, and a collision here is an email the provider dedupes away — 128 bits does not get there.
|
|
56
|
+
* Stability: SHA-256's output is fixed by its specification, so a key minted by one Bun version
|
|
57
|
+
* still matches the one minted by the next — `Bun.hash`'s families promise no such thing.
|
|
58
|
+
*/
|
|
59
|
+
function contentDigest(input: string): string {
|
|
60
|
+
return new Bun.CryptoHasher('sha256').update(input).digest('hex').slice(0, DIGEST_HEX_CHARS);
|
|
61
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type { CalloutTone, MailBlock, MailTemplate, TemplateArgs } from './blocks';
|
|
2
|
+
export { blocks } from './blocks';
|
|
3
|
+
export { MAIL_CATALOG, MAIL_CATALOG_SOURCE, registerMailCatalog } from './catalog';
|
|
4
|
+
export type { MailDriver, MailMessage, MemoryMailDriver, ResendDriverOptions, SendResult, SentMail, SmtpDriverOptions, } from './driver';
|
|
5
|
+
export { createLogDriver, createMemoryDriver, createResendDriver, createSmtpDriver, mailDriver, messageHeaders, resetMailDriver, setMailDriver, tryMailDriver, } from './driver';
|
|
6
|
+
export type { MailErrorCode, MailErrorInit } from './errors';
|
|
7
|
+
export { driverUnavailable, layoutUnknown, localeMissing, MAIL_ERROR_CODES, MAIL_ERROR_TITLES, MailError, mailDuplicate, templateUnknown, textMissing, transportNotImplemented, } from './errors';
|
|
8
|
+
export { escapeHtml, safeUrl } from './html';
|
|
9
|
+
export { mailIdempotencyKey, mailMessageSchema, sendMailJob } from './job';
|
|
10
|
+
export type { ColorScheme, DarkRule, LayoutInput, MailLayout, MailToken, UnsubscribeSlot, } from './layout';
|
|
11
|
+
export { BASE_LAYOUT, baseLayout, DARK_RULES, darkModeCss, layoutFor, MAIL_FONT_STACK, MAIL_TOKENS, MAIL_WIDTH_PX, registeredLayouts, registerLayout, token, } from './layout';
|
|
12
|
+
export type { AnyMailDefinition, MailDefinition, MailInit, SendOptions } from './mail';
|
|
13
|
+
export { defineMail, mailFor, registeredMailIds, registeredMails, renderMessage, resetMails, send, sendById, } from './mail';
|
|
14
|
+
export type { RenderableMail, RenderedMail, RenderOptions } from './render';
|
|
15
|
+
export { FOOTER_KEYS, renderMail, textOf, UNSUBSCRIBE_KEY } from './render';
|
|
16
|
+
export { FRAMEWORK_MAILS, type InviteInput, inviteInput, inviteMail, MFA_METHODS, type MfaEnrolledInput, mfaEnrolledInput, mfaEnrolledMail, type ResetPasswordInput, resetPasswordInput, resetPasswordMail, type SecurityAlertInput, securityAlertInput, securityAlertMail, type VerifyEmailInput, verifyEmailInput, verifyEmailMail, type WelcomeInput, welcomeInput, welcomeMail, } from './templates';
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAGA,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACnF,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAElC,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AACnF,YAAY,EACV,UAAU,EACV,WAAW,EACX,gBAAgB,EAChB,mBAAmB,EACnB,UAAU,EACV,QAAQ,EACR,iBAAiB,GAClB,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,EAChB,UAAU,EACV,cAAc,EACd,eAAe,EACf,aAAa,EACb,aAAa,GACd,MAAM,UAAU,CAAC;AAClB,YAAY,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAC7D,OAAO,EACL,iBAAiB,EACjB,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,iBAAiB,EACjB,SAAS,EACT,aAAa,EACb,eAAe,EACf,WAAW,EACX,uBAAuB,GACxB,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAE7C,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;AAC3E,YAAY,EACV,WAAW,EACX,QAAQ,EACR,WAAW,EACX,UAAU,EACV,SAAS,EACT,eAAe,GAChB,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,WAAW,EACX,UAAU,EACV,UAAU,EACV,WAAW,EACX,SAAS,EACT,eAAe,EACf,WAAW,EACX,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,KAAK,GACN,MAAM,UAAU,CAAC;AAClB,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AACvF,OAAO,EACL,UAAU,EACV,OAAO,EACP,iBAAiB,EACjB,eAAe,EACf,aAAa,EACb,UAAU,EACV,IAAI,EACJ,QAAQ,GACT,MAAM,QAAQ,CAAC;AAChB,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAC5E,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAE5E,OAAO,EACL,eAAe,EACf,KAAK,WAAW,EAChB,WAAW,EACX,UAAU,EACV,WAAW,EACX,KAAK,gBAAgB,EACrB,gBAAgB,EAChB,eAAe,EACf,KAAK,kBAAkB,EACvB,kBAAkB,EAClB,iBAAiB,EACjB,KAAK,kBAAkB,EACvB,kBAAkB,EAClB,iBAAiB,EACjB,KAAK,gBAAgB,EACrB,gBAAgB,EAChB,eAAe,EACf,KAAK,YAAY,EACjB,YAAY,EACZ,WAAW,GACZ,MAAM,aAAa,CAAC"}
|