Implement Attachment Support in Email Messaging

- Enhanced the `mailer` provider to support email attachments, including both regular and inline attachments.
- Updated the `buildMessage` method to handle multipart email formats for messages with attachments.
- Added new tests for attachment handling in `mailer_test.go` and `mailgun_test.go`, ensuring comprehensive coverage for single, multiple, and inline attachments.
- Revised `TODO.md` to reflect the completion of attachment support across email providers, confirming that all major providers now support this feature.
This commit is contained in:
Max 2026-01-18 17:05:24 +08:00
parent ef7f32b428
commit 8d88042489
6 changed files with 569 additions and 4 deletions

View file

@ -912,7 +912,31 @@ Created new `yao/assert` package for universal assertion/validation:
### 10.2 Messenger Attachment Support ✅
> **Conclusion:** `yao/messenger` already supports attachments
> **Conclusion:** All email providers now support attachments.
**Implementation Status:**
| Provider | Attachment Support | Implementation |
|----------|-------------------|----------------|
| Twilio/SendGrid | ✅ Supported | `buildAttachments()` - base64 encoded |
| Mailgun | ✅ Supported | `sendEmailWithAttachments()` - multipart/form-data |
| SMTP (mailer) | ✅ Supported | `buildMessageWithAttachments()` - MIME multipart/mixed |
**Features Supported:**
- Regular attachments (Content-Disposition: attachment)
- Inline attachments (Content-Disposition: inline) with Content-ID for HTML embedding
- Multiple attachments per email
- Automatic content type detection
- Base64 encoding for SMTP (RFC 2045 compliant, 76-char line wrapping)
**Tests Added:**
- `messenger/providers/mailgun/mailgun_test.go`:
- `TestSend_EmailWithAttachments_MockServer`
- `TestSend_EmailWithInlineAttachment_MockServer`
- `TestSend_EmailWithAttachments_RealAPI`
- `messenger/providers/mailer/mailer_test.go`:
- `TestBuildMessage_WithAttachments` (single, multiple, inline, no attachments)
- `TestSend_EmailWithAttachments_RealAPI`
```go
// messenger/types/types.go

View file

@ -3,6 +3,7 @@ package mailer
import (
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"net"
"net/smtp"
@ -370,6 +371,20 @@ func (p *Provider) buildMessage(message *types.Message) (string, error) {
}
}
// Check if we have attachments
hasAttachments := len(message.Attachments) > 0
if hasAttachments {
// Use multipart/mixed for attachments
return p.buildMessageWithAttachments(&content, message)
}
// No attachments - use simple format
return p.buildMessageSimple(&content, message)
}
// buildMessageSimple builds email without attachments
func (p *Provider) buildMessageSimple(content *strings.Builder, message *types.Message) (string, error) {
// MIME headers for HTML content
if message.HTML != "" {
content.WriteString("MIME-Version: 1.0\r\n")
@ -402,6 +417,107 @@ func (p *Provider) buildMessage(message *types.Message) (string, error) {
return content.String(), nil
}
// buildMessageWithAttachments builds email with attachments using multipart/mixed
func (p *Provider) buildMessageWithAttachments(content *strings.Builder, message *types.Message) (string, error) {
// Use unique boundaries
mixedBoundary := fmt.Sprintf("mixed_%d", time.Now().UnixNano())
altBoundary := fmt.Sprintf("alt_%d", time.Now().UnixNano())
content.WriteString("MIME-Version: 1.0\r\n")
content.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n", mixedBoundary))
content.WriteString("\r\n")
// Start mixed boundary
content.WriteString(fmt.Sprintf("--%s\r\n", mixedBoundary))
// Add body content
if message.HTML != "" && message.Body != "" {
// Both text and HTML - use multipart/alternative
content.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", altBoundary))
content.WriteString("\r\n")
// Plain text part
content.WriteString(fmt.Sprintf("--%s\r\n", altBoundary))
content.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
content.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
content.WriteString("\r\n")
content.WriteString(message.Body)
content.WriteString("\r\n")
// HTML part
content.WriteString(fmt.Sprintf("--%s\r\n", altBoundary))
content.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
content.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
content.WriteString("\r\n")
content.WriteString(message.HTML)
content.WriteString("\r\n")
// End alternative boundary
content.WriteString(fmt.Sprintf("--%s--\r\n", altBoundary))
} else if message.HTML != "" {
// HTML only
content.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
content.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
content.WriteString("\r\n")
content.WriteString(message.HTML)
content.WriteString("\r\n")
} else {
// Plain text only
content.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
content.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
content.WriteString("\r\n")
content.WriteString(message.Body)
content.WriteString("\r\n")
}
// Add attachments
for _, attachment := range message.Attachments {
content.WriteString(fmt.Sprintf("--%s\r\n", mixedBoundary))
// Determine content type
contentType := attachment.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
// Determine disposition
disposition := "attachment"
if attachment.Inline {
disposition = "inline"
}
// Write attachment headers
content.WriteString(fmt.Sprintf("Content-Type: %s; name=\"%s\"\r\n", contentType, attachment.Filename))
content.WriteString("Content-Transfer-Encoding: base64\r\n")
content.WriteString(fmt.Sprintf("Content-Disposition: %s; filename=\"%s\"\r\n", disposition, attachment.Filename))
// Add Content-ID for inline attachments
if attachment.Inline && attachment.CID != "" {
content.WriteString(fmt.Sprintf("Content-ID: <%s>\r\n", attachment.CID))
}
content.WriteString("\r\n")
// Encode attachment content as base64
encoded := base64.StdEncoding.EncodeToString(attachment.Content)
// Split into 76-character lines (RFC 2045)
for i := 0; i < len(encoded); i += 76 {
end := i + 76
if end > len(encoded) {
end = len(encoded)
}
content.WriteString(encoded[i:end])
content.WriteString("\r\n")
}
}
// End mixed boundary
content.WriteString(fmt.Sprintf("--%s--\r\n", mixedBoundary))
return content.String(), nil
}
// extractEmailAddress extracts the email address from a string that may contain display name
// e.g., "John Doe <john@example.com>" -> "john@example.com"
func extractEmailAddress(address string) string {

View file

@ -768,3 +768,156 @@ func TestProvider_TriggerWebhook(t *testing.T) {
assert.Nil(t, msg)
assert.Contains(t, err.Error(), "TriggerWebhook not supported for SMTP/mailer provider")
}
// ============================================================================
// Attachment Tests
// ============================================================================
func TestBuildMessage_WithAttachments(t *testing.T) {
config := types.ProviderConfig{
Name: "test",
Connector: "mailer",
Options: map[string]interface{}{
"smtp": map[string]interface{}{
"host": "smtp.example.com",
"port": 587,
"username": "test@example.com",
"password": "testpass",
"from": "sender@example.com",
},
},
}
provider, err := NewMailerProvider(config)
require.NoError(t, err)
t.Run("single_attachment", func(t *testing.T) {
message := &types.Message{
Type: types.MessageTypeEmail,
To: []string{"test@example.com"},
Subject: "Test with Attachment",
Body: "This is a test email with attachment",
Attachments: []types.Attachment{
{
Filename: "test.txt",
ContentType: "text/plain",
Content: []byte("Hello, this is test content!"),
},
},
}
content, err := provider.buildMessage(message)
require.NoError(t, err)
// Verify multipart/mixed boundary
assert.Contains(t, content, "multipart/mixed")
assert.Contains(t, content, "Content-Disposition: attachment")
assert.Contains(t, content, `filename="test.txt"`)
assert.Contains(t, content, "Content-Transfer-Encoding: base64")
})
t.Run("multiple_attachments", func(t *testing.T) {
message := &types.Message{
Type: types.MessageTypeEmail,
To: []string{"test@example.com"},
Subject: "Test with Multiple Attachments",
Body: "This is a test email with multiple attachments",
HTML: "<p>This is a test email with multiple attachments</p>",
Attachments: []types.Attachment{
{
Filename: "doc1.txt",
ContentType: "text/plain",
Content: []byte("Document 1 content"),
},
{
Filename: "doc2.pdf",
ContentType: "application/pdf",
Content: []byte("%PDF-1.4 fake pdf"),
},
},
}
content, err := provider.buildMessage(message)
require.NoError(t, err)
// Verify both attachments are present
assert.Contains(t, content, `filename="doc1.txt"`)
assert.Contains(t, content, `filename="doc2.pdf"`)
assert.Contains(t, content, "text/plain")
assert.Contains(t, content, "application/pdf")
})
t.Run("inline_attachment", func(t *testing.T) {
message := &types.Message{
Type: types.MessageTypeEmail,
To: []string{"test@example.com"},
Subject: "Test with Inline Image",
HTML: `<p>Image: <img src="cid:logo123"></p>`,
Attachments: []types.Attachment{
{
Filename: "logo.png",
ContentType: "image/png",
Content: []byte{0x89, 0x50, 0x4E, 0x47}, // PNG magic bytes
Inline: true,
CID: "logo123",
},
},
}
content, err := provider.buildMessage(message)
require.NoError(t, err)
// Verify inline disposition and Content-ID
assert.Contains(t, content, "Content-Disposition: inline")
assert.Contains(t, content, "Content-ID: <logo123>")
})
t.Run("no_attachments", func(t *testing.T) {
message := &types.Message{
Type: types.MessageTypeEmail,
To: []string{"test@example.com"},
Subject: "Test without Attachment",
Body: "This is a plain text email",
}
content, err := provider.buildMessage(message)
require.NoError(t, err)
// Should not contain multipart/mixed
assert.NotContains(t, content, "multipart/mixed")
assert.Contains(t, content, "text/plain")
})
}
func TestSend_EmailWithAttachments_RealAPI(t *testing.T) {
if testing.Short() {
t.Skip("Skipping real API test in short mode")
}
config := loadPrimaryTestConfig(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
ctx := context.Background()
emailMessage := &types.Message{
Type: types.MessageTypeEmail,
To: []string{TestEmailAgent},
Subject: "SMTP Test Email with Attachment - " + time.Now().Format("2006-01-02 15:04:05"),
Body: "This is a test email with attachment sent via SMTP",
HTML: "<h1>SMTP Test</h1><p>This email has an attachment.</p>",
Attachments: []types.Attachment{
{
Filename: "test-attachment.txt",
ContentType: "text/plain",
Content: []byte("This is a test attachment content.\nLine 2 of the attachment.\nLine 3."),
},
},
}
err = provider.Send(ctx, emailMessage)
if err != nil {
t.Logf("Real SMTP call with attachment failed (may be expected in CI): %v", err)
} else {
t.Log("Real SMTP call with attachment succeeded")
}
}

View file

@ -1,10 +1,13 @@
package mailgun
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"strings"
"time"
@ -218,6 +221,17 @@ func (p *Provider) Close() error {
func (p *Provider) sendEmail(ctx context.Context, message *types.Message) error {
apiURL := fmt.Sprintf("%s/%s/messages", p.baseURL, p.domain)
// Check if we have attachments - use multipart/form-data if so
if len(message.Attachments) > 0 {
return p.sendEmailWithAttachments(ctx, apiURL, message)
}
// No attachments - use simple URL-encoded form
return p.sendEmailSimple(ctx, apiURL, message)
}
// sendEmailSimple sends email without attachments using URL-encoded form
func (p *Provider) sendEmailSimple(ctx context.Context, apiURL string, message *types.Message) error {
// Prepare form data
data := url.Values{}
@ -295,3 +309,134 @@ func (p *Provider) sendEmail(ctx context.Context, message *types.Message) error
return nil
}
// sendEmailWithAttachments sends email with attachments using multipart/form-data
func (p *Provider) sendEmailWithAttachments(ctx context.Context, apiURL string, message *types.Message) error {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
// From address
from := message.From
if from == "" {
from = p.from
}
if err := writer.WriteField("from", from); err != nil {
return fmt.Errorf("failed to write from field: %w", err)
}
// To addresses
for _, to := range message.To {
if err := writer.WriteField("to", to); err != nil {
return fmt.Errorf("failed to write to field: %w", err)
}
}
// Subject
if err := writer.WriteField("subject", message.Subject); err != nil {
return fmt.Errorf("failed to write subject field: %w", err)
}
// Text body
if message.Body != "" {
if err := writer.WriteField("text", message.Body); err != nil {
return fmt.Errorf("failed to write text field: %w", err)
}
}
// HTML body
if message.HTML != "" {
if err := writer.WriteField("html", message.HTML); err != nil {
return fmt.Errorf("failed to write html field: %w", err)
}
}
// Custom headers
if message.Headers != nil {
for key, value := range message.Headers {
if err := writer.WriteField("h:"+key, value); err != nil {
return fmt.Errorf("failed to write header field: %w", err)
}
}
}
// Custom variables (metadata)
if message.Metadata != nil {
for key, value := range message.Metadata {
if str, ok := value.(string); ok {
if err := writer.WriteField("v:"+key, str); err != nil {
return fmt.Errorf("failed to write metadata field: %w", err)
}
}
}
}
// Priority
if message.Priority > 0 {
if err := writer.WriteField("o:priority", fmt.Sprintf("%d", message.Priority)); err != nil {
return fmt.Errorf("failed to write priority field: %w", err)
}
}
// Scheduled sending
if message.ScheduledAt != nil {
if err := writer.WriteField("o:deliverytime", message.ScheduledAt.Format(time.RFC1123Z)); err != nil {
return fmt.Errorf("failed to write deliverytime field: %w", err)
}
}
// Add attachments
for _, attachment := range message.Attachments {
fieldName := "attachment"
if attachment.Inline {
fieldName = "inline"
}
// Create form file with proper headers
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, attachment.Filename))
if attachment.ContentType != "" {
h.Set("Content-Type", attachment.ContentType)
} else {
h.Set("Content-Type", "application/octet-stream")
}
part, err := writer.CreatePart(h)
if err != nil {
return fmt.Errorf("failed to create attachment part: %w", err)
}
if _, err := part.Write(attachment.Content); err != nil {
return fmt.Errorf("failed to write attachment content: %w", err)
}
}
// Close multipart writer
if err := writer.Close(); err != nil {
return fmt.Errorf("failed to close multipart writer: %w", err)
}
// Create request with context
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, &body)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Set authentication and content type
req.SetBasicAuth("api", p.apiKey)
req.Header.Set("Content-Type", writer.FormDataContentType())
// Send request
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
// Check response
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("Mailgun API error: %s - %s", resp.Status, string(respBody))
}
return nil
}

View file

@ -28,7 +28,7 @@ func (p *Provider) TriggerWebhook(c interface{}) (*types.Message, error) {
// Extract common Mailgun webhook fields
event := ginCtx.Request.FormValue("event")
recipient := ginCtx.Request.FormValue("recipient")
messageId := ginCtx.Request.FormValue("message-id")
messageID := ginCtx.Request.FormValue("message-id")
timestamp := ginCtx.Request.FormValue("timestamp")
token := ginCtx.Request.FormValue("token")
signature := ginCtx.Request.FormValue("signature")
@ -38,8 +38,8 @@ func (p *Provider) TriggerWebhook(c interface{}) (*types.Message, error) {
if recipient != "" {
message.To = []string{recipient}
}
if messageId != "" {
message.Metadata["message_id"] = messageId
if messageID != "" {
message.Metadata["message_id"] = messageID
}
// Store webhook-specific data

View file

@ -551,3 +551,130 @@ func BenchmarkValidate(b *testing.B) {
}
}
}
// ============================================================================
// Attachment Tests
// ============================================================================
func TestSend_EmailWithAttachments_MockServer(t *testing.T) {
// Create a mock HTTP server that validates the multipart request
var receivedContentType string
var receivedBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedContentType = r.Header.Get("Content-Type")
// Read the body
body, _ := r.Body.Read(make([]byte, 1024*1024))
_ = body
receivedBody = make([]byte, r.ContentLength)
r.Body.Read(receivedBody)
// Return success
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id": "test-id", "message": "Queued"}`))
}))
defer server.Close()
config := loadTestConfig(t)
provider, err := NewMailgunProvider(config)
require.NoError(t, err)
// Override base URL to use mock server
provider.baseURL = server.URL
ctx := context.Background()
emailMessage := &types.Message{
Type: types.MessageTypeEmail,
To: []string{"test@example.com"},
Subject: "Test Email with Attachment",
Body: "This is a test email with attachment",
HTML: "<h1>Test</h1><p>This is a test email with attachment</p>",
Attachments: []types.Attachment{
{
Filename: "test.txt",
ContentType: "text/plain",
Content: []byte("Hello, this is a test attachment content!"),
},
{
Filename: "test.pdf",
ContentType: "application/pdf",
Content: []byte("%PDF-1.4 fake pdf content"),
},
},
}
err = provider.Send(ctx, emailMessage)
assert.NoError(t, err)
// Verify the request used multipart/form-data
assert.Contains(t, receivedContentType, "multipart/form-data")
}
func TestSend_EmailWithInlineAttachment_MockServer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id": "test-id", "message": "Queued"}`))
}))
defer server.Close()
config := loadTestConfig(t)
provider, err := NewMailgunProvider(config)
require.NoError(t, err)
provider.baseURL = server.URL
ctx := context.Background()
emailMessage := &types.Message{
Type: types.MessageTypeEmail,
To: []string{"test@example.com"},
Subject: "Test Email with Inline Image",
Body: "This is a test email with inline image",
HTML: `<h1>Test</h1><p>Image: <img src="cid:logo123"></p>`,
Attachments: []types.Attachment{
{
Filename: "logo.png",
ContentType: "image/png",
Content: []byte{0x89, 0x50, 0x4E, 0x47}, // PNG magic bytes
Inline: true,
CID: "logo123",
},
},
}
err = provider.Send(ctx, emailMessage)
assert.NoError(t, err)
}
func TestSend_EmailWithAttachments_RealAPI(t *testing.T) {
if testing.Short() {
t.Skip("Skipping real API test in short mode")
}
config := loadTestConfig(t)
provider, err := NewMailgunProvider(config)
require.NoError(t, err)
ctx := context.Background()
emailMessage := &types.Message{
Type: types.MessageTypeEmail,
To: []string{TestEmailAgent},
Subject: "Unit Test Email with Attachment - " + time.Now().Format("2006-01-02 15:04:05"),
Body: "This is a unit test email with attachment sent via real Mailgun API",
HTML: "<h1>Unit Test</h1><p>This email has an attachment.</p>",
Attachments: []types.Attachment{
{
Filename: "test-attachment.txt",
ContentType: "text/plain",
Content: []byte("This is a test attachment content.\nLine 2 of the attachment."),
},
},
}
err = provider.Send(ctx, emailMessage)
if err != nil {
t.Logf("Real API call with attachment failed (may be expected in CI): %v", err)
} else {
t.Log("Real Mailgun API call with attachment succeeded")
}
}