dsh-aimail 0.1.23 → 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 +223 -135
- package/lib/mail-service.js +5 -6
- package/lib/register-cli.js +2 -2
- package/package.json +3 -3
- package/resources/skills/SKILL.md +6 -6
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 } 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,44 +42,123 @@ function readBody(req) {
|
|
|
36
42
|
req.on('error', reject);
|
|
37
43
|
});
|
|
38
44
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
}
|
|
43
69
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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();
|
|
49
129
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
continue;
|
|
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);
|
|
64
143
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
144
|
+
})
|
|
145
|
+
.then(() => handle.dispose())
|
|
146
|
+
.catch(() => { });
|
|
147
|
+
return { status: 'delivered', detail: `fresh session ${sessionId}`, ok: true };
|
|
69
148
|
}
|
|
70
|
-
catch {
|
|
71
|
-
//
|
|
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
|
+
};
|
|
72
157
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
158
|
+
}
|
|
159
|
+
/** The push handler (HTTP route) around the shared chain. */
|
|
160
|
+
export function createInboundHandler(ctx, mail, deliverPath) {
|
|
161
|
+
return async (req, res) => {
|
|
77
162
|
try {
|
|
78
163
|
if (req.method !== 'POST' || (req.url ?? '').split('?')[0] !== deliverPath) {
|
|
79
164
|
writeJson(res, 404, { status: 'not_found' });
|
|
@@ -88,123 +173,114 @@ export function apply(ctx, config = {}) {
|
|
|
88
173
|
writeJson(res, 400, { status: 'bad_json' });
|
|
89
174
|
return;
|
|
90
175
|
}
|
|
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
176
|
const headers = {
|
|
98
177
|
...req.headers,
|
|
99
178
|
...(payload.headers ?? {}),
|
|
100
179
|
};
|
|
101
|
-
const
|
|
102
|
-
|
|
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) {
|
|
180
|
+
const target = await resolveInboundTarget(mail, payload, headers);
|
|
181
|
+
if (!target) {
|
|
118
182
|
writeJson(res, 200, { status: 'no_agent', detail: 'no binding' });
|
|
119
183
|
return;
|
|
120
184
|
}
|
|
121
|
-
// HMAC verify (per-address webhook_secret)
|
|
185
|
+
// HMAC verify (per-address webhook_secret) — push-only trust step; pulled
|
|
186
|
+
// mail is authenticated by the gateway on the agent-scope key.
|
|
122
187
|
const sig = req.headers['x-webhook-signature'] ?? '';
|
|
123
|
-
if (!verifySignature(rawBody, sig, cfg.webhook_secret ?? '')) {
|
|
188
|
+
if (!verifySignature(rawBody, sig, target.cfg.webhook_secret ?? '')) {
|
|
124
189
|
writeJson(res, 401, { status: 'bad_signature' });
|
|
125
190
|
return;
|
|
126
191
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
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
|
-
}
|
|
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);
|
|
203
198
|
}
|
|
204
199
|
catch (e) {
|
|
205
200
|
writeJson(res, 500, { status: 'error', detail: e instanceof Error ? e.message : String(e) });
|
|
206
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
|
+
},
|
|
207
241
|
});
|
|
242
|
+
}
|
|
243
|
+
export function apply(ctx, config = {}) {
|
|
244
|
+
const mail = ctx.get('mail');
|
|
245
|
+
if (mail === undefined) {
|
|
246
|
+
throw new Error('mail-inbound requires the mail service: mount dsh-aimail/mail-service first');
|
|
247
|
+
}
|
|
248
|
+
// SDK-shipped skill → <dshHome>/skills/agentmail/ (idempotent;
|
|
249
|
+
// identical-content skip; dshHome resolution mirrors mail-service).
|
|
250
|
+
// SKILL.md owns the inbound-message protocol (6-step flow) — a
|
|
251
|
+
// different category from tool registration (tool usage). Symmetric
|
|
252
|
+
// across openclaw/dsh/pi.
|
|
253
|
+
try {
|
|
254
|
+
const dshHome = process.env.AIMAIL_SYSTEM_HOME?.trim() ||
|
|
255
|
+
process.env.DSH_HOME?.trim() ||
|
|
256
|
+
path.join(os.homedir(), '.dsh');
|
|
257
|
+
const skillSrc = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'resources', 'skills');
|
|
258
|
+
const skillDst = path.join(dshHome, 'skills', 'agentmail');
|
|
259
|
+
fs.mkdirSync(skillDst, { recursive: true });
|
|
260
|
+
for (const f of ['SKILL.md', 'DESCRIPTION.md']) {
|
|
261
|
+
const from = path.join(skillSrc, f);
|
|
262
|
+
const to = path.join(skillDst, f);
|
|
263
|
+
if (!fs.existsSync(from)) {
|
|
264
|
+
// missing package resources = packaging/materialize defect: loud but must not block inbound handling
|
|
265
|
+
console.error(`[dsh-aimail] skill resource missing: ${from} ` +
|
|
266
|
+
'(repo: run scripts/materialize-resources.sh; installed: reinstall the package)');
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (fs.existsSync(to) && fs.readFileSync(from).equals(fs.readFileSync(to)))
|
|
270
|
+
continue;
|
|
271
|
+
fs.copyFileSync(from, to);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// non-fatal; retried on next plugin start
|
|
276
|
+
}
|
|
277
|
+
const host = config.host ?? '127.0.0.1';
|
|
278
|
+
const port = config.port ?? Number(process.env.AIMAIL_INBOUND_PORT ?? INBOUND_PORTS.dsh);
|
|
279
|
+
const deliverPath = config.path ?? INBOUND_PATH;
|
|
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/lib/mail-service.js
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import { resolveByRecipient, resolveByEmail, resolveBySessionId, } from '@aimail/mail';
|
|
2
|
-
import { autoBind, emailForAgent, ensureSystem, detectSystemForHome, hasAnySystem, listSystemDirs, readSystemConfig, releaseAllSystems, } from '@aimail/mail-core';
|
|
2
|
+
import { autoBind, emailForAgent, ensureSystem, detectSystemForHome, hasAnySystem, INBOUND_PATH, INBOUND_PORTS, listSystemDirs, readSystemConfig, releaseAllSystems, } from '@aimail/mail-core';
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
export const name = 'mail';
|
|
7
7
|
export const inject = [];
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
/** The local receive endpoint registered as this session's webhook_url. */
|
|
8
|
+
/** The local receive endpoint registered as this session's webhook_url.
|
|
9
|
+
* 路径/端口契约常量唯一副本 = @aimail/mail-core(contract/aimail-contract.json)。 */
|
|
11
10
|
function inboundWebhookUrl() {
|
|
12
11
|
const fromEnv = (process.env.AIMAIL_INBOUND_URL ?? '').trim();
|
|
13
12
|
if (fromEnv)
|
|
14
13
|
return fromEnv.replace(/\/+$/, '') + INBOUND_PATH;
|
|
15
|
-
const port = Number(process.env.AIMAIL_INBOUND_PORT ??
|
|
16
|
-
const p = Number.isInteger(port) && port > 0 ? port :
|
|
14
|
+
const port = Number(process.env.AIMAIL_INBOUND_PORT ?? INBOUND_PORTS.dsh);
|
|
15
|
+
const p = Number.isInteger(port) && port > 0 ? port : INBOUND_PORTS.dsh;
|
|
17
16
|
return `http://127.0.0.1:${p}${INBOUND_PATH}`;
|
|
18
17
|
}
|
|
19
18
|
/** Process once-guard per (system, session): at most one auto-bind attempt. */
|
package/lib/register-cli.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* [--force]
|
|
22
22
|
*/
|
|
23
23
|
import { randomUUID } from 'node:crypto';
|
|
24
|
-
import { autoBind, emailForAgent, listSystemDirs, loadAgentConfig, readSystemConfig, registerAddress, registerBridgeRoute, resolveRegisterWebhook, saveBinding, } from '@aimail/mail-core';
|
|
24
|
+
import { autoBind, emailForAgent, inboundUrl, INBOUND_PORTS, listSystemDirs, loadAgentConfig, readSystemConfig, registerAddress, registerBridgeRoute, resolveRegisterWebhook, saveBinding, } from '@aimail/mail-core';
|
|
25
25
|
function arg(argv, name) {
|
|
26
26
|
const i = argv.indexOf(name);
|
|
27
27
|
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : '';
|
|
@@ -36,7 +36,7 @@ async function main() {
|
|
|
36
36
|
const manager = arg(argv, '--manager');
|
|
37
37
|
const sessionId = arg(argv, '--session-id');
|
|
38
38
|
const preset = arg(argv, '--preset') || 'mail';
|
|
39
|
-
const localWebhook = arg(argv, '--local-webhook') ||
|
|
39
|
+
const localWebhook = arg(argv, '--local-webhook') || inboundUrl(INBOUND_PORTS.dsh);
|
|
40
40
|
const force = flag(argv, '--force');
|
|
41
41
|
const systemId = systemIdArg ||
|
|
42
42
|
process.env.AIMAIL_SYSTEM_ID ||
|
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",
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
2
|
+
name: agentmail
|
|
3
3
|
description: "Send outbound emails for reporting deliverables, updating project status, requesting decisions/approvals, or engaging in A2A collaboration. Also reply to or forward inbound emails from other agents or humans."
|
|
4
4
|
version: 1.0.0
|
|
5
5
|
author: MeterCai
|
|
6
6
|
license: GPL-3.0
|
|
7
7
|
metadata:
|
|
8
8
|
hermes:
|
|
9
|
-
tags: [email, mail,
|
|
10
|
-
toolset:
|
|
9
|
+
tags: [email, mail, agentmail, conversation]
|
|
10
|
+
toolset: agentmail
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
#
|
|
13
|
+
# agentmail — Email Conversation Agent
|
|
14
14
|
|
|
15
|
-
Your email: **{profile_name}@{domain}**. You conduct conversations via email — replying or forwarding to incoming messages to continue the dialogue, and proactively sending outbound ones for deliverables, status updates, approval requests, or A2A collaboration. The
|
|
15
|
+
Your email: **{profile_name}@{domain}**. You conduct conversations via email — replying or forwarding to incoming messages to continue the dialogue, and proactively sending outbound ones for deliverables, status updates, approval requests, or A2A collaboration. The agentmail toolset handles delivery, contacts, and summaries; you focus on understanding, deciding, and composing.
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
@@ -221,7 +221,7 @@ set_contact_profile(address="alice@example.com",
|
|
|
221
221
|
|
|
222
222
|
## Tools
|
|
223
223
|
|
|
224
|
-
The
|
|
224
|
+
The agentmail tools are registered with full schemas — parameter names, types, and descriptions are visible to you automatically. This table is a quick reference:
|
|
225
225
|
|
|
226
226
|
| Tool | Use |
|
|
227
227
|
|------|-----|
|