feat: contact form backend for linux and freebsd servers
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
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
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/smtp"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nuntius/internal/config"
|
||||
)
|
||||
|
||||
// emailTemplateAcknowledgement is the HTML body of the automated receipt
|
||||
// a form with auto_reply enabled sends to the submitter.
|
||||
var emailTemplateAcknowledgement = template.Must(template.New("acknowledgement").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">Message received</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:24px">
|
||||
<p style="font-size:14px;line-height:1.6;color:#1f2937;margin:0 0 12px">Hello,</p>
|
||||
<p style="font-size:14px;line-height:1.6;color:#1f2937;margin:0 0 12px">your message to <strong>{{.FormName}}</strong> was received. A reply will follow as soon as possible.</p>
|
||||
<p style="font-size:14px;line-height:1.6;color:#1f2937;margin:0">Please do not respond to this automated receipt.</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>`))
|
||||
|
||||
// SendAcknowledgement mails the submitter a short receipt. It uses the
|
||||
// form's SMTP identity, and the Reply-To points at the owner, so a reply
|
||||
// to the receipt lands in the human's inbox and not into the void.
|
||||
func (s *FormSender) SendAcknowledgement(to string) error {
|
||||
auth := smtp.PlainAuth("", s.form.SMTP.User, s.form.SMTP.Password, s.form.SMTP.Host)
|
||||
msg, err := composeAcknowledgement(s.form, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose acknowledgement: %w", err)
|
||||
}
|
||||
return sendMail(s.form.SMTP, auth, s.form.From, []string{to}, msg, s.form.SMTP.Timeout(), s.TLSConfig)
|
||||
}
|
||||
|
||||
// composeAcknowledgement builds the multipart receipt. The submitted
|
||||
// values are deliberately not echoed back: the receipt confirms arrival,
|
||||
// it does not mirror a message's content into the submitter's inbox,
|
||||
// where any third party who could fill the form would read it.
|
||||
func composeAcknowledgement(form *config.Form, to string) ([]byte, error) {
|
||||
// --- HTML part ---
|
||||
var htmlBuf bytes.Buffer
|
||||
if err := emailTemplateAcknowledgement.Execute(&htmlBuf, map[string]string{
|
||||
"FormName": form.Name,
|
||||
"Brand": form.Brand(),
|
||||
}); err != nil {
|
||||
// template.Must guarantees valid templates; unreachable in
|
||||
// normal operation.
|
||||
htmlBuf.Reset()
|
||||
fmt.Fprintf(&htmlBuf, "<p>Email generation error: %v</p>", err)
|
||||
}
|
||||
|
||||
// --- Subject + plain-text body ---
|
||||
subject := "Message received"
|
||||
if tag := subjectTag(form, ""); tag != "" {
|
||||
subject = tag + " " + subject
|
||||
}
|
||||
|
||||
footer := ""
|
||||
if brand := form.Brand(); brand != "" {
|
||||
footer = "--\r\nDelivered by " + brand + "\r\n"
|
||||
}
|
||||
text := fmt.Sprintf(
|
||||
"Hello,\r\n\r\n"+
|
||||
"your message to %s was received. A reply will follow as soon as possible.\r\n"+
|
||||
"Please do not respond to this automated receipt.\r\n\r\n%s",
|
||||
form.Name, footer,
|
||||
)
|
||||
|
||||
// Assemble multipart/alternative. 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(form.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(form.To)))
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package email
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nuntius/internal/config"
|
||||
)
|
||||
|
||||
func ackForm() *config.Form {
|
||||
return &config.Form{
|
||||
Name: "contact",
|
||||
Type: "contact",
|
||||
To: "owner@example.com",
|
||||
From: "noreply@example.com",
|
||||
SubjectPrefix: new("nuntius"),
|
||||
EmailBrand: new("nuntius"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeAcknowledgement(t *testing.T) {
|
||||
msg, err := composeAcknowledgement(ackForm(), "jane@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
raw := string(msg)
|
||||
|
||||
for _, want := range []string{
|
||||
"From: noreply@example.com\r\n",
|
||||
"To: jane@example.com\r\n",
|
||||
"Subject: [nuntius/contact] Message received\r\n",
|
||||
// The reply lands in the owner's inbox, not in the void.
|
||||
"Reply-To: owner@example.com\r\n",
|
||||
"Content-Type: multipart/alternative; boundary=nuntius-",
|
||||
"your message to contact was received",
|
||||
"Delivered by nuntius",
|
||||
} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Errorf("message missing %q", want)
|
||||
}
|
||||
}
|
||||
// The submitted values are deliberately never echoed back.
|
||||
if strings.Contains(raw, "jane@example.com\r\n\r\n") && strings.Count(raw, "jane@example.com") != 1 {
|
||||
t.Errorf("message echoes the submitter context beyond the To header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeAcknowledgementHeaderInjection(t *testing.T) {
|
||||
msg, err := composeAcknowledgement(ackForm(), "jane@example.com\r\nBcc: victim@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
raw := string(msg)
|
||||
// The whole string collapses into one To header line: no line starts
|
||||
// with the injected header name.
|
||||
for line := range strings.SplitSeq(raw, "\r\n") {
|
||||
if strings.HasPrefix(line, "Bcc:") {
|
||||
t.Errorf("a CR/LF in the recipient injected a header line %q", line)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(raw, "To: jane@example.com Bcc: victim@example.com\r\n") {
|
||||
t.Errorf("the CR/LF was not neutralised into spaces")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeAcknowledgementSubjectWithoutPrefix(t *testing.T) {
|
||||
form := ackForm()
|
||||
form.SubjectPrefix = new("")
|
||||
msg, err := composeAcknowledgement(form, "jane@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(msg), "Subject: Message received\r\n") {
|
||||
t.Errorf("subject with an empty prefix = %q, want the bare subject", subjectOf(t, string(msg)))
|
||||
}
|
||||
}
|
||||
|
||||
func subjectOf(t *testing.T, raw string) string {
|
||||
t.Helper()
|
||||
for line := range strings.SplitSeq(raw, "\r\n") {
|
||||
if after, ok := strings.CutPrefix(line, "Subject: "); ok {
|
||||
return after
|
||||
}
|
||||
}
|
||||
t.Fatalf("no subject line in message")
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nuntius/internal/config"
|
||||
)
|
||||
|
||||
// SendConfirmation mails the double opt-in link directly to the subscriber.
|
||||
// It uses the form's SMTP identity so replies land at the owner address,
|
||||
// which is set as Reply-To.
|
||||
func (s *FormSender) SendConfirmation(to, link string) error {
|
||||
auth := smtp.PlainAuth("", s.form.SMTP.User, s.form.SMTP.Password, s.form.SMTP.Host)
|
||||
msg := composeConfirmation(s.form.From, to, s.form.To, s.form, link)
|
||||
return sendMail(s.form.SMTP, auth, s.form.From, []string{to}, msg, s.form.SMTP.Timeout(), s.TLSConfig)
|
||||
}
|
||||
|
||||
// composeConfirmation builds a single-part plain-text message. Transactional
|
||||
// confirmations stay deliberately simple: one link, no tracking, no HTML.
|
||||
// The subject prefix and the footer brand come from the form's
|
||||
// configuration; the defaults reproduce the earlier wording.
|
||||
func composeConfirmation(from, to, replyToOwner string, form *config.Form, link string) []byte {
|
||||
subject := "Confirm your subscription"
|
||||
if prefix := form.EmailSubjectPrefix(); prefix != "" {
|
||||
subject = fmt.Sprintf("[%s/%s] %s", prefix, form.Name, subject)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
fmt.Fprintf(&body, "Hi,\r\n\r\n")
|
||||
fmt.Fprintf(&body, "someone signed this address up for the \"%s\" form.\r\n", form.Name)
|
||||
fmt.Fprintf(&body, "If that was you, please confirm the subscription by opening:\r\n\r\n")
|
||||
fmt.Fprintf(&body, " %s\r\n\r\n", link)
|
||||
fmt.Fprintf(&body, "If it was not you, ignore this message and nothing will happen:\r\n")
|
||||
fmt.Fprintf(&body, "the request expires automatically without any action from you.\r\n\r\n")
|
||||
if brand := form.Brand(); brand != "" {
|
||||
fmt.Fprintf(&body, "Delivered by %s\r\n", brand)
|
||||
}
|
||||
|
||||
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(replyToOwner)))
|
||||
msg.WriteString("MIME-Version: 1.0\r\n")
|
||||
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
msg.WriteString("Content-Transfer-Encoding: 8bit\r\n")
|
||||
msg.WriteString("\r\n")
|
||||
msg.Write(body.Bytes())
|
||||
return msg.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package email
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nuntius/internal/config"
|
||||
)
|
||||
|
||||
func TestComposeConfirmation(t *testing.T) {
|
||||
form := &config.Form{Name: "newsletter"}
|
||||
msg := string(composeConfirmation("noreply@example.com", "jane@example.com",
|
||||
"owner@example.com", form,
|
||||
"https://example.com/api/news/confirm?token=abc123"))
|
||||
|
||||
headers := msg[:strings.Index(msg, "\r\n\r\n")]
|
||||
if !strings.Contains(headers, "Subject: [nuntius/newsletter] Confirm your subscription") {
|
||||
t.Errorf("subject missing in:\n%s", headers)
|
||||
}
|
||||
if strings.Count(headers, "Subject:") != 1 {
|
||||
t.Errorf("exactly one Subject header expected:\n%s", headers)
|
||||
}
|
||||
if !strings.Contains(headers, "Reply-To: owner@example.com") {
|
||||
t.Errorf("Reply-To should reach the owner:\n%s", headers)
|
||||
}
|
||||
if !strings.Contains(msg, "https://example.com/api/news/confirm?token=abc123") {
|
||||
t.Error("confirmation link missing from the body")
|
||||
}
|
||||
if !strings.Contains(msg, "Delivered by nuntius\r\n") {
|
||||
t.Error("default brand footer missing from the body")
|
||||
}
|
||||
}
|
||||
|
||||
// A configured prefix and brand replace the default nuntius wording; an
|
||||
// empty prefix drops the bracket segment and an empty brand drops the
|
||||
// footer line entirely.
|
||||
func TestComposeConfirmationPrefixAndBrand(t *testing.T) {
|
||||
form := &config.Form{
|
||||
Name: "news",
|
||||
SubjectPrefix: new("web"),
|
||||
EmailBrand: new("Acme Mail"),
|
||||
}
|
||||
msg := string(composeConfirmation("noreply@example.com", "jane@example.com",
|
||||
"owner@example.com", form, "https://example.com/confirm?token=abc"))
|
||||
if !strings.Contains(msg, "Subject: [web/news] Confirm your subscription") {
|
||||
t.Errorf("configured prefix missing:\n%s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "Delivered by Acme Mail\r\n") {
|
||||
t.Errorf("configured brand missing:\n%s", msg)
|
||||
}
|
||||
|
||||
form.SubjectPrefix = new("")
|
||||
form.EmailBrand = new("")
|
||||
msg = string(composeConfirmation("noreply@example.com", "jane@example.com",
|
||||
"owner@example.com", form, "https://example.com/confirm?token=abc"))
|
||||
if !strings.Contains(msg, "Subject: Confirm your subscription\r\n") {
|
||||
t.Errorf("empty prefix must drop the bracket segment:\n%s", msg)
|
||||
}
|
||||
if strings.Contains(msg, "Delivered by") {
|
||||
t.Errorf("empty brand must drop the footer:\n%s", msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package email
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nuntius/internal/config"
|
||||
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
|
||||
)
|
||||
|
||||
// mustCompose wraps compose for tests: a composition failure is always a
|
||||
// bug, not a case worth branching on. The bare form carries no policy
|
||||
// keys, so the composition defaults apply.
|
||||
func mustCompose(t *testing.T, from, to string, req contactform.Request, formName, formType string) []byte {
|
||||
t.Helper()
|
||||
b, err := compose(from, to, req, &config.Form{Name: formName, Type: formType})
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestComposeContactWithoutService(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
Message: "I have a question about your services.",
|
||||
}
|
||||
b := mustCompose(t, "from@example.com", "to@example.com", req, "MyForm", "contact")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Subject: [nuntius/MyForm] Contact form submission") {
|
||||
t.Errorf("expected subject with form name only, got body:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "From: from@example.com") {
|
||||
t.Error("expected From header")
|
||||
}
|
||||
if !strings.Contains(s, "To: to@example.com") {
|
||||
t.Error("expected To header")
|
||||
}
|
||||
if !strings.Contains(s, "Reply-To: alice@example.com") {
|
||||
t.Error("expected Reply-To header")
|
||||
}
|
||||
if !strings.Contains(s, "MIME-Version: 1.0") {
|
||||
t.Error("expected MIME-Version header")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/plain; charset=UTF-8") {
|
||||
t.Error("expected text/plain part")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/html; charset=UTF-8") {
|
||||
t.Error("expected text/html part")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: multipart/alternative") {
|
||||
t.Error("expected multipart/alternative content type")
|
||||
}
|
||||
if !strings.Contains(s, "I have a question about your services.") {
|
||||
t.Error("expected message content in body")
|
||||
}
|
||||
if !strings.Contains(s, "Contact Form Submission:") {
|
||||
t.Error("expected contact form plain-text header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeContactWithService(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Bob",
|
||||
Email: "bob@example.com",
|
||||
Service: "architecture",
|
||||
Message: "I would like a consultation.",
|
||||
}
|
||||
b := mustCompose(t, "from@e.com", "to@e.com", req, "ContactForm", "contact")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Subject: [nuntius/ContactForm][architecture] Contact form submission") {
|
||||
t.Errorf("expected subject with service tag, got body:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "Service interest: architecture") {
|
||||
t.Error("expected service interest in plain text")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||
t.Error("expected text/plain part")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/html") {
|
||||
t.Error("expected text/html part")
|
||||
}
|
||||
if !strings.Contains(s, "bob@example.com") {
|
||||
t.Error("expected submitter email in body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeFeedback(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Carol",
|
||||
Email: "carol@example.com",
|
||||
Message: "Great platform, but can you add dark mode?",
|
||||
}
|
||||
b := mustCompose(t, "sender@h.com", "recv@h.com", req, "FeedbackForm", "feedback")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Subject: [nuntius/FeedbackForm] New feedback") {
|
||||
t.Errorf("expected feedback subject, got body:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "New Feedback:") {
|
||||
t.Error("expected feedback plain-text header")
|
||||
}
|
||||
if !strings.Contains(s, "From: Carol <carol@example.com>") {
|
||||
t.Error("expected From line in plain text")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||
t.Error("expected text/plain part")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/html") {
|
||||
t.Error("expected text/html part")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeNewsletter(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Email: "subscriber@example.com",
|
||||
}
|
||||
b := mustCompose(t, "news@h.com", "owner@h.com", req, "NewsletterSignup", "newsletter")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Subject: [nuntius/NewsletterSignup] New newsletter subscriber") {
|
||||
t.Errorf("expected newsletter subject, got body:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "New Newsletter Subscriber:") {
|
||||
t.Error("expected newsletter plain-text header")
|
||||
}
|
||||
if !strings.Contains(s, "Email: subscriber@example.com") {
|
||||
t.Error("expected subscriber email in plain text")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||
t.Error("expected text/plain part")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/html") {
|
||||
t.Error("expected text/html part")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeGeneric(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Dave",
|
||||
Email: "dave@example.com",
|
||||
Message: "Generic inquiry.",
|
||||
}
|
||||
b := mustCompose(t, "g@h.com", "g@h.com", req, "GenericForm", "generic")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Subject: [nuntius/GenericForm] New submission") {
|
||||
t.Errorf("expected generic subject, got body:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "New Submission:") {
|
||||
t.Error("expected generic plain-text header")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||
t.Error("expected text/plain part")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/html") {
|
||||
t.Error("expected text/html part")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeEmptyFormNameAndEmail(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "",
|
||||
Email: "",
|
||||
Message: "",
|
||||
}
|
||||
b := mustCompose(t, "", "", req, "", "generic")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Subject: [nuntius/] New submission") {
|
||||
t.Errorf("expected subject with empty form name, got body:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "MIME-Version: 1.0") {
|
||||
t.Error("expected MIME-Version header even with empty fields")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||
t.Error("expected text/plain part even with empty fields")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/html") {
|
||||
t.Error("expected text/html part even with empty fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeLongMessage(t *testing.T) {
|
||||
longMsg := strings.Repeat("Lorem ipsum dolor sit amet. ", 200)
|
||||
req := contactform.Request{
|
||||
Name: "Eve",
|
||||
Email: "eve@example.com",
|
||||
Message: longMsg,
|
||||
}
|
||||
b := mustCompose(t, "x@y.com", "z@y.com", req, "LongForm", "feedback")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, longMsg) {
|
||||
t.Error("expected long message content in body")
|
||||
}
|
||||
// Verify it's in both text and html parts by checking after the respective
|
||||
// content-type boundaries.
|
||||
textIdx := strings.Index(s, "Content-Type: text/plain")
|
||||
htmlIdx := strings.Index(s, "Content-Type: text/html")
|
||||
if textIdx == -1 || htmlIdx == -1 {
|
||||
t.Fatal("expected both text/plain and text/html parts")
|
||||
}
|
||||
textPart := s[textIdx:htmlIdx]
|
||||
htmlPart := s[htmlIdx:]
|
||||
if !strings.Contains(textPart, longMsg) {
|
||||
t.Error("expected long message in text/plain part")
|
||||
}
|
||||
if !strings.Contains(htmlPart, longMsg) {
|
||||
t.Error("expected long message in text/html part")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeSpecialCharacters(t *testing.T) {
|
||||
specialMsg := "Café résumé, déjà vu\nLine\twith\ttabs\n€uro sign © 2026"
|
||||
req := contactform.Request{
|
||||
Name: "Renée",
|
||||
Email: "renée@example.com",
|
||||
Message: specialMsg,
|
||||
}
|
||||
b := mustCompose(t, "ñ@c.com", "ö@c.com", req, "SpaForm", "contact")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Café résumé, déjà vu") {
|
||||
t.Error("expected accented characters to survive round-trip")
|
||||
}
|
||||
if !strings.Contains(s, "€uro sign © 2026") {
|
||||
t.Error("expected special symbols to survive round-trip")
|
||||
}
|
||||
if !strings.Contains(s, "Renée") {
|
||||
t.Error("expected accented name in body")
|
||||
}
|
||||
if !strings.Contains(s, "MIME-Version: 1.0") {
|
||||
t.Error("expected MIME-Version header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeUnknownFormTypeFallsBackToContact(t *testing.T) {
|
||||
// Unknown form type should default to the contact template.
|
||||
req := contactform.Request{
|
||||
Name: "Fallback",
|
||||
Email: "fallback@example.com",
|
||||
Message: "Does this work?",
|
||||
}
|
||||
b := mustCompose(t, "a@b.com", "c@b.com", req, "UnknownForm", "nonexistent")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Subject: [nuntius/UnknownForm] Contact form submission") {
|
||||
t.Errorf("expected contact fallback subject, got body:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||
t.Error("expected text/plain part in fallback")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/html") {
|
||||
t.Error("expected text/html part in fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeAllHeadersPresent(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Test",
|
||||
Email: "test@example.com",
|
||||
Message: "Checking headers.",
|
||||
}
|
||||
b := mustCompose(t, "from@x.com", "to@x.com", req, "HeaderForm", "contact")
|
||||
s := string(b)
|
||||
|
||||
required := []string{
|
||||
"From: from@x.com",
|
||||
"To: to@x.com",
|
||||
"Subject:",
|
||||
"Date:",
|
||||
"Reply-To: test@example.com",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: multipart/alternative",
|
||||
}
|
||||
for _, h := range required {
|
||||
if !strings.Contains(s, h) {
|
||||
t.Errorf("expected header %q in message", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeMultipartBoundary(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Boundary",
|
||||
Email: "boundary@example.com",
|
||||
Message: "Boundary test.",
|
||||
}
|
||||
b := mustCompose(t, "from@t.com", "to@t.com", req, "BoundForm", "contact")
|
||||
s := string(b)
|
||||
|
||||
// The boundary is random per message; verify structure, not a fixed value.
|
||||
if !strings.Contains(s, "Content-Type: multipart/alternative; boundary=nuntius-") {
|
||||
t.Error("expected multipart/alternative with nuntius- prefixed boundary")
|
||||
}
|
||||
// Extract the boundary token and verify opening, middle and closing markers.
|
||||
idx := strings.Index(s, "boundary=nuntius-")
|
||||
if idx == -1 {
|
||||
t.Fatal("boundary token not found")
|
||||
}
|
||||
boundary := s[idx+len("boundary="):]
|
||||
if end := strings.IndexByte(boundary, '\r'); end >= 0 {
|
||||
boundary = boundary[:end]
|
||||
}
|
||||
if count := strings.Count(s, "--"+boundary); count < 3 {
|
||||
t.Errorf("expected at least 3 boundary markers for %q, got %d", boundary, count)
|
||||
}
|
||||
if !strings.Contains(s, "--"+boundary+"--") {
|
||||
t.Error("expected closing boundary marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDeliveredByFooter(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Footer",
|
||||
Email: "footer@example.com",
|
||||
Message: "Footer check.",
|
||||
}
|
||||
for _, ft := range []string{"contact", "feedback", "newsletter", "generic"} {
|
||||
b := mustCompose(t, "f@t.com", "t@t.com", req, "FooterForm", ft)
|
||||
s := string(b)
|
||||
if !strings.Contains(s, "Delivered by nuntius") {
|
||||
t.Errorf("form type %q: expected 'Delivered by nuntius' footer in plain text", ft)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A configured subject prefix and brand replace the default nuntius
|
||||
// wording in both the plain-text and the HTML part.
|
||||
func TestComposePrefixAndBrand(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
Message: "A custom branding check.",
|
||||
}
|
||||
form := &config.Form{
|
||||
Name: "MyForm",
|
||||
Type: "feedback",
|
||||
SubjectPrefix: new("web"),
|
||||
EmailBrand: new("Acme Mail"),
|
||||
}
|
||||
b, err := compose("from@example.com", "to@example.com", req, form)
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
s := string(b)
|
||||
if !strings.Contains(s, "Subject: [web/MyForm] New feedback") {
|
||||
t.Errorf("configured prefix missing from subject:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "Delivered by Acme Mail") {
|
||||
t.Error("configured brand missing from the plain-text footer")
|
||||
}
|
||||
if !strings.Contains(s, "Delivered by <strong style=\"color:#6b7280\">Acme Mail</strong>") {
|
||||
t.Error("configured brand missing from the HTML footer")
|
||||
}
|
||||
|
||||
// An empty prefix and brand drop the segments entirely.
|
||||
form.SubjectPrefix = new("")
|
||||
form.EmailBrand = new("")
|
||||
b, err = compose("from@example.com", "to@example.com", req, form)
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
s = string(b)
|
||||
if !strings.Contains(s, "Subject: New feedback\r\n") {
|
||||
t.Errorf("empty prefix must drop the bracket segment:\n%s", s)
|
||||
}
|
||||
if strings.Contains(s, "Delivered by") {
|
||||
t.Errorf("empty brand must drop the footer:\n%s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// A service value configured onto a non-contact form surfaces in the
|
||||
// subject tag, the plain-text body and the HTML body.
|
||||
func TestComposeServiceOnFeedback(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
Service: "bug",
|
||||
Message: "A service-aware feedback.",
|
||||
}
|
||||
form := &config.Form{
|
||||
Name: "MyForm",
|
||||
Type: "feedback",
|
||||
Services: []string{"bug", "idea"},
|
||||
}
|
||||
b, err := compose("from@example.com", "to@example.com", req, form)
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
s := string(b)
|
||||
if !strings.Contains(s, "Subject: [nuntius/MyForm][bug] New feedback") {
|
||||
t.Errorf("service tag missing from subject:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "Service interest: bug") {
|
||||
t.Error("service line missing from the plain-text body")
|
||||
}
|
||||
if !strings.Contains(s, "Service Interest") {
|
||||
t.Error("service block missing from the HTML body")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SMTP delivery tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func selfSignedCert(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}
|
||||
}
|
||||
|
||||
// startFakeSMTP runs a minimal SMTP server that supports STARTTLS and AUTH.
|
||||
// It returns the listen address.
|
||||
func startFakeSMTP(t *testing.T, cert tls.Certificate) string {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
fmt.Fprintf(conn, "220 fake ESMTP\r\n")
|
||||
reader := bufio.NewReader(conn)
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"):
|
||||
fmt.Fprintf(conn, "250-fake\r\n250-STARTTLS\r\n250 AUTH PLAIN\r\n")
|
||||
case strings.HasPrefix(line, "STARTTLS"):
|
||||
fmt.Fprintf(conn, "220 Ready to start TLS\r\n")
|
||||
tlsConn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return
|
||||
}
|
||||
conn = tlsConn
|
||||
reader = bufio.NewReader(conn)
|
||||
case strings.HasPrefix(line, "AUTH"):
|
||||
fmt.Fprintf(conn, "235 Authentication successful\r\n")
|
||||
case strings.HasPrefix(line, "MAIL FROM:"), strings.HasPrefix(line, "RCPT TO:"):
|
||||
fmt.Fprintf(conn, "250 OK\r\n")
|
||||
case strings.HasPrefix(line, "DATA"):
|
||||
fmt.Fprintf(conn, "354 End data with <CR><LF>.<CR><LF>\r\n")
|
||||
for {
|
||||
dataLine, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(dataLine) == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(conn, "250 OK\r\n")
|
||||
case strings.HasPrefix(line, "QUIT"):
|
||||
fmt.Fprintf(conn, "221 Bye\r\n")
|
||||
return
|
||||
default:
|
||||
fmt.Fprintf(conn, "250 OK\r\n")
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
// A validated email may still contain characters that would terminate a
|
||||
// header line; composition must neutralise them defensively in the header
|
||||
// block. Body content is free-form and delimited by the random boundary.
|
||||
func TestComposeSanitisesHeaderInjection(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Eve",
|
||||
Email: "eve@example.com",
|
||||
Message: "Hello there, this is fine.",
|
||||
}
|
||||
b := mustCompose(t, "from@example.com", "to@example.com", req,
|
||||
"Form\r\nBcc: victim@example.com", "contact")
|
||||
s := string(b)
|
||||
|
||||
headers := s[:strings.Index(s, "\r\n\r\n")]
|
||||
for line := range strings.SplitSeq(headers, "\r\n") {
|
||||
if strings.HasPrefix(line, "Bcc:") {
|
||||
t.Errorf("injected Bcc header survived composition:\n%s", s)
|
||||
}
|
||||
}
|
||||
if n := strings.Count(headers, "Subject:"); n != 1 {
|
||||
t.Errorf("expected exactly one Subject header in block %q, got %d", headers, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomBoundary(t *testing.T) {
|
||||
b1, err := randomBoundary()
|
||||
if err != nil {
|
||||
t.Fatalf("randomBoundary: %v", err)
|
||||
}
|
||||
b2, err := randomBoundary()
|
||||
if err != nil {
|
||||
t.Fatalf("randomBoundary: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(b1, "nuntius-") {
|
||||
t.Errorf("boundary %q missing nuntius- prefix", b1)
|
||||
}
|
||||
if b1 == b2 {
|
||||
t.Error("two consecutive boundaries should differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMailDialError(t *testing.T) {
|
||||
// Connect to a closed port to trigger a dial error quickly.
|
||||
err := sendMail(config.SMTPConfig{Host: "127.0.0.1", Port: 1}, nil, "a@b.c", []string{"d@e.f"}, []byte("x"), 100*time.Millisecond, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected dial error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMailWithTLS(t *testing.T) {
|
||||
cert := selfSignedCert(t)
|
||||
addr := startFakeSMTP(t, cert)
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("split host: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatalf("parse port: %v", err)
|
||||
}
|
||||
|
||||
auth := smtp.PlainAuth("", "user", "pass", host)
|
||||
msg := mustCompose(t, "from@example.com", "to@example.com", contactform.Request{
|
||||
Name: "Test User",
|
||||
Email: "test@example.com",
|
||||
Message: "Hello, this is a test message.",
|
||||
}, "test", "contact")
|
||||
|
||||
err = sendMail(config.SMTPConfig{Host: host, Port: port}, auth, "from@example.com", []string{"to@example.com"}, msg, 5*time.Second, &tls.Config{InsecureSkipVerify: true})
|
||||
if err != nil {
|
||||
t.Fatalf("sendMail: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormSenderSend(t *testing.T) {
|
||||
cert := selfSignedCert(t)
|
||||
addr := startFakeSMTP(t, cert)
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("split host: %v", err)
|
||||
}
|
||||
port := 0
|
||||
for _, c := range portStr {
|
||||
port = port*10 + int(c-'0')
|
||||
}
|
||||
|
||||
form := &config.Form{
|
||||
Name: "test",
|
||||
Type: "contact",
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
SMTP: config.SMTPConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
User: "user",
|
||||
Password: "pass",
|
||||
},
|
||||
}
|
||||
s := NewFormSender(form)
|
||||
s.TLSConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
|
||||
err = s.Send(contactform.Request{
|
||||
Name: "Test User",
|
||||
Email: "test@example.com",
|
||||
Message: "Hello, this is a test message.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user