snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"error": {
|
||||
"code": 404,
|
||||
"status": "Not Found",
|
||||
"message": "Unable to locate the resource"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"error": {
|
||||
"code": 404,
|
||||
"status": "Not Found",
|
||||
"message": "Unable to locate the resource"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"error": {
|
||||
"code": 400,
|
||||
"status": "Bad Request",
|
||||
"message": "uuid: incorrect UUID length 10 in string \"not-a-uuid\""
|
||||
}
|
||||
}
|
||||
@@ -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
13
courier/channel.go
Normal 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
108
courier/courier.go
Normal 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)
|
||||
}
|
||||
}
|
||||
173
courier/courier_dispatcher.go
Normal file
173
courier/courier_dispatcher.go
Normal 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
|
||||
}
|
||||
176
courier/courier_dispatcher_test.go
Normal file
176
courier/courier_dispatcher_test.go
Normal 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
120
courier/email_templates.go
Normal 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)
|
||||
}
|
||||
}
|
||||
74
courier/email_templates_test.go
Normal file
74
courier/email_templates_test.go
Normal 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
219
courier/handler.go
Normal 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
285
courier/handler_test.go
Normal 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
157
courier/http_channel.go
Normal 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
169
courier/http_test.go
Normal 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
226
courier/message.go
Normal 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 }
|
||||
55
courier/message_dispatch.go
Normal file
55
courier/message_dispatch.go
Normal 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
61
courier/message_test.go
Normal 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
43
courier/persistence.go
Normal 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
52
courier/sms.go
Normal 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
69
courier/sms_templates.go
Normal 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)
|
||||
}
|
||||
}
|
||||
64
courier/sms_templates_test.go
Normal file
64
courier/sms_templates_test.go
Normal 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
140
courier/sms_test.go
Normal 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
159
courier/smtp.go
Normal 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
201
courier/smtp_channel.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package courier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
stdmail "net/mail"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.20.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/courier/template"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/mail/v3"
|
||||
"github.com/ory/x/otelx"
|
||||
)
|
||||
|
||||
type (
|
||||
SMTPChannel struct {
|
||||
smtpClient *SMTPClient
|
||||
d Dependencies
|
||||
|
||||
newEmailTemplateFromMessage func(d template.Dependencies, msg Message) (EmailTemplate, error)
|
||||
}
|
||||
)
|
||||
|
||||
var _ Channel = new(SMTPChannel)
|
||||
|
||||
func NewSMTPChannel(deps Dependencies, cfg *config.SMTPConfig) (*SMTPChannel, error) {
|
||||
return NewSMTPChannelWithCustomTemplates(deps, cfg, NewEmailTemplateFromMessage)
|
||||
}
|
||||
|
||||
func NewSMTPChannelWithCustomTemplates(deps Dependencies, cfg *config.SMTPConfig, newEmailTemplateFromMessage func(d template.Dependencies, msg Message) (EmailTemplate, error)) (*SMTPChannel, error) {
|
||||
smtpClient, err := NewSMTPClient(deps, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SMTPChannel{
|
||||
smtpClient: smtpClient,
|
||||
d: deps,
|
||||
newEmailTemplateFromMessage: newEmailTemplateFromMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *SMTPChannel) ID() string {
|
||||
return "email"
|
||||
}
|
||||
|
||||
func (c *SMTPChannel) Dispatch(ctx context.Context, msg Message) (err error) {
|
||||
ctx, span := c.d.Tracer(ctx).Tracer().Start(ctx, "courier.SMTPChannel.Dispatch")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
if c.smtpClient.Host == "" {
|
||||
return errors.WithStack(herodot.ErrInternalServerError().WithErrorf("Courier tried to deliver an email but %s is not set!", config.ViperKeyCourierSMTPURL))
|
||||
}
|
||||
|
||||
channels, err := c.d.CourierConfig().CourierChannels(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var cfg *config.SMTPConfig
|
||||
for _, channel := range channels {
|
||||
if channel.ID == "email" && channel.SMTPConfig != nil {
|
||||
cfg = channel.SMTPConfig
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
return errors.WithStack(herodot.ErrInternalServerError().WithErrorf("Courier tried to deliver an email but SMTP channel is misconfigured."))
|
||||
}
|
||||
|
||||
// Re-validate the recipient read from the database: a value that is neither
|
||||
// RFC 5322-valid nor injection-safe per x.IsEmailAddress is abandoned
|
||||
// rather than placed in an SMTP command or header.
|
||||
_, rfcErr := stdmail.ParseAddress(msg.Recipient)
|
||||
if rfcErr != nil && !x.IsEmailAddress(msg.Recipient) {
|
||||
if err := c.d.CourierPersister().SetMessageStatus(ctx, msg.ID, MessageStatusAbandoned); err != nil {
|
||||
c.d.Logger().WithError(err).Error(`Unable to set the message status to "abandoned".`)
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
return errors.WithStack(herodot.ErrInternalServerError().
|
||||
WithReasonf("courier: email recipient for message %s is not a valid email address", msg.ID))
|
||||
}
|
||||
|
||||
gm := mail.NewMessage()
|
||||
if cfg.FromName == "" {
|
||||
gm.SetHeader("From", cfg.FromAddress)
|
||||
} else {
|
||||
gm.SetAddressHeader("From", cfg.FromAddress, cfg.FromName)
|
||||
}
|
||||
|
||||
gm.SetHeader("To", msg.Recipient)
|
||||
gm.SetHeader("Subject", msg.Subject)
|
||||
|
||||
headers := cfg.Headers
|
||||
for k, v := range headers {
|
||||
gm.SetHeader(k, v)
|
||||
}
|
||||
|
||||
gm.SetBody("text/plain", msg.Body)
|
||||
|
||||
logger := c.d.Logger().
|
||||
WithField("smtp_server", net.JoinHostPort(c.smtpClient.Host, strconv.Itoa(c.smtpClient.Port))).
|
||||
WithField("smtp_ssl_enabled", c.smtpClient.SSL).
|
||||
WithField("message_from", cfg.FromAddress).
|
||||
WithField("message_id", msg.ID).
|
||||
WithField("message_nid", msg.NID).
|
||||
WithField("message_type", msg.Type).
|
||||
WithField("message_template_type", msg.TemplateType).
|
||||
WithField("message_subject", msg.Subject).
|
||||
WithField("trace_id", span.SpanContext().TraceID())
|
||||
|
||||
tmpl, err := c.newEmailTemplateFromMessage(c.d, msg)
|
||||
if err != nil {
|
||||
logger.
|
||||
WithError(err).Error(`Unable to get email template from message.`)
|
||||
} else if htmlBody, err := tmpl.EmailBody(ctx); err != nil {
|
||||
logger.
|
||||
WithError(err).Error(`Unable to get email body from template.`)
|
||||
} else {
|
||||
gm.AddAlternative("text/html", htmlBody)
|
||||
}
|
||||
|
||||
dialCtx, dialSpan := c.d.Tracer(ctx).Tracer().Start(ctx, "courier.SMTPChannel.Dispatch.Dial", trace.WithAttributes(
|
||||
semconv.NetPeerName(c.smtpClient.Host),
|
||||
semconv.NetPeerPort(c.smtpClient.Port),
|
||||
semconv.NetProtocolName("smtp"),
|
||||
))
|
||||
snd, err := c.smtpClient.Dial(dialCtx)
|
||||
otelx.End(dialSpan, &err)
|
||||
|
||||
if err != nil {
|
||||
logger.
|
||||
WithError(err).
|
||||
WithField("smtp_host", c.smtpClient.Host).
|
||||
WithField("smtp_port", c.smtpClient.Port).
|
||||
Error("Unable to dial SMTP connection.")
|
||||
return errors.WithStack(herodot.ErrInternalServerError().
|
||||
WithError(err.Error()).WithReason("failed to send email via smtp"))
|
||||
}
|
||||
defer func() { _ = snd.Close() }()
|
||||
|
||||
sendCtx, sendSpan := c.d.Tracer(ctx).Tracer().Start(ctx, "courier.SMTPChannel.Dispatch.Send")
|
||||
if rfcErr == nil {
|
||||
// mail.Send derives the envelope from the To/Cc/Bcc headers, so Cc/Bcc
|
||||
// configured via courier.smtp.headers are delivered.
|
||||
err = mail.Send(sendCtx, snd, gm)
|
||||
} else {
|
||||
// A non-RFC recipient bypasses mail.Send's strict header re-parse with
|
||||
// an explicit envelope; Cc/Bcc headers are not delivered on this path.
|
||||
for k := range headers {
|
||||
if strings.EqualFold(k, "Cc") || strings.EqualFold(k, "Bcc") {
|
||||
logger.Warnf("Configured %q recipients are not delivered for a non-RFC email address.", k)
|
||||
}
|
||||
}
|
||||
err = snd.Send(sendCtx, cfg.FromAddress, []string{msg.Recipient}, gm)
|
||||
}
|
||||
otelx.End(sendSpan, &err)
|
||||
|
||||
if err != nil {
|
||||
logger.
|
||||
WithError(err).
|
||||
Error("Unable to send email using SMTP connection.")
|
||||
|
||||
var protoErr *textproto.Error
|
||||
var mailErr *mail.SendError
|
||||
|
||||
switch {
|
||||
case errors.As(err, &mailErr) && errors.As(mailErr.Cause, &protoErr) && protoErr.Code >= 500:
|
||||
fallthrough
|
||||
case errors.As(err, &protoErr) && protoErr.Code >= 500:
|
||||
// See https://en.wikipedia.org/wiki/List_of_SMTP_server_return_codes
|
||||
// If the SMTP server responds with 5xx, sending the message should not be retried (without changing something about the request)
|
||||
if err := c.d.CourierPersister().SetMessageStatus(ctx, msg.ID, MessageStatusAbandoned); err != nil {
|
||||
logger.
|
||||
WithError(err).
|
||||
Error(`Unable to reset the retried message's status to "abandoned".`)
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
return errors.WithStack(herodot.ErrInternalServerError().
|
||||
WithError(err.Error()).WithReason("failed to send email via smtp"))
|
||||
}
|
||||
|
||||
dispatchDuration := time.Since(msg.CreatedAt).Milliseconds()
|
||||
logger.WithField("dispatch_duration_ms", dispatchDuration).Debug("Courier sent out message.")
|
||||
|
||||
return nil
|
||||
}
|
||||
249
courier/smtp_test.go
Normal file
249
courier/smtp_test.go
Normal 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
216
courier/smtp_wire_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
12
courier/stub/request.config.mailer.jsonnet
Normal file
12
courier/stub/request.config.mailer.jsonnet
Normal 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
|
||||
}
|
||||
5
courier/stub/request.config.twilio.jsonnet
Normal file
5
courier/stub/request.config.twilio.jsonnet
Normal file
@@ -0,0 +1,5 @@
|
||||
function(ctx) {
|
||||
from: "Kratos Test",
|
||||
to: ctx.recipient,
|
||||
body: ctx.body
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1 @@
|
||||
A new sign-in method was added to your account
|
||||
@@ -0,0 +1 @@
|
||||
A new sign-in method (device key) was added to your account. If this wasn't you, secure your account.
|
||||
@@ -0,0 +1,5 @@
|
||||
Login to your account with the following code:
|
||||
|
||||
{{ .LoginCode }}
|
||||
|
||||
It expires in {{ .ExpiresInMinutes }} minutes.
|
||||
@@ -0,0 +1,5 @@
|
||||
Login to your account with the following code:
|
||||
|
||||
{{ .LoginCode }}
|
||||
|
||||
It expires in {{ .ExpiresInMinutes }} minutes.
|
||||
@@ -0,0 +1 @@
|
||||
Use code {{ .LoginCode }} to log in
|
||||
@@ -0,0 +1,6 @@
|
||||
Your login code is: {{ .LoginCode }}
|
||||
|
||||
It expires in {{ .ExpiresInMinutes }} minutes.
|
||||
{{ if .RequestURLDomain }}
|
||||
@{{ .RequestURLDomain }} #{{ .LoginCode }}
|
||||
{{ end -}}
|
||||
@@ -0,0 +1 @@
|
||||
Your verification code is: {{ .Code }}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
Account access attempted
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Recover access to your account
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
Account access attempted
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
Use code {{ .RecoveryCode }} to recover access to your account
|
||||
@@ -0,0 +1,3 @@
|
||||
Your recovery code is: {{ .RecoveryCode }}
|
||||
|
||||
@{{ .RequestURLDomain }} #{{ .RecoveryCode }}
|
||||
@@ -0,0 +1,5 @@
|
||||
Complete your account registration with the following code:
|
||||
|
||||
{{ .RegistrationCode }}
|
||||
|
||||
This code expires in {{ .ExpiresInMinutes }} minutes.
|
||||
@@ -0,0 +1,5 @@
|
||||
Complete your account registration with the following code:
|
||||
|
||||
{{ .RegistrationCode }}
|
||||
|
||||
This code expires in {{ .ExpiresInMinutes }} minutes.
|
||||
@@ -0,0 +1 @@
|
||||
Use code {{ .RegistrationCode }} to complete your account registration
|
||||
@@ -0,0 +1,6 @@
|
||||
Your registration code is: {{ .RegistrationCode }}
|
||||
|
||||
It expires in {{ .ExpiresInMinutes }} minutes.
|
||||
{{ if .RequestURLDomain }}
|
||||
@{{ .RequestURLDomain }} #{{ .RegistrationCode }}
|
||||
{{ end -}}
|
||||
@@ -0,0 +1 @@
|
||||
stub email body {{ if .HTMLBody }}{{ .HTMLBody }}{{ else }}{{ .Body }}{{ end }}
|
||||
@@ -0,0 +1,2 @@
|
||||
{{ $l := cat "lang=" .lang }}
|
||||
{{ nospace $l }}
|
||||
@@ -0,0 +1,3 @@
|
||||
{{- if eq .lang "en_US" -}}
|
||||
{{ template "email.body.html.en_US.gotmpl" . }}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1 @@
|
||||
stub email body {{ .Body }}
|
||||
@@ -0,0 +1,5 @@
|
||||
{{ $t1 := title .input }}
|
||||
{{ $t1 := nospace $t1 }}
|
||||
{{ $t2 := upper $t1 }}
|
||||
{{ $t3 := cat $t1 "," $t2 }}
|
||||
{{ nospace $t3 }}
|
||||
@@ -0,0 +1 @@
|
||||
stub email subject {{ .Subject }}
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1 @@
|
||||
A verifiable address on your account was changed
|
||||
@@ -0,0 +1 @@
|
||||
An address on your account was changed. If this wasn't you, contact support.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
Someone tried to verify this email address
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
Please verify your email address
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
Someone tried to verify this email address
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
Use code {{ .VerificationCode }} to verify your account
|
||||
@@ -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 -}}
|
||||
55
courier/template/email/authenticator_key_added.go
Normal file
55
courier/template/email/authenticator_key_added.go
Normal 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
|
||||
}
|
||||
55
courier/template/email/authenticator_key_added_test.go
Normal file
55
courier/template/email/authenticator_key_added_test.go
Normal 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")
|
||||
}
|
||||
65
courier/template/email/login_code_valid.go
Normal file
65
courier/template/email/login_code_valid.go
Normal 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
|
||||
}
|
||||
30
courier/template/email/login_code_valid_test.go
Normal file
30
courier/template/email/login_code_valid_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
58
courier/template/email/recovery_code_invalid.go
Normal file
58
courier/template/email/recovery_code_invalid.go
Normal 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
|
||||
}
|
||||
30
courier/template/email/recovery_code_invalid_test.go
Normal file
30
courier/template/email/recovery_code_invalid_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
63
courier/template/email/recovery_code_valid.go
Normal file
63
courier/template/email/recovery_code_valid.go
Normal 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
|
||||
}
|
||||
30
courier/template/email/recovery_code_valid_test.go
Normal file
30
courier/template/email/recovery_code_valid_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
55
courier/template/email/recovery_invalid.go
Normal file
55
courier/template/email/recovery_invalid.go
Normal 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
|
||||
}
|
||||
30
courier/template/email/recovery_invalid_test.go
Normal file
30
courier/template/email/recovery_invalid_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
58
courier/template/email/recovery_valid.go
Normal file
58
courier/template/email/recovery_valid.go
Normal 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
|
||||
}
|
||||
30
courier/template/email/recovery_valid_test.go
Normal file
30
courier/template/email/recovery_valid_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
65
courier/template/email/registration_code_valid.go
Normal file
65
courier/template/email/registration_code_valid.go
Normal 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
|
||||
}
|
||||
30
courier/template/email/registration_code_valid_test.go
Normal file
30
courier/template/email/registration_code_valid_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
41
courier/template/email/stub.go
Normal file
41
courier/template/email/stub.go
Normal 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 }
|
||||
55
courier/template/email/verifiable_address_changed.go
Normal file
55
courier/template/email/verifiable_address_changed.go
Normal 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
|
||||
}
|
||||
44
courier/template/email/verifiable_address_changed_test.go
Normal file
44
courier/template/email/verifiable_address_changed_test.go
Normal 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")
|
||||
}
|
||||
79
courier/template/email/verification_code_invalid.go
Normal file
79
courier/template/email/verification_code_invalid.go
Normal 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
|
||||
}
|
||||
30
courier/template/email/verification_code_invalid_test.go
Normal file
30
courier/template/email/verification_code_invalid_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
82
courier/template/email/verification_code_valid.go
Normal file
82
courier/template/email/verification_code_valid.go
Normal 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
|
||||
}
|
||||
30
courier/template/email/verification_code_valid_test.go
Normal file
30
courier/template/email/verification_code_valid_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
55
courier/template/email/verification_invalid.go
Normal file
55
courier/template/email/verification_invalid.go
Normal 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
|
||||
}
|
||||
32
courier/template/email/verification_invalid_test.go
Normal file
32
courier/template/email/verification_invalid_test.go
Normal 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
Reference in New Issue
Block a user