@carrierllc/mcp 0.12.0 → 0.13.3

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.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * "Your eSIM is ready" — the message a customer actually installs from.
3
+ *
4
+ * Written as a plain HTML string rather than React Email components, for the
5
+ * reason given in resend.ts: this file ships into somebody else's repository,
6
+ * and an operator re-wording their own delivery email should not first have to
7
+ * learn a component library. Inline styles and a single-column table are the
8
+ * only layout most mail clients render the same way twice.
9
+ *
10
+ * The layering is deliberate, worst case first:
11
+ *
12
+ * 1. A QR code, attached and referenced as `cid:` — never a `data:` URI,
13
+ * which Gmail strips.
14
+ * 2. A direct-install link, which needs no typing at all on iOS 17.4+.
15
+ * 3. The SM-DP+ address and the activation code as two separate values,
16
+ * because those are two separate fields on the phone. One opaque
17
+ * `LPA:1$…$…` blob is not something a person can act on.
18
+ * 4. The steps, in words, for iPhone and for Android.
19
+ *
20
+ * A QR code alone fails the commonest case there is: the customer is reading
21
+ * this on the phone the eSIM is for, and cannot photograph its own screen.
22
+ */
23
+ import {
24
+ ANDROID_INSTALL_STEPS,
25
+ IPHONE_INSTALL_STEPS,
26
+ directInstallUrl,
27
+ manualActivationCode,
28
+ } from "@/lib/esim-install";
29
+ import type { EmailAttachment, EmailMessage } from "./resend";
30
+
31
+ /** The QR image, already fetched and base64-encoded by the caller. */
32
+ export interface QrImage {
33
+ base64: string;
34
+ contentType: string;
35
+ }
36
+
37
+ export interface QrDeliveryBrand {
38
+ name: string;
39
+ supportEmail?: string;
40
+ supportUrl?: string;
41
+ appUrl?: string;
42
+ colors?: { accent?: string; text?: string };
43
+ }
44
+
45
+ export interface QrDeliveryOrder {
46
+ orderId: string;
47
+ planName?: string;
48
+ iccid?: string;
49
+ activationCode?: string;
50
+ smdpAddress?: string;
51
+ matchingId?: string;
52
+ }
53
+
54
+ export interface QrDeliveryInput {
55
+ to: string;
56
+ order: QrDeliveryOrder;
57
+ brand: QrDeliveryBrand;
58
+ /** Absent when the operator's fulfilment produced no renderable QR. */
59
+ qr?: QrImage | null;
60
+ }
61
+
62
+ /**
63
+ * The content id the HTML points at. Any stable token works; this one is
64
+ * spelled out so the test can assert the HTML and the attachment agree.
65
+ */
66
+ export const QR_CONTENT_ID = "esim-qr";
67
+
68
+ function escapeHtml(value: string): string {
69
+ return value
70
+ .replace(/&/g, "&")
71
+ .replace(/</g, "&lt;")
72
+ .replace(/>/g, "&gt;")
73
+ .replace(/"/g, "&quot;")
74
+ .replace(/'/g, "&#39;");
75
+ }
76
+
77
+ function steps(title: string, items: readonly string[]): string {
78
+ return `
79
+ <p style="margin:0 0 8px;font-size:12px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#6b7280;">${escapeHtml(
80
+ title,
81
+ )}</p>
82
+ <ol style="margin:0 0 20px;padding-left:20px;font-size:14px;line-height:1.6;color:#374151;">
83
+ ${items.map((s) => `<li>${escapeHtml(s)}</li>`).join("")}
84
+ </ol>`;
85
+ }
86
+
87
+ /** One labelled credential, in a font a person can read a hyphen in. */
88
+ function credential(label: string, value: string): string {
89
+ return `
90
+ <tr>
91
+ <td style="padding:0 0 14px;">
92
+ <p style="margin:0 0 2px;font-size:12px;color:#6b7280;">${escapeHtml(label)}</p>
93
+ <p style="margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:14px;word-break:break-all;color:#111827;">${escapeHtml(
94
+ value,
95
+ )}</p>
96
+ </td>
97
+ </tr>`;
98
+ }
99
+
100
+ /**
101
+ * Build the message. Pure: every input is an argument, so the shape of what
102
+ * goes out can be asserted without a network or an API key.
103
+ */
104
+ export function buildQrDeliveryEmail(input: QrDeliveryInput): EmailMessage {
105
+ const { to, order, brand, qr } = input;
106
+ const accent = brand.colors?.accent ?? "#1a1a1a";
107
+ const lpa = manualActivationCode(order);
108
+ const attachments: EmailAttachment[] = [];
109
+
110
+ let qrBlock = "";
111
+ if (qr) {
112
+ attachments.push({
113
+ filename: "esim-qr.png",
114
+ content: qr.base64,
115
+ content_type: qr.contentType,
116
+ content_id: QR_CONTENT_ID,
117
+ });
118
+ qrBlock = `
119
+ <tr>
120
+ <td align="center" style="padding:8px 32px 24px;">
121
+ <img src="cid:${QR_CONTENT_ID}" width="200" height="200" alt="eSIM activation QR code" style="display:block;border:0;background:#ffffff;padding:8px;border-radius:12px;" />
122
+ <p style="margin:12px 0 0;font-size:13px;color:#6b7280;">Scan this from another device&rsquo;s camera.</p>
123
+ </td>
124
+ </tr>`;
125
+ }
126
+
127
+ const installButton = lpa
128
+ ? `
129
+ <tr>
130
+ <td align="center" style="padding:0 32px 8px;">
131
+ <a href="${escapeHtml(directInstallUrl(lpa))}" style="display:inline-block;background:${escapeHtml(
132
+ accent,
133
+ )};color:#ffffff;font-size:16px;font-weight:600;padding:14px 32px;border-radius:999px;text-decoration:none;">Install on this iPhone</a>
134
+ <p style="margin:10px 0 0;font-size:12px;color:#6b7280;">One tap, no typing. iPhone on iOS&nbsp;17.4 or later. On Android, use the steps below.</p>
135
+ </td>
136
+ </tr>`
137
+ : "";
138
+
139
+ const credentials = [
140
+ order.smdpAddress ? credential("SM-DP+ address", order.smdpAddress) : "",
141
+ order.matchingId ? credential("Activation code", order.matchingId) : "",
142
+ order.iccid ? credential("ICCID", order.iccid) : "",
143
+ ].join("");
144
+
145
+ const manualBlock = credentials
146
+ ? `
147
+ <tr>
148
+ <td style="padding:24px 32px 0;">
149
+ <div style="border:1px solid #e5e7eb;border-radius:16px;padding:20px;">
150
+ <p style="margin:0 0 14px;font-size:14px;font-weight:600;color:#111827;">Install manually</p>
151
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${credentials}</table>
152
+ <div style="border-top:1px solid #e5e7eb;padding-top:18px;margin-top:4px;">
153
+ ${steps("On iPhone", IPHONE_INSTALL_STEPS)}
154
+ ${steps("On Android", ANDROID_INSTALL_STEPS)}
155
+ </div>
156
+ </div>
157
+ </td>
158
+ </tr>`
159
+ : "";
160
+
161
+ const activateUrl = brand.appUrl
162
+ ? `${brand.appUrl.replace(/\/+$/, "")}/activate/${encodeURIComponent(order.orderId)}`
163
+ : null;
164
+
165
+ const supportLine = brand.supportEmail
166
+ ? `Questions? <a href="mailto:${escapeHtml(brand.supportEmail)}" style="color:${escapeHtml(
167
+ accent,
168
+ )};">${escapeHtml(brand.supportEmail)}</a>`
169
+ : brand.supportUrl
170
+ ? `Questions? <a href="${escapeHtml(brand.supportUrl)}" style="color:${escapeHtml(accent)};">Contact support</a>`
171
+ : "";
172
+
173
+ const html = `<!DOCTYPE html>
174
+ <html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width" /><title>${escapeHtml(
175
+ brand.name,
176
+ )}</title></head>
177
+ <body style="margin:0;padding:0;background:#f6f6f4;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;">
178
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f6f6f4;">
179
+ <tr><td align="center" style="padding:24px 12px;">
180
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;background:#ffffff;border-radius:20px;overflow:hidden;">
181
+ <tr>
182
+ <td style="background:${escapeHtml(accent)};padding:20px 32px;">
183
+ <p style="margin:0;color:#ffffff;font-size:20px;font-weight:700;letter-spacing:-0.4px;">${escapeHtml(
184
+ brand.name,
185
+ )}</p>
186
+ </td>
187
+ </tr>
188
+ <tr>
189
+ <td style="padding:28px 32px 4px;">
190
+ <p style="margin:0 0 8px;font-size:24px;font-weight:700;color:#111827;">Your eSIM is ready.</p>
191
+ <p style="margin:0;font-size:15px;line-height:1.55;color:#4b5563;">Install it now and it stays dormant until you arrive. ${
192
+ order.planName ? `Your plan: <strong>${escapeHtml(order.planName)}</strong>.` : ""
193
+ }</p>
194
+ </td>
195
+ </tr>
196
+ ${installButton}
197
+ ${qrBlock}
198
+ ${manualBlock}
199
+ <tr>
200
+ <td style="padding:24px 32px 28px;">
201
+ ${
202
+ activateUrl
203
+ ? `<p style="margin:0 0 10px;font-size:13px;color:#6b7280;">All of this is also on your <a href="${escapeHtml(
204
+ activateUrl,
205
+ )}" style="color:${escapeHtml(accent)};">activation page</a>.</p>`
206
+ : ""
207
+ }
208
+ <p style="margin:0 0 6px;font-size:13px;color:#6b7280;">${supportLine}</p>
209
+ <p style="margin:0;font-size:12px;color:#9ca3af;">Order ${escapeHtml(
210
+ order.orderId,
211
+ )} &middot; You are receiving this because you bought a plan from ${escapeHtml(
212
+ brand.name,
213
+ )}.</p>
214
+ </td>
215
+ </tr>
216
+ </table>
217
+ </td></tr>
218
+ </table>
219
+ </body></html>`;
220
+
221
+ // null drops a line that does not apply. "" is a blank line on purpose:
222
+ // filtering every empty string turns the five install routes into one paragraph.
223
+ const text = [
224
+ "Your eSIM is ready.",
225
+ order.planName ? `Plan: ${order.planName}` : null,
226
+ "",
227
+ lpa ? `Install on iPhone (iOS 17.4+), one tap: ${directInstallUrl(lpa)}` : null,
228
+ "",
229
+ "Install manually:",
230
+ order.smdpAddress ? ` SM-DP+ address: ${order.smdpAddress}` : null,
231
+ order.matchingId ? ` Activation code: ${order.matchingId}` : null,
232
+ order.iccid ? ` ICCID: ${order.iccid}` : null,
233
+ "",
234
+ "On iPhone:",
235
+ ...IPHONE_INSTALL_STEPS.map((s, i) => ` ${i + 1}. ${s}`),
236
+ "",
237
+ "On Android:",
238
+ ...ANDROID_INSTALL_STEPS.map((s, i) => ` ${i + 1}. ${s}`),
239
+ "",
240
+ activateUrl ? `Activation page: ${activateUrl}` : null,
241
+ brand.supportEmail ? `Questions? ${brand.supportEmail}` : null,
242
+ `Order ${order.orderId}`,
243
+ ]
244
+ .filter((line) => line !== null)
245
+ .join("\n");
246
+
247
+ return {
248
+ to,
249
+ subject: `Your ${brand.name} eSIM is ready`,
250
+ html,
251
+ text,
252
+ attachments,
253
+ };
254
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Transactional email over the Resend HTTP API.
3
+ *
4
+ * Deliberately no SDK. This template is copied into other people's
5
+ * repositories, so every dependency added here is one the operator inherits,
6
+ * has to audit, and has to keep patched. Sending one message is a single POST
7
+ * with a JSON body; `resend` and `@react-email/components` would buy us
8
+ * nothing that `fetch` does not already do, and `@react-email/components`
9
+ * would additionally drag a server-side React render into a Cloudflare Worker
10
+ * request path. The same reasoning put Stripe.js on a CDN rather than in
11
+ * package.json.
12
+ *
13
+ * The cost of that choice is honest: no typed client, and the field names
14
+ * below are the raw API's snake_case (`content_id`, not the SDK's
15
+ * `contentId`). They are pinned by tests.
16
+ */
17
+ import brand from "@/brand.config";
18
+
19
+ const RESEND_API = "https://api.resend.com";
20
+
21
+ /**
22
+ * Resend rejects any request without a User-Agent with a 403 that looks
23
+ * exactly like a bad key. Workers do not set one, so we always do.
24
+ */
25
+ const USER_AGENT = "carrier-storefront/1.0";
26
+
27
+ /** One file travelling with the message. Inline when `content_id` is set. */
28
+ export interface EmailAttachment {
29
+ filename: string;
30
+ /** Base64, no data: prefix. */
31
+ content: string;
32
+ content_type?: string;
33
+ /**
34
+ * Makes this attachment addressable from the HTML as `cid:<content_id>`.
35
+ *
36
+ * This is the whole reason the QR is an attachment rather than a `data:`
37
+ * URI in an `<img src>`. Gmail strips `data:` image sources, so a `data:`
38
+ * QR renders as a broken image for a large share of buyers — and a broken
39
+ * QR in a "your eSIM is ready" email is a support ticket, every time.
40
+ */
41
+ content_id?: string;
42
+ }
43
+
44
+ export interface EmailMessage {
45
+ to: string;
46
+ subject: string;
47
+ html: string;
48
+ text?: string;
49
+ attachments?: EmailAttachment[];
50
+ }
51
+
52
+ /**
53
+ * Why mail cannot be sent, when it cannot.
54
+ *
55
+ * "unconfigured" is a normal state, not a fault: a storefront with no Resend
56
+ * key still sells eSIMs, and every caller must treat this as a thing to
57
+ * mention rather than a thing to fail on.
58
+ */
59
+ export type EmailUnavailable = "unconfigured" | "no-from-address";
60
+
61
+ export type EmailSendResult =
62
+ | { ok: true; id?: string }
63
+ | { ok: false; reason: EmailUnavailable | "rejected" | "network-error"; detail?: string };
64
+
65
+ /** Server-side only. Never reached from a client bundle, so never inlined. */
66
+ function apiKey(): string {
67
+ return process.env.RESEND_API_KEY?.trim() ?? "";
68
+ }
69
+
70
+ export function emailConfigured(): boolean {
71
+ return apiKey().length > 0;
72
+ }
73
+
74
+ /**
75
+ * The address this storefront sends from, composed from the operator's own
76
+ * mailbox and their own domain.
77
+ *
78
+ * It is composed rather than stored whole on purpose. A literal from-address
79
+ * in a white-label template drifts onto a domain the operator does not own the
80
+ * first time somebody copies the file, and mail from a domain you cannot add
81
+ * SPF and DKIM records to does not arrive. There is no default: if the brand
82
+ * has no mailbox or no domain, this returns null and the caller says email is
83
+ * not configured rather than sending from somebody else's domain.
84
+ */
85
+ export function fromAddress(
86
+ config: { emailFromName?: string; emailFromMailbox?: string; domain?: string } = brand,
87
+ ): string | null {
88
+ const mailbox = config.emailFromMailbox?.trim();
89
+ const domain = config.domain?.trim().replace(/^https?:\/\//, "").replace(/\/+$/, "");
90
+ if (!mailbox || !domain) return null;
91
+
92
+ const address = `${mailbox}@${domain}`;
93
+ const name = config.emailFromName?.trim();
94
+ return name ? `${name} <${address}>` : address;
95
+ }
96
+
97
+ /**
98
+ * Send one message. Never throws: a storefront that cannot send an email must
99
+ * still be able to finish taking an order.
100
+ */
101
+ export async function sendEmail(message: EmailMessage): Promise<EmailSendResult> {
102
+ const key = apiKey();
103
+ if (!key) return { ok: false, reason: "unconfigured" };
104
+
105
+ const from = fromAddress();
106
+ if (!from) return { ok: false, reason: "no-from-address" };
107
+
108
+ try {
109
+ const res = await fetch(`${RESEND_API}/emails`, {
110
+ method: "POST",
111
+ headers: {
112
+ Authorization: `Bearer ${key}`,
113
+ "Content-Type": "application/json",
114
+ "User-Agent": USER_AGENT,
115
+ },
116
+ body: JSON.stringify({
117
+ from,
118
+ to: [message.to],
119
+ subject: message.subject,
120
+ html: message.html,
121
+ ...(message.text ? { text: message.text } : {}),
122
+ ...(message.attachments?.length ? { attachments: message.attachments } : {}),
123
+ }),
124
+ });
125
+
126
+ if (!res.ok) {
127
+ const detail = (await res.text().catch(() => "")).slice(0, 300);
128
+ return { ok: false, reason: "rejected", detail: `HTTP ${res.status} ${detail}`.trim() };
129
+ }
130
+
131
+ const body = (await res.json().catch(() => ({}))) as { id?: string };
132
+ return { ok: true, id: body.id };
133
+ } catch (e: unknown) {
134
+ return {
135
+ ok: false,
136
+ reason: "network-error",
137
+ detail: e instanceof Error ? e.message : String(e),
138
+ };
139
+ }
140
+ }