Files
worker/internal/sender/email.go
Gleb Tv 2c884c5612
Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
refactor: adopt worker module path
2026-07-13 17:56:12 +03:00

220 строки
6.3 KiB
Go

package sender
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/mail"
"net/smtp"
"strings"
"time"
"gopkg.in/gomail.v2"
"rocketgit.ru/rsmon/worker/app/models"
"rocketgit.ru/rsmon/worker/internal/wire"
)
// RunEmail provides functionality.
func RunEmail(message *models.Message) error {
subject, textBody, _, htmlBody := GetContent(message, time.Now())
return SendEmail(message.Contact.Value, subject.String(), textBody.String(), htmlBody.String())
}
// SendEmail provides functionality.
func SendEmail(to, subject, body, html string) error {
cred, err := firstSMTPCredential()
if err != nil {
return err
}
return SendEmailWithCredential(to, subject, body, html, cred)
}
func firstSMTPCredential() (*wire.SMTPCredential, error) {
creds, err := models.EnabledCredentialsByKind(models.CredentialKindSMTP)
if err != nil {
return nil, err
}
if len(creds) == 0 {
return nil, errors.New("smtp credential is not configured")
}
return smtpModelToWire(&creds[0])
}
func smtpModelToWire(cred *models.NotificationCredential) (*wire.SMTPCredential, error) {
if cred == nil {
return nil, errors.New("smtp credential is nil")
}
password, err := cred.GetSecret()
if err != nil {
return nil, fmt.Errorf("smtp credential secret: %w", err)
}
out := &wire.SMTPCredential{
ID: cred.ID,
Name: cred.Name,
Password: password,
InsecureSkipVerify: cred.InsecureSkipVerify,
}
if cred.Server != nil {
out.Server = *cred.Server
}
if cred.Port != nil {
out.Port = *cred.Port
}
if cred.Login != nil {
out.Login = *cred.Login
}
if cred.FromName != nil {
out.FromName = *cred.FromName
}
if cred.FromAddr != nil {
out.FromAddress = *cred.FromAddr
}
return out, nil
}
// SendEmailWithCredential delivers an email using the given wire SMTP
// credential. Phase 1 of docs/plans/worker-notifier-mvp.md ships every
// enabled SMTP credential to the worker; this function is what the worker
// executor calls so the same code path covers the operated-worker path and
// the per-customer-credential path Phase 4 will introduce.
func SendEmailWithCredential(to, subject, body, html string, cred *wire.SMTPCredential) error {
return SendEmailWithCredentialContext(context.Background(), to, subject, body, html, cred)
}
// SendEmailWithCredentialContext performs SMTP over a context-aware dialer.
// Socket deadlines propagate cancellation through SMTP commands and DATA writes.
func SendEmailWithCredentialContext(ctx context.Context, to, subject, body, html string, cred *wire.SMTPCredential) error {
return sendEmailWithCredentialContext(ctx, to, subject, body, html, cred, cred != nil && cred.Port == 465)
}
// sendEmailWithCredentialContext permits the SMTPS transport choice to be
// tested with an unprivileged local listener.
func sendEmailWithCredentialContext(ctx context.Context, to, subject, body, html string, cred *wire.SMTPCredential, implicitTLS bool) error {
if cred == nil {
return errors.New("smtp credential is nil")
}
if cred.Server == "" {
return errors.New("smtp credential: server is empty")
}
fromAddr := &mail.Address{Name: cred.FromName, Address: cred.FromAddress}
from := fromAddr.String()
m := gomail.NewMessage()
m.SetHeader("From", from)
m.SetHeader("To", to)
m.SetHeader("Subject", subject)
m.AddAlternative("text/plain", body)
m.AddAlternative("text/html", html)
var raw bytes.Buffer
if _, err := m.WriteTo(&raw); err != nil {
return err
}
dialer := &net.Dialer{}
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(cred.Server, fmt.Sprint(cred.Port)))
if err != nil {
return err
}
defer conn.Close() //nolint:errcheck
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
done := make(chan struct{})
defer close(done)
go func() {
select {
case <-ctx.Done():
_ = conn.SetDeadline(time.Now())
case <-done:
}
}()
if implicitTLS {
tlsConn := tls.Client(conn, smtpTLSConfig(cred))
if err := tlsConn.HandshakeContext(ctx); err != nil {
return smtpContextError(ctx, err)
}
conn = tlsConn
}
client, err := smtp.NewClient(conn, cred.Server)
if err != nil {
return smtpContextError(ctx, err)
}
defer client.Quit() //nolint:errcheck
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(smtpTLSConfig(cred)); err != nil {
return smtpContextError(ctx, err)
}
}
if cred.Login != "" {
if err := client.Auth(smtpAuth(client, cred)); err != nil {
return smtpContextError(ctx, err)
}
}
if err := client.Mail(cred.FromAddress); err != nil {
return smtpContextError(ctx, err)
}
if err := client.Rcpt(to); err != nil {
return smtpContextError(ctx, err)
}
writer, err := client.Data()
if err != nil {
return smtpContextError(ctx, err)
}
if _, err := writer.Write(raw.Bytes()); err != nil {
return smtpContextError(ctx, err)
}
if err := writer.Close(); err != nil {
return smtpContextError(ctx, err)
}
return nil
}
func smtpContextError(ctx context.Context, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if deadline, ok := ctx.Deadline(); ok && !time.Now().Before(deadline) {
return context.DeadlineExceeded
}
return err
}
func smtpTLSConfig(cred *wire.SMTPCredential) *tls.Config {
return &tls.Config{ServerName: cred.Server, InsecureSkipVerify: cred.InsecureSkipVerify}
}
func smtpAuth(client *smtp.Client, cred *wire.SMTPCredential) smtp.Auth {
_, mechanisms := client.Extension("AUTH")
for _, mechanism := range strings.Fields(strings.ToUpper(mechanisms)) {
switch mechanism {
case "CRAM-MD5":
return smtp.CRAMMD5Auth(cred.Login, cred.Password)
case "LOGIN":
return loginAuth{username: cred.Login, password: cred.Password}
case "PLAIN":
return smtp.PlainAuth("", cred.Login, cred.Password, cred.Server)
}
}
// Preserve gomail's default when the server does not advertise mechanisms;
// smtp.Client returns the server's authoritative AUTH failure.
return smtp.PlainAuth("", cred.Login, cred.Password, cred.Server)
}
type loginAuth struct{ username, password string }
func (a loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) { return "LOGIN", nil, nil }
func (a loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if !more {
return nil, nil
}
challenge := strings.ToLower(string(fromServer))
if strings.Contains(challenge, "username") || strings.Contains(challenge, "user") {
return []byte(a.username), nil
}
return []byte(a.password), nil
}