Introduction
The project (application) I'm presenting here is a relatively simple SMTP server (ok, only the receiver part, no mail routing or the like); while it started just as a tool to help developing SMTP sending applications and diagnosing issues, I then decided to evolve it by adding a number of features so that, as of today, the program can be used for the following purposes
- Basic SMTP receiver, useful to test/diagnose SMTP sending problems
- Plain vanilla SMTP proxy, to receive email and store it inside some other SMTP router "pickup folder" for delivery
- Mail collector, to receive junk email/worms/similar stuff and feed email filter or antivirus signatures and help keeping junk out from inboxes
- Fake MX, helping to reduce the amount of junk and the load on real MX servers
and then, by the way, it may be used for whatever other purpose you'll brain will suggest (ok, I admit it, I don't think it will be useful as a bottle opener)
Background
While this isn't the first SMTP application I wrote, this is the first one I wrote using .NET and C# in particular; my previous SMTP receiver was a program I wrote in regular C named "fakeMX", I wrote it after reading some documents dealing with the "MX sandwich" trick (see this, this and this); that application was an SMTP receiver as well and since it worked well, helping me (and a number of others) reducing the amount of junk hitting the mailservers ... I decided that writing a similar application in C# was worth, so I went on and wrote the application I'm presenting here.
Using the code
The whole project was born after reading this article;
I was seeking for a way to create an SMTP listener in C# and that code helped me getting started; so, I fired up VS-2010 and created
a new console application, I selected it since it's easier to convert a console app into a window service (see here for infos)
if desired (and in this case, it will be the perfect fit for the SMTP listener) and, at the same time, it's easy to debug such an app;
anyhow, after creating the new project and having my startup class ready with its default "Main()" method, I left it alone and went
on adding an "app.config" to the project; call me a dinosaur, but one of my old habits (since Win 3.x !) is to start by putting
together the configuration file for an application (be it an INI file or an "app.config" in our case) since, from direct experience,
this helps thinking about the features you want to implement. In this case, I ended up with the following "app.config" file
="1.0"="utf-8"
<configuration>
<appSettings>
<add key="HostName" value=""/>
<add key="ListenAddress" value="127.0.0.1"/>
<add key="ListenPort" value="25"/>
<add key="ReceiveTimeOut" value="8000"/>
<add key="MaxSmtpErrors" value="4"/>
<add key="MaxSmtpNoop" value="7"/>
<add key="MaxSmtpVrfy" value="10"/>
<add key="MaxSmtpRcpt" value="100"/>
<add key="MaxMessages" value="10"/>
<add key="MaxSessions" value="16"/>
<add key="StoreData" value="True"/>
<add key="StorePath" value=""/>
<add key="MaxDataSize" value="2097152"/>
<add key="LogPath" value=""/>
<add key="VerboseLogging" value="False"/>
<add key="BannerDelay" value="1000"/>
<add key="ErrorDelay" value="500"/>
<add key="DoTempFail" value="False"/>
<add key="DoEarlyTalk" value="True"/>
<add key="RWLproviders" value="swl.spamhaus.org,iadb.isipp.com"/>
<add key="RBLproviders" value="zen.spamhaus.org,bb.barracudacentral.org,
ix.dnsbl.manitu.net,bl.spamcop.net,combined.njabl.org"/>
<add key="LocalDomains" value=""/>
<add key="LocalMailBoxes" value=""/>
</appSettings>
</configuration>
The comments I left inside the file should be pretty much explanatory, but since we're just at the beginning, I'll get back on some
of those settings later on; for the moment, least suffice saying that by changing the settings inside the above file you'll be able
to tune the program for different purposes (as seen at the beginning).
Anyhow, once I had the above config ready, I went on and added a new public, static class to the project, it's used to keep the configuration values (so that we'll have them handy)
and also contains some common data/code, like, for example, the one used to assign a unique SMTP sessionID or to keep track of the number of active SMTP sessions; here's a snippet from
the class code.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net;
using System.IO;
namespace FakeSMTP
{
public static class AppGlobals
{
#region "privateData"
private static IPAddress _listenIP = IPAddress.Loopback;
private static string _listenAddress = null;
private static int _listenPort = 0;
private static int _receiveTimeout = 0;
private static string _hostName = null;
private static bool _doTempFail = false;
private static string _logPath = null;
private static bool _verboseLog = false;
private static long _maxSessions = 0;
private static int _maxMessages = 0;
private static bool _storeData = false;
private static long _maxDataSize = 0;
private static string _storePath = null;
private static bool _earlyTalk = false;
private static string[] _whiteLists = null;
private static string[] _blackLists = null;
private static int _maxSmtpErr = 0;
private static int _maxSmtpNoop = 0;
private static int _maxSmtpVrfy = 0;
private static int _maxSmtpRcpt = 0;
private static int _bannerDelay = 0;
private static int _errorDelay = 0;
private static List<string> _localDomains = null;
private static List<string> _localMailBoxes = null;
private static object _lkSessions = new object();
private static long _sessions = 0;
private static object _lkSessID = new object();
private static long _sessID = 0;
#endregion
as you probably noticed, most private vars above match the configuration parameters and that's exactly the case, since they're exposed through properties like
public static IPAddress listenIP
{
get { return _listenIP; }
set { _listenIP = value; }
}
public static string listenAddress
{
get { return _listenAddress; }
set { _listenAddress = value; }
}
public static int listenPort
{
get { return _listenPort; }
set { _listenPort = value; }
}
public static int receiveTimeout
{
get { return _receiveTimeout; }
set { _receiveTimeout = value; }
}
public static string hostName
{
get { return _hostName; }
set { _hostName = value; }
}
which allow to set and retrieve their values; at this point, having the config file and "global stuff" in place,
I went on and added to my main class some code to load/parse the configuration values from file and populate the class, so in the main class we have something like
static void loadConfig()
{
IPAddress listenIP = IPAddress.Loopback;
string listenAddress = ConfigurationManager.AppSettings["ListenAddress"];
if (String.IsNullOrEmpty(listenAddress)) listenAddress = "127.0.0.1";
if (false == IPAddress.TryParse(listenAddress, out listenIP))
{
listenAddress = "127.0.0.1";
listenIP = IPAddress.Loopback;
}
int listenPort = int.Parse(ConfigurationManager.AppSettings["ListenPort"]);
if ((listenPort < 1) || (listenPort > 65535))
listenPort = 25;
int receiveTimeout = int.Parse(ConfigurationManager.AppSettings["ReceiveTimeOut"]);
if (receiveTimeout < 0)
receiveTimeout = 0;
string hostName = ConfigurationManager.AppSettings["HostName"];
if (string.IsNullOrEmpty(hostName))
hostName = System.Net.Dns.GetHostEntry("").HostName;
bool doTempFail = bool.Parse(ConfigurationManager.AppSettings["DoTempFail"]);
the "loadConfig()" method above proceeds loading the various config values from file, checking if they're ok (and if not turning them into some viable defaults)
and then storing them into our "AppGlobals" class
AppGlobals.listenIP = listenIP;
AppGlobals.listenAddress = listenAddress;
AppGlobals.listenPort = listenPort;
AppGlobals.receiveTimeout = receiveTimeout;
AppGlobals.hostName = hostName.ToLower();
AppGlobals.doTempFail = doTempFail;
AppGlobals.storeData = storeData;
AppGlobals.maxDataSize = storeSize;
done that, we'll now need to setup our listener and handle incoming SMTP connections and, since I wanted to keep the listener and the session handler separate,
I decided to add another class to the project, namely the "SMTPsession" one; this class encapsulates all the logic needed to handle an SMTP session between
the client and our "server" and takes care of storing and logging the session informations (and, if so configured, the received messages); the main functions
of this class are its constructor, that is
public SMTPsession(TcpClient client)
{
try
{
this._sessCount = AppGlobals.addSession();
this._sessionID = AppGlobals.sessionID();
this._hostName = AppGlobals.hostName;
if (null != AppGlobals.LocalDomains)
this._mailDomains = AppGlobals.LocalDomains;
if (null != AppGlobals.LocalMailBoxes)
this._mailBoxes = AppGlobals.LocalMailBoxes;
this._client = client;
this._clientIP = this._client.Client.RemoteEndPoint.ToString();
int i = this._clientIP.IndexOf(':');
if (-1 != i) this._clientIP = this._clientIP.Substring(0, i);
this._client.ReceiveTimeout = AppGlobals.receiveTimeout;
this._stream = this._client.GetStream();
this._reader = new StreamReader(this._stream);
this._writer = new StreamWriter(this._stream);
this._writer.NewLine = "\r\n";
this._writer.AutoFlush = true;
AppGlobals.writeConsole("client {0} connected, sess={1}, ID={2}.",
this._clientIP, this._sessCount, this._sessionID);
this._initOk = true;
}
catch (Exception ex)
{
AppGlobals.writeConsole("SMTPsession::Exception: " + ex.Message);
closeSession();
}
}
which initializes the instance of the class and is called by the "Main()" whenever there's an incoming connection and the real worker,
that is the "handleSession()" function
public void handleSession()
{
string cmdLine = "?";
string response = cmd_ok(null);
cmdID currCmd = cmdID.invalid;
bool connOk = true;
if (false == this._initOk)
{
closeSession();
return;
}
if (this._sessCount > AppGlobals.maxSessions)
{
if (connOk) sendLine(TEMPFAIL_MSG);
closeSession();
return;
}
if (!isPrivateIP(this._clientIP))
{
bool isDnsListed = isListed(this._clientIP, AppGlobals.whiteLists, "white");
if (!isDnsListed)
{
isDnsListed = isListed(this._clientIP, AppGlobals.blackLists, "black");
if ((isDnsListed) && (!AppGlobals.storeData))
{
sendLine(string.Format(DNSBL_MSG, this._clientIP, this._dnsListName));
closeSession();
return;
}
}
}
sleepDown(AppGlobals.bannerDelay);
this._earlyTalker = isEarlyTalker();
if (this._earlyTalker)
{
sendLine(ETALKER_MSG);
closeSession();
return;
}
connOk = sendLine(cmd_banner(null));
while ((null != cmdLine) && (true == connOk))
{
if (this._lastCmd == cmdID.data)
{
string mailMsg = recvData();
if (this._timedOut)
{
if (connOk) sendLine(TIMEOUT_MSG);
closeSession();
return;
}
response = cmd_dot(null);
if (String.IsNullOrEmpty(mailMsg))
response = "422 Recipient mailbox exceeded quota limit.";
else
{
storeMailMsg(mailMsg);
if (AppGlobals.doTempFail)
{
if (connOk) sendLine(TEMPFAIL_MSG);
closeSession();
return;
}
}
resetSession();
}
else
{
cmdLine = recvLine();
if (null != cmdLine)
{
logCmdAndResp(DIR_RX, cmdLine);
currCmd = getCommandID(cmdLine);
switch (currCmd)
{
case cmdID.helo:
response = cmd_helo(cmdLine);
break;
case cmdID.ehlo:
response = cmd_helo(cmdLine);
break;
case cmdID.mailFrom:
response = cmd_mail(cmdLine);
break;
case cmdID.rcptTo:
response = cmd_rcpt(cmdLine);
break;
case cmdID.data:
if ((AppGlobals.doTempFail) && (!AppGlobals.storeData))
{
response = TEMPFAIL_MSG;
cmdLine = null;
this._lastCmd = currCmd = cmdID.quit;
}
else
response = cmd_data(cmdLine);
break;
case cmdID.rset:
response = cmd_rset(cmdLine);
break;
case cmdID.quit:
response = cmd_quit(cmdLine);
cmdLine = null;
break;
case cmdID.vrfy:
response = cmd_vrfy(cmdLine);
break;
case cmdID.expn:
response = cmd_vrfy(cmdLine);
break;
case cmdID.help:
response = cmd_help(cmdLine);
break;
case cmdID.noop:
response = cmd_noop(cmdLine);
break;
default:
response = cmd_unknown(cmdLine);
break;
}
}
else
{
response = TIMEOUT_MSG;
currCmd = cmdID.quit;
}
}
if ((this._errCount > 0) && (cmdID.quit != currCmd))
{
sleepDown(AppGlobals.errorDelay * this._errCount);
}
else
{
sleepDown(25);
}
this._earlyTalker = isEarlyTalker();
connOk = sendLine(response);
if ((cmdID.quit != currCmd) && (connOk))
{
string errMsg = null;
if (this._msgCount > AppGlobals.maxMessages)
{
errMsg = "451 Session messages count exceeded";
}
else if (this._errCount > AppGlobals.maxSmtpErr)
{
errMsg = "550 Max errors exceeded";
}
else if (this._vrfyCount > AppGlobals.maxSmtpVrfy)
{
errMsg = "451 Max recipient verification exceeded";
}
else if (this._noopCount > AppGlobals.maxSmtpNoop)
{
errMsg = "451 Max NOOP count exceeded";
}
else if (this._rcptTo.Count > AppGlobals.maxSmtpRcpt)
{
errMsg = "452 Too many recipients";
}
else if (this._earlyTalker)
{
errMsg = ETALKER_MSG;
}
if (null != errMsg)
{
if (connOk) connOk = sendLine(errMsg);
cmdLine = null;
}
}
if (connOk) connOk = this._client.Connected;
}
closeSession();
}
Which is the real workhorse, it deals with all the SMTP protocol rules, handing commands and responses and, in practice, handing the whole client session from its start
to its end; this functions also takes care of enforcing the limits we configured (max errors, bad clients and so on); the class then contains a number of helper functions used
to handle the different SMTP commands supported by the application, for example, the "rcpt_to" command is handled by this function
private string cmd_rcpt(string cmdLine)
{
if (string.IsNullOrEmpty(this._mailFrom))
{
this._errCount++;
return "503 Need MAIL before RCPT";
}
List<string> parts = parseCmdLine(cmdID.rcptTo, cmdLine);
if (2 != parts.Count)
{
this._errCount++;
return String.Format("501 {0} needs argument", parts[0]);
}
if (!checkMailAddr(parts[1]))
{
this._errCount++;
return String.Format("553 Invalid address {0}", parts[1]);
}
if (!isLocalDomain(this._mailDom))
{
this._errCount++;
return "530 Relaying not allowed for policy reasons";
}
else if (!isLocalBox(this._mailBox, this._mailDom))
{
this._errCount++;
return String.Format("553 Unknown email address {0}", parts[1]);
}
this._rcptTo.Add(parts[1]);
this._lastCmd = cmdID.rcptTo;
return string.Format("250 {0}... Recipient ok", parts[1]);
}
which performs a number of basic checks on the command, increases the error count if needed and, in any case, returns the response
to be sent back to the client; this class also handles the tasks of logging the various messages sent by the client and, if so configured, of saving them to files.
At this point, the app was almost complete, all I needed was editing the "Main()" function and adding the code needed to setup the listener, accept incoming
connections and spawn a thread running an instance of the "SMTPsession" to handle that given client, and the "Main()", once filled up with such code, ended up like this
static int Main(string[] args)
{
IPAddress listenAddr = IPAddress.Loopback;
int listenPort = 25;
int retCode = 0;
loadConfig();
AppGlobals.writeConsole("{0} {1} starting up (NET {2})",
AppGlobals.appName, AppGlobals.appVersion, AppGlobals.appRuntime);
if (AppGlobals.logVerbose)
dumpSettings();
listenAddr = AppGlobals.listenIP;
listenPort = AppGlobals.listenPort;
try
{
listener = new TcpListener(listenAddr, listenPort);
listener.Start();
}
catch (Exception ex)
{
AppGlobals.writeConsole("Listener::Error: " + ex.Message);
return 1;
}
AppGlobals.writeConsole("Listening for connections on {0}:{1}", listenAddr, listenPort);
while (!timeToStop)
{
try
{
SMTPsession handler = new SMTPsession(listener.AcceptTcpClient());
Thread thread = new System.Threading.Thread(new ThreadStart(handler.handleSession));
thread.Start();
}
catch (Exception ex)
{
retCode = 2;
AppGlobals.writeConsole("Handler::Error: " + ex.Message);
timeToStop = true;
}
}
if (null != listener)
{
try { listener.Stop(); }
catch { }
}
return retCode;
}
Nothing special, isn't it ? The main method really does little more than loading the configuration, creating the listening socket and passing
each incoming connection to an instance of SMTPsession running inside its own thread; the latter will then take care of checking if we are dealing with
too many sessions (and drop the exceeding ones), of setting up a timeout so that we won't keep waiting for a client which doesn't send us any command and,
in any case, of dealing with the nuts and bolts of the SMTP session.
Points of Interest
As I wrote at the beginning, thanks to the configuration parameters, you may use the application as a vanilla
SMTP receiver which will just accept connections from whatever client, deal with the SMTP commands, log the session and store the sent email messages,
or, if you want to have some more fun, you may use the application to help you fighting spammers; to do so, you may want to set it up to act
as a "fake MX" sitting inside an "MX sandwich", to setup such a thing, you'll need to have your own domain and, at least,
an MX server dealing with the SMTP traffic; let's say that you own the domain named "example.com" and that the DNS zone for such a domain looks this way
$ORIGIN example.com.
@ IN SOA ns1.example.com. root.example.com. (
2012080968 ; serial
7200 ; refresh (2 hours)
3600 ; retry (1 hour)
1209600 ; expire (2 weeks)
3600 ; minimum (1 hour)
)
@ IN NS ns1.example.com
@ IN NS ns2.example.com
@ IN A 192.0.2.25
mx1 IN A 192.0.2.25
www IN A 192.0.2.80
ftp IN CNAME www.example.com.
@ IN TXT "v=spf1 a mx -all"
@ IN MX 20 mx1.example.com.
Now, let's leave the various records alone and focus on the ones related to MX, that is, the "mx1.example.com" and the MX record with preference
20 pointing to it; with such a setup, willing to implement the "MX sandwich" we'll need to have a couple of additional IP addresses, one on which there
isn't (and there will never be) something listening on port 25 and a second one on which we'll setup our "Fake MX" using the FakeSMTP program;
let's say we will use 192.0.2.1 as the first (no SMTP) IP address and 192.0.2.254 as the second one, on which the FakeSMTP will listen; we'll then start by installing
the program on whatever box and changing the config file as follows:
<appSettings>
<add key="HostName" value=""/>
<add key="ListenAddress" value="0.0.0.0"/>
<add key="ListenPort" value="25"/>
<add key="ReceiveTimeOut" value="8000"/>
<add key="MaxSmtpErrors" value="4"/>
<add key="MaxSmtpNoop" value="7"/>
<add key="MaxSmtpVrfy" value="10"/>
<add key="MaxSmtpRcpt" value="100"/>
<add key="MaxMessages" value="10"/>
<add key="MaxSessions" value="32"/>
<add key="StoreData" value="False"/>
<add key="StorePath" value="X:\FakeSMTP\Data"/>
<add key="MaxDataSize" value="2097152"/>
<add key="LogPath" value="X:\FakeSMTP\Logs"/>
<add key="VerboseLogging" value="False"/>
<add key="BannerDelay" value="1500"/>
<add key="ErrorDelay" value="750"/>
<add key="DoTempFail" value="True"/>
<add key="DoEarlyTalk" value="True"/>
<add key="RWLproviders" value="swl.spamhaus.org,iadb.isipp.com"/>
<add key="RBLproviders" value="zen.spamhaus.org,bb.barracudacentral.org,ix.dnsbl.manitu.net,bl.spamcop.net,combined.njabl.org"/>
<add key="LocalDomains" value=""/>
<add key="LocalMailBoxes" value=""/>
</appSettings>
with the above configuration, the program will answer with a ""421 Service temporarily unavailable, closing transmission channel." message as soon as it receives
the SMTP "DATA" command (if you prefer, you may set the StoreData option to True, in such a case, the same message will be emitted after receiving and storing
the mail message, this may be useful in case you want to use such junk messages to feed a spamfilter, but be warned, those may take up quite a bunch of storage space);
once the app will be up and running (you may test it by using e.g. "telnet") you'll be ready to publish it, so, after opening your firewall to allow incoming
connections to 192.0.2.254:25, you'll go on and change your DNS zone as follows:
mx0 IN A 192.0.2.1
mx1 IN A 192.0.2.25
mx9 IN A 192.0.2.254
@ IN MX 10 mx0.example.com.
@ IN MX 20 mx1.example.com.
@ IN MX 50 mx9.example.com.
That is, adding the relevant "A" and "MX" records pointing to the "no receiver" and to the "fake receiver" addresses;
what will happen then is simple, a regular, "good" SMTP sender will just follow the RFCs and, willing to send you an email, will retrieve the list of MX records
and try contacting the first one in preference order to deliver its message, now, we know that the first MX is "filtered" so the sender will get
back a "connection error" and, following the RFCs it will immediately retry sending the message to the next MX in order, that is, to your REAL MX server
which will possibly accept it... all's well... but what about spammers/spambots ? Well, those try playing tricks to speed up delivery and/or to try bypassing spam filters so,
they either try going straight to the higher MX in preference order or to go to the first and, if that fails, to the last... and I think you can imagine what happens then
Notes
I'll admit, I'm a "rookie" when it comes to .NET and C#, so the code isn't exactly a good example of programming since I tried to keep things simple/clear
(mostly for myself) and avoiding some tricks which I'd otherwise use (e.g. in vanilla "C") so, the whole code has quite some room for improvement,
for example, the code checking the HELO/EHLO and the email addresses may be improved using regular expressions; other portions of the code may be rewritten
to optimize them and... well ... more; I didn't go on and do it just since the program, as is served my purpose, that is, putting together something
to help me learning C# and at the same time... something which may be useful and, I hope, it will be useful for someone else too.
Updates
The latest version (the downloadable one) contains a small code fix; the logging code was updated to use a "lock(...)" statement whenever logging to the application or session logs, this helps avoiding race conditions and properly serializing log writes; a better approach would have been adding a separate logging class implementing a queue, running a couple instances of such a class in two separate threads and then using them to log messages; this would dramatically speed up logging and avoid problems (the same may be applied to the code used to save email files)