// Package notify отправляет администраторам уведомления о новых заявках. // // Канал — электронная почта через SMTP. Мессенджеры с зарубежными серверами // (Telegram и т. п.) сюда намеренно не добавлены: в заявке имя, телефон и услуга, // а политика обработки ПДн клиники (п. 11) заявляет, что трансграничной передачи нет. package notify import ( "bytes" "context" "crypto/tls" "encoding/base64" "errors" "fmt" "mime" "net" "net/mail" "net/smtp" "strconv" "strings" "time" "dana/internal/domain" ) type Notifier interface { LeadCreated(ctx context.Context, lead domain.LeadNotice) error } // Email шлёт письмо о заявке. Порт 465 — сразу TLS (так работает smtp.yandex.ru), // другие порты — обычное соединение с переходом на TLS через STARTTLS. type Email struct { Host string Port int User string Password string From string // «DANA — сайт » или просто адрес To []string // общие получатели, если в форме свои не заданы SiteURL string // для ссылки на заявку в админке; можно пустым } var errNoRecipients = errors.New("notify: no recipients") func (e *Email) LeadCreated(ctx context.Context, lead domain.LeadNotice) error { to := validAddresses(lead.Recipients) if len(to) == 0 { to = validAddresses(e.To) } if len(to) == 0 { return errNoRecipients } from, err := mail.ParseAddress(e.From) if err != nil { return fmt.Errorf("notify: bad From %q: %w", e.From, err) } subject := "Заявка с сайта" if lead.FormTitle != "" { subject += ": " + lead.FormTitle } msg := buildMessage(from, to, subject, leadText(lead, e.SiteURL)) return e.send(ctx, from.Address, to, msg) } func (e *Email) send(ctx context.Context, from string, to []string, msg []byte) error { addr := net.JoinHostPort(e.Host, strconv.Itoa(e.Port)) dialer := &net.Dialer{Timeout: 15 * time.Second} tlsCfg := &tls.Config{ServerName: e.Host, MinVersion: tls.VersionTLS12} var conn net.Conn var err error if e.Port == 465 { conn, err = (&tls.Dialer{NetDialer: dialer, Config: tlsCfg}).DialContext(ctx, "tcp", addr) } else { conn, err = dialer.DialContext(ctx, "tcp", addr) } if err != nil { return fmt.Errorf("notify: dial %s: %w", addr, err) } if deadline, ok := ctx.Deadline(); ok { _ = conn.SetDeadline(deadline) } c, err := smtp.NewClient(conn, e.Host) if err != nil { conn.Close() return fmt.Errorf("notify: smtp handshake: %w", err) } defer c.Close() if e.Port != 465 { if ok, _ := c.Extension("STARTTLS"); ok { if err := c.StartTLS(tlsCfg); err != nil { return fmt.Errorf("notify: starttls: %w", err) } } } if e.User != "" { if err := c.Auth(smtp.PlainAuth("", e.User, e.Password, e.Host)); err != nil { return fmt.Errorf("notify: auth: %w", err) } } if err := c.Mail(from); err != nil { return fmt.Errorf("notify: MAIL FROM: %w", err) } for _, rcpt := range to { if err := c.Rcpt(rcpt); err != nil { return fmt.Errorf("notify: RCPT TO %s: %w", rcpt, err) } } w, err := c.Data() if err != nil { return fmt.Errorf("notify: DATA: %w", err) } if _, err := w.Write(msg); err != nil { w.Close() return fmt.Errorf("notify: write: %w", err) } if err := w.Close(); err != nil { return fmt.Errorf("notify: finish DATA: %w", err) } return c.Quit() } func validAddresses(list []string) []string { out := make([]string, 0, len(list)) for _, raw := range list { if a, err := mail.ParseAddress(strings.TrimSpace(raw)); err == nil { out = append(out, a.Address) } } return out } // oneLine убирает переводы строк: значения из формы не должны превращаться в заголовки письма. func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") } func leadText(l domain.LeadNotice, siteURL string) string { var b strings.Builder fmt.Fprintf(&b, "Новая заявка № %d\n", l.ID) if !l.CreatedAt.IsZero() { fmt.Fprintf(&b, "Время: %s\n", l.CreatedAt.In(moscow).Format("02.01.2006 15:04")) } b.WriteString("\n") line := func(label, value string) { if value = strings.TrimSpace(value); value != "" { fmt.Fprintf(&b, "%s: %s\n", label, value) } } line("Форма", l.FormTitle) line("Имя", oneLine(l.Name)) line("Телефон", oneLine(l.Phone)) line("E-mail", oneLine(l.Email)) line("Услуга", l.Service) line("Врач", l.Doctor) line("Филиал", l.Branch) if v, ok := l.Extra["date"].(string); ok { line("Желаемая дата", oneLine(v)) } if v, ok := l.Extra["time"].(string); ok { line("Удобное время", oneLine(v)) } if v, ok := l.Extra["rating"].(float64); ok { line("Оценка", strconv.Itoa(int(v))) } if msg := strings.TrimSpace(l.Message); msg != "" { fmt.Fprintf(&b, "\nСообщение:\n%s\n", msg) } b.WriteString("\n") line("Страница", l.PageURL) if siteURL != "" { line("Все заявки", strings.TrimRight(siteURL, "/")+"/admin/") } return b.String() } var moscow = func() *time.Location { if loc, err := time.LoadLocation("Europe/Moscow"); err == nil { return loc } return time.FixedZone("MSK", 3*60*60) }() func buildMessage(from *mail.Address, to []string, subject, body string) []byte { var buf bytes.Buffer header := func(k, v string) { fmt.Fprintf(&buf, "%s: %s\r\n", k, v) } header("From", from.String()) header("To", strings.Join(to, ", ")) header("Subject", mime.BEncoding.Encode("utf-8", oneLine(subject))) header("Date", time.Now().Format(time.RFC1123Z)) header("MIME-Version", "1.0") header("Content-Type", "text/plain; charset=utf-8") header("Content-Transfer-Encoding", "base64") buf.WriteString("\r\n") encoded := base64.StdEncoding.EncodeToString([]byte(body)) for len(encoded) > 76 { buf.WriteString(encoded[:76] + "\r\n") encoded = encoded[76:] } buf.WriteString(encoded + "\r\n") return buf.Bytes() }