@awsmbiz/inbis-notifications 0.0.0-stage → 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/README.md +206 -2
- package/dist/index.d.ts +354 -0
- package/dist/index.js +644 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { AppError, isPlaceholderEmail } from "@awsmbiz/inbis-core";
|
|
2
|
+
// src/layout.ts
|
|
3
|
+
const NEUTRAL_ACCENT = "#18181b";
|
|
4
|
+
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
5
|
+
function escapeHtml(value) {
|
|
6
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
7
|
+
}
|
|
8
|
+
function httpsUrl(value) {
|
|
9
|
+
if (value === void 0) return void 0;
|
|
10
|
+
try {
|
|
11
|
+
return new URL(value).protocol === "https:" ? value : void 0;
|
|
12
|
+
} catch {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function paragraph(text, style = "margin:0 0 16px;font-size:15px;line-height:24px;color:#3f3f46") {
|
|
17
|
+
return `<p style="${style}">${escapeHtml(text)}</p>`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Renders a branded email as plain text and HTML. All text is escaped; the
|
|
21
|
+
* accent colour must be `#rrggbb` and the logo an https URL, otherwise they
|
|
22
|
+
* are left out. Table-based, inline-styled HTML for broad client support.
|
|
23
|
+
*/
|
|
24
|
+
function renderBrandedEmail(content) {
|
|
25
|
+
const { brand } = content;
|
|
26
|
+
const accent = brand.accentColor !== void 0 && HEX_COLOR.test(brand.accentColor) ? brand.accentColor : NEUTRAL_ACCENT;
|
|
27
|
+
const logo = httpsUrl(brand.logoUrl);
|
|
28
|
+
const footer = brand.footer?.trim();
|
|
29
|
+
const text = [
|
|
30
|
+
content.greeting,
|
|
31
|
+
"",
|
|
32
|
+
...content.intro.flatMap((line) => [line, ""]),
|
|
33
|
+
`${content.action.label}: ${content.action.url}`,
|
|
34
|
+
"",
|
|
35
|
+
...content.outro.flatMap((line) => [line, ""]),
|
|
36
|
+
content.signOff,
|
|
37
|
+
...footer === void 0 || footer === "" ? [] : [
|
|
38
|
+
"",
|
|
39
|
+
"--",
|
|
40
|
+
footer
|
|
41
|
+
]
|
|
42
|
+
].join("\n");
|
|
43
|
+
const header = logo === void 0 ? `<p style="margin:0;font-size:18px;font-weight:600;color:#18181b">${escapeHtml(brand.name)}</p>` : `<img src="${escapeHtml(logo)}" alt="${escapeHtml(brand.name)}" height="32" style="display:block;height:32px;border:0">`;
|
|
44
|
+
const href = escapeHtml(content.action.url);
|
|
45
|
+
const html = `<!doctype html>
|
|
46
|
+
<html lang="en">
|
|
47
|
+
<head>
|
|
48
|
+
<meta charset="utf-8">
|
|
49
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
50
|
+
<title>${escapeHtml(content.subject)}</title>
|
|
51
|
+
</head>
|
|
52
|
+
<body style="margin:0;padding:0;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">
|
|
53
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:24px 12px">
|
|
54
|
+
<tr><td align="center">
|
|
55
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;background:#ffffff;border:1px solid #e4e4e7;border-radius:8px">
|
|
56
|
+
<tr><td style="padding:24px 32px 0">${header}</td></tr>
|
|
57
|
+
<tr><td style="padding:24px 32px 8px">
|
|
58
|
+
<h1 style="margin:0 0 16px;font-size:20px;line-height:28px;color:#18181b">${escapeHtml(content.heading)}</h1>
|
|
59
|
+
${paragraph(content.greeting)}
|
|
60
|
+
${content.intro.map((line) => paragraph(line)).join("\n")}
|
|
61
|
+
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:8px 0 24px"><tr><td style="border-radius:6px;background:${accent}">
|
|
62
|
+
<a href="${href}" style="display:inline-block;padding:12px 20px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none">${escapeHtml(content.action.label)}</a>
|
|
63
|
+
</td></tr></table>
|
|
64
|
+
${paragraph(content.linkFallback, "margin:0 0 8px;font-size:13px;line-height:20px;color:#71717a")}
|
|
65
|
+
<p style="margin:0 0 24px;font-size:13px;line-height:20px;word-break:break-all"><a href="${href}" style="color:${accent}">${href}</a></p>
|
|
66
|
+
${content.outro.map((line) => paragraph(line)).join("\n")}
|
|
67
|
+
${paragraph(content.signOff)}
|
|
68
|
+
</td></tr>
|
|
69
|
+
</table>
|
|
70
|
+
${footer === void 0 || footer === "" ? "" : paragraph(footer, "margin:16px 0 0;font-size:12px;line-height:18px;color:#71717a")}
|
|
71
|
+
</td></tr>
|
|
72
|
+
</table>
|
|
73
|
+
</body>
|
|
74
|
+
</html>
|
|
75
|
+
`;
|
|
76
|
+
return {
|
|
77
|
+
subject: content.subject,
|
|
78
|
+
text,
|
|
79
|
+
html
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// src/messages.ts
|
|
83
|
+
/**
|
|
84
|
+
* Every string of the email templates, by message key. English only in the
|
|
85
|
+
* baseline; another locale is another catalogue passed to
|
|
86
|
+
* `createAuthEmailTemplates`. `{name}` placeholders are filled at render time.
|
|
87
|
+
*/
|
|
88
|
+
const emailMessages = {
|
|
89
|
+
"email.greeting": "Hi {name},",
|
|
90
|
+
"email.greetingNoName": "Hi,",
|
|
91
|
+
"email.linkFallback": "If the button does not work, copy this link into your browser:",
|
|
92
|
+
"email.signOff": "— The {appName} team",
|
|
93
|
+
"auth.verifyEmail.subject": "Verify your email address for {appName}",
|
|
94
|
+
"auth.verifyEmail.heading": "Verify your email address",
|
|
95
|
+
"auth.verifyEmail.intro": "Confirm that this is your email address to finish setting up your {appName} account.",
|
|
96
|
+
"auth.verifyEmail.action": "Verify email address",
|
|
97
|
+
"auth.verifyEmail.expiry": "This link works for {minutes} minutes.",
|
|
98
|
+
"auth.verifyEmail.ignore": "If you did not create an account, you can ignore this email.",
|
|
99
|
+
"auth.resetPassword.subject": "Reset your {appName} password",
|
|
100
|
+
"auth.resetPassword.heading": "Reset your password",
|
|
101
|
+
"auth.resetPassword.intro": "Someone asked to reset the password of your {appName} account.",
|
|
102
|
+
"auth.resetPassword.action": "Reset password",
|
|
103
|
+
"auth.resetPassword.expiry": "This link works for {minutes} minutes and can be used once.",
|
|
104
|
+
"auth.resetPassword.ignore": "If you did not ask for this, ignore this email: your password stays the same.",
|
|
105
|
+
"auth.invitation.subject": "{inviter} invited you to {appName}",
|
|
106
|
+
"auth.invitation.subjectNoInviter": "You are invited to {appName}",
|
|
107
|
+
"auth.invitation.heading": "You are invited to {appName}",
|
|
108
|
+
"auth.invitation.intro": "{inviter} invited you to join {appName}.",
|
|
109
|
+
"auth.invitation.introNoInviter": "You have been invited to join {appName}.",
|
|
110
|
+
"auth.invitation.introAdmin": "You will join as an administrator.",
|
|
111
|
+
"auth.invitation.action": "Accept invitation",
|
|
112
|
+
"auth.invitation.expiry": "This invitation works for {duration} and can be used once.",
|
|
113
|
+
"auth.invitation.ignore": "If you were not expecting this invitation, you can ignore this email.",
|
|
114
|
+
"duration.minutes": "{count} minutes",
|
|
115
|
+
"duration.hours": "{count} hours",
|
|
116
|
+
"duration.days": "{count} days"
|
|
117
|
+
};
|
|
118
|
+
/** Fills `{name}` placeholders; unknown placeholders are left as they are. */
|
|
119
|
+
function format(template, values) {
|
|
120
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => values[name] ?? match);
|
|
121
|
+
}
|
|
122
|
+
// src/auth-templates.ts
|
|
123
|
+
function linkEmail(messages, kind, input) {
|
|
124
|
+
const values = {
|
|
125
|
+
appName: input.brand.name,
|
|
126
|
+
minutes: String(input.expiresInMinutes),
|
|
127
|
+
name: input.recipientName.trim()
|
|
128
|
+
};
|
|
129
|
+
const m = (key) => format(messages[key], values);
|
|
130
|
+
return renderBrandedEmail({
|
|
131
|
+
brand: input.brand,
|
|
132
|
+
subject: m(`auth.${kind}.subject`),
|
|
133
|
+
greeting: values.name === "" ? m("email.greetingNoName") : m("email.greeting"),
|
|
134
|
+
heading: m(`auth.${kind}.heading`),
|
|
135
|
+
intro: [m(`auth.${kind}.intro`)],
|
|
136
|
+
action: {
|
|
137
|
+
label: m(`auth.${kind}.action`),
|
|
138
|
+
url: input.url
|
|
139
|
+
},
|
|
140
|
+
linkFallback: m("email.linkFallback"),
|
|
141
|
+
outro: [m(`auth.${kind}.expiry`), m(`auth.${kind}.ignore`)],
|
|
142
|
+
signOff: m("email.signOff")
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/** A lifetime in whole days, else whole hours, else minutes. */
|
|
146
|
+
function duration(messages, minutes) {
|
|
147
|
+
if (minutes % 1440 === 0) return format(messages["duration.days"], { count: String(minutes / 1440) });
|
|
148
|
+
if (minutes % 60 === 0) return format(messages["duration.hours"], { count: String(minutes / 60) });
|
|
149
|
+
return format(messages["duration.minutes"], { count: String(minutes) });
|
|
150
|
+
}
|
|
151
|
+
function invitationEmail(messages, input) {
|
|
152
|
+
const inviter = input.inviterName.trim();
|
|
153
|
+
const name = input.recipientName.trim();
|
|
154
|
+
const values = {
|
|
155
|
+
appName: input.brand.name,
|
|
156
|
+
inviter,
|
|
157
|
+
name,
|
|
158
|
+
duration: duration(messages, input.expiresInMinutes)
|
|
159
|
+
};
|
|
160
|
+
const m = (key) => format(messages[key], values);
|
|
161
|
+
return renderBrandedEmail({
|
|
162
|
+
brand: input.brand,
|
|
163
|
+
subject: inviter === "" ? m("auth.invitation.subjectNoInviter") : m("auth.invitation.subject"),
|
|
164
|
+
greeting: name === "" ? m("email.greetingNoName") : m("email.greeting"),
|
|
165
|
+
heading: m("auth.invitation.heading"),
|
|
166
|
+
intro: [inviter === "" ? m("auth.invitation.introNoInviter") : m("auth.invitation.intro"), ...input.role === "admin" ? [m("auth.invitation.introAdmin")] : []],
|
|
167
|
+
action: {
|
|
168
|
+
label: m("auth.invitation.action"),
|
|
169
|
+
url: input.url
|
|
170
|
+
},
|
|
171
|
+
linkFallback: m("email.linkFallback"),
|
|
172
|
+
outro: [m("auth.invitation.expiry"), m("auth.invitation.ignore")],
|
|
173
|
+
signOff: m("email.signOff")
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/** Auth email templates over a message catalogue (one locale). */
|
|
177
|
+
function createAuthEmailTemplates(messages = emailMessages) {
|
|
178
|
+
return {
|
|
179
|
+
verifyEmail: (input) => linkEmail(messages, "verifyEmail", input),
|
|
180
|
+
resetPassword: (input) => linkEmail(messages, "resetPassword", input),
|
|
181
|
+
invitation: (input) => invitationEmail(messages, input)
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** The English auth email templates; pass to `createInbisAuth({ emailTemplates })`. */
|
|
185
|
+
const authEmailTemplates = createAuthEmailTemplates();
|
|
186
|
+
// src/communication-service.ts
|
|
187
|
+
/**
|
|
188
|
+
* Types a service binding as the communication service. `wrangler types`
|
|
189
|
+
* types a service binding without the service's source as a plain `Fetcher`;
|
|
190
|
+
* at runtime its RPC methods are callable all the same.
|
|
191
|
+
*/
|
|
192
|
+
function communicationServiceBinding(binding) {
|
|
193
|
+
return binding;
|
|
194
|
+
}
|
|
195
|
+
/** Throws at startup when the app ID is missing; it must be the platform-set `APP_ID`. */
|
|
196
|
+
function requireAppId(appId) {
|
|
197
|
+
if (typeof appId !== "string" || appId.trim() === "") throw new Error("The communication service needs the app's APP_ID (set by the platform); it is missing or blank.");
|
|
198
|
+
return appId;
|
|
199
|
+
}
|
|
200
|
+
/** The message of an error that crossed the RPC boundary (custom classes do not survive it). */
|
|
201
|
+
function errorMessage(error) {
|
|
202
|
+
if (error instanceof Error) return error.message;
|
|
203
|
+
if (typeof error === "string") return error;
|
|
204
|
+
return "";
|
|
205
|
+
}
|
|
206
|
+
// src/errors.ts
|
|
207
|
+
/** Throws `EMAIL_RECIPIENT_PLACEHOLDER` for a phone-only user's placeholder address (plan §5.5). */
|
|
208
|
+
function refusePlaceholderRecipient(to) {
|
|
209
|
+
if (isPlaceholderEmail(to)) throw new EmailDeliveryError({ code: "EMAIL_RECIPIENT_PLACEHOLDER" });
|
|
210
|
+
}
|
|
211
|
+
const DETAILS = {
|
|
212
|
+
EMAIL_APP_NOT_REGISTERED: {
|
|
213
|
+
message: "Email is not set up for this app.",
|
|
214
|
+
retryable: false,
|
|
215
|
+
status: 500
|
|
216
|
+
},
|
|
217
|
+
EMAIL_APP_SUSPENDED: {
|
|
218
|
+
message: "Email sending is suspended for this app.",
|
|
219
|
+
retryable: false,
|
|
220
|
+
status: 503
|
|
221
|
+
},
|
|
222
|
+
EMAIL_QUOTA_EXCEEDED: {
|
|
223
|
+
message: "The monthly email limit is reached.",
|
|
224
|
+
retryable: true,
|
|
225
|
+
status: 503
|
|
226
|
+
},
|
|
227
|
+
EMAIL_UNSUPPORTED_FIELD: {
|
|
228
|
+
message: "This email cannot be sent as configured.",
|
|
229
|
+
retryable: false,
|
|
230
|
+
status: 500
|
|
231
|
+
},
|
|
232
|
+
EMAIL_SENDER_NOT_VERIFIED: {
|
|
233
|
+
message: "The email sender is not verified.",
|
|
234
|
+
retryable: false,
|
|
235
|
+
status: 502
|
|
236
|
+
},
|
|
237
|
+
EMAIL_RECIPIENT_NOT_ALLOWED: {
|
|
238
|
+
message: "The email recipient is not allowed.",
|
|
239
|
+
retryable: false,
|
|
240
|
+
status: 502
|
|
241
|
+
},
|
|
242
|
+
EMAIL_RATE_LIMITED: {
|
|
243
|
+
message: "Email sending is rate limited. Try again later.",
|
|
244
|
+
retryable: true,
|
|
245
|
+
status: 503
|
|
246
|
+
},
|
|
247
|
+
EMAIL_DAILY_LIMIT_EXCEEDED: {
|
|
248
|
+
message: "The daily email limit is reached.",
|
|
249
|
+
retryable: true,
|
|
250
|
+
status: 503
|
|
251
|
+
},
|
|
252
|
+
EMAIL_SENDER_MISSING: {
|
|
253
|
+
message: "No email sender address is configured.",
|
|
254
|
+
retryable: false,
|
|
255
|
+
status: 500
|
|
256
|
+
},
|
|
257
|
+
EMAIL_RECIPIENT_PLACEHOLDER: {
|
|
258
|
+
message: "This account has no email address to send to.",
|
|
259
|
+
retryable: false,
|
|
260
|
+
status: 400
|
|
261
|
+
},
|
|
262
|
+
EMAIL_DELIVERY_FAILED: {
|
|
263
|
+
message: "The email could not be sent.",
|
|
264
|
+
retryable: false,
|
|
265
|
+
status: 502
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* An email that was not accepted for delivery. Never swallowed: adapters throw it.
|
|
270
|
+
* The message is caller-safe (no addresses or content); the service's error
|
|
271
|
+
* is kept as `cause` for logs.
|
|
272
|
+
*/
|
|
273
|
+
var EmailDeliveryError = class extends AppError {
|
|
274
|
+
providerCode;
|
|
275
|
+
/** True when sending the same message later may succeed (rate, daily or monthly limits). */
|
|
276
|
+
retryable;
|
|
277
|
+
constructor(options) {
|
|
278
|
+
const details = DETAILS[options.code];
|
|
279
|
+
super({
|
|
280
|
+
code: options.code,
|
|
281
|
+
message: details.message,
|
|
282
|
+
status: details.status,
|
|
283
|
+
cause: options.cause
|
|
284
|
+
});
|
|
285
|
+
this.name = "EmailDeliveryError";
|
|
286
|
+
this.providerCode = options.providerCode;
|
|
287
|
+
this.retryable = details.retryable;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
const SMS_DETAILS = {
|
|
291
|
+
SMS_TEMPLATE_NOT_CONFIGURED: {
|
|
292
|
+
message: "No SMS template is configured for this message.",
|
|
293
|
+
retryable: false,
|
|
294
|
+
status: 500
|
|
295
|
+
},
|
|
296
|
+
SMS_TEMPLATE_VARIABLE_MISSING: {
|
|
297
|
+
message: "The SMS template needs a value the message lacks.",
|
|
298
|
+
retryable: false,
|
|
299
|
+
status: 500
|
|
300
|
+
},
|
|
301
|
+
SMS_RECIPIENT_INVALID: {
|
|
302
|
+
message: "The SMS recipient is not a valid phone number.",
|
|
303
|
+
retryable: false,
|
|
304
|
+
status: 400
|
|
305
|
+
},
|
|
306
|
+
SMS_APP_NOT_REGISTERED: {
|
|
307
|
+
message: "SMS is not set up for this app.",
|
|
308
|
+
retryable: false,
|
|
309
|
+
status: 500
|
|
310
|
+
},
|
|
311
|
+
SMS_APP_SUSPENDED: {
|
|
312
|
+
message: "SMS sending is suspended for this app.",
|
|
313
|
+
retryable: false,
|
|
314
|
+
status: 503
|
|
315
|
+
},
|
|
316
|
+
SMS_QUOTA_EXCEEDED: {
|
|
317
|
+
message: "The monthly SMS limit is reached.",
|
|
318
|
+
retryable: true,
|
|
319
|
+
status: 503
|
|
320
|
+
},
|
|
321
|
+
SMS_INVALID: {
|
|
322
|
+
message: "The SMS could not be sent as configured.",
|
|
323
|
+
retryable: false,
|
|
324
|
+
status: 500
|
|
325
|
+
},
|
|
326
|
+
SMS_TEMPLATE_MISMATCH: {
|
|
327
|
+
message: "The SMS does not match its registered template.",
|
|
328
|
+
retryable: false,
|
|
329
|
+
status: 500
|
|
330
|
+
},
|
|
331
|
+
SMS_PROVIDER_UNAVAILABLE: {
|
|
332
|
+
message: "The SMS provider is unavailable. Try again later.",
|
|
333
|
+
retryable: true,
|
|
334
|
+
status: 503
|
|
335
|
+
},
|
|
336
|
+
SMS_DELIVERY_FAILED: {
|
|
337
|
+
message: "The SMS could not be sent.",
|
|
338
|
+
retryable: false,
|
|
339
|
+
status: 502
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
/**
|
|
343
|
+
* An SMS that was not accepted for delivery, or that could not be sent as configured.
|
|
344
|
+
* Never swallowed: adapters throw it. The message is caller-safe (no numbers or
|
|
345
|
+
* content); the service's error is kept as `cause` for logs.
|
|
346
|
+
*/
|
|
347
|
+
var SmsDeliveryError = class extends AppError {
|
|
348
|
+
providerStatus;
|
|
349
|
+
/** True when sending the same message later may succeed. */
|
|
350
|
+
retryable;
|
|
351
|
+
constructor(options) {
|
|
352
|
+
const details = SMS_DETAILS[options.code];
|
|
353
|
+
super({
|
|
354
|
+
code: options.code,
|
|
355
|
+
message: details.message,
|
|
356
|
+
status: details.status,
|
|
357
|
+
cause: options.cause
|
|
358
|
+
});
|
|
359
|
+
this.name = "SmsDeliveryError";
|
|
360
|
+
this.providerStatus = options.providerStatus;
|
|
361
|
+
this.retryable = details.retryable;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
function serviceErrorKind(message) {
|
|
365
|
+
if (message.startsWith("No app registered with app_id")) return "APP_NOT_REGISTERED";
|
|
366
|
+
if (/^App ".*" is suspended$/.test(message)) return "APP_SUSPENDED";
|
|
367
|
+
if (/^App ".*" has exceeded its monthly quota$/.test(message)) return "QUOTA_EXCEEDED";
|
|
368
|
+
}
|
|
369
|
+
const PROVIDER_CODES = {
|
|
370
|
+
E_SENDER_NOT_VERIFIED: "EMAIL_SENDER_NOT_VERIFIED",
|
|
371
|
+
E_RECIPIENT_NOT_ALLOWED: "EMAIL_RECIPIENT_NOT_ALLOWED",
|
|
372
|
+
E_RATE_LIMIT_EXCEEDED: "EMAIL_RATE_LIMITED",
|
|
373
|
+
E_DAILY_LIMIT_EXCEEDED: "EMAIL_DAILY_LIMIT_EXCEEDED"
|
|
374
|
+
};
|
|
375
|
+
/** Maps an error thrown by the communication service's `sendEmail` to an {@link EmailDeliveryError}. */
|
|
376
|
+
function fromServiceEmailError(error) {
|
|
377
|
+
const message = errorMessage(error);
|
|
378
|
+
const kind = serviceErrorKind(message);
|
|
379
|
+
if (kind !== void 0) return new EmailDeliveryError({
|
|
380
|
+
code: `EMAIL_${kind}`,
|
|
381
|
+
cause: error
|
|
382
|
+
});
|
|
383
|
+
if (message.startsWith("The Cloudflare send path does not support")) return new EmailDeliveryError({
|
|
384
|
+
code: "EMAIL_UNSUPPORTED_FIELD",
|
|
385
|
+
cause: error
|
|
386
|
+
});
|
|
387
|
+
const providerCode = /\bE_[A-Z_]+\b/.exec(message)?.[0];
|
|
388
|
+
return new EmailDeliveryError({
|
|
389
|
+
code: (providerCode === void 0 ? void 0 : PROVIDER_CODES[providerCode]) ?? "EMAIL_DELIVERY_FAILED",
|
|
390
|
+
...providerCode === void 0 ? {} : { providerCode },
|
|
391
|
+
cause: error
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
/** Chatico statuses the service records for its own automatic retry (besides 5xx). */
|
|
395
|
+
const RETRIED_CHATICO_STATUSES = /* @__PURE__ */ new Set([
|
|
396
|
+
402,
|
|
397
|
+
408,
|
|
398
|
+
413
|
|
399
|
+
]);
|
|
400
|
+
/** Maps an error thrown by the communication service's `sendSms` to an {@link SmsDeliveryError}. */
|
|
401
|
+
function fromServiceSmsError(error) {
|
|
402
|
+
const message = errorMessage(error);
|
|
403
|
+
const kind = serviceErrorKind(message);
|
|
404
|
+
if (kind !== void 0) return new SmsDeliveryError({
|
|
405
|
+
code: `SMS_${kind}`,
|
|
406
|
+
cause: error
|
|
407
|
+
});
|
|
408
|
+
if (message.startsWith("sendSms needs at least one recipient")) return new SmsDeliveryError({
|
|
409
|
+
code: "SMS_INVALID",
|
|
410
|
+
cause: error
|
|
411
|
+
});
|
|
412
|
+
const chatico = /^Chatico send failed \((\d+)\)/.exec(message);
|
|
413
|
+
if (chatico?.[1] !== void 0) {
|
|
414
|
+
const providerStatus = Number(chatico[1]);
|
|
415
|
+
return new SmsDeliveryError({
|
|
416
|
+
code: providerStatus === 409 ? "SMS_TEMPLATE_MISMATCH" : providerStatus >= 500 || RETRIED_CHATICO_STATUSES.has(providerStatus) ? "SMS_PROVIDER_UNAVAILABLE" : "SMS_DELIVERY_FAILED",
|
|
417
|
+
providerStatus,
|
|
418
|
+
cause: error
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return new SmsDeliveryError({
|
|
422
|
+
code: "SMS_DELIVERY_FAILED",
|
|
423
|
+
cause: error
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
// src/communication-service-email.ts
|
|
427
|
+
/** Splits `Name <address>` into its parts; a bare address has no name. */
|
|
428
|
+
function parseMailbox(value) {
|
|
429
|
+
const match = /^\s*(.*?)\s*<([^<>]+)>\s*$/.exec(value);
|
|
430
|
+
if (match?.[2] === void 0) return { email: value.trim() };
|
|
431
|
+
const name = match[1]?.replace(/^"(.*)"$/, "$1") ?? "";
|
|
432
|
+
return name === "" ? { email: match[2].trim() } : {
|
|
433
|
+
name,
|
|
434
|
+
email: match[2].trim()
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* {@link EmailSender} over AWSM's shared communication service. Without `smtp`
|
|
439
|
+
* it uses the metered Cloudflare path, which takes only plain-string addresses:
|
|
440
|
+
* a display name in `from` is dropped there (kept on the SMTP path). Quota,
|
|
441
|
+
* suspension and retries belong to the service. Every failure is thrown as an
|
|
442
|
+
* {@link EmailDeliveryError}; nothing is swallowed or logged.
|
|
443
|
+
*/
|
|
444
|
+
function createCommunicationServiceEmailSender(options) {
|
|
445
|
+
const appId = requireAppId(options.appId);
|
|
446
|
+
const { binding, smtp } = options;
|
|
447
|
+
return { async send(message) {
|
|
448
|
+
refusePlaceholderRecipient(message.to);
|
|
449
|
+
const from = message.from ?? options.from;
|
|
450
|
+
if (from === void 0 || from.trim() === "") throw new EmailDeliveryError({ code: "EMAIL_SENDER_MISSING" });
|
|
451
|
+
const sender = parseMailbox(from);
|
|
452
|
+
const replyTo = message.replyTo ?? options.replyTo;
|
|
453
|
+
const email = {
|
|
454
|
+
from: smtp === void 0 ? sender.email : sender,
|
|
455
|
+
to: parseMailbox(message.to).email,
|
|
456
|
+
...replyTo === void 0 ? {} : { reply: parseMailbox(replyTo).email },
|
|
457
|
+
subject: message.subject,
|
|
458
|
+
text: message.text,
|
|
459
|
+
...message.html === void 0 ? {} : { html: message.html }
|
|
460
|
+
};
|
|
461
|
+
let result;
|
|
462
|
+
try {
|
|
463
|
+
result = smtp === void 0 ? await binding.sendEmail(appId, email) : await binding.sendEmail(appId, email, smtp);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
throw fromServiceEmailError(error);
|
|
466
|
+
}
|
|
467
|
+
const messageId = typeof result === "object" && result !== null && "messageId" in result ? result.messageId : void 0;
|
|
468
|
+
return typeof messageId === "string" && messageId !== "" ? { messageId } : {};
|
|
469
|
+
} };
|
|
470
|
+
}
|
|
471
|
+
// src/communication-service-sms.ts
|
|
472
|
+
const E164 = /^\+[1-9]\d{6,14}$/;
|
|
473
|
+
/** `+919876543210` → `919876543210` (country code first, no `+`). The default. */
|
|
474
|
+
function e164DigitsRecipient(e164) {
|
|
475
|
+
return e164.slice(1);
|
|
476
|
+
}
|
|
477
|
+
/** `+919876543210` → `9876543210` (Indian numbers without the country code); other numbers as {@link e164DigitsRecipient}. */
|
|
478
|
+
function indianTenDigitRecipient(e164) {
|
|
479
|
+
return /^\+91\d{10}$/.test(e164) ? e164.slice(3) : e164.slice(1);
|
|
480
|
+
}
|
|
481
|
+
function blank(value) {
|
|
482
|
+
return value === void 0 || value.trim() === "";
|
|
483
|
+
}
|
|
484
|
+
/** Throws at startup on any missing or incomplete template, naming it (never any content). */
|
|
485
|
+
function validateTemplates(options) {
|
|
486
|
+
for (const key of options.requiredTemplates ?? []) if (options.templates[key] === void 0) throw new Error(`SMS template "${key}" is not configured.`);
|
|
487
|
+
for (const [key, template] of Object.entries(options.templates)) {
|
|
488
|
+
if (blank(template.smsHeader ?? options.smsHeader)) throw new Error(`SMS template "${key}" has no DLT sender header.`);
|
|
489
|
+
if (blank(template.templateId)) throw new Error(`SMS template "${key}" has no DLT template ID.`);
|
|
490
|
+
if (blank(template.text)) throw new Error(`SMS template "${key}" has no text.`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function render$2(text, variables) {
|
|
494
|
+
return text.replace(/\{(\w+)\}/g, (_, name) => {
|
|
495
|
+
const value = variables[name];
|
|
496
|
+
if (value === void 0) throw new SmsDeliveryError({ code: "SMS_TEMPLATE_VARIABLE_MISSING" });
|
|
497
|
+
return value;
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* {@link SmsSender} over AWSM's shared communication service (Chatico, AWSM as
|
|
502
|
+
* the DLT entity). The message's `template.id` picks the app's DLT
|
|
503
|
+
* configuration; the text sent is that template's text with the message's
|
|
504
|
+
* variables filled in (the message's own `text` is not sent). Quota,
|
|
505
|
+
* suspension and retries belong to the service. Every failure is thrown as an
|
|
506
|
+
* {@link SmsDeliveryError}; nothing is swallowed or logged.
|
|
507
|
+
*/
|
|
508
|
+
function createCommunicationServiceSmsSender(options) {
|
|
509
|
+
const appId = requireAppId(options.appId);
|
|
510
|
+
validateTemplates(options);
|
|
511
|
+
const formatRecipient = options.formatRecipient ?? e164DigitsRecipient;
|
|
512
|
+
const { binding } = options;
|
|
513
|
+
return { async send(message) {
|
|
514
|
+
const template = message.template === void 0 ? void 0 : options.templates[message.template.id];
|
|
515
|
+
if (template === void 0 || message.template === void 0) throw new SmsDeliveryError({ code: "SMS_TEMPLATE_NOT_CONFIGURED" });
|
|
516
|
+
if (!E164.test(message.to)) throw new SmsDeliveryError({ code: "SMS_RECIPIENT_INVALID" });
|
|
517
|
+
const text = render$2(template.text, message.template.variables);
|
|
518
|
+
let result;
|
|
519
|
+
try {
|
|
520
|
+
result = await binding.sendSms(appId, {
|
|
521
|
+
smsHeader: template.smsHeader ?? options.smsHeader ?? "",
|
|
522
|
+
templateId: template.templateId,
|
|
523
|
+
recipients: [formatRecipient(message.to)],
|
|
524
|
+
message: text
|
|
525
|
+
});
|
|
526
|
+
} catch (error) {
|
|
527
|
+
throw fromServiceSmsError(error);
|
|
528
|
+
}
|
|
529
|
+
const messageId = result.messageIds[0] ?? result.submissionId;
|
|
530
|
+
return messageId === "" ? {} : { messageId };
|
|
531
|
+
} };
|
|
532
|
+
}
|
|
533
|
+
// src/log-email.ts
|
|
534
|
+
const TEXT_LINK = /https?:\/\/[^\s<>"')\]]+/g;
|
|
535
|
+
const HREF = /href\s*=\s*"([^"]+)"|href\s*=\s*'([^']+)'/gi;
|
|
536
|
+
function decodeEntities(value) {
|
|
537
|
+
return value.replaceAll(""", "\"").replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
538
|
+
}
|
|
539
|
+
function linksOf(text, html) {
|
|
540
|
+
const links = [...text.matchAll(TEXT_LINK)].map((match) => match[0]);
|
|
541
|
+
for (const match of html?.matchAll(HREF) ?? []) {
|
|
542
|
+
const href = decodeEntities(match[1] ?? match[2] ?? "");
|
|
543
|
+
if (/^https?:\/\//i.test(href)) links.push(href);
|
|
544
|
+
}
|
|
545
|
+
return [...new Set(links)];
|
|
546
|
+
}
|
|
547
|
+
function render$1(email) {
|
|
548
|
+
return [
|
|
549
|
+
`[email ${email.messageId}]`,
|
|
550
|
+
`From: ${email.from ?? "(none)"}`,
|
|
551
|
+
`To: ${email.to}`,
|
|
552
|
+
...email.replyTo === void 0 ? [] : [`Reply-To: ${email.replyTo}`],
|
|
553
|
+
`Subject: ${email.subject}`,
|
|
554
|
+
"",
|
|
555
|
+
email.text,
|
|
556
|
+
"",
|
|
557
|
+
email.html === void 0 ? "(no HTML part)" : `(HTML part: ${email.html.length} characters)`,
|
|
558
|
+
...email.links.length === 0 ? [] : ["Links:", ...email.links.map((link) => ` ${link}`)]
|
|
559
|
+
].join("\n");
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Development {@link EmailSender} that prints every email in full (addresses,
|
|
563
|
+
* subject, text body and links) instead of delivering it, and keeps the recent
|
|
564
|
+
* ones for an outbox viewer. Nothing is silently discarded.
|
|
565
|
+
*
|
|
566
|
+
* Never use it where real users expect email: it delivers nothing, and its
|
|
567
|
+
* output (and outbox) contains live verification and reset links.
|
|
568
|
+
*/
|
|
569
|
+
function createLogEmailSender(options = {}) {
|
|
570
|
+
const print = options.print ?? ((text) => {
|
|
571
|
+
console.info(text);
|
|
572
|
+
});
|
|
573
|
+
const keep = options.keep ?? 50;
|
|
574
|
+
const recent = [];
|
|
575
|
+
let sent = 0;
|
|
576
|
+
return {
|
|
577
|
+
send(message) {
|
|
578
|
+
if (isPlaceholderEmail(message.to)) return Promise.reject(new EmailDeliveryError({ code: "EMAIL_RECIPIENT_PLACEHOLDER" }));
|
|
579
|
+
sent += 1;
|
|
580
|
+
const email = {
|
|
581
|
+
messageId: `log-${Date.now().toString(36)}-${sent}`,
|
|
582
|
+
sentAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
583
|
+
to: message.to,
|
|
584
|
+
from: message.from ?? options.from,
|
|
585
|
+
replyTo: message.replyTo,
|
|
586
|
+
subject: message.subject,
|
|
587
|
+
text: message.text,
|
|
588
|
+
html: message.html,
|
|
589
|
+
links: linksOf(message.text, message.html)
|
|
590
|
+
};
|
|
591
|
+
recent.push(email);
|
|
592
|
+
if (recent.length > keep) recent.splice(0, recent.length - keep);
|
|
593
|
+
print(render$1(email));
|
|
594
|
+
return Promise.resolve({ messageId: email.messageId });
|
|
595
|
+
},
|
|
596
|
+
recent: () => recent
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
// src/log-sms.ts
|
|
600
|
+
function render(sms) {
|
|
601
|
+
return [
|
|
602
|
+
`[sms ${sms.messageId}]`,
|
|
603
|
+
`To: ${sms.to}`,
|
|
604
|
+
...sms.templateId === void 0 ? [] : [`Template: ${sms.templateId}`],
|
|
605
|
+
"",
|
|
606
|
+
sms.text
|
|
607
|
+
].join("\n");
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Development {@link SmsSender} that prints every message in full instead of
|
|
611
|
+
* delivering it, and keeps the recent ones for an outbox viewer. Nothing is
|
|
612
|
+
* silently discarded.
|
|
613
|
+
*
|
|
614
|
+
* Never use it where real users expect SMS: it delivers nothing, and its
|
|
615
|
+
* output (and outbox) contains live sign-in codes.
|
|
616
|
+
*/
|
|
617
|
+
function createLogSmsSender(options = {}) {
|
|
618
|
+
const print = options.print ?? ((text) => {
|
|
619
|
+
console.info(text);
|
|
620
|
+
});
|
|
621
|
+
const keep = options.keep ?? 50;
|
|
622
|
+
const recent = [];
|
|
623
|
+
let sent = 0;
|
|
624
|
+
return {
|
|
625
|
+
send(message) {
|
|
626
|
+
sent += 1;
|
|
627
|
+
const sms = {
|
|
628
|
+
messageId: `log-sms-${Date.now().toString(36)}-${sent}`,
|
|
629
|
+
sentAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
630
|
+
to: message.to,
|
|
631
|
+
text: message.text,
|
|
632
|
+
templateId: message.template?.id
|
|
633
|
+
};
|
|
634
|
+
recent.push(sms);
|
|
635
|
+
if (recent.length > keep) recent.splice(0, recent.length - keep);
|
|
636
|
+
print(render(sms));
|
|
637
|
+
return Promise.resolve({ messageId: sms.messageId });
|
|
638
|
+
},
|
|
639
|
+
recent: () => recent
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
export { EmailDeliveryError, SmsDeliveryError, authEmailTemplates, communicationServiceBinding, createAuthEmailTemplates, createCommunicationServiceEmailSender, createCommunicationServiceSmsSender, createLogEmailSender, createLogSmsSender, e164DigitsRecipient, emailMessages, indianTenDigitRecipient, renderBrandedEmail };
|
|
643
|
+
|
|
644
|
+
//# sourceMappingURL=index.js.map
|