Add Technical Kiwi website with Go, templ, and HTMX.
Single-page site with gallery by album and event, contact form over SMTP, Docker dev/prod setup, and on-server image derivatives. Gallery photos stay local (app/images/ is gitignored). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
71
app/internal/mail/config.go
Normal file
71
app/internal/mail/config.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Config holds SMTP relay settings from the environment.
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
From string
|
||||
To string
|
||||
// TLS: "auto" (STARTTLS on 587, plain on 25), "tls" (implicit TLS, e.g. 465), "plain"
|
||||
TLS string
|
||||
}
|
||||
|
||||
// Load reads SMTP settings from environment variables.
|
||||
//
|
||||
// Required: SMTP_HOST, SMTP_FROM, SMTP_TO
|
||||
// Optional: SMTP_PORT (587), SMTP_USER, SMTP_PASSWORD, SMTP_TLS (auto)
|
||||
func Load() (Config, error) {
|
||||
host := os.Getenv("SMTP_HOST")
|
||||
if host == "" {
|
||||
return Config{}, fmt.Errorf("SMTP_HOST is required")
|
||||
}
|
||||
|
||||
from := os.Getenv("SMTP_FROM")
|
||||
if from == "" {
|
||||
return Config{}, fmt.Errorf("SMTP_FROM is required")
|
||||
}
|
||||
|
||||
to := os.Getenv("SMTP_TO")
|
||||
if to == "" {
|
||||
return Config{}, fmt.Errorf("SMTP_TO is required")
|
||||
}
|
||||
|
||||
port := 587
|
||||
if p := os.Getenv("SMTP_PORT"); p != "" {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("SMTP_PORT: %w", err)
|
||||
}
|
||||
port = n
|
||||
}
|
||||
|
||||
tls := os.Getenv("SMTP_TLS")
|
||||
if tls == "" {
|
||||
tls = "auto"
|
||||
}
|
||||
|
||||
return Config{
|
||||
Host: host,
|
||||
Port: port,
|
||||
User: os.Getenv("SMTP_USER"),
|
||||
Password: os.Getenv("SMTP_PASSWORD"),
|
||||
From: from,
|
||||
To: to,
|
||||
TLS: tls,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Enabled reports whether relay settings are present.
|
||||
func Enabled() bool {
|
||||
return os.Getenv("SMTP_HOST") != "" &&
|
||||
os.Getenv("SMTP_FROM") != "" &&
|
||||
os.Getenv("SMTP_TO") != ""
|
||||
}
|
||||
163
app/internal/mail/send.go
Normal file
163
app/internal/mail/send.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ContactEmail sends a contact form message via the configured SMTP relay.
|
||||
func (c Config) ContactEmail(name, replyTo, message string) error {
|
||||
subject := fmt.Sprintf("Contact form: %s", name)
|
||||
var body bytes.Buffer
|
||||
body.WriteString(fmt.Sprintf("Name: %s\n", name))
|
||||
body.WriteString(fmt.Sprintf("Email: %s\n\n", replyTo))
|
||||
body.WriteString(message)
|
||||
|
||||
return c.send(subject, body.String(), replyTo)
|
||||
}
|
||||
|
||||
func (c Config) send(subject, body, replyTo string) error {
|
||||
addr := fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||
|
||||
var msg bytes.Buffer
|
||||
msg.WriteString(fmt.Sprintf("From: %s\r\n", c.From))
|
||||
msg.WriteString(fmt.Sprintf("To: %s\r\n", c.To))
|
||||
if replyTo != "" {
|
||||
msg.WriteString(fmt.Sprintf("Reply-To: %s\r\n", replyTo))
|
||||
}
|
||||
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
||||
msg.WriteString("MIME-Version: 1.0\r\n")
|
||||
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
msg.WriteString("\r\n")
|
||||
msg.WriteString(body)
|
||||
|
||||
raw := msg.Bytes()
|
||||
from := extractAddr(c.From)
|
||||
|
||||
var auth smtp.Auth
|
||||
if c.User != "" {
|
||||
auth = smtp.PlainAuth("", c.User, c.Password, c.Host)
|
||||
}
|
||||
|
||||
switch c.tlsMode() {
|
||||
case "plain":
|
||||
return smtp.SendMail(addr, auth, from, []string{c.To}, raw)
|
||||
case "tls":
|
||||
return c.sendTLS(addr, auth, from, raw)
|
||||
default:
|
||||
return c.sendStartTLS(addr, auth, from, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) tlsMode() string {
|
||||
switch strings.ToLower(c.TLS) {
|
||||
case "plain", "tls":
|
||||
return strings.ToLower(c.TLS)
|
||||
}
|
||||
if c.Port == 465 {
|
||||
return "tls"
|
||||
}
|
||||
if c.Port == 25 {
|
||||
return "plain"
|
||||
}
|
||||
return "starttls"
|
||||
}
|
||||
|
||||
func (c Config) sendStartTLS(addr string, auth smtp.Auth, from string, raw []byte) error {
|
||||
conn, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, c.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: c.Host}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if auth != nil {
|
||||
if ok, _ := client.Extension("AUTH"); ok {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(c.To); err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func (c Config) sendTLS(addr string, auth smtp.Auth, from string, raw []byte) error {
|
||||
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: c.Host})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, c.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if auth != nil {
|
||||
if ok, _ := client.Extension("AUTH"); ok {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(c.To); err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func extractAddr(from string) string {
|
||||
if i := strings.Index(from, "<"); i >= 0 {
|
||||
if j := strings.Index(from[i:], ">"); j > 0 {
|
||||
return strings.TrimSpace(from[i+1 : i+j])
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(from)
|
||||
}
|
||||
Reference in New Issue
Block a user