Add project files.
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
using System.Collections;
|
||||
using System.Dynamic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Elsa.Email.Contracts;
|
||||
using Elsa.Email.Models;
|
||||
using Elsa.Email.Options;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Workflows;
|
||||
using Elsa.Workflows.Attributes;
|
||||
using Elsa.Workflows.UIHints;
|
||||
using Elsa.Workflows.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
|
||||
namespace Elsa.Email.Activities;
|
||||
|
||||
/// <summary>
|
||||
/// Send an email message.
|
||||
/// </summary>
|
||||
[Activity("Elsa", "Email", "Send an email message.", Kind = ActivityKind.Task)]
|
||||
public class SendEmail : Activity
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public SendEmail([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The sender's email address.
|
||||
/// </summary>
|
||||
[Input(Description = "The sender's email address.")]
|
||||
public Input<string?> From { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The recipients email addresses.
|
||||
/// </summary>
|
||||
[Input(Description = "The recipients email addresses.", UIHint = InputUIHints.MultiText)]
|
||||
public Input<ICollection<string>> To { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The CC recipient email addresses.
|
||||
/// </summary>
|
||||
[Input(
|
||||
Description = "The CC recipient email addresses.",
|
||||
UIHint = InputUIHints.MultiText,
|
||||
Category = "More")]
|
||||
public Input<ICollection<string>> Cc { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The BCC recipients email addresses.
|
||||
/// </summary>
|
||||
[Input(
|
||||
Description = "The BCC recipients email addresses.",
|
||||
UIHint = InputUIHints.MultiText,
|
||||
Category = "More")]
|
||||
public Input<ICollection<string>> Bcc { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The subject of the email message.
|
||||
/// </summary>
|
||||
[Input(Description = "The subject of the email message.")]
|
||||
public Input<string?> Subject { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The attachments to send with the email message.
|
||||
/// </summary>
|
||||
[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; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The body of the email message.
|
||||
/// </summary>
|
||||
[Input(
|
||||
Description = "The body of the email message.",
|
||||
UIHint = InputUIHints.MultiLine
|
||||
)]
|
||||
public Input<string> Body { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The activity to execute when an error occurs while trying to send the email.
|
||||
/// </summary>
|
||||
[Port] public IActivity? Error { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
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.GetOrDefault(context)) ? options.DefaultSender : From.Get(context)!;
|
||||
|
||||
message.Sender = MailboxAddress.Parse(from);
|
||||
message.From.Add(MailboxAddress.Parse(from));
|
||||
message.Subject = Subject.GetOrDefault(context) ?? "";
|
||||
|
||||
var bodyBuilder = new BodyBuilder
|
||||
{
|
||||
HtmlBody = Body.GetOrDefault(context)
|
||||
};
|
||||
await AddAttachmentsAsync(context, bodyBuilder, cancellationToken);
|
||||
|
||||
message.Body = bodyBuilder.ToMessageBody();
|
||||
|
||||
SetRecipientsEmailAddresses(message.To, GetAddresses(context, To));
|
||||
SetRecipientsEmailAddresses(message.Cc, GetAddresses(context, Cc));
|
||||
SetRecipientsEmailAddresses(message.Bcc, GetAddresses(context, Bcc));
|
||||
|
||||
var smtpService = context.GetRequiredService<ISmtpService>();
|
||||
var logger = context.GetRequiredService<ILogger<SendEmail>>();
|
||||
|
||||
try
|
||||
{
|
||||
await smtpService.SendAsync(message, context.CancellationToken);
|
||||
await context.CompleteActivityAsync();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogWarning(e, "Error while sending email message");
|
||||
context.AddExecutionLogEntry("Error", e.Message, payload: new
|
||||
{
|
||||
e.StackTrace
|
||||
});
|
||||
await context.ScheduleActivityAsync(Error, OnErrorCompletedAsync);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask OnErrorCompletedAsync(ActivityCompletedContext context) => await context.TargetContext.CompleteActivityAsync();
|
||||
|
||||
private static ICollection<string> GetAddresses(ActivityExecutionContext context, Input<ICollection<string>> input)
|
||||
{
|
||||
return input.GetOrDefault(context) ?? new List<string>(0);
|
||||
}
|
||||
|
||||
private async Task AddAttachmentsAsync(ActivityExecutionContext context, BodyBuilder bodyBuilder, CancellationToken cancellationToken)
|
||||
{
|
||||
var attachments = Attachments.GetOrDefault(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;
|
||||
}
|
||||
case ExpandoObject expandoObject:
|
||||
{
|
||||
var dictionary = new Dictionary<string, object>(expandoObject, StringComparer.OrdinalIgnoreCase);
|
||||
var fileName = dictionary.GetValue<string>("FileName") ?? $"Attachment-{++index}";
|
||||
var contentType = dictionary.GetValue<string>("ContentType") ?? "application/binary";
|
||||
var parsedContentType = ContentType.Parse(contentType);
|
||||
var content = dictionary.GetValue<object>("Content");
|
||||
|
||||
if (content is byte[] bytes)
|
||||
bodyBuilder.Attachments.Add(fileName, bytes, parsedContentType);
|
||||
|
||||
else if (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)
|
||||
{
|
||||
if (attachments is byte[] bytes)
|
||||
return new[]
|
||||
{
|
||||
bytes
|
||||
};
|
||||
|
||||
return attachments is string text
|
||||
? new[]
|
||||
{
|
||||
text
|
||||
}
|
||||
: attachments as IEnumerable ?? new[]
|
||||
{
|
||||
attachments
|
||||
};
|
||||
}
|
||||
|
||||
private void SetRecipientsEmailAddresses(InternetAddressList list, IEnumerable<string>? addresses)
|
||||
{
|
||||
if (addresses == null)
|
||||
return;
|
||||
|
||||
list.AddRange(addresses.Select(MailboxAddress.Parse));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Elsa.Email.Contracts;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using MimeKit;
|
||||
|
||||
namespace Elsa.Email.Contracts;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<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" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
|
||||
<ProjectReference Include="..\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,20 @@
|
||||
using Elsa.Email.Features;
|
||||
using Elsa.Features.Services;
|
||||
|
||||
// ReSharper disable once CheckNamespace
|
||||
namespace Elsa.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Elsa.Email.Contracts;
|
||||
using Elsa.Email.Options;
|
||||
using Elsa.Email.Services;
|
||||
using Elsa.Extensions;
|
||||
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>
|
||||
/// Gets or sets a callback for configuring the <see cref="ISmtpService"/> implementation.
|
||||
/// </summary>
|
||||
public Func<IServiceProvider, ISmtpService> SmtpService { get; set; } = sp => sp.GetRequiredService<MailKitSmtpService>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets 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 Configure()
|
||||
{
|
||||
Module.AddActivitiesFrom<EmailFeature>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Apply()
|
||||
{
|
||||
Services
|
||||
.Configure(ConfigureOptions)
|
||||
.AddScoped<MailKitSmtpService>()
|
||||
.AddScoped(SmtpService)
|
||||
.AddHttpClient<IDownloader, DefaultDownloader>(ConfigureDownloaderHttpClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<ConfigureAwait />
|
||||
</Weavers>
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Elsa.Email.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an email attachment.
|
||||
/// </summary>
|
||||
/// <param name="Content">The content of the attachment.</param>
|
||||
/// <param name="FileName">The name of the attachment.</param>
|
||||
/// <param name="ContentType">The content type of the attachment.</param>
|
||||
public record EmailAttachment(object Content, string? FileName, string? ContentType);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Elsa.Email.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the encryption method to use when connecting to an SMTP server.
|
||||
/// </summary>
|
||||
public enum SmtpEncryptionMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// No encryption.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// SSL/TLS encryption.
|
||||
/// </summary>
|
||||
SslTlS = 1,
|
||||
|
||||
/// <summary>
|
||||
/// STARTTLS encryption.
|
||||
/// </summary>
|
||||
StartTls = 2
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Elsa.Email.Contracts;
|
||||
|
||||
namespace Elsa.Email.Services;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Elsa.Email.Contracts;
|
||||
using Elsa.Email.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
using SmtpClient = MailKit.Net.Smtp.SmtpClient;
|
||||
|
||||
namespace Elsa.Email.Services;
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user