@assembly-js/app-foundation 0.0.14 → 0.0.16

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.
@@ -0,0 +1,38 @@
1
+ import { type IntegrationClient } from './nango.js';
2
+ export interface GmailSendArgs {
3
+ to: string | string[];
4
+ cc?: string | string[];
5
+ bcc?: string | string[];
6
+ subject: string;
7
+ /** Plain-text body. At least one of text/html is required. */
8
+ text?: string;
9
+ /** HTML body. With both, the message is multipart/alternative. */
10
+ html?: string;
11
+ replyTo?: string;
12
+ /** Gmail thread to append to — take it from a previous send's result. */
13
+ threadId?: string;
14
+ /**
15
+ * Message-ID of the message being replied to. threadId only threads inside
16
+ * Gmail; recipients' mail clients thread on In-Reply-To/References.
17
+ */
18
+ inReplyTo?: string;
19
+ }
20
+ export interface GmailSendResult {
21
+ id: string;
22
+ threadId: string;
23
+ }
24
+ export declare function buildMimeMessage(args: GmailSendArgs): string;
25
+ /**
26
+ * Send an email through the workspace's Gmail connection. Use this instead of
27
+ * calling the send-message action yourself: it builds the RFC 2822 MIME
28
+ * message (multipart/alternative when both text and html are given, RFC 2047
29
+ * subject encoding, CRLF line endings) and base64url-encodes it into `raw`.
30
+ *
31
+ * Gmail sends from the connected account; there is no `from` override. To
32
+ * reply on an existing thread pass `threadId` from a previous send's result —
33
+ * and `inReplyTo` (the original Message-ID) so recipients' clients thread too.
34
+ *
35
+ * Broker-level failures (provider not connected, throttled) throw
36
+ * ConnectorError like every other connector call.
37
+ */
38
+ export declare function gmailSendEmail(gmail: IntegrationClient, args: GmailSendArgs): Promise<GmailSendResult>;
package/dist/gmail.js ADDED
@@ -0,0 +1,137 @@
1
+ const CRLF = '\r\n';
2
+ // RFC 2045 caps encoded body lines at 76 characters.
3
+ const MIME_LINE_LENGTH = 76;
4
+ // Addresses and message ids are interpolated into headers verbatim, so a value
5
+ // carrying CR/LF (or another control char) would inject arbitrary headers —
6
+ // e.g. a user-supplied "a@x.com\r\nBcc: …". Reject instead of sanitizing so the
7
+ // caller surfaces the bad input. (Subjects are exempt: encodeHeaderValue
8
+ // base64-encodes anything outside printable ASCII.)
9
+ function assertNoControlChars(value, field) {
10
+ // eslint-disable-next-line no-control-regex
11
+ if (/[\x00-\x1f\x7f]/.test(value)) {
12
+ throw new Error(`gmailSendEmail: \`${field}\` contains control characters`);
13
+ }
14
+ return value;
15
+ }
16
+ function toList(value, field) {
17
+ if (value === undefined)
18
+ return [];
19
+ return (Array.isArray(value) ? value : [value])
20
+ .map((addr) => assertNoControlChars(addr.trim(), field))
21
+ .filter(Boolean);
22
+ }
23
+ function base64Encode(bytes) {
24
+ let binary = '';
25
+ const chunk = 0x8000;
26
+ for (let i = 0; i < bytes.length; i += chunk) {
27
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
28
+ }
29
+ return btoa(binary);
30
+ }
31
+ function base64Url(bytes) {
32
+ return base64Encode(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
33
+ }
34
+ function utf8(value) {
35
+ return new TextEncoder().encode(value);
36
+ }
37
+ // RFC 2047 caps each encoded-word at 75 characters. "=?UTF-8?B?" + "?=" is 12,
38
+ // leaving 63 base64 chars = 45 source bytes per word.
39
+ const ENCODED_WORD_BYTE_LIMIT = 45;
40
+ // RFC 2047 encoded-word(s) for header values; ASCII-only values pass through.
41
+ // Long values fold into multiple words joined by CRLF + space — decoders
42
+ // concatenate adjacent encoded-words, dropping the whitespace between them.
43
+ // Chunks break on UTF-8 sequence boundaries since each word decodes alone.
44
+ function encodeHeaderValue(value) {
45
+ // eslint-disable-next-line no-control-regex
46
+ if (/^[\x20-\x7e]*$/.test(value))
47
+ return value;
48
+ const bytes = utf8(value);
49
+ const words = [];
50
+ let start = 0;
51
+ while (start < bytes.length) {
52
+ let end = Math.min(start + ENCODED_WORD_BYTE_LIMIT, bytes.length);
53
+ while (end < bytes.length && (bytes[end] & 0xc0) === 0x80)
54
+ end--;
55
+ words.push(`=?UTF-8?B?${base64Encode(bytes.subarray(start, end))}?=`);
56
+ start = end;
57
+ }
58
+ return words.join(`${CRLF} `);
59
+ }
60
+ function wrapBase64(encoded) {
61
+ const lines = [];
62
+ for (let i = 0; i < encoded.length; i += MIME_LINE_LENGTH) {
63
+ lines.push(encoded.slice(i, i + MIME_LINE_LENGTH));
64
+ }
65
+ return lines.join(CRLF);
66
+ }
67
+ function bodyPart(mimeType, content) {
68
+ return [
69
+ `Content-Type: ${mimeType}; charset=UTF-8`,
70
+ 'Content-Transfer-Encoding: base64',
71
+ '',
72
+ wrapBase64(base64Encode(utf8(content))),
73
+ ].join(CRLF);
74
+ }
75
+ export function buildMimeMessage(args) {
76
+ const to = toList(args.to, 'to');
77
+ if (to.length === 0)
78
+ throw new Error('gmailSendEmail: `to` is required');
79
+ if (!args.text && !args.html) {
80
+ throw new Error('gmailSendEmail: at least one of `text` or `html` is required');
81
+ }
82
+ const headers = [`To: ${to.join(', ')}`];
83
+ const cc = toList(args.cc, 'cc');
84
+ if (cc.length > 0)
85
+ headers.push(`Cc: ${cc.join(', ')}`);
86
+ const bcc = toList(args.bcc, 'bcc');
87
+ if (bcc.length > 0)
88
+ headers.push(`Bcc: ${bcc.join(', ')}`);
89
+ if (args.replyTo)
90
+ headers.push(`Reply-To: ${assertNoControlChars(args.replyTo, 'replyTo')}`);
91
+ if (args.inReplyTo) {
92
+ const raw = assertNoControlChars(args.inReplyTo, 'inReplyTo');
93
+ const messageId = raw.startsWith('<') ? raw : `<${raw}>`;
94
+ headers.push(`In-Reply-To: ${messageId}`, `References: ${messageId}`);
95
+ }
96
+ headers.push(`Subject: ${encodeHeaderValue(args.subject)}`, 'MIME-Version: 1.0');
97
+ if (args.text && args.html) {
98
+ const boundary = `=_assembly_${crypto.randomUUID()}`;
99
+ return [
100
+ ...headers,
101
+ `Content-Type: multipart/alternative; boundary="${boundary}"`,
102
+ '',
103
+ `--${boundary}`,
104
+ // Plain text first: multipart/alternative parts go least- to
105
+ // most-preferred, and clients render the last part they support.
106
+ bodyPart('text/plain', args.text),
107
+ `--${boundary}`,
108
+ bodyPart('text/html', args.html),
109
+ `--${boundary}--`,
110
+ '',
111
+ ].join(CRLF);
112
+ }
113
+ const single = args.html
114
+ ? bodyPart('text/html', args.html)
115
+ : bodyPart('text/plain', args.text);
116
+ return [...headers, single].join(CRLF);
117
+ }
118
+ /**
119
+ * Send an email through the workspace's Gmail connection. Use this instead of
120
+ * calling the send-message action yourself: it builds the RFC 2822 MIME
121
+ * message (multipart/alternative when both text and html are given, RFC 2047
122
+ * subject encoding, CRLF line endings) and base64url-encodes it into `raw`.
123
+ *
124
+ * Gmail sends from the connected account; there is no `from` override. To
125
+ * reply on an existing thread pass `threadId` from a previous send's result —
126
+ * and `inReplyTo` (the original Message-ID) so recipients' clients thread too.
127
+ *
128
+ * Broker-level failures (provider not connected, throttled) throw
129
+ * ConnectorError like every other connector call.
130
+ */
131
+ export async function gmailSendEmail(gmail, args) {
132
+ const raw = base64Url(utf8(buildMimeMessage(args)));
133
+ const input = { raw };
134
+ if (args.threadId)
135
+ input.threadId = args.threadId;
136
+ return gmail.action('send-message', input);
137
+ }
package/dist/server.d.ts CHANGED
@@ -5,6 +5,7 @@ export * from './client-access.js';
5
5
  export * from './mint-bearer.js';
6
6
  export * from './nango.js';
7
7
  export * from './airtable.js';
8
+ export * from './gmail.js';
8
9
  export * from './app-storage.js';
9
10
  export * from './labels.js';
10
11
  export * from './notifications.js';
package/dist/server.js CHANGED
@@ -9,6 +9,7 @@ export * from './client-access.js';
9
9
  export * from './mint-bearer.js';
10
10
  export * from './nango.js';
11
11
  export * from './airtable.js';
12
+ export * from './gmail.js';
12
13
  export * from './app-storage.js';
13
14
  export * from './labels.js';
14
15
  export * from './notifications.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -2,6 +2,22 @@
2
2
 
3
3
  Send-only: `gmail.send` is Google's one sensitive-tier Gmail scope; every read/draft scope is restricted (CASA audit), so those actions are unvendored. Do not invent a list or read action, and do not declare a read scope.
4
4
 
5
- `send-message` takes `raw`: the complete RFC 2822 MIME message, base64url-encoded. `threadId` makes it a reply; there is no in-catalog resolver for threads, so collect it from a previous send result, not a picker.
5
+ **Send with `gmailSendEmail` from `@assembly-js/app-foundation/server` — never build the `raw` MIME message yourself.** The `send-message` action takes a complete RFC 2822 MIME message, base64url-encoded; hand-rolled construction gets header folding, multipart boundaries, non-ASCII subjects, or CRLF endings wrong. The helper builds it (multipart/alternative when both `text` and `html` are given) and calls the action:
6
+
7
+ ```ts
8
+ import { getIntegration, gmailSendEmail } from '@assembly-js/app-foundation/server';
9
+
10
+ const gmail = await getIntegration('gmail');
11
+ const { id, threadId } = await gmailSendEmail(gmail, {
12
+ to: 'client@example.com',
13
+ subject: 'Your monthly report',
14
+ text: 'Plain-text fallback',
15
+ html: '<p>Rich body</p>',
16
+ });
17
+ ```
18
+
19
+ Mail sends from the connected account — there is no `from` override; use `replyTo` if replies should land elsewhere.
20
+
21
+ Replies: pass `threadId` from a previous send's result to stay on the Gmail thread, and `inReplyTo` (the original Message-ID) so recipients' clients thread it too. There is no in-catalog resolver for threads, so collect `threadId` from your own send results, not a picker.
6
22
 
7
23
  <!-- ASSEMBLY_ACTIONS:gmail -->
@@ -1,7 +1,11 @@
1
1
  # Google Calendar
2
2
 
3
- `calendarId` defaults to `primary` on `list-events`. `create-event` requires `calendarId` plus `start` / `end` as `{ dateTime, timeZone? }` in RFC3339.
3
+ `calendarId` defaults to `"primary"` — the connected account's own calendar. Don't build a calendar picker unless the app genuinely targets shared calendars; there is no in-catalog calendar resolver, so a picker would need the proxy.
4
4
 
5
- `list-events` paginates with `cursor` in and `nextPageToken` out. `update-event` is a patch — only supplied fields change.
5
+ Event `start` and `end` are objects, not strings: `{ dateTime: "2026-08-27T14:00:00-04:00", timeZone: "America/New_York" }` (RFC3339 with offset). All-day events use `{ date: "2026-08-27" }` instead of `dateTime`. Passing a bare ISO string where the object is expected is the most common failure.
6
+
7
+ `attendees` is `[{ email }]`. Creating or updating with attendees does **not** email them unless `sendUpdates: "all"` is passed on `update-event` (create sends per Google defaults) — surface this as an explicit choice in the UI, not a surprise.
8
+
9
+ For listing over a window pass `timeMin`/`timeMax` (RFC3339) and `singleEvents: true` so recurring events expand into instances — without it you get the recurring master only, which renders as one event instead of many. `recurrence` on create takes RRULE strings, e.g. `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`.
6
10
 
7
11
  <!-- ASSEMBLY_ACTIONS:google-calendar -->
@@ -2,8 +2,17 @@
2
2
 
3
3
  Sheets has no in-provider listing. Spreadsheets live in Drive.
4
4
 
5
- When `google-drive` is on the supported-provider list, declare it too and resolve `spreadsheetId` via Drive `find-file` with query `mimeType='application/vnd.google-apps.spreadsheet'` (id field `id`, label `name`). When Drive is not connectable, collect the spreadsheet URL and extract the id — never a bare "enter the ID" box.
5
+ When `google-drive` is on the supported-provider list, declare it too and resolve `spreadsheetId` via Drive `find-file` with query `mimeType='application/vnd.google-apps.spreadsheet'` (id field `id`, label `name`). When Drive is not connectable, collect the **spreadsheet URL** and extract the id — never a bare "enter the ID" box:
6
6
 
7
- Ranges are A1 notation; `values` is a 2D array of rows. `get-values` reads, `append-values-to-spreadsheet` appends to the table found in the range, `update-values` overwrites an exact range.
7
+ ```ts
8
+ // https://docs.google.com/spreadsheets/d/<spreadsheetId>/edit#gid=0
9
+ const id = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/)?.[1];
10
+ ```
11
+
12
+ Ranges are A1 notation (`Sheet1!A1:D10`; quote sheet names with spaces: `'Q3 Data'!A:D`); `values` is a 2D array of rows — one inner array per row, cells as strings/numbers, never objects. `get-values` reads, `append-values-to-spreadsheet` appends to the table found in the range, `update-values` overwrites an exact range.
13
+
14
+ Writes take `valueInputOption`: `RAW` stores values verbatim; `USER_ENTERED` parses them as if typed in the UI (strings that look like numbers/dates convert, `=SUM(...)` becomes a formula). Default to `USER_ENTERED` for user-facing data entry and `RAW` when round-tripping values the app will read back.
15
+
16
+ `get-values` returns rows only up to the last non-empty cell — inner arrays can be shorter than the header row, so index defensively when mapping rows to objects.
8
17
 
9
18
  <!-- ASSEMBLY_ACTIONS:google-sheet -->
@@ -24,4 +24,6 @@ const history = await slack.action('get-conversation-history', {
24
24
 
25
25
  For an operation no action covers, relay through the proxy — e.g. `slack.post({ endpoint: '/chat.update', data: { channel: channelId, ts, text } })`.
26
26
 
27
+ - **DMs are not supported.** The integration does not grant `im:write`, and `conversations.open` is not vendored. A Slack user ID (`U…`) or DM channel ID (`D…`) also fails `slackPostMessage`'s channel-ID check, so it falls through to a channel-name lookup and returns `channel_not_found` instead of opening the DM. If a user asks for DMs, say so up front and offer `createNotification` (in-product + email) or posting to a channel instead.
28
+
27
29
  <!-- ASSEMBLY_ACTIONS:slack -->
@@ -1,7 +1,22 @@
1
1
  # Stripe
2
2
 
3
- List cursors are the id of the last item on the previous page (Stripe's `starting_after`), not an opaque token. Passing a `next_cursor` from another provider, or anything that is not an item id, skips or repeats pages.
3
+ Read-only: the vendored actions cover listing and looking up customers, payment intents, and invoices. There are no write actions — do not plan flows that create charges, invoices, or customers, and do not fall back to the proxy for writes; apps report on the connected Stripe account, they don't bill through it.
4
4
 
5
- `get-customer` returns the customer flat, not wrapped. Email filters on `list-customers` are case-sensitive exact matches.
5
+ List cursors are the id of the last item on the previous page (Stripe's `starting_after`), not an opaque token. Passing a `next_cursor` from another provider, or anything that is not an item id, skips or repeats pages:
6
+
7
+ ```ts
8
+ let cursor: string | undefined;
9
+ do {
10
+ const page = await stripe.action('list-invoices', { customer, cursor, limit: 100 });
11
+ all.push(...page.items);
12
+ cursor = page.next_cursor; // the last item's id, pass back verbatim
13
+ } while (cursor);
14
+ ```
15
+
16
+ Money amounts are integers in the smallest currency unit (cents): render `amount / 100` with the item's `currency`, never the raw integer. Timestamps (`created`, `period_*`) are unix seconds.
17
+
18
+ `get-customer` returns the customer flat, not wrapped. Email filters on `list-customers` are case-sensitive exact matches — for a "search by email" UI, prefer listing and filtering client-side unless the exact address is known.
19
+
20
+ Status filters take Stripe's enums verbatim: invoices `draft | open | paid | uncollectible | void`; payment intents `requires_payment_method | requires_confirmation | requires_action | processing | requires_capture | canceled | succeeded`.
6
21
 
7
22
  <!-- ASSEMBLY_ACTIONS:stripe -->