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
202 lines
6.4 KiB
Go
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
|
|
}
|