@assembly-js/app-foundation 0.0.15 → 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.
- package/dist/gmail.d.ts +38 -0
- package/dist/gmail.js +137 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/package.json +1 -1
- package/skills/assembly-integrations/providers/gmail.md +17 -1
- package/skills/assembly-integrations/providers/google-calendar.md +6 -2
- package/skills/assembly-integrations/providers/google-sheet.md +11 -2
- package/skills/assembly-integrations/providers/stripe.md +17 -2
package/dist/gmail.d.ts
ADDED
|
@@ -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
|
@@ -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
|
|
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`
|
|
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
|
-
`
|
|
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
|
-
|
|
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 -->
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
# Stripe
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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 -->
|