runline 0.37.0 → 0.38.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/credentials/http.d.ts +17 -3
- package/dist/credentials/http.js +31 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plugins/_shared/attachments.js +97 -0
- package/dist/plugins/_shared/microsoftAuth.js +33 -4
- package/dist/plugins/_shared/schemas.js +8 -0
- package/dist/plugins/agentMail/src/drafts.js +2 -1
- package/dist/plugins/agentMail/src/inboxes.js +2 -1
- package/dist/plugins/agentMail/src/messages.js +2 -1
- package/dist/plugins/agentMail/src/schemas.js +4 -41
- package/dist/plugins/agentMail/src/shared.js +28 -50
- package/dist/plugins/agentMail/src/threads.js +2 -1
- package/dist/plugins/gmail/src/index.js +10 -22
- package/dist/plugins/microsoftCalendar/src/index.js +513 -17
- package/dist/plugins/microsoftFiles/src/index.js +3 -3
- package/dist/plugins/microsoftMail/src/index.js +305 -54
- package/dist/plugins/todoist/src/credentials.js +4 -11
- package/dist/plugins/todoist/src/index.js +390 -281
- package/dist/plugins/zohoMail/src/credentials.js +92 -0
- package/dist/plugins/zohoMail/src/index.js +241 -0
- package/package.json +1 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { configChoice, grantSchema } from "runline";
|
|
2
|
+
/**
|
|
3
|
+
* Zoho's data centres, each an accounts host and a mail API host. Tokens
|
|
4
|
+
* are data-centre bound: one minted at accounts.zoho.eu is rejected by
|
|
5
|
+
* mail.zoho.com, and the failure reads like a permissions problem rather
|
|
6
|
+
* than a region one. `dc` therefore selects both hosts as a pair, so the
|
|
7
|
+
* token endpoint and the API cannot drift apart.
|
|
8
|
+
*/
|
|
9
|
+
export const DC = {
|
|
10
|
+
com: { accounts: "https://accounts.zoho.com", api: "https://mail.zoho.com" },
|
|
11
|
+
eu: { accounts: "https://accounts.zoho.eu", api: "https://mail.zoho.eu" },
|
|
12
|
+
in: { accounts: "https://accounts.zoho.in", api: "https://mail.zoho.in" },
|
|
13
|
+
"com.au": {
|
|
14
|
+
accounts: "https://accounts.zoho.com.au",
|
|
15
|
+
api: "https://mail.zoho.com.au",
|
|
16
|
+
},
|
|
17
|
+
jp: { accounts: "https://accounts.zoho.jp", api: "https://mail.zoho.jp" },
|
|
18
|
+
ca: {
|
|
19
|
+
accounts: "https://accounts.zohocloud.ca",
|
|
20
|
+
api: "https://mail.zohocloud.ca",
|
|
21
|
+
},
|
|
22
|
+
"com.cn": {
|
|
23
|
+
accounts: "https://accounts.zoho.com.cn",
|
|
24
|
+
api: "https://mail.zoho.com.cn",
|
|
25
|
+
},
|
|
26
|
+
ae: { accounts: "https://accounts.zoho.ae", api: "https://mail.zoho.ae" },
|
|
27
|
+
sa: { accounts: "https://accounts.zoho.sa", api: "https://mail.zoho.sa" },
|
|
28
|
+
};
|
|
29
|
+
export const DCS = Object.keys(DC);
|
|
30
|
+
/** What `runline auth zohoMail` consents to, and the grant's tokens carry. */
|
|
31
|
+
export const SCOPES = [
|
|
32
|
+
"ZohoMail.accounts.READ",
|
|
33
|
+
"ZohoMail.folders.READ",
|
|
34
|
+
"ZohoMail.messages.READ",
|
|
35
|
+
"ZohoMail.tags.READ",
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* The owner's Zoho Mail grant: a refresh token renewed at the data
|
|
39
|
+
* centre's accounts host, whose access tokens sign the mail API on the
|
|
40
|
+
* same data centre. The client credentials go as form fields
|
|
41
|
+
* (`client_secret_post`), as Zoho takes them. The broker signs with
|
|
42
|
+
* `Authorization: Bearer`, which Zoho's OAuth accepts alongside its own
|
|
43
|
+
* `Zoho-oauthtoken` scheme. An unknown `dc` is invalid_credentials, never
|
|
44
|
+
* a default: a mistyped region would send the token to another one.
|
|
45
|
+
*/
|
|
46
|
+
export const zohoMailCredential = (config) => {
|
|
47
|
+
const hosts = DC[configChoice(config.dc, DCS, "com")];
|
|
48
|
+
return {
|
|
49
|
+
type: {
|
|
50
|
+
id: "zohoMail",
|
|
51
|
+
methods: {
|
|
52
|
+
oauth2: {
|
|
53
|
+
schema: grantSchema,
|
|
54
|
+
authentication: {
|
|
55
|
+
kind: "oauth2",
|
|
56
|
+
field: "grant",
|
|
57
|
+
renewal: "refresh",
|
|
58
|
+
scopes: SCOPES,
|
|
59
|
+
definition: {
|
|
60
|
+
id: "zohoMail.oauth2",
|
|
61
|
+
provider: "zoho",
|
|
62
|
+
refresh: {
|
|
63
|
+
url: `${hosts.accounts}/oauth/v2/token`,
|
|
64
|
+
clientAuthentication: "client_secret_post",
|
|
65
|
+
encoding: "form",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
targets: {
|
|
70
|
+
api: {
|
|
71
|
+
baseUrl: `${hosts.api}/api/`,
|
|
72
|
+
methods: ["GET"],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
probe: {
|
|
76
|
+
target: "api",
|
|
77
|
+
path: "accounts",
|
|
78
|
+
method: "GET",
|
|
79
|
+
acceptedStatuses: [200],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
method: "oauth2",
|
|
85
|
+
application: {
|
|
86
|
+
clientId: config.clientId,
|
|
87
|
+
...(typeof config.clientSecret === "string"
|
|
88
|
+
? { clientSecret: config.clientSecret }
|
|
89
|
+
: {}),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { credentialRequest, jsonAnswer, pathSegment, responseFailed, } from "runline";
|
|
2
|
+
import * as t from "typebox";
|
|
3
|
+
import { DCS, SCOPES, zohoMailCredential } from "./credentials.js";
|
|
4
|
+
/**
|
|
5
|
+
* Zoho Mail, read-only: accounts, folders, labels, message lists and
|
|
6
|
+
* search, message content, attachments and raw source, which is what
|
|
7
|
+
* triaging a mailbox takes. Zoho CRM is the separate `zoho` plugin.
|
|
8
|
+
*
|
|
9
|
+
* Tokens are bound to a data centre: `credentials.ts` selects the accounts
|
|
10
|
+
* host and the API host as a pair from `dc`.
|
|
11
|
+
*/
|
|
12
|
+
const STRICT = { additionalProperties: false };
|
|
13
|
+
const Id = (description) => t.String({ minLength: 1, ...(description ? { description } : {}) });
|
|
14
|
+
/** Zoho's error envelope, `{ status, data: { errorCode, moreInfo? } }`: only the code is kept. */
|
|
15
|
+
function zohoError(body) {
|
|
16
|
+
const data = body?.data;
|
|
17
|
+
return { code: data?.errorCode };
|
|
18
|
+
}
|
|
19
|
+
/** One read of the mail API; a failure reports the status and Zoho's errorCode. */
|
|
20
|
+
async function read(ctx, path, options = {}) {
|
|
21
|
+
const res = await credentialRequest(ctx, zohoMailCredential, {
|
|
22
|
+
target: "api",
|
|
23
|
+
path,
|
|
24
|
+
query: options.query,
|
|
25
|
+
...(options.accept ? { headers: { Accept: options.accept } } : {}),
|
|
26
|
+
});
|
|
27
|
+
if (!res.ok)
|
|
28
|
+
throw await responseFailed("zohoMail", res, zohoError);
|
|
29
|
+
return res;
|
|
30
|
+
}
|
|
31
|
+
/** A JSON read, unwrapped from Zoho's `{ status, data }` envelope. */
|
|
32
|
+
async function api(ctx, path, query) {
|
|
33
|
+
const body = await jsonAnswer(await read(ctx, path, { query }));
|
|
34
|
+
return body.data !== undefined ? body.data : body;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* An attachment's bytes as a googleDrive.file.download result carries
|
|
38
|
+
* them, so the answer passes unedited to a mail plugin's attachment input.
|
|
39
|
+
* The endpoint returns the file itself and answers
|
|
40
|
+
* `Accept: application/json` with 406 NOT_ACCEPTABLE, so it asks for any
|
|
41
|
+
* type.
|
|
42
|
+
*/
|
|
43
|
+
async function download(ctx, path) {
|
|
44
|
+
const res = await read(ctx, path, { accept: "*/*" });
|
|
45
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
46
|
+
const mimeType = res.headers.get("content-type")?.split(";")[0].trim();
|
|
47
|
+
return {
|
|
48
|
+
contentBase64: bytes.toString("base64"),
|
|
49
|
+
...(mimeType ? { mimeType } : {}),
|
|
50
|
+
size: bytes.byteLength,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** The call's accountId, else the connection's, as one path segment. */
|
|
54
|
+
function account(accountId, ctx) {
|
|
55
|
+
const id = accountId ?? ctx.connection.config.accountId;
|
|
56
|
+
if (typeof id !== "string" || !id)
|
|
57
|
+
throw new Error("zohoMail: accountId required — pass it, set ZOHOMAIL_ACCOUNT_ID, or call account.list first");
|
|
58
|
+
return pathSegment(id);
|
|
59
|
+
}
|
|
60
|
+
/** `accounts/{accountId}/folders/{folderId}/messages/{messageId}` */
|
|
61
|
+
function messagePath(input, ctx) {
|
|
62
|
+
return `accounts/${account(input.accountId, ctx)}/folders/${pathSegment(input.folderId)}/messages/${pathSegment(input.messageId)}`;
|
|
63
|
+
}
|
|
64
|
+
const AccountId = t.Optional(Id("Defaults to the connection's accountId"));
|
|
65
|
+
const Start = t.Optional(t.Integer({ minimum: 1, description: "1-based start index (default 1)" }));
|
|
66
|
+
const accountOnly = t.Object({ accountId: AccountId }, STRICT);
|
|
67
|
+
const messageList = t.Object({
|
|
68
|
+
accountId: AccountId,
|
|
69
|
+
folderId: Id("From folder.list. Without it Zoho mixes every folder."),
|
|
70
|
+
start: Start,
|
|
71
|
+
limit: t.Optional(t.Integer({ minimum: 1, maximum: 200, description: "Default 25" })),
|
|
72
|
+
status: t.Optional(t.Union([t.Literal("read"), t.Literal("unread"), t.Literal("all")])),
|
|
73
|
+
includeto: t.Optional(t.Boolean({ description: "Include recipient details" })),
|
|
74
|
+
}, STRICT);
|
|
75
|
+
const messageSearch = t.Object({
|
|
76
|
+
accountId: AccountId,
|
|
77
|
+
searchKey: Id(),
|
|
78
|
+
start: Start,
|
|
79
|
+
limit: t.Optional(t.Integer({ minimum: 1 })),
|
|
80
|
+
}, STRICT);
|
|
81
|
+
const message = {
|
|
82
|
+
accountId: AccountId,
|
|
83
|
+
folderId: Id("From folder.list or message.list"),
|
|
84
|
+
messageId: Id(),
|
|
85
|
+
};
|
|
86
|
+
const messageContent = t.Object({
|
|
87
|
+
...message,
|
|
88
|
+
includeBlockContent: t.Optional(t.Boolean({
|
|
89
|
+
description: "Return the reply and the quoted parent separately",
|
|
90
|
+
})),
|
|
91
|
+
}, STRICT);
|
|
92
|
+
const messageOnly = t.Object(message, STRICT);
|
|
93
|
+
const attachment = t.Object({ ...message, attachmentId: Id("From message.attachmentInfo") }, STRICT);
|
|
94
|
+
const original = t.Object({ accountId: AccountId, messageId: Id() }, STRICT);
|
|
95
|
+
export default function zohoMail(rl) {
|
|
96
|
+
rl.setName("zohoMail");
|
|
97
|
+
rl.setVersion("0.1.0");
|
|
98
|
+
rl.setConnectionSchema(t.Object({
|
|
99
|
+
dc: t.Optional(t.Union(DCS.map((k) => t.Literal(k)), {
|
|
100
|
+
env: "ZOHOMAIL_DC",
|
|
101
|
+
description: 'Data centre the account lives in. Must match the API console the client was created in — a token from one region is rejected by the others. Default "com".',
|
|
102
|
+
})),
|
|
103
|
+
clientId: t.Optional(t.String({
|
|
104
|
+
env: "ZOHOMAIL_CLIENT_ID",
|
|
105
|
+
description: "Client ID from the Zoho API console for this data centre",
|
|
106
|
+
})),
|
|
107
|
+
clientSecret: t.Optional(t.String({
|
|
108
|
+
env: "ZOHOMAIL_CLIENT_SECRET",
|
|
109
|
+
description: "Client secret",
|
|
110
|
+
})),
|
|
111
|
+
refreshToken: t.Optional(t.String({
|
|
112
|
+
env: "ZOHOMAIL_REFRESH_TOKEN",
|
|
113
|
+
description: "OAuth2 refresh token (set by the login flow)",
|
|
114
|
+
})),
|
|
115
|
+
accountId: t.Optional(t.String({
|
|
116
|
+
env: "ZOHOMAIL_ACCOUNT_ID",
|
|
117
|
+
description: "Default accountId so actions need not repeat it; get it from account.list",
|
|
118
|
+
})),
|
|
119
|
+
}));
|
|
120
|
+
rl.setCredential(zohoMailCredential);
|
|
121
|
+
rl.setOAuth({
|
|
122
|
+
authUrl: "https://accounts.zoho.com/oauth/v2/auth",
|
|
123
|
+
tokenUrl: "https://accounts.zoho.com/oauth/v2/token",
|
|
124
|
+
scopes: SCOPES,
|
|
125
|
+
setupHelp: [
|
|
126
|
+
"You need a Zoho OAuth client. One-time, ~5 minutes.",
|
|
127
|
+
"",
|
|
128
|
+
"FIRST, find your data centre: sign in to Zoho Mail and read the address bar.",
|
|
129
|
+
" mail.zoho.eu means eu, mail.zoho.com means com, and so on. Set `dc` to it.",
|
|
130
|
+
" This matters more than it looks: a token issued by one region is rejected",
|
|
131
|
+
" by every other, and the failure reads like a permissions problem.",
|
|
132
|
+
"",
|
|
133
|
+
"1. Open the API console FOR THAT REGION -- api-console.zoho.eu for eu,",
|
|
134
|
+
" api-console.zoho.com for com. The console, the accounts host and the API",
|
|
135
|
+
" host must all match.",
|
|
136
|
+
"2. Add a client of type 'Server-based Application'.",
|
|
137
|
+
"3. Authorized Redirect URI:",
|
|
138
|
+
" {{redirectUri}}",
|
|
139
|
+
"4. Copy the Client ID and Client Secret.",
|
|
140
|
+
`5. runline auth zohoMail (scopes: ${SCOPES.join(", ")})`,
|
|
141
|
+
"",
|
|
142
|
+
"A 'Self Client' also works if you would rather not register a redirect URI,",
|
|
143
|
+
"but then you generate a grant token in the console and exchange it yourself;",
|
|
144
|
+
"`runline auth` cannot drive that flow.",
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
rl.registerAction("account.list", {
|
|
148
|
+
access: "read",
|
|
149
|
+
description: "List the mail accounts this user can access, with accountId and primaryEmailAddress. Call this first — every other action needs an accountId.",
|
|
150
|
+
inputSchema: t.Object({}, STRICT),
|
|
151
|
+
async execute(_input, ctx) {
|
|
152
|
+
return api(ctx, "accounts");
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
rl.registerAction("folder.list", {
|
|
156
|
+
access: "read",
|
|
157
|
+
description: "List all folders with folderId, folderName and folderType. Match on folderType, not folderName: a mailbox migrated from IMAP can carry a 'Deleted Messages' folder whose folderType is Inbox, distinct from both Trash and Archive.",
|
|
158
|
+
inputSchema: accountOnly,
|
|
159
|
+
async execute(input, ctx) {
|
|
160
|
+
const p = input;
|
|
161
|
+
return api(ctx, `accounts/${account(p.accountId, ctx)}/folders`);
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
rl.registerAction("label.list", {
|
|
165
|
+
access: "read",
|
|
166
|
+
description: "List all labels with their ids and display names.",
|
|
167
|
+
inputSchema: accountOnly,
|
|
168
|
+
async execute(input, ctx) {
|
|
169
|
+
const p = input;
|
|
170
|
+
return api(ctx, `accounts/${account(p.accountId, ctx)}/labels`);
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
rl.registerAction("message.list", {
|
|
174
|
+
access: "read",
|
|
175
|
+
description: 'List messages in one folder. folderId is required: without it Zoho returns every folder mixed together, so archived and deleted mail is indistinguishable from inbox. Each entry carries a `summary` snippet, usually enough to triage without fetching content. `status` "0" means unread, and receivedTime is epoch milliseconds.',
|
|
176
|
+
inputSchema: messageList,
|
|
177
|
+
async execute(input, ctx) {
|
|
178
|
+
const p = input;
|
|
179
|
+
return api(ctx, `accounts/${account(p.accountId, ctx)}/messages/view`, {
|
|
180
|
+
folderId: p.folderId,
|
|
181
|
+
start: p.start,
|
|
182
|
+
limit: p.limit,
|
|
183
|
+
status: p.status,
|
|
184
|
+
includeto: p.includeto,
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
rl.registerAction("message.search", {
|
|
189
|
+
access: "read",
|
|
190
|
+
description: 'Search messages. searchKey is {field}:{value}, joined with "::" for AND and ":or:" for OR. Fields include entire, subject, sender, to, cc, content, fileName, fileContent, has:attachment, in:{folderId}, fromDate/toDate as DD-MMM-YYYY. Example: sender:bolt.eu::in:9000000002014',
|
|
191
|
+
inputSchema: messageSearch,
|
|
192
|
+
async execute(input, ctx) {
|
|
193
|
+
const p = input;
|
|
194
|
+
return api(ctx, `accounts/${account(p.accountId, ctx)}/messages/search`, {
|
|
195
|
+
searchKey: p.searchKey,
|
|
196
|
+
start: p.start,
|
|
197
|
+
limit: p.limit,
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
rl.registerAction("message.content", {
|
|
202
|
+
access: "read",
|
|
203
|
+
description: "Get the full body of one message. Requires folderId as well as messageId.",
|
|
204
|
+
inputSchema: messageContent,
|
|
205
|
+
async execute(input, ctx) {
|
|
206
|
+
const p = input;
|
|
207
|
+
return api(ctx, `${messagePath(p, ctx)}/content`, {
|
|
208
|
+
includeBlockContent: p.includeBlockContent,
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
rl.registerAction("message.attachmentInfo", {
|
|
213
|
+
access: "read",
|
|
214
|
+
description: "List a message's attachments with names, sizes and attachmentIds. Read the filenames before concluding an invoice is attached — senders routinely attach terms and cancellation notices while the figures sit only in the body.",
|
|
215
|
+
inputSchema: messageOnly,
|
|
216
|
+
async execute(input, ctx) {
|
|
217
|
+
const p = input;
|
|
218
|
+
return api(ctx, `${messagePath(p, ctx)}/attachmentinfo`);
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
rl.registerAction("message.attachment", {
|
|
222
|
+
access: "read",
|
|
223
|
+
description: "Download one attachment. Returns { contentBase64, mimeType, size }, accepted as-is as an attachment by mail plugins; its name is in message.attachmentInfo.",
|
|
224
|
+
inputSchema: attachment,
|
|
225
|
+
async execute(input, ctx) {
|
|
226
|
+
const p = input;
|
|
227
|
+
return download(ctx, `${messagePath(p, ctx)}/attachments/${pathSegment(p.attachmentId)}`);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
rl.registerAction("message.original", {
|
|
231
|
+
access: "read",
|
|
232
|
+
description: "Get the raw RFC-822 source of a message, attachments inline as base64. Large — prefer message.content plus message.attachment unless the MIME tree itself is needed.",
|
|
233
|
+
inputSchema: original,
|
|
234
|
+
async execute(input, ctx) {
|
|
235
|
+
const p = input;
|
|
236
|
+
// Account-wide: with a folder segment in the path Zoho answers
|
|
237
|
+
// 404 URL_RULE_NOT_CONFIGURED.
|
|
238
|
+
return api(ctx, `accounts/${account(p.accountId, ctx)}/messages/${pathSegment(p.messageId)}/originalmessage`);
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|