// Copyright (c) 2026 Petr BalvĂ­n (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(`
{{if .Brand}} {{end}}

Contact Form Submission

{{.FormName}}

{{if .Service}} {{end}}
From
{{.Name}} {{.Email}}
Service Interest
{{.Service}}
Received
{{.Received}}

Message

{{.Message}}

Delivered by {{.Brand}}, a contact form backend for Linux servers.

`)) // emailTemplateFeedback is the HTML body template for feedback-type forms. var emailTemplateFeedback = template.Must(template.New("feedback").Parse(`

New Feedback

{{.FormName}}

From

{{.Name}} {{.Email}}

{{if .Service}}

Service Interest

{{.Service}}
{{end}}

Feedback

{{.Message}}
{{if .Brand}}

Delivered by {{.Brand}}

{{end}}
`)) // emailTemplateNewsletter is the HTML body template for newsletter-signup forms. var emailTemplateNewsletter = template.Must(template.New("newsletter").Parse(`

{{.FormName}}: new subscriber

Email

{{.Email}}

{{if .Brand}}

Delivered by {{.Brand}}

{{end}}
`)) // emailTemplateGeneric is the HTML body template for generic forms. var emailTemplateGeneric = template.Must(template.New("generic").Parse(`

Form Submission

{{.FormName}}

From

{{.Name}} {{.Email}}

{{if .Service}}

Service Interest

{{.Service}}
{{end}}

Message

{{.Message}}
{{if .Brand}}

Delivered by {{.Brand}}

{{end}}
`)) // 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, "

Email generation error: %v

", 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 "[/
]" segment when a prefix is set, // plus the "[]" 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) }