Test / test (push) Successful in 2m1s
Release / gates (push) Successful in 1m57s
Release / build (amd64, freebsd) (push) Successful in 1m26s
Release / build (amd64, linux) (push) Successful in 1m30s
Release / build (arm64, freebsd) (push) Successful in 1m28s
Release / build (arm64, linux) (push) Successful in 1m49s
Release / build (loong64, linux) (push) Successful in 1m30s
Release / build (riscv64, linux) (push) Successful in 1m29s
Release / release (push) Successful in 41s
Assisted-by: GLM 5.3 Flash
495 lines
18 KiB
Go
495 lines
18 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
//go:build linux || freebsd
|
|
|
|
// Package email handles SMTP message composition and delivery.
|
|
package email
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"html/template"
|
|
"net"
|
|
"net/smtp"
|
|
"strings"
|
|
"time"
|
|
|
|
"sourcedock.dev/petrbalvin/nuntius/internal/config"
|
|
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
|
|
)
|
|
|
|
// effectiveTLSConfig returns cfg when set, otherwise a default config that
|
|
// verifies the server certificate against host.
|
|
func effectiveTLSConfig(cfg *tls.Config, host string) *tls.Config {
|
|
if cfg != nil {
|
|
return cfg
|
|
}
|
|
return &tls.Config{ServerName: host}
|
|
}
|
|
|
|
// FormSender delivers contact form submissions for a single form
|
|
// using its own SMTP credentials. Each form has its own FormSender
|
|
// so credentials are isolated per tenant.
|
|
type FormSender struct {
|
|
form *config.Form
|
|
// TLSConfig optionally overrides the TLS configuration used for
|
|
// STARTTLS. When nil, a default config with ServerName set to the
|
|
// SMTP host is used.
|
|
TLSConfig *tls.Config
|
|
}
|
|
|
|
// NewFormSender returns a new FormSender bound to the given form.
|
|
func NewFormSender(form *config.Form) *FormSender {
|
|
return &FormSender{form: form}
|
|
}
|
|
|
|
// Send composes and sends a multi-part email (plain text + HTML)
|
|
// for the given request. Returns an error if composition or the SMTP
|
|
// round-trip fails. The SMTP conversation bound comes from the form's
|
|
// configured smtp.timeout_seconds.
|
|
func (s *FormSender) Send(req contactform.Request) error {
|
|
auth := smtp.PlainAuth("", s.form.SMTP.User, s.form.SMTP.Password, s.form.SMTP.Host)
|
|
msg, err := compose(s.form.From, s.form.To, req, s.form)
|
|
if err != nil {
|
|
return fmt.Errorf("compose message: %w", err)
|
|
}
|
|
return sendMail(s.form.SMTP, auth, s.form.From, []string{s.form.To}, msg, s.form.SMTP.Timeout(), s.TLSConfig)
|
|
}
|
|
|
|
// sendMail delivers msg over SMTP with a hard timeout on every network
|
|
// operation. When cfg.Port is 465 the connection speaks TLS from the first
|
|
// byte (implicit TLS); any other port starts plaintext and upgrades via
|
|
// STARTTLS when the server advertises it. With cfg.RequireTLS set, an SMTP
|
|
// server that never offers STARTTLS aborts the delivery instead of sending
|
|
// over plaintext. If tlsConfig is nil, a default config with ServerName set
|
|
// to the target host is used.
|
|
func sendMail(cfg config.SMTPConfig, auth smtp.Auth, from string, to []string, msg []byte, timeout time.Duration, tlsConfig *tls.Config) error {
|
|
addr := cfg.AddrFor()
|
|
implicitTLS := cfg.Port == config.ImplicitTLSPort
|
|
|
|
var conn net.Conn
|
|
if implicitTLS {
|
|
dialer := &net.Dialer{Timeout: timeout}
|
|
tconn, err := tls.DialWithDialer(dialer, "tcp", addr, effectiveTLSConfig(tlsConfig, cfg.Host))
|
|
if err != nil {
|
|
return fmt.Errorf("dial smtps %s: %w", addr, err)
|
|
}
|
|
conn = tconn
|
|
} else {
|
|
pconn, err := net.DialTimeout("tcp", addr, timeout)
|
|
if err != nil {
|
|
return fmt.Errorf("dial smtp %s: %w", addr, err)
|
|
}
|
|
conn = pconn
|
|
}
|
|
defer conn.Close()
|
|
|
|
if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
|
|
return fmt.Errorf("set smtp deadline: %w", err)
|
|
}
|
|
|
|
c, err := smtp.NewClient(conn, cfg.Host)
|
|
if err != nil {
|
|
return fmt.Errorf("smtp client: %w", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
if !implicitTLS {
|
|
ok, _ := c.Extension("STARTTLS")
|
|
switch {
|
|
case ok:
|
|
if err := c.StartTLS(effectiveTLSConfig(tlsConfig, cfg.Host)); err != nil {
|
|
return fmt.Errorf("starttls: %w", err)
|
|
}
|
|
case cfg.RequireTLS:
|
|
return fmt.Errorf("smtp server %s does not advertise starttls but require_tls is enabled", addr)
|
|
}
|
|
}
|
|
|
|
if auth != nil {
|
|
if ok, _ := c.Extension("AUTH"); ok {
|
|
if err := c.Auth(auth); err != nil {
|
|
return fmt.Errorf("smtp auth: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := c.Mail(from); err != nil {
|
|
return fmt.Errorf("smtp mail from: %w", err)
|
|
}
|
|
for _, rcpt := range to {
|
|
if err := c.Rcpt(rcpt); err != nil {
|
|
return fmt.Errorf("smtp rcpt to: %w", err)
|
|
}
|
|
}
|
|
|
|
w, err := c.Data()
|
|
if err != nil {
|
|
return fmt.Errorf("smtp data: %w", err)
|
|
}
|
|
if _, err := w.Write(msg); err != nil {
|
|
return fmt.Errorf("smtp write data: %w", err)
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return fmt.Errorf("smtp close data: %w", err)
|
|
}
|
|
|
|
if err := c.Quit(); err != nil {
|
|
return fmt.Errorf("smtp quit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// randomBoundary returns a MIME boundary that is practically impossible
|
|
// to collide with message content. An error means crypto/rand failed; the
|
|
// message must then not be sent at all, because a predictable delimiter
|
|
// would let crafted content forge MIME part boundaries.
|
|
func randomBoundary() (string, error) {
|
|
var buf [16]byte
|
|
if _, err := rand.Read(buf[:]); err != nil {
|
|
return "", fmt.Errorf("generate mime boundary: %w", err)
|
|
}
|
|
return "nuntius-" + hex.EncodeToString(buf[:]), nil
|
|
}
|
|
|
|
// sanitizeHeaderValue makes an interpolated value safe to embed in a
|
|
// single RFC 5322 header line. A CR or LF would terminate the header and
|
|
// let a crafted value inject arbitrary additional headers.
|
|
func sanitizeHeaderValue(v string) string {
|
|
return strings.NewReplacer("\r", " ", "\n", " ").Replace(v)
|
|
}
|
|
|
|
// emailTemplateContact is the HTML body template for contact-type forms.
|
|
var emailTemplateContact = template.Must(template.New("contact").Parse(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
</head>
|
|
<body style="margin:0;padding:0;background-color:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f3f4f6;padding:32px 0">
|
|
<tr><td align="center">
|
|
<table width="560" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,0.06)">
|
|
|
|
<!-- Header -->
|
|
<tr>
|
|
<td style="background-color:#1e40af;padding:24px 28px">
|
|
<p style="margin:0;font-size:13px;font-weight:600;color:#93c5fd;text-transform:uppercase;letter-spacing:0.5px">
|
|
Contact Form Submission
|
|
</p>
|
|
<p style="margin:4px 0 0;font-size:18px;font-weight:700;color:#ffffff">
|
|
{{.FormName}}
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Body -->
|
|
<tr>
|
|
<td style="padding:28px 28px 12px">
|
|
|
|
<!-- Submitter -->
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:20px">
|
|
<tr>
|
|
<td style="padding-bottom:8px;border-bottom:1px solid #e5e7eb">
|
|
<span style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">From</span>
|
|
<br>
|
|
<span style="font-size:15px;font-weight:600;color:#1f2937">{{.Name}}</span>
|
|
<span style="font-size:14px;color:#1e40af;margin-left:8px">{{.Email}}</span>
|
|
</td>
|
|
</tr>
|
|
{{if .Service}}
|
|
<tr>
|
|
<td style="padding:12px 0 8px;border-bottom:1px solid #e5e7eb">
|
|
<span style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Service Interest</span>
|
|
<br>
|
|
<span style="font-size:14px;color:#1f2937">{{.Service}}</span>
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
<tr>
|
|
<td style="padding:12px 0 8px;border-bottom:1px solid #e5e7eb">
|
|
<span style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Received</span>
|
|
<br>
|
|
<span style="font-size:13px;color:#9ca3af">{{.Received}}</span>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<!-- Message -->
|
|
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;margin:0 0 8px">Message</p>
|
|
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap;padding:6px 0">{{.Message}}</div>
|
|
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Footer -->
|
|
{{if .Brand}}
|
|
<tr>
|
|
<td style="padding:16px 28px 28px">
|
|
<p style="margin:0;font-size:11px;color:#9ca3af;border-top:1px solid #e5e7eb;padding-top:16px">
|
|
Delivered by <strong style="color:#6b7280">{{.Brand}}</strong>, a contact form backend for Linux servers.
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
</body>
|
|
</html>`))
|
|
|
|
// emailTemplateFeedback is the HTML body template for feedback-type forms.
|
|
var emailTemplateFeedback = template.Must(template.New("feedback").Parse(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head><meta charset="UTF-8"></head>
|
|
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,sans-serif">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:32px 0">
|
|
<tr><td align="center">
|
|
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.06)">
|
|
<tr><td style="background:#0f766e;padding:24px 28px">
|
|
<p style="margin:0;font-size:13px;font-weight:600;color:#5eead4;text-transform:uppercase;letter-spacing:.5px">New Feedback</p>
|
|
<p style="margin:4px 0 0;font-size:18px;font-weight:700;color:#fff">{{.FormName}}</p>
|
|
</td></tr>
|
|
<tr><td style="padding:28px">
|
|
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0">From</p>
|
|
<p style="font-size:15px;font-weight:600;color:#1f2937;margin:2px 0 16px">{{.Name}} <span style="color:#0f766e">{{.Email}}</span></p>
|
|
{{if .Service}}
|
|
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 8px">Service Interest</p>
|
|
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap;margin:0 0 16px">{{.Service}}</div>
|
|
{{end}}
|
|
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 8px">Feedback</p>
|
|
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap">{{.Message}}</div>
|
|
{{if .Brand}}
|
|
<p style="font-size:11px;color:#9ca3af;margin:20px 0 0;border-top:1px solid #e5e7eb;padding-top:16px">Delivered by <strong style="color:#6b7280">{{.Brand}}</strong></p>
|
|
{{end}}
|
|
</td></tr>
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
</body>
|
|
</html>`))
|
|
|
|
// emailTemplateNewsletter is the HTML body template for newsletter-signup forms.
|
|
var emailTemplateNewsletter = template.Must(template.New("newsletter").Parse(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head><meta charset="UTF-8"></head>
|
|
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,sans-serif">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:32px 0">
|
|
<tr><td align="center">
|
|
<table width="460" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.06)">
|
|
<tr><td style="background:#1e40af;padding:20px 24px">
|
|
<p style="margin:0;font-size:18px;font-weight:700;color:#fff">{{.FormName}}: new subscriber</p>
|
|
</td></tr>
|
|
<tr><td style="padding:24px">
|
|
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 4px">Email</p>
|
|
<p style="font-size:16px;font-weight:600;color:#1e40af;margin:0 0 16px">{{.Email}}</p>
|
|
{{if .Brand}}
|
|
<p style="font-size:11px;color:#9ca3af;margin:16px 0 0;border-top:1px solid #e5e7eb;padding-top:16px">Delivered by <strong style="color:#6b7280">{{.Brand}}</strong></p>
|
|
{{end}}
|
|
</td></tr>
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
</body>
|
|
</html>`))
|
|
|
|
// emailTemplateGeneric is the HTML body template for generic forms.
|
|
var emailTemplateGeneric = template.Must(template.New("generic").Parse(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head><meta charset="UTF-8"></head>
|
|
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,sans-serif">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:32px 0">
|
|
<tr><td align="center">
|
|
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.06)">
|
|
<tr><td style="background:#1e40af;padding:24px 28px">
|
|
<p style="margin:0;font-size:13px;font-weight:600;color:#93c5fd;text-transform:uppercase;letter-spacing:.5px">Form Submission</p>
|
|
<p style="margin:4px 0 0;font-size:18px;font-weight:700;color:#fff">{{.FormName}}</p>
|
|
</td></tr>
|
|
<tr><td style="padding:28px">
|
|
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0">From</p>
|
|
<p style="font-size:15px;font-weight:600;color:#1f2937;margin:2px 0 16px">{{.Name}} <span style="color:#1e40af">{{.Email}}</span></p>
|
|
{{if .Service}}
|
|
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 8px">Service Interest</p>
|
|
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap;margin:0 0 16px">{{.Service}}</div>
|
|
{{end}}
|
|
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 8px">Message</p>
|
|
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap">{{.Message}}</div>
|
|
{{if .Brand}}
|
|
<p style="font-size:11px;color:#9ca3af;margin:20px 0 0;border-top:1px solid #e5e7eb;padding-top:16px">Delivered by <strong style="color:#6b7280">{{.Brand}}</strong></p>
|
|
{{end}}
|
|
</td></tr>
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
</body>
|
|
</html>`))
|
|
|
|
// compose builds the multipart message for one submission. The subject
|
|
// prefix and the footer brand come from the form's configuration; the
|
|
// defaults reproduce the subjects and footers earlier releases sent.
|
|
func compose(from, to string, req contactform.Request, form *config.Form) ([]byte, error) {
|
|
received := time.Now().UTC().Format("January 2, 2006 at 15:04 UTC")
|
|
|
|
// The service interest only surfaces when the form actually accepts
|
|
// the field: the contact template has always shown it, and a form
|
|
// with its own services list has opted the field into validation.
|
|
service := ""
|
|
if req.Service != "" && (form.Type == "contact" || form.Services != nil) {
|
|
service = req.Service
|
|
}
|
|
|
|
// --- HTML part ---
|
|
var htmlBuf bytes.Buffer
|
|
tmpl := emailTemplateContact
|
|
switch form.Type {
|
|
case "newsletter":
|
|
tmpl = emailTemplateNewsletter
|
|
case "feedback":
|
|
tmpl = emailTemplateFeedback
|
|
case "generic":
|
|
tmpl = emailTemplateGeneric
|
|
}
|
|
if err := tmpl.Execute(&htmlBuf, map[string]string{
|
|
"FormName": form.Name,
|
|
"Name": req.Name,
|
|
"Email": req.Email,
|
|
"Service": service,
|
|
"Message": req.Message,
|
|
"Received": received,
|
|
"Brand": form.Brand(),
|
|
}); err != nil {
|
|
// template.Must guarantees valid templates; this path is
|
|
// unreachable in normal operation.
|
|
htmlBuf.Reset()
|
|
fmt.Fprintf(&htmlBuf, "<p>Email generation error: %v</p>", err)
|
|
}
|
|
|
|
// --- Subject + plain-text body per form type ---
|
|
var base string
|
|
switch form.Type {
|
|
case "newsletter":
|
|
base = "New newsletter subscriber"
|
|
case "feedback":
|
|
base = "New feedback"
|
|
case "generic":
|
|
base = "New submission"
|
|
default: // contact
|
|
base = "Contact form submission"
|
|
}
|
|
subject := base
|
|
if tag := subjectTag(form, service); tag != "" {
|
|
subject = tag + " " + base
|
|
}
|
|
|
|
footer := ""
|
|
if brand := form.Brand(); brand != "" {
|
|
footer = "Delivered by " + brand + "\r\n"
|
|
}
|
|
|
|
var text string
|
|
switch form.Type {
|
|
case "newsletter":
|
|
text = fmt.Sprintf(
|
|
"New Newsletter Subscriber: %s\r\n"+
|
|
"---\r\n"+
|
|
"Email: %s\r\n"+
|
|
"Received: %s\r\n"+
|
|
"\r\n",
|
|
form.Name, req.Email, received,
|
|
)
|
|
case "feedback":
|
|
text = fmt.Sprintf(
|
|
"New Feedback: %s\r\n"+
|
|
"---\r\n"+
|
|
"From: %s <%s>\r\n"+
|
|
"%s"+
|
|
"Received: %s\r\n"+
|
|
"\r\n"+
|
|
"%s\r\n\r\n",
|
|
form.Name, req.Name, req.Email, serviceLine(service), received, req.Message,
|
|
)
|
|
case "generic":
|
|
text = fmt.Sprintf(
|
|
"New Submission: %s\r\n"+
|
|
"---\r\n"+
|
|
"From: %s <%s>\r\n"+
|
|
"%s"+
|
|
"Received: %s\r\n"+
|
|
"\r\n"+
|
|
"%s\r\n\r\n",
|
|
form.Name, req.Name, req.Email, serviceLine(service), received, req.Message,
|
|
)
|
|
default: // contact; the line is printed even when empty, as always
|
|
text = fmt.Sprintf(
|
|
"Contact Form Submission: %s\r\n"+
|
|
"---\r\n"+
|
|
"From: %s <%s>\r\n"+
|
|
"Service interest: %s\r\n"+
|
|
"Received: %s\r\n"+
|
|
"\r\n"+
|
|
"%s\r\n\r\n",
|
|
form.Name, req.Name, req.Email, req.Service, received, req.Message,
|
|
)
|
|
}
|
|
text += footer
|
|
|
|
// Assemble multipart/alternative message. The boundary comes first so
|
|
// that randomness failure aborts the message before anything is built.
|
|
boundary, err := randomBoundary()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var msg bytes.Buffer
|
|
msg.WriteString(fmt.Sprintf("From: %s\r\n", sanitizeHeaderValue(from)))
|
|
msg.WriteString(fmt.Sprintf("To: %s\r\n", sanitizeHeaderValue(to)))
|
|
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", sanitizeHeaderValue(subject)))
|
|
msg.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)))
|
|
msg.WriteString(fmt.Sprintf("Reply-To: %s\r\n", sanitizeHeaderValue(req.Email)))
|
|
msg.WriteString("MIME-Version: 1.0\r\n")
|
|
msg.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n", boundary))
|
|
msg.WriteString("\r\n")
|
|
|
|
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
|
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
|
msg.WriteString("\r\n")
|
|
msg.WriteString(text)
|
|
msg.WriteString("\r\n")
|
|
|
|
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
|
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
|
msg.WriteString("\r\n")
|
|
msg.WriteString(htmlBuf.String())
|
|
msg.WriteString("\r\n")
|
|
|
|
msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
|
return msg.Bytes(), nil
|
|
}
|
|
|
|
// subjectTag renders the bracket segments of a subject line: the
|
|
// configurable "[<prefix>/<form name>]" segment when a prefix is set,
|
|
// plus the "[<service>]" segment when a service value surfaced.
|
|
func subjectTag(form *config.Form, service string) string {
|
|
tag := ""
|
|
if prefix := form.EmailSubjectPrefix(); prefix != "" {
|
|
tag = "[" + prefix + "/" + form.Name + "]"
|
|
}
|
|
if service != "" {
|
|
tag += "[" + service + "]"
|
|
}
|
|
return tag
|
|
}
|
|
|
|
// serviceLine renders the optional plain-text service row for the form
|
|
// types whose body has no fixed service field.
|
|
func serviceLine(service string) string {
|
|
if service == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("Service interest: %s\r\n", service)
|
|
}
|