package sender import ( "bytes" "context" "crypto/tls" "errors" "fmt" "net" "net/mail" "net/smtp" "strings" "time" "gopkg.in/gomail.v2" "rsgit.ru/rsmon/rsmon/app/models" "rsgit.ru/rsmon/rsmon/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 }