From 1b7d199269634a42e91a9c467953e8f163b997af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:51:59 +0000 Subject: [PATCH 1/4] Initial plan From 831179359eaeec3c02eef5fd5d5ed7bf23bc66af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:58:52 +0000 Subject: [PATCH 2/4] Add custom headers and raw EML support to POST /api/Messages/send Agent-Logs-Url: https://github.com/rnwood/smtp4dev/sessions/34f0d6ef-2575-431d-8172-1e5efca78cf9 Co-authored-by: rnwood <1327895+rnwood@users.noreply.github.com> --- .../Controllers/SendMessageTests.cs | 309 ++++++++++++++++++ .../src/ApiClient/MessagesController.ts | 25 +- .../Controllers/MessagesController.cs | 108 +++++- Rnwood.Smtp4dev/Server/ISmtp4devServer.cs | 5 + Rnwood.Smtp4dev/Server/Smtp4devServer.cs | 12 + 5 files changed, 441 insertions(+), 18 deletions(-) create mode 100644 Rnwood.Smtp4dev.Tests/Controllers/SendMessageTests.cs diff --git a/Rnwood.Smtp4dev.Tests/Controllers/SendMessageTests.cs b/Rnwood.Smtp4dev.Tests/Controllers/SendMessageTests.cs new file mode 100644 index 000000000..5e3a8cee4 --- /dev/null +++ b/Rnwood.Smtp4dev.Tests/Controllers/SendMessageTests.cs @@ -0,0 +1,309 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using MimeKit; +using NSubstitute; +using Rnwood.Smtp4dev.Controllers; +using Rnwood.Smtp4dev.Data; +using Rnwood.Smtp4dev.Server; +using Xunit; + +namespace Rnwood.Smtp4dev.Tests.Controllers +{ + public class SendMessageTests + { + private readonly MessagesController controller; + private readonly ISmtp4devServer server; + + public SendMessageTests() + { + var messagesRepository = Substitute.For(); + server = Substitute.For(); + controller = new MessagesController(messagesRepository, server, new MimeProcessingService()); + } + + private void SetupHttpContext(string contentType, byte[] body = null) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.ContentType = contentType; + if (body != null) + { + httpContext.Request.Body = new MemoryStream(body); + } + controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + } + + [Fact] + public async Task Send_WithCustomHeadersJson_PassesHeadersToServer() + { + // Arrange + SetupHttpContext("multipart/form-data"); + string headersJson = "{\"X-Custom-Header\": \"custom-value\", \"X-Another\": \"another-value\"}"; + + // Act + var result = await controller.Send( + to: "to@example.com", + cc: null, + bcc: null, + from: "from@example.com", + deliverToAll: false, + subject: "Test Subject", + bodyHtml: "Test", + headers: headersJson, + attachments: null); + + // Assert + result.Should().BeOfType(); + server.Received(1).Send( + Arg.Is>(h => + h["X-Custom-Header"] == "custom-value" && + h["X-Another"] == "another-value"), + Arg.Any(), + Arg.Any(), + "from@example.com", + Arg.Any(), + "Test Subject", + "Test", + Arg.Any>()); + } + + [Fact] + public async Task Send_WithInvalidHeadersJson_ReturnsBadRequest() + { + // Arrange + SetupHttpContext("multipart/form-data"); + string invalidJson = "not-valid-json"; + + // Act + var result = await controller.Send( + to: "to@example.com", + cc: null, + bcc: null, + from: "from@example.com", + deliverToAll: false, + subject: "Test Subject", + bodyHtml: "Test", + headers: invalidJson, + attachments: null); + + // Assert + result.Should().BeOfType(); + server.DidNotReceive().Send( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task Send_WithRawEml_CallsSendRaw() + { + // Arrange + var mimeMessage = new MimeMessage(); + mimeMessage.From.Add(InternetAddress.Parse("sender@example.com")); + mimeMessage.To.Add(InternetAddress.Parse("recipient@example.com")); + mimeMessage.Subject = "Raw EML Test"; + var bodyBuilder = new BodyBuilder { HtmlBody = "Hello" }; + mimeMessage.Body = bodyBuilder.ToMessageBody(); + + using var emlStream = new MemoryStream(); + await mimeMessage.WriteToAsync(emlStream); + byte[] emlData = emlStream.ToArray(); + + SetupHttpContext("message/rfc822", emlData); + + // Act + var result = await controller.Send( + to: null, + cc: null, + bcc: null, + from: null, + deliverToAll: false, + subject: null, + bodyHtml: null, + headers: null, + attachments: null); + + // Assert + result.Should().BeOfType(); + server.Received(1).SendRaw( + Arg.Is(m => m.Subject == "Raw EML Test"), + "sender@example.com", + Arg.Is(r => r.Length == 1 && r[0] == "recipient@example.com")); + } + + [Fact] + public async Task Send_WithRawEmlAndOverrideRecipients_UsesQueryParamRecipients() + { + // Arrange + var mimeMessage = new MimeMessage(); + mimeMessage.From.Add(InternetAddress.Parse("sender@example.com")); + mimeMessage.To.Add(InternetAddress.Parse("original@example.com")); + mimeMessage.Subject = "Override Recipients Test"; + var bodyBuilder = new BodyBuilder { HtmlBody = "Hello" }; + mimeMessage.Body = bodyBuilder.ToMessageBody(); + + using var emlStream = new MemoryStream(); + await mimeMessage.WriteToAsync(emlStream); + byte[] emlData = emlStream.ToArray(); + + SetupHttpContext("message/rfc822", emlData); + + // Act + var result = await controller.Send( + to: "override@example.com", + cc: null, + bcc: null, + from: "override-sender@example.com", + deliverToAll: false, + subject: null, + bodyHtml: null, + headers: null, + attachments: null); + + // Assert + result.Should().BeOfType(); + server.Received(1).SendRaw( + Arg.Any(), + "override-sender@example.com", + Arg.Is(r => r.Length == 1 && r[0] == "override@example.com")); + } + + [Fact] + public async Task Send_WithRawEmlMissingFrom_ReturnsBadRequest() + { + // Arrange - EML with no From header + var mimeMessage = new MimeMessage(); + mimeMessage.To.Add(InternetAddress.Parse("recipient@example.com")); + mimeMessage.Subject = "No From Test"; + var bodyBuilder = new BodyBuilder { HtmlBody = "Hello" }; + mimeMessage.Body = bodyBuilder.ToMessageBody(); + + using var emlStream = new MemoryStream(); + await mimeMessage.WriteToAsync(emlStream); + byte[] emlData = emlStream.ToArray(); + + SetupHttpContext("message/rfc822", emlData); + + // Act + var result = await controller.Send( + to: null, + cc: null, + bcc: null, + from: null, + deliverToAll: false, + subject: null, + bodyHtml: null, + headers: null, + attachments: null); + + // Assert + result.Should().BeOfType(); + server.DidNotReceive().SendRaw( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Send_WithRawEmlMissingRecipients_ReturnsBadRequest() + { + // Arrange - EML with no recipients + var mimeMessage = new MimeMessage(); + mimeMessage.From.Add(InternetAddress.Parse("sender@example.com")); + mimeMessage.Subject = "No Recipients Test"; + var bodyBuilder = new BodyBuilder { HtmlBody = "Hello" }; + mimeMessage.Body = bodyBuilder.ToMessageBody(); + + using var emlStream = new MemoryStream(); + await mimeMessage.WriteToAsync(emlStream); + byte[] emlData = emlStream.ToArray(); + + SetupHttpContext("message/rfc822", emlData); + + // Act + var result = await controller.Send( + to: null, + cc: null, + bcc: null, + from: null, + deliverToAll: false, + subject: null, + bodyHtml: null, + headers: null, + attachments: null); + + // Assert + result.Should().BeOfType(); + server.DidNotReceive().SendRaw( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Send_WithRawEmlEmpty_ReturnsBadRequest() + { + // Arrange + SetupHttpContext("message/rfc822", Array.Empty()); + + // Act + var result = await controller.Send( + to: null, + cc: null, + bcc: null, + from: null, + deliverToAll: false, + subject: null, + bodyHtml: null, + headers: null, + attachments: null); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task Send_WithNoCustomHeaders_SendsEmptyHeaderDictionary() + { + // Arrange + SetupHttpContext("multipart/form-data"); + + // Act + var result = await controller.Send( + to: "to@example.com", + cc: null, + bcc: null, + from: "from@example.com", + deliverToAll: false, + subject: "Test Subject", + bodyHtml: "Test", + headers: null, + attachments: null); + + // Assert + result.Should().BeOfType(); + server.Received(1).Send( + Arg.Is>(h => h.Count == 0), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + } +} diff --git a/Rnwood.Smtp4dev/ClientApp/src/ApiClient/MessagesController.ts b/Rnwood.Smtp4dev/ClientApp/src/ApiClient/MessagesController.ts index 502a30a06..ad1fc8c57 100644 --- a/Rnwood.Smtp4dev/ClientApp/src/ApiClient/MessagesController.ts +++ b/Rnwood.Smtp4dev/ClientApp/src/ApiClient/MessagesController.ts @@ -105,12 +105,15 @@ export default class MessagesController { return `${this.apiBaseUrl}/send?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&cc=${encodeURIComponent(cc)}&bcc=${encodeURIComponent(bcc)}&deliverToAll=${encodeURIComponent(deliverToAll)}&subject=${encodeURIComponent(subject)}`; } - public async send(from: string, to: string, cc: string, bcc: string, deliverToAll: boolean, subject: string, bodyHtml: string, attachments?: File[]): Promise { - // If attachments provided, use multipart/form-data, otherwise use text/html - if (attachments && attachments.length > 0) { + public async send(from: string, to: string, cc: string, bcc: string, deliverToAll: boolean, subject: string, bodyHtml: string, attachments?: File[], customHeaders?: Record): Promise { + // If attachments or custom headers provided, use multipart/form-data, otherwise use text/html + if ((attachments && attachments.length > 0) || customHeaders) { const formData = new FormData(); formData.append('bodyHtml', bodyHtml); - attachments.forEach(file => { + if (customHeaders && Object.keys(customHeaders).length > 0) { + formData.append('headers', JSON.stringify(customHeaders)); + } + attachments?.forEach(file => { formData.append('attachments', file); }); return (await axios.post(this.send_url(from, to, cc, bcc, deliverToAll, subject), formData, { @@ -123,6 +126,20 @@ export default class MessagesController { } } + public async sendRaw(emlContent: string | ArrayBuffer, from?: string, to?: string, deliverToAll?: boolean): Promise { + let url = `${this.apiBaseUrl}/send`; + const params: string[] = []; + if (from) params.push(`from=${encodeURIComponent(from)}`); + if (to) params.push(`to=${encodeURIComponent(to)}`); + if (deliverToAll !== undefined) params.push(`deliverToAll=${encodeURIComponent(deliverToAll)}`); + if (params.length > 0) url += '?' + params.join('&'); + + const body = typeof emlContent === 'string' ? new Blob([emlContent], { type: 'message/rfc822' }) : new Blob([emlContent], { type: 'message/rfc822' }); + return (await axios.post(url, body, { + headers: { "Content-Type": "message/rfc822" } + })).data as void; + } + // get: api/Messages/${encodeURIComponent(id)}/part/${encodeURIComponent(partid)}/content public getPartContent_url(id: string, partid: string, download: boolean): string { return `${this.apiBaseUrl}/${encodeURIComponent(id)}/part/${encodeURIComponent(partid)}/content?download=${download}`; diff --git a/Rnwood.Smtp4dev/Controllers/MessagesController.cs b/Rnwood.Smtp4dev/Controllers/MessagesController.cs index 73ac721ce..9a29d50c7 100644 --- a/Rnwood.Smtp4dev/Controllers/MessagesController.cs +++ b/Rnwood.Smtp4dev/Controllers/MessagesController.cs @@ -21,6 +21,7 @@ using MimeKit; using HtmlAgilityPack; using Serilog; +using System.Text.Json; namespace Rnwood.Smtp4dev.Controllers { @@ -241,20 +242,23 @@ public async Task Reply( /// /// Sends a message via the configured upstream/relay SMTP server. - /// Accepts either text/html (for body only) or multipart/form-data (for body with optional attachments). + /// Accepts text/html (for body only), multipart/form-data (for body with optional attachments and custom headers), + /// or message/rfc822 (raw EML). /// - /// List of email addresses separated by commas + /// List of email addresses separated by commas. When content type is message/rfc822, overrides the envelope recipients (optional - defaults to message To header). /// List of email addresses separated by commas /// List of email addresses separated by commas - /// Email address + /// Email address. When content type is message/rfc822, overrides the envelope sender (optional - defaults to message From header). /// True if the message should be delivered to the CC and BCC recipients in addition to the TO recipients. When false, the message is only delivered to the TO recipients, but the message headers will show the specified other recipients. /// The subject of message /// HTML body content (when using multipart/form-data) + /// Optional JSON-encoded dictionary of custom headers to add to the message e.g. {"X-Custom-Header": "value"} (when using multipart/form-data or text/html) /// Optional files to attach (when using multipart/form-data) /// [HttpPost("send")] - [Consumes("text/html", "multipart/form-data")] + [Consumes("text/html", "multipart/form-data", "message/rfc822")] [SwaggerResponse(System.Net.HttpStatusCode.OK, typeof(void), Description = "")] + [SwaggerResponse(System.Net.HttpStatusCode.BadRequest, typeof(void), Description = "If custom headers JSON is invalid or EML content is invalid.")] [SwaggerResponse(System.Net.HttpStatusCode.InternalServerError, typeof(void), Description = "If message fails to send.")] public async Task Send( string to, @@ -264,29 +268,105 @@ public async Task Send( bool deliverToAll, string subject, [FromForm] string bodyHtml = null, + [FromForm] string headers = null, [FromForm] List attachments = null) { - // Handle different content types + // Handle raw EML (message/rfc822) content type + if (HttpContext.Request.ContentType?.StartsWith("message/rfc822") == true) + { + byte[] emlData; + using (var stream = new MemoryStream()) + { + await HttpContext.Request.Body.CopyToAsync(stream); + emlData = stream.ToArray(); + } + + if (emlData.Length == 0) + { + return BadRequest("EML content is empty"); + } + + MimeMessage mimeMessage; + try + { + using var emlStream = new MemoryStream(emlData); + mimeMessage = await MimeMessage.LoadAsync(emlStream); + } + catch (Exception ex) + { + return BadRequest($"Failed to parse EML: {ex.Message}"); + } + + // Determine envelope sender: use query param if provided, else fall back to message From header + string envelopeFrom = from; + if (string.IsNullOrEmpty(envelopeFrom)) + { + envelopeFrom = mimeMessage.From.OfType().FirstOrDefault()?.Address; + if (string.IsNullOrEmpty(envelopeFrom)) + { + return BadRequest("No sender specified. Provide a 'from' query parameter or a From header in the EML."); + } + } + + // Determine envelope recipients: use query params if provided, else fall back to message headers + List envelopeRecips; + if (!string.IsNullOrEmpty(to)) + { + var toRecips = to.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var ccRecips = cc?.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; + var bccRecips = bcc?.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; + envelopeRecips = deliverToAll ? [.. toRecips, .. ccRecips, .. bccRecips] : [.. toRecips]; + } + else + { + envelopeRecips = mimeMessage.To.OfType().Select(a => a.Address).ToList(); + if (deliverToAll) + { + envelopeRecips.AddRange(mimeMessage.Cc.OfType().Select(a => a.Address)); + envelopeRecips.AddRange(mimeMessage.Bcc.OfType().Select(a => a.Address)); + } + if (!envelopeRecips.Any()) + { + return BadRequest("No recipients specified. Provide a 'to' query parameter or To/Cc/Bcc headers in the EML."); + } + } + + this.server.SendRaw(mimeMessage, envelopeFrom, envelopeRecips.Distinct().ToArray()); + return Ok(); + } + + // Handle text/html or multipart/form-data if (HttpContext.Request.ContentType?.StartsWith("text/html") == true) { bodyHtml = await HttpContext.Request.Body.ReadStringAsync(Encoding.UTF8); } - Dictionary headers = new Dictionary(); + Dictionary customHeaders = new Dictionary(); + if (!string.IsNullOrEmpty(headers)) + { + try + { + customHeaders = JsonSerializer.Deserialize>(headers); + } + catch (JsonException ex) + { + return BadRequest($"Invalid headers JSON: {ex.Message}"); + } + } - var toRecips = to?.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; - var ccRecips = cc?.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; - var bccRecips = bcc?.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; + var toRecipients = to?.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; + var ccRecipients = cc?.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; + var bccRecipients = bcc?.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; - List envelopeRecips = deliverToAll ? [.. toRecips, .. ccRecips, .. bccRecips] : [.. toRecips]; + List envelopeRecipients = deliverToAll ? [.. toRecipients, .. ccRecipients, .. bccRecipients] : [.. toRecipients]; // Process attachments if provided var attachmentInfos = await ProcessAttachments(attachments); - this.server.Send(headers, - toRecips, - ccRecips, - from, envelopeRecips.Distinct().ToArray(), subject, bodyHtml, attachmentInfos); + this.server.Send(customHeaders, + toRecipients, + ccRecipients, + from, envelopeRecipients.Distinct().ToArray(), subject, bodyHtml, attachmentInfos); return Ok(); } diff --git a/Rnwood.Smtp4dev/Server/ISmtp4devServer.cs b/Rnwood.Smtp4dev/Server/ISmtp4devServer.cs index 4693d3901..77b3154e0 100644 --- a/Rnwood.Smtp4dev/Server/ISmtp4devServer.cs +++ b/Rnwood.Smtp4dev/Server/ISmtp4devServer.cs @@ -42,5 +42,10 @@ public interface ISmtp4devServer Task DeleteSession(Guid id); Task DeleteAllSessions(); void Send(IDictionary headers, string[] to, string[] cc, string from, string[] envelopeRecipients, string subject, string bodyHtml, IEnumerable attachments = null); + + /// + /// Sends a pre-built MimeMessage via the configured relay SMTP server. + /// + void SendRaw(MimeMessage message, string from, string[] envelopeRecipients); } } \ No newline at end of file diff --git a/Rnwood.Smtp4dev/Server/Smtp4devServer.cs b/Rnwood.Smtp4dev/Server/Smtp4devServer.cs index c7bbe053b..60a04b597 100644 --- a/Rnwood.Smtp4dev/Server/Smtp4devServer.cs +++ b/Rnwood.Smtp4dev/Server/Smtp4devServer.cs @@ -992,5 +992,17 @@ public void Send(IDictionary headers, string[] to, string[] cc, relaySmtpClient.Send(message, MailboxAddress.Parse(from), envelopeRecipients.Select(t => MailboxAddress.Parse(t))); } + + public void SendRaw(MimeMessage message, string from, string[] envelopeRecipients) + { + var relaySmtpClient = this.relaySmtpClientFactory(this.relayOptions.CurrentValue); + + if (relaySmtpClient == null) + { + throw new InvalidOperationException("Relay SMTP server must be configured to send messages."); + } + + relaySmtpClient.Send(message, MailboxAddress.Parse(from), envelopeRecipients.Select(t => MailboxAddress.Parse(t))); + } } } \ No newline at end of file From 3ac52575cf5b97591b7ca9fa509020214fb04b22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:04:54 +0000 Subject: [PATCH 3/4] Address code review feedback: extract relay client factory, fix exception handling, fix variable names, simplify Blob creation Agent-Logs-Url: https://github.com/rnwood/smtp4dev/sessions/34f0d6ef-2575-431d-8172-1e5efca78cf9 Co-authored-by: rnwood <1327895+rnwood@users.noreply.github.com> --- .../src/ApiClient/MessagesController.ts | 2 +- .../Controllers/MessagesController.cs | 26 +++++++++++-------- Rnwood.Smtp4dev/Server/Smtp4devServer.cs | 17 ++++++------ 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/Rnwood.Smtp4dev/ClientApp/src/ApiClient/MessagesController.ts b/Rnwood.Smtp4dev/ClientApp/src/ApiClient/MessagesController.ts index ad1fc8c57..d3a7b68ca 100644 --- a/Rnwood.Smtp4dev/ClientApp/src/ApiClient/MessagesController.ts +++ b/Rnwood.Smtp4dev/ClientApp/src/ApiClient/MessagesController.ts @@ -134,7 +134,7 @@ export default class MessagesController { if (deliverToAll !== undefined) params.push(`deliverToAll=${encodeURIComponent(deliverToAll)}`); if (params.length > 0) url += '?' + params.join('&'); - const body = typeof emlContent === 'string' ? new Blob([emlContent], { type: 'message/rfc822' }) : new Blob([emlContent], { type: 'message/rfc822' }); + const body = new Blob([emlContent], { type: 'message/rfc822' }); return (await axios.post(url, body, { headers: { "Content-Type": "message/rfc822" } })).data as void; diff --git a/Rnwood.Smtp4dev/Controllers/MessagesController.cs b/Rnwood.Smtp4dev/Controllers/MessagesController.cs index 9a29d50c7..2ce77d206 100644 --- a/Rnwood.Smtp4dev/Controllers/MessagesController.cs +++ b/Rnwood.Smtp4dev/Controllers/MessagesController.cs @@ -292,10 +292,14 @@ public async Task Send( using var emlStream = new MemoryStream(emlData); mimeMessage = await MimeMessage.LoadAsync(emlStream); } - catch (Exception ex) + catch (FormatException ex) { return BadRequest($"Failed to parse EML: {ex.Message}"); } + catch (IOException ex) + { + return BadRequest($"Failed to read EML: {ex.Message}"); + } // Determine envelope sender: use query param if provided, else fall back to message From header string envelopeFrom = from; @@ -309,29 +313,29 @@ public async Task Send( } // Determine envelope recipients: use query params if provided, else fall back to message headers - List envelopeRecips; + List emlEnvelopeRecipients; if (!string.IsNullOrEmpty(to)) { - var toRecips = to.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var ccRecips = cc?.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; - var bccRecips = bcc?.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; - envelopeRecips = deliverToAll ? [.. toRecips, .. ccRecips, .. bccRecips] : [.. toRecips]; + var emlToRecipients = to.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var emlCcRecipients = cc?.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; + var emlBccRecipients = bcc?.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; + emlEnvelopeRecipients = deliverToAll ? [.. emlToRecipients, .. emlCcRecipients, .. emlBccRecipients] : [.. emlToRecipients]; } else { - envelopeRecips = mimeMessage.To.OfType().Select(a => a.Address).ToList(); + emlEnvelopeRecipients = mimeMessage.To.OfType().Select(a => a.Address).ToList(); if (deliverToAll) { - envelopeRecips.AddRange(mimeMessage.Cc.OfType().Select(a => a.Address)); - envelopeRecips.AddRange(mimeMessage.Bcc.OfType().Select(a => a.Address)); + emlEnvelopeRecipients.AddRange(mimeMessage.Cc.OfType().Select(a => a.Address)); + emlEnvelopeRecipients.AddRange(mimeMessage.Bcc.OfType().Select(a => a.Address)); } - if (!envelopeRecips.Any()) + if (!emlEnvelopeRecipients.Any()) { return BadRequest("No recipients specified. Provide a 'to' query parameter or To/Cc/Bcc headers in the EML."); } } - this.server.SendRaw(mimeMessage, envelopeFrom, envelopeRecips.Distinct().ToArray()); + this.server.SendRaw(mimeMessage, envelopeFrom, emlEnvelopeRecipients.Distinct().ToArray()); return Ok(); } diff --git a/Rnwood.Smtp4dev/Server/Smtp4devServer.cs b/Rnwood.Smtp4dev/Server/Smtp4devServer.cs index 60a04b597..fddb3d9ef 100644 --- a/Rnwood.Smtp4dev/Server/Smtp4devServer.cs +++ b/Rnwood.Smtp4dev/Server/Smtp4devServer.cs @@ -950,12 +950,7 @@ Task IHostedService.StopAsync(CancellationToken cancellationToken) public void Send(IDictionary headers, string[] to, string[] cc, string from, string[] envelopeRecipients, string subject, string bodyHtml, IEnumerable attachments = null) { MailboxAddress sender = MailboxAddress.Parse(from); - var relaySmtpClient = this.relaySmtpClientFactory(this.relayOptions.CurrentValue); - - if (relaySmtpClient == null) - { - throw new InvalidOperationException("Relay SMTP server must be configued to send messages."); - } + var relaySmtpClient = GetRelaySmtpClient(); MimeMessage message = new MimeMessage(); message.Subject = subject; @@ -995,14 +990,18 @@ public void Send(IDictionary headers, string[] to, string[] cc, public void SendRaw(MimeMessage message, string from, string[] envelopeRecipients) { - var relaySmtpClient = this.relaySmtpClientFactory(this.relayOptions.CurrentValue); + var relaySmtpClient = GetRelaySmtpClient(); + relaySmtpClient.Send(message, MailboxAddress.Parse(from), envelopeRecipients.Select(t => MailboxAddress.Parse(t))); + } + private SmtpClient GetRelaySmtpClient() + { + var relaySmtpClient = this.relaySmtpClientFactory(this.relayOptions.CurrentValue); if (relaySmtpClient == null) { throw new InvalidOperationException("Relay SMTP server must be configured to send messages."); } - - relaySmtpClient.Send(message, MailboxAddress.Parse(from), envelopeRecipients.Select(t => MailboxAddress.Parse(t))); + return relaySmtpClient; } } } \ No newline at end of file From 56d7333ebdbed83689a5f82685629ec1a4227065 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:15:23 +0000 Subject: [PATCH 4/4] Changes before error encountered Agent-Logs-Url: https://github.com/rnwood/smtp4dev/sessions/24cf3982-e977-40cb-b90d-1d06204ea55d Co-authored-by: rnwood <1327895+rnwood@users.noreply.github.com> --- .../src/components/messagecompose.vue | 164 ++++++++++++------ 1 file changed, 111 insertions(+), 53 deletions(-) diff --git a/Rnwood.Smtp4dev/ClientApp/src/components/messagecompose.vue b/Rnwood.Smtp4dev/ClientApp/src/components/messagecompose.vue index db241aceb..b168169b4 100644 --- a/Rnwood.Smtp4dev/ClientApp/src/components/messagecompose.vue +++ b/Rnwood.Smtp4dev/ClientApp/src/components/messagecompose.vue @@ -1,48 +1,82 @@