dana-medical-site/internal/notify/email.go
2026-10-14 13:33:13 +00:00

208 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 — сайт <site@example.ru>» или просто адрес
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()
}