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).
503 lines
17 KiB
Go
503 lines
17 KiB
Go
// Package services — MailService — SMTP delivery for transactional email.
|
||
//
|
||
// Handles two flows today: deadline reminders (reminder_service.go) and
|
||
// colleague invitations (handlers/invite.go). Body and subject for both come
|
||
// from EmailTemplateService — a DB-backed override falls through to the
|
||
// embedded per-language file when no override exists. See
|
||
// docs/design-email-templates-2026-04-29.md for the override semantics.
|
||
//
|
||
// Config is read from env vars at startup; when any required var is unset
|
||
// the service logs a warning and becomes a silent no-op. This lets the
|
||
// server run locally without SMTP credentials — no crashes, no surprise
|
||
// 500s.
|
||
//
|
||
// Port 465 uses implicit TLS (tls.Dial from the start), not STARTTLS. The
|
||
// Hostinger submission endpoint only accepts implicit TLS on that port.
|
||
package services
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/tls"
|
||
"errors"
|
||
"fmt"
|
||
htmltemplate "html/template"
|
||
"log/slog"
|
||
"maps"
|
||
"mime"
|
||
"net"
|
||
"net/smtp"
|
||
"os"
|
||
"regexp"
|
||
"strings"
|
||
texttemplate "text/template"
|
||
"time"
|
||
|
||
"mgit.msbls.de/m/paliad/internal/branding"
|
||
)
|
||
|
||
// MailConfig holds resolved SMTP settings. Built once at startup.
|
||
type MailConfig struct {
|
||
Host string
|
||
Port string
|
||
Username string
|
||
Password string
|
||
From string
|
||
FromName string
|
||
UseTLS bool
|
||
}
|
||
|
||
// MailService sends branded HTML+text email over SMTP. Safe to use
|
||
// concurrently. When the service is disabled (missing env vars), every Send*
|
||
// call logs and returns nil so callers can treat it as fire-and-forget.
|
||
//
|
||
// Templates are looked up via EmailTemplateService at render time, so an
|
||
// admin save propagates without a process restart. The service is created
|
||
// without one and gets it via SetTemplateService — wiring order in main.go
|
||
// (mail before DB pool) makes constructor injection awkward.
|
||
type MailService struct {
|
||
cfg MailConfig
|
||
enabled bool
|
||
templateSvc *EmailTemplateService
|
||
}
|
||
|
||
// NewMailService reads SMTP_* from the environment. Returns a non-nil
|
||
// service either way; callers check Enabled() if they care whether mail
|
||
// actually went out. Template lookup is bound later via SetTemplateService.
|
||
func NewMailService() (*MailService, error) {
|
||
cfg := MailConfig{
|
||
Host: strings.TrimSpace(os.Getenv("SMTP_HOST")),
|
||
Port: strings.TrimSpace(os.Getenv("SMTP_PORT")),
|
||
Username: strings.TrimSpace(os.Getenv("SMTP_USERNAME")),
|
||
Password: os.Getenv("SMTP_PASSWORD"),
|
||
From: strings.TrimSpace(os.Getenv("SMTP_FROM")),
|
||
FromName: strings.TrimSpace(os.Getenv("SMTP_FROM_NAME")),
|
||
UseTLS: !strings.EqualFold(os.Getenv("SMTP_USE_TLS"), "false"),
|
||
}
|
||
if cfg.Port == "" {
|
||
cfg.Port = "465"
|
||
}
|
||
if cfg.From == "" && cfg.Username != "" {
|
||
cfg.From = cfg.Username
|
||
}
|
||
if cfg.FromName == "" {
|
||
cfg.FromName = "Paliad"
|
||
}
|
||
|
||
enabled := cfg.Host != "" && cfg.Username != "" && cfg.Password != "" && cfg.From != ""
|
||
if !enabled {
|
||
slog.Warn("mail: SMTP_* env vars incomplete — email delivery disabled",
|
||
"host_set", cfg.Host != "",
|
||
"username_set", cfg.Username != "",
|
||
"password_set", cfg.Password != "",
|
||
"from_set", cfg.From != "",
|
||
)
|
||
} else {
|
||
slog.Info("mail: SMTP configured", "host", cfg.Host, "port", cfg.Port, "from", cfg.From)
|
||
}
|
||
|
||
// Default to a fallback-only EmailTemplateService (nil DB). Tests that
|
||
// don't wire a DB still get rendering against the embedded files.
|
||
return &MailService{
|
||
cfg: cfg,
|
||
enabled: enabled,
|
||
templateSvc: NewEmailTemplateService(nil),
|
||
}, nil
|
||
}
|
||
|
||
// SetTemplateService swaps the in-use EmailTemplateService. main.go calls
|
||
// this once after the DB pool is up so DB-backed overrides start applying.
|
||
// Safe to call before any Send/Render — there's no concurrent access yet.
|
||
func (s *MailService) SetTemplateService(t *EmailTemplateService) {
|
||
if t == nil {
|
||
t = NewEmailTemplateService(nil)
|
||
}
|
||
s.templateSvc = t
|
||
}
|
||
|
||
// Enabled reports whether SMTP is configured. Handlers can surface a clearer
|
||
// error when invite/reminder features require a live SMTP connection.
|
||
func (s *MailService) Enabled() bool { return s.enabled }
|
||
|
||
// Send delivers one multipart/alternative email (HTML + text). A zero or
|
||
// nil-value service (Enabled() == false) no-ops and returns nil.
|
||
//
|
||
// textBody may be empty; when omitted we auto-derive a plain-text fallback
|
||
// from the HTML so every message still has both parts (some clients flag
|
||
// HTML-only mail as spam).
|
||
func (s *MailService) Send(to, subject, htmlBody, textBody string) error {
|
||
if !s.enabled {
|
||
slog.Debug("mail: Send skipped (disabled)", "to", to, "subject", subject)
|
||
return nil
|
||
}
|
||
if to == "" {
|
||
return errors.New("mail: empty recipient")
|
||
}
|
||
if subject == "" {
|
||
return errors.New("mail: empty subject")
|
||
}
|
||
if textBody == "" {
|
||
textBody = htmlToText(htmlBody)
|
||
}
|
||
|
||
msg := buildMIME(s.cfg.From, s.cfg.FromName, to, subject, htmlBody, textBody)
|
||
return s.deliver(to, msg)
|
||
}
|
||
|
||
// TemplateData is the shape passed to SendTemplate. Lang defaults to "de"
|
||
// when empty. Subject is no longer set by the caller — it's looked up and
|
||
// rendered from the (key, lang) row.
|
||
type TemplateData struct {
|
||
To string
|
||
Lang string
|
||
Name string
|
||
Data map[string]any
|
||
}
|
||
|
||
// SendTemplate renders subject + body via EmailTemplateService and sends.
|
||
// Render errors surface even when SMTP is disabled — that catches typos and
|
||
// missing fields in dev/test where SMTP isn't configured.
|
||
func (s *MailService) SendTemplate(in TemplateData) error {
|
||
subject, html, err := s.RenderTemplate(in)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !s.enabled {
|
||
slog.Debug("mail: SendTemplate skipped (disabled)", "to", in.To, "template", in.Name)
|
||
return nil
|
||
}
|
||
return s.Send(in.To, subject, html, htmlToText(html))
|
||
}
|
||
|
||
// RenderTemplate produces the rendered subject and HTML body. Falls back to
|
||
// the embedded default if a DB row is malformed at parse time — admins can
|
||
// never wedge the send path with a bad save (per design-email-templates §3).
|
||
// Exposed for tests and the admin preview endpoint.
|
||
func (s *MailService) RenderTemplate(in TemplateData) (subject string, html string, err error) {
|
||
lang := in.Lang
|
||
if lang == "" {
|
||
lang = "de"
|
||
}
|
||
|
||
ctx := context.Background()
|
||
|
||
// Build payload once — both subject and body use the same data.
|
||
payload := map[string]any{
|
||
"Lang": lang,
|
||
"Firm": branding.Name,
|
||
}
|
||
maps.Copy(payload, in.Data)
|
||
|
||
// Body comes from (key, lang); on parse error fall back to the embedded
|
||
// default so a corrupt DB row never breaks delivery.
|
||
body, _, err := s.lookupBody(ctx, in.Name, lang)
|
||
if err != nil {
|
||
return "", "", fmt.Errorf("lookup body %s: %w", in.Name, err)
|
||
}
|
||
|
||
// Base wrapper: same lookup, key='base'.
|
||
baseBody, _, err := s.lookupBody(ctx, EmailTemplateKeyBase, lang)
|
||
if err != nil {
|
||
return "", "", fmt.Errorf("lookup base: %w", err)
|
||
}
|
||
|
||
// Compose: parse base, then layer the content body on top. If either
|
||
// parse fails on the active row, retry with the embedded default.
|
||
html, err = renderBaseAndContent(baseBody, body, payload)
|
||
if err != nil {
|
||
// Active row was bad. Pull the embedded fallback for both and retry —
|
||
// log loudly so the admin can fix it, but keep email working.
|
||
slog.Error("mail: active template parse failed, falling back to embedded default",
|
||
"key", in.Name, "lang", lang, "err", err)
|
||
fbContent, fbErr := readEmbeddedBody(in.Name, lang)
|
||
if fbErr != nil {
|
||
return "", "", fmt.Errorf("fallback body %s: %w", in.Name, fbErr)
|
||
}
|
||
fbBase, fbErr := readEmbeddedBody(EmailTemplateKeyBase, lang)
|
||
if fbErr != nil {
|
||
return "", "", fmt.Errorf("fallback base: %w", fbErr)
|
||
}
|
||
html, err = renderBaseAndContent(fbBase, fbContent, payload)
|
||
if err != nil {
|
||
return "", "", fmt.Errorf("render embedded fallback %s: %w", in.Name, err)
|
||
}
|
||
}
|
||
|
||
// Subject: same fallback discipline. Empty subject template (key='base'
|
||
// case, when SendTemplate is somehow asked for the wrapper directly) is
|
||
// allowed and returns "".
|
||
subjectTpl, _, err := s.lookupSubject(ctx, in.Name, lang)
|
||
if err != nil {
|
||
return "", "", fmt.Errorf("lookup subject %s: %w", in.Name, err)
|
||
}
|
||
subject, err = renderSubject(subjectTpl, payload)
|
||
if err != nil {
|
||
slog.Error("mail: active subject parse failed, falling back to embedded default",
|
||
"key", in.Name, "lang", lang, "err", err)
|
||
fbSubj := defaultSubjects[in.Name][lang]
|
||
fbSubject, fbErr := renderSubject(fbSubj, payload)
|
||
if fbErr != nil {
|
||
return "", "", fmt.Errorf("render embedded fallback subject %s: %w", in.Name, fbErr)
|
||
}
|
||
subject = fbSubject
|
||
}
|
||
return subject, html, nil
|
||
}
|
||
|
||
// lookupBody returns the body string + IsDefault marker. When the template
|
||
// service has no DB or the row is missing, the embedded body wins.
|
||
func (s *MailService) lookupBody(ctx context.Context, key, lang string) (string, bool, error) {
|
||
row, err := s.templateSvc.GetActive(ctx, key, lang)
|
||
if err != nil {
|
||
return "", false, err
|
||
}
|
||
return row.Body, row.IsDefault, nil
|
||
}
|
||
|
||
// lookupSubject mirrors lookupBody for subjects.
|
||
func (s *MailService) lookupSubject(ctx context.Context, key, lang string) (string, bool, error) {
|
||
row, err := s.templateSvc.GetActive(ctx, key, lang)
|
||
if err != nil {
|
||
return "", false, err
|
||
}
|
||
return row.Subject, row.IsDefault, nil
|
||
}
|
||
|
||
// RenderPreview renders user-supplied subject+body against the active base
|
||
// wrapper (or embedded fallback) for (lang). Used by the admin preview
|
||
// endpoint — never persists. Sample data is supplied by the caller and is
|
||
// merged on top of {Lang, Firm} so previews see the same baseline payload
|
||
// the production render would.
|
||
//
|
||
// For key=='base' the user-supplied body IS the wrapper; we layer a small
|
||
// built-in content sample on top so the preview shows what an inner email
|
||
// looks like inside the proposed wrapper.
|
||
func (s *MailService) RenderPreview(key, lang, subjectSrc, bodySrc string, data map[string]any) (string, string, error) {
|
||
if lang == "" {
|
||
lang = "de"
|
||
}
|
||
payload := map[string]any{
|
||
"Lang": lang,
|
||
"Firm": branding.Name,
|
||
}
|
||
maps.Copy(payload, data)
|
||
|
||
var (
|
||
baseBody, contentBody string
|
||
err error
|
||
)
|
||
if key == EmailTemplateKeyBase {
|
||
baseBody = bodySrc
|
||
contentBody = previewBaseInnerContent(lang)
|
||
} else {
|
||
baseBody, _, err = s.lookupBody(context.Background(), EmailTemplateKeyBase, lang)
|
||
if err != nil {
|
||
return "", "", fmt.Errorf("lookup base: %w", err)
|
||
}
|
||
contentBody = bodySrc
|
||
}
|
||
|
||
html, err := renderBaseAndContent(baseBody, contentBody, payload)
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
subject, err := renderSubject(subjectSrc, payload)
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
return subject, html, nil
|
||
}
|
||
|
||
// previewBaseInnerContent is the placeholder body wrapped during a base
|
||
// preview. Kept tiny so the preview pane shows the wrapper, not a fake email.
|
||
func previewBaseInnerContent(lang string) string {
|
||
if lang == "en" {
|
||
return `{{define "content"}}
|
||
<p>Inner content of the specific email is rendered here.</p>
|
||
<p>Example link: <a href="https://paliad.de">paliad.de</a>.</p>
|
||
{{end}}`
|
||
}
|
||
return `{{define "content"}}
|
||
<p>Inhalt der spezifischen Mail wird hier gerendert.</p>
|
||
<p>Beispiel-Link: <a href="https://paliad.de">paliad.de</a>.</p>
|
||
{{end}}`
|
||
}
|
||
|
||
func renderBaseAndContent(baseBody, contentBody string, payload map[string]any) (string, error) {
|
||
tpl, err := htmltemplate.New("base.html").Parse(baseBody)
|
||
if err != nil {
|
||
return "", fmt.Errorf("parse base: %w", err)
|
||
}
|
||
if _, err := tpl.Parse(contentBody); err != nil {
|
||
return "", fmt.Errorf("parse content: %w", err)
|
||
}
|
||
var out bytes.Buffer
|
||
if err := tpl.ExecuteTemplate(&out, "base.html", payload); err != nil {
|
||
return "", fmt.Errorf("execute: %w", err)
|
||
}
|
||
return out.String(), nil
|
||
}
|
||
|
||
func renderSubject(src string, payload map[string]any) (string, error) {
|
||
if strings.TrimSpace(src) == "" {
|
||
return "", nil
|
||
}
|
||
tpl, err := texttemplate.New("subject").Parse(src)
|
||
if err != nil {
|
||
return "", fmt.Errorf("parse subject: %w", err)
|
||
}
|
||
var out bytes.Buffer
|
||
if err := tpl.Execute(&out, payload); err != nil {
|
||
return "", fmt.Errorf("execute subject: %w", err)
|
||
}
|
||
return out.String(), nil
|
||
}
|
||
|
||
// --- SMTP transport ---------------------------------------------------------
|
||
|
||
func (s *MailService) deliver(to string, msg []byte) error {
|
||
addr := net.JoinHostPort(s.cfg.Host, s.cfg.Port)
|
||
tlsCfg := &tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12}
|
||
|
||
var (
|
||
client *smtp.Client
|
||
err error
|
||
)
|
||
if s.cfg.UseTLS {
|
||
// Implicit TLS (port 465). Establish the TLS connection first, then
|
||
// hand it to smtp.NewClient. STARTTLS upgrades on port 587 would go
|
||
// through smtp.Dial + client.StartTLS — different code path, not
|
||
// needed for Hostinger's submission endpoint.
|
||
conn, dialErr := tls.Dial("tcp", addr, tlsCfg)
|
||
if dialErr != nil {
|
||
return fmt.Errorf("smtp tls dial: %w", dialErr)
|
||
}
|
||
client, err = smtp.NewClient(conn, s.cfg.Host)
|
||
} else {
|
||
client, err = smtp.Dial(addr)
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("smtp connect: %w", err)
|
||
}
|
||
defer client.Close()
|
||
|
||
if err := client.Hello(hostnameForHelo()); err != nil {
|
||
return fmt.Errorf("smtp helo: %w", err)
|
||
}
|
||
auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
|
||
if err := client.Auth(auth); err != nil {
|
||
return fmt.Errorf("smtp auth: %w", err)
|
||
}
|
||
if err := client.Mail(s.cfg.From); err != nil {
|
||
return fmt.Errorf("smtp mail from: %w", err)
|
||
}
|
||
if err := client.Rcpt(to); err != nil {
|
||
return fmt.Errorf("smtp rcpt to: %w", err)
|
||
}
|
||
w, err := client.Data()
|
||
if err != nil {
|
||
return fmt.Errorf("smtp data: %w", err)
|
||
}
|
||
if _, err := w.Write(msg); err != nil {
|
||
w.Close()
|
||
return fmt.Errorf("smtp write: %w", err)
|
||
}
|
||
if err := w.Close(); err != nil {
|
||
return fmt.Errorf("smtp close data: %w", err)
|
||
}
|
||
return client.Quit()
|
||
}
|
||
|
||
func hostnameForHelo() string {
|
||
if h, err := os.Hostname(); err == nil && h != "" {
|
||
return h
|
||
}
|
||
return "localhost"
|
||
}
|
||
|
||
// --- MIME construction ------------------------------------------------------
|
||
|
||
// buildMIME assembles a multipart/alternative message with a fixed boundary.
|
||
// Subjects are encoded as UTF-8 per RFC 2047 so non-ASCII characters
|
||
// (umlauts) render correctly in every client.
|
||
func buildMIME(from, fromName, to, subject, htmlBody, textBody string) []byte {
|
||
return buildMIMEWithReplyTo(from, fromName, "", to, subject, htmlBody, textBody)
|
||
}
|
||
|
||
// buildMIMEWithReplyTo is buildMIME plus an optional Reply-To header.
|
||
// Bulk-broadcast email uses this so replies route to the human sender even
|
||
// though From: stays on the SMTP infrastructure address.
|
||
func buildMIMEWithReplyTo(from, fromName, replyTo, to, subject, htmlBody, textBody string) []byte {
|
||
boundary := "paliad-mixed-" + randBoundary()
|
||
fromHeader := from
|
||
if fromName != "" {
|
||
fromHeader = fmt.Sprintf("%s <%s>", mime.QEncoding.Encode("utf-8", fromName), from)
|
||
}
|
||
var b bytes.Buffer
|
||
fmt.Fprintf(&b, "From: %s\r\n", fromHeader)
|
||
if replyTo != "" {
|
||
fmt.Fprintf(&b, "Reply-To: %s\r\n", replyTo)
|
||
}
|
||
fmt.Fprintf(&b, "To: %s\r\n", to)
|
||
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
|
||
fmt.Fprintf(&b, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
|
||
fmt.Fprintf(&b, "MIME-Version: 1.0\r\n")
|
||
fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary)
|
||
|
||
// Plain text part
|
||
fmt.Fprintf(&b, "--%s\r\n", boundary)
|
||
fmt.Fprintf(&b, "Content-Type: text/plain; charset=\"utf-8\"\r\n")
|
||
fmt.Fprintf(&b, "Content-Transfer-Encoding: 8bit\r\n\r\n")
|
||
b.WriteString(textBody)
|
||
b.WriteString("\r\n")
|
||
|
||
// HTML part
|
||
fmt.Fprintf(&b, "--%s\r\n", boundary)
|
||
fmt.Fprintf(&b, "Content-Type: text/html; charset=\"utf-8\"\r\n")
|
||
fmt.Fprintf(&b, "Content-Transfer-Encoding: 8bit\r\n\r\n")
|
||
b.WriteString(htmlBody)
|
||
b.WriteString("\r\n")
|
||
|
||
fmt.Fprintf(&b, "--%s--\r\n", boundary)
|
||
return b.Bytes()
|
||
}
|
||
|
||
// randBoundary produces a short unique boundary marker. Crypto-strength
|
||
// isn't required — we only need to avoid collision with the body content.
|
||
func randBoundary() string {
|
||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||
}
|
||
|
||
// --- HTML → plain text ------------------------------------------------------
|
||
|
||
var (
|
||
stripTagRE = regexp.MustCompile(`(?is)<(script|style)[^>]*>.*?</(script|style)>`)
|
||
stripHTMLRE = regexp.MustCompile(`<[^>]+>`)
|
||
wsRE = regexp.MustCompile(`[ \t]+`)
|
||
nlRE = regexp.MustCompile(`\n{3,}`)
|
||
)
|
||
|
||
// htmlToText produces a readable plain-text version of an HTML body. Good
|
||
// enough for the fallback part of a multipart/alternative message — users
|
||
// whose clients render HTML will see the styled version; this is only read
|
||
// by text-only clients and spam filters.
|
||
func htmlToText(html string) string {
|
||
s := stripTagRE.ReplaceAllString(html, "")
|
||
// Convert common block breaks to newlines before stripping.
|
||
s = strings.NewReplacer(
|
||
"<br>", "\n", "<br/>", "\n", "<br />", "\n",
|
||
"</p>", "\n\n", "</div>", "\n", "</tr>", "\n",
|
||
"</li>", "\n", "</h1>", "\n\n", "</h2>", "\n\n", "</h3>", "\n\n",
|
||
).Replace(s)
|
||
s = stripHTMLRE.ReplaceAllString(s, "")
|
||
s = strings.NewReplacer(
|
||
" ", " ", "&", "&", "<", "<", ">", ">", """, "\"",
|
||
"—", "—", "–", "–", "ä", "ä", "ö", "ö",
|
||
"ü", "ü", "Ä", "Ä", "Ö", "Ö", "Ü", "Ü", "ß", "ß",
|
||
"…", "…",
|
||
).Replace(s)
|
||
s = wsRE.ReplaceAllString(s, " ")
|
||
s = nlRE.ReplaceAllString(s, "\n\n")
|
||
return strings.TrimSpace(s)
|
||
}
|