snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
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
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
This commit is contained in:
201
courier/smtp_channel.go
Normal file
201
courier/smtp_channel.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user