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..d3a7b68ca 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 = 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/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 @@