shiply-cli 0.15.0 → 0.16.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/email.js ADDED
@@ -0,0 +1,147 @@
1
+ /** `shiply email <subcommand>` — agent email send + inbox.
2
+ *
3
+ * Pattern mirrors functions.ts: readFlags helper, exported runEmail
4
+ * entry point, individual async command functions. Top-level flags
5
+ * (--base / --key) are forwarded from index.ts via InheritedFlags. */
6
+ import { loadApiKey } from './config.js';
7
+ import { api, resolveBase } from './publish.js';
8
+ function readFlags(argv, inherited = {}) {
9
+ const raw = {};
10
+ const rest = [];
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const a = argv[i];
13
+ if (a.startsWith('--')) {
14
+ const eq = a.indexOf('=');
15
+ if (eq >= 0) {
16
+ raw[a.slice(2, eq)] = a.slice(eq + 1);
17
+ }
18
+ else {
19
+ const next = argv[i + 1];
20
+ if (next !== undefined && !next.startsWith('--')) {
21
+ raw[a.slice(2)] = next;
22
+ i++;
23
+ }
24
+ else {
25
+ raw[a.slice(2)] = true;
26
+ }
27
+ }
28
+ }
29
+ else {
30
+ rest.push(a);
31
+ }
32
+ }
33
+ // For each field: local argv flag takes precedence, then fall back to the
34
+ // value forwarded from index.ts parseArgs via inherited (the top-level
35
+ // parseArgs consumed these flags before argv reached us).
36
+ const flags = {
37
+ base: (typeof raw.base === 'string' ? raw.base : undefined) ?? inherited.base,
38
+ key: (typeof raw.key === 'string' ? raw.key : undefined) ?? inherited.key,
39
+ site: (typeof raw.site === 'string' ? raw.site : undefined) ?? inherited.site,
40
+ to: (typeof raw.to === 'string' ? raw.to : undefined) ?? inherited.to,
41
+ subject: (typeof raw.subject === 'string' ? raw.subject : undefined) ?? inherited.subject,
42
+ html: (typeof raw.html === 'string' ? raw.html : undefined) ?? inherited.html,
43
+ text: (typeof raw.text === 'string' ? raw.text : undefined) ?? inherited.text,
44
+ };
45
+ return { rest, flags };
46
+ }
47
+ const headers = (apiKey) => ({
48
+ 'content-type': 'application/json',
49
+ authorization: `Bearer ${apiKey}`,
50
+ });
51
+ async function getApiKey(flags, what) {
52
+ const k = flags.key ?? (await loadApiKey());
53
+ if (!k)
54
+ throw new Error(`${what} commands need an API key — run \`shiply login\` first`);
55
+ return k;
56
+ }
57
+ const EMAIL_USAGE = [
58
+ 'Usage: shiply email <send|inbox>',
59
+ ' shiply email send --site <slug> --to <addr> --subject <s> --html <h> [--text <t>]',
60
+ ' shiply email inbox [--site <slug>]',
61
+ ].join('\n');
62
+ export async function runEmail(argv, inherited = {}) {
63
+ const action = argv[0];
64
+ const rest = argv.slice(1);
65
+ switch (action) {
66
+ case 'send':
67
+ return emailSendCmd(rest, inherited);
68
+ case 'inbox':
69
+ return emailInboxCmd(rest, inherited);
70
+ default:
71
+ console.error(EMAIL_USAGE);
72
+ process.exit(1);
73
+ }
74
+ }
75
+ async function emailSendCmd(argv, inherited) {
76
+ const { flags } = readFlags(argv, inherited);
77
+ if (!flags.site) {
78
+ console.error('--site <slug> is required');
79
+ console.error(EMAIL_USAGE);
80
+ process.exit(1);
81
+ }
82
+ if (!flags.to) {
83
+ console.error('--to <addr> is required');
84
+ console.error(EMAIL_USAGE);
85
+ process.exit(1);
86
+ }
87
+ if (!flags.subject) {
88
+ console.error('--subject <s> is required');
89
+ console.error(EMAIL_USAGE);
90
+ process.exit(1);
91
+ }
92
+ if (!flags.html) {
93
+ console.error('--html <h> is required');
94
+ console.error(EMAIL_USAGE);
95
+ process.exit(1);
96
+ }
97
+ const apiKey = await getApiKey(flags, 'email');
98
+ const base = resolveBase(flags.base);
99
+ const body = {
100
+ slug: flags.site,
101
+ to: flags.to,
102
+ subject: flags.subject,
103
+ html: flags.html,
104
+ };
105
+ if (flags.text)
106
+ body.text = flags.text;
107
+ const r = await api(`${base}/api/v1/email/send`, {
108
+ method: 'POST',
109
+ headers: headers(apiKey),
110
+ body: JSON.stringify(body),
111
+ });
112
+ console.log(`✔ sent`);
113
+ console.log(` messageId: ${r.messageId}`);
114
+ }
115
+ async function emailInboxCmd(argv, inherited) {
116
+ const { flags } = readFlags(argv, inherited);
117
+ const apiKey = await getApiKey(flags, 'email');
118
+ const base = resolveBase(flags.base);
119
+ const qs = flags.site ? `?slug=${encodeURIComponent(flags.site)}` : '';
120
+ const r = await api(`${base}/api/v1/email/inbox${qs}`, {
121
+ headers: headers(apiKey),
122
+ });
123
+ if (!r.threads.length) {
124
+ console.log('No threads yet.');
125
+ return;
126
+ }
127
+ // Compact table: ID | SITE | FROM | SUBJECT | LAST
128
+ const cols = r.threads.map((t) => ({
129
+ id: t.id,
130
+ site: t.siteSlug ?? '',
131
+ from: t.from ?? '',
132
+ subject: t.subject ?? '',
133
+ last: t.lastAt ?? '',
134
+ count: String(t.messageCount ?? 1),
135
+ }));
136
+ const w = {
137
+ id: Math.max('ID'.length, ...cols.map((c) => c.id.length)),
138
+ site: Math.max('SITE'.length, ...cols.map((c) => c.site.length)),
139
+ from: Math.max('FROM'.length, ...cols.map((c) => c.from.length)),
140
+ subject: Math.max('SUBJECT'.length, ...cols.map((c) => c.subject.length)),
141
+ count: Math.max('#'.length, ...cols.map((c) => c.count.length)),
142
+ };
143
+ console.log(`${'ID'.padEnd(w.id)} ${'SITE'.padEnd(w.site)} ${'FROM'.padEnd(w.from)} ${'SUBJECT'.padEnd(w.subject)} ${'#'.padEnd(w.count)} LAST`);
144
+ for (const c of cols) {
145
+ console.log(`${c.id.padEnd(w.id)} ${c.site.padEnd(w.site)} ${c.from.padEnd(w.from)} ${c.subject.padEnd(w.subject)} ${c.count.padEnd(w.count)} ${c.last}`);
146
+ }
147
+ }
package/dist/index.js CHANGED
@@ -16,6 +16,8 @@ import { runListing } from './listings.js';
16
16
  import { runSendingDomain } from './sending-domains.js';
17
17
  import { contractAmend, contractDraft, contractList, contractPdf, contractRetract, contractSend, contractShow, } from './contract.js';
18
18
  import { runCron, runFunction, runSecret } from './functions.js';
19
+ import { runEmail } from './email.js';
20
+ import { runMailbox } from './mailbox.js';
19
21
  import { loadApiKey, saveApiKey } from './config.js';
20
22
  import { loginViaDeviceFlow } from './login.js';
21
23
  import { api, ApiError, DEFAULT_BASE, publish, resolveBase } from './publish.js';
@@ -94,6 +96,16 @@ Usage:
94
96
  pdf <contract-id> -o file.pdf — signed PDF download
95
97
  amend <id> --scope "…" [--fee-delta N] [--target-date YYYY-MM-DD]
96
98
  retract <id> [--keep-as-draft] — undo a sent contract
99
+ shiply email send --site <slug> --to <addr> --subject <s> --html <h> [--text <t>]
100
+ Send a transactional email from a site's inbox address
101
+ shiply email inbox [--site <slug>] List email threads (optionally filtered by site)
102
+ shiply mailbox set <slug> <collection> [--confirm] [--no-confirm] [--notify] [--no-notify]
103
+ [--notify-to <email>] [--from <sendingDomainId>]
104
+ Update mailbox settings (double-opt-in, owner notifications…)
105
+ shiply mailbox broadcast <slug> <collection> --subject <s> --html <h> [--text <t>]
106
+ Send a broadcast to all contacts in a mailbox collection
107
+ shiply mailbox contacts <slug> <collection> [--status <signed_up|confirmed|unsubscribed>]
108
+ List contacts in a mailbox collection
97
109
  shiply data init [dir] Scaffold a starter .shiply/data.json in <dir> (default cwd)
98
110
  shiply data list <slug> List collections on a site with counts
99
111
  shiply data query <slug> <coll> [--limit N] [--cursor C] [--where '<json>']
@@ -211,6 +223,17 @@ async function main() {
211
223
  'fee-delta': { type: 'string' },
212
224
  'target-date': { type: 'string' },
213
225
  'keep-as-draft': { type: 'boolean' },
226
+ to: { type: 'string' },
227
+ subject: { type: 'string' },
228
+ html: { type: 'string' },
229
+ text: { type: 'string' },
230
+ status: { type: 'string' },
231
+ confirm: { type: 'boolean' },
232
+ 'no-confirm': { type: 'boolean' },
233
+ notify: { type: 'boolean' },
234
+ 'no-notify': { type: 'boolean' },
235
+ 'notify-to': { type: 'string' },
236
+ from: { type: 'string' },
214
237
  help: { type: 'boolean', short: 'h' },
215
238
  version: { type: 'boolean', short: 'v' },
216
239
  },
@@ -666,6 +689,44 @@ async function main() {
666
689
  throw new Error('usage: shiply contract <list|draft|show|send|pdf|amend|retract>');
667
690
  }
668
691
  }
692
+ case 'email': {
693
+ await runEmail(positionals.slice(1), {
694
+ base: values.base,
695
+ key: values.key,
696
+ site: values.site,
697
+ to: values.to,
698
+ subject: values.subject,
699
+ html: values.html,
700
+ text: values.text,
701
+ });
702
+ break;
703
+ }
704
+ case 'mailbox': {
705
+ // Resolve tri-state for confirm/notify:
706
+ // --confirm → true (values.confirm === true)
707
+ // --no-confirm → false (values['no-confirm'] === true)
708
+ // neither provided → undefined (omit from PATCH body)
709
+ const confirmValue = values.confirm === true ? true :
710
+ values['no-confirm'] === true ? false :
711
+ undefined;
712
+ const notifyValue = values.notify === true ? true :
713
+ values['no-notify'] === true ? false :
714
+ undefined;
715
+ await runMailbox(positionals.slice(1), {
716
+ base: values.base,
717
+ key: values.key,
718
+ site: values.site,
719
+ subject: values.subject,
720
+ html: values.html,
721
+ text: values.text,
722
+ status: values.status,
723
+ ...(confirmValue !== undefined ? { confirm: confirmValue } : {}),
724
+ ...(notifyValue !== undefined ? { notify: notifyValue } : {}),
725
+ 'notify-to': values['notify-to'],
726
+ from: values.from,
727
+ });
728
+ break;
729
+ }
669
730
  case 'claim': {
670
731
  if (dir !== 'verify') {
671
732
  console.error('Usage: shiply claim verify <SHIPLY-XXXXXXXX>');
@@ -0,0 +1,199 @@
1
+ /** `shiply mailbox <subcommand>` — mailbox settings, broadcasts, contacts.
2
+ *
3
+ * Pattern mirrors functions.ts: readFlags helper, exported runMailbox
4
+ * entry point, individual async command functions. Top-level flags
5
+ * (--base / --key) are forwarded from index.ts via InheritedFlags. */
6
+ import { loadApiKey } from './config.js';
7
+ import { api, resolveBase } from './publish.js';
8
+ function readFlags(argv, inherited = {}) {
9
+ const raw = {};
10
+ const rest = [];
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const a = argv[i];
13
+ if (a.startsWith('--no-')) {
14
+ // --no-confirm, --no-notify → store as false
15
+ raw[a.slice(5)] = false;
16
+ }
17
+ else if (a.startsWith('--')) {
18
+ const eq = a.indexOf('=');
19
+ if (eq >= 0) {
20
+ raw[a.slice(2, eq)] = a.slice(eq + 1);
21
+ }
22
+ else {
23
+ const next = argv[i + 1];
24
+ if (next !== undefined && !next.startsWith('--')) {
25
+ raw[a.slice(2)] = next;
26
+ i++;
27
+ }
28
+ else {
29
+ raw[a.slice(2)] = true;
30
+ }
31
+ }
32
+ }
33
+ else {
34
+ rest.push(a);
35
+ }
36
+ }
37
+ // For each field: local argv flag takes precedence, then fall back to the
38
+ // value forwarded from index.ts parseArgs via inherited (the top-level
39
+ // parseArgs consumed these flags before argv reached us).
40
+ const flags = {
41
+ base: (typeof raw.base === 'string' ? raw.base : undefined) ?? inherited.base,
42
+ key: (typeof raw.key === 'string' ? raw.key : undefined) ?? inherited.key,
43
+ from: (typeof raw.from === 'string' ? raw.from : undefined) ?? inherited.from,
44
+ 'notify-to': (typeof raw['notify-to'] === 'string' ? raw['notify-to'] : undefined) ?? inherited['notify-to'],
45
+ subject: (typeof raw.subject === 'string' ? raw.subject : undefined) ?? inherited.subject,
46
+ html: (typeof raw.html === 'string' ? raw.html : undefined) ?? inherited.html,
47
+ text: (typeof raw.text === 'string' ? raw.text : undefined) ?? inherited.text,
48
+ status: (typeof raw.status === 'string' ? raw.status : undefined) ?? inherited.status,
49
+ };
50
+ // Boolean toggles — only set when the flag was explicitly provided (local
51
+ // argv), or when index.ts forwarded a parsed value via inherited.
52
+ if ('confirm' in raw) {
53
+ flags.confirm = Boolean(raw.confirm);
54
+ }
55
+ else if (inherited.confirm !== undefined) {
56
+ flags.confirm = inherited.confirm;
57
+ }
58
+ if ('notify' in raw) {
59
+ flags.notify = Boolean(raw.notify);
60
+ }
61
+ else if (inherited.notify !== undefined) {
62
+ flags.notify = inherited.notify;
63
+ }
64
+ return { rest, flags };
65
+ }
66
+ const headers = (apiKey) => ({
67
+ 'content-type': 'application/json',
68
+ authorization: `Bearer ${apiKey}`,
69
+ });
70
+ async function getApiKey(flags, what) {
71
+ const k = flags.key ?? (await loadApiKey());
72
+ if (!k)
73
+ throw new Error(`${what} commands need an API key — run \`shiply login\` first`);
74
+ return k;
75
+ }
76
+ const MAILBOX_USAGE = [
77
+ 'Usage: shiply mailbox <set|broadcast|contacts>',
78
+ ' shiply mailbox set <slug> <collection> [--confirm] [--no-confirm] [--notify] [--no-notify] [--notify-to <email>] [--from <sendingDomainId>]',
79
+ ' shiply mailbox broadcast <slug> <collection> --subject <s> --html <h> [--text <t>]',
80
+ ' shiply mailbox contacts <slug> <collection> [--status <signed_up|confirmed|unsubscribed>]',
81
+ ].join('\n');
82
+ export async function runMailbox(argv, inherited = {}) {
83
+ const action = argv[0];
84
+ const rest = argv.slice(1);
85
+ switch (action) {
86
+ case 'set':
87
+ return mailboxSetCmd(rest, inherited);
88
+ case 'broadcast':
89
+ return mailboxBroadcastCmd(rest, inherited);
90
+ case 'contacts':
91
+ return mailboxContactsCmd(rest, inherited);
92
+ default:
93
+ console.error(MAILBOX_USAGE);
94
+ process.exit(1);
95
+ }
96
+ }
97
+ async function mailboxSetCmd(argv, inherited) {
98
+ const { rest, flags } = readFlags(argv, inherited);
99
+ const [slug, collection] = rest;
100
+ if (!slug || !collection) {
101
+ console.error('usage: shiply mailbox set <slug> <collection> [options]');
102
+ console.error(MAILBOX_USAGE);
103
+ process.exit(1);
104
+ }
105
+ const apiKey = await getApiKey(flags, 'mailbox');
106
+ const base = resolveBase(flags.base);
107
+ // Only include fields that were explicitly provided
108
+ const body = {};
109
+ if (flags.confirm !== undefined)
110
+ body.doubleOptIn = flags.confirm;
111
+ if (flags.notify !== undefined)
112
+ body.notifyOwner = flags.notify;
113
+ if (flags['notify-to'] !== undefined)
114
+ body.notifyTo = flags['notify-to'];
115
+ if (flags.from !== undefined)
116
+ body.sendingDomainId = flags.from;
117
+ const r = await api(`${base}/api/v1/publishes/${encodeURIComponent(slug)}/mailboxes/${encodeURIComponent(collection)}`, {
118
+ method: 'PATCH',
119
+ headers: headers(apiKey),
120
+ body: JSON.stringify(body),
121
+ });
122
+ console.log(`✔ mailbox updated for ${slug}/${collection}`);
123
+ if (r.doubleOptIn !== undefined)
124
+ console.log(` doubleOptIn: ${r.doubleOptIn}`);
125
+ if (r.notifyOwner !== undefined)
126
+ console.log(` notifyOwner: ${r.notifyOwner}`);
127
+ if (r.notifyTo !== undefined && r.notifyTo !== null)
128
+ console.log(` notifyTo: ${r.notifyTo}`);
129
+ if (r.sendingDomainId !== undefined && r.sendingDomainId !== null)
130
+ console.log(` sendingDomainId: ${r.sendingDomainId}`);
131
+ }
132
+ async function mailboxBroadcastCmd(argv, inherited) {
133
+ const { rest, flags } = readFlags(argv, inherited);
134
+ const [slug, collection] = rest;
135
+ if (!slug || !collection) {
136
+ console.error('usage: shiply mailbox broadcast <slug> <collection> --subject <s> --html <h>');
137
+ console.error(MAILBOX_USAGE);
138
+ process.exit(1);
139
+ }
140
+ if (!flags.subject) {
141
+ console.error('--subject <s> is required');
142
+ console.error(MAILBOX_USAGE);
143
+ process.exit(1);
144
+ }
145
+ if (!flags.html) {
146
+ console.error('--html <h> is required');
147
+ console.error(MAILBOX_USAGE);
148
+ process.exit(1);
149
+ }
150
+ const apiKey = await getApiKey(flags, 'mailbox');
151
+ const base = resolveBase(flags.base);
152
+ const body = {
153
+ subject: flags.subject,
154
+ html: flags.html,
155
+ };
156
+ if (flags.text)
157
+ body.text = flags.text;
158
+ const r = await api(`${base}/api/v1/publishes/${encodeURIComponent(slug)}/mailboxes/${encodeURIComponent(collection)}/broadcast`, {
159
+ method: 'POST',
160
+ headers: headers(apiKey),
161
+ body: JSON.stringify(body),
162
+ });
163
+ console.log(`✔ broadcast queued`);
164
+ console.log(` broadcastId: ${r.broadcastId}`);
165
+ console.log(` recipientCount: ${r.recipientCount}`);
166
+ }
167
+ async function mailboxContactsCmd(argv, inherited) {
168
+ const { rest, flags } = readFlags(argv, inherited);
169
+ const [slug, collection] = rest;
170
+ if (!slug || !collection) {
171
+ console.error('usage: shiply mailbox contacts <slug> <collection> [--status <...>]');
172
+ console.error(MAILBOX_USAGE);
173
+ process.exit(1);
174
+ }
175
+ const apiKey = await getApiKey(flags, 'mailbox');
176
+ const base = resolveBase(flags.base);
177
+ const qs = flags.status ? `?status=${encodeURIComponent(flags.status)}` : '';
178
+ const r = await api(`${base}/api/v1/publishes/${encodeURIComponent(slug)}/mailboxes/${encodeURIComponent(collection)}/contacts${qs}`, { headers: headers(apiKey) });
179
+ if (!r.contacts.length) {
180
+ console.log(`No contacts in ${slug}/${collection}${flags.status ? ` (status: ${flags.status})` : ''} yet.`);
181
+ return;
182
+ }
183
+ // Compact table: EMAIL | STATUS | CONFIRMED | CREATED
184
+ const cols = r.contacts.map((c) => ({
185
+ email: c.email,
186
+ status: c.status ?? '',
187
+ confirmed: c.confirmedAt ?? '',
188
+ created: c.createdAt ?? '',
189
+ }));
190
+ const w = {
191
+ email: Math.max('EMAIL'.length, ...cols.map((c) => c.email.length)),
192
+ status: Math.max('STATUS'.length, ...cols.map((c) => c.status.length)),
193
+ confirmed: Math.max('CONFIRMED'.length, ...cols.map((c) => c.confirmed.length)),
194
+ };
195
+ console.log(`${'EMAIL'.padEnd(w.email)} ${'STATUS'.padEnd(w.status)} ${'CONFIRMED'.padEnd(w.confirmed)} CREATED`);
196
+ for (const c of cols) {
197
+ console.log(`${c.email.padEnd(w.email)} ${c.status.padEnd(w.status)} ${c.confirmed.padEnd(w.confirmed)} ${c.created}`);
198
+ }
199
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shiply-cli",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Publish static sites to shiply.now from the command line — instant web hosting for agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/skill/SKILL.md CHANGED
@@ -18,7 +18,7 @@ description: Publish static sites to the web instantly with shiply.now and manag
18
18
  > fresh — your cached copy may be missing features like databases, projects,
19
19
  > marketplace, or sending domains.
20
20
  >
21
- > **Last updated: 2026-06-21**
21
+ > **Last updated: 2026-06-22**
22
22
 
23
23
  ---
24
24
 
@@ -154,6 +154,8 @@ lines for automation; exit code 0 = ready.
154
154
  (+ "Authorization: Bearer shp_…" for permanent owned sites)
155
155
  2. PUT each file's bytes to response upload.uploads[].url
156
156
  3. POST upload.finalizeUrl with {"versionId":"..."}
157
+ → both the publish and finalize responses include a "toUpdate" string: the
158
+ exact call to update THIS site next time. Follow it — never create a new one.
157
159
  ```
158
160
 
159
161
  **ALWAYS include `agentName` on anonymous publishes.** The response then
@@ -164,9 +166,11 @@ poll `poll_url` every `interval` seconds with the `device_code`, and on
164
166
  is claimed in the same Allow click. Without `agentName` the user has to
165
167
  do a separate manual claim step every time.
166
168
 
167
- **Updates:** anonymous include `"claimToken":"..."` (returned ONCE by the
168
- first publish save it); owned include `"slug":"..."`. Hashes make updates
169
- cheap: unchanged files are skipped server-side.
169
+ **Updates (the response tells you how):** every publish/finalize response
170
+ includes a `toUpdate` string with the exact call follow it verbatim. In short:
171
+ anonymous → include `"claimToken":"..."` (returned ONCE by the first publish —
172
+ save it); owned → include `"slug":"..."`. Hashes make updates cheap: unchanged
173
+ files are skipped server-side.
170
174
 
171
175
  ## The lifecycle to explain to users
172
176
  - Anonymous site: live instantly, expires in 24 h. Give the user the
@@ -265,6 +269,98 @@ validated server-side; the owner reads them in the dashboard (Data section,
265
269
  CSV export) or GET /api/v1/publishes/<slug>/data/<collection> with a Bearer
266
270
  key. Owned sites only. Docs: /docs/site-data
267
271
 
272
+ ## Agent Email — inbox, send, capture, broadcast (built into every owned site)
273
+
274
+ Every owned site has a real email address and can send, receive, capture signups,
275
+ and broadcast — in one line. Like AgentMail, built into the site.
276
+
277
+ ### Capture (public, zero-config)
278
+ ```
279
+ POST /.shiply/email { "email": "user@example.com", ...anyExtraFields }
280
+ ```
281
+ No manifest needed. Works on the **relative path** from the published page.
282
+ On success: record lands in the site inbox + owner gets a notification ping +
283
+ a double-opt-in confirmation email goes to the visitor (all on by default).
284
+ **Owned sites only** — anonymous sites get `403 email_requires_account` (claim
285
+ the site first). A non-empty `company` field is treated as a honeypot (bot
286
+ submissions are silently dropped). Caps: 16 KB body, 30 fields max.
287
+
288
+ ### Send (authenticated — Bearer key only)
289
+ The public page can NEVER send; only authenticated calls can.
290
+ ```
291
+ POST https://shiply.now/api/v1/email/send
292
+ Authorization: Bearer shp_…
293
+ { "slug": "my-site", "to": "user@example.com", "subject": "Hi", "html": "<p>Hi</p>", "text": "Hi" }
294
+ → { "messageId": "..." }
295
+ ```
296
+
297
+ ### Read inbox
298
+ ```
299
+ GET https://shiply.now/api/v1/email/inbox # all sites
300
+ GET https://shiply.now/api/v1/email/inbox?slug=my-site
301
+ Authorization: Bearer shp_…
302
+ ```
303
+
304
+ ### Typed upgrade (optional)
305
+ Declare an `email` block on a `.shiply/data.json` collection for typed fields +
306
+ fine-grained control:
307
+ ```json
308
+ {
309
+ "collections": {
310
+ "signups": {
311
+ "fields": { "email": { "type": "email", "required": true } },
312
+ "email": {
313
+ "emailField": "email",
314
+ "confirm": true,
315
+ "notify": true,
316
+ "audience": true
317
+ }
318
+ }
319
+ }
320
+ }
321
+ ```
322
+ All three flags default to `true`. With this manifest, POST to
323
+ `/.shiply/data/signups` as usual — the `email` block activates the email
324
+ layer on that collection.
325
+
326
+ ### Confirm / unsubscribe (public — in the confirmation email)
327
+ ```
328
+ GET /api/email/<slug>/<collection>/confirm?token=<token>
329
+ GET /api/email/<slug>/<collection>/unsubscribe?email=<address>
330
+ ```
331
+
332
+ ### Audience + broadcast
333
+ Confirmed (double-opt-in) signups form a list. Broadcast to them:
334
+ ```
335
+ POST /api/v1/publishes/<slug>/mailboxes/<collection>/broadcast
336
+ Authorization: Bearer shp_…
337
+ { "subject": "Launch day", "html": "<p>We're live!</p>" }
338
+ ```
339
+ Spam-checked before send; unsubscribe footer is auto-added; requires ≥1
340
+ confirmed subscriber.
341
+
342
+ Configure a mailbox: `GET/PATCH /api/v1/publishes/<slug>/mailboxes/<collection>`
343
+ List contacts: `GET /api/v1/publishes/<slug>/mailboxes/<collection>/contacts?status=confirmed`
344
+
345
+ ### MCP tools
346
+ | Tool | Purpose |
347
+ |---|---|
348
+ | `send_email` | Send transactional email from a site (Bearer) |
349
+ | `list_site_inbox` | Read the inbox (all threads or filter by slug) |
350
+ | `set_mailbox` | Configure a collection's mailbox settings |
351
+ | `list_mailbox_contacts` | List audience contacts (filter by status) |
352
+ | `send_mailbox_broadcast` | Broadcast to confirmed audience (spam-checked) |
353
+
354
+ ### CLI
355
+ ```bash
356
+ shiply email send --site <slug> --to <address> --subject <subject> --html <html>
357
+ shiply email inbox [--site <slug>]
358
+ shiply mailbox set <slug> <collection> [--confirm] [--notify] [--from <domain>]
359
+ shiply mailbox broadcast <slug> <collection> --subject <subject> --html <html>
360
+ ```
361
+
362
+ Docs: https://shiply.now/docs/agent-email
363
+
268
364
  ## SQL databases (Cloudflare D1 + Neon Postgres)
269
365
  Two engines, same CLI/REST/MCP surface. **D1 (SQLite at the edge)** is
270
366
  free on every plan and queryable from the browser via a built-in fetch