@agenticmail/api 0.9.27 → 0.9.29
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 +11 -0
- package/dist/index.js +46 -7
- package/package.json +1 -1
- package/public/js/message-view.js +30 -8
- package/public/styles.css +30 -3
package/README.md
CHANGED
|
@@ -408,6 +408,17 @@ JSON parse errors (malformed request bodies) return a clear 400 error rather tha
|
|
|
408
408
|
|
|
409
409
|
---
|
|
410
410
|
|
|
411
|
+
## External inbox exposure — what `/gateway/relay` actually opens up
|
|
412
|
+
|
|
413
|
+
> **The `POST /gateway/relay` endpoint (the one `agenticmail setup-email` calls) makes every sub-agent publicly reachable from the internet via plus-addressing.** This is by design — agents that can only email each other aren't very useful for talking to real people — but the implications surprise some operators:
|
|
414
|
+
|
|
415
|
+
- **Plus-addresses are publicly guessable.** Once relay is connected, anyone can hit `your-relay+secretary@gmail.com`, `your-relay+kepler@gmail.com`, etc. and the corresponding agent's AgenticMail inbox receives the message. The `+sub` part is not a secret.
|
|
416
|
+
- **External mail wakes the dispatcher identically to internal `@localhost` mail.** The API publishes the same SSE `new-mail` event regardless of source; the host integration (`@agenticmail/claudecode`, `@agenticmail/codex`) spawns a worker turn either way.
|
|
417
|
+
- **The host bridges take a different path.** Mail to `your-relay+claudecode@gmail.com` / `your-relay+codex@gmail.com` routes to `handleBridgeMail` in the dispatcher, which uses the host SDK's `resume` option to wake the operator's last session headlessly. If that fails it falls through to the bridge-escalation email configured via `setup_operator_email`.
|
|
418
|
+
- **Spam = worker turns.** Throttles in order of escalation: the `wake-budget` guard in `dispatcher.handleEvent` (automatic, default cap per minute per agent), the built-in relay-level spam filter (runs before publishing the SSE event), and `metadata.host`-based fencing for agents that should stay internal-only.
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
411
422
|
## License
|
|
412
423
|
|
|
413
424
|
[MIT](./LICENSE) - Ope Olatunji ([@ope-olatunji](https://github.com/ope-olatunji))
|
package/dist/index.js
CHANGED
|
@@ -1956,18 +1956,45 @@ function normalizeWakeList(value) {
|
|
|
1956
1956
|
return void 0;
|
|
1957
1957
|
}
|
|
1958
1958
|
function deriveDefaultWakeList(toField) {
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1959
|
+
const localNames = extractLocalNames(toField);
|
|
1960
|
+
return localNames.length > 0 ? localNames : void 0;
|
|
1961
|
+
}
|
|
1962
|
+
function extractLocalNames(field) {
|
|
1963
|
+
if (!field) return [];
|
|
1964
|
+
const arr = Array.isArray(field) ? field : String(field).split(",");
|
|
1965
|
+
const out = [];
|
|
1962
1966
|
for (const raw of arr) {
|
|
1963
1967
|
const trimmed = String(raw).slice(0, 500).trim().toLowerCase();
|
|
1964
1968
|
const m = trimmed.match(/<([^>]+)>/);
|
|
1965
1969
|
const bare = (m ? m[1] : trimmed).trim();
|
|
1966
1970
|
if (!bare.endsWith("@localhost")) continue;
|
|
1967
1971
|
const name = bare.replace(/@localhost$/i, "");
|
|
1968
|
-
if (name)
|
|
1972
|
+
if (name) out.push(name);
|
|
1969
1973
|
}
|
|
1970
|
-
return
|
|
1974
|
+
return out;
|
|
1975
|
+
}
|
|
1976
|
+
function deriveWakeFromBody(body, candidateNames) {
|
|
1977
|
+
if (!body || candidateNames.length === 0) return [];
|
|
1978
|
+
const sample = body.length > 2e4 ? body.slice(0, 2e4) : body;
|
|
1979
|
+
const found = /* @__PURE__ */ new Set();
|
|
1980
|
+
for (const raw of candidateNames) {
|
|
1981
|
+
const name = String(raw).trim().toLowerCase();
|
|
1982
|
+
if (!name) continue;
|
|
1983
|
+
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1984
|
+
const patterns = [
|
|
1985
|
+
// Greeting / handoff anchors:
|
|
1986
|
+
// "Marlow —" "Marlow:" "Marlow,"
|
|
1987
|
+
new RegExp(`(?:^|[\\n.])\\s*${esc}\\s*[\u2014\u2013\\-:,]`, "i"),
|
|
1988
|
+
// Mention syntax:
|
|
1989
|
+
// "@marlow"
|
|
1990
|
+
new RegExp(`@${esc}\\b`, "i"),
|
|
1991
|
+
// Conversational handoff phrases:
|
|
1992
|
+
// "over to marlow" "handing off to marlow" "next up: marlow"
|
|
1993
|
+
new RegExp(`\\b(?:hi|hey|hello|over to|hand(?:ing)? off to|dispatch(?:ing)? to|assigning to|next up:?|next slice:?)\\s+${esc}\\b`, "i")
|
|
1994
|
+
];
|
|
1995
|
+
if (patterns.some((p) => p.test(sample))) found.add(name);
|
|
1996
|
+
}
|
|
1997
|
+
return Array.from(found);
|
|
1971
1998
|
}
|
|
1972
1999
|
function wakeHeaders(wakeList) {
|
|
1973
2000
|
if (wakeList === void 0) return {};
|
|
@@ -2113,7 +2140,13 @@ function createMailRoutes(accountManager, config, db, gatewayManager) {
|
|
|
2113
2140
|
const ownerName2 = agent.metadata?.ownerName;
|
|
2114
2141
|
const fromName2 = ownerName2 ? `${agent.name} from ${ownerName2}` : agent.name;
|
|
2115
2142
|
const explicitWakeForPersist = normalizeWakeList(wake);
|
|
2116
|
-
|
|
2143
|
+
let wakeListForPersist;
|
|
2144
|
+
if (wake !== void 0) {
|
|
2145
|
+
wakeListForPersist = explicitWakeForPersist;
|
|
2146
|
+
} else {
|
|
2147
|
+
const bodyDerived = deriveWakeFromBody(typeof text === "string" ? text : "", extractLocalNames(cc));
|
|
2148
|
+
wakeListForPersist = bodyDerived.length > 0 ? bodyDerived : deriveDefaultWakeList(to);
|
|
2149
|
+
}
|
|
2117
2150
|
const mailOptions = {
|
|
2118
2151
|
to,
|
|
2119
2152
|
subject,
|
|
@@ -2197,7 +2230,13 @@ function createMailRoutes(accountManager, config, db, gatewayManager) {
|
|
|
2197
2230
|
const ownerName = agent.metadata?.ownerName;
|
|
2198
2231
|
const fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
|
|
2199
2232
|
const explicitWake = normalizeWakeList(wake);
|
|
2200
|
-
|
|
2233
|
+
let wakeList;
|
|
2234
|
+
if (wake !== void 0) {
|
|
2235
|
+
wakeList = explicitWake;
|
|
2236
|
+
} else {
|
|
2237
|
+
const bodyDerived = deriveWakeFromBody(typeof text === "string" ? text : "", extractLocalNames(cc));
|
|
2238
|
+
wakeList = bodyDerived.length > 0 ? bodyDerived : deriveDefaultWakeList(to);
|
|
2239
|
+
}
|
|
2201
2240
|
const customHeaders = wakeHeaders(wakeList);
|
|
2202
2241
|
const mailOpts = {
|
|
2203
2242
|
to,
|
package/package.json
CHANGED
|
@@ -324,6 +324,17 @@ function renderBodyWithThreading(src, audienceLookup = null) {
|
|
|
324
324
|
i++;
|
|
325
325
|
continue;
|
|
326
326
|
}
|
|
327
|
+
// Strip any trailing blank lines from the prose buffer before
|
|
328
|
+
// flushing. Reply builders typically pad the new content with
|
|
329
|
+
// `\n\n` before the `On <date>, <addr> wrote:` header so the
|
|
330
|
+
// quote stands apart in plaintext clients. In the web view those
|
|
331
|
+
// blank lines render as empty <p> blocks with their own margins,
|
|
332
|
+
// producing the huge gap operators flagged between the latest
|
|
333
|
+
// message and the quoted thread. Trimming here keeps plain-text
|
|
334
|
+
// readers happy and tightens the rendered HTML at the same time.
|
|
335
|
+
while (prose.length > 0 && prose[prose.length - 1].trim() === '') {
|
|
336
|
+
prose.pop();
|
|
337
|
+
}
|
|
327
338
|
flushProse();
|
|
328
339
|
const dateRaw = m[1];
|
|
329
340
|
const sender = m[2];
|
|
@@ -361,16 +372,27 @@ function renderBodyWithThreading(src, audienceLookup = null) {
|
|
|
361
372
|
}
|
|
362
373
|
break;
|
|
363
374
|
}
|
|
364
|
-
// Fall back to cross-message lookup
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
375
|
+
// Fall back to cross-message lookup to fill ANY missing audience
|
|
376
|
+
// field — not just the all-empty case.
|
|
377
|
+
//
|
|
378
|
+
// The previous gate (`!toAddrs && !ccAddrs && !bccAddrs`) was wrong:
|
|
379
|
+
// it treated a body that had a `To:` line but no `Cc:` line as
|
|
380
|
+
// "audience encoded, lookup not needed", silently dropping the Cc
|
|
381
|
+
// info that state.messages had. This surfaced as quoted blocks
|
|
382
|
+
// showing only `TO:` even when the original message had Cc'd
|
|
383
|
+
// multiple agents — operators correctly flagged this as "the
|
|
384
|
+
// earlier email's CCs aren't being shown".
|
|
385
|
+
//
|
|
386
|
+
// Now: run the lookup whenever any field is missing, and only
|
|
387
|
+
// backfill the fields that weren't already in the body. The
|
|
388
|
+
// body-encoded value always wins (it's authoritative); the
|
|
389
|
+
// lookup is purely a backfill.
|
|
390
|
+
if ((!toAddrs || !ccAddrs || !bccAddrs) && typeof audienceLookup === 'function') {
|
|
369
391
|
const fromLookup = audienceLookup(sender, dateRaw);
|
|
370
392
|
if (fromLookup) {
|
|
371
|
-
toAddrs = fromLookup.to;
|
|
372
|
-
ccAddrs = fromLookup.cc;
|
|
373
|
-
bccAddrs = fromLookup.bcc;
|
|
393
|
+
if (!toAddrs) toAddrs = fromLookup.to || '';
|
|
394
|
+
if (!ccAddrs) ccAddrs = fromLookup.cc || '';
|
|
395
|
+
if (!bccAddrs) bccAddrs = fromLookup.bcc || '';
|
|
374
396
|
}
|
|
375
397
|
}
|
|
376
398
|
out.push(renderThreadQuote(dateRaw, sender, quoted.join('\n'), { to: toAddrs, cc: ccAddrs, bcc: bccAddrs }, audienceLookup));
|
package/public/styles.css
CHANGED
|
@@ -1019,14 +1019,41 @@ mark.search-hl {
|
|
|
1019
1019
|
styled card with proper avatar + name + friendly date instead
|
|
1020
1020
|
of leaving the raw ISO timestamp visible. Nested replies recurse
|
|
1021
1021
|
so a 3-deep thread gets 3 nested cards. */
|
|
1022
|
+
/* Thread-quote: the boxed-up older email a reply quotes. Two issues
|
|
1023
|
+
* users flagged on the rendered view before:
|
|
1024
|
+
*
|
|
1025
|
+
* 1. Huge vertical gap before the quote — was 16px margin-top here
|
|
1026
|
+
* PLUS empty <p> blocks from trailing newlines in the parent
|
|
1027
|
+
* message body (now also fixed in message-view.js by trimming
|
|
1028
|
+
* trailing whitespace before the quote). Dropped to a tight 4px
|
|
1029
|
+
* since the horizontal divider below carries the separation.
|
|
1030
|
+
* 2. The pink left rule looked like a stub. We add a full-width
|
|
1031
|
+
* hairline divider on ::before so the visual break between the
|
|
1032
|
+
* parent message and the quote is unambiguous, and pin the rule
|
|
1033
|
+
* so it reads as a thread spine continuing down through the
|
|
1034
|
+
* whole quoted block.
|
|
1035
|
+
*
|
|
1036
|
+
* Nested quotes (replies-of-replies) get progressively warmer rule
|
|
1037
|
+
* colours (pink → purple → amber) so the depth is visible at a
|
|
1038
|
+
* glance without staring at indentation. */
|
|
1022
1039
|
.thread-quote {
|
|
1023
|
-
|
|
1040
|
+
position: relative;
|
|
1041
|
+
margin: 4px 0 8px;
|
|
1042
|
+
padding: 14px 0 4px 16px;
|
|
1024
1043
|
border-left: 3px solid var(--pink-rule);
|
|
1025
|
-
|
|
1044
|
+
}
|
|
1045
|
+
.thread-quote::before {
|
|
1046
|
+
content: '';
|
|
1047
|
+
position: absolute;
|
|
1048
|
+
top: 0;
|
|
1049
|
+
left: -3px;
|
|
1050
|
+
right: 0;
|
|
1051
|
+
height: 1px;
|
|
1052
|
+
background: var(--line, rgba(255,255,255,0.08));
|
|
1026
1053
|
}
|
|
1027
1054
|
.thread-quote .thread-quote {
|
|
1028
1055
|
border-left-color: #c084fc;
|
|
1029
|
-
margin:
|
|
1056
|
+
margin: 4px 0 8px;
|
|
1030
1057
|
}
|
|
1031
1058
|
.thread-quote .thread-quote .thread-quote { border-left-color: #f59e0b; }
|
|
1032
1059
|
.thread-quote-head {
|