/// <summary> /// Send mail and kicks off the send in a new task /// </summary> /// <param name="foundUser">Found user</param> /// <param name="reader">Reader</param> /// <param name="writer">Writer</param> /// <param name="line">Line</param> /// <param name="endPoint">End point</param> /// <param name="prepMessage">Allow changing mime message right before it is sent</param> /// <returns>Task</returns> private async Task SendMail(MailDemonUser foundUser, Stream reader, StreamWriter writer, string line, IPEndPoint endPoint, Action <MimeMessage> prepMessage) { MailFromResult result = await ParseMailFrom(foundUser, reader, writer, line, endPoint); SendMail(writer, result, endPoint, true, prepMessage).GetAwaiter(); await writer.WriteLineAsync($"250 2.1.0 OK"); await writer.FlushAsync(); }
private async Task <MailDemonUser> AuthenticateLogin(Stream reader, StreamWriter writer, string line) { MailDemonUser foundUser = null; await writer.WriteLineAsync("334 VXNlcm5hbWU6"); // user await writer.FlushAsync(); string userName = await ReadLineAsync(reader) ?? string.Empty; await writer.WriteLineAsync("334 UGFzc3dvcmQ6"); // pwd await writer.FlushAsync(); string password = await ReadLineAsync(reader) ?? string.Empty; userName = Encoding.UTF8.GetString(Convert.FromBase64String(userName)).Trim(); password = Encoding.UTF8.GetString(Convert.FromBase64String(password)); string sentAuth = userName + ":" + password; foreach (MailDemonUser user in users) { if (user.Authenticate(userName, password)) { foundUser = user; break; } } if (foundUser != null) { MailDemonLog.Info("User {0} authenticated", foundUser.UserName); await writer.WriteLineAsync($"235 2.7.0 Accepted"); await writer.FlushAsync(); return(foundUser); } // fail MailDemonLog.Warn("Authentication failed: {0}", sentAuth); await writer.WriteLineAsync($"535 authentication failed"); await writer.FlushAsync(); return(new MailDemonUser(userName, userName, password, userName, null, false, false)); }
private async Task <bool> ReceiveMail(Stream reader, StreamWriter writer, string line, IPEndPoint endPoint) { IPHostEntry entry = await Dns.GetHostEntryAsync(endPoint.Address); MailFromResult result = await ParseMailFrom(null, reader, writer, line, endPoint); if (result is null) { return(false); } try { string subject; MimeMessage msg; using (Stream stream = File.OpenRead(result.BackingFile)) { msg = await MimeMessage.LoadAsync(stream, true, cancelToken); subject = msg.Subject; } subject = (subject ?? string.Empty).Trim(); if (subject.Equals("unsubscribe", StringComparison.OrdinalIgnoreCase)) { UnsubscribeHandler?.Invoke(result.From.Address, subject, msg.HtmlBody); return(true); } // mail demon doesn't have an inbox yet, only forwarding, so see if any of the to addresses can be forwarded foreach (var kv in result.ToAddresses) { foreach (MailboxAddress address in kv.Value) { MailDemonUser user = users.FirstOrDefault(u => u.MailAddress.Address.Equals(address.Address, StringComparison.OrdinalIgnoreCase)); // if no user or the forward address points to a user, fail if (user == null || users.FirstOrDefault(u => u.MailAddress.Address.Equals(user.ForwardAddress.Address, StringComparison.Ordinal)) != null) { await writer.WriteLineAsync($"500 invalid command - user not found"); await writer.FlushAsync(); } // setup forward headers MailboxAddress forwardToAddress = (user.ForwardAddress ?? globalForwardAddress); if (forwardToAddress == null) { await writer.WriteLineAsync($"500 invalid command - user not found 2"); await writer.FlushAsync(); } else { string forwardDomain = forwardToAddress.Address.Substring(forwardToAddress.Address.IndexOf('@') + 1); // create new object to forward on MailFromResult newResult = new MailFromResult { BackingFile = result.BackingFile, From = user.MailAddress, ToAddresses = new Dictionary <string, IEnumerable <MailboxAddress> > { { forwardDomain, new List <MailboxAddress> { forwardToAddress } } } }; // forward the message on and clear the forward headers MailDemonLog.Info("Forwarding message, from: {0}, to: {1}, forward: {2}", result.From, address, forwardToAddress); result.BackingFile = null; // we took ownership of the file // send in background SendMail(newResult, true, prepMsg => { prepMsg.Subject = $"FW from {result.From}: {prepMsg.Subject}"; prepMsg.Cc.Clear(); prepMsg.Bcc.Clear(); }, false).ConfigureAwait(false).GetAwaiter(); return(true); // only forward to the first valid address } } } } finally { result.Dispose(); } return(true); }
public MailDemonService(string[] args, IConfiguration configuration) { IConfigurationSection rootSection = configuration.GetSection("mailDemon"); Domain = (rootSection["domain"] ?? Domain); ip = (string.IsNullOrWhiteSpace(rootSection["ip"]) ? IPAddress.Any : IPAddress.Parse(rootSection["ip"])); port = rootSection.GetValue("port", port); maxFailuresPerIPAddress = rootSection.GetValue("maxFailuresPerIPAddress", maxFailuresPerIPAddress); maxConnectionCount = rootSection.GetValue("maxConnectionCount", maxConnectionCount); maxMessageSize = rootSection.GetValue("maxMessageSize", maxMessageSize); globalForwardAddress = rootSection.GetValue("globalForwardAddress", globalForwardAddress); greeting = (rootSection["greeting"] ?? greeting).Replace("\r", string.Empty).Replace("\n", string.Empty); if (TimeSpan.TryParse(rootSection["failureLockoutTimespan"], out TimeSpan _failureLockoutTimespan)) { failureLockoutTimespan = _failureLockoutTimespan; } failureLockoutTimespan = _failureLockoutTimespan; IConfigurationSection userSection = rootSection.GetSection("users"); foreach (var child in userSection.GetChildren()) { MailDemonUser user = new MailDemonUser(child["name"], child["displayName"], child["password"], child["address"], child["forwardAddress"], true); users.Add(user); MailDemonLog.Debug("Loaded user {0}", user); } requireEhloIpHostMatch = rootSection.GetValue <bool>("requireEhloIpHostMatch", requireEhloIpHostMatch); requireSpfMatch = rootSection.GetValue <bool>("requireSpfMatch", requireSpfMatch); string dkimFile = rootSection.GetValue <string>("dkimPemFile", null); string dkimSelector = rootSection.GetValue <string>("dkimSelector", null); if (File.Exists(dkimFile) && !string.IsNullOrWhiteSpace(dkimSelector)) { try { using (StringReader stringReader = new StringReader(File.ReadAllText(dkimFile))) { PemReader pemReader = new PemReader(stringReader); object pemObject = pemReader.ReadObject(); AsymmetricKeyParameter privateKey = ((AsymmetricCipherKeyPair)pemObject).Private; dkimSigner = new DkimSigner(privateKey, Domain, dkimSelector); MailDemonLog.Info("Loaded dkim file at {0}", dkimFile); } } catch (Exception ex) { MailDemonLog.Error(ex); } } sslCertificateFile = rootSection["sslCertificateFile"]; sslCertificatePrivateKeyFile = rootSection["sslCertificatePrivateKeyFile"]; if (!string.IsNullOrWhiteSpace(sslCertificateFile)) { sslCertificatePassword = (rootSection["sslCertificatePassword"] ?? string.Empty).ToSecureString(); } TestSslCertificate(); IConfigurationSection ignoreRegexSection = rootSection.GetSection("ignoreCertificateErrorsRegex"); if (ignoreRegexSection != null) { foreach (var child in ignoreRegexSection.GetChildren()) { Regex re = new Regex(child["regex"].ToString(), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline); foreach (var domain in child.GetSection("domains").GetChildren()) { ignoreCertificateErrorsRegex[domain.Value] = re; } } } }
private async Task <MailFromResult> ParseMailFrom(MailDemonUser fromUser, Stream reader, StreamWriter writer, string line, IPEndPoint endPoint) { string fromAddress = line.Substring(11); int pos = fromAddress.IndexOf('>'); if (pos >= 0) { fromAddress = fromAddress.Substring(0, pos); } // if this is an anonymous user, ensure spf is a match if (fromUser == null || !fromUser.Authenticated) { // validate spf await ValidateSPF(writer, endPoint, fromAddress, fromAddress.Substring(fromAddress.IndexOf('@') + 1)); } bool binaryMime = (line.Contains("BODY=BINARYMIME", StringComparison.OrdinalIgnoreCase)); if (!fromAddress.TryParseEmailAddress(out _)) { await writer.WriteLineAsync($"500 invalid command - bad from address format"); await writer.FlushAsync(); throw new ArgumentException("Invalid format for from address: " + fromAddress); } if (fromUser != null && !fromUser.MailAddress.Address.Equals(fromAddress)) { await writer.WriteLineAsync($"500 invalid command - bad from address"); await writer.FlushAsync(); throw new InvalidOperationException($"Invalid from address - bad from address '{fromAddress}'"); } // denote success for sender and binarymime string binaryMimeOk = (binaryMime ? " and BINARYMIME" : string.Empty); string fromUserName = (fromUser == null ? fromAddress : fromUser.UserName); await writer.WriteLineAsync($"250 2.1.0 sender {fromUserName}{binaryMimeOk} OK"); // read to addresses line = await ReadLineAsync(reader); if (line.Equals("QUIT", StringComparison.OrdinalIgnoreCase)) { return(null); } Dictionary <string, IEnumerable <MailboxAddress> > toAddressesByDomain = new Dictionary <string, IEnumerable <MailboxAddress> >(StringComparer.OrdinalIgnoreCase); while (line.StartsWith("RCPT TO:<", StringComparison.OrdinalIgnoreCase)) { string toAddress = line.Substring(9).Trim('>'); if (!toAddress.TryParseEmailAddress(out MailboxAddress toAddressMail)) { await writer.WriteLineAsync($"500 invalid command - bad to address format for address '{toAddress}'"); await writer.FlushAsync(); throw new ArgumentException($"Invalid to address '{toAddress}'"); } // if no authenticated user, the to address must match an existing user address else if (fromUser == null && users.FirstOrDefault(u => u.MailAddress.Address.Equals(toAddress, StringComparison.OrdinalIgnoreCase)) == null) { await writer.WriteLineAsync($"500 invalid command - bad to address '{toAddress}'"); await writer.FlushAsync(); throw new InvalidOperationException($"Invalid to address '{toAddress}'"); } // else user is authenticated, can send email to anyone // group addresses by domain pos = toAddress.LastIndexOf('@'); if (pos > 0) { string addressDomain = toAddress.Substring(++pos); if (!toAddressesByDomain.TryGetValue(addressDomain, out IEnumerable <MailboxAddress> addressList)) { toAddressesByDomain[addressDomain] = addressList = new List <MailboxAddress>(); } (addressList as List <MailboxAddress>).Add(toAddressMail); } // denote success for recipient await writer.WriteLineAsync($"250 2.1.0 recipient {toAddress} OK"); line = await ReadLineAsync(reader); } // if no to addresses, fail if (toAddressesByDomain.Count == 0) { await writer.WriteLineAsync($"500 invalid command - no to address"); await writer.FlushAsync(); throw new InvalidOperationException("Invalid message: " + line); } if (line.StartsWith("DATA", StringComparison.OrdinalIgnoreCase)) { if (binaryMime) { await writer.WriteLineAsync("503 5.5.1 Bad sequence of commands, BODY=BINARYMIME requires BDAT, not DATA"); await writer.FlushAsync(); throw new InvalidOperationException("Invalid message: " + line); } await writer.WriteLineAsync($"354"); string tempFile = Path.GetTempFileName(); int totalCount = 0; try { using (Stream tempFileWriter = File.OpenWrite(tempFile)) { int b; int state = 0; while (state != 5 && (b = reader.ReadByte()) >= 0) { if (b == (byte)'.') { if (state == 2) { // \r\n. state = 3; } else { // reset state = 0; } } else if (b == (byte)'\r') { if (state == 3) { // \r\n.\r state = 4; } else { // \r state = 1; } } else if (b == (byte)'\n') { if (state == 1) { // \r\n state = 2; } else if (state == 4) { // \r\n.\r\n state = 5; } else { // reset state = 0; } } else { // reset state = 0; } totalCount++; if (totalCount > maxMessageSize) { await writer.WriteLineAsync("552 message too large"); throw new InvalidOperationException("Invalid message: " + line); } tempFileWriter.WriteByte((byte)b); } } // strip off the \r\n.\r\n, that is part of the protocol using (FileStream tempFileStream = File.Open(tempFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) { if (tempFileStream.Length >= 5) { tempFileStream.SetLength(tempFileStream.Length - 5); } } await writer.WriteLineAsync($"250 2.5.0 OK"); return(new MailFromResult { BackingFile = tempFile, From = (fromUser == null ? new MailboxAddress(fromAddress) : fromUser.MailAddress), ToAddresses = toAddressesByDomain }); } catch { File.Delete(tempFile); throw; } } else if (line.StartsWith("BDAT", StringComparison.OrdinalIgnoreCase)) { // https://tools.ietf.org/html/rfc1830 string tempFile = Path.GetTempFileName(); bool last = false; int totalBytes = 0; try { // send bdat to temp file to avoid memory issues using (Stream stream = File.OpenWrite(tempFile)) { do { int space = line.IndexOf(' '); int space2 = line.IndexOf(' ', space + 1); if (space2 < 0) { space2 = line.Length; } if (space < 0 || !int.TryParse(line.AsSpan(space, space2 - space), NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite, CultureInfo.InvariantCulture, out int size)) { await writer.WriteLineAsync($"500 invalid command"); throw new InvalidOperationException("Invalid message: " + line); } last = line.Contains("LAST", StringComparison.OrdinalIgnoreCase); totalBytes += size; if (totalBytes > maxMessageSize) { await writer.WriteLineAsync("552 message too large"); throw new InvalidOperationException("Invalid message: " + line); } await ReadWriteAsync(reader, stream, size); if (last) { await writer.WriteLineAsync($"250 2.5.0 total {totalBytes} bytes received message OK"); } else { await writer.WriteLineAsync($"250 2.0.0 {size} bytes received OK"); } }while (!last && !cancelToken.IsCancellationRequested && (line = await ReadLineAsync(reader)) != null); } Stream fileStream = null; try { fileStream = File.OpenRead(tempFile); return(new MailFromResult { BackingFile = tempFile, From = (fromUser == null ? new MailboxAddress(fromAddress) : fromUser.MailAddress), ToAddresses = toAddressesByDomain }); } catch { fileStream?.Dispose(); throw; } } catch { File.Delete(tempFile); throw; } } else { await writer.WriteLineAsync($"500 invalid command"); await writer.FlushAsync(); throw new InvalidOperationException("Invalid line in mail from: " + line); } }
private async Task HandleClientConnectionAsync(TcpClient tcpClient) { if (tcpClient is null || tcpClient.Client is null || !tcpClient.Client.Connected) { return; } DateTime start = DateTime.UtcNow; string ipAddress = (tcpClient.Client.RemoteEndPoint as IPEndPoint).Address.ToString(); MailDemonUser authenticatedUser = null; NetworkStream clientStream = null; X509Certificate2 sslCert = null; SslStream sslStream = null; bool helo = false; try { tcpClient.ReceiveTimeout = tcpClient.SendTimeout = streamTimeoutMilliseconds; MailDemonLog.Info("Connection from {0}", ipAddress); // immediately drop if client is blocked if (CheckBlocked(ipAddress)) { MailDemonLog.Warn("Blocked {0}", ipAddress); return; } clientStream = tcpClient.GetStream(); // create comm streams clientStream.ReadTimeout = clientStream.WriteTimeout = streamTimeoutMilliseconds; Stream reader = clientStream; StreamWriter writer = new StreamWriter(clientStream, MailDemonExtensionMethods.Utf8EncodingNoByteMarker) { AutoFlush = true, NewLine = "\r\n" }; async Task StartSSL() { sslCert = await CertificateCache.Instance.LoadSslCertificateAsync(sslCertificateFile, sslCertificatePrivateKeyFile, sslCertificatePassword); Tuple <SslStream, Stream, StreamWriter> tls = await StartTls(tcpClient, ipAddress, reader, writer, true, sslCert); if (tls == null) { await writer.WriteLineAsync("503 Failed to start TLS"); await writer.FlushAsync(); throw new IOException("Failed to start TLS, ssl certificate failed to load"); } else { sslStream = tls.Item1; reader = tls.Item2; writer = tls.Item3; } } if (port == 465 || port == 587) { await StartSSL(); } MailDemonLog.Info("Connection accepted from {0}", ipAddress); // send greeting await writer.WriteLineAsync($"220 {Domain} {greeting}"); await writer.FlushAsync(); IPEndPoint endPoint = tcpClient.Client.RemoteEndPoint as IPEndPoint; while (true) { if ((DateTime.UtcNow - start) > sessionTimeout) { throw new TimeoutException($"Session expired after {sessionTimeout.TotalMinutes:0.00} minutes"); } string line = await ReadLineAsync(reader); // these commands are allowed before HELO if (string.IsNullOrWhiteSpace(line) || line.StartsWith("QUIT", StringComparison.OrdinalIgnoreCase)) { await writer.WriteLineAsync("221 session terminated"); await writer.FlushAsync(); break; } else if (line.StartsWith("EHLO", StringComparison.OrdinalIgnoreCase)) { await HandleEhlo(writer, line, sslStream, sslCert, endPoint); helo = true; } else if (line.StartsWith("STARTTLS", StringComparison.OrdinalIgnoreCase)) { if (sslStream != null) { await writer.WriteLineAsync("503 TLS already initiated"); await writer.FlushAsync(); } else { await StartSSL(); } } else if (line.StartsWith("HELO", StringComparison.OrdinalIgnoreCase)) { await HandleHelo(writer, line, endPoint); helo = true; } else if (line.StartsWith("NOOP", StringComparison.OrdinalIgnoreCase)) { await writer.WriteLineAsync("220 OK"); await writer.FlushAsync(); } else if (line.StartsWith("HELP", StringComparison.OrdinalIgnoreCase)) { await writer.WriteLineAsync("220 OK Please use EHLO command"); await writer.FlushAsync(); } else if (!helo) { throw new InvalidOperationException("Client did not send greeting before line " + line); } // these commands may only appear after HELO/EHLO else if (line.StartsWith("RSET", StringComparison.OrdinalIgnoreCase)) { await writer.WriteLineAsync($"250 2.0.0 Resetting"); await writer.FlushAsync(); authenticatedUser = null; } else if (line.StartsWith("AUTH PLAIN", StringComparison.OrdinalIgnoreCase)) { authenticatedUser = await AuthenticatePlain(reader, writer, line); if (authenticatedUser.Authenticated && tcpClient.Client.RemoteEndPoint is IPEndPoint remoteEndPoint) { IPBan.IPBanPlugin.IPBanLoginSucceeded("SMTP", authenticatedUser.UserName, remoteEndPoint.Address.ToString()); } else { throw new InvalidOperationException("Authentication failed"); } } else if (line.StartsWith("AUTH LOGIN", StringComparison.OrdinalIgnoreCase)) { authenticatedUser = await AuthenticateLogin(reader, writer, line); if (authenticatedUser.Authenticated && tcpClient.Client.RemoteEndPoint is IPEndPoint remoteEndPoint) { IPBan.IPBanPlugin.IPBanLoginSucceeded("SMTP", authenticatedUser.UserName, remoteEndPoint.Address.ToString()); } else { throw new InvalidOperationException("Authentication failed"); } } // if authenticated, only valid line is MAIL FROM // TODO: consider changing this else if (authenticatedUser != null) { if (line.StartsWith("MAIL FROM:<", StringComparison.OrdinalIgnoreCase)) { try { await SendMail(authenticatedUser, reader, writer, line, endPoint, null); } catch (Exception ex) { throw new ApplicationException("Error sending mail from " + endPoint, ex); } } else { MailDemonLog.Warn("Ignoring client command: {0}", line); } } else { if (line.StartsWith("MAIL FROM:<", StringComparison.OrdinalIgnoreCase)) { // non-authenticated user, forward message on if possible, check settings try { bool result = await ReceiveMail(reader, writer, line, endPoint); if (!result) { await writer.WriteLineAsync("221 session terminated"); await writer.FlushAsync(); break; } } catch (Exception ex) { throw new ApplicationException("Error receiving mail from " + endPoint, ex); } } else { throw new InvalidOperationException("Invalid message: " + line + ", not authenticated"); } } } } catch (Exception ex) { if (!(ex is SocketException)) { IncrementFailure(ipAddress, authenticatedUser?.UserName); MailDemonLog.Error(ex, "{0} error", ipAddress); } } finally { sslStream?.Dispose(); clientStream?.Dispose(); MailDemonLog.Info("{0} disconnected", ipAddress); } }