package main import ( "bytes" "crypto/tls" "encoding/json" "errors" "fmt" "io" "log" "net" "net/http" "net/mail" "net/smtp" "net/url" "os" "path/filepath" "strings" "time" ) type Mailer struct { Host string `json:"host"` Port int `json:"port"` User string `json:"user"` Password string `json:"password"` From string `json:"from"` StartTLS bool `json:"starttls"` Insecure bool `json:"insecure"` API string `json:"api"` Key string `json:"key"` Account string `json:"account"` Token string `json:"token"` } var cloudflareAPI = "https://api.cloudflare.com/client/v4" var mailSlots = make(chan struct{}, 4) func loadMailer(root string) *Mailer { b, err := os.ReadFile(filepath.Join(root, "smtp.json")) if err != nil { return nil } var m Mailer if json.Unmarshal(b, &m) != nil || (m.Host == "" && m.API == "" && m.Account == "") || m.From == "" { return nil } if m.Port == 0 { m.Port = 465 } return &m } func (m *Mailer) Send(to, subject, body string) error { if m == nil { return errors.New("mail is not configured") } if m.Account != "" { return m.cloudflare(to, subject, body) } if m.API != "" { return m.post(to, subject, body) } addr := net.JoinHostPort(m.Host, fmt.Sprint(m.Port)) tlsConfig := &tls.Config{ServerName: m.Host, InsecureSkipVerify: m.Insecure} var client *smtp.Client var err error if m.StartTLS { client, err = smtp.Dial(addr) if err == nil { err = client.StartTLS(tlsConfig) } } else if m.Insecure && m.Port != 465 { client, err = smtp.Dial(addr) } else { var conn net.Conn conn, err = tls.DialWithDialer(&net.Dialer{Timeout: 15 * time.Second}, "tcp", addr, tlsConfig) if err == nil { client, err = smtp.NewClient(conn, m.Host) } } if err != nil { return err } defer client.Close() if m.User != "" { if err := client.Auth(smtp.PlainAuth("", m.User, m.Password, m.Host)); err != nil { return err } } if err := client.Mail(m.From); err != nil { return err } if err := client.Rcpt(to); err != nil { return err } w, err := client.Data() if err != nil { return err } msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%d.%s>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n", m.From, to, headerText(subject), time.Now().Format(time.RFC1123Z), time.Now().UnixNano(), m.domain(), body) if _, err := w.Write([]byte(msg)); err != nil { return err } if err := w.Close(); err != nil { return err } return client.Quit() } func (s *Server) notify(to, subject, body string) { if to == "" || s.mailer == nil { return } go func() { mailSlots <- struct{}{} defer func() { <-mailSlots }() if err := s.mailer.Send(to, subject, body); err != nil { log.Printf("mail to %s: %v", to, err) } }() } func (m *Mailer) domain() string { from := m.From if i := strings.LastIndex(from, "<"); i >= 0 { from = strings.TrimSuffix(from[i+1:], ">") } _, d, _ := strings.Cut(from, "@") return d } func (m *Mailer) post(to, subject, body string) error { payload, _ := json.Marshal(map[string]any{"from": m.From, "to": []string{to}, "subject": headerText(subject), "text": body}) req, err := http.NewRequest("POST", m.API, bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+m.Key) req.Header.Set("Content-Type", "application/json") resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode/100 != 2 { msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return fmt.Errorf("mail api: %s: %s", resp.Status, strings.TrimSpace(string(msg))) } return nil } func headerText(s string) string { return strings.Map(func(r rune) rune { if r < 0x20 || r == 0x7f { return ' ' } return r }, s) } func (m *Mailer) cloudflare(to, subject, body string) error { from := m.From if addr, err := mail.ParseAddress(from); err == nil { from = addr.Address } payload, _ := json.Marshal(map[string]any{"from": from, "to": to, "subject": headerText(subject), "text": body}) req, err := http.NewRequest("POST", cloudflareAPI+"/accounts/"+url.PathEscape(m.Account)+"/email/sending/send", bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+m.Token) req.Header.Set("Content-Type", "application/json") resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) if err != nil { return err } defer resp.Body.Close() var out struct { Success bool `json:"success"` Errors []struct { Code int `json:"code"` Message string `json:"message"` } `json:"errors"` Result *struct { Delivered []string `json:"delivered"` Queued []string `json:"queued"` Bounced []string `json:"permanent_bounces"` } `json:"result"` } if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&out); err != nil { return fmt.Errorf("cloudflare mail: %s", resp.Status) } if !out.Success || out.Result == nil { var reasons []string for _, e := range out.Errors { reasons = append(reasons, fmt.Sprintf("%d %s", e.Code, e.Message)) } return fmt.Errorf("cloudflare mail: %s: %s", resp.Status, strings.Join(reasons, ", ")) } for _, list := range [][]string{out.Result.Delivered, out.Result.Queued} { for _, addr := range list { if strings.EqualFold(addr, to) { return nil } } } return fmt.Errorf("cloudflare mail: %s bounced", to) }