Skip to main content
Fumi uses a powerful Koa-style middleware system that lets you compose request handlers for different phases of the SMTP conversation. Each middleware function receives a context object and a next function, allowing you to build flexible, composable logic.

Middleware Pattern

Middleware functions follow the (ctx, next) pattern:
import { Fumi } from "@puiusabin/fumi";

const app = new Fumi();

app.onMailFrom(async (ctx, next) => {
  // Code before next() runs before downstream middleware
  console.log(`Sender: ${ctx.address.address}`);
  
  // Call next() to pass control to the next middleware
  await next();
  
  // Code after next() runs after downstream middleware completes
  console.log('MailFrom processing complete');
});
Always await next() to ensure downstream middleware completes before continuing execution.

Middleware Composition

Middleware chains execute in registration order. Fumi uses an internal compose function (~/workspace/source/src/compose.ts:3) to build these chains:
const app = new Fumi({ authOptional: true });

// Execution flows: 1 → 2 → 3 → 2 → 1
app.onMailFrom(async (ctx, next) => {
  console.log('1: before');
  await next();
  console.log('1: after');
});

app.onMailFrom(async (ctx, next) => {
  console.log('2: before');
  await next();
  console.log('2: after');
});

app.onMailFrom(async (ctx, next) => {
  console.log('3: final handler');
  // No next() call - end of chain
});
Output:
1: before
2: before
3: final handler
2: after
1: after
Calling next() multiple times in the same middleware will throw an error: "next() called multiple times"

SMTP Phases

Fumi provides middleware hooks for six distinct SMTP phases:

1. Connect (onConnect)

Runs when a client first connects to the server.
app.onConnect(async (ctx, next) => {
  console.log(`Connection from ${ctx.session.remoteAddress}`);
  
  // Block connections from specific IPs
  if (ctx.session.remoteAddress === '192.0.2.1') {
    ctx.reject('Access denied', 550);
  }
  
  await next();
});
See: ~/workspace/source/src/fumi.ts:118

2. Auth (onAuth)

Handles SMTP authentication. Must call ctx.accept() to allow authentication.
app.onAuth(async (ctx, next) => {
  const { username, password } = ctx.credentials;
  
  if (username === 'admin' && password === 'secret') {
    ctx.accept({ id: 1, name: 'Admin' });
  } else {
    ctx.reject('Invalid credentials', 535);
  }
  
  await next();
});
See: ~/workspace/source/src/fumi.ts:133

3. Mail From (onMailFrom)

Processes the MAIL FROM command and sender address.
app.onMailFrom(async (ctx, next) => {
  const sender = ctx.address.address;
  
  // Block specific sender domains
  if (sender.endsWith('@spam.example')) {
    ctx.reject('Sender domain blocked', 550);
  }
  
  await next();
});
See: ~/workspace/source/src/fumi.ts:166

4. Recipient To (onRcptTo)

Handles each RCPT TO command. Called once per recipient.
app.onRcptTo(async (ctx, next) => {
  const recipient = ctx.address.address;
  
  // Only accept mail for specific domains
  if (!recipient.endsWith('@mycompany.com')) {
    ctx.reject('Recipient not accepted', 550);
  }
  
  await next();
});
See: ~/workspace/source/src/fumi.ts:183

5. Data (onData)

Processes the message body after the DATA command.
app.onData(async (ctx, next) => {
  // Check if message exceeded size limit
  if (ctx.sizeExceeded) {
    ctx.reject('Message too large', 552);
  }
  
  // Read the message stream
  const chunks: Uint8Array[] = [];
  for await (const chunk of ctx.stream) {
    chunks.push(chunk);
  }
  const body = Buffer.concat(chunks).toString();
  
  console.log('Received message:', body);
  await next();
});
See: ~/workspace/source/src/fumi.ts:200

6. Close (onClose)

Fires when the connection closes. Fire-and-forget - errors are swallowed.
app.onClose(async (ctx) => {
  console.log(`Session ${ctx.session.id} ended`);
  // Cleanup resources, log analytics, etc.
});
onClose is fire-and-forget. It doesn’t support ctx.reject() and errors are silently caught.
See: ~/workspace/source/src/fumi.ts:220

Building Plugins

Middleware can be packaged as reusable plugins:
import type { Fumi } from '@puiusabin/fumi';

// Plugin function receives the Fumi instance
export function denylist(blockedIps: string[]) {
  return (app: Fumi) => {
    app.onConnect(async (ctx, next) => {
      if (blockedIps.includes(ctx.session.remoteAddress)) {
        ctx.reject('Access denied', 550);
      }
      await next();
    });
  };
}

// Usage
const app = new Fumi();
app.use(denylist(['192.0.2.1', '192.0.2.2']));

Real-World Examples

Logging Middleware

app.onConnect(async (ctx, next) => {
  console.log(`[${ctx.session.id}] Connected: ${ctx.session.remoteAddress}`);
  await next();
});

app.onMailFrom(async (ctx, next) => {
  console.log(`[${ctx.session.id}] MAIL FROM: ${ctx.address.address}`);
  await next();
});

app.onRcptTo(async (ctx, next) => {
  console.log(`[${ctx.session.id}] RCPT TO: ${ctx.address.address}`);
  await next();
});

Rate Limiting

const connections = new Map<string, number>();

app.onConnect(async (ctx, next) => {
  const ip = ctx.session.remoteAddress;
  const count = (connections.get(ip) || 0) + 1;
  
  if (count > 10) {
    ctx.reject('Too many connections', 421);
  }
  
  connections.set(ip, count);
  await next();
});

app.onClose(async (ctx) => {
  const ip = ctx.session.remoteAddress;
  const count = connections.get(ip) || 0;
  connections.set(ip, Math.max(0, count - 1));
});

Content Filtering

app.onData(async (ctx, next) => {
  const chunks: Uint8Array[] = [];
  for await (const chunk of ctx.stream) {
    chunks.push(chunk);
  }
  const message = Buffer.concat(chunks).toString();
  
  // Check for spam patterns
  if (message.includes('CLICK HERE NOW')) {
    ctx.reject('Message rejected as spam', 550);
  }
  
  await next();
});

Performance Considerations

Fumi only builds middleware runners for phases that have registered middleware (~/workspace/source/src/fumi.ts:107). Empty phases fall back to the underlying SMTP server’s default behavior, avoiding unnecessary Promise allocation overhead.

Next Steps

Build docs developers (and LLMs) love