From 9d651edaa55707c97c957f65cb3aeca6a063fc9c Mon Sep 17 00:00:00 2001 From: "nicole.bruni" Date: Wed, 8 Apr 2026 16:29:42 +0200 Subject: [PATCH 1/2] EMAIL-FILE-ATTACHMENTS | added dual format for email attachements --- internal/email/payload.go | 30 +++++- internal/email/payload_test.go | 169 +++++++++++++++++++++++++++++++ internal/pipeline/intake_test.go | 33 ++++++ internal/smtp/message_builder.go | 25 ++--- 4 files changed, 238 insertions(+), 19 deletions(-) create mode 100644 internal/email/payload_test.go diff --git a/internal/email/payload.go b/internal/email/payload.go index c1a883c..79256a3 100644 --- a/internal/email/payload.go +++ b/internal/email/payload.go @@ -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"` @@ -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"` } diff --git a/internal/email/payload_test.go b/internal/email/payload_test.go new file mode 100644 index 0000000..67d8c9e --- /dev/null +++ b/internal/email/payload_test.go @@ -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) +} diff --git a/internal/pipeline/intake_test.go b/internal/pipeline/intake_test.go index abd6321..1813860 100644 --- a/internal/pipeline/intake_test.go +++ b/internal/pipeline/intake_test.go @@ -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") +} diff --git a/internal/smtp/message_builder.go b/internal/smtp/message_builder.go index d25aa70..d906766 100644 --- a/internal/smtp/message_builder.go +++ b/internal/smtp/message_builder.go @@ -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 } } @@ -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} @@ -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) @@ -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) } From 69e665184f546c122edab07db7e516009537c2fc Mon Sep 17 00:00:00 2001 From: "lorenzo.massarini" Date: Wed, 8 Apr 2026 17:00:35 +0200 Subject: [PATCH 2/2] aggiornata versione del runner e versioni github actions --- .github/workflows/push.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 94563c8..38bbfa6 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -33,7 +33,7 @@ jobs: contents: read steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ secrets.OIDC_ROLE_ARN }} aws-region: ${{ vars.AWS_REGION }} @@ -108,10 +108,10 @@ jobs: - set-environment-variables steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ secrets.OIDC_ROLE_ARN }} aws-region: ${{ vars.AWS_REGION }} @@ -158,16 +158,16 @@ jobs: - set-environment-variables - build-image container: - image: 823598220965.dkr.ecr.eu-west-1.amazonaws.com/alpine-cdk-runner:0ce104344ee2d098f181b1d785bfa55fa68b6e9f + image: 823598220965.dkr.ecr.eu-west-1.amazonaws.com/alpine-cdk-runner:3e35d0454dbadd9b2b02f623e56f3bc759acaca3 credentials: username: AWS password: ${{ needs.fetch-ecr-password.outputs.ECR_PW }} steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ secrets.OIDC_ROLE_ARN }} aws-region: ${{ vars.AWS_REGION }}