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 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(async (req, res) => {
77
- try {
78
- if (req.method !== 'POST' || (req.url ?? '').split('?')[0] !== deliverPath) {
79
- writeJson(res, 404, { status: 'not_found' });
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.24",
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.24",
55
- "@aimail/mail": "^0.1.24"
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",