Skip to content

Instantly share code, notes, and snippets.

@Quintus
Last active August 29, 2015 14:21
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Quintus/f4faf26aa022f74f7778 to your computer and use it in GitHub Desktop.
Save Quintus/f4faf26aa022f74f7778 to your computer and use it in GitHub Desktop.
A mini LMTP server for testing purposes.
# -*- coding: utf-8 -*-
#
# Super-simple LMTP server.
#
# Copyright © 2015 Marvin Gülker
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
require "socket"
# LMTP server class. Instances of this class utilize a UNIX socket to implement
# the LMTP protocol (see {RFC 2033}[https://tools.ietf.org/html/rfc1854]) in its
# most minimal and basic form. LMTP is spoken by possibly any MTA, so using this
# class you can make your Ruby program an email endpoint as long as you know how
# to configure your MTA. I’ve only tested with Postfix, though, so no guarantees.
#
# Instances of this class support several callbacks. The main callback is the message
# callback, which is passed the the block to ::new. It gets called whenever the
# LMTP client hands in an email, and receives the entire email as plain text
# as its argument. You can use the “mail” library or other means to parse it.
# Other callbacks you might find useful can be set with the #logging and #headers
# methods.
#
# This class makes no use of threads for multiple connections. Thus,
# any emails submitted at once to the UNIX domain socket are processed
# ony-by-one. A single client could block all other clients thus, but
# because LMTP should only ever be used in a completely trusted
# environment (see RFC 2033, sections 3 and 5), this is not an issue.
#
# It _does_ employ a mutex for the #stop method and the checking of the
# stopping variable. This means you can safely call #stop from another
# thread.
#
# Example use:
#
# server = LmtpServer.new("/var/spool/postfix/private/mysocket") do |message|
# puts "--- Start of email ---"
# puts message
# puts "--- End of email ---"
# end
#
# server.logging do |level, msg|
# $stderr.puts "[#{level}] #{msg}"
# end
#
# server.start
#
# The LMTP server by this class implements the following SMTP Service
# Extensions (see below for the list of RFCs). Do not implement them
# yourself by utilising #moreextensions and the callbacks, they’re
# there already!
#
# * PIPELINING
# * ENHANCEDSTATUSCODES
# * 8BITMIME
#
# RFCs implemented by this class:
#
# * {RFC 2033}[https://tools.ietf.org/html/rfc2033]
# * {RFC 2034}[https://tools.ietf.org/html/rfc2034]
# * {RFC 1854}[https://tools.ietf.org/html/rfc1854]
# * {RFC 1869}[https://tools.ietf.org/html/rfc1869]
# * {RFC 1652}[https://tools.ietf.org/html/rfc1652]
# * {RFC 821}[https://tools.ietf.org/html/rfc821] for the minimally required parts
class LmtpServer
# The machine’s hostname is read from this file.
HOSTNAME_FILE = "/etc/hostname"
# Timeout in seconds when a client is forcibly disconnected when
# it does nothing.
attr_accessor :timeout
# This is an array of extra extensions that are announced to
# the client in response to LHLO. Just append the names of
# the extensions to this array (e.g. "MYCOOLEXTENSION").
# The class will take care to prefix is with the proper LMTP
# reply code.
#
# This array is empty by default. Modifying it only makes sense
# if you actually implement the extensions you advertise here.
attr_accessor :moreextensions
# Message text to return on a successful message acceptance. This is
# automatically prefixed by "250 2.6.0 " so you don’t have to care
# about the LMTP status code. This text is purely informational and
# has no meaning to the protocol. It will show up in the sending
# MTA’s logs.
attr_accessor :successmsg
# Create a new LMTP server.
#
# === Parameters
# [path]
# Path on which the UNIX domain socket is created.
# All parent directories must exist, but the “file” itself
# must not exist (an ArgumentError is thrown if it exists).
# [mode (nil)]
# UNIX permissions to set on the UNIX socket file as
# a numeric mode (example: 0666 for rw-rw-rw-).
# User and Group of the file are determined by whatever
# the process environment mandates. +nil+ means to use
# whatever the process umask mandates.
# [callback]
# Message callback. Receives any email as a string that is
# passed to this LMTP server. The string will contain the
# original carriagereturn+newline line breaks from the protcol.
#
# === Return value
# Returns the new instance.
def initialize(path, mode = nil, &callback)
@path = path
@mode = mode
@hostname = File.read(HOSTNAME_FILE).strip
@msgcb = callback
@client = nil
@timeout = 30
@mutex = Mutex.new
@do_stop = false
@headercb = method(:default_headercb)
@moreextensions = []
@successmsg = "All your bytes are belong to us."
if File.exist?(path)
raise(ArgumentError, "File already exists: #{path}")
end
end
# Specify the logging callback. It will receive a syslog
# logging level as a symbol and the log message.
#
# By default, no logging callback is set and hence nothing
# is logged.
def logging(&callback)
@logcb = callback
end
# Override the callback used for responding to the LMTP client for
# the LMTP commands before DATA, e.g. MAIL FROM and RCPT TO. The
# callback receives the entire line the client sent, including the
# trailing carriagereturn-newline.
#
# The default callback only answers "250 2.1.0 ok" for every
# command. Note that RFC 2033 (LMTP) requires in section 5 that any
# LMTP server MUST implement RFC 2034, which in turn refers to RFC
# 1893 for the actual status codes, so for any replies you make you
# must make use of the extended statuscodes defined in RFC 1893 in
# the format defined by RFC 2034. Don’t worry — both of these RFCs
# are simple enough to just read quickly through them.
#
# Example:
#
# server.headers do |line|
# case line
# when /^MAIL FROM/ then "250 ok"
# when /^RCPT TO:<.*?>/ then
# if this_account_exists($1)
# "250 2.1.5 Recipient ok"
# else
# "550 5.1.1 Recipient does not exist over here."
# end
# else
# "250 2.1.0 ok"
# end
# end
#
# Note that the replies you define here are not immediately sent
# to the client, which is a result of the PIPELINING extension
# that is required by LMTP (see RFC 2033, section 5, and RFC 1854).
# Instead they’re accumulated and send as a big swall to the client
# when he issues the DATA command.
def headers(&callback)
@headercb = callback
end
# Halt the running server. This method is threadsafe.
def stop
@mutex.synchronize{ @do_stop = true }
end
# Create the UNIX domain socket and start listening on it.
# This method starts a listening loop and thus blocks.
# Use #stop from another thread to issue a halt.
def start
log :info, "Starting server"
@mutex.synchronize{ @do_stop = false }
UNIXServer.open(@path) do |server|
File.chmod(@mode, @path) if @mode
log :info, "Accepting connections."
while @client = server.accept
addr = @client.addr.last
log :info, "Client connect from #{addr}."
# TODO: Use #accept_nonblock in loop? This way, a client has to connect
# first to have the server shut down.
break if @mutex.synchronize{ @do_stop }
begin
catch :timeout do
handle_client
end
rescue => e
log :err, "Exception: #{e.class.name}: #{e.message}: #{e.backtrace.join("\n")}"
log :err, "Aborting connection due to exception."
@client.close
end
log :info, "Client connection closed: #{addr}"
@client = nil
end
end
log :info, "Server stopped."
ensure
if File.exist?(@path)
log :info, "Removing UNIX socket '#@path'"
File.delete(@path)
end
end
private
def log(level, msg)
@logcb.call(level, msg) if @logcb
end
def reply(msg)
str = msg.strip + "\r\n"
log :debug, "server: #{str.inspect}"
@client.puts(str)
end
def gets(raw = false)
if IO.select([@client], nil, nil, @timeout)
str = @client.gets
log :debug, "client: #{str.inspect}"
return nil if str.nil?
str.gsub!("\r\n", "\n") unless raw
if str.strip == "RSET" && !raw # Ensure that in DATA we can ignore it if this text occurs
reply "220 2.0.0 Resetting."
throw :rset
end
str
else
log :err, "Client #{@client.addr.last} timed out. Closing."
reply "422 4.5.0 Timeout."
@client.close
throw :timeout
end
end
def handle_client
reply "220 #{@hostname} LMTP server ready"
line = gets
if line !~ /^LHLO (.*?)$/
reply "500 5.5.1 You must great me first."
@client.close
return
end
log :info, "Client reports name: '#$1'"
reply "250-#{@hostname}"
reply "250-PIPELINING"
reply "250-ENHANCEDSTATUSCODES"
@moreextensions.each{|ext| reply("250-#{ext}")}
reply "250 8BITMIME"
loop do
no_valid_recipients = true
catch :rset do
# Allow pipelining by accumulation
responses = []
loop do
line = gets
break if line =~ /^DATA$/
response = @headercb.call(line)
# Conform to section 4.2(2) of RFC 2033. We need at least one RCPT
# command to succeed, otherwise DATA further below must fail.
if line.start_with?("RCPT") && response.start_with?("2")
no_valid_recipients = false
end
responses << response
end
# Answer the pipeline
responses.each do |response|
reply response
end
# Prepare for receiving
reply "354 Start data. End with <CRLF>.<CRLF>"
message = ""
loop do
line = gets(true) # Keep carriage returns and prevent RSET
break if line.strip == "."
# Honour transparency process as per section 4.5.2 of RFC 821
line.slice!(0) if line.start_with?(".") && line.strip.length > 1
message << line
end
# Conform to section 4.2(2) of RFC 2033 by failing with 503 if no valid
# recipients were found.
if no_valid_recipients
log :info, "No valid RCPT commands received, denying relay."
reply "503 5.0.0 No valid RCPT command received, denying DATA."
next
end
begin
log :debug, "Invoking message callback."
@msgcb.call(message)
rescue => e
reply "551 Internal error: #{e.class}: #{e.message}"
@client.close
return
end
reply "250 2.6.0 #@successmsg"
final = gets
if final
if final =~ /^QUIT$/
# Regular QUIT
reply "221 2.0.0 #{@hostname} Goodbye."
@client.close
return
else
# Not closing connection, client wants to sent another email
end
else
# Whoops. Client closed connection without QUIT. Bad guy!
log :warning, "Client closed connection without QUIT."
@client.close
return
end
end
end
end
def default_headercb(line)
"250 2.1.0 ok"
end
end
sock = "/var/spool/postfix/private/socktest"
server = LmtpServer.new(sock, 0666) do |msg|
puts "--- Start of email ---"
puts msg
puts "--- End of email ---"
end
server.logging do |tag, msg|
$stderr.puts "[#{tag}] #{msg}"
end
server.start
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment