Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion internal/email/payload.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,38 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"

"github.com/go-playground/validator/v10"
)

type AttachmentList []Attachment

type Attachment struct {
Path string `json:"path" validate:"required,uri"`
Name string `json:"name" validate:"required"`
}

func (a *AttachmentList) UnmarshalJSON(data []byte) error {
var strings []string
if err := json.Unmarshal(data, &strings); err == nil {
*a = make([]Attachment, len(strings))
for i, s := range strings {
(*a)[i] = Attachment{
Path: s,
Name: filepath.Base(s),
}
}
return nil
}
var attachments []Attachment
if err := json.Unmarshal(data, &attachments); err != nil {
return fmt.Errorf("attachments must be either array of strings or array of objects: %w", err)
}
*a = attachments
return nil
}

type Payload struct {
Id string `json:"id" validate:"required,uuid"`
From string `json:"from" validate:"required,email"`
Expand All @@ -16,7 +44,7 @@ type Payload struct {
Subject string `json:"subject" validate:"required"`
BodyHTML string `json:"body_html" validate:"required_without=BodyText"`
BodyText string `json:"body_text" validate:"required_without=BodyHTML"`
Attachments []string `json:"attachments" validate:"dive,uri"`
Attachments AttachmentList `json:"attachments" validate:"dive"`
CustomHeaders map[string]string `json:"custom_headers"`
}

Expand Down
169 changes: 169 additions & 0 deletions internal/email/payload_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
//go:build unit

package email

import (
"encoding/json"
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAttachmentList_UnmarshalJSON_ArrayOfStrings(t *testing.T) {
jsonData := []byte(`["file:///path/to/file1.pdf", "file:///path/to/file2.docx"]`)

var attachments AttachmentList
err := json.Unmarshal(jsonData, &attachments)

require.NoError(t, err)
assert.Len(t, attachments, 2)
assert.Equal(t, "file:///path/to/file1.pdf", attachments[0].Path)
assert.Equal(t, "file1.pdf", attachments[0].Name)
assert.Equal(t, "file:///path/to/file2.docx", attachments[1].Path)
assert.Equal(t, "file2.docx", attachments[1].Name)
}

func TestAttachmentList_UnmarshalJSON_ArrayOfObjects(t *testing.T) {
jsonData := []byte(`[
{"path": "file:///path/to/file1.pdf", "name": "Report Finale.pdf"},
{"path": "file:///path/to/file2.docx", "name": "Contratto.docx"}
]`)

var attachments AttachmentList
err := json.Unmarshal(jsonData, &attachments)

require.NoError(t, err)
assert.Len(t, attachments, 2)
assert.Equal(t, "file:///path/to/file1.pdf", attachments[0].Path)
assert.Equal(t, "Report Finale.pdf", attachments[0].Name)
assert.Equal(t, "file:///path/to/file2.docx", attachments[1].Path)
assert.Equal(t, "Contratto.docx", attachments[1].Name)
}

func TestAttachmentList_UnmarshalJSON_EmptyArray(t *testing.T) {
jsonData := []byte(`[]`)

var attachments AttachmentList
err := json.Unmarshal(jsonData, &attachments)

require.NoError(t, err)
assert.Len(t, attachments, 0)
}

func TestAttachmentList_UnmarshalJSON_InvalidFormat(t *testing.T) {
jsonData := []byte(`"not an array"`)

var attachments AttachmentList
err := json.Unmarshal(jsonData, &attachments)

require.Error(t, err)
assert.Contains(t, err.Error(), "attachments must be either array of strings or array of objects")
}

func TestLoadPayload_WithAttachmentsAsStrings(t *testing.T) {
jsonContent := `{
"id": "550e8400-e29b-41d4-a716-446655440000",
"from": "sender@example.com",
"reply_to": "reply@example.com",
"to": "recipient@example.com",
"subject": "Test Subject",
"body_text": "Test body",
"attachments": ["file:///path/to/file1.pdf", "file:///path/to/file2.docx"]
}`

tmpFile, err := os.CreateTemp("", "payload-*.json")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())

_, err = tmpFile.WriteString(jsonContent)
require.NoError(t, err)
tmpFile.Close()

payload, err := LoadPayload(tmpFile.Name())

require.NoError(t, err)
assert.Len(t, payload.Attachments, 2)
assert.Equal(t, "file:///path/to/file1.pdf", payload.Attachments[0].Path)
assert.Equal(t, "file1.pdf", payload.Attachments[0].Name)
assert.Equal(t, "file:///path/to/file2.docx", payload.Attachments[1].Path)
assert.Equal(t, "file2.docx", payload.Attachments[1].Name)
}

func TestLoadPayload_WithAttachmentsAsObjects(t *testing.T) {
jsonContent := `{
"id": "550e8400-e29b-41d4-a716-446655440000",
"from": "sender@example.com",
"reply_to": "reply@example.com",
"to": "recipient@example.com",
"subject": "Test Subject",
"body_text": "Test body",
"attachments": [
{"path": "file:///path/to/file1.pdf", "name": "Report.pdf"},
{"path": "file:///path/to/file2.docx", "name": "Contract.docx"}
]
}`

tmpFile, err := os.CreateTemp("", "payload-*.json")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())

_, err = tmpFile.WriteString(jsonContent)
require.NoError(t, err)
tmpFile.Close()

payload, err := LoadPayload(tmpFile.Name())

require.NoError(t, err)
assert.Len(t, payload.Attachments, 2)
assert.Equal(t, "file:///path/to/file1.pdf", payload.Attachments[0].Path)
assert.Equal(t, "Report.pdf", payload.Attachments[0].Name)
assert.Equal(t, "file:///path/to/file2.docx", payload.Attachments[1].Path)
assert.Equal(t, "Contract.docx", payload.Attachments[1].Name)
}

func TestLoadPayload_WithoutAttachments(t *testing.T) {
jsonContent := `{
"id": "550e8400-e29b-41d4-a716-446655440000",
"from": "sender@example.com",
"reply_to": "reply@example.com",
"to": "recipient@example.com",
"subject": "Test Subject",
"body_text": "Test body"
}`

tmpFile, err := os.CreateTemp("", "payload-*.json")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())

_, err = tmpFile.WriteString(jsonContent)
require.NoError(t, err)
tmpFile.Close()

payload, err := LoadPayload(tmpFile.Name())

require.NoError(t, err)
assert.Len(t, payload.Attachments, 0)
}

func TestAttachmentList_UnmarshalJSON_WithWindowsPath(t *testing.T) {
jsonData := []byte(`["file:///C:/Users/test/document.pdf"]`)

var attachments AttachmentList
err := json.Unmarshal(jsonData, &attachments)

require.NoError(t, err)
assert.Len(t, attachments, 1)
assert.Equal(t, "file:///C:/Users/test/document.pdf", attachments[0].Path)
assert.Equal(t, "document.pdf", attachments[0].Name)
}

func TestAttachmentList_UnmarshalJSON_MixedFormatsNotAllowed(t *testing.T) {
jsonData := []byte(`["file:///path/to/file1.pdf", {"path": "file:///path/to/file2.pdf", "name": "Custom.pdf"}]`)

var attachments AttachmentList
err := json.Unmarshal(jsonData, &attachments)

require.Error(t, err)
}
33 changes: 33 additions & 0 deletions internal/pipeline/intake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,36 @@ func TestIntakeValidationError(t *testing.T) {
assert.Contains(t, buf.String(), "level=ERROR msg=\"failed to validate payload")
assert.Contains(t, buf.String(), "payload validation failed")
}

func TestSuccessfulIntakeWithAttachmentsAsStrings(t *testing.T) {
payload := email.Payload{
Id: "550e8400-e29b-41d4-a716-446655440000",
From: "sender@example.com",
ReplyTo: "reply@example.com",
To: "recipient@example.com",
Subject: "Test Subject",
BodyText: "Test",
Attachments: email.AttachmentList{
{Path: "file:///path/to/file.pdf", Name: "file.pdf"},
},
}

payloadFile := createTestPayloadFile(t, payload)

outboxServiceMock := mocks.NewOutboxMock(
mocks.Email(outbox.Email{
Id: "1",
Status: outbox.StatusAccepted,
PayloadFilePath: payloadFile,
}),
)

buf, logger := mocks.NewLoggerMock()

intake := NewIntakePipeline(outboxServiceMock)
intake.logger = logger

intake.Process(context.TODO())

assert.Contains(t, buf.String(), "level=INFO msg=\"successfully intaken\" outbox=1")
}
25 changes: 7 additions & 18 deletions internal/smtp/message_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,15 @@ func (b *MessageBuilder) Build(payload email.Payload, attachmentsBasePath string
}
}

for _, attachment := range b.resolveAttachments(payload.Attachments, attachmentsBasePath) {
attachmentData, err := os.ReadFile(attachment)
for _, attachment := range payload.Attachments {
fullPath := attachmentsBasePath + attachment.Path

attachmentData, err := os.ReadFile(fullPath)
if err != nil {
return nil, fmt.Errorf("failed to read attachment: %w", err)
}

if err = b.writeAttachment(&buf, payload.Id, attachment, attachmentData); err != nil {
if err = b.writeAttachmentWithName(&buf, payload.Id, fullPath, attachment.Name, attachmentData); err != nil {
return nil, err
}
}
Expand All @@ -89,19 +91,6 @@ func (b *MessageBuilder) Build(payload email.Payload, attachmentsBasePath string
return buf.Bytes(), nil
}

func (b *MessageBuilder) resolveAttachments(attachments []string, basePath string) []string {
if len(attachments) == 0 {
return nil
}

attachmentsWithBasePath := make([]string, len(attachments))
for i, attachment := range attachments {
attachmentsWithBasePath[i] = basePath + attachment
}

return attachmentsWithBasePath
}

func (b *MessageBuilder) addStandardHeadersToMessage(msg *mail.Message, data email.Payload) {
msg.Header = make(mail.Header)
msg.Header["From"] = []string{data.From}
Expand Down Expand Up @@ -223,7 +212,7 @@ func (lbw *lineBreakWriter) Write(p []byte) (n int, err error) {
return n, nil
}

func (b *MessageBuilder) writeAttachment(target io.Writer, boundary string, path string, data []byte) error {
func (b *MessageBuilder) writeAttachmentWithName(target io.Writer, boundary string, path string, name string, data []byte) error {
mimeType, err := b.detectFileMime(path)
if err != nil {
return fmt.Errorf("failed to detect file mime type: %w", err)
Expand All @@ -233,7 +222,7 @@ func (b *MessageBuilder) writeAttachment(target io.Writer, boundary string, path
return fmt.Errorf("failed to write boundary: %w", err)
}

contentDisposition := fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(path))
contentDisposition := fmt.Sprintf("attachment; filename=\"%s\"", name)
if err := b.writeFoldedHeader(target, "Content-Disposition", contentDisposition); err != nil {
return fmt.Errorf("failed to write Content-Disposition header: %w", err)
}
Expand Down
Loading