Files
upstream-ory-kratos/courier/smtp_channel.go
upstream-mirror c60cc7ca75
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

202 lines
6.4 KiB
Go

// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier
import (
"context"
"net"
stdmail "net/mail"
"net/textproto"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
semconv "go.opentelemetry.io/otel/semconv/v1.20.0"
"go.opentelemetry.io/otel/trace"
"github.com/ory/herodot"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/x"
"github.com/ory/mail/v3"
"github.com/ory/x/otelx"
)
type (
SMTPChannel struct {
smtpClient *SMTPClient
d Dependencies
newEmailTemplateFromMessage func(d template.Dependencies, msg Message) (EmailTemplate, error)
}
)
var _ Channel = new(SMTPChannel)
func NewSMTPChannel(deps Dependencies, cfg *config.SMTPConfig) (*SMTPChannel, error) {
return NewSMTPChannelWithCustomTemplates(deps, cfg, NewEmailTemplateFromMessage)
}
func NewSMTPChannelWithCustomTemplates(deps Dependencies, cfg *config.SMTPConfig, newEmailTemplateFromMessage func(d template.Dependencies, msg Message) (EmailTemplate, error)) (*SMTPChannel, error) {
smtpClient, err := NewSMTPClient(deps, cfg)
if err != nil {
return nil, err
}
return &SMTPChannel{
smtpClient: smtpClient,
d: deps,
newEmailTemplateFromMessage: newEmailTemplateFromMessage,
}, nil
}
func (c *SMTPChannel) ID() string {
return "email"
}
func (c *SMTPChannel) Dispatch(ctx context.Context, msg Message) (err error) {
ctx, span := c.d.Tracer(ctx).Tracer().Start(ctx, "courier.SMTPChannel.Dispatch")
defer otelx.End(span, &err)
if c.smtpClient.Host == "" {
return errors.WithStack(herodot.ErrInternalServerError().WithErrorf("Courier tried to deliver an email but %s is not set!", config.ViperKeyCourierSMTPURL))
}
channels, err := c.d.CourierConfig().CourierChannels(ctx)
if err != nil {
return err
}
var cfg *config.SMTPConfig
for _, channel := range channels {
if channel.ID == "email" && channel.SMTPConfig != nil {
cfg = channel.SMTPConfig
break
}
}
if cfg == nil {
return errors.WithStack(herodot.ErrInternalServerError().WithErrorf("Courier tried to deliver an email but SMTP channel is misconfigured."))
}
// Re-validate the recipient read from the database: a value that is neither
// RFC 5322-valid nor injection-safe per x.IsEmailAddress is abandoned
// rather than placed in an SMTP command or header.
_, rfcErr := stdmail.ParseAddress(msg.Recipient)
if rfcErr != nil && !x.IsEmailAddress(msg.Recipient) {
if err := c.d.CourierPersister().SetMessageStatus(ctx, msg.ID, MessageStatusAbandoned); err != nil {
c.d.Logger().WithError(err).Error(`Unable to set the message status to "abandoned".`)
return errors.WithStack(err)
}
return errors.WithStack(herodot.ErrInternalServerError().
WithReasonf("courier: email recipient for message %s is not a valid email address", msg.ID))
}
gm := mail.NewMessage()
if cfg.FromName == "" {
gm.SetHeader("From", cfg.FromAddress)
} else {
gm.SetAddressHeader("From", cfg.FromAddress, cfg.FromName)
}
gm.SetHeader("To", msg.Recipient)
gm.SetHeader("Subject", msg.Subject)
headers := cfg.Headers
for k, v := range headers {
gm.SetHeader(k, v)
}
gm.SetBody("text/plain", msg.Body)
logger := c.d.Logger().
WithField("smtp_server", net.JoinHostPort(c.smtpClient.Host, strconv.Itoa(c.smtpClient.Port))).
WithField("smtp_ssl_enabled", c.smtpClient.SSL).
WithField("message_from", cfg.FromAddress).
WithField("message_id", msg.ID).
WithField("message_nid", msg.NID).
WithField("message_type", msg.Type).
WithField("message_template_type", msg.TemplateType).
WithField("message_subject", msg.Subject).
WithField("trace_id", span.SpanContext().TraceID())
tmpl, err := c.newEmailTemplateFromMessage(c.d, msg)
if err != nil {
logger.
WithError(err).Error(`Unable to get email template from message.`)
} else if htmlBody, err := tmpl.EmailBody(ctx); err != nil {
logger.
WithError(err).Error(`Unable to get email body from template.`)
} else {
gm.AddAlternative("text/html", htmlBody)
}
dialCtx, dialSpan := c.d.Tracer(ctx).Tracer().Start(ctx, "courier.SMTPChannel.Dispatch.Dial", trace.WithAttributes(
semconv.NetPeerName(c.smtpClient.Host),
semconv.NetPeerPort(c.smtpClient.Port),
semconv.NetProtocolName("smtp"),
))
snd, err := c.smtpClient.Dial(dialCtx)
otelx.End(dialSpan, &err)
if err != nil {
logger.
WithError(err).
WithField("smtp_host", c.smtpClient.Host).
WithField("smtp_port", c.smtpClient.Port).
Error("Unable to dial SMTP connection.")
return errors.WithStack(herodot.ErrInternalServerError().
WithError(err.Error()).WithReason("failed to send email via smtp"))
}
defer func() { _ = snd.Close() }()
sendCtx, sendSpan := c.d.Tracer(ctx).Tracer().Start(ctx, "courier.SMTPChannel.Dispatch.Send")
if rfcErr == nil {
// mail.Send derives the envelope from the To/Cc/Bcc headers, so Cc/Bcc
// configured via courier.smtp.headers are delivered.
err = mail.Send(sendCtx, snd, gm)
} else {
// A non-RFC recipient bypasses mail.Send's strict header re-parse with
// an explicit envelope; Cc/Bcc headers are not delivered on this path.
for k := range headers {
if strings.EqualFold(k, "Cc") || strings.EqualFold(k, "Bcc") {
logger.Warnf("Configured %q recipients are not delivered for a non-RFC email address.", k)
}
}
err = snd.Send(sendCtx, cfg.FromAddress, []string{msg.Recipient}, gm)
}
otelx.End(sendSpan, &err)
if err != nil {
logger.
WithError(err).
Error("Unable to send email using SMTP connection.")
var protoErr *textproto.Error
var mailErr *mail.SendError
switch {
case errors.As(err, &mailErr) && errors.As(mailErr.Cause, &protoErr) && protoErr.Code >= 500:
fallthrough
case errors.As(err, &protoErr) && protoErr.Code >= 500:
// See https://en.wikipedia.org/wiki/List_of_SMTP_server_return_codes
// If the SMTP server responds with 5xx, sending the message should not be retried (without changing something about the request)
if err := c.d.CourierPersister().SetMessageStatus(ctx, msg.ID, MessageStatusAbandoned); err != nil {
logger.
WithError(err).
Error(`Unable to reset the retried message's status to "abandoned".`)
return errors.WithStack(err)
}
}
return errors.WithStack(herodot.ErrInternalServerError().
WithError(err.Error()).WithReason("failed to send email via smtp"))
}
dispatchDuration := time.Since(msg.CreatedAt).Milliseconds()
logger.WithField("dispatch_duration_ms", dispatchDuration).Debug("Courier sent out message.")
return nil
}