@atomicmail/agent-skill 0.1.0 → 0.2.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/README.md +91 -0
- package/SKILL.md +53 -193
- package/esm/_dnt.polyfills.d.ts +101 -0
- package/esm/_dnt.polyfills.d.ts.map +1 -0
- package/esm/_dnt.polyfills.js +127 -0
- package/esm/{skill/scripts/lib/auth.d.ts → lib/agent/auth/agent-auth-http.d.ts} +1 -17
- package/esm/lib/agent/auth/agent-auth-http.d.ts.map +1 -0
- package/esm/lib/agent/auth/agent-auth-http.js +76 -0
- package/esm/lib/agent/auth/agent-jwt.d.ts +14 -0
- package/esm/lib/agent/auth/agent-jwt.d.ts.map +1 -0
- package/esm/lib/agent/auth/agent-jwt.js +29 -0
- package/esm/lib/agent/auth/agent-pow.d.ts +5 -0
- package/esm/lib/agent/auth/agent-pow.d.ts.map +1 -0
- package/esm/lib/agent/auth/agent-pow.js +49 -0
- package/esm/lib/agent/jmap/agent-help-content.d.ts +4 -0
- package/esm/lib/agent/jmap/agent-help-content.d.ts.map +1 -0
- package/esm/lib/agent/jmap/agent-help-content.js +244 -0
- package/esm/lib/agent/jmap/agent-jmap.d.ts +49 -0
- package/esm/lib/agent/jmap/agent-jmap.d.ts.map +1 -0
- package/esm/lib/agent/jmap/agent-jmap.js +174 -0
- package/esm/lib/agent/jmap/agent-vars.d.ts +23 -0
- package/esm/lib/agent/jmap/agent-vars.d.ts.map +1 -0
- package/esm/lib/agent/jmap/agent-vars.js +65 -0
- package/esm/{skill/scripts/lib/credentials.d.ts → lib/agent/session/agent-credentials-store.d.ts} +4 -1
- package/esm/lib/agent/session/agent-credentials-store.d.ts.map +1 -0
- package/esm/{skill/scripts/lib/credentials.js → lib/agent/session/agent-credentials-store.js} +28 -8
- package/esm/lib/agent/session/agent-resolve-config.d.ts +24 -0
- package/esm/lib/agent/session/agent-resolve-config.d.ts.map +1 -0
- package/esm/lib/agent/session/agent-resolve-config.js +70 -0
- package/esm/lib/agent/session/agent-session.d.ts +62 -0
- package/esm/lib/agent/session/agent-session.d.ts.map +1 -0
- package/esm/lib/agent/session/agent-session.js +206 -0
- package/esm/lib/core/consts.d.ts.map +1 -0
- package/esm/lib/core/types.d.ts +2 -0
- package/esm/lib/core/types.d.ts.map +1 -0
- package/esm/lib/core/types.js +1 -0
- package/esm/lib/core/utils.d.ts +10 -0
- package/esm/lib/core/utils.d.ts.map +1 -0
- package/esm/lib/core/utils.js +28 -0
- package/esm/lib/mod.d.ts +14 -0
- package/esm/lib/mod.d.ts.map +1 -0
- package/esm/lib/mod.js +13 -0
- package/esm/lib/network/auth-client.d.ts +57 -0
- package/esm/lib/network/auth-client.d.ts.map +1 -0
- package/esm/lib/network/auth-client.js +188 -0
- package/esm/skill/cli.d.ts +3 -0
- package/esm/skill/cli.d.ts.map +1 -0
- package/esm/skill/cli.js +306 -0
- package/package.json +5 -6
- package/presets/list_inbox.json +39 -0
- package/presets/reply.json +75 -0
- package/presets/send_mail.json +42 -0
- package/esm/lib/src/consts.d.ts.map +0 -1
- package/esm/skill/scripts/jmap_request.d.ts +0 -3
- package/esm/skill/scripts/jmap_request.d.ts.map +0 -1
- package/esm/skill/scripts/jmap_request.js +0 -265
- package/esm/skill/scripts/lib/auth.d.ts.map +0 -1
- package/esm/skill/scripts/lib/auth.js +0 -163
- package/esm/skill/scripts/lib/credentials.d.ts.map +0 -1
- package/esm/skill/scripts/signup.d.ts +0 -3
- package/esm/skill/scripts/signup.d.ts.map +0 -1
- package/esm/skill/scripts/signup.js +0 -170
- /package/esm/lib/{src → core}/consts.d.ts +0 -0
- /package/esm/lib/{src → core}/consts.js +0 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// auth-service HTTP: challenge → session → capability.
|
|
2
|
+
import { decodeJwtPayload } from "./agent-jwt.js";
|
|
3
|
+
import { solvePow } from "./agent-pow.js";
|
|
4
|
+
async function postJson(url, body, headers = {}) {
|
|
5
|
+
const res = await fetch(url, {
|
|
6
|
+
method: "POST",
|
|
7
|
+
headers: {
|
|
8
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
9
|
+
...headers,
|
|
10
|
+
},
|
|
11
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
12
|
+
});
|
|
13
|
+
const text = await res.text();
|
|
14
|
+
const path = (() => {
|
|
15
|
+
try {
|
|
16
|
+
return new URL(url).pathname;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return url;
|
|
20
|
+
}
|
|
21
|
+
})();
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
throw new Error(`auth-service ${path} returned ${res.status}: ${text}`);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(text);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Error(`auth-service ${path} returned non-JSON body: ${text}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function fetchChallenge(authUrl) {
|
|
33
|
+
const data = await postJson(`${authUrl}/api/v1/challenge`, undefined);
|
|
34
|
+
if (typeof data.challengeJWT !== "string") {
|
|
35
|
+
throw new Error("Challenge response missing challengeJWT.");
|
|
36
|
+
}
|
|
37
|
+
const payload = decodeJwtPayload(data.challengeJWT);
|
|
38
|
+
if (typeof payload.jti !== "string" ||
|
|
39
|
+
typeof payload.difficulty !== "number") {
|
|
40
|
+
throw new Error("Challenge JWT payload malformed (missing jti or difficulty).");
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
challengeJWT: data.challengeJWT,
|
|
44
|
+
challenge: payload.jti,
|
|
45
|
+
difficulty: payload.difficulty,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export async function exchangeSession(authUrl, body) {
|
|
49
|
+
const data = await postJson(`${authUrl}/api/v1/session`, { ...body });
|
|
50
|
+
if (typeof data.sessionJWT !== "string") {
|
|
51
|
+
throw new Error("Session response missing sessionJWT.");
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
sessionJWT: data.sessionJWT,
|
|
55
|
+
apiKey: typeof data.apiKey === "string" ? data.apiKey : undefined,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export async function fetchCapability(authUrl, sessionJWT) {
|
|
59
|
+
const data = await postJson(`${authUrl}/api/v1/capability`, undefined, { Authorization: `Bearer ${sessionJWT}` });
|
|
60
|
+
if (typeof data.capabilityJWT !== "string") {
|
|
61
|
+
throw new Error("Capability response missing capabilityJWT.");
|
|
62
|
+
}
|
|
63
|
+
return data.capabilityJWT;
|
|
64
|
+
}
|
|
65
|
+
export async function performPoWAndSession(input) {
|
|
66
|
+
const { authUrl, scryptSalt } = input;
|
|
67
|
+
const { challengeJWT, challenge, difficulty } = await fetchChallenge(authUrl);
|
|
68
|
+
const { powHex, nonce } = await solvePow(challenge, difficulty, scryptSalt, input.onPowProgress);
|
|
69
|
+
return exchangeSession(authUrl, {
|
|
70
|
+
challengeJWT,
|
|
71
|
+
powHex,
|
|
72
|
+
nonce,
|
|
73
|
+
apiKey: input.apiKey,
|
|
74
|
+
username: input.username,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const SESSION_TTL_MS: number;
|
|
2
|
+
export declare const CAPABILITY_TTL_MS: number;
|
|
3
|
+
export declare const SESSION_SAFETY_MARGIN_MS = 60000;
|
|
4
|
+
export declare const CAPABILITY_SAFETY_MARGIN_MS = 20000;
|
|
5
|
+
export interface JwtPayload {
|
|
6
|
+
exp?: number;
|
|
7
|
+
iat?: number;
|
|
8
|
+
jti?: string;
|
|
9
|
+
inboxId?: string;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
export declare function decodeJwtPayload<T = JwtPayload>(jwt: string): T;
|
|
13
|
+
export declare function isJwtExpired(jwt: string, marginMs: number): boolean;
|
|
14
|
+
//# sourceMappingURL=agent-jwt.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-jwt.d.ts","sourceRoot":"","sources":["../../../../src/lib/agent/auth/agent-jwt.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,cAAc,QAAqB,CAAC;AACjD,eAAO,MAAM,iBAAiB,QAAgB,CAAC;AAE/C,eAAO,MAAM,wBAAwB,QAAS,CAAC;AAC/C,eAAO,MAAM,2BAA2B,QAAS,CAAC;AAElD,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,wBAAgB,gBAAgB,CAAC,CAAC,GAAG,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,CAAC,CAc/D;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAQnE"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// JWT helpers for capability/session expiry checks.
|
|
2
|
+
export const SESSION_TTL_MS = 4 * 60 * 60 * 1000;
|
|
3
|
+
export const CAPABILITY_TTL_MS = 2 * 60 * 1000;
|
|
4
|
+
export const SESSION_SAFETY_MARGIN_MS = 60_000;
|
|
5
|
+
export const CAPABILITY_SAFETY_MARGIN_MS = 20_000;
|
|
6
|
+
export function decodeJwtPayload(jwt) {
|
|
7
|
+
const parts = jwt.split(".");
|
|
8
|
+
if (parts.length < 2) {
|
|
9
|
+
throw new Error("Malformed JWT: expected at least 2 dot-separated segments.");
|
|
10
|
+
}
|
|
11
|
+
const payloadB64Url = parts[1];
|
|
12
|
+
const padLen = (4 - (payloadB64Url.length % 4)) % 4;
|
|
13
|
+
const base64 = payloadB64Url
|
|
14
|
+
.replace(/-/g, "+")
|
|
15
|
+
.replace(/_/g, "/")
|
|
16
|
+
.padEnd(payloadB64Url.length + padLen, "=");
|
|
17
|
+
return JSON.parse(atob(base64));
|
|
18
|
+
}
|
|
19
|
+
export function isJwtExpired(jwt, marginMs) {
|
|
20
|
+
try {
|
|
21
|
+
const { exp } = decodeJwtPayload(jwt);
|
|
22
|
+
if (typeof exp !== "number")
|
|
23
|
+
return true;
|
|
24
|
+
return Date.now() >= exp * 1000 - marginMs;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-pow.d.ts","sourceRoot":"","sources":["../../../../src/lib/agent/auth/agent-pow.ts"],"names":[],"mappings":"AAuCA,wBAAsB,QAAQ,CAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GACnC,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAU5C"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// PoW scrypt — mirrors services/auth-service/src/crypto.ts.
|
|
2
|
+
import { scrypt } from "node:crypto";
|
|
3
|
+
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 };
|
|
4
|
+
const POW_HASH_BYTES = 64;
|
|
5
|
+
function bytesToHex(bytes) {
|
|
6
|
+
let hex = "";
|
|
7
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
8
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
9
|
+
}
|
|
10
|
+
return hex;
|
|
11
|
+
}
|
|
12
|
+
function hasLeadingZeroBits(hash, bits) {
|
|
13
|
+
if (bits > hash.length * 8)
|
|
14
|
+
return false;
|
|
15
|
+
const fullBytes = Math.floor(bits / 8);
|
|
16
|
+
const remainingBits = bits % 8;
|
|
17
|
+
for (let i = 0; i < fullBytes; i++) {
|
|
18
|
+
if (hash[i] !== 0)
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
if (remainingBits > 0) {
|
|
22
|
+
const mask = (0xff << (8 - remainingBits)) & 0xff;
|
|
23
|
+
if ((hash[fullBytes] & mask) !== 0)
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
function scryptHash(data, salt) {
|
|
29
|
+
const bytes = new TextEncoder().encode(data);
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
scrypt(bytes, salt, POW_HASH_BYTES, SCRYPT_PARAMS, (err, derived) => {
|
|
32
|
+
if (err)
|
|
33
|
+
return reject(err);
|
|
34
|
+
resolve(new Uint8Array(derived));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export async function solvePow(challenge, difficulty, salt, onProgress) {
|
|
39
|
+
let nonce = 0n;
|
|
40
|
+
while (true) {
|
|
41
|
+
const digest = await scryptHash(`${challenge}:${nonce}`, salt);
|
|
42
|
+
if (hasLeadingZeroBits(digest, difficulty)) {
|
|
43
|
+
return { powHex: bytesToHex(digest), nonce: nonce.toString() };
|
|
44
|
+
}
|
|
45
|
+
nonce++;
|
|
46
|
+
if (onProgress && nonce % 64n === 0n)
|
|
47
|
+
onProgress(nonce);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-help-content.d.ts","sourceRoot":"","sources":["../../../../src/lib/agent/jmap/agent-help-content.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CA+O9C,CAAC;AAEF,eAAO,MAAM,eAAe,UAA2B,CAAC;AAExD,wBAAgB,OAAO,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAW9C"}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Long-form help for MCP `help` tool and AgentSkill `help` command.
|
|
2
|
+
export const HELP_TOPICS = {
|
|
3
|
+
overview: `\
|
|
4
|
+
# Atomic Mail — Overview
|
|
5
|
+
|
|
6
|
+
Atomic Mail is an email service provider (ESP) designed for AI agents. You
|
|
7
|
+
manage mail over JMAP (RFC 8620 + RFC 8621).
|
|
8
|
+
|
|
9
|
+
## Public surface (identical for MCP and AgentSkill)
|
|
10
|
+
|
|
11
|
+
Three operations only:
|
|
12
|
+
|
|
13
|
+
1. **register** — Proof-of-work signup (or idempotent replay when the same
|
|
14
|
+
username matches the inbox already on disk). Persists credentials and
|
|
15
|
+
returns \`{ inbox, accountId }\` (and \`apiKey\` on first signup).
|
|
16
|
+
2. **jmap_request** — Send a JMAP method-call batch. Auth and JWT rotation are
|
|
17
|
+
automatic. Pass inline \`ops\` JSON or an \`ops_file\` preset path (same
|
|
18
|
+
substitution applies to both). Uppercase tokens like \`$ACCOUNT_ID\`,
|
|
19
|
+
\`$INBOX\`, \`$TO\`, \`$SUBJECT\` are replaced before the request is sent.
|
|
20
|
+
\`$ACCOUNT_ID\` / \`$INBOX\` come from the JMAP session and credentials; pass
|
|
21
|
+
any other names via MCP \`vars\` or skill \`--vars\`.
|
|
22
|
+
3. **help** — This documentation (optional \`topic\` / \`--topic\`).
|
|
23
|
+
|
|
24
|
+
## Typical workflow
|
|
25
|
+
|
|
26
|
+
1. \`register\` with a username.
|
|
27
|
+
2. \`jmap_request\` with JMAP method calls (presets may use \`$VAR_NAME\`; pass
|
|
28
|
+
custom values in \`vars\` / \`--vars\`).
|
|
29
|
+
3. If stuck, read error hints and call \`help\`.
|
|
30
|
+
|
|
31
|
+
Available topics: overview, installation, auth, jmap_cheatsheet, tools,
|
|
32
|
+
presets, troubleshooting.`,
|
|
33
|
+
installation: `\
|
|
34
|
+
# Atomic Mail — Installation
|
|
35
|
+
|
|
36
|
+
## MCP (stdio)
|
|
37
|
+
|
|
38
|
+
\`\`\`json
|
|
39
|
+
{
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"atomicmail": {
|
|
42
|
+
"command": "npx",
|
|
43
|
+
"args": ["-y", "@atomicmail/mcp"]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
\`\`\`
|
|
48
|
+
|
|
49
|
+
## AgentSkill (shell)
|
|
50
|
+
|
|
51
|
+
\`\`\`bash
|
|
52
|
+
npx --package=@atomicmail/agent-skill atomicmail register --username "myagent"
|
|
53
|
+
npx --package=@atomicmail/agent-skill atomicmail jmap_request \\
|
|
54
|
+
--ops-file list_inbox.json \\
|
|
55
|
+
--vars '{"COUNT":"10"}'
|
|
56
|
+
npx --package=@atomicmail/agent-skill atomicmail help
|
|
57
|
+
\`\`\`
|
|
58
|
+
|
|
59
|
+
From the repo: \`deno run -A scripts/cli.ts <command> ...\` inside \`skill/\`.
|
|
60
|
+
|
|
61
|
+
## Shared credentials
|
|
62
|
+
|
|
63
|
+
MCP and the skill use the same directory layout (default \`~/.atomicmail/\`):
|
|
64
|
+
|
|
65
|
+
- \`credentials.json\`, \`session.jwt\`, \`capability.jwt\`
|
|
66
|
+
|
|
67
|
+
## Overriding defaults
|
|
68
|
+
|
|
69
|
+
- Endpoints: \`ATOMIC_MAIL_AUTH_URL\`, \`ATOMIC_MAIL_API_URL\`
|
|
70
|
+
- Credentials path: \`ATOMIC_MAIL_CREDENTIALS_DIR\` (MCP), \`--credentials-dir\` (skill)
|
|
71
|
+
- Optional PoW salt: \`ATOMIC_MAIL_SCRYPT_SALT\`
|
|
72
|
+
|
|
73
|
+
## From source (development)
|
|
74
|
+
|
|
75
|
+
From the repo \`mcp/\` or \`skill/\` directory:
|
|
76
|
+
|
|
77
|
+
\`\`\`bash
|
|
78
|
+
deno task start # MCP
|
|
79
|
+
deno task build:npm
|
|
80
|
+
\`\`\`
|
|
81
|
+
|
|
82
|
+
See each package README for details.`,
|
|
83
|
+
auth: `\
|
|
84
|
+
# Atomic Mail — Auth flow
|
|
85
|
+
|
|
86
|
+
Auth is automatic after \`register\` (or when \`credentials.json\` + API key
|
|
87
|
+
exist).
|
|
88
|
+
|
|
89
|
+
1. **Challenge** — \`POST /api/v1/challenge\`
|
|
90
|
+
2. **Proof-of-work** — scrypt until difficulty satisfied
|
|
91
|
+
3. **Session JWT** — \`POST /api/v1/session\` (4h TTL); signup returns a
|
|
92
|
+
one-time \`apiKey\`
|
|
93
|
+
4. **Capability JWT** — \`POST /api/v1/capability\` (2 min TTL) used as the
|
|
94
|
+
JMAP bearer
|
|
95
|
+
|
|
96
|
+
JWTs are rotated before expiry and written back to disk.
|
|
97
|
+
|
|
98
|
+
## Credential files (mode 0600)
|
|
99
|
+
|
|
100
|
+
\`credentials.json\` — \`{ apiKey, inboxId, authUrl, apiUrl, scryptSalt }\`
|
|
101
|
+
\`session.jwt\` — session token
|
|
102
|
+
\`capability.jwt\` — capability token
|
|
103
|
+
|
|
104
|
+
## Overriding defaults
|
|
105
|
+
|
|
106
|
+
- \`ATOMIC_MAIL_AUTH_URL\` (default: \`https://auth.atomicmail.ai\`)
|
|
107
|
+
- \`ATOMIC_MAIL_API_URL\` (default: \`https://api.atomicmail.ai\`)
|
|
108
|
+
- \`ATOMIC_MAIL_SCRYPT_SALT\` (optional)
|
|
109
|
+
- \`ATOMIC_MAIL_API_KEY\` (optional)
|
|
110
|
+
- \`ATOMIC_MAIL_CREDENTIALS_DIR\` (default: \`~/.atomicmail\`)`,
|
|
111
|
+
jmap_cheatsheet: `\
|
|
112
|
+
# JMAP cheatsheet
|
|
113
|
+
|
|
114
|
+
## Capabilities (\`using\`)
|
|
115
|
+
|
|
116
|
+
- urn:ietf:params:jmap:core
|
|
117
|
+
- urn:ietf:params:jmap:mail
|
|
118
|
+
- urn:ietf:params:jmap:submission
|
|
119
|
+
|
|
120
|
+
## Examples
|
|
121
|
+
|
|
122
|
+
Use \`$ACCOUNT_ID\` and \`$INBOX\` for session fields; use \`$TO\`, \`$SUBJECT\`,
|
|
123
|
+
etc., and supply values via MCP \`vars\` or \`--vars\` (JSON object of strings).
|
|
124
|
+
|
|
125
|
+
### Mailboxes
|
|
126
|
+
\`\`\`json
|
|
127
|
+
["Mailbox/get", {"accountId": "$ACCOUNT_ID"}, "m0"]
|
|
128
|
+
\`\`\`
|
|
129
|
+
|
|
130
|
+
### Query + fetch emails
|
|
131
|
+
\`\`\`json
|
|
132
|
+
["Email/query", {
|
|
133
|
+
"accountId": "$ACCOUNT_ID",
|
|
134
|
+
"filter": {"inMailbox": "$INBOX"},
|
|
135
|
+
"sort": [{"property": "receivedAt", "isAscending": false}],
|
|
136
|
+
"limit": 100
|
|
137
|
+
}, "q0"]
|
|
138
|
+
\`\`\`
|
|
139
|
+
|
|
140
|
+
### Send (add submission to \`using\`)
|
|
141
|
+
|
|
142
|
+
Include \`urn:ietf:params:jmap:submission\` in the envelope \`using\` array
|
|
143
|
+
when calling \`EmailSubmission/set\`.
|
|
144
|
+
|
|
145
|
+
## Tips
|
|
146
|
+
|
|
147
|
+
- Back-references (\`#ids\`, \`#draft\`) chain calls in one batch.
|
|
148
|
+
- Save reusable JSON as preset files and pass \`ops_file\`.`,
|
|
149
|
+
tools: `\
|
|
150
|
+
# Tool / CLI reference
|
|
151
|
+
|
|
152
|
+
## register
|
|
153
|
+
|
|
154
|
+
**MCP input:** \`{ "username": string }\`
|
|
155
|
+
**Skill:** \`register --username NAME\` (or \`--api-key KEY\`).
|
|
156
|
+
|
|
157
|
+
Creates an inbox or returns the same \`{ inbox, accountId }\` when the
|
|
158
|
+
username matches the stored inbox local-part. A **different** username
|
|
159
|
+
replaces credentials in the directory and registers a new inbox.
|
|
160
|
+
|
|
161
|
+
## jmap_request
|
|
162
|
+
|
|
163
|
+
**MCP input:** \`{ "using"?: string[], "ops"?: string, "ops_file"?: string,
|
|
164
|
+
"vars"?: Record<string, string> }\` — keys in \`vars\` are names without \`$\`
|
|
165
|
+
(e.g. \`TO\` for \`$TO\`). Exactly one of \`ops\` or \`ops_file\`.
|
|
166
|
+
|
|
167
|
+
**Skill:** \`jmap_request --ops '...'\` or \`--ops-file path\` plus
|
|
168
|
+
\`--credentials-dir\` (optional), plus optional \`--vars '<json>'\`, \`--using\`,
|
|
169
|
+
\`--dry-run\`.
|
|
170
|
+
|
|
171
|
+
## help
|
|
172
|
+
|
|
173
|
+
**MCP:** \`{ "topic"?: string }\`
|
|
174
|
+
**Skill:** \`help [--topic TOPIC]\`
|
|
175
|
+
|
|
176
|
+
Topics: overview, installation, auth, jmap_cheatsheet, tools, presets,
|
|
177
|
+
troubleshooting.`,
|
|
178
|
+
presets: `\
|
|
179
|
+
# JMAP presets
|
|
180
|
+
|
|
181
|
+
Save a method-call array or a full \`{ "using", "methodCalls" }\` envelope
|
|
182
|
+
as JSON, then pass \`ops_file\` (MCP) or \`--ops-file\` (skill).
|
|
183
|
+
|
|
184
|
+
Relative paths first resolve against the credential directory (MCP) or current
|
|
185
|
+
\`--credentials-dir\` (skill). If not found, the runtime falls back to bundled
|
|
186
|
+
presets that ship in both npm packages.
|
|
187
|
+
|
|
188
|
+
## Bundled presets
|
|
189
|
+
|
|
190
|
+
- \`send_mail.json\` — sends one email using \`$TO\`, \`$SUBJECT\`, \`$BODY\`.
|
|
191
|
+
- \`list_inbox.json\` — returns the latest \`$COUNT\` inbox messages.
|
|
192
|
+
- \`reply.json\` — replies in-thread using \`$MAIL_ID\` and \`$BODY\`.
|
|
193
|
+
|
|
194
|
+
## Placeholders
|
|
195
|
+
|
|
196
|
+
Syntax: \`$VAR_NAME\` where \`VAR_NAME\` matches \`/^[A-Z][A-Z0-9_]*$/\` (so JMAP
|
|
197
|
+
keywords like \`$draft\` stay untouched).
|
|
198
|
+
|
|
199
|
+
- \`$ACCOUNT_ID\` — primary mail account id (from \`GET /.well-known/jmap\` when
|
|
200
|
+
referenced).
|
|
201
|
+
- \`$INBOX\` — inbox email address from credentials.
|
|
202
|
+
- Any other \`$FOO\` — must appear in MCP \`vars\` or skill \`--vars\` as
|
|
203
|
+
\`"FOO": "..."\` (string values only; JSON escaping in the preset body is your
|
|
204
|
+
responsibility).
|
|
205
|
+
|
|
206
|
+
You may override \`ACCOUNT_ID\` / \`INBOX\` via \`vars\` / \`--vars\` if needed.`,
|
|
207
|
+
troubleshooting: `\
|
|
208
|
+
# Troubleshooting
|
|
209
|
+
|
|
210
|
+
## Custom endpoint configuration issues
|
|
211
|
+
|
|
212
|
+
Defaults are production endpoints. Set env vars only for custom deployments.
|
|
213
|
+
|
|
214
|
+
## No API key / register first
|
|
215
|
+
|
|
216
|
+
Run \`register\`, or set \`ATOMIC_MAIL_API_KEY\`, or copy an existing
|
|
217
|
+
\`credentials.json\` into the credential directory.
|
|
218
|
+
|
|
219
|
+
## auth-service /api/v1/session returned 401
|
|
220
|
+
|
|
221
|
+
Invalid \`apiKey\` or wrong \`ATOMIC_MAIL_SCRYPT_SALT\` for this deployment.
|
|
222
|
+
|
|
223
|
+
## Capability JWT missing inboxId
|
|
224
|
+
|
|
225
|
+
Server/version mismatch — verify \`ATOMIC_MAIL_AUTH_URL\`.
|
|
226
|
+
|
|
227
|
+
## Could not read ops file
|
|
228
|
+
|
|
229
|
+
Check the path; use an absolute path if unsure.
|
|
230
|
+
|
|
231
|
+
## Missing values for variables (\`$TO\`, etc.)
|
|
232
|
+
|
|
233
|
+
Pass every custom placeholder in MCP \`vars\` or \`--vars\` as a JSON object of
|
|
234
|
+
strings. Ensure \`register\` completed so \`$ACCOUNT_ID\` / \`$INBOX\` can resolve.`,
|
|
235
|
+
};
|
|
236
|
+
export const HELP_TOPIC_LIST = Object.keys(HELP_TOPICS);
|
|
237
|
+
export function getHelp(topic) {
|
|
238
|
+
if (!topic) {
|
|
239
|
+
return HELP_TOPICS["overview"];
|
|
240
|
+
}
|
|
241
|
+
const key = topic.toLowerCase().replace(/[\s-]/g, "_");
|
|
242
|
+
return (HELP_TOPICS[key] ??
|
|
243
|
+
`Unknown topic "${topic}". Available topics: ${HELP_TOPIC_LIST.join(", ")}`);
|
|
244
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export declare const DEFAULT_JMAP_USING: readonly ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"];
|
|
2
|
+
export declare const JMAP_MAIL_URN: "urn:ietf:params:jmap:mail";
|
|
3
|
+
export interface JmapEnvelope {
|
|
4
|
+
using: string[];
|
|
5
|
+
methodCalls: unknown[];
|
|
6
|
+
}
|
|
7
|
+
export declare function parseJmapEnvelope(raw: string, defaultUsing: string[], source: string): JmapEnvelope;
|
|
8
|
+
export declare function resolveOpsFilePath(credentialDir: string, opsFile: string): string;
|
|
9
|
+
export declare function readOpsFile(credentialDir: string, opsFile: string): Promise<string>;
|
|
10
|
+
export declare function extractPrimaryMailAccountId(session: Record<string, unknown>): string;
|
|
11
|
+
export declare function fetchJmapWellKnown(apiUrl: string, capabilityJwt: string): Promise<Record<string, unknown>>;
|
|
12
|
+
/** Minimal surface for JMAP execution (implemented by AgentSession). */
|
|
13
|
+
export interface JmapSessionPort {
|
|
14
|
+
readonly apiUrl: string;
|
|
15
|
+
readonly files: {
|
|
16
|
+
credentialsFile: string;
|
|
17
|
+
};
|
|
18
|
+
getPrimaryMailAccountId(): Promise<string>;
|
|
19
|
+
getCapabilityToken(): Promise<string>;
|
|
20
|
+
readonly currentInboxId?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface RunJmapRequestInput {
|
|
23
|
+
session: JmapSessionPort;
|
|
24
|
+
/** Raw JSON: methodCalls array or full envelope */
|
|
25
|
+
opsJson: string;
|
|
26
|
+
/** Default `using` when the envelope omits it */
|
|
27
|
+
defaultUsing: string[];
|
|
28
|
+
/** Label for parse errors */
|
|
29
|
+
sourceLabel: string;
|
|
30
|
+
dryRun?: boolean;
|
|
31
|
+
/** Values for `$VAR` tokens (keys without `$`). Overrides session vars when present. */
|
|
32
|
+
vars?: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Parse ops JSON, substitute `$VAR_NAME` tokens (session + caller vars), POST to JMAP.
|
|
36
|
+
*/
|
|
37
|
+
export declare function runJmapRequest(input: RunJmapRequestInput): Promise<{
|
|
38
|
+
ok: boolean;
|
|
39
|
+
status: number;
|
|
40
|
+
bodyText: string;
|
|
41
|
+
}>;
|
|
42
|
+
export declare function postJmap(apiUrl: string, capabilityJwt: string, envelope: JmapEnvelope): Promise<{
|
|
43
|
+
ok: boolean;
|
|
44
|
+
status: number;
|
|
45
|
+
bodyText: string;
|
|
46
|
+
}>;
|
|
47
|
+
/** Attach _next hints to a successful JMAP JSON object when parseable. */
|
|
48
|
+
export declare function attachJmapNextHints(bodyText: string): string;
|
|
49
|
+
//# sourceMappingURL=agent-jmap.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-jmap.d.ts","sourceRoot":"","sources":["../../../../src/lib/agent/jmap/agent-jmap.ts"],"names":[],"mappings":"AASA,eAAO,MAAM,kBAAkB,qEAGrB,CAAC;AAEX,eAAO,MAAM,aAAa,EAAG,2BAAoC,CAAC;AAElE,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,WAAW,EAAE,OAAO,EAAE,CAAC;CACxB;AAED,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,EACX,YAAY,EAAE,MAAM,EAAE,EACtB,MAAM,EAAE,MAAM,GACb,YAAY,CAyBd;AAED,wBAAgB,kBAAkB,CAChC,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM,GACd,MAAM,CAER;AAED,wBAAsB,WAAW,CAC/B,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,CAAC,CAiBjB;AA8CD,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,MAAM,CAcR;AAED,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,MAAM,EACd,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAclC;AAED,wEAAwE;AACxE,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE;QAAE,eAAe,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5C,uBAAuB,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3C,kBAAkB,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IACtC,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;CAClC;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,eAAe,CAAC;IACzB,mDAAmD;IACnD,OAAO,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,6BAA6B;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,wFAAwF;IACxF,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAED;;GAEG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,mBAAmB,GACzB,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CA4C5D;AAED,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,MAAM,EACd,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,YAAY,GACrB,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAY5D;AAQD,0EAA0E;AAC1E,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAU5D"}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// JMAP envelope parsing, preset paths, $VAR substitution, and HTTP helpers.
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { readCredentials } from "../session/agent-credentials-store.js";
|
|
6
|
+
import { substituteVars } from "./agent-vars.js";
|
|
7
|
+
export const DEFAULT_JMAP_USING = [
|
|
8
|
+
"urn:ietf:params:jmap:core",
|
|
9
|
+
"urn:ietf:params:jmap:mail",
|
|
10
|
+
];
|
|
11
|
+
export const JMAP_MAIL_URN = "urn:ietf:params:jmap:mail";
|
|
12
|
+
export function parseJmapEnvelope(raw, defaultUsing, source) {
|
|
13
|
+
let value;
|
|
14
|
+
try {
|
|
15
|
+
value = JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
throw new Error(`${source} is not valid JSON: ${err.message}`);
|
|
19
|
+
}
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
return { using: [...defaultUsing], methodCalls: value };
|
|
22
|
+
}
|
|
23
|
+
if (value !== null &&
|
|
24
|
+
typeof value === "object" &&
|
|
25
|
+
Array.isArray(value.methodCalls)) {
|
|
26
|
+
const obj = value;
|
|
27
|
+
const using = Array.isArray(obj.using)
|
|
28
|
+
? obj.using.filter((u) => typeof u === "string")
|
|
29
|
+
: [...defaultUsing];
|
|
30
|
+
return { using, methodCalls: obj.methodCalls };
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`${source} must be a methodCalls array, e.g. ` +
|
|
33
|
+
'[["Mailbox/get",{...},"m0"]], or an object with a methodCalls array.');
|
|
34
|
+
}
|
|
35
|
+
export function resolveOpsFilePath(credentialDir, opsFile) {
|
|
36
|
+
return isAbsolute(opsFile) ? opsFile : resolvePath(credentialDir, opsFile);
|
|
37
|
+
}
|
|
38
|
+
export async function readOpsFile(credentialDir, opsFile) {
|
|
39
|
+
const filePath = resolveOpsFilePath(credentialDir, opsFile);
|
|
40
|
+
try {
|
|
41
|
+
return await readFile(filePath, "utf-8");
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (!(err instanceof Error) || !isFileNotFound(err) || isAbsolute(opsFile)) {
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const bundledPath = await resolveBundledPresetPath(opsFile);
|
|
49
|
+
if (!bundledPath) {
|
|
50
|
+
return await readFile(filePath, "utf-8");
|
|
51
|
+
}
|
|
52
|
+
return await readFile(bundledPath, "utf-8");
|
|
53
|
+
}
|
|
54
|
+
function isFileNotFound(err) {
|
|
55
|
+
const code = err.code;
|
|
56
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
57
|
+
}
|
|
58
|
+
async function resolveBundledPresetPath(opsFile) {
|
|
59
|
+
const moduleDir = dirname(fileURLToPath(globalThis[Symbol.for("import-meta-ponyfill-esmodule")](import.meta).url));
|
|
60
|
+
let currentDir = moduleDir;
|
|
61
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
62
|
+
const candidates = [
|
|
63
|
+
resolvePath(currentDir, "presets", opsFile),
|
|
64
|
+
resolvePath(currentDir, "agent", "jmap", "presets", opsFile),
|
|
65
|
+
resolvePath(currentDir, "lib", "src", "agent", "jmap", "presets", opsFile),
|
|
66
|
+
];
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
try {
|
|
69
|
+
await readFile(candidate, "utf-8");
|
|
70
|
+
return candidate;
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (!(err instanceof Error) || !isFileNotFound(err)) {
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const parent = resolvePath(currentDir, "..");
|
|
79
|
+
if (parent === currentDir)
|
|
80
|
+
break;
|
|
81
|
+
currentDir = parent;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
export function extractPrimaryMailAccountId(session) {
|
|
86
|
+
const primary = session["primaryAccounts"];
|
|
87
|
+
if (!primary || typeof primary !== "object") {
|
|
88
|
+
throw new Error("JMAP session missing primaryAccounts.");
|
|
89
|
+
}
|
|
90
|
+
const id = primary[JMAP_MAIL_URN];
|
|
91
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
92
|
+
throw new Error(`JMAP session missing primaryAccounts['${JMAP_MAIL_URN}'].`);
|
|
93
|
+
}
|
|
94
|
+
return id;
|
|
95
|
+
}
|
|
96
|
+
export async function fetchJmapWellKnown(apiUrl, capabilityJwt) {
|
|
97
|
+
const base = apiUrl.replace(/\/+$/, "");
|
|
98
|
+
const res = await fetch(`${base}/.well-known/jmap`, {
|
|
99
|
+
headers: { Authorization: `Bearer ${capabilityJwt}` },
|
|
100
|
+
});
|
|
101
|
+
const text = await res.text();
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
throw new Error(`JMAP session fetch failed (HTTP ${res.status}): ${text}`);
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(text);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new Error("JMAP session response is not valid JSON.");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Parse ops JSON, substitute `$VAR_NAME` tokens (session + caller vars), POST to JMAP.
|
|
114
|
+
*/
|
|
115
|
+
export async function runJmapRequest(input) {
|
|
116
|
+
const { text: raw } = await substituteVars({
|
|
117
|
+
raw: input.opsJson,
|
|
118
|
+
vars: input.vars,
|
|
119
|
+
autoResolvers: {
|
|
120
|
+
ACCOUNT_ID: () => input.session.getPrimaryMailAccountId(),
|
|
121
|
+
INBOX: async () => input.session.currentInboxId ??
|
|
122
|
+
(await readCredentials(input.session.files.credentialsFile)).inboxId,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const envelope = parseJmapEnvelope(raw, input.defaultUsing, input.sourceLabel);
|
|
126
|
+
if (input.dryRun) {
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
status: 200,
|
|
130
|
+
bodyText: JSON.stringify({
|
|
131
|
+
dryRun: true,
|
|
132
|
+
url: `${input.session.apiUrl.replace(/\/+$/, "")}/jmap/`,
|
|
133
|
+
envelope,
|
|
134
|
+
}, null, 2),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const capabilityJwt = await input.session.getCapabilityToken();
|
|
138
|
+
const { ok, status, bodyText } = await postJmap(input.session.apiUrl, capabilityJwt, envelope);
|
|
139
|
+
if (!ok) {
|
|
140
|
+
return { ok, status, bodyText };
|
|
141
|
+
}
|
|
142
|
+
return { ok, status, bodyText: attachJmapNextHints(bodyText) };
|
|
143
|
+
}
|
|
144
|
+
export async function postJmap(apiUrl, capabilityJwt, envelope) {
|
|
145
|
+
const base = apiUrl.replace(/\/+$/, "");
|
|
146
|
+
const res = await fetch(`${base}/jmap/`, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: {
|
|
149
|
+
"Content-Type": "application/json",
|
|
150
|
+
Authorization: `Bearer ${capabilityJwt}`,
|
|
151
|
+
},
|
|
152
|
+
body: JSON.stringify(envelope),
|
|
153
|
+
});
|
|
154
|
+
const bodyText = await res.text();
|
|
155
|
+
return { ok: res.ok, status: res.status, bodyText };
|
|
156
|
+
}
|
|
157
|
+
const JMAP_NEXT_HINTS = [
|
|
158
|
+
"Use jmap_request with Mailbox/get or Email/query to work with mail data.",
|
|
159
|
+
"Use presets with $VAR placeholders — $ACCOUNT_ID and $INBOX come from the session; pass others via vars / --vars.",
|
|
160
|
+
"Call help for the JMAP cheatsheet and troubleshooting.",
|
|
161
|
+
];
|
|
162
|
+
/** Attach _next hints to a successful JMAP JSON object when parseable. */
|
|
163
|
+
export function attachJmapNextHints(bodyText) {
|
|
164
|
+
try {
|
|
165
|
+
const obj = JSON.parse(bodyText);
|
|
166
|
+
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
167
|
+
return JSON.stringify({ ...obj, _next: [...JMAP_NEXT_HINTS] }, null, 2);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// not JSON — return raw
|
|
172
|
+
}
|
|
173
|
+
return bodyText;
|
|
174
|
+
}
|