@carrierllc/mcp 0.12.0 → 0.13.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/dist/{chunk-J52VP7E7.js → chunk-ZUUC3J4J.js} +14 -3
- package/dist/chunk-ZUUC3J4J.js.map +1 -0
- package/dist/cli.js +385 -12
- package/dist/cli.js.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +79 -22
- package/templates/storefront/src/app/api/orders/[orderId]/email/route.ts +73 -0
- package/templates/storefront/src/lib/checkout-order-claim.ts +45 -4
- package/templates/storefront/src/lib/esim-install.ts +74 -0
- package/templates/storefront/src/lib/qr-delivery-email.ts +106 -0
- package/templates/storefront/src/vendor/email/qr-delivery.ts +254 -0
- package/templates/storefront/src/vendor/email/resend.ts +140 -0
- package/dist/chunk-J52VP7E7.js.map +0 -1
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carrierllc/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Carrier MCP \u2014 natural-language control of MVNO/eSIM fleets via eSIMVault OCS. Stdio mode for direct integration with Claude Desktop, Cursor, Windsurf, and MCP-compatible clients. Ships the `carrier` CLI (plugin install + white-label eSIM storefront scaffold).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Carrier (Lifecycle Innovations Limited)",
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
* Phone is NEVER required — user can skip.
|
|
20
20
|
*/
|
|
21
21
|
import { useCallback, useEffect, useRef, useState, FormEvent } from "react";
|
|
22
|
+
import {
|
|
23
|
+
ANDROID_INSTALL_STEPS,
|
|
24
|
+
IPHONE_INSTALL_STEPS,
|
|
25
|
+
directInstallUrl,
|
|
26
|
+
manualActivationCode,
|
|
27
|
+
} from "@/lib/esim-install";
|
|
22
28
|
import type { EsimOrder } from "@/vendor/carrier/types";
|
|
23
29
|
|
|
24
30
|
interface Props {
|
|
@@ -42,17 +48,17 @@ const POLL_INTERVAL_MS = 4000;
|
|
|
42
48
|
const MAX_POLLS = 15;
|
|
43
49
|
|
|
44
50
|
/**
|
|
45
|
-
*
|
|
46
|
-
* string straight to Settings, so the customer never types anything.
|
|
51
|
+
* What happened to the delivery email, as the page is allowed to describe it.
|
|
47
52
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
53
|
+
* "unconfigured" is on this list because a storefront with no Resend key is a
|
|
54
|
+
* working storefront, and the page must say "we cannot email this" rather than
|
|
55
|
+
* letting the customer leave believing a copy is on its way.
|
|
50
56
|
*/
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
57
|
+
type EmailState =
|
|
58
|
+
| { phase: "idle" }
|
|
59
|
+
| { phase: "sending" }
|
|
60
|
+
| { phase: "sent" }
|
|
61
|
+
| { phase: "unavailable"; reason: string };
|
|
56
62
|
|
|
57
63
|
export function ActivateClient({ orderId }: Props) {
|
|
58
64
|
const [phone, setPhone] = useState("");
|
|
@@ -61,7 +67,9 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
61
67
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
62
68
|
const [state, setState] = useState<LoadState>({ phase: "loading" });
|
|
63
69
|
const [copied, setCopied] = useState(false);
|
|
70
|
+
const [email, setEmail] = useState<EmailState>({ phase: "idle" });
|
|
64
71
|
const pollsRef = useRef(0);
|
|
72
|
+
const emailAskedRef = useRef(false);
|
|
65
73
|
// Bumped by "Check again". The polling effect depends on it, so a new value
|
|
66
74
|
// tears the old loop down and starts a fresh one. Resetting the counter and
|
|
67
75
|
// calling loadOrder once would fetch a single time and then sit on the
|
|
@@ -133,6 +141,47 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
133
141
|
};
|
|
134
142
|
}, [loadOrder, pollCycle]);
|
|
135
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Ask the server to email this eSIM, once, the moment it is ready.
|
|
146
|
+
*
|
|
147
|
+
* The page has always promised "we'll email you the QR code" and, until
|
|
148
|
+
* there was a sender, nothing was ever sent. The request is fired from here
|
|
149
|
+
* rather than from fulfilment because this is the first place that knows the
|
|
150
|
+
* order reached `ready`; the route itself carries the ownership check and
|
|
151
|
+
* the send-once marker, so a reload does not produce a second copy.
|
|
152
|
+
*/
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (state.phase !== "ready" || state.order.status !== "ready") return;
|
|
155
|
+
if (emailAskedRef.current) return;
|
|
156
|
+
emailAskedRef.current = true;
|
|
157
|
+
|
|
158
|
+
let cancelled = false;
|
|
159
|
+
setEmail({ phase: "sending" });
|
|
160
|
+
void (async () => {
|
|
161
|
+
try {
|
|
162
|
+
const res = await fetch(`/api/orders/${encodeURIComponent(orderId)}/email`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
});
|
|
165
|
+
const payload = (await res.json().catch(() => ({}))) as {
|
|
166
|
+
sent?: boolean;
|
|
167
|
+
reason?: string;
|
|
168
|
+
};
|
|
169
|
+
if (cancelled) return;
|
|
170
|
+
if (res.ok && payload.sent) {
|
|
171
|
+
setEmail({ phase: "sent" });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
setEmail({ phase: "unavailable", reason: payload.reason ?? "unavailable" });
|
|
175
|
+
} catch {
|
|
176
|
+
if (!cancelled) setEmail({ phase: "unavailable", reason: "network" });
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
179
|
+
|
|
180
|
+
return () => {
|
|
181
|
+
cancelled = true;
|
|
182
|
+
};
|
|
183
|
+
}, [state, orderId]);
|
|
184
|
+
|
|
136
185
|
async function handlePhoneSubmit(e: FormEvent<HTMLFormElement>) {
|
|
137
186
|
e.preventDefault();
|
|
138
187
|
setSaving(true);
|
|
@@ -170,9 +219,7 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
170
219
|
const order =
|
|
171
220
|
state.phase === "ready" || state.phase === "stalled" ? state.order : null;
|
|
172
221
|
const ready = order?.status === "ready";
|
|
173
|
-
const manualCode =
|
|
174
|
-
order?.activationCode ??
|
|
175
|
-
(order?.smdpAddress && order?.matchingId ? `LPA:1$${order.smdpAddress}$${order.matchingId}` : null);
|
|
222
|
+
const manualCode = order ? manualActivationCode(order) : null;
|
|
176
223
|
|
|
177
224
|
return (
|
|
178
225
|
<div className="mx-auto max-w-lg px-4 py-16">
|
|
@@ -323,6 +370,20 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
323
370
|
)}
|
|
324
371
|
</div>
|
|
325
372
|
|
|
373
|
+
{/* What happened to the email, in one line. The page promises a copy by
|
|
374
|
+
email in two other places; if none can be sent, it has to say so here
|
|
375
|
+
rather than let the customer navigate away expecting one. */}
|
|
376
|
+
{ready && email.phase !== "idle" && (
|
|
377
|
+
<p className="mb-4 text-center text-xs text-sand-500">
|
|
378
|
+
{email.phase === "sending" && "Emailing you a copy…"}
|
|
379
|
+
{email.phase === "sent" && "We've emailed a copy to the address that paid."}
|
|
380
|
+
{email.phase === "unavailable" &&
|
|
381
|
+
(email.reason === "unconfigured" || email.reason === "no-from-address"
|
|
382
|
+
? "Email delivery isn't set up on this store, so keep this page — everything you need to install is on it."
|
|
383
|
+
: "We couldn't email you a copy. Everything you need to install is on this page.")}
|
|
384
|
+
</p>
|
|
385
|
+
)}
|
|
386
|
+
|
|
326
387
|
{/* One tap, no typing — iOS 17.4+ only, and harmless everywhere else:
|
|
327
388
|
a phone that cannot handle the link opens a page that explains the
|
|
328
389
|
manual route, which is the next card down. */}
|
|
@@ -387,11 +448,9 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
387
448
|
On iPhone
|
|
388
449
|
</p>
|
|
389
450
|
<ol className="list-decimal space-y-1 pl-4 text-sm text-sand-700 dark:text-sand-300">
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
<li>Paste the SM-DP+ address and activation code from above.</li>
|
|
394
|
-
<li>Leave data roaming on — that is how the plan connects abroad.</li>
|
|
451
|
+
{IPHONE_INSTALL_STEPS.map((step) => (
|
|
452
|
+
<li key={step}>{step}</li>
|
|
453
|
+
))}
|
|
395
454
|
</ol>
|
|
396
455
|
</div>
|
|
397
456
|
<div>
|
|
@@ -399,11 +458,9 @@ export function ActivateClient({ orderId }: Props) {
|
|
|
399
458
|
On Android
|
|
400
459
|
</p>
|
|
401
460
|
<ol className="list-decimal space-y-1 pl-4 text-sm text-sand-700 dark:text-sand-300">
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
<li>Paste the activation code from above.</li>
|
|
406
|
-
<li>Turn on roaming for this SIM once it appears.</li>
|
|
461
|
+
{ANDROID_INSTALL_STEPS.map((step) => (
|
|
462
|
+
<li key={step}>{step}</li>
|
|
463
|
+
))}
|
|
407
464
|
</ol>
|
|
408
465
|
</div>
|
|
409
466
|
</div>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/orders/[orderId]/email
|
|
3
|
+
*
|
|
4
|
+
* Send the "your eSIM is ready" email for one order, to the address that paid
|
|
5
|
+
* for it. Called by the activation page the moment fulfilment reports `ready`,
|
|
6
|
+
* which is the first instant the email has anything true to say.
|
|
7
|
+
*
|
|
8
|
+
* Three things this route refuses to do:
|
|
9
|
+
*
|
|
10
|
+
* - send for an order that is not ready. An email whose QR code is a blank
|
|
11
|
+
* square is worse than no email;
|
|
12
|
+
* - send twice. The marker lives in Stripe session metadata, because that is
|
|
13
|
+
* the only durable store this storefront has;
|
|
14
|
+
* - fail the order because email is not configured. A storefront with no
|
|
15
|
+
* Resend key sells eSIMs perfectly well; it answers 200 with
|
|
16
|
+
* `sent: false` and a reason, and the page says so in a sentence.
|
|
17
|
+
*
|
|
18
|
+
* It is guarded by the same auth + ownership check as every other
|
|
19
|
+
* order-scoped route, so a session id alone cannot make this storefront send
|
|
20
|
+
* mail to the address on someone else's order.
|
|
21
|
+
*/
|
|
22
|
+
import { NextResponse } from "next/server";
|
|
23
|
+
import { markQrEmailSent, orderPurchaserEmail, qrEmailSentAt } from "@/lib/checkout-order-claim";
|
|
24
|
+
import { guardOrderRoute, fulfilmentFailureResponse } from "@/lib/order-route-guard";
|
|
25
|
+
import { deliverQrEmail } from "@/lib/qr-delivery-email";
|
|
26
|
+
import { fetchOrder } from "@/vendor/carrier/client";
|
|
27
|
+
|
|
28
|
+
export const dynamic = "force-dynamic";
|
|
29
|
+
|
|
30
|
+
interface Context {
|
|
31
|
+
params: Promise<{ orderId: string }>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function POST(_req: Request, ctx: Context): Promise<NextResponse> {
|
|
35
|
+
const guard = await guardOrderRoute(ctx.params);
|
|
36
|
+
if (!guard.ok) return guard.response;
|
|
37
|
+
const { orderId } = guard;
|
|
38
|
+
|
|
39
|
+
const already = await qrEmailSentAt(orderId);
|
|
40
|
+
if (already) {
|
|
41
|
+
return NextResponse.json({ sent: true, deduplicated: true, sentAt: already });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lookup = await fetchOrder(orderId);
|
|
45
|
+
if (!lookup.ok) {
|
|
46
|
+
return fulfilmentFailureResponse(lookup.reason, "send your eSIM email", orderId);
|
|
47
|
+
}
|
|
48
|
+
if (lookup.order.status !== "ready") {
|
|
49
|
+
return NextResponse.json(
|
|
50
|
+
{ sent: false, reason: "not_ready", status: lookup.order.status },
|
|
51
|
+
{ status: 409 },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const to = await orderPurchaserEmail(orderId);
|
|
56
|
+
if (!to) {
|
|
57
|
+
// No address on the payment means nowhere to send. That is a fact about
|
|
58
|
+
// the order, not a failure of this storefront, and the page has the same
|
|
59
|
+
// QR on screen regardless.
|
|
60
|
+
return NextResponse.json({ sent: false, reason: "no_recipient" });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const result = await deliverQrEmail(lookup.order, to);
|
|
64
|
+
if (!result.sent) {
|
|
65
|
+
if (result.detail) {
|
|
66
|
+
console.error(`[email] QR delivery for ${orderId} failed (${result.reason}): ${result.detail}`);
|
|
67
|
+
}
|
|
68
|
+
return NextResponse.json({ sent: false, reason: result.reason });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await markQrEmailSent(orderId, new Date().toISOString());
|
|
72
|
+
return NextResponse.json({ sent: true, withQrImage: result.withQrImage });
|
|
73
|
+
}
|
|
@@ -30,15 +30,19 @@ async function fetchStripeSession(sessionId: string): Promise<StripeSession | nu
|
|
|
30
30
|
return (await resp.json()) as StripeSession;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
async function
|
|
33
|
+
async function setSessionMetadata(
|
|
34
|
+
sessionId: string,
|
|
35
|
+
fields: Record<string, string>,
|
|
36
|
+
): Promise<boolean> {
|
|
34
37
|
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
35
38
|
if (!stripeKey) return false;
|
|
36
39
|
const safeSessionId = safeStripeSessionId(sessionId);
|
|
37
40
|
if (!safeSessionId) return false;
|
|
38
41
|
|
|
39
|
-
const formBody = new URLSearchParams(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
const formBody = new URLSearchParams();
|
|
43
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
44
|
+
formBody.set(`metadata[${key}]`, value);
|
|
45
|
+
}
|
|
42
46
|
|
|
43
47
|
const resp = await fetch(`https://api.stripe.com/v1/checkout/sessions/${safeSessionId}`, {
|
|
44
48
|
method: "POST",
|
|
@@ -52,6 +56,43 @@ async function setStripeClaimedBy(sessionId: string, userId: string): Promise<bo
|
|
|
52
56
|
return resp.ok;
|
|
53
57
|
}
|
|
54
58
|
|
|
59
|
+
function setStripeClaimedBy(sessionId: string, userId: string): Promise<boolean> {
|
|
60
|
+
return setSessionMetadata(sessionId, { claimedByUserId: userId });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The address that paid, which is the address the eSIM email goes to.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately taken from the Stripe session rather than from Clerk. The
|
|
67
|
+
* person who paid is the person expecting the QR code; the signed-in account
|
|
68
|
+
* may have several addresses on it, and picking one of those would be a guess.
|
|
69
|
+
*/
|
|
70
|
+
export async function orderPurchaserEmail(orderId: string): Promise<string | null> {
|
|
71
|
+
if (!orderId.startsWith("cs_")) return null;
|
|
72
|
+
const session = await fetchStripeSession(orderId);
|
|
73
|
+
return session ? sessionPurchaserEmail(session) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* When the delivery email for this order was sent, if it was.
|
|
78
|
+
*
|
|
79
|
+
* The storefront has no database — Stripe session metadata is the only place
|
|
80
|
+
* it can record a fact about an order, and it is already used that way for
|
|
81
|
+
* `claimedByUserId`. Without this marker every reload of the activation page
|
|
82
|
+
* would send the customer another copy of the same email.
|
|
83
|
+
*/
|
|
84
|
+
export async function qrEmailSentAt(orderId: string): Promise<string | null> {
|
|
85
|
+
if (!process.env.STRIPE_SECRET_KEY || !orderId.startsWith("cs_")) return null;
|
|
86
|
+
const session = await fetchStripeSession(orderId);
|
|
87
|
+
return session?.metadata?.qrEmailSentAt ?? null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Best-effort. A send that happened and was not recorded is a duplicate, not a loss. */
|
|
91
|
+
export async function markQrEmailSent(orderId: string, at: string): Promise<void> {
|
|
92
|
+
if (!process.env.STRIPE_SECRET_KEY || !orderId.startsWith("cs_")) return;
|
|
93
|
+
await setSessionMetadata(orderId, { qrEmailSentAt: at }).catch(() => false);
|
|
94
|
+
}
|
|
95
|
+
|
|
55
96
|
function claimedOrderIds(metadata: Record<string, unknown> | undefined): string[] {
|
|
56
97
|
const value = metadata?.claimedOrderIds;
|
|
57
98
|
return Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : [];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a customer gets an eSIM onto a phone, in every form we can offer.
|
|
3
|
+
*
|
|
4
|
+
* This lives apart from the pages because the activate page and the delivery
|
|
5
|
+
* email have to say exactly the same thing. They did not, in the product this
|
|
6
|
+
* template grew out of: the page learned about one-tap install and the email
|
|
7
|
+
* kept telling people to scan a QR code on the device they were reading it on.
|
|
8
|
+
* Two copies of install instructions drift, and the copy that drifts is the one
|
|
9
|
+
* the customer is holding.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Both halves of the credential, as a phone's manual-entry form asks for them. */
|
|
13
|
+
export interface ManualInstallDetails {
|
|
14
|
+
/** e.g. consumer.rsp.example.com — the "SM-DP+ address" field. */
|
|
15
|
+
smdpAddress?: string;
|
|
16
|
+
/** e.g. K2-1A2B3C-4D5E6F — the "activation code" field. */
|
|
17
|
+
matchingId?: string;
|
|
18
|
+
/** The whole LPA string, which is what a QR code encodes. */
|
|
19
|
+
activationCode?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The full LPA string, from whichever pieces the operator supplied.
|
|
24
|
+
*
|
|
25
|
+
* `LPA:1$<smdp>$<matchingId>` is the format every modern handset accepts, so
|
|
26
|
+
* either the operator hands it over whole or we can rebuild it.
|
|
27
|
+
*/
|
|
28
|
+
export function manualActivationCode(details: ManualInstallDetails): string | null {
|
|
29
|
+
if (details.activationCode) return details.activationCode;
|
|
30
|
+
if (details.smdpAddress && details.matchingId) {
|
|
31
|
+
return `LPA:1$${details.smdpAddress}$${details.matchingId}`;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Apple's universal eSIM install link — iOS 17.4 and later hand the activation
|
|
38
|
+
* string straight to Settings, so the customer never types anything.
|
|
39
|
+
*
|
|
40
|
+
* Nothing equivalent exists on Android: install there is Settings → Network →
|
|
41
|
+
* add eSIM → enter manually, which is what the written steps below are for.
|
|
42
|
+
* The link is harmless on a phone that cannot use it; it opens a page that
|
|
43
|
+
* explains the manual route.
|
|
44
|
+
*/
|
|
45
|
+
export function directInstallUrl(activationCode: string): string {
|
|
46
|
+
return `https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=${encodeURIComponent(
|
|
47
|
+
activationCode,
|
|
48
|
+
)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The steps in words.
|
|
53
|
+
*
|
|
54
|
+
* A customer whose camera cannot reach a second screen, and whose phone is too
|
|
55
|
+
* old for the one-tap link, otherwise has two credentials and no idea where to
|
|
56
|
+
* type them. The SM-DP+ address and the activation code are named here exactly
|
|
57
|
+
* as the handset names them, because "paste your activation string" does not
|
|
58
|
+
* match any field on either operating system.
|
|
59
|
+
*/
|
|
60
|
+
export const IPHONE_INSTALL_STEPS: readonly string[] = [
|
|
61
|
+
"Open Settings, then Cellular (or Mobile Service).",
|
|
62
|
+
"Tap Add eSIM, then Use QR Code.",
|
|
63
|
+
"Tap Enter Details Manually.",
|
|
64
|
+
"Paste the SM-DP+ address and activation code from above.",
|
|
65
|
+
"Leave data roaming on — that is how the plan connects abroad.",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export const ANDROID_INSTALL_STEPS: readonly string[] = [
|
|
69
|
+
"Open Settings, then Network & internet.",
|
|
70
|
+
"Tap SIMs, then Add SIM or Download a SIM instead.",
|
|
71
|
+
"Choose Need help? then Enter it manually.",
|
|
72
|
+
"Paste the activation code from above.",
|
|
73
|
+
"Turn on roaming for this SIM once it appears.",
|
|
74
|
+
];
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn one ready order into one delivered email.
|
|
3
|
+
*
|
|
4
|
+
* The interesting part is getting the QR out of whatever the operator's
|
|
5
|
+
* fulfilment handed us and into an attachment. `EsimOrder.qrCode` is
|
|
6
|
+
* documented as "a data: URI or an https URL" because both are things a
|
|
7
|
+
* fulfilment API plausibly returns, and neither can be dropped into an email
|
|
8
|
+
* as-is:
|
|
9
|
+
*
|
|
10
|
+
* - a `data:` URI in `<img src>` is stripped by Gmail and shows as a broken
|
|
11
|
+
* image, which is the single most expensive way for this email to fail;
|
|
12
|
+
* - an `https` URL survives, but only while that host is up and only if the
|
|
13
|
+
* reader's client loads remote images, which many do not by default.
|
|
14
|
+
*
|
|
15
|
+
* So both are normalised to bytes and attached with a `content_id`, and the
|
|
16
|
+
* HTML points at `cid:`. An attachment is part of the message; it cannot 404
|
|
17
|
+
* next month and it does not need remote images enabled.
|
|
18
|
+
*/
|
|
19
|
+
import brand from "@/brand.config";
|
|
20
|
+
import type { EsimOrder } from "@/vendor/carrier/types";
|
|
21
|
+
import { buildQrDeliveryEmail, type QrImage } from "@/vendor/email/qr-delivery";
|
|
22
|
+
import { emailConfigured, fromAddress, sendEmail, type EmailSendResult } from "@/vendor/email/resend";
|
|
23
|
+
|
|
24
|
+
/** A QR code is a few kB. Anything this size is not one, and is not attached. */
|
|
25
|
+
const MAX_QR_BYTES = 2 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
// `[\s\S]` rather than the `s` flag: the template's tsconfig targets an older
|
|
28
|
+
// ES level than dotAll, and a base64 payload can contain newlines.
|
|
29
|
+
const DATA_URI = /^data:([^;,]+)?(;base64)?,([\s\S]*)$/;
|
|
30
|
+
|
|
31
|
+
function toBase64(bytes: Uint8Array): string {
|
|
32
|
+
// Chunked so a large-ish buffer cannot blow the argument limit of
|
|
33
|
+
// String.fromCharCode. btoa exists in both Workers and Node 18+.
|
|
34
|
+
let binary = "";
|
|
35
|
+
const chunk = 0x8000;
|
|
36
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
37
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
38
|
+
}
|
|
39
|
+
return btoa(binary);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Fetch or decode the QR into base64 bytes. Null whenever that cannot be done
|
|
44
|
+
* — the email is still worth sending, because the manual credentials and the
|
|
45
|
+
* written steps below the QR are what most people use anyway.
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveQrImage(qrCode: string | undefined): Promise<QrImage | null> {
|
|
48
|
+
if (!qrCode) return null;
|
|
49
|
+
|
|
50
|
+
const data = DATA_URI.exec(qrCode);
|
|
51
|
+
if (data) {
|
|
52
|
+
const [, contentType, isBase64, payload] = data;
|
|
53
|
+
if (!isBase64 || !payload) return null;
|
|
54
|
+
return { base64: payload.replace(/\s+/g, ""), contentType: contentType || "image/png" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!/^https:\/\//i.test(qrCode)) return null;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(qrCode, { cache: "no-store" });
|
|
61
|
+
if (!res.ok) return null;
|
|
62
|
+
const buffer = await res.arrayBuffer();
|
|
63
|
+
if (buffer.byteLength === 0 || buffer.byteLength > MAX_QR_BYTES) return null;
|
|
64
|
+
return {
|
|
65
|
+
base64: toBase64(new Uint8Array(buffer)),
|
|
66
|
+
contentType: res.headers.get("content-type")?.split(";")[0]?.trim() || "image/png",
|
|
67
|
+
};
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type QrDeliveryResult =
|
|
74
|
+
| { sent: true; id?: string; withQrImage: boolean }
|
|
75
|
+
| { sent: false; reason: "unconfigured" | "no-from-address" | "rejected" | "network-error"; detail?: string };
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Send the delivery email for one ready order.
|
|
79
|
+
*
|
|
80
|
+
* Never throws and never reports success it did not get. A storefront with no
|
|
81
|
+
* Resend key returns `unconfigured`, which the caller shows as a plain
|
|
82
|
+
* sentence — the order is complete either way, and pretending an email went
|
|
83
|
+
* out is worse than saying none did.
|
|
84
|
+
*/
|
|
85
|
+
export async function deliverQrEmail(order: EsimOrder, to: string): Promise<QrDeliveryResult> {
|
|
86
|
+
if (!emailConfigured()) return { sent: false, reason: "unconfigured" };
|
|
87
|
+
if (!fromAddress()) return { sent: false, reason: "no-from-address" };
|
|
88
|
+
|
|
89
|
+
const qr = await resolveQrImage(order.qrCode);
|
|
90
|
+
const message = buildQrDeliveryEmail({
|
|
91
|
+
to,
|
|
92
|
+
order,
|
|
93
|
+
brand: {
|
|
94
|
+
name: brand.name,
|
|
95
|
+
supportEmail: brand.supportEmail,
|
|
96
|
+
supportUrl: brand.supportUrl,
|
|
97
|
+
appUrl: brand.appUrl,
|
|
98
|
+
colors: { accent: brand.colors.accent },
|
|
99
|
+
},
|
|
100
|
+
qr,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const result: EmailSendResult = await sendEmail(message);
|
|
104
|
+
if (!result.ok) return { sent: false, reason: result.reason, detail: result.detail };
|
|
105
|
+
return { sent: true, id: result.id, withQrImage: qr !== null };
|
|
106
|
+
}
|