From 549576dd3495ec7fceb9ae8b9172beda26442745 Mon Sep 17 00:00:00 2001 From: barnard Date: Mon, 5 Dec 2022 14:52:05 +0200 Subject: [PATCH] feat: send emails via http api endpoint instead of smtp (#1030) --- courier/courier.go | 20 ++-- courier/mailer.go | 75 +++++++++++++ courier/mailer_test.go | 125 +++++++++++++++++++++ courier/smtp.go | 3 + courier/stub/request.config.mailer.jsonnet | 11 ++ driver/config/config.go | 29 ++++- 6 files changed, 253 insertions(+), 10 deletions(-) create mode 100644 courier/mailer.go create mode 100644 courier/mailer_test.go create mode 100644 courier/stub/request.config.mailer.jsonnet diff --git a/courier/courier.go b/courier/courier.go index 824d0b2e17b8..a827b9fbc624 100644 --- a/courier/courier.go +++ b/courier/courier.go @@ -49,11 +49,12 @@ type ( } courier struct { - smsClient *smsClient - smtpClient *smtpClient - deps Dependencies - failOnError bool - backoff backoff.BackOff + smsClient *smsClient + smtpClient *smtpClient + mailerClient *mailerClient + deps Dependencies + failOnError bool + backoff backoff.BackOff } ) @@ -63,10 +64,11 @@ func NewCourier(ctx context.Context, deps Dependencies) (Courier, error) { return nil, err } return &courier{ - smsClient: newSMS(ctx, deps), - smtpClient: smtp, - deps: deps, - backoff: backoff.NewExponentialBackOff(), + smsClient: newSMS(ctx, deps), + smtpClient: smtp, + mailerClient: newMailer(ctx, deps), + deps: deps, + backoff: backoff.NewExponentialBackOff(), }, nil } diff --git a/courier/mailer.go b/courier/mailer.go new file mode 100644 index 000000000000..14ab70bd0bd2 --- /dev/null +++ b/courier/mailer.go @@ -0,0 +1,75 @@ +// Copyright © 2022 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package courier + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/pkg/errors" + + "github.com/ory/kratos/request" +) + +type mailerDataModel struct { + Recipient string + TemplateType TemplateType + TemplateData EmailTemplate +} + +type mailerClient struct { + RequestConfig json.RawMessage +} + +func newMailer(ctx context.Context, deps Dependencies) *mailerClient { + return &mailerClient{ + RequestConfig: deps.CourierConfig().CourierMailerRequestConfig(ctx), + } +} +func (c *courier) dispatchMailerEmail(ctx context.Context, msg Message) error { + builder, err := request.NewBuilder(c.mailerClient.RequestConfig, c.deps) + if err != nil { + return err + } + + tmpl, err := c.smtpClient.NewTemplateFromMessage(c.deps, msg) + if err != nil { + return err + } + + td := mailerDataModel{ + Recipient: msg.Recipient, + TemplateType: msg.TemplateType, + TemplateData: tmpl, + } + + req, err := builder.BuildRequest(ctx, td) + if err != nil { + return err + } + + res, err := c.deps.HTTPClient(ctx).Do(req) + if err != nil { + return err + } + + defer res.Body.Close() + + switch res.StatusCode { + case http.StatusOK: + case http.StatusCreated: + default: + return errors.New(http.StatusText(res.StatusCode)) + } + + c.deps.Logger(). + WithField("message_id", msg.ID). + WithField("message_type", msg.Type). + WithField("message_template_type", msg.TemplateType). + WithField("message_subject", msg.Subject). + Debug("Courier sent out mailer.") + + return nil +} diff --git a/courier/mailer_test.go b/courier/mailer_test.go new file mode 100644 index 000000000000..757c4fd60218 --- /dev/null +++ b/courier/mailer_test.go @@ -0,0 +1,125 @@ +// Copyright © 2022 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package courier_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofrs/uuid" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/kratos/courier/template/email" + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/internal" + "github.com/ory/x/resilience" +) + +func TestQueueMailerEmail(t *testing.T) { + ctx := context.Background() + + type sendEmailRequestBody struct { + IdentityID string + IdentityEmail string + Recipient string + TemplateType string + To string + RecoveryCode string + RecoveryURL string + VerificationURL string + VerificationCode string + Body string + Subject string + } + + expectedEmail := []*email.TestStubModel{ + { + To: "test-2@test.com", + Subject: "test-mailer-subject-1", + Body: "test-mailer-body-1", + }, + { + To: "test-2@test.com", + Subject: "test-mailer-subject-2", + Body: "test-mailer-body-2", + }, + } + + actual := make([]sendEmailRequestBody, 0, 2) + 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 = append(actual, body) + })) + t.Cleanup(srv.Close) + + requestConfig := fmt.Sprintf(`{ + "url": "%s", + "method": "POST", + "body": "file://./stub/request.config.mailer.jsonnet", + "auth": { + "type": "basic_auth", + "config": { + "user": "me", + "password": "12345" + } + } + }`, srv.URL) + + conf, reg := internal.NewFastRegistryWithMocks(t) + conf.MustSet(ctx, config.ViperKeyCourierMailerEnabled, true) + conf.MustSet(ctx, config.ViperKeyCourierMailerRequestConfig, requestConfig) + conf.MustSet(ctx, config.ViperKeyCourierSMTPURL, "http://foo.url") + reg.Logger().Level = logrus.TraceLevel + + c, err := reg.Courier(ctx) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(ctx) + defer t.Cleanup(cancel) + + for _, message := range expectedEmail { + id, err := c.QueueEmail(ctx, email.NewTestStub(reg, message)) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, id) + } + + go func() { + require.NoError(t, c.Work(ctx)) + }() + + require.NoError(t, resilience.Retry(reg.Logger(), time.Second, time.Minute*10, func() error { + if len(actual) == len(expectedEmail) { + return nil + } + return errors.New("capacity not reached") + })) + + for i, message := range actual { + expected := expectedEmail[i] + + assert.Equal(t, expected.To, message.To) + assert.Equal(t, expected.Body, message.Body) + assert.Equal(t, expected.Subject, message.Subject) + } +} diff --git a/courier/smtp.go b/courier/smtp.go index 9d374c2bd629..8804171136ee 100644 --- a/courier/smtp.go +++ b/courier/smtp.go @@ -157,6 +157,9 @@ func (c *courier) QueueEmail(ctx context.Context, t EmailTemplate) (uuid.UUID, e } func (c *courier) dispatchEmail(ctx context.Context, msg Message) error { + if c.deps.CourierConfig().CourierMailerEnabled(ctx) { + return c.dispatchMailerEmail(ctx, msg) + } if c.smtpClient.Host == "" { return errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Courier tried to deliver an email but %s is not set!", config.ViperKeyCourierSMTPURL)) } diff --git a/courier/stub/request.config.mailer.jsonnet b/courier/stub/request.config.mailer.jsonnet new file mode 100644 index 000000000000..903aff05f541 --- /dev/null +++ b/courier/stub/request.config.mailer.jsonnet @@ -0,0 +1,11 @@ +function(ctx) { + recipient: ctx.Recipient, + template_type: ctx.TemplateType, + to: if "TemplateData" in ctx && "To" in ctx.TemplateData then ctx.TemplateData.To else null, + recovery_code: if "TemplateData" in ctx && "RecoveryCode" in ctx.TemplateData then ctx.TemplateData.RecoveryCode else null, + recovery_url: if "TemplateData" in ctx && "RecoveryURL" in ctx.TemplateData then ctx.TemplateData.RecoveryURL else null, + verification_url: if "TemplateData" in ctx && "VerificationURL" in ctx.TemplateData then ctx.TemplateData.VerificationURL else null, + verification_code: if "TemplateData" in ctx && "VerificationCode" in ctx.TemplateData then ctx.TemplateData.VerificationCode else null, + subject: if "TemplateData" in ctx && "Subject" in ctx.TemplateData then ctx.TemplateData.Subject else null, + body: if "TemplateData" in ctx && "Body" in ctx.TemplateData then ctx.TemplateData.Body else null +} diff --git a/driver/config/config.go b/driver/config/config.go index 301704fc176e..7a8f13578814 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -78,6 +78,8 @@ const ( ViperKeyCourierTemplatesVerificationValidEmail = "courier.templates.verification.valid.email" ViperKeyCourierTemplatesVerificationCodeInvalidEmail = "courier.templates.verification_code.invalid.email" ViperKeyCourierTemplatesVerificationCodeValidEmail = "courier.templates.verification_code.valid.email" + ViperKeyCourierMailerEnabled = "courier.mailer.enabled" + ViperKeyCourierMailerRequestConfig = "courier.mailer.request_config" ViperKeyCourierSMTPFrom = "courier.smtp.from_address" ViperKeyCourierSMTPFromName = "courier.smtp.from_name" ViperKeyCourierSMTPHeaders = "courier.smtp.headers" @@ -264,6 +266,8 @@ type ( Config() *Config } CourierConfigs interface { + CourierMailerEnabled(ctx context.Context) bool + CourierMailerRequestConfig(ctx context.Context) json.RawMessage CourierSMTPURL(ctx context.Context) (*url.URL, error) CourierSMTPClientCertPath(ctx context.Context) string CourierSMTPClientKeyPath(ctx context.Context) string @@ -976,6 +980,29 @@ func (p *Config) SelfServiceFlowLogoutRedirectURL(ctx context.Context) *url.URL return p.GetProvider(ctx).RequestURIF(ViperKeySelfServiceLogoutBrowserDefaultReturnTo, p.SelfServiceBrowserDefaultReturnTo(ctx)) } +func (p *Config) CourierMailerEnabled(ctx context.Context) bool { + return p.GetProvider(ctx).Bool(ViperKeyCourierMailerEnabled) +} + +func (p *Config) CourierMailerRequestConfig(ctx context.Context) json.RawMessage { + if !p.GetProvider(ctx).Bool(ViperKeyCourierMailerEnabled) { + return nil + } + + out, err := p.GetProvider(ctx).Marshal(kjson.Parser()) + if err != nil { + p.l.WithError(err).Warn("Unable to marshal mailer request configuration.") + return nil + } + + config := gjson.GetBytes(out, ViperKeyCourierMailerRequestConfig).Raw + if len(config) <= 0 { + return json.RawMessage("{}") + } + + return json.RawMessage(config) +} + func (p *Config) CourierSMTPClientCertPath(ctx context.Context) string { return p.GetProvider(ctx).StringF(ViperKeyCourierSMTPClientCertPath, "") } @@ -1078,7 +1105,7 @@ func (p *Config) CourierSMSRequestConfig(ctx context.Context) json.RawMessage { out, err := p.GetProvider(ctx).Marshal(kjson.Parser()) if err != nil { - p.l.WithError(err).Warn("Unable to marshal self service strategy configuration.") + p.l.WithError(err).Warn("Unable to marshal SMS request configuration.") return nil }