@inbrx/cli 0.0.1-alpha.2 → 0.0.1-alpha.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/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # @inbrx/cli
2
+
3
+ CLI for capturing local SMTP mail with inbrx. Start it, point your application at the local SMTP endpoint, and inspect captured emails in the browser.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ npx @inbrx/cli
9
+ ```
10
+
11
+ Defaults:
12
+
13
+ - SMTP server: `127.0.0.1:2525`
14
+ - Web UI: `http://127.0.0.1:3000`
15
+ - Storage: file-backed, using the standard user data directory for your operating system
16
+
17
+ Use memory-only storage for throwaway sessions:
18
+
19
+ ```bash
20
+ npx @inbrx/cli --storage memory
21
+ ```
22
+
23
+ ## Options
24
+
25
+ ```bash
26
+ inbrx start \
27
+ --smtp-host 127.0.0.1 \
28
+ --smtp-port 2525 \
29
+ --smtp-starttls \
30
+ --smtp-tls-key ./smtp.key \
31
+ --smtp-tls-cert ./smtp.crt \
32
+ --http-host 127.0.0.1 \
33
+ --http-port 3000 \
34
+ --max-messages 500 \
35
+ --storage file
36
+ ```
37
+
38
+ Environment variables:
39
+
40
+ - `SMTP_TEST_SMTP_HOST`
41
+ - `SMTP_TEST_SMTP_PORT`
42
+ - `INBRX_SMTP_STARTTLS=true|false`
43
+ - `INBRX_SMTP_TLS_KEY`
44
+ - `INBRX_SMTP_TLS_CERT`
45
+ - `SMTP_TEST_HTTP_HOST`
46
+ - `SMTP_TEST_HTTP_PORT`
47
+ - `SMTP_TEST_MAX_MESSAGES`
48
+ - `INBRX_STORAGE=file|memory`
49
+
50
+ ## STARTTLS
51
+
52
+ Enable STARTTLS with an automatically generated in-memory self-signed certificate:
53
+
54
+ ```bash
55
+ inbrx start --smtp-starttls
56
+ ```
57
+
58
+ Use your own certificate by providing both paths. Providing both paths also enables STARTTLS:
59
+
60
+ ```bash
61
+ inbrx start --smtp-tls-key ./smtp.key --smtp-tls-cert ./smtp.crt
62
+ ```
63
+
64
+ Self-signed certificates usually require test clients to disable certificate verification:
65
+
66
+ ```bash
67
+ swaks --to recipient@example.com \
68
+ --from sender@example.com \
69
+ --server 127.0.0.1 \
70
+ --port 2525 \
71
+ --tls \
72
+ --tls-verify false \
73
+ --auth LOGIN \
74
+ --auth-user sender@example.com \
75
+ --auth-password 'your_password' \
76
+ --header "Subject: test email" \
77
+ --body "Hello from SMTP"
78
+ ```
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { getDefaultConfig, loadConfig } from './config.js';
3
3
  import { startApp } from './index.js';
4
+ import { getDefaultDataDir } from './store/data-dir.js';
4
5
  export async function runCli(argv) {
5
6
  const program = createCliProgram();
6
7
  await program.parseAsync(normalizeArgv(argv), { from: 'node' });
@@ -18,17 +19,25 @@ export function createCliProgram() {
18
19
  .description('Start the SMTP server and HTTP API/UI.')
19
20
  .option('--smtp-host <host>', `SMTP bind host. Default: ${defaults.smtpHost}`)
20
21
  .option('--smtp-port <port>', `SMTP port. Default: ${defaults.smtpPort}`)
22
+ .option('--smtp-starttls', 'Advertise and support STARTTLS. Generates a temporary self-signed certificate unless TLS paths are provided.')
23
+ .option('--smtp-tls-key <path>', 'TLS private key path for STARTTLS. Enables STARTTLS when paired with --smtp-tls-cert.')
24
+ .option('--smtp-tls-cert <path>', 'TLS certificate path for STARTTLS. Enables STARTTLS when paired with --smtp-tls-key.')
21
25
  .option('--http-host <host>', `HTTP bind host. Default: ${defaults.httpHost}`)
22
26
  .option('--http-port <port>', `HTTP port. Default: ${defaults.httpPort}`)
23
27
  .option('--max-messages <count>', `Maximum retained messages. Default: ${defaults.maxMessages}`)
28
+ .option('--storage <mode>', `Storage mode: file or memory. Default: ${defaults.storage}`)
24
29
  .addHelpText('after', `
25
30
 
26
31
  Environment:
27
32
  SMTP_TEST_SMTP_HOST
28
33
  SMTP_TEST_SMTP_PORT
34
+ INBRX_SMTP_STARTTLS
35
+ INBRX_SMTP_TLS_KEY
36
+ INBRX_SMTP_TLS_CERT
29
37
  SMTP_TEST_HTTP_HOST
30
38
  SMTP_TEST_HTTP_PORT
31
- SMTP_TEST_MAX_MESSAGES`)
39
+ SMTP_TEST_MAX_MESSAGES
40
+ INBRX_STORAGE`)
32
41
  .action(async (options) => {
33
42
  await startServer(options);
34
43
  });
@@ -39,7 +48,9 @@ async function startServer(options) {
39
48
  const app = await startApp(config);
40
49
  console.log('SMTP test server ready');
41
50
  console.log(`SMTP: smtp://${config.smtpHost}:${config.smtpPort}`);
51
+ console.log(`SMTP STARTTLS: ${config.smtpStartTls ? 'enabled' : 'disabled'}`);
42
52
  console.log(`Web UI: http://${config.httpHost}:${config.httpPort}`);
53
+ console.log(`Storage: ${config.storage}${config.storage === 'file' ? ` (${getDefaultDataDir()})` : ''}`);
43
54
  console.log('Press Ctrl+C to stop.');
44
55
  const shutdown = async () => {
45
56
  console.log('\nShutting down...');
package/dist/config.js CHANGED
@@ -1,20 +1,35 @@
1
1
  const DEFAULTS = {
2
2
  smtpHost: '127.0.0.1',
3
3
  smtpPort: 2525,
4
+ smtpStartTls: false,
5
+ smtpTlsKeyPath: null,
6
+ smtpTlsCertPath: null,
4
7
  httpHost: '127.0.0.1',
5
8
  httpPort: 3000,
6
- maxMessages: 500
9
+ maxMessages: 500,
10
+ storage: 'file'
7
11
  };
8
12
  export function loadConfig(overrides = {}) {
13
+ const smtpTlsKeyPath = readOptionalString(overrides.smtpTlsKeyPath, process.env.INBRX_SMTP_TLS_KEY, DEFAULTS.smtpTlsKeyPath);
14
+ const smtpTlsCertPath = readOptionalString(overrides.smtpTlsCertPath, process.env.INBRX_SMTP_TLS_CERT, DEFAULTS.smtpTlsCertPath);
9
15
  const config = {
10
16
  smtpHost: readString(overrides.smtpHost, process.env.SMTP_TEST_SMTP_HOST, DEFAULTS.smtpHost),
11
17
  smtpPort: readNumber(overrides.smtpPort, process.env.SMTP_TEST_SMTP_PORT, DEFAULTS.smtpPort),
18
+ smtpStartTls: readBoolean(overrides.smtpStartTls ?? overrides.smtpStarttls, process.env.INBRX_SMTP_STARTTLS, DEFAULTS.smtpStartTls) ||
19
+ Boolean(smtpTlsKeyPath) ||
20
+ Boolean(smtpTlsCertPath),
21
+ smtpTlsKeyPath,
22
+ smtpTlsCertPath,
12
23
  httpHost: readString(overrides.httpHost, process.env.SMTP_TEST_HTTP_HOST, DEFAULTS.httpHost),
13
24
  httpPort: readNumber(overrides.httpPort, process.env.SMTP_TEST_HTTP_PORT, DEFAULTS.httpPort),
14
- maxMessages: readNumber(overrides.maxMessages, process.env.SMTP_TEST_MAX_MESSAGES, DEFAULTS.maxMessages)
25
+ maxMessages: readNumber(overrides.maxMessages, process.env.SMTP_TEST_MAX_MESSAGES, DEFAULTS.maxMessages),
26
+ storage: readStorageMode(overrides.storage, process.env.INBRX_STORAGE, DEFAULTS.storage)
15
27
  };
16
28
  assertPort(config.smtpPort, 'SMTP port');
17
29
  assertPort(config.httpPort, 'HTTP port');
30
+ if (Boolean(config.smtpTlsKeyPath) !== Boolean(config.smtpTlsCertPath)) {
31
+ throw new Error('SMTP TLS key and certificate paths must be provided together.');
32
+ }
18
33
  if (!Number.isInteger(config.maxMessages) || config.maxMessages < 1) {
19
34
  throw new Error('Max messages must be a positive integer.');
20
35
  }
@@ -32,6 +47,15 @@ function readString(override, envValue, fallback) {
32
47
  }
33
48
  return fallback;
34
49
  }
50
+ function readOptionalString(override, envValue, fallback) {
51
+ if (override !== undefined && override !== null && override !== '') {
52
+ return String(override);
53
+ }
54
+ if (envValue !== undefined && envValue !== '') {
55
+ return envValue;
56
+ }
57
+ return fallback;
58
+ }
35
59
  function readNumber(override, envValue, fallback) {
36
60
  const value = override ?? envValue;
37
61
  if (value === undefined || value === '') {
@@ -43,8 +67,32 @@ function readNumber(override, envValue, fallback) {
43
67
  }
44
68
  return parsed;
45
69
  }
70
+ function readBoolean(override, envValue, fallback) {
71
+ const value = override ?? envValue;
72
+ if (value === undefined || value === null || value === '') {
73
+ return fallback;
74
+ }
75
+ if (typeof value === 'boolean') {
76
+ return value;
77
+ }
78
+ const normalized = String(value).toLowerCase();
79
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) {
80
+ return true;
81
+ }
82
+ if (['0', 'false', 'no', 'off'].includes(normalized)) {
83
+ return false;
84
+ }
85
+ throw new Error(`Expected a boolean, received "${value}".`);
86
+ }
46
87
  function assertPort(value, label) {
47
88
  if (!Number.isInteger(value) || value < 1 || value > 65535) {
48
89
  throw new Error(`${label} must be an integer between 1 and 65535.`);
49
90
  }
50
91
  }
92
+ function readStorageMode(override, envValue, fallback) {
93
+ const value = readString(override, envValue, fallback);
94
+ if (value === 'file' || value === 'memory') {
95
+ return value;
96
+ }
97
+ throw new Error('Storage must be either "file" or "memory".');
98
+ }
@@ -0,0 +1,16 @@
1
+ export function createMailboxEvents() {
2
+ const listeners = new Set();
3
+ return {
4
+ emit(event) {
5
+ for (const listener of listeners) {
6
+ listener(event);
7
+ }
8
+ },
9
+ subscribe(listener) {
10
+ listeners.add(listener);
11
+ return () => {
12
+ listeners.delete(listener);
13
+ };
14
+ }
15
+ };
16
+ }
@@ -5,10 +5,11 @@ import { Readable } from 'node:stream';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { serve } from '@hono/node-server';
7
7
  import { Hono } from 'hono';
8
+ import { createMailboxEvents } from '../events/mailbox-events.js';
8
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
10
  const WEB_ROOT = path.resolve(__dirname, '../../..', 'web/dist');
10
- export function createHttpServer({ store }) {
11
- const app = createHttpApp({ store });
11
+ export function createHttpServer({ store, attachmentStore, events = createMailboxEvents() }) {
12
+ const app = createHttpApp({ store, attachmentStore, events });
12
13
  let server = null;
13
14
  return {
14
15
  listen(port, host) {
@@ -43,34 +44,123 @@ export function createHttpServer({ store }) {
43
44
  }
44
45
  };
45
46
  }
46
- export function createHttpApp({ store }) {
47
+ export function createHttpApp({ store, attachmentStore, events = createMailboxEvents() }) {
47
48
  const app = new Hono();
48
49
  app.onError((error, c) => {
49
50
  console.error(error);
50
51
  return c.json({ error: 'Internal server error' }, 500);
51
52
  });
52
53
  app.get('/api/health', (c) => c.json({ status: 'ok' }));
53
- app.get('/api/messages', (c) => c.json({
54
- messages: store.list().map(toMessageSummary)
54
+ app.get('/api/events', (c) => sseResponse(events, c.req.raw.signal));
55
+ app.get('/api/messages', async (c) => c.json({
56
+ messages: (await store.list()).map(toMessageSummary)
55
57
  }));
56
- app.delete('/api/messages', (c) => {
57
- const deleted = store.clear();
58
+ app.delete('/api/messages', async (c) => {
59
+ const deleted = await store.clear();
60
+ events.emit({ type: 'messages.cleared', deleted });
58
61
  return c.json({ deleted });
59
62
  });
60
- app.get('/api/messages/:id', (c) => {
61
- const message = store.get(c.req.param('id'));
63
+ app.get('/api/messages/:id', async (c) => {
64
+ const message = await store.get(c.req.param('id'));
62
65
  if (!message) {
63
66
  return c.json({ error: 'Message not found' }, 404);
64
67
  }
65
68
  return c.json({ message: toMessageDetail(message) });
66
69
  });
67
- app.delete('/api/messages/:id', (c) => {
68
- const deleted = store.delete(c.req.param('id'));
70
+ app.get('/api/messages/:id/attachments/:attachmentId', async (c) => {
71
+ const messageId = c.req.param('id');
72
+ const attachmentId = c.req.param('attachmentId');
73
+ const message = await store.get(messageId);
74
+ if (!message) {
75
+ return c.json({ error: 'Message not found' }, 404);
76
+ }
77
+ const attachment = message.attachments.find((item) => item.id === attachmentId);
78
+ if (!attachment) {
79
+ return c.json({ error: 'Attachment not found' }, 404);
80
+ }
81
+ const storedAttachment = await attachmentStore.get(messageId, attachmentId);
82
+ if (!storedAttachment) {
83
+ return c.json({ error: 'Attachment not found' }, 404);
84
+ }
85
+ return new Response(storedAttachment.content, {
86
+ headers: attachmentHeaders(attachment)
87
+ });
88
+ });
89
+ app.delete('/api/messages/:id', async (c) => {
90
+ const messageId = c.req.param('id');
91
+ const deleted = await store.delete(messageId);
92
+ if (deleted) {
93
+ events.emit({ type: 'message.deleted', id: messageId });
94
+ }
69
95
  return c.json({ deleted }, deleted ? 200 : 404);
70
96
  });
71
97
  app.all('*', (c) => serveStatic(new URL(c.req.url).pathname));
72
98
  return app;
73
99
  }
100
+ function sseResponse(events, signal) {
101
+ const encoder = new TextEncoder();
102
+ let unsubscribe = null;
103
+ let keepalive = null;
104
+ let closed = false;
105
+ const stream = new ReadableStream({
106
+ start(controller) {
107
+ const send = (event, data) => {
108
+ if (closed) {
109
+ return;
110
+ }
111
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
112
+ };
113
+ const cleanup = () => {
114
+ if (closed) {
115
+ return;
116
+ }
117
+ closed = true;
118
+ unsubscribe?.();
119
+ unsubscribe = null;
120
+ if (keepalive) {
121
+ clearInterval(keepalive);
122
+ keepalive = null;
123
+ }
124
+ };
125
+ send('ready', { version: 1 });
126
+ unsubscribe = events.subscribe((event) => {
127
+ send(event.type, eventData(event));
128
+ });
129
+ keepalive = setInterval(() => {
130
+ if (!closed) {
131
+ controller.enqueue(encoder.encode(': keepalive\n\n'));
132
+ }
133
+ }, 30000);
134
+ signal.addEventListener('abort', cleanup, { once: true });
135
+ },
136
+ cancel() {
137
+ closed = true;
138
+ unsubscribe?.();
139
+ unsubscribe = null;
140
+ if (keepalive) {
141
+ clearInterval(keepalive);
142
+ keepalive = null;
143
+ }
144
+ }
145
+ });
146
+ return new Response(stream, {
147
+ headers: {
148
+ 'content-type': 'text/event-stream; charset=utf-8',
149
+ 'cache-control': 'no-cache, no-transform',
150
+ connection: 'keep-alive'
151
+ }
152
+ });
153
+ }
154
+ function eventData(event) {
155
+ switch (event.type) {
156
+ case 'message.created':
157
+ return { id: event.id, receivedAt: event.receivedAt };
158
+ case 'message.deleted':
159
+ return { id: event.id };
160
+ case 'messages.cleared':
161
+ return { deleted: event.deleted };
162
+ }
163
+ }
74
164
  function toMessageSummary(message) {
75
165
  return {
76
166
  id: message.id,
@@ -135,3 +225,14 @@ function contentTypeFor(filePath) {
135
225
  }
136
226
  return 'application/octet-stream';
137
227
  }
228
+ function attachmentHeaders(attachment) {
229
+ const headers = new Headers();
230
+ headers.set('content-type', attachment.contentType);
231
+ headers.set('content-length', String(attachment.sizeBytes));
232
+ headers.set('content-disposition', contentDispositionFor(attachment.filename));
233
+ return headers;
234
+ }
235
+ function contentDispositionFor(filename) {
236
+ const safeFilename = (filename || 'attachment').replace(/[^\w.!#$&+^`{}~-]+/g, '_');
237
+ return `attachment; filename="${safeFilename}"`;
238
+ }
package/dist/index.js CHANGED
@@ -1,10 +1,22 @@
1
+ import { createMailboxEvents } from './events/mailbox-events.js';
1
2
  import { createHttpServer } from './http/server.js';
3
+ import { createFileAttachmentStore, createMemoryAttachmentStore } from './store/attachment-store.js';
4
+ import { getDefaultDataDir } from './store/data-dir.js';
5
+ import { createFileMessageStore } from './store/file-store.js';
2
6
  import { createMemoryStore } from './store/memory-store.js';
3
7
  import { createSmtpServer } from './smtp/server.js';
4
8
  export async function startApp(config) {
5
- const store = createMemoryStore({ maxMessages: config.maxMessages });
6
- const smtpServer = createSmtpServer({ store });
7
- const httpServer = createHttpServer({ store });
9
+ const events = createMailboxEvents();
10
+ const { store, attachmentStore } = createStores(config, events);
11
+ const smtpServer = await createSmtpServer({
12
+ store,
13
+ attachmentStore,
14
+ events,
15
+ startTls: config.smtpStartTls,
16
+ tlsKeyPath: config.smtpTlsKeyPath,
17
+ tlsCertPath: config.smtpTlsCertPath
18
+ });
19
+ const httpServer = createHttpServer({ store, attachmentStore, events });
8
20
  await Promise.all([
9
21
  smtpServer.listen(config.smtpPort, config.smtpHost),
10
22
  httpServer.listen(config.httpPort, config.httpHost)
@@ -16,3 +28,33 @@ export async function startApp(config) {
16
28
  }
17
29
  };
18
30
  }
31
+ function createStores(config, events) {
32
+ if (config.storage === 'memory') {
33
+ const attachmentStore = createMemoryAttachmentStore();
34
+ return {
35
+ attachmentStore,
36
+ store: createMemoryStore({
37
+ maxMessages: config.maxMessages,
38
+ onDelete: createDeleteHandler(attachmentStore, events)
39
+ })
40
+ };
41
+ }
42
+ const dataDir = getDefaultDataDir();
43
+ const attachmentStore = createFileAttachmentStore({ rootDir: dataDir });
44
+ return {
45
+ attachmentStore,
46
+ store: createFileMessageStore({
47
+ rootDir: dataDir,
48
+ maxMessages: config.maxMessages,
49
+ onDelete: createDeleteHandler(attachmentStore, events)
50
+ })
51
+ };
52
+ }
53
+ function createDeleteHandler(attachmentStore, events) {
54
+ return async (messageId, reason) => {
55
+ await attachmentStore.deleteForMessage(messageId);
56
+ if (reason === 'evicted') {
57
+ events.emit({ type: 'message.deleted', id: messageId });
58
+ }
59
+ };
60
+ }
@@ -1,9 +1,20 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { simpleParser } from 'mailparser';
3
- export async function parseMessage({ raw, envelope, smtp }) {
3
+ export async function parseMessage({ raw, envelope, smtp, attachmentStore }) {
4
4
  const parsed = await simpleParser(Buffer.from(raw));
5
+ const id = randomUUID();
6
+ const attachments = [];
7
+ for (const attachment of parsed.attachments) {
8
+ attachments.push(await attachmentStore.save({
9
+ messageId: id,
10
+ filename: attachment.filename || null,
11
+ contentType: attachment.contentType,
12
+ contentId: attachment.contentId,
13
+ content: attachment.content
14
+ }));
15
+ }
5
16
  return {
6
- id: randomUUID(),
17
+ id,
7
18
  receivedAt: new Date().toISOString(),
8
19
  from: envelope.from || firstAddress(parsed.from),
9
20
  to: envelope.to.length > 0 ? envelope.to : addressList(parsed.to),
@@ -13,12 +24,7 @@ export async function parseMessage({ raw, envelope, smtp }) {
13
24
  headers: headersToRecord(parsed.headers),
14
25
  text: parsed.text || null,
15
26
  html: typeof parsed.html === 'string' ? parsed.html : null,
16
- attachments: parsed.attachments.map((attachment) => ({
17
- filename: attachment.filename || null,
18
- contentType: attachment.contentType,
19
- sizeBytes: attachment.size,
20
- contentId: attachment.contentId
21
- })),
27
+ attachments,
22
28
  rawSizeBytes: Buffer.byteLength(raw),
23
29
  raw,
24
30
  smtp
@@ -1,13 +1,21 @@
1
+ import { readFile } from 'node:fs/promises';
1
2
  import { SMTPServer } from 'smtp-server';
3
+ import { generate } from 'selfsigned';
2
4
  import { parseMessage } from '../mail/parser.js';
3
- export function createSmtpServer({ store }) {
5
+ export async function createSmtpServer({ store, attachmentStore, events, startTls = false, tlsKeyPath = null, tlsCertPath = null }) {
6
+ const tlsConfig = startTls ? await createTlsConfig({ keyPath: tlsKeyPath, certPath: tlsCertPath }) : null;
4
7
  const server = new SMTPServer({
5
8
  name: 'inbrx',
6
9
  banner: 'inbrx ready',
10
+ ...(tlsConfig ?? {}),
7
11
  authOptional: true,
8
- disabledCommands: ['AUTH', 'STARTTLS'],
12
+ allowInsecureAuth: true,
13
+ disabledCommands: startTls ? [] : ['STARTTLS'],
9
14
  hidePIPELINING: true,
10
15
  logger: false,
16
+ onAuth(auth, _session, callback) {
17
+ callback(null, { user: auth.username || 'anonymous' });
18
+ },
11
19
  onMailFrom(_address, _session, callback) {
12
20
  callback();
13
21
  },
@@ -15,8 +23,11 @@ export function createSmtpServer({ store }) {
15
23
  callback();
16
24
  },
17
25
  onData(stream, session, callback) {
18
- void handleData(stream, session, store)
19
- .then((messageId) => callback(null, `OK captured as ${messageId}`))
26
+ void handleData(stream, session, store, attachmentStore)
27
+ .then((message) => {
28
+ events?.emit({ type: 'message.created', id: message.id, receivedAt: message.receivedAt });
29
+ callback(null, `OK captured as ${message.id}`);
30
+ })
20
31
  .catch((error) => callback(error instanceof Error ? error : new Error(String(error))));
21
32
  }
22
33
  });
@@ -37,13 +48,55 @@ export function createSmtpServer({ store }) {
37
48
  }
38
49
  };
39
50
  }
40
- async function handleData(stream, session, store) {
51
+ async function createTlsConfig({ keyPath, certPath }) {
52
+ if (keyPath || certPath) {
53
+ if (!keyPath || !certPath) {
54
+ throw new Error('SMTP TLS key and certificate paths must be provided together.');
55
+ }
56
+ const [key, cert] = await Promise.all([readFile(keyPath), readFile(certPath)]);
57
+ return { key, cert };
58
+ }
59
+ const pems = await generate([{ name: 'commonName', value: 'localhost' }], {
60
+ keySize: 2048,
61
+ algorithm: 'sha256',
62
+ extensions: [
63
+ {
64
+ name: 'basicConstraints',
65
+ cA: false
66
+ },
67
+ {
68
+ name: 'keyUsage',
69
+ digitalSignature: true,
70
+ keyEncipherment: true
71
+ },
72
+ {
73
+ name: 'subjectAltName',
74
+ altNames: [
75
+ { type: 2, value: 'localhost' },
76
+ { type: 7, ip: '127.0.0.1' },
77
+ { type: 7, ip: '::1' }
78
+ ]
79
+ }
80
+ ]
81
+ });
82
+ return {
83
+ key: pems.private,
84
+ cert: pems.cert
85
+ };
86
+ }
87
+ async function handleData(stream, session, store, attachmentStore) {
41
88
  const raw = await readStream(stream);
42
89
  const smtp = toCapturedSmtpSession(session);
43
90
  const envelope = toMailEnvelope(smtp);
44
- const message = await parseMessage({ raw, envelope, smtp });
45
- store.add(message);
46
- return message.id;
91
+ const message = await parseMessage({ raw, envelope, smtp, attachmentStore });
92
+ try {
93
+ await store.add(message);
94
+ }
95
+ catch (error) {
96
+ await attachmentStore.deleteForMessage(message.id);
97
+ throw error;
98
+ }
99
+ return message;
47
100
  }
48
101
  function readStream(stream) {
49
102
  return new Promise((resolve, reject) => {
@@ -0,0 +1,84 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ export function createMemoryAttachmentStore() {
5
+ const attachments = new Map();
6
+ return {
7
+ async save(input) {
8
+ const attachment = toCapturedAttachment(input);
9
+ attachments.set(toMapKey(input.messageId, attachment.id), {
10
+ ...attachment,
11
+ content: input.content
12
+ });
13
+ return attachment;
14
+ },
15
+ async get(messageId, attachmentId) {
16
+ return attachments.get(toMapKey(messageId, attachmentId)) || null;
17
+ },
18
+ async deleteForMessage(messageId) {
19
+ for (const key of attachments.keys()) {
20
+ if (key.startsWith(`${messageId}/`)) {
21
+ attachments.delete(key);
22
+ }
23
+ }
24
+ },
25
+ async clear() {
26
+ attachments.clear();
27
+ }
28
+ };
29
+ }
30
+ export function createFileAttachmentStore({ rootDir }) {
31
+ const attachmentsDir = path.join(rootDir, 'attachments');
32
+ return {
33
+ async save(input) {
34
+ const attachment = toCapturedAttachment(input);
35
+ const filePath = attachmentPath(attachmentsDir, input.messageId, attachment.id);
36
+ await mkdir(path.dirname(filePath), { recursive: true });
37
+ await writeFile(filePath, input.content);
38
+ return attachment;
39
+ },
40
+ async get(messageId, attachmentId) {
41
+ const filePath = attachmentPath(attachmentsDir, messageId, attachmentId);
42
+ try {
43
+ const content = await readFile(filePath);
44
+ return {
45
+ id: attachmentId,
46
+ filename: null,
47
+ contentType: 'application/octet-stream',
48
+ sizeBytes: content.byteLength,
49
+ storageKey: storageKeyFor(messageId, attachmentId),
50
+ content
51
+ };
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ },
57
+ async deleteForMessage(messageId) {
58
+ await rm(path.join(attachmentsDir, messageId), { recursive: true, force: true });
59
+ },
60
+ async clear() {
61
+ await rm(attachmentsDir, { recursive: true, force: true });
62
+ }
63
+ };
64
+ }
65
+ function toCapturedAttachment(input) {
66
+ const id = randomUUID();
67
+ return {
68
+ id,
69
+ filename: input.filename,
70
+ contentType: input.contentType,
71
+ sizeBytes: input.content.byteLength,
72
+ storageKey: storageKeyFor(input.messageId, id),
73
+ ...(input.contentId ? { contentId: input.contentId } : {})
74
+ };
75
+ }
76
+ function toMapKey(messageId, attachmentId) {
77
+ return `${messageId}/${attachmentId}`;
78
+ }
79
+ function storageKeyFor(messageId, attachmentId) {
80
+ return toMapKey(messageId, attachmentId);
81
+ }
82
+ function attachmentPath(attachmentsDir, messageId, attachmentId) {
83
+ return path.join(attachmentsDir, messageId, attachmentId);
84
+ }
@@ -0,0 +1,12 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ const APP_DIR_NAME = 'inbrx';
4
+ export function getDefaultDataDir(platform = process.platform, env = process.env) {
5
+ if (platform === 'darwin') {
6
+ return path.join(os.homedir(), 'Library', 'Application Support', APP_DIR_NAME);
7
+ }
8
+ if (platform === 'win32') {
9
+ return path.join(env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), APP_DIR_NAME);
10
+ }
11
+ return path.join(env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), APP_DIR_NAME);
12
+ }
@@ -0,0 +1,71 @@
1
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export function createFileMessageStore({ rootDir, maxMessages, onDelete }) {
4
+ const messagesDir = path.join(rootDir, 'messages');
5
+ return {
6
+ async add(message) {
7
+ await mkdir(messagesDir, { recursive: true });
8
+ await writeFile(messagePath(messagesDir, message.id), JSON.stringify(message, null, 2));
9
+ await trimMessages({ messagesDir, maxMessages, onDelete });
10
+ return message;
11
+ },
12
+ async list() {
13
+ return listMessages(messagesDir);
14
+ },
15
+ async get(id) {
16
+ return readMessage(messagesDir, id);
17
+ },
18
+ async delete(id) {
19
+ const message = await readMessage(messagesDir, id);
20
+ if (!message) {
21
+ return false;
22
+ }
23
+ await rm(messagePath(messagesDir, id), { force: true });
24
+ await onDelete?.(id, 'deleted');
25
+ return true;
26
+ },
27
+ async clear() {
28
+ const messages = await listMessages(messagesDir);
29
+ await rm(messagesDir, { recursive: true, force: true });
30
+ await Promise.all(messages.map((message) => onDelete?.(message.id, 'cleared')));
31
+ return messages.length;
32
+ }
33
+ };
34
+ }
35
+ async function trimMessages({ messagesDir, maxMessages, onDelete }) {
36
+ const messages = await listMessages(messagesDir);
37
+ const removed = messages.slice(maxMessages);
38
+ await Promise.all(removed.map(async (message) => {
39
+ await rm(messagePath(messagesDir, message.id), { force: true });
40
+ await onDelete?.(message.id, 'evicted');
41
+ }));
42
+ }
43
+ async function listMessages(messagesDir) {
44
+ let entries;
45
+ try {
46
+ entries = await readdir(messagesDir);
47
+ }
48
+ catch {
49
+ return [];
50
+ }
51
+ const messages = await Promise.all(entries
52
+ .filter((entry) => entry.endsWith('.json'))
53
+ .map((entry) => readMessageFile(path.join(messagesDir, entry))));
54
+ return messages
55
+ .filter((message) => Boolean(message))
56
+ .sort((a, b) => b.receivedAt.localeCompare(a.receivedAt));
57
+ }
58
+ async function readMessage(messagesDir, id) {
59
+ return readMessageFile(messagePath(messagesDir, id));
60
+ }
61
+ async function readMessageFile(filePath) {
62
+ try {
63
+ return JSON.parse(await readFile(filePath, 'utf8'));
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ function messagePath(messagesDir, id) {
70
+ return path.join(messagesDir, `${id}.json`);
71
+ }
@@ -1,36 +1,42 @@
1
- export function createMemoryStore({ maxMessages }) {
1
+ export function createMemoryStore({ maxMessages, onDelete }) {
2
2
  const messages = new Map();
3
3
  const order = [];
4
4
  return {
5
- add(message) {
5
+ async add(message) {
6
6
  messages.set(message.id, message);
7
7
  order.unshift(message.id);
8
8
  while (order.length > maxMessages) {
9
9
  const removedId = order.pop();
10
10
  if (removedId) {
11
11
  messages.delete(removedId);
12
+ await onDelete?.(removedId, 'evicted');
12
13
  }
13
14
  }
14
15
  return message;
15
16
  },
16
- list() {
17
+ async list() {
17
18
  return order.map((id) => messages.get(id)).filter((message) => Boolean(message));
18
19
  },
19
- get(id) {
20
+ async get(id) {
20
21
  return messages.get(id) || null;
21
22
  },
22
- delete(id) {
23
+ async delete(id) {
23
24
  const existed = messages.delete(id);
24
25
  const index = order.indexOf(id);
25
26
  if (index !== -1) {
26
27
  order.splice(index, 1);
27
28
  }
29
+ if (existed) {
30
+ await onDelete?.(id, 'deleted');
31
+ }
28
32
  return existed;
29
33
  },
30
- clear() {
34
+ async clear() {
31
35
  const count = messages.size;
36
+ const ids = [...messages.keys()];
32
37
  messages.clear();
33
38
  order.length = 0;
39
+ await Promise.all(ids.map((id) => onDelete?.(id, 'cleared')));
34
40
  return count;
35
41
  }
36
42
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@inbrx/cli",
3
- "version": "0.0.1-alpha.2",
4
- "description": "SMTP capture server and CLI for inbrx.",
3
+ "version": "0.0.1-alpha.4",
4
+ "description": "CLI for capturing local SMTP mail with inbrx.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/shuaixr/inbrx.git"
@@ -9,7 +9,8 @@
9
9
  "type": "module",
10
10
  "files": [
11
11
  "bin",
12
- "dist"
12
+ "dist",
13
+ "README.md"
13
14
  ],
14
15
  "bin": {
15
16
  "inbrx": "./bin/inbrx.js"
@@ -23,20 +24,23 @@
23
24
  "dev": "tsx watch ./src/cli.ts start",
24
25
  "test": "vitest run --exclude \"dist/**\"",
25
26
  "test:watch": "vitest",
27
+ "coverage": "vitest run --coverage --coverage.reporter=lcov --exclude \"dist/**\"",
26
28
  "check": "tsc --noEmit -p tsconfig.json && node --check ./bin/inbrx.js && npm run test"
27
29
  },
28
30
  "dependencies": {
29
31
  "@hono/node-server": "^2.0.8",
30
- "@inbrx/web": "0.0.1-alpha.2",
32
+ "@inbrx/web": "0.0.1-alpha.4",
31
33
  "commander": "^15.0.0",
32
34
  "hono": "^4.12.27",
33
35
  "mailparser": "^3.9.14",
36
+ "selfsigned": "^5.5.0",
34
37
  "smtp-server": "^3.19.2"
35
38
  },
36
39
  "devDependencies": {
37
40
  "@types/mailparser": "^3.4.6",
38
41
  "@types/nodemailer": "^8.0.1",
39
42
  "@types/smtp-server": "^3.5.13",
43
+ "@vitest/coverage-v8": "^4.1.9",
40
44
  "nodemailer": "^9.0.3",
41
45
  "tsx": "^4.19.0",
42
46
  "vitest": "^4.1.9"