@protobase/workflow-plugins 0.71.1 → 0.71.3

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,85 @@
1
+ import { ConnectionError, ResponseTooLargeError } from "@protobase/workflows/plugin";
2
+ //#region src/gmail/api.ts
3
+ /** Gmail answered 404: an expired history id, or a message deleted since it was listed. */
4
+ var GmailNotFound = class extends Error {
5
+ name = "GmailNotFound";
6
+ };
7
+ /** A Gmail id as it may go into a path: letters and digits only, so no answer can steer a request elsewhere. */
8
+ const gmailId = /^[A-Za-z0-9]{1,64}$/;
9
+ /** A history id: a number, as text. */
10
+ const historyId = /^\d{1,30}$/;
11
+ const errorStatuses = /* @__PURE__ */ new Set([
12
+ "CANCELLED",
13
+ "UNKNOWN",
14
+ "INVALID_ARGUMENT",
15
+ "DEADLINE_EXCEEDED",
16
+ "NOT_FOUND",
17
+ "ALREADY_EXISTS",
18
+ "PERMISSION_DENIED",
19
+ "RESOURCE_EXHAUSTED",
20
+ "FAILED_PRECONDITION",
21
+ "ABORTED",
22
+ "OUT_OF_RANGE",
23
+ "UNIMPLEMENTED",
24
+ "INTERNAL",
25
+ "UNAVAILABLE",
26
+ "DATA_LOSS",
27
+ "UNAUTHENTICATED"
28
+ ]);
29
+ const statusOf = (body) => {
30
+ const status = parseJson(body)?.error?.status;
31
+ return typeof status === "string" && errorStatuses.has(status) ? ` (${status})` : "";
32
+ };
33
+ const parseJson = (text) => {
34
+ try {
35
+ return JSON.parse(text);
36
+ } catch (error) {
37
+ if (!(error instanceof SyntaxError)) throw error;
38
+ return;
39
+ }
40
+ };
41
+ /**
42
+ * The few read-only Gmail calls the trigger makes, all GET, through the plugin context's `fetch`. The token goes only in
43
+ * the `Authorization` header, asked for once a poll; a 401 asks for it again and tries once more, and a second 401 is an
44
+ * expired sign-in. An error names the call and its status, never a URL, a header or Google's own message.
45
+ */
46
+ const gmailApi = ({ origin, fetch, connection, signal }) => {
47
+ if (!connection) throw new Error("Not implemented: the Gmail trigger always declares its Google connection");
48
+ let token;
49
+ const send = async (url, call, bearer, maxBytes) => {
50
+ try {
51
+ return await fetch(url, {
52
+ headers: {
53
+ authorization: `Bearer ${bearer}`,
54
+ accept: "application/json"
55
+ },
56
+ signal,
57
+ ...maxBytes !== void 0 && { maxBytes }
58
+ });
59
+ } catch (error) {
60
+ if (error instanceof ResponseTooLargeError || signal.aborted) throw error;
61
+ throw new Error(`Gmail ${call} failed: ${error instanceof Error ? error.name : "unknown error"}`, { cause: error });
62
+ }
63
+ };
64
+ /** GET `path` with `query`, a list of names and values so a name may repeat; a value left undefined is left out. */
65
+ const get = async (path, query, call, maxBytes) => {
66
+ const params = new URLSearchParams(query.filter((entry) => entry[1] !== void 0));
67
+ const url = `${origin}/gmail/v1/users/me/${path}${params.size > 0 ? `?${params}` : ""}`;
68
+ token ??= await connection.accessToken();
69
+ let answer = await send(url, call, token, maxBytes);
70
+ if (answer.status === 401) {
71
+ token = await connection.accessToken();
72
+ answer = await send(url, call, token, maxBytes);
73
+ if (answer.status === 401) throw new ConnectionError("link_expired", "Google refused the access token twice: sign in to the Google account again");
74
+ }
75
+ const text = await answer.text();
76
+ if (answer.status === 404) throw new GmailNotFound(`Gmail ${call} found nothing`);
77
+ if (!answer.ok) throw new Error(`Gmail ${call} answered ${answer.status}${statusOf(text)}`);
78
+ const body = parseJson(text);
79
+ if (typeof body !== "object" || body === null) throw new Error(`Gmail ${call} answered no JSON object`);
80
+ return body;
81
+ };
82
+ return { get };
83
+ };
84
+ //#endregion
85
+ export { GmailNotFound, gmailApi, gmailId, historyId };
@@ -0,0 +1,114 @@
1
+ import { gmailId, historyId } from "./api.js";
2
+ //#region src/gmail/calls.ts
3
+ const bad = (call) => /* @__PURE__ */ new Error(`Gmail ${call} answered an unexpected shape`);
4
+ const pageToken = (value, call) => {
5
+ if (value === void 0) return;
6
+ if (typeof value !== "string" || value.length === 0 || value.length > 1e3) throw bad(call);
7
+ return value;
8
+ };
9
+ const messageRef = (value, call) => {
10
+ const { id, threadId } = value ?? {};
11
+ if (typeof id !== "string" || !gmailId.test(id) || typeof threadId !== "string" || !gmailId.test(threadId)) throw bad(call);
12
+ return {
13
+ id,
14
+ threadId
15
+ };
16
+ };
17
+ const base64url = (text) => {
18
+ const binary = atob(text.replaceAll("-", "+").replaceAll("_", "/"));
19
+ const bytes = new Uint8Array(binary.length);
20
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
21
+ return bytes;
22
+ };
23
+ const list = (value, call) => {
24
+ if (value === void 0) return [];
25
+ if (!Array.isArray(value)) throw bad(call);
26
+ return value;
27
+ };
28
+ /** The mailbox's current history id (`users.getProfile`). */
29
+ const profile = async (api) => {
30
+ const body = await api.get("profile", [], "getProfile");
31
+ if (typeof body.historyId !== "string" || !historyId.test(body.historyId)) throw bad("getProfile");
32
+ return body.historyId;
33
+ };
34
+ /** The mailbox's labels, by id and name (`users.labels.list`). */
35
+ const labels = async (api) => {
36
+ const body = await api.get("labels", [], "labels.list");
37
+ return list(body.labels, "labels.list").map((label) => {
38
+ const { id, name } = label ?? {};
39
+ if (typeof id !== "string" || typeof name !== "string") throw bad("labels.list");
40
+ return {
41
+ id,
42
+ name
43
+ };
44
+ });
45
+ };
46
+ /** One page of the added messages after `start` on `label` (`users.history.list`). */
47
+ const historyPage = async (api, { start, label, page }) => {
48
+ const call = "history.list";
49
+ const body = await api.get("history", [
50
+ ["startHistoryId", start],
51
+ ["historyTypes", "messageAdded"],
52
+ ["labelId", label],
53
+ ["maxResults", "100"],
54
+ ["pageToken", page]
55
+ ], call);
56
+ if (typeof body.historyId !== "string" || !historyId.test(body.historyId)) throw bad(call);
57
+ return {
58
+ records: list(body.history, call).map((record) => {
59
+ const { id, messagesAdded } = record ?? {};
60
+ if (typeof id !== "string" || !historyId.test(id)) throw bad(call);
61
+ return {
62
+ id,
63
+ added: list(messagesAdded, call).map((added) => messageRef(added?.message, call))
64
+ };
65
+ }),
66
+ next: pageToken(body.nextPageToken, call),
67
+ historyId: body.historyId
68
+ };
69
+ };
70
+ /** One page of at most `limit` messages on `label` after the time `since`, in seconds (`users.messages.list`). */
71
+ const messagesPage = async (api, { label, since, page, limit }) => {
72
+ const call = "messages.list";
73
+ const body = await api.get("messages", [
74
+ ["labelIds", label],
75
+ ["q", `after:${since}`],
76
+ ["maxResults", String(limit)],
77
+ ["pageToken", page]
78
+ ], call);
79
+ return {
80
+ messages: list(body.messages, call).map((message) => messageRef(message, call)),
81
+ next: pageToken(body.nextPageToken, call)
82
+ };
83
+ };
84
+ /** The headers a message's metadata gives, for a mail too large to read whole. */
85
+ const metadataHeaders = [
86
+ "From",
87
+ "To",
88
+ "Cc",
89
+ "Subject",
90
+ "Date"
91
+ ];
92
+ /** A message's whole RFC 822 source (`users.messages.get`, `format=raw`), at most `maxBytes` of answer. */
93
+ const rawMessage = async (api, id, maxBytes) => {
94
+ const call = "messages.get";
95
+ const body = await api.get(`messages/${encodeURIComponent(id)}`, [["format", "raw"]], call, maxBytes);
96
+ if (typeof body.raw !== "string" || !/^[A-Za-z0-9_-]*={0,2}$/.test(body.raw)) throw bad(call);
97
+ return base64url(body.raw);
98
+ };
99
+ /** A message's headers alone (`users.messages.get`, `format=metadata`). */
100
+ const messageHeaders = async (api, id) => {
101
+ const call = "messages.get";
102
+ const query = [["format", "metadata"], ...metadataHeaders.map((name) => ["metadataHeaders", name])];
103
+ const body = await api.get(`messages/${encodeURIComponent(id)}`, query, call);
104
+ return list(body.payload?.headers, call).map((header) => {
105
+ const { name, value } = header ?? {};
106
+ if (typeof name !== "string" || typeof value !== "string") throw bad(call);
107
+ return {
108
+ name,
109
+ value
110
+ };
111
+ });
112
+ };
113
+ //#endregion
114
+ export { historyPage, labels, messageHeaders, messagesPage, metadataHeaders, profile, rawMessage };
@@ -0,0 +1,35 @@
1
+ import { historyId } from "./api.js";
2
+ //#region src/gmail/cursor.ts
3
+ /** A Gmail label id, such as `INBOX` or `Label_12`, as it goes into a query. */
4
+ const labelId = /^[A-Za-z0-9_-]{1,100}$/;
5
+ const seconds = (value) => Number.isSafeInteger(value) && value >= 0;
6
+ const historyText = (value) => typeof value === "string" && historyId.test(value);
7
+ /** The cursor a poll got back as the trigger stored it, checked field by field; one that does not fit fails the poll. */
8
+ const readCursor = (value) => {
9
+ const cursor = value ?? {};
10
+ const label = cursor.label_id;
11
+ if (typeof label !== "string" || !labelId.test(label) || !seconds(cursor.polled_at)) throw new Error("The Gmail trigger has a cursor it cannot read");
12
+ if (cursor.recovery === void 0) {
13
+ if (!historyText(cursor.history_id) || !seconds(cursor.offset)) throw new Error("The Gmail trigger has a cursor it cannot read");
14
+ return {
15
+ label_id: label,
16
+ history_id: cursor.history_id,
17
+ offset: cursor.offset,
18
+ polled_at: cursor.polled_at
19
+ };
20
+ }
21
+ const { since, page_token: page, resume_history_id: resume } = cursor.recovery ?? {};
22
+ const pageFits = page === null || typeof page === "string" && page.length > 0 && page.length <= 1e3;
23
+ if (!seconds(since) || !pageFits || !historyText(resume)) throw new Error("The Gmail trigger has a cursor it cannot read");
24
+ return {
25
+ label_id: label,
26
+ polled_at: cursor.polled_at,
27
+ recovery: {
28
+ since,
29
+ page_token: page,
30
+ resume_history_id: resume
31
+ }
32
+ };
33
+ };
34
+ //#endregion
35
+ export { labelId, readCursor };
@@ -0,0 +1,10 @@
1
+ //#region src/gmail/filters.ts
2
+ const contains = (value, part) => value !== null && value.toLowerCase().includes(part.toLowerCase());
3
+ const anyAddress = (list, part) => list.some(({ name, address }) => contains(name, part) || contains(address, part));
4
+ /**
5
+ * Whether a mail passes every filter that is set: `from` on the sender's name or address, `to` on any To or Cc
6
+ * recipient, `subject` on the subject. A mail without the value a set filter reads does not pass.
7
+ */
8
+ const passes = (item, { from, to, subject }) => (from === void 0 || anyAddress(item.from ? [item.from] : [], from)) && (to === void 0 || anyAddress([...item.to, ...item.cc], to)) && (subject === void 0 || contains(item.subject, subject));
9
+ //#endregion
10
+ export { passes };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The first `limit` messages added after `start`, skipping `offset` messages of its first record, and where the next
3
+ * walk starts: inside a record, the record before it (or `start`) with the messages of it now taken; at a record's end,
4
+ * that record; after the last record of the last page, the mailbox's current history id, the only place it is used,
5
+ * and `caughtUp`.
6
+ */
7
+ const walkHistory = async (read, start, offset, limit) => {
8
+ const taken = [];
9
+ let before = start;
10
+ let skip = offset;
11
+ let page;
12
+ for (let pages = 0; pages < 10; pages++) {
13
+ const answer = await read(page);
14
+ for (const [index, record] of answer.records.entries()) {
15
+ const left = record.added.slice(skip);
16
+ const room = limit - taken.length;
17
+ if (left.length > room) {
18
+ taken.push(...left.slice(0, room));
19
+ return {
20
+ taken,
21
+ history_id: before,
22
+ offset: skip + room,
23
+ caughtUp: false
24
+ };
25
+ }
26
+ taken.push(...left);
27
+ before = record.id;
28
+ skip = 0;
29
+ const last = index === answer.records.length - 1 && answer.next === void 0;
30
+ if (taken.length === limit && !last) return {
31
+ taken,
32
+ history_id: record.id,
33
+ offset: 0,
34
+ caughtUp: false
35
+ };
36
+ }
37
+ if (answer.next === void 0) return {
38
+ taken,
39
+ history_id: answer.historyId,
40
+ offset: 0,
41
+ caughtUp: true
42
+ };
43
+ page = answer.next;
44
+ }
45
+ return {
46
+ taken,
47
+ history_id: before,
48
+ offset: skip,
49
+ caughtUp: false
50
+ };
51
+ };
52
+ //#endregion
53
+ export { walkHistory };
@@ -0,0 +1,4 @@
1
+ import { gmailApiUrl, gmailPlugin } from "./plugin.js";
2
+ import { mailsPerPoll } from "./new-mail.js";
3
+ import { MailAddress, MailAttachment, MailItem } from "./item-bounds.js";
4
+ export { type MailAddress, type MailAttachment, type MailItem, gmailApiUrl, gmailPlugin, mailsPerPoll };
@@ -0,0 +1,3 @@
1
+ import { mailsPerPoll } from "./new-mail.js";
2
+ import { gmailApiUrl, gmailPlugin } from "./plugin.js";
3
+ export { gmailApiUrl, gmailPlugin, mailsPerPoll };
@@ -0,0 +1,26 @@
1
+ //#region src/gmail/item-bounds.d.ts
2
+ /** An address of a mail as a run gets it: the display name and address as the mail states them, `null` when it states none. */
3
+ export type MailAddress = {
4
+ name: string | null;
5
+ address: string | null;
6
+ };
7
+ export type MailAttachment = {
8
+ filename: string | null;
9
+ content_type: string | null;
10
+ size: number;
11
+ };
12
+ /** One new mail as a run's input. `truncated` is set when a value was cut short or left out to fit the bounds below. */
13
+ export type MailItem = {
14
+ message_id: string;
15
+ thread_id: string;
16
+ from: MailAddress | null;
17
+ to: MailAddress[];
18
+ cc: MailAddress[];
19
+ subject: string | null;
20
+ date: string | null;
21
+ text: string | null;
22
+ html: string | null;
23
+ attachments: MailAttachment[] | null;
24
+ truncated: boolean;
25
+ };
26
+ //#endregion
@@ -0,0 +1,76 @@
1
+ //#region src/gmail/item-bounds.ts
2
+ /** The most of each value a run gets of one mail, so a mail cannot make a poll's item too large to start a run. */
3
+ const bounds = {
4
+ addressLength: 320,
5
+ nameLength: 320,
6
+ addresses: 50,
7
+ subjectLength: 1e3,
8
+ dateLength: 100,
9
+ /** Characters of `text` and of `html` each; as JSON, at most six bytes a character. */
10
+ bodyLength: 131072,
11
+ attachments: 50,
12
+ filenameLength: 255,
13
+ contentTypeLength: 255,
14
+ /** The most one item is as JSON, below a run's input limit of 1 MiB: past it, `html` is left out, then `text`. */
15
+ itemBytes: 786432
16
+ };
17
+ const jsonBytes = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
18
+ const unstorable = /\u0000|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
19
+ /**
20
+ * Leaves out of each value what the database cannot store and cuts it to its bound, never between the halves of a
21
+ * surrogate pair, then leaves out `html` and then `text` while the item is over `itemBytes` as JSON. Every other value
22
+ * together stays under it even when each character is escaped, so the result always fits.
23
+ */
24
+ const boundItem = (item) => {
25
+ let truncated = item.truncated;
26
+ const cut = (value, most) => {
27
+ if (value === null) return value;
28
+ const storable = value.replace(unstorable, "");
29
+ if (storable.length !== value.length) truncated = true;
30
+ if (storable.length <= most) return storable;
31
+ truncated = true;
32
+ const splitsPair = /[\uD800-\uDBFF]/.test(storable.charAt(most - 1));
33
+ return storable.slice(0, splitsPair ? most - 1 : most);
34
+ };
35
+ const few = (list, most) => {
36
+ if (list.length <= most) return list;
37
+ truncated = true;
38
+ return list.slice(0, most);
39
+ };
40
+ const address = ({ name, address }) => ({
41
+ name: cut(name, bounds.nameLength),
42
+ address: cut(address, bounds.addressLength)
43
+ });
44
+ const bounded = {
45
+ message_id: item.message_id,
46
+ thread_id: item.thread_id,
47
+ from: item.from && address(item.from),
48
+ to: few(item.to, bounds.addresses).map(address),
49
+ cc: few(item.cc, bounds.addresses).map(address),
50
+ subject: cut(item.subject, bounds.subjectLength),
51
+ date: cut(item.date, bounds.dateLength),
52
+ text: cut(item.text, bounds.bodyLength),
53
+ html: cut(item.html, bounds.bodyLength),
54
+ attachments: item.attachments && few(item.attachments, bounds.attachments).map(({ filename, content_type, size }) => ({
55
+ filename: cut(filename, bounds.filenameLength),
56
+ content_type: cut(content_type, bounds.contentTypeLength),
57
+ size
58
+ })),
59
+ truncated: false
60
+ };
61
+ for (const field of ["html", "text"]) {
62
+ if (jsonBytes({
63
+ ...bounded,
64
+ truncated
65
+ }) <= bounds.itemBytes) break;
66
+ if (bounded[field] === null) continue;
67
+ bounded[field] = null;
68
+ truncated = true;
69
+ }
70
+ return {
71
+ ...bounded,
72
+ truncated
73
+ };
74
+ };
75
+ //#endregion
76
+ export { boundItem, bounds };
@@ -0,0 +1,69 @@
1
+ import PostalMime, { addressParser, decodeWords } from "postal-mime";
2
+ //#region src/gmail/mail-item.ts
3
+ const tooComplex = /* @__PURE__ */ new Set([
4
+ "EMAXNESTINGDEPTH",
5
+ "EMAXHEADERSIZE",
6
+ "EMAXPARTCOUNT",
7
+ "EMAXFORWARDEDADDRESSES"
8
+ ]);
9
+ const stated = (value) => value === void 0 || value === "" ? null : value;
10
+ const mailboxes = (list) => (list ?? []).flatMap((entry) => entry.group ? entry.group : [entry]).map(({ name, address }) => ({
11
+ name: stated(name),
12
+ address: stated(address)
13
+ }));
14
+ /** A mail's raw source as the run's input, values as the mail states them; `undefined` when it is too complex to parse. */
15
+ const parsedItem = async (ref, raw) => {
16
+ const mail = await PostalMime.parse(raw).catch((error) => {
17
+ if (tooComplex.has(error?.code ?? "")) return;
18
+ throw error;
19
+ });
20
+ if (!mail) return;
21
+ return {
22
+ message_id: ref.id,
23
+ thread_id: ref.threadId,
24
+ from: mailboxes(mail.from ? [mail.from] : void 0)[0] ?? null,
25
+ to: mailboxes(mail.to),
26
+ cc: mailboxes(mail.cc),
27
+ subject: stated(mail.subject),
28
+ date: stated(mail.date),
29
+ text: stated(mail.text),
30
+ html: stated(mail.html),
31
+ attachments: mail.attachments.map((attachment) => ({
32
+ filename: attachment.filename,
33
+ content_type: stated(attachment.mimeType),
34
+ size: attachmentSize(attachment.content)
35
+ })),
36
+ truncated: false
37
+ };
38
+ };
39
+ const attachmentSize = (content) => typeof content === "string" ? content.length : content.byteLength;
40
+ /**
41
+ * A mail too large or too complex to read whole, from its headers alone: no body and no attachment names, which the
42
+ * headers do not hold, and `truncated` set.
43
+ */
44
+ const headersItem = (ref, headers) => {
45
+ const header = (name) => headers.find((entry) => entry.name.toLowerCase() === name.toLowerCase())?.value;
46
+ const addresses = (name) => {
47
+ const value = header(name);
48
+ return value === void 0 ? [] : mailboxes(addressParser(value)).map(({ name: shown, address }) => ({
49
+ name: shown === null ? null : stated(decodeWords(shown)),
50
+ address
51
+ }));
52
+ };
53
+ const subject = header("Subject");
54
+ return {
55
+ message_id: ref.id,
56
+ thread_id: ref.threadId,
57
+ from: addresses("From")[0] ?? null,
58
+ to: addresses("To"),
59
+ cc: addresses("Cc"),
60
+ subject: subject === void 0 ? null : stated(decodeWords(subject)),
61
+ date: stated(header("Date")),
62
+ text: null,
63
+ html: null,
64
+ attachments: null,
65
+ truncated: true
66
+ };
67
+ };
68
+ //#endregion
69
+ export { headersItem, parsedItem };
@@ -0,0 +1,5 @@
1
+ import "@protobase/workflows/plugin";
2
+ //#region src/gmail/new-mail.d.ts
3
+ /** The most mails one poll reads: 90 raw reads and a history call stay within a mailbox's quota for one minute. */
4
+ export declare const mailsPerPoll = 90;
5
+ //#endregion
@@ -0,0 +1,161 @@
1
+ import { GmailNotFound, gmailApi } from "./api.js";
2
+ import { historyPage, labels, messagesPage, profile } from "./calls.js";
3
+ import { labelId, readCursor } from "./cursor.js";
4
+ import { passes } from "./filters.js";
5
+ import { walkHistory } from "./history-walk.js";
6
+ import { boundItem } from "./item-bounds.js";
7
+ import { readMessage } from "./read-message.js";
8
+ //#region src/gmail/new-mail.ts
9
+ /** The most mails one poll reads: 90 raw reads and a history call stay within a mailbox's quota for one minute. */
10
+ const mailsPerPoll = 90;
11
+ const filterText = {
12
+ type: "string",
13
+ minLength: 1,
14
+ maxLength: 500
15
+ };
16
+ const seconds = (time) => Math.floor(time.getTime() / 1e3);
17
+ const findLabel = async (api, wanted) => {
18
+ const label = (await labels(api)).find(({ id, name }) => id === wanted || name === wanted);
19
+ if (!label) throw new Error(`The mailbox has no label "${wanted}"`);
20
+ if (!labelId.test(label.id)) throw new Error("Gmail labels.list answered an unexpected shape");
21
+ return label.id;
22
+ };
23
+ const filtersOf = (input) => ({
24
+ ...typeof input.from === "string" && { from: input.from },
25
+ ...typeof input.to === "string" && { to: input.to },
26
+ ...typeof input.subject === "string" && { subject: input.subject }
27
+ });
28
+ const itemsOf = async (api, taken, filters) => {
29
+ const items = [];
30
+ const seen = /* @__PURE__ */ new Set();
31
+ for (const ref of taken) {
32
+ if (seen.has(ref.id)) continue;
33
+ seen.add(ref.id);
34
+ const item = await readMessage(api, ref);
35
+ if (item && passes(item, filters)) items.push({
36
+ key: ref.id,
37
+ data: boundItem(item)
38
+ });
39
+ }
40
+ return items;
41
+ };
42
+ const historyPoll = async (api, cursor, filters, limit, due) => {
43
+ const read = (page) => historyPage(api, {
44
+ start: cursor.history_id,
45
+ label: cursor.label_id,
46
+ page
47
+ });
48
+ const walked = await walkHistory(read, cursor.history_id, cursor.offset, limit).catch((error) => {
49
+ if (error instanceof GmailNotFound) return;
50
+ throw error;
51
+ });
52
+ if (!walked) return {
53
+ items: [],
54
+ cursor: {
55
+ label_id: cursor.label_id,
56
+ polled_at: cursor.polled_at,
57
+ recovery: {
58
+ since: cursor.polled_at,
59
+ page_token: null,
60
+ resume_history_id: await profile(api)
61
+ }
62
+ }
63
+ };
64
+ const next = {
65
+ label_id: cursor.label_id,
66
+ history_id: walked.history_id,
67
+ offset: walked.offset,
68
+ polled_at: walked.caughtUp ? seconds(due) : cursor.polled_at
69
+ };
70
+ return {
71
+ items: await itemsOf(api, walked.taken, filters),
72
+ cursor: next
73
+ };
74
+ };
75
+ const recoveryPoll = async (api, cursor, filters, limit) => {
76
+ const { since, page_token: page, resume_history_id: resume } = cursor.recovery;
77
+ const listed = await messagesPage(api, {
78
+ label: cursor.label_id,
79
+ since,
80
+ page: page ?? void 0,
81
+ limit
82
+ });
83
+ return {
84
+ items: await itemsOf(api, listed.messages.slice(0, limit), filters),
85
+ cursor: listed.next ? {
86
+ ...cursor,
87
+ recovery: {
88
+ ...cursor.recovery,
89
+ page_token: listed.next
90
+ }
91
+ } : {
92
+ label_id: cursor.label_id,
93
+ history_id: resume,
94
+ offset: 0,
95
+ polled_at: cursor.polled_at
96
+ }
97
+ };
98
+ };
99
+ const api = (origin, { fetch, connection, signal }) => gmailApi({
100
+ origin,
101
+ fetch,
102
+ connection,
103
+ signal
104
+ });
105
+ /**
106
+ * `gmail:new_mail`: one run per new mail on a label of the connected mailbox, from the time the trigger is turned on,
107
+ * read with read-only access by polling the mailbox's history every minute. Google answers at `origin`.
108
+ */
109
+ const newMailTrigger = (origin) => ({
110
+ kind: "polling",
111
+ label: "New mail",
112
+ description: "Starts one run for each new mail on a label of a connected mailbox, INBOX by default, that passes the filters set.",
113
+ connection: "google",
114
+ input: {
115
+ type: "object",
116
+ additionalProperties: false,
117
+ properties: {
118
+ label: {
119
+ type: "string",
120
+ minLength: 1,
121
+ maxLength: 225,
122
+ description: "the label to watch, by name or id; default INBOX"
123
+ },
124
+ from: {
125
+ ...filterText,
126
+ description: "only mail whose sender's name or address contains this, in any case"
127
+ },
128
+ to: {
129
+ ...filterText,
130
+ description: "only mail with a To or Cc recipient whose name or address contains this, in any case"
131
+ },
132
+ subject: {
133
+ ...filterText,
134
+ description: "only mail whose subject contains this, in any case"
135
+ }
136
+ }
137
+ },
138
+ output: {
139
+ type: "object",
140
+ description: "{ message_id, thread_id, from, to, cc, subject, date, text, html, attachments: [{ filename, content_type, size }], truncated }"
141
+ },
142
+ every: "1 minute",
143
+ limit: 90,
144
+ start: async (context) => {
145
+ const gmail = api(origin, context);
146
+ return {
147
+ label_id: await findLabel(gmail, typeof context.input.label === "string" ? context.input.label : "INBOX"),
148
+ history_id: await profile(gmail),
149
+ offset: 0,
150
+ polled_at: seconds(/* @__PURE__ */ new Date())
151
+ };
152
+ },
153
+ poll: async (context) => {
154
+ const gmail = api(origin, context);
155
+ const cursor = readCursor(context.cursor);
156
+ const filters = filtersOf(context.input);
157
+ return "recovery" in cursor ? recoveryPoll(gmail, cursor, filters, context.limit) : historyPoll(gmail, cursor, filters, context.limit, context.due);
158
+ }
159
+ });
160
+ //#endregion
161
+ export { mailsPerPoll, newMailTrigger };
@@ -0,0 +1,29 @@
1
+ //#region src/gmail/plugin.d.ts
2
+ /** Where Gmail's API answers. */
3
+ export declare const gmailApiUrl = "https://gmail.googleapis.com";
4
+ /**
5
+ * `gmail:*`: new mail of a mailbox its owner connected through the app's Google sign-in, with read-only access. Only
6
+ * the app's code passes `apiUrl`, such as a test's own Gmail; a workflow never sets where the token goes.
7
+ */
8
+ export declare const gmailPlugin: ({ apiUrl }?: {
9
+ apiUrl?: string;
10
+ }) => {
11
+ readonly name: "gmail";
12
+ readonly label: "Gmail";
13
+ readonly connections: {
14
+ readonly google: {
15
+ readonly label: "Google account";
16
+ readonly signIn: "google";
17
+ readonly scopes: ["https://www.googleapis.com/auth/gmail.readonly"];
18
+ readonly link_params: {
19
+ readonly access_type: "offline";
20
+ readonly prompt: "consent";
21
+ };
22
+ readonly revocation_url: "https://oauth2.googleapis.com/revoke";
23
+ };
24
+ };
25
+ readonly triggers: {
26
+ readonly new_mail: import("@protobase/workflows/plugin").PollingTrigger;
27
+ };
28
+ };
29
+ //#endregion
@@ -0,0 +1,36 @@
1
+ import { newMailTrigger } from "./new-mail.js";
2
+ import { definePlugin } from "@protobase/workflows/plugin";
3
+ //#region src/gmail/plugin.ts
4
+ /** Where Gmail's API answers. */
5
+ const gmailApiUrl = "https://gmail.googleapis.com";
6
+ const localHosts = [
7
+ "localhost",
8
+ "127.0.0.1",
9
+ "[::1]"
10
+ ];
11
+ const apiOrigin = (value) => {
12
+ const url = URL.canParse(value) ? new URL(value) : void 0;
13
+ if (!url || !(url.protocol === "https:" || url.protocol === "http:" && localHosts.includes(url.hostname))) throw new Error(`The Gmail plugin's apiUrl must be an https URL: ${value}`);
14
+ return url.origin;
15
+ };
16
+ /**
17
+ * `gmail:*`: new mail of a mailbox its owner connected through the app's Google sign-in, with read-only access. Only
18
+ * the app's code passes `apiUrl`, such as a test's own Gmail; a workflow never sets where the token goes.
19
+ */
20
+ const gmailPlugin = ({ apiUrl = gmailApiUrl } = {}) => definePlugin({
21
+ name: "gmail",
22
+ label: "Gmail",
23
+ connections: { google: {
24
+ label: "Google account",
25
+ signIn: "google",
26
+ scopes: ["https://www.googleapis.com/auth/gmail.readonly"],
27
+ link_params: {
28
+ access_type: "offline",
29
+ prompt: "consent"
30
+ },
31
+ revocation_url: "https://oauth2.googleapis.com/revoke"
32
+ } },
33
+ triggers: { new_mail: newMailTrigger(apiOrigin(apiUrl)) }
34
+ });
35
+ //#endregion
36
+ export { gmailApiUrl, gmailPlugin };
@@ -0,0 +1,28 @@
1
+ import { GmailNotFound } from "./api.js";
2
+ import { messageHeaders, rawMessage } from "./calls.js";
3
+ import { headersItem, parsedItem } from "./mail-item.js";
4
+ import { ResponseTooLargeError, fetchMaxBytes } from "@protobase/workflows/plugin";
5
+ //#region src/gmail/read-message.ts
6
+ const unlessDeleted = (read) => read.catch((error) => {
7
+ if (error instanceof GmailNotFound) return;
8
+ throw error;
9
+ });
10
+ const rawOrTooLarge = (api, id) => rawMessage(api, id, fetchMaxBytes).catch((error) => {
11
+ if (error instanceof ResponseTooLargeError) return "too large";
12
+ throw error;
13
+ });
14
+ /**
15
+ * One message as a run's input: read whole, at most 10 MiB of answer, which holds the mail in base64 and so about
16
+ * 7.5 MiB of mail, else from its headers alone when it is larger or too complex to parse; `undefined` when it was
17
+ * deleted since it was listed.
18
+ */
19
+ const readMessage = async (api, ref) => {
20
+ const raw = await unlessDeleted(rawOrTooLarge(api, ref.id));
21
+ if (raw === void 0) return;
22
+ const parsed = raw === "too large" ? void 0 : await parsedItem(ref, raw);
23
+ if (parsed) return parsed;
24
+ const headers = await unlessDeleted(messageHeaders(api, ref.id));
25
+ return headers && headersItem(ref, headers);
26
+ };
27
+ //#endregion
28
+ export { readMessage };
package/dist/index.d.ts CHANGED
@@ -1,2 +1,4 @@
1
+ import { gmailPlugin } from "./gmail/plugin.js";
2
+ import "./gmail/index.js";
1
3
  import { appPlugin } from "./app.js";
2
- export { appPlugin, appPlugin as default };
4
+ export { appPlugin, appPlugin as default, gmailPlugin };
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  import { appPlugin } from "./app.js";
2
- export { appPlugin, appPlugin as default };
2
+ import { gmailPlugin } from "./gmail/plugin.js";
3
+ export { appPlugin, appPlugin as default, gmailPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/workflow-plugins",
3
- "version": "0.71.1",
3
+ "version": "0.71.3",
4
4
  "description": "The built-in workflow trigger and step types, written on the public plugin interface like any other plugin.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -15,7 +15,8 @@
15
15
  "node": ">=22.12.0"
16
16
  },
17
17
  "exports": {
18
- ".": "./dist/index.js"
18
+ ".": "./dist/index.js",
19
+ "./gmail": "./dist/gmail/index.js"
19
20
  },
20
21
  "types": "./dist/index.d.ts",
21
22
  "files": [
@@ -29,6 +30,7 @@
29
30
  "access": "public"
30
31
  },
31
32
  "dependencies": {
32
- "@protobase/workflows": "0.71.1"
33
+ "@protobase/workflows": "0.71.3",
34
+ "postal-mime": "^4.2.0"
33
35
  }
34
36
  }