@inbrx/cli 0.0.1-alpha.2

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/bin/inbrx.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
8
+ const cliPath = resolve(root, 'dist/cli.js');
9
+
10
+ if (!existsSync(cliPath)) {
11
+ console.error('Build output is missing. Run "npm run build" before using the CLI.');
12
+ process.exit(1);
13
+ }
14
+
15
+ const { runCli } = await import('../dist/cli.js');
16
+
17
+ runCli(process.argv).catch((error) => {
18
+ console.error(error instanceof Error ? error.message : String(error));
19
+ process.exitCode = 1;
20
+ });
package/dist/cli.js ADDED
@@ -0,0 +1,67 @@
1
+ import { Command } from 'commander';
2
+ import { getDefaultConfig, loadConfig } from './config.js';
3
+ import { startApp } from './index.js';
4
+ export async function runCli(argv) {
5
+ const program = createCliProgram();
6
+ await program.parseAsync(normalizeArgv(argv), { from: 'node' });
7
+ }
8
+ export function createCliProgram() {
9
+ const defaults = getDefaultConfig();
10
+ const program = new Command();
11
+ program
12
+ .name('inbrx')
13
+ .description('Start a local SMTP capture server and web UI.')
14
+ .showHelpAfterError()
15
+ .showSuggestionAfterError();
16
+ program
17
+ .command('start')
18
+ .description('Start the SMTP server and HTTP API/UI.')
19
+ .option('--smtp-host <host>', `SMTP bind host. Default: ${defaults.smtpHost}`)
20
+ .option('--smtp-port <port>', `SMTP port. Default: ${defaults.smtpPort}`)
21
+ .option('--http-host <host>', `HTTP bind host. Default: ${defaults.httpHost}`)
22
+ .option('--http-port <port>', `HTTP port. Default: ${defaults.httpPort}`)
23
+ .option('--max-messages <count>', `Maximum retained messages. Default: ${defaults.maxMessages}`)
24
+ .addHelpText('after', `
25
+
26
+ Environment:
27
+ SMTP_TEST_SMTP_HOST
28
+ SMTP_TEST_SMTP_PORT
29
+ SMTP_TEST_HTTP_HOST
30
+ SMTP_TEST_HTTP_PORT
31
+ SMTP_TEST_MAX_MESSAGES`)
32
+ .action(async (options) => {
33
+ await startServer(options);
34
+ });
35
+ return program;
36
+ }
37
+ async function startServer(options) {
38
+ const config = loadConfig(options);
39
+ const app = await startApp(config);
40
+ console.log('SMTP test server ready');
41
+ console.log(`SMTP: smtp://${config.smtpHost}:${config.smtpPort}`);
42
+ console.log(`Web UI: http://${config.httpHost}:${config.httpPort}`);
43
+ console.log('Press Ctrl+C to stop.');
44
+ const shutdown = async () => {
45
+ console.log('\nShutting down...');
46
+ await app.stop();
47
+ process.exit(0);
48
+ };
49
+ process.once('SIGINT', shutdown);
50
+ process.once('SIGTERM', shutdown);
51
+ }
52
+ function normalizeArgv(argv) {
53
+ const firstArg = argv[2];
54
+ if (!firstArg) {
55
+ return [...argv, 'start'];
56
+ }
57
+ if (firstArg.startsWith('-') && firstArg !== '-h' && firstArg !== '--help') {
58
+ return [argv[0] || 'node', argv[1] || 'inbrx', 'start', ...argv.slice(2)];
59
+ }
60
+ return argv;
61
+ }
62
+ if (import.meta.url === `file://${process.argv[1]}`) {
63
+ runCli(process.argv).catch((error) => {
64
+ console.error(error instanceof Error ? error.message : String(error));
65
+ process.exitCode = 1;
66
+ });
67
+ }
package/dist/config.js ADDED
@@ -0,0 +1,50 @@
1
+ const DEFAULTS = {
2
+ smtpHost: '127.0.0.1',
3
+ smtpPort: 2525,
4
+ httpHost: '127.0.0.1',
5
+ httpPort: 3000,
6
+ maxMessages: 500
7
+ };
8
+ export function loadConfig(overrides = {}) {
9
+ const config = {
10
+ smtpHost: readString(overrides.smtpHost, process.env.SMTP_TEST_SMTP_HOST, DEFAULTS.smtpHost),
11
+ smtpPort: readNumber(overrides.smtpPort, process.env.SMTP_TEST_SMTP_PORT, DEFAULTS.smtpPort),
12
+ httpHost: readString(overrides.httpHost, process.env.SMTP_TEST_HTTP_HOST, DEFAULTS.httpHost),
13
+ 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
+ };
16
+ assertPort(config.smtpPort, 'SMTP port');
17
+ assertPort(config.httpPort, 'HTTP port');
18
+ if (!Number.isInteger(config.maxMessages) || config.maxMessages < 1) {
19
+ throw new Error('Max messages must be a positive integer.');
20
+ }
21
+ return config;
22
+ }
23
+ export function getDefaultConfig() {
24
+ return { ...DEFAULTS };
25
+ }
26
+ function readString(override, envValue, fallback) {
27
+ if (override !== undefined && override !== '') {
28
+ return String(override);
29
+ }
30
+ if (envValue !== undefined && envValue !== '') {
31
+ return envValue;
32
+ }
33
+ return fallback;
34
+ }
35
+ function readNumber(override, envValue, fallback) {
36
+ const value = override ?? envValue;
37
+ if (value === undefined || value === '') {
38
+ return fallback;
39
+ }
40
+ const parsed = Number(value);
41
+ if (!Number.isInteger(parsed)) {
42
+ throw new Error(`Expected an integer, received "${value}".`);
43
+ }
44
+ return parsed;
45
+ }
46
+ function assertPort(value, label) {
47
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
48
+ throw new Error(`${label} must be an integer between 1 and 65535.`);
49
+ }
50
+ }
@@ -0,0 +1,137 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { serve } from '@hono/node-server';
7
+ import { Hono } from 'hono';
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const WEB_ROOT = path.resolve(__dirname, '../../..', 'web/dist');
10
+ export function createHttpServer({ store }) {
11
+ const app = createHttpApp({ store });
12
+ let server = null;
13
+ return {
14
+ listen(port, host) {
15
+ return new Promise((resolve, reject) => {
16
+ const listeningServer = serve({
17
+ fetch: app.fetch,
18
+ hostname: host,
19
+ port
20
+ }, () => {
21
+ listeningServer.off('error', reject);
22
+ server = listeningServer;
23
+ resolve();
24
+ });
25
+ listeningServer.once('error', reject);
26
+ });
27
+ },
28
+ close() {
29
+ return new Promise((resolve, reject) => {
30
+ if (!server) {
31
+ resolve();
32
+ return;
33
+ }
34
+ server.close((error) => {
35
+ if (error) {
36
+ reject(error);
37
+ return;
38
+ }
39
+ server = null;
40
+ resolve();
41
+ });
42
+ });
43
+ }
44
+ };
45
+ }
46
+ export function createHttpApp({ store }) {
47
+ const app = new Hono();
48
+ app.onError((error, c) => {
49
+ console.error(error);
50
+ return c.json({ error: 'Internal server error' }, 500);
51
+ });
52
+ app.get('/api/health', (c) => c.json({ status: 'ok' }));
53
+ app.get('/api/messages', (c) => c.json({
54
+ messages: store.list().map(toMessageSummary)
55
+ }));
56
+ app.delete('/api/messages', (c) => {
57
+ const deleted = store.clear();
58
+ return c.json({ deleted });
59
+ });
60
+ app.get('/api/messages/:id', (c) => {
61
+ const message = store.get(c.req.param('id'));
62
+ if (!message) {
63
+ return c.json({ error: 'Message not found' }, 404);
64
+ }
65
+ return c.json({ message: toMessageDetail(message) });
66
+ });
67
+ app.delete('/api/messages/:id', (c) => {
68
+ const deleted = store.delete(c.req.param('id'));
69
+ return c.json({ deleted }, deleted ? 200 : 404);
70
+ });
71
+ app.all('*', (c) => serveStatic(new URL(c.req.url).pathname));
72
+ return app;
73
+ }
74
+ function toMessageSummary(message) {
75
+ return {
76
+ id: message.id,
77
+ receivedAt: message.receivedAt,
78
+ from: message.from,
79
+ to: message.to,
80
+ subject: message.subject,
81
+ rawSizeBytes: message.rawSizeBytes
82
+ };
83
+ }
84
+ function toMessageDetail(message) {
85
+ return {
86
+ ...toMessageSummary(message),
87
+ cc: message.cc,
88
+ bcc: message.bcc,
89
+ headers: message.headers,
90
+ text: message.text,
91
+ html: message.html,
92
+ attachments: message.attachments,
93
+ raw: message.raw,
94
+ smtp: message.smtp
95
+ };
96
+ }
97
+ async function serveStatic(urlPath) {
98
+ const relativePath = urlPath === '/' ? 'index.html' : decodeURIComponent(urlPath.slice(1));
99
+ const filePath = path.resolve(WEB_ROOT, relativePath);
100
+ if (!filePath.startsWith(`${WEB_ROOT}${path.sep}`) && filePath !== WEB_ROOT) {
101
+ return textResponse('Forbidden', 403);
102
+ }
103
+ try {
104
+ const stats = await stat(filePath);
105
+ if (!stats.isFile()) {
106
+ return textResponse('Not found', 404);
107
+ }
108
+ }
109
+ catch {
110
+ return textResponse('Not found', 404);
111
+ }
112
+ return new Response(Readable.toWeb(createReadStream(filePath)), {
113
+ headers: {
114
+ 'content-type': contentTypeFor(filePath)
115
+ }
116
+ });
117
+ }
118
+ function textResponse(text, statusCode) {
119
+ return new Response(text, {
120
+ status: statusCode,
121
+ headers: {
122
+ 'content-type': 'text/plain; charset=utf-8'
123
+ }
124
+ });
125
+ }
126
+ function contentTypeFor(filePath) {
127
+ if (filePath.endsWith('.html')) {
128
+ return 'text/html; charset=utf-8';
129
+ }
130
+ if (filePath.endsWith('.css')) {
131
+ return 'text/css; charset=utf-8';
132
+ }
133
+ if (filePath.endsWith('.js')) {
134
+ return 'text/javascript; charset=utf-8';
135
+ }
136
+ return 'application/octet-stream';
137
+ }
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ import { createHttpServer } from './http/server.js';
2
+ import { createMemoryStore } from './store/memory-store.js';
3
+ import { createSmtpServer } from './smtp/server.js';
4
+ export async function startApp(config) {
5
+ const store = createMemoryStore({ maxMessages: config.maxMessages });
6
+ const smtpServer = createSmtpServer({ store });
7
+ const httpServer = createHttpServer({ store });
8
+ await Promise.all([
9
+ smtpServer.listen(config.smtpPort, config.smtpHost),
10
+ httpServer.listen(config.httpPort, config.httpHost)
11
+ ]);
12
+ return {
13
+ store,
14
+ async stop() {
15
+ await Promise.allSettled([smtpServer.close(), httpServer.close()]);
16
+ }
17
+ };
18
+ }
@@ -0,0 +1,52 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { simpleParser } from 'mailparser';
3
+ export async function parseMessage({ raw, envelope, smtp }) {
4
+ const parsed = await simpleParser(Buffer.from(raw));
5
+ return {
6
+ id: randomUUID(),
7
+ receivedAt: new Date().toISOString(),
8
+ from: envelope.from || firstAddress(parsed.from),
9
+ to: envelope.to.length > 0 ? envelope.to : addressList(parsed.to),
10
+ cc: addressList(parsed.cc),
11
+ bcc: addressList(parsed.bcc),
12
+ subject: parsed.subject || null,
13
+ headers: headersToRecord(parsed.headers),
14
+ text: parsed.text || null,
15
+ 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
+ })),
22
+ rawSizeBytes: Buffer.byteLength(raw),
23
+ raw,
24
+ smtp
25
+ };
26
+ }
27
+ function headersToRecord(parsedHeaders) {
28
+ const record = {};
29
+ for (const [key, value] of parsedHeaders) {
30
+ record[key] = headerValueToString(value);
31
+ }
32
+ return record;
33
+ }
34
+ function headerValueToString(value) {
35
+ if (Array.isArray(value)) {
36
+ return value.map((item) => String(item));
37
+ }
38
+ if (value instanceof Date) {
39
+ return value.toISOString();
40
+ }
41
+ if (typeof value === 'object' && value !== null) {
42
+ return 'text' in value ? value.text : JSON.stringify(value);
43
+ }
44
+ return String(value);
45
+ }
46
+ function firstAddress(value) {
47
+ return value?.value.find((address) => Boolean(address.address))?.address || null;
48
+ }
49
+ function addressList(value) {
50
+ const values = Array.isArray(value) ? value : value ? [value] : [];
51
+ return values.flatMap((item) => item.value.map((address) => address.address).filter(Boolean));
52
+ }
@@ -0,0 +1,86 @@
1
+ import { SMTPServer } from 'smtp-server';
2
+ import { parseMessage } from '../mail/parser.js';
3
+ export function createSmtpServer({ store }) {
4
+ const server = new SMTPServer({
5
+ name: 'inbrx',
6
+ banner: 'inbrx ready',
7
+ authOptional: true,
8
+ disabledCommands: ['AUTH', 'STARTTLS'],
9
+ hidePIPELINING: true,
10
+ logger: false,
11
+ onMailFrom(_address, _session, callback) {
12
+ callback();
13
+ },
14
+ onRcptTo(_address, _session, callback) {
15
+ callback();
16
+ },
17
+ onData(stream, session, callback) {
18
+ void handleData(stream, session, store)
19
+ .then((messageId) => callback(null, `OK captured as ${messageId}`))
20
+ .catch((error) => callback(error instanceof Error ? error : new Error(String(error))));
21
+ }
22
+ });
23
+ return {
24
+ listen(port, host) {
25
+ return new Promise((resolve, reject) => {
26
+ server.once('error', reject);
27
+ server.listen(port, host, () => {
28
+ server.off('error', reject);
29
+ resolve();
30
+ });
31
+ });
32
+ },
33
+ close() {
34
+ return new Promise((resolve) => {
35
+ server.close(resolve);
36
+ });
37
+ }
38
+ };
39
+ }
40
+ async function handleData(stream, session, store) {
41
+ const raw = await readStream(stream);
42
+ const smtp = toCapturedSmtpSession(session);
43
+ const envelope = toMailEnvelope(smtp);
44
+ const message = await parseMessage({ raw, envelope, smtp });
45
+ store.add(message);
46
+ return message.id;
47
+ }
48
+ function readStream(stream) {
49
+ return new Promise((resolve, reject) => {
50
+ const chunks = [];
51
+ stream.on('data', (chunk) => {
52
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
53
+ });
54
+ stream.on('error', reject);
55
+ stream.on('end', () => {
56
+ resolve(Buffer.concat(chunks).toString('utf8'));
57
+ });
58
+ });
59
+ }
60
+ function toCapturedSmtpSession(session) {
61
+ return {
62
+ id: session.id,
63
+ remoteAddress: session.remoteAddress,
64
+ remotePort: session.remotePort,
65
+ clientHostname: session.clientHostname,
66
+ openingCommand: session.openingCommand,
67
+ hostNameAppearsAs: session.hostNameAppearsAs,
68
+ secure: session.secure,
69
+ envelope: {
70
+ mailFrom: session.envelope.mailFrom ? toCapturedAddress(session.envelope.mailFrom) : null,
71
+ rcptTo: session.envelope.rcptTo.map(toCapturedAddress)
72
+ }
73
+ };
74
+ }
75
+ function toCapturedAddress(address) {
76
+ return {
77
+ address: address.address,
78
+ args: address.args && typeof address.args === 'object' ? address.args : {}
79
+ };
80
+ }
81
+ function toMailEnvelope(smtp) {
82
+ return {
83
+ from: smtp.envelope.mailFrom?.address || null,
84
+ to: smtp.envelope.rcptTo.map((recipient) => recipient.address)
85
+ };
86
+ }
@@ -0,0 +1,37 @@
1
+ export function createMemoryStore({ maxMessages }) {
2
+ const messages = new Map();
3
+ const order = [];
4
+ return {
5
+ add(message) {
6
+ messages.set(message.id, message);
7
+ order.unshift(message.id);
8
+ while (order.length > maxMessages) {
9
+ const removedId = order.pop();
10
+ if (removedId) {
11
+ messages.delete(removedId);
12
+ }
13
+ }
14
+ return message;
15
+ },
16
+ list() {
17
+ return order.map((id) => messages.get(id)).filter((message) => Boolean(message));
18
+ },
19
+ get(id) {
20
+ return messages.get(id) || null;
21
+ },
22
+ delete(id) {
23
+ const existed = messages.delete(id);
24
+ const index = order.indexOf(id);
25
+ if (index !== -1) {
26
+ order.splice(index, 1);
27
+ }
28
+ return existed;
29
+ },
30
+ clear() {
31
+ const count = messages.size;
32
+ messages.clear();
33
+ order.length = 0;
34
+ return count;
35
+ }
36
+ };
37
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@inbrx/cli",
3
+ "version": "0.0.1-alpha.2",
4
+ "description": "SMTP capture server and CLI for inbrx.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/shuaixr/inbrx.git"
8
+ },
9
+ "type": "module",
10
+ "files": [
11
+ "bin",
12
+ "dist"
13
+ ],
14
+ "bin": {
15
+ "inbrx": "./bin/inbrx.js"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.build.json",
22
+ "start": "npm run build && node ./bin/inbrx.js start",
23
+ "dev": "tsx watch ./src/cli.ts start",
24
+ "test": "vitest run --exclude \"dist/**\"",
25
+ "test:watch": "vitest",
26
+ "check": "tsc --noEmit -p tsconfig.json && node --check ./bin/inbrx.js && npm run test"
27
+ },
28
+ "dependencies": {
29
+ "@hono/node-server": "^2.0.8",
30
+ "@inbrx/web": "0.0.1-alpha.2",
31
+ "commander": "^15.0.0",
32
+ "hono": "^4.12.27",
33
+ "mailparser": "^3.9.14",
34
+ "smtp-server": "^3.19.2"
35
+ },
36
+ "devDependencies": {
37
+ "@types/mailparser": "^3.4.6",
38
+ "@types/nodemailer": "^8.0.1",
39
+ "@types/smtp-server": "^3.5.13",
40
+ "nodemailer": "^9.0.3",
41
+ "tsx": "^4.19.0",
42
+ "vitest": "^4.1.9"
43
+ },
44
+ "engines": {
45
+ "node": ">=22.12.0"
46
+ }
47
+ }