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.
@@ -1,27 +1,210 @@
1
- import { graphRequest, microsoftCredential, microsoftOAuth, userBase, } from "../../_shared/microsoftAuth.js";
1
+ /**
2
+ * Microsoft Outlook mail plugin for runline (Microsoft Graph).
3
+ *
4
+ * Auth: Microsoft identity platform OAuth2 (delegated, acts as the signed-in
5
+ * user → /me) seeded by the OAuth flow; or app-only client credentials
6
+ * (set tenantId/clientId/clientSecret + userUpn) for an unattended service
7
+ * mailbox. Delegated consent belongs to the "microsoft" grant, so one Entra
8
+ * app and one cached token serve every Microsoft plugin. See
9
+ * _shared/microsoftAuth.ts.
10
+ *
11
+ * Graph delegated scopes: Mail.Send, Mail.ReadWrite, Mail.Read.
12
+ *
13
+ * Attachments follow Graph's two documented paths: a file under 3 MB goes
14
+ * in one request with its message (or one POST to a draft); a file of 3 to
15
+ * 150 MB is attached to a draft through an upload session, its bytes PUT
16
+ * in ranges under 4 MB to the session's pre-authenticated Outlook URL. A
17
+ * message built as a draft is sent once every file is attached, and the
18
+ * draft is deleted if any step fails.
19
+ */
20
+ import { jsonAnswer, pathSegment, } from "runline";
21
+ import * as t from "typebox";
22
+ import { attachmentFile, attachmentSources, } from "../../_shared/attachments.js";
23
+ import { graphFailed, graphRequest, graphResponse, microsoftCredential, microsoftOAuth, outlookUpload, userBase, } from "../../_shared/microsoftAuth.js";
2
24
  const NAME = "microsoftMail";
3
25
  const SCOPES = [
4
26
  "https://graph.microsoft.com/Mail.Send",
5
27
  "https://graph.microsoft.com/Mail.ReadWrite",
6
28
  "https://graph.microsoft.com/Mail.Read",
7
29
  ];
8
- const recipients = (addrs) => (Array.isArray(addrs) ? addrs : addrs ? [addrs] : []).map((a) => ({
9
- emailAddress: { address: a },
30
+ /** Graph attaches a file in one request only under 3 MB. */
31
+ const ONE_REQUEST_BYTES = 3 * 1024 * 1024;
32
+ /** Graph's ceiling for one file attachment, through an upload session. */
33
+ const MAX_ATTACHMENT_BYTES = 150 * 1024 * 1024;
34
+ /** Upload session ranges stay under Graph's 4 MB guidance. */
35
+ const UPLOAD_RANGE_BYTES = 3 * 1024 * 1024;
36
+ const STRICT = { additionalProperties: false };
37
+ const Id = (description) => t.String({ minLength: 1, ...(description ? { description } : {}) });
38
+ const Addresses = (description, minItems = 0) => t.Array(t.String({ minLength: 1 }), {
39
+ description,
40
+ ...(minItems ? { minItems } : {}),
41
+ });
42
+ const Attachments = t.Array(t.Union([
43
+ ...attachmentSources("Up to 150 MB a file; Exchange limits a whole message to 35 MB unless the tenant raises it."),
44
+ ]), { minItems: 1 });
45
+ /** What mail.send and mail.draft compose a message from. */
46
+ const compose = {
47
+ to: t.Optional(Addresses("Recipient addresses")),
48
+ cc: t.Optional(Addresses("Cc addresses")),
49
+ bcc: t.Optional(Addresses("Bcc addresses")),
50
+ replyTo: t.Optional(Addresses("Reply-To addresses")),
51
+ subject: t.String(),
52
+ body: t.String(),
53
+ html: t.Optional(t.Boolean({ description: "body is HTML (default plain text)" })),
54
+ importance: t.Optional(t.Union([t.Literal("low"), t.Literal("normal"), t.Literal("high")])),
55
+ attachments: t.Optional(Attachments),
56
+ };
57
+ const send = t.Object({ ...compose, to: Addresses("Recipient addresses", 1) }, STRICT);
58
+ const draft = t.Object(compose, STRICT);
59
+ const byId = t.Object({ id: Id("Message id") }, STRICT);
60
+ const SendNow = t.Optional(t.Boolean({
61
+ description: "Send at once (default); false keeps the draft and returns it",
10
62
  }));
11
- function toMessage(input) {
63
+ const reply = t.Object({
64
+ id: Id("The message to reply to"),
65
+ comment: t.Optional(t.String({ description: "Text of the reply" })),
66
+ replyAll: t.Optional(t.Boolean({ description: "Reply to the sender and every recipient" })),
67
+ attachments: t.Optional(Attachments),
68
+ send: SendNow,
69
+ }, STRICT);
70
+ const forward = t.Object({
71
+ id: Id("The message to forward"),
72
+ to: Addresses("Recipient addresses", 1),
73
+ comment: t.Optional(t.String({ description: "Text above the message" })),
74
+ attachments: t.Optional(Attachments),
75
+ send: SendNow,
76
+ }, STRICT);
77
+ const list = t.Object({
78
+ search: t.Optional(Id("KQL search across the mailbox")),
79
+ top: t.Optional(t.Integer({ minimum: 1, description: "Default 20" })),
80
+ }, STRICT);
81
+ const attachmentAdd = t.Object({
82
+ messageId: Id("A draft message"),
83
+ attachment: t.Union([
84
+ ...attachmentSources("Up to 150 MB; Exchange limits a whole message to 35 MB unless the tenant raises it."),
85
+ ]),
86
+ }, STRICT);
87
+ const attachmentList = t.Object({ messageId: Id() }, STRICT);
88
+ const attachmentRef = t.Object({ messageId: Id(), attachmentId: Id("From attachment.list") }, STRICT);
89
+ const recipients = (addresses) => (addresses ?? []).map((address) => ({ emailAddress: { address } }));
90
+ function graph(ctx, method, path, body) {
91
+ return graphRequest(ctx, NAME, SCOPES, method, path, body);
92
+ }
93
+ const messagePath = (ctx, id) => `${userBase(ctx)}/messages/${pathSegment(id)}`;
94
+ /** Every attachment's bytes, named, before any request. */
95
+ function files(inputs = []) {
96
+ return Promise.all(inputs.map(async (input, index) => {
97
+ const file = await attachmentFile(input, index, {
98
+ plugin: NAME,
99
+ maxBytes: MAX_ATTACHMENT_BYTES,
100
+ oversize: `${NAME}: attachment ${index} exceeds Graph's 150 MB attachment limit`,
101
+ });
102
+ return { ...file, name: file.name ?? `attachment-${index + 1}`, input };
103
+ }));
104
+ }
105
+ /** Whether the files ride in the same request as their message. */
106
+ const oneRequest = (all) => all.reduce((sum, file) => sum + file.bytes.length, 0) < ONE_REQUEST_BYTES;
107
+ /** Graph's fileAttachment, for a file sent in one request. */
108
+ function fileAttachment(file) {
12
109
  return {
13
- subject: input.subject,
14
- body: {
15
- contentType: input.html ? "HTML" : "Text",
16
- content: input.body ?? "",
110
+ "@odata.type": "#microsoft.graph.fileAttachment",
111
+ name: file.name,
112
+ contentBytes: file.bytes.toString("base64"),
113
+ ...(file.mimeType ? { contentType: file.mimeType } : {}),
114
+ ...(file.input.inline !== undefined ? { isInline: file.input.inline } : {}),
115
+ ...(file.input.contentId ? { contentId: file.input.contentId } : {}),
116
+ };
117
+ }
118
+ /** The attachment id in an upload session's final Location header. */
119
+ const LOCATION_ID = /Attachments\('([^']+)'\)/;
120
+ /**
121
+ * Attach one file of 3 to 150 MB through an upload session: its ranges
122
+ * PUT in order, each checked against the range the session expects next.
123
+ * A failed upload deletes the session.
124
+ */
125
+ async function uploadSession(ctx, messageId, file) {
126
+ const session = await graph(ctx, "POST", `${messagePath(ctx, messageId)}/attachments/createUploadSession`, {
127
+ AttachmentItem: {
128
+ attachmentType: "file",
129
+ name: file.name,
130
+ size: file.bytes.length,
131
+ ...(file.mimeType ? { contentType: file.mimeType } : {}),
132
+ ...(file.input.inline !== undefined
133
+ ? { isInline: file.input.inline }
134
+ : {}),
135
+ ...(file.input.contentId ? { contentId: file.input.contentId } : {}),
17
136
  },
18
- toRecipients: recipients(input.to),
19
- ccRecipients: recipients(input.cc),
137
+ });
138
+ const total = file.bytes.length;
139
+ try {
140
+ for (let start = 0; start < total; start += UPLOAD_RANGE_BYTES) {
141
+ const end = Math.min(start + UPLOAD_RANGE_BYTES, total);
142
+ const res = await outlookUpload(session.uploadUrl, {
143
+ method: "PUT",
144
+ headers: {
145
+ "Content-Type": "application/octet-stream",
146
+ "Content-Range": `bytes ${start}-${end - 1}/${total}`,
147
+ },
148
+ body: file.bytes.subarray(start, end),
149
+ });
150
+ if (!res.ok)
151
+ throw await graphFailed(NAME, res);
152
+ if (end === total)
153
+ return LOCATION_ID.exec(res.headers.get("location") ?? "")?.[1];
154
+ const { nextExpectedRanges } = await jsonAnswer(res);
155
+ const next = nextExpectedRanges?.[0]?.split("-")[0];
156
+ if (next !== undefined && Number(next) !== end)
157
+ throw new Error(`${NAME}: the upload session expects byte ${next}, not ${end}`);
158
+ }
159
+ }
160
+ catch (error) {
161
+ await outlookUpload(session.uploadUrl, { method: "DELETE" }).catch(() => undefined);
162
+ throw error;
163
+ }
164
+ return undefined;
165
+ }
166
+ /** Attach one file to a draft, by the path its size takes. */
167
+ async function attach(ctx, messageId, file) {
168
+ const id = file.bytes.length < ONE_REQUEST_BYTES
169
+ ? (await graph(ctx, "POST", `${messagePath(ctx, messageId)}/attachments`, fileAttachment(file))).id
170
+ : await uploadSession(ctx, messageId, file);
171
+ return { ...(id ? { id } : {}), name: file.name, size: file.bytes.length };
172
+ }
173
+ /**
174
+ * Finish a draft this call created: attach its files, then send it or
175
+ * hand it back. Any failure deletes the draft, so no half-built message
176
+ * is left in Drafts.
177
+ */
178
+ async function finishDraft(ctx, created, pending, sendIt) {
179
+ try {
180
+ for (const file of pending)
181
+ await attach(ctx, created.id, file);
182
+ if (!sendIt)
183
+ return { id: created.id, webLink: created.webLink };
184
+ await graph(ctx, "POST", `${messagePath(ctx, created.id)}/send`);
185
+ return { success: true };
186
+ }
187
+ catch (error) {
188
+ await graph(ctx, "DELETE", messagePath(ctx, created.id)).catch(() => undefined);
189
+ throw error;
190
+ }
191
+ }
192
+ /** A Graph message from compose fields, with the files that ride along. */
193
+ function message(p, attached) {
194
+ return {
195
+ subject: p.subject,
196
+ body: { contentType: p.html ? "HTML" : "Text", content: p.body },
197
+ toRecipients: recipients(p.to),
198
+ ccRecipients: recipients(p.cc),
199
+ ...(p.bcc ? { bccRecipients: recipients(p.bcc) } : {}),
200
+ ...(p.replyTo ? { replyTo: recipients(p.replyTo) } : {}),
201
+ ...(p.importance ? { importance: p.importance } : {}),
202
+ ...(attached.length ? { attachments: attached.map(fileAttachment) } : {}),
20
203
  };
21
204
  }
22
205
  export default function microsoftMail(rl) {
23
206
  rl.setName(NAME);
24
- rl.setVersion("1.0.0");
207
+ rl.setVersion("1.1.0");
25
208
  rl.setCredential(microsoftCredential(NAME, SCOPES));
26
209
  rl.setConnectionSchema({
27
210
  authMethod: {
@@ -63,76 +246,144 @@ export default function microsoftMail(rl) {
63
246
  rl.setOAuth(microsoftOAuth(SCOPES, "Mail.Send, Mail.ReadWrite, Mail.Read"));
64
247
  rl.registerAction("mail.send", {
65
248
  access: "write",
66
- description: "Send an email as the connected mailbox. Returns {success}. Get user approval before sending external mail.",
67
- inputSchema: {
68
- to: {
69
- type: "array",
70
- required: true,
71
- description: "Recipient address(es)",
72
- },
73
- subject: { type: "string", required: true },
74
- body: { type: "string", required: true },
75
- cc: { type: "array", required: false },
76
- html: {
77
- type: "boolean",
78
- required: false,
79
- description: "Body is HTML (default plain text)",
80
- },
249
+ description: "Send an email as the connected mailbox, with attachments. Returns {success}. Get user approval before sending external mail.",
250
+ inputSchema: send,
251
+ async execute(input, ctx) {
252
+ const p = input;
253
+ const all = await files(p.attachments);
254
+ if (oneRequest(all)) {
255
+ await graph(ctx, "POST", `${userBase(ctx)}/sendMail`, {
256
+ message: message(p, all),
257
+ saveToSentItems: true,
258
+ });
259
+ return { success: true };
260
+ }
261
+ const created = await graph(ctx, "POST", `${userBase(ctx)}/messages`, message(p, []));
262
+ return finishDraft(ctx, created, all, true);
81
263
  },
264
+ });
265
+ rl.registerAction("mail.draft", {
266
+ access: "write",
267
+ description: "Create a draft email, with attachments (not sent). Returns {id, webLink}; draft.send sends it.",
268
+ inputSchema: draft,
82
269
  async execute(input, ctx) {
83
- await graphRequest(ctx, NAME, SCOPES, "POST", `${userBase(ctx)}/sendMail`, {
84
- message: toMessage(input),
85
- saveToSentItems: true,
86
- });
270
+ const p = input;
271
+ const all = await files(p.attachments);
272
+ const together = oneRequest(all);
273
+ const created = await graph(ctx, "POST", `${userBase(ctx)}/messages`, message(p, together ? all : []));
274
+ return finishDraft(ctx, created, together ? [] : all, false);
275
+ },
276
+ });
277
+ rl.registerAction("draft.send", {
278
+ access: "write",
279
+ description: "Send a draft message. Returns {success}.",
280
+ inputSchema: byId,
281
+ async execute(input, ctx) {
282
+ const { id } = input;
283
+ await graph(ctx, "POST", `${messagePath(ctx, id)}/send`);
87
284
  return { success: true };
88
285
  },
89
286
  });
90
- rl.registerAction("mail.draft", {
287
+ rl.registerAction("mail.reply", {
91
288
  access: "write",
92
- description: "Create a draft email (not sent). Returns {id, webLink}.",
93
- inputSchema: {
94
- to: { type: "array", required: false },
95
- subject: { type: "string", required: true },
96
- body: { type: "string", required: true },
97
- cc: { type: "array", required: false },
98
- html: { type: "boolean", required: false },
289
+ description: "Reply to a message, or with replyAll to everyone on it, with attachments. Returns {success}, or {id, webLink} of the draft when send is false.",
290
+ inputSchema: reply,
291
+ async execute(input, ctx) {
292
+ const p = input;
293
+ const all = await files(p.attachments);
294
+ const created = await graph(ctx, "POST", `${messagePath(ctx, p.id)}/${p.replyAll ? "createReplyAll" : "createReply"}`, p.comment === undefined ? {} : { comment: p.comment });
295
+ return finishDraft(ctx, created, all, p.send ?? true);
99
296
  },
297
+ });
298
+ rl.registerAction("mail.forward", {
299
+ access: "write",
300
+ description: "Forward a message to more people, with attachments. Returns {success}, or {id, webLink} of the draft when send is false.",
301
+ inputSchema: forward,
100
302
  async execute(input, ctx) {
101
- const r = await graphRequest(ctx, NAME, SCOPES, "POST", `${userBase(ctx)}/messages`, toMessage(input));
102
- return { id: r.id, webLink: r.webLink };
303
+ const p = input;
304
+ const all = await files(p.attachments);
305
+ const created = await graph(ctx, "POST", `${messagePath(ctx, p.id)}/createForward`, {
306
+ toRecipients: recipients(p.to),
307
+ ...(p.comment === undefined ? {} : { comment: p.comment }),
308
+ });
309
+ return finishDraft(ctx, created, all, p.send ?? true);
103
310
  },
104
311
  });
105
312
  rl.registerAction("mail.list", {
106
313
  access: "read",
107
- description: "List recent messages. Optional KQL search. Returns [{id,subject,from,receivedDateTime,bodyPreview,hasAttachments}].",
108
- inputSchema: {
109
- search: {
110
- type: "string",
111
- required: false,
112
- description: "KQL search across the mailbox",
113
- },
114
- top: { type: "number", required: false, default: 20 },
115
- },
314
+ description: "List recent messages, or search with KQL (results come newest first). Returns [{id,subject,from,receivedDateTime,bodyPreview,hasAttachments}].",
315
+ inputSchema: list,
116
316
  async execute(input, ctx) {
117
317
  const p = input;
118
318
  const qs = new URLSearchParams({
119
319
  $top: String(p.top ?? 20),
120
320
  $select: "id,subject,from,receivedDateTime,bodyPreview,hasAttachments",
121
- $orderby: "receivedDateTime desc",
122
321
  });
123
322
  if (p.search)
124
323
  qs.set("$search", `"${p.search}"`);
125
- const r = await graphRequest(ctx, NAME, SCOPES, "GET", `${userBase(ctx)}/messages?${qs}`);
324
+ else
325
+ qs.set("$orderby", "receivedDateTime desc");
326
+ const r = await graph(ctx, "GET", `${userBase(ctx)}/messages?${qs}`);
126
327
  return r.value;
127
328
  },
128
329
  });
129
330
  rl.registerAction("mail.get", {
130
331
  access: "read",
131
- description: "Get one message with full body by id.",
132
- inputSchema: { id: { type: "string", required: true } },
332
+ description: "Get one message with full body by id. Its files are listed by attachment.list.",
333
+ inputSchema: byId,
334
+ async execute(input, ctx) {
335
+ const { id } = input;
336
+ return graph(ctx, "GET", messagePath(ctx, id));
337
+ },
338
+ });
339
+ rl.registerAction("attachment.add", {
340
+ access: "write",
341
+ description: "Attach a file to a draft message. Returns {id, name, size}.",
342
+ inputSchema: attachmentAdd,
343
+ async execute(input, ctx) {
344
+ const p = input;
345
+ const [file] = await files([p.attachment]);
346
+ return attach(ctx, p.messageId, file);
347
+ },
348
+ });
349
+ rl.registerAction("attachment.list", {
350
+ access: "read",
351
+ description: "List a message's attachments. Returns [{id,name,contentType,size,isInline}].",
352
+ inputSchema: attachmentList,
353
+ async execute(input, ctx) {
354
+ const { messageId } = input;
355
+ const r = await graph(ctx, "GET", `${messagePath(ctx, messageId)}/attachments?$select=id,name,contentType,size,isInline`);
356
+ return r.value;
357
+ },
358
+ });
359
+ rl.registerAction("attachment.get", {
360
+ access: "read",
361
+ description: "Download one file attachment. Returns {name, mimeType, size, contentBase64}, accepted as-is as an attachment by mail plugins. A link (reference) attachment has no bytes and fails with HTTP 405.",
362
+ inputSchema: attachmentRef,
133
363
  async execute(input, ctx) {
134
364
  const p = input;
135
- return graphRequest(ctx, NAME, SCOPES, "GET", `${userBase(ctx)}/messages/${encodeURIComponent(p.id)}`);
365
+ const path = `${messagePath(ctx, p.messageId)}/attachments/${pathSegment(p.attachmentId)}`;
366
+ const meta = await graph(ctx, "GET", `${path}?$select=name,contentType`);
367
+ const res = await graphResponse(ctx, NAME, SCOPES, "GET", `${path}/$value`, undefined, undefined, "*/*");
368
+ if (!res.ok)
369
+ throw await graphFailed(NAME, res);
370
+ const bytes = Buffer.from(await res.arrayBuffer());
371
+ return {
372
+ ...(meta.name ? { name: meta.name } : {}),
373
+ ...(meta.contentType ? { mimeType: meta.contentType } : {}),
374
+ size: bytes.length,
375
+ contentBase64: bytes.toString("base64"),
376
+ };
377
+ },
378
+ });
379
+ rl.registerAction("attachment.delete", {
380
+ access: "write",
381
+ description: "Remove an attachment from a draft message. Returns {success}.",
382
+ inputSchema: attachmentRef,
383
+ async execute(input, ctx) {
384
+ const p = input;
385
+ await graph(ctx, "DELETE", `${messagePath(ctx, p.messageId)}/attachments/${pathSegment(p.attachmentId)}`);
386
+ return { success: true };
136
387
  },
137
388
  });
138
389
  }
@@ -1,24 +1,17 @@
1
1
  import { staticCredential } from "runline";
2
- /**
3
- * An API token, sent as a bearer to Todoist's REST v2 and Sync v9
4
- * surfaces on the one API origin.
5
- */
2
+ /** An API token, sent as a bearer to Todoist's unified API v1. */
6
3
  export const todoistCredential = staticCredential({
7
4
  id: "todoist",
8
5
  auth: { kind: "bearer" },
9
6
  local: { secret: "apiToken" },
10
7
  targets: {
11
- rest: {
12
- baseUrl: "https://api.todoist.com/rest/v2/",
8
+ api: {
9
+ baseUrl: "https://api.todoist.com/api/v1/",
13
10
  methods: ["GET", "POST", "DELETE"],
14
11
  },
15
- sync: {
16
- baseUrl: "https://api.todoist.com/sync/v9/",
17
- methods: ["POST"],
18
- },
19
12
  },
20
13
  probe: {
21
- target: "rest",
14
+ target: "api",
22
15
  path: "projects",
23
16
  method: "GET",
24
17
  acceptedStatuses: [200],