Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
220 строки
6.3 KiB
Go
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
|
|
}
|