@inbrx/cli 0.0.1-alpha.2 → 0.0.1-alpha.3

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/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' });
@@ -21,6 +22,7 @@ export function createCliProgram() {
21
22
  .option('--http-host <host>', `HTTP bind host. Default: ${defaults.httpHost}`)
22
23
  .option('--http-port <port>', `HTTP port. Default: ${defaults.httpPort}`)
23
24
  .option('--max-messages <count>', `Maximum retained messages. Default: ${defaults.maxMessages}`)
25
+ .option('--storage <mode>', `Storage mode: file or memory. Default: ${defaults.storage}`)
24
26
  .addHelpText('after', `
25
27
 
26
28
  Environment:
@@ -28,7 +30,8 @@ Environment:
28
30
  SMTP_TEST_SMTP_PORT
29
31
  SMTP_TEST_HTTP_HOST
30
32
  SMTP_TEST_HTTP_PORT
31
- SMTP_TEST_MAX_MESSAGES`)
33
+ SMTP_TEST_MAX_MESSAGES
34
+ INBRX_STORAGE`)
32
35
  .action(async (options) => {
33
36
  await startServer(options);
34
37
  });
@@ -40,6 +43,7 @@ async function startServer(options) {
40
43
  console.log('SMTP test server ready');
41
44
  console.log(`SMTP: smtp://${config.smtpHost}:${config.smtpPort}`);
42
45
  console.log(`Web UI: http://${config.httpHost}:${config.httpPort}`);
46
+ console.log(`Storage: ${config.storage}${config.storage === 'file' ? ` (${getDefaultDataDir()})` : ''}`);
43
47
  console.log('Press Ctrl+C to stop.');
44
48
  const shutdown = async () => {
45
49
  console.log('\nShutting down...');
package/dist/config.js CHANGED
@@ -3,7 +3,8 @@ const DEFAULTS = {
3
3
  smtpPort: 2525,
4
4
  httpHost: '127.0.0.1',
5
5
  httpPort: 3000,
6
- maxMessages: 500
6
+ maxMessages: 500,
7
+ storage: 'file'
7
8
  };
8
9
  export function loadConfig(overrides = {}) {
9
10
  const config = {
@@ -11,7 +12,8 @@ export function loadConfig(overrides = {}) {
11
12
  smtpPort: readNumber(overrides.smtpPort, process.env.SMTP_TEST_SMTP_PORT, DEFAULTS.smtpPort),
12
13
  httpHost: readString(overrides.httpHost, process.env.SMTP_TEST_HTTP_HOST, DEFAULTS.httpHost),
13
14
  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)
15
+ maxMessages: readNumber(overrides.maxMessages, process.env.SMTP_TEST_MAX_MESSAGES, DEFAULTS.maxMessages),
16
+ storage: readStorageMode(overrides.storage, process.env.INBRX_STORAGE, DEFAULTS.storage)
15
17
  };
16
18
  assertPort(config.smtpPort, 'SMTP port');
17
19
  assertPort(config.httpPort, 'HTTP port');
@@ -48,3 +50,10 @@ function assertPort(value, label) {
48
50
  throw new Error(`${label} must be an integer between 1 and 65535.`);
49
51
  }
50
52
  }
53
+ function readStorageMode(override, envValue, fallback) {
54
+ const value = readString(override, envValue, fallback);
55
+ if (value === 'file' || value === 'memory') {
56
+ return value;
57
+ }
58
+ throw new Error('Storage must be either "file" or "memory".');
59
+ }
@@ -7,8 +7,8 @@ import { serve } from '@hono/node-server';
7
7
  import { Hono } from 'hono';
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const WEB_ROOT = path.resolve(__dirname, '../../..', 'web/dist');
10
- export function createHttpServer({ store }) {
11
- const app = createHttpApp({ store });
10
+ export function createHttpServer({ store, attachmentStore }) {
11
+ const app = createHttpApp({ store, attachmentStore });
12
12
  let server = null;
13
13
  return {
14
14
  listen(port, host) {
@@ -43,29 +43,48 @@ export function createHttpServer({ store }) {
43
43
  }
44
44
  };
45
45
  }
46
- export function createHttpApp({ store }) {
46
+ export function createHttpApp({ store, attachmentStore }) {
47
47
  const app = new Hono();
48
48
  app.onError((error, c) => {
49
49
  console.error(error);
50
50
  return c.json({ error: 'Internal server error' }, 500);
51
51
  });
52
52
  app.get('/api/health', (c) => c.json({ status: 'ok' }));
53
- app.get('/api/messages', (c) => c.json({
54
- messages: store.list().map(toMessageSummary)
53
+ app.get('/api/messages', async (c) => c.json({
54
+ messages: (await store.list()).map(toMessageSummary)
55
55
  }));
56
- app.delete('/api/messages', (c) => {
57
- const deleted = store.clear();
56
+ app.delete('/api/messages', async (c) => {
57
+ const deleted = await store.clear();
58
58
  return c.json({ deleted });
59
59
  });
60
- app.get('/api/messages/:id', (c) => {
61
- const message = store.get(c.req.param('id'));
60
+ app.get('/api/messages/:id', async (c) => {
61
+ const message = await store.get(c.req.param('id'));
62
62
  if (!message) {
63
63
  return c.json({ error: 'Message not found' }, 404);
64
64
  }
65
65
  return c.json({ message: toMessageDetail(message) });
66
66
  });
67
- app.delete('/api/messages/:id', (c) => {
68
- const deleted = store.delete(c.req.param('id'));
67
+ app.get('/api/messages/:id/attachments/:attachmentId', async (c) => {
68
+ const messageId = c.req.param('id');
69
+ const attachmentId = c.req.param('attachmentId');
70
+ const message = await store.get(messageId);
71
+ if (!message) {
72
+ return c.json({ error: 'Message not found' }, 404);
73
+ }
74
+ const attachment = message.attachments.find((item) => item.id === attachmentId);
75
+ if (!attachment) {
76
+ return c.json({ error: 'Attachment not found' }, 404);
77
+ }
78
+ const storedAttachment = await attachmentStore.get(messageId, attachmentId);
79
+ if (!storedAttachment) {
80
+ return c.json({ error: 'Attachment not found' }, 404);
81
+ }
82
+ return new Response(storedAttachment.content, {
83
+ headers: attachmentHeaders(attachment)
84
+ });
85
+ });
86
+ app.delete('/api/messages/:id', async (c) => {
87
+ const deleted = await store.delete(c.req.param('id'));
69
88
  return c.json({ deleted }, deleted ? 200 : 404);
70
89
  });
71
90
  app.all('*', (c) => serveStatic(new URL(c.req.url).pathname));
@@ -135,3 +154,14 @@ function contentTypeFor(filePath) {
135
154
  }
136
155
  return 'application/octet-stream';
137
156
  }
157
+ function attachmentHeaders(attachment) {
158
+ const headers = new Headers();
159
+ headers.set('content-type', attachment.contentType);
160
+ headers.set('content-length', String(attachment.sizeBytes));
161
+ headers.set('content-disposition', contentDispositionFor(attachment.filename));
162
+ return headers;
163
+ }
164
+ function contentDispositionFor(filename) {
165
+ const safeFilename = (filename || 'attachment').replace(/[^\w.!#$&+^`{}~-]+/g, '_');
166
+ return `attachment; filename="${safeFilename}"`;
167
+ }
package/dist/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { createHttpServer } from './http/server.js';
2
+ import { createFileAttachmentStore, createMemoryAttachmentStore } from './store/attachment-store.js';
3
+ import { getDefaultDataDir } from './store/data-dir.js';
4
+ import { createFileMessageStore } from './store/file-store.js';
2
5
  import { createMemoryStore } from './store/memory-store.js';
3
6
  import { createSmtpServer } from './smtp/server.js';
4
7
  export async function startApp(config) {
5
- const store = createMemoryStore({ maxMessages: config.maxMessages });
6
- const smtpServer = createSmtpServer({ store });
7
- const httpServer = createHttpServer({ store });
8
+ const { store, attachmentStore } = createStores(config);
9
+ const smtpServer = createSmtpServer({ store, attachmentStore });
10
+ const httpServer = createHttpServer({ store, attachmentStore });
8
11
  await Promise.all([
9
12
  smtpServer.listen(config.smtpPort, config.smtpHost),
10
13
  httpServer.listen(config.httpPort, config.httpHost)
@@ -16,3 +19,25 @@ export async function startApp(config) {
16
19
  }
17
20
  };
18
21
  }
22
+ function createStores(config) {
23
+ if (config.storage === 'memory') {
24
+ const attachmentStore = createMemoryAttachmentStore();
25
+ return {
26
+ attachmentStore,
27
+ store: createMemoryStore({
28
+ maxMessages: config.maxMessages,
29
+ onDelete: (messageId) => attachmentStore.deleteForMessage(messageId)
30
+ })
31
+ };
32
+ }
33
+ const dataDir = getDefaultDataDir();
34
+ const attachmentStore = createFileAttachmentStore({ rootDir: dataDir });
35
+ return {
36
+ attachmentStore,
37
+ store: createFileMessageStore({
38
+ rootDir: dataDir,
39
+ maxMessages: config.maxMessages,
40
+ onDelete: (messageId) => attachmentStore.deleteForMessage(messageId)
41
+ })
42
+ };
43
+ }
@@ -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,6 +1,6 @@
1
1
  import { SMTPServer } from 'smtp-server';
2
2
  import { parseMessage } from '../mail/parser.js';
3
- export function createSmtpServer({ store }) {
3
+ export function createSmtpServer({ store, attachmentStore }) {
4
4
  const server = new SMTPServer({
5
5
  name: 'inbrx',
6
6
  banner: 'inbrx ready',
@@ -15,7 +15,7 @@ export function createSmtpServer({ store }) {
15
15
  callback();
16
16
  },
17
17
  onData(stream, session, callback) {
18
- void handleData(stream, session, store)
18
+ void handleData(stream, session, store, attachmentStore)
19
19
  .then((messageId) => callback(null, `OK captured as ${messageId}`))
20
20
  .catch((error) => callback(error instanceof Error ? error : new Error(String(error))));
21
21
  }
@@ -37,12 +37,18 @@ export function createSmtpServer({ store }) {
37
37
  }
38
38
  };
39
39
  }
40
- async function handleData(stream, session, store) {
40
+ async function handleData(stream, session, store, attachmentStore) {
41
41
  const raw = await readStream(stream);
42
42
  const smtp = toCapturedSmtpSession(session);
43
43
  const envelope = toMailEnvelope(smtp);
44
- const message = await parseMessage({ raw, envelope, smtp });
45
- store.add(message);
44
+ const message = await parseMessage({ raw, envelope, smtp, attachmentStore });
45
+ try {
46
+ await store.add(message);
47
+ }
48
+ catch (error) {
49
+ await attachmentStore.deleteForMessage(message.id);
50
+ throw error;
51
+ }
46
52
  return message.id;
47
53
  }
48
54
  function readStream(stream) {
@@ -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);
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)));
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);
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);
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);
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)));
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.3",
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"
@@ -23,11 +23,12 @@
23
23
  "dev": "tsx watch ./src/cli.ts start",
24
24
  "test": "vitest run --exclude \"dist/**\"",
25
25
  "test:watch": "vitest",
26
+ "coverage": "vitest run --coverage --coverage.reporter=lcov --exclude \"dist/**\"",
26
27
  "check": "tsc --noEmit -p tsconfig.json && node --check ./bin/inbrx.js && npm run test"
27
28
  },
28
29
  "dependencies": {
29
30
  "@hono/node-server": "^2.0.8",
30
- "@inbrx/web": "0.0.1-alpha.2",
31
+ "@inbrx/web": "0.0.1-alpha.3",
31
32
  "commander": "^15.0.0",
32
33
  "hono": "^4.12.27",
33
34
  "mailparser": "^3.9.14",
@@ -37,6 +38,7 @@
37
38
  "@types/mailparser": "^3.4.6",
38
39
  "@types/nodemailer": "^8.0.1",
39
40
  "@types/smtp-server": "^3.5.13",
41
+ "@vitest/coverage-v8": "^4.1.9",
40
42
  "nodemailer": "^9.0.3",
41
43
  "tsx": "^4.19.0",
42
44
  "vitest": "^4.1.9"