Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide cc support #38

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@ The following table lists the configurable parameters for MiddleMail. To pass th
| `REDIS_INSTANCE_NAME` | The Redis instance name |
| `DISABLE_SMTP` | Do not actually send anything via SMTP. |

## Basic Local Setup
This setup assumes the use of Visual Studio Code as an editor. If not using
VSCode, configure environment variables in whatever way is appropriate for your
preferred launch mechanism. This setup also runs the dependencies in Docker.
You don't have to do this, but it sure is convenient.

1. Clone this repository.
1. Create a `launch.json` in which you configure the environment variables described above in the Configuration section. This `launch.json` should target the build of the `MiddleMail.Server` project, since that's the standalone process. You probably also need to create a `tasks.json` to run it, with a build task, but that's no different from the standard `launch.json` setup.
1. Run the `docker-compose.dev.yml` file to spin up dependency services. It mainly includes:
- A RabbitMQ container as an email message queue.
- An Elasticsearch container as a storage solution.
- _Note that if you're not using Elasticsearch, you can edit the services found in `MiddleMail/Server/Program.cs` to replace `ElasticSearchStorage` in `services.AddSingleton<IStorage, ElasticSearchStorage>();` with `MemoryStorage`, an in-memory storage solution. You will also need to edit the dependencies in `MiddleMail.Server.csproj` to depend on `MiddleMail.Storage.Memory` instead of `MiddleMail.Storage.Elastic`._
- A Redis container as a caching solution.
- An smtp4dev container as an SMTP server for development and testing.
1. Run the MiddleMailServer process (with your `launch.json`).
1. Optional: Test your new MiddleMail instance using the `tools/EmailMessageGenerator` project to send test emails.


## Tools

| Project | Description |
Expand Down
38 changes: 38 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Docker compose file for developers to run the necessary background services.
version: '3.8'

services:
rabbitmq:
image: rabbitmq:3.13-management
ports:
- "127.0.0.1:5672:5672"
- "127.0.0.1:15672:15672"
volumes:
- rabbitmq_data:/var/lib/rabbitmq

elasticsearch:
image: elasticsearch:8.14.3
environment:
- discovery.type=single-node
- xpack.security.http.ssl.enabled=false
- xpack.license.self_generated.type=trial
- xpack.security.enabled=false
ports:
- "127.0.0.1:9200:9200"
volumes:
- elasticsearch_data:/usr/share/elasticsearch/data

redis:
image: redis
ports:
- "127.0.0.1:6379:6379"

smtp4dev:
image: rnwood/smtp4dev
ports:
- "127.0.0.1:5000:80"
- "127.0.0.1:2525:25"

volumes:
rabbitmq_data:
elasticsearch_data:
2 changes: 1 addition & 1 deletion helm/middlemail/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Transactional Email
type: application

version: 1.1.0
appVersion: 0.5.0
appVersion: 0.6.0

dependencies:
- name: rabbitmq
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<WarningAsErrors>true</WarningAsErrors>
<RootNamespace>MiddleMail.Client.RabbitMQ</RootNamespace>
<PackageId>MiddleMail.Client.RabbitMQ</PackageId>
<Version>0.4.0</Version>
<Version>0.6.0</Version>
<Authors>Miaplaza Inc.</Authors>
<Company>MiaPlaza</Company>
<Copyright>Copyright ©2020 Miaplaza Inc.</Copyright>
Expand Down
6 changes: 3 additions & 3 deletions src/MiddleMail.Client.RabbitMQ/MiddleMailClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading.Tasks;
using EasyNetQ;
using Microsoft.Extensions.Options;
Expand Down Expand Up @@ -31,12 +31,12 @@ public async Task<bool> SendEmailAsync(EmailMessage emailMessage) {
try {
await bus.PublishAsync(emailMessage);
return true;
} catch(Exception e) {
} catch (Exception e) {
logger.LogError("Failed to publish to rabbitmq queue.", e);
return false;
}
}

public void Dispose() {
bus?.Dispose();
}
Expand Down
16 changes: 9 additions & 7 deletions src/MiddleMail.Delivery.Smtp/MimeMessageBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
using Microsoft.Extensions.Options;

namespace MiddleMail.Delivery.Smtp {

/// <summary>
/// Builds MIME messages from an <see cref="EMailMessage" />.
/// </summary>
Expand All @@ -22,20 +22,22 @@ public MimeMessageBuilder(IOptions<MimeMessageOptions> options) {
/// Note that <paramref name="emailMessage" /> must specify at least a plaintext version.
/// </summary>
public MimeMessage Create(EmailMessage emailMessage) {
if(emailMessage.PlainText == null) {
if (emailMessage.PlainText == null) {
throw new ArgumentException($"EmailMessage should always contains a Plaintext, but it is null.", nameof(emailMessage));
}

var mimeMessage = new MimeMessage();
mimeMessage.From.Add(new MailboxAddress(emailMessage.From.name, emailMessage.From.address));
mimeMessage.To.Add(new MailboxAddress(emailMessage.To.name, emailMessage.To.address));

if(emailMessage.ReplyTo.HasValue) {
emailMessage.Cc?.ForEach(cc => mimeMessage.Cc.Add(new MailboxAddress(cc.name, cc.address)));

if (emailMessage.ReplyTo.HasValue) {
mimeMessage.ReplyTo.Add(new MailboxAddress(emailMessage.ReplyTo.Value.name, emailMessage.ReplyTo.Value.address));
}
mimeMessage.Subject = emailMessage.Subject;

if(emailMessage.HtmlText == null) {
if (emailMessage.HtmlText == null) {
createBodyPlainText(emailMessage, mimeMessage);
} else {
createBodyMultipart(emailMessage, mimeMessage);
Expand All @@ -56,15 +58,15 @@ private void createBodyPlainText(EmailMessage emailMessage, MimeMessage message)
private void createBodyMultipart(EmailMessage emailMessage, MimeMessage message) {
var bodyBuilder = new BodyBuilder();
bodyBuilder.HtmlBody = emailMessage.HtmlText;
bodyBuilder.TextBody = emailMessage.PlainText;
bodyBuilder.TextBody = emailMessage.PlainText;

message.Body = bodyBuilder.ToMessageBody();
}

private void setHeaders(EmailMessage emailMessage, MimeMessage message) {
foreach (var item in emailMessage.Headers) {
// do not override any headers
var header = message.Headers.FirstOrDefault(h => h.Field == item.Key);
var header = message.Headers.FirstOrDefault(h => h.Field == item.Key);
if (header == null) {
message.Headers.Add(item.Key, item.Value);
}
Expand Down
2 changes: 1 addition & 1 deletion src/MiddleMail.Delivery.Smtp/SmtpDeliverer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public async Task DeliverAsync(EmailMessage emailMessage) {
MimeMessage mimeMessage;
try {
mimeMessage = builder.Create(emailMessage);
} catch(Exception e) {
} catch (Exception e) {
throw new MimeMessageBuilderException(emailMessage, e);
}
try {
Expand Down
9 changes: 6 additions & 3 deletions src/MiddleMail.Model/EmailMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public class EmailMessage {
public Guid Id { get; set; }
public (string name, string address) From { get; set; }
public (string name, string address) To { get; set; }
public List<(string name, string address)> Cc { get; set; }
public (string name, string address)? ReplyTo { get; set; }
public string Subject { get; set; }
public string PlainText { get; set; }
Expand All @@ -36,9 +37,10 @@ public class EmailMessage {

public EmailMessage() {}

public EmailMessage(Guid id, (string name, string address) from, (string name, string address) to,
(string name, string address)? replyTo, string subject, string plainText, string htmlText,
List<string> tags, Dictionary<string, string> headers, int retryCount = 0, bool store = true) {
public EmailMessage(Guid id, (string name, string address) from, (string name, string address) to,
List<(string name, string address)> cc, (string name, string address)? replyTo, string subject,
string plainText, string htmlText, List<string> tags, Dictionary<string, string> headers,
int retryCount = 0, bool store = true) {

if (String.IsNullOrEmpty(from.address)) {
throw new ArgumentException("Must specify a from email address.", nameof(from));
Expand All @@ -52,6 +54,7 @@ public EmailMessage(Guid id, (string name, string address) from, (string name, s
Id = id;
From = from;
To = to;
Cc = cc ?? new List<(string name, string address)>();
ReplyTo = replyTo;
Subject = subject;
PlainText = plainText;
Expand Down
2 changes: 1 addition & 1 deletion src/MiddleMail.Model/MiddleMail.Model.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<WarningAsErrors>true</WarningAsErrors>
<RootNamespace>MiddleMail.Model</RootNamespace>
<PackageId>MiddleMail.Model</PackageId>
<Version>0.4.0</Version>
<Version>0.6.0</Version>
<Authors>Miaplaza Inc.</Authors>
<Company>MiaPlaza</Company>
<Copyright>Copyright ©2020 Miaplaza Inc.</Copyright>
Expand Down
18 changes: 9 additions & 9 deletions src/MiddleMail.Storage.ElasticSearch/ElasticSearchStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace MiddleMail.Storage.ElasticSearch {
/// Use <see cref="ElasticSearchStorageOptions" /> to configure the connection and index.
/// </summary>
public class ElasticSearchStorage : IMailStorage {

private readonly ElasticClient client;
private readonly ElasticSearchStorageOptions options;
public ElasticSearchStorage(IOptions<ElasticSearchStorageOptions> options) {
Expand All @@ -28,21 +28,21 @@ public ElasticSearchStorage(IOptions<ElasticSearchStorageOptions> options) {
}

public async Task SetProcessedAsync(EmailMessage emailMessage) {
await updateOrCreateAsync(emailMessage,
await updateOrCreateAsync(emailMessage,
update: (EmailDocument emailDocument) => { },
create: () => new EmailDocument(emailMessage));
}

public async Task SetSentAsync(EmailMessage emailMessage) {
await updateOrCreateAsync(emailMessage,
await updateOrCreateAsync(emailMessage,
update: (EmailDocument emailDocument) => {
emailDocument.Sent = DateTime.UtcNow;
},
create: () => new EmailDocument(emailMessage, sent: DateTime.UtcNow));
}

public async Task SetErrorAsync(EmailMessage emailMessage, string errorMessage) {
await updateOrCreateAsync(emailMessage,
await updateOrCreateAsync(emailMessage,
update: (EmailDocument emailDocument) => {
emailDocument.Error = errorMessage;
},
Expand All @@ -51,7 +51,7 @@ await updateOrCreateAsync(emailMessage,

private async Task updateOrCreateAsync(EmailMessage emailMessage, Action<EmailDocument> update, Func<EmailDocument> create) {
var emailDocument = await searchDocument(emailMessage.Id);
if(emailDocument != null) {
if (emailDocument != null) {
if (emailDocument.Sent != null) {
throw new EMailMessageAlreadySentStorageException(emailMessage);
}
Expand All @@ -71,22 +71,22 @@ private async Task updateOrCreateAsync(EmailMessage emailMessage, Action<EmailDo
var existingDocument = await searchDocument(emailMessage.Id);
return existingDocument?.Sent != null;
}

public async Task<string> GetErrorAsync(EmailMessage emailMessage) {
var existingDocument = await searchDocument(emailMessage.Id);
return existingDocument?.Error;
return existingDocument?.Error;
}

private string index => options.Index;

private async Task<EmailDocument> searchDocument(Guid id) {
var response = await client.SearchAsync<EmailDocument>(s => s
.Index(index)
.Query(q => q
.Term(c => c
.Field(p => p.Id)
.Value(id))));

return response.Documents.SingleOrDefault();
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/MiddleMail.Storage.ElasticSearch/EmailDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public class EmailDocument {
public string FromAddress { get; set; }
public string ToName { get; set; }
public string ToAddress { get; set; }
public List<string> CcNames { get; set; }
public List<string> CcAddresses { get; set; }
public string ReplyToName { get; set; }
public string ReplyToAddress { get; set; }
public string Subject { get; set; }
Expand All @@ -36,6 +38,8 @@ public EmailDocument(EmailMessage emailMessage, DateTime? sent = null, string er
FromAddress = emailMessage.From.address;
ToName = emailMessage.To.name;
ToAddress = emailMessage.To.address;
CcNames = emailMessage.Cc.ConvertAll(x => x.name);
CcAddresses = emailMessage.Cc.ConvertAll(x => x.address);
ReplyToName = emailMessage.ReplyTo?.name;
ReplyToAddress = emailMessage.ReplyTo?.address;

Expand Down
14 changes: 7 additions & 7 deletions src/MiddleMail.Storage.Memory/MemoryStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@ namespace MiddleMail.Storage.Memory {
/// A reference implementation for <see cref="IMailStorage" /> backed by a dictionary in memory.
/// </summary>
public class MemoryStorage : IMailStorage {

private readonly ConcurrentDictionary<EmailMessage, (bool sent, string error)> storage;

public MemoryStorage() {
this.storage = new ConcurrentDictionary<EmailMessage, (bool sent, string error)>();
}

public Task SetProcessedAsync(EmailMessage emailMessage) {
storage.AddOrUpdate(emailMessage,
addValue: (false, null),
storage.AddOrUpdate(emailMessage,
addValue: (false, null),
updateValueFactory: (EmailMessage key, (bool sent, string error) value) => {
if (value.sent) {
throw new EMailMessageAlreadySentStorageException(emailMessage);
Expand All @@ -32,8 +32,8 @@ public Task SetProcessedAsync(EmailMessage emailMessage) {
}

public Task SetSentAsync(EmailMessage emailMessage) {
storage.AddOrUpdate(emailMessage,
addValue: (true, null),
storage.AddOrUpdate(emailMessage,
addValue: (true, null),
updateValueFactory: (EmailMessage key, (bool sent, string error) value) => {
if (value.sent) {
throw new EMailMessageAlreadySentStorageException(emailMessage);
Expand All @@ -45,7 +45,7 @@ public Task SetSentAsync(EmailMessage emailMessage) {
}

public Task SetErrorAsync(EmailMessage emailMessage, string errorMessage) {
storage.AddOrUpdate(emailMessage,
storage.AddOrUpdate(emailMessage,
addValue: (false, errorMessage),
updateValueFactory: (EmailMessage key, (bool sent, string error) value) => {
if (value.sent) {
Expand All @@ -56,7 +56,7 @@ public Task SetErrorAsync(EmailMessage emailMessage, string errorMessage) {
);
return Task.CompletedTask;
}

public Task<bool?> GetSentAsync(EmailMessage emailMessage) {
var found = storage.TryGetValue(emailMessage, out var value);
if (found) {
Expand Down
4 changes: 2 additions & 2 deletions src/MiddleMail/IMailStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
using MiddleMail.Model;

namespace MiddleMail {

/// <summary>
/// A storage to persist email activity. When calling any of the methods that set data, the order must be kept.
/// Writing data does not need to be instantly and might not be reflected when reading it directly back.
Expand All @@ -14,7 +14,7 @@ public interface IMailStorage {
/// This can happen multiple times.
/// </summary>
Task SetProcessedAsync(EmailMessage emailMessage);

/// <summary>
/// Store that an <see cref="EmailMessage" /> was successfully sent.
/// </summary>
Expand Down
Loading