predictor-outreach 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -2
- package/SKILL.md +37 -0
- package/dist/client.d.ts +45 -0
- package/dist/client.js +44 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +55 -0
- package/dist/connect.d.ts +29 -0
- package/dist/connect.js +131 -0
- package/dist/executor.d.ts +30 -0
- package/dist/executor.js +150 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +76 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/journal.d.ts +49 -0
- package/dist/journal.js +139 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +60 -0
- package/dist/platform-url.d.ts +5 -0
- package/dist/platform-url.js +24 -0
- package/dist/private-config.d.ts +9 -0
- package/dist/private-config.js +136 -0
- package/dist/self-check.d.ts +15 -0
- package/dist/self-check.js +35 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +105 -0
- package/dist/smtp.d.ts +25 -0
- package/dist/smtp.js +87 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +54 -0
- package/openapi.json +689 -0
- package/package.json +46 -4
package/dist/http.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
|
+
export async function serveLocalMcp(createMcp, port, localToken) {
|
|
5
|
+
if (localToken.length < 32 ||
|
|
6
|
+
/\s/u.test(localToken) ||
|
|
7
|
+
!Number.isInteger(port) ||
|
|
8
|
+
port < 1 ||
|
|
9
|
+
port > 65535)
|
|
10
|
+
throw new Error('INVALID_LOCAL_HTTP_CONFIG');
|
|
11
|
+
const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
|
|
12
|
+
const server = createServer(async (req, res) => {
|
|
13
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
|
|
14
|
+
if (!allowedHosts.has(req.headers.host ?? '') ||
|
|
15
|
+
(req.headers.origin &&
|
|
16
|
+
![`http://127.0.0.1:${port}`, `http://localhost:${port}`].includes(req.headers.origin))) {
|
|
17
|
+
res.writeHead(403).end();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const supplied = Buffer.from(req.headers.authorization ?? '');
|
|
21
|
+
const expected = Buffer.from(`Bearer ${localToken}`);
|
|
22
|
+
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) {
|
|
23
|
+
res.writeHead(401).end();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (url.pathname === '/healthz') {
|
|
27
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end('{"ready":true}');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (url.pathname !== '/mcp' || req.method !== 'POST') {
|
|
31
|
+
res.writeHead(404).end();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!req.headers['content-type']?.startsWith('application/json')) {
|
|
35
|
+
res.writeHead(415).end();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
let bytes = 0;
|
|
39
|
+
const chunks = [];
|
|
40
|
+
try {
|
|
41
|
+
for await (const chunk of req) {
|
|
42
|
+
const data = Buffer.from(chunk);
|
|
43
|
+
bytes += data.length;
|
|
44
|
+
if (bytes > 1_048_576) {
|
|
45
|
+
res.writeHead(413).end();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
chunks.push(data);
|
|
49
|
+
}
|
|
50
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
51
|
+
const mcp = createMcp();
|
|
52
|
+
const transport = new StreamableHTTPServerTransport({
|
|
53
|
+
enableJsonResponse: true,
|
|
54
|
+
});
|
|
55
|
+
res.on('close', () => {
|
|
56
|
+
void transport.close();
|
|
57
|
+
void mcp.close();
|
|
58
|
+
});
|
|
59
|
+
// SDK's HTTP callbacks include undefined while its Transport interface uses
|
|
60
|
+
// optional callbacks; the documented transport implements this interface.
|
|
61
|
+
await mcp.connect(transport);
|
|
62
|
+
await transport.handleRequest(req, res, body);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
if (!res.headersSent)
|
|
66
|
+
res.writeHead(400).end('{"error":"INVALID_MCP_REQUEST"}');
|
|
67
|
+
else
|
|
68
|
+
res.end();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
await new Promise((resolve, reject) => {
|
|
72
|
+
server.once('error', reject);
|
|
73
|
+
server.listen(port, '127.0.0.1', resolve);
|
|
74
|
+
});
|
|
75
|
+
return server;
|
|
76
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { OutreachClient } from './client.js';
|
|
2
|
+
export { Executor } from './executor.js';
|
|
3
|
+
export { Journal } from './journal.js';
|
|
4
|
+
export { NodemailerSmtp, render } from './smtp.js';
|
|
5
|
+
export { mailboxesFromEnv, platformConfig, shanghaiDay } from './config.js';
|
|
6
|
+
export { createOutreachServer } from './server.js';
|
|
7
|
+
export { serveLocalMcp } from './http.js';
|
|
8
|
+
export { connect, ConnectError, CONNECT_PROMPT, CONNECT_COMPLETED } from './connect.js';
|
|
9
|
+
export { savePrivateConfig, readPrivateConfig } from './private-config.js';
|
|
10
|
+
export { connectionSelfCheck } from './self-check.js';
|
|
11
|
+
export { DEFAULT_PLATFORM_URL } from './platform-url.js';
|
|
12
|
+
export type { Campaign, Contact, Platform, Receipt } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { OutreachClient } from './client.js';
|
|
2
|
+
export { Executor } from './executor.js';
|
|
3
|
+
export { Journal } from './journal.js';
|
|
4
|
+
export { NodemailerSmtp, render } from './smtp.js';
|
|
5
|
+
export { mailboxesFromEnv, platformConfig, shanghaiDay } from './config.js';
|
|
6
|
+
export { createOutreachServer } from './server.js';
|
|
7
|
+
export { serveLocalMcp } from './http.js';
|
|
8
|
+
export { connect, ConnectError, CONNECT_PROMPT, CONNECT_COMPLETED } from './connect.js';
|
|
9
|
+
export { savePrivateConfig, readPrivateConfig } from './private-config.js';
|
|
10
|
+
export { connectionSelfCheck } from './self-check.js';
|
|
11
|
+
export { DEFAULT_PLATFORM_URL } from './platform-url.js';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type Receipt } from './types.js';
|
|
3
|
+
declare const EntrySchema: z.ZodObject<{
|
|
4
|
+
batchId: z.ZodString;
|
|
5
|
+
address: z.ZodString;
|
|
6
|
+
mailbox: z.ZodString;
|
|
7
|
+
authorizationId: z.ZodString;
|
|
8
|
+
providerMessageId: z.ZodOptional<z.ZodString>;
|
|
9
|
+
error: z.ZodOptional<z.ZodString>;
|
|
10
|
+
status: z.ZodEnum<{
|
|
11
|
+
unknown: "unknown";
|
|
12
|
+
sent: "sent";
|
|
13
|
+
failed: "failed";
|
|
14
|
+
suppressed: "suppressed";
|
|
15
|
+
bounce: "bounce";
|
|
16
|
+
intent: "intent";
|
|
17
|
+
}>;
|
|
18
|
+
day: z.ZodString;
|
|
19
|
+
at: z.ZodString;
|
|
20
|
+
reported: z.ZodDefault<z.ZodBoolean>;
|
|
21
|
+
}, z.core.$strict>;
|
|
22
|
+
export type JournalEntry = z.infer<typeof EntrySchema>;
|
|
23
|
+
export declare class Journal {
|
|
24
|
+
readonly directory: string;
|
|
25
|
+
readonly entries: Map<string, {
|
|
26
|
+
batchId: string;
|
|
27
|
+
address: string;
|
|
28
|
+
mailbox: string;
|
|
29
|
+
authorizationId: string;
|
|
30
|
+
status: "unknown" | "sent" | "failed" | "suppressed" | "bounce" | "intent";
|
|
31
|
+
day: string;
|
|
32
|
+
at: string;
|
|
33
|
+
reported: boolean;
|
|
34
|
+
providerMessageId?: string | undefined;
|
|
35
|
+
error?: string | undefined;
|
|
36
|
+
}>;
|
|
37
|
+
private locked;
|
|
38
|
+
constructor(directory: string);
|
|
39
|
+
static key(batchId: string, address: string): string;
|
|
40
|
+
withLock<T>(operation: () => Promise<T>): Promise<T>;
|
|
41
|
+
private load;
|
|
42
|
+
append(entry: JournalEntry): Promise<void>;
|
|
43
|
+
receipt(entry: JournalEntry): Receipt;
|
|
44
|
+
daily(mailbox: string, day: string): {
|
|
45
|
+
attempts: number;
|
|
46
|
+
failures: number;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export {};
|
package/dist/journal.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { ReceiptSchema } from './types.js';
|
|
6
|
+
const EntrySchema = ReceiptSchema.extend({
|
|
7
|
+
status: z.enum(['intent', 'sent', 'failed', 'unknown', 'suppressed', 'bounce']),
|
|
8
|
+
day: z.string(),
|
|
9
|
+
at: z.string(),
|
|
10
|
+
reported: z.boolean().default(false),
|
|
11
|
+
});
|
|
12
|
+
export class Journal {
|
|
13
|
+
directory;
|
|
14
|
+
entries = new Map();
|
|
15
|
+
locked = false;
|
|
16
|
+
constructor(directory) {
|
|
17
|
+
this.directory = directory;
|
|
18
|
+
}
|
|
19
|
+
static key(batchId, address) {
|
|
20
|
+
return `${batchId}:${address.toLowerCase()}`;
|
|
21
|
+
}
|
|
22
|
+
async withLock(operation) {
|
|
23
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
24
|
+
const path = join(this.directory, 'executor.lock');
|
|
25
|
+
const token = randomUUID();
|
|
26
|
+
const acquire = async () => {
|
|
27
|
+
const file = await open(path, 'wx', 0o600);
|
|
28
|
+
try {
|
|
29
|
+
await file.writeFile(JSON.stringify({ pid: process.pid, token }));
|
|
30
|
+
await file.sync();
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await file.close();
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
try {
|
|
37
|
+
await acquire();
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code !== 'EEXIST')
|
|
41
|
+
throw error;
|
|
42
|
+
let owner;
|
|
43
|
+
try {
|
|
44
|
+
owner = JSON.parse(await readFile(path, 'utf8'));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error('JOURNAL_LOCK_NEEDS_MANUAL_REVIEW', { cause: error });
|
|
48
|
+
}
|
|
49
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0)
|
|
50
|
+
throw new Error('JOURNAL_LOCK_NEEDS_MANUAL_REVIEW', { cause: error });
|
|
51
|
+
try {
|
|
52
|
+
process.kill(owner.pid, 0);
|
|
53
|
+
throw new Error('EXECUTOR_ALREADY_RUNNING', { cause: error });
|
|
54
|
+
}
|
|
55
|
+
catch (probeError) {
|
|
56
|
+
if (probeError.code !== 'ESRCH')
|
|
57
|
+
throw new Error('EXECUTOR_ALREADY_RUNNING', { cause: probeError });
|
|
58
|
+
}
|
|
59
|
+
// Renaming/removing a stale lock concurrently would permit two senders. A separate
|
|
60
|
+
// exclusive recovery lock serializes reclaim, and acquire remains exclusive.
|
|
61
|
+
const recoveryPath = join(this.directory, 'recovery.lock');
|
|
62
|
+
let recovery;
|
|
63
|
+
try {
|
|
64
|
+
recovery = await open(recoveryPath, 'wx', 0o600);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new Error('EXECUTOR_ALREADY_RUNNING', { cause: error });
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const current = JSON.parse(await readFile(path, 'utf8'));
|
|
71
|
+
if (current.token !== owner.token)
|
|
72
|
+
throw new Error('EXECUTOR_ALREADY_RUNNING', { cause: error });
|
|
73
|
+
await rm(path);
|
|
74
|
+
await acquire();
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
await recovery.close();
|
|
78
|
+
await rm(recoveryPath);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
this.locked = true;
|
|
82
|
+
try {
|
|
83
|
+
await this.load();
|
|
84
|
+
return await operation();
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
this.locked = false;
|
|
88
|
+
const current = JSON.parse(await readFile(path, 'utf8'));
|
|
89
|
+
if (current.token === token)
|
|
90
|
+
await rm(path);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async load() {
|
|
94
|
+
this.entries.clear();
|
|
95
|
+
let data;
|
|
96
|
+
try {
|
|
97
|
+
data = await readFile(join(this.directory, 'journal.jsonl'), 'utf8');
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error.code === 'ENOENT')
|
|
101
|
+
return;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
if (data && !data.endsWith('\n'))
|
|
105
|
+
throw new Error('JOURNAL_INCOMPLETE_NEEDS_REVIEW');
|
|
106
|
+
for (const line of data.split('\n').filter(Boolean)) {
|
|
107
|
+
const entry = EntrySchema.parse(JSON.parse(line));
|
|
108
|
+
this.entries.set(Journal.key(entry.batchId, entry.address), entry);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async append(entry) {
|
|
112
|
+
if (!this.locked)
|
|
113
|
+
throw new Error('JOURNAL_LOCK_REQUIRED');
|
|
114
|
+
const safe = EntrySchema.parse(entry);
|
|
115
|
+
const file = await open(join(this.directory, 'journal.jsonl'), 'a', 0o600);
|
|
116
|
+
try {
|
|
117
|
+
await file.writeFile(`${JSON.stringify(safe)}\n`);
|
|
118
|
+
await file.sync();
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
await file.close();
|
|
122
|
+
}
|
|
123
|
+
this.entries.set(Journal.key(safe.batchId, safe.address), safe);
|
|
124
|
+
}
|
|
125
|
+
receipt(entry) {
|
|
126
|
+
const { day: _day, at: _at, reported: _reported, ...receipt } = entry;
|
|
127
|
+
return ReceiptSchema.parse({
|
|
128
|
+
...receipt,
|
|
129
|
+
status: entry.status === 'intent' ? 'unknown' : entry.status,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
daily(mailbox, day) {
|
|
133
|
+
const entries = [...this.entries.values()].filter((entry) => entry.mailbox === mailbox && entry.day === day && entry.status !== 'suppressed');
|
|
134
|
+
return {
|
|
135
|
+
attempts: entries.length,
|
|
136
|
+
failures: entries.filter((entry) => ['failed', 'unknown', 'intent', 'bounce'].includes(entry.status)).length,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
package/dist/main.d.ts
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { OutreachClient } from './client.js';
|
|
5
|
+
import { connect, ConnectError, CONNECT_COMPLETED } from './connect.js';
|
|
6
|
+
import { journalDirectory, mailboxesFromEnv } from './config.js';
|
|
7
|
+
import { Executor } from './executor.js';
|
|
8
|
+
import { Journal } from './journal.js';
|
|
9
|
+
import { serveLocalMcp } from './http.js';
|
|
10
|
+
import { createOutreachServer } from './server.js';
|
|
11
|
+
import { NodemailerSmtp } from './smtp.js';
|
|
12
|
+
import { connectionSelfCheck } from './self-check.js';
|
|
13
|
+
try {
|
|
14
|
+
const command = process.argv[2] ?? 'mcp';
|
|
15
|
+
if (command === 'connect') {
|
|
16
|
+
const result = await connect();
|
|
17
|
+
if (!result.ready)
|
|
18
|
+
process.exitCode = 2;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
const mailboxes = mailboxesFromEnv();
|
|
22
|
+
const smtp = new NodemailerSmtp();
|
|
23
|
+
if (command === 'self-check') {
|
|
24
|
+
const api = new OutreachClient();
|
|
25
|
+
const check = await connectionSelfCheck(await api.request('GET', 'status'), mailboxes, smtp);
|
|
26
|
+
process.stdout.write(`${JSON.stringify(check)}\n`);
|
|
27
|
+
if (check.ready)
|
|
28
|
+
process.stdout.write(`${CONNECT_COMPLETED}\n`);
|
|
29
|
+
else
|
|
30
|
+
process.exitCode = 2;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
const api = new OutreachClient();
|
|
34
|
+
const executor = new Executor(api, mailboxes, smtp, new Journal(journalDirectory()));
|
|
35
|
+
if (command === 'send') {
|
|
36
|
+
const batchId = z.string().uuid().parse(process.argv[3]);
|
|
37
|
+
process.stdout.write(`${JSON.stringify(await executor.send(batchId))}\n`);
|
|
38
|
+
}
|
|
39
|
+
else if (command === 'flush-receipts') {
|
|
40
|
+
process.stdout.write(`${JSON.stringify(await executor.flush())}\n`);
|
|
41
|
+
}
|
|
42
|
+
else if (command === 'http') {
|
|
43
|
+
await serveLocalMcp(() => createOutreachServer(api, executor), Number(process.env.OUTREACH_HTTP_PORT ?? 3457), process.env.OUTREACH_LOCAL_HTTP_TOKEN ?? '');
|
|
44
|
+
process.stderr.write('Predictor outreach MCP listening on loopback only.\n');
|
|
45
|
+
}
|
|
46
|
+
else if (command === 'mcp') {
|
|
47
|
+
await createOutreachServer(api, executor).connect(new StdioServerTransport());
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
throw new Error('UNKNOWN_COMMAND');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
process.stderr.write(error instanceof ConnectError
|
|
57
|
+
? `Predictor outreach connect failed (${error.code}). 授权失败后请停止,不要自动重试;可在连接管理中撤销已签发连接。\n`
|
|
58
|
+
: 'Predictor outreach operation failed. Check configuration and durable receipts locally; unknown deliveries must not be resent.\n');
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const DEFAULT_PLATFORM_URL = 'https://www.xhnova.com';
|
|
2
|
+
export function platformEndpoint(platformUrl) {
|
|
3
|
+
let url;
|
|
4
|
+
try {
|
|
5
|
+
url = new URL(platformUrl);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
throw new Error('INVALID_PLATFORM_URL');
|
|
9
|
+
}
|
|
10
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
11
|
+
if ((url.protocol !== 'https:' && !(local && url.protocol === 'http:')) ||
|
|
12
|
+
url.username ||
|
|
13
|
+
url.password ||
|
|
14
|
+
url.search ||
|
|
15
|
+
url.hash)
|
|
16
|
+
throw new Error('INVALID_PLATFORM_URL');
|
|
17
|
+
const prefix = url.pathname.replace(/\/+$/u, '');
|
|
18
|
+
const path = prefix.endsWith('/api/outreach/v1')
|
|
19
|
+
? prefix
|
|
20
|
+
: prefix.endsWith('/api')
|
|
21
|
+
? `${prefix}/outreach/v1`
|
|
22
|
+
: `${prefix}/api/outreach/v1`;
|
|
23
|
+
return { baseUrl: `${url.origin}${path}`, origin: url.origin };
|
|
24
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
declare const ConfigSchema: z.ZodObject<{
|
|
3
|
+
platformUrl: z.ZodString;
|
|
4
|
+
token: z.ZodString;
|
|
5
|
+
}, z.core.$strict>;
|
|
6
|
+
export type PrivateConfig = z.infer<typeof ConfigSchema>;
|
|
7
|
+
export declare function readPrivateConfig(directory: string): PrivateConfig | undefined;
|
|
8
|
+
export declare function savePrivateConfig(directory: string, input: PrivateConfig): Promise<void>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { chmod, lstat, mkdir, open, rename, unlink } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { platformEndpoint } from './platform-url.js';
|
|
8
|
+
const ConfigSchema = z
|
|
9
|
+
.object({
|
|
10
|
+
platformUrl: z.string().max(2000),
|
|
11
|
+
token: z
|
|
12
|
+
.string()
|
|
13
|
+
.min(1)
|
|
14
|
+
.max(8192)
|
|
15
|
+
.regex(/^[^\s]+$/u),
|
|
16
|
+
})
|
|
17
|
+
.strict();
|
|
18
|
+
function validConfig(input) {
|
|
19
|
+
const parsed = ConfigSchema.safeParse(input);
|
|
20
|
+
if (!parsed.success)
|
|
21
|
+
throw new Error('INVALID_PRIVATE_CONFIG');
|
|
22
|
+
platformEndpoint(parsed.data.platformUrl);
|
|
23
|
+
return parsed.data;
|
|
24
|
+
}
|
|
25
|
+
export function readPrivateConfig(directory) {
|
|
26
|
+
try {
|
|
27
|
+
const path = join(directory, 'config.json');
|
|
28
|
+
const info = lstatSync(path);
|
|
29
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 16_384)
|
|
30
|
+
throw new Error('INVALID_PRIVATE_CONFIG');
|
|
31
|
+
if (process.platform !== 'win32' && (info.mode & 0o777) !== 0o600)
|
|
32
|
+
throw new Error('PRIVATE_CONFIG_PERMISSIONS');
|
|
33
|
+
return validConfig(JSON.parse(readFileSync(path, 'utf8')));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code === 'ENOENT')
|
|
37
|
+
return undefined;
|
|
38
|
+
// eslint-disable-next-line preserve-caught-error -- JSON parser diagnostics may contain token bytes.
|
|
39
|
+
throw new Error('INVALID_PRIVATE_CONFIG');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** NTFS does not encode POSIX 0600. Remove inherited grants and grant only this user. */
|
|
43
|
+
async function restrictPath(path, mode) {
|
|
44
|
+
if (process.platform !== 'win32') {
|
|
45
|
+
await chmod(path, mode);
|
|
46
|
+
if (((await lstat(path)).mode & 0o777) !== mode)
|
|
47
|
+
throw new Error('PRIVATE_CONFIG_PERMISSIONS');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Windows may add explicit default-DACL grants when a file is created. A
|
|
51
|
+
// replacement DACL removes those too, instead of only removing inheritance.
|
|
52
|
+
const script = `
|
|
53
|
+
$ErrorActionPreference = 'Stop'
|
|
54
|
+
try {
|
|
55
|
+
$owner = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
56
|
+
if ($env:OUTREACH_PRIVATE_DIRECTORY -eq '1') {
|
|
57
|
+
$acl = [System.Security.AccessControl.DirectorySecurity]::new()
|
|
58
|
+
$acl.SetSecurityDescriptorSddlForm('D:P(A;OICI;FA;;;' + $owner.Value + ')', [System.Security.AccessControl.AccessControlSections]::Access)
|
|
59
|
+
[System.IO.Directory]::SetAccessControl($env:OUTREACH_PRIVATE_PATH, $acl)
|
|
60
|
+
} else {
|
|
61
|
+
$acl = [System.Security.AccessControl.FileSecurity]::new()
|
|
62
|
+
$acl.SetSecurityDescriptorSddlForm('D:P(A;;FA;;;' + $owner.Value + ')', [System.Security.AccessControl.AccessControlSections]::Access)
|
|
63
|
+
[System.IO.File]::SetAccessControl($env:OUTREACH_PRIVATE_PATH, $acl)
|
|
64
|
+
}
|
|
65
|
+
exit 0
|
|
66
|
+
} catch { exit 1 }
|
|
67
|
+
`;
|
|
68
|
+
const permission = spawnSync('powershell.exe', [
|
|
69
|
+
'-NoLogo',
|
|
70
|
+
'-NoProfile',
|
|
71
|
+
'-NonInteractive',
|
|
72
|
+
'-EncodedCommand',
|
|
73
|
+
Buffer.from(script, 'utf16le').toString('base64'),
|
|
74
|
+
], {
|
|
75
|
+
env: {
|
|
76
|
+
...process.env,
|
|
77
|
+
OUTREACH_PRIVATE_PATH: path,
|
|
78
|
+
OUTREACH_PRIVATE_DIRECTORY: mode === 0o700 ? '1' : '0',
|
|
79
|
+
},
|
|
80
|
+
encoding: 'utf8',
|
|
81
|
+
windowsHide: true,
|
|
82
|
+
});
|
|
83
|
+
if (permission.status !== 0)
|
|
84
|
+
throw new Error('PRIVATE_CONFIG_PERMISSIONS');
|
|
85
|
+
}
|
|
86
|
+
export async function savePrivateConfig(directory, input) {
|
|
87
|
+
const config = validConfig(input);
|
|
88
|
+
const target = join(directory, 'config.json');
|
|
89
|
+
const temporary = join(directory, `.config-${randomUUID()}.tmp`);
|
|
90
|
+
let created = false;
|
|
91
|
+
try {
|
|
92
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
93
|
+
const parent = await lstat(directory);
|
|
94
|
+
if (!parent.isDirectory() || parent.isSymbolicLink())
|
|
95
|
+
throw new Error('PRIVATE_CONFIG_DIRECTORY');
|
|
96
|
+
await restrictPath(directory, 0o700);
|
|
97
|
+
try {
|
|
98
|
+
const old = await lstat(target);
|
|
99
|
+
if (!old.isFile() || old.isSymbolicLink())
|
|
100
|
+
throw new Error('INVALID_PRIVATE_CONFIG');
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error.code !== 'ENOENT')
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
107
|
+
created = true;
|
|
108
|
+
try {
|
|
109
|
+
// Secure the temporary file before writing any token bytes.
|
|
110
|
+
await restrictPath(temporary, 0o600);
|
|
111
|
+
await file.writeFile(`${JSON.stringify(config)}\n`, 'utf8');
|
|
112
|
+
await file.sync();
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
await file.close();
|
|
116
|
+
}
|
|
117
|
+
await rename(temporary, target);
|
|
118
|
+
created = false;
|
|
119
|
+
// Flush the directory entry on filesystems that support it. Windows rename
|
|
120
|
+
// preserves the explicitly restricted file ACL instead of a POSIX mode.
|
|
121
|
+
if (process.platform !== 'win32') {
|
|
122
|
+
const parentFile = await open(directory, 'r');
|
|
123
|
+
try {
|
|
124
|
+
await parentFile.sync();
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await parentFile.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
if (created)
|
|
133
|
+
await unlink(temporary).catch(() => undefined);
|
|
134
|
+
throw new Error('PRIVATE_CONFIG_WRITE_FAILED');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Mailbox } from './config.js';
|
|
2
|
+
import type { LocalSmtp } from './smtp.js';
|
|
3
|
+
export declare function assertMailboxPoolLimit(status: unknown, mailboxes: Mailbox[]): void;
|
|
4
|
+
export declare function mailboxSelfCheck(status: unknown, mailboxes: Mailbox[], smtp: LocalSmtp): Promise<{
|
|
5
|
+
mailbox: string;
|
|
6
|
+
available: boolean;
|
|
7
|
+
}[]>;
|
|
8
|
+
export declare function connectionSelfCheck(status: unknown, mailboxes: Mailbox[], smtp: LocalSmtp): Promise<{
|
|
9
|
+
platformStatus: string;
|
|
10
|
+
mailboxes: {
|
|
11
|
+
mailbox: string;
|
|
12
|
+
available: boolean;
|
|
13
|
+
}[];
|
|
14
|
+
ready: boolean;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const PolicySchema = z.object({
|
|
3
|
+
status: z.string().optional(),
|
|
4
|
+
quota: z
|
|
5
|
+
.object({ mailboxPoolLimit: z.number().int().positive().nullable().optional() })
|
|
6
|
+
.optional(),
|
|
7
|
+
});
|
|
8
|
+
export function assertMailboxPoolLimit(status, mailboxes) {
|
|
9
|
+
const parsed = PolicySchema.safeParse(status);
|
|
10
|
+
if (!parsed.success)
|
|
11
|
+
throw new Error('INVALID_PLATFORM_POLICY');
|
|
12
|
+
const limit = parsed.data.quota?.mailboxPoolLimit;
|
|
13
|
+
if (limit !== null && limit !== undefined && mailboxes.length > limit)
|
|
14
|
+
throw new Error('MAILBOX_POOL_LIMIT_EXCEEDED');
|
|
15
|
+
}
|
|
16
|
+
export async function mailboxSelfCheck(status, mailboxes, smtp) {
|
|
17
|
+
assertMailboxPoolLimit(status, mailboxes);
|
|
18
|
+
return Promise.all(mailboxes.map(async (mailbox) => ({
|
|
19
|
+
mailbox: mailbox.user,
|
|
20
|
+
available: await smtp.verify(mailbox).catch(() => false),
|
|
21
|
+
})));
|
|
22
|
+
}
|
|
23
|
+
export async function connectionSelfCheck(status, mailboxes, smtp) {
|
|
24
|
+
const parsed = PolicySchema.safeParse(status);
|
|
25
|
+
if (!parsed.success || !parsed.data.status)
|
|
26
|
+
throw new Error('INVALID_PLATFORM_POLICY');
|
|
27
|
+
const health = await mailboxSelfCheck(status, mailboxes, smtp);
|
|
28
|
+
return {
|
|
29
|
+
platformStatus: parsed.data.status,
|
|
30
|
+
mailboxes: health,
|
|
31
|
+
ready: parsed.data.status === 'active' &&
|
|
32
|
+
health.length > 0 &&
|
|
33
|
+
health.every((mailbox) => mailbox.available),
|
|
34
|
+
};
|
|
35
|
+
}
|
package/dist/server.d.ts
ADDED