package notification import ( "context" "crypto/rand" "crypto/tls" "encoding/hex" "errors" "fmt" "net" "net/smtp" "strconv" "strings" "time" ) // SMTPConfig describes one SMTP relay. Username/Password are optional — // local relays like Mailpit accept anonymous SMTP. TLS modes: // // - "starttls" upgrade after EHLO (most relays, default) // - "implicit" TLS handshake before SMTP (port 465) // - "none" plain socket — only acceptable on a trusted LAN type SMTPConfig struct { Host string Port int Username string Password string FromEmail string FromName string TLS string } // SMTPEmailSender implements auth.EmailSender (verification/reset) AND // GuestEmailDispatcher (invitation/confirmation/reminder) on top of any // SMTP relay. Used for Mailpit in dev; works against Gmail, Fastmail, etc. // in production if the user prefers plain SMTP over an HTTP API. type SMTPEmailSender struct { cfg SMTPConfig tpls *Templates from string } func NewSMTPEmailSender(cfg SMTPConfig, tpls *Templates) (*SMTPEmailSender, error) { if cfg.Host == "" { return nil, errors.New("smtp: Host required") } if cfg.Port <= 0 { cfg.Port = 587 } if cfg.FromEmail == "" { return nil, errors.New("smtp: FromEmail required") } if cfg.TLS == "" { cfg.TLS = "starttls" } from := cfg.FromEmail if cfg.FromName != "" { from = fmt.Sprintf("%s <%s>", cfg.FromName, cfg.FromEmail) } return &SMTPEmailSender{cfg: cfg, tpls: tpls, from: from}, nil } // --- auth.EmailSender --- func (s *SMTPEmailSender) SendVerification(ctx context.Context, to, name, link string) error { return s.sendTemplated(ctx, to, "Verify your GuestGuard email", TmplVerification, map[string]any{"Name": name, "Link": link}) } func (s *SMTPEmailSender) SendPasswordReset(ctx context.Context, to, name, link string) error { return s.sendTemplated(ctx, to, "Reset your GuestGuard password", TmplPasswordReset, map[string]any{"Name": name, "Link": link, "ExpiryHumane": "1 hour"}) } func (s *SMTPEmailSender) SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error { return s.sendTemplated(ctx, to, inviterName+" invited you to "+eventName, TmplCollaboratorInvite, map[string]any{ "InviterName": inviterName, "EventName": eventName, "Role": role, "Link": link, }) } // --- GuestEmailDispatcher --- func (s *SMTPEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) { return s.sendTemplatedReturnID(ctx, to, subject, name, data) } // --- internals --- func (s *SMTPEmailSender) sendTemplated(ctx context.Context, to, subject string, name TemplateName, data map[string]any) error { _, err := s.sendTemplatedReturnID(ctx, to, subject, name, data) return err } func (s *SMTPEmailSender) sendTemplatedReturnID(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) { if data == nil { data = map[string]any{} } data["Subject"] = subject html, text, err := s.tpls.Render(name, data) if err != nil { return "", err } msgID := generateMessageID(s.cfg.FromEmail) body := buildMIMEMessage(mimeMessage{ MessageID: msgID, From: s.from, To: to, Subject: subject, Text: text, HTML: html, }) if err := s.dial(ctx, []string{to}, body); err != nil { return "", err } return msgID, nil } func (s *SMTPEmailSender) dial(ctx context.Context, to []string, body []byte) error { addr := net.JoinHostPort(s.cfg.Host, strconv.Itoa(s.cfg.Port)) d := &net.Dialer{Timeout: 10 * time.Second} deadline, ok := ctx.Deadline() if ok { d.Deadline = deadline } var ( conn net.Conn err error ) switch strings.ToLower(s.cfg.TLS) { case "implicit": conn, err = tls.DialWithDialer(d, "tcp", addr, &tls.Config{ServerName: s.cfg.Host}) default: conn, err = d.DialContext(ctx, "tcp", addr) } if err != nil { return fmt.Errorf("smtp: dial: %w", err) } c, err := smtp.NewClient(conn, s.cfg.Host) if err != nil { conn.Close() return fmt.Errorf("smtp: new client: %w", err) } defer c.Close() if strings.ToLower(s.cfg.TLS) == "starttls" { if ok, _ := c.Extension("STARTTLS"); ok { if err := c.StartTLS(&tls.Config{ServerName: s.cfg.Host}); err != nil { return fmt.Errorf("smtp: starttls: %w", err) } } } if s.cfg.Username != "" { auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host) if err := c.Auth(auth); err != nil { return fmt.Errorf("smtp: auth: %w", err) } } if err := c.Mail(s.cfg.FromEmail); 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 %s: %w", rcpt, err) } } w, err := c.Data() if err != nil { return fmt.Errorf("smtp: DATA: %w", err) } if _, err := w.Write(body); err != nil { _ = w.Close() return fmt.Errorf("smtp: write body: %w", err) } if err := w.Close(); err != nil { return fmt.Errorf("smtp: close body: %w", err) } return c.Quit() } type mimeMessage struct { MessageID string From string To string Subject string Text string HTML string } // buildMIMEMessage assembles an RFC 5322 message with a multipart/alternative // body so receiving clients pick HTML when they can and fall back to text // otherwise. func buildMIMEMessage(m mimeMessage) []byte { boundary := randomBoundary() var b strings.Builder b.WriteString("Message-ID: <" + m.MessageID + ">\r\n") b.WriteString("Date: " + time.Now().UTC().Format(time.RFC1123Z) + "\r\n") b.WriteString("From: " + m.From + "\r\n") b.WriteString("To: " + m.To + "\r\n") b.WriteString("Subject: " + m.Subject + "\r\n") b.WriteString("MIME-Version: 1.0\r\n") b.WriteString("Content-Type: multipart/alternative; boundary=" + boundary + "\r\n") b.WriteString("\r\n") b.WriteString("--" + boundary + "\r\n") b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") b.WriteString("Content-Transfer-Encoding: 8bit\r\n") b.WriteString("\r\n") b.WriteString(m.Text) if !strings.HasSuffix(m.Text, "\n") { b.WriteString("\r\n") } b.WriteString("--" + boundary + "\r\n") b.WriteString("Content-Type: text/html; charset=UTF-8\r\n") b.WriteString("Content-Transfer-Encoding: 8bit\r\n") b.WriteString("\r\n") b.WriteString(m.HTML) if !strings.HasSuffix(m.HTML, "\n") { b.WriteString("\r\n") } b.WriteString("--" + boundary + "--\r\n") return []byte(b.String()) } func randomBoundary() string { var buf [16]byte _, _ = rand.Read(buf[:]) return "gg=" + hex.EncodeToString(buf[:]) } func generateMessageID(from string) string { var buf [12]byte _, _ = rand.Read(buf[:]) domain := "guestguard.local" if at := strings.LastIndexByte(from, '@'); at >= 0 && at+1 < len(from) { domain = from[at+1:] } return hex.EncodeToString(buf[:]) + "@" + domain }