Add html_body field for multipart email notifications

Callers can now supply a plain-text Body alongside an HTML html_body;
the SMTP sender emits multipart/alternative using both verbatim instead
of auto-stripping HTML to derive the plain-text fallback. The legacy
content_type=HTML path is preserved (deprecated) for existing callers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 23:59:12 -07:00
parent a35b3e6283
commit ac3ba35736
5 changed files with 42 additions and 25 deletions
+2
View File
@@ -66,6 +66,7 @@ func (h *NotifierHandler) SendNotification(ctx context.Context, req *pb.SendNoti
Priority: domain.Priority(req.Priority), Priority: domain.Priority(req.Priority),
Subject: req.Subject, Subject: req.Subject,
Body: req.Body, Body: req.Body,
HTMLBody: req.HtmlBody,
ContentType: contentType, ContentType: contentType,
Recipients: req.Recipients, Recipients: req.Recipients,
CC: req.Cc, CC: req.Cc,
@@ -368,6 +369,7 @@ func convertDomainToProtoNotification(notif *domain.Notification) *pb.Notificati
Status: convertDomainToProtoStatus(notif.Status), Status: convertDomainToProtoStatus(notif.Status),
Subject: notif.Subject, Subject: notif.Subject,
Body: notif.Body, Body: notif.Body,
HtmlBody: notif.HTMLBody,
Recipients: notif.Recipients, Recipients: notif.Recipients,
Metadata: convertInterfaceMapToString(notif.Metadata), Metadata: convertInterfaceMapToString(notif.Metadata),
CreatedAt: timestamppb.New(notif.CreatedAt), CreatedAt: timestamppb.New(notif.CreatedAt),
+4 -2
View File
@@ -81,7 +81,8 @@ message Notification {
NotificationStatus status = 5; NotificationStatus status = 5;
string subject = 6; string subject = 6;
string body = 7; string body = 7;
ContentType content_type = 18; // Format of the body (text or html) ContentType content_type = 18 [deprecated = true]; // Deprecated: use html_body instead. Format of the body (text or html).
string html_body = 19; // Optional HTML body for email; if set, sends multipart/alternative with body as text/plain and html_body as text/html. Ignored for non-email types.
repeated string recipients = 8; repeated string recipients = 8;
repeated string cc = 16; // Carbon copy recipients (email only) repeated string cc = 16; // Carbon copy recipients (email only)
repeated string bcc = 17; // Blind carbon copy recipients (email only) repeated string bcc = 17; // Blind carbon copy recipients (email only)
@@ -111,13 +112,14 @@ message SendNotificationRequest {
Priority priority = 3; Priority priority = 3;
string subject = 4; string subject = 4;
string body = 5; string body = 5;
ContentType content_type = 12; // Format of the body (text or html) - auto-detected if not specified ContentType content_type = 12 [deprecated = true]; // Deprecated: use html_body instead. Format of the body (text or html) - auto-detected if not specified.
repeated string recipients = 6; repeated string recipients = 6;
repeated string cc = 10; // Carbon copy recipients (email only) repeated string cc = 10; // Carbon copy recipients (email only)
repeated string bcc = 11; // Blind carbon copy recipients (email only) repeated string bcc = 11; // Blind carbon copy recipients (email only)
map<string, string> metadata = 7; map<string, string> metadata = 7;
google.protobuf.Timestamp scheduled_for = 8; google.protobuf.Timestamp scheduled_for = 8;
int32 max_retries = 9; int32 max_retries = 9;
string html_body = 13; // Optional HTML body for email; if set, sends multipart/alternative with body as text/plain and html_body as text/html. Ignored for non-email types.
} }
// SendNotificationResponse returns the result of sending a notification // SendNotificationResponse returns the result of sending a notification
+5 -1
View File
@@ -16,7 +16,8 @@ type SendNotificationRequest struct {
Priority int `json:"priority,omitempty"` Priority int `json:"priority,omitempty"`
Subject string `json:"subject"` Subject string `json:"subject"`
Body string `json:"body"` Body string `json:"body"`
ContentType string `json:"content_type,omitempty"` // "text" or "html" - auto-detected if not specified HTMLBody string `json:"html_body,omitempty"` // Optional HTML body for email; if set, sends multipart/alternative.
ContentType string `json:"content_type,omitempty"` // Deprecated: prefer html_body. "text" or "html".
Recipients []string `json:"recipients"` Recipients []string `json:"recipients"`
CC []string `json:"cc,omitempty"` // Carbon copy recipients (email only) CC []string `json:"cc,omitempty"` // Carbon copy recipients (email only)
BCC []string `json:"bcc,omitempty"` // Blind carbon copy recipients (email only) BCC []string `json:"bcc,omitempty"` // Blind carbon copy recipients (email only)
@@ -75,6 +76,7 @@ func (r *SendNotificationRequest) ToNotification() *domain.Notification {
Status: domain.StatusPending, Status: domain.StatusPending,
Subject: r.Subject, Subject: r.Subject,
Body: r.Body, Body: r.Body,
HTMLBody: r.HTMLBody,
ContentType: contentType, ContentType: contentType,
Recipients: r.Recipients, Recipients: r.Recipients,
CC: r.CC, CC: r.CC,
@@ -111,6 +113,7 @@ type Notification struct {
Status string `json:"status"` Status string `json:"status"`
Subject string `json:"subject"` Subject string `json:"subject"`
Body string `json:"body"` Body string `json:"body"`
HTMLBody string `json:"html_body,omitempty"`
ContentType string `json:"content_type,omitempty"` ContentType string `json:"content_type,omitempty"`
Recipients []string `json:"recipients"` Recipients []string `json:"recipients"`
CC []string `json:"cc,omitempty"` CC []string `json:"cc,omitempty"`
@@ -134,6 +137,7 @@ func NotificationFromDomain(n *domain.Notification) Notification {
Status: string(n.Status), Status: string(n.Status),
Subject: n.Subject, Subject: n.Subject,
Body: n.Body, Body: n.Body,
HTMLBody: n.HTMLBody,
ContentType: string(n.ContentType), ContentType: string(n.ContentType),
Recipients: n.Recipients, Recipients: n.Recipients,
CC: n.CC, CC: n.CC,
+7 -2
View File
@@ -68,8 +68,13 @@ type Notification struct {
// Body is the main content of the notification // Body is the main content of the notification
Body string `json:"body"` Body string `json:"body"`
// ContentType specifies the format of the body (text or html) // HTMLBody is an optional HTML body for email notifications. If non-empty, the email is
// Defaults to "text" if not specified. HTML is auto-detected if body starts with < or contains HTML tags. // sent as multipart/alternative with Body as text/plain and HTMLBody as text/html.
// Ignored for non-email notification types.
HTMLBody string `json:"html_body,omitempty"`
// ContentType specifies the format of the body (text or html).
// Deprecated: prefer setting HTMLBody alongside a plain-text Body.
ContentType ContentType `json:"content_type,omitempty"` ContentType ContentType `json:"content_type,omitempty"`
// Recipients contains the target addresses (email, slack channel, ntfy topic, etc.) // Recipients contains the target addresses (email, slack channel, ntfy topic, etc.)
+24 -20
View File
@@ -145,18 +145,15 @@ func (s *SMTPNotifier) buildMessage(notification *domain.Notification) string {
builder.WriteString(fmt.Sprintf("Subject: %s\r\n", notification.Subject)) builder.WriteString(fmt.Sprintf("Subject: %s\r\n", notification.Subject))
builder.WriteString("MIME-Version: 1.0\r\n") builder.WriteString("MIME-Version: 1.0\r\n")
// Auto-detect HTML if content type not explicitly set to text switch {
contentType := notification.ContentType case notification.HTMLBody != "":
if contentType == "" || contentType == "auto" { // Caller provided distinct plain-text and HTML versions: send multipart/alternative
contentType = detectContentType(notification.Body) // using Body verbatim as text/plain and HTMLBody as text/html (no auto-strip).
} s.buildMultipartMessage(&builder, notification.Body, notification.HTMLBody)
case isHTMLContent(notification):
// Build message based on content type // Legacy path (deprecated): Body itself is HTML. Auto-derive a plain-text fallback.
if contentType == domain.ContentTypeHTML { s.buildMultipartMessage(&builder, htmlToPlainText(notification.Body), notification.Body)
// Send multipart/alternative with both text and HTML default:
s.buildMultipartMessage(&builder, notification)
} else {
// Send plain text only
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
builder.WriteString("\r\n") builder.WriteString("\r\n")
builder.WriteString(notification.Body) builder.WriteString(notification.Body)
@@ -165,31 +162,38 @@ func (s *SMTPNotifier) buildMessage(notification *domain.Notification) string {
return builder.String() return builder.String()
} }
// buildMultipartMessage builds a multipart/alternative email with both text and HTML versions // isHTMLContent reports whether the notification's Body should be treated as HTML
func (s *SMTPNotifier) buildMultipartMessage(builder *strings.Builder, notification *domain.Notification) { // under the legacy content_type path.
// Generate a unique boundary func isHTMLContent(notification *domain.Notification) bool {
contentType := notification.ContentType
if contentType == "" || contentType == "auto" {
contentType = detectContentType(notification.Body)
}
return contentType == domain.ContentTypeHTML
}
// buildMultipartMessage builds a multipart/alternative email with the given plain-text
// and HTML parts.
func (s *SMTPNotifier) buildMultipartMessage(builder *strings.Builder, plainText, htmlBody string) {
boundary := generateBoundary() boundary := generateBoundary()
builder.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary)) builder.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
builder.WriteString("\r\n") builder.WriteString("\r\n")
// Plain text version (auto-generated from HTML)
builder.WriteString(fmt.Sprintf("--%s\r\n", boundary)) builder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
builder.WriteString("Content-Transfer-Encoding: 7bit\r\n") builder.WriteString("Content-Transfer-Encoding: 7bit\r\n")
builder.WriteString("\r\n") builder.WriteString("\r\n")
builder.WriteString(htmlToPlainText(notification.Body)) builder.WriteString(plainText)
builder.WriteString("\r\n\r\n") builder.WriteString("\r\n\r\n")
// HTML version
builder.WriteString(fmt.Sprintf("--%s\r\n", boundary)) builder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
builder.WriteString("Content-Type: text/html; charset=UTF-8\r\n") builder.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
builder.WriteString("Content-Transfer-Encoding: 7bit\r\n") builder.WriteString("Content-Transfer-Encoding: 7bit\r\n")
builder.WriteString("\r\n") builder.WriteString("\r\n")
builder.WriteString(notification.Body) builder.WriteString(htmlBody)
builder.WriteString("\r\n\r\n") builder.WriteString("\r\n\r\n")
// End boundary
builder.WriteString(fmt.Sprintf("--%s--\r\n", boundary)) builder.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
} }