Skip to content

Commit

Permalink
Add agent.ProdAgent.Send
Browse files Browse the repository at this point in the history
All that's left now is to add a Lambda event for email.Messages and a
CLI command to invoke the Lambda with an email.Message. Essentially.
  • Loading branch information
mbland committed May 16, 2023
1 parent d74719f commit de7ce74
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 19 deletions.
41 changes: 32 additions & 9 deletions agent/agent.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package agent

import (
"bytes"
"context"
"errors"
"fmt"
Expand Down Expand Up @@ -29,6 +28,7 @@ type SubscriptionAgent interface {
type ProdAgent struct {
SenderAddress string
EmailSiteTitle string
EmailDomainName string
UnsubscribeEmail string
ApiBaseUrl string
NewUid func() (uuid.UUID, error)
Expand Down Expand Up @@ -136,19 +136,13 @@ func verifyHtmlBody(siteTitle, verifyLink string) string {
func (a *ProdAgent) makeVerificationEmail(sub *db.Subscriber) []byte {
verifyLink := ops.VerifyUrl(a.ApiBaseUrl, sub.Email, sub.Uid)
recipient := &email.Recipient{Email: sub.Email, Uid: sub.Uid}
buf := &bytes.Buffer{}

msg := email.NewMessageTemplate(&email.Message{
mt := email.NewMessageTemplate(&email.Message{
From: a.SenderAddress,
Subject: verifySubjectPrefix + a.EmailSiteTitle,
TextBody: verifyTextBody(a.EmailSiteTitle, verifyLink),
HtmlBody: verifyHtmlBody(a.EmailSiteTitle, verifyLink),
})

// Don't check the EmitMessage error because bytes.Buffer can essentially
// never return an error. If it runs out of memory, it panics.
msg.EmitMessage(buf, recipient)
return buf.Bytes()
return mt.GenerateMessage(recipient)
}

func (a *ProdAgent) Verify(
Expand Down Expand Up @@ -221,3 +215,32 @@ func (a *ProdAgent) Restore(ctx context.Context, address string) (err error) {
}
return
}

func (a *ProdAgent) Send(ctx context.Context, msg *email.Message) (err error) {
if err = msg.Validate(email.CheckDomain(a.EmailDomainName)); err != nil {
return err
}

mt := email.NewMessageTemplate(msg)

var sendErr error
sender := db.SubscriberFunc(func(sub *db.Subscriber) bool {
recipient := &email.Recipient{Email: sub.Email, Uid: sub.Uid}
recipient.SetUnsubscribeInfo(a.UnsubscribeEmail, a.ApiBaseUrl)

msg := mt.GenerateMessage(recipient)
var msgId string

if msgId, sendErr = a.Mailer.Send(ctx, sub.Email, msg); sendErr != nil {
return false
}
a.Log.Printf("sent %s to %s", msgId, sub.Email)
return true
})

err = a.Db.ProcessSubscribersInState(ctx, db.SubscriberVerified, sender)
if err = errors.Join(err, sendErr); err != nil {
err = fmt.Errorf("error sending message to list: %w", err)
}
return
}
128 changes: 125 additions & 3 deletions agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ package agent

import (
"context"
"encoding/json"
"errors"
"fmt"
"testing"
"time"

Expand All @@ -19,9 +21,10 @@ import (
is "gotest.tools/assert/cmp"
)

const testEmail = "foo@bar.com"
const testSender = "updates@foo.com"
const testEmail = td.TestEmail
const testSender = "Blog Updates <updates@foo.com>"
const testSiteTitle = "Foo Blog"
const testDomainName = "foo.com"
const testUnsubEmail = "unsubscribe@foo.com"
const testUnsubBaseUrl = "https://foo.com/email/"

Expand Down Expand Up @@ -63,6 +66,7 @@ func newProdAgentTestFixture() *prodAgentTestFixture {
pa := &ProdAgent{
testSender,
testSiteTitle,
testDomainName,
testUnsubEmail,
testUnsubBaseUrl,
newUid,
Expand Down Expand Up @@ -178,7 +182,8 @@ func TestSubscribe(t *testing.T) {
sub := f.db.Index[testEmail]
assert.DeepEqual(t, pendingSubscriber, sub)

verifyEmail := f.mailer.GetMessageTo(t, testEmail)
sentMsgId, verifyEmail := f.mailer.GetMessageTo(t, testEmail)
assert.Equal(t, msgId, sentMsgId)
assert.Assert(t, is.Contains(verifyEmail, verifySubjectPrefix))

expectedLog := "sent verification email to " + testEmail +
Expand Down Expand Up @@ -559,3 +564,120 @@ func TestRestore(t *testing.T) {
assertServerErrorContains(t, err, errMsg)
})
}

func TestSend(t *testing.T) {
msg := &email.Message{}
err := json.Unmarshal([]byte(email.ExampleMessageJson), &msg)
if err != nil {
panic("email.ExampleMessageJson failed to unmarshal: " + err.Error())
}
msg.From = testSender

setup := func() (
*ProdAgent,
*testdoubles.Database,
*testdoubles.Mailer,
*tu.Logs,
context.Context) {
f := newProdAgentTestFixture()
ctx := context.Background()

for i, sub := range db.TestSubscribers {
if err := f.db.Put(ctx, sub); err != nil {
msg := "failed to Put test subscriber " + sub.Email + ": " +
err.Error()
panic(msg)
}
f.mailer.MessageIds[sub.Email] = fmt.Sprintf("msg-%d", i)
}
return f.agent, f.db, f.mailer, f.logs, ctx
}

assertSentToVerifiedSubscriber := func(
t *testing.T,
i int,
sub *db.Subscriber,
mailer *testdoubles.Mailer,
logs *tu.Logs,
) {
t.Helper()

msgId, msg := mailer.GetMessageTo(t, sub.Email)
unsubUrl := ops.UnsubscribeUrl(testUnsubBaseUrl, sub.Email, sub.Uid)
unsubMailto := ops.UnsubscribeMailto(testUnsubEmail, sub.Email, sub.Uid)
assert.Equal(t, mailer.MessageIds[sub.Email], msgId)
assert.Assert(t, is.Contains(msg, unsubUrl))
assert.Assert(t, is.Contains(msg, unsubMailto))
logs.AssertContains(t, "sent "+msgId+" to "+sub.Email)
}

assertSentToVerifiedSubscribers := func(
t *testing.T, mailer *testdoubles.Mailer, logs *tu.Logs,
) {
t.Helper()

for i, sub := range db.TestVerifiedSubscribers {
assertSentToVerifiedSubscriber(t, i, sub, mailer, logs)
}
}

assertDidNotSendToPendingSubscribers := func(
t *testing.T, mailer *testdoubles.Mailer,
) {
t.Helper()

for _, sub := range db.TestPendingSubscribers {
mailer.AssertNoMessageSent(t, sub.Email)
}
}

t.Run("Succeeds", func(t *testing.T) {
agent, _, mailer, logs, ctx := setup()

err := agent.Send(ctx, msg)

assert.NilError(t, err)
assertSentToVerifiedSubscribers(t, mailer, logs)
assertDidNotSendToPendingSubscribers(t, mailer)
})

t.Run("FailsIfMessageFailsValidation", func(t *testing.T) {
agent, _, mailer, _, ctx := setup()
badMsg := *msg
badMsg.From = ""

err := agent.Send(ctx, &badMsg)

assert.ErrorContains(t, err, "missing From")
assert.Equal(t, 0, len(mailer.RecipientMessages))
})

t.Run("FailsIfProcessSubscribersInStateFails", func(t *testing.T) {
agent, dbase, mailer, _, ctx := setup()
procSubsErr := errors.New("ProcSubsInState error")
dbase.SimulateProcSubsErr = func(_ string) error {
return procSubsErr
}

err := agent.Send(ctx, msg)

assert.Assert(t, tu.ErrorIs(err, procSubsErr))
assert.Equal(t, 0, len(mailer.RecipientMessages))
})

t.Run("StopsProcessingAndFailsIfSendFails", func(t *testing.T) {
agent, _, mailer, logs, ctx := setup()
secondSub := db.TestVerifiedSubscribers[1]
sendErr := errors.New("Mailer.Send failed")
mailer.RecipientErrors[secondSub.Email] = sendErr

err := agent.Send(ctx, msg)

assert.Assert(t, tu.ErrorIs(err, sendErr))
assertSentToVerifiedSubscriber(
t, 0, db.TestVerifiedSubscribers[0], mailer, logs,
)
mailer.AssertNoMessageSent(t, secondSub.Email)
assert.Equal(t, 1, len(mailer.RecipientMessages))
})
}
1 change: 1 addition & 0 deletions agent/decoy_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
type DecoyAgent struct {
SenderAddress string
EmailSiteTitle string
EmailDomainName string
UnsubscribeEmail string
ApiBaseUrl string
NewUid func() (uuid.UUID, error)
Expand Down
3 changes: 2 additions & 1 deletion lambda/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ func buildHandler() (h *handler.Handler, err error) {
opts.SenderUserName,
opts.EmailDomainName,
),
EmailSiteTitle: opts.EmailSiteTitle,
EmailSiteTitle: opts.EmailSiteTitle,
EmailDomainName: opts.EmailDomainName,
UnsubscribeEmail: opts.UnsubscribeUserName +
"@" + opts.EmailDomainName,
ApiBaseUrl: fmt.Sprintf(
Expand Down
17 changes: 11 additions & 6 deletions testdoubles/mailer.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,24 @@ func NewMailer() *Mailer {
func (m *Mailer) Send(
ctx context.Context, recipient string, msg []byte,
) (messageId string, err error) {
m.RecipientMessages[recipient] = msg
return m.MessageIds[recipient], m.RecipientErrors[recipient]
if err = m.RecipientErrors[recipient]; err == nil {
messageId = m.MessageIds[recipient]
m.RecipientMessages[recipient] = msg
}
return
}

func (m *Mailer) GetMessageTo(t *testing.T, recipient string) string {
func (m *Mailer) GetMessageTo(
t *testing.T, recipient string,
) (msgId, msg string) {
t.Helper()
var msg []byte
var rawMsg []byte
var ok bool

if msg, ok = m.RecipientMessages[recipient]; !ok {
if rawMsg, ok = m.RecipientMessages[recipient]; !ok {
t.Fatalf("did not receive a message to %s", recipient)
}
return string(msg)
return m.MessageIds[recipient], string(rawMsg)
}

func (m *Mailer) AssertNoMessageSent(t *testing.T, recipient string) {
Expand Down

0 comments on commit de7ce74

Please sign in to comment.