package notify import ( "context" "errors" "fmt" "io" "net" "net/smtp" "strings" "time" "github.com/getsentry/sentry-go" ) const smtpSendTimeout = 10 * time.Second type smtpEmailNotificationService struct { Name string Address string Host string Port string } func NewSMTPEmailNotificationService(name, address, host, port string) EmailNotificationService { return &smtpEmailNotificationService{ Name: name, Address: address, Host: host, Port: port, } } func (s *smtpEmailNotificationService) Send(ctx context.Context, to, cc, bcc []string, subject, message string) error { from := fmt.Sprintf("%s <%s>", s.Name, s.Address) addr := net.JoinHostPort(s.Host, s.Port) allRecipients := make([]string, 0, len(to)+len(cc)+len(bcc)) allRecipients = append(allRecipients, to...) allRecipients = append(allRecipients, cc...) allRecipients = append(allRecipients, bcc...) headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=\"UTF-8\"\r\n\r\n", from, strings.Join(to, ", "), subject, ) msg := []byte(headers + message) if err := sendSMTP(ctx, addr, s.Host, s.Address, allRecipients, msg); err != nil { sentry.CaptureException(err) return err } return nil } // SendOutreach is Send with an explicit Reply-To. The SMTP transport // doesn't have a structured envelope for this so we forge the header // directly into the message. func (s *smtpEmailNotificationService) SendOutreach(ctx context.Context, to []string, replyTo, subject, message string) error { from := fmt.Sprintf("%s <%s>", s.Name, s.Address) addr := net.JoinHostPort(s.Host, s.Port) headers := "From: " + from + "\r\n" + "To: " + strings.Join(to, ", ") + "\r\n" + "Subject: " + subject + "\r\n" if replyTo != "" { headers += "Reply-To: " + replyTo + "\r\n" } headers += "MIME-Version: 1.0\r\n" + "Content-Type: text/html; charset=\"UTF-8\"\r\n\r\n" msg := []byte(headers + message) if err := sendSMTP(ctx, addr, s.Host, s.Address, to, msg); err != nil { sentry.CaptureException(err) return err } return nil } func sendSMTP(ctx context.Context, addr, host, from string, recipients []string, msg []byte) error { if len(recipients) == 0 { return errors.New("smtp send requires at least one recipient") } ctx, cancel := context.WithTimeout(ctx, smtpSendTimeout) defer cancel() dialer := net.Dialer{Timeout: smtpSendTimeout} conn, err := dialer.DialContext(ctx, "tcp", addr) if err != nil { return err } defer conn.Close() if deadline, ok := ctx.Deadline(); ok { if err := conn.SetDeadline(deadline); err != nil { return err } } client, err := smtp.NewClient(conn, host) if err != nil { return err } defer client.Close() if err := client.Mail(from); err != nil { return err } for _, recipient := range recipients { if err := client.Rcpt(recipient); err != nil { return err } } writer, err := client.Data() if err != nil { return err } if _, err := writer.Write(msg); err != nil { _ = writer.Close() return err } if err := writer.Close(); err != nil { return err } if err := client.Quit(); err != nil && !errors.Is(err, io.EOF) { return err } return nil }