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:
parent
ef7f32b428
commit
8d88042489
6 changed files with 569 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue