agent-inbox 0.2.2 → 0.2.4
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/AGENTS.md +18 -0
- package/CLAUDE.md +92 -1
- package/README.md +73 -6
- package/bench/inbox-growth.bench.ts +224 -0
- package/dist/federation/connection-manager.d.ts +8 -0
- package/dist/federation/connection-manager.d.ts.map +1 -1
- package/dist/federation/connection-manager.js +12 -0
- package/dist/federation/connection-manager.js.map +1 -1
- package/dist/federation/delivery-queue.d.ts +11 -3
- package/dist/federation/delivery-queue.d.ts.map +1 -1
- package/dist/federation/delivery-queue.js +38 -8
- package/dist/federation/delivery-queue.js.map +1 -1
- package/dist/federation/queue-store.d.ts +42 -0
- package/dist/federation/queue-store.d.ts.map +1 -0
- package/dist/federation/queue-store.js +87 -0
- package/dist/federation/queue-store.js.map +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +124 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -0
- package/dist/index.mjs.map +1 -0
- package/dist/jsonrpc/mail-push-types.d.ts +9 -0
- package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
- package/dist/jsonrpc/mail-push-types.js +1 -0
- package/dist/jsonrpc/mail-push-types.js.map +1 -1
- package/dist/jsonrpc/mail-server.d.ts +8 -1
- package/dist/jsonrpc/mail-server.d.ts.map +1 -1
- package/dist/jsonrpc/mail-server.js +42 -1
- package/dist/jsonrpc/mail-server.js.map +1 -1
- package/dist/mail/address-book.d.ts +43 -0
- package/dist/mail/address-book.d.ts.map +1 -0
- package/dist/mail/address-book.js +95 -0
- package/dist/mail/address-book.js.map +1 -0
- package/dist/mail/attachment-store.d.ts +31 -0
- package/dist/mail/attachment-store.d.ts.map +1 -0
- package/dist/mail/attachment-store.js +74 -0
- package/dist/mail/attachment-store.js.map +1 -0
- package/dist/mail/email-mapper.d.ts +41 -0
- package/dist/mail/email-mapper.d.ts.map +1 -0
- package/dist/mail/email-mapper.js +216 -0
- package/dist/mail/email-mapper.js.map +1 -0
- package/dist/mail/fs-attachment-store.d.ts +38 -0
- package/dist/mail/fs-attachment-store.d.ts.map +1 -0
- package/dist/mail/fs-attachment-store.js +165 -0
- package/dist/mail/fs-attachment-store.js.map +1 -0
- package/dist/mail/mail-gateway.d.ts +114 -0
- package/dist/mail/mail-gateway.d.ts.map +1 -0
- package/dist/mail/mail-gateway.js +402 -0
- package/dist/mail/mail-gateway.js.map +1 -0
- package/dist/mail/provider-transport.d.ts +138 -0
- package/dist/mail/provider-transport.d.ts.map +1 -0
- package/dist/mail/provider-transport.js +434 -0
- package/dist/mail/provider-transport.js.map +1 -0
- package/dist/mail/rate-limiter.d.ts +20 -0
- package/dist/mail/rate-limiter.d.ts.map +1 -0
- package/dist/mail/rate-limiter.js +56 -0
- package/dist/mail/rate-limiter.js.map +1 -0
- package/dist/mail/smtp-transport.d.ts +141 -0
- package/dist/mail/smtp-transport.d.ts.map +1 -0
- package/dist/mail/smtp-transport.js +415 -0
- package/dist/mail/smtp-transport.js.map +1 -0
- package/dist/mail/types.d.ts +177 -0
- package/dist/mail/types.d.ts.map +1 -0
- package/dist/mail/types.js +11 -0
- package/dist/mail/types.js.map +1 -0
- package/dist/push/notifier.d.ts +21 -0
- package/dist/push/notifier.d.ts.map +1 -1
- package/dist/push/notifier.js +84 -2
- package/dist/push/notifier.js.map +1 -1
- package/dist/router/destination.d.ts +69 -0
- package/dist/router/destination.d.ts.map +1 -0
- package/dist/router/destination.js +106 -0
- package/dist/router/destination.js.map +1 -0
- package/dist/router/message-router.d.ts +15 -0
- package/dist/router/message-router.d.ts.map +1 -1
- package/dist/router/message-router.js +25 -3
- package/dist/router/message-router.js.map +1 -1
- package/dist/storage/interface.d.ts +21 -0
- package/dist/storage/interface.d.ts.map +1 -1
- package/dist/storage/memory.d.ts +12 -0
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +50 -0
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite.d.ts +14 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +79 -1
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/traceability/traceability.d.ts.map +1 -1
- package/dist/traceability/traceability.js +7 -17
- package/dist/traceability/traceability.js.map +1 -1
- package/dist/types.d.ts +80 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/DESIGN.md +15 -0
- package/docs/MAIL-INTEROP-PLAN.md +660 -0
- package/package.json +29 -3
- package/renovate.json5 +6 -0
- package/rules/agent-inbox.md +1 -0
- package/src/federation/connection-manager.ts +12 -0
- package/src/federation/delivery-queue.ts +38 -8
- package/src/federation/queue-store.ts +124 -0
- package/src/index.ts +186 -1
- package/src/jsonrpc/mail-push-types.ts +10 -0
- package/src/jsonrpc/mail-server.ts +48 -1
- package/src/mail/address-book.ts +111 -0
- package/src/mail/attachment-store.ts +90 -0
- package/src/mail/email-mapper.ts +288 -0
- package/src/mail/fs-attachment-store.ts +163 -0
- package/src/mail/mail-gateway.ts +505 -0
- package/src/mail/provider-transport.ts +577 -0
- package/src/mail/rate-limiter.ts +51 -0
- package/src/mail/smtp-transport.ts +589 -0
- package/src/mail/types.ts +221 -0
- package/src/push/notifier.ts +98 -2
- package/src/router/destination.ts +140 -0
- package/src/router/message-router.ts +41 -4
- package/src/storage/interface.ts +22 -0
- package/src/storage/memory.ts +59 -0
- package/src/storage/sqlite.ts +114 -1
- package/src/traceability/traceability.ts +7 -16
- package/src/types.ts +74 -0
- package/test/federation/delivery-queue-sqlite.test.ts +158 -0
- package/test/load.test.ts +288 -0
- package/test/mail/address-book.test.ts +111 -0
- package/test/mail/attachment-store-contract.test.ts +92 -0
- package/test/mail/attachment-store.test.ts +69 -0
- package/test/mail/destination.test.ts +115 -0
- package/test/mail/dsn-parse.test.ts +239 -0
- package/test/mail/email-mapper.test.ts +341 -0
- package/test/mail/external-id.test.ts +43 -0
- package/test/mail/fs-attachment-store.test.ts +134 -0
- package/test/mail/full-flow-e2e.test.ts +200 -0
- package/test/mail/mail-gateway.test.ts +419 -0
- package/test/mail/mail-transport-contract.test.ts +134 -0
- package/test/mail/mock-mail.ts +161 -0
- package/test/mail/mock-postmark.ts +66 -0
- package/test/mail/provider-transport.test.ts +381 -0
- package/test/mail/rate-limiter.test.ts +48 -0
- package/test/mail/router-mail-integration.test.ts +138 -0
- package/test/mail/smtp-e2e.test.ts +98 -0
- package/test/mail/smtp-transport.test.ts +138 -0
- package/test/mail-presence.test.ts +149 -0
- package/test/mail-push.test.ts +44 -0
- package/test/mail-server.test.ts +25 -0
- package/test/push-notifier.test.ts +81 -0
- package/test/sqlite-storage.test.ts +106 -0
- package/test/storage.test.ts +92 -0
- package/vitest.bench.config.ts +8 -0
package/src/storage/sqlite.ts
CHANGED
|
@@ -36,6 +36,8 @@ export class SqliteStorage implements Storage {
|
|
|
36
36
|
this.db = new Database(path);
|
|
37
37
|
this.db.pragma("journal_mode = WAL");
|
|
38
38
|
this.db.pragma("foreign_keys = ON");
|
|
39
|
+
// Bound the WAL file: checkpoint after ~1000 dirty pages (~4 MiB).
|
|
40
|
+
this.db.pragma("wal_autocheckpoint = 1000");
|
|
39
41
|
this.externalDb = false;
|
|
40
42
|
}
|
|
41
43
|
|
|
@@ -126,6 +128,13 @@ export class SqliteStorage implements Storage {
|
|
|
126
128
|
FOREIGN KEY (conversation_id) REFERENCES ${t("conversations")}(id)
|
|
127
129
|
);
|
|
128
130
|
|
|
131
|
+
-- External-id mapping (mail dedup + bounce correlation)
|
|
132
|
+
CREATE TABLE IF NOT EXISTS ${t("external_ids")} (
|
|
133
|
+
external_id TEXT PRIMARY KEY,
|
|
134
|
+
message_id TEXT NOT NULL,
|
|
135
|
+
recorded_at TEXT NOT NULL
|
|
136
|
+
);
|
|
137
|
+
|
|
129
138
|
-- Indexes for common queries
|
|
130
139
|
CREATE INDEX IF NOT EXISTS idx_${t("messages")}_scope ON ${t("messages")}(scope);
|
|
131
140
|
CREATE INDEX IF NOT EXISTS idx_${t("messages")}_sender ON ${t("messages")}(sender_id);
|
|
@@ -162,7 +171,11 @@ export class SqliteStorage implements Storage {
|
|
|
162
171
|
ELSE '' END);
|
|
163
172
|
END;
|
|
164
173
|
|
|
165
|
-
|
|
174
|
+
-- Drop legacy unscoped trigger if present, so we re-create it with OF clause
|
|
175
|
+
DROP TRIGGER IF EXISTS ${t("messages_au")};
|
|
176
|
+
|
|
177
|
+
CREATE TRIGGER IF NOT EXISTS ${t("messages_au")}
|
|
178
|
+
AFTER UPDATE OF subject, content ON ${t("messages")} BEGIN
|
|
166
179
|
INSERT INTO ${t("messages_fts")}(${t("messages_fts")}, rowid, id, subject, text_content)
|
|
167
180
|
VALUES ('delete', OLD.rowid, OLD.id, OLD.subject,
|
|
168
181
|
CASE WHEN json_extract(OLD.content, '$.type') = 'text'
|
|
@@ -177,6 +190,12 @@ export class SqliteStorage implements Storage {
|
|
|
177
190
|
`);
|
|
178
191
|
}
|
|
179
192
|
|
|
193
|
+
/** Expose the underlying handle so sibling features (mail queue/attachments)
|
|
194
|
+
* can co-locate their tables. Caller must not close it. */
|
|
195
|
+
getDatabase(): Database.Database {
|
|
196
|
+
return this.db;
|
|
197
|
+
}
|
|
198
|
+
|
|
180
199
|
// --- Agents ---
|
|
181
200
|
|
|
182
201
|
getAgent(agentId: string): Agent | undefined {
|
|
@@ -277,6 +296,75 @@ export class SqliteStorage implements Storage {
|
|
|
277
296
|
return message;
|
|
278
297
|
}
|
|
279
298
|
|
|
299
|
+
setMessageConversationId(messageId: string, conversationId: string): void {
|
|
300
|
+
this.db
|
|
301
|
+
.prepare(
|
|
302
|
+
`UPDATE ${this.p("messages")} SET conversation_id = ? WHERE id = ?`
|
|
303
|
+
)
|
|
304
|
+
.run(conversationId, messageId);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
recordExternalId(externalId: string, messageId: string): void {
|
|
308
|
+
this.db
|
|
309
|
+
.prepare(
|
|
310
|
+
`INSERT INTO ${this.p("external_ids")} (external_id, message_id, recorded_at)
|
|
311
|
+
VALUES (?, ?, ?)
|
|
312
|
+
ON CONFLICT(external_id) DO NOTHING`
|
|
313
|
+
)
|
|
314
|
+
.run(externalId, messageId, new Date().toISOString());
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
getMessageIdByExternalId(externalId: string): string | undefined {
|
|
318
|
+
const row = this.db
|
|
319
|
+
.prepare(
|
|
320
|
+
`SELECT message_id FROM ${this.p("external_ids")} WHERE external_id = ?`
|
|
321
|
+
)
|
|
322
|
+
.get(externalId) as { message_id: string } | undefined;
|
|
323
|
+
return row?.message_id;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
hasSeenExternalId(externalId: string): boolean {
|
|
327
|
+
return this.getMessageIdByExternalId(externalId) !== undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
pruneMessagesOlderThan(cutoff: string): number {
|
|
331
|
+
const m = this.p("messages");
|
|
332
|
+
const r = this.p("recipients");
|
|
333
|
+
const turns = this.p("turns");
|
|
334
|
+
const threads = this.p("threads");
|
|
335
|
+
|
|
336
|
+
return this.db.transaction(() => {
|
|
337
|
+
const ids = (
|
|
338
|
+
this.db
|
|
339
|
+
.prepare(`SELECT id FROM ${m} WHERE created_at < ?`)
|
|
340
|
+
.all(cutoff) as { id: string }[]
|
|
341
|
+
).map((row) => row.id);
|
|
342
|
+
|
|
343
|
+
if (ids.length === 0) return 0;
|
|
344
|
+
|
|
345
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
346
|
+
this.db
|
|
347
|
+
.prepare(`DELETE FROM ${r} WHERE message_id IN (${placeholders})`)
|
|
348
|
+
.run(...ids);
|
|
349
|
+
this.db
|
|
350
|
+
.prepare(
|
|
351
|
+
`DELETE FROM ${turns} WHERE source_message_id IN (${placeholders})`
|
|
352
|
+
)
|
|
353
|
+
.run(...ids);
|
|
354
|
+
// Threads pinned to a pruned root turn are now orphans — drop them.
|
|
355
|
+
this.db
|
|
356
|
+
.prepare(
|
|
357
|
+
`DELETE FROM ${threads} WHERE root_turn_id NOT IN (SELECT id FROM ${turns})`
|
|
358
|
+
)
|
|
359
|
+
.run();
|
|
360
|
+
this.db
|
|
361
|
+
.prepare(`DELETE FROM ${m} WHERE id IN (${placeholders})`)
|
|
362
|
+
.run(...ids);
|
|
363
|
+
|
|
364
|
+
return ids.length;
|
|
365
|
+
})();
|
|
366
|
+
}
|
|
367
|
+
|
|
280
368
|
getInbox(agentId: string, opts?: InboxQuery): Message[] {
|
|
281
369
|
const m = this.p("messages");
|
|
282
370
|
const r = this.p("recipients");
|
|
@@ -414,6 +502,31 @@ export class SqliteStorage implements Storage {
|
|
|
414
502
|
return conversation;
|
|
415
503
|
}
|
|
416
504
|
|
|
505
|
+
touchConversation(conversationId: string, updatedAt: string): void {
|
|
506
|
+
this.db
|
|
507
|
+
.prepare(
|
|
508
|
+
`UPDATE ${this.p("conversations")} SET updated_at = ? WHERE id = ?`
|
|
509
|
+
)
|
|
510
|
+
.run(updatedAt, conversationId);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
addParticipant(
|
|
514
|
+
conversationId: string,
|
|
515
|
+
participant: { agent_id: string; role?: string; joined_at: string }
|
|
516
|
+
): void {
|
|
517
|
+
this.db
|
|
518
|
+
.prepare(
|
|
519
|
+
`INSERT OR IGNORE INTO ${this.p("participants")}
|
|
520
|
+
(conversation_id, agent_id, role, joined_at) VALUES (?, ?, ?, ?)`
|
|
521
|
+
)
|
|
522
|
+
.run(
|
|
523
|
+
conversationId,
|
|
524
|
+
participant.agent_id,
|
|
525
|
+
participant.role ?? null,
|
|
526
|
+
participant.joined_at
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
417
530
|
listConversations(scope?: string): Conversation[] {
|
|
418
531
|
let rows: ConversationRow[];
|
|
419
532
|
if (scope) {
|
|
@@ -30,16 +30,15 @@ export class TraceabilityLayer {
|
|
|
30
30
|
private onMessage(message: Message): void {
|
|
31
31
|
const conversationId = this.resolveConversation(message);
|
|
32
32
|
|
|
33
|
-
// Update message's conversation_id if not already set
|
|
33
|
+
// Update message's conversation_id if not already set. Use a targeted
|
|
34
|
+
// UPDATE rather than re-upserting the whole row + recipients.
|
|
34
35
|
if (!message.conversation_id) {
|
|
35
36
|
message.conversation_id = conversationId;
|
|
36
|
-
this.storage.
|
|
37
|
+
this.storage.setMessageConversationId(message.id, conversationId);
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
// Ensure sender
|
|
40
|
+
// Ensure sender + recipients are participants (no-op if already present)
|
|
40
41
|
this.ensureParticipant(conversationId, message.sender_id);
|
|
41
|
-
|
|
42
|
-
// Ensure recipients are participants
|
|
43
42
|
for (const r of message.recipients) {
|
|
44
43
|
this.ensureParticipant(conversationId, r.agent_id);
|
|
45
44
|
}
|
|
@@ -64,12 +63,8 @@ export class TraceabilityLayer {
|
|
|
64
63
|
|
|
65
64
|
this.storage.addTurn(turn);
|
|
66
65
|
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
if (conv) {
|
|
70
|
-
conv.updated_at = message.created_at;
|
|
71
|
-
this.storage.putConversation(conv);
|
|
72
|
-
}
|
|
66
|
+
// Bump conversation updated_at — single column UPDATE, no participant resync.
|
|
67
|
+
this.storage.touchConversation(conversationId, message.created_at);
|
|
73
68
|
}
|
|
74
69
|
|
|
75
70
|
private resolveConversation(message: Message): string {
|
|
@@ -129,14 +124,10 @@ export class TraceabilityLayer {
|
|
|
129
124
|
}
|
|
130
125
|
|
|
131
126
|
private ensureParticipant(conversationId: string, agentId: string): void {
|
|
132
|
-
|
|
133
|
-
if (!conv) return;
|
|
134
|
-
if (conv.participants.some((p) => p.agent_id === agentId)) return;
|
|
135
|
-
conv.participants.push({
|
|
127
|
+
this.storage.addParticipant(conversationId, {
|
|
136
128
|
agent_id: agentId,
|
|
137
129
|
joined_at: new Date().toISOString(),
|
|
138
130
|
});
|
|
139
|
-
this.storage.putConversation(conv);
|
|
140
131
|
}
|
|
141
132
|
|
|
142
133
|
private resolveThread(
|
package/src/types.ts
CHANGED
|
@@ -83,6 +83,7 @@ export interface Turn {
|
|
|
83
83
|
content: MessageContent;
|
|
84
84
|
thread_id?: string;
|
|
85
85
|
in_reply_to?: string;
|
|
86
|
+
importance?: Importance;
|
|
86
87
|
created_at: string;
|
|
87
88
|
}
|
|
88
89
|
|
|
@@ -210,6 +211,11 @@ export interface FederationPeerConfig {
|
|
|
210
211
|
url?: string;
|
|
211
212
|
/** Mesh peer ID for agentic-mesh transport connections. */
|
|
212
213
|
meshPeerId?: string;
|
|
214
|
+
/**
|
|
215
|
+
* Dotted addresses (e.g. "team.corp.internal") routed to this peer rather
|
|
216
|
+
* than treated as external mail. See destination classification (§1).
|
|
217
|
+
*/
|
|
218
|
+
domains?: string[];
|
|
213
219
|
auth?: FederationAuth;
|
|
214
220
|
exposure?: ExposurePolicy;
|
|
215
221
|
}
|
|
@@ -310,6 +316,73 @@ export interface QueuedMessage {
|
|
|
310
316
|
nextRetry?: string;
|
|
311
317
|
}
|
|
312
318
|
|
|
319
|
+
// --- Mail interop ---
|
|
320
|
+
|
|
321
|
+
/** Maps between agent ids and email addresses; declares owned domains. */
|
|
322
|
+
export interface MailIdentityConfig {
|
|
323
|
+
/** Domains we receive for; inbound RCPT TO outside these is rejected. */
|
|
324
|
+
localDomains: string[];
|
|
325
|
+
/** agent_id ↔ primary email. Plus-addressing (agent+scope@domain) aware. */
|
|
326
|
+
mappings: Array<{ agentId: string; address: string }>;
|
|
327
|
+
/** Default mailbox for accepted-but-unmatched inbound (e.g. a triage agent). */
|
|
328
|
+
catchAllAgentId?: string;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export interface MailConfig {
|
|
332
|
+
enabled: boolean;
|
|
333
|
+
backend: "smtp" | "provider";
|
|
334
|
+
identity: MailIdentityConfig;
|
|
335
|
+
/**
|
|
336
|
+
* Domains we will SEND to without erroring (destination classification §1).
|
|
337
|
+
* Receiving uses identity.localDomains. Anything outside both → error.
|
|
338
|
+
*/
|
|
339
|
+
routableDomains?: string[];
|
|
340
|
+
smtp?: {
|
|
341
|
+
listenPort?: number;
|
|
342
|
+
relay?: {
|
|
343
|
+
host: string;
|
|
344
|
+
port: number;
|
|
345
|
+
auth?: { user: string; pass: string };
|
|
346
|
+
};
|
|
347
|
+
dkim?: { domain: string; selector: string; privateKeyRef: string };
|
|
348
|
+
};
|
|
349
|
+
provider?: {
|
|
350
|
+
name: "ses" | "postmark" | "mailgun";
|
|
351
|
+
apiKeyRef: string;
|
|
352
|
+
webhookPath?: string;
|
|
353
|
+
};
|
|
354
|
+
/** Delivery queue overrides; defaults to sqlite persistence when mail enabled. */
|
|
355
|
+
queue?: Partial<DeliveryQueueConfig>;
|
|
356
|
+
/**
|
|
357
|
+
* Attachment byte storage. Defaults to "sqlite" (content-addressed BLOBs in
|
|
358
|
+
* the DB). Use "fs" to keep bytes on disk for large/high-volume deployments.
|
|
359
|
+
*/
|
|
360
|
+
attachments?: {
|
|
361
|
+
backend?: "sqlite" | "fs";
|
|
362
|
+
/** Directory for the "fs" backend. Defaults to <home>/.claude/agent-inbox/attachments. */
|
|
363
|
+
dir?: string;
|
|
364
|
+
};
|
|
365
|
+
/** Inbound sender allow-list (domains). Empty/undefined = allow all. */
|
|
366
|
+
allowedSenderDomains?: string[];
|
|
367
|
+
/** Drop inbound whose DMARC verdict is "fail". */
|
|
368
|
+
rejectDmarcFail?: boolean;
|
|
369
|
+
/** Max attachments on an inbound message before rejection. */
|
|
370
|
+
maxAttachments?: number;
|
|
371
|
+
/** Inbound rate limiting (abuse control). Disabled if unset. */
|
|
372
|
+
rateLimit?: {
|
|
373
|
+
windowMs?: number;
|
|
374
|
+
perSenderDomain?: number;
|
|
375
|
+
global?: number;
|
|
376
|
+
};
|
|
377
|
+
/** Bounce handling (both default on). See §8. */
|
|
378
|
+
bounce?: {
|
|
379
|
+
/** Emit the mail.bounced event. */
|
|
380
|
+
emitEvent?: boolean;
|
|
381
|
+
/** Inject a synthetic bounce Message into the original sender's inbox. */
|
|
382
|
+
synthesizeInboxMessage?: boolean;
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
313
386
|
// --- Config ---
|
|
314
387
|
|
|
315
388
|
export interface InboxConfig {
|
|
@@ -326,4 +399,5 @@ export interface InboxConfig {
|
|
|
326
399
|
};
|
|
327
400
|
};
|
|
328
401
|
federation?: FederationConfig;
|
|
402
|
+
mail?: MailConfig;
|
|
329
403
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import { DeliveryQueue } from "../../src/federation/delivery-queue.js";
|
|
5
|
+
import { SqliteQueueStore } from "../../src/federation/queue-store.js";
|
|
6
|
+
import type { DeliveryQueueConfig, Message } from "../../src/types.js";
|
|
7
|
+
|
|
8
|
+
function makeMessage(id: string): Message {
|
|
9
|
+
return {
|
|
10
|
+
id,
|
|
11
|
+
scope: "default",
|
|
12
|
+
sender_id: "sender",
|
|
13
|
+
recipients: [{ agent_id: "remote@peer", kind: "to" }],
|
|
14
|
+
content: { type: "text", text: `msg-${id}` },
|
|
15
|
+
importance: "normal",
|
|
16
|
+
metadata: {},
|
|
17
|
+
created_at: new Date().toISOString(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const baseConfig: DeliveryQueueConfig = {
|
|
22
|
+
persistence: "sqlite",
|
|
23
|
+
maxTTL: 10_000,
|
|
24
|
+
maxQueueSize: 3,
|
|
25
|
+
retryStrategy: "exponential",
|
|
26
|
+
retryBaseInterval: 100,
|
|
27
|
+
retryMaxAttempts: 0,
|
|
28
|
+
flushOnReconnect: true,
|
|
29
|
+
overflow: "drop-oldest",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe("DeliveryQueue (sqlite persistence)", () => {
|
|
33
|
+
let db: Database.Database;
|
|
34
|
+
let events: EventEmitter;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
db = new Database(":memory:");
|
|
38
|
+
events = new EventEmitter();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
db.close();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function makeQueue(config: Partial<DeliveryQueueConfig> = {}) {
|
|
46
|
+
return new DeliveryQueue(
|
|
47
|
+
events,
|
|
48
|
+
{ ...baseConfig, ...config },
|
|
49
|
+
{ store: new SqliteQueueStore(db) }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
it("persists enqueued messages to the store", () => {
|
|
54
|
+
const q = makeQueue();
|
|
55
|
+
q.enqueue("peer-1", makeMessage("m1"));
|
|
56
|
+
q.enqueue("peer-1", makeMessage("m2"));
|
|
57
|
+
|
|
58
|
+
const rows = db.prepare("SELECT * FROM delivery_queue").all();
|
|
59
|
+
expect(rows).toHaveLength(2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("survives a restart by hydrating from the store", () => {
|
|
63
|
+
const q1 = makeQueue();
|
|
64
|
+
q1.enqueue("peer-1", makeMessage("m1"));
|
|
65
|
+
q1.enqueue("peer-2", makeMessage("m2"));
|
|
66
|
+
expect(q1.totalSize()).toBe(2);
|
|
67
|
+
|
|
68
|
+
// Simulate restart: new queue + new store over the same db handle.
|
|
69
|
+
const q2 = new DeliveryQueue(events, baseConfig, {
|
|
70
|
+
store: new SqliteQueueStore(db),
|
|
71
|
+
});
|
|
72
|
+
expect(q2.totalSize()).toBe(2);
|
|
73
|
+
expect(q2.size("peer-1")).toBe(1);
|
|
74
|
+
expect(q2.size("peer-2")).toBe(1);
|
|
75
|
+
expect(q2.peers().sort()).toEqual(["peer-1", "peer-2"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("removes entries from the store on removeEntry", () => {
|
|
79
|
+
const q = makeQueue();
|
|
80
|
+
const id = q.enqueue("peer-1", makeMessage("m1"))!;
|
|
81
|
+
q.removeEntry("peer-1", id);
|
|
82
|
+
|
|
83
|
+
expect(db.prepare("SELECT * FROM delivery_queue").all()).toHaveLength(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("clears the store for a peer on flush", () => {
|
|
87
|
+
const q = makeQueue();
|
|
88
|
+
q.enqueue("peer-1", makeMessage("m1"));
|
|
89
|
+
q.enqueue("peer-1", makeMessage("m2"));
|
|
90
|
+
q.flush("peer-1");
|
|
91
|
+
|
|
92
|
+
expect(
|
|
93
|
+
db.prepare("SELECT * FROM delivery_queue WHERE peer_id = ?").all("peer-1")
|
|
94
|
+
).toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("drops the oldest entry from the store on overflow", () => {
|
|
98
|
+
const q = makeQueue({ maxQueueSize: 2, overflow: "drop-oldest" });
|
|
99
|
+
q.enqueue("peer-1", makeMessage("m1"));
|
|
100
|
+
q.enqueue("peer-1", makeMessage("m2"));
|
|
101
|
+
q.enqueue("peer-1", makeMessage("m3")); // evicts m1
|
|
102
|
+
|
|
103
|
+
const messages = (
|
|
104
|
+
db.prepare("SELECT message FROM delivery_queue").all() as Array<{
|
|
105
|
+
message: string;
|
|
106
|
+
}>
|
|
107
|
+
).map((r) => JSON.parse(r.message).id);
|
|
108
|
+
expect(messages.sort()).toEqual(["m2", "m3"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("updates attempt/next-retry state in the store on recordAttempt", () => {
|
|
112
|
+
const q = makeQueue({ retryMaxAttempts: 5 });
|
|
113
|
+
const id = q.enqueue("peer-1", makeMessage("m1"))!;
|
|
114
|
+
q.recordAttempt("peer-1", id);
|
|
115
|
+
|
|
116
|
+
const row = db
|
|
117
|
+
.prepare("SELECT attempts, next_retry FROM delivery_queue WHERE id = ?")
|
|
118
|
+
.get(id) as { attempts: number; next_retry: string | null };
|
|
119
|
+
expect(row.attempts).toBe(1);
|
|
120
|
+
expect(row.next_retry).toBeTruthy();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("removes the entry from the store when max attempts are exceeded", () => {
|
|
124
|
+
const q = makeQueue({ retryMaxAttempts: 1 });
|
|
125
|
+
const id = q.enqueue("peer-1", makeMessage("m1"))!;
|
|
126
|
+
const kept = q.recordAttempt("peer-1", id);
|
|
127
|
+
expect(kept).toBe(false);
|
|
128
|
+
expect(db.prepare("SELECT * FROM delivery_queue").all()).toHaveLength(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("removes expired entries from the store on tick", () => {
|
|
132
|
+
const q = makeQueue({ maxTTL: -1 }); // everything is immediately expired
|
|
133
|
+
q.enqueue("peer-1", makeMessage("m1"));
|
|
134
|
+
const expired = q.tick();
|
|
135
|
+
expect(expired).toBe(1);
|
|
136
|
+
expect(db.prepare("SELECT * FROM delivery_queue").all()).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("round-trips the full message payload through the store", () => {
|
|
140
|
+
const q1 = makeQueue();
|
|
141
|
+
const msg = makeMessage("m1");
|
|
142
|
+
msg.subject = "hello";
|
|
143
|
+
msg.thread_tag = "thread-xyz";
|
|
144
|
+
q1.enqueue("peer-1", msg);
|
|
145
|
+
|
|
146
|
+
const q2 = new DeliveryQueue(events, baseConfig, {
|
|
147
|
+
store: new SqliteQueueStore(db),
|
|
148
|
+
});
|
|
149
|
+
const flushed = q2.flush("peer-1");
|
|
150
|
+
expect(flushed).toHaveLength(1);
|
|
151
|
+
expect(flushed[0].message.subject).toBe("hello");
|
|
152
|
+
expect(flushed[0].message.thread_tag).toBe("thread-xyz");
|
|
153
|
+
expect(flushed[0].message.content).toEqual({
|
|
154
|
+
type: "text",
|
|
155
|
+
text: "msg-m1",
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|