@pylonsync/functions 0.3.375 → 0.3.378

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/types.d.ts CHANGED
@@ -217,7 +217,7 @@ export interface Scheduler {
217
217
  * Transactional email transport.
218
218
  *
219
219
  * Sends through whatever provider the runtime is configured for
220
- * (PYLON_EMAIL_PROVIDER env var → SendGrid / Resend / Stack0 / SMTP /
220
+ * (PYLON_EMAIL_PROVIDER env var → SendGrid / Resend / Stack0 /
221
221
  * webhook). Available on action ctx only — sending email is external
222
222
  * I/O, not allowed in mutation transactions.
223
223
  *
@@ -230,16 +230,49 @@ export interface Scheduler {
230
230
  * shared auth key can never be used to send arbitrary mail.
231
231
  *
232
232
  * The runtime owns provider config + credentials; functions only
233
- * supply the (to, subject, body) tuple. Failures are surfaced as
234
- * thrown errors; on success the return is void.
233
+ * supply the message. Failures are surfaced as thrown errors; on
234
+ * success the return is void.
235
235
  *
236
236
  * Use cases: invite emails, password-reset hand-offs, notifications,
237
- * digest reports. NOT for marketing email — those should go through
238
- * a dedicated bulk transport, not the transactional path.
237
+ * digest reports, calendar invites (attach an .ics with contentType
238
+ * `text/calendar; method=REQUEST` and mail clients render an
239
+ * RSVP-able event). NOT for marketing email — those should go
240
+ * through a dedicated bulk transport, not the transactional path.
239
241
  */
242
+ export interface EmailAttachment {
243
+ filename: string;
244
+ /**
245
+ * Full MIME content type, passed to the provider VERBATIM —
246
+ * parameterized types like `text/calendar; method=REQUEST` are
247
+ * preserved (that parameter is what makes an invite RSVP-able).
248
+ */
249
+ contentType: string;
250
+ /** Base64-encoded file bytes. */
251
+ content: string;
252
+ }
253
+ export interface EmailOptions {
254
+ /** Single recipient address. */
255
+ to: string;
256
+ subject: string;
257
+ /**
258
+ * Plain-text body. Required even with `html` — it's the text/plain
259
+ * part clients with HTML disabled fall back to.
260
+ */
261
+ text: string;
262
+ /** Optional HTML body; providers send multipart with `text`. */
263
+ html?: string;
264
+ /**
265
+ * Base64 attachments. Limits: at most 20 per email, 15MB of base64
266
+ * text total (≈11MB of raw file data); larger sends throw before
267
+ * any network I/O.
268
+ */
269
+ attachments?: EmailAttachment[];
270
+ }
240
271
  export interface EmailSender {
241
272
  /** Send a plain-text email. `to` is a single address. */
242
273
  send(to: string, subject: string, body: string): Promise<void>;
274
+ /** Send with options: HTML body and/or base64 attachments. */
275
+ send(options: EmailOptions): Promise<void>;
243
276
  }
244
277
  /**
245
278
  * Server-side LLM client. Available on every ctx variant (query,
@@ -67,6 +67,12 @@ export declare const v: {
67
67
  literal: <const TValue extends string | number | boolean>(value: TValue) => Validator<TValue, false>;
68
68
  /** Any valid JSON value. */
69
69
  any: () => Validator<any, false>;
70
+ /**
71
+ * Arbitrary JSON value (object, array, or scalar). The validator-side
72
+ * match for `field.json()` — accepts any JSON shape but types the arg
73
+ * as `unknown`, so handlers narrow before use instead of getting `any`.
74
+ */
75
+ json: () => Validator<unknown, false>;
70
76
  };
71
77
  export declare function validateArgs(args: unknown, schema: Record<string, Validator>): {
72
78
  valid: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.375",
3
+ "version": "0.3.378",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `ctx.email.send` — options overload and size caps.
3
+ *
4
+ * The contract under test:
5
+ *
6
+ * 1. The positional form still emits the legacy frame shape.
7
+ * 2. The options form emits `html` and snake_case attachments, with
8
+ * the `contentType` value passed through VERBATIM (parameterized
9
+ * types like `text/calendar; method=REQUEST` are load-bearing).
10
+ * 3. Over-limit attachments throw BEFORE any frame is emitted.
11
+ *
12
+ * Same child-process NDJSON harness as runtime-rpc-queue.test.ts.
13
+ */
14
+ import { expect, test } from "bun:test";
15
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ const RUNTIME = join(import.meta.dir, "runtime.ts");
20
+
21
+ function parseFrames(text: string): Record<string, unknown>[] {
22
+ return text
23
+ .split("\n")
24
+ .filter((l) => l.trim().startsWith("{"))
25
+ .map((l) => JSON.parse(l) as Record<string, unknown>);
26
+ }
27
+
28
+ function spawnRuntime(probeSource: string) {
29
+ const dir = mkdtempSync(join(tmpdir(), "pylon-fn-email-"));
30
+ mkdirSync(join(dir, "functions"));
31
+ writeFileSync(join(dir, "functions", "probe.ts"), probeSource);
32
+ return Bun.spawn([process.execPath, RUNTIME, "./functions"], {
33
+ cwd: dir,
34
+ stdin: "pipe",
35
+ stdout: "pipe",
36
+ stderr: "pipe",
37
+ });
38
+ }
39
+
40
+ function frameReader(proc: ReturnType<typeof Bun.spawn>) {
41
+ const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
42
+ const decoder = new TextDecoder();
43
+ let buffered = "";
44
+ return async function readUntil(
45
+ pred: (frames: Record<string, unknown>[]) => boolean,
46
+ ): Promise<Record<string, unknown>[]> {
47
+ let frames = parseFrames(buffered);
48
+ while (!pred(frames)) {
49
+ const { done, value } = await reader.read();
50
+ if (done) break;
51
+ buffered += decoder.decode(value, { stream: true });
52
+ frames = parseFrames(buffered);
53
+ }
54
+ return frames;
55
+ };
56
+ }
57
+
58
+ test("options form emits html + snake_case attachments with verbatim contentType", async () => {
59
+ const proc = spawnRuntime(`
60
+ export default {
61
+ type: "action",
62
+ handler: async (ctx) => {
63
+ // Positional form first, then options form.
64
+ await ctx.email.send("a@t.com", "Plain", "plain body");
65
+ await ctx.email.send({
66
+ to: "b@t.com",
67
+ subject: "Invite",
68
+ text: "You're invited",
69
+ html: "<p>hi</p>",
70
+ attachments: [{
71
+ filename: "invite.ics",
72
+ contentType: "text/calendar; method=REQUEST",
73
+ content: "QkVHSU46VkNBTEVOREFS",
74
+ }],
75
+ });
76
+ return { ok: true };
77
+ },
78
+ };
79
+ `);
80
+ const readUntil = frameReader(proc);
81
+ const send = (msg: Record<string, unknown>) =>
82
+ (proc.stdin as import("bun").FileSink).write(JSON.stringify(msg) + "\n");
83
+
84
+ await readUntil((fs) => fs.some((f) => f.type === "ready"));
85
+ send({
86
+ type: "call",
87
+ call_id: "c_1",
88
+ fn_name: "probe",
89
+ fn_type: "action",
90
+ args: {},
91
+ auth: { user_id: "u1", is_admin: false, tenant_id: null },
92
+ });
93
+
94
+ let frames = await readUntil((fs) => fs.some((f) => f.type === "send_email"));
95
+ const first = frames.find((f) => f.type === "send_email")!;
96
+ expect(first).toMatchObject({ to: "a@t.com", subject: "Plain", body: "plain body" });
97
+ send({ type: "result", call_id: "c_1", data: { sent: true } });
98
+
99
+ frames = await readUntil(
100
+ (fs) => fs.filter((f) => f.type === "send_email").length >= 2,
101
+ );
102
+ const second = frames.filter((f) => f.type === "send_email")[1];
103
+ expect(second).toMatchObject({
104
+ to: "b@t.com",
105
+ subject: "Invite",
106
+ body: "You're invited",
107
+ html: "<p>hi</p>",
108
+ attachments: [
109
+ {
110
+ filename: "invite.ics",
111
+ content_type: "text/calendar; method=REQUEST",
112
+ content: "QkVHSU46VkNBTEVOREFS",
113
+ },
114
+ ],
115
+ });
116
+ send({ type: "result", call_id: "c_1", data: { sent: true } });
117
+
118
+ frames = await readUntil((fs) => fs.some((f) => f.type === "return"));
119
+ expect(frames.find((f) => f.type === "return")!.value).toEqual({ ok: true });
120
+
121
+ proc.kill();
122
+ await proc.exited;
123
+ });
124
+
125
+ test("oversized attachments throw before any frame is emitted", async () => {
126
+ const proc = spawnRuntime(`
127
+ export default {
128
+ type: "action",
129
+ handler: async (ctx) => {
130
+ try {
131
+ await ctx.email.send({
132
+ to: "b@t.com",
133
+ subject: "Big",
134
+ text: "x",
135
+ attachments: [{
136
+ filename: "huge.bin",
137
+ contentType: "application/octet-stream",
138
+ content: "A".repeat(16 * 1024 * 1024),
139
+ }],
140
+ });
141
+ return { threw: false };
142
+ } catch (err) {
143
+ return { threw: true, message: String(err.message) };
144
+ }
145
+ },
146
+ };
147
+ `);
148
+ const readUntil = frameReader(proc);
149
+ const send = (msg: Record<string, unknown>) =>
150
+ (proc.stdin as import("bun").FileSink).write(JSON.stringify(msg) + "\n");
151
+
152
+ await readUntil((fs) => fs.some((f) => f.type === "ready"));
153
+ send({
154
+ type: "call",
155
+ call_id: "c_2",
156
+ fn_name: "probe",
157
+ fn_type: "action",
158
+ args: {},
159
+ auth: { user_id: "u1", is_admin: false, tenant_id: null },
160
+ });
161
+
162
+ const frames = await readUntil((fs) => fs.some((f) => f.type === "return"));
163
+ // The reject happened locally: no send_email frame ever hit the wire.
164
+ expect(frames.filter((f) => f.type === "send_email")).toHaveLength(0);
165
+ const ret = frames.find((f) => f.type === "return")!.value as {
166
+ threw: boolean;
167
+ message: string;
168
+ };
169
+ expect(ret.threw).toBe(true);
170
+ expect(ret.message).toContain("exceeding");
171
+
172
+ proc.kill();
173
+ await proc.exited;
174
+ });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Legacy (call_id-keyed) RPCs QUEUE instead of rejecting.
3
+ *
4
+ * The contract under test:
5
+ *
6
+ * 1. An UN-AWAITED `ctx.auth.elevate(...)` followed by
7
+ * `ctx.scheduler.runAfter(...)` works: the elevate frame reaches
8
+ * the host BEFORE the schedule frame (wire order == call order),
9
+ * the schedule reply resolves with the real job id, and the call
10
+ * returns normally. Before the queue, this rejected with
11
+ * "Internal: concurrent RPC attempted on same call_id".
12
+ * 2. `Promise.all` over two legacy RPCs serializes: both frames go
13
+ * out (second only after the first's reply) and each promise
14
+ * resolves with its own reply, not its neighbor's.
15
+ *
16
+ * Drives the REAL runtime.ts dispatcher in a child process via a full
17
+ * `call` frame — the handler is loaded from a temp functions dir, and
18
+ * we play the host over stdin/stdout NDJSON exactly like the Rust side.
19
+ */
20
+ import { expect, test } from "bun:test";
21
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ const RUNTIME = join(import.meta.dir, "runtime.ts");
26
+
27
+ /** Collect NDJSON frames from a chunk of the child's stdout. */
28
+ function parseFrames(text: string): Record<string, unknown>[] {
29
+ return text
30
+ .split("\n")
31
+ .filter((l) => l.trim().startsWith("{"))
32
+ .map((l) => JSON.parse(l) as Record<string, unknown>);
33
+ }
34
+
35
+ /** Spawn the runtime against a temp functions dir holding `probe.ts`. */
36
+ function spawnRuntime(probeSource: string) {
37
+ const dir = mkdtempSync(join(tmpdir(), "pylon-fn-rpcq-"));
38
+ mkdirSync(join(dir, "functions"));
39
+ writeFileSync(join(dir, "functions", "probe.ts"), probeSource);
40
+ const proc = Bun.spawn([process.execPath, RUNTIME, "./functions"], {
41
+ cwd: dir,
42
+ stdin: "pipe",
43
+ stdout: "pipe",
44
+ stderr: "pipe",
45
+ });
46
+ return proc;
47
+ }
48
+
49
+ /** Incremental frame reader over the child's stdout. */
50
+ function frameReader(proc: ReturnType<typeof Bun.spawn>) {
51
+ const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
52
+ const decoder = new TextDecoder();
53
+ let buffered = "";
54
+ return async function readUntil(
55
+ pred: (frames: Record<string, unknown>[]) => boolean,
56
+ ): Promise<Record<string, unknown>[]> {
57
+ let frames = parseFrames(buffered);
58
+ while (!pred(frames)) {
59
+ const { done, value } = await reader.read();
60
+ if (done) break;
61
+ buffered += decoder.decode(value, { stream: true });
62
+ frames = parseFrames(buffered);
63
+ }
64
+ return frames;
65
+ };
66
+ }
67
+
68
+ test("un-awaited elevate then runAfter queues: elevate frame first, real job id back", async () => {
69
+ const proc = spawnRuntime(`
70
+ export default {
71
+ type: "action",
72
+ handler: async (ctx) => {
73
+ // Deliberately NOT awaited — the exact bug pattern under test.
74
+ ctx.auth.elevate({ admin: true, reason: "test" });
75
+ const jobId = await ctx.scheduler.runAfter(5000, "internalTarget", { n: 1 });
76
+ return { jobId, isAdmin: ctx.auth.isAdmin };
77
+ },
78
+ };
79
+ `);
80
+ const readUntil = frameReader(proc);
81
+ const send = (msg: Record<string, unknown>) =>
82
+ (proc.stdin as import("bun").FileSink).write(JSON.stringify(msg) + "\n");
83
+
84
+ await readUntil((fs) => fs.some((f) => f.type === "ready"));
85
+ send({
86
+ type: "call",
87
+ call_id: "c_9",
88
+ fn_name: "probe",
89
+ fn_type: "action",
90
+ args: {},
91
+ auth: { user_id: "u1", is_admin: false, tenant_id: null },
92
+ });
93
+
94
+ // The elevate frame must arrive BEFORE any schedule frame — and with
95
+ // its reply outstanding, the schedule frame must not have been sent.
96
+ let frames = await readUntil((fs) => fs.some((f) => f.type === "elevate_auth"));
97
+ expect(frames.filter((f) => f.type === "schedule")).toHaveLength(0);
98
+ send({ type: "result", call_id: "c_9", data: { elevated: true } });
99
+
100
+ frames = await readUntil((fs) => fs.some((f) => f.type === "schedule"));
101
+ const schedule = frames.find((f) => f.type === "schedule")!;
102
+ expect(schedule).toMatchObject({
103
+ fn_name: "internalTarget",
104
+ args: { n: 1 },
105
+ delay_ms: 5000,
106
+ });
107
+ send({ type: "result", call_id: "c_9", data: { id: "job_123" } });
108
+
109
+ frames = await readUntil((fs) => fs.some((f) => f.type === "return"));
110
+ const ret = frames.find((f) => f.type === "return")!;
111
+ expect(ret.value).toEqual({ jobId: "job_123", isAdmin: true });
112
+
113
+ proc.kill();
114
+ await proc.exited;
115
+ });
116
+
117
+ test("Promise.all over two legacy RPCs serializes and each resolves its own reply", async () => {
118
+ const proc = spawnRuntime(`
119
+ export default {
120
+ type: "action",
121
+ handler: async (ctx) => {
122
+ const [a, b] = await Promise.all([
123
+ ctx.scheduler.runAfter(1000, "first", {}),
124
+ ctx.scheduler.runAfter(2000, "second", {}),
125
+ ]);
126
+ return { a, b };
127
+ },
128
+ };
129
+ `);
130
+ const readUntil = frameReader(proc);
131
+ const send = (msg: Record<string, unknown>) =>
132
+ (proc.stdin as import("bun").FileSink).write(JSON.stringify(msg) + "\n");
133
+
134
+ await readUntil((fs) => fs.some((f) => f.type === "ready"));
135
+ send({
136
+ type: "call",
137
+ call_id: "c_10",
138
+ fn_name: "probe",
139
+ fn_type: "action",
140
+ args: {},
141
+ auth: { user_id: null, is_admin: true, tenant_id: null },
142
+ });
143
+
144
+ // First schedule frame out; the second must be held until we reply.
145
+ let frames = await readUntil((fs) => fs.some((f) => f.type === "schedule"));
146
+ expect(frames.filter((f) => f.type === "schedule")).toHaveLength(1);
147
+ expect(frames.find((f) => f.type === "schedule")).toMatchObject({
148
+ fn_name: "first",
149
+ });
150
+ send({ type: "result", call_id: "c_10", data: { id: "s1" } });
151
+
152
+ frames = await readUntil(
153
+ (fs) => fs.filter((f) => f.type === "schedule").length >= 2,
154
+ );
155
+ expect(frames.filter((f) => f.type === "schedule")[1]).toMatchObject({
156
+ fn_name: "second",
157
+ });
158
+ send({ type: "result", call_id: "c_10", data: { id: "s2" } });
159
+
160
+ frames = await readUntil((fs) => fs.some((f) => f.type === "return"));
161
+ expect(frames.find((f) => f.type === "return")!.value).toEqual({
162
+ a: "s1",
163
+ b: "s2",
164
+ });
165
+
166
+ proc.kill();
167
+ await proc.exited;
168
+ });
package/src/runtime.ts CHANGED
@@ -18,6 +18,8 @@
18
18
  import type {
19
19
  DbReader,
20
20
  DbWriter,
21
+ EmailAttachment,
22
+ EmailOptions,
21
23
  EmailSender,
22
24
  Stream,
23
25
  Scheduler,
@@ -428,34 +430,68 @@ function rpcStreaming(
428
430
  }
429
431
 
430
432
  /**
431
- * RPC for non-db protocol replies (scheduler.runAfter, nested function
432
- * calls, etc.) where at-most-one in-flight per call_id is the right
433
- * contract. Keeps the legacy keying so these reply shapes don't need
434
- * op_id support on the host.
433
+ * Tail of the in-order queue of legacy (call_id-keyed) RPCs, per call.
434
+ * See {@link rpc} for why these serialize.
435
+ */
436
+ const rpcQueues = new Map<string, Promise<unknown>>();
437
+
438
+ /**
439
+ * RPC for non-db protocol replies (scheduler.runAfter, elevate, email,
440
+ * nested function calls, etc.). These reply frames carry no op_id, so
441
+ * only one can be in flight per call_id — but instead of rejecting a
442
+ * second request (the old "concurrent RPC attempted on same call_id"
443
+ * error), requests QUEUE: each one waits for the previous request on
444
+ * the same call_id to settle, then sends. The host drains a per-call
445
+ * channel strictly in order, so wire order == call order and the
446
+ * replies can't cross.
447
+ *
448
+ * This is what makes an un-awaited `ctx.auth.elevate(...)` followed by
449
+ * `ctx.scheduler.runAfter(...)` behave correctly: the elevate frame is
450
+ * sent (and replied to) before the schedule frame goes out, so the
451
+ * host-side admin flag is already set when the schedule arm reads it.
452
+ * `Promise.all` over these ctx calls serializes for the same reason.
453
+ *
454
+ * The timeout starts when the frame is actually SENT, not while the
455
+ * request is queued behind a predecessor, and each request chains on
456
+ * the predecessor's SETTLEMENT — a rejected predecessor doesn't poison
457
+ * the rest of the queue.
435
458
  */
436
459
  function rpc(callId: string, msg: Record<string, unknown>): Promise<unknown> {
437
- return new Promise((resolve, reject) => {
438
- if (pendingRpcs.has(callId)) {
439
- reject(
440
- new Error(
441
- `Internal: concurrent RPC attempted on same call_id (${callId})`,
442
- ),
443
- );
444
- return;
445
- }
446
- const timeout = setTimeout(() => {
447
- if (pendingRpcs.has(callId)) {
448
- pendingRpcs.delete(callId);
449
- reject(
450
- new Error(
451
- `RPC timed out after ${RPC_TIMEOUT_MS}ms (call_id=${callId})`,
452
- ),
453
- );
454
- }
455
- }, RPC_TIMEOUT_MS);
456
- pendingRpcs.set(callId, { resolve, reject, timeout });
457
- send({ ...msg, call_id: callId });
458
- });
460
+ const prev = rpcQueues.get(callId) ?? Promise.resolve();
461
+ const run = prev
462
+ .then(
463
+ () => undefined,
464
+ () => undefined,
465
+ )
466
+ .then(
467
+ () =>
468
+ new Promise((resolve, reject) => {
469
+ const timeout = setTimeout(() => {
470
+ if (pendingRpcs.has(callId)) {
471
+ pendingRpcs.delete(callId);
472
+ reject(
473
+ new Error(
474
+ `RPC timed out after ${RPC_TIMEOUT_MS}ms (call_id=${callId})`,
475
+ ),
476
+ );
477
+ }
478
+ }, RPC_TIMEOUT_MS);
479
+ pendingRpcs.set(callId, { resolve, reject, timeout });
480
+ send({ ...msg, call_id: callId });
481
+ }),
482
+ );
483
+ rpcQueues.set(callId, run);
484
+ // Drop the queue entry once this tail settles (if nothing chained
485
+ // after it) so the map doesn't hold one entry per call_id forever.
486
+ run.then(
487
+ () => {
488
+ if (rpcQueues.get(callId) === run) rpcQueues.delete(callId);
489
+ },
490
+ () => {
491
+ if (rpcQueues.get(callId) === run) rpcQueues.delete(callId);
492
+ },
493
+ );
494
+ return run;
459
495
  }
460
496
 
461
497
  // ---------------------------------------------------------------------------
@@ -705,17 +741,60 @@ function buildScheduler(callId: string): Scheduler {
705
741
  * replies success or error. Errors arrive as thrown exceptions on
706
742
  * the action's await, just like every other RPC. No silent failures.
707
743
  */
744
+ // Mirrors the Rust-side guard in the send_email dispatch arm: attachments
745
+ // ride one NDJSON line, so an oversized payload is an unbounded allocation
746
+ // on both sides of the pipe. Reject before any framing happens.
747
+ const EMAIL_MAX_ATTACHMENT_B64_BYTES = 15 * 1024 * 1024;
748
+ const EMAIL_MAX_ATTACHMENTS = 20;
749
+
708
750
  function buildEmail(callId: string): EmailSender {
709
- return {
710
- async send(to, subject, body) {
751
+ async function send(
752
+ toOrOptions: string | EmailOptions,
753
+ subject?: string,
754
+ body?: string,
755
+ ): Promise<void> {
756
+ // Options form: first argument is the message object.
757
+ if (typeof toOrOptions === "object" && toOrOptions !== null) {
758
+ const opts = toOrOptions;
759
+ const attachments = opts.attachments ?? [];
760
+ if (attachments.length > EMAIL_MAX_ATTACHMENTS) {
761
+ throw new Error(
762
+ `ctx.email.send: ${attachments.length} attachments exceeds the limit of ${EMAIL_MAX_ATTACHMENTS} per email`,
763
+ );
764
+ }
765
+ const b64Total = attachments.reduce(
766
+ (n: number, a: EmailAttachment) => n + a.content.length,
767
+ 0,
768
+ );
769
+ if (b64Total > EMAIL_MAX_ATTACHMENT_B64_BYTES) {
770
+ throw new Error(
771
+ `ctx.email.send: attachments total ${b64Total} base64 bytes, exceeding the ${EMAIL_MAX_ATTACHMENT_B64_BYTES}-byte limit (≈11MB of raw file data)`,
772
+ );
773
+ }
711
774
  await rpc(callId, {
712
775
  type: "send_email",
713
- to,
714
- subject,
715
- body,
776
+ to: opts.to,
777
+ subject: opts.subject,
778
+ body: opts.text,
779
+ html: opts.html,
780
+ // Wire fields are snake_case; contentType is the TS-facing name.
781
+ attachments: attachments.map((a: EmailAttachment) => ({
782
+ filename: a.filename,
783
+ content_type: a.contentType,
784
+ content: a.content,
785
+ })),
716
786
  });
717
- },
718
- };
787
+ return;
788
+ }
789
+ // Positional form: send(to, subject, body).
790
+ await rpc(callId, {
791
+ type: "send_email",
792
+ to: toOrOptions,
793
+ subject,
794
+ body,
795
+ });
796
+ }
797
+ return { send } as EmailSender;
719
798
  }
720
799
 
721
800
  /**
package/src/types.ts CHANGED
@@ -289,7 +289,7 @@ export interface Scheduler {
289
289
  * Transactional email transport.
290
290
  *
291
291
  * Sends through whatever provider the runtime is configured for
292
- * (PYLON_EMAIL_PROVIDER env var → SendGrid / Resend / Stack0 / SMTP /
292
+ * (PYLON_EMAIL_PROVIDER env var → SendGrid / Resend / Stack0 /
293
293
  * webhook). Available on action ctx only — sending email is external
294
294
  * I/O, not allowed in mutation transactions.
295
295
  *
@@ -302,16 +302,51 @@ export interface Scheduler {
302
302
  * shared auth key can never be used to send arbitrary mail.
303
303
  *
304
304
  * The runtime owns provider config + credentials; functions only
305
- * supply the (to, subject, body) tuple. Failures are surfaced as
306
- * thrown errors; on success the return is void.
305
+ * supply the message. Failures are surfaced as thrown errors; on
306
+ * success the return is void.
307
307
  *
308
308
  * Use cases: invite emails, password-reset hand-offs, notifications,
309
- * digest reports. NOT for marketing email — those should go through
310
- * a dedicated bulk transport, not the transactional path.
309
+ * digest reports, calendar invites (attach an .ics with contentType
310
+ * `text/calendar; method=REQUEST` and mail clients render an
311
+ * RSVP-able event). NOT for marketing email — those should go
312
+ * through a dedicated bulk transport, not the transactional path.
311
313
  */
314
+ export interface EmailAttachment {
315
+ filename: string;
316
+ /**
317
+ * Full MIME content type, passed to the provider VERBATIM —
318
+ * parameterized types like `text/calendar; method=REQUEST` are
319
+ * preserved (that parameter is what makes an invite RSVP-able).
320
+ */
321
+ contentType: string;
322
+ /** Base64-encoded file bytes. */
323
+ content: string;
324
+ }
325
+
326
+ export interface EmailOptions {
327
+ /** Single recipient address. */
328
+ to: string;
329
+ subject: string;
330
+ /**
331
+ * Plain-text body. Required even with `html` — it's the text/plain
332
+ * part clients with HTML disabled fall back to.
333
+ */
334
+ text: string;
335
+ /** Optional HTML body; providers send multipart with `text`. */
336
+ html?: string;
337
+ /**
338
+ * Base64 attachments. Limits: at most 20 per email, 15MB of base64
339
+ * text total (≈11MB of raw file data); larger sends throw before
340
+ * any network I/O.
341
+ */
342
+ attachments?: EmailAttachment[];
343
+ }
344
+
312
345
  export interface EmailSender {
313
346
  /** Send a plain-text email. `to` is a single address. */
314
347
  send(to: string, subject: string, body: string): Promise<void>;
348
+ /** Send with options: HTML body and/or base64 attachments. */
349
+ send(options: EmailOptions): Promise<void>;
315
350
  }
316
351
 
317
352
  // ---------------------------------------------------------------------------
package/src/validators.ts CHANGED
@@ -115,6 +115,13 @@ export const v = {
115
115
 
116
116
  /** Any valid JSON value. */
117
117
  any: (): Validator<any, false> => validator("any"),
118
+
119
+ /**
120
+ * Arbitrary JSON value (object, array, or scalar). The validator-side
121
+ * match for `field.json()` — accepts any JSON shape but types the arg
122
+ * as `unknown`, so handlers narrow before use instead of getting `any`.
123
+ */
124
+ json: (): Validator<unknown, false> => validator("json"),
118
125
  };
119
126
 
120
127
  // ---------------------------------------------------------------------------
@@ -179,6 +186,10 @@ function validateValue(
179
186
  : `${path}: expected null, got ${typeof value}`;
180
187
  case "any":
181
188
  return null;
189
+ case "json":
190
+ // Any JSON shape is legal, including null — presence/absence is
191
+ // handled by the optional pass above.
192
+ return null;
182
193
  case "literal":
183
194
  return value === validator.value
184
195
  ? null