@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carrierllc/mcp",
3
- "version": "0.12.0",
3
+ "version": "0.13.3",
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)",
@@ -50,7 +50,8 @@ instructions.
50
50
 
51
51
  | Command | Does |
52
52
  |---|---|
53
- | `/carrier:provision` | Provision eSIMs + assign a starter package |
53
+ | `/carrier:issue-esim` | Issue one eSIM via `connectivity.intent` (the vibe-coder path) |
54
+ | `/carrier:provision` | Operator path: inventory stock + a starter package |
54
55
  | `/carrier:subscribers` | List / inspect / suspend / resume / terminate subscribers |
55
56
  | `/carrier:packages` | Create, assign, optimize data packages |
56
57
  | `/carrier:usage` | Usage, projections, depletion, anomalies |
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: issue-esim
3
+ description: Issue one Carrier eSIM with POST /v1/connectivity/intents. Use when someone wants global connectivity, a test eSIM, or test_global_1gb. Do not use the operator OCS catalog for this.
4
+ argument-hint: "[test_global_1gb|test_eu_1gb|live plan slug] [idempotency key]"
5
+ ---
6
+
7
+ # Issue one eSIM
8
+
9
+ This is the developer path. It is one object, `connectivity.intent`, not the OCS method list.
10
+
11
+ The description above is the trigger. Load this skill when the task is connectivity. Do not load the operator MCP catalog first: clients inject every tool on that server before a skill can hide them.
12
+
13
+ ## Install, if this skill is not already available
14
+
15
+ Once per user, so every project sees it:
16
+
17
+ ```bash
18
+ npx skills add https://carrier.llc -g -a cursor -a claude-code -y
19
+ ```
20
+
21
+ If that host is not published yet, install this directory:
22
+
23
+ ```bash
24
+ npx skills add https://github.com/Lifecycle-Innovations-Limited/carrier.llc/tree/main/apps/mcp-stdio/plugin/carrier/skills/issue-esim -g -a cursor -a claude-code -y
25
+ ```
26
+
27
+ ## Issue
28
+
29
+ `POST https://api.carrier.llc/v1/connectivity/intents`
30
+
31
+ Required header: `Idempotency-Key` (stable per order, max 255 characters).
32
+
33
+ Auth: `Authorization: Bearer` with an `ak_` key from the console. Do not put the key in the repo.
34
+
35
+ ```json
36
+ { "plan": "test_global_1gb" }
37
+ ```
38
+
39
+ Plans:
40
+
41
+ - `test_global_1gb` and `test_eu_1gb` succeed immediately, set `livemode` false, and never call the network. They work on the free tier. The ICCID `8900000000000000001` and SM-DP+ `test.smdp.carrier.llc` are fixtures.
42
+ - Any other slug is a live catalogue plan. It draws one eSIM from the caller's own account and needs the write scope. Do not fall back to a platform stock account.
43
+
44
+ The same key and body return the original intent. Do not issue a second SIM to "make sure".
45
+
46
+ Confirm a plan later with `POST /v1/connectivity/intents/:id/confirm` when the first call omitted `plan`.
47
+
48
+ ## One-tool MCP
49
+
50
+ `carrier-intent` is a separate process with one tool, `create_connectivity_intent`. It posts the intent above. It is not registered on `https://mcp.carrier.llc/mcp`.
51
+
52
+ ```json
53
+ {
54
+ "mcpServers": {
55
+ "carrier-intent": {
56
+ "command": "npx",
57
+ "args": ["-y", "-p", "@carrierllc/mcp-intent", "carrier-intent"],
58
+ "env": { "CARRIER_API_KEY": "" }
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ Put the `ak_` key in that env block on the machine. Call the tool with `plan` and `idempotency_key`. If the package is not installed yet, use the HTTP call in the previous section.
65
+
66
+ ## After it succeeds
67
+
68
+ - Read `esim.iccid` and `esim.smdp_address` back to the user.
69
+ - Webhooks: `POST /v1/connectivity/webhook_endpoints` with an https URL. The secret is shown once. Deliveries use `Carrier-Signature`.
70
+ - Test usage: `POST /v1/connectivity/usage_records` with the intent id and `quantity_mb`. A live intent refuses a self-reported quantity (`network_metered`) because the wallet meter already debits those bytes.
71
+
72
+ ## If the person is new, use these sentences
73
+
74
+ A skill is a note your AI reads so it follows the same steps every time.
75
+ A plug lets an AI app press a button. This plug has one button: give this order a SIM.
76
+ This other plug opens every button a phone company uses to run many SIMs.
77
+ These are the same switchboard buttons, typed into a terminal instead of clicked. People call that a CLI.
78
+ An intent is your ask: please attach a SIM to this order. The same ask twice does not make two SIMs.
79
+ A product is the offer you sell: how much data, how many days, which countries, and the price. Saving it does not charge anyone.
80
+ A package is the data sitting on one SIM after it is issued. A code package, such as the library named @carrierllc/sdk, is a different thing.
81
+ A subscriber is one SIM line, and the person or device that line belongs to.
82
+ On this page a client is the small code library your app uses to ask for a SIM. A customer is the person who buys that SIM.
83
+ A country list is the countries someone typed on an offer. Carrier stores that list. It does not look up a live coverage map.
84
+ Steering is which mobile networks a SIM is told to join. Changing it is a job for the person who runs the SIMs.
85
+ A green zone is a short list of sites that can stay reachable after the data is used up, so someone can get help, pay, or download the SIM again.
86
+
87
+ ## Do not
88
+
89
+ - Do not register `https://mcp.carrier.llc/mcp` to issue this SIM. That server is the operator fleet.
90
+ - Do not call `assign_package`, `affectPackageToSubscriber`, or another OCS write to issue this SIM.
91
+ - Do not invent coverage, fair-use, or a second network.
92
+ - Do not put a token in the repo.
@@ -6,6 +6,8 @@ argument-hint: "[account/reseller] [country or package] [qty]"
6
6
 
7
7
  # Provision eSIM
8
8
 
9
+ A developer adding one SIM to an app should use the `issue-esim` skill (`POST /v1/connectivity/intents`) instead of this operator path.
10
+
9
11
  Issue free eSIM stock and assign a starter package so SIMs are ready to activate.
10
12
 
11
13
  Inputs from `$ARGUMENTS` (ask for anything missing): target **account/reseller**, destination **country/region** or **package template**, and **quantity** (default 1).
@@ -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
- * Apple's universal eSIM install link — iOS 17.4 and later hand the activation
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
- * Nothing equivalent exists on Android: install there is Settings → Network →
49
- * add eSIM → enter manually, which is what the written steps below are for.
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
- function directInstallUrl(activationCode: string): string {
52
- return `https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=${encodeURIComponent(
53
- activationCode,
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
- <li>Open Settings, then Cellular (or Mobile Service).</li>
391
- <li>Tap Add eSIM, then Use QR Code.</li>
392
- <li>Tap Enter Details Manually.</li>
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
- <li>Open Settings, then Network &amp; internet.</li>
403
- <li>Tap SIMs, then Add SIM or Download a SIM instead.</li>
404
- <li>Choose Need help? then Enter it manually.</li>
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 setStripeClaimedBy(sessionId: string, userId: string): Promise<boolean> {
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
- "metadata[claimedByUserId]": userId,
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
+ }