dsh-aimail 0.1.24 → 0.1.25
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/lib/inbound.d.ts +51 -0
- package/lib/inbound.js +221 -133
- package/package.json +3 -3
package/lib/inbound.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
1
2
|
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
import { type AgentConfig, type AgentPullHandle, type AgentPullOverrides, type InboundPayload } from '@aimail/mail-core';
|
|
4
|
+
import type { MailService } from './mail-service.js';
|
|
2
5
|
export declare const name = "mail-inbound";
|
|
3
6
|
export declare const inject: string[];
|
|
4
7
|
export interface Config {
|
|
@@ -9,5 +12,53 @@ export interface Config {
|
|
|
9
12
|
/** Deliver path (default /aimail/inbound). */
|
|
10
13
|
path?: string;
|
|
11
14
|
}
|
|
15
|
+
/** The resolved target of one inbound payload (binding + routed address). */
|
|
16
|
+
export interface InboundTarget {
|
|
17
|
+
cfg: AgentConfig;
|
|
18
|
+
agentAddr: string;
|
|
19
|
+
}
|
|
20
|
+
/** Result of the shared inbound chain (routing→deliver). */
|
|
21
|
+
export interface InboundOutcome {
|
|
22
|
+
status: string;
|
|
23
|
+
detail?: string | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Did the mail reach a dsh session? `false` ⇒ a pulled delivery must NOT be
|
|
26
|
+
* acked (it re-pulls next round) — the pull-side equivalent of the push
|
|
27
|
+
* side's 503.
|
|
28
|
+
*/
|
|
29
|
+
ok: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Recipient routing — the ONE routing step both entries share (push handler +
|
|
33
|
+
* pull loop). The per-delivery target is authoritative: the bridge injects
|
|
34
|
+
* X-AIMail-Email on each single-delivery POST (legacy); payload.to is the
|
|
35
|
+
* FILTERED full list (external recipients first), so to[0] is often an external
|
|
36
|
+
* address. Use the header when present; only iterate toRaw when the header is
|
|
37
|
+
* absent (batch deliveries carry no such header).
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveInboundTarget(mail: MailService, payload: InboundPayload, headers: Record<string, unknown>): Promise<InboundTarget | undefined>;
|
|
40
|
+
/**
|
|
41
|
+
* Post-routing inbound chain: preprocess (13 steps + ping/pong) → deliver to a
|
|
42
|
+
* dsh session. Shared by the push handler and the pull loop.
|
|
43
|
+
*
|
|
44
|
+
* ⚠ The TRUST step is not here: pushed mail is verified by the per-address HMAC
|
|
45
|
+
* (bridge↔endpoint boundary), pulled mail by the gateway's own verification of
|
|
46
|
+
* the agent-scope key. Routing, enrichment and delivery are one chain.
|
|
47
|
+
*/
|
|
48
|
+
export declare function deliverInbound(ctx: Context, target: InboundTarget, payload: InboundPayload, headers: Record<string, unknown>): Promise<InboundOutcome>;
|
|
49
|
+
/** The push handler (HTTP route) around the shared chain. */
|
|
50
|
+
export declare function createInboundHandler(ctx: Context, mail: MailService, deliverPath: string): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Pull entry (agent-scope bindings only) — the missing production wire for the
|
|
53
|
+
* address-code flow. Pulled mail enters `deliverInbound`: the very same chain
|
|
54
|
+
* pushed mail takes (routing → preprocess → session delivery). Never started
|
|
55
|
+
* for system/bridge bindings (push-served): the decision lives in mail-core.
|
|
56
|
+
*/
|
|
57
|
+
export declare function startInboundPull(ctx: Context, opts?: {
|
|
58
|
+
log?: (line: string) => void;
|
|
59
|
+
env?: NodeJS.ProcessEnv;
|
|
60
|
+
overrides?: AgentPullOverrides;
|
|
61
|
+
systemId?: string;
|
|
62
|
+
}): Promise<AgentPullHandle[]>;
|
|
12
63
|
export declare function apply(ctx: Context, config?: Config): () => void;
|
|
13
64
|
//# sourceMappingURL=inbound.d.ts.map
|
package/lib/inbound.js
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* resume) or spawn a fresh disposable session when unbound — context
|
|
12
12
|
* continuity is aimail's (local meta threading), not the session's.
|
|
13
13
|
* 200 ack on delivery; 503 on session-create failure (bridge retries).
|
|
14
|
+
*
|
|
15
|
+
* Pull entry (2026-09-27): an address activated by an activation CODE has no
|
|
16
|
+
* push path (the gateway stores webhook_url=NULL for it), so this plugin also
|
|
17
|
+
* polls its own mailbox on a timer and delivers through the SAME chain above
|
|
18
|
+
* (`deliverInbound`). Enabled only for agent-scope bindings — the decision and
|
|
19
|
+
* the loop live in mail-core (`startAgentPullEntries` / `startPolling`).
|
|
14
20
|
*/
|
|
15
21
|
import * as fs from 'node:fs';
|
|
16
22
|
import * as os from 'node:os';
|
|
@@ -19,7 +25,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
19
25
|
import { createServer } from 'node:http';
|
|
20
26
|
import { randomUUID } from 'node:crypto';
|
|
21
27
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
22
|
-
import { processInboundMail, verifySignature, routeAddressFromHeaders, updateAgentConfig, loadAgentConfig, saveAgentConfig, INBOUND_PATH, INBOUND_PORTS } from '@aimail/mail-core';
|
|
28
|
+
import { processInboundMail, verifySignature, routeAddressFromHeaders, updateAgentConfig, loadAgentConfig, saveAgentConfig, startAgentPullEntries, INBOUND_PATH, INBOUND_PORTS } from '@aimail/mail-core';
|
|
23
29
|
import { ensureBridgeRoutesForSystem, formatBridgeRouteLine, isBridgeRouteWarning } from '@aimail/mail-core';
|
|
24
30
|
export const name = 'mail-inbound';
|
|
25
31
|
export const inject = ['mail', 'agents'];
|
|
@@ -36,6 +42,204 @@ function readBody(req) {
|
|
|
36
42
|
req.on('error', reject);
|
|
37
43
|
});
|
|
38
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Recipient routing — the ONE routing step both entries share (push handler +
|
|
47
|
+
* pull loop). The per-delivery target is authoritative: the bridge injects
|
|
48
|
+
* X-AIMail-Email on each single-delivery POST (legacy); payload.to is the
|
|
49
|
+
* FILTERED full list (external recipients first), so to[0] is often an external
|
|
50
|
+
* address. Use the header when present; only iterate toRaw when the header is
|
|
51
|
+
* absent (batch deliveries carry no such header).
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveInboundTarget(mail, payload, headers) {
|
|
54
|
+
const routeAddr = routeAddressFromHeaders(headers);
|
|
55
|
+
const toRaw = Array.isArray(payload.to) ? payload.to : typeof payload.to === 'string' ? [payload.to] : [];
|
|
56
|
+
const routeCandidates = routeAddr ? [routeAddr] : toRaw;
|
|
57
|
+
let cfg;
|
|
58
|
+
let agentAddr = '';
|
|
59
|
+
for (const t of routeCandidates) {
|
|
60
|
+
const addr = String(t).trim();
|
|
61
|
+
if (!addr.includes('@'))
|
|
62
|
+
continue;
|
|
63
|
+
const c = await mail.resolveByRecipient(addr);
|
|
64
|
+
if (c) {
|
|
65
|
+
cfg = c;
|
|
66
|
+
agentAddr = addr;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return cfg ? { cfg, agentAddr } : undefined;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Post-routing inbound chain: preprocess (13 steps + ping/pong) → deliver to a
|
|
74
|
+
* dsh session. Shared by the push handler and the pull loop.
|
|
75
|
+
*
|
|
76
|
+
* ⚠ The TRUST step is not here: pushed mail is verified by the per-address HMAC
|
|
77
|
+
* (bridge↔endpoint boundary), pulled mail by the gateway's own verification of
|
|
78
|
+
* the agent-scope key. Routing, enrichment and delivery are one chain.
|
|
79
|
+
*/
|
|
80
|
+
export async function deliverInbound(ctx, target, payload, headers) {
|
|
81
|
+
const { cfg, agentAddr } = target;
|
|
82
|
+
// TS preprocess chain (13 steps) + ping/pong intercept
|
|
83
|
+
const result = await processInboundMail(payload, headers, {
|
|
84
|
+
systemId: cfg.system_id,
|
|
85
|
+
email: cfg.email,
|
|
86
|
+
});
|
|
87
|
+
if (result === null) {
|
|
88
|
+
return { status: 'intercepted', ok: true };
|
|
89
|
+
}
|
|
90
|
+
// Deliver to a dsh session:
|
|
91
|
+
// - cfg.session_id set + live → followup that session (UI continuity)
|
|
92
|
+
// - cfg.session_id set + cold → resume it, else fall through
|
|
93
|
+
// - unbound (or resume failed) → spawn a FRESH session. Context
|
|
94
|
+
// continuity is aimail's job (local meta threading + email_summary),
|
|
95
|
+
// not the session's — per the deployment decision each inbound email
|
|
96
|
+
// gets its own disposable session.
|
|
97
|
+
const agents = ctx.get('agents');
|
|
98
|
+
if (agents === undefined) {
|
|
99
|
+
return { status: 'no_agents_service', detail: 'dsh-agent not mounted', ok: false };
|
|
100
|
+
}
|
|
101
|
+
// Model route: the deployment's default selection (base bundle's
|
|
102
|
+
// `agent-default-model` row, e.g. deepseek-official/deepseek-v4-flash)
|
|
103
|
+
// — same source the web UI's api-proxy uses for agents.create().
|
|
104
|
+
// Without it the turn dies with "no provider/model".
|
|
105
|
+
const agentOptions = ctx.get('agentDefaultModel')
|
|
106
|
+
?.currentSelection();
|
|
107
|
+
const boundId = cfg.session_id ?? '';
|
|
108
|
+
const message = createUserMessage({
|
|
109
|
+
content: [{ type: 'text', text: JSON.stringify({ ...result, to: agentAddr }) }],
|
|
110
|
+
source: { kind: 'user' },
|
|
111
|
+
});
|
|
112
|
+
const live = boundId ? agents.get(boundId) : undefined;
|
|
113
|
+
if (live) {
|
|
114
|
+
live.followup(message);
|
|
115
|
+
return { status: 'delivered', detail: 'followup queued', ok: true };
|
|
116
|
+
}
|
|
117
|
+
if (boundId) {
|
|
118
|
+
try {
|
|
119
|
+
const handle = await agents.resume({ resumeSessionId: boundId, agentOptions });
|
|
120
|
+
handle.agent.followup(message);
|
|
121
|
+
return { status: 'resumed', detail: 'cold session resumed + followup queued', ok: true };
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// resume failed (no persistence, stale id) — fall through to a fresh session
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Fresh disposable session for this email.
|
|
128
|
+
const sessionId = randomUUID();
|
|
129
|
+
try {
|
|
130
|
+
// Bind this session into the agent's config so the mail tools can
|
|
131
|
+
// resolve credentials (resolveBySessionId matches agentmail.json's
|
|
132
|
+
// session_id). Unbind again once the turn settles — but only if the
|
|
133
|
+
// binding is still OURS (a concurrent email may have re-bound).
|
|
134
|
+
await updateAgentConfig(cfg.system_id, cfg.email, { session_id: sessionId });
|
|
135
|
+
const handle = await agents.create({ sessionId, meta: { cwd: process.cwd() }, agentOptions });
|
|
136
|
+
handle.agent.followup(message);
|
|
137
|
+
void handle.agent.whenIdle()
|
|
138
|
+
.then(async () => {
|
|
139
|
+
const cur = await loadAgentConfig(cfg.system_id, cfg.email);
|
|
140
|
+
if (cur && cur.session_id === sessionId) {
|
|
141
|
+
const { session_id: _drop, ...rest } = cur;
|
|
142
|
+
await saveAgentConfig(rest, cfg.system_id);
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
.then(() => handle.dispose())
|
|
146
|
+
.catch(() => { });
|
|
147
|
+
return { status: 'delivered', detail: `fresh session ${sessionId}`, ok: true };
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
// push side: 503 (not 2xx) so the bridge does NOT ack and will retry; a 200
|
|
151
|
+
// here would silently swallow the email. Pull side: !ok ⇒ not acked.
|
|
152
|
+
return {
|
|
153
|
+
status: 'session_create_failed',
|
|
154
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
155
|
+
ok: false,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** The push handler (HTTP route) around the shared chain. */
|
|
160
|
+
export function createInboundHandler(ctx, mail, deliverPath) {
|
|
161
|
+
return async (req, res) => {
|
|
162
|
+
try {
|
|
163
|
+
if (req.method !== 'POST' || (req.url ?? '').split('?')[0] !== deliverPath) {
|
|
164
|
+
writeJson(res, 404, { status: 'not_found' });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const rawBody = await readBody(req);
|
|
168
|
+
let payload;
|
|
169
|
+
try {
|
|
170
|
+
payload = JSON.parse(rawBody.toString('utf-8'));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
writeJson(res, 400, { status: 'bad_json' });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const headers = {
|
|
177
|
+
...req.headers,
|
|
178
|
+
...(payload.headers ?? {}),
|
|
179
|
+
};
|
|
180
|
+
const target = await resolveInboundTarget(mail, payload, headers);
|
|
181
|
+
if (!target) {
|
|
182
|
+
writeJson(res, 200, { status: 'no_agent', detail: 'no binding' });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// HMAC verify (per-address webhook_secret) — push-only trust step; pulled
|
|
186
|
+
// mail is authenticated by the gateway on the agent-scope key.
|
|
187
|
+
const sig = req.headers['x-webhook-signature'] ?? '';
|
|
188
|
+
if (!verifySignature(rawBody, sig, target.cfg.webhook_secret ?? '')) {
|
|
189
|
+
writeJson(res, 401, { status: 'bad_signature' });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const out = await deliverInbound(ctx, target, payload, headers);
|
|
193
|
+
// Non-2xx on delivery failure so the bridge retries (unchanged semantics).
|
|
194
|
+
// `detail` is omitted when absent (exactOptionalPropertyTypes: an explicit
|
|
195
|
+
// `undefined` would not satisfy DeliveryOutcome's optional string).
|
|
196
|
+
const body = out.detail === undefined ? { status: out.status } : { status: out.status, detail: out.detail };
|
|
197
|
+
writeJson(res, out.ok ? 200 : 503, body);
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
writeJson(res, 500, { status: 'error', detail: e instanceof Error ? e.message : String(e) });
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Pull entry (agent-scope bindings only) — the missing production wire for the
|
|
206
|
+
* address-code flow. Pulled mail enters `deliverInbound`: the very same chain
|
|
207
|
+
* pushed mail takes (routing → preprocess → session delivery). Never started
|
|
208
|
+
* for system/bridge bindings (push-served): the decision lives in mail-core.
|
|
209
|
+
*/
|
|
210
|
+
export async function startInboundPull(ctx, opts = {}) {
|
|
211
|
+
const mail = ctx.get('mail');
|
|
212
|
+
if (mail === undefined) {
|
|
213
|
+
throw new Error('mail-inbound requires the mail service: mount dsh-aimail/mail-service first');
|
|
214
|
+
}
|
|
215
|
+
return await startAgentPullEntries({
|
|
216
|
+
// Scope = the plugin's own system when it has one, else every system
|
|
217
|
+
// (the per-address binding file is the authority — single source).
|
|
218
|
+
systemId: opts.systemId ?? mail.systemId,
|
|
219
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
220
|
+
...(opts.env ? { env: opts.env } : {}),
|
|
221
|
+
...(opts.overrides ? { overrides: opts.overrides } : {}),
|
|
222
|
+
onEmail: async (cfg, pulled) => {
|
|
223
|
+
if (typeof pulled.body !== 'object' || pulled.body === null) {
|
|
224
|
+
throw new Error(`pulled delivery ${pulled.id} for ${cfg.email} carries no JSON payload — not acking`);
|
|
225
|
+
}
|
|
226
|
+
// The delivery's own address is authoritative for routing (it is what the
|
|
227
|
+
// agent-scope key was verified against server-side).
|
|
228
|
+
const headers = {
|
|
229
|
+
...(pulled.headers ?? {}),
|
|
230
|
+
'x-aimail-email': pulled.email,
|
|
231
|
+
};
|
|
232
|
+
const target = await resolveInboundTarget(mail, pulled.body, headers);
|
|
233
|
+
if (!target) {
|
|
234
|
+
throw new Error(`pulled delivery ${pulled.id}: no binding for ${pulled.email} — not acking`);
|
|
235
|
+
}
|
|
236
|
+
const out = await deliverInbound(ctx, target, pulled.body, headers);
|
|
237
|
+
if (!out.ok) {
|
|
238
|
+
throw new Error(`pulled delivery ${pulled.id}: inbound chain did not deliver (${out.status}: ${out.detail ?? ''}) — not acking`);
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
39
243
|
export function apply(ctx, config = {}) {
|
|
40
244
|
const mail = ctx.get('mail');
|
|
41
245
|
if (mail === undefined) {
|
|
@@ -73,138 +277,10 @@ export function apply(ctx, config = {}) {
|
|
|
73
277
|
const host = config.host ?? '127.0.0.1';
|
|
74
278
|
const port = config.port ?? Number(process.env.AIMAIL_INBOUND_PORT ?? INBOUND_PORTS.dsh);
|
|
75
279
|
const deliverPath = config.path ?? INBOUND_PATH;
|
|
76
|
-
const server = createServer(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
const rawBody = await readBody(req);
|
|
83
|
-
let payload;
|
|
84
|
-
try {
|
|
85
|
-
payload = JSON.parse(rawBody.toString('utf-8'));
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
writeJson(res, 400, { status: 'bad_json' });
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
// Inbound routing (Q3 — mirror Python bridge routing): the per-delivery
|
|
92
|
-
// target is authoritative. The bridge injects X-AIMail-Email (legacy
|
|
93
|
-
// on each single-delivery POST; payload.to is
|
|
94
|
-
// the FILTERED full list (external recipients first), so to[0] is often
|
|
95
|
-
// an external address. Use the header when present; only iterate toRaw
|
|
96
|
-
// when the header is absent (batch deliveries carry no such header).
|
|
97
|
-
const headers = {
|
|
98
|
-
...req.headers,
|
|
99
|
-
...(payload.headers ?? {}),
|
|
100
|
-
};
|
|
101
|
-
const routeAddr = routeAddressFromHeaders(headers);
|
|
102
|
-
const toRaw = Array.isArray(payload.to) ? payload.to : typeof payload.to === 'string' ? [payload.to] : [];
|
|
103
|
-
const routeCandidates = routeAddr ? [routeAddr] : toRaw;
|
|
104
|
-
let cfg;
|
|
105
|
-
let agentAddr = '';
|
|
106
|
-
for (const t of routeCandidates) {
|
|
107
|
-
const addr = String(t).trim();
|
|
108
|
-
if (!addr.includes('@'))
|
|
109
|
-
continue;
|
|
110
|
-
const c = await mail.resolveByRecipient(addr);
|
|
111
|
-
if (c) {
|
|
112
|
-
cfg = c;
|
|
113
|
-
agentAddr = addr;
|
|
114
|
-
break;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
if (!cfg) {
|
|
118
|
-
writeJson(res, 200, { status: 'no_agent', detail: 'no binding' });
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
// HMAC verify (per-address webhook_secret)
|
|
122
|
-
const sig = req.headers['x-webhook-signature'] ?? '';
|
|
123
|
-
if (!verifySignature(rawBody, sig, cfg.webhook_secret ?? '')) {
|
|
124
|
-
writeJson(res, 401, { status: 'bad_signature' });
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
// TS preprocess chain (13 steps) + ping/pong intercept
|
|
128
|
-
const result = await processInboundMail(payload, headers, {
|
|
129
|
-
systemId: cfg.system_id,
|
|
130
|
-
email: cfg.email,
|
|
131
|
-
});
|
|
132
|
-
if (result === null) {
|
|
133
|
-
writeJson(res, 200, { status: 'intercepted' });
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
// Deliver to a dsh session:
|
|
137
|
-
// - cfg.session_id set + live → followup that session (UI continuity)
|
|
138
|
-
// - cfg.session_id set + cold → resume it, else fall through
|
|
139
|
-
// - unbound (or resume failed) → spawn a FRESH session. Context
|
|
140
|
-
// continuity is aimail's job (local meta threading + email_summary),
|
|
141
|
-
// not the session's — per the deployment decision each inbound email
|
|
142
|
-
// gets its own disposable session.
|
|
143
|
-
const agents = ctx.get('agents');
|
|
144
|
-
if (agents === undefined) {
|
|
145
|
-
writeJson(res, 200, { status: 'no_agents_service', detail: 'dsh-agent not mounted' });
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
// Model route: the deployment's default selection (base bundle's
|
|
149
|
-
// `agent-default-model` row, e.g. deepseek-official/deepseek-v4-flash)
|
|
150
|
-
// — same source the web UI's api-proxy uses for agents.create().
|
|
151
|
-
// Without it the turn dies with "no provider/model".
|
|
152
|
-
const agentOptions = ctx.get('agentDefaultModel')
|
|
153
|
-
?.currentSelection();
|
|
154
|
-
const boundId = cfg.session_id ?? '';
|
|
155
|
-
const message = createUserMessage({
|
|
156
|
-
content: [{ type: 'text', text: JSON.stringify({ ...result, to: agentAddr }) }],
|
|
157
|
-
source: { kind: 'user' },
|
|
158
|
-
});
|
|
159
|
-
const live = boundId ? agents.get(boundId) : undefined;
|
|
160
|
-
if (live) {
|
|
161
|
-
live.followup(message);
|
|
162
|
-
writeJson(res, 200, { status: 'delivered', detail: 'followup queued' });
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
if (boundId) {
|
|
166
|
-
try {
|
|
167
|
-
const handle = await agents.resume({ resumeSessionId: boundId, agentOptions });
|
|
168
|
-
handle.agent.followup(message);
|
|
169
|
-
writeJson(res, 200, { status: 'resumed', detail: 'cold session resumed + followup queued' });
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
catch {
|
|
173
|
-
// resume failed (no persistence, stale id) — fall through to a fresh session
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
// Fresh disposable session for this email.
|
|
177
|
-
const sessionId = randomUUID();
|
|
178
|
-
try {
|
|
179
|
-
// Bind this session into the agent's config so the mail tools can
|
|
180
|
-
// resolve credentials (resolveBySessionId matches agentmail.json's
|
|
181
|
-
// session_id). Unbind again once the turn settles — but only if the
|
|
182
|
-
// binding is still OURS (a concurrent email may have re-bound).
|
|
183
|
-
await updateAgentConfig(cfg.system_id, cfg.email, { session_id: sessionId });
|
|
184
|
-
const handle = await agents.create({ sessionId, meta: { cwd: process.cwd() }, agentOptions });
|
|
185
|
-
handle.agent.followup(message);
|
|
186
|
-
void handle.agent.whenIdle()
|
|
187
|
-
.then(async () => {
|
|
188
|
-
const cur = await loadAgentConfig(cfg.system_id, cfg.email);
|
|
189
|
-
if (cur && cur.session_id === sessionId) {
|
|
190
|
-
const { session_id: _drop, ...rest } = cur;
|
|
191
|
-
await saveAgentConfig(rest, cfg.system_id);
|
|
192
|
-
}
|
|
193
|
-
})
|
|
194
|
-
.then(() => handle.dispose())
|
|
195
|
-
.catch(() => { });
|
|
196
|
-
writeJson(res, 200, { status: 'delivered', detail: `fresh session ${sessionId}` });
|
|
197
|
-
}
|
|
198
|
-
catch (e) {
|
|
199
|
-
// 503 (not 2xx) so the bridge does NOT ack and will retry; a 200 here
|
|
200
|
-
// would silently swallow the email.
|
|
201
|
-
writeJson(res, 503, { status: 'session_create_failed', detail: e instanceof Error ? e.message : String(e) });
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
catch (e) {
|
|
205
|
-
writeJson(res, 500, { status: 'error', detail: e instanceof Error ? e.message : String(e) });
|
|
206
|
-
}
|
|
207
|
-
});
|
|
280
|
+
const server = createServer(createInboundHandler(ctx, mail, deliverPath));
|
|
281
|
+
// Pull loops (agent-scope bindings only). Held here so the fiber's dispose
|
|
282
|
+
// stops them with the listener — the host lifecycle owns both.
|
|
283
|
+
let pullHandles = [];
|
|
208
284
|
server.listen(port, host, () => {
|
|
209
285
|
// Route side (owner ruling 2026-09-27): the listener is up, so this is the
|
|
210
286
|
// moment to (re-)pair every address of this system. The bridge deletes
|
|
@@ -226,8 +302,20 @@ export function apply(ctx, config = {}) {
|
|
|
226
302
|
.catch((e) => {
|
|
227
303
|
console.warn(`[dsh-aimail] route ensure failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
228
304
|
});
|
|
305
|
+
// Pull entry: inbound is live ⇒ the pulled mail can enter the same chain.
|
|
306
|
+
// Only agent-scope (address-code) bindings arm a loop (mail-core decides).
|
|
307
|
+
void startInboundPull(ctx, { log: (line) => console.log(line) })
|
|
308
|
+
.then((handles) => {
|
|
309
|
+
pullHandles = handles;
|
|
310
|
+
})
|
|
311
|
+
.catch((e) => {
|
|
312
|
+
console.warn(`[dsh-aimail] pull entry failed to start: ${e instanceof Error ? e.message : String(e)}`);
|
|
313
|
+
});
|
|
229
314
|
});
|
|
230
315
|
return () => {
|
|
316
|
+
for (const h of pullHandles)
|
|
317
|
+
h.stop();
|
|
318
|
+
pullHandles = [];
|
|
231
319
|
server.close();
|
|
232
320
|
};
|
|
233
321
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-aimail",
|
|
3
3
|
"description": "AIMail plugin for dsh — install via: dsh plugin --profile web add dsh-aimail",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.25",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
7
7
|
"provenance": true
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@aimail/mail-core": "^0.1.
|
|
55
|
-
"@aimail/mail": "^0.1.
|
|
54
|
+
"@aimail/mail-core": "^0.1.25",
|
|
55
|
+
"@aimail/mail": "^0.1.25"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@deepseek-ai/cordis": "^4.0.1",
|