diff --git a/Elsa.sln b/Elsa.sln index 190d01ced..97910b36f 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -116,6 +116,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Samples.Composition", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.EntityFrameworkCore.SqlServer", "src\modules\Elsa.Persistence.EntityFrameworkCore.SqlServer\Elsa.Persistence.EntityFrameworkCore.SqlServer.csproj", "{555A4306-3BAB-409E-8BB8-3171A5867B2C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Email", "src\modules\Elsa.Email\Elsa.Email.csproj", "{1E5BD8D9-55BE-41E2-B389-6AB9F26C22AF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -294,6 +296,10 @@ Global {555A4306-3BAB-409E-8BB8-3171A5867B2C}.Debug|Any CPU.Build.0 = Debug|Any CPU {555A4306-3BAB-409E-8BB8-3171A5867B2C}.Release|Any CPU.ActiveCfg = Release|Any CPU {555A4306-3BAB-409E-8BB8-3171A5867B2C}.Release|Any CPU.Build.0 = Release|Any CPU + {1E5BD8D9-55BE-41E2-B389-6AB9F26C22AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E5BD8D9-55BE-41E2-B389-6AB9F26C22AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E5BD8D9-55BE-41E2-B389-6AB9F26C22AF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E5BD8D9-55BE-41E2-B389-6AB9F26C22AF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {155227F0-A33B-40AA-A4B4-06F813EB921B} = {61017E64-6D00-49CB-9E81-5002DC8F7D5F} @@ -346,5 +352,6 @@ Global {55AAF940-12DC-4793-805C-992AEF2C1E8D} = {56C2FFB8-EA54-45B5-A095-4A78142EB4B5} {1AA0AEFC-BD58-4284-A6C5-AD15C5C79782} = {873BFC3E-63C2-4495-A503-5EC05DCD84E4} {555A4306-3BAB-409E-8BB8-3171A5867B2C} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {1E5BD8D9-55BE-41E2-B389-6AB9F26C22AF} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} EndGlobalSection EndGlobal diff --git a/src/modules/Elsa.Email/Activities/SendEmail.cs b/src/modules/Elsa.Email/Activities/SendEmail.cs new file mode 100644 index 000000000..538bc3042 --- /dev/null +++ b/src/modules/Elsa.Email/Activities/SendEmail.cs @@ -0,0 +1,181 @@ +using System.Collections; +using System.Text; +using System.Text.Json; +using Elsa.Email.Models; +using Elsa.Email.Options; +using Elsa.Email.Services; +using Elsa.Workflows.Core; +using Elsa.Workflows.Core.Attributes; +using Elsa.Workflows.Core.Models; +using Elsa.Workflows.Core.Services; +using Elsa.Workflows.Management.Models; +using Microsoft.Extensions.Options; +using MimeKit; + +namespace Elsa.Email.Activities; + +/// +/// Send an email message. +/// +[Activity("Email", "Send an email message.", Kind = ActivityKind.Task)] +public class SendEmail : ActivityBase +{ + /// + /// The sender's email address. + /// + [Input(Description = "The sender's email address.")] + public Input From { get; set; } + + [Input(Description = "The recipients email addresses.", UIHint = InputUIHints.MultiText)] + public Input> To { get; set; } = default!; + + [Input( + Description = "The cc recipient email addresses.", + UIHint = InputUIHints.MultiText, + Category = "More")] + public Input> Cc { get; set; } = default!; + + [Input( + Description = "The Bcc recipients email addresses.", + UIHint = InputUIHints.MultiText, + Category = "More")] + public Input> Bcc { get; set; } = default!; + + [Input(Description = "The subject of the email message.")] + public Input Subject { get; set; } = default!; + + [Input( + Description = "The attachments to send with the email message. Can be (an array of) a fully-qualified file path, URL, stream, byte array or instances of EmailAttachment.", + UIHint = InputUIHints.MultiLine + )] + public Input Attachments { get; set; } = default!; + + [Input( + Description = "The body of the email message.", + UIHint = InputUIHints.MultiLine + )] + public Input Body { get; set; } = default!; + + /// + /// The activity to execute when an error occurs while trying to send the email. + /// + [Port] + public IActivity? Error { get; set; } + + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var cancellationToken = context.CancellationToken; + var message = new MimeMessage(); + var options = context.GetRequiredService>().Value; + var from = string.IsNullOrWhiteSpace(From.TryGet(context)) ? options.DefaultSender : From.Get(context)!; + + message.Sender = MailboxAddress.Parse(from); + message.From.Add(MailboxAddress.Parse(from)); + message.Subject = Subject.TryGet(context); + + var bodyBuilder = new BodyBuilder { HtmlBody = Body.TryGet(context) }; + await AddAttachmentsAsync(context, bodyBuilder, cancellationToken); + + message.Body = bodyBuilder.ToMessageBody(); + + SetRecipientsEmailAddresses(message.To, To.Get(context)); + SetRecipientsEmailAddresses(message.Cc, Cc.TryGet(context)); + SetRecipientsEmailAddresses(message.Bcc, Bcc.TryGet(context)); + + var smtpService = context.GetRequiredService(); + + try + { + await smtpService.SendAsync(message, context.CancellationToken); + await context.CompleteActivityAsync(); + } + catch (Exception) + { + await context.ScheduleActivityAsync(Error, OnErrorCompletedAsync); + } + } + + private async ValueTask OnErrorCompletedAsync(ActivityExecutionContext context, ActivityExecutionContext childContext) => await context.CompleteActivityAsync(); + + private async Task AddAttachmentsAsync(ActivityExecutionContext context, BodyBuilder bodyBuilder, CancellationToken cancellationToken) + { + var attachments = Attachments.TryGet(context); + + if (attachments == null || attachments is string s && string.IsNullOrWhiteSpace(s)) + return; + + var index = 0; + var attachmentObjects = InterpretAttachmentsModel(attachments); + + foreach (var attachmentObject in attachmentObjects) + { + switch (attachmentObject) + { + case Uri url: + await AttachOnlineFileAsync(context, bodyBuilder, url, cancellationToken); + break; + case string path when path?.Contains("://") == true: + await AttachOnlineFileAsync(context, bodyBuilder, new Uri(path), cancellationToken); + break; + case string path when !string.IsNullOrWhiteSpace(path): + await AttachLocalFileAsync(bodyBuilder, path, cancellationToken); + break; + case byte[] bytes: + { + var fileName = $"Attachment-{++index}"; + bodyBuilder.Attachments.Add(fileName, bytes, ContentType.Parse("application/binary")); + break; + } + case Stream stream: + { + var fileName = $"Attachment-{++index}"; + await bodyBuilder.Attachments.AddAsync(fileName, stream, ContentType.Parse("application/binary"), cancellationToken); + break; + } + case EmailAttachment emailAttachment: + { + var fileName = emailAttachment.FileName ?? $"Attachment-{++index}"; + var contentType = emailAttachment.ContentType ?? "application/binary"; + var parsedContentType = ContentType.Parse(contentType); + + if (emailAttachment.Content is byte[] bytes) + bodyBuilder.Attachments.Add(fileName, bytes, parsedContentType); + + else if (emailAttachment.Content is Stream stream) + await bodyBuilder.Attachments.AddAsync(fileName, stream, parsedContentType, cancellationToken); + + break; + } + default: + { + var json = JsonSerializer.Serialize(attachmentObject); + var fileName = $"Attachment-{++index}"; + bodyBuilder.Attachments.Add(fileName, Encoding.UTF8.GetBytes(json), ContentType.Parse("application/json")); + break; + } + } + } + } + + private async Task AttachLocalFileAsync(BodyBuilder bodyBuilder, string path, CancellationToken cancellationToken) => await bodyBuilder.Attachments.AddAsync(path, cancellationToken); + + private async Task AttachOnlineFileAsync(ActivityExecutionContext context, BodyBuilder bodyBuilder, Uri url, CancellationToken cancellationToken) + { + var fileName = Path.GetFileName(url.LocalPath); + var downloader = context.GetRequiredService(); + var response = await downloader.DownloadAsync(url, cancellationToken); + var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); + var contentType = response.Content.Headers.ContentType?.MediaType ?? "application/binary"; + await bodyBuilder.Attachments.AddAsync(fileName, contentStream, ContentType.Parse(contentType), cancellationToken); + } + + private IEnumerable InterpretAttachmentsModel(object attachments) => attachments is string text ? new[] { text } : attachments is IEnumerable enumerable ? enumerable : new[] { attachments }; + + private void SetRecipientsEmailAddresses(InternetAddressList list, IEnumerable? addresses) + { + if (addresses == null) + return; + + list.AddRange(addresses.Select(MailboxAddress.Parse)); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Email/Elsa.Email.csproj b/src/modules/Elsa.Email/Elsa.Email.csproj new file mode 100644 index 000000000..1fae0f5cf --- /dev/null +++ b/src/modules/Elsa.Email/Elsa.Email.csproj @@ -0,0 +1,25 @@ + + + + + + + net6.0 + + Provides a SendEmail activity that lets you send emails from a workflow. + + elsa module activities email + + + + + + + + + + + + + + diff --git a/src/modules/Elsa.Email/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Email/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..2d024554a --- /dev/null +++ b/src/modules/Elsa.Email/Extensions/ModuleExtensions.cs @@ -0,0 +1,19 @@ +using Elsa.Email.Features; +using Elsa.Features.Services; + +namespace Elsa.Email.Extensions; + +/// +/// Provides methods to install and configure email related features. +/// +public static class ModuleExtensions +{ + /// + /// Adds the feature to the system. + /// + public static IModule UseEmail(this IModule configuration, Action? configure = default) + { + configuration.Configure(configure); + return configuration; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Email/Features/EmailFeature.cs b/src/modules/Elsa.Email/Features/EmailFeature.cs new file mode 100644 index 000000000..12cfb2aba --- /dev/null +++ b/src/modules/Elsa.Email/Features/EmailFeature.cs @@ -0,0 +1,37 @@ +using Elsa.Email.Implementations; +using Elsa.Email.Options; +using Elsa.Email.Services; +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Email.Features; + +/// +/// Setup email features. +/// +public class EmailFeature : FeatureBase +{ + /// + public EmailFeature(IModule module) : base(module) + { + } + + /// + /// Set a callback to configure . + /// + public Action ConfigureOptions { get; set; } = _ => { }; + + /// + /// Set a callback for configuring the HTTP client use by the default implementation of . + /// + public Action ConfigureDownloaderHttpClient { get; set; } = (_, _) => { }; + + /// + public override void Apply() + { + Services.Configure(ConfigureOptions); + Services.AddSingleton(); + Services.AddHttpClient(ConfigureDownloaderHttpClient); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Email/FodyWeavers.xml b/src/modules/Elsa.Email/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.Email/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.Email/Implementations/DefaultDownloader.cs b/src/modules/Elsa.Email/Implementations/DefaultDownloader.cs new file mode 100644 index 000000000..8f61600bc --- /dev/null +++ b/src/modules/Elsa.Email/Implementations/DefaultDownloader.cs @@ -0,0 +1,24 @@ +using Elsa.Email.Services; + +namespace Elsa.Email.Implementations; + +/// +/// Download files from the Internet. +/// +public class DefaultDownloader : IDownloader +{ + private readonly HttpClient _httpClient; + + /// + /// Constructor. + /// + public DefaultDownloader(HttpClient httpClient) + { + _httpClient = httpClient; + } + + /// + /// Download whatever is returned at the specified URL. + /// + public async Task DownloadAsync(Uri url, CancellationToken cancellationToken = default) => await _httpClient.GetAsync(url, cancellationToken); +} \ No newline at end of file diff --git a/src/modules/Elsa.Email/Implementations/MailKitSmtpService.cs b/src/modules/Elsa.Email/Implementations/MailKitSmtpService.cs new file mode 100644 index 000000000..4f28a494e --- /dev/null +++ b/src/modules/Elsa.Email/Implementations/MailKitSmtpService.cs @@ -0,0 +1,73 @@ +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Elsa.Email.Options; +using Elsa.Email.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MimeKit; +using SmtpClient = MailKit.Net.Smtp.SmtpClient; + +namespace Elsa.Email.Implementations; + +/// +/// A MailKit implementation of . +/// +public class MailKitSmtpService : ISmtpService +{ + private readonly SmtpOptions _options; + private readonly ILogger _logger; + + /// + /// Constructor. + /// + public MailKitSmtpService( + IOptions options, + ILogger logger + ) + { + _options = options.Value; + _logger = logger; + } + + /// + /// Sends the specified message. + /// + public async Task SendAsync(MimeMessage message, CancellationToken cancellationToken) => await SendMessage(message, cancellationToken); + + private async Task SendMessage(MimeMessage message, CancellationToken cancellationToken) + { + using var client = new SmtpClient(); + client.ServerCertificateValidationCallback = CertificateValidationCallback; + + await client.ConnectAsync(_options.Host, _options.Port, _options.SecureSocketOptions, cancellationToken); + + if (_options.RequireCredentials) + await client.AuthenticateAsync(_options.UserName ?? "", _options.Password ?? "", cancellationToken); + + await client.SendAsync(message, cancellationToken); + await client.DisconnectAsync(true, cancellationToken); + } + + private bool CertificateValidationCallback(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) + { + if (sslPolicyErrors == SslPolicyErrors.None) + return true; + + _logger.LogError( + "SMTP Server's certificate {CertificateSubject} issued by {CertificateIssuer} with thumbprint {CertificateThumbprint} and expiration date {CertificateExpirationDate} is considered invalid with {SslPolicyErrors} policy errors", + certificate?.Subject, + certificate?.Issuer, + certificate?.GetCertHashString(), + certificate?.GetExpirationDateString(), + sslPolicyErrors); + + if (!sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors) || chain?.ChainStatus == null) + return false; + + foreach (var chainStatus in chain.ChainStatus) + _logger.LogError("Status: {Status} - {StatusInformation}", chainStatus.Status, chainStatus.StatusInformation); + + return false; + } + +} \ No newline at end of file diff --git a/src/modules/Elsa.Email/Models/EmailAttachment.cs b/src/modules/Elsa.Email/Models/EmailAttachment.cs new file mode 100644 index 000000000..ae25849c6 --- /dev/null +++ b/src/modules/Elsa.Email/Models/EmailAttachment.cs @@ -0,0 +1,4 @@ +namespace Elsa.Email.Models +{ + public record EmailAttachment(object Content, string? FileName, string? ContentType); +} \ No newline at end of file diff --git a/src/modules/Elsa.Email/Options/SmtpEncryptionMethod.cs b/src/modules/Elsa.Email/Options/SmtpEncryptionMethod.cs new file mode 100644 index 000000000..7b87fdf35 --- /dev/null +++ b/src/modules/Elsa.Email/Options/SmtpEncryptionMethod.cs @@ -0,0 +1,8 @@ +namespace Elsa.Email.Options; + +public enum SmtpEncryptionMethod +{ + None = 0, + SslTlS = 1, + StartTls = 2 +} \ No newline at end of file diff --git a/src/modules/Elsa.Email/Options/SmtpOptions.cs b/src/modules/Elsa.Email/Options/SmtpOptions.cs new file mode 100644 index 000000000..4201ae00a --- /dev/null +++ b/src/modules/Elsa.Email/Options/SmtpOptions.cs @@ -0,0 +1,44 @@ +using MailKit.Security; + +namespace Elsa.Email.Options; + +/// +/// Options to configure the SMTP client with. +/// +public class SmtpOptions +{ + /// + /// The default sender address when no sender is specified while sending emails. + /// + public string DefaultSender { get; set; } = default!; + + /// + /// The SMTP server IP address or hostname. + /// + public string? Host { get; set; } + + /// + /// The SMTP server port + /// + public int Port { get; set; } = 25; + + /// + /// Secure socket options. + /// + public SecureSocketOptions SecureSocketOptions { get; set; } = SecureSocketOptions.Auto; + + /// + /// True if the SMTP host requires credentials. + /// + public bool RequireCredentials { get; set; } + + /// + /// The username to authenticate with. + /// + public string? UserName { get; set; } + + /// + /// The password to authenticate with. + /// + public string? Password { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Email/Services/IDownloader.cs b/src/modules/Elsa.Email/Services/IDownloader.cs new file mode 100644 index 000000000..785526659 --- /dev/null +++ b/src/modules/Elsa.Email/Services/IDownloader.cs @@ -0,0 +1,12 @@ +namespace Elsa.Email.Services; + +/// +/// Download files from the Internet. +/// +public interface IDownloader +{ + /// + /// Download whatever is returned at the specified URL. + /// + Task DownloadAsync(Uri url, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Email/Services/ISmtpService.cs b/src/modules/Elsa.Email/Services/ISmtpService.cs new file mode 100644 index 000000000..fc7c3f5d5 --- /dev/null +++ b/src/modules/Elsa.Email/Services/ISmtpService.cs @@ -0,0 +1,14 @@ +using MimeKit; + +namespace Elsa.Email.Services; + +/// +/// Use this service to send emails. +/// +public interface ISmtpService +{ + /// + /// Send the specified . + /// + Task SendAsync(MimeMessage message, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/modules/Elsa.Expressions/Elsa.Expressions.csproj b/src/modules/Elsa.Expressions/Elsa.Expressions.csproj index ba22ee747..c88c1984f 100644 --- a/src/modules/Elsa.Expressions/Elsa.Expressions.csproj +++ b/src/modules/Elsa.Expressions/Elsa.Expressions.csproj @@ -1,7 +1,7 @@ - - + + net6.0 diff --git a/src/modules/Elsa.Expressions/Extensions/DependencyInjectionExtensions.cs b/src/modules/Elsa.Expressions/Extensions/ModuleExtensions.cs similarity index 95% rename from src/modules/Elsa.Expressions/Extensions/DependencyInjectionExtensions.cs rename to src/modules/Elsa.Expressions/Extensions/ModuleExtensions.cs index d84b97cba..e2c8ddd0d 100644 --- a/src/modules/Elsa.Expressions/Extensions/DependencyInjectionExtensions.cs +++ b/src/modules/Elsa.Expressions/Extensions/ModuleExtensions.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; namespace Elsa.Expressions.Extensions; -public static class DependencyInjectionExtensions +public static class ModuleExtensions { public static IModule UseExpressions(this IModule configuration, Action? configure = default) {