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

This commit is contained in:
upstream-mirror
2026-09-15 21:19:12 +05:00
commit c60cc7ca75
8092 changed files with 394160 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
{
"error": {
"code": 404,
"status": "Not Found",
"message": "Unable to locate the resource"
}
}

View File

@@ -0,0 +1,7 @@
{
"error": {
"code": 404,
"status": "Not Found",
"message": "Unable to locate the resource"
}
}

View File

@@ -0,0 +1,7 @@
{
"error": {
"code": 400,
"status": "Bad Request",
"message": "uuid: incorrect UUID length 10 in string \"not-a-uuid\""
}
}

View File

@@ -0,0 +1,7 @@
{
"error": {
"code": 400,
"status": "Bad Request",
"message": "uuid: incorrect UUID length 10 in string \"not-a-uuid\""
}
}

13
courier/channel.go Normal file
View File

@@ -0,0 +1,13 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier
import (
"context"
)
type Channel interface {
ID() string
Dispatch(ctx context.Context, msg Message) error
}

108
courier/courier.go Normal file
View File

@@ -0,0 +1,108 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier
import (
"context"
"time"
"github.com/cenkalti/backoff"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/ory/x/httpx"
"github.com/ory/x/jsonnetsecure"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/driver/config"
)
type (
Dependencies interface {
PersistenceProvider
otelx.Provider
logrusx.Provider
ConfigProvider
httpx.ClientProvider
jsonnetsecure.VMProvider
}
Courier interface {
Work(ctx context.Context) error
QueueEmail(ctx context.Context, t EmailTemplate) (uuid.UUID, error)
QueueSMS(ctx context.Context, t SMSTemplate) (uuid.UUID, error)
DispatchQueue(ctx context.Context) error
DispatchMessage(ctx context.Context, msg Message) error
UseBackoff(b backoff.BackOff)
FailOnDispatchError()
}
Provider interface {
Courier(ctx context.Context) (Courier, error)
}
ConfigProvider interface {
CourierConfig() config.CourierConfigs
}
courier struct {
deps Dependencies
failOnDispatchError bool
backoff backoff.BackOff
newEmailTemplateFromMessage func(d template.Dependencies, msg Message) (EmailTemplate, error)
}
)
func NewCourier(ctx context.Context, deps Dependencies) (Courier, error) {
return NewCourierWithCustomTemplates(ctx, deps, NewEmailTemplateFromMessage)
}
func NewCourierWithCustomTemplates(_ context.Context, deps Dependencies, newEmailTemplateFromMessage func(d template.Dependencies, msg Message) (EmailTemplate, error)) (Courier, error) {
return &courier{
deps: deps,
backoff: backoff.NewExponentialBackOff(),
newEmailTemplateFromMessage: newEmailTemplateFromMessage,
}, nil
}
func (c *courier) FailOnDispatchError() {
c.failOnDispatchError = true
}
func (c *courier) Work(ctx context.Context) error {
errChan := make(chan error)
defer close(errChan)
go c.watchMessages(ctx, errChan)
select {
case <-ctx.Done():
if errors.Is(ctx.Err(), context.Canceled) {
return nil
}
return ctx.Err()
case err := <-errChan:
return errors.WithStack(err)
}
}
func (c *courier) UseBackoff(b backoff.BackOff) {
c.backoff = b
}
func (c *courier) watchMessages(ctx context.Context, errChan chan error) {
wait := c.deps.CourierConfig().CourierWorkerPullWait(ctx)
c.backoff.Reset()
for {
if err := backoff.Retry(func() error {
return c.DispatchQueue(ctx)
}, c.backoff); err != nil {
errChan <- errors.WithStack(err)
return
}
time.Sleep(wait)
}
}

View File

@@ -0,0 +1,173 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier
import (
"context"
"time"
"github.com/pkg/errors"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/ory/kratos/x/events"
"github.com/ory/x/otelx"
"github.com/ory/x/otelx/semconv"
)
func (c *courier) channels(ctx context.Context, id string) (Channel, error) {
cs, err := c.deps.CourierConfig().CourierChannels(ctx)
if err != nil {
return nil, err
}
for _, channel := range cs {
if channel.ID != id {
continue
}
switch channel.Type {
case "smtp":
courierChannel, err := NewSMTPChannelWithCustomTemplates(c.deps, channel.SMTPConfig, c.newEmailTemplateFromMessage)
if err != nil {
return nil, err
}
return courierChannel, nil
case "http":
return newHttpChannel(channel.ID, &channel.RequestConfig, c.deps), nil
default:
return nil, errors.Errorf("unknown courier channel type: %s", channel.Type)
}
}
return nil, errors.Errorf("no courier channels configured for: %s", id)
}
func (c *courier) DispatchMessage(ctx context.Context, msg Message) (err error) {
ctx, span := c.deps.Tracer(ctx).Tracer().Start(ctx, "courier.DispatchMessage", trace.WithAttributes(
attribute.Stringer("message.id", msg.ID),
attribute.Stringer("message.nid", msg.NID),
attribute.Stringer("message.type", msg.Type),
attribute.String("message.template_type", string(msg.TemplateType)),
attribute.Int("message.send_count", msg.SendCount),
))
defer otelx.End(span, &err)
ctx = semconv.ContextWithAttributes(ctx, semconv.AttrNID(msg.NID))
logger := c.deps.Logger().
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())
if err := c.deps.CourierPersister().IncrementMessageSendCount(ctx, msg.ID); err != nil {
logger.
WithError(err).
Error(`Unable to increment the message's "send_count" field`)
return err
}
channel, err := c.channels(ctx, msg.Channel.String())
if err != nil {
return err
}
span.SetAttributes(attribute.String("channel.id", channel.ID()))
logger = logger.
WithField("channel", channel.ID())
if err := channel.Dispatch(ctx, msg); err != nil {
return err
}
span.AddEvent(events.NewCourierMessageDispatched(ctx, msg.ID, msg.Channel.String(), string(msg.TemplateType)))
if err := c.deps.CourierPersister().SetMessageStatus(ctx, msg.ID, MessageStatusSent); err != nil {
logger.
WithError(err).
Error(`Unable to set the message status to "sent".`)
return err
}
dispatchDuration := time.Since(msg.CreatedAt).Milliseconds()
logger.WithField("dispatch_duration_ms", dispatchDuration).Debug("Courier sent out message.")
return nil
}
func (c *courier) DispatchQueue(ctx context.Context) (err error) {
ctx, span := c.deps.Tracer(ctx).Tracer().Start(ctx, "courier.DispatchQueue")
defer otelx.End(span, &err)
maxRetries := c.deps.CourierConfig().CourierMessageRetries(ctx)
pullCount := c.deps.CourierConfig().CourierWorkerPullCount(ctx)
//nolint:gosec // disable G115
messages, err := c.deps.CourierPersister().NextMessages(ctx, uint8(pullCount))
if err != nil {
if errors.Is(err, ErrQueueEmpty) {
return nil
}
return err
}
span.SetAttributes(attribute.Int("messages_count", len(messages)))
for k, msg := range messages {
logger := c.deps.Logger().
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)
if msg.SendCount > maxRetries {
if err := c.deps.CourierPersister().SetMessageStatus(ctx, msg.ID, MessageStatusAbandoned); err != nil {
logger.
WithError(err).
Error(`Unable to set the retried message's status to "abandoned".`)
return err
}
msgCtx := semconv.ContextWithAttributes(ctx, semconv.AttrNID(msg.NID))
span.AddEvent(events.NewCourierMessageAbandoned(msgCtx, msg.ID, msg.Channel.String(), string(msg.TemplateType)))
// Skip the message
logger.
Warnf(`Message was abandoned because it did not deliver after %d attempts`, msg.SendCount)
} else if err := c.DispatchMessage(ctx, msg); err != nil {
logger.
WithError(err).
Warn(`Unable to dispatch message.`)
if err := c.deps.CourierPersister().RecordDispatch(ctx, msg.ID, CourierMessageDispatchStatusFailed, err); err != nil {
logger.
WithError(err).
Error(`Unable to record failure log entry.`)
if c.failOnDispatchError {
return err
}
}
for _, replace := range messages[k:] {
if err := c.deps.CourierPersister().SetMessageStatus(ctx, replace.ID, MessageStatusQueued); err != nil {
logger.
WithError(err).
Error(`Unable to reset the failed message's status to "queued".`)
if c.failOnDispatchError {
return err
}
}
}
if c.failOnDispatchError {
return err
}
} else if err := c.deps.CourierPersister().RecordDispatch(ctx, msg.ID, CourierMessageDispatchStatusSuccess, nil); err != nil {
logger.
WithError(err).
Error(`Unable to record success log entry.`)
// continue with execution, as the message was successfully dispatched
}
}
return nil
}

View File

@@ -0,0 +1,176 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier_test
import (
"fmt"
"net/http"
"net/http/httptest"
"slices"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"go.opentelemetry.io/otel/attribute"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"github.com/ory/kratos/courier"
templates "github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/x/events"
"github.com/ory/x/configx"
"github.com/ory/x/otelx"
"github.com/ory/x/otelx/semconv"
)
func queueNewMessage(t *testing.T, c courier.Courier) uuid.UUID {
t.Helper()
id, err := c.QueueEmail(t.Context(), templates.NewTestStub(&templates.TestStubModel{
To: "test-recipient-1@example.org",
Subject: "test-subject-1",
Body: "test-body-1",
}))
require.NoError(t, err)
return id
}
func TestDispatchMessageWithInvalidSMTP(t *testing.T) {
_, reg := pkg.NewRegistryDefaultWithDSN(t, "", configx.WithValues(map[string]any{
config.ViperKeyCourierMessageRetries: 5,
config.ViperKeyCourierSMTPURL: "http://foo.url",
}))
c, err := reg.Courier(t.Context())
require.NoError(t, err)
t.Run("case=failed sending", func(t *testing.T) {
id := queueNewMessage(t, c)
message, err := reg.CourierPersister().LatestQueuedMessage(t.Context())
require.NoError(t, err)
require.Equal(t, id, message.ID)
err = c.DispatchMessage(t.Context(), *message)
// sending the email fails, because there is no SMTP server at foo.url
require.Error(t, err)
messages, err := reg.CourierPersister().NextMessages(t.Context(), 10)
require.NoError(t, err)
require.Len(t, messages, 1)
})
}
func TestDispatchMessage(t *testing.T) {
_, reg := pkg.NewRegistryDefaultWithDSN(t, "", configx.WithValues(map[string]any{
config.ViperKeyCourierMessageRetries: 5,
config.ViperKeyCourierSMTPURL: "http://foo.url",
}))
c, err := reg.Courier(t.Context())
require.NoError(t, err)
t.Run("case=invalid channel", func(t *testing.T) {
message := courier.Message{
Channel: "invalid-channel",
Status: courier.MessageStatusQueued,
Type: courier.MessageTypeEmail,
Recipient: testhelpers.RandomEmail(),
Subject: "test-subject-1",
Body: "test-body-1",
TemplateType: "stub",
}
require.NoError(t, reg.CourierPersister().AddMessage(t.Context(), &message))
assert.ErrorContains(t, c.DispatchMessage(t.Context(), message), "no courier channels configured for: invalid-channel")
})
}
func TestDispatchQueue(t *testing.T) {
_, reg := pkg.NewRegistryDefaultWithDSN(t, "", configx.WithValue(config.ViperKeyCourierMessageRetries, 1))
c, err := reg.Courier(t.Context())
require.NoError(t, err)
c.FailOnDispatchError()
id := queueNewMessage(t, c)
require.NotEqual(t, uuid.Nil, id)
// Fails to deliver the first time
err = c.DispatchQueue(t.Context())
require.Error(t, err)
// Retry once, as we set above - still fails
err = c.DispatchQueue(t.Context())
require.Error(t, err)
// Now it has been retried once, which means 2 > 1 is true and it is no longer tried
err = c.DispatchQueue(t.Context())
require.NoError(t, err)
var message courier.Message
err = reg.Persister().GetConnection(t.Context()).
Where("status = ?", courier.MessageStatusAbandoned).
Eager("Dispatches").
First(&message)
require.NoError(t, err)
require.Equal(t, id, message.ID)
require.Len(t, message.Dispatches, 2)
require.Contains(t, gjson.GetBytes(message.Dispatches[0].Error, "reason").String(), "failed to send email via smtp")
require.Contains(t, gjson.GetBytes(message.Dispatches[1].Error, "reason").String(), "failed to send email via smtp")
}
func TestDispatchMessageEmitsEventWithNID(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
requestConfig := fmt.Sprintf(`{"url": "%s", "method": "POST", "body": "file://./stub/request.config.mailer.jsonnet"}`, srv.URL)
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]any{
config.ViperKeyCourierDeliveryStrategy: "http",
config.ViperKeyCourierHTTPRequestConfig: requestConfig,
}))
recorder := tracetest.NewSpanRecorder()
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
reg.SetTracer(otelx.NewNoop().WithOTLP(provider.Tracer("test")))
c, err := reg.Courier(t.Context())
require.NoError(t, err)
_, err = c.QueueEmail(t.Context(), templates.NewTestStub(&templates.TestStubModel{
To: testhelpers.RandomEmail(),
Subject: "test-subject",
Body: "test-body",
}))
require.NoError(t, err)
require.NoError(t, c.DispatchQueue(t.Context()))
ended := recorder.Ended()
i := slices.IndexFunc(ended, func(sp sdktrace.ReadOnlySpan) bool {
return sp.Name() == "courier.DispatchMessage"
})
require.GreaterOrEqual(t, i, 0, "courier.DispatchMessage span not found")
evs := ended[i].Events()
j := slices.IndexFunc(evs, func(ev sdktrace.Event) bool {
return ev.Name == events.CourierMessageDispatched.String()
})
require.GreaterOrEqual(t, j, 0, "CourierMessageDispatched event not found on span")
attrs := evs[j].Attributes
k := slices.IndexFunc([]attribute.KeyValue(attrs), func(a attribute.KeyValue) bool {
return string(a.Key) == semconv.AttributeKeyNID.String()
})
require.GreaterOrEqual(t, k, 0, "NID attribute not found on event")
assert.NotEmpty(t, attrs[k].Value.AsString())
}

120
courier/email_templates.go Normal file
View File

@@ -0,0 +1,120 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier
import (
"context"
"encoding/json"
"net/http"
"github.com/ory/kratos/courier/template"
"github.com/pkg/errors"
"github.com/ory/kratos/courier/template/email"
)
type (
Template interface {
json.Marshaler
TemplateType() template.TemplateType
}
EmailTemplate interface {
Template
EmailSubject(context.Context) (string, error)
EmailBody(context.Context) (string, error)
EmailBodyPlaintext(context.Context) (string, error)
EmailRecipient() (string, error)
}
RequestHeadersCarrier interface {
RequestHeaders() http.Header
}
)
func NewEmailTemplateFromMessage(d template.Dependencies, msg Message) (EmailTemplate, error) {
switch msg.TemplateType {
case template.TypeRecoveryInvalid:
var t email.RecoveryInvalidModel
if err := json.Unmarshal(msg.TemplateData, &t); err != nil {
return nil, err
}
return email.NewRecoveryInvalid(d, &t), nil
case template.TypeRecoveryValid:
var t email.RecoveryValidModel
if err := json.Unmarshal(msg.TemplateData, &t); err != nil {
return nil, err
}
return email.NewRecoveryValid(d, &t), nil
case template.TypeRecoveryCodeInvalid:
var t email.RecoveryCodeInvalidModel
if err := json.Unmarshal(msg.TemplateData, &t); err != nil {
return nil, err
}
return email.NewRecoveryCodeInvalid(d, &t), nil
case template.TypeRecoveryCodeValid:
var t email.RecoveryCodeValidModel
if err := json.Unmarshal(msg.TemplateData, &t); err != nil {
return nil, err
}
return email.NewRecoveryCodeValid(d, &t), nil
case template.TypeVerificationInvalid:
var t email.VerificationInvalidModel
if err := json.Unmarshal(msg.TemplateData, &t); err != nil {
return nil, err
}
return email.NewVerificationInvalid(d, &t), nil
case template.TypeVerificationValid:
var t email.VerificationValidModel
if err := json.Unmarshal(msg.TemplateData, &t); err != nil {
return nil, err
}
return email.NewVerificationValid(d, &t), nil
case template.TypeVerificationCodeInvalid:
var t email.VerificationCodeInvalidModel
if err := json.Unmarshal(msg.TemplateData, &t); err != nil {
return nil, err
}
return email.NewVerificationCodeInvalid(d, &t), nil
case template.TypeVerificationCodeValid:
var t email.VerificationCodeValidModel
if err := json.Unmarshal(msg.TemplateData, &t); err != nil {
return nil, err
}
return email.NewVerificationCodeValid(d, &t), nil
case template.TypeTestStub:
var t email.TestStubModel
if err := json.Unmarshal(msg.TemplateData, &t); err != nil {
return nil, err
}
return email.NewTestStub(&t), nil
case template.TypeLoginCodeValid:
var t email.LoginCodeValidModel
if err := json.Unmarshal(msg.TemplateData, &t); err != nil {
return nil, err
}
return email.NewLoginCodeValid(d, &t), nil
case template.TypeRegistrationCodeValid:
var t email.RegistrationCodeValidModel
if err := json.Unmarshal(msg.TemplateData, &t); err != nil {
return nil, err
}
return email.NewRegistrationCodeValid(d, &t), nil
case template.TypeVerifiableAddressChanged:
var t email.VerifiableAddressChangedModel
if err := json.Unmarshal(msg.TemplateData, &t); err != nil {
return nil, err
}
return email.NewVerifiableAddressChanged(d, &t), nil
case template.TypeAuthenticatorKeyAdded:
var t email.AuthenticatorKeyAddedModel
if err := json.Unmarshal(msg.TemplateData, &t); err != nil {
return nil, err
}
return email.NewAuthenticatorKeyAdded(d, &t), nil
default:
return nil, errors.Errorf("received unexpected message template type: %s", msg.TemplateType)
}
}

View File

@@ -0,0 +1,74 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier_test
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/pkg"
)
func TestNewEmailTemplateFromMessage(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
ctx := context.Background()
for tmplType, expectedTmpl := range map[template.TemplateType]courier.EmailTemplate{
template.TypeRecoveryInvalid: email.NewRecoveryInvalid(reg, &email.RecoveryInvalidModel{To: "foo"}),
template.TypeRecoveryValid: email.NewRecoveryValid(reg, &email.RecoveryValidModel{To: "bar", RecoveryURL: "http://foo.bar"}),
template.TypeRecoveryCodeValid: email.NewRecoveryCodeValid(reg, &email.RecoveryCodeValidModel{To: "bar", RecoveryCode: "12345678"}),
template.TypeRecoveryCodeInvalid: email.NewRecoveryCodeInvalid(reg, &email.RecoveryCodeInvalidModel{To: "bar"}),
template.TypeVerificationInvalid: email.NewVerificationInvalid(reg, &email.VerificationInvalidModel{To: "baz"}),
template.TypeVerificationValid: email.NewVerificationValid(reg, &email.VerificationValidModel{To: "faz", VerificationURL: "http://bar.foo"}),
template.TypeVerificationCodeInvalid: email.NewVerificationCodeInvalid(reg, &email.VerificationCodeInvalidModel{To: "baz"}),
template.TypeVerificationCodeValid: email.NewVerificationCodeValid(reg, &email.VerificationCodeValidModel{To: "faz", VerificationURL: "http://bar.foo", VerificationCode: "123456678"}),
template.TypeTestStub: email.NewTestStub(&email.TestStubModel{To: "far", Subject: "test subject", Body: "test body"}),
template.TypeLoginCodeValid: email.NewLoginCodeValid(reg, &email.LoginCodeValidModel{To: "far", LoginCode: "123456"}),
template.TypeRegistrationCodeValid: email.NewRegistrationCodeValid(reg, &email.RegistrationCodeValidModel{To: "far", RegistrationCode: "123456"}),
template.TypeVerifiableAddressChanged: email.NewVerifiableAddressChanged(reg, &email.VerifiableAddressChangedModel{To: "far", ChangedAt: "2026-04-21T12:00:00Z", Identity: map[string]any{"ID": "00000000-0000-0000-0000-000000000001"}}),
template.TypeAuthenticatorKeyAdded: email.NewAuthenticatorKeyAdded(reg, &email.AuthenticatorKeyAddedModel{To: "far", AddedAt: "2026-04-21T12:00:00Z", Identity: map[string]any{"ID": "00000000-0000-0000-0000-000000000001"}}),
} {
t.Run(fmt.Sprintf("case=%s", tmplType), func(t *testing.T) {
tmplData, err := json.Marshal(expectedTmpl)
require.NoError(t, err)
m := courier.Message{TemplateType: tmplType, TemplateData: tmplData}
actualTmpl, err := courier.NewEmailTemplateFromMessage(reg, m)
require.NoError(t, err)
require.IsType(t, expectedTmpl, actualTmpl)
expectedRecipient, err := expectedTmpl.EmailRecipient()
require.NoError(t, err)
actualRecipient, err := actualTmpl.EmailRecipient()
require.NoError(t, err)
require.Equal(t, expectedRecipient, actualRecipient)
expectedSubject, err := expectedTmpl.EmailSubject(ctx)
require.NoError(t, err)
actualSubject, err := actualTmpl.EmailSubject(ctx)
require.NoError(t, err)
require.Equal(t, expectedSubject, actualSubject)
expectedBody, err := expectedTmpl.EmailBody(ctx)
require.NoError(t, err)
actualBody, err := actualTmpl.EmailBody(ctx)
require.NoError(t, err)
require.Equal(t, expectedBody, actualBody)
expectedBodyPlaintext, err := expectedTmpl.EmailBodyPlaintext(ctx)
require.NoError(t, err)
actualBodyPlaintext, err := actualTmpl.EmailBodyPlaintext(ctx)
require.NoError(t, err)
require.Equal(t, expectedBodyPlaintext, actualBodyPlaintext)
})
}
}

219
courier/handler.go Normal file
View File

@@ -0,0 +1,219 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier
import (
"net/http"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/x/redir"
"github.com/ory/x/httprouterx"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
keysetpagination "github.com/ory/x/pagination/keysetpagination_v2"
)
const (
AdminRouteCourier = "/courier"
AdminRouteListMessages = AdminRouteCourier + "/messages"
AdminRouteGetMessage = AdminRouteCourier + "/messages/{msgID}"
)
type (
handlerDependencies interface {
httpx.WriterProvider
logrusx.Provider
nosurfx.CSRFProvider
PersistenceProvider
config.Provider
}
Handler struct {
r handlerDependencies
}
HandlerProvider interface {
CourierHandler() *Handler
}
)
func NewHandler(r handlerDependencies) *Handler {
return &Handler{r: r}
}
func (h *Handler) RegisterPublicRoutes(public *httprouterx.RouterPublic) {
h.r.CSRFHandler().IgnoreGlobs(httprouterx.AdminPrefix+AdminRouteListMessages, AdminRouteListMessages)
public.GET(httprouterx.AdminPrefix+AdminRouteListMessages, redir.RedirectToAdminRoute(h.r))
public.GET(httprouterx.AdminPrefix+AdminRouteGetMessage, redir.RedirectToAdminRoute(h.r))
}
func (h *Handler) RegisterAdminRoutes(admin *httprouterx.RouterAdmin) {
admin.GET(AdminRouteListMessages, h.listCourierMessages)
admin.GET(AdminRouteGetMessage, h.getCourierMessage)
}
// Paginated Courier Message List Response
//
// swagger:response listCourierMessages
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type listCourierMessagesResponse struct {
keysetpagination.ResponseHeaders
// List of identities
//
// in:body
Body []Message
}
// Paginated List Courier Message Parameters
//
// swagger:parameters listCourierMessages
type ListCourierMessagesParameters struct {
keysetpagination.RequestParameters
// Status filters out messages based on status.
// If no value is provided, it doesn't take effect on filter.
//
// required: false
// in: query
Status *MessageStatus `json:"status"`
// Recipient filters out messages based on recipient.
// If no value is provided, it doesn't take effect on filter.
//
// required: false
// in: query
Recipient string `json:"recipient"`
}
// swagger:route GET /admin/courier/messages courier listCourierMessages
//
// # List Messages
//
// Lists all messages by given status and recipient.
//
// Produces:
// - application/json
//
// Security:
// oryAccessToken:
//
// Schemes: http, https
//
// Responses:
// 200: listCourierMessages
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-admin-low
func (h *Handler) listCourierMessages(w http.ResponseWriter, r *http.Request) {
keys := h.r.Config().SecretsPagination(r.Context())
filter, paginator, err := parseMessagesFilter(r, keys)
if err != nil {
h.r.Writer().WriteErrorCode(w, r, http.StatusBadRequest, err)
return
}
messages, nextPage, err := h.r.CourierPersister().ListMessages(r.Context(), filter, paginator)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
if !h.r.Config().IsInsecureDevMode(r.Context()) {
for i := range messages {
messages[i].Body = "<redacted-unless-dev-mode>"
messages[i].Subject = "<redacted-unless-dev-mode>"
}
}
u := *r.URL
keysetpagination.SetLinkHeader(w, keys, &u, nextPage)
h.r.Writer().Write(w, r, messages)
}
func parseMessagesFilter(r *http.Request, keys [][32]byte) (ListCourierMessagesParameters, []keysetpagination.Option, error) {
var status *MessageStatus
if r.URL.Query().Has("status") {
ms, err := ToMessageStatus(r.URL.Query().Get("status"))
if err != nil {
return ListCourierMessagesParameters{}, nil, errors.WithStack(err)
}
status = &ms
}
opts, err := keysetpagination.ParseQueryParams(keys, r.URL.Query())
if err != nil {
return ListCourierMessagesParameters{}, nil, errors.WithStack(err)
}
return ListCourierMessagesParameters{
Status: status,
Recipient: r.URL.Query().Get("recipient"),
}, opts, nil
}
// Get Courier Message Parameters
//
// swagger:parameters getCourierMessage
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type getCourierMessage struct {
// MessageID is the ID of the message.
//
// required: true
// in: path
MessageID string `json:"id"`
}
// swagger:route GET /admin/courier/messages/{id} courier getCourierMessage
//
// # Get a Message
//
// Gets a specific messages by the given ID.
//
// Produces:
// - application/json
//
// Security:
// oryAccessToken:
//
// Schemes: http, https
//
// Responses:
// 200: message
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-admin-medium
func (h *Handler) getCourierMessage(w http.ResponseWriter, r *http.Request) {
msgID, err := uuid.FromString(r.PathValue("msgID"))
if err != nil {
h.r.Writer().WriteError(w, r, herodot.ErrBadRequest().WithError(err.Error()).WithDebugf("could not parse parameter {id} as UUID, got %s", r.PathValue("id")))
return
}
message, err := h.r.CourierPersister().FetchMessage(r.Context(), msgID)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
if !h.r.Config().IsInsecureDevMode(r.Context()) {
message.Body = "<redacted-unless-dev-mode>"
message.Subject = "<redacted-unless-dev-mode>"
}
h.r.Writer().Write(w, r, message)
}

285
courier/handler_test.go Normal file
View File

@@ -0,0 +1,285 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier_test
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-faker/faker/v4"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/courier"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/x/httprouterx"
"github.com/ory/x/ioutilx"
"github.com/ory/x/snapshotx"
"github.com/ory/x/urlx"
"github.com/ory/x/uuidx"
)
var defaultPageToken = courier.Message{}.DefaultPageToken().Encrypt(nil)
func TestHandler(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
// Start kratos server
publicTS, adminTS := testhelpers.NewKratosServerWithCSRF(t, reg)
tss := []struct {
name string
s *httptest.Server
}{
{
name: "public",
s: publicTS,
},
{
name: "admin",
s: adminTS,
},
}
mockServerURL := urlx.ParseOrPanic(publicTS.URL)
conf.MustSet(ctx, config.ViperKeyAdminBaseURL, adminTS.URL)
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, mockServerURL.String())
get := func(t *testing.T, base *httptest.Server, href string, expectCode int) gjson.Result {
t.Helper()
res, err := base.Client().Get(base.URL + href)
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
assert.EqualValuesf(t, expectCode, res.StatusCode, "%s", body)
return gjson.ParseBytes(body)
}
getList := func(t *testing.T, tsName string, qs string) gjson.Result {
t.Helper()
href := courier.AdminRouteListMessages + qs
ts := adminTS
if tsName == "public" {
href = httprouterx.AdminPrefix + href
ts = publicTS
}
parsed := get(t, ts, href, http.StatusOK)
require.Truef(t, parsed.IsArray(), "%s", parsed.Raw)
return parsed
}
t.Run("case=should return an empty list of messages", func(t *testing.T) {
for _, name := range []string{"public", "admin"} {
t.Run("endpoint="+name, func(t *testing.T) {
parsed := getList(t, name, "")
assert.Len(t, parsed.Array(), 0)
})
}
})
t.Run("case=list messages", func(t *testing.T) {
// Arrange test data
const msgCount = 10 // total message count
const procCount = 5 // how many messages' status should be equal to `processing`
const rcptOryCount = 2 // how many messages' recipient should be equal to `noreply@ory.sh`
messages := make([]courier.Message, msgCount)
for i := range messages {
require.NoError(t, faker.FakeData(&messages[i]))
messages[i].Type = courier.MessageTypeEmail
messages[i].Body = "body content"
if i < rcptOryCount {
messages[i].Recipient = "noreply@ory.sh"
}
require.NoError(t, reg.CourierPersister().AddMessage(context.Background(), &messages[i]))
}
for i := range procCount {
require.NoError(t, reg.CourierPersister().SetMessageStatus(context.Background(), messages[i].ID, courier.MessageStatusProcessing))
}
t.Run("paging", func(t *testing.T) {
t.Run("case=should return first half of the messages", func(t *testing.T) {
qs := fmt.Sprintf("?page_token=%s&page_size=%d", defaultPageToken, msgCount/2)
for _, tc := range tss {
t.Run("endpoint="+tc.name, func(t *testing.T) {
parsed := getList(t, tc.name, qs)
assert.Len(t, parsed.Array(), msgCount/2)
})
}
})
t.Run("case=should error with random page token", func(t *testing.T) {
qs := fmt.Sprintf(`?page_token=%s&page_size=%s`, uuidx.NewV4().String(), "250")
for _, tc := range tss {
t.Run("endpoint="+tc.name, func(t *testing.T) {
path := courier.AdminRouteListMessages + qs
if tc.name == "public" {
path = httprouterx.AdminPrefix + path
}
resp, err := tc.s.Client().Get(tc.s.URL + path)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
}
})
})
t.Run("filtering", func(t *testing.T) {
t.Run("case=should return all queued messages", func(t *testing.T) {
qs := fmt.Sprintf(`?page_token=%s&page_size=250&status=queued`, defaultPageToken)
for _, tc := range tss {
t.Run("endpoint="+tc.name, func(t *testing.T) {
parsed := getList(t, tc.name, qs)
assert.Len(t, parsed.Array(), msgCount-procCount)
for _, item := range parsed.Array() {
assert.Equal(t, "queued", item.Get("status").String())
}
})
}
})
t.Run("case=should return all processing messages", func(t *testing.T) {
qs := fmt.Sprintf(`?page_token=%s&page_size=250&status=processing`, defaultPageToken)
for _, tc := range tss {
t.Run("endpoint="+tc.name, func(t *testing.T) {
parsed := getList(t, tc.name, qs)
assert.Len(t, parsed.Array(), procCount)
for _, item := range parsed.Array() {
assert.Equal(t, "processing", item.Get("status").String())
}
})
}
})
t.Run("case=should return all messages with recipient equals to noreply@ory.sh", func(t *testing.T) {
qs := fmt.Sprintf(`?page_token=%s&page_size=250&recipient=noreply@ory.sh`, defaultPageToken)
for _, tc := range tss {
t.Run("endpoint="+tc.name, func(t *testing.T) {
parsed := getList(t, tc.name, qs)
assert.Len(t, parsed.Array(), rcptOryCount)
for _, item := range parsed.Array() {
assert.Equal(t, "noreply@ory.sh", item.Get("recipient").String())
}
})
}
})
})
t.Run("case=body should be redacted if kratos is not in dev mode", func(t *testing.T) {
conf.MustSet(ctx, "dev", false)
for _, tc := range tss {
t.Run("endpoint="+tc.name, func(t *testing.T) {
parsed := getList(t, tc.name, "")
require.Lenf(t, parsed.Array(), msgCount, "%s", parsed.Raw)
for _, item := range parsed.Array() {
assert.Equal(t, "<redacted-unless-dev-mode>", item.Get("body").String())
assert.Equal(t, "<redacted-unless-dev-mode>", item.Get("subject").String())
}
})
}
})
t.Run("case=body should not be redacted if kratos is in dev mode", func(t *testing.T) {
conf.MustSet(ctx, "dev", true)
for _, tc := range tss {
t.Run("endpoint="+tc.name, func(t *testing.T) {
parsed := getList(t, tc.name, "")
require.Lenf(t, parsed.Array(), msgCount, "%s", parsed.Raw)
for _, item := range parsed.Array() {
assert.Equal(t, "body content", item.Get("body").String())
assert.NotEqual(t, "<redacted-unless-dev-mode>", item.Get("subject").String())
}
})
}
})
t.Run("case=should return with http status BadRequest when given status is invalid", func(t *testing.T) {
qs := fmt.Sprintf(`?page_token=%s&page_size=250&status=invalid_status`, defaultPageToken)
res, err := adminTS.Client().Get(adminTS.URL + courier.AdminRouteListMessages + qs)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, res.StatusCode, "status code should be equal to StatusBadRequest")
})
})
t.Run("handler=getCourierMessage", func(t *testing.T) {
message := courier.Message{}
require.NoError(t, faker.FakeData(&message))
message.Type = courier.MessageTypeEmail
message.Body = "body content"
require.NoError(t, reg.CourierPersister().AddMessage(context.Background(), &message))
require.NoError(t, reg.CourierPersister().RecordDispatch(ctx, message.ID, courier.CourierMessageDispatchStatusSuccess, errors.New("some error")))
getCourierMessag := func(s *httptest.Server, id string) gjson.Result {
r, err := s.Client().Get(s.URL + "/admin/courier/messages/" + id)
require.NoError(t, err)
return gjson.ParseBytes(ioutilx.MustReadAll(r.Body))
}
t.Run("case=should return a message by id", func(t *testing.T) {
conf.MustSet(ctx, "dev", true)
for _, tc := range tss {
t.Run("endpoint="+tc.name, func(t *testing.T) {
body := getCourierMessag(tc.s, message.ID.String())
assert.Equal(t, message.ID.String(), body.Get("id").String())
assert.Equal(t, message.Recipient, body.Get("recipient").String())
assert.Equal(t, message.Body, body.Get("body").String())
assert.Equal(t, message.Subject, body.Get("subject").String())
// assert Eager works
assert.NotEmpty(t, body.Get("dispatches").Array())
})
}
})
t.Run("case=does not contain body if not in production", func(t *testing.T) {
conf.MustSet(ctx, "dev", false)
for _, tc := range tss {
t.Run("endpoint="+tc.name, func(t *testing.T) {
body := getCourierMessag(tc.s, message.ID.String())
assert.Equal(t, message.ID.String(), body.Get("id").String())
assert.Equal(t, "<redacted-unless-dev-mode>", body.Get("body").String())
assert.Equal(t, "<redacted-unless-dev-mode>", body.Get("subject").String())
})
}
})
t.Run("case=returns an error if parameter is malformed", func(t *testing.T) {
for _, tc := range tss {
t.Run("endpoint="+tc.name, func(t *testing.T) {
body := getCourierMessag(tc.s, "not-a-uuid")
snapshotx.SnapshotTJSON(t, body.Raw)
})
}
})
t.Run("case=returns an error if no message is found", func(t *testing.T) {
for _, tc := range tss {
t.Run("endpoint="+tc.name, func(t *testing.T) {
body := getCourierMessag(tc.s, uuid.Nil.String())
snapshotx.SnapshotTJSON(t, body.Raw)
})
}
})
})
}

157
courier/http_channel.go Normal file
View File

@@ -0,0 +1,157 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier
import (
"context"
"encoding/json"
"fmt"
"io"
"time"
"github.com/pkg/errors"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/request"
"github.com/ory/x/jsonnetsecure"
"github.com/ory/x/otelx"
)
type (
httpChannel struct {
id string
requestConfig *request.Config
d channelDependencies
}
channelDependencies interface {
otelx.Provider
logrusx.Provider
httpx.ClientProvider
jsonnetsecure.VMProvider
ConfigProvider
}
)
var _ Channel = new(httpChannel)
func newHttpChannel(id string, requestConfig *request.Config, d channelDependencies) *httpChannel {
return &httpChannel{
id: id,
requestConfig: requestConfig,
d: d,
}
}
func (c *httpChannel) ID() string {
return c.id
}
type httpDataModel struct {
Recipient string `json:"recipient"`
Subject string `json:"subject"`
Body string `json:"body"`
// HTMLBody optionally contains the HTML version of an email template when available.
HTMLBody string `json:"html_body,omitempty"`
TemplateType template.TemplateType `json:"template_type"`
TemplateData Template `json:"template_data"`
MessageType string `json:"message_type"`
RequestHeaders json.RawMessage `json:"request_headers"`
}
func (c *httpChannel) Dispatch(ctx context.Context, msg Message) (err error) {
ctx, span := c.d.Tracer(ctx).Tracer().Start(ctx, "courier.httpChannel.Dispatch")
defer otelx.End(span, &err)
builder, err := request.NewBuilder(c.requestConfig, c.d)
if err != nil {
return errors.WithStack(err)
}
tmpl, err := newTemplate(c.d, msg)
if err != nil {
return errors.WithStack(err)
}
td := httpDataModel{
Recipient: msg.Recipient,
Subject: msg.Subject,
Body: msg.Body,
TemplateType: msg.TemplateType,
TemplateData: tmpl,
RequestHeaders: msg.RequestHeaders,
MessageType: msg.Type.String(),
}
c.tryPopulateHTMLBody(ctx, tmpl, &td)
req, err := builder.BuildRequest(ctx, td)
if err != nil {
return errors.WithStack(err)
}
res, err := c.d.HTTPClient(ctx,
// fail fast and let the courier retry if needed instead of blocking the queue
httpx.ResilientClientWithMaxRetry(0),
httpx.ResilientClientWithConnectionTimeout(10*time.Second),
).Do(req)
if err != nil {
return errors.WithStack(err)
}
// Close the original body, not the NopCloser below: closing it returns
// the connection to the pool and ends the otelhttp client span.
defer func(body io.ReadCloser) { _ = body.Close() }(res.Body)
res.Body = io.NopCloser(io.LimitReader(res.Body, 1024))
logger := c.d.Logger().
WithField("http_server", c.requestConfig.URL).
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)
if res.StatusCode >= 200 && res.StatusCode < 300 {
logger.Debug("Courier sent out mailer.")
return nil
}
err = errors.Errorf(
"unable to dispatch mail delivery because upstream server replied with status code %d",
res.StatusCode,
)
body, _ := io.ReadAll(res.Body)
logger.
WithError(err).
WithField("http_response_body", string(body)).
Error("sending mail via HTTP failed.")
return errors.WithStack(err)
}
func (c *httpChannel) tryPopulateHTMLBody(ctx context.Context, tmpl Template, td *httpDataModel) {
if emailTmpl, ok := tmpl.(EmailTemplate); ok {
// Only get the HTML body from the template; plaintext body comes from msg.Body
// to maintain backward compatibility with existing behavior
if htmlBody, err := emailTmpl.EmailBody(ctx); err != nil {
c.d.Logger().WithError(err).Error("Unable to get email HTML body from template.")
} else {
td.HTMLBody = htmlBody
}
}
}
func newTemplate(d template.Dependencies, msg Message) (Template, error) {
switch msg.Type {
case MessageTypeEmail:
return NewEmailTemplateFromMessage(d, msg)
case MessageTypeSMS:
return NewSMSTemplateFromMessage(d, msg)
default:
return nil, fmt.Errorf("received unexpected message type: %s", msg.Type)
}
}

169
courier/http_test.go Normal file
View File

@@ -0,0 +1,169 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier_test
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/x/configx"
)
func TestQueueHTTPEmail(t *testing.T) {
type sendEmailRequestBody struct {
IdentityID string `json:"identity_id"`
IdentityEmail string `json:"identity_email"`
Recipient string `json:"recipient"`
TemplateType string `json:"template_type"`
To string `json:"to"`
RecoveryCode string `json:"recovery_code"`
RecoveryURL string `json:"recovery_url"`
VerificationURL string `json:"verification_url"`
VerificationCode string `json:"verification_code"`
Body string `json:"body"`
HTMLBody string `json:"html_body"`
Subject string `json:"subject"`
}
expectedEmail := []*email.TestStubModel{
{
To: "test-2@test.com",
Subject: "test-mailer-subject-1",
Body: "test-mailer-body-1",
HTMLBody: "<html><body>test-mailer-body-html-1</body></html>",
},
{
To: "test-2@test.com",
Subject: "test-mailer-subject-2",
Body: "test-mailer-body-2",
},
}
actual := make(chan sendEmailRequestBody, len(expectedEmail))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rb, err := io.ReadAll(r.Body)
require.NoError(t, err)
var body sendEmailRequestBody
err = json.Unmarshal(rb, &body)
require.NoError(t, err)
assert.NotEmpty(t, r.Header["Authorization"])
assert.Equal(t, "Basic bWU6MTIzNDU=", r.Header["Authorization"][0])
actual <- body
}))
t.Cleanup(srv.Close)
requestConfig := fmt.Sprintf(`{
"url": "%s",
"method": "POST",
"auth": {
"type": "basic_auth",
"config": {
"user": "me",
"password": "12345"
}
},
"body": "file://./stub/request.config.mailer.jsonnet"
}`, srv.URL)
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]any{
config.ViperKeyCourierDeliveryStrategy: "http",
config.ViperKeyCourierHTTPRequestConfig: requestConfig,
config.ViperKeyCourierSMTPURL: "http://foo.url",
}))
courier, err := reg.Courier(t.Context())
require.NoError(t, err)
for _, message := range expectedEmail {
id, err := courier.QueueEmail(t.Context(), email.NewTestStub(message))
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, id)
}
require.NoError(t, courier.DispatchQueue(t.Context()))
close(actual)
require.Len(t, actual, len(expectedEmail))
i := 0
for message := range actual {
expected := expectedEmail[i]
assert.Equal(t, expected.To, message.To)
assert.Equal(t, expected.Body, message.Body)
assert.Equal(t, expected.HTMLBody, message.HTMLBody)
assert.Equal(t, expected.Subject, message.Subject)
i++
}
}
func TestQueueHTTPEmailWithOAuth2LoginRequest(t *testing.T) {
actual := make(chan []byte, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rb, err := io.ReadAll(r.Body)
require.NoError(t, err)
actual <- rb
}))
t.Cleanup(srv.Close)
requestConfig := fmt.Sprintf(`{
"url": "%s",
"method": "POST",
"body": "base64://%s"
}`, srv.URL, base64.StdEncoding.EncodeToString([]byte("function(ctx) ctx")))
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]any{
config.ViperKeyCourierDeliveryStrategy: "http",
config.ViperKeyCourierHTTPRequestConfig: requestConfig,
config.ViperKeyCourierSMTPURL: "http://foo.url",
}))
c, err := reg.Courier(t.Context())
require.NoError(t, err)
_, err = c.QueueEmail(t.Context(), email.NewLoginCodeValid(reg, &email.LoginCodeValidModel{
To: "test@test.com",
LoginCode: "123456",
OAuth2LoginRequest: &template.OAuth2LoginRequest{
Challenge: "test-challenge",
Client: template.OAuth2Client{
ClientID: "test-client-id",
ClientName: "Test Client",
ClientURI: "https://client.example.com",
LogoURI: "https://client.example.com/logo.png",
Metadata: map[string]any{"brand": "acme"},
},
},
}))
require.NoError(t, err)
require.NoError(t, c.DispatchQueue(t.Context()))
body := <-actual
assert.Equal(t, "test-challenge", gjson.GetBytes(body, "template_data.oauth2_login_request.challenge").String())
assert.Equal(t, "test-client-id", gjson.GetBytes(body, "template_data.oauth2_login_request.client.client_id").String())
assert.Equal(t, "Test Client", gjson.GetBytes(body, "template_data.oauth2_login_request.client.client_name").String())
assert.Equal(t, "https://client.example.com", gjson.GetBytes(body, "template_data.oauth2_login_request.client.client_uri").String())
assert.Equal(t, "https://client.example.com/logo.png", gjson.GetBytes(body, "template_data.oauth2_login_request.client.logo_uri").String())
assert.Equal(t, "acme", gjson.GetBytes(body, "template_data.oauth2_login_request.client.metadata.brand").String())
}

226
courier/message.go Normal file
View File

@@ -0,0 +1,226 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier
import (
"encoding/json"
"time"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/courier/template"
keysetpagination "github.com/ory/x/pagination/keysetpagination_v2"
"github.com/ory/x/sqlxx"
"github.com/ory/x/stringsx"
)
// A Message's Status
//
// swagger:model courierMessageStatus
type MessageStatus int
const (
MessageStatusQueued MessageStatus = iota + 1
MessageStatusSent
MessageStatusProcessing
MessageStatusAbandoned
)
const (
messageStatusQueuedText = "queued"
messageStatusSentText = "sent"
messageStatusProcessingText = "processing"
messageStatusAbandonedText = "abandoned"
)
func ToMessageStatus(str string) (MessageStatus, error) {
switch s := stringsx.SwitchExact(str); {
case s.AddCase(MessageStatusQueued.String()):
return MessageStatusQueued, nil
case s.AddCase(MessageStatusSent.String()):
return MessageStatusSent, nil
case s.AddCase(MessageStatusProcessing.String()):
return MessageStatusProcessing, nil
case s.AddCase(MessageStatusAbandoned.String()):
return MessageStatusAbandoned, nil
default:
return 0, errors.WithStack(herodot.ErrBadRequest().WithWrap(s.ToUnknownCaseErr()).WithReason("Message status is not valid"))
}
}
func (ms MessageStatus) String() string {
switch ms {
case MessageStatusQueued:
return messageStatusQueuedText
case MessageStatusSent:
return messageStatusSentText
case MessageStatusProcessing:
return messageStatusProcessingText
case MessageStatusAbandoned:
return messageStatusAbandonedText
default:
return ""
}
}
func (ms MessageStatus) IsValid() error {
switch ms {
case MessageStatusQueued, MessageStatusSent, MessageStatusProcessing, MessageStatusAbandoned:
return nil
default:
return errors.WithStack(herodot.ErrBadRequest().WithReason("Message status is not valid"))
}
}
func (ms MessageStatus) MarshalJSON() ([]byte, error) {
if err := ms.IsValid(); err != nil {
return nil, err
}
return json.Marshal(ms.String())
}
func (ms *MessageStatus) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
s, err := ToMessageStatus(str)
if err != nil {
return err
}
*ms = s
return nil
}
// A Message's Type
//
// It can either be `email` or `phone`
//
// swagger:model courierMessageType
type MessageType int
const (
MessageTypeEmail MessageType = iota + 1
MessageTypeSMS
)
const (
messageTypeEmailText = "email"
messageTypeSMSText = "sms"
)
func ToMessageType(str string) (MessageType, error) {
switch s := stringsx.SwitchExact(str); {
case s.AddCase(messageTypeEmailText):
return MessageTypeEmail, nil
case s.AddCase(messageTypeSMSText):
return MessageTypeSMS, nil
default:
return 0, errors.WithStack(herodot.ErrBadRequest().WithWrap(s.ToUnknownCaseErr()).WithReason("Message type is not valid"))
}
}
func (mt MessageType) String() string {
switch mt {
case MessageTypeEmail:
return messageTypeEmailText
case MessageTypeSMS:
return messageTypeSMSText
default:
return ""
}
}
func (mt MessageType) IsValid() error {
switch mt {
case MessageTypeEmail, MessageTypeSMS:
return nil
default:
return errors.WithStack(herodot.ErrBadRequest().WithReason("Message type is not valid"))
}
}
func (mt MessageType) MarshalJSON() ([]byte, error) {
if err := mt.IsValid(); err != nil {
return nil, err
}
return json.Marshal(mt.String())
}
func (mt *MessageType) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
t, err := ToMessageType(str)
if err != nil {
return err
}
*mt = t
return nil
}
// swagger:model message
type Message struct {
// required: true
ID uuid.UUID `json:"id" faker:"-" db:"id"`
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
// required: true
Status MessageStatus `json:"status" db:"status"`
// required: true
Type MessageType `json:"type" db:"type"`
// required: true
Recipient string `json:"recipient" db:"recipient"`
// required: true
Body string `json:"body" db:"body"`
// required: true
Subject string `json:"subject" db:"subject"`
// required: true
TemplateType template.TemplateType `json:"template_type" db:"template_type"`
Channel sqlxx.NullString `json:"channel" db:"channel"`
TemplateData []byte `json:"-" db:"template_data"`
RequestHeaders []byte `json:"-" faker:"-" db:"request_headers"`
// required: true
SendCount int `json:"send_count" db:"send_count"`
// Dispatches store information about the attempts of delivering a message
// May contain an error if any happened, or just the `success` state.
Dispatches []MessageDispatch `json:"dispatches,omitempty" has_many:"courier_message_dispatches" order_by:"created_at desc" faker:"-"`
// CreatedAt is a helper struct field for gobuffalo.pop.
// required: true
CreatedAt time.Time `json:"created_at" faker:"-" db:"created_at"`
// UpdatedAt is a helper struct field for gobuffalo.pop.
// required: true
UpdatedAt time.Time `json:"updated_at" faker:"-" db:"updated_at"`
}
func (m Message) PageToken() keysetpagination.PageToken {
return keysetpagination.NewPageToken(
keysetpagination.Column{
Name: "created_at",
Order: keysetpagination.OrderDescending,
Value: m.CreatedAt,
}, keysetpagination.Column{
Name: "id",
Value: m.ID,
},
)
}
func (m Message) DefaultPageToken() keysetpagination.PageToken {
return Message{ID: uuid.Nil, CreatedAt: time.Date(2200, 12, 31, 23, 59, 59, 0, time.UTC)}.PageToken()
}
func (m Message) TableName() string { return "courier_messages" }
func (m *Message) GetID() uuid.UUID { return m.ID }

View File

@@ -0,0 +1,55 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier
import (
"time"
"github.com/gofrs/uuid"
"github.com/ory/x/sqlxx"
)
// swagger:enum CourierMessageDispatchStatus
type CourierMessageDispatchStatus string
const (
CourierMessageDispatchStatusFailed CourierMessageDispatchStatus = "failed"
CourierMessageDispatchStatusSuccess CourierMessageDispatchStatus = "success"
)
// MessageDispatch represents an attempt of sending a courier message
// It contains the status of the attempt (failed or successful) and the error if any occured
//
// swagger:model messageDispatch
type MessageDispatch struct {
// The ID of this message dispatch
// required: true
ID uuid.UUID `json:"id" db:"id"`
// The ID of the message being dispatched
// required: true
MessageID uuid.UUID `json:"message_id" db:"message_id"`
// The status of this dispatch
// Either "failed" or "success"
// required: true
Status CourierMessageDispatchStatus `json:"status" db:"status"`
// An optional error
Error sqlxx.JSONRawMessage `json:"error,omitempty" db:"error"`
// CreatedAt is a helper struct field for gobuffalo.pop.
// required: true
CreatedAt time.Time `json:"created_at" db:"created_at"`
// UpdatedAt is a helper struct field for gobuffalo.pop.
// required: true
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
NID uuid.UUID `json:"-" db:"nid"`
}
func (MessageDispatch) TableName() string {
return "courier_message_dispatches"
}

61
courier/message_test.go Normal file
View File

@@ -0,0 +1,61 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/ory/herodot"
"github.com/ory/kratos/courier"
)
func TestMessageStatusValidity(t *testing.T) {
invalid := courier.MessageStatus(0)
require.ErrorIs(t, invalid.IsValid(), herodot.ErrBadRequest(), "IsValid() should return an error when message status is invalid")
}
func TestToMessageStatus(t *testing.T) {
t.Run("case=should return corresponding MessageStatus for given str", func(t *testing.T) {
for str, exp := range map[string]courier.MessageStatus{
"queued": courier.MessageStatusQueued,
"sent": courier.MessageStatusSent,
"processing": courier.MessageStatusProcessing,
"abandoned": courier.MessageStatusAbandoned,
} {
result, err := courier.ToMessageStatus(str)
require.NoError(t, err)
require.Equal(t, exp, result)
}
})
t.Run("case=should return error for invalid message status str", func(t *testing.T) {
result, err := courier.ToMessageStatus("invalid")
require.Error(t, err, herodot.ErrBadRequest())
require.Error(t, result.IsValid(), herodot.ErrBadRequest())
})
}
func TestMessageTypeValidity(t *testing.T) {
invalid := courier.MessageType(0)
require.ErrorIs(t, invalid.IsValid(), herodot.ErrBadRequest(), "IsValid() should return an error when message type is invalid")
}
func TestToMessageType(t *testing.T) {
t.Run("case=should return corresponding MessageType for given str", func(t *testing.T) {
for str, exp := range map[string]courier.MessageType{
"email": courier.MessageTypeEmail,
"sms": courier.MessageTypeSMS,
} {
result, err := courier.ToMessageType(str)
require.NoError(t, err)
require.Equal(t, exp, result)
}
})
t.Run("case=should return error for invalid message type str", func(t *testing.T) {
result, err := courier.ToMessageType("invalid")
require.ErrorIs(t, err, herodot.ErrBadRequest())
require.ErrorIs(t, result.IsValid(), herodot.ErrBadRequest())
})
}

43
courier/persistence.go Normal file
View File

@@ -0,0 +1,43 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier
import (
"context"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
keysetpagination "github.com/ory/x/pagination/keysetpagination_v2"
)
var ErrQueueEmpty = errors.New("queue is empty")
type (
Persister interface {
AddMessage(context.Context, *Message) error
NextMessages(context.Context, uint8) ([]Message, error)
SetMessageStatus(context.Context, uuid.UUID, MessageStatus) error
LatestQueuedMessage(ctx context.Context) (*Message, error)
IncrementMessageSendCount(context.Context, uuid.UUID) error
// ListMessages lists all messages in the store given the page, itemsPerPage, status and recipient.
// Returns list of messages, total count of messages satisfied by given filter, and error if any
ListMessages(context.Context, ListCourierMessagesParameters, []keysetpagination.Option) ([]Message, *keysetpagination.Paginator, error)
// FetchMessage returns a message with the id or nil and an error if not found
FetchMessage(context.Context, uuid.UUID) (*Message, error)
// Records an attempt of sending out a courier message
// Returns an error if it fails
RecordDispatch(ctx context.Context, msgID uuid.UUID, status CourierMessageDispatchStatus, err error) error
}
PersistenceProvider interface {
CourierPersister() Persister
}
)

52
courier/sms.go Normal file
View File

@@ -0,0 +1,52 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier
import (
"context"
"encoding/json"
"github.com/gofrs/uuid"
)
func (c *courier) QueueSMS(ctx context.Context, t SMSTemplate) (uuid.UUID, error) {
recipient, err := t.PhoneNumber()
if err != nil {
return uuid.Nil, err
}
templateData, err := json.Marshal(t)
if err != nil {
return uuid.Nil, err
}
body, err := t.SMSBody(ctx)
if err != nil {
return uuid.Nil, err
}
requestHeaders := []byte(`{}`)
if t, ok := t.(RequestHeadersCarrier); ok {
requestHeaders, err = json.Marshal(t.RequestHeaders())
if err != nil {
return uuid.Nil, err
}
}
message := &Message{
Status: MessageStatusQueued,
Type: MessageTypeSMS,
Channel: "sms",
Recipient: recipient,
TemplateType: t.TemplateType(),
TemplateData: templateData,
RequestHeaders: requestHeaders,
Body: body,
}
if err := c.deps.CourierPersister().AddMessage(ctx, message); err != nil {
return uuid.Nil, err
}
return message.ID, nil
}

69
courier/sms_templates.go Normal file
View File

@@ -0,0 +1,69 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier
import (
"context"
"encoding/json"
"github.com/pkg/errors"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/sms"
)
type SMSTemplate interface {
Template
SMSBody(context.Context) (string, error)
PhoneNumber() (string, error)
}
func NewSMSTemplateFromMessage(d template.Dependencies, m Message) (SMSTemplate, error) {
switch m.TemplateType {
case template.TypeVerificationCodeValid:
var t sms.VerificationCodeValidModel
if err := json.Unmarshal(m.TemplateData, &t); err != nil {
return nil, err
}
return sms.NewVerificationCodeValid(d, &t), nil
case template.TypeRecoveryCodeValid:
var t sms.RecoveryCodeValidModel
if err := json.Unmarshal(m.TemplateData, &t); err != nil {
return nil, err
}
return sms.NewRecoveryCodeValid(d, &t), nil
case template.TypeTestStub:
var t sms.TestStubModel
if err := json.Unmarshal(m.TemplateData, &t); err != nil {
return nil, err
}
return sms.NewTestStub(&t), nil
case template.TypeLoginCodeValid:
var t sms.LoginCodeValidModel
if err := json.Unmarshal(m.TemplateData, &t); err != nil {
return nil, err
}
return sms.NewLoginCodeValid(d, &t), nil
case template.TypeRegistrationCodeValid:
var t sms.RegistrationCodeValidModel
if err := json.Unmarshal(m.TemplateData, &t); err != nil {
return nil, err
}
return sms.NewRegistrationCodeValid(d, &t), nil
case template.TypeVerifiableAddressChanged:
var t sms.VerifiableAddressChangedModel
if err := json.Unmarshal(m.TemplateData, &t); err != nil {
return nil, err
}
return sms.NewVerifiableAddressChanged(d, &t), nil
case template.TypeAuthenticatorKeyAdded:
var t sms.AuthenticatorKeyAddedModel
if err := json.Unmarshal(m.TemplateData, &t); err != nil {
return nil, err
}
return sms.NewAuthenticatorKeyAdded(d, &t), nil
default:
return nil, errors.Errorf("received unexpected message template type: %s", m.TemplateType)
}
}

View File

@@ -0,0 +1,64 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier_test
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/sms"
"github.com/ory/kratos/pkg"
)
func TestSMSTemplateType(t *testing.T) {
for expectedType, tmpl := range map[template.TemplateType]courier.SMSTemplate{
template.TypeVerificationCodeValid: &sms.VerificationCodeValid{},
template.TypeTestStub: &sms.TestStub{},
} {
t.Run(fmt.Sprintf("case=%s", expectedType), func(t *testing.T) {
require.Equal(t, expectedType, tmpl.TemplateType())
})
}
}
func TestNewSMSTemplateFromMessage(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
ctx := context.Background()
for tmplType, expectedTmpl := range map[template.TemplateType]courier.SMSTemplate{
template.TypeVerificationCodeValid: sms.NewVerificationCodeValid(reg, &sms.VerificationCodeValidModel{To: "+12345678901"}),
template.TypeTestStub: sms.NewTestStub(&sms.TestStubModel{To: "+12345678901", Body: "test body"}),
template.TypeVerifiableAddressChanged: sms.NewVerifiableAddressChanged(reg, &sms.VerifiableAddressChangedModel{To: "+12345678901", ChangedAt: "2026-04-21T12:00:00Z", Identity: map[string]any{"ID": "00000000-0000-0000-0000-000000000001"}}),
template.TypeAuthenticatorKeyAdded: sms.NewAuthenticatorKeyAdded(reg, &sms.AuthenticatorKeyAddedModel{To: "+12345678901", AddedAt: "2026-04-21T12:00:00Z", Identity: map[string]any{"ID": "00000000-0000-0000-0000-000000000001"}}),
} {
t.Run(fmt.Sprintf("case=%s", tmplType), func(t *testing.T) {
tmplData, err := json.Marshal(expectedTmpl)
require.NoError(t, err)
m := courier.Message{TemplateType: tmplType, TemplateData: tmplData}
actualTmpl, err := courier.NewSMSTemplateFromMessage(reg, m)
require.NoError(t, err)
require.IsType(t, expectedTmpl, actualTmpl)
expectedRecipient, err := expectedTmpl.PhoneNumber()
require.NoError(t, err)
actualRecipient, err := actualTmpl.PhoneNumber()
require.NoError(t, err)
require.Equal(t, expectedRecipient, actualRecipient)
expectedBody, err := expectedTmpl.SMSBody(ctx)
require.NoError(t, err)
actualBody, err := actualTmpl.SMSBody(ctx)
require.NoError(t, err)
require.Equal(t, expectedBody, actualBody)
})
}
}

140
courier/sms_test.go Normal file
View File

@@ -0,0 +1,140 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier_test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier/template/sms"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/x/configx"
)
func TestQueueSMS(t *testing.T) {
expectedSender := "Kratos Test"
expectedSMS := []*sms.TestStubModel{
{
To: "+12065550101",
Body: "test-sms-body-1",
},
{
To: "+12065550102",
Body: "test-sms-body-2",
},
}
actual := make(chan *sms.TestStubModel, len(expectedSMS))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type sendSMSRequestBody struct {
To string
From string
Body string
}
rb, err := io.ReadAll(r.Body)
require.NoError(t, err)
var body sendSMSRequestBody
err = json.Unmarshal(rb, &body)
require.NoError(t, err)
assert.NotEmpty(t, r.Header["Authorization"])
assert.Equal(t, "Basic bWU6MTIzNDU=", r.Header["Authorization"][0])
assert.Equal(t, body.From, expectedSender)
actual <- &sms.TestStubModel{
To: body.To,
Body: body.Body,
}
}))
t.Cleanup(srv.Close)
requestConfig := fmt.Sprintf(`{
"url": "%s",
"method": "POST",
"body": "file://./stub/request.config.twilio.jsonnet",
"auth": {
"type": "basic_auth",
"config": {
"user": "me",
"password": "12345"
}
}
}`, srv.URL)
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]any{
config.ViperKeyCourierChannels: fmt.Sprintf(`[{
"id": "sms",
"type": "http",
"request_config": %s
}]`, requestConfig),
config.ViperKeyCourierSMTPURL: "http://foo.url",
}))
c, err := reg.Courier(t.Context())
require.NoError(t, err)
for _, message := range expectedSMS {
id, err := c.QueueSMS(t.Context(), sms.NewTestStub(message))
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, id)
}
require.NoError(t, c.DispatchQueue(t.Context()))
close(actual)
require.Len(t, actual, len(expectedSMS))
i := 0
for message := range actual {
expected := expectedSMS[i]
assert.Equal(t, expected.To, message.To)
assert.Equal(t, expected.Body, message.Body)
i++
}
}
func TestDisallowedInternalNetwork(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]any{
config.ViperKeyCourierChannels: `[
{
"id": "sms",
"type": "http",
"request_config": {
"url": "http://127.0.0.1/",
"method": "GET",
"body": "file://./stub/request.config.twilio.jsonnet"
}
}
]`,
config.ViperKeyCourierSMTPURL: "http://foo.url",
config.ViperKeyClientHTTPNoPrivateIPRanges: true,
}))
c, err := reg.Courier(t.Context())
require.NoError(t, err)
c.(interface {
FailOnDispatchError()
}).FailOnDispatchError()
_, err = c.QueueSMS(t.Context(), sms.NewTestStub(&sms.TestStubModel{
To: "+12065550101",
Body: "test-sms-body-1",
}))
require.NoError(t, err)
err = c.DispatchQueue(t.Context())
assert.ErrorContains(t, err, "no route to host")
}

159
courier/smtp.go Normal file
View File

@@ -0,0 +1,159 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier
import (
"context"
"crypto/tls"
"encoding/json"
stdmail "net/mail"
"net/url"
"strconv"
"time"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/x"
"github.com/ory/x/contextx"
"github.com/ory/x/ipx"
"github.com/gofrs/uuid"
gomail "github.com/ory/mail/v3"
)
type SMTPClient struct {
*gomail.Dialer
}
func NewSMTPClient(deps Dependencies, cfg *config.SMTPConfig) (*SMTPClient, error) {
uri, err := url.Parse(cfg.ConnectionURI)
if err != nil {
return nil, errors.WithStack(herodot.ErrMisconfiguration().WithReasonf("The SMTP connection URI is malformed. Please contact a system administrator."))
}
var tlsCertificates []tls.Certificate
if cfg.ClientCertPath != "" && cfg.ClientKeyPath != "" {
clientCert, err := tls.LoadX509KeyPair(cfg.ClientCertPath, cfg.ClientKeyPath)
if err == nil {
tlsCertificates = append(tlsCertificates, clientCert)
} else {
deps.Logger().
WithError(err).
Error("Unable to load tls certificate and private key for smtp client.")
}
}
password, _ := uri.User.Password()
port, _ := strconv.ParseInt(uri.Port(), 10, 0)
dial := ipx.AllowInternalDialFunc
if deps.CourierConfig().ClientSMTPNoPrivateIPRanges(contextx.RootContext) {
dial = ipx.ProhibitInternalDialFunc
}
dialer := &gomail.Dialer{
Host: uri.Hostname(),
Port: int(port),
Username: uri.User.Username(),
Password: password,
LocalName: cfg.LocalName,
DialFunc: dial,
Timeout: time.Second * 10,
RetryFailure: true,
}
sslSkipVerify, _ := strconv.ParseBool(uri.Query().Get("skip_ssl_verify"))
serverName := uri.Query().Get("server_name")
if serverName == "" {
serverName = uri.Hostname()
}
tlsConfig := &tls.Config{
InsecureSkipVerify: sslSkipVerify, // #nosec G402 -- This is ok (and required!) because it is configurable and disabled by default.
Certificates: tlsCertificates,
ServerName: serverName,
MinVersion: tls.VersionTLS12,
}
// SMTP schemes
// smtp: smtp clear text (with uri parameter) or with StartTLS (enforced by default)
// smtps: smtp with implicit TLS (recommended way in 2021 to avoid StartTLS downgrade attacks
// and defaulting to fully-encrypted protocols https://datatracker.ietf.org/doc/html/rfc8314)
switch uri.Scheme {
case "smtp":
// Enforcing StartTLS by default for security best practices (config review, etc.)
skipStartTLS, _ := strconv.ParseBool(uri.Query().Get("disable_starttls"))
if !skipStartTLS {
dialer.TLSConfig = tlsConfig
// Enforcing StartTLS
dialer.StartTLSPolicy = gomail.MandatoryStartTLS
}
case "smtps":
dialer.TLSConfig = tlsConfig
dialer.SSL = true
}
return &SMTPClient{
Dialer: dialer,
}, nil
}
func (c *courier) QueueEmail(ctx context.Context, t EmailTemplate) (uuid.UUID, error) {
recipient, err := t.EmailRecipient()
if err != nil {
return uuid.Nil, errors.WithStack(err)
}
// The RFC 5322 fallback keeps previously-valid addresses working; the
// address is not included in the error because it is identity PII.
if _, err := stdmail.ParseAddress(recipient); err != nil && !x.IsEmailAddress(recipient) {
return uuid.Nil, errors.New("courier: email recipient is not a valid email address")
}
subject, err := t.EmailSubject(ctx)
if err != nil {
return uuid.Nil, errors.WithStack(err)
}
bodyPlaintext, err := t.EmailBodyPlaintext(ctx)
if err != nil {
return uuid.Nil, errors.WithStack(err)
}
templateData, err := json.Marshal(t)
if err != nil {
return uuid.Nil, errors.WithStack(err)
}
requestHeaders := []byte(`{}`)
if t, ok := t.(RequestHeadersCarrier); ok {
requestHeaders, err = json.Marshal(t.RequestHeaders())
if err != nil {
return uuid.Nil, err
}
}
message := &Message{
Status: MessageStatusQueued,
Type: MessageTypeEmail,
Channel: "email",
Recipient: recipient,
Body: bodyPlaintext,
Subject: subject,
TemplateType: t.TemplateType(),
TemplateData: templateData,
RequestHeaders: requestHeaders,
}
if err := c.deps.CourierPersister().AddMessage(ctx, message); err != nil {
return uuid.Nil, errors.WithStack(err)
}
return message.ID, nil
}

201
courier/smtp_channel.go Normal file
View 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
}

249
courier/smtp_test.go Normal file
View File

@@ -0,0 +1,249 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"flag"
"fmt"
"io"
"math/big"
"net/http"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/courier"
templates "github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
gomail "github.com/ory/mail/v3"
"github.com/ory/x/configx"
)
func TestNewSMTPClientPreventLeak(t *testing.T) {
// Test for https://hackerone.com/reports/2384028
invalidURL := "sm<>t>p://f%oo::bar:baz@my-server:1234:122/"
conf, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyCourierSMTPURL, invalidURL))
channels, err := conf.CourierChannels(t.Context())
require.NoError(t, err)
require.Len(t, channels, 1)
_, err = courier.NewSMTPClient(reg, channels[0].SMTPConfig)
require.Error(t, err)
assert.NotContains(t, err.Error(), invalidURL)
}
func TestNewSMTP(t *testing.T) {
conf, reg := pkg.NewFastRegistryWithMocks(t)
setupSMTPClient := func(stringURL string) *courier.SMTPClient {
conf.MustSet(t.Context(), config.ViperKeyCourierSMTPURL, stringURL)
channels, err := conf.CourierChannels(t.Context())
require.NoError(t, err)
require.Len(t, channels, 1)
c, err := courier.NewSMTPClient(reg, channels[0].SMTPConfig)
require.NoError(t, err)
return c
}
// Should enforce StartTLS => dialer.StartTLSPolicy = gomail.MandatoryStartTLS and dialer.SSL = false
smtp := setupSMTPClient("smtp://foo:bar@my-server:1234/")
assert.Equal(t, smtp.StartTLSPolicy, gomail.MandatoryStartTLS, "StartTLS not enforced")
assert.Equal(t, smtp.SSL, false, "Implicit TLS should not be enabled")
// Should enforce TLS => dialer.SSL = true
smtp = setupSMTPClient("smtps://foo:bar@my-server:1234/")
assert.Equal(t, smtp.SSL, true, "Implicit TLS should be enabled")
// Should allow cleartext => dialer.StartTLSPolicy = gomail.OpportunisticStartTLS and dialer.SSL = false
smtp = setupSMTPClient("smtp://foo:bar@my-server:1234/?disable_starttls=true")
assert.Equal(t, smtp.StartTLSPolicy, gomail.OpportunisticStartTLS, "StartTLS is enforced")
assert.Equal(t, smtp.SSL, false, "Implicit TLS should not be enabled")
// Test cert based SMTP client auth
clientCert, clientKey, err := generateTestClientCert(t)
require.NoError(t, err)
t.Cleanup(func() { _ = os.Remove(clientCert.Name()) }) //nolint:gosec
t.Cleanup(func() { _ = os.Remove(clientKey.Name()) }) //nolint:gosec
conf.MustSet(t.Context(), config.ViperKeyCourierSMTPClientCertPath, clientCert.Name())
conf.MustSet(t.Context(), config.ViperKeyCourierSMTPClientKeyPath, clientKey.Name())
clientPEM, err := tls.LoadX509KeyPair(clientCert.Name(), clientKey.Name())
require.NoError(t, err)
smtpWithCert := setupSMTPClient("smtps://subdomain.my-server:1234/?server_name=my-server")
assert.Equal(t, smtpWithCert.SSL, true, "Implicit TLS should be enabled")
assert.Equal(t, smtpWithCert.Host, "subdomain.my-server", "SMTP Dialer host should match")
assert.Equal(t, smtpWithCert.TLSConfig.ServerName, "my-server", "TLS config server name should match")
assert.Equal(t, smtpWithCert.TLSConfig.ServerName, "my-server", "TLS config server name should match")
assert.Contains(t, smtpWithCert.TLSConfig.Certificates, clientPEM, "TLS config should contain client pem")
// error case: invalid client key
require.NoError(t, conf.Set(t.Context(), config.ViperKeyCourierSMTPClientKeyPath, clientCert.Name())) // mixup client key and client cert
smtpWithCert = setupSMTPClient("smtps://subdomain.my-server:1234/?server_name=my-server")
assert.Equal(t, len(smtpWithCert.TLSConfig.Certificates), 0, "TLS config certificates should be empty")
}
func TestNotAllowedPrivateIPs(t *testing.T) {
smtp, _ := testhelpers.StartMailhog(t, false)
_, reg := pkg.NewRegistryDefaultWithDSN(t, "", configx.WithValues(map[string]any{
config.ViperKeyCourierSMTPURL: smtp,
config.ViperKeyCourierSMTPFrom: "test-stub@ory.sh",
config.ViperKeyCourierSMTPFromName: "Bob",
config.ViperKeyCourierSMTPHeaders + ".test-stub-header1": "foo",
config.ViperKeyCourierSMTPHeaders + ".test-stub-header2": "bar",
config.ViperKeyCourierMessageRetries: 50,
config.ViperKeyClientSMTPNoPrivateIPRanges: true,
}))
c, err := reg.Courier(t.Context())
require.NoError(t, err)
c.FailOnDispatchError()
id, err := c.QueueEmail(t.Context(), templates.NewTestStub(&templates.TestStubModel{
To: "test-recipient-1@example.org",
Subject: "test-subject-1",
Body: "test-body-1",
}))
require.NoError(t, err)
require.NotZero(t, id)
err = c.DispatchQueue(t.Context())
require.ErrorContains(t, err, "no route to host")
}
func TestQueueEmail(t *testing.T) {
smtp, api := testhelpers.StartMailhog(t, true)
_, reg := pkg.NewRegistryDefaultWithDSN(t, "", configx.WithValues(map[string]any{
config.ViperKeyCourierSMTPURL: smtp,
config.ViperKeyCourierSMTPFrom: "test-stub@ory.sh",
config.ViperKeyCourierSMTPFromName: "Bob",
config.ViperKeyCourierSMTPHeaders + ".test-stub-header1": "foo",
config.ViperKeyCourierSMTPHeaders + ".test-stub-header2": "bar",
config.ViperKeyCourierMessageRetries: 50,
config.ViperKeyClientSMTPNoPrivateIPRanges: false,
}))
c, err := reg.Courier(t.Context())
require.NoError(t, err)
c.FailOnDispatchError()
_, err = c.QueueEmail(t.Context(), templates.NewTestStub(&templates.TestStubModel{
To: "invalid-email",
Subject: "test-subject-1",
Body: "test-body-1",
}))
require.Error(t, err)
id, err := c.QueueEmail(t.Context(), templates.NewTestStub(&templates.TestStubModel{
To: "test-recipient-1@example.org",
Subject: "test-subject-1",
Body: "test-body-1",
}))
require.NoError(t, err)
require.NotZero(t, id)
id, err = c.QueueEmail(t.Context(), templates.NewTestStub(&templates.TestStubModel{
To: "test-recipient-2@example.org",
Subject: "test-subject-2",
Body: "test-body-2",
}))
require.NoError(t, err)
require.NotZero(t, id)
id, err = c.QueueEmail(t.Context(), templates.NewTestStub(&templates.TestStubModel{
To: "test-recipient-3@example.org",
Subject: "test-subject-3",
Body: "test-body-3",
}))
require.NoError(t, err)
require.NotZero(t, id)
// A schema-approved non-RFC carrier address must queue and be delivered.
id, err = c.QueueEmail(t.Context(), templates.NewTestStub(&templates.TestStubModel{
To: "foo.@docomo.ne.jp",
Subject: "test-subject-docomo",
Body: "test-body-docomo",
}))
require.NoError(t, err)
require.NotZero(t, id)
require.EventuallyWithT(t, func(t *assert.CollectT) {
require.NoError(t, c.DispatchQueue(context.Background()))
}, time.Second, 10*time.Millisecond)
res, err := testhelpers.NewTestClient(t).Get(api + "/api/v2/messages")
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
require.EqualValues(t, 4, gjson.GetBytes(body, "total").Int())
for k := 1; k <= 3; k++ {
assert.Contains(t, string(body), fmt.Sprintf("test-subject-%d", k))
assert.Contains(t, string(body), fmt.Sprintf("test-body-%d", k))
assert.Contains(t, string(body), fmt.Sprintf("test-recipient-%d@example.org", k))
assert.Contains(t, string(body), "test-stub@ory.sh")
}
assert.Contains(t, string(body), "test-subject-docomo")
assert.Contains(t, string(body), "test-body-docomo")
assert.Contains(t, string(body), "foo.@docomo.ne.jp")
assert.Contains(t, string(body), "Bob")
assert.Contains(t, string(body), `"test-stub-header1":["foo"]`)
assert.Contains(t, string(body), `"test-stub-header2":["bar"]`)
}
func generateTestClientCert(t *testing.T) (clientCert *os.File, clientKey *os.File, err error) {
hostName := flag.String("host", "127.0.0.1", "Hostname to certify")
priv, err := rsa.GenerateKey(rand.Reader, 1024) // #nosec G403 -- test code
require.NoError(t, err)
now := time.Now()
certTemplate := x509.Certificate{
SerialNumber: big.NewInt(1234),
Subject: pkix.Name{
CommonName: *hostName,
Organization: []string{"myorg"},
},
NotBefore: now.Add(-300 * time.Second),
NotAfter: now.Add(24 * time.Hour),
SubjectKeyId: []byte{1, 2, 3, 4},
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
}
cert, err := x509.CreateCertificate(rand.Reader, &certTemplate, &certTemplate, &priv.PublicKey, priv)
require.NoError(t, err)
clientCert, err = os.CreateTemp("./test", "testCert")
require.NoError(t, err)
defer func() { _ = clientCert.Close() }()
require.NoError(t, pem.Encode(clientCert, &pem.Block{Type: "CERTIFICATE", Bytes: cert}))
clientKey, err = os.CreateTemp("./test", "testKey")
require.NoError(t, err)
defer func() { _ = clientKey.Close() }()
require.NoError(t, pem.Encode(clientKey, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}))
return clientCert, clientKey, nil
}

216
courier/smtp_wire_test.go Normal file
View File

@@ -0,0 +1,216 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier_test
import (
"bufio"
"fmt"
"net"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier"
"github.com/ory/kratos/courier/template"
templates "github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/x/configx"
"github.com/ory/x/sqlxx"
)
// smtpRecorder captures the raw SMTP commands and DATA lines a client sends.
type smtpRecorder struct {
mu sync.Mutex
cmds []string
data []string
}
func (r *smtpRecorder) snapshot() (cmds, data []string) {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.cmds...), append([]string(nil), r.data...)
}
// newRecordingSMTPServer starts a minimal SMTP server that accepts everything
// and records the raw protocol. It returns a courier SMTP connection URI.
func newRecordingSMTPServer(t *testing.T) (string, *smtpRecorder) {
t.Helper()
rec := &smtpRecorder{}
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = l.Close() })
go func() {
for {
conn, err := l.Accept()
if err != nil {
return
}
go func(conn net.Conn) {
defer func() { _ = conn.Close() }()
_, _ = fmt.Fprintf(conn, "220 fake ESMTP\r\n")
sc := bufio.NewScanner(conn)
inData := false
for sc.Scan() {
line := sc.Text()
if inData {
if line == "." {
inData = false
_, _ = fmt.Fprintf(conn, "250 ok\r\n")
continue
}
rec.mu.Lock()
rec.data = append(rec.data, line)
rec.mu.Unlock()
continue
}
rec.mu.Lock()
rec.cmds = append(rec.cmds, line)
rec.mu.Unlock()
switch verb := strings.ToUpper(line); {
case strings.HasPrefix(verb, "EHLO"), strings.HasPrefix(verb, "HELO"):
_, _ = fmt.Fprintf(conn, "250-fake\r\n250 8BITMIME\r\n")
case strings.HasPrefix(verb, "DATA"):
inData = true
_, _ = fmt.Fprintf(conn, "354 go ahead\r\n")
case strings.HasPrefix(verb, "QUIT"):
_, _ = fmt.Fprintf(conn, "221 bye\r\n")
return
default:
_, _ = fmt.Fprintf(conn, "250 ok\r\n")
}
}
}(conn)
}
}()
return fmt.Sprintf("smtp://%s/?disable_starttls=true", l.Addr().String()), rec
}
func rcptLines(cmds []string) []string {
var out []string
for _, c := range cmds {
if strings.HasPrefix(strings.ToUpper(c), "RCPT TO:") {
out = append(out, c)
}
}
return out
}
func headerLines(data []string, prefix string) []string {
var out []string
for _, d := range data {
if d == "" {
break // End of headers.
}
if strings.HasPrefix(d, prefix) {
out = append(out, d)
}
}
return out
}
func newWireCourier(t *testing.T, smtpURL string, extra map[string]any) (courier.Courier, *config.Config) {
t.Helper()
values := map[string]any{
config.ViperKeyCourierSMTPURL: smtpURL,
config.ViperKeyCourierSMTPFrom: "from@ory.sh",
config.ViperKeyCourierMessageRetries: 5,
config.ViperKeyClientSMTPNoPrivateIPRanges: false,
}
for k, v := range extra {
values[k] = v
}
conf, reg := pkg.NewRegistryDefaultWithDSN(t, "", configx.WithValues(values))
c, err := reg.Courier(t.Context())
require.NoError(t, err)
c.FailOnDispatchError()
return c, conf
}
func TestDispatchSMTPWireProtocol(t *testing.T) {
t.Run("case=rfc recipient keeps Cc and Bcc header recipients in the envelope", func(t *testing.T) {
smtpURL, rec := newRecordingSMTPServer(t)
c, _ := newWireCourier(t, smtpURL, map[string]any{
config.ViperKeyCourierSMTPHeaders + ".Bcc": "archive@ory.sh",
})
_, err := c.QueueEmail(t.Context(), templates.NewTestStub(&templates.TestStubModel{
To: "user@example.org", Subject: "s", Body: "b",
}))
require.NoError(t, err)
require.NoError(t, c.DispatchQueue(t.Context()))
require.EventuallyWithT(t, func(t *assert.CollectT) {
cmds, _ := rec.snapshot()
rcpts := rcptLines(cmds)
assert.Contains(t, rcpts, "RCPT TO:<user@example.org>")
assert.Contains(t, rcpts, "RCPT TO:<archive@ory.sh>")
}, 5*time.Second, 50*time.Millisecond)
})
t.Run("case=non-rfc recipient produces exactly one RCPT and one To header", func(t *testing.T) {
smtpURL, rec := newRecordingSMTPServer(t)
c, _ := newWireCourier(t, smtpURL, nil)
_, err := c.QueueEmail(t.Context(), templates.NewTestStub(&templates.TestStubModel{
To: "foo.@docomo.ne.jp", Subject: "s", Body: "b",
}))
require.NoError(t, err)
require.NoError(t, c.DispatchQueue(t.Context()))
require.EventuallyWithT(t, func(t *assert.CollectT) {
cmds, data := rec.snapshot()
assert.Equal(t, []string{"RCPT TO:<foo.@docomo.ne.jp>"}, rcptLines(cmds))
assert.Equal(t, []string{"To: foo.@docomo.ne.jp"}, headerLines(data, "To:"))
}, 5*time.Second, 50*time.Millisecond)
})
t.Run("case=poisoned recipient is abandoned at dispatch, nothing hits the wire", func(t *testing.T) {
smtpURL, rec := newRecordingSMTPServer(t)
_, reg := pkg.NewRegistryDefaultWithDSN(t, "", configx.WithValues(map[string]any{
config.ViperKeyCourierSMTPURL: smtpURL,
config.ViperKeyCourierSMTPFrom: "from@ory.sh",
config.ViperKeyCourierMessageRetries: 5,
config.ViperKeyClientSMTPNoPrivateIPRanges: false,
}))
c, err := reg.Courier(t.Context())
require.NoError(t, err)
// Insert a row with a smuggled header directly, bypassing QueueEmail's
// validation, to simulate a poisoned message from another path.
require.NoError(t, reg.CourierPersister().AddMessage(t.Context(), &courier.Message{
Type: courier.MessageTypeEmail,
Recipient: "victim@example.org\r\nBcc: attacker@evil.com",
Body: "b",
Subject: "s",
TemplateType: template.TypeTestStub,
Channel: sqlxx.NullString("email"),
}))
// Dispatch the message directly. DispatchMessage (unlike DispatchQueue)
// does not reset a failed message back to "queued", so the abandonment
// is observable.
queued, err := reg.CourierPersister().LatestQueuedMessage(t.Context())
require.NoError(t, err)
require.Error(t, c.DispatchMessage(t.Context(), *queued))
var got courier.Message
require.NoError(t, reg.Persister().GetConnection(t.Context()).Where("id = ?", queued.ID).First(&got))
assert.Equal(t, courier.MessageStatusAbandoned, got.Status)
cmds, data := rec.snapshot()
assert.Empty(t, rcptLines(cmds), "poisoned recipient must not produce a RCPT")
assert.Empty(t, headerLines(data, "Bcc:"), "poisoned recipient must not smuggle a Bcc header")
for _, line := range data {
assert.NotContains(t, line, "attacker@evil.com", "attacker address must never reach the wire")
}
})
}

View File

@@ -0,0 +1,12 @@
function(ctx) {
recipient: ctx.recipient,
template_type: ctx.template_type,
to: if "template_data" in ctx && "to" in ctx.template_data then ctx.template_data.to else null,
recovery_code: if "template_data" in ctx && "recovery_code" in ctx.template_data then ctx.template_data.recovery_code else null,
recovery_url: if "template_data" in ctx && "recovery_url" in ctx.template_data then ctx.template_data.recovery_url else null,
verification_url: if "template_data" in ctx && "verification_url" in ctx.template_data then ctx.template_data.verification_url else null,
verification_code: if "template_data" in ctx && "verification_code" in ctx.template_data then ctx.template_data.verification_code else null,
subject: if "template_data" in ctx && "subject" in ctx.template_data then ctx.template_data.subject else null,
body: if "template_data" in ctx && "body" in ctx.template_data then ctx.template_data.body else null,
html_body: if "template_data" in ctx && "html_body" in ctx.template_data then ctx.template_data.html_body else null
}

View File

@@ -0,0 +1,5 @@
function(ctx) {
from: "Kratos Test",
to: ctx.recipient,
body: ctx.body
}

View File

@@ -0,0 +1,5 @@
<p>Hello,</p>
<p>A new sign-in method (device key) was added to your account. If you did this, no further action is needed.</p>
<p>If this wasn't you, secure your account immediately and contact support.</p>
<p><strong>Account ID:</strong> {{ index .Identity "id" }}<br/>
<strong>Added at:</strong> {{ .AddedAt }}</p>

View File

@@ -0,0 +1,9 @@
Hello,
A new sign-in method (device key) was added to your account. If you did
this, no further action is needed.
If this wasn't you, secure your account immediately and contact support.
Account ID: {{ index .Identity "id" }}
Added at: {{ .AddedAt }}

View File

@@ -0,0 +1 @@
A new sign-in method was added to your account

View File

@@ -0,0 +1 @@
A new sign-in method (device key) was added to your account. If this wasn't you, secure your account.

View File

@@ -0,0 +1,5 @@
Login to your account with the following code:
{{ .LoginCode }}
It expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1,5 @@
Login to your account with the following code:
{{ .LoginCode }}
It expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1 @@
Use code {{ .LoginCode }} to log in

View File

@@ -0,0 +1,6 @@
Your login code is: {{ .LoginCode }}
It expires in {{ .ExpiresInMinutes }} minutes.
{{ if .RequestURLDomain }}
@{{ .RequestURLDomain }} #{{ .LoginCode }}
{{ end -}}

View File

@@ -0,0 +1 @@
Your verification code is: {{ .Code }}

View File

@@ -0,0 +1,9 @@
Hi,
you (or someone else) entered this email address when trying to recover access to an account.
However, this email address is not on our database of registered users and therefore the attempt has failed.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1,9 @@
Hi,
you (or someone else) entered this email address when trying to recover access to an account.
However, this email address is not on our database of registered users and therefore the attempt has failed.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1 @@
Account access attempted

View File

@@ -0,0 +1,5 @@
Recover access to your account by clicking the following link:
<a href="{{ .RecoveryURL }}">{{ .RecoveryURL }}</a>
If this was not you, do nothing. This link expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1,6 @@
Recover access to your account by clicking the following link:
{{ .RecoveryURL }}
If this was not you, do nothing. This link expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1 @@
Recover access to your account

View File

@@ -0,0 +1,9 @@
Hi,
you (or someone else) entered this email address when trying to recover access to an account.
However, this email address is not on our database of registered users and therefore the attempt has failed.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1,9 @@
Hi,
you (or someone else) entered this email address when trying to recover access to an account.
However, this email address is not on our database of registered users and therefore the attempt has failed.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1 @@
Account access attempted

View File

@@ -0,0 +1,5 @@
Recover access to your account by entering the following code:
{{ .RecoveryCode }}
If this was not you, do nothing. This code expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1,5 @@
Recover access to your account by entering the following code:
{{ .RecoveryCode }}
If this was not you, do nothing. This code expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1 @@
Use code {{ .RecoveryCode }} to recover access to your account

View File

@@ -0,0 +1,3 @@
Your recovery code is: {{ .RecoveryCode }}
@{{ .RequestURLDomain }} #{{ .RecoveryCode }}

View File

@@ -0,0 +1,5 @@
Complete your account registration with the following code:
{{ .RegistrationCode }}
This code expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1,5 @@
Complete your account registration with the following code:
{{ .RegistrationCode }}
This code expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1 @@
Use code {{ .RegistrationCode }} to complete your account registration

View File

@@ -0,0 +1,6 @@
Your registration code is: {{ .RegistrationCode }}
It expires in {{ .ExpiresInMinutes }} minutes.
{{ if .RequestURLDomain }}
@{{ .RequestURLDomain }} #{{ .RegistrationCode }}
{{ end -}}

View File

@@ -0,0 +1 @@
stub email body {{ if .HTMLBody }}{{ .HTMLBody }}{{ else }}{{ .Body }}{{ end }}

View File

@@ -0,0 +1,2 @@
{{ $l := cat "lang=" .lang }}
{{ nospace $l }}

View File

@@ -0,0 +1,3 @@
{{- if eq .lang "en_US" -}}
{{ template "email.body.html.en_US.gotmpl" . }}
{{- end -}}

View File

@@ -0,0 +1 @@
stub email body {{ .Body }}

View File

@@ -0,0 +1,5 @@
{{ $t1 := title .input }}
{{ $t1 := nospace $t1 }}
{{ $t2 := upper $t1 }}
{{ $t3 := cat $t1 "," $t2 }}
{{ nospace $t3 }}

View File

@@ -0,0 +1 @@
stub email subject {{ .Subject }}

View File

@@ -0,0 +1,5 @@
<p>Hello,</p>
<p>A verifiable address (email or phone) on your account was recently changed. If you made this change, no further action is needed.</p>
<p>If you did not make this change, please contact support immediately.</p>
<p><strong>Account ID:</strong> {{ index .Identity "ID" }}<br/>
<strong>Changed at:</strong> {{ .ChangedAt }}</p>

View File

@@ -0,0 +1,9 @@
Hello,
A verifiable address (email or phone) on your account was recently
changed. If you made this change, no further action is needed.
If you did not make this change, please contact support immediately.
Account ID: {{ index .Identity "ID" }}
Changed at: {{ .ChangedAt }}

View File

@@ -0,0 +1 @@
A verifiable address on your account was changed

View File

@@ -0,0 +1 @@
An address on your account was changed. If this wasn't you, contact support.

View File

@@ -0,0 +1,5 @@
Someone asked to verify this email address, but we were unable to find an account for this address.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1,5 @@
Someone asked to verify this email address, but we were unable to find an account for this address.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1 @@
Someone tried to verify this email address

View File

@@ -0,0 +1,5 @@
Verify your account by opening the following link:
<a href="{{ .VerificationURL }}">{{ .VerificationURL }}</a>
If this was not you, do nothing. This link expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1,5 @@
Verify your account by opening the following link:
{{ .VerificationURL }}
If this was not you, do nothing. This link expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1 @@
Please verify your email address

View File

@@ -0,0 +1,7 @@
Hi,
someone asked to verify this email address, but we were unable to find an account for this address.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1,7 @@
Hi,
someone asked to verify this email address, but we were unable to find an account for this address.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1 @@
Someone tried to verify this email address

View File

@@ -0,0 +1,9 @@
Verify your account with the following code:
{{ .VerificationCode }}
or clicking the following link:
<a href="{{ .VerificationURL }}">{{ .VerificationURL }}</a>
If this was not you, do nothing. This code / link expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1,9 @@
Verify your account with the following code:
{{ .VerificationCode }}
or clicking the following link:
{{ .VerificationURL }}
If this was not you, do nothing. This code / link expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1 @@
Use code {{ .VerificationCode }} to verify your account

View File

@@ -0,0 +1,6 @@
Your verification code is: {{ .VerificationCode }}
If this was not you, do nothing. It expires in {{ .ExpiresInMinutes }} minutes.
{{ if .RequestURLDomain }}
@{{ .RequestURLDomain }} #{{ .VerificationCode }}
{{ end -}}

View File

@@ -0,0 +1,55 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
AuthenticatorKeyAdded struct {
d template.Dependencies
m *AuthenticatorKeyAddedModel
}
AuthenticatorKeyAddedModel struct {
To string `json:"to"`
Identity map[string]any `json:"identity"`
AddedAt string `json:"added_at"`
TransientPayload map[string]any `json:"transient_payload"`
}
)
func NewAuthenticatorKeyAdded(d template.Dependencies, m *AuthenticatorKeyAddedModel) *AuthenticatorKeyAdded {
return &AuthenticatorKeyAdded{d: d, m: m}
}
func (t *AuthenticatorKeyAdded) EmailRecipient() (string, error) {
return t.m.To, nil
}
func (t *AuthenticatorKeyAdded) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "authenticator_key_added/email.subject.gotmpl", "authenticator_key_added/email.subject*", t.m, t.d.CourierConfig().CourierTemplatesAuthenticatorKeyAdded(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *AuthenticatorKeyAdded) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "authenticator_key_added/email.body.gotmpl", "authenticator_key_added/email.body*", t.m, t.d.CourierConfig().CourierTemplatesAuthenticatorKeyAdded(ctx).Body.HTML)
}
func (t *AuthenticatorKeyAdded) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "authenticator_key_added/email.body.plaintext.gotmpl", "authenticator_key_added/email.body.plaintext*", t.m, t.d.CourierConfig().CourierTemplatesAuthenticatorKeyAdded(ctx).Body.PlainText)
}
func (t *AuthenticatorKeyAdded) MarshalJSON() ([]byte, error) {
return json.Marshal(t.m)
}
func (t *AuthenticatorKeyAdded) TemplateType() template.TemplateType {
return template.TypeAuthenticatorKeyAdded
}

View File

@@ -0,0 +1,55 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"strings"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/x"
)
func TestAuthenticatorKeyAdded(t *testing.T) {
ctx := t.Context()
_, reg := pkg.NewFastRegistryWithMocks(t)
// Build the Identity map exactly as the courier does in production
// (identity.Manager.sendIdentityNotifications uses x.StructToMap), so the
// template's key lookup is tested against the real key casing instead of a
// hand-crafted map. StructToMap marshals via JSON tags, so the ID lands under
// the lowercase "id" key.
id := &identity.Identity{ID: uuid.Must(uuid.FromString("00000000-0000-0000-0000-000000000001"))}
idMap, err := x.StructToMap(id)
require.NoError(t, err)
tpl := email.NewAuthenticatorKeyAdded(reg, &email.AuthenticatorKeyAddedModel{
To: "owner@example.com",
AddedAt: "2026-04-21T12:00:00Z",
Identity: idMap,
})
recipient, err := tpl.EmailRecipient()
require.NoError(t, err)
assert.Equal(t, "owner@example.com", recipient)
subject, err := tpl.EmailSubject(ctx)
require.NoError(t, err)
assert.Contains(t, strings.ToLower(subject), "sign-in method")
body, err := tpl.EmailBody(ctx)
require.NoError(t, err)
assert.Contains(t, body, "00000000-0000-0000-0000-000000000001", "the account ID must render in the email body")
plain, err := tpl.EmailBodyPlaintext(ctx)
require.NoError(t, err)
assert.Contains(t, plain, "00000000-0000-0000-0000-000000000001", "the account ID must render in the plaintext body")
assert.Contains(t, plain, "2026-04-21T12:00:00Z")
}

View File

@@ -0,0 +1,65 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"net/http"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
LoginCodeValid struct {
deps template.Dependencies
model *LoginCodeValidModel
}
LoginCodeValidModel struct {
To string `json:"to"`
LoginCode string `json:"login_code"`
Identity map[string]any `json:"identity"`
RequestURL string `json:"request_url"`
TransientPayload map[string]any `json:"transient_payload"`
ExpiresInMinutes int `json:"expires_in_minutes"`
OAuth2LoginRequest *template.OAuth2LoginRequest `json:"oauth2_login_request,omitempty"`
UserRequestHeaders http.Header `json:"-"`
}
)
func NewLoginCodeValid(d template.Dependencies, m *LoginCodeValidModel) *LoginCodeValid {
return &LoginCodeValid{deps: d, model: m}
}
func (t *LoginCodeValid) EmailRecipient() (string, error) {
return t.model.To, nil
}
func (t *LoginCodeValid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "login_code/valid/email.subject.gotmpl", "login_code/valid/email.subject*", t.model, t.deps.CourierConfig().CourierTemplatesLoginCodeValid(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *LoginCodeValid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "login_code/valid/email.body.gotmpl", "login_code/valid/email.body*", t.model, t.deps.CourierConfig().CourierTemplatesLoginCodeValid(ctx).Body.HTML)
}
func (t *LoginCodeValid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "login_code/valid/email.body.plaintext.gotmpl", "login_code/valid/email.body.plaintext*", t.model, t.deps.CourierConfig().CourierTemplatesLoginCodeValid(ctx).Body.PlainText)
}
func (t *LoginCodeValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.model)
}
func (t *LoginCodeValid) TemplateType() template.TemplateType {
return template.TypeLoginCodeValid
}
func (t *LoginCodeValid) RequestHeaders() http.Header {
return t.model.UserRequestHeaders
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestLoginCodeValid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewLoginCodeValid(reg, &email.LoginCodeValidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("test=with remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/login_code/valid", template.TypeLoginCodeValid)
})
}

View File

@@ -0,0 +1,58 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
RecoveryCodeInvalid struct {
deps template.Dependencies
model *RecoveryCodeInvalidModel
}
RecoveryCodeInvalidModel struct {
To string `json:"to"`
RequestURL string `json:"request_url"`
TransientPayload map[string]interface{} `json:"transient_payload"`
}
)
func NewRecoveryCodeInvalid(d template.Dependencies, m *RecoveryCodeInvalidModel) *RecoveryCodeInvalid {
return &RecoveryCodeInvalid{deps: d, model: m}
}
func (t *RecoveryCodeInvalid) EmailRecipient() (string, error) {
return t.model.To, nil
}
func (t *RecoveryCodeInvalid) EmailSubject(ctx context.Context) (string, error) {
filesystem := os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx))
remoteURL := t.deps.CourierConfig().CourierTemplatesRecoveryCodeInvalid(ctx).Subject
subject, err := template.LoadText(ctx, t.deps, filesystem, "recovery_code/invalid/email.subject.gotmpl", "recovery_code/invalid/email.subject*", t.model, remoteURL)
return strings.TrimSpace(subject), err
}
func (t *RecoveryCodeInvalid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "recovery_code/invalid/email.body.gotmpl", "recovery_code/invalid/email.body*", t.model, t.deps.CourierConfig().CourierTemplatesRecoveryCodeInvalid(ctx).Body.HTML)
}
func (t *RecoveryCodeInvalid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "recovery_code/invalid/email.body.plaintext.gotmpl", "recovery_code/invalid/email.body.plaintext*", t.model, t.deps.CourierConfig().CourierTemplatesRecoveryCodeInvalid(ctx).Body.PlainText)
}
func (t *RecoveryCodeInvalid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.model)
}
func (t *RecoveryCodeInvalid) TemplateType() template.TemplateType {
return template.TypeRecoveryCodeInvalid
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestRecoveryCodeInvalid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewRecoveryCodeInvalid(reg, &email.RecoveryCodeInvalidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("case=test remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/recovery_code/invalid", template.TypeRecoveryCodeInvalid)
})
}

View File

@@ -0,0 +1,63 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"net/http"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
RecoveryCodeValid struct {
deps template.Dependencies
model *RecoveryCodeValidModel
}
RecoveryCodeValidModel struct {
To string `json:"to"`
RecoveryCode string `json:"recovery_code"`
Identity map[string]interface{} `json:"identity"`
RequestURL string `json:"request_url"`
TransientPayload map[string]interface{} `json:"transient_payload"`
ExpiresInMinutes int `json:"expires_in_minutes"`
UserRequestHeaders http.Header `json:"-"`
}
)
func NewRecoveryCodeValid(d template.Dependencies, m *RecoveryCodeValidModel) *RecoveryCodeValid {
return &RecoveryCodeValid{deps: d, model: m}
}
func (t *RecoveryCodeValid) EmailRecipient() (string, error) {
return t.model.To, nil
}
func (t *RecoveryCodeValid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "recovery_code/valid/email.subject.gotmpl", "recovery_code/valid/email.subject*", t.model, t.deps.CourierConfig().CourierTemplatesRecoveryCodeValid(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *RecoveryCodeValid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "recovery_code/valid/email.body.gotmpl", "recovery_code/valid/email.body*", t.model, t.deps.CourierConfig().CourierTemplatesRecoveryCodeValid(ctx).Body.HTML)
}
func (t *RecoveryCodeValid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "recovery_code/valid/email.body.plaintext.gotmpl", "recovery_code/valid/email.body.plaintext*", t.model, t.deps.CourierConfig().CourierTemplatesRecoveryCodeValid(ctx).Body.PlainText)
}
func (t *RecoveryCodeValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.model)
}
func (t *RecoveryCodeValid) TemplateType() template.TemplateType {
return template.TypeRecoveryCodeValid
}
func (t *RecoveryCodeValid) RequestHeaders() http.Header {
return t.model.UserRequestHeaders
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestRecoveryCodeValid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewRecoveryCodeValid(reg, &email.RecoveryCodeValidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("test=with remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/recovery_code/valid", template.TypeRecoveryCodeValid)
})
}

View File

@@ -0,0 +1,55 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
RecoveryInvalid struct {
d template.Dependencies
m *RecoveryInvalidModel
}
RecoveryInvalidModel struct {
To string `json:"to"`
RequestURL string `json:"request_url"`
TransientPayload map[string]interface{} `json:"transient_payload"`
}
)
func NewRecoveryInvalid(d template.Dependencies, m *RecoveryInvalidModel) *RecoveryInvalid {
return &RecoveryInvalid{d: d, m: m}
}
func (t *RecoveryInvalid) EmailRecipient() (string, error) {
return t.m.To, nil
}
func (t *RecoveryInvalid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "recovery/invalid/email.subject.gotmpl", "recovery/invalid/email.subject*", t.m, t.d.CourierConfig().CourierTemplatesRecoveryInvalid(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *RecoveryInvalid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "recovery/invalid/email.body.gotmpl", "recovery/invalid/email.body*", t.m, t.d.CourierConfig().CourierTemplatesRecoveryInvalid(ctx).Body.HTML)
}
func (t *RecoveryInvalid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "recovery/invalid/email.body.plaintext.gotmpl", "recovery/invalid/email.body.plaintext*", t.m, t.d.CourierConfig().CourierTemplatesRecoveryInvalid(ctx).Body.PlainText)
}
func (t *RecoveryInvalid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.m)
}
func (t *RecoveryInvalid) TemplateType() template.TemplateType {
return template.TypeRecoveryInvalid
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestRecoverInvalid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewRecoveryInvalid(reg, &email.RecoveryInvalidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("case=test remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/recovery/invalid", template.TypeRecoveryInvalid)
})
}

View File

@@ -0,0 +1,58 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
RecoveryValid struct {
d template.Dependencies
m *RecoveryValidModel
}
RecoveryValidModel struct {
To string `json:"to"`
RecoveryURL string `json:"recovery_url"`
Identity map[string]interface{} `json:"identity"`
RequestURL string `json:"request_url"`
TransientPayload map[string]interface{} `json:"transient_payload"`
ExpiresInMinutes int `json:"expires_in_minutes"`
}
)
func NewRecoveryValid(d template.Dependencies, m *RecoveryValidModel) *RecoveryValid {
return &RecoveryValid{d: d, m: m}
}
func (t *RecoveryValid) EmailRecipient() (string, error) {
return t.m.To, nil
}
func (t *RecoveryValid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "recovery/valid/email.subject.gotmpl", "recovery/valid/email.subject*", t.m, t.d.CourierConfig().CourierTemplatesRecoveryValid(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *RecoveryValid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "recovery/valid/email.body.gotmpl", "recovery/valid/email.body*", t.m, t.d.CourierConfig().CourierTemplatesRecoveryValid(ctx).Body.HTML)
}
func (t *RecoveryValid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "recovery/valid/email.body.plaintext.gotmpl", "recovery/valid/email.body.plaintext*", t.m, t.d.CourierConfig().CourierTemplatesRecoveryValid(ctx).Body.PlainText)
}
func (t *RecoveryValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.m)
}
func (t *RecoveryValid) TemplateType() template.TemplateType {
return template.TypeRecoveryValid
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestRecoverValid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewRecoveryValid(reg, &email.RecoveryValidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("test=with remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/recovery/valid", template.TypeRecoveryValid)
})
}

View File

@@ -0,0 +1,65 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"net/http"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
RegistrationCodeValid struct {
deps template.Dependencies
model *RegistrationCodeValidModel
}
RegistrationCodeValidModel struct {
To string `json:"to"`
Traits map[string]any `json:"traits"`
RegistrationCode string `json:"registration_code"`
RequestURL string `json:"request_url"`
TransientPayload map[string]any `json:"transient_payload"`
ExpiresInMinutes int `json:"expires_in_minutes"`
OAuth2LoginRequest *template.OAuth2LoginRequest `json:"oauth2_login_request,omitempty"`
UserRequestHeaders http.Header `json:"-"`
}
)
func NewRegistrationCodeValid(d template.Dependencies, m *RegistrationCodeValidModel) *RegistrationCodeValid {
return &RegistrationCodeValid{deps: d, model: m}
}
func (t *RegistrationCodeValid) EmailRecipient() (string, error) {
return t.model.To, nil
}
func (t *RegistrationCodeValid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "registration_code/valid/email.subject.gotmpl", "registration_code/valid/email.subject*", t.model, t.deps.CourierConfig().CourierTemplatesRegistrationCodeValid(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *RegistrationCodeValid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "registration_code/valid/email.body.gotmpl", "registration_code/valid/email.body*", t.model, t.deps.CourierConfig().CourierTemplatesRegistrationCodeValid(ctx).Body.HTML)
}
func (t *RegistrationCodeValid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "registration_code/valid/email.body.plaintext.gotmpl", "registration_code/valid/email.body.plaintext*", t.model, t.deps.CourierConfig().CourierTemplatesRegistrationCodeValid(ctx).Body.PlainText)
}
func (t *RegistrationCodeValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.model)
}
func (t *RegistrationCodeValid) TemplateType() template.TemplateType {
return template.TypeRegistrationCodeValid
}
func (t *RegistrationCodeValid) RequestHeaders() http.Header {
return t.model.UserRequestHeaders
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestRegistrationCodeValid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewRegistrationCodeValid(reg, &email.RegistrationCodeValidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("test=with remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/registration_code/valid", template.TypeRegistrationCodeValid)
})
}

View File

@@ -0,0 +1,41 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"github.com/ory/kratos/courier/template"
)
type (
TestStub struct {
m *TestStubModel
}
TestStubModel struct {
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
HTMLBody string `json:"html_body,omitempty"`
}
)
func NewTestStub(m *TestStubModel) *TestStub {
return &TestStub{m: m}
}
func (t *TestStub) EmailRecipient() (string, error) { return t.m.To, nil }
func (t *TestStub) EmailSubject(context.Context) (string, error) { return t.m.Subject, nil }
func (t *TestStub) EmailBody(ctx context.Context) (string, error) {
if t.m.HTMLBody != "" {
return t.m.HTMLBody, nil
}
return t.EmailBodyPlaintext(ctx)
}
func (t *TestStub) EmailBodyPlaintext(context.Context) (string, error) { return t.m.Body, nil }
func (t *TestStub) MarshalJSON() ([]byte, error) { return json.Marshal(t.m) }
func (t *TestStub) TemplateType() template.TemplateType { return template.TypeTestStub }

View File

@@ -0,0 +1,55 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
VerifiableAddressChanged struct {
d template.Dependencies
m *VerifiableAddressChangedModel
}
VerifiableAddressChangedModel struct {
To string `json:"to"`
Identity map[string]any `json:"identity"`
ChangedAt string `json:"changed_at"`
TransientPayload map[string]any `json:"transient_payload"`
}
)
func NewVerifiableAddressChanged(d template.Dependencies, m *VerifiableAddressChangedModel) *VerifiableAddressChanged {
return &VerifiableAddressChanged{d: d, m: m}
}
func (t *VerifiableAddressChanged) EmailRecipient() (string, error) {
return t.m.To, nil
}
func (t *VerifiableAddressChanged) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "verifiable_address_changed/email.subject.gotmpl", "verifiable_address_changed/email.subject*", t.m, t.d.CourierConfig().CourierTemplatesVerifiableAddressChanged(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *VerifiableAddressChanged) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "verifiable_address_changed/email.body.gotmpl", "verifiable_address_changed/email.body*", t.m, t.d.CourierConfig().CourierTemplatesVerifiableAddressChanged(ctx).Body.HTML)
}
func (t *VerifiableAddressChanged) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "verifiable_address_changed/email.body.plaintext.gotmpl", "verifiable_address_changed/email.body.plaintext*", t.m, t.d.CourierConfig().CourierTemplatesVerifiableAddressChanged(ctx).Body.PlainText)
}
func (t *VerifiableAddressChanged) MarshalJSON() ([]byte, error) {
return json.Marshal(t.m)
}
func (t *VerifiableAddressChanged) TemplateType() template.TemplateType {
return template.TypeVerifiableAddressChanged
}

View File

@@ -0,0 +1,44 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/pkg"
)
func TestVerifiableAddressChanged(t *testing.T) {
ctx := context.Background()
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewVerifiableAddressChanged(reg, &email.VerifiableAddressChangedModel{
To: "old@example.com",
ChangedAt: "2026-04-21T12:00:00Z",
Identity: map[string]any{"ID": "00000000-0000-0000-0000-000000000001", "Traits": map[string]any{"email": "new@example.com"}},
})
recipient, err := tpl.EmailRecipient()
require.NoError(t, err)
assert.Equal(t, "old@example.com", recipient)
subject, err := tpl.EmailSubject(ctx)
require.NoError(t, err)
assert.Contains(t, strings.ToLower(subject), "address")
body, err := tpl.EmailBody(ctx)
require.NoError(t, err)
assert.Contains(t, body, "00000000-0000-0000-0000-000000000001")
plain, err := tpl.EmailBodyPlaintext(ctx)
require.NoError(t, err)
assert.Contains(t, plain, "00000000-0000-0000-0000-000000000001")
assert.Contains(t, plain, "2026-04-21T12:00:00Z")
}

View File

@@ -0,0 +1,79 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
VerificationCodeInvalid struct {
d template.Dependencies
m *VerificationCodeInvalidModel
}
VerificationCodeInvalidModel struct {
To string `json:"to"`
RequestURL string `json:"request_url"`
TransientPayload map[string]interface{} `json:"transient_payload"`
}
)
func NewVerificationCodeInvalid(d template.Dependencies, m *VerificationCodeInvalidModel) *VerificationCodeInvalid {
return &VerificationCodeInvalid{d: d, m: m}
}
func (t *VerificationCodeInvalid) EmailRecipient() (string, error) {
return t.m.To, nil
}
func (t *VerificationCodeInvalid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(
ctx,
t.d,
os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)),
"verification_code/invalid/email.subject.gotmpl",
"verification_code/invalid/email.subject*",
t.m,
t.d.CourierConfig().CourierTemplatesVerificationCodeInvalid(ctx).Subject,
)
return strings.TrimSpace(subject), err
}
func (t *VerificationCodeInvalid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(
ctx,
t.d,
os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)),
"verification_code/invalid/email.body.gotmpl",
"verification_code/invalid/email.body*",
t.m,
t.d.CourierConfig().CourierTemplatesVerificationCodeInvalid(ctx).Body.HTML,
)
}
func (t *VerificationCodeInvalid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(
ctx,
t.d,
os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)),
"verification_code/invalid/email.body.plaintext.gotmpl",
"verification_code/invalid/email.body.plaintext*",
t.m,
t.d.CourierConfig().CourierTemplatesVerificationCodeInvalid(ctx).Body.PlainText,
)
}
func (t *VerificationCodeInvalid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.m)
}
func (t *VerificationCodeInvalid) TemplateType() template.TemplateType {
return template.TypeVerificationCodeInvalid
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestVerifyCodeInvalid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewVerificationCodeInvalid(reg, &email.VerificationCodeInvalidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("test=with remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/verification_code/invalid", template.TypeVerificationCodeInvalid)
})
}

View File

@@ -0,0 +1,82 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
VerificationCodeValid struct {
d template.Dependencies
m *VerificationCodeValidModel
}
VerificationCodeValidModel struct {
To string `json:"to"`
VerificationURL string `json:"verification_url"`
VerificationCode string `json:"verification_code"`
Identity map[string]any `json:"identity"`
RequestURL string `json:"request_url"`
TransientPayload map[string]any `json:"transient_payload"`
ExpiresInMinutes int `json:"expires_in_minutes"`
OAuth2LoginRequest *template.OAuth2LoginRequest `json:"oauth2_login_request,omitempty"`
}
)
func NewVerificationCodeValid(d template.Dependencies, m *VerificationCodeValidModel) *VerificationCodeValid {
return &VerificationCodeValid{d: d, m: m}
}
func (t *VerificationCodeValid) EmailRecipient() (string, error) {
return t.m.To, nil
}
func (t *VerificationCodeValid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(
ctx,
t.d,
os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)),
"verification_code/valid/email.subject.gotmpl",
"verification_code/valid/email.subject*",
t.m,
t.d.CourierConfig().CourierTemplatesVerificationCodeValid(ctx).Subject,
)
return strings.TrimSpace(subject), err
}
func (t *VerificationCodeValid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx,
t.d,
os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)),
"verification_code/valid/email.body.gotmpl",
"verification_code/valid/email.body*",
t.m,
t.d.CourierConfig().CourierTemplatesVerificationCodeValid(ctx).Body.HTML,
)
}
func (t *VerificationCodeValid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx,
t.d,
os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)),
"verification_code/valid/email.body.plaintext.gotmpl",
"verification_code/valid/email.body.plaintext*",
t.m,
t.d.CourierConfig().CourierTemplatesVerificationCodeValid(ctx).Body.PlainText,
)
}
func (t *VerificationCodeValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.m)
}
func (t *VerificationCodeValid) TemplateType() template.TemplateType {
return template.TypeVerificationCodeValid
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestVerifyCodeValid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewVerificationCodeValid(reg, &email.VerificationCodeValidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("test=with remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/verification_code/valid", template.TypeVerificationCodeValid)
})
}

View File

@@ -0,0 +1,55 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
VerificationInvalid struct {
d template.Dependencies
m *VerificationInvalidModel
}
VerificationInvalidModel struct {
To string `json:"to"`
RequestURL string `json:"request_url"`
TransientPayload map[string]interface{} `json:"transient_payload"`
}
)
func NewVerificationInvalid(d template.Dependencies, m *VerificationInvalidModel) *VerificationInvalid {
return &VerificationInvalid{d: d, m: m}
}
func (t *VerificationInvalid) EmailRecipient() (string, error) {
return t.m.To, nil
}
func (t *VerificationInvalid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "verification/invalid/email.subject.gotmpl", "verification/invalid/email.subject*", t.m, t.d.CourierConfig().CourierTemplatesVerificationInvalid(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *VerificationInvalid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "verification/invalid/email.body.gotmpl", "verification/invalid/email.body*", t.m, t.d.CourierConfig().CourierTemplatesVerificationInvalid(ctx).Body.HTML)
}
func (t *VerificationInvalid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "verification/invalid/email.body.plaintext.gotmpl", "verification/invalid/email.body.plaintext*", t.m, t.d.CourierConfig().CourierTemplatesVerificationInvalid(ctx).Body.PlainText)
}
func (t *VerificationInvalid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.m)
}
func (t *VerificationInvalid) TemplateType() template.TemplateType {
return template.TypeVerificationInvalid
}

View File

@@ -0,0 +1,32 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestVerifyInvalid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewVerificationInvalid(reg, &email.VerificationInvalidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("test=with remote resources", func(t *testing.T) {
t.Run("test=with remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/verification/invalid", template.TypeVerificationInvalid)
})
})
}

Some files were not shown because too many files have changed in this diff Show More