From d091d7065a72defe542dd334742ba20292f80b3f Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Wed, 1 Jul 2026 16:25:22 +0300 Subject: [PATCH] feat: add native POP3 receiver adapters (MailKit + Rebex) Port the external Pop3 receiver adapter into two in-process native adapters, selectable per subscription like other native adapters: - NativePop3Receiver: free, MailKit-based (MIT). No license required. - NativeRebexPop3Receiver: Rebex-based parity port of the existing external adapter. Registered only when REBEX_LICENSE_KEY is set, so it stays hidden from the adapter picker without a license. The key is read from the environment rather than per-subscription config. Connection is hardcoded to standard implicit-SSL POP3 (port 995), matching the original external adapter; Port/UseSsl are internal-only seams (InternalsVisibleTo) used by tests, not user-facing config. Adds an in-process fake POP3 server and 7 behavior tests per adapter (list/batch-size/body/attachment-utf8/attachment-base64/unknown-encoding/ delete). Rebex tests report Inconclusive when REBEX_LICENSE_KEY is unset so CI stays green without a license. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Pop3Receiver/NativePop3Receiver.cs | 78 ++++++++ .../Pop3Receiver/Pop3ReceiverInput.cs | 23 +++ .../NativeRebexPop3Receiver.cs | 81 +++++++++ .../RebexPop3ReceiverInput.cs | 23 +++ .../SW.Bitween.NativeAdapters.csproj | 7 + .../ServiceCollectionExtensions.cs | 11 ++ SW.Bitween.UnitTests/FakePop3Server.cs | 171 ++++++++++++++++++ .../NativePop3ReceiverTests.cs | 126 +++++++++++++ .../NativeRebexPop3ReceiverTests.cs | 138 ++++++++++++++ SW.Bitween.UnitTests/Pop3TestMessages.cs | 38 ++++ 10 files changed, 696 insertions(+) create mode 100644 SW.Bitween.NativeAdapters/Pop3Receiver/NativePop3Receiver.cs create mode 100644 SW.Bitween.NativeAdapters/Pop3Receiver/Pop3ReceiverInput.cs create mode 100644 SW.Bitween.NativeAdapters/RebexPop3Receiver/NativeRebexPop3Receiver.cs create mode 100644 SW.Bitween.NativeAdapters/RebexPop3Receiver/RebexPop3ReceiverInput.cs create mode 100644 SW.Bitween.UnitTests/FakePop3Server.cs create mode 100644 SW.Bitween.UnitTests/NativePop3ReceiverTests.cs create mode 100644 SW.Bitween.UnitTests/NativeRebexPop3ReceiverTests.cs create mode 100644 SW.Bitween.UnitTests/Pop3TestMessages.cs diff --git a/SW.Bitween.NativeAdapters/Pop3Receiver/NativePop3Receiver.cs b/SW.Bitween.NativeAdapters/Pop3Receiver/NativePop3Receiver.cs new file mode 100644 index 00000000..7635cc45 --- /dev/null +++ b/SW.Bitween.NativeAdapters/Pop3Receiver/NativePop3Receiver.cs @@ -0,0 +1,78 @@ +using System.Text; +using MailKit.Net.Pop3; +using MailKit.Security; +using MimeKit; +using SW.PrimitiveTypes; + +namespace SW.Bitween.NativeAdapters.Pop3Receiver; + +public class NativePop3Receiver : INativeInfolinkReceiver +{ + private Pop3ReceiverInput _options = new(); + private Pop3Client? _client; + + // Connection defaults matching the standard implicit-SSL POP3 endpoint. + // Not user-configurable; exposed internally only so tests can point at a local fake server. + internal int Port { get; set; } = 995; + internal bool UseSsl { get; set; } = true; + + public async Task Initialize() + { + _client = new Pop3Client(); + var sslOptions = UseSsl ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.None; + await _client.ConnectAsync(_options.Host, Port, sslOptions); + await _client.AuthenticateAsync(_options.Username, _options.Password); + } + + public async Task Finalize() + { + await _client!.DisconnectAsync(true); + _client.Dispose(); + } + + public Task> ListFiles() + { + var count = Math.Min(_client!.Count, _options.BatchSize); + return Task.FromResult(Enumerable.Range(0, count).Select(i => i.ToString())); + } + + public async Task GetFile(string fileId) + { + var index = int.Parse(fileId); + var message = await _client!.GetMessageAsync(index); + + var attachment = message.Attachments.FirstOrDefault(); + if (attachment == null) + return new XchangeFile(message.TextBody ?? message.HtmlBody ?? string.Empty, message.Subject); + + using var memoryStream = new MemoryStream(); + if (attachment is MessagePart rfc822) + await rfc822.Message.WriteToAsync(memoryStream); + else + await ((MimePart)attachment).Content.DecodeToAsync(memoryStream); + + var buffer = memoryStream.ToArray(); + + return _options.ResponseEncoding.ToLower() switch + { + "base64" => new XchangeFile(Convert.ToBase64String(buffer), message.Subject), + "utf8" => new XchangeFile(Encoding.UTF8.GetString(buffer), message.Subject), + _ => throw new ArgumentException( + $"Unknown {nameof(Pop3ReceiverInput.ResponseEncoding)} '{_options.ResponseEncoding}'") + }; + } + + public async Task DeleteFile(string fileId) + { + await _client!.DeleteMessageAsync(int.Parse(fileId)); + } + + public string Name => "NativePop3Receiver"; + + public void InitializeStartupValues(IDictionary settings) + { + _options = settings.ConvertTo(); + } + + public Type StartupValuesType => typeof(Pop3ReceiverInput); +} diff --git a/SW.Bitween.NativeAdapters/Pop3Receiver/Pop3ReceiverInput.cs b/SW.Bitween.NativeAdapters/Pop3Receiver/Pop3ReceiverInput.cs new file mode 100644 index 00000000..564361f5 --- /dev/null +++ b/SW.Bitween.NativeAdapters/Pop3Receiver/Pop3ReceiverInput.cs @@ -0,0 +1,23 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace SW.Bitween.NativeAdapters.Pop3Receiver; + +public class Pop3ReceiverInput +{ + [Required] + public string Host { get; set; } = string.Empty; + + [Required] + public string Username { get; set; } = string.Empty; + + [Required] + [Secure] + public string Password { get; set; } = string.Empty; + + [DefaultValue(50)] + public int BatchSize { get; set; } = 50; + + [DefaultValue("utf8")] + public string ResponseEncoding { get; set; } = "utf8"; +} diff --git a/SW.Bitween.NativeAdapters/RebexPop3Receiver/NativeRebexPop3Receiver.cs b/SW.Bitween.NativeAdapters/RebexPop3Receiver/NativeRebexPop3Receiver.cs new file mode 100644 index 00000000..a1de067d --- /dev/null +++ b/SW.Bitween.NativeAdapters/RebexPop3Receiver/NativeRebexPop3Receiver.cs @@ -0,0 +1,81 @@ +using System.Text; +using Rebex.Net; +using SW.PrimitiveTypes; + +namespace SW.Bitween.NativeAdapters.RebexPop3Receiver; + +public class NativeRebexPop3Receiver : INativeInfolinkReceiver +{ + public const string LicenseKeyEnvironmentVariable = "REBEX_LICENSE_KEY"; + + private RebexPop3ReceiverInput _options = new(); + private Pop3 _pop3 = new(); + + // Connection defaults matching the standard implicit-SSL POP3 endpoint. + // Not user-configurable; exposed internally only so tests can point at a local fake server. + internal int Port { get; set; } = 995; + internal bool UseSsl { get; set; } = true; + + public async Task Initialize() + { + Rebex.Licensing.Key = Environment.GetEnvironmentVariable(LicenseKeyEnvironmentVariable); + _pop3 = new Pop3(); + var sslMode = UseSsl ? SslMode.Implicit : SslMode.None; + await _pop3.ConnectAsync(_options.Host, Port, sslMode); + await _pop3.LoginAsync(_options.Username, _options.Password); + } + + public async Task Finalize() + { + await _pop3.DisconnectAsync(false); + _pop3.Dispose(); + } + + public async Task> ListFiles() + { + var messages = await _pop3.GetMessageListAsync(Pop3ListFields.Fast); + + return messages.Select(m => m.SequenceNumber.ToString()) + .Take(_options.BatchSize) + .ToList(); + } + + public async Task GetFile(string fileId) + { + var sequenceNumber = int.Parse(fileId); + var message = await _pop3.GetMailMessageAsync(sequenceNumber); + + if (message.Attachments.Count < 1) + return new XchangeFile(message.BodyText, message.Subject); + + var attachment = message.Attachments[0]; + await _pop3.GetMessageAsync(sequenceNumber, attachment.FileName); + + await using var stream = attachment.GetContentStream(); + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream); + var buffer = memoryStream.ToArray(); + + return _options.ResponseEncoding.ToLower() switch + { + "base64" => new XchangeFile(Convert.ToBase64String(buffer), message.Subject), + "utf8" => new XchangeFile(Encoding.UTF8.GetString(buffer), message.Subject), + _ => throw new ArgumentException( + $"Unknown {nameof(RebexPop3ReceiverInput.ResponseEncoding)} '{_options.ResponseEncoding}'") + }; + } + + public async Task DeleteFile(string fileId) + { + await _pop3.DeleteAsync(int.Parse(fileId)); + } + + public string Name => "NativeRebexPop3Receiver"; + + public void InitializeStartupValues(IDictionary settings) + { + _options = settings.ConvertTo(); + } + + public Type StartupValuesType => typeof(RebexPop3ReceiverInput); +} diff --git a/SW.Bitween.NativeAdapters/RebexPop3Receiver/RebexPop3ReceiverInput.cs b/SW.Bitween.NativeAdapters/RebexPop3Receiver/RebexPop3ReceiverInput.cs new file mode 100644 index 00000000..330f8bba --- /dev/null +++ b/SW.Bitween.NativeAdapters/RebexPop3Receiver/RebexPop3ReceiverInput.cs @@ -0,0 +1,23 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace SW.Bitween.NativeAdapters.RebexPop3Receiver; + +public class RebexPop3ReceiverInput +{ + [Required] + public string Host { get; set; } = string.Empty; + + [Required] + public string Username { get; set; } = string.Empty; + + [Required] + [Secure] + public string Password { get; set; } = string.Empty; + + [DefaultValue(50)] + public int BatchSize { get; set; } = 50; + + [DefaultValue("utf8")] + public string ResponseEncoding { get; set; } = "utf8"; +} diff --git a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj index 575dd240..cd939126 100644 --- a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj +++ b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj @@ -9,9 +9,16 @@ + + + + + + + diff --git a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs index bb0e9883..af7fb59a 100644 --- a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs +++ b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using SW.Bitween.NativeAdapters.HttpReceiver; +using SW.Bitween.NativeAdapters.Pop3Receiver; +using SW.Bitween.NativeAdapters.RebexPop3Receiver; namespace SW.Bitween.NativeAdapters; @@ -30,5 +32,14 @@ public static void AddNativeAdapters(this IServiceCollection serviceCollection) serviceCollection.AddScoped(); serviceCollection.AddScoped(); + + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(NativeRebexPop3Receiver.LicenseKeyEnvironmentVariable))) + { + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + } } } \ No newline at end of file diff --git a/SW.Bitween.UnitTests/FakePop3Server.cs b/SW.Bitween.UnitTests/FakePop3Server.cs new file mode 100644 index 00000000..57bf2434 --- /dev/null +++ b/SW.Bitween.UnitTests/FakePop3Server.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace SW.Bitween.UnitTests; + +/// +/// Minimal in-process POP3 server used to exercise the real MailKit/Rebex clients +/// against USER/PASS/STAT/LIST/RETR/DELE/QUIT without touching a real mailbox. +/// +public sealed class FakePop3Server : IDisposable +{ + private readonly TcpListener _listener; + private readonly List _messages; + private readonly HashSet _deleted = new(); + private readonly string _expectedUser; + private readonly string _expectedPassword; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _acceptTask; + + public int Port { get; } + public IReadOnlyCollection DeletedMessageNumbers => _deleted; + + public FakePop3Server(IEnumerable messages, string expectedUser = "user", string expectedPassword = "pass") + { + _messages = messages.ToList(); + _expectedUser = expectedUser; + _expectedPassword = expectedPassword; + + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + + _acceptTask = AcceptLoop(_cts.Token); + } + + private async Task AcceptLoop(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + var client = await _listener.AcceptTcpClientAsync(ct); + _ = HandleClient(client, ct); + } + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + } + + private async Task HandleClient(TcpClient client, CancellationToken ct) + { + using var _ = client; + await using var stream = client.GetStream(); + var reader = new StreamReader(stream, Encoding.ASCII, false, 1024, leaveOpen: true); + var writer = new StreamWriter(stream, Encoding.ASCII, 1024, leaveOpen: true) { NewLine = "\r\n", AutoFlush = true }; + + await writer.WriteLineAsync("+OK Fake POP3 server ready"); + + var authenticatedUser = string.Empty; + + while (!ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(); + if (line == null) break; + + var spaceIndex = line.IndexOf(' '); + var command = (spaceIndex < 0 ? line : line[..spaceIndex]).ToUpperInvariant(); + var argument = spaceIndex < 0 ? string.Empty : line[(spaceIndex + 1)..]; + + switch (command) + { + case "CAPA": + await writer.WriteLineAsync("-ERR capabilities not supported"); + break; + + case "USER": + authenticatedUser = argument; + await writer.WriteLineAsync("+OK"); + break; + + case "PASS": + if (authenticatedUser == _expectedUser && argument == _expectedPassword) + await writer.WriteLineAsync("+OK logged in"); + else + await writer.WriteLineAsync("-ERR invalid credentials"); + break; + + case "STAT": + var activeCount = _messages.Count - _deleted.Count; + var totalSize = ActiveIndexes().Sum(i => Encoding.ASCII.GetByteCount(_messages[i])); + await writer.WriteLineAsync($"+OK {activeCount} {totalSize}"); + break; + + case "LIST": + await writer.WriteLineAsync($"+OK {_messages.Count - _deleted.Count} messages"); + foreach (var i in ActiveIndexes()) + await writer.WriteLineAsync($"{i + 1} {Encoding.ASCII.GetByteCount(_messages[i])}"); + await writer.WriteLineAsync("."); + break; + + case "RETR": + await HandleRetr(writer, argument); + break; + + case "DELE": + if (int.TryParse(argument, out var delNum) && delNum >= 1 && delNum <= _messages.Count) + { + _deleted.Add(delNum); + await writer.WriteLineAsync("+OK marked for deletion"); + } + else + { + await writer.WriteLineAsync("-ERR no such message"); + } + break; + + case "NOOP": + await writer.WriteLineAsync("+OK"); + break; + + case "QUIT": + await writer.WriteLineAsync("+OK bye"); + return; + + default: + await writer.WriteLineAsync("-ERR unknown command"); + break; + } + } + } + + private IEnumerable ActiveIndexes() => + Enumerable.Range(0, _messages.Count).Where(i => !_deleted.Contains(i + 1)); + + private async Task HandleRetr(StreamWriter writer, string argument) + { + if (!int.TryParse(argument, out var num) || num < 1 || num > _messages.Count || _deleted.Contains(num)) + { + await writer.WriteLineAsync("-ERR no such message"); + return; + } + + var message = _messages[num - 1]; + await writer.WriteLineAsync($"+OK {Encoding.ASCII.GetByteCount(message)} octets"); + + foreach (var messageLine in message.Replace("\r\n", "\n").Split('\n')) + { + var stuffed = messageLine.StartsWith('.') ? "." + messageLine : messageLine; + await writer.WriteLineAsync(stuffed); + } + + await writer.WriteLineAsync("."); + } + + public void Dispose() + { + _cts.Cancel(); + _listener.Stop(); + } +} diff --git a/SW.Bitween.UnitTests/NativePop3ReceiverTests.cs b/SW.Bitween.UnitTests/NativePop3ReceiverTests.cs new file mode 100644 index 00000000..192a44dc --- /dev/null +++ b/SW.Bitween.UnitTests/NativePop3ReceiverTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.NativeAdapters.Pop3Receiver; + +namespace SW.Bitween.UnitTests; + +[TestClass] +public class NativePop3ReceiverTests +{ + private static Dictionary Settings(string encoding = "utf8", int batchSize = 50) => new() + { + ["Host"] = "127.0.0.1", + ["Username"] = "user", + ["Password"] = "pass", + ["ResponseEncoding"] = encoding, + ["BatchSize"] = batchSize.ToString() + }; + + private static NativePop3Receiver CreateReceiver(FakePop3Server server, string encoding = "utf8", int batchSize = 50) + { + var receiver = new NativePop3Receiver { Port = server.Port, UseSsl = false }; + receiver.InitializeStartupValues(Settings(encoding, batchSize)); + return receiver; + } + + [TestMethod] + public async Task ListFiles_ReturnsOneEntryPerMessage() + { + using var server = new FakePop3Server(new[] { Pop3TestMessages.Plain, Pop3TestMessages.WithAttachment }); + var receiver = CreateReceiver(server); + + await receiver.Initialize(); + var files = (await receiver.ListFiles()).ToList(); + await receiver.Finalize(); + + Assert.AreEqual(2, files.Count); + } + + [TestMethod] + public async Task ListFiles_RespectsBatchSize() + { + using var server = new FakePop3Server(new[] + { Pop3TestMessages.Plain, Pop3TestMessages.WithAttachment, Pop3TestMessages.Plain }); + var receiver = CreateReceiver(server, batchSize: 2); + + await receiver.Initialize(); + var files = (await receiver.ListFiles()).ToList(); + await receiver.Finalize(); + + Assert.AreEqual(2, files.Count); + } + + [TestMethod] + public async Task GetFile_WithoutAttachment_ReturnsBodyText() + { + using var server = new FakePop3Server(new[] { Pop3TestMessages.Plain }); + var receiver = CreateReceiver(server); + + await receiver.Initialize(); + var files = (await receiver.ListFiles()).ToList(); + var file = await receiver.GetFile(files[0]); + await receiver.Finalize(); + + StringAssert.Contains(file.Data, "Hello, this is the body text."); + Assert.AreEqual("Plain Message", file.Filename); + } + + [TestMethod] + public async Task GetFile_WithAttachment_ReturnsAttachmentAsUtf8() + { + using var server = new FakePop3Server(new[] { Pop3TestMessages.WithAttachment }); + var receiver = CreateReceiver(server, "utf8"); + + await receiver.Initialize(); + var files = (await receiver.ListFiles()).ToList(); + var file = await receiver.GetFile(files[0]); + await receiver.Finalize(); + + Assert.AreEqual(Pop3TestMessages.AttachmentDecoded, file.Data); + } + + [TestMethod] + public async Task GetFile_WithAttachment_ReturnsAttachmentAsBase64() + { + using var server = new FakePop3Server(new[] { Pop3TestMessages.WithAttachment }); + var receiver = CreateReceiver(server, "base64"); + + await receiver.Initialize(); + var files = (await receiver.ListFiles()).ToList(); + var file = await receiver.GetFile(files[0]); + await receiver.Finalize(); + + Assert.AreEqual(Convert.ToBase64String(Encoding.UTF8.GetBytes(Pop3TestMessages.AttachmentDecoded)), file.Data); + } + + [TestMethod] + public async Task GetFile_WithUnknownEncoding_Throws() + { + using var server = new FakePop3Server(new[] { Pop3TestMessages.WithAttachment }); + var receiver = CreateReceiver(server, "unknown-encoding"); + + await receiver.Initialize(); + var files = (await receiver.ListFiles()).ToList(); + + await Assert.ThrowsExceptionAsync(() => receiver.GetFile(files[0])); + await receiver.Finalize(); + } + + [TestMethod] + public async Task DeleteFile_MarksMessageDeletedOnServer() + { + using var server = new FakePop3Server(new[] { Pop3TestMessages.Plain }); + var receiver = CreateReceiver(server); + + await receiver.Initialize(); + var files = (await receiver.ListFiles()).ToList(); + await receiver.DeleteFile(files[0]); + await receiver.Finalize(); + + CollectionAssert.Contains(server.DeletedMessageNumbers.ToList(), 1); + } +} diff --git a/SW.Bitween.UnitTests/NativeRebexPop3ReceiverTests.cs b/SW.Bitween.UnitTests/NativeRebexPop3ReceiverTests.cs new file mode 100644 index 00000000..a4a566a0 --- /dev/null +++ b/SW.Bitween.UnitTests/NativeRebexPop3ReceiverTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.NativeAdapters.RebexPop3Receiver; + +namespace SW.Bitween.UnitTests; + +/// +/// Requires a real Rebex license via the REBEX_LICENSE_KEY environment variable. +/// Tests report Inconclusive (not Failed) when it's not set, so CI/dev machines +/// without a license don't fail the build. +/// +[TestClass] +public class NativeRebexPop3ReceiverTests +{ + [TestInitialize] + public void SkipIfNoLicense() + { + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(NativeRebexPop3Receiver.LicenseKeyEnvironmentVariable))) + Assert.Inconclusive($"{NativeRebexPop3Receiver.LicenseKeyEnvironmentVariable} is not set."); + } + + private static Dictionary Settings(string encoding = "utf8", int batchSize = 50) => new() + { + ["Host"] = "127.0.0.1", + ["Username"] = "user", + ["Password"] = "pass", + ["ResponseEncoding"] = encoding, + ["BatchSize"] = batchSize.ToString() + }; + + private static NativeRebexPop3Receiver CreateReceiver(FakePop3Server server, string encoding = "utf8", int batchSize = 50) + { + var receiver = new NativeRebexPop3Receiver { Port = server.Port, UseSsl = false }; + receiver.InitializeStartupValues(Settings(encoding, batchSize)); + return receiver; + } + + [TestMethod] + public async Task ListFiles_ReturnsOneEntryPerMessage() + { + using var server = new FakePop3Server(new[] { Pop3TestMessages.Plain, Pop3TestMessages.WithAttachment }); + var receiver = CreateReceiver(server); + + await receiver.Initialize(); + var files = (await receiver.ListFiles()).ToList(); + await receiver.Finalize(); + + Assert.AreEqual(2, files.Count); + } + + [TestMethod] + public async Task ListFiles_RespectsBatchSize() + { + using var server = new FakePop3Server(new[] + { Pop3TestMessages.Plain, Pop3TestMessages.WithAttachment, Pop3TestMessages.Plain }); + var receiver = CreateReceiver(server, batchSize: 2); + + await receiver.Initialize(); + var files = (await receiver.ListFiles()).ToList(); + await receiver.Finalize(); + + Assert.AreEqual(2, files.Count); + } + + [TestMethod] + public async Task GetFile_WithoutAttachment_ReturnsBodyText() + { + using var server = new FakePop3Server(new[] { Pop3TestMessages.Plain }); + var receiver = CreateReceiver(server); + + await receiver.Initialize(); + var files = (await receiver.ListFiles()).ToList(); + var file = await receiver.GetFile(files[0]); + await receiver.Finalize(); + + StringAssert.Contains(file.Data, "Hello, this is the body text."); + Assert.AreEqual("Plain Message", file.Filename); + } + + [TestMethod] + public async Task GetFile_WithAttachment_ReturnsAttachmentAsUtf8() + { + using var server = new FakePop3Server(new[] { Pop3TestMessages.WithAttachment }); + var receiver = CreateReceiver(server, "utf8"); + + await receiver.Initialize(); + var files = (await receiver.ListFiles()).ToList(); + var file = await receiver.GetFile(files[0]); + await receiver.Finalize(); + + Assert.AreEqual(Pop3TestMessages.AttachmentDecoded, file.Data); + } + + [TestMethod] + public async Task GetFile_WithAttachment_ReturnsAttachmentAsBase64() + { + using var server = new FakePop3Server(new[] { Pop3TestMessages.WithAttachment }); + var receiver = CreateReceiver(server, "base64"); + + await receiver.Initialize(); + var files = (await receiver.ListFiles()).ToList(); + var file = await receiver.GetFile(files[0]); + await receiver.Finalize(); + + Assert.AreEqual(Convert.ToBase64String(Encoding.UTF8.GetBytes(Pop3TestMessages.AttachmentDecoded)), file.Data); + } + + [TestMethod] + public async Task GetFile_WithUnknownEncoding_Throws() + { + using var server = new FakePop3Server(new[] { Pop3TestMessages.WithAttachment }); + var receiver = CreateReceiver(server, "unknown-encoding"); + + await receiver.Initialize(); + var files = (await receiver.ListFiles()).ToList(); + + await Assert.ThrowsExceptionAsync(() => receiver.GetFile(files[0])); + await receiver.Finalize(); + } + + [TestMethod] + public async Task DeleteFile_MarksMessageDeletedOnServer() + { + using var server = new FakePop3Server(new[] { Pop3TestMessages.Plain }); + var receiver = CreateReceiver(server); + + await receiver.Initialize(); + var files = (await receiver.ListFiles()).ToList(); + await receiver.DeleteFile(files[0]); + await receiver.Finalize(); + + CollectionAssert.Contains(server.DeletedMessageNumbers.ToList(), 1); + } +} diff --git a/SW.Bitween.UnitTests/Pop3TestMessages.cs b/SW.Bitween.UnitTests/Pop3TestMessages.cs new file mode 100644 index 00000000..d985d77e --- /dev/null +++ b/SW.Bitween.UnitTests/Pop3TestMessages.cs @@ -0,0 +1,38 @@ +namespace SW.Bitween.UnitTests; + +internal static class Pop3TestMessages +{ + public const string Plain = + "From: sender@example.com\r\n" + + "To: receiver@example.com\r\n" + + "Subject: Plain Message\r\n" + + "Date: Mon, 1 Jan 2024 00:00:00 +0000\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "\r\n" + + "Hello, this is the body text.\r\n"; + + // Attachment content ("SGVsbG8gV29ybGQh") base64-decodes to "Hello World!" + public const string WithAttachment = + "From: sender@example.com\r\n" + + "To: receiver@example.com\r\n" + + "Subject: With Attachment\r\n" + + "Date: Mon, 1 Jan 2024 00:00:00 +0000\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: multipart/mixed; boundary=\"BOUNDARY123\"\r\n" + + "\r\n" + + "--BOUNDARY123\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "\r\n" + + "See attached file.\r\n" + + "\r\n" + + "--BOUNDARY123\r\n" + + "Content-Type: application/octet-stream; name=\"hello.txt\"\r\n" + + "Content-Transfer-Encoding: base64\r\n" + + "Content-Disposition: attachment; filename=\"hello.txt\"\r\n" + + "\r\n" + + "SGVsbG8gV29ybGQh\r\n" + + "\r\n" + + "--BOUNDARY123--\r\n"; + + public const string AttachmentDecoded = "Hello World!"; +}