public void Multiple_senders_should_not_match() { var parser = new SendgridEmailParser(); var request = EmailRequest.Parse(Load("mail.txt"), parser); var config = GetConfig(); config.Rules[0].Filters = new[] { new EmailFilter { Type = "!sender contains", AllOf = new[] { "*****@*****.**", "*****@*****.**" } } }; request.Email.From.Email = "*****@*****.**"; var actions = EmailService.ActionsToPerform(request.Email, config); actions.Select(a => a.Id).Should().BeEmpty(); request.Email.From.Name = request.Email.From.Email = "*****@*****.**"; actions = EmailService.ActionsToPerform(request.Email, config); actions.Select(a => a.Id).Should().ContainInOrder("forward-all", "notify-all"); }
public void Subject_filter_should_match() { var parser = new SendgridEmailParser(); var request = EmailRequest.Parse(Load("mail.txt"), parser); var config = GetConfig(); config.Rules[0].Filters = new[] { new EmailFilter { Type = "subject contains", OneOf = new[] { "Test 1", "not found" } }, new EmailFilter { Type = "sender contains", OneOf = new[] { "not found", "sender@" } } }; var actions = EmailService.ActionsToPerform(request.Email, config); actions.Select(a => a.Id).Should().ContainInOrder("forward-all", "notify-all"); }
public async Task Service_should_ignore_disabled_rule_entirely() { var status = new Mock <IStatusService>(); var vault = new Mock <IKeyVaultHelper>(); var config = new Mock <IConfigService>(); var storage = new Mock <IBlobStorageService>(); var http = new Mock <IHttpClient>(); var parser = new SendgridEmailParser(); var request = EmailRequest.Parse(Load("mail.txt"), parser); status.Setup(x => x.GetStatiAsync(request, It.IsAny <CancellationToken>())) .ReturnsAsync(new Dictionary <string, StatusModel>()); var cfg = GetConfig(); cfg.Rules = new[] { new EmailRule { Enabled = false, Filters = new [] { new EmailFilter { Type = "subject contains", OneOf = new[] { "not found" } } }, Actions = new[] { new EmailAction { Id = "executed", Type = ActionType.Forward, Properties = JObject.FromObject(new { webhook = new { secretName = "fwd" } }) } } } }; config.Setup(x => x.LoadAsync(It.IsAny <CancellationToken>())) .ReturnsAsync(cfg); var service = new EmailService(status.Object, vault.Object, config.Object, storage.Object, http.Object); var success = await service.ProcessMailAsync(request, CancellationToken.None); success.Should().BeTrue(); status.Verify(x => x.GetStatiAsync(request, It.IsAny <CancellationToken>())); status.VerifyNoOtherCalls(); }
public void No_action_filter_should_return_all() { var parser = new SendgridEmailParser(); var request = EmailRequest.Parse(Load("mail.txt"), parser); var config = GetConfig(); var actions = EmailService.ActionsToPerform(request.Email, config); actions.Select(a => a.Id).Should().ContainInOrder("forward-all", "notify-all"); }
public void AttachmentsShouldBeCorrectlyParsed() { var parser = new SendgridEmailParser(); using (var form = Build(new Dictionary <string, object> { { "from", "from <*****@*****.**>" }, { "to", "no name <*****@*****.**>" }, { "subject", "subj" }, { "html", "other content" }, { "attachment-info", JsonConvert.SerializeObject(new { attachment1 = new { filename = "en.txt", type = "text/plain" }, attachment2 = new { filename = "img.jpg", type = "image/jpeg" } }) }, { "attachment1", new EmailAttachment { FileName = "en.txt", ContentType = "text/plain", Base64Data = "This is the actual content" } }, { "attachment2", new EmailAttachment { FileName = "img.jpg", ContentType = "image/jpeg", Base64Data = "large base64 payload" } } })) { var result = parser.Parse(form); result.From.Email.Should().Be("*****@*****.**"); result.To[0].Email.Should().Be("*****@*****.**"); result.Subject.Should().Be("subj"); result.Html.Should().Be("other content"); result.Attachments.Should().HaveCount(2); result.Attachments[0].ContentType.Should().Be("text/plain"); result.Attachments[0].FileName.Should().Be("en.txt"); result.Attachments[0].Base64Data.Should().Be(Convert.ToBase64String(Encoding.UTF8.GetBytes("This is the actual content"))); result.Attachments[1].ContentType.Should().Be("image/jpeg"); result.Attachments[1].FileName.Should().Be("img.jpg"); result.Attachments[1].Base64Data.Should().Be(Convert.ToBase64String(Encoding.UTF8.GetBytes("large base64 payload"))); } }
public void HtmlShouldBePreferredOverText() { var parser = new SendgridEmailParser(); const string actualContent = "Only this should be submitted"; var data = new Dictionary <string, object> { { "from", "from <*****@*****.**>" }, { "to", "no name <*****@*****.**>" }, { "subject", "subj" }, { "text", "other content" }, { "html", @"<html> <head> <meta http-equiv=""Content-Type"" content=""text/html; charset=iso-8859-1""> <style type=""text/css"" style=""display:none;""> P {margin-top:0;margin-bottom:0;} </style> </head> <body dir=""ltr""> <div style=""font-family:Calibri,Helvetica,sans-serif; font-size:12pt; color:rgb(0,0,0)"">" + actualContent + @"<br> </div> <div style=""font-family:Calibri,Helvetica,sans-serif; font-size:12pt; color:rgb(0,0,0)""> </div> <div style=""font-family:Calibri,Helvetica,sans-serif; font-size:12pt; color:rgb(0,0,0)""> <br> </div> <div id=""Signature""> <div id=""divtagdefaultwrapper"" style=""font-size:12pt; color:#000000; font-family:Calibri,Arial,Helvetica,sans-serif""> Regards, <div>Sender</div> </div> </div> </body> </html>" } }; using (var form = Build(data)) { var result = parser.Parse(form); result.From.Email.Should().Be("*****@*****.**"); result.To[0].Email.Should().Be("*****@*****.**"); result.Subject.Should().Be("subj"); result.Html.Should().Be((string)data["html"]); } }
public async Task Retry_should_only_invoke_non_successful_actions() { var status = new Mock <IStatusService>(); var vault = new Mock <IKeyVaultHelper>(); var config = new Mock <IConfigService>(); var storage = new Mock <IBlobStorageService>(); var http = new Mock <IHttpClient>(); var parser = new SendgridEmailParser(); var request = EmailRequest.Parse(Load("mail.txt"), parser); // pretend email was received before and this is a retry status.Setup(x => x.GetStatiAsync(request, It.IsAny <CancellationToken>())) .ReturnsAsync(new Dictionary <string, StatusModel> { ["forward-all"] = new StatusModel { ActionId = "forward-all", Status = EmailFanoutStatus.DeferredOrFailed.ToString() }, ["notify-all"] = new StatusModel { ActionId = "notify-all", Status = EmailFanoutStatus.Completed.ToString() } }); config.Setup(x => x.LoadAsync(It.IsAny <CancellationToken>())) .ReturnsAsync(GetConfig()); vault.Setup(x => x.GetSecretAsync("Webhook1", It.IsAny <CancellationToken>())) .ReturnsAsync("url1"); http.Setup(x => x.SendAsync(It.Is <HttpRequestMessage>(r => r.RequestUri.ToString() == "url1"), It.IsAny <CancellationToken>())) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); var service = new EmailService(status.Object, vault.Object, config.Object, storage.Object, http.Object); var success = await service.ProcessMailAsync(request, CancellationToken.None); success.Should().BeTrue(); status.Verify(x => x.GetStatiAsync(request, It.IsAny <CancellationToken>())); status.Verify(x => x.UpdateAsync(request, It.Is <EmailAction>(a => a.Id == "forward-all"), EmailFanoutStatus.Completed, It.IsAny <CancellationToken>())); status.VerifyNoOtherCalls(); }
public async Task Multiple_exceptions_should_be_aggregated_when_more_than_one_service_fails() { var status = new Mock <IStatusService>(); var vault = new Mock <IKeyVaultHelper>(); var config = new Mock <IConfigService>(); var storage = new Mock <IBlobStorageService>(); var http = new Mock <IHttpClient>(); var parser = new SendgridEmailParser(); var request = EmailRequest.Parse(Load("mail.txt"), parser); status.Setup(x => x.GetStatiAsync(request, It.IsAny <CancellationToken>())) .ReturnsAsync(new Dictionary <string, StatusModel>()); config.Setup(x => x.LoadAsync(It.IsAny <CancellationToken>())) .ReturnsAsync(GetConfig()); vault.Setup(x => x.GetSecretAsync("Webhook1", It.IsAny <CancellationToken>())) .Throws(new WebException("pretent secret not found")); vault.Setup(x => x.GetSecretAsync("Webhook2", It.IsAny <CancellationToken>())) .ReturnsAsync("url2"); http.Setup(x => x.SendAsync(It.Is <HttpRequestMessage>(r => r.RequestUri.ToString() == "url2"), It.IsAny <CancellationToken>())) .Throws(new WebException("pretent secret not reachable")); var service = new EmailService(status.Object, vault.Object, config.Object, storage.Object, http.Object); try { await service.ProcessMailAsync(request, CancellationToken.None); Assert.Fail(); } catch (AggregateException ex) { // expected due to action failure ex.InnerExceptions.Count.Should().Be(2, "because two services failed"); } status.Verify(x => x.GetStatiAsync(request, It.IsAny <CancellationToken>())); status.Verify(x => x.UpdateAsync(request, It.Is <EmailAction>(a => a.Id == "forward-all"), EmailFanoutStatus.DeferredOrFailed, It.IsAny <CancellationToken>())); status.Verify(x => x.UpdateAsync(request, It.Is <EmailAction>(a => a.Id == "notify-all"), EmailFanoutStatus.DeferredOrFailed, It.IsAny <CancellationToken>())); status.VerifyNoOtherCalls(); }
public void ParsingDisplayNamesShouldWork() { var parser = new SendgridEmailParser(); using (var form = Build(new Dictionary <string, object> { { "from", "from <*****@*****.**>" }, { "to", "no name <*****@*****.**>" }, { "subject", "subj" }, { "html", "text" } })) { var result = parser.Parse(form); result.From.Email.Should().Be("*****@*****.**"); result.To[0].Email.Should().Be("*****@*****.**"); result.Subject.Should().Be("subj"); result.Html.Should().Be("text"); } }
public void TextShouldBeFallbackIfHtmlFails() { var parser = new SendgridEmailParser(); using (var form = Build(new Dictionary <string, object> { { "from", "from <*****@*****.**>" }, { "to", "no name <*****@*****.**>" }, { "subject", "subj" }, { "text", "other content" }, { "html", "" } })) { var result = parser.Parse(form); result.From.Email.Should().Be("*****@*****.**"); result.To[0].Email.Should().Be("*****@*****.**"); result.Subject.Should().Be("subj"); result.Html.Should().Be(""); result.Text.Should().Be("other content"); } }
public void Unmatched_action_filter_should_ignore_all() { var parser = new SendgridEmailParser(); var request = EmailRequest.Parse(Load("mail.txt"), parser); var config = GetConfig(); config.Rules[0].Filters = new[] { new EmailFilter { Type = "subject contains", OneOf = new[] { "not found" } } }; var actions = EmailService.ActionsToPerform(request.Email, config); actions.Should().BeEmpty(); }
public async Task Should_be_successful_even_if_all_filtered() { var status = new Mock <IStatusService>(); var vault = new Mock <IKeyVaultHelper>(); var config = new Mock <IConfigService>(); var storage = new Mock <IBlobStorageService>(); var http = new Mock <IHttpClient>(); var parser = new SendgridEmailParser(); var request = EmailRequest.Parse(Load("mail.txt"), parser); status.Setup(x => x.GetStatiAsync(request, It.IsAny <CancellationToken>())) .ReturnsAsync(new Dictionary <string, StatusModel>()); var cfg = GetConfig(); cfg.Rules[0].Filters = new[] { new EmailFilter { Type = "subject contains", OneOf = new[] { "not-found" } } }; config.Setup(x => x.LoadAsync(It.IsAny <CancellationToken>())) .ReturnsAsync(cfg); var service = new EmailService(status.Object, vault.Object, config.Object, storage.Object, http.Object); var success = await service.ProcessMailAsync(request, CancellationToken.None); success.Should().BeTrue(); status.Verify(x => x.GetStatiAsync(request, It.IsAny <CancellationToken>())); status.VerifyNoOtherCalls(); }
public void AttachmentsShouldBeCorrectlyParsedFromActualPayload() { var parser = new SendgridEmailParser(); using (var form = new MemoryStream()) { var content = File.ReadAllText("Data/multipart.txt"); var writer = new StreamWriter(form); writer.Write(content); writer.Flush(); form.Position = 0; var result = parser.Parse(form); result.From.Email.Should().Be("*****@*****.**"); result.From.Name.Should().Be("Marc Stan"); result.To[0].Email.Should().Be("*****@*****.**"); result.To[0].Name.Should().Be("*****@*****.**"); result.Subject.Should().Be("Subject1"); result.Text.Should().Be("Content1"); result.Attachments.Should().HaveCount(1); result.Attachments[0].ContentType.Should().Be("text/plain"); result.Attachments[0].FileName.Should().Be("en.txt"); result.Attachments[0].Base64Data.Should().Be(Convert.ToBase64String(Encoding.UTF8.GetBytes("this is plaintext"))); } }
public async Task Service_should_ignore_disabled_filter_and_run_action() { var status = new Mock <IStatusService>(); var vault = new Mock <IKeyVaultHelper>(); var config = new Mock <IConfigService>(); var storage = new Mock <IBlobStorageService>(); var http = new Mock <IHttpClient>(); var parser = new SendgridEmailParser(); var request = EmailRequest.Parse(Load("mail.txt"), parser); status.Setup(x => x.GetStatiAsync(request, It.IsAny <CancellationToken>())) .ReturnsAsync(new Dictionary <string, StatusModel>()); var cfg = GetConfig(); cfg.Rules = new[] { new EmailRule { Filters = new [] { new EmailFilter { Enabled = false, Type = "subject contains", OneOf = new[] { "not found" } } }, Actions = new[] { new EmailAction { Id = "executed", Type = ActionType.Forward, Properties = JObject.FromObject(new { webhook = new { secretName = "fwd" } }) } } } }; config.Setup(x => x.LoadAsync(It.IsAny <CancellationToken>())) .ReturnsAsync(cfg); vault.Setup(x => x.GetSecretAsync("fwd", It.IsAny <CancellationToken>())) .ReturnsAsync("url1"); http.Setup(x => x.SendAsync(It.Is <HttpRequestMessage>(r => r.RequestUri.ToString() == "url1"), It.IsAny <CancellationToken>())) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); var service = new EmailService(status.Object, vault.Object, config.Object, storage.Object, http.Object); var success = await service.ProcessMailAsync(request, CancellationToken.None); success.Should().BeTrue(); status.Verify(x => x.GetStatiAsync(request, It.IsAny <CancellationToken>())); status.Verify(x => x.UpdateAsync(request, It.Is <EmailAction>(a => a.Id == "executed"), EmailFanoutStatus.Completed, It.IsAny <CancellationToken>())); status.VerifyNoOtherCalls(); }
public static async Task <IActionResult> Run( [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] HttpRequest req, Microsoft.Azure.WebJobs.ExecutionContext context, ILogger log, CancellationToken cancellationToken) { try { var config = LoadConfig(context.FunctionAppDirectory, log); var container = config["ArchiveContainerName"]; var target = config["RelayTargetEmail"]; if (string.IsNullOrEmpty(container) && string.IsNullOrEmpty(target)) { throw new NotSupportedException("Neither email target nor container name where set. Please set either ArchiveContainerName or RelayTargetEmail"); } Email email; using (var stream = new MemoryStream()) { // body can only be read once req.Body.CopyTo(stream); stream.Position = 0; var parser = new SendgridEmailParser(); email = parser.Parse(stream); } if (!string.IsNullOrEmpty(container)) { IPersister auditLogger = new BlobStoragePersister(config["AzureWebJobsStorage"], container); var d = DateTimeOffset.UtcNow; // one folder per day is fine for now var id = $"{d.ToString("yyyy-MM")}/{d.ToString("dd")}/{d.ToString("HH-mm-ss")}_{email.From.Email} - {email.Subject}"; await auditLogger.PersistAsync($"{id}.json", JsonConvert.SerializeObject(email, Formatting.Indented)); // save all attachments in subfolder await Task.WhenAll(email.Attachments.Select(a => auditLogger.PersistAsync($"{id} (Attachments)/{a.FileName}", Convert.FromBase64String(a.Base64Data)))); } if (!string.IsNullOrEmpty(target)) { var domain = config["Domain"]; var key = config["SendgridApiKey"]; if (!string.IsNullOrEmpty(target) && string.IsNullOrEmpty(domain)) { throw new NotSupportedException("Domain must be set as well when relay is used."); } if (!string.IsNullOrEmpty(target) && string.IsNullOrEmpty(key)) { throw new NotSupportedException("SendgridApiKey must be set as well when relay is used."); } var client = new SendGridClient(key); var subjectParser = new SubjectParser(config["Prefix"]); var relay = new RelayLogic(client, subjectParser, log, new[] { new OutlookWebSanitizer(subjectParser) } ); var sendAsDomain = "true".Equals(config["SendAsDomain"], StringComparison.OrdinalIgnoreCase); await relay.RelayAsync(email, target, domain, sendAsDomain, cancellationToken); } return(new OkResult()); } catch (Exception e) { log.LogCritical(e, "Failed to process request!"); return(new BadRequestResult()); } }
public async Task Service_should_call_all_actions_correctly() { var status = new Mock <IStatusService>(); var vault = new Mock <IKeyVaultHelper>(); var config = new Mock <IConfigService>(); var storage = new Mock <IBlobStorageService>(); var http = new Mock <IHttpClient>(); var parser = new SendgridEmailParser(); var request = EmailRequest.Parse(Load("mail.txt"), parser); status.Setup(x => x.GetStatiAsync(request, It.IsAny <CancellationToken>())) .ReturnsAsync(new Dictionary <string, StatusModel>()); var cfg = GetConfig(); cfg.Rules[0].Actions = new[] { new EmailAction { Id = "forward-all", Type = ActionType.Forward, Properties = JObject.FromObject(new { webhook = new { secretName = "Webhook1", body = new { sender = "%sender%" } } }) }, new EmailAction { Id = "notify-all", Type = ActionType.Webhook, Properties = JObject.FromObject(new { webhook = new { secretName = "Webhook2" }, body = new { subject = "You've got mail!", body = "%subject%" } }) }, new EmailAction { Id = "archive-all", Type = ActionType.Archive, Properties = JObject.FromObject(new { containerName = "emails" }) } }; config.Setup(x => x.LoadAsync(It.IsAny <CancellationToken>())) .ReturnsAsync(cfg); storage.Setup(x => x.UploadAsync("emails", It.IsAny <string>(), It.IsAny <string>(), It.IsAny <CancellationToken>())) .Returns(Task.CompletedTask); vault.Setup(x => x.GetSecretAsync("Webhook1", It.IsAny <CancellationToken>())) .ReturnsAsync("url1"); vault.Setup(x => x.GetSecretAsync("Webhook2", It.IsAny <CancellationToken>())) .ReturnsAsync("url2"); http.Setup(x => x.SendAsync(It.Is <HttpRequestMessage>(r => r.RequestUri.ToString() == "url1"), It.IsAny <CancellationToken>())) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); http.Setup(x => x.SendAsync(It.Is <HttpRequestMessage>(r => r.RequestUri.ToString() == "url2"), It.IsAny <CancellationToken>())) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); var service = new EmailService(status.Object, vault.Object, config.Object, storage.Object, http.Object); var success = await service.ProcessMailAsync(request, CancellationToken.None); success.Should().BeTrue(); status.Verify(x => x.GetStatiAsync(request, It.IsAny <CancellationToken>())); status.Verify(x => x.UpdateAsync(request, It.Is <EmailAction>(a => a.Id == "forward-all"), EmailFanoutStatus.Completed, It.IsAny <CancellationToken>())); status.Verify(x => x.UpdateAsync(request, It.Is <EmailAction>(a => a.Id == "notify-all"), EmailFanoutStatus.Completed, It.IsAny <CancellationToken>())); status.Verify(x => x.UpdateAsync(request, It.Is <EmailAction>(a => a.Id == "archive-all"), EmailFanoutStatus.Completed, It.IsAny <CancellationToken>())); status.VerifyNoOtherCalls(); storage.Verify(x => x.UploadAsync("emails", It.IsAny <string>(), It.IsAny <string>(), It.IsAny <CancellationToken>())); storage.VerifyNoOtherCalls(); }
public void Subject_filter_should_match_only_its_actions(string rule, string match, string sender, string recipient, string subject, string body, bool shouldMatch) { var parser = new SendgridEmailParser(); var request = EmailRequest.Parse(Load("mail.txt"), parser); request.Email.From.Email = sender; request.Email.To[0].Email = recipient; request.Email.Subject = subject; request.Email.Text = request.Email.Html = body; var config = GetConfig(); config.Rules = new[] { new EmailRule { Filters = new[] { new EmailFilter { Type = rule, OneOf = new[] { match, "not found" } } }, Actions = new[] { new EmailAction { Id = "notify-all", Type = ActionType.Webhook, Properties = JObject.FromObject(new { webhook = new { secretName = "Webhook2" }, subject = "You've got mail!", body = "%subject%" }) } } } }; if (rule.StartsWith("!")) { config.Rules[0].Filters[0].AllOf = config.Rules[0].Filters[0].OneOf; config.Rules[0].Filters[0].OneOf = null; } var actions = EmailService.ActionsToPerform(request.Email, config); if (shouldMatch) { actions.Select(a => a.Id).Should().ContainInOrder("notify-all"); } else { actions.Should().BeEmpty(); } }