Skip to content

Commit

Permalink
feat: add attachments to kannon (#211)
Browse files Browse the repository at this point in the history
  • Loading branch information
ludusrusso authored Apr 20, 2024
1 parent 185d91e commit 78a1e5c
Show file tree
Hide file tree
Showing 15 changed files with 408 additions and 111 deletions.
7 changes: 7 additions & 0 deletions .proto/kannon/mailer/apiv1/mailerapiv1.proto
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,18 @@ service Mailer {
rpc SendTemplate(SendTemplateReq) returns (SendRes) {}
}

message Attachment {
string filename = 1;
bytes content = 2;
}

message SendHTMLReq {
pkg.kannon.mailer.types.Sender sender = 1;
string subject = 3;
string html = 4;
optional google.protobuf.Timestamp scheduled_time = 5;
repeated pkg.kannon.mailer.types.Recipient recipients = 6;
repeated Attachment attachments = 7;
}

message SendTemplateReq {
Expand All @@ -25,6 +31,7 @@ message SendTemplateReq {
string template_id = 4;
optional google.protobuf.Timestamp scheduled_time = 5;
repeated pkg.kannon.mailer.types.Recipient recipients = 6;
repeated Attachment attachments = 7;
}

message SendRes {
Expand Down
6 changes: 6 additions & 0 deletions db/migrations/20240420090612_add-attachments-to-message.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- migrate:up
ALTER TABLE messages ADD COLUMN attachments JSONB;

-- migrate:down

ALTER TABLE messages DROP COLUMN attachments;
8 changes: 5 additions & 3 deletions db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ CREATE TABLE public.messages (
sender_email character varying(320) NOT NULL,
sender_alias character varying(100) NOT NULL,
template_id character varying(50) NOT NULL,
domain character varying(254) NOT NULL
domain character varying(254) NOT NULL,
attachments jsonb
);


Expand All @@ -76,7 +77,7 @@ CREATE TABLE public.messages (
--

CREATE TABLE public.schema_migrations (
version character varying(255) NOT NULL
version character varying(128) NOT NULL
);


Expand Down Expand Up @@ -323,4 +324,5 @@ INSERT INTO public.schema_migrations (version) VALUES
('20220806075424'),
('20220809092503'),
('20220830073617'),
('20220904111715');
('20220904111715'),
('20240420090612');
45 changes: 45 additions & 0 deletions internal/db/attachment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package sqlc

import (
"database/sql/driver"
"encoding/json"
"fmt"
)

var ErrInvalidAttachment = fmt.Errorf("invalid attachment")

type Attachments map[string][]byte

// implement Vauler interface
func (a Attachments) Value() (driver.Value, error) {
return json.Marshal(a)
}

// implement Scanner interface
func (a *Attachments) Scan(src interface{}) (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("%w: %w", ErrInvalidAttachment, err)
}
}()

if src == nil {
*a = nil
return nil
}

var byteSrc []byte

switch s := src.(type) {
case []byte:
byteSrc = s
case string:
byteSrc = []byte(s)
default:
return fmt.Errorf("unsupported scan type for TemplateType: %T", src)
}

err = json.Unmarshal(byteSrc, a)

return
}
52 changes: 52 additions & 0 deletions internal/db/attachment_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package sqlc_test

import (
"reflect"
"testing"

sqlc "github.com/ludusrusso/kannon/internal/db"
)

func TestReadWriteAttachment(t *testing.T) {
var testData = []struct {
name string
data sqlc.Attachments
}{
{
name: "empty attachment",
data: sqlc.Attachments{},
},
{
name: "single file attachment",
data: sqlc.Attachments{
"file1.txt": []byte("this is a file"),
},
},
{
name: "nil attachment",
data: nil,
},
}

for _, tt := range testData {
t.Run(tt.name, func(t *testing.T) {
// Marshal the attachment
value, err := tt.data.Value()
if err != nil {
t.Fatalf("error marshaling attachment: %v", err)
}

// Unmarshal the attachment
var att sqlc.Attachments
err = att.Scan(value)
if err != nil {
t.Fatalf("error unmarshaling attachment: %v", err)
}

// Check if the attachments are the same
if !reflect.DeepEqual(tt.data, att) {
t.Fatalf("attachments are not equal: %v != %v", tt.data, att)
}
})
}
}
1 change: 1 addition & 0 deletions internal/db/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions internal/db/pool.sql
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ SELECT * FROM sending_pool_emails WHERE message_id = $1 LIMIT $2 OFFSET $3;

-- name: CreateMessage :one
INSERT INTO messages
(message_id, subject, sender_email, sender_alias, template_id, domain) VALUES
($1, $2, $3, $4, $5, $6) RETURNING *;
(message_id, subject, sender_email, sender_alias, template_id, domain, attachments) VALUES
($1, $2, $3, $4, $5, $6, $7) RETURNING *;

-- name: CreatePool :exec
INSERT INTO sending_pool_emails (email, status, scheduled_time, original_scheduled_time, message_id, fields, domain) VALUES
Expand All @@ -61,7 +61,8 @@ SELECT
m.subject,
m.message_id,
m.sender_email,
m.sender_alias
m.sender_alias,
m.attachments
FROM messages as m
JOIN templates as t ON t.template_id = m.template_id
JOIN domains as d ON d.domain = m.domain
Expand Down
12 changes: 9 additions & 3 deletions internal/db/pool.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions internal/mailbuilder/attachments.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package mailbuilder

import "io"

type Attachments map[string]io.Reader
18 changes: 14 additions & 4 deletions internal/mailbuilder/mailbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,15 @@ func (m *mailBuilder) BuildEmail(ctx context.Context, email sqlc.SendingPoolEmai
Alias: emailData.SenderAlias,
}

logrus.Infof("📧 Building attachmes for %+v\n", emailData.Attachments)

attachments := make(Attachments)
for name, r := range emailData.Attachments {
attachments[name] = bytes.NewReader(r)
}

returnPath := buildReturnPath(email.Email, emailData.MessageID)
msg, err := m.prepareMessage(ctx, sender, emailData.Subject, email.Email, emailData.Domain, emailData.MessageID, emailData.Html, m.headers, email.Fields)
msg, err := m.prepareMessage(ctx, sender, emailData.Subject, email.Email, emailData.Domain, emailData.MessageID, emailData.Html, m.headers, email.Fields, attachments)
if err != nil {
return nil, err
}
Expand All @@ -72,7 +79,7 @@ func (m *mailBuilder) BuildEmail(ctx context.Context, email sqlc.SendingPoolEmai
}, nil
}

func (m *mailBuilder) prepareMessage(ctx context.Context, sender pool.Sender, subject string, to string, domain string, messageID string, html string, baseHeaders headers, fields map[string]string) ([]byte, error) {
func (m *mailBuilder) prepareMessage(ctx context.Context, sender pool.Sender, subject string, to string, domain string, messageID string, html string, baseHeaders headers, fields map[string]string, attachments Attachments) ([]byte, error) {
emailMessageID := buildEmailID(to, messageID)
html, err := m.preparedHTML(ctx, html, to, domain, messageID, fields)
if err != nil {
Expand All @@ -83,7 +90,7 @@ func (m *mailBuilder) prepareMessage(ctx context.Context, sender pool.Sender, su
return nil, err
}
h := buildHeaders(subject, sender, to, messageID, emailMessageID, baseHeaders)
return renderMsg(html, h)
return renderMsg(html, h, attachments)
}

func signMessage(domain string, dkimPrivateKey string, msg []byte) ([]byte, error) {
Expand Down Expand Up @@ -136,14 +143,17 @@ func (m *mailBuilder) addTrackPixel(ctx context.Context, html string, email stri
}

// renderMsg render a MsgPayload to an SMTP message
func renderMsg(html string, headers headers) ([]byte, error) {
func renderMsg(html string, headers headers, attachments Attachments) ([]byte, error) {
msg := mail.NewMessage()

for key, value := range headers {
msg.SetHeader(key, value)
}
msg.SetDateHeader("Date", time.Now())
msg.SetBody("text/html", html)
for name, r := range attachments {
msg.AttachReader(name, r)
}

var buff bytes.Buffer
if _, err := msg.WriteTo(&buff); err != nil {
Expand Down
53 changes: 53 additions & 0 deletions internal/mailbuilder/mailbuilder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net/mail"
"os"
"strings"
"testing"

schema "github.com/ludusrusso/kannon/db"
Expand Down Expand Up @@ -115,3 +116,55 @@ func TestPrepareMail(t *testing.T) {

assert.Equal(t, "test Test", string(html))
}

func TestPrepareMailWithAttachments(t *testing.T) {
d, err := adminAPI.CreateDomain(context.Background(), &adminapiv1.CreateDomainRequest{
Domain: "test2.com",
})
assert.Nil(t, err)

token := base64.StdEncoding.EncodeToString([]byte(d.Domain + ":" + d.Key))
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "Basic "+token))

res, err := ma.SendHTML(ctx, &mailerapiv1.SendHTMLReq{
Sender: &pb.Sender{
Email: "[email protected]",
Alias: "Test",
},
Subject: "Test {{ name }}",
Html: "test {{name }}",
ScheduledTime: timestamppb.Now(),
Recipients: []*pb.Recipient{
{
Email: "[email protected]",
Fields: map[string]string{
"name": "Test",
},
},
},
Attachments: []*mailerapiv1.Attachment{
{
Filename: "test.txt",
Content: []byte("test"),
},
},
})
assert.Nil(t, err)

err = pm.SetScheduled(ctx, res.MessageId, "[email protected]")
assert.Nil(t, err)

emails, err := pm.PrepareForSend(context.Background(), 1)
assert.Nil(t, err)
assert.Equal(t, 1, len(emails))

m, err := mb.BuildEmail(context.Background(), emails[0])
assert.Nil(t, err)
parsed, err := mail.ReadMessage(bytes.NewReader(m.Body))
assert.Nil(t, err)

assert.Nil(t, err)
assert.Equal(t, "[email protected]", parsed.Header.Get("To"))
assert.Equal(t, "Test <[email protected]>", parsed.Header.Get("From"))
assert.Equal(t, "multipart/mixed", strings.Split(parsed.Header.Get("Content-Type"), ";")[0])
}
5 changes: 3 additions & 2 deletions internal/pool/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type Sender struct {

// SendingPoolManager is a manger for sending pool
type SendingPoolManager interface {
AddRecipientsPool(ctx context.Context, template sqlc.Template, recipents []*pb.Recipient, from Sender, scheduled time.Time, subject string, domain string) (sqlc.Message, error)
AddRecipientsPool(ctx context.Context, template sqlc.Template, recipents []*pb.Recipient, from Sender, scheduled time.Time, subject string, domain string, attachments sqlc.Attachments) (sqlc.Message, error)
PrepareForSend(ctx context.Context, max uint) ([]sqlc.SendingPoolEmail, error)
PrepareForValidate(ctx context.Context, max uint) ([]sqlc.SendingPoolEmail, error)
SetScheduled(ctx context.Context, messageID string, email string) error
Expand All @@ -30,14 +30,15 @@ type sendingPoolManager struct {
}

// AddPool starts a new schedule in the pool
func (m *sendingPoolManager) AddRecipientsPool(ctx context.Context, template sqlc.Template, recipents []*pb.Recipient, from Sender, scheduled time.Time, subject string, domain string) (sqlc.Message, error) {
func (m *sendingPoolManager) AddRecipientsPool(ctx context.Context, template sqlc.Template, recipents []*pb.Recipient, from Sender, scheduled time.Time, subject string, domain string, attachments sqlc.Attachments) (sqlc.Message, error) {
msg, err := m.db.CreateMessage(ctx, sqlc.CreateMessageParams{
TemplateID: template.TemplateID,
Domain: domain,
Subject: subject,
SenderEmail: from.Email,
SenderAlias: from.Alias,
MessageID: utils.CreateMessageID(domain),
Attachments: attachments,
})
if err != nil {
return sqlc.Message{}, err
Expand Down
Loading

0 comments on commit 78a1e5c

Please sign in to comment.