Files
zoe-shaldon/server/internal/mail/mail.go
T
warkanum b7e88099c8 feat(ui): add RSVP attendance option and admin features
* Implement attendance selection in RSVP form
* Add admin endpoints for managing RSVPs and guests
* Update RSVP handling to include attendance status
2026-09-06 16:02:52 +02:00

145 lines
4.0 KiB
Go

package mail
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"net/smtp"
"strings"
"time"
"wedding-server/internal/config"
"wedding-server/internal/store"
)
type Mailer struct {
cfg config.EmailConfig
siteURL string
}
func New(cfg config.EmailConfig, siteURL string) *Mailer {
return &Mailer{cfg: cfg, siteURL: strings.TrimSuffix(siteURL, "/")}
}
// absoluteURL turns a site-relative path (e.g. "/uploads/1/foo.jpg") into a
// full URL so it's clickable straight from an email client.
func (m *Mailer) absoluteURL(path string) string {
if m.siteURL == "" {
return path
}
return m.siteURL + path
}
// header prefixes every notification with a one-line explainer of where it
// came from and what triggered it, so it's never a mystery in an inbox.
func (m *Mailer) header(event string) string {
site := m.siteURL
if site == "" {
site = "the wedding site"
}
return fmt.Sprintf("This is an automated email that gets triggered for %s when %s.\n\n", site, event)
}
// newMessageID builds an RFC 5322 Message-ID, scoped to the sender's own
// domain so it satisfies mail servers that reject messages lacking one.
func newMessageID(from string) string {
domain := "localhost"
if i := strings.LastIndex(from, "@"); i != -1 {
domain = strings.TrimSuffix(from[i+1:], ">")
}
var buf [16]byte
_, _ = rand.Read(buf[:])
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), hex.EncodeToString(buf[:]), domain)
}
func (m *Mailer) send(subject, body string) error {
if m.cfg.SMTPHost == "" {
log.Printf("mail: SMTP not configured, skipping email %q", subject)
return nil
}
addr := fmt.Sprintf("%s:%d", m.cfg.SMTPHost, m.cfg.SMTPPort)
auth := smtp.PlainAuth("", m.cfg.SMTPUser, m.cfg.SMTPPass, m.cfg.SMTPHost)
msg := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMessage-ID: %s\r\nDate: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
m.cfg.From, m.cfg.To, subject, newMessageID(m.cfg.From), time.Now().Format(time.RFC1123Z), body,
)
recipients := []string{m.cfg.To}
for bcc := range strings.SplitSeq(m.cfg.Bcc, ",") {
if bcc = strings.TrimSpace(bcc); bcc != "" {
recipients = append(recipients, bcc)
}
}
return smtp.SendMail(addr, auth, m.cfg.From, recipients, []byte(msg))
}
func (m *Mailer) SendRSVP(r store.RSVP, allGuests []store.Guest) error {
names := make([]string, len(r.Guests))
adults, children := 0, 0
for i, g := range r.Guests {
if g.IsChild {
names[i] = g.Name + " (child)"
children++
} else {
names[i] = g.Name
adults++
}
}
joined := strings.Join(names, ", ")
var b strings.Builder
if r.Attending {
fmt.Fprintf(&b, "New RSVP (ATTENDING) for %d guest(s) — %d adult(s), %d child(ren): %s\n", len(r.Guests), adults, children, joined)
} else {
fmt.Fprintf(&b, "New RSVP (NOT ATTENDING) — %s\n", joined)
}
if r.Message != "" {
fmt.Fprintf(&b, "\nMessage:\n%s\n", r.Message)
}
if len(allGuests) > 0 {
totalAdults, totalChildren := 0, 0
fmt.Fprintf(&b, "\nRSVPs so far (%d guest(s)):\n", len(allGuests))
for _, g := range allGuests {
if g.IsChild {
totalChildren++
fmt.Fprintf(&b, "- %s (child)\n", g.Name)
} else {
totalAdults++
fmt.Fprintf(&b, "- %s\n", g.Name)
}
}
fmt.Fprintf(&b, "\n%d adult(s), %d child(ren) total\n", totalAdults, totalChildren)
}
subjectState := "attending"
if !r.Attending {
subjectState = "not attending"
}
return m.send(fmt.Sprintf("RSVP from %s (%s)", joined, subjectState), m.header("an RSVP is made")+b.String())
}
func (m *Mailer) SendPhotoUpload(u store.PhotoUpload, files []store.PhotoFile) error {
var b strings.Builder
fmt.Fprintf(&b, "%s uploaded %d photo(s)\n\n", u.Name, len(files))
if u.Email != "" {
fmt.Fprintf(&b, "Email: %s\n", u.Email)
}
if u.Phone != "" {
fmt.Fprintf(&b, "Phone: %s\n", u.Phone)
}
if u.Message != "" {
fmt.Fprintf(&b, "\nMessage:\n%s\n", u.Message)
}
b.WriteString("\nPhotos:\n")
for _, f := range files {
fmt.Fprintf(&b, "- %s\n", m.absoluteURL(f.URL))
}
return m.send(fmt.Sprintf("%s uploaded photos", u.Name), m.header("photos are uploaded")+b.String())
}