Add email module (#3469)

This commit is contained in:
Sipke Schoorstra 2022-11-24 14:28:29 +01:00 committed by GitHub
parent d14cd617f3
commit cf6c80ab85
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 454 additions and 3 deletions

View file

@ -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

View file

@ -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;
/// <summary>
/// Send an email message.
/// </summary>
[Activity("Email", "Send an email message.", Kind = ActivityKind.Task)]
public class SendEmail : ActivityBase
{
/// <summary>
/// The sender's email address.
/// </summary>
[Input(Description = "The sender's email address.")]
public Input<string?> From { get; set; }
[Input(Description = "The recipients email addresses.", UIHint = InputUIHints.MultiText)]
public Input<ICollection<string>> To { get; set; } = default!;
[Input(
Description = "The cc recipient email addresses.",
UIHint = InputUIHints.MultiText,
Category = "More")]
public Input<ICollection<string>> Cc { get; set; } = default!;
[Input(
Description = "The Bcc recipients email addresses.",
UIHint = InputUIHints.MultiText,
Category = "More")]
public Input<ICollection<string>> Bcc { get; set; } = default!;
[Input(Description = "The subject of the email message.")]
public Input<string?> 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<object?> Attachments { get; set; } = default!;
[Input(
Description = "The body of the email message.",
UIHint = InputUIHints.MultiLine
)]
public Input<string?> Body { get; set; } = default!;
/// <summary>
/// The activity to execute when an error occurs while trying to send the email.
/// </summary>
[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<IOptions<SmtpOptions>>().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<ISmtpService>();
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<IDownloader>();
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<string>? addresses)
{
if (addresses == null)
return;
list.AddRange(addresses.Select(MailboxAddress.Parse));
}
}

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\common.props"/>
<Import Project="..\..\..\configureawait.props"/>
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Description>
Provides a SendEmail activity that lets you send emails from a workflow.
</Description>
<PackageTags>elsa module activities email</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MailKit" Version="3.4.2"/>
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Elsa.Mediator\Elsa.Mediator.csproj"/>
<ProjectReference Include="..\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj"/>
<ProjectReference Include="..\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj"/>
</ItemGroup>
</Project>

View file

@ -0,0 +1,19 @@
using Elsa.Email.Features;
using Elsa.Features.Services;
namespace Elsa.Email.Extensions;
/// <summary>
/// Provides methods to install and configure email related features.
/// </summary>
public static class ModuleExtensions
{
/// <summary>
/// Adds the <see cref="EmailFeature"/> feature to the system.
/// </summary>
public static IModule UseEmail(this IModule configuration, Action<EmailFeature>? configure = default)
{
configuration.Configure(configure);
return configuration;
}
}

View file

@ -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;
/// <summary>
/// Setup email features.
/// </summary>
public class EmailFeature : FeatureBase
{
/// <inheritdoc />
public EmailFeature(IModule module) : base(module)
{
}
/// <summary>
/// Set a callback to configure <see cref="SmtpOptions"/>.
/// </summary>
public Action<SmtpOptions> ConfigureOptions { get; set; } = _ => { };
/// <summary>
/// Set a callback for configuring the HTTP client use by the default implementation of <see cref="IDownloader"/>.
/// </summary>
public Action<IServiceProvider, HttpClient> ConfigureDownloaderHttpClient { get; set; } = (_, _) => { };
/// <inheritdoc />
public override void Apply()
{
Services.Configure(ConfigureOptions);
Services.AddSingleton<ISmtpService, MailKitSmtpService>();
Services.AddHttpClient<IDownloader, DefaultDownloader>(ConfigureDownloaderHttpClient);
}
}

View file

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>

View file

@ -0,0 +1,24 @@
using Elsa.Email.Services;
namespace Elsa.Email.Implementations;
/// <summary>
/// Download files from the Internet.
/// </summary>
public class DefaultDownloader : IDownloader
{
private readonly HttpClient _httpClient;
/// <summary>
/// Constructor.
/// </summary>
public DefaultDownloader(HttpClient httpClient)
{
_httpClient = httpClient;
}
/// <summary>
/// Download whatever is returned at the specified URL.
/// </summary>
public async Task<HttpResponseMessage> DownloadAsync(Uri url, CancellationToken cancellationToken = default) => await _httpClient.GetAsync(url, cancellationToken);
}

View file

@ -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;
/// <summary>
/// A MailKit implementation of <see cref="ISmtpService"/>.
/// </summary>
public class MailKitSmtpService : ISmtpService
{
private readonly SmtpOptions _options;
private readonly ILogger<MailKitSmtpService> _logger;
/// <summary>
/// Constructor.
/// </summary>
public MailKitSmtpService(
IOptions<SmtpOptions> options,
ILogger<MailKitSmtpService> logger
)
{
_options = options.Value;
_logger = logger;
}
/// <summary>
/// Sends the specified message.
/// </summary>
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;
}
}

View file

@ -0,0 +1,4 @@
namespace Elsa.Email.Models
{
public record EmailAttachment(object Content, string? FileName, string? ContentType);
}

View file

@ -0,0 +1,8 @@
namespace Elsa.Email.Options;
public enum SmtpEncryptionMethod
{
None = 0,
SslTlS = 1,
StartTls = 2
}

View file

@ -0,0 +1,44 @@
using MailKit.Security;
namespace Elsa.Email.Options;
/// <summary>
/// Options to configure the SMTP client with.
/// </summary>
public class SmtpOptions
{
/// <summary>
/// The default sender address when no sender is specified while sending emails.
/// </summary>
public string DefaultSender { get; set; } = default!;
/// <summary>
/// The SMTP server IP address or hostname.
/// </summary>
public string? Host { get; set; }
/// <summary>
/// The SMTP server port
/// </summary>
public int Port { get; set; } = 25;
/// <summary>
/// Secure socket options.
/// </summary>
public SecureSocketOptions SecureSocketOptions { get; set; } = SecureSocketOptions.Auto;
/// <summary>
/// True if the SMTP host requires credentials.
/// </summary>
public bool RequireCredentials { get; set; }
/// <summary>
/// The username to authenticate with.
/// </summary>
public string? UserName { get; set; }
/// <summary>
/// The password to authenticate with.
/// </summary>
public string? Password { get; set; }
}

View file

@ -0,0 +1,12 @@
namespace Elsa.Email.Services;
/// <summary>
/// Download files from the Internet.
/// </summary>
public interface IDownloader
{
/// <summary>
/// Download whatever is returned at the specified URL.
/// </summary>
Task<HttpResponseMessage> DownloadAsync(Uri url, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,14 @@
using MimeKit;
namespace Elsa.Email.Services;
/// <summary>
/// Use this service to send emails.
/// </summary>
public interface ISmtpService
{
/// <summary>
/// Send the specified <see cref="MimeMessage"/>.
/// </summary>
Task SendAsync(MimeMessage message, CancellationToken cancellationToken);
}

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\common.props"/>
<Import Project="..\..\..\configureawait.props"/>
<Import Project="..\..\..\common.props" />
<Import Project="..\..\..\configureawait.props" />
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>

View file

@ -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<ExpressionsFeature>? configure = default)
{