Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/emails-cc-bcc-attachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"PostHog": minor
---

`SendCodeEmailOptions` takes `Cc`, `Bcc` and `Attachments`. An `EmailAttachment` is a `Filename` and its `Content` as bytes, which the client sends as Base64, with an optional `ContentType` and `ContentId` for inline images. Empty lists are left out of the request. A PostHog version that doesn't take one of these fields yet refuses the send with a `validation_error` that names the field, instead of sending without it.
55 changes: 55 additions & 0 deletions src/PostHog/Emails/EmailSender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,26 @@ static PreparedSend PrepareSend(SendEmailOptions email, int defaultMaxRetries)
throw new ArgumentException("ReplyTo must not hold a blank address.", nameof(email));
}

if (email.Cc is { } cc && cc.Any(string.IsNullOrWhiteSpace))
{
throw new ArgumentException("Cc must not hold a blank address.", nameof(email));
}

if (email.Bcc is { } bcc && bcc.Any(string.IsNullOrWhiteSpace))
{
throw new ArgumentException("Bcc must not hold a blank address.", nameof(email));
}

if (email.Attachments is { } attachments && attachments.Any(file => file is null))
{
throw new ArgumentException("Attachments must not hold a null file.", nameof(email));
}

if (email.Attachments is { } named && named.Any(file => string.IsNullOrWhiteSpace(file.Filename)))
{
throw new ArgumentException("Every attachment needs a Filename, such as invoice.pdf.", nameof(email));
}

var body = new Dictionary<string, object?>
{
["template_key"] = email.TemplateKey,
Expand All @@ -577,6 +597,21 @@ static PreparedSend PrepareSend(SendEmailOptions email, int defaultMaxRetries)
body["reply_to"] = email.ReplyTo;
}

if (email.Cc is { Count: > 0 })
{
body["cc"] = email.Cc;
}

if (email.Bcc is { Count: > 0 })
{
body["bcc"] = email.Bcc;
}

if (email.Attachments is { Count: > 0 })
{
body["attachments"] = email.Attachments.Select(AttachmentBody).ToList();
}

body["capture_engagement"] = email.CaptureEngagement;
if (email.DistinctId is not null)
{
Expand All @@ -586,6 +621,26 @@ static PreparedSend PrepareSend(SendEmailOptions email, int defaultMaxRetries)
return body;
}

static Dictionary<string, object> AttachmentBody(EmailAttachment file)
{
var body = new Dictionary<string, object>
{
["filename"] = file.Filename,
["content"] = file.Content,
};
if (file.ContentType is not null)
{
body["content_type"] = file.ContentType;
}

if (file.ContentId is not null)
{
body["content_id"] = file.ContentId;
}

return body;
}

static bool IsTemplateKey(string? key)
{
if (key is not { Length: > 0 and <= MaxTemplateKeyLength })
Expand Down
47 changes: 47 additions & 0 deletions src/PostHog/Emails/SendEmailOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ public sealed class SendTemplateEmailOptions : SendEmailOptions
/// <remarks>
/// Needs <see cref="Html"/>, <see cref="Text"/> or both. To send a React email, render it to HTML in your app first
/// and pass it as <see cref="Html"/>.
/// <para>
/// A PostHog version that doesn't take <see cref="Cc"/>, <see cref="Bcc"/> or <see cref="Attachments"/> yet refuses
/// the send with a <c>validation_error</c> whose <see cref="EmailException.Field"/> names the field. It never sends
/// the email without them.
/// </para>
/// </remarks>
public sealed class SendCodeEmailOptions : SendEmailOptions
{
Expand Down Expand Up @@ -96,6 +101,21 @@ public sealed class SendCodeEmailOptions : SendEmailOptions
/// </summary>
public IReadOnlyList<string>? ReplyTo { get; init; }

/// <summary>
/// Bare addresses, such as <c>[email protected]</c>, that get a visible copy. At most 10.
/// </summary>
public IReadOnlyList<string>? Cc { get; init; }

/// <summary>
/// Bare addresses that get a copy the other recipients never see. At most 10.
/// </summary>
public IReadOnlyList<string>? Bcc { get; init; }

/// <summary>
/// Files sent with the email. At most 10, and 512 KB in total.
/// </summary>
public IReadOnlyList<EmailAttachment>? Attachments { get; init; }

/// <summary>
/// Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as events
/// of the recipient. (Default: <c>true</c>)
Expand All @@ -106,3 +126,30 @@ public sealed class SendCodeEmailOptions : SendEmailOptions
/// </remarks>
public bool CaptureEngagement { get; init; } = true;
}

/// <summary>
/// A file sent with a <see cref="SendCodeEmailOptions"/> email.
/// </summary>
public sealed class EmailAttachment
{
/// <summary>
/// The file name the recipient sees, such as <c>invoice.pdf</c>. No path.
/// </summary>
public required string Filename { get; init; }

/// <summary>
/// The file's bytes. The client encodes them as Base64. PostHog can't fetch a file from a path or URL, so read
/// the file first.
/// </summary>
public required ReadOnlyMemory<byte> Content { get; init; }

/// <summary>
/// The MIME type, such as <c>application/pdf</c>. Without it, the type follows from <see cref="Filename"/>.
/// </summary>
public string? ContentType { get; init; }

/// <summary>
/// Shows the file inline: with <c>logo</c> here, reference it in the HTML as <c>&lt;img src="cid:logo"&gt;</c>.
/// </summary>
public string? ContentId { get; init; }
}
16 changes: 16 additions & 0 deletions src/PostHog/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,23 @@ PostHog.Emails.EmailWarning.Message.init -> void
PostHog.Emails.IPostHogEmails
PostHog.Emails.IPostHogEmails.GetAsync(string! messageId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<PostHog.Emails.EmailStatus!>!
PostHog.Emails.IPostHogEmails.SendAsync(PostHog.Emails.SendEmailOptions! email, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<PostHog.Emails.SendEmailResult!>!
PostHog.Emails.EmailAttachment
PostHog.Emails.EmailAttachment.Content.get -> System.ReadOnlyMemory<byte>
PostHog.Emails.EmailAttachment.Content.init -> void
PostHog.Emails.EmailAttachment.ContentId.get -> string?
PostHog.Emails.EmailAttachment.ContentId.init -> void
PostHog.Emails.EmailAttachment.ContentType.get -> string?
PostHog.Emails.EmailAttachment.ContentType.init -> void
PostHog.Emails.EmailAttachment.EmailAttachment() -> void
PostHog.Emails.EmailAttachment.Filename.get -> string!
PostHog.Emails.EmailAttachment.Filename.init -> void
PostHog.Emails.SendCodeEmailOptions
PostHog.Emails.SendCodeEmailOptions.Attachments.get -> System.Collections.Generic.IReadOnlyList<PostHog.Emails.EmailAttachment!>?
PostHog.Emails.SendCodeEmailOptions.Attachments.init -> void
PostHog.Emails.SendCodeEmailOptions.Bcc.get -> System.Collections.Generic.IReadOnlyList<string!>?
PostHog.Emails.SendCodeEmailOptions.Bcc.init -> void
PostHog.Emails.SendCodeEmailOptions.Cc.get -> System.Collections.Generic.IReadOnlyList<string!>?
PostHog.Emails.SendCodeEmailOptions.Cc.init -> void
PostHog.Emails.SendCodeEmailOptions.CaptureEngagement.get -> bool
PostHog.Emails.SendCodeEmailOptions.CaptureEngagement.init -> void
PostHog.Emails.SendCodeEmailOptions.From.get -> string?
Expand Down
59 changes: 59 additions & 0 deletions tests/UnitTests/Emails/EmailsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,67 @@ await harness.Client.Emails.SendAsync(new SendCodeEmailOptions
Assert.Single(harness.Requests).Body);
}

[Fact]
public async Task SendsCopiesAndAttachments()
{
await using var harness = new EmailHarness();
harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}""");
byte[] file = [0x00, 0x25, 0x50, 0x44, 0x46, 0xFF];

await harness.Client.Emails.SendAsync(new SendCodeEmailOptions
{
To = "[email protected]",
TemplateKey = "invoice",
Subject = "Your invoice",
Html = "<img src=\"cid:logo\">",
Cc = ["[email protected]"],
Bcc = ["[email protected]", "[email protected]"],
Attachments =
[
new EmailAttachment
{
Filename = "invoice.pdf",
Content = file.AsMemory(1, 4),
ContentType = "application/pdf",
},
new EmailAttachment { Filename = "logo.png", Content = file, ContentId = "logo" },
],
});

Assert.Equal(
"""{"template_key":"invoice","to":"[email protected]","subject":"Your invoice","html":"\u003Cimg src=\u0022cid:logo\u0022\u003E","cc":["[email protected]"],"bcc":["[email protected]","[email protected]"],"attachments":[{"filename":"invoice.pdf","content":"JVBERg==","content_type":"application/pdf"},{"filename":"logo.png","content":"ACVQREb/","content_id":"logo"}],"capture_engagement":true}""",
Assert.Single(harness.Requests).Body);
}

[Fact]
public async Task LeavesOutEmptyCopyAndAttachmentLists()
{
await using var harness = new EmailHarness();
harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}""");

await harness.Client.Emails.SendAsync(new SendCodeEmailOptions
{
To = "[email protected]",
TemplateKey = "receipt",
Subject = "Your receipt",
Text = "Thanks!",
Cc = [],
Bcc = [],
Attachments = [],
});

Assert.Equal(
"""{"template_key":"receipt","to":"[email protected]","subject":"Your receipt","text":"Thanks!","capture_engagement":true}""",
Assert.Single(harness.Requests).Body);
}

public static TheoryData<string, SendCodeEmailOptions> InvalidCodeSends => new()
{
{ "Cc", new SendCodeEmailOptions { To = "[email protected]", TemplateKey = "k", Subject = "s", Html = "h", Cc = [" "] } },
{ "Bcc", new SendCodeEmailOptions { To = "[email protected]", TemplateKey = "k", Subject = "s", Html = "h", Bcc = [null!] } },
{ "Attachments", new SendCodeEmailOptions { To = "[email protected]", TemplateKey = "k", Subject = "s", Html = "h", Attachments = [null!] } },
{ "Filename", new SendCodeEmailOptions { To = "[email protected]", TemplateKey = "k", Subject = "s", Html = "h", Attachments = [new EmailAttachment { Filename = " ", Content = new byte[] { 1 } }] } },
{ "Filename", new SendCodeEmailOptions { To = "[email protected]", TemplateKey = "k", Subject = "s", Html = "h", Attachments = [new EmailAttachment { Filename = null!, Content = new byte[] { 1 } }] } },
{ "TemplateKey", new SendCodeEmailOptions { To = "[email protected]", TemplateKey = "password reset", Subject = "s", Html = "h" } },
{ "TemplateKey", new SendCodeEmailOptions { To = "[email protected]", TemplateKey = "", Subject = "s", Html = "h" } },
{ "TemplateKey", new SendCodeEmailOptions { To = "[email protected]", TemplateKey = new string('k', 401), Subject = "s", Html = "h" } },
Expand Down
Loading