Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends personalised emails to a filter-narrowed subset of the team. Each recipient gets their own envelope (per-recipient privacy, no shared To: list); From stays on the SMTP infrastructure address with Reply-To set to the human sender so replies route correctly without forging DKIM/SPF. Backend - Migration 057: paliad.email_broadcasts (subject, body, sender_id, template_key, recipient_filter jsonb, recipient_user_ids uuid[], send_report jsonb, sent_at). RLS: senders read own rows, global_admin reads all; inserts must self-attribute. No CHECK-constraint extension to partner_unit_events — broadcasts get their own table per the lock. - BroadcastService (internal/services/broadcast_service.go): validates subject/body/recipient cap (100), enforces project_lead-OR-global_admin, persists audit row, dispatches via 5-deep goroutine pool with 15s per-send timeout. Send report (sent/failed counts + per-recipient errors) is captured back into email_broadcasts.send_report. - markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**, *italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped first; only whitelisted tags re-emitted. Script tags and javascript: URLs can't slip through. - Placeholder substitution: {{name}}, {{first_name}}, {{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass through unchanged. - mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header on top of the existing multipart/alternative envelope. - TeamService.ListMembershipsIndex: visibility-gated user→project_ids index. Powers the /team project multi-select filter without N round trips per project. - Handlers: POST /api/team/broadcast (gateOnboarded; service enforces authority), GET /api/team/memberships, GET /api/admin/broadcasts (list), GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page). /admin/broadcasts is gateOnboarded (not adminGate) so leads can see their own sends; the service applies the per-row visibility filter. Frontend - /team gains a project multi-select chip dropdown (visible projects loaded from /api/projects, intersected against the memberships index) alongside the existing office and role filters. - "E-Mail an Auswahl (N)" button appears only when canBroadcast() is true (global_admin always; non-admin needs lead-ship on selected projects, or at least one project when no filter is set). Server still re-checks per send. - Compose modal (broadcast.ts): subject + body textarea + optional template dropdown (loads existing email templates and strips Go-template directives) + recipient preview (first 5 + expand) + send. Hard-blocks empty subject/body and N=0. Shows per-send report on success. - /admin/broadcasts viewer: read-only list with click-row-to-expand detail (subject, body, recipient list, send_report counts). Tests - broadcast_service_test.go: placeholder substitution table-driven, Markdown safe-render incl. XSS guards (<script>, javascript: URLs), validation cases (empty subject/body, recipient cap, invalid email), signature rendering DE/EN. - broadcast_service_live_test.go: end-to-end Send + List + Get + visibility rules (lead can send on own project, member cannot, admin sees all, member can't read lead's row). Skips when TEST_DATABASE_URL is unset. i18n: 60 new keys × 2 langs (broadcast modal labels, error messages, recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/ load_error).
128 lines
4.2 KiB
Go
128 lines
4.2 KiB
Go
// markdown.go — minimal Markdown → safe HTML converter for broadcast emails.
|
|
//
|
|
// Paliad doesn't pull in a third-party Markdown library — the body subset
|
|
// senders need is small and predictable, so we render it inline. Inputs are
|
|
// HTML-escaped first; the renderer then re-introduces a small whitelist of
|
|
// inline tags (<strong>, <em>, <code>, <a>) and block elements (<p>, <ul>,
|
|
// <li>, <br>) for the patterns it recognises. Anything we don't recognise
|
|
// stays escaped, so an attacker who tries to slip a <script> tag through
|
|
// the compose modal sees a literal "<script>" in the rendered email.
|
|
//
|
|
// Supported syntax:
|
|
// - Paragraphs separated by blank lines.
|
|
// - Single line break inside a paragraph → <br>.
|
|
// - **bold** → <strong>bold</strong>
|
|
// - _italic_ or *italic* → <em>italic</em>
|
|
// - `inline code` → <code>inline code</code>
|
|
// - [text](https://link) → <a href="...">text</a>
|
|
// - Lines starting with "- " or "* " → <ul><li>...</li></ul>
|
|
//
|
|
// Out-of-scope (intentional, per t-paliad-147 v1):
|
|
// - Headings, blockquotes, ordered lists, fenced code blocks, images,
|
|
// tables. These can be added on demand without changing the contract.
|
|
package services
|
|
|
|
import (
|
|
"fmt"
|
|
"html"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// renderMarkdownSafe converts Markdown to HTML. Output is safe for direct
|
|
// embedding in an HTML email body: every byte of input is escaped before
|
|
// the markdown post-processor runs, and the inline rewriter only re-emits
|
|
// a small whitelist of tags.
|
|
func renderMarkdownSafe(src string) string {
|
|
src = strings.ReplaceAll(src, "\r\n", "\n")
|
|
src = strings.ReplaceAll(src, "\r", "\n")
|
|
|
|
// Split into paragraphs on blank lines.
|
|
paragraphs := strings.Split(src, "\n\n")
|
|
var out strings.Builder
|
|
for _, raw := range paragraphs {
|
|
p := strings.TrimSpace(raw)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
// Bullet lists: every line starts with "- " or "* ".
|
|
if isBulletList(p) {
|
|
out.WriteString("<ul>\n")
|
|
for _, line := range strings.Split(p, "\n") {
|
|
item := strings.TrimSpace(line)
|
|
if len(item) >= 2 && (item[:2] == "- " || item[:2] == "* ") {
|
|
item = strings.TrimSpace(item[2:])
|
|
}
|
|
out.WriteString(" <li>")
|
|
out.WriteString(renderInline(item))
|
|
out.WriteString("</li>\n")
|
|
}
|
|
out.WriteString("</ul>\n")
|
|
continue
|
|
}
|
|
|
|
// Plain paragraph. Single-newline within → <br>.
|
|
lines := strings.Split(p, "\n")
|
|
out.WriteString("<p>")
|
|
for i, line := range lines {
|
|
if i > 0 {
|
|
out.WriteString("<br>\n")
|
|
}
|
|
out.WriteString(renderInline(strings.TrimSpace(line)))
|
|
}
|
|
out.WriteString("</p>\n")
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func isBulletList(p string) bool {
|
|
for _, line := range strings.Split(p, "\n") {
|
|
t := strings.TrimSpace(line)
|
|
if len(t) < 2 {
|
|
return false
|
|
}
|
|
if t[:2] != "- " && t[:2] != "* " {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
var (
|
|
mdLinkRE = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^\s)]+)\)`)
|
|
mdBoldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`)
|
|
mdItalRE1 = regexp.MustCompile(`(^|[^\w])_([^_]+)_($|[^\w])`)
|
|
mdItalRE2 = regexp.MustCompile(`(^|[^\w*])\*([^*]+)\*($|[^\w*])`)
|
|
mdCodeRE = regexp.MustCompile("`([^`]+)`")
|
|
)
|
|
|
|
// renderInline applies inline markdown to one line. The input is escaped
|
|
// first; replacements re-emit whitelisted tags.
|
|
func renderInline(line string) string {
|
|
s := html.EscapeString(line)
|
|
// Order matters: links first (they wrap text+URL), then bold (which is
|
|
// **…** and would otherwise be split by the italic *…* rule), then
|
|
// italics, then code.
|
|
s = mdLinkRE.ReplaceAllStringFunc(s, func(m string) string {
|
|
matches := mdLinkRE.FindStringSubmatch(m)
|
|
if len(matches) != 3 {
|
|
return m
|
|
}
|
|
text, url := matches[1], matches[2]
|
|
// URL is already escaped by html.EscapeString above; href quoting
|
|
// also needs the &-form so screen readers don't choke.
|
|
return fmt.Sprintf(`<a href="%s">%s</a>`, url, text)
|
|
})
|
|
s = mdBoldRE.ReplaceAllString(s, `<strong>$1</strong>`)
|
|
s = mdItalRE1.ReplaceAllString(s, `$1<em>$2</em>$3`)
|
|
s = mdItalRE2.ReplaceAllString(s, `$1<em>$2</em>$3`)
|
|
s = mdCodeRE.ReplaceAllString(s, `<code>$1</code>`)
|
|
return s
|
|
}
|
|
|
|
// escapeHTML is a thin alias used by senderSignature so the broadcast file
|
|
// doesn't need to import html directly.
|
|
func escapeHTML(s string) string {
|
|
return html.EscapeString(s)
|
|
}
|