Skip to main content
Plugins in Fumi are simple functions that register middleware. The plugin pattern (app: Fumi) => void makes it easy to package and share reusable SMTP functionality.

Plugin Basics

A plugin is a function that receives a Fumi instance and registers middleware:
import type { Plugin } from "@puiusabin/fumi";

export function myPlugin(): Plugin {
  return (app) => {
    app.onConnect(async (ctx, next) => {
      // Your logic here
      await next();
    });
  };
}
Use the plugin with app.use():
import { Fumi } from "@puiusabin/fumi";
import { myPlugin } from "./plugins/my-plugin";

const app = new Fumi();
app.use(myPlugin());

await app.listen(25);

Plugin Type

The Plugin type is defined as:
export type Plugin = (app: Fumi) => void;
Plugins are synchronous functions that register middleware. They don’t return a value.

Built-in Plugin Examples

Fumi includes several plugins that demonstrate best practices:

Logger Plugin

Logs SMTP events to stdout:
import type { Plugin } from "@puiusabin/fumi";

export function logger(): Plugin {
  return (app) => {
    app.onConnect(async (ctx, next) => {
      console.log(`[connect] ${ctx.session.remoteAddress}`);
      await next();
    });

    app.onMailFrom(async (ctx, next) => {
      console.log(`[mail from] ${ctx.address.address}`);
      await next();
    });

    app.onRcptTo(async (ctx, next) => {
      console.log(`[rcpt to] ${ctx.address.address}`);
      await next();
    });

    app.onClose(async (ctx) => {
      console.log(`[close] ${ctx.session.remoteAddress}`);
    });
  };
}
Usage:
import { logger } from "@puiusabin/fumi/plugins";

app.use(logger());

Denylist Plugin

Blocks connections from specific IP addresses:
import type { Plugin } from "@puiusabin/fumi";

export function denylist(ips: string[]): Plugin {
  const blocked = new Set(ips);
  
  return (app) => {
    app.onConnect(async (ctx, next) => {
      if (blocked.has(ctx.session.remoteAddress)) {
        ctx.reject("Connection refused", 550);
      }
      await next();
    });
  };
}
Usage:
import { denylist } from "@puiusabin/fumi/plugins";

app.use(denylist(["192.168.1.100", "10.0.0.50"]));

Sender Block Plugin

Rejects mail from specific sender domains:
import type { Plugin } from "@puiusabin/fumi";

export function senderBlock(domains: string[]): Plugin {
  const blocked = new Set(domains.map((d) => d.toLowerCase()));
  
  return (app) => {
    app.onMailFrom(async (ctx, next) => {
      const domain = ctx.address.address.split("@")[1]?.toLowerCase() ?? "";
      if (blocked.has(domain)) {
        ctx.reject(`Mail from ${domain} is not accepted`, 550);
      }
      await next();
    });
  };
}
Usage:
import { senderBlock } from "@puiusabin/fumi/plugins";

app.use(senderBlock(["spam.example", "blocked.org"]));

Recipient Filter Plugin

Only accepts recipients in allowed domains:
import type { Plugin } from "@puiusabin/fumi";

export function rcptFilter(allowedDomains: string[]): Plugin {
  const allowed = new Set(allowedDomains.map((d) => d.toLowerCase()));
  
  return (app) => {
    app.onRcptTo(async (ctx, next) => {
      const domain = ctx.address.address.split("@")[1]?.toLowerCase() ?? "";
      if (!allowed.has(domain)) {
        ctx.reject(`Recipient domain ${domain} not accepted`, 550);
      }
      await next();
    });
  };
}
Usage:
import { rcptFilter } from "@puiusabin/fumi/plugins";

app.use(rcptFilter(["mycompany.com", "subsidiary.com"]));

Max Size Plugin

Rejects messages exceeding a byte limit:
import type { Plugin } from "@puiusabin/fumi";

export function maxSize(bytes: number): Plugin {
  return (app) => {
    app.onData(async (ctx, next) => {
      await next();
      await ctx.stream.pipeTo(new WritableStream());
      if (ctx.sizeExceeded) {
        ctx.reject(`Message exceeds the maximum size of ${bytes} bytes`, 552);
      }
    });
  };
}
Usage:
import { maxSize } from "@puiusabin/fumi/plugins";

const app = new Fumi({ size: 1_000_000 });
app.use(maxSize(1_000_000));
The maxSize plugin requires FumiOptions.size to be set to the same value for size tracking to work.

Require TLS Plugin

Rejects MAIL FROM on unencrypted connections:
import type { Plugin } from "@puiusabin/fumi";

export function requireTls(): Plugin {
  return (app) => {
    app.onMailFrom(async (ctx, next) => {
      if (!ctx.session.secure) {
        ctx.reject("Must issue STARTTLS first", 530);
      }
      await next();
    });
  };
}
Usage:
import { requireTls } from "@puiusabin/fumi/plugins";

app.use(requireTls());

Creating Custom Plugins

Plugin with Configuration

Plugins can accept parameters:
import type { Plugin } from "@puiusabin/fumi";

interface RateLimitOptions {
  maxConnections: number;
  windowMs: number;
}

export function rateLimit(options: RateLimitOptions): Plugin {
  const connections = new Map<string, number[]>();
  
  return (app) => {
    app.onConnect(async (ctx, next) => {
      const ip = ctx.session.remoteAddress;
      const now = Date.now();
      const window = options.windowMs;
      
      // Get recent connections from this IP
      const timestamps = connections.get(ip) || [];
      const recent = timestamps.filter(t => now - t < window);
      
      if (recent.length >= options.maxConnections) {
        ctx.reject("Rate limit exceeded", 421);
      }
      
      // Record this connection
      recent.push(now);
      connections.set(ip, recent);
      
      await next();
    });
  };
}
Usage:
app.use(rateLimit({
  maxConnections: 10,
  windowMs: 60000  // 1 minute
}));

Plugin with Multiple Hooks

Plugins can register middleware for multiple phases:
import type { Plugin } from "@puiusabin/fumi";

interface MetricsCollector {
  recordConnection(ip: string): void;
  recordMessage(from: string, to: string[]): void;
}

export function metrics(collector: MetricsCollector): Plugin {
  return (app) => {
    app.onConnect(async (ctx, next) => {
      collector.recordConnection(ctx.session.remoteAddress);
      await next();
    });
    
    app.onData(async (ctx, next) => {
      const { mailFrom, rcptTo } = ctx.session.envelope;
      collector.recordMessage(
        mailFrom.address,
        rcptTo.map(r => r.address)
      );
      await next();
    });
  };
}

Plugin with State

Plugins can maintain internal state:
import type { Plugin } from "@puiusabin/fumi";

export function greylisting(): Plugin {
  const seen = new Map<string, Date>();
  const approved = new Set<string>();
  const delayMinutes = 5;
  
  return (app) => {
    app.onMailFrom(async (ctx, next) => {
      const key = `${ctx.session.remoteAddress}:${ctx.address.address}`;
      
      if (approved.has(key)) {
        // Already approved
        await next();
        return;
      }
      
      const firstSeen = seen.get(key);
      
      if (!firstSeen) {
        // First attempt - record and reject
        seen.set(key, new Date());
        ctx.reject("Please try again later", 451);
      }
      
      const elapsed = Date.now() - firstSeen.getTime();
      const delayMs = delayMinutes * 60 * 1000;
      
      if (elapsed < delayMs) {
        // Too soon
        ctx.reject("Please try again later", 451);
      }
      
      // Approve and remember
      approved.add(key);
      await next();
    });
  };
}

Best Practices

1
Return a Plugin Function
2
Always return (app: Fumi) => void:
3
// Good
export function myPlugin(): Plugin {
  return (app) => {
    app.onConnect(async (ctx, next) => {
      await next();
    });
  };
}

// Bad - not a function
export const myPlugin: Plugin = (app) => {
  app.onConnect(async (ctx, next) => {
    await next();
  });
};
4
Use TypeScript
5
Type your plugins for better developer experience:
6
import type { Plugin, ConnectContext } from "@puiusabin/fumi";

export function myPlugin(): Plugin {
  return (app) => {
    app.onConnect(async (ctx: ConnectContext, next) => {
      // ctx is fully typed
      console.log(ctx.session.remoteAddress);
      await next();
    });
  };
}
7
Always Call next()
8
Unless you’re rejecting, always call await next():
9
export function myPlugin(): Plugin {
  return (app) => {
    app.onConnect(async (ctx, next) => {
      // Do work before subsequent middleware
      console.log("Before");
      
      await next();  // Let other middleware run
      
      // Do work after subsequent middleware
      console.log("After");
    });
  };
}
10
Handle Errors Gracefully
11
Use try-catch for error handling:
12
export function myPlugin(): Plugin {
  return (app) => {
    app.onData(async (ctx, next) => {
      try {
        await processMessage(ctx);
        await next();
      } catch (err) {
        console.error("Processing failed:", err);
        ctx.reject("Processing error", 451);
      }
    });
  };
}
13
Document Your Plugin
14
Provide JSDoc comments:
15
/**
 * Blocks connections from IP addresses in a denylist.
 * 
 * @example
 * app.use(denylist(["192.168.1.100", "10.0.0.50"]))
 * 
 * @param ips - Array of IP addresses to block
 * @returns Plugin that rejects blocked IPs with code 550
 */
export function denylist(ips: string[]): Plugin {
  const blocked = new Set(ips);
  return (app) => {
    app.onConnect(async (ctx, next) => {
      if (blocked.has(ctx.session.remoteAddress)) {
        ctx.reject("Connection refused", 550);
      }
      await next();
    });
  };
}
16
Make Plugins Configurable
17
Accept options for flexibility:
18
interface DenylistOptions {
  ips: string[];
  message?: string;
  code?: number;
}

export function denylist(options: DenylistOptions): Plugin {
  const blocked = new Set(options.ips);
  const message = options.message || "Connection refused";
  const code = options.code || 550;
  
  return (app) => {
    app.onConnect(async (ctx, next) => {
      if (blocked.has(ctx.session.remoteAddress)) {
        ctx.reject(message, code);
      }
      await next();
    });
  };
}

Testing Plugins

Test plugins by creating a Fumi instance and using the smtpTalk helper:
import { test, expect } from "bun:test";
import { Fumi } from "@puiusabin/fumi";
import { denylist } from "./denylist";

test("denylist blocks IPs", async () => {
  const app = new Fumi({ authOptional: true });
  app.use(denylist(["127.0.0.1"]));
  await app.listen(12525);
  
  const responses = await smtpTalk(12525, []);
  expect(responses[0]).toContain("550");
  
  await app.close();
});

Publishing Plugins

Package your plugin as an npm module:
{
  "name": "@yourname/fumi-plugin-example",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": "./dist/index.js"
  },
  "types": "./dist/index.d.ts",
  "peerDependencies": {
    "@puiusabin/fumi": "^1.0.0"
  }
}
Export your plugin:
export { myPlugin } from "./my-plugin";
export type { MyPluginOptions } from "./my-plugin";
Users can install and use it:
bun add @yourname/fumi-plugin-example
import { Fumi } from "@puiusabin/fumi";
import { myPlugin } from "@yourname/fumi-plugin-example";

const app = new Fumi();
app.use(myPlugin());

Plugin Composition

Combine multiple plugins:
import type { Plugin } from "@puiusabin/fumi";

export function securitySuite(): Plugin {
  return (app) => {
    // Apply multiple plugins
    app.use(denylist(["192.168.1.100"]));
    app.use(requireTls());
    app.use(rateLimit({ maxConnections: 10, windowMs: 60000 }));
  };
}
Usage:
app.use(securitySuite());

Next Steps

Build docs developers (and LLMs) love