feat: Marketing site
Server Deploy / deploy (push) Successful in 5m51s

This commit is contained in:
2026-07-22 16:50:13 +01:00
parent 7a3b8cb700
commit 693d59a3e2
51 changed files with 10656 additions and 12 deletions
+174
View File
@@ -0,0 +1,174 @@
package mail
import (
"crypto/tls"
"fmt"
"mime"
"net"
"net/smtp"
"os"
"strings"
"time"
)
const timeout = 15 * time.Second
// Config is read once at boot. When Host is empty the service still accepts and
// stores submissions; it just does not email them.
type Config struct {
Host string
Port string
Username string
Password string
From string
To string
}
func FromEnv() Config {
return Config{
Host: os.Getenv("SMTP_HOST"),
Port: envOr("SMTP_PORT", "587"),
Username: os.Getenv("SMTP_USERNAME"),
Password: os.Getenv("SMTP_PASSWORD"),
From: os.Getenv("SMTP_FROM"),
To: envOr("SMTP_TO", "support@hostxtra.co.uk"),
}
}
func (c Config) Enabled() bool {
return c.Host != "" && c.From != "" && c.To != ""
}
// Port 465 uses implicit TLS; any other port starts plain and upgrades with
// STARTTLS when the server advertises it. Dial and connection deadlines keep an
// unreachable host from hanging the caller until the OS TCP timeout.
// Send delivers a plain-text message. replyTo, when set, becomes the Reply-To
// header so hitting reply in a mail client answers the person who filled in the
// form rather than the service's own sending address. The envelope sender stays
// as From, so a submitted address can never affect SPF or DMARC alignment.
func (c Config) Send(subject, body, replyTo string) error {
return c.sendTo(c.To, subject, body, replyTo)
}
// sendTo delivers to an explicit recipient. Contact enquiries go to the support
// inbox (c.To); verification links go to the person signing up.
func (c Config) sendTo(to, subject, body, replyTo string) error {
if !c.Enabled() {
return fmt.Errorf("smtp: not configured")
}
if to == "" {
return fmt.Errorf("smtp: no recipient")
}
addr := net.JoinHostPort(c.Host, c.Port)
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return fmt.Errorf("smtp: dial %s: %w", addr, err)
}
_ = conn.SetDeadline(time.Now().Add(timeout))
if c.Port == "465" {
conn = tls.Client(conn, &tls.Config{ServerName: c.Host})
}
client, err := smtp.NewClient(conn, c.Host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp: client: %w", err)
}
defer client.Close()
if c.Port != "465" {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: c.Host}); err != nil {
return fmt.Errorf("smtp: starttls: %w", err)
}
}
}
if c.Username != "" {
if err := client.Auth(smtp.PlainAuth("", c.Username, c.Password, c.Host)); err != nil {
return fmt.Errorf("smtp: auth: %w", err)
}
}
if err := client.Mail(c.From); err != nil {
return fmt.Errorf("smtp: mail from: %w", err)
}
for _, rcpt := range recipients(to) {
if err := client.Rcpt(rcpt); err != nil {
return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)
}
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp: data: %w", err)
}
if _, err := w.Write(message(c.From, to, subject, body, replyTo)); err != nil {
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 recipients(to string) []string {
parts := strings.Split(to, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// message builds the MIME body. The subject is encoded rather than interpolated
// raw, and headers are stripped of CR/LF so submitted content cannot inject
// extra headers.
func message(from, to, subject, body, replyTo string) []byte {
var b strings.Builder
b.WriteString("From: " + sanitizeHeader(from) + "\r\n")
b.WriteString("To: " + sanitizeHeader(to) + "\r\n")
if replyTo != "" {
b.WriteString("Reply-To: " + sanitizeHeader(replyTo) + "\r\n")
}
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", sanitizeHeader(subject)) + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("\r\n")
b.WriteString(body)
return []byte(b.String())
}
func sanitizeHeader(v string) string {
return strings.NewReplacer("\r", " ", "\n", " ").Replace(v)
}
// SendVerification emails the one-time link that completes a signup. The
// address is the person signing up, not the support inbox, so To is overridden
// for this one message.
func (c Config) SendVerification(to, orgName, link string, ttl time.Duration) error {
body := fmt.Sprintf(`Confirm your email to finish creating %s on Vantage.
Open this link:
%s
The link works once and expires in %d hours. Until you use it, no account
exists — nothing has been created and the address is not registered.
If you did not request this, ignore this email and nothing will happen.
`, orgName, link, int(ttl.Hours()))
return c.sendTo(to, "Confirm your Vantage organisation", body, "")
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}