Skip to main content

Overview

LMTP (Local Mail Transfer Protocol) is a variant of SMTP designed for local mail delivery. Unlike SMTP, LMTP provides per-recipient status responses, making it ideal for mail storage systems and local delivery agents.

Key Differences from SMTP

Per-Recipient Responses

LMTP returns a separate response for each recipient, allowing partial delivery success

LHLO Command

Uses LHLO instead of EHLO/HELO for session initialization

Required Success

Server must accept or reject each recipient individually

No Queueing

Designed for immediate delivery, not store-and-forward

Basic LMTP Server

Enable LMTP mode with the lmtp option:
1

Create LMTP server

const { SMTPServer } = require('smtp-server');

const server = new SMTPServer({
  // Enable LMTP mode
  lmtp: true,
  
  // Optional banner
  banner: 'Welcome to My Awesome LMTP Server',
  
  logger: true,
  
  onData(stream, session, callback) {
    stream.pipe(process.stdout);
    stream.on('end', () => {
      callback(null, true); // Accept for all recipients
    });
  }
});

server.listen(2524, '0.0.0.0');
2

Verify LMTP mode

Connect to test the LMTP greeting:
telnet localhost 2524
# 220 hostname LMTP Welcome to My Awesome LMTP Server
The server responds with “LMTP” instead of “SMTP” in the greeting from lib/smtp-server.js:316-323.

Complete LMTP Example

Here’s a full example from examples/lmtp.js:1-82:
const { SMTPServer } = require('smtp-server');

const SERVER_PORT = 2524;
const SERVER_HOST = '0.0.0.0';

const server = new SMTPServer({
  logger: true,
  lmtp: true,
  
  banner: 'Welcome to My Awesome LMTP Server',
  
  // LMTP servers typically don't use STARTTLS
  disabledCommands: ['STARTTLS', 'AUTH'],
  
  // Accept messages up to 10 MB
  size: 10 * 1024 * 1024,
  
  // Validate sender
  onMailFrom(address, session, callback) {
    if (/^deny/i.test(address.address)) {
      return callback(new Error('Not accepted'));
    }
    callback();
  },
  
  // Validate recipients with per-recipient responses
  onRcptTo(address, session, callback) {
    let err;
    
    if (/^deny/i.test(address.address)) {
      return callback(new Error('Not accepted'));
    }
    
    // Reject large messages to specific users (quota example)
    if (
      address.address.toLowerCase() === '[email protected]' &&
      Number(session.envelope.mailFrom.args.SIZE) > 100
    ) {
      err = new Error('Insufficient channel storage: ' + address.address);
      err.responseCode = 452;
      return callback(err);
    }
    
    callback();
  },
  
  // Handle message with per-recipient success
  onData(stream, session, callback) {
    stream.pipe(process.stdout);
    stream.on('end', () => {
      let err;
      if (stream.sizeExceeded) {
        err = new Error('Error: message exceeds fixed maximum message size 10 MB');
        err.responseCode = 552;
        return callback(err);
      }
      // Return true for LMTP per-recipient success
      callback(null, true);
    });
  }
});

server.on('error', err => {
  console.log('Error occurred');
  console.log(err);
});

server.listen(SERVER_PORT, SERVER_HOST);

LHLO vs EHLO/HELO

LMTP uses LHLO instead of EHLO/HELO. The server automatically enforces this:
const server = new SMTPServer({
  lmtp: true,
  
  onConnect(session, callback) {
    console.log('LMTP connection from:', session.remoteAddress);
    callback();
  }
});
From lib/smtp-connection.js:582-591, when lmtp: true:
  • EHLO and HELO commands are rejected with error 500
  • Only LHLO is accepted
  • LHLO is internally mapped to EHLO handling
Clients must use LHLO when connecting to an LMTP server. EHLO and HELO will be rejected.

Per-Recipient Responses

LMTP’s key feature is per-recipient status responses. Handle this in your onData callback:

Single Response for All Recipients

onData(stream, session, callback) {
  stream.on('end', () => {
    // Same response for all recipients
    callback(null, 'Message accepted');
  });
}

Different Response Per Recipient

onData(stream, session, callback) {
  const chunks = [];
  
  stream.on('data', chunk => chunks.push(chunk));
  
  stream.on('end', () => {
    const message = Buffer.concat(chunks);
    const responses = [];
    
    // Process each recipient individually
    session.envelope.rcptTo.forEach(recipient => {
      try {
        // Deliver to recipient's mailbox
        deliverToMailbox(recipient.address, message);
        
        // Success response
        responses.push(`Delivered to ${recipient.address}`);
      } catch (err) {
        // Failure response for this recipient
        const error = new Error(`Failed for ${recipient.address}`);
        error.responseCode = 450;
        responses.push(error);
      }
    });
    
    // Return array of responses
    callback(null, responses);
  });
}
From lib/smtp-connection.js:1725-1742, LMTP sends separate responses:
// For errors in LMTP mode
if (this._server.options.lmtp) {
  for (i = 0, len = this.session.envelope.rcptTo.length; i < len; i++) {
    this.send(err.responseCode || 450, err.message);
  }
}

// For success with array responses
message.forEach(response => {
  if (response instanceof Error) {
    this.send(response.responseCode || 450, response.message);
  } else {
    this.send(250, typeof response === 'string' ? response : 'OK: message accepted');
  }
});
When returning an array from onData, each element corresponds to a recipient in session.envelope.rcptTo order.

Error Handling in LMTP

Per-Recipient Errors

onData(stream, session, callback) {
  stream.on('end', () => {
    const responses = session.envelope.rcptTo.map(recipient => {
      // Check recipient status
      if (isMailboxFull(recipient.address)) {
        const err = new Error('Mailbox full');
        err.responseCode = 452; // Temporary failure
        return err;
      }
      
      if (!mailboxExists(recipient.address)) {
        const err = new Error('Mailbox not found');
        err.responseCode = 550; // Permanent failure
        return err;
      }
      
      return 'Message delivered';
    });
    
    callback(null, responses);
  });
}

Global Error

onData(stream, session, callback) {
  stream.on('end', () => {
    // Single error applies to all recipients
    const err = new Error('System temporarily unavailable');
    err.responseCode = 421;
    callback(err);
  });
}

Quota Management

LMTP is perfect for implementing per-user quotas:
onRcptTo(address, session, callback) {
  const mailbox = address.address.toLowerCase();
  const messageSize = Number(session.envelope.mailFrom.args.SIZE) || 0;
  
  // Check quota
  const quota = getMailboxQuota(mailbox);
  const usage = getMailboxUsage(mailbox);
  
  if (usage + messageSize > quota) {
    const err = new Error(
      `Insufficient channel storage: ${mailbox} (quota: ${quota} bytes)`
    );
    err.responseCode = 452; // Temporary failure
    return callback(err);
  }
  
  callback();
}

LMTP with Authentication

While uncommon, you can enable authentication in LMTP:
const server = new SMTPServer({
  lmtp: true,
  
  // Enable auth (remove from disabledCommands)
  authMethods: ['PLAIN', 'LOGIN'],
  
  onAuth(auth, session, callback) {
    if (auth.username === 'mailbox' && auth.password === 'secret') {
      return callback(null, { user: auth.username });
    }
    callback(new Error('Invalid credentials'));
  }
});
LMTP is typically used for local delivery where authentication isn’t needed. Most LMTP servers disable AUTH and STARTTLS commands.

Size Limits

Enforce message size limits in LMTP:
const server = new SMTPServer({
  lmtp: true,
  
  // Advertise 10MB limit
  size: 10 * 1024 * 1024,
  
  onData(stream, session, callback) {
    stream.on('end', () => {
      // Check if size was exceeded
      if (stream.sizeExceeded) {
        const err = new Error(
          'Error: message exceeds fixed maximum message size 10 MB'
        );
        err.responseCode = 552;
        return callback(err);
      }
      callback(null, true);
    });
  }
});

LMTP Response Codes

Common LMTP response codes:
CodeMeaningUse Case
250SuccessMessage delivered to recipient
450Temporary failureMailbox locked, try later
452Insufficient storageMailbox quota exceeded
550Permanent failureMailbox doesn’t exist
552Storage exceededMessage too large
553Invalid mailboxMalformed address

Testing LMTP

Test your LMTP server manually:
telnet localhost 2524
# 220 hostname LMTP Welcome to My Awesome LMTP Server

LHLO test.example.com
# 250-hostname
# 250-PIPELINING
# 250-8BITMIME
# 250 SIZE 10485760

MAIL FROM:<[email protected]m>
# 250 Accepted

RCPT TO:<[email protected]m>
# 250 Accepted

RCPT TO:<[email protected]m>
# 250 Accepted

DATA
# 354 End data with <CR><LF>.<CR><LF>

Subject: Test

Test message
.
# 250 Delivered to [email protected]
# 250 Delivered to [email protected]

Best Practices

  • Use LMTP for local delivery, not internet mail transfer
  • Implement per-recipient responses for detailed delivery status
  • Disable AUTH and STARTTLS for local-only LMTP servers
  • Check quotas in onRcptTo before accepting recipients
  • Use response code 452 for temporary failures (quota full)
  • Use response code 550 for permanent failures (no such user)
  • Process messages immediately; don’t queue in LMTP mode

Next Steps

Error Handling

Learn about error codes and response handling

Logging

Monitor LMTP delivery status with logging

Build docs developers (and LLMs) love