diff --git a/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/FodyWeavers.xml b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/FodyWeavers.xml new file mode 100644 index 000000000..5d6962159 --- /dev/null +++ b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/FodyWeavers.xsd b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/FodyWeavers.xsd new file mode 100644 index 000000000..3f3946e28 --- /dev/null +++ b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN.Abp.ElsaNext.Email.csproj b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN.Abp.ElsaNext.Email.csproj new file mode 100644 index 000000000..8277c0b2b --- /dev/null +++ b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN.Abp.ElsaNext.Email.csproj @@ -0,0 +1,29 @@ + + + + + + + net10.0 + LINGYUN.Abp.ElsaNext.Email + LINGYUN.Abp.ElsaNext.Email + false + false + false + + + + + + + + + + + + + + + + + diff --git a/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/AbpElsaNextEmailModule.cs b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/AbpElsaNextEmailModule.cs new file mode 100644 index 000000000..b73c13f66 --- /dev/null +++ b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/AbpElsaNextEmailModule.cs @@ -0,0 +1,38 @@ +using Elsa.Features.Services; +using LINGYUN.Abp.ElsaNext.Email.Extensions; +using LINGYUN.Abp.ElsaNext.Localization; +using Volo.Abp.Emailing; +using Volo.Abp.Localization; +using Volo.Abp.Modularity; +using Volo.Abp.VirtualFileSystem; + +namespace LINGYUN.Abp.ElsaNext.Email; + +[DependsOn( + typeof(AbpEmailingModule), + typeof(AbpElsaNextModule))] +public class AbpElsaNextEmailModule : AbpModule +{ + public override void PreConfigureServices(ServiceConfigurationContext context) + { + PreConfigure(elsa => + { + elsa.UseAbpEmail(); + }); + } + + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.FileSets.AddEmbedded(); + }); + + Configure(options => + { + options.Resources + .Get() + .AddVirtualJson("/LINGYUN/Abp/ElsaNext/Email/Localization/Resources"); + }); + } +} diff --git a/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Activities/SendEmail.cs b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Activities/SendEmail.cs new file mode 100644 index 000000000..a9cc3e01e --- /dev/null +++ b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Activities/SendEmail.cs @@ -0,0 +1,274 @@ +using Elsa.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; +using LINGYUN.Abp.ElsaNext.Email.Contracts; +using Microsoft.Extensions.Logging; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Dynamic; +using System.IO; +using System.Net.Mail; +using System.Net.Mime; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp.Emailing; +using Volo.Abp.Json; + +namespace LINGYUN.Abp.ElsaNext.Email.Activities; + +[Activity("Elsa", "Email", "Send an email message.", Kind = ActivityKind.Task)] +public class SendEmail : Activity +{ + /// + /// The sender's email address. + /// + [Input(Description = "The sender's email address.")] + public Input From { get; set; } = null!; + + /// + /// The recipients email addresses. + /// + [Input(Description = "The recipients email addresses.", UIHint = InputUIHints.MultiText)] + public Input> To { get; set; } = null!; + + /// + /// The CC recipient email addresses. + /// + [Input( + Description = "The CC recipient email addresses.", + UIHint = InputUIHints.MultiText, + Category = "More")] + public Input> Cc { get; set; } = null!; + + /// + /// The BCC recipients email addresses. + /// + [Input( + Description = "The BCC recipients email addresses.", + UIHint = InputUIHints.MultiText, + Category = "More")] + public Input> Bcc { get; set; } = null!; + + /// + /// The subject of the email message. + /// + [Input(Description = "The subject of the email message.")] + public Input Subject { get; set; } = null!; + + /// + /// The attachments to send with the email message. + /// + [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; } = null!; + + /// + /// The body of the email message. + /// + [Input( + Description = "The body of the email message.", + UIHint = InputUIHints.MultiLine + )] + public Input Body { get; set; } = null!; + + /// + /// 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 MailMessage(); + var from = From.GetOrDefault(context); + + if (MailAddress.TryCreate(from, out var fromAddress)) + { + message.Sender = fromAddress; + message.From = fromAddress; + } + message.Subject = Subject.GetOrDefault(context) ?? ""; + + await AddAttachmentsAsync(context, message, cancellationToken); + + message.Body = Body.GetOrDefault(context); + + SetRecipientsEmailAddresses(message.To, GetAddresses(context, To)); + SetRecipientsEmailAddresses(message.CC, GetAddresses(context, Cc)); + SetRecipientsEmailAddresses(message.Bcc, GetAddresses(context, Bcc)); + + var emailSender = context.GetRequiredService(); + var logger = context.GetRequiredService>(); + + try + { + await emailSender.SendAsync(message); + 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 GetAddresses(ActivityExecutionContext context, Input> input) + { + return input.GetOrDefault(context) ?? new List(0); + } + + private async Task AddAttachmentsAsync(ActivityExecutionContext context, MailMessage mailMessage, 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, mailMessage, url, cancellationToken); + break; + case string path when path?.Contains("://") == true: + await AttachOnlineFileAsync(context, mailMessage, new Uri(path), cancellationToken); + break; + case string path when !string.IsNullOrWhiteSpace(path): + AttachLocalFile(mailMessage, path); + break; + case byte[] bytes: + { + var fileName = $"Attachment-{++index}"; + var byteStream = new MemoryStream(bytes.Length); + await byteStream.WriteAsync(bytes, cancellationToken); + mailMessage.Attachments.Add(new Attachment(byteStream, new ContentType { Name = fileName })); + break; + } + case Stream stream: + { + var fileName = $"Attachment-{++index}"; + mailMessage.Attachments.Add(new Attachment(stream, new ContentType { Name = fileName })); + break; + } + case Attachment attachment: + { + attachment.Name ??= $"Attachment-{++index}"; + mailMessage.Attachments.Add(attachment); + break; + } + case EmailAttachment emailAttachment when emailAttachment.File != null: + { + var fileName = emailAttachment.Name ?? $"Attachment-{++index}"; + var emailAttachmentStream = new MemoryStream(emailAttachment.File.Length); + await emailAttachmentStream.WriteAsync(emailAttachment.File, cancellationToken); + mailMessage.Attachments.Add(new Attachment(emailAttachmentStream, new ContentType { Name = fileName })); + break; + } + case ExpandoObject expandoObject: + { + var dictionary = new Dictionary(expandoObject!, StringComparer.OrdinalIgnoreCase); + var fileName = dictionary.GetValue("FileName") ?? $"Attachment-{++index}"; + var contentType = dictionary.GetValue("ContentType") ?? "application/octet-stream"; + var parsedContentType = new ContentType(contentType); + var content = dictionary.GetValue("Content"); + + if (content is byte[] bytes) + { + var byteStream = new MemoryStream(bytes.Length); + await byteStream.WriteAsync(bytes, cancellationToken); + mailMessage.Attachments.Add(new Attachment(byteStream, new ContentType { Name = fileName })); + } + else if (content is Stream stream) + { + mailMessage.Attachments.Add(new Attachment(stream, new ContentType { Name = fileName })); + } + + break; + } + default: + { + var jsonSerializer = context.GetRequiredService(); + var json = jsonSerializer.Serialize(attachmentObject); + var fileName = $"Attachment-{++index}"; + + var jsonBytes = Encoding.UTF8.GetBytes(json); + var byteStream = new MemoryStream(jsonBytes.Length); + await byteStream.WriteAsync(jsonBytes, cancellationToken); + mailMessage.Attachments.Add(new Attachment(byteStream, new ContentType { Name = fileName })); + break; + } + } + } + } + + private static void AttachLocalFile(MailMessage mailMessage, string path) + { + var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); + mailMessage.Attachments.Add(new Attachment(stream, new ContentType{ Name = Path.GetFileName(path) })); + } + + private async static Task AttachOnlineFileAsync(ActivityExecutionContext context, MailMessage mailMessage, 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"; + mailMessage.Attachments.Add(new Attachment(contentStream, new ContentType(contentType) { Name = fileName })); + } + + private static 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 static void SetRecipientsEmailAddresses(MailAddressCollection list, IEnumerable? addresses) + { + if (addresses == null) + { + return; + } + foreach (var addresse in addresses) + { + if (MailAddress.TryCreate(addresse, out var mailAddress)) + { + list.Add(mailAddress); + } + } + } +} diff --git a/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Contracts/IDownloader.cs b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Contracts/IDownloader.cs new file mode 100644 index 000000000..89fb4fac2 --- /dev/null +++ b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Contracts/IDownloader.cs @@ -0,0 +1,16 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace LINGYUN.Abp.ElsaNext.Email.Contracts; +/// +/// Download files from the Internet. +/// +public interface IDownloader +{ + /// + /// Download whatever is returned at the specified URL. + /// + Task DownloadAsync(Uri url, CancellationToken cancellationToken = default); +} diff --git a/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Extensions/ModuleExtensions.cs b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..95a388ac5 --- /dev/null +++ b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Extensions/ModuleExtensions.cs @@ -0,0 +1,14 @@ +using Elsa.Features.Services; +using LINGYUN.Abp.ElsaNext.Email.Features; +using System; + +namespace LINGYUN.Abp.ElsaNext.Email.Extensions; + +public static class ModuleExtensions +{ + public static IModule UseAbpEmail(this IModule configuration, Action? configure = null) + { + configuration.Configure(configure); + return configuration; + } +} diff --git a/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Features/EmailFeature.cs b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Features/EmailFeature.cs new file mode 100644 index 000000000..1c7db185c --- /dev/null +++ b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Features/EmailFeature.cs @@ -0,0 +1,30 @@ +using Elsa.Extensions; +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using LINGYUN.Abp.ElsaNext.Email.Contracts; +using LINGYUN.Abp.ElsaNext.Email.Services; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Net.Http; + +namespace LINGYUN.Abp.ElsaNext.Email.Features; + +public class EmailFeature : FeatureBase +{ + public Action ConfigureDownloaderHttpClient { get; set; } = (_, _) => { }; + + public EmailFeature(IModule module) : base(module) + { + } + + public override void Configure() + { + Module.AddActivitiesFrom(); + } + + public override void Apply() + { + Services + .AddHttpClient(ConfigureDownloaderHttpClient); + } +} diff --git a/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Localization/Resources/en.json b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Localization/Resources/en.json new file mode 100644 index 000000000..a10e83028 --- /dev/null +++ b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Localization/Resources/en.json @@ -0,0 +1,12 @@ +{ + "culture": "en", + "texts": { + "The sender's email address.": "The sender's email address.", + "The recipients email addresses.": "The recipients email addresses.", + "The CC recipient email addresses.": "The CC recipient email addresses.", + "The BCC recipients email addresses.": "The BCC recipients email addresses.", + "The subject of the email message.": "The subject of the email message.", + "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.": "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.", + "The body of the email message.": "The body of the email message." + } +} diff --git a/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Localization/Resources/zh-Hans.json b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Localization/Resources/zh-Hans.json new file mode 100644 index 000000000..d64b1471e --- /dev/null +++ b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Localization/Resources/zh-Hans.json @@ -0,0 +1,12 @@ +{ + "culture": "zh-Hans", + "texts": { + "The sender's email address.": "发件人的电子邮件地址。", + "The recipients email addresses.": "收件人的电子邮件地址。", + "The CC recipient email addresses.": "CC收件人电子邮件地址。", + "The BCC recipients email addresses.": "BCC收件人邮箱地址。", + "The subject of the email message.": "邮件主题。", + "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.": "随邮件发送的附件。可以是(一个数组)完全限定的文件路径、URL、流、字节数组,或 EmailAttachment 类的实例。", + "The body of the email message.": "邮件正文。" + } +} diff --git a/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Services/DefaultDownloader.cs b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Services/DefaultDownloader.cs new file mode 100644 index 000000000..9e841603c --- /dev/null +++ b/aspnet-core/modules/elsa/LINGYUN.Abp.ElsaNext.Email/LINGYUN/Abp/ElsaNext/Email/Services/DefaultDownloader.cs @@ -0,0 +1,28 @@ +using LINGYUN.Abp.ElsaNext.Email.Contracts; +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace LINGYUN.Abp.ElsaNext.Email.Services; + +/// +/// 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); +}