MailKit icon indicating copy to clipboard operation
MailKit copied to clipboard

MailKit Performance issue

Open yogigrantz opened this issue 3 years ago • 9 comments

I was hoping that MailKit would send emails faster than System.Net.Mail. The reason being, is that System.Net.Mail in .net 6 is 5 times slower than System.Net.Mail in .net 4 . When I brought this up as a .net 6 issue in https://github.com/dotnet/runtime/issues/72555 , I was advised to use a more modern SMTPClient library such as MailKit.

But, as I tested Mailkit, it is even slower than System.Net.Mail in .net 6. Sending 100 emails took about a minute with MailKit, where it took 7.4 seconds with System.Net.Mail in .net 6, and 1.5 second with System.Net.Mail in .net 4.7.2

Describe the solution you'd like If it is possible, can the performance of MailKit be improved to be capable of sending multiple emails in shorter time? Ideally, if it could surpass System.Net.Mail in .net 4.7.2, that can send 100 emails in 1.5 seconds

I have not considered any alternatives other than MailKit, System.Net.Mail for .net 6, and System.Net.Mail for .net 4. But I am open for suggestions, anything that help me process a large number of emails in a short time. Please don't get me wrong. We are not spamming people. We do have a lot of clients, candidates, customers, and recruiters in 31 cities whose mean of communication is mostly by emails. Shown below is my code to test the performance for MailKit:

    using MailKit.Net.Smtp;
    using MailKit.Security;
    using MimeKit;
    using (ISmtpClient smtp = new SmtpClient())
    {
        smtp.Connect(host, port, SecureSocketOptions.StartTls);
        smtp.Authenticate(username, password);
        for (i = 0; i < 100; i++)
        {
            using (MimeMessage mm = new MimeMessage())
            {
                mm.From.Add(MailboxAddress.Parse(username));
                mm.To.Add(MailboxAddress.Parse($"EmailTester{i}@mailinator.com"));
                mm.Subject = $"Email sent at {DateTime.Now.ToString("HH: mm:ss.ff")} ";
                mm.Body = new TextPart($"<h1>Performance Test Email </h1> {dotnetVersion} version. The time is now: {DateTime.Now.ToString("HH: mm:ss.ff")}. <br />Thank you for reading this");
                smtp.Send(mm);
            }
        }

        smtp.Disconnect(true);
    }

And I am using similar fashion to test System.Net.Mail:

               using (SmtpClient smtp = new SmtpClient())
                {
                    smtp.Port = port;
                    smtp.Host = host;
                    smtp.Credentials = new System.Net.NetworkCredential(username, password);
                    smtp.DeliveryMethod = SmtpDeliveryMethod.Network;
                    smtp.EnableSsl = false;

                    for (int i = 0; i < 100; i++)
                    {
                        using (MailMessage mm = new MailMessage(username, $"EmailTester{i}@mailinator.com", $"Performance Test {DateTime.Now.ToString("HH: mm:ss.ff")} ", $"<h1>Performance Test Email </h1> The time is now: {DateTime.Now.ToString("HH: mm:ss.ff")}. <br />Thank you for reading this"))
                        {
                            smtp.Send(mm);
                        }
                    }
                }

yogigrantz avatar Jul 22 '22 01:07 yogigrantz

I saw someone else mentioned in the System.Net.Mail bug report that you should verify that your SMTP server isn't throttling you and I would second that suggestion.

If throttling is not the problem, then I imagine that the issue is likely to be similar to issue #1335 (except SMTP instead of IMAP).

Starting with MailKit 2.0, the SMTP, POP3 and IMAP implementations got a rewrite to be fully async all the way down to Stream.ReadAsync/WriteAsync whereas with 1.x, the async APIs were only Task.Run() wrappers around the synchronous APIs which all used Stream.Read/Write at the lowest levels. The way in which this was done, all of the internal APIs had to return Task and ended up getting a bool doAsync argument that is passed down to lower and lower level methods until it gets to the actual I/O logic at which point it branches for Read vs ReadAsync or Write vs WriteAsync.

The downside to this approach is that for developers using the synchronous API, there's all of the compiler auto-generated async state machines (and their cleanup) that unnecessarily causes the GC to waste time collecting state objects that were completely unnecessary.

For the IMAP issue linked above, I have been working toward untangling the sync and async logic for the FETCH command so that there are fewer unnecessary compiler-generated async state objects being instantiated during the parsing of IMAP FETCH responses.

I could do the same for SMTP, but before I spend all that time doing that, would it be possible for you to test MailKit 1.22.0 to see if that makes any difference?

jstedfast avatar Jul 22 '22 12:07 jstedfast

Hello Jeff: I tested MailKit 1.22.0 and compared with 3.3.0, both in .net 6. It looks like 3.3.0 is more stable and performs better, while 1.22.0 takes longer and longer over time. I also noticed that MailMessage was not disposable in 1.22.0 and it is in 3.3.0. Shown below is my test results:

11:38:28.35 Start (.net 6 with MailKit 1.22.0) --- 11:38:32.12 End (.net 6 with MailKit 1.22.0) --- 9 emails sent. Elapse time: 3.7728497 seconds 11:39:37.81 Start (.net 6 with MailKit 1.22.0) --- 11:39:41.59 End (.net 6 with MailKit 1.22.0) --- 9 emails sent. Elapse time: 3.7779075 seconds 11:39:52.46 Start (.net 6 with MailKit 1.22.0) --- 11:39:56.18 End (.net 6 with MailKit 1.22.0) --- 9 emails sent. Elapse time: 3.7182712 seconds 11:40:16.09 Start (.net 6 with MailKit 1.22.0) --- 11:40:20.02 End (.net 6 with MailKit 1.22.0) --- 9 emails sent. Elapse time: 3.9268819 seconds 11:41:07.25 Start (.net 6 with MailKit 1.22.0) --- 11:41:11.44 End (.net 6 with MailKit 1.22.0) --- 9 emails sent. Elapse time: 4.1910811 seconds 11:42:03.93 Start (.net 6 with MailKit 1.22.0) --- 11:42:08.66 End (.net 6 with MailKit 1.22.0) --- 9 emails sent. Elapse time: 4.7273934 seconds 11:49:28.63 Start (.net 6 with MailKit 1.22.0) --- 11:49:33.24 End (.net 6 with MailKit 1.22.0) --- 9 emails sent. Elapse time: 4.6151653 seconds

11:51:08.75 Start (.net 6 with MailKit 3.3.0) --- 11:51:12.67 End (.net 6 with MailKit 3.3.0) --- 9 emails sent. Elapse time: 3.9223814 seconds 11:51:28.74 Start (.net 6 with MailKit 3.3.0) --- 11:51:32.67 End (.net 6 with MailKit 3.3.0) --- 9 emails sent. Elapse time: 3.9334835 seconds 11:52:20.79 Start (.net 6 with MailKit 3.3.0) --- 11:52:24.58 End (.net 6 with MailKit 3.3.0) --- 9 emails sent. Elapse time: 3.7829888 seconds 11:53:35.02 Start (.net 6 with MailKit 3.3.0) --- 11:53:38.77 End (.net 6 with MailKit 3.3.0) --- 9 emails sent. Elapse time: 3.7535778 seconds 11:54:14.61 Start (.net 6 with MailKit 3.3.0) --- 11:54:18.09 End (.net 6 with MailKit 3.3.0) --- 9 emails sent. Elapse time: 3.4795396 seconds

And, here is my source code with MailKit 3.3.0, minus the credentials and server info:

DateTime currTime1 = DateTime.Now;
using (StreamWriter sw = new StreamWriter(@"C:\Temp\Log.txt", true))
{
    sw.WriteLine($"{currTime1.ToString("HH:mm:ss.ff")} Start ({dotnetVersion}) ---");

    int i = 0;

    using (SmtpClient smtp = new SmtpClient())
    {
        smtp.Connect(host, port, ssl);
        smtp.Authenticate(username, password);
        for (i = 0; i < 9; i++)
        {
            using (MimeMessage mm = new MimeMessage())
            {
                mm.From.Add(MailboxAddress.Parse(username));
                mm.To.Add(MailboxAddress.Parse(destEmail));
                mm.Subject = $"Mailkit test: {DateTime.Now.ToString("HH:mm:ss.ff")}";
                mm.Body = new TextPart(TextFormat.Html) { Text = "<b>This</b> is a test" };
                smtp.Send(mm);
            }
        }

        smtp.Disconnect(true);
    }
    DateTime currTime2 = DateTime.Now;
    TimeSpan ts = currTime2 - currTime1;

    sw.WriteLine($"{currTime2.ToString("HH:mm:ss.ff")} End ({dotnetVersion}) --- {i} emails sent. Elapse time: {ts.TotalSeconds} seconds");
     }

I think @ManickaP may be right, the SMTP server may have throttling setup, but I can't verify with the System Admin. But, when I compared MailKit and System.Net.Mail with identical SMTP server, I am getting these results:

10:54:21.62 Start (.net 4.7.2 with System.net.Mail) --- 10:54:28.20 End (.net 4.7.2 with System.net.Mail) --- 9 emails sent. Elapse time: 6.5751037 seconds

10:55:42.42 Start (.net 6 with MailKit) --- 10:55:47.28 End (.net 6 with MailKit) --- 9 emails sent. Elapse time: 4.85591 seconds

This shows that on a modern SMTP server, MailKit 3.3.0 in .net 6 performs better than System.Net.Mail in .net 4.7.2

The only caveat is, MailKit would not send email with old internal SMTP that may have a different authentication protocol. If we keep this statement: smtp.Authenticate(username, password); it would error out saying "The SMTP server does not support authentication". If we comment that out, it would go through and this statement: string x = smtp.Send(mm);

will return this message:

2.6.0 ZOA7N6E9FHU4.GK8NI1TS6MWU3@ccla713 Queued mail for delivery

and no emails got sent.

Whereas, with the obsolete System.Net.Mail going through port 25, the API would send the emails really really fast in .net 4.7.2, and 5 times slower in .net 6. If MailKit could process the same server (port 25 and not erroring out on the authentication), I would imagine it would go even faster than System.Net,Mail in 4.7.2

yogigrantz avatar Jul 22 '22 19:07 yogigrantz

Does the SMTP server advertise any authentication mechanisms? See the protocol log to find out.

using (SmtpClient smtp = new SmtpClient(new ProtocolLogger ("smtp.log")))
...

FWIW, if the SMTP server is returning 2.6.0 [ZOA7N6E9FHU4.GK8NI1TS6MWU3@ccla713](mailto:ZOA7N6E9FHU4.GK8NI1TS6MWU3@ccla713) Queued mail for delivery, then it suggests that the message has been accepted for delivery.

I think a lot of people assume that if they set credentials on their System.Net.Mail.SmtpClient, then it will use those credentials for authentication. That only holds true if the server supports authentication (which it sounds like your server doesn't).

SMTP originally did not have any way to authentication - it was always 100% anonymous. Later, a SASL AUTH command was added but is not required for an SMTP server to implement.

jstedfast avatar Jul 22 '22 20:07 jstedfast

Thank you @jstedfast : Apparently the emails with this return message: "2.6.0 ZOA7N6E9FHU4.GK8NI1TS6MWU3@ccla713 Queued mail for delivery" must have been filtered by Norton in our office.

When I sent them to mailinator, it worked, but slower than System.Net.Mail Shown below is my latest test results, with the same SMTP, and same destination - 2 sets of tests: Test #1 & #3 for MailKit, Test #2 & #4 for System.Net.Mail:

Test 1 -MailKit: 08:45:38.66 Start (.net 6 with MailKit 3.3.0 - port 25) --- 08:45:40.19 End (.net 6 with MailKit 3.3.0 - port 25) --- 9 emails sent. Elapse time: 1.5290837 seconds

Test 2 - System.Net.Mail: 09:03:32.29 Start (.net 6 with System.net.Mail - port 25) --- 09:03:33.06 End (.net 6 with System.net.Mail - port 25) --- 9 emails sent. Elapse time: 0.773345 seconds

Test 3 - MailKit: 09:07:26.30 Start (.net 6 with MailKit 3.3.0 - port 25) --- 09:07:28.00 End (.net 6 with MailKit 3.3.0 - port 25) --- 9 emails sent. Elapse time: 1.6964857 seconds

Test 4 - System.Net.Mail: 09:09:48.85 Start (.net 6 with System.net.Mail - port 25) --- 09:09:49.65 End (.net 6 with System.net.Mail - port 25) --- 9 emails sent. Elapse time: 0.8044539 seconds

For comparing between .net 4.7.2 and .net 6, between System.net.Mail and MailKit, I ran 3 tests:

09:36:58.16 Start (.net 4.7.2 with System.net.Mail - port 25) --- 09:36:58.44 End (.net 4.7.2 with System.net.Mail - port 25) --- 9 emails sent. Elapse time: 0.2840162 seconds 09:37:59.49 Start (.net 6 with System.net.Mail - port 25) --- 09:38:00.36 End (.net 6 with System.net.Mail - port 25) --- 9 emails sent. Elapse time: 0.8662199 seconds 09:38:32.91 Start (.net 6 with MailKit 3.3.0 - port 25) --- 09:38:34.29 End (.net 6 with MailKit 3.3.0 - port 25) --- 9 emails sent. Elapse time: 1.3823187 seconds

yogigrantz avatar Jul 25 '22 16:07 yogigrantz

@jstedfast : Going back to your comment: "I could do the same for SMTP, but before I spend all that time doing that, would it be possible for you to test MailKit 1.22.0 to see if that makes any difference?" :

I did try 1.22.0 , and ran multiple times, and found that it got slower and slower over time. I tried 3.3.0, and it ran better, even got a bit faster over time. Version 1.22.0: 3.8s for the first run, 4.6s for the 7th run. Version 3.3.0: 3.9s for the first run, 3.5s for the fifth run. So, 3.3.0 performs much better than 1.22.0.

I also tested .SendAsync, and the lapse time is about the same with .Send, and this is understandable because the program is a console program with a single user running it. Furthermore, the .Send method is actually calling .SendAsync, with .Result()

Going to your other comment regarding ticket: https://github.com/jstedfast/MailKit/issues/1335, I think 1335 seems to be a Memory issue whereas this ticket(1408) is a timing issue.

I must admit that I am 5 years late to use MailKit, and 5 years late to notice that the time to send an email with System.Net.Mail in .net 6 is considerably longer than in .net 4, and MailKit is even longer than System.Net.Mail in .net 6. And this is because it was not until recently that I'm assigned to upgrade a central email program that sends large amount of emails every second.

Could you shine a light for me on what I should do to handle this situation, where keeping the old technology is out of the question? Thank you so much for your time Jeff :)

yogigrantz avatar Jul 27 '22 01:07 yogigrantz

The 1.22.0 slowdown is probably caused by memory consumption due to MimeMessage not being disposable.

The SmtpClient code hasn't changed a whole lot since 1.22.0 other than being made Async from the bottom up (which would slow things down more than anything).

I figured it might be faster because the synchronous API's wouldn't have gotten bogged down with all of the AsyncBuilder compiler-generated code and the GC having to clean up all of those compiler-generated objects up at runtime. I guess it didn't make much of a difference...

Honestly, I was hoping that 1.22.0 performed better because that would have indicated to me that having a synchronous-only code-path would have helped (and the faster 1.22.0 was compared to 3.3.0, the more I would expect that having it would improve 3.x if I implemented that).

Going to your other comment regarding ticket: https://github.com/jstedfast/MailKit/issues/1335, I think 1335 seems to be a Memory issue whereas this ticket(1408) is a timing issue.

Yes, but the memory issue is caused almost entirely by overhead due to the async nature of the internal APIs.

For example, Send() (a synchronous API) calls an internal SendAsync() method that has a bool doAsync parameter. If doAsync is true, then the code runs asynchronously by using Stream.ReadAsync/WriteAsync. If doAsync is false, then it uses Stream.Read/Write(), but because methods marked with the async method modifier causes the compiler to generate a state machine for the method no matter what, there's a lot more memory allocations that happen behind the scenes that also have to be cleaned up by the GC, causing execution performance to slow down in order to handle more garbage collection.

For issue #1335, the reason the bug submitter is mostly bitten by this with the FETCH method is because there are often thousands of responses to the FETCH command (at least 1 response per message in the folder). With a folder of 16,000 messages, that's 16,000 (or more) responses. That's a LOT.

SMTP can respond with multi-line responses for a command, but the SmtpClient will typically read an entire response in a single I/O operation (since the responses will typically fit in a single 4K buffered read) unlike the IMAP response handling logic which reads a token at a time.

It should have been expected (on my part) that the async nature of the APIs in 3.3.0 would not be a significant performance degredation over 1.22.0, but I had to verify :)

Okay, so... where does that leave us? Honestly, I'm not sure.

My best guesses right now are:

  1. If/when the SMTP server supports the SIZE (or CHUNKING) extension, then the SmtpClient will measure the size of the message in order to pass a SIZE=### parameter to the MAIL FROM command. https://github.com/jstedfast/MailKit/blob/master/MailKit/Net/Smtp/SmtpClient.cs#L2533-L2540
  2. Maybe SmtpStream's output buffer should be increased to 16K instead of 4K. Perhaps that would increase output performance. This might require MimeKit API changes, though, in order to tell MimeMessage.WriteTo() to increase the buffer sizes of the FilterStream/etc.

I'm leaning more towards scenario 1 as the culprit, but based on your test case, the messages should be pretty small and not have a ton of overhead (e.g. encoding content in base64/quoted-printable/etc), so I'd be a tad surprised at this.

The 4K output buffer size is optimized based on research I did back in the early 2000's but back then, I was using C and not C# and hardware has changed/improved a lot since then, so it's possible that using 16K would be better.

Back when I did my original research, a 2K buffer was significantly better than 1K and 4K was significantly better than 2K, but 8K was barely faster than 4K and 16K at the time was essentially identical to 8K. I opted for 4K because it was kind of the best of both worlds, it was a "best bang for the buck". Maybe 8K or 16K has taken that role in more modern hardware.

You can easily test the SIZE/CHUNKING theory by doing this:

smtp.Connect(host, port, ssl);
smtp.Authenticate(username, password);
smtp.Capabilities &= ~(SmtpCapabilities.Size | SmtpCapabilities.Chunking);

That will tell the SmtpClient to ignore those capabilities which will prevent it from trying to measure the length of the message, eliminating that overhead.

If that significantly improves performance, then I think we've found our culprit and it means I'll have to rethink how I implement those protocol extensions going forward.

jstedfast avatar Jul 30 '22 12:07 jstedfast

Something else that is likely slowing it down (but might be minimal in your test case) is you aren't setting a ContentTransferEncoding on your TextPart which means that SmtpClient has to dynamically calculate the most appropriate encoding scheme to use for it.

To see how much of a difference this idea makes, try this:

mm.Body = new TextPart(TextFormat.Html) { Text = "<b>This</b> is a test", ContentTransferEncoding = ContentEncoding.SevenBit };

jstedfast avatar Jul 30 '22 16:07 jstedfast

Hi Jeff, thank you for looking into this. Yea definitely we should not use 1.22.0 because the MimeMessage is not disposable. 3.3.0 is better. I tried both

    smtp.Capabilities &= ~(SmtpCapabilities.Size | SmtpCapabilities.Chunking); and 
    , ContentTransferEncoding = ContentEncoding.SevenBit 

, and did not see noticable difference.

So ok just to make sure I am comparing apples to apples, I created a solution with two projects: .net 6, and .net 4.7.2. In the .net 6, I have two email classes: EmailWithMailKit, and EmailWithSystemNetMail. In the .net 4.7.2, I have only one class, EmailWithSystemNetMail. Within each class, I have both Async and Sync implementation. All classes are abstracted. The console program will pass the email parameters. I ran the program with local server, and with ethereal server. The matrix result is as follows:

`

21:37:49.38 End (.net 6 with MailKit, host: local) ---Async 9 emails sent. Elapse time: 1.3023737 seconds
21:37:49.98 End (.net 6 with MailKit, host: local) ---Sync 9 emails sent. Elapse time: 0.5921675 seconds
21:37:50.85 End (.net 6 with System.Net.Mail, host: local) ---Async 9 emails sent. Elapse time: 0.8215435 seconds
21:37:51.73 End (.net 6 with System.Net.Mail, host: local) ---Sync 9 emails sent. Elapse time: 0.8792586 seconds
21:38:49.53 End (.net 4.7.2 with System.Net.Mail, host: local) ---Async 9 emails sent. Elapse time: 1.1441305 seconds
21:38:49.63 End (.net 4.7.2 with System.Net.Mail, host: local) ---Sync 9 emails sent. Elapse time: 0.1003332 seconds

21:41:08.13 End (.net 6 with MailKit, host: smtp.ethereal.email) ---Async 9 emails sent. Elapse time: 8.1383525 seconds
21:41:15.26 End (.net 6 with MailKit, host: smtp.ethereal.email) ---Sync 9 emails sent. Elapse time: 7.1248917 seconds
21:41:34.83 End (.net 6 with System.Net.Mail, host: smtp.ethereal.email) ---Async 9 emails sent. Elapse time: 19.5204687 seconds
21:41:42.69 End (.net 6 with System.Net.Mail, host: smtp.ethereal.email) ---Sync 9 emails sent. Elapse time: 7.8595914 seconds
21:42:07.53 End (.net 4.7.2 with System.Net.Mail, host: smtp.ethereal.email) ---Async 9 emails sent. Elapse time: 7.2334718 seconds
21:42:14.30 End (.net 4.7.2 with System.Net.Mail, host: smtp.ethereal.email) ---Sync 9 emails sent. Elapse time: 6.7668919 seconds

I store my solution in this Github repository: https://github.com/yogigrantz/EmailTester

From the result, we can see that Sync calls in .net 4.7.2 with System.Net.Mail is the fastest. (0.1s) .Net 6 with MailKit Sync calls is twice as fast as it's Async call counterpart (0.59s vs 1.3s). And MailKit is faster than System.Net.Mail in .net 6, but still cannot beat the sync calls of .net 4.7.2 with System.Net.Mail.

We will still be using MailKit since we are using .net 6. But the key performance drop is between .net 4.7.2 to .net 6. And, surprisingly, the sync calls are faster than async for this single user runs.

Not sure if this ticket is workable but at least, it leads to a performance matrix report that you may be interested in. And, the source is there in GitHub for you to try in different servers maybe?

yogigrantz avatar Aug 02 '22 05:08 yogigrantz

After running numerous tests, I found that those numbers fluctuate depending on the state of the server. But, the consistent result I'm getting is that for a single user case, i.e: console / desktop apps, the sync calls are faster than their async counterpart. Sometimes by a little, sometimes by a lot. Perhaps because an async call would have to assign a worker and setup a callback. So if only one user is going through the thread, it's not worth to call the async. Whereas in web apps, multiple users could potentially going through the same thread and it would be more efficient to fetch other requests while processing the async calls. The MailKit sync calls came out ahead of System.net.mail whereas the async calls are lagging a bit

yogigrantz avatar Aug 03 '22 18:08 yogigrantz

@jstedfast : Just to recap, the performance issue is not directly MailKit issue. It is between .net 4 SNM and .net 6 SNM. But, the SNM group are done with their product so they referred me to you to get help because MailKit takes over SNM. The net effect is, our .net 4 app with SNM runs faster than .net 6 in sending emails, either with SNM or MailKit. I already ditched SNM so now I'm relying on MailKit. I did fork the source code, but so far have not seen a place where performance improvement can be made. Something between .net 4 and .net 6 component that degraded the performance.

yogigrantz avatar Aug 19 '22 18:08 yogigrantz

Yea, it's not obvious to me where performance can be improved.

jstedfast avatar Aug 19 '22 19:08 jstedfast

Depending on how important this is to you, I would recommend getting a license for a performance profiler and doing a bit of digging to figure out where the bottleneck is.

Maybe once we have that information, we'll be able to figure out a way to improve performance. Without it, all I can do is guess and that's not a very efficient way to spend my free time on solving this.

You could also check to see if a patch like this makes any difference:

diff --git a/MailKit/Net/Smtp/SmtpStream.cs b/MailKit/Net/Smtp/SmtpStream.cs
index 6fe3f9a6..57fefff3 100644
--- a/MailKit/Net/Smtp/SmtpStream.cs
+++ b/MailKit/Net/Smtp/SmtpStream.cs
@@ -203,10 +203,9 @@ namespace MailKit.Net.Smtp {
 			get { return Stream.Length; }
 		}
 
-		async Task<int> ReadAheadAsync (bool doAsync, CancellationToken cancellationToken)
+		void AlignReadAheadBuffer (out int offset, out int count)
 		{
 			int left = inputEnd - inputIndex;
-			int index, nread;
 
 			if (left > 0) {
 				if (inputIndex > 0) {
@@ -220,23 +219,49 @@ namespace MailKit.Net.Smtp {
 				inputEnd = 0;
 			}
 
-			left = input.Length - inputEnd;
-			index = inputEnd;
+			count = input.Length - inputEnd;
+			offset = inputEnd;
+		}
+
+		int ReadAhead (CancellationToken cancellationToken)
+		{
+			AlignReadAheadBuffer (out int offset, out int count);
 
 			try {
 				var network = Stream as NetworkStream;
 
 				cancellationToken.ThrowIfCancellationRequested ();
 
-				if (doAsync) {
-					nread = await Stream.ReadAsync (input, index, left, cancellationToken).ConfigureAwait (false);
+				network?.Poll (SelectMode.SelectRead, cancellationToken);
+				int nread = Stream.Read (input, offset, count);
+
+				if (nread > 0) {
+					logger.LogServer (input, offset, nread);
+					inputEnd += nread;
 				} else {
-					network?.Poll (SelectMode.SelectRead, cancellationToken);
-					nread = Stream.Read (input, index, left);
+					throw new SmtpProtocolException ("The SMTP server has unexpectedly disconnected.");
 				}
+			} catch {
+				IsConnected = false;
+				throw;
+			}
+
+			return inputEnd - inputIndex;
+		}
+
+		async Task<int> ReadAheadAsync (CancellationToken cancellationToken)
+		{
+			AlignReadAheadBuffer (out int offset, out int count);
+
+			try {
+				var network = Stream as NetworkStream;
+
+				cancellationToken.ThrowIfCancellationRequested ();
+
+				int nread = await Stream.ReadAsync (input, offset, count, cancellationToken).ConfigureAwait (false);
 
 				if (nread > 0) {
-					logger.LogServer (input, index, nread);
+					logger.LogServer (input, offset, nread);
 					inputEnd += nread;
 				} else {
 					throw new SmtpProtocolException ("The SMTP server has unexpectedly disconnected.");
@@ -414,79 +439,54 @@ namespace MailKit.Net.Smtp {
 #endif
 		}
 
-		async Task<SmtpResponse> ReadResponseAsync (bool doAsync, CancellationToken cancellationToken)
+		bool ReadResponse (ByteArrayBuilder builder, ref bool complete, ref bool newLine, ref bool more, ref int code)
 		{
-			CheckDisposed ();
+			do {
+				int startIndex = inputIndex;
 
-			using (var builder = new ByteArrayBuilder (256)) {
-				bool needInput = inputIndex == inputEnd;
-				bool complete = false;
-				bool newLine = true;
-				bool more = true;
-				int code = 0;
+				if (newLine && inputIndex < inputEnd) {
+					if (!ByteArrayBuilder.TryParse (input, ref inputIndex, inputEnd, out int value))
+						throw new SmtpProtocolException ("Unable to parse status code returned by the server.");
 
-				do {
-					if (needInput) {
-						await ReadAheadAsync (doAsync, cancellationToken).ConfigureAwait (false);
-						needInput = false;
+					if (inputIndex == inputEnd) {
+						inputIndex = startIndex;
+						return true;
 					}
 
-					complete = false;
-
-					do {
-						int startIndex = inputIndex;
-
-						if (newLine && inputIndex < inputEnd) {
-							if (!ByteArrayBuilder.TryParse (input, ref inputIndex, inputEnd, out int value))
-								throw new SmtpProtocolException ("Unable to parse status code returned by the server.");
-
-							if (inputIndex == inputEnd) {
-								inputIndex = startIndex;
-								needInput = true;
-								break;
-							}
-
-							if (code == 0) {
-								code = value;
-							} else if (value != code) {
-								throw new SmtpProtocolException ("The status codes returned by the server did not match.");
-							}
-
-							newLine = false;
+					if (code == 0) {
+						code = value;
+					} else if (value != code) {
+						throw new SmtpProtocolException ("The status codes returned by the server did not match.");
+					}
 
-							if (input[inputIndex] != (byte) '\r' && input[inputIndex] != (byte) '\n')
-								more = input[inputIndex++] == (byte) '-';
-							else
-								more = false;
+					newLine = false;
 
-							startIndex = inputIndex;
-						}
+					if (input[inputIndex] != (byte) '\r' && input[inputIndex] != (byte) '\n')
+						more = input[inputIndex++] == (byte) '-';
+					else
+						more = false;
 
-						while (inputIndex < inputEnd && input[inputIndex] != (byte) '\r' && input[inputIndex] != (byte) '\n')
-							inputIndex++;
+					startIndex = inputIndex;
+				}
 
-						builder.Append (input, startIndex, inputIndex - startIndex);
+				while (inputIndex < inputEnd && input[inputIndex] != (byte) '\r' && input[inputIndex] != (byte) '\n')
+					inputIndex++;
 
-						if (inputIndex < inputEnd && input[inputIndex] == (byte) '\r')
-							inputIndex++;
+				builder.Append (input, startIndex, inputIndex - startIndex);
 
-						if (inputIndex < inputEnd && input[inputIndex] == (byte) '\n') {
-							if (more)
-								builder.Append (input[inputIndex]);
-							complete = true;
-							newLine = true;
-							inputIndex++;
-						}
-					} while (more && inputIndex < inputEnd);
+				if (inputIndex < inputEnd && input[inputIndex] == (byte) '\r')
+					inputIndex++;
 
-					if (inputIndex == inputEnd)
-						needInput = true;
-				} while (more || !complete);
-
-				var message = builder.ToString ();
+				if (inputIndex < inputEnd && input[inputIndex] == (byte) '\n') {
+					if (more)
+						builder.Append (input[inputIndex]);
+					complete = true;
+					newLine = true;
+					inputIndex++;
+				}
+			} while (more && inputIndex < inputEnd);
 
-				return new SmtpResponse ((SmtpStatusCode) code, message);
-			}
+			return inputIndex == inputEnd;
 		}
 
 		/// <summary>
@@ -511,7 +511,28 @@ namespace MailKit.Net.Smtp {
 		/// </exception>
 		public SmtpResponse ReadResponse (CancellationToken cancellationToken)
 		{
-			return ReadResponseAsync (false, cancellationToken).GetAwaiter ().GetResult ();
+			CheckDisposed ();
+
+			using (var builder = new ByteArrayBuilder (256)) {
+				bool needInput = inputIndex == inputEnd;
+				bool complete = false;
+				bool newLine = true;
+				bool more = true;
+				int code = 0;
+
+				do {
+					if (needInput)
+						ReadAhead (cancellationToken);
+
+					complete = false;
+
+					needInput = ReadResponse (builder, ref complete, ref newLine, ref more, ref code);
+				} while (more || !complete);
+
+				var message = builder.ToString ();
+
+				return new SmtpResponse ((SmtpStatusCode) code, message);
+			}
 		}
 
 		/// <summary>
@@ -534,9 +555,30 @@ namespace MailKit.Net.Smtp {
 		/// <exception cref="SmtpProtocolException">
 		/// An SMTP protocol error occurred.
 		/// </exception>
-		public Task<SmtpResponse> ReadResponseAsync (CancellationToken cancellationToken)
+		public async Task<SmtpResponse> ReadResponseAsync (CancellationToken cancellationToken)
 		{
-			return ReadResponseAsync (true, cancellationToken);
+			CheckDisposed ();
+
+			using (var builder = new ByteArrayBuilder (256)) {
+				bool needInput = inputIndex == inputEnd;
+				bool complete = false;
+				bool newLine = true;
+				bool more = true;
+				int code = 0;
+
+				do {
+					if (needInput)
+						await ReadAheadAsync (cancellationToken).ConfigureAwait (false);
+
+					complete = false;
+
+					needInput = ReadResponse (builder, ref complete, ref newLine, ref more, ref code);
+				} while (more || !complete);
+
+				var message = builder.ToString ();
+
+				return new SmtpResponse ((SmtpStatusCode) code, message);
+			}
 		}
 
 		async Task WriteAsync (byte[] buffer, int offset, int count, bool doAsync, CancellationToken cancellationToken)
@@ -631,7 +673,51 @@ namespace MailKit.Net.Smtp {
 		/// </exception>
 		public void Write (byte[] buffer, int offset, int count, CancellationToken cancellationToken)
 		{
-			WriteAsync (buffer, offset, count, false, cancellationToken).GetAwaiter ().GetResult ();
+			CheckDisposed ();
+
+			ValidateArguments (buffer, offset, count);
+
+			try {
+				var network = NetworkStream.Get (Stream);
+				int index = offset;
+				int left = count;
+
+				while (left > 0) {
+					int n = Math.Min (BlockSize - outputIndex, left);
+
+					if (outputIndex > 0 || n < BlockSize) {
+						// append the data to the output buffer
+						Buffer.BlockCopy (buffer, index, output, outputIndex, n);
+						outputIndex += n;
+						index += n;
+						left -= n;
+					}
+
+					if (outputIndex == BlockSize) {
+						// flush the output buffer
+						network?.Poll (SelectMode.SelectWrite, cancellationToken);
+						Stream.Write (output, 0, BlockSize);
+						logger.LogClient (output, 0, BlockSize);
+						outputIndex = 0;
+					}
+
+					if (outputIndex == 0) {
+						// write blocks of data to the stream without buffering
+						while (left >= BlockSize) {
+							network?.Poll (SelectMode.SelectWrite, cancellationToken);
+							Stream.Write (buffer, index, BlockSize);
+							logger.LogClient (buffer, index, BlockSize);
+							index += BlockSize;
+							left -= BlockSize;
+						}
+					}
+				}
+			} catch (Exception ex) {
+				IsConnected = false;
+				if (!(ex is OperationCanceledException))
+					cancellationToken.ThrowIfCancellationRequested ();
+				throw;
+			}
 		}
 
 		/// <summary>
@@ -702,9 +788,51 @@ namespace MailKit.Net.Smtp {
 		/// <exception cref="System.IO.IOException">
 		/// An I/O error occurred.
 		/// </exception>
-		public override Task WriteAsync (byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+		public override async Task WriteAsync (byte[] buffer, int offset, int count, CancellationToken cancellationToken)
 		{
-			return WriteAsync (buffer, offset, count, true, cancellationToken);
+			CheckDisposed ();
+
+			ValidateArguments (buffer, offset, count);
+
+			try {
+				var network = NetworkStream.Get (Stream);
+				int index = offset;
+				int left = count;
+
+				while (left > 0) {
+					int n = Math.Min (BlockSize - outputIndex, left);
+
+					if (outputIndex > 0 || n < BlockSize) {
+						// append the data to the output buffer
+						Buffer.BlockCopy (buffer, index, output, outputIndex, n);
+						outputIndex += n;
+						index += n;
+						left -= n;
+					}
+
+					if (outputIndex == BlockSize) {
+						// flush the output buffer
+						await Stream.WriteAsync (output, 0, BlockSize, cancellationToken).ConfigureAwait (false);
+						logger.LogClient (output, 0, BlockSize);
+						outputIndex = 0;
+					}
+
+					if (outputIndex == 0) {
+						// write blocks of data to the stream without buffering
+						while (left >= BlockSize) {
+							await Stream.WriteAsync (buffer, index, BlockSize, cancellationToken).ConfigureAwait (false);
+							logger.LogClient (buffer, index, BlockSize);
+							index += BlockSize;
+							left -= BlockSize;
+						}
+					}
+				}
+			} catch (Exception ex) {
+				IsConnected = false;
+				if (!(ex is OperationCanceledException))
+					cancellationToken.ThrowIfCancellationRequested ();
+				throw;
+			}
 		}
 
 		async Task FlushAsync (bool doAsync, CancellationToken cancellationToken)

jstedfast avatar Aug 20 '22 13:08 jstedfast

I've setup an smtp-perf branch (based on MailKit v3.4.1) for trying various performance optimizations.

When you get a chance, try building that and see how that affects the numbers. Unfortunately, I only have GMail that I can use for testing and I'm worried that I'll just hit throttling and won't get accurate numbers because of that.

jstedfast avatar Sep 12 '22 14:09 jstedfast

In a previous comment, I theorized that perhaps upping the output buffer size to 8K or 16K might help, but based on some Googling, if TCP/IP packet sizes are typically constrained to 1500 bytes or less, then the ideal output buffer size in SmtpStream would be more like 1500 - 20 (a TCP/IP packet header is usually at least 20 bytes).

Perhaps setting it to 1440 or 1472 (instead of the current 4096) would be more optimal?

jstedfast avatar Sep 12 '22 15:09 jstedfast

Thank you for setting up smtp-perf Jeff. I just forked it, and I will try out the different buffer sizes

yogigrantz avatar Sep 16 '22 19:09 yogigrantz

@jstedfast : I changed that BlockSize to 1440, 1472, 2048, 4092, 8192, I even tried 640 and 720. Only at the extreme 640 and 8192, I could see noticeable latency difference. But between 1440 - 4096, it is very hard to tell. FlushCommandQueueAsync process fluctuates between 40 - 50 milliseconds and DataAsync fluctuates between 20 and 30 milliseconds, with BlockSize 1440

In order for me to see the effects, I created a special class "LogTimer" that stores a collection of logs. Just before and after each one of 14 phases of sending email, it takes the time stamp and measure the elapse time from the previous log. And at the end of the run, it append-writes the logs to a log file. So for each email I would see 14 logs like this, with elapse times in milliseconds:

1 GetMessageRecipients Start 0 2 GetMessageRecipients - Done 0 3 GetMessageSender Done 0 4 CheckDisposed Start 0 5 CheckDisposed Done 0 6 options.Cloned 0 7 Prepare Done 0 8 visitor.Visit Done 0 9 GetSize Done 0 10 MailFromAsync Done 0 11 RcptToAsync Done 0 12 FlushCommandQueueAsync Done 50 13 BdatAsync Done 0 14 DataAsync Done 19

These numbers came out when I use our company in-house SMTP server. If I use ethereal email, FlushCommandQueueAsync came out about 319 milliseconds, and DataAsync took about 469 milliseconds.

You can see what I did in here: https://github.com/jstedfast/MailKit/compare/master...yogigrantz:MailKit:master and please feel free to use that forked branch to test. The PerformanceTest project is in .net 6. I should create another one for .net 4 because that's where the difference came in, between .net 4 and .net 6

yogigrantz avatar Sep 17 '22 00:09 yogigrantz

BTW, the Async for 3.4.1 is better than 3.3.0. Now both sync and async takes about 65ms/email, surpassing SNM, which takes about 79ms/email . Nice work, thank you! I updated my Class Library to use 3.4.1

yogigrantz avatar Sep 17 '22 00:09 yogigrantz

When you say perf improved between 3.3.0 and 3.4.1, do you mean 3.4.1 as compiled from the smtp-perf branch?

I don't recall changing any SMTP-related code between 3.3.0 and 3.4.1. Odd.

The logTimer idea was good - at least now we know for sure which areas take the most time (not surprising, but good to have solid data).

But between 1440 - 4096, it is very hard to tell.

What this likely means is that the kernel is breaking apart the 4096 byte buffers into multiple ~1440 packets and the overhead is microscopic, or at least overshadowed by fewer kernel transitions (which are typically "expensive").

Good to know. I'll probably just keep the buffer sized at 4096, then.

I just made a few more commits to the smtp-perf branch that may or may not improve things.

jstedfast avatar Sep 17 '22 03:09 jstedfast

More improvements to reduce memory allocations when sending/queueing commands:

No more Encoding.UTF8.GetBytes (command + "\r\n").

This allocates once to append the "\r\n" and then has to at least allocate a byte[] buffer to convert the resulting string into bytes.

Instead, the code now gets the Encoder and blits the converted bytes right into the SmtpStream's output buffer.

jstedfast avatar Sep 17 '22 20:09 jstedfast

Not sure if this might be useful in what you are doing, but I'll leave it here https://github.com/dotnet/runtime/tree/main/src/coreclr/tools/Common/Internal/Text

ekalchev avatar Sep 18 '22 09:09 ekalchev

@ekalchev thanks, but unfortunately it doesn't look like it's useful :(

It looks like .NET5/6 and .NETStandard 2.1 have Encoder.Convert() methods that take a ReadOnlySpan<char> which might be nicer to use than unsafe pointers (and casting a string to char*). I've got a benchmark test program I've been using to try and find the fastest way to get SMTP command strings into the output buffer:

using System;
using System.Text;
using System.Buffers;

using BenchmarkDotNet;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Running;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;

namespace SendCommandBenchmark
{
    public class Program
    {
        static readonly string[] SmtpCommands = new string[] {
            "MAIL FROM:<[email protected]> SMTPUTF8 SIZE=123456 BODY=8BITMIME\r\n",
            "RCPT TO:<[email protected]>\r\n",
            "RCPT TO:<[email protected]>\r\n",
            "RCPT TO:<[email protected]>\r\n",
            "RCPT TO:<點看@example.com>\r\n",
            "DATA\r\n",
        };
        static readonly byte[] output = new byte[4096];

        static void Main(string[] args)
        {
            var config = ManualConfig.CreateMinimumViable();

            var summary = BenchmarkRunner.Run(typeof(Program).Assembly, config);
        }

        static void EncodingGetBytes (string command, byte[] output)
        {
            var bytes = Encoding.UTF8.GetBytes(command);

            Buffer.BlockCopy(bytes, 0, output, 0, bytes.Length);
        }

        [Benchmark]
        public void BenchmarkEncodingGetBytes ()
        {
            foreach (var command in SmtpCommands)
                EncodingGetBytes(command, output);
        }

        static void EncoderConvert(string command, byte[] output)
        {
            var encoder = Encoding.UTF8.GetEncoder();
            var chars = command.ToCharArray();
            int outputIndex = 0;
            int charIndex = 0;

            encoder.Convert(chars, charIndex, chars.Length - charIndex, output, 0, output.Length, true, out int charsUsed, out int bytesUsed, out bool completed);
            outputIndex += bytesUsed;
            charIndex += charsUsed;
        }

        [Benchmark]
        public void BenchmarkEncoderConvert()
        {
            foreach (var command in SmtpCommands)
                EncoderConvert(command, output);
        }

        static unsafe void EncoderConvertPointer(string command, byte[] output)
        {
            var encoder = Encoding.UTF8.GetEncoder();
            int outputIndex = 0;
            int charIndex = 0;

            fixed (char* cmd = command) {
                char* chars = cmd;

                fixed (byte* outbuf = output) {
                    byte* outptr = outbuf + outputIndex;

                    encoder.Convert(chars, command.Length - charIndex, outptr, output.Length, true, out int charsUsed, out int bytesUsed, out bool completed);
                    outputIndex += bytesUsed;
                    charIndex += charsUsed;
                    chars += charsUsed;
                }
            }
        }

        [Benchmark]
        public void BenchmarkEncoderConvertPointer()
        {
            foreach (var command in SmtpCommands)
                EncoderConvertPointer(command, output);
        }

        static void EncoderConvertSpan(string command, byte[] output)
        {
            var encoder = Encoding.UTF8.GetEncoder();
            var chars = command.AsSpan();
            int outputIndex = 0;
            int charIndex = 0;

            encoder.Convert(chars, output.AsSpan(), true, out int charsUsed, out int bytesUsed, out bool completed);
            outputIndex += bytesUsed;
            charIndex += charsUsed;
        }

        [Benchmark]
        public void BenchmarkEncoderConvertSpan()
        {
            foreach (var command in SmtpCommands)
                EncoderConvertSpan(command, output);
        }

        static unsafe void ManualConversion(string command, byte[] output)
        {
            int outputIndex = 0;

            fixed (byte* outbuf = output) {
                byte* outptr = outbuf + outputIndex;
                byte* outend = outbuf + output.Length;

                fixed (char* cmd = command) {
                    char* inend = cmd + command.Length;
                    char* inptr = cmd;

                    while (inptr < inend && *inptr < 128 && outptr < outend)
                        *outptr++ = (byte) *inptr++;

                    outputIndex = (int)(outptr - outbuf);

                    if (inptr < inend) {
                        var encoder = Encoding.UTF8.GetEncoder();

                        encoder.Convert(inptr, (int) (inend - inptr), outptr, (int) (outend - outptr), true, out int charsUsed, out int bytesUsed, out bool completed);
                        outputIndex += bytesUsed;
                        outptr += bytesUsed;
                        inptr += charsUsed;
                    }
                }
            }
        }

        [Benchmark]
        public void BenchmarkManualConversion()
        {
            foreach (var command in SmtpCommands)
                ManualConversion(command, output);
        }
    }
}

results:

BenchmarkDotNet=v0.13.2, OS=Windows 11 (10.0.22621.521)
Intel Core i7-9700 CPU 3.00GHz, 1 CPU, 8 logical and 8 physical cores
.NET SDK=6.0.401
  [Host]     : .NET 6.0.9 (6.0.922.41905), X64 RyuJIT AVX2
  DefaultJob : .NET 6.0.9 (6.0.922.41905), X64 RyuJIT AVX2


|                         Method |     Mean |   Error |  StdDev |
|------------------------------- |---------:|--------:|--------:|
|      BenchmarkEncodingGetBytes | 218.0 ns | 1.37 ns | 1.14 ns |
|        BenchmarkEncoderConvert | 263.4 ns | 1.99 ns | 1.76 ns |
| BenchmarkEncoderConvertPointer | 176.3 ns | 1.27 ns | 1.13 ns |
|    BenchmarkEncoderConvertSpan | 197.2 ns | 3.84 ns | 5.38 ns |
|      BenchmarkManualConversion | 200.5 ns | 3.09 ns | 2.58 ns |

jstedfast avatar Sep 18 '22 12:09 jstedfast

@jstedfast I was testing 3.4.1 smtp-perf branch only. And after many, many tests spamming myself, I can say that the performance did fluctuate. And what I observed as improvement over 3.3.0 could have been the fluctuation. For sending 9 emails async with our fastest, non auth smtp, the latency fluctuates between 1.5 - 3.5 seconds, with rare occassional spike to 5 seconds(1 time out of 10 tests). For sync: 0.5 - 2.3 s with occassional (1 time spike at 3.5s). This is running on .net 6 console program. Version 3.3.0 apparently yield similar result, sorry this info may not be helpful. I also tested with .net 4.6.2 and got the same fluctuating result. Its actually a bit frustrating when we dont get consistency but i guess thats the nature of digital world. The only thing that is apparent is that the legacy snm was still considerably faster than .net 6 when sending emails, that i experience firsthand

yogigrantz avatar Sep 18 '22 15:09 yogigrantz

@yogigrantz Okay, if when you said 3.4.1 you meant smtp-perf, then yea, I would expect an improvement. I just got confused because I thought you might have meant the official 3.4.1 release to nuget.org which doesn't have any of the smtp-perf changes in it.

Anyway, I think the latest smtp-perf changes should make things even faster, although I kinda doubt it'll get things down to 0.1s.

jstedfast avatar Sep 18 '22 15:09 jstedfast

@yogigrantz btw, did we ever confirm that disabling SIZE and CHUNKING made MailKit slower? They might actually increase performance if we leave them enabled (well, Chunking which requires calculating the size so might as well leave Size enabled as well).

jstedfast avatar Sep 19 '22 02:09 jstedfast

I ended up merging all of these changes to the mainline branch this morning. This means that going forward, you'll be able to use the packages from https://www.myget.org/feed/mimekit/package/nuget/MailKit to test changes.

jstedfast avatar Sep 19 '22 14:09 jstedfast

MailKitTestResult

Tested 3.4.1.488, compared with 3.4.1 smtp-perf branch, and yes there is a performance improvement in Async. Looks like about 40% improvement in async, with the sync just slightly slower in the first two runs, and the same on the last run. Shown in the screenshot: A is 3.4.1 smtp-perf, B is 3.4.1.488

yogigrantz avatar Sep 19 '22 16:09 yogigrantz

And we're still 0.5s slower (sync-mode) than S.N.M.SmtpClient in net4x? Ugh :(

jstedfast avatar Sep 19 '22 16:09 jstedfast

If you subclass MailKit's SmtpClient and override PreferSendAsBinaryData to return true, does that help at all?

When you try this, you'll need to comment out:

smtp.Capabilities &= ~(SmtpCapabilities.Size | SmtpCapabilities.Chunking);

This should make SmtpClient use the BDAT command instead of DATA which will do 2 things for us:

  1. It avoids having to byte-stuff the message (meaning it has to look for lines beginning with . and send .. at the beginning of the line instead).
  2. Saves a round-trip having to send the DATA command and then waiting for a response before sending the actual message data.

The downside is that it has to calculate the size of the message - but hopefully that's already fast (MimeKit has been much better optimized than MailKit at this point, but depending on the message, it still might have to base64 encode stuff or do other encoding which is obviously not cheap).

jstedfast avatar Sep 19 '22 16:09 jstedfast

FWIW, it looks like the DATA command can be PIPELINED with the MAIL FROM and RCPT TO commands, so I'll work on modifying the code to do that tonight after work. That should improve performance by reducing latency.

jstedfast avatar Sep 19 '22 16:09 jstedfast

@jstedfast : I think the async mode is pretty close to SNM in 4.7.2. Only the sync mode is a bit behind. Should I get a new fork from smtp-perf before making changes to PreferSendAsBinaryData , or, should I wait until you make more changes?

yogigrantz avatar Sep 19 '22 17:09 yogigrantz