208 lines
6.2 KiB
Go
208 lines
6.2 KiB
Go
// 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()
|
||
}
|