From d4b03026f0b98734a95e9cc22d3e77e89a7d3f4f Mon Sep 17 00:00:00 2001 From: kolaente Date: Sun, 8 Mar 2026 15:52:53 +0100 Subject: [PATCH] feat: add conversational email template and rendering Add conversational email style with GitHub-inspired header design. Includes mail struct extensions (headerLine, conversational flag), CreateConversationalHeader helper, HTML template with avatar support, p-tag wrapping for content lines, plain-text stripping, and conditional action link rendering. --- pkg/notifications/mail.go | 60 +++++++-- pkg/notifications/mail_render.go | 206 +++++++++++++++++++++++++++++-- 2 files changed, 247 insertions(+), 19 deletions(-) diff --git a/pkg/notifications/mail.go b/pkg/notifications/mail.go index 481553f62..8c4ede991 100644 --- a/pkg/notifications/mail.go +++ b/pkg/notifications/mail.go @@ -26,16 +26,18 @@ import ( // Mail is a mail message type Mail struct { - from string - to string - subject string - actionText string - actionURL string - greeting string - introLines []*mailLine - outroLines []*mailLine - footerLines []*mailLine - threadID string + from string + to string + subject string + actionText string + actionURL string + greeting string + headerLine *mailLine + introLines []*mailLine + outroLines []*mailLine + footerLines []*mailLine + threadID string + conversational bool } type mailLine struct { @@ -101,12 +103,50 @@ func (m *Mail) HTML(line string) *Mail { return m.appendLine(line, true) } +// HeaderLine sets the header line for conversational emails (e.g., "@user mentioned you") +func (m *Mail) HeaderLine(line string) *Mail { + m.headerLine = &mailLine{Text: line, isHTML: true} + return m +} + // ThreadID sets the thread ID of the mail message for email threading func (m *Mail) ThreadID(threadID string) *Mail { m.threadID = threadID return m } +// Conversational sets the email to use conversational styling +func (m *Mail) Conversational() *Mail { + m.conversational = true + return m +} + +// IsConversational returns whether the email uses conversational styling +func (m *Mail) IsConversational() bool { + return m.conversational +} + +// CreateConversationalHeader creates a GitHub-style header line with avatar, action text, and task reference. +// The action string should already contain the doer's name (e.g. "alice left a comment"). +func CreateConversationalHeader(avatarDataURI, action, taskURL, projectTitle, taskIdentifier, taskTitle string) string { + avatarHTML := "" + if avatarDataURI != "" { + avatarHTML = fmt.Sprintf( + ``, + avatarDataURI, + ) + } + return fmt.Sprintf( + `%s%s (%s > %s) %s`, + avatarHTML, + action, + taskURL, + projectTitle, + taskTitle, + taskIdentifier, + ) +} + func (m *Mail) appendLine(line string, isHTML bool) *Mail { if m.actionURL == "" { m.introLines = append(m.introLines, &mailLine{ diff --git a/pkg/notifications/mail_render.go b/pkg/notifications/mail_render.go index 7539aded5..67b254d35 100644 --- a/pkg/notifications/mail_render.go +++ b/pkg/notifications/mail_render.go @@ -20,6 +20,8 @@ import ( "bytes" "embed" templatehtml "html/template" + "regexp" + "strings" templatetext "text/template" "code.vikunja.io/api/pkg/config" @@ -45,11 +47,25 @@ const mailTemplatePlain = ` {{ $line.Text }} {{ end }}` +const mailTemplateConversationalPlain = ` +{{ if .HeaderLinePlain }}{{ .HeaderLinePlain }} +{{ end }}{{ range $line := .IntroLines}} +{{ $line.Text }} +{{ end }} +{{ if .ActionURL }}{{ .ActionText }}: +{{ .ActionURL }}{{end}} +{{ range $line := .OutroLines}} +{{ $line.Text }} +{{ end }} +{{ range $line := .FooterLines}} +{{ $line.Text }} +{{ end }}` + const mailTemplateHTML = ` - +