create-mailslot 0.0.1 → 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 CHANGED
@@ -1,11 +1,43 @@
1
1
  # create-mailslot
2
2
 
3
3
  Deploy [Mailslot](https://github.com/mailslot/mailslot) — a self-hosted email
4
- inbox for AI agents — to your own Cloudflare account.
4
+ inbox for AI agents — to your own Cloudflare account, interactively.
5
5
 
6
6
  > Your agent's email shouldn't come with a landlord.
7
7
 
8
- The wizard is **not released yet**; this package currently points you to the
9
- [manual setup guide](https://github.com/mailslot/mailslot#quick-start)
10
- (~10 minutes). When released, `npx create-mailslot` will handle Email Routing,
11
- storage, secrets, and a live round-trip test end to end.
8
+ ```sh
9
+ npx create-mailslot
10
+ ```
11
+
12
+ The wizard:
13
+
14
+ 1. **Scaffolds a project you own** — a thin worker depending on
15
+ `@mailslot/core`, deployed from your machine with wrangler
16
+ 2. **Guards your existing mail** — if the domain's apex already receives
17
+ mail elsewhere (Google Workspace, Lark, O365…), the wizard tells you the
18
+ truth: Email Routing is zone-level, so that domain can't host Mailslot —
19
+ not even on a subdomain — without breaking its mail. It steers you to a
20
+ different domain instead of letting you find out the hard way
21
+ 3. **Provisions everything** — R2 bucket, generated API token (secret),
22
+ worker deploy with your domain baked in
23
+ 4. **Sets up Email Routing** — fully automatic with a Cloudflare API token
24
+ (`Zone → Email Routing Rules → Edit`), or guided dashboard steps with
25
+ live DNS verification if you'd rather click
26
+ 5. **Proves it works** — ends with a live round-trip: send an email from
27
+ your phone, watch it appear in your terminal
28
+
29
+ ## Flags (all optional — omitted values are prompted)
30
+
31
+ ```
32
+ --dir <path> project directory (default: mailslot)
33
+ --domain <domain> email domain on Cloudflare (e.g. mail.example.com)
34
+ --worker-name <name> worker name (default: mailslot)
35
+ --cf-token <token> Cloudflare API token for Email Routing setup
36
+ --core-spec <spec> @mailslot/core version/spec (default: ^0.0.2)
37
+ --skip-install / --skip-routing / --skip-test
38
+ ```
39
+
40
+ `CLOUDFLARE_API_TOKEN` in the environment is also honored.
41
+
42
+ Requirements: Node 18+, a Cloudflare account (free tier works), a domain on
43
+ Cloudflare.
package/index.js CHANGED
@@ -1,13 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { run } from "./lib/run.js";
2
3
 
3
- console.log(`
4
- Mailslot — self-hosted email inbox for AI agents,
5
- on your own Cloudflare account, on your own domain.
6
-
7
- The deploy wizard isn't released yet. Manual setup
8
- takes ~10 minutes today:
9
-
10
- https://github.com/mailslot/mailslot#quick-start
11
-
12
- Star/watch the repo to catch the wizard release.
13
- `);
4
+ run(process.argv.slice(2)).catch((e) => {
5
+ console.error(`\ncreate-mailslot failed: ${e.message}`);
6
+ process.exit(1);
7
+ });
package/lib/cf-api.js ADDED
@@ -0,0 +1,47 @@
1
+ const API = "https://api.cloudflare.com/client/v4";
2
+
3
+ async function cf(token, path, init = {}) {
4
+ const res = await fetch(API + path, {
5
+ ...init,
6
+ headers: {
7
+ authorization: `Bearer ${token}`,
8
+ "content-type": "application/json",
9
+ ...init.headers
10
+ }
11
+ });
12
+ const body = await res.json().catch(() => null);
13
+ if (!body?.success) {
14
+ const msg = body?.errors?.map((e) => e.message).join("; ") || `HTTP ${res.status}`;
15
+ const err = new Error(msg);
16
+ err.status = res.status;
17
+ throw err;
18
+ }
19
+ return body.result;
20
+ }
21
+
22
+ export async function zoneId(token, zoneName) {
23
+ const zones = await cf(token, `/zones?name=${encodeURIComponent(zoneName)}`);
24
+ return zones?.[0]?.id ?? null;
25
+ }
26
+
27
+ export function routingStatus(token, zid) {
28
+ return cf(token, `/zones/${zid}/email/routing`);
29
+ }
30
+
31
+ /** Enable Email Routing on the zone apex (adds MX/SPF). Best-effort. */
32
+ export function enableRouting(token, zid) {
33
+ return cf(token, `/zones/${zid}/email/routing/enable`, { method: "POST" });
34
+ }
35
+
36
+ /** Point the zone's catch-all rule at a Worker. */
37
+ export function setCatchAllToWorker(token, zid, workerName) {
38
+ return cf(token, `/zones/${zid}/email/routing/rules/catch_all`, {
39
+ method: "PUT",
40
+ body: JSON.stringify({
41
+ name: "mailslot catch-all",
42
+ enabled: true,
43
+ matchers: [{ type: "all" }],
44
+ actions: [{ type: "worker", value: [workerName] }]
45
+ })
46
+ });
47
+ }
package/lib/dns.js ADDED
@@ -0,0 +1,67 @@
1
+ const DOH = "https://cloudflare-dns.com/dns-query";
2
+ const TYPE_IDS = { A: 1, NS: 2, MX: 15 };
3
+
4
+ /** Resolve via DNS-over-HTTPS (no system dig dependency). */
5
+ export async function dohResolve(name, type) {
6
+ const res = await fetch(`${DOH}?name=${encodeURIComponent(name)}&type=${type}`, {
7
+ headers: { accept: "application/dns-json" }
8
+ });
9
+ if (!res.ok) throw new Error(`DNS lookup failed (${res.status})`);
10
+ const data = await res.json();
11
+ return (data.Answer ?? [])
12
+ .filter((a) => a.type === TYPE_IDS[type])
13
+ .map((a) => String(a.data).toLowerCase());
14
+ }
15
+
16
+ export const mxRecords = (domain, resolve = dohResolve) => resolve(domain, "MX");
17
+
18
+ export function isCloudflareMx(records) {
19
+ return records.some((r) => r.includes("mx.cloudflare.net"));
20
+ }
21
+
22
+ export function isCloudflareNs(records) {
23
+ return records.some((r) => r.includes("ns.cloudflare.com"));
24
+ }
25
+
26
+ /**
27
+ * Zone-level Email Routing state, inferred from the APEX MX records.
28
+ *
29
+ * Email Routing is a zone-level feature: subdomains can only be enrolled
30
+ * AFTER the zone has Email Routing enabled, and enabling it requires
31
+ * Cloudflare to own the apex MX. Consequences:
32
+ * "cloudflare" — routing enabled; apex and subdomains both usable
33
+ * "none" — no mail yet; routing can be enabled cleanly
34
+ * "foreign" — apex mail lives elsewhere (Google/Lark/O365…); the zone
35
+ * CANNOT be used for Mailslot — not even via a subdomain —
36
+ * without deleting the provider's MX (breaking that mail)
37
+ */
38
+ export function zoneRoutingState(apexMxRecords) {
39
+ if (apexMxRecords.length === 0) return "none";
40
+ return isCloudflareMx(apexMxRecords) ? "cloudflare" : "foreign";
41
+ }
42
+
43
+ /**
44
+ * Walk labels upward until a zone apex (has NS records) is found.
45
+ * mail.foo.example.com → example.com (where NS answers).
46
+ */
47
+ export async function findZone(domain, resolve = dohResolve) {
48
+ const labels = domain.split(".").filter(Boolean);
49
+ for (let i = 0; i <= labels.length - 2; i++) {
50
+ const candidate = labels.slice(i).join(".");
51
+ const ns = await resolve(candidate, "NS");
52
+ if (ns.length > 0) return { zone: candidate, ns };
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /** Poll until the domain's MX records point at Cloudflare, or time out. */
58
+ export async function waitForCloudflareMx(domain, { timeoutMs = 300_000, intervalMs = 5_000, onTick } = {}) {
59
+ const deadline = Date.now() + timeoutMs;
60
+ while (Date.now() < deadline) {
61
+ const mx = await mxRecords(domain).catch(() => []);
62
+ if (isCloudflareMx(mx)) return true;
63
+ onTick?.();
64
+ await new Promise((r) => setTimeout(r, intervalMs));
65
+ }
66
+ return false;
67
+ }
package/lib/run.js ADDED
@@ -0,0 +1,339 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import * as p from "@clack/prompts";
5
+ import { sh, shInteractive, sleep } from "./sh.js";
6
+ import { mxRecords, isCloudflareMx, isCloudflareNs, findZone, waitForCloudflareMx, zoneRoutingState } from "./dns.js";
7
+ import { templates, writeScaffold } from "./scaffold.js";
8
+ import { zoneId, routingStatus, enableRouting, setCatchAllToWorker } from "./cf-api.js";
9
+
10
+ export function parseArgs(argv) {
11
+ const flags = {};
12
+ for (let i = 0; i < argv.length; i++) {
13
+ const a = argv[i];
14
+ if (!a.startsWith("--")) continue;
15
+ const key = a.slice(2);
16
+ const next = argv[i + 1];
17
+ if (next && !next.startsWith("--")) {
18
+ flags[key] = next;
19
+ i++;
20
+ } else {
21
+ flags[key] = true;
22
+ }
23
+ }
24
+ return flags;
25
+ }
26
+
27
+ function bail(value) {
28
+ if (p.isCancel(value)) {
29
+ p.cancel("Setup cancelled — nothing was deployed.");
30
+ process.exit(0);
31
+ }
32
+ return value;
33
+ }
34
+
35
+ export async function run(argv) {
36
+ const flags = parseArgs(argv);
37
+
38
+ p.intro("create-mailslot — your agent's inbox, on your own Cloudflare account");
39
+
40
+ // ---- gather inputs ----
41
+ const dir = resolve(
42
+ flags.dir ??
43
+ bail(await p.text({ message: "Project directory", initialValue: "mailslot", validate: (v) => (v.trim() ? undefined : "required") }))
44
+ );
45
+
46
+ let domain = String(
47
+ flags.domain ??
48
+ bail(
49
+ await p.text({
50
+ message: "Email domain for agent addresses (on Cloudflare)",
51
+ placeholder: "mail.example.com",
52
+ validate: (v) => (/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(v.trim()) ? undefined : "enter a domain like mail.example.com")
53
+ })
54
+ )
55
+ )
56
+ .trim()
57
+ .toLowerCase();
58
+
59
+ const workerName = String(
60
+ flags["worker-name"] ??
61
+ bail(await p.text({ message: "Worker name", initialValue: "mailslot", validate: (v) => (/^[a-z0-9-]+$/.test(v) ? undefined : "lowercase letters, digits, dashes") }))
62
+ );
63
+
64
+ // ---- DNS analysis & the zone-routing reality check ----
65
+ // Email Routing is zone-level: a subdomain only works once the ZONE has
66
+ // Email Routing enabled, and enabling it requires Cloudflare to own the
67
+ // apex MX. A zone whose apex mail lives elsewhere cannot host Mailslot
68
+ // at all (not even via a subdomain) without breaking that mail.
69
+ const s = p.spinner();
70
+ let apex;
71
+ let zoneState; // "cloudflare" | "none" | "foreign" (foreign only if user forces)
72
+ for (;;) {
73
+ s.start("Checking DNS");
74
+ const zone = await findZone(domain).catch(() => null);
75
+ if (!zone) {
76
+ s.stop("DNS check failed");
77
+ throw new Error(`could not find a DNS zone for ${domain} — is the domain registered and on Cloudflare?`);
78
+ }
79
+ apex = zone.zone;
80
+ const onCloudflare = isCloudflareNs(zone.ns);
81
+ const apexMx = await mxRecords(apex).catch(() => []);
82
+ zoneState = zoneRoutingState(apexMx);
83
+ s.stop(`Zone: ${apex}${onCloudflare ? " (Cloudflare ✓)" : ""} — email routing: ${zoneState}`);
84
+
85
+ if (!onCloudflare) {
86
+ p.log.warn(`${apex} does not appear to use Cloudflare nameservers.\nMailslot requires the zone to be on Cloudflare (free plan is fine).`);
87
+ const cont = bail(await p.confirm({ message: "Continue anyway?", initialValue: false }));
88
+ if (!cont) return p.outro("Add the domain to Cloudflare first, then re-run.");
89
+ }
90
+
91
+ if (zoneState !== "foreign") break;
92
+
93
+ p.log.error(
94
+ `${apex} already receives mail elsewhere (MX: ${apexMx[0]} …).\n\n` +
95
+ `Cloudflare Email Routing is zone-level: enabling it requires replacing\n` +
96
+ `the apex MX records, and subdomains can only be added AFTER the zone\n` +
97
+ `has Email Routing enabled. This domain cannot host Mailslot — not even\n` +
98
+ `on a subdomain — without breaking its existing email.`
99
+ );
100
+ if (flags.domain) {
101
+ throw new Error(`${apex} has a third-party mail provider — use a different domain (zone) for Mailslot`);
102
+ }
103
+ const choice = bail(
104
+ await p.select({
105
+ message: "What now?",
106
+ options: [
107
+ { value: "different", label: "Use a different domain (recommended — a spare domain or a fresh one)" },
108
+ { value: "force", label: `Proceed with ${apex} anyway (I will delete my provider's MX — BREAKS existing mail)` },
109
+ { value: "abort", label: "Abort" }
110
+ ]
111
+ })
112
+ );
113
+ if (choice === "abort") return p.outro("No changes made. A dedicated domain for agent mail is cheap and clean.");
114
+ if (choice === "force") {
115
+ const sure = bail(await p.confirm({ message: `Really proceed? Mail to @${apex} will stop working until you reconfigure it.`, initialValue: false }));
116
+ if (sure) break;
117
+ continue;
118
+ }
119
+ domain = String(
120
+ bail(
121
+ await p.text({
122
+ message: "Email domain for agent addresses (on Cloudflare)",
123
+ placeholder: "agentmail-domain.com",
124
+ validate: (v) => (/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(v.trim()) ? undefined : "enter a valid domain")
125
+ })
126
+ )
127
+ )
128
+ .trim()
129
+ .toLowerCase();
130
+ }
131
+
132
+ const domainMx = await mxRecords(domain).catch(() => []);
133
+ const routingReady = isCloudflareMx(domainMx);
134
+
135
+ // ---- scaffold ----
136
+ const coreSpec = flags["core-spec"] ?? "^0.0.2";
137
+ const files = templates({ workerName, coreSpec });
138
+ await writeScaffold(dir, files);
139
+ p.log.success(`Scaffolded ${dir}`);
140
+
141
+ if (!flags["skip-install"]) {
142
+ p.log.step("Installing dependencies…");
143
+ const code = await shInteractive("npm", ["install", "--no-fund", "--no-audit"], { cwd: dir });
144
+ if (code !== 0) throw new Error("npm install failed");
145
+ }
146
+
147
+ const wrangler = (args, opts = {}) => sh("npx", ["wrangler", ...args], { cwd: dir, ...opts });
148
+
149
+ // ---- wrangler auth ----
150
+ const who = await wrangler(["whoami"]);
151
+ if (!/You are logged in/i.test(who.all)) {
152
+ p.log.step("Logging in to Cloudflare (browser will open)…");
153
+ const code = await shInteractive("npx", ["wrangler", "login"], { cwd: dir });
154
+ if (code !== 0) throw new Error("wrangler login failed");
155
+ }
156
+
157
+ // ---- provision: bucket, token, deploy ----
158
+ s.start("Creating R2 bucket");
159
+ const bucket = await wrangler(["r2", "bucket", "create", `${workerName}-raw`]);
160
+ if (bucket.code !== 0 && !/already exists/i.test(bucket.all)) {
161
+ s.stop("R2 bucket failed");
162
+ throw new Error(`could not create R2 bucket: ${bucket.err.slice(0, 300)}`);
163
+ }
164
+ s.stop("R2 bucket ready");
165
+
166
+ const token = randomBytes(24).toString("hex");
167
+ s.start("Setting MAILSLOT_TOKEN secret");
168
+ const secret = await wrangler(["secret", "put", "MAILSLOT_TOKEN"], { input: token + "\n" });
169
+ if (secret.code !== 0) {
170
+ s.stop("Secret failed");
171
+ throw new Error(`could not set secret: ${secret.err.slice(0, 300)}`);
172
+ }
173
+ s.stop("Token secret set");
174
+
175
+ s.start("Deploying worker");
176
+ const deploy = await wrangler(["deploy", "--var", `EMAIL_DOMAIN:${domain}`]);
177
+ if (deploy.code !== 0) {
178
+ s.stop("Deploy failed");
179
+ throw new Error(`wrangler deploy failed:\n${deploy.all.slice(-600)}`);
180
+ }
181
+ const workerUrl = deploy.all.match(/https:\/\/[a-z0-9.-]+\.workers\.dev/i)?.[0] ?? null;
182
+ s.stop(`Deployed${workerUrl ? `: ${workerUrl}` : ""}`);
183
+
184
+ await writeFile(join(dir, ".dev.vars"), `MAILSLOT_TOKEN=${token}\nEMAIL_DOMAIN=${domain}\n`);
185
+
186
+ if (workerUrl) {
187
+ const health = await fetch(`${workerUrl}/v1/health`).then((r) => r.ok).catch(() => false);
188
+ if (health) p.log.success("Health check passed");
189
+ }
190
+
191
+ // ---- email routing ----
192
+ if (!flags["skip-routing"]) {
193
+ await setupRouting({ apex, domain, workerName, routingReady, zoneState, flags });
194
+ }
195
+
196
+ // ---- round-trip finale ----
197
+ if (!flags["skip-test"] && workerUrl) {
198
+ await roundTrip({ workerUrl, token, domain });
199
+ }
200
+
201
+ p.note(
202
+ [
203
+ `Worker ${workerUrl ?? "(see wrangler output)"}`,
204
+ `Domain ${domain}`,
205
+ `API token ${dir}/.dev.vars (keep it safe)`,
206
+ ``,
207
+ `Connect an agent (MCP):`,
208
+ ` claude mcp add mailslot ${workerUrl ?? "<worker-url>"}/mcp \\`,
209
+ ` --transport http --header "Authorization: Bearer <token>"`,
210
+ ``,
211
+ `Mint an address:`,
212
+ ` curl -X POST ${workerUrl ?? "<worker-url>"}/v1/addresses \\`,
213
+ ` -H "Authorization: Bearer <token>"`
214
+ ].join("\n"),
215
+ "Your agent has email now"
216
+ );
217
+ p.outro("Docs & issues: https://github.com/mailslot/mailslot");
218
+ }
219
+
220
+ async function setupRouting({ apex, domain, workerName, routingReady, zoneState, flags }) {
221
+ const isSubdomain = domain !== apex;
222
+ let token = flags["cf-token"] ?? process.env.CLOUDFLARE_API_TOKEN ?? null;
223
+ if (!token) {
224
+ const entered = bail(
225
+ await p.password({
226
+ message:
227
+ "Cloudflare API token for Email Routing setup (Enter to skip and use guided steps).\n" +
228
+ " Create one at dash.cloudflare.com → My Profile → API Tokens with:\n" +
229
+ " Zone → Email Routing Rules → Edit, Zone → Zone → Read (this zone)",
230
+ mask: "•"
231
+ })
232
+ );
233
+ token = entered && String(entered).trim() ? String(entered).trim() : null;
234
+ }
235
+
236
+ if (token) {
237
+ try {
238
+ const zid = await zoneId(token, apex);
239
+ if (!zid) throw new Error(`zone ${apex} not visible to this token`);
240
+
241
+ // Zone-level enable comes first — subdomains can only be enrolled
242
+ // once the zone has Email Routing.
243
+ if (zoneState !== "cloudflare") {
244
+ const status = await routingStatus(token, zid).catch(() => null);
245
+ if (status?.enabled !== true) await enableRouting(token, zid);
246
+ await waitForCloudflareMx(apex, { timeoutMs: 120_000 });
247
+ p.log.success(`Email Routing enabled on ${apex}`);
248
+ }
249
+
250
+ if (!routingReady && isSubdomain) {
251
+ // No public API for subdomain enrollment — guided step, then verify.
252
+ await guidedSubdomain(apex, domain);
253
+ }
254
+
255
+ await setCatchAllToWorker(token, zid, workerName);
256
+ p.log.success(`Catch-all rule → worker "${workerName}"`);
257
+ return;
258
+ } catch (e) {
259
+ p.log.warn(`API setup incomplete (${e.message}) — falling back to guided steps.`);
260
+ }
261
+ }
262
+
263
+ // Guided path
264
+ if (!routingReady) {
265
+ if (zoneState !== "cloudflare") {
266
+ p.note(
267
+ [
268
+ `1. dash.cloudflare.com → ${apex} → Email Routing`,
269
+ ...(zoneState === "foreign"
270
+ ? [`2. Delete the existing third-party MX records when prompted`, ` (this is the step that breaks your old mail — you chose this)`, `3. Enable Email Routing (accept the MX/SPF records)`]
271
+ : [`2. Enable Email Routing (accept the MX/SPF records)`])
272
+ ].join("\n"),
273
+ `Enable Email Routing on ${apex}`
274
+ );
275
+ await waitForMxInteractive(apex);
276
+ }
277
+ if (isSubdomain) await guidedSubdomain(apex, domain);
278
+ }
279
+ p.note(
280
+ [
281
+ `1. ${apex} → Email Routing → Routing rules`,
282
+ `2. Catch-all address → Edit`,
283
+ `3. Action: "Send to a Worker" → ${workerName}`,
284
+ `4. Toggle: Enabled ← easy to miss`
285
+ ].join("\n"),
286
+ "Point mail at the worker"
287
+ );
288
+ bail(await p.confirm({ message: "Catch-all rule set and enabled?", initialValue: true }));
289
+ }
290
+
291
+ async function guidedSubdomain(apex, domain) {
292
+ const sub = domain.slice(0, -(apex.length + 1));
293
+ p.note(
294
+ [
295
+ `(requires Email Routing already enabled on ${apex})`,
296
+ `1. dash.cloudflare.com → ${apex} → Email Routing → Settings`,
297
+ `2. Subdomains → add "${sub}"`,
298
+ `3. Cloudflare writes MX/SPF records on ${domain}`
299
+ ].join("\n"),
300
+ "Add the subdomain to Email Routing"
301
+ );
302
+ await waitForMxInteractive(domain);
303
+ }
304
+
305
+ async function waitForMxInteractive(domain) {
306
+ const s = p.spinner();
307
+ s.start(`Waiting for Cloudflare MX records on ${domain}`);
308
+ const ok = await waitForCloudflareMx(domain, { timeoutMs: 300_000 });
309
+ if (ok) s.stop(`MX records live on ${domain}`);
310
+ else {
311
+ s.stop("Timed out waiting for MX records");
312
+ throw new Error(`MX records for ${domain} did not appear — complete the dashboard step and re-run`);
313
+ }
314
+ }
315
+
316
+ async function roundTrip({ workerUrl, token, domain }) {
317
+ const local = `welcome-${randomBytes(3).toString("hex")}`;
318
+ const address = `${local}@${domain}`;
319
+ p.log.step(`Round-trip test — send any email to: ${address}`);
320
+ const s = p.spinner();
321
+ s.start("Waiting for your email (up to 5 minutes, Ctrl+C to skip)");
322
+ const deadline = Date.now() + 300_000;
323
+ while (Date.now() < deadline) {
324
+ try {
325
+ const res = await fetch(
326
+ `${workerUrl}/v1/inboxes/${encodeURIComponent(address)}/wait?timeout_s=50&since_s=600`,
327
+ { headers: { authorization: `Bearer ${token}` } }
328
+ );
329
+ const data = await res.json();
330
+ if (data.message) {
331
+ s.stop(`Received: "${data.message.subject}" from ${data.message.from} ✓`);
332
+ return;
333
+ }
334
+ } catch {
335
+ await sleep(3000);
336
+ }
337
+ }
338
+ s.stop("No email arrived — check the catch-all rule, then test manually (see README).");
339
+ }
@@ -0,0 +1,109 @@
1
+ import { mkdir, readdir, writeFile } from "node:fs/promises";
2
+ import { join, dirname } from "node:path";
3
+
4
+ /** Render the files of a scaffolded Mailslot project. */
5
+ export function templates({ workerName, coreSpec = "^0.0.2" }) {
6
+ const pkg = {
7
+ name: workerName,
8
+ private: true,
9
+ type: "module",
10
+ scripts: {
11
+ dev: "wrangler dev",
12
+ deploy: "wrangler deploy",
13
+ "cf-typegen": "wrangler types"
14
+ },
15
+ dependencies: {
16
+ "@mailslot/core": coreSpec
17
+ },
18
+ devDependencies: {
19
+ wrangler: "^4.0.0"
20
+ }
21
+ };
22
+
23
+ const wranglerConfig = `{
24
+ "$schema": "https://unpkg.com/wrangler/config-schema.json",
25
+ "name": ${JSON.stringify(workerName)},
26
+ "main": "src/index.ts",
27
+ "compatibility_date": "2026-04-01",
28
+ "compatibility_flags": ["nodejs_compat"],
29
+ "durable_objects": {
30
+ "bindings": [
31
+ { "name": "Inbox", "class_name": "Inbox" },
32
+ { "name": "MailslotMcp", "class_name": "MailslotMcp" }
33
+ ]
34
+ },
35
+ "migrations": [
36
+ { "tag": "v1", "new_sqlite_classes": ["Inbox", "MailslotMcp"] }
37
+ ],
38
+ "r2_buckets": [
39
+ { "binding": "RAW", "bucket_name": ${JSON.stringify(`${workerName}-raw`)} }
40
+ ],
41
+ "vars": {
42
+ "FORWARD_MODE": "none"
43
+ },
44
+ // Instance values (EMAIL_DOMAIN var, MAILSLOT_TOKEN secret) are set by the
45
+ // wizard and survive deploys thanks to keep_vars.
46
+ "keep_vars": true,
47
+ "alias": {
48
+ // Optional dep of the agents SDK, dynamically imported in unused paths
49
+ "ai": "./src/shims/ai.ts"
50
+ },
51
+ "observability": { "enabled": true }
52
+ }
53
+ `;
54
+
55
+ const indexTs = `export { Inbox, MailslotMcp } from "@mailslot/core";
56
+ export { default } from "@mailslot/core";
57
+ `;
58
+
59
+ const aiShim = `/** Stub for the agents SDK's optional dynamic import("ai"). Unused by Mailslot. */
60
+ export function jsonSchema(): never {
61
+ throw new Error("The 'ai' package is not installed — this code path is unused by Mailslot.");
62
+ }
63
+ `;
64
+
65
+ const devVarsExample = `# Copy to .dev.vars for local development (never commit .dev.vars)
66
+ MAILSLOT_TOKEN=replace-me
67
+ EMAIL_DOMAIN=mail.example.com
68
+ `;
69
+
70
+ const gitignore = `node_modules/
71
+ .wrangler/
72
+ .dev.vars
73
+ `;
74
+
75
+ const readme = `# ${workerName}
76
+
77
+ A self-hosted [Mailslot](https://github.com/mailslot/mailslot) instance —
78
+ email inbox for AI agents on your own Cloudflare account.
79
+
80
+ Created with \`npx create-mailslot\`. Deploy updates with \`npm run deploy\`;
81
+ upgrade by bumping \`@mailslot/core\`.
82
+
83
+ Your API/MCP token is in \`.dev.vars\` (gitignored). MCP endpoint: \`/mcp\`.
84
+ `;
85
+
86
+ return {
87
+ "package.json": JSON.stringify(pkg, null, 2) + "\n",
88
+ "wrangler.jsonc": wranglerConfig,
89
+ "src/index.ts": indexTs,
90
+ "src/shims/ai.ts": aiShim,
91
+ ".dev.vars.example": devVarsExample,
92
+ ".gitignore": gitignore,
93
+ "README.md": readme
94
+ };
95
+ }
96
+
97
+ /** Write scaffold files. Refuses to write into a non-empty directory. */
98
+ export async function writeScaffold(dir, files) {
99
+ await mkdir(dir, { recursive: true });
100
+ const existing = await readdir(dir);
101
+ if (existing.length > 0) {
102
+ throw new Error(`directory "${dir}" is not empty — pick a fresh directory`);
103
+ }
104
+ for (const [rel, content] of Object.entries(files)) {
105
+ const path = join(dir, rel);
106
+ await mkdir(dirname(path), { recursive: true });
107
+ await writeFile(path, content);
108
+ }
109
+ }
package/lib/sh.js ADDED
@@ -0,0 +1,31 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /** Run a command, capture output. Never rejects — inspect .code. */
4
+ export function sh(cmd, args, opts = {}) {
5
+ return new Promise((resolve) => {
6
+ const child = spawn(cmd, args, {
7
+ stdio: ["pipe", "pipe", "pipe"],
8
+ cwd: opts.cwd,
9
+ env: { ...process.env, ...opts.env }
10
+ });
11
+ let out = "";
12
+ let err = "";
13
+ child.stdout.on("data", (d) => (out += d));
14
+ child.stderr.on("data", (d) => (err += d));
15
+ if (opts.input !== undefined) child.stdin.write(opts.input);
16
+ child.stdin.end();
17
+ child.on("error", (e) => resolve({ code: -1, out, err: String(e), all: out + err }));
18
+ child.on("close", (code) => resolve({ code, out, err, all: out + err }));
19
+ });
20
+ }
21
+
22
+ /** Run a command with inherited stdio (interactive). Resolves with exit code. */
23
+ export function shInteractive(cmd, args, opts = {}) {
24
+ return new Promise((resolve) => {
25
+ const child = spawn(cmd, args, { stdio: "inherit", cwd: opts.cwd });
26
+ child.on("error", () => resolve(-1));
27
+ child.on("close", (code) => resolve(code));
28
+ });
29
+ }
30
+
31
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
package/package.json CHANGED
@@ -1,19 +1,26 @@
1
1
  {
2
2
  "name": "create-mailslot",
3
- "version": "0.0.1",
4
- "description": "Deploy Mailslot a self-hosted email inbox for AI agents to your own Cloudflare account. Wizard coming soon.",
3
+ "version": "0.1.0",
4
+ "description": "Deploy Mailslot \u2014 a self-hosted email inbox for AI agents \u2014 to your own Cloudflare account.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
- "create-mailslot": "./index.js"
8
+ "create-mailslot": "index.js"
9
9
  },
10
10
  "files": [
11
11
  "index.js",
12
+ "lib",
12
13
  "README.md"
13
14
  ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "dependencies": {
19
+ "@clack/prompts": "^0.11.0"
20
+ },
14
21
  "repository": {
15
22
  "type": "git",
16
- "url": "https://github.com/mailslot/mailslot"
23
+ "url": "git+https://github.com/mailslot/mailslot.git"
17
24
  },
18
25
  "homepage": "https://mailslot.dev",
19
26
  "keywords": [
@@ -26,6 +33,6 @@
26
33
  ],
27
34
  "scripts": {
28
35
  "typecheck": "true",
29
- "test": "true"
36
+ "test": "node --test"
30
37
  }
31
38
  }