@ganju/utils 0.0.1
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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +23 -0
- package/dist/apiError.d.ts +3 -0
- package/dist/apiError.d.ts.map +1 -0
- package/dist/apiError.js +36 -0
- package/dist/attachment.d.ts +33 -0
- package/dist/attachment.d.ts.map +1 -0
- package/dist/attachment.js +59 -0
- package/dist/base64.d.ts +10 -0
- package/dist/base64.d.ts.map +1 -0
- package/dist/base64.js +50 -0
- package/dist/channelDebounce.d.ts +26 -0
- package/dist/channelDebounce.d.ts.map +1 -0
- package/dist/channelDebounce.js +35 -0
- package/dist/channelNotifier.d.ts +9 -0
- package/dist/channelNotifier.d.ts.map +1 -0
- package/dist/channelNotifier.js +31 -0
- package/dist/chunking.d.ts +28 -0
- package/dist/chunking.d.ts.map +1 -0
- package/dist/chunking.js +97 -0
- package/dist/cliConstants.d.ts +9 -0
- package/dist/cliConstants.d.ts.map +1 -0
- package/dist/cliConstants.js +47 -0
- package/dist/coerce.d.ts +2 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +13 -0
- package/dist/constants.d.ts +624 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +2220 -0
- package/dist/crypto.d.ts +14 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +93 -0
- package/dist/customCodeProject.d.ts +91 -0
- package/dist/customCodeProject.d.ts.map +1 -0
- package/dist/customCodeProject.js +154 -0
- package/dist/customCodeToken.d.ts +41 -0
- package/dist/customCodeToken.d.ts.map +1 -0
- package/dist/customCodeToken.js +115 -0
- package/dist/deadline.d.ts +6 -0
- package/dist/deadline.d.ts.map +1 -0
- package/dist/deadline.js +45 -0
- package/dist/discordSend.d.ts +25 -0
- package/dist/discordSend.d.ts.map +1 -0
- package/dist/discordSend.js +8 -0
- package/dist/embeddable.d.ts +2 -0
- package/dist/embeddable.d.ts.map +1 -0
- package/dist/embeddable.js +6 -0
- package/dist/exposedResource.d.ts +6 -0
- package/dist/exposedResource.d.ts.map +1 -0
- package/dist/exposedResource.js +18 -0
- package/dist/extractedDocument.d.ts +70 -0
- package/dist/extractedDocument.d.ts.map +1 -0
- package/dist/extractedDocument.js +2 -0
- package/dist/fetcher.d.ts +8 -0
- package/dist/fetcher.d.ts.map +1 -0
- package/dist/fetcher.js +68 -0
- package/dist/formatFilename.d.ts +2 -0
- package/dist/formatFilename.d.ts.map +1 -0
- package/dist/formatFilename.js +19 -0
- package/dist/formatRelative.d.ts +12 -0
- package/dist/formatRelative.d.ts.map +1 -0
- package/dist/formatRelative.js +28 -0
- package/dist/formatRelativeTime.d.ts +2 -0
- package/dist/formatRelativeTime.d.ts.map +1 -0
- package/dist/formatRelativeTime.js +49 -0
- package/dist/getEnv.d.ts +5 -0
- package/dist/getEnv.d.ts.map +1 -0
- package/dist/getEnv.js +11 -0
- package/dist/gmailSend.d.ts +24 -0
- package/dist/gmailSend.d.ts.map +1 -0
- package/dist/gmailSend.js +9 -0
- package/dist/index.d.ts +2839 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +155 -0
- package/dist/jsonSchemaToZodShape.d.ts +38 -0
- package/dist/jsonSchemaToZodShape.d.ts.map +1 -0
- package/dist/jsonSchemaToZodShape.js +117 -0
- package/dist/l2Normalize.d.ts +2 -0
- package/dist/l2Normalize.d.ts.map +1 -0
- package/dist/l2Normalize.js +25 -0
- package/dist/languageCookieDomain.d.ts +21 -0
- package/dist/languageCookieDomain.d.ts.map +1 -0
- package/dist/languageCookieDomain.js +39 -0
- package/dist/localizeZodIssue.d.ts +43 -0
- package/dist/localizeZodIssue.d.ts.map +1 -0
- package/dist/localizeZodIssue.js +90 -0
- package/dist/mailHeaders.d.ts +4 -0
- package/dist/mailHeaders.d.ts.map +1 -0
- package/dist/mailHeaders.js +11 -0
- package/dist/mcpProxy.d.ts +2 -0
- package/dist/mcpProxy.d.ts.map +1 -0
- package/dist/mcpProxy.js +21 -0
- package/dist/mimeMessage.d.ts +21 -0
- package/dist/mimeMessage.d.ts.map +1 -0
- package/dist/mimeMessage.js +58 -0
- package/dist/oauth.d.ts +21 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +75 -0
- package/dist/oauthProviders.d.ts +12 -0
- package/dist/oauthProviders.d.ts.map +1 -0
- package/dist/oauthProviders.js +99 -0
- package/dist/outlookSend.d.ts +19 -0
- package/dist/outlookSend.d.ts.map +1 -0
- package/dist/outlookSend.js +9 -0
- package/dist/parseHttpError.d.ts +2 -0
- package/dist/parseHttpError.d.ts.map +1 -0
- package/dist/parseHttpError.js +23 -0
- package/dist/planLimitError.d.ts +18 -0
- package/dist/planLimitError.d.ts.map +1 -0
- package/dist/planLimitError.js +35 -0
- package/dist/processQueueBatch.d.ts +17 -0
- package/dist/processQueueBatch.d.ts.map +1 -0
- package/dist/processQueueBatch.js +32 -0
- package/dist/reservedToolName.d.ts +16 -0
- package/dist/reservedToolName.d.ts.map +1 -0
- package/dist/reservedToolName.js +25 -0
- package/dist/resourceUri.d.ts +2 -0
- package/dist/resourceUri.d.ts.map +1 -0
- package/dist/resourceUri.js +19 -0
- package/dist/retry.d.ts +10 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +37 -0
- package/dist/sanitize.d.ts +7 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/sanitize.js +69 -0
- package/dist/schema.d.ts +2175 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +1382 -0
- package/dist/sdkConstants.d.ts +15 -0
- package/dist/sdkConstants.d.ts.map +1 -0
- package/dist/sdkConstants.js +43 -0
- package/dist/slackSend.d.ts +38 -0
- package/dist/slackSend.d.ts.map +1 -0
- package/dist/slackSend.js +14 -0
- package/dist/slug.d.ts +5 -0
- package/dist/slug.d.ts.map +1 -0
- package/dist/slug.js +34 -0
- package/dist/slugifyTitle.d.ts +2 -0
- package/dist/slugifyTitle.d.ts.map +1 -0
- package/dist/slugifyTitle.js +11 -0
- package/dist/sources.d.ts +43 -0
- package/dist/sources.d.ts.map +1 -0
- package/dist/sources.js +73 -0
- package/dist/ssrf.d.ts +3 -0
- package/dist/ssrf.d.ts.map +1 -0
- package/dist/ssrf.js +46 -0
- package/dist/tallyUsageKinds.d.ts +9 -0
- package/dist/tallyUsageKinds.d.ts.map +1 -0
- package/dist/tallyUsageKinds.js +20 -0
- package/dist/telegramSend.d.ts +28 -0
- package/dist/telegramSend.d.ts.map +1 -0
- package/dist/telegramSend.js +9 -0
- package/dist/toolCatalog.d.ts +452 -0
- package/dist/toolCatalog.d.ts.map +1 -0
- package/dist/toolCatalog.js +552 -0
- package/dist/validateMessageVariables.d.ts +7 -0
- package/dist/validateMessageVariables.d.ts.map +1 -0
- package/dist/validateMessageVariables.js +16 -0
- package/dist/vendorError.d.ts +2 -0
- package/dist/vendorError.d.ts.map +1 -0
- package/dist/vendorError.js +25 -0
- package/dist/whatsappSend.d.ts +31 -0
- package/dist/whatsappSend.d.ts.map +1 -0
- package/dist/whatsappSend.js +11 -0
- package/package.json +76 -0
- package/src/apiError.ts +40 -0
- package/src/attachment.ts +97 -0
- package/src/base64.ts +60 -0
- package/src/channelDebounce.ts +76 -0
- package/src/channelNotifier.ts +39 -0
- package/src/chunking.ts +131 -0
- package/src/cliConstants.ts +50 -0
- package/src/coerce.ts +9 -0
- package/src/constants.ts +2542 -0
- package/src/crypto.ts +108 -0
- package/src/customCodeProject.ts +182 -0
- package/src/customCodeToken.ts +198 -0
- package/src/deadline.ts +43 -0
- package/src/discordSend.ts +41 -0
- package/src/embeddable.ts +4 -0
- package/src/exposedResource.ts +40 -0
- package/src/extractedDocument.ts +62 -0
- package/src/fetcher.ts +82 -0
- package/src/formatFilename.ts +19 -0
- package/src/formatRelative.ts +36 -0
- package/src/formatRelativeTime.ts +45 -0
- package/src/getEnv.ts +13 -0
- package/src/gmailSend.ts +34 -0
- package/src/index.ts +397 -0
- package/src/jsonSchemaToZodShape.ts +111 -0
- package/src/l2Normalize.ts +19 -0
- package/src/languageCookieDomain.ts +37 -0
- package/src/localizeZodIssue.ts +124 -0
- package/src/mailHeaders.ts +12 -0
- package/src/mcpProxy.ts +20 -0
- package/src/mimeMessage.ts +82 -0
- package/src/oauth.ts +103 -0
- package/src/oauthProviders.ts +118 -0
- package/src/outlookSend.ts +44 -0
- package/src/parseHttpError.ts +16 -0
- package/src/planLimitError.ts +50 -0
- package/src/processQueueBatch.ts +46 -0
- package/src/reservedToolName.ts +27 -0
- package/src/resourceUri.ts +17 -0
- package/src/retry.ts +43 -0
- package/src/sanitize.ts +67 -0
- package/src/schema.ts +1624 -0
- package/src/sdkConstants.ts +44 -0
- package/src/slackSend.ts +65 -0
- package/src/slug.ts +37 -0
- package/src/slugifyTitle.ts +8 -0
- package/src/sources.ts +117 -0
- package/src/ssrf.ts +34 -0
- package/src/tallyUsageKinds.ts +20 -0
- package/src/telegramSend.ts +39 -0
- package/src/toolCatalog.ts +635 -0
- package/src/validateMessageVariables.ts +18 -0
- package/src/vendorError.ts +19 -0
- package/src/whatsappSend.ts +47 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
|
|
3
|
+
export type JsonSchemaProperty = {
|
|
4
|
+
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
|
5
|
+
description?: string;
|
|
6
|
+
minimum?: number;
|
|
7
|
+
maximum?: number;
|
|
8
|
+
minLength?: number;
|
|
9
|
+
maxLength?: number;
|
|
10
|
+
pattern?: string;
|
|
11
|
+
enum?: string[];
|
|
12
|
+
items?: JsonSchemaProperty;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type JsonSchema = {
|
|
16
|
+
type: 'object';
|
|
17
|
+
properties?: Record<string, JsonSchemaProperty>;
|
|
18
|
+
required?: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const propertyToZod = (prop: JsonSchemaProperty): z.ZodTypeAny => {
|
|
22
|
+
let field: z.ZodTypeAny;
|
|
23
|
+
|
|
24
|
+
switch (prop.type) {
|
|
25
|
+
case 'string': {
|
|
26
|
+
if (prop.enum) {
|
|
27
|
+
field = z.enum(prop.enum as [string, ...string[]]);
|
|
28
|
+
} else {
|
|
29
|
+
let str = z.string();
|
|
30
|
+
if (prop.minLength !== undefined) str = str.min(prop.minLength);
|
|
31
|
+
if (prop.maxLength !== undefined) str = str.max(prop.maxLength);
|
|
32
|
+
if (prop.pattern !== undefined)
|
|
33
|
+
str = str.regex(new RegExp(prop.pattern));
|
|
34
|
+
field = str;
|
|
35
|
+
}
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
case 'number': {
|
|
39
|
+
let num = z.number();
|
|
40
|
+
if (prop.minimum !== undefined) num = num.min(prop.minimum);
|
|
41
|
+
if (prop.maximum !== undefined) num = num.max(prop.maximum);
|
|
42
|
+
field = num;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
case 'boolean':
|
|
46
|
+
field = z.boolean();
|
|
47
|
+
break;
|
|
48
|
+
case 'array':
|
|
49
|
+
field = z.array(prop.items ? propertyToZod(prop.items) : z.any());
|
|
50
|
+
break;
|
|
51
|
+
case 'object':
|
|
52
|
+
field = z.record(z.string(), z.any());
|
|
53
|
+
break;
|
|
54
|
+
default:
|
|
55
|
+
field = z.any();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (prop.description) {
|
|
59
|
+
field = field.describe(prop.description);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return field;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const jsonSchemaToZodShape = (
|
|
66
|
+
schema: JsonSchema
|
|
67
|
+
): Record<string, z.ZodTypeAny> => {
|
|
68
|
+
const properties = schema.properties ?? {};
|
|
69
|
+
const required = schema.required ?? [];
|
|
70
|
+
|
|
71
|
+
const shape: Record<string, z.ZodTypeAny> = {};
|
|
72
|
+
|
|
73
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
74
|
+
const field = propertyToZod(prop);
|
|
75
|
+
shape[key] = required.includes(key) ? field : field.optional();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return shape;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export interface SchemaViolation {
|
|
82
|
+
// Dotted path to the offending field, empty for the value as a whole.
|
|
83
|
+
path: string;
|
|
84
|
+
message: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check a value against a declared JSON schema, returning the violations.
|
|
89
|
+
*
|
|
90
|
+
* The same compiler the MCP boot loop registers tools with, pointed at a value
|
|
91
|
+
* instead of at a tool definition — so what the dashboard's Test panel calls
|
|
92
|
+
* invalid is exactly what an MCP client would have refused to send. Written here
|
|
93
|
+
* rather than in apps/api because zod lives here, and a second copy of this
|
|
94
|
+
* would be a second opinion about what "valid" means.
|
|
95
|
+
*
|
|
96
|
+
* Unknown keys are allowed through: a JSON Schema without
|
|
97
|
+
* `additionalProperties: false` does not forbid them, and refusing an extra
|
|
98
|
+
* field on a test input that a real client would have passed along would make
|
|
99
|
+
* the panel lie.
|
|
100
|
+
*/
|
|
101
|
+
export const validateAgainstJsonSchema = (
|
|
102
|
+
schema: JsonSchema,
|
|
103
|
+
value: unknown
|
|
104
|
+
): SchemaViolation[] => {
|
|
105
|
+
const result = z.object(jsonSchemaToZodShape(schema)).safeParse(value ?? {});
|
|
106
|
+
if (result.success) return [];
|
|
107
|
+
return result.error.issues.map(issue => ({
|
|
108
|
+
path: issue.path.join('.'),
|
|
109
|
+
message: issue.message
|
|
110
|
+
}));
|
|
111
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Scale a vector to unit length.
|
|
2
|
+
//
|
|
3
|
+
// Needed because `gemini-embedding-001` only returns a normalised vector at its
|
|
4
|
+
// full 3072 dimensions. Ask for fewer via `outputDimensionality` — as we do, to
|
|
5
|
+
// halve storage — and the values come back UNNORMALISED, because truncating a
|
|
6
|
+
// unit vector leaves it shorter than 1. Cosine distance in pgvector assumes
|
|
7
|
+
// nothing about magnitude, but our stored vectors and query vectors have to be
|
|
8
|
+
// treated identically, and an unnormalised mix silently skews ranking rather
|
|
9
|
+
// than failing. So both the ingest path and the query path normalise here.
|
|
10
|
+
//
|
|
11
|
+
// Returns the input unchanged when the magnitude is zero, which would otherwise
|
|
12
|
+
// divide by zero and produce NaNs that poison the whole index.
|
|
13
|
+
export const l2Normalize = (values: number[]): number[] => {
|
|
14
|
+
let sum = 0;
|
|
15
|
+
for (const v of values) sum += v * v;
|
|
16
|
+
const magnitude = Math.sqrt(sum);
|
|
17
|
+
if (!magnitude || !Number.isFinite(magnitude)) return values;
|
|
18
|
+
return values.map(v => v / magnitude);
|
|
19
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie `Domain` for the remembered language, derived from the host serving
|
|
3
|
+
* the request.
|
|
4
|
+
*
|
|
5
|
+
* `ganju.ai` and `app.ganju.ai` are two hosts, so a host-only cookie set by one
|
|
6
|
+
* is invisible to the other — someone who explicitly picks English on the
|
|
7
|
+
* marketing site would sign in and be handed Spanish again by the country
|
|
8
|
+
* lookup, which defeats the point of remembering the choice. Scoping the cookie
|
|
9
|
+
* to `.ganju.ai` makes one click cover both.
|
|
10
|
+
*
|
|
11
|
+
* Derived from the host rather than configured, because the two surfaces deploy
|
|
12
|
+
* separately and a value set in one place would silently rot in the other. The
|
|
13
|
+
* cost is the assumption below.
|
|
14
|
+
*
|
|
15
|
+
* **Assumes a single-label public suffix** — `ganju.ai`, `vocesqueabrazan.com`.
|
|
16
|
+
* A deploy under a multi-part suffix (`example.co.uk`) would compute `.co.uk`,
|
|
17
|
+
* which browsers reject, and the cookie would quietly go nowhere. Add that
|
|
18
|
+
* suffix here if it ever happens.
|
|
19
|
+
*/
|
|
20
|
+
export const languageCookieDomain = (hostname: string): string | undefined => {
|
|
21
|
+
const labels = hostname.split('.');
|
|
22
|
+
|
|
23
|
+
// `localhost`, and anything else with nothing to share with.
|
|
24
|
+
if (labels.length < 2) return undefined;
|
|
25
|
+
|
|
26
|
+
// A bare IP has no domain to scope to.
|
|
27
|
+
if (/^\d+$/.test(labels[labels.length - 1])) return undefined;
|
|
28
|
+
|
|
29
|
+
const apex = labels.slice(-2).join('.');
|
|
30
|
+
|
|
31
|
+
// Cloudflare's preview hosts are a public suffix: every Pages project on the
|
|
32
|
+
// planet shares them, so browsers refuse a cookie scoped there. Falling back
|
|
33
|
+
// to host-only keeps previews working on their own.
|
|
34
|
+
if (apex === 'pages.dev' || apex === 'workers.dev') return undefined;
|
|
35
|
+
|
|
36
|
+
return `.${apex}`;
|
|
37
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import esLocale from 'zod/v4/locales/es.js';
|
|
2
|
+
|
|
3
|
+
import { constants } from './constants';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Re-render a Zod validation issue in the reader's language.
|
|
7
|
+
*
|
|
8
|
+
* Zod already ships a complete Spanish locale, so all ~305 built-in validators
|
|
9
|
+
* in `schema.ts` are covered without hand-translating anything. The trick is
|
|
10
|
+
* *where* to apply it. There are ~100 parse call sites across the three
|
|
11
|
+
* services; threading a per-parse error map through all of them is a hundred
|
|
12
|
+
* chances to miss one. Instead this runs once, in `handleError`'s ZodError
|
|
13
|
+
* branch, on the way out.
|
|
14
|
+
*
|
|
15
|
+
* That works because a Zod 4 issue keeps its structured fields — `code`,
|
|
16
|
+
* `origin`, `minimum`, `divisor`, `keys` — alongside the rendered `message`,
|
|
17
|
+
* and a locale error map is exactly a function from that structured issue to a
|
|
18
|
+
* string. So the English message Zod produced at parse time can be thrown away
|
|
19
|
+
* and rebuilt in Spanish at catch time.
|
|
20
|
+
*
|
|
21
|
+
* **Do not reach for `z.config({ localeError })` instead.** It mutates a
|
|
22
|
+
* module-level global (`zod/src/v4/core/config.ts`), and a Workers isolate
|
|
23
|
+
* serves concurrent requests in different languages — one request would set the
|
|
24
|
+
* locale for whatever else happened to be in flight. Nothing here holds state.
|
|
25
|
+
*
|
|
26
|
+
* What lands in `error_log` is untouched: `handleError` persists `err.message`,
|
|
27
|
+
* which is still Zod's own English dump from parse time. Nothing about
|
|
28
|
+
* persistence, and no migration, is involved.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Minimal shape of the issue we need; Zod's own type lives in `zod`. */
|
|
32
|
+
export interface ZodIssueLike {
|
|
33
|
+
code?: string;
|
|
34
|
+
message: string;
|
|
35
|
+
path?: PropertyKey[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type LocaleError = (issue: never) => string | { message: string };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Messages we wrote ourselves. Zod renders a custom `message:` verbatim, so the
|
|
42
|
+
* locale map never sees them — they arrive here as the exact English string,
|
|
43
|
+
* which is what these keys are. Keying on our own text rather than on a code
|
|
44
|
+
* keeps `schema.ts` free of translation concerns, and keeps the English in
|
|
45
|
+
* `error_log` identical to what it has always been.
|
|
46
|
+
*/
|
|
47
|
+
const CUSTOM_MESSAGES: Record<string, Record<string, string>> = {
|
|
48
|
+
'This title is reserved as a bot command': {
|
|
49
|
+
es: 'Este título está reservado como comando del bot'
|
|
50
|
+
},
|
|
51
|
+
'Enter a valid email address': {
|
|
52
|
+
es: 'Escribe un correo electrónico válido'
|
|
53
|
+
},
|
|
54
|
+
'Unsupported mime type': {
|
|
55
|
+
es: 'Tipo de archivo no admitido'
|
|
56
|
+
},
|
|
57
|
+
'Slug is reserved': {
|
|
58
|
+
es: 'Ese slug está reservado'
|
|
59
|
+
},
|
|
60
|
+
[constants.RESERVED_TOOL_NAME_MESSAGE]: {
|
|
61
|
+
es: 'Nombre de herramienta no válido — reservado por la plataforma'
|
|
62
|
+
},
|
|
63
|
+
[constants.CUSTOM_CODE_UNKNOWN_CONNECTION_MESSAGE]: {
|
|
64
|
+
es: 'Conexión no válida — no existe un proveedor gestionado con ese nombre'
|
|
65
|
+
},
|
|
66
|
+
[constants.OUTPUT_SCHEMA_NOT_OBJECT_MESSAGE]: {
|
|
67
|
+
es: 'Esquema de salida no válido — debe describir un objeto'
|
|
68
|
+
},
|
|
69
|
+
[constants.CUSTOM_CODE_RESOURCE_PAYLOAD_MESSAGE]: {
|
|
70
|
+
es: 'Envía exactamente uno de content (texto) o bytes (base64)'
|
|
71
|
+
},
|
|
72
|
+
[constants.CUSTOM_CODE_RESOURCE_TEXT_TOO_LARGE_MESSAGE]: {
|
|
73
|
+
es: `El contenido en línea supera el límite de ${constants.CUSTOM_CODE_MAX_RESOURCE_TEXT_BYTES / (1024 * 1024)}MB`
|
|
74
|
+
},
|
|
75
|
+
[constants.CUSTOM_CODE_RESOURCE_FILE_TOO_LARGE_MESSAGE]: {
|
|
76
|
+
es: `Los bytes del archivo superan el límite de ${constants.CUSTOM_CODE_MAX_RESOURCE_FILE_BYTES / (1024 * 1024)}MB`
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Built once. `esLocale()` allocates its dictionaries on every call, and this
|
|
81
|
+
// runs per issue, per failed request.
|
|
82
|
+
const LOCALE_ERRORS: Record<string, LocaleError> = {
|
|
83
|
+
[constants.LANGUAGE_ES]: esLocale().localeError as LocaleError
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Narrow an `Accept-Language` header to a language we ship.
|
|
88
|
+
*
|
|
89
|
+
* `fetcher` sends the app's own locale rather than the browser's list, so this
|
|
90
|
+
* is normally a bare `en` or `es` — but the header is public, and a browser
|
|
91
|
+
* hitting the API directly will send the full `es-CO,es;q=0.9,en;q=0.8` form.
|
|
92
|
+
* Take the first tag and match on its primary subtag.
|
|
93
|
+
*/
|
|
94
|
+
export const languageFromHeader = (header?: string | null): string => {
|
|
95
|
+
const tag = header?.split(',')[0]?.trim().toLowerCase() ?? '';
|
|
96
|
+
return tag.startsWith(constants.LANGUAGE_ES)
|
|
97
|
+
? constants.LANGUAGE_ES
|
|
98
|
+
: constants.LANGUAGE_EN;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The issue's message in `language`, or its original English. */
|
|
102
|
+
export const localizeZodIssue = (
|
|
103
|
+
issue: ZodIssueLike,
|
|
104
|
+
language: string
|
|
105
|
+
): string => {
|
|
106
|
+
if (language === constants.LANGUAGE_EN) return issue.message;
|
|
107
|
+
|
|
108
|
+
const custom = CUSTOM_MESSAGES[issue.message]?.[language];
|
|
109
|
+
if (custom) return custom;
|
|
110
|
+
|
|
111
|
+
const localeError = LOCALE_ERRORS[language];
|
|
112
|
+
if (!localeError) return issue.message;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const rendered = localeError(issue as never);
|
|
116
|
+
const message = typeof rendered === 'string' ? rendered : rendered?.message;
|
|
117
|
+
// A locale map may decline an issue it does not recognise; English is a
|
|
118
|
+
// better answer than an empty string.
|
|
119
|
+
return message || issue.message;
|
|
120
|
+
} catch {
|
|
121
|
+
// Never let a formatting failure turn a 400 into a 500.
|
|
122
|
+
return issue.message;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { utf8ToBase64 } from './base64';
|
|
2
|
+
|
|
3
|
+
const ASCII_ONLY = /^[\x00-\x7F]*$/;
|
|
4
|
+
|
|
5
|
+
export const sanitizeMailHeader = (value: string): string =>
|
|
6
|
+
value.replace(/[\r\n]+/g, ' ').trim();
|
|
7
|
+
|
|
8
|
+
export const encodeRfc2047 = (value: string): string =>
|
|
9
|
+
ASCII_ONLY.test(value) ? value : `=?UTF-8?B?${utf8ToBase64(value)}?=`;
|
|
10
|
+
|
|
11
|
+
export const formatMailHeader = (value: string): string =>
|
|
12
|
+
encodeRfc2047(sanitizeMailHeader(value));
|
package/src/mcpProxy.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { constants } from './constants';
|
|
2
|
+
|
|
3
|
+
// Remote MCP tool keys are untrusted (the remote chooses them). Only allow the
|
|
4
|
+
// charset MCP clients accept for tool names so the composed local name can't
|
|
5
|
+
// break registration or downstream API calls.
|
|
6
|
+
const REMOTE_TOOL_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
7
|
+
|
|
8
|
+
// Compose the local MCP tool name for a proxied remote tool (`<prefix><sep><key>`),
|
|
9
|
+
// or return null when the remote key can't be safely surfaced — bad charset, or
|
|
10
|
+
// the composed name exceeds the tool-name length limit clients enforce. Callers
|
|
11
|
+
// skip-and-log nulls rather than registering an unusable tool.
|
|
12
|
+
export const buildProxyToolName = (
|
|
13
|
+
prefix: string,
|
|
14
|
+
remoteName: string
|
|
15
|
+
): string | null => {
|
|
16
|
+
if (!remoteName || !REMOTE_TOOL_NAME_RE.test(remoteName)) return null;
|
|
17
|
+
const name = `${prefix}${constants.MCP_PROXY_TOOL_NAME_SEP}${remoteName}`;
|
|
18
|
+
if (name.length > constants.MCP_PROXY_TOOL_NAME_MAX) return null;
|
|
19
|
+
return name;
|
|
20
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { sanitizeMailHeader, formatMailHeader } from './mailHeaders';
|
|
2
|
+
|
|
3
|
+
export interface MimeAttachment {
|
|
4
|
+
filename: string;
|
|
5
|
+
mimeType: string;
|
|
6
|
+
base64: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface MimeMessageInput {
|
|
10
|
+
to: string;
|
|
11
|
+
subject: string;
|
|
12
|
+
body: string;
|
|
13
|
+
cc?: string;
|
|
14
|
+
bcc?: string;
|
|
15
|
+
inReplyTo?: string;
|
|
16
|
+
references?: string;
|
|
17
|
+
contentType?: 'text/html' | 'text/plain';
|
|
18
|
+
attachments?: MimeAttachment[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const sanitizeFilename = (name: string): string => {
|
|
22
|
+
const cleaned = name.replace(/[\r\n"]/g, '').trim();
|
|
23
|
+
return cleaned || 'attachment';
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const formatFilenameHeader = (name: string): string => {
|
|
27
|
+
const safe = sanitizeFilename(name);
|
|
28
|
+
if (/^[\x20-\x7E]+$/.test(safe)) {
|
|
29
|
+
return `filename="${safe.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
30
|
+
}
|
|
31
|
+
return `filename*=UTF-8''${encodeURIComponent(safe)}`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const chunkBase64 = (b64: string): string =>
|
|
35
|
+
b64.replace(/(.{76})/g, '$1\r\n');
|
|
36
|
+
|
|
37
|
+
export const buildMimeMessage = (input: MimeMessageInput): string => {
|
|
38
|
+
const headers: string[] = [];
|
|
39
|
+
headers.push(`To: ${formatMailHeader(input.to)}`);
|
|
40
|
+
if (input.cc) headers.push(`Cc: ${formatMailHeader(input.cc)}`);
|
|
41
|
+
if (input.bcc) headers.push(`Bcc: ${formatMailHeader(input.bcc)}`);
|
|
42
|
+
headers.push(`Subject: ${formatMailHeader(input.subject)}`);
|
|
43
|
+
if (input.inReplyTo)
|
|
44
|
+
headers.push(`In-Reply-To: ${sanitizeMailHeader(input.inReplyTo)}`);
|
|
45
|
+
if (input.references)
|
|
46
|
+
headers.push(`References: ${sanitizeMailHeader(input.references)}`);
|
|
47
|
+
headers.push('MIME-Version: 1.0');
|
|
48
|
+
|
|
49
|
+
const bodyContentType = input.contentType || 'text/html';
|
|
50
|
+
|
|
51
|
+
if (!input.attachments || input.attachments.length === 0) {
|
|
52
|
+
headers.push(`Content-Type: ${bodyContentType}; charset=utf-8`);
|
|
53
|
+
return `${headers.join('\r\n')}\r\n\r\n${input.body}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const boundary = `_ganju_${crypto.randomUUID().replace(/-/g, '')}`;
|
|
57
|
+
headers.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
|
|
58
|
+
|
|
59
|
+
const parts: string[] = [headers.join('\r\n'), ''];
|
|
60
|
+
parts.push(`--${boundary}`);
|
|
61
|
+
parts.push(`Content-Type: ${bodyContentType}; charset=utf-8`);
|
|
62
|
+
parts.push('Content-Transfer-Encoding: 7bit');
|
|
63
|
+
parts.push('');
|
|
64
|
+
parts.push(input.body);
|
|
65
|
+
|
|
66
|
+
for (const att of input.attachments) {
|
|
67
|
+
const safeName = sanitizeFilename(att.filename);
|
|
68
|
+
parts.push(`--${boundary}`);
|
|
69
|
+
parts.push(
|
|
70
|
+
`Content-Type: ${att.mimeType}; name="${safeName.replace(/"/g, '\\"')}"`
|
|
71
|
+
);
|
|
72
|
+
parts.push('Content-Transfer-Encoding: base64');
|
|
73
|
+
parts.push(
|
|
74
|
+
`Content-Disposition: attachment; ${formatFilenameHeader(att.filename)}`
|
|
75
|
+
);
|
|
76
|
+
parts.push('');
|
|
77
|
+
parts.push(chunkBase64(att.base64));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
parts.push(`--${boundary}--`);
|
|
81
|
+
return parts.join('\r\n');
|
|
82
|
+
};
|
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { constants } from './constants';
|
|
2
|
+
|
|
3
|
+
export interface RefreshOAuthTokenInput {
|
|
4
|
+
tokenUrl: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
clientSecret: string;
|
|
7
|
+
refreshToken: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RefreshedOAuthToken {
|
|
11
|
+
accessToken: string;
|
|
12
|
+
refreshToken?: string;
|
|
13
|
+
expiresIn?: number;
|
|
14
|
+
scope?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class OAuthReauthRequiredError extends Error {
|
|
18
|
+
readonly code: string;
|
|
19
|
+
|
|
20
|
+
constructor(code: string) {
|
|
21
|
+
super(`OAuth reauth required (${code})`);
|
|
22
|
+
this.name = 'OAuthReauthRequiredError';
|
|
23
|
+
this.code = code;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const parseErrorCode = (body: string): string | undefined => {
|
|
28
|
+
try {
|
|
29
|
+
return (JSON.parse(body) as { error?: string })?.error;
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const refreshOAuthToken = async (
|
|
36
|
+
input: RefreshOAuthTokenInput
|
|
37
|
+
): Promise<RefreshedOAuthToken> => {
|
|
38
|
+
const response = await fetch(input.tokenUrl, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
41
|
+
body: new URLSearchParams({
|
|
42
|
+
client_id: input.clientId,
|
|
43
|
+
client_secret: input.clientSecret,
|
|
44
|
+
grant_type: 'refresh_token',
|
|
45
|
+
refresh_token: input.refreshToken
|
|
46
|
+
})
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
const body = await response.text();
|
|
51
|
+
const code = parseErrorCode(body);
|
|
52
|
+
if (code && constants.REAUTH_ERROR_CODES.includes(code)) {
|
|
53
|
+
throw new OAuthReauthRequiredError(code);
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`oauth refresh failed (${response.status}): ${body}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const tokens = (await response.json()) as {
|
|
59
|
+
access_token?: string;
|
|
60
|
+
refresh_token?: string;
|
|
61
|
+
expires_in?: number;
|
|
62
|
+
scope?: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (!tokens.access_token) {
|
|
66
|
+
throw new Error('oauth refresh missing access_token');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
accessToken: tokens.access_token,
|
|
71
|
+
refreshToken: tokens.refresh_token,
|
|
72
|
+
expiresIn: tokens.expires_in,
|
|
73
|
+
scope: tokens.scope
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const buildReauthMetadata = (
|
|
78
|
+
previous: Record<string, unknown> | null,
|
|
79
|
+
reason: string
|
|
80
|
+
): Record<string, unknown> => ({
|
|
81
|
+
...(previous || {}),
|
|
82
|
+
needsReauth: true,
|
|
83
|
+
reauthReason: reason,
|
|
84
|
+
reauthAt: new Date().toISOString()
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export const clearReauthMetadata = (
|
|
88
|
+
previous: Record<string, unknown> | null
|
|
89
|
+
): Record<string, unknown> | null => {
|
|
90
|
+
if (!previous) return null;
|
|
91
|
+
const next: Record<string, unknown> = { ...previous };
|
|
92
|
+
delete next.needsReauth;
|
|
93
|
+
delete next.reauthReason;
|
|
94
|
+
delete next.reauthAt;
|
|
95
|
+
return Object.keys(next).length > 0 ? next : null;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const isCredentialNeedingReauth = (
|
|
99
|
+
metadata: unknown
|
|
100
|
+
): boolean => {
|
|
101
|
+
if (!metadata || typeof metadata !== 'object') return false;
|
|
102
|
+
return (metadata as Record<string, unknown>).needsReauth === true;
|
|
103
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { constants } from './constants';
|
|
2
|
+
|
|
3
|
+
export interface OAuthProviderConfig {
|
|
4
|
+
authUrl: string;
|
|
5
|
+
tokenUrl: string;
|
|
6
|
+
clientIdEnv: string;
|
|
7
|
+
clientSecretEnv: string;
|
|
8
|
+
defaultScopes: string[];
|
|
9
|
+
// Slack distinguishes the bot scope bucket (`scope=`) from the user scope
|
|
10
|
+
// bucket (`user_scope=`). Default is 'scope'; the slack-user provider sets
|
|
11
|
+
// this to 'user_scope' so its scopes are requested in the user bucket.
|
|
12
|
+
scopeParam?: 'scope' | 'user_scope';
|
|
13
|
+
// For providers (Slack user flow) whose token-exchange response carries
|
|
14
|
+
// the credential under `authed_user` instead of at top level. Default is
|
|
15
|
+
// 'top'.
|
|
16
|
+
tokenSource?: 'top' | 'authed_user';
|
|
17
|
+
// Slack's oauth.v2.access endpoint returns `internal_error` when the
|
|
18
|
+
// standard OAuth2 `grant_type=authorization_code` body param is included
|
|
19
|
+
// on the code-exchange call. Set true to omit it. Refresh-token requests
|
|
20
|
+
// still send `grant_type=refresh_token` (Slack does document that one).
|
|
21
|
+
omitGrantType?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The managed OAuth apps we run. This lives in @ganju/utils rather than in
|
|
25
|
+
// apps/api because three workers now need it: apps/api (the authorization flow),
|
|
26
|
+
// and apps/tool-broker, which mints short-lived access tokens for user code and
|
|
27
|
+
// must refresh with the same client credentials against the same token URLs.
|
|
28
|
+
// Adding a provider in one place and not the other is exactly the drift this
|
|
29
|
+
// prevents.
|
|
30
|
+
export const oauthProviders: Record<string, OAuthProviderConfig> = {
|
|
31
|
+
[constants.OAUTH_PROVIDER_GOOGLE_GMAIL]: {
|
|
32
|
+
authUrl: constants.GOOGLE_OAUTH_AUTH_URL,
|
|
33
|
+
tokenUrl: constants.GOOGLE_OAUTH_TOKEN_URL,
|
|
34
|
+
clientIdEnv: 'GOOGLE_CLIENT_ID',
|
|
35
|
+
clientSecretEnv: 'GOOGLE_CLIENT_SECRET',
|
|
36
|
+
defaultScopes: [
|
|
37
|
+
'https://www.googleapis.com/auth/gmail.readonly',
|
|
38
|
+
'https://www.googleapis.com/auth/gmail.send',
|
|
39
|
+
'https://www.googleapis.com/auth/gmail.modify',
|
|
40
|
+
'https://www.googleapis.com/auth/gmail.compose',
|
|
41
|
+
'https://www.googleapis.com/auth/gmail.labels'
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
[constants.OAUTH_PROVIDER_GOOGLE_DRIVE]: {
|
|
45
|
+
authUrl: constants.GOOGLE_OAUTH_AUTH_URL,
|
|
46
|
+
tokenUrl: constants.GOOGLE_OAUTH_TOKEN_URL,
|
|
47
|
+
clientIdEnv: 'GOOGLE_CLIENT_ID',
|
|
48
|
+
clientSecretEnv: 'GOOGLE_CLIENT_SECRET',
|
|
49
|
+
defaultScopes: [
|
|
50
|
+
'https://www.googleapis.com/auth/drive.readonly',
|
|
51
|
+
'https://www.googleapis.com/auth/drive.metadata.readonly'
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
[constants.OAUTH_PROVIDER_GOOGLE_CALENDAR]: {
|
|
55
|
+
authUrl: constants.GOOGLE_OAUTH_AUTH_URL,
|
|
56
|
+
tokenUrl: constants.GOOGLE_OAUTH_TOKEN_URL,
|
|
57
|
+
clientIdEnv: 'GOOGLE_CLIENT_ID',
|
|
58
|
+
clientSecretEnv: 'GOOGLE_CLIENT_SECRET',
|
|
59
|
+
defaultScopes: [
|
|
60
|
+
'https://www.googleapis.com/auth/calendar.events',
|
|
61
|
+
'https://www.googleapis.com/auth/calendar.readonly'
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
[constants.OAUTH_PROVIDER_MICROSOFT_OUTLOOK]: {
|
|
65
|
+
authUrl: constants.MICROSOFT_OAUTH_AUTH_URL,
|
|
66
|
+
tokenUrl: constants.MICROSOFT_OAUTH_TOKEN_URL,
|
|
67
|
+
clientIdEnv: 'MICROSOFT_CLIENT_ID',
|
|
68
|
+
clientSecretEnv: 'MICROSOFT_CLIENT_SECRET',
|
|
69
|
+
defaultScopes: [
|
|
70
|
+
'https://graph.microsoft.com/Mail.ReadWrite',
|
|
71
|
+
'https://graph.microsoft.com/Mail.Send',
|
|
72
|
+
'https://graph.microsoft.com/User.Read',
|
|
73
|
+
'offline_access'
|
|
74
|
+
]
|
|
75
|
+
},
|
|
76
|
+
[constants.OAUTH_PROVIDER_ONE_DRIVE]: {
|
|
77
|
+
authUrl: constants.MICROSOFT_OAUTH_AUTH_URL,
|
|
78
|
+
tokenUrl: constants.MICROSOFT_OAUTH_TOKEN_URL,
|
|
79
|
+
clientIdEnv: 'MICROSOFT_CLIENT_ID',
|
|
80
|
+
clientSecretEnv: 'MICROSOFT_CLIENT_SECRET',
|
|
81
|
+
defaultScopes: [
|
|
82
|
+
'https://graph.microsoft.com/Files.Read',
|
|
83
|
+
'https://graph.microsoft.com/Files.Read.All',
|
|
84
|
+
'offline_access'
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
[constants.OAUTH_PROVIDER_SLACK]: {
|
|
88
|
+
authUrl: constants.SLACK_OAUTH_AUTH_URL,
|
|
89
|
+
tokenUrl: constants.SLACK_OAUTH_TOKEN_URL,
|
|
90
|
+
clientIdEnv: 'SLACK_CLIENT_ID',
|
|
91
|
+
clientSecretEnv: 'SLACK_CLIENT_SECRET',
|
|
92
|
+
defaultScopes: [
|
|
93
|
+
'chat:write',
|
|
94
|
+
'channels:read',
|
|
95
|
+
'groups:read',
|
|
96
|
+
'mpim:read',
|
|
97
|
+
'im:read',
|
|
98
|
+
'users:read',
|
|
99
|
+
'users:read.email',
|
|
100
|
+
'files:write'
|
|
101
|
+
],
|
|
102
|
+
omitGrantType: true
|
|
103
|
+
},
|
|
104
|
+
[constants.OAUTH_PROVIDER_SLACK_USER]: {
|
|
105
|
+
authUrl: constants.SLACK_OAUTH_AUTH_URL,
|
|
106
|
+
tokenUrl: constants.SLACK_OAUTH_TOKEN_URL,
|
|
107
|
+
clientIdEnv: 'SLACK_CLIENT_ID',
|
|
108
|
+
clientSecretEnv: 'SLACK_CLIENT_SECRET',
|
|
109
|
+
// search.messages is the only Slack endpoint that needs a user (xoxp)
|
|
110
|
+
// token. Kept as its own provider so the OAuth flow stays generic —
|
|
111
|
+
// scopes are requested in the user_scope bucket, and the access_token
|
|
112
|
+
// is read from authed_user on callback.
|
|
113
|
+
defaultScopes: ['search:read'],
|
|
114
|
+
scopeParam: 'user_scope',
|
|
115
|
+
tokenSource: 'authed_user',
|
|
116
|
+
omitGrantType: true
|
|
117
|
+
}
|
|
118
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Wire protocol shared between the MCP worker (which builds the request) and
|
|
2
|
+
// the resource-handler container (which talks to Microsoft Graph).
|
|
3
|
+
//
|
|
4
|
+
// The worker sends a multipart/form-data POST with one `metadata` field (JSON
|
|
5
|
+
// matching OutlookSendRequest) and zero-or-more `attachment` fields. The
|
|
6
|
+
// container decides per-attachment whether to inline it in the Graph message
|
|
7
|
+
// JSON (≤3MB) or upload it via createUploadSession with chunked PUTs.
|
|
8
|
+
|
|
9
|
+
export type OutlookOperation =
|
|
10
|
+
| 'send-email'
|
|
11
|
+
| 'reply-email'
|
|
12
|
+
| 'forward-email'
|
|
13
|
+
| 'create-draft'
|
|
14
|
+
| 'update-draft';
|
|
15
|
+
|
|
16
|
+
export interface OutlookSendRequest {
|
|
17
|
+
accessToken: string;
|
|
18
|
+
operation: OutlookOperation;
|
|
19
|
+
body: string;
|
|
20
|
+
// Graph terms: 'html' or 'text'. Defaults to 'html' on the container side.
|
|
21
|
+
contentType?: 'html' | 'text';
|
|
22
|
+
|
|
23
|
+
// send-email / forward-email / create-draft / update-draft
|
|
24
|
+
to?: string;
|
|
25
|
+
subject?: string;
|
|
26
|
+
cc?: string;
|
|
27
|
+
bcc?: string;
|
|
28
|
+
|
|
29
|
+
// reply-email / forward-email need the source message
|
|
30
|
+
messageId?: string;
|
|
31
|
+
// reply-email
|
|
32
|
+
replyAll?: boolean;
|
|
33
|
+
|
|
34
|
+
// update-draft target
|
|
35
|
+
draftId?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface OutlookSendResponse {
|
|
39
|
+
// Resulting message ID for send / reply / forward. Draft ID for
|
|
40
|
+
// create-draft / update-draft. Empty string when Graph returns 202 with
|
|
41
|
+
// no body (sendMail without saveToSentItems).
|
|
42
|
+
id: string;
|
|
43
|
+
conversationId?: string;
|
|
44
|
+
}
|