mirror of
https://github.com/nabbar/golib.git
synced 2025-12-24 11:51:02 +08:00
Improvements, test & documentatons (2025-11 #2)
[root] - UPDATE documentation: enhanced README and TESTING guidelines - UPDATE dependencies: bump dependencies [config/components] - UPDATE mail component: apply update following changes in related package - UPDATE smtp component: apply update following changes in related package [mail] - MAJOR REFACTORING - REFACTOR package structure: reorganized into 4 specialized subpackages (queuer, render, sender, smtp) - ADD mail/queuer: mail queue management with counter, monitoring, and comprehensive tests - ADD mail/render: email template rendering with themes and direction handling (moved from mailer package) - ADD mail/sender: email composition and sending with attachments, priorities, and encoding - ADD mail/smtp: SMTP protocol handling with TLS modes and DSN support - ADD documentation: comprehensive README and TESTING for all subpackages - ADD tests: complete test suites with benchmarks, concurrency, and edge cases for all subpackages [mailer] - DEPRECATED - DELETE package: entire package merged into mail/render [mailPooler] - DEPRECATED - DELETE package: entire package merged into mail/queuer [smtp] - DEPRECATED - DELETE root package: entire package moved to mail/smtp - REFACTOR tlsmode: enhanced with encoding, formatting, and viper support (moved to mail/smtp/tlsmode) [size] - ADD documentation: comprehensive README - UPDATE interface: improved Size type methods - UPDATE encoding: enhanced marshaling support - UPDATE formatting: better unit handling and display - UPDATE parsing: improved error handling and validation [socket/server/unix] - ADD platform support: macOS-specific permission handling (perm_darwin.go) - ADD platform support: Linux-specific permission handling (perm_linux.go) - UPDATE listener: improved Unix socket and datagram listeners - UPDATE error handling: enhanced error messages for Unix sockets [socket/server/unixgram] - ADD platform support: macOS-specific permission handling (perm_darwin.go) - ADD platform support: Linux-specific permission handling (perm_linux.go) - UPDATE listener: improved Unix datagram listener - UPDATE error handling: enhanced error messages [socket/server/tcp] - UPDATE listener: improved TCP listener implementation
This commit is contained in:
846
mail/sender/README.md
Normal file
846
mail/sender/README.md
Normal file
@@ -0,0 +1,846 @@
|
||||
# Mail Sender Package
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://golang.org/)
|
||||
[](TESTING.md)
|
||||
|
||||
High-level email composition and sending library for Go with SMTP integration, multi-part content, file attachments, and configuration-based setup.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Key Features](#key-features)
|
||||
- [Installation](#installation)
|
||||
- [Architecture](#architecture)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Performance](#performance)
|
||||
- [Use Cases](#use-cases)
|
||||
- [Integration](#integration)
|
||||
- [API Reference](#api-reference)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Testing](#testing)
|
||||
- [Contributing](#contributing)
|
||||
- [Future Enhancements](#future-enhancements)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This package provides a production-ready email composition and delivery system for Go applications. It emphasizes ease of use, flexibility, and robustness while supporting all standard email features including multi-part content, attachments, custom headers, and priority management.
|
||||
|
||||
### Design Philosophy
|
||||
|
||||
1. **Developer-Friendly**: Intuitive API with method chaining and clear semantics
|
||||
2. **Configuration-First**: JSON/YAML/TOML support for declarative email setup
|
||||
3. **Type-Safe**: Strong typing with validation at compile and runtime
|
||||
4. **SMTP Integration**: Seamless integration with the `mail/smtp` package
|
||||
5. **Standard Compliance**: RFC-compliant email generation (RFC 822, RFC 2045, RFC 2156)
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Multi-Part Content**: Plain text and HTML alternatives in single email
|
||||
- **File Attachments**: Regular attachments and inline embedded files (e.g., images in HTML)
|
||||
- **Flexible Addressing**: From, Sender, ReplyTo, ReturnPath with intelligent fallbacks
|
||||
- **Recipient Management**: To, CC, BCC with automatic deduplication
|
||||
- **Custom Headers**: Full control over email headers
|
||||
- **Transfer Encoding**: None, Binary, Base64, Quoted-Printable
|
||||
- **Priority Levels**: Normal, Low, High with multi-client compatibility
|
||||
- **Configuration Support**: JSON, YAML, TOML, Viper mapstructure tags
|
||||
- **Validation**: Comprehensive validation using `go-playground/validator`
|
||||
- **Error Handling**: Structured error codes with parent error wrapping
|
||||
- **Stream-Friendly**: io.Reader/io.Writer interfaces for efficient memory usage
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/nabbar/golib/mail/sender
|
||||
```
|
||||
|
||||
**Dependencies:**
|
||||
- `github.com/nabbar/golib/errors` - Structured error handling
|
||||
- `github.com/nabbar/golib/mail/smtp` - SMTP client (for sending)
|
||||
- `github.com/nabbar/golib/file/progress` - File operations
|
||||
- `github.com/go-playground/validator/v10` - Configuration validation
|
||||
- `github.com/wneessen/go-mail` - MIME message generation
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Package Structure
|
||||
|
||||
```
|
||||
mail/sender/
|
||||
├── interface.go # Mail and Email interfaces
|
||||
├── mail.go # Mail implementation
|
||||
├── email.go # Email address management
|
||||
├── sender.go # Sender interface and implementation
|
||||
├── config.go # Configuration structs and validation
|
||||
├── body.go # Email body parts
|
||||
├── file.go # File attachments
|
||||
├── encoding.go # Transfer encoding types
|
||||
├── priority.go # Priority management
|
||||
├── contentType.go # Content type definitions
|
||||
├── recipientType.go # Recipient categories
|
||||
└── error.go # Error codes and messages
|
||||
```
|
||||
|
||||
### Component Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Mail Interface │
|
||||
│ Compose, Configure, Manage Content │
|
||||
└──────────────┬────────────────┬─────────────────────┘
|
||||
│ │
|
||||
┌────────▼───────┐ ┌─────▼────────┐
|
||||
│ Email │ │ Sender │
|
||||
│ Addresses │ │ SMTP Delivery│
|
||||
│ To/CC/BCC │ │ Send/Close │
|
||||
└────────────────┘ └──────────────┘
|
||||
│ │
|
||||
┌────────▼────────────────▼─────────┐
|
||||
│ Config (Optional) │
|
||||
│ JSON/YAML/TOML Configuration │
|
||||
└───────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Component | Purpose | Memory | Thread-Safe |
|
||||
|-----------|---------|--------|-------------|
|
||||
| **`Mail`** | Email composition and configuration | O(1) per email | ⚠️ Clone for concurrency |
|
||||
| **`Email`** | Address management with fallbacks | O(n) recipients | ⚠️ Part of Mail |
|
||||
| **`Sender`** | SMTP transmission preparation | O(1) | ⚠️ One-time use |
|
||||
| **`Config`** | Declarative email setup | O(1) | ✅ Immutable |
|
||||
|
||||
### Email Composition Flow
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐
|
||||
│ Create │────▶│ Configure│────▶│ Validate │────▶│ Send │
|
||||
│ Mail │ │ Content │ │ Email │ │ SMTP │
|
||||
└──────────┘ └──────────┘ └──────────┘ └─────────┘
|
||||
│ │ │ │
|
||||
New() SetSubject() Sender() Send(ctx)
|
||||
SetBody() SendClose(ctx)
|
||||
AddAttachment()
|
||||
Email().SetFrom()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### Benchmark Results
|
||||
|
||||
Tests run on Go 1.21, AMD64 architecture:
|
||||
|
||||
| Operation | Throughput | Memory | Notes |
|
||||
|-----------|------------|--------|-------|
|
||||
| Mail Creation | ~200 µs | O(1) | Empty mail object |
|
||||
| Set Properties | <1 µs | O(1) | Subject, encoding, priority |
|
||||
| Add Recipients | <1 µs | O(n) | Per recipient |
|
||||
| Add Headers | <1 µs | O(1) | Per header |
|
||||
| Add Body | <1 µs | O(1) | Body content |
|
||||
| Add Attachment | 100-300 µs | O(1) | File opening |
|
||||
| Clone Mail | <1 µs | O(n) | Full deep copy |
|
||||
| Create Sender | 100-200 µs | O(1) | MIME message prep |
|
||||
| Config Validation | 100 µs | O(1) | Field validation |
|
||||
| Config NewMailer | <1 µs | O(1) | Mail from config |
|
||||
| Encoding Parse | <1 µs | O(1) | String to enum |
|
||||
| Priority Parse | <1 µs | O(1) | String to enum |
|
||||
|
||||
### Test Results
|
||||
|
||||
- **Total Tests**: 252 specs
|
||||
- **Test Duration**: ~0.8s (normal), ~1.5s (with race detector)
|
||||
- **Coverage**: 81.4% of statements
|
||||
- **Race Detection**: Zero data races detected
|
||||
|
||||
### Memory Characteristics
|
||||
|
||||
- **Constant Overhead**: ~2KB per Mail instance
|
||||
- **Streaming Body**: Body content not loaded into memory
|
||||
- **File Streaming**: Attachments streamed from io.Reader
|
||||
- **No Buffering**: Direct passthrough to SMTP client
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
This package is designed for applications requiring robust email functionality:
|
||||
|
||||
**Transactional Emails**
|
||||
- User registration confirmations
|
||||
- Password reset emails
|
||||
- Order confirmations and invoices
|
||||
- Account notifications
|
||||
|
||||
**Notification Systems**
|
||||
- Application alerts and monitoring
|
||||
- CI/CD pipeline notifications
|
||||
- System health reports
|
||||
- Error reporting
|
||||
|
||||
**Newsletter and Marketing**
|
||||
- HTML newsletters with inline images
|
||||
- Multi-recipient campaigns (using BCC for privacy)
|
||||
- Personalized email templates
|
||||
- Tracking pixels (inline attachments)
|
||||
|
||||
**Report Distribution**
|
||||
- Automated report generation
|
||||
- PDF invoice attachments
|
||||
- Data export emails
|
||||
- Log file delivery
|
||||
|
||||
**Support and Communication**
|
||||
- Support ticket responses
|
||||
- Customer service emails
|
||||
- Team collaboration notifications
|
||||
- Document sharing
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Simple Email
|
||||
|
||||
Send a basic plain-text email:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/nabbar/golib/mail/sender"
|
||||
"github.com/nabbar/golib/mail/smtp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create email
|
||||
mail := sender.New()
|
||||
mail.SetSubject("Hello World")
|
||||
mail.SetCharset("UTF-8")
|
||||
mail.SetEncoding(sender.EncodingBase64)
|
||||
|
||||
// Set sender and recipient
|
||||
mail.Email().SetFrom("noreply@example.com")
|
||||
mail.Email().AddRecipients(sender.RecipientTo, "user@example.com")
|
||||
|
||||
// Add body
|
||||
body := io.NopCloser(strings.NewReader("This is a test email."))
|
||||
mail.SetBody(sender.ContentPlainText, body)
|
||||
|
||||
// Create sender
|
||||
snd, _ := mail.Sender()
|
||||
defer snd.Close()
|
||||
|
||||
// Send via SMTP
|
||||
smtpClient, _ := smtp.New(smtpConfig, nil)
|
||||
err := snd.Send(context.Background(), smtpClient)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTML Email with Attachment
|
||||
|
||||
Send an HTML email with file attachment:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/nabbar/golib/mail/sender"
|
||||
"github.com/nabbar/golib/mail/smtp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mail := sender.New()
|
||||
mail.SetSubject("Monthly Report")
|
||||
mail.SetCharset("UTF-8")
|
||||
mail.SetEncoding(sender.EncodingBase64)
|
||||
mail.SetPriority(sender.PriorityHigh)
|
||||
|
||||
// Addresses
|
||||
mail.Email().SetFrom("reports@company.com")
|
||||
mail.Email().AddRecipients(sender.RecipientTo, "manager@company.com")
|
||||
mail.Email().AddRecipients(sender.RecipientCC, "team@company.com")
|
||||
|
||||
// Multi-part body
|
||||
plainBody := io.NopCloser(strings.NewReader("Please see the attached report."))
|
||||
htmlBody := io.NopCloser(strings.NewReader("<p>Please see the <b>attached report</b>.</p>"))
|
||||
|
||||
mail.SetBody(sender.ContentPlainText, plainBody)
|
||||
mail.AddBody(sender.ContentHTML, htmlBody)
|
||||
|
||||
// Add PDF attachment
|
||||
file, _ := os.Open("/path/to/report.pdf")
|
||||
mail.AddAttachment("Monthly_Report.pdf", "application/pdf", file, false)
|
||||
|
||||
// Send
|
||||
snd, _ := mail.Sender()
|
||||
defer snd.Close()
|
||||
|
||||
smtpClient, _ := smtp.New(smtpConfig, nil)
|
||||
_ = snd.SendClose(context.Background(), smtpClient)
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration-Based Email
|
||||
|
||||
Create email from configuration file:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/nabbar/golib/mail/sender"
|
||||
"github.com/nabbar/golib/mail/smtp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load configuration from JSON
|
||||
var config sender.Config
|
||||
data, _ := os.ReadFile("email-config.json")
|
||||
json.Unmarshal(data, &config)
|
||||
|
||||
// Validate configuration
|
||||
if err := config.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create mail from config
|
||||
mail, err := config.NewMailer()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Add body (not in config)
|
||||
body := io.NopCloser(strings.NewReader("Email body content"))
|
||||
mail.SetBody(sender.ContentPlainText, body)
|
||||
|
||||
// Send
|
||||
snd, _ := mail.Sender()
|
||||
defer snd.Close()
|
||||
|
||||
smtpClient, _ := smtp.New(smtpConfig, nil)
|
||||
_ = snd.SendClose(context.Background(), smtpClient)
|
||||
}
|
||||
```
|
||||
|
||||
**email-config.json:**
|
||||
```json
|
||||
{
|
||||
"charset": "UTF-8",
|
||||
"subject": "Welcome to Our Service",
|
||||
"encoding": "Base 64",
|
||||
"priority": "Normal",
|
||||
"from": "welcome@service.com",
|
||||
"to": ["newuser@example.com"],
|
||||
"attach": [
|
||||
{
|
||||
"name": "Welcome_Guide.pdf",
|
||||
"mime": "application/pdf",
|
||||
"path": "/assets/welcome-guide.pdf"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Inline Images in HTML
|
||||
|
||||
Embed images in HTML emails:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/nabbar/golib/mail/sender"
|
||||
"github.com/nabbar/golib/mail/smtp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mail := sender.New()
|
||||
mail.SetSubject("Newsletter")
|
||||
|
||||
// HTML body with inline image reference
|
||||
html := `
|
||||
<html>
|
||||
<body>
|
||||
<h1>Company Newsletter</h1>
|
||||
<img src="cid:logo.png" alt="Logo"/>
|
||||
<p>Welcome to our monthly newsletter!</p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
htmlBody := io.NopCloser(strings.NewReader(html))
|
||||
mail.SetBody(sender.ContentHTML, htmlBody)
|
||||
|
||||
// Add inline image
|
||||
logo, _ := os.Open("/assets/logo.png")
|
||||
mail.AddAttachment("logo.png", "image/png", logo, true) // inline=true
|
||||
|
||||
// Send
|
||||
mail.Email().SetFrom("newsletter@company.com")
|
||||
mail.Email().AddRecipients(sender.RecipientTo, "subscriber@example.com")
|
||||
|
||||
snd, _ := mail.Sender()
|
||||
defer snd.Close()
|
||||
|
||||
smtpClient, _ := smtp.New(smtpConfig, nil)
|
||||
_ = snd.SendClose(context.Background(), smtpClient)
|
||||
}
|
||||
```
|
||||
|
||||
### Template-Based Emails
|
||||
|
||||
Use mail cloning for template-based emails:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/nabbar/golib/mail/sender"
|
||||
"github.com/nabbar/golib/mail/smtp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create template
|
||||
template := sender.New()
|
||||
template.SetSubject("Account Notification")
|
||||
template.SetCharset("UTF-8")
|
||||
template.SetEncoding(sender.EncodingBase64)
|
||||
template.Email().SetFrom("notifications@service.com")
|
||||
|
||||
// Send to multiple users
|
||||
users := []string{"user1@example.com", "user2@example.com", "user3@example.com"}
|
||||
|
||||
for _, user := range users {
|
||||
// Clone template for each user
|
||||
mail := template.Clone()
|
||||
|
||||
// Personalize
|
||||
mail.Email().AddRecipients(sender.RecipientTo, user)
|
||||
body := io.NopCloser(strings.NewReader(fmt.Sprintf("Hello %s!", user)))
|
||||
mail.SetBody(sender.ContentPlainText, body)
|
||||
|
||||
// Send
|
||||
snd, _ := mail.Sender()
|
||||
smtpClient, _ := smtp.New(smtpConfig, nil)
|
||||
_ = snd.SendClose(context.Background(), smtpClient)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration
|
||||
|
||||
### SMTP Client Integration
|
||||
|
||||
This package integrates with the `mail/smtp` package for email delivery:
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/nabbar/golib/mail/sender"
|
||||
"github.com/nabbar/golib/mail/smtp"
|
||||
"github.com/nabbar/golib/mail/smtp/config"
|
||||
"github.com/nabbar/golib/mail/smtp/tlsmode"
|
||||
)
|
||||
|
||||
// Configure SMTP client
|
||||
smtpConfig := config.ConfigModel{
|
||||
DSN: "tcp(smtp.gmail.com:587)/starttls",
|
||||
}
|
||||
cfg, _ := smtpConfig.Config()
|
||||
|
||||
// Create SMTP client
|
||||
smtpClient, _ := smtp.New(cfg, nil)
|
||||
defer smtpClient.Close()
|
||||
|
||||
// Create and send email
|
||||
mail := sender.New()
|
||||
// ... configure mail ...
|
||||
|
||||
snd, _ := mail.Sender()
|
||||
defer snd.Close()
|
||||
|
||||
err := snd.Send(context.Background(), smtpClient)
|
||||
```
|
||||
|
||||
See [github.com/nabbar/golib/mail/smtp](../smtp/README.md) for SMTP client documentation.
|
||||
|
||||
### Configuration File Integration
|
||||
|
||||
Supports multiple configuration formats:
|
||||
|
||||
**JSON:**
|
||||
```json
|
||||
{
|
||||
"charset": "UTF-8",
|
||||
"subject": "Test Email",
|
||||
"encoding": "Base 64",
|
||||
"priority": "Normal",
|
||||
"from": "sender@example.com",
|
||||
"to": ["recipient@example.com"],
|
||||
"cc": ["manager@example.com"],
|
||||
"headers": {
|
||||
"X-Campaign-ID": "2024-Q1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**YAML:**
|
||||
```yaml
|
||||
charset: UTF-8
|
||||
subject: Test Email
|
||||
encoding: Base 64
|
||||
priority: Normal
|
||||
from: sender@example.com
|
||||
to:
|
||||
- recipient@example.com
|
||||
cc:
|
||||
- manager@example.com
|
||||
headers:
|
||||
X-Campaign-ID: 2024-Q1
|
||||
```
|
||||
|
||||
**TOML:**
|
||||
```toml
|
||||
charset = "UTF-8"
|
||||
subject = "Test Email"
|
||||
encoding = "Base 64"
|
||||
priority = "Normal"
|
||||
from = "sender@example.com"
|
||||
to = ["recipient@example.com"]
|
||||
cc = ["manager@example.com"]
|
||||
|
||||
[headers]
|
||||
X-Campaign-ID = "2024-Q1"
|
||||
```
|
||||
|
||||
### Viper Integration
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/spf13/viper"
|
||||
"github.com/nabbar/golib/mail/sender"
|
||||
)
|
||||
|
||||
// Load config with Viper
|
||||
viper.SetConfigFile("email.yaml")
|
||||
viper.ReadInConfig()
|
||||
|
||||
var config sender.Config
|
||||
viper.Unmarshal(&config)
|
||||
|
||||
if err := config.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
mail, _ := config.NewMailer()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### Core Interfaces
|
||||
|
||||
**Mail Interface**
|
||||
- `New() Mail` - Create new email
|
||||
- `Clone() Mail` - Deep copy
|
||||
- `SetSubject(string)` - Set subject
|
||||
- `SetCharset(string)` - Set character encoding
|
||||
- `SetEncoding(Encoding)` - Set transfer encoding
|
||||
- `SetPriority(Priority)` - Set priority
|
||||
- `SetBody(ContentType, io.ReadCloser)` - Set body
|
||||
- `AddBody(ContentType, io.ReadCloser)` - Add body part
|
||||
- `AddAttachment(name, mime string, data io.ReadCloser, inline bool)` - Add file
|
||||
- `Email() Email` - Access address management
|
||||
- `Sender() (Sender, error)` - Create sender
|
||||
|
||||
**Email Interface**
|
||||
- `SetFrom(string)` - Set From address
|
||||
- `SetSender(string)` - Set Sender address
|
||||
- `SetReplyTo(string)` - Set Reply-To address
|
||||
- `SetReturnPath(string)` - Set Return-Path
|
||||
- `AddRecipients(recipientType, ...string)` - Add recipients
|
||||
- `SetRecipients(recipientType, ...string)` - Replace recipients
|
||||
- `GetRecipients(recipientType) []string` - Get recipients
|
||||
|
||||
**Sender Interface**
|
||||
- `Send(context.Context, smtp.SMTP) error` - Send email
|
||||
- `SendClose(context.Context, smtp.SMTP) error` - Send and close
|
||||
- `Close() error` - Cleanup resources
|
||||
|
||||
### Configuration
|
||||
|
||||
**Config Struct**
|
||||
- `Validate() error` - Validate configuration
|
||||
- `NewMailer() (Mail, error)` - Create mail from config
|
||||
|
||||
See [GoDoc](https://pkg.go.dev/github.com/nabbar/golib/mail/sender) for complete API documentation.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
**Test Suite**: 252 specs using Ginkgo v2 and Gomega
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
go test ./...
|
||||
|
||||
# With coverage
|
||||
go test -cover ./...
|
||||
|
||||
# With race detection (recommended)
|
||||
CGO_ENABLED=1 go test -race ./...
|
||||
```
|
||||
|
||||
**Test Results:**
|
||||
- ✅ 252/252 tests passing
|
||||
- ✅ 81.4% code coverage
|
||||
- ✅ Zero data races detected
|
||||
- ✅ All benchmarks passing
|
||||
|
||||
**Coverage Areas:**
|
||||
- Mail interface operations
|
||||
- Email address management
|
||||
- Configuration validation
|
||||
- Body and attachment handling
|
||||
- Sender creation and lifecycle
|
||||
- Error handling and edge cases
|
||||
- Type parsing (encoding, priority)
|
||||
- Concurrent operations
|
||||
|
||||
See [TESTING.md](TESTING.md) for detailed testing documentation.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
**Always Close Resources**
|
||||
```go
|
||||
// ✅ Good: Proper cleanup
|
||||
func sendEmail(mail sender.Mail) error {
|
||||
snd, err := mail.Sender()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer snd.Close() // Cleanup resources
|
||||
|
||||
return snd.SendClose(ctx, smtpClient)
|
||||
}
|
||||
```
|
||||
|
||||
**Validate Configuration**
|
||||
```go
|
||||
// ✅ Good: Validate before use
|
||||
func loadConfig(path string) (sender.Mail, error) {
|
||||
var cfg sender.Config
|
||||
// load config...
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
return cfg.NewMailer()
|
||||
}
|
||||
|
||||
// ❌ Bad: No validation
|
||||
func loadConfigBad(path string) sender.Mail {
|
||||
var cfg sender.Config
|
||||
// load config...
|
||||
mail, _ := cfg.NewMailer() // May fail silently
|
||||
return mail
|
||||
}
|
||||
```
|
||||
|
||||
**Use Clone for Templates**
|
||||
```go
|
||||
// ✅ Good: Clone template for each recipient
|
||||
func sendBulk(template sender.Mail, recipients []string) error {
|
||||
for _, rcpt := range recipients {
|
||||
mail := template.Clone() // Independent copy
|
||||
mail.Email().AddRecipients(sender.RecipientTo, rcpt)
|
||||
// send mail...
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ❌ Bad: Reuse same mail object
|
||||
func sendBulkBad(mail sender.Mail, recipients []string) error {
|
||||
for _, rcpt := range recipients {
|
||||
mail.Email().AddRecipients(sender.RecipientTo, rcpt)
|
||||
// send mail... (recipients accumulate!)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Handle Errors Properly**
|
||||
```go
|
||||
// ✅ Good: Check all errors
|
||||
func sendWithAttachment(mail sender.Mail, path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
mail.AddAttachment(filepath.Base(path), "application/pdf", file, false)
|
||||
|
||||
snd, err := mail.Sender()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create sender: %w", err)
|
||||
}
|
||||
defer snd.Close()
|
||||
|
||||
if err := snd.Send(ctx, smtpClient); err != nil {
|
||||
return fmt.Errorf("send email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Stream Large Files**
|
||||
```go
|
||||
// ✅ Good: Stream from file
|
||||
func addLargeAttachment(mail sender.Mail, path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Don't close here - Sender will close it
|
||||
|
||||
mail.AddAttachment(filepath.Base(path), "application/zip", file, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ❌ Bad: Load entire file into memory
|
||||
func addLargeAttachmentBad(mail sender.Mail, path string) error {
|
||||
data, _ := os.ReadFile(path) // Full file in RAM!
|
||||
reader := io.NopCloser(bytes.NewReader(data))
|
||||
mail.AddAttachment(filepath.Base(path), "application/zip", reader, false)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please follow these guidelines:
|
||||
|
||||
**Code Contributions**
|
||||
- Do not use AI to generate package implementation code
|
||||
- AI may assist with tests, documentation, and bug fixing
|
||||
- All contributions must pass `go test -race`
|
||||
- Maintain or improve test coverage (≥80%)
|
||||
- Follow existing code style and patterns
|
||||
|
||||
**Documentation**
|
||||
- Update README.md for new features
|
||||
- Add examples for common use cases
|
||||
- Keep TESTING.md synchronized with test changes
|
||||
- Document all public APIs with GoDoc comments
|
||||
|
||||
**Testing**
|
||||
- Write tests for all new features
|
||||
- Test edge cases and error conditions
|
||||
- Verify thread safety concerns
|
||||
- Add comments explaining complex scenarios
|
||||
|
||||
**Pull Requests**
|
||||
- Provide clear description of changes
|
||||
- Reference related issues
|
||||
- Include test results and coverage
|
||||
- Update documentation
|
||||
|
||||
See [CONTRIBUTING.md](../../CONTRIBUTING.md) for detailed guidelines.
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for future versions:
|
||||
|
||||
**Email Features**
|
||||
- Email templates with variable substitution
|
||||
- Batch sending with rate limiting
|
||||
- Email scheduling and queuing
|
||||
- Read receipts and delivery status notifications
|
||||
- S/MIME encryption and signing
|
||||
- DKIM signing support
|
||||
|
||||
**Performance**
|
||||
- Connection pooling for SMTP
|
||||
- Concurrent email sending
|
||||
- Attachment streaming optimization
|
||||
- Message caching for templates
|
||||
|
||||
**Integration**
|
||||
- Direct integration with popular SMTP services (SendGrid, Mailgun, AWS SES)
|
||||
- Webhook support for delivery tracking
|
||||
- Email analytics and metrics
|
||||
- Template management system
|
||||
|
||||
Suggestions and contributions are welcome via GitHub issues.
|
||||
|
||||
---
|
||||
|
||||
## AI Transparency Notice
|
||||
|
||||
In accordance with Article 50.4 of the EU AI Act, AI assistance has been used for testing, documentation, and bug fixing under human supervision.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See [LICENSE](../../LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/nabbar/golib/issues)
|
||||
- **Documentation**: [GoDoc](https://pkg.go.dev/github.com/nabbar/golib/mail/sender)
|
||||
- **SMTP Client**: [mail/smtp Package](../smtp/README.md)
|
||||
- **Testing Guide**: [TESTING.md](TESTING.md)
|
||||
- **Contributing**: [CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
870
mail/sender/TESTING.md
Normal file
870
mail/sender/TESTING.md
Normal file
@@ -0,0 +1,870 @@
|
||||
# Testing Guide
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://golang.org/)
|
||||
[]()
|
||||
[]()
|
||||
|
||||
Comprehensive testing documentation for the mail/sender package, covering test execution, race detection, and quality assurance.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Test Framework](#test-framework)
|
||||
- [Running Tests](#running-tests)
|
||||
- [Test Coverage](#test-coverage)
|
||||
- [Thread Safety](#thread-safety)
|
||||
- [Test File Organization](#test-file-organization)
|
||||
- [Writing Tests](#writing-tests)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [CI Integration](#ci-integration)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The mail/sender package uses **Ginkgo v2** (BDD testing framework) and **Gomega** (matcher library) for comprehensive testing with expressive assertions and real SMTP server integration.
|
||||
|
||||
**Test Suite Statistics**
|
||||
- Total Specs: 252
|
||||
- Coverage: 81.4%
|
||||
- Race Detection: ✅ Zero data races
|
||||
- Execution Time: ~0.8s (without race), ~1.5s (with race)
|
||||
|
||||
**Coverage Areas**
|
||||
- Mail interface operations (creation, properties, headers)
|
||||
- Email address management (From, To, CC, BCC)
|
||||
- Body and attachment handling
|
||||
- Configuration validation and creation
|
||||
- Sender creation and lifecycle
|
||||
- Error handling and edge cases
|
||||
- Type parsing (encoding, priority, content type)
|
||||
- Real SMTP server integration for send operations
|
||||
- Performance benchmarks with gmeasure
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install Ginkgo CLI (optional)
|
||||
go install github.com/onsi/ginkgo/v2/ginkgo@latest
|
||||
|
||||
# Run all tests
|
||||
go test ./...
|
||||
|
||||
# Run with coverage
|
||||
go test -cover ./...
|
||||
|
||||
# Run with race detection (recommended)
|
||||
CGO_ENABLED=1 go test -race ./...
|
||||
|
||||
# Using Ginkgo CLI
|
||||
ginkgo -cover -race
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
Ran 252 of 252 Specs in 0.808 seconds
|
||||
SUCCESS! -- 252 Passed | 0 Failed | 0 Pending | 0 Skipped
|
||||
PASS
|
||||
coverage: 81.4% of statements
|
||||
ok github.com/nabbar/golib/mail/sender 0.839s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Ginkgo v2** - BDD testing framework ([docs](https://onsi.github.io/ginkgo/))
|
||||
- Hierarchical test organization (`Describe`, `Context`, `It`)
|
||||
- Setup/teardown hooks (`BeforeEach`, `AfterEach`, `BeforeSuite`, `AfterSuite`)
|
||||
- Rich CLI with filtering and focus
|
||||
- Detailed failure reporting
|
||||
|
||||
**Gomega** - Matcher library ([docs](https://onsi.github.io/gomega/))
|
||||
- Readable assertion syntax
|
||||
- Extensive built-in matchers
|
||||
- Detailed failure messages
|
||||
- Custom matchers support
|
||||
|
||||
**gmeasure** - Performance benchmarking ([docs](https://onsi.github.io/gomega/#gmeasure-benchmarking-code))
|
||||
- Built on top of Gomega
|
||||
- Statistical analysis (mean, median, stddev)
|
||||
- Performance regression detection
|
||||
- Experiment-based benchmarking
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Standard test run
|
||||
go test ./...
|
||||
|
||||
# Verbose output
|
||||
go test -v ./...
|
||||
|
||||
# With coverage
|
||||
go test -cover ./...
|
||||
|
||||
# Specific timeout
|
||||
go test -timeout=10m ./...
|
||||
|
||||
# Generate HTML coverage report
|
||||
go test -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
```
|
||||
|
||||
### Ginkgo CLI Options
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
ginkgo
|
||||
|
||||
# Specific test file
|
||||
ginkgo --focus-file=mail_test.go
|
||||
|
||||
# Pattern matching
|
||||
ginkgo --focus="Email address"
|
||||
|
||||
# Skip specific tests
|
||||
ginkgo --skip="Performance"
|
||||
|
||||
# Parallel execution (use with caution - SMTP server conflicts)
|
||||
ginkgo -p --procs=4
|
||||
|
||||
# JUnit report
|
||||
ginkgo --junit-report=results.xml
|
||||
|
||||
# JSON report
|
||||
ginkgo --json-report=results.json
|
||||
```
|
||||
|
||||
### Race Detection
|
||||
|
||||
**Critical for validating thread-safety assumptions**
|
||||
|
||||
```bash
|
||||
# Enable race detector (requires CGO)
|
||||
CGO_ENABLED=1 go test -race ./...
|
||||
|
||||
# With Ginkgo
|
||||
CGO_ENABLED=1 ginkgo -race
|
||||
|
||||
# With coverage and race detection
|
||||
CGO_ENABLED=1 go test -race -cover -covermode=atomic ./...
|
||||
```
|
||||
|
||||
**What it validates:**
|
||||
- Mail object cloning safety
|
||||
- Concurrent sender creation
|
||||
- SMTP server state management
|
||||
- No data races in test helpers
|
||||
|
||||
**Expected Output:**
|
||||
```bash
|
||||
# ✅ Success
|
||||
Ran 252 of 252 Specs in 1.553 seconds
|
||||
SUCCESS! -- 252 Passed | 0 Failed | 0 Pending | 0 Skipped
|
||||
PASS
|
||||
ok github.com/nabbar/golib/mail/sender 2.738s
|
||||
|
||||
# ❌ Race detected (example - should never occur)
|
||||
WARNING: DATA RACE
|
||||
Read at 0x... by goroutine ...
|
||||
```
|
||||
|
||||
**Status**: ✅ Zero data races detected
|
||||
|
||||
### Performance & Profiling
|
||||
|
||||
```bash
|
||||
# View benchmark results during tests
|
||||
go test -v ./...
|
||||
|
||||
# Memory profiling
|
||||
go test -memprofile=mem.out ./...
|
||||
go tool pprof mem.out
|
||||
|
||||
# CPU profiling
|
||||
go test -cpuprofile=cpu.out ./...
|
||||
go tool pprof cpu.out
|
||||
|
||||
# Block profiling (goroutine blocking)
|
||||
go test -blockprofile=block.out ./...
|
||||
go tool pprof block.out
|
||||
```
|
||||
|
||||
**Performance Expectations**
|
||||
|
||||
| Test Type | Duration | Notes |
|
||||
|-----------|----------|-------|
|
||||
| Full Suite | ~0.8s | Without race |
|
||||
| With `-race` | ~1.5s | 2x slower (expected) |
|
||||
| Individual Spec | <50ms | Most tests |
|
||||
| SMTP Tests | 50-200ms | Server startup overhead |
|
||||
| Benchmarks | Included | gmeasure reports |
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage
|
||||
|
||||
**Target**: ≥80% statement coverage
|
||||
**Current**: 81.4%
|
||||
|
||||
### Coverage By Category
|
||||
|
||||
| Category | Files | Specs | Description |
|
||||
|----------|-------|-------|-------------|
|
||||
| **Mail Operations** | `mail_test.go` | 54 | Create, clone, properties, headers, body, attachments |
|
||||
| **Email Addresses** | `email_test.go` | 37 | From, sender, replyTo, recipients, fallback behavior |
|
||||
| **Sending** | `send_test.go` | 25 | Sender creation, SMTP integration, context handling |
|
||||
| **Configuration** | `config_test.go` | 43 | Validation, mailer creation, file attachments |
|
||||
| **Types** | `types_test.go` | 29 | Encoding, priority, content type, recipient type |
|
||||
| **Errors** | `errors_test.go` | 32 | Error codes, wrapping, validation, edge cases |
|
||||
| **Edge Cases** | `edge_cases_test.go` | 20 | Empty values, large data, special chars, limits |
|
||||
| **Benchmarks** | `benchmark_test.go` | 12 | Performance measurements with gmeasure |
|
||||
|
||||
### View Coverage
|
||||
|
||||
```bash
|
||||
# Generate coverage report
|
||||
go test -coverprofile=coverage.out -covermode=atomic ./...
|
||||
|
||||
# View in terminal
|
||||
go tool cover -func=coverage.out
|
||||
|
||||
# Generate HTML report
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
open coverage.html # macOS
|
||||
xdg-open coverage.html # Linux
|
||||
start coverage.html # Windows
|
||||
```
|
||||
|
||||
### Coverage Analysis
|
||||
|
||||
**Well-Covered Areas (>85%)**
|
||||
- Mail interface implementation
|
||||
- Email address management
|
||||
- Configuration validation
|
||||
- Type parsing and conversion
|
||||
- Error handling
|
||||
|
||||
**Areas for Improvement (<75%)**
|
||||
- Some edge cases in sender.go
|
||||
- Complex error scenarios
|
||||
- Date/time parsing edge cases
|
||||
|
||||
### Test Structure
|
||||
|
||||
Tests follow Ginkgo's hierarchical BDD structure:
|
||||
|
||||
```go
|
||||
var _ = Describe("mail/sender Component", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
cnl context.CancelFunc
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx, cnl = context.WithTimeout(context.Background(), 30*time.Second)
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if cnl != nil {
|
||||
cnl()
|
||||
}
|
||||
})
|
||||
|
||||
Context("Feature or scenario", func() {
|
||||
It("should do something specific", func() {
|
||||
mail := sender.New()
|
||||
mail.SetSubject("Test")
|
||||
|
||||
Expect(mail.GetSubject()).To(Equal("Test"))
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Thread Safety
|
||||
|
||||
The mail/sender package has specific thread-safety characteristics that are validated through testing.
|
||||
|
||||
### Thread-Safety Model
|
||||
|
||||
**NOT Thread-Safe (by design):**
|
||||
- `Mail` objects - Clone for concurrent use
|
||||
- `Email` interface - Part of Mail
|
||||
- `Sender` objects - One-time use
|
||||
|
||||
**Thread-Safe:**
|
||||
- `Config` objects - Immutable after creation
|
||||
- Type parsing functions - Pure functions
|
||||
- Error codes - Constants
|
||||
|
||||
### Concurrent Usage Pattern
|
||||
|
||||
```go
|
||||
// ✅ Correct: Clone for concurrency
|
||||
template := sender.New()
|
||||
template.SetSubject("Notification")
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(index int) {
|
||||
mail := template.Clone() // Independent copy
|
||||
mail.Email().AddRecipients(sender.RecipientTo, fmt.Sprintf("user%d@example.com", index))
|
||||
// Use mail...
|
||||
}(i)
|
||||
}
|
||||
|
||||
// ❌ Incorrect: Shared Mail object
|
||||
mail := sender.New()
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
mail.SetSubject("Test") // DATA RACE!
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Commands
|
||||
|
||||
```bash
|
||||
# Full suite with race detection
|
||||
CGO_ENABLED=1 go test -race -v ./...
|
||||
|
||||
# Focus on concurrent scenarios
|
||||
CGO_ENABLED=1 go test -race -v -run "Clone\|Concurrent" ./...
|
||||
|
||||
# Stress test (10 iterations)
|
||||
for i in {1..10}; do CGO_ENABLED=1 go test -race ./... || break; done
|
||||
```
|
||||
|
||||
**Result**: ✅ Zero data races across all test runs
|
||||
|
||||
---
|
||||
|
||||
## Test File Organization
|
||||
|
||||
| File | Purpose | Specs | Notes |
|
||||
|------|---------|-------|-------|
|
||||
| `sender_suite_test.go` | Suite initialization, test helpers | 1 | SMTP server setup |
|
||||
| `mail_test.go` | Mail interface operations | 54 | Core functionality |
|
||||
| `email_test.go` | Email address management | 37 | Recipients, addresses |
|
||||
| `send_test.go` | Sending operations | 25 | Real SMTP integration |
|
||||
| `config_test.go` | Configuration operations | 43 | Validation, creation |
|
||||
| `types_test.go` | Type definitions | 29 | Enums, parsing |
|
||||
| `errors_test.go` | Error handling | 32 | Error codes, wrapping |
|
||||
| `edge_cases_test.go` | Edge cases and limits | 20 | Boundary conditions |
|
||||
| `benchmark_test.go` | Performance benchmarks | 12 | gmeasure stats |
|
||||
|
||||
### Test Helper Infrastructure
|
||||
|
||||
**`sender_suite_test.go` provides:**
|
||||
|
||||
```go
|
||||
// Context management
|
||||
var testCtx context.Context
|
||||
var testCtxCancel context.CancelFunc
|
||||
|
||||
// SMTP test server backend
|
||||
type testBackend struct {
|
||||
requireAuth bool
|
||||
messages []testMessage
|
||||
}
|
||||
|
||||
// SMTP session for testing
|
||||
type testSession struct {
|
||||
backend *testBackend
|
||||
from string
|
||||
to []string
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func startTestSMTPServer(backend *testBackend, useTLS bool) (*smtpsv.Server, string, int, error)
|
||||
func newTestConfig(host string, port int, tlsMode smtptp.TLSMode) *smtpcfg.Config
|
||||
func newTestSMTPClient(host string, port int) libsmtp.SMTP
|
||||
func newReadCloser(s string) io.ReadCloser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Test File Template
|
||||
|
||||
```go
|
||||
package sender_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
libsnd "github.com/nabbar/golib/mail/sender"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Component Name", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
cnl context.CancelFunc
|
||||
mail libsnd.Mail
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx, cnl = context.WithTimeout(testCtx, 10*time.Second)
|
||||
mail = libsnd.New()
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if cnl != nil {
|
||||
cnl()
|
||||
}
|
||||
})
|
||||
|
||||
Context("Scenario description", func() {
|
||||
It("should have expected behavior", func() {
|
||||
// Arrange
|
||||
mail.SetSubject("Test")
|
||||
|
||||
// Act
|
||||
result := mail.GetSubject()
|
||||
|
||||
// Assert
|
||||
Expect(result).To(Equal("Test"))
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Common Gomega Matchers
|
||||
|
||||
```go
|
||||
// Equality
|
||||
Expect(value).To(Equal(expected))
|
||||
Expect(value).ToNot(Equal(unexpected))
|
||||
|
||||
// Nil checks
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ptr).To(BeNil())
|
||||
|
||||
// Strings
|
||||
Expect(str).To(ContainSubstring("substring"))
|
||||
Expect(str).To(HavePrefix("prefix"))
|
||||
Expect(str).To(MatchRegexp("pattern"))
|
||||
|
||||
// Collections
|
||||
Expect(slice).To(HaveLen(5))
|
||||
Expect(slice).To(ContainElement("item"))
|
||||
Expect(slice).To(BeEmpty())
|
||||
|
||||
// Numeric
|
||||
Expect(num).To(BeNumerically(">", 0))
|
||||
Expect(num).To(BeNumerically("~", 1.5, 0.1))
|
||||
|
||||
// Boolean
|
||||
Expect(condition).To(BeTrue())
|
||||
Expect(condition).To(BeFalse())
|
||||
|
||||
// Types
|
||||
Expect(obj).To(BeAssignableToTypeOf(&Type{}))
|
||||
```
|
||||
|
||||
### Benchmark Tests with gmeasure
|
||||
|
||||
```go
|
||||
It("should measure performance", func() {
|
||||
experiment := gmeasure.NewExperiment("Operation Name")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("operation", func() {
|
||||
// Code to benchmark
|
||||
mail := sender.New()
|
||||
mail.SetSubject("Test")
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 1000})
|
||||
|
||||
stats := experiment.GetStats("operation")
|
||||
AddReportEntry(experiment.Name+" Stats",
|
||||
fmt.Sprintf("Mean: %v, StdDev: %v", stats.DurationFor(gmeasure.StatMean),
|
||||
stats.DurationFor(gmeasure.StatStdDev)))
|
||||
})
|
||||
```
|
||||
|
||||
### SMTP Integration Tests
|
||||
|
||||
```go
|
||||
It("should send email via SMTP", func() {
|
||||
// Setup SMTP test server
|
||||
backend := &testBackend{requireAuth: false, messages: make([]testMessage, 0)}
|
||||
smtpServer, host, port, err := startTestSMTPServer(backend, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer smtpServer.Close()
|
||||
|
||||
// Create SMTP client
|
||||
smtpClient := newTestSMTPClient(host, port)
|
||||
defer smtpClient.Close()
|
||||
|
||||
// Create and configure email
|
||||
mail := sender.New()
|
||||
mail.SetSubject("Test")
|
||||
mail.Email().SetFrom("sender@test.com")
|
||||
mail.Email().AddRecipients(sender.RecipientTo, "recipient@test.com")
|
||||
body := newReadCloser("Test body")
|
||||
mail.SetBody(sender.ContentPlainText, body)
|
||||
|
||||
// Send
|
||||
snd, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer snd.Close()
|
||||
|
||||
err = snd.Send(ctx, smtpClient)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Verify
|
||||
Expect(backend.messages).To(HaveLen(1))
|
||||
Expect(backend.messages[0].from).To(Equal("sender@test.com"))
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
**Organize Tests Logically**
|
||||
```go
|
||||
// ✅ Good: Clear hierarchy
|
||||
Describe("Mail Interface", func() {
|
||||
Context("Subject Operations", func() {
|
||||
It("should set subject", func() { /* ... */ })
|
||||
It("should get subject", func() { /* ... */ })
|
||||
})
|
||||
|
||||
Context("Body Operations", func() {
|
||||
It("should set plain text body", func() { /* ... */ })
|
||||
It("should add HTML body", func() { /* ... */ })
|
||||
})
|
||||
})
|
||||
|
||||
// ❌ Bad: Flat structure
|
||||
Describe("Tests", func() {
|
||||
It("test 1", func() { /* ... */ })
|
||||
It("test 2", func() { /* ... */ })
|
||||
It("test 3", func() { /* ... */ })
|
||||
})
|
||||
```
|
||||
|
||||
**Use Descriptive Names**
|
||||
```go
|
||||
// ✅ Good: Clear intent
|
||||
It("should return error when from address is invalid", func() { /* ... */ })
|
||||
|
||||
// ❌ Bad: Vague
|
||||
It("should fail", func() { /* ... */ })
|
||||
```
|
||||
|
||||
**Clean Up Resources**
|
||||
```go
|
||||
// ✅ Good: Proper cleanup
|
||||
It("should send with attachment", func() {
|
||||
file, _ := os.Open("test.pdf")
|
||||
// Don't defer here - let sender close it
|
||||
mail.AddAttachment("test.pdf", "application/pdf", file, false)
|
||||
|
||||
snd, _ := mail.Sender()
|
||||
defer snd.Close() // Closes file too
|
||||
})
|
||||
|
||||
// ❌ Bad: Resource leak
|
||||
It("should send", func() {
|
||||
file, _ := os.Open("test.pdf")
|
||||
mail.AddAttachment("test.pdf", "application/pdf", file, false)
|
||||
// File never closed if test fails
|
||||
})
|
||||
```
|
||||
|
||||
**Test Edge Cases**
|
||||
```go
|
||||
Context("Edge Cases", func() {
|
||||
It("should handle empty subject", func() {
|
||||
mail.SetSubject("")
|
||||
Expect(mail.GetSubject()).To(Equal(""))
|
||||
})
|
||||
|
||||
It("should handle very long subject", func() {
|
||||
longSubject := strings.Repeat("A", 10000)
|
||||
mail.SetSubject(longSubject)
|
||||
Expect(mail.GetSubject()).To(Equal(longSubject))
|
||||
})
|
||||
|
||||
It("should handle special characters", func() {
|
||||
mail.SetSubject("Test 日本語 émojis 🎉")
|
||||
Expect(mail.GetSubject()).To(ContainSubstring("🎉"))
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Use Table-Driven Tests for Patterns**
|
||||
```go
|
||||
DescribeTable("Encoding parsing",
|
||||
func(input string, expected sender.Encoding) {
|
||||
result := sender.ParseEncoding(input)
|
||||
Expect(result).To(Equal(expected))
|
||||
},
|
||||
Entry("Base64", "Base 64", sender.EncodingBase64),
|
||||
Entry("Quoted-Printable", "Quoted Printable", sender.EncodingQuotedPrintable),
|
||||
Entry("Case insensitive", "base 64", sender.EncodingBase64),
|
||||
Entry("Invalid defaults to None", "invalid", sender.EncodingNone),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**1. Race Detector Not Working**
|
||||
|
||||
```bash
|
||||
# Problem: Race detector requires CGO
|
||||
go test -race ./...
|
||||
# Error: -race requires cgo
|
||||
|
||||
# Solution: Enable CGO
|
||||
CGO_ENABLED=1 go test -race ./...
|
||||
```
|
||||
|
||||
**2. Tests Timeout**
|
||||
|
||||
```bash
|
||||
# Problem: Default 10m timeout exceeded
|
||||
go test ./...
|
||||
# FAIL: timeout after 10m0s
|
||||
|
||||
# Solution: Increase timeout
|
||||
go test -timeout=30m ./...
|
||||
```
|
||||
|
||||
**3. SMTP Server Port Conflicts**
|
||||
|
||||
```bash
|
||||
# Problem: Port already in use
|
||||
# Error: bind: address already in use
|
||||
|
||||
# Solution 1: Kill conflicting process
|
||||
lsof -i :25 | grep LISTEN | awk '{print $2}' | xargs kill
|
||||
|
||||
# Solution 2: Tests use random free ports (already implemented)
|
||||
# The test suite automatically finds free ports
|
||||
```
|
||||
|
||||
**4. Coverage Report Not Generated**
|
||||
|
||||
```bash
|
||||
# Problem: Missing coverage file
|
||||
go tool cover -html=coverage.out
|
||||
# Error: coverage.out: no such file
|
||||
|
||||
# Solution: Generate coverage first
|
||||
go test -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
```
|
||||
|
||||
**5. Ginkgo Not Found**
|
||||
|
||||
```bash
|
||||
# Problem: Ginkgo CLI not installed
|
||||
ginkgo
|
||||
# Error: command not found
|
||||
|
||||
# Solution: Install Ginkgo
|
||||
go install github.com/onsi/ginkgo/v2/ginkgo@latest
|
||||
|
||||
# Verify installation
|
||||
ginkgo version
|
||||
```
|
||||
|
||||
### Debug Failing Tests
|
||||
|
||||
```bash
|
||||
# Run specific test
|
||||
go test -v -run "TestSender/Mail_Operations/Subject"
|
||||
|
||||
# With Ginkgo focus
|
||||
ginkgo --focus="should set subject"
|
||||
|
||||
# Print debug output (add to test)
|
||||
It("should work", func() {
|
||||
GinkgoWriter.Println("Debug:", value)
|
||||
// Test code...
|
||||
})
|
||||
|
||||
# Fail fast on first error
|
||||
go test -failfast ./...
|
||||
```
|
||||
|
||||
### Performance Issues
|
||||
|
||||
```bash
|
||||
# Identify slow tests
|
||||
go test -v ./... | grep "seconds"
|
||||
|
||||
# Profile CPU usage
|
||||
go test -cpuprofile=cpu.out ./...
|
||||
go tool pprof cpu.out
|
||||
# In pprof: top10, list <function>
|
||||
|
||||
# Profile memory
|
||||
go test -memprofile=mem.out ./...
|
||||
go tool pprof mem.out
|
||||
# In pprof: top10, list <function>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI Integration
|
||||
|
||||
### GitHub Actions Example
|
||||
|
||||
```yaml
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: ['1.18', '1.19', '1.20', '1.21']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v -cover ./...
|
||||
|
||||
- name: Race detection
|
||||
run: CGO_ENABLED=1 go test -race -v ./...
|
||||
|
||||
- name: Generate coverage
|
||||
run: go test -coverprofile=coverage.out -covermode=atomic ./...
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: ./coverage.out
|
||||
flags: unittests
|
||||
```
|
||||
|
||||
### GitLab CI Example
|
||||
|
||||
```yaml
|
||||
test:
|
||||
image: golang:1.21
|
||||
stage: test
|
||||
script:
|
||||
- go mod download
|
||||
- go test -v -cover ./...
|
||||
- CGO_ENABLED=1 go test -race ./...
|
||||
coverage: '/coverage: \d+\.\d+% of statements/'
|
||||
artifacts:
|
||||
reports:
|
||||
junit: report.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
```
|
||||
|
||||
### Jenkins Pipeline Example
|
||||
|
||||
```groovy
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
stages {
|
||||
stage('Test') {
|
||||
steps {
|
||||
sh 'go mod download'
|
||||
sh 'go test -v -cover ./...'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Race Detection') {
|
||||
steps {
|
||||
sh 'CGO_ENABLED=1 go test -race ./...'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Coverage') {
|
||||
steps {
|
||||
sh 'go test -coverprofile=coverage.out ./...'
|
||||
sh 'go tool cover -html=coverage.out -o coverage.html'
|
||||
publishHTML([reportDir: '.', reportFiles: 'coverage.html', reportName: 'Coverage Report'])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pre-Commit Hook
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .git/hooks/pre-commit
|
||||
|
||||
echo "Running tests..."
|
||||
go test ./...
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Tests failed. Commit aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running race detector..."
|
||||
CGO_ENABLED=1 go test -race ./...
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Race detector found issues. Commit aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All checks passed!"
|
||||
exit 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI Transparency Notice
|
||||
|
||||
In accordance with Article 50.4 of the EU AI Act, AI assistance has been used for testing, documentation, and bug fixing under human supervision.
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- **Ginkgo Documentation**: [https://onsi.github.io/ginkgo/](https://onsi.github.io/ginkgo/)
|
||||
- **Gomega Documentation**: [https://onsi.github.io/gomega/](https://onsi.github.io/gomega/)
|
||||
- **Go Testing**: [https://golang.org/pkg/testing/](https://golang.org/pkg/testing/)
|
||||
- **Race Detector**: [https://go.dev/doc/articles/race_detector](https://go.dev/doc/articles/race_detector)
|
||||
- **Package Documentation**: [README.md](README.md)
|
||||
- **Contributing**: [CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
527
mail/sender/benchmark_test.go
Normal file
527
mail/sender/benchmark_test.go
Normal file
@@ -0,0 +1,527 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package sender_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
libsnd "github.com/nabbar/golib/mail/sender"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/onsi/gomega/gmeasure"
|
||||
)
|
||||
|
||||
var _ = Describe("Performance Benchmarks", func() {
|
||||
|
||||
Describe("Mail Creation Performance", func() {
|
||||
It("should measure mail creation time", func() {
|
||||
experiment := gmeasure.NewExperiment("Mail Creation")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("creation", func() {
|
||||
mail := newMail()
|
||||
Expect(mail).ToNot(BeNil())
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 1000})
|
||||
|
||||
stats := experiment.GetStats("creation")
|
||||
AddReportEntry("Creation Stats", fmt.Sprintf("Mean: %v, StdDev: %v", stats.DurationFor(gmeasure.StatMean), stats.DurationFor(gmeasure.StatStdDev)))
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 100*time.Microsecond))
|
||||
})
|
||||
|
||||
It("should measure configured mail creation time", func() {
|
||||
experiment := gmeasure.NewExperiment("Configured Mail Creation")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("configured_creation", func() {
|
||||
mail := newMailWithBasicConfig()
|
||||
Expect(mail).ToNot(BeNil())
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 1000})
|
||||
|
||||
stats := experiment.GetStats("configured_creation")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 500*time.Microsecond))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Mail Property Operations Performance", func() {
|
||||
var mail libsnd.Mail
|
||||
|
||||
BeforeEach(func() {
|
||||
mail = newMail()
|
||||
})
|
||||
|
||||
It("should measure subject set/get performance", func() {
|
||||
experiment := gmeasure.NewExperiment("Subject Operations")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
subject := "Test Subject"
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("set_subject", func() {
|
||||
mail.SetSubject(subject)
|
||||
})
|
||||
experiment.MeasureDuration("get_subject", func() {
|
||||
_ = mail.GetSubject()
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 10000})
|
||||
|
||||
setStats := experiment.GetStats("set_subject")
|
||||
getStats := experiment.GetStats("get_subject")
|
||||
|
||||
Expect(setStats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 10*time.Microsecond))
|
||||
Expect(getStats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 10*time.Microsecond))
|
||||
})
|
||||
|
||||
It("should measure encoding set/get performance", func() {
|
||||
experiment := gmeasure.NewExperiment("Encoding Operations")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("set_encoding", func() {
|
||||
mail.SetEncoding(libsnd.EncodingBase64)
|
||||
})
|
||||
experiment.MeasureDuration("get_encoding", func() {
|
||||
_ = mail.GetEncoding()
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 10000})
|
||||
|
||||
setStats := experiment.GetStats("set_encoding")
|
||||
getStats := experiment.GetStats("get_encoding")
|
||||
|
||||
Expect(setStats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 10*time.Microsecond))
|
||||
Expect(getStats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 10*time.Microsecond))
|
||||
})
|
||||
|
||||
It("should measure priority set/get performance", func() {
|
||||
experiment := gmeasure.NewExperiment("Priority Operations")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("set_priority", func() {
|
||||
mail.SetPriority(libsnd.PriorityHigh)
|
||||
})
|
||||
experiment.MeasureDuration("get_priority", func() {
|
||||
_ = mail.GetPriority()
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 10000})
|
||||
|
||||
setStats := experiment.GetStats("set_priority")
|
||||
getStats := experiment.GetStats("get_priority")
|
||||
|
||||
Expect(setStats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 10*time.Microsecond))
|
||||
Expect(getStats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 10*time.Microsecond))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Email Address Operations Performance", func() {
|
||||
var mail libsnd.Mail
|
||||
|
||||
BeforeEach(func() {
|
||||
mail = newMail()
|
||||
})
|
||||
|
||||
It("should measure from address operations", func() {
|
||||
experiment := gmeasure.NewExperiment("From Address Operations")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
email := mail.Email()
|
||||
addr := "sender@example.com"
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("set_from", func() {
|
||||
email.SetFrom(addr)
|
||||
})
|
||||
experiment.MeasureDuration("get_from", func() {
|
||||
_ = email.GetFrom()
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 10000})
|
||||
|
||||
setStats := experiment.GetStats("set_from")
|
||||
getStats := experiment.GetStats("get_from")
|
||||
|
||||
Expect(setStats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 10*time.Microsecond))
|
||||
Expect(getStats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 10*time.Microsecond))
|
||||
})
|
||||
|
||||
It("should measure recipient operations", func() {
|
||||
experiment := gmeasure.NewExperiment("Recipient Operations")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
email := mail.Email()
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("add_recipient", func() {
|
||||
email.AddRecipients(libsnd.RecipientTo, fmt.Sprintf("recipient%d@example.com", idx))
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 100})
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("get_recipients", func() {
|
||||
_ = email.GetRecipients(libsnd.RecipientTo)
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 1000})
|
||||
|
||||
addStats := experiment.GetStats("add_recipient")
|
||||
getStats := experiment.GetStats("get_recipients")
|
||||
|
||||
Expect(addStats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 50*time.Microsecond))
|
||||
Expect(getStats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 20*time.Microsecond))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Header Operations Performance", func() {
|
||||
var mail libsnd.Mail
|
||||
|
||||
BeforeEach(func() {
|
||||
mail = newMail()
|
||||
})
|
||||
|
||||
It("should measure header addition", func() {
|
||||
experiment := gmeasure.NewExperiment("Header Addition")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("add_header", func() {
|
||||
mail.AddHeader(fmt.Sprintf("X-Custom-%d", idx), "value")
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 100})
|
||||
|
||||
stats := experiment.GetStats("add_header")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 50*time.Microsecond))
|
||||
})
|
||||
|
||||
It("should measure get all headers", func() {
|
||||
// Pre-populate headers
|
||||
for i := 0; i < 50; i++ {
|
||||
mail.AddHeader(fmt.Sprintf("X-Header-%d", i), "value")
|
||||
}
|
||||
|
||||
experiment := gmeasure.NewExperiment("Get All Headers")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("get_headers", func() {
|
||||
_ = mail.GetHeaders()
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 1000})
|
||||
|
||||
stats := experiment.GetStats("get_headers")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 200*time.Microsecond))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Body Operations Performance", func() {
|
||||
var mail libsnd.Mail
|
||||
|
||||
BeforeEach(func() {
|
||||
mail = newMail()
|
||||
})
|
||||
|
||||
It("should measure small body operations", func() {
|
||||
experiment := gmeasure.NewExperiment("Small Body Operations")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
smallContent := "Small body content"
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("set_body", func() {
|
||||
body := newReadCloser(smallContent)
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 1000})
|
||||
|
||||
stats := experiment.GetStats("set_body")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 100*time.Microsecond))
|
||||
})
|
||||
|
||||
It("should measure large body operations", func() {
|
||||
experiment := gmeasure.NewExperiment("Large Body Operations")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
largeContent := strings.Repeat("Content ", 10000)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("set_large_body", func() {
|
||||
body := newReadCloser(largeContent)
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 100})
|
||||
|
||||
stats := experiment.GetStats("set_large_body")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 500*time.Microsecond))
|
||||
})
|
||||
|
||||
It("should measure add alternative body", func() {
|
||||
experiment := gmeasure.NewExperiment("Add Alternative Body")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
plainBody := newReadCloser("Plain text")
|
||||
mail.SetBody(libsnd.ContentPlainText, plainBody)
|
||||
|
||||
experiment.MeasureDuration("add_html_body", func() {
|
||||
htmlBody := newReadCloser("<html>HTML</html>")
|
||||
mail.AddBody(libsnd.ContentHTML, htmlBody)
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 1000})
|
||||
|
||||
stats := experiment.GetStats("add_html_body")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 100*time.Microsecond))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Attachment Operations Performance", func() {
|
||||
var mail libsnd.Mail
|
||||
|
||||
BeforeEach(func() {
|
||||
mail = newMail()
|
||||
})
|
||||
|
||||
It("should measure attachment addition", func() {
|
||||
experiment := gmeasure.NewExperiment("Attachment Addition")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("add_attachment", func() {
|
||||
data := newReadCloser("attachment data")
|
||||
mail.AddAttachment(fmt.Sprintf("file%d.txt", idx), "text/plain", data, false)
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 100})
|
||||
|
||||
stats := experiment.GetStats("add_attachment")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 100*time.Microsecond))
|
||||
})
|
||||
|
||||
It("should measure inline attachment addition", func() {
|
||||
experiment := gmeasure.NewExperiment("Inline Attachment Addition")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("add_inline", func() {
|
||||
data := newReadCloser("inline data")
|
||||
mail.AddAttachment(fmt.Sprintf("inline%d.png", idx), "image/png", data, true)
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 100})
|
||||
|
||||
stats := experiment.GetStats("add_inline")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 100*time.Microsecond))
|
||||
})
|
||||
|
||||
It("should measure getting attachments", func() {
|
||||
// Pre-populate attachments
|
||||
for i := 0; i < 20; i++ {
|
||||
data := newReadCloser("data")
|
||||
mail.AddAttachment(fmt.Sprintf("file%d.txt", i), "text/plain", data, false)
|
||||
}
|
||||
|
||||
experiment := gmeasure.NewExperiment("Get Attachments")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("get_attachments", func() {
|
||||
_ = mail.GetAttachment(false)
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 10000})
|
||||
|
||||
stats := experiment.GetStats("get_attachments")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 20*time.Microsecond))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Clone Performance", func() {
|
||||
It("should measure clone operation with minimal data", func() {
|
||||
experiment := gmeasure.NewExperiment("Clone Minimal Mail")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
mail := newMail()
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("clone", func() {
|
||||
_ = mail.Clone()
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 1000})
|
||||
|
||||
stats := experiment.GetStats("clone")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 100*time.Microsecond))
|
||||
})
|
||||
|
||||
It("should measure clone operation with full data", func() {
|
||||
experiment := gmeasure.NewExperiment("Clone Full Mail")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
mail := newMailWithBasicConfig()
|
||||
mail.AddHeader("X-Custom-1", "value1")
|
||||
mail.AddHeader("X-Custom-2", "value2")
|
||||
mail.SetBody(libsnd.ContentPlainText, newReadCloser("body"))
|
||||
mail.AddAttachment("file.txt", "text/plain", newReadCloser("data"), false)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("clone_full", func() {
|
||||
_ = mail.Clone()
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 1000})
|
||||
|
||||
stats := experiment.GetStats("clone_full")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 200*time.Microsecond))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Sender Creation Performance", func() {
|
||||
It("should measure sender creation", func() {
|
||||
experiment := gmeasure.NewExperiment("Sender Creation")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
mail := newMailWithBasicConfig()
|
||||
mail.SetBody(libsnd.ContentPlainText, newReadCloser("Test body"))
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("create_sender", func() {
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
if sender != nil {
|
||||
_ = sender.Close()
|
||||
}
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 100})
|
||||
|
||||
stats := experiment.GetStats("create_sender")
|
||||
AddReportEntry("Sender Creation Stats", fmt.Sprintf("Mean: %v, StdDev: %v", stats.DurationFor(gmeasure.StatMean), stats.DurationFor(gmeasure.StatStdDev)))
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 10*time.Millisecond))
|
||||
})
|
||||
|
||||
It("should measure sender creation with attachments", func() {
|
||||
experiment := gmeasure.NewExperiment("Sender with Attachments")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
mail := newMailWithBasicConfig()
|
||||
mail.SetBody(libsnd.ContentPlainText, newReadCloser("Body"))
|
||||
mail.AddAttachment("file.txt", "text/plain", newReadCloser("data"), false)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("create_sender_with_attachment", func() {
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
if sender != nil {
|
||||
_ = sender.Close()
|
||||
}
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 50})
|
||||
|
||||
stats := experiment.GetStats("create_sender_with_attachment")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 20*time.Millisecond))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Config Operations Performance", func() {
|
||||
It("should measure config validation", func() {
|
||||
experiment := gmeasure.NewExperiment("Config Validation")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "Base 64",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
To: []string{"recipient@example.com"},
|
||||
}
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("validate", func() {
|
||||
_ = cfg.Validate()
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 1000})
|
||||
|
||||
stats := experiment.GetStats("validate")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 500*time.Microsecond))
|
||||
})
|
||||
|
||||
It("should measure new mailer from config", func() {
|
||||
experiment := gmeasure.NewExperiment("NewMailer from Config")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "Base 64",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
To: []string{"recipient@example.com"},
|
||||
}
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("new_mailer", func() {
|
||||
_, _ = cfg.NewMailer()
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 1000})
|
||||
|
||||
stats := experiment.GetStats("new_mailer")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 500*time.Microsecond))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Type Parsing Performance", func() {
|
||||
It("should measure encoding parsing", func() {
|
||||
experiment := gmeasure.NewExperiment("Encoding Parsing")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
encodings := []string{"None", "Binary", "Base 64", "Quoted Printable"}
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
enc := encodings[idx%len(encodings)]
|
||||
experiment.MeasureDuration("parse_encoding", func() {
|
||||
_ = libsnd.ParseEncoding(enc)
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 10000})
|
||||
|
||||
stats := experiment.GetStats("parse_encoding")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 10*time.Microsecond))
|
||||
})
|
||||
|
||||
It("should measure priority parsing", func() {
|
||||
experiment := gmeasure.NewExperiment("Priority Parsing")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
|
||||
priorities := []string{"Normal", "Low", "High"}
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
pri := priorities[idx%len(priorities)]
|
||||
experiment.MeasureDuration("parse_priority", func() {
|
||||
_ = libsnd.ParsePriority(pri)
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 10000})
|
||||
|
||||
stats := experiment.GetStats("parse_priority")
|
||||
Expect(stats.DurationFor(gmeasure.StatMean)).To(BeNumerically("<", 10*time.Microsecond))
|
||||
})
|
||||
})
|
||||
})
|
||||
66
mail/sender/body.go
Normal file
66
mail/sender/body.go
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"io"
|
||||
)
|
||||
|
||||
// Body represents a content part of an email message with its associated content type.
|
||||
// An email can contain multiple body parts, such as plain text and HTML alternatives.
|
||||
//
|
||||
// The body content is provided as an io.Reader, allowing for efficient streaming
|
||||
// of large email content without loading everything into memory.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// plainText := sender.NewBody(sender.ContentPlainText, strings.NewReader("Hello"))
|
||||
// htmlBody := sender.NewBody(sender.ContentHTML, strings.NewReader("<p>Hello</p>"))
|
||||
//
|
||||
// See ContentType for available content type options.
|
||||
type Body struct {
|
||||
contentType ContentType
|
||||
body io.Reader
|
||||
}
|
||||
|
||||
// NewBody creates a new Body instance with the specified content type and reader.
|
||||
//
|
||||
// Parameters:
|
||||
// - ct: The content type of the body (e.g., ContentPlainText, ContentHTML)
|
||||
// - body: An io.Reader providing the body content
|
||||
//
|
||||
// Returns a configured Body instance that can be added to a Mail object.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// body := sender.NewBody(sender.ContentPlainText, bytes.NewReader([]byte("Email content")))
|
||||
// mail.SetBody(sender.ContentPlainText, body)
|
||||
func NewBody(ct ContentType, body io.Reader) Body {
|
||||
return Body{
|
||||
contentType: ct,
|
||||
body: body,
|
||||
}
|
||||
}
|
||||
391
mail/sender/config.go
Normal file
391
mail/sender/config.go
Normal file
@@ -0,0 +1,391 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2021 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/textproto"
|
||||
|
||||
libval "github.com/go-playground/validator/v10"
|
||||
liberr "github.com/nabbar/golib/errors"
|
||||
libfpg "github.com/nabbar/golib/file/progress"
|
||||
)
|
||||
|
||||
// Config provides a declarative way to configure email messages.
|
||||
// It supports JSON, YAML, TOML, and Viper mapstructure tags for easy
|
||||
// integration with configuration management systems.
|
||||
//
|
||||
// All fields are validated using github.com/go-playground/validator/v10.
|
||||
// Use Config.Validate() to check for errors before creating emails.
|
||||
//
|
||||
// Example JSON configuration:
|
||||
//
|
||||
// {
|
||||
// "charset": "UTF-8",
|
||||
// "subject": "Welcome Email",
|
||||
// "encoding": "Base 64",
|
||||
// "priority": "Normal",
|
||||
// "from": "noreply@example.com",
|
||||
// "to": ["user@example.com"],
|
||||
// "attach": [
|
||||
// {"name": "report.pdf", "mime": "application/pdf", "path": "/path/to/report.pdf"}
|
||||
// ]
|
||||
// }
|
||||
//
|
||||
// Create a Mail instance from configuration:
|
||||
//
|
||||
// config := sender.Config{ /* ... */ }
|
||||
// if err := config.Validate(); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// mail, err := config.NewMailer()
|
||||
type Config struct {
|
||||
// Charset specifies the character encoding for the email.
|
||||
// Required. Common values: "UTF-8", "ISO-8859-1", "ISO-8859-15".
|
||||
//
|
||||
// Example: "UTF-8"
|
||||
Charset string `json:"charset" yaml:"charset" toml:"charset" mapstructure:"charset" validate:"required"`
|
||||
|
||||
// Subject is the email subject line.
|
||||
// Required. Appears in the recipient's inbox.
|
||||
//
|
||||
// Example: "Monthly Report - January 2024"
|
||||
Subject string `json:"subject" yaml:"subject" toml:"subject" mapstructure:"subject" validate:"required"`
|
||||
|
||||
// Encoding specifies the transfer encoding for the email body.
|
||||
// Required. Valid values: "None", "Binary", "Base 64", "Quoted Printable".
|
||||
// Case-insensitive. Parsed by ParseEncoding().
|
||||
//
|
||||
// Recommended:
|
||||
// - "Base 64" for binary data and non-ASCII text
|
||||
// - "Quoted Printable" for mostly ASCII text
|
||||
//
|
||||
// Example: "Base 64"
|
||||
Encoding string `json:"encoding" yaml:"encoding" toml:"encoding" mapstructure:"encoding" validate:"required"`
|
||||
|
||||
// Priority specifies the urgency level of the email.
|
||||
// Required. Valid values: "Normal", "Low", "High".
|
||||
// Case-insensitive. Parsed by ParsePriority().
|
||||
//
|
||||
// Example: "Normal"
|
||||
Priority string `json:"priority" yaml:"priority" toml:"priority" mapstructure:"priority" validate:"required"`
|
||||
|
||||
// Headers is a map of custom email headers to add.
|
||||
// Optional. Use for custom headers like "X-Campaign-ID", "X-Mailer", etc.
|
||||
// Standard headers (To, From, Subject) are set via other fields.
|
||||
//
|
||||
// Example: {"X-Campaign-ID": "2024-Q1", "X-Mailer": "MyApp/1.0"}
|
||||
Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty" toml:"headers,omitempty" mapstructure:"headers,omitempty"`
|
||||
|
||||
// From is the sender's email address (required).
|
||||
// This address appears in the "From" header and is mandatory.
|
||||
// Used as fallback for Sender, ReplyTo, and ReturnPath if they're not set.
|
||||
//
|
||||
// Validated as a proper email address format.
|
||||
//
|
||||
// Example: "noreply@example.com"
|
||||
From string `json:"from" yaml:"from" toml:"from" mapstructure:"from" validate:"required,email"`
|
||||
|
||||
// Sender is the actual sender email if different from From (optional).
|
||||
// Use when sending on behalf of someone else (e.g., mailing lists, delegation).
|
||||
// If not set, falls back to ReplyTo, ReturnPath, then From.
|
||||
//
|
||||
// Validated as email if provided.
|
||||
//
|
||||
// Example: "system@example.com"
|
||||
Sender string `json:"sender,omitempty" yaml:"sender,omitempty" toml:"sender,omitempty" mapstructure:"sender,omitempty" validate:"omitempty,email"`
|
||||
|
||||
// ReplyTo specifies where replies should be sent (optional).
|
||||
// If not set, falls back to Sender, ReturnPath, then From.
|
||||
//
|
||||
// Validated as email if provided.
|
||||
//
|
||||
// Example: "support@example.com"
|
||||
ReplyTo string `json:"replyTo,omitempty" yaml:"replyTo,omitempty" toml:"replyTo,omitempty" mapstructure:"replyTo,omitempty" validate:"omitempty,email"`
|
||||
|
||||
// ReturnPath specifies the bounce address (optional).
|
||||
// Used for delivery failure notifications.
|
||||
// If not set, falls back to ReplyTo, Sender, then From.
|
||||
//
|
||||
// Useful when the sender's IP is not public or for bounce tracking.
|
||||
//
|
||||
// Example: "bounces@example.com"
|
||||
ReturnPath string `json:"returnPath,omitempty" yaml:"returnPath,omitempty" toml:"returnPath,omitempty" mapstructure:"returnPath,omitempty"`
|
||||
|
||||
// To is the list of primary recipients (optional).
|
||||
// Each email address is validated for proper format.
|
||||
//
|
||||
// Example: ["user1@example.com", "user2@example.com"]
|
||||
To []string `json:"to,omitempty" yaml:"to,omitempty" toml:"to,omitempty" mapstructure:"to,omitempty" validate:"dive,email"`
|
||||
|
||||
// Cc is the list of carbon copy recipients (optional).
|
||||
// These recipients are visible to all other recipients.
|
||||
// Each email address is validated for proper format.
|
||||
//
|
||||
// Example: ["manager@example.com"]
|
||||
Cc []string `json:"cc,omitempty" yaml:"cc,omitempty" toml:"cc,omitempty" mapstructure:"cc,omitempty" validate:"dive,email"`
|
||||
|
||||
// Bcc is the list of blind carbon copy recipients (optional).
|
||||
// These recipients are hidden from all other recipients.
|
||||
// Each email address is validated for proper format.
|
||||
//
|
||||
// Example: ["archive@example.com", "audit@example.com"]
|
||||
Bcc []string `json:"bcc,omitempty" yaml:"bcc,omitempty" toml:"bcc,omitempty" mapstructure:"bcc,omitempty" validate:"dive,email"`
|
||||
|
||||
// Attach is the list of files to attach to the email (optional).
|
||||
// Files are attached as regular attachments visible in the attachment list.
|
||||
// Each ConfigFile is validated for required fields and file existence.
|
||||
//
|
||||
// See ConfigFile for file specification format.
|
||||
Attach []ConfigFile `json:"attach,omitempty" yaml:"attach,omitempty" toml:"attach,omitempty" mapstructure:"attach,omitempty" validate:"dive"`
|
||||
|
||||
// Inline is the list of files to embed inline in the email body (optional).
|
||||
// Inline files are typically images referenced in HTML content.
|
||||
// Each ConfigFile is validated for required fields and file existence.
|
||||
//
|
||||
// Use this for images embedded in HTML emails: <img src="cid:logo.png">
|
||||
//
|
||||
// See ConfigFile for file specification format.
|
||||
Inline []ConfigFile `json:"inline,omitempty" yaml:"inline,omitempty" toml:"inline,omitempty" mapstructure:"inline,omitempty" validate:"dive"`
|
||||
}
|
||||
|
||||
// ConfigFile specifies a file attachment or inline file configuration.
|
||||
//
|
||||
// Example JSON:
|
||||
//
|
||||
// {
|
||||
// "name": "report.pdf",
|
||||
// "mime": "application/pdf",
|
||||
// "path": "/var/data/reports/monthly.pdf"
|
||||
// }
|
||||
//
|
||||
// Common MIME types:
|
||||
// - "application/pdf" - PDF documents
|
||||
// - "image/jpeg", "image/png" - Images
|
||||
// - "text/plain" - Text files
|
||||
// - "application/zip" - ZIP archives
|
||||
type ConfigFile struct {
|
||||
// Name is the filename as it will appear in the email.
|
||||
// Required.
|
||||
//
|
||||
// Example: "report.pdf"
|
||||
Name string `json:"name" yaml:"name" toml:"name" mapstructure:"name" validate:"required"`
|
||||
|
||||
// Mime is the MIME type of the file.
|
||||
// Required.
|
||||
//
|
||||
// Example: "application/pdf"
|
||||
Mime string `json:"mime" yaml:"mime" toml:"mime" mapstructure:"mime" validate:"required"`
|
||||
|
||||
// Path is the filesystem path to the file.
|
||||
// Required. Must be an existing file accessible to the application.
|
||||
// Validated using the "file" constraint.
|
||||
//
|
||||
// Example: "/var/data/reports/monthly.pdf"
|
||||
Path string `json:"path" yaml:"path" toml:"path" mapstructure:"path" validate:"required,file"`
|
||||
}
|
||||
|
||||
// Validate checks the Config for errors using struct validation tags.
|
||||
//
|
||||
// This method uses github.com/go-playground/validator/v10 to validate:
|
||||
// - Required fields (Charset, Subject, Encoding, Priority, From)
|
||||
// - Email address formats (From, Sender, ReplyTo, To, Cc, Bcc)
|
||||
// - File paths for attachments (Attach, Inline)
|
||||
//
|
||||
// Returns nil if validation passes, or ErrorMailConfigInvalid with
|
||||
// detailed parent errors describing what validation failed.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// config := sender.Config{
|
||||
// Charset: "UTF-8",
|
||||
// Subject: "Test",
|
||||
// Encoding: "Base 64",
|
||||
// Priority: "Normal",
|
||||
// From: "sender@example.com",
|
||||
// }
|
||||
// if err := config.Validate(); err != nil {
|
||||
// // Handle validation error
|
||||
// log.Printf("Config validation failed: %v", err)
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// See github.com/go-playground/validator/v10 for validation tag documentation.
|
||||
func (c Config) Validate() liberr.Error {
|
||||
err := ErrorMailConfigInvalid.Error(nil)
|
||||
|
||||
if er := libval.New().Struct(c); er != nil {
|
||||
if e, ok := er.(*libval.InvalidValidationError); ok {
|
||||
err.Add(e)
|
||||
}
|
||||
|
||||
for _, e := range er.(libval.ValidationErrors) {
|
||||
//nolint goerr113
|
||||
err.Add(fmt.Errorf("config field '%s' is not validated by constraint '%s'", e.Namespace(), e.ActualTag()))
|
||||
}
|
||||
}
|
||||
|
||||
if err.HasParent() {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewMailer creates a Mail instance from the configuration.
|
||||
//
|
||||
// This method:
|
||||
// 1. Creates a new Mail with default settings
|
||||
// 2. Applies all configuration values (charset, subject, encoding, priority)
|
||||
// 3. Sets email addresses (from, sender, replyTo, returnPath, recipients)
|
||||
// 4. Opens and attaches configured files
|
||||
// 5. Adds custom headers
|
||||
//
|
||||
// File handling:
|
||||
// - Files specified in Attach and Inline are opened using github.com/nabbar/golib/file/progress
|
||||
// - If a file cannot be opened, returns ErrorFileOpenCreate with parent error
|
||||
// - The calling code is responsible for eventually closing file handles via Sender.Close()
|
||||
//
|
||||
// Address fallback logic:
|
||||
// - If optional addresses (Sender, ReplyTo, ReturnPath) are not set,
|
||||
// they fall back to other addresses according to Email interface rules
|
||||
//
|
||||
// Returns:
|
||||
// - Mail instance and nil error on success
|
||||
// - nil and ErrorFileOpenCreate if a file cannot be opened
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// config := sender.Config{
|
||||
// Charset: "UTF-8",
|
||||
// Subject: "Welcome",
|
||||
// Encoding: "Base 64",
|
||||
// Priority: "Normal",
|
||||
// From: "noreply@example.com",
|
||||
// To: []string{"user@example.com"},
|
||||
// }
|
||||
//
|
||||
// mail, err := config.NewMailer()
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// // Add body and send
|
||||
// mail.SetBody(sender.ContentPlainText, body)
|
||||
// sender, _ := mail.Sender()
|
||||
// err = sender.SendClose(ctx, smtpClient)
|
||||
//
|
||||
// See github.com/nabbar/golib/file/progress for file handling details.
|
||||
func (c Config) NewMailer() (Mail, liberr.Error) {
|
||||
m := &mail{
|
||||
headers: make(textproto.MIMEHeader),
|
||||
charset: "UTF-8",
|
||||
encoding: ParseEncoding(c.Encoding),
|
||||
priority: ParsePriority(c.Priority),
|
||||
address: &email{
|
||||
from: "",
|
||||
sender: "",
|
||||
replyTo: "",
|
||||
returnPath: "",
|
||||
to: make([]string, 0),
|
||||
cc: make([]string, 0),
|
||||
bcc: make([]string, 0),
|
||||
},
|
||||
attach: make([]File, 0),
|
||||
inline: make([]File, 0),
|
||||
body: make([]Body, 0),
|
||||
}
|
||||
|
||||
m.headers.Set("MIME-Version", "1.0")
|
||||
if len(c.Headers) > 0 {
|
||||
for k, v := range c.Headers {
|
||||
m.headers.Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
if c.Charset != "" {
|
||||
m.charset = c.Charset
|
||||
}
|
||||
|
||||
if c.Subject != "" {
|
||||
m.SetSubject(c.Subject)
|
||||
}
|
||||
|
||||
m.Email().SetFrom(c.From)
|
||||
|
||||
if c.Sender != "" {
|
||||
m.Email().SetSender(c.Sender)
|
||||
}
|
||||
|
||||
if c.ReplyTo != "" {
|
||||
m.Email().SetReplyTo(c.ReplyTo)
|
||||
} else if c.Sender != "" {
|
||||
m.Email().SetReplyTo(c.Sender)
|
||||
}
|
||||
|
||||
if c.ReturnPath != "" {
|
||||
m.Email().SetReturnPath(c.ReturnPath)
|
||||
} else if c.ReplyTo != "" {
|
||||
m.Email().SetReturnPath(c.ReplyTo)
|
||||
} else if c.Sender != "" {
|
||||
m.Email().SetReturnPath(c.Sender)
|
||||
}
|
||||
|
||||
if len(c.To) > 0 {
|
||||
m.Email().AddRecipients(RecipientTo, c.To...)
|
||||
}
|
||||
|
||||
if len(c.Cc) > 0 {
|
||||
m.Email().AddRecipients(RecipientCC, c.Cc...)
|
||||
}
|
||||
|
||||
if len(c.Bcc) > 0 {
|
||||
m.Email().AddRecipients(RecipientBCC, c.Bcc...)
|
||||
}
|
||||
|
||||
if len(c.Attach) > 0 {
|
||||
for _, f := range c.Attach {
|
||||
if h, e := libfpg.Open(f.Path); e != nil {
|
||||
return nil, ErrorFileOpenCreate.Error(e)
|
||||
} else {
|
||||
m.AddAttachment(f.Name, f.Mime, h, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(c.Inline) > 0 {
|
||||
for _, f := range c.Inline {
|
||||
if h, e := libfpg.Open(f.Path); e != nil {
|
||||
return nil, ErrorFileOpenCreate.Error(e)
|
||||
} else {
|
||||
m.AddAttachment(f.Name, f.Mime, h, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
458
mail/sender/config_test.go
Normal file
458
mail/sender/config_test.go
Normal file
@@ -0,0 +1,458 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package sender_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
libsnd "github.com/nabbar/golib/mail/sender"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Config Operations", func() {
|
||||
Describe("Config Validation", func() {
|
||||
It("should validate valid minimal config", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test Subject",
|
||||
Encoding: "Base 64",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
To: []string{"recipient@example.com"},
|
||||
}
|
||||
|
||||
err := cfg.Validate()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should reject config with missing required fields", func() {
|
||||
cfg := libsnd.Config{}
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should reject config with invalid email format", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "invalid-email",
|
||||
}
|
||||
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should validate config with optional fields", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "Base 64",
|
||||
Priority: "High",
|
||||
From: "sender@example.com",
|
||||
Sender: "actual-sender@example.com",
|
||||
ReplyTo: "reply@example.com",
|
||||
ReturnPath: "return@example.com",
|
||||
To: []string{"to@example.com"},
|
||||
Cc: []string{"cc@example.com"},
|
||||
Bcc: []string{"bcc@example.com"},
|
||||
}
|
||||
|
||||
err := cfg.Validate()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should reject config with invalid To email", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
To: []string{"invalid-email"},
|
||||
}
|
||||
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should reject config with invalid Cc email", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
Cc: []string{"invalid-cc"},
|
||||
}
|
||||
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should reject config with invalid Bcc email", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
Bcc: []string{"invalid-bcc"},
|
||||
}
|
||||
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NewMailer from Config", func() {
|
||||
It("should create mail from valid config", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test Subject",
|
||||
Encoding: "Base 64",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
To: []string{"recipient@example.com"},
|
||||
}
|
||||
|
||||
mail, err := cfg.NewMailer()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mail).ToNot(BeNil())
|
||||
Expect(mail.GetSubject()).To(Equal("Test Subject"))
|
||||
Expect(mail.GetCharset()).To(Equal("UTF-8"))
|
||||
Expect(mail.GetEncoding()).To(Equal(libsnd.EncodingBase64))
|
||||
Expect(mail.GetPriority()).To(Equal(libsnd.PriorityNormal))
|
||||
})
|
||||
|
||||
It("should set from address", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
}
|
||||
|
||||
mail, err := cfg.NewMailer()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mail.Email().GetFrom()).To(Equal("sender@example.com"))
|
||||
})
|
||||
|
||||
It("should set sender address when provided", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "from@example.com",
|
||||
Sender: "sender@example.com",
|
||||
}
|
||||
|
||||
mail, err := cfg.NewMailer()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mail.Email().GetSender()).To(Equal("sender@example.com"))
|
||||
})
|
||||
|
||||
It("should set replyTo address when provided", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "from@example.com",
|
||||
ReplyTo: "reply@example.com",
|
||||
}
|
||||
|
||||
mail, err := cfg.NewMailer()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mail.Email().GetReplyTo()).To(Equal("reply@example.com"))
|
||||
})
|
||||
|
||||
It("should set returnPath when provided", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "from@example.com",
|
||||
ReturnPath: "return@example.com",
|
||||
}
|
||||
|
||||
mail, err := cfg.NewMailer()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mail.Email().GetReturnPath()).To(Equal("return@example.com"))
|
||||
})
|
||||
|
||||
It("should set To recipients", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
To: []string{"to1@example.com", "to2@example.com"},
|
||||
}
|
||||
|
||||
mail, err := cfg.NewMailer()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
recipients := mail.Email().GetRecipients(libsnd.RecipientTo)
|
||||
Expect(recipients).To(HaveLen(2))
|
||||
Expect(recipients).To(ContainElements("to1@example.com", "to2@example.com"))
|
||||
})
|
||||
|
||||
It("should set Cc recipients", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
Cc: []string{"cc@example.com"},
|
||||
}
|
||||
|
||||
mail, err := cfg.NewMailer()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
recipients := mail.Email().GetRecipients(libsnd.RecipientCC)
|
||||
Expect(recipients).To(ContainElement("cc@example.com"))
|
||||
})
|
||||
|
||||
It("should set Bcc recipients", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
Bcc: []string{"bcc@example.com"},
|
||||
}
|
||||
|
||||
mail, err := cfg.NewMailer()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
recipients := mail.Email().GetRecipients(libsnd.RecipientBCC)
|
||||
Expect(recipients).To(ContainElement("bcc@example.com"))
|
||||
})
|
||||
|
||||
It("should set custom headers", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
Headers: map[string]string{
|
||||
"X-Custom-Header": "custom-value",
|
||||
"X-Another": "another-value",
|
||||
},
|
||||
}
|
||||
|
||||
mail, err := cfg.NewMailer()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
headers := mail.GetHeaders()
|
||||
Expect(headers.Get("X-Custom-Header")).To(Equal("custom-value"))
|
||||
Expect(headers.Get("X-Another")).To(Equal("another-value"))
|
||||
})
|
||||
|
||||
It("should handle different encodings", func() {
|
||||
encodings := map[string]libsnd.Encoding{
|
||||
"None": libsnd.EncodingNone,
|
||||
"Binary": libsnd.EncodingBinary,
|
||||
"Base 64": libsnd.EncodingBase64,
|
||||
"Quoted Printable": libsnd.EncodingQuotedPrintable,
|
||||
}
|
||||
|
||||
for encodingStr, expectedEncoding := range encodings {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: encodingStr,
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
}
|
||||
|
||||
mail, err := cfg.NewMailer()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mail.GetEncoding()).To(Equal(expectedEncoding))
|
||||
}
|
||||
})
|
||||
|
||||
It("should handle different priorities", func() {
|
||||
priorities := map[string]libsnd.Priority{
|
||||
"Normal": libsnd.PriorityNormal,
|
||||
"Low": libsnd.PriorityLow,
|
||||
"High": libsnd.PriorityHigh,
|
||||
}
|
||||
|
||||
for priorityStr, expectedPriority := range priorities {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: priorityStr,
|
||||
From: "sender@example.com",
|
||||
}
|
||||
|
||||
mail, err := cfg.NewMailer()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mail.GetPriority()).To(Equal(expectedPriority))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ConfigFile with Attachments", func() {
|
||||
var tempDir string
|
||||
var testFile string
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
tempDir, err = os.MkdirTemp("", "mail-sender-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
testFile = filepath.Join(tempDir, "test-attachment.txt")
|
||||
err = os.WriteFile(testFile, []byte("Test attachment content"), 0644)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if tempDir != "" {
|
||||
_ = os.RemoveAll(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
It("should attach file from config", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
Attach: []libsnd.ConfigFile{
|
||||
{
|
||||
Name: "attachment.txt",
|
||||
Mime: "text/plain",
|
||||
Path: testFile,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mail, err := cfg.NewMailer()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
attachments := mail.GetAttachment(false)
|
||||
Expect(attachments).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should inline file from config", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
Inline: []libsnd.ConfigFile{
|
||||
{
|
||||
Name: "inline.txt",
|
||||
Mime: "text/plain",
|
||||
Path: testFile,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mail, err := cfg.NewMailer()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
inlines := mail.GetAttachment(true)
|
||||
Expect(inlines).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should return error for non-existent file", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
Attach: []libsnd.ConfigFile{
|
||||
{
|
||||
Name: "missing.txt",
|
||||
Mime: "text/plain",
|
||||
Path: "/non/existent/file.txt",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := cfg.NewMailer()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should handle multiple attachments", func() {
|
||||
file2 := filepath.Join(tempDir, "test2.txt")
|
||||
err := os.WriteFile(file2, []byte("Second file"), 0644)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
Attach: []libsnd.ConfigFile{
|
||||
{Name: "file1.txt", Mime: "text/plain", Path: testFile},
|
||||
{Name: "file2.txt", Mime: "text/plain", Path: file2},
|
||||
},
|
||||
}
|
||||
|
||||
mail, err := cfg.NewMailer()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
attachments := mail.GetAttachment(false)
|
||||
Expect(attachments).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("should handle both attachments and inlines", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "sender@example.com",
|
||||
Attach: []libsnd.ConfigFile{
|
||||
{Name: "attach.txt", Mime: "text/plain", Path: testFile},
|
||||
},
|
||||
Inline: []libsnd.ConfigFile{
|
||||
{Name: "inline.txt", Mime: "text/plain", Path: testFile},
|
||||
},
|
||||
}
|
||||
|
||||
mail, err := cfg.NewMailer()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mail.GetAttachment(false)).To(HaveLen(1))
|
||||
Expect(mail.GetAttachment(true)).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
})
|
||||
89
mail/sender/contentType.go
Normal file
89
mail/sender/contentType.go
Normal file
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package sender
|
||||
|
||||
// ContentType defines the type of content in an email body part.
|
||||
//
|
||||
// Email bodies can contain multiple parts with different content types,
|
||||
// allowing clients to display the most appropriate version. For example,
|
||||
// an email can include both plain text and HTML versions, with the email
|
||||
// client choosing which to display based on its capabilities.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// // Add plain text body
|
||||
// mail.SetBody(sender.ContentPlainText, strings.NewReader("Hello World"))
|
||||
//
|
||||
// // Add HTML alternative
|
||||
// mail.AddBody(sender.ContentHTML, strings.NewReader("<p>Hello World</p>"))
|
||||
//
|
||||
// Best practice is to always include a ContentPlainText version for
|
||||
// maximum compatibility with all email clients.
|
||||
type ContentType uint8
|
||||
|
||||
const (
|
||||
// ContentPlainText represents plain text content (MIME type: text/plain).
|
||||
// This is the most basic and universally supported content type.
|
||||
// Plain text emails are displayed exactly as written, without any formatting.
|
||||
//
|
||||
// Use this for simple text-only emails or as a fallback when also
|
||||
// providing an HTML version.
|
||||
//
|
||||
// Example:
|
||||
// mail.SetBody(sender.ContentPlainText, strings.NewReader("Hello"))
|
||||
ContentPlainText ContentType = iota
|
||||
|
||||
// ContentHTML represents HTML content (MIME type: text/html).
|
||||
// HTML emails can include rich formatting, colors, images, and links.
|
||||
// Most modern email clients support HTML rendering.
|
||||
//
|
||||
// When using HTML content, it's recommended to also provide a plain text
|
||||
// alternative using ContentPlainText for email clients that don't support
|
||||
// HTML or for users who prefer plain text.
|
||||
//
|
||||
// Example:
|
||||
// mail.AddBody(sender.ContentHTML, strings.NewReader("<p><b>Hello</b></p>"))
|
||||
ContentHTML
|
||||
)
|
||||
|
||||
// String returns a human-readable string representation of the ContentType.
|
||||
//
|
||||
// Returns:
|
||||
// - "Plain Text" for ContentPlainText
|
||||
// - "HTML" for ContentHTML
|
||||
// - Defaults to "Plain Text" for unknown values
|
||||
//
|
||||
// This method is useful for logging and debugging purposes.
|
||||
func (c ContentType) String() string {
|
||||
switch c {
|
||||
case ContentPlainText:
|
||||
return "Plain Text"
|
||||
case ContentHTML:
|
||||
return "HTML"
|
||||
}
|
||||
|
||||
return ContentPlainText.String()
|
||||
}
|
||||
445
mail/sender/edge_cases_test.go
Normal file
445
mail/sender/edge_cases_test.go
Normal file
@@ -0,0 +1,445 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package sender_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
libsnd "github.com/nabbar/golib/mail/sender"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Edge Cases and Boundary Conditions", func() {
|
||||
var mail libsnd.Mail
|
||||
|
||||
BeforeEach(func() {
|
||||
mail = newMail()
|
||||
})
|
||||
|
||||
Describe("Empty and Nil Values", func() {
|
||||
It("should handle empty charset", func() {
|
||||
mail.SetCharset("")
|
||||
Expect(mail.GetCharset()).To(Equal(""))
|
||||
})
|
||||
|
||||
It("should handle empty subject", func() {
|
||||
mail.SetSubject("")
|
||||
Expect(mail.GetSubject()).To(Equal(""))
|
||||
})
|
||||
|
||||
It("should handle empty from address", func() {
|
||||
mail.Email().SetFrom("")
|
||||
Expect(mail.Email().GetFrom()).To(Equal(""))
|
||||
})
|
||||
|
||||
It("should handle nil body reader", func() {
|
||||
// This might panic or handle gracefully depending on implementation
|
||||
// Testing the actual behavior
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Panic occurred, that's one valid behavior
|
||||
Expect(r).ToNot(BeNil())
|
||||
}
|
||||
}()
|
||||
|
||||
mail.SetBody(libsnd.ContentPlainText, nil)
|
||||
})
|
||||
|
||||
It("should handle empty recipient list", func() {
|
||||
recipients := mail.Email().GetRecipients(libsnd.RecipientTo)
|
||||
Expect(recipients).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should handle setting empty recipients", func() {
|
||||
mail.Email().AddRecipients(libsnd.RecipientTo, "test@example.com")
|
||||
mail.Email().SetRecipients(libsnd.RecipientTo)
|
||||
recipients := mail.Email().GetRecipients(libsnd.RecipientTo)
|
||||
Expect(recipients).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Large Values", func() {
|
||||
It("should handle very long subject", func() {
|
||||
longSubject := strings.Repeat("A", 10000)
|
||||
mail.SetSubject(longSubject)
|
||||
Expect(mail.GetSubject()).To(Equal(longSubject))
|
||||
})
|
||||
|
||||
It("should handle large number of recipients", func() {
|
||||
for i := 0; i < 1000; i++ {
|
||||
mail.Email().AddRecipients(libsnd.RecipientTo, "recipient"+string(rune(i))+"@example.com")
|
||||
}
|
||||
recipients := mail.Email().GetRecipients(libsnd.RecipientTo)
|
||||
Expect(len(recipients)).To(BeNumerically(">=", 1))
|
||||
})
|
||||
|
||||
It("should handle large body content", func() {
|
||||
largeContent := strings.Repeat("Content ", 100000)
|
||||
body := newReadCloser(largeContent)
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
bodies := mail.GetBody()
|
||||
Expect(bodies).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should handle many custom headers", func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
mail.AddHeader("X-Custom-"+string(rune(i)), "value")
|
||||
}
|
||||
headers := mail.GetHeaders()
|
||||
Expect(headers).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should handle many attachments", func() {
|
||||
for i := 0; i < 50; i++ {
|
||||
data := newReadCloser("attachment " + string(rune(i)))
|
||||
mail.AddAttachment("file"+string(rune(i))+".txt", "text/plain", data, false)
|
||||
}
|
||||
attachments := mail.GetAttachment(false)
|
||||
Expect(len(attachments)).To(BeNumerically(">=", 1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Special Characters", func() {
|
||||
It("should handle unicode in subject", func() {
|
||||
subject := "Test 🎉 émojis et caractères spéciaux © ® ™ 你好"
|
||||
mail.SetSubject(subject)
|
||||
Expect(mail.GetSubject()).To(Equal(subject))
|
||||
})
|
||||
|
||||
It("should handle unicode in email addresses", func() {
|
||||
// While not technically valid, testing the storage
|
||||
mail.Email().SetFrom("test@exämple.com")
|
||||
Expect(mail.Email().GetFrom()).To(Equal("test@exämple.com"))
|
||||
})
|
||||
|
||||
It("should handle special characters in header values", func() {
|
||||
mail.AddHeader("X-Special", "Value with\nnewline and\ttab")
|
||||
headers := mail.GetHeaders()
|
||||
Expect(headers.Get("X-Special")).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("should handle HTML entities in subject", func() {
|
||||
subject := "Test <html> & entities €"
|
||||
mail.SetSubject(subject)
|
||||
Expect(mail.GetSubject()).To(Equal(subject))
|
||||
})
|
||||
|
||||
It("should handle quotes in subject", func() {
|
||||
subject := `Subject with "quotes" and 'apostrophes'`
|
||||
mail.SetSubject(subject)
|
||||
Expect(mail.GetSubject()).To(Equal(subject))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Boundary Dates", func() {
|
||||
It("should handle zero time", func() {
|
||||
mail.SetDateTime(time.Time{})
|
||||
result := mail.GetDateTime()
|
||||
Expect(result).To(Equal(time.Time{}))
|
||||
})
|
||||
|
||||
It("should handle very old date", func() {
|
||||
oldDate := time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
mail.SetDateTime(oldDate)
|
||||
Expect(mail.GetDateTime()).To(Equal(oldDate))
|
||||
})
|
||||
|
||||
It("should handle far future date", func() {
|
||||
futureDate := time.Date(2100, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
mail.SetDateTime(futureDate)
|
||||
Expect(mail.GetDateTime()).To(Equal(futureDate))
|
||||
})
|
||||
|
||||
It("should handle dates with different timezones", func() {
|
||||
date1 := time.Now().In(time.UTC)
|
||||
mail.SetDateTime(date1)
|
||||
|
||||
date2 := time.Now().In(time.FixedZone("TEST", 3600))
|
||||
mail.SetDateTime(date2)
|
||||
|
||||
result := mail.GetDateTime()
|
||||
Expect(result.Location()).To(Equal(date2.Location()))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Address Fallback Behavior", func() {
|
||||
It("should chain sender fallback correctly", func() {
|
||||
mail.Email().SetReturnPath("return@example.com")
|
||||
Expect(mail.Email().GetSender()).To(Equal("return@example.com"))
|
||||
|
||||
mail.Email().SetReplyTo("reply@example.com")
|
||||
Expect(mail.Email().GetSender()).To(Equal("reply@example.com"))
|
||||
|
||||
mail.Email().SetSender("sender@example.com")
|
||||
Expect(mail.Email().GetSender()).To(Equal("sender@example.com"))
|
||||
})
|
||||
|
||||
It("should chain replyTo fallback correctly", func() {
|
||||
mail.Email().SetReturnPath("return@example.com")
|
||||
Expect(mail.Email().GetReplyTo()).To(Equal("return@example.com"))
|
||||
|
||||
mail.Email().SetSender("sender@example.com")
|
||||
Expect(mail.Email().GetReplyTo()).To(Equal("sender@example.com"))
|
||||
|
||||
mail.Email().SetReplyTo("reply@example.com")
|
||||
Expect(mail.Email().GetReplyTo()).To(Equal("reply@example.com"))
|
||||
})
|
||||
|
||||
It("should chain returnPath fallback correctly", func() {
|
||||
mail.Email().SetReplyTo("reply@example.com")
|
||||
Expect(mail.Email().GetReturnPath()).To(Equal("reply@example.com"))
|
||||
|
||||
mail.Email().SetSender("sender@example.com")
|
||||
Expect(mail.Email().GetReturnPath()).To(Equal("sender@example.com"))
|
||||
|
||||
mail.Email().SetReturnPath("return@example.com")
|
||||
Expect(mail.Email().GetReturnPath()).To(Equal("return@example.com"))
|
||||
})
|
||||
|
||||
It("should return empty when all addresses are empty", func() {
|
||||
Expect(mail.Email().GetSender()).To(Equal(""))
|
||||
Expect(mail.Email().GetReplyTo()).To(Equal(""))
|
||||
Expect(mail.Email().GetReturnPath()).To(Equal(""))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Attachment Edge Cases", func() {
|
||||
It("should handle attachment with empty name", func() {
|
||||
data := newReadCloser("content")
|
||||
mail.AddAttachment("", "text/plain", data, false)
|
||||
attachments := mail.GetAttachment(false)
|
||||
Expect(attachments).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should handle attachment with empty mime", func() {
|
||||
data := newReadCloser("content")
|
||||
mail.AddAttachment("file.txt", "", data, false)
|
||||
attachments := mail.GetAttachment(false)
|
||||
Expect(attachments).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should handle attachment with very long filename", func() {
|
||||
longName := strings.Repeat("a", 1000) + ".txt"
|
||||
data := newReadCloser("content")
|
||||
mail.AddAttachment(longName, "text/plain", data, false)
|
||||
attachments := mail.GetAttachment(false)
|
||||
Expect(attachments).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should handle attachment with special characters in filename", func() {
|
||||
name := "file with spaces and special-chars_123.txt"
|
||||
data := newReadCloser("content")
|
||||
mail.AddAttachment(name, "text/plain", data, false)
|
||||
attachments := mail.GetAttachment(false)
|
||||
Expect(attachments).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should replace inline attachment in attach list if names match", func() {
|
||||
data1 := newReadCloser("first")
|
||||
data2 := newReadCloser("second")
|
||||
|
||||
mail.AddAttachment("same.txt", "text/plain", data1, false)
|
||||
mail.AddAttachment("same.txt", "text/plain", data2, false)
|
||||
|
||||
attachments := mail.GetAttachment(false)
|
||||
Expect(attachments).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Clone Edge Cases", func() {
|
||||
It("should clone mail with no fields set", func() {
|
||||
emptyMail := libsnd.New()
|
||||
cloned := emptyMail.Clone()
|
||||
Expect(cloned).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should clone mail with all fields set", func() {
|
||||
mail.SetSubject("Subject")
|
||||
mail.SetCharset("UTF-8")
|
||||
mail.SetEncoding(libsnd.EncodingBase64)
|
||||
mail.SetPriority(libsnd.PriorityHigh)
|
||||
mail.SetDateTime(time.Now())
|
||||
mail.Email().SetFrom("from@example.com")
|
||||
mail.Email().AddRecipients(libsnd.RecipientTo, "to@example.com")
|
||||
mail.AddHeader("X-Custom", "value")
|
||||
mail.SetBody(libsnd.ContentPlainText, newReadCloser("body"))
|
||||
mail.AddAttachment("file.txt", "text/plain", newReadCloser("data"), false)
|
||||
|
||||
cloned := mail.Clone()
|
||||
Expect(cloned).ToNot(BeNil())
|
||||
Expect(cloned.GetSubject()).To(Equal("Subject"))
|
||||
})
|
||||
|
||||
It("should not share state between original and clone", func() {
|
||||
mail.SetSubject("Original")
|
||||
cloned := mail.Clone()
|
||||
|
||||
mail.SetSubject("Modified")
|
||||
cloned.SetSubject("Clone Modified")
|
||||
|
||||
Expect(mail.GetSubject()).To(Equal("Modified"))
|
||||
Expect(cloned.GetSubject()).To(Equal("Clone Modified"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Header Edge Cases", func() {
|
||||
It("should handle getting non-existent header", func() {
|
||||
values := mail.GetHeader("X-NonExistent")
|
||||
Expect(values).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should handle adding header with multiple values", func() {
|
||||
mail.AddHeader("X-Multi", "value1", "value2", "value3")
|
||||
values := mail.GetHeader("X-Multi")
|
||||
Expect(values).To(HaveLen(3))
|
||||
})
|
||||
|
||||
It("should skip empty header values", func() {
|
||||
mail.AddHeader("X-Test", "value1", "", "value2", "")
|
||||
values := mail.GetHeader("X-Test")
|
||||
Expect(values).To(HaveLen(2))
|
||||
Expect(values).To(ConsistOf("value1", "value2"))
|
||||
})
|
||||
|
||||
It("should handle case-sensitive header names", func() {
|
||||
mail.AddHeader("X-Custom", "value")
|
||||
// Header names are typically case-insensitive in MIME
|
||||
headers := mail.GetHeaders()
|
||||
Expect(headers.Get("X-Custom")).To(Equal("value"))
|
||||
Expect(headers.Get("x-custom")).To(Equal("value"))
|
||||
})
|
||||
|
||||
It("should preserve header order for multiple values", func() {
|
||||
mail.AddHeader("X-Order", "first")
|
||||
mail.AddHeader("X-Order", "second")
|
||||
mail.AddHeader("X-Order", "third")
|
||||
values := mail.GetHeader("X-Order")
|
||||
Expect(values).To(HaveLen(3))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Body Edge Cases", func() {
|
||||
It("should handle AddBody when no body exists", func() {
|
||||
body := newReadCloser("first body")
|
||||
mail.AddBody(libsnd.ContentPlainText, body)
|
||||
bodies := mail.GetBody()
|
||||
Expect(bodies).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should replace body of same content type", func() {
|
||||
body1 := newReadCloser("first")
|
||||
body2 := newReadCloser("second")
|
||||
|
||||
mail.AddBody(libsnd.ContentPlainText, body1)
|
||||
mail.AddBody(libsnd.ContentPlainText, body2)
|
||||
|
||||
bodies := mail.GetBody()
|
||||
Expect(bodies).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should keep bodies of different content types", func() {
|
||||
plainBody := newReadCloser("plain")
|
||||
htmlBody := newReadCloser("html")
|
||||
|
||||
mail.AddBody(libsnd.ContentPlainText, plainBody)
|
||||
mail.AddBody(libsnd.ContentHTML, htmlBody)
|
||||
|
||||
bodies := mail.GetBody()
|
||||
Expect(bodies).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("should handle SetBody multiple times", func() {
|
||||
for i := 0; i < 10; i++ {
|
||||
body := newReadCloser("body " + string(rune(i)))
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
}
|
||||
|
||||
bodies := mail.GetBody()
|
||||
Expect(bodies).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Recipient Deduplication", func() {
|
||||
It("should not add duplicate To recipients", func() {
|
||||
mail.Email().AddRecipients(libsnd.RecipientTo, "same@example.com")
|
||||
mail.Email().AddRecipients(libsnd.RecipientTo, "same@example.com")
|
||||
mail.Email().AddRecipients(libsnd.RecipientTo, "same@example.com")
|
||||
|
||||
recipients := mail.Email().GetRecipients(libsnd.RecipientTo)
|
||||
Expect(recipients).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should not add duplicate Cc recipients", func() {
|
||||
mail.Email().AddRecipients(libsnd.RecipientCC, "same@example.com")
|
||||
mail.Email().AddRecipients(libsnd.RecipientCC, "same@example.com")
|
||||
|
||||
recipients := mail.Email().GetRecipients(libsnd.RecipientCC)
|
||||
Expect(recipients).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should not add duplicate Bcc recipients", func() {
|
||||
mail.Email().AddRecipients(libsnd.RecipientBCC, "same@example.com")
|
||||
mail.Email().AddRecipients(libsnd.RecipientBCC, "same@example.com")
|
||||
|
||||
recipients := mail.Email().GetRecipients(libsnd.RecipientBCC)
|
||||
Expect(recipients).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should allow same address in different recipient types", func() {
|
||||
mail.Email().AddRecipients(libsnd.RecipientTo, "same@example.com")
|
||||
mail.Email().AddRecipients(libsnd.RecipientCC, "same@example.com")
|
||||
mail.Email().AddRecipients(libsnd.RecipientBCC, "same@example.com")
|
||||
|
||||
Expect(mail.Email().GetRecipients(libsnd.RecipientTo)).To(HaveLen(1))
|
||||
Expect(mail.Email().GetRecipients(libsnd.RecipientCC)).To(HaveLen(1))
|
||||
Expect(mail.Email().GetRecipients(libsnd.RecipientBCC)).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Encoding and Priority Defaults", func() {
|
||||
It("should default to EncodingNone", func() {
|
||||
Expect(mail.GetEncoding()).To(Equal(libsnd.EncodingNone))
|
||||
})
|
||||
|
||||
It("should default to PriorityNormal", func() {
|
||||
Expect(mail.GetPriority()).To(Equal(libsnd.PriorityNormal))
|
||||
})
|
||||
|
||||
It("should handle invalid encoding gracefully", func() {
|
||||
invalidEncoding := libsnd.ParseEncoding("invalid")
|
||||
Expect(invalidEncoding).To(Equal(libsnd.EncodingNone))
|
||||
})
|
||||
|
||||
It("should handle invalid priority gracefully", func() {
|
||||
invalidPriority := libsnd.ParsePriority("invalid")
|
||||
Expect(invalidPriority).To(Equal(libsnd.PriorityNormal))
|
||||
})
|
||||
})
|
||||
})
|
||||
178
mail/sender/email.go
Normal file
178
mail/sender/email.go
Normal file
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package sender
|
||||
|
||||
import "slices"
|
||||
|
||||
const (
|
||||
headerFrom = "From"
|
||||
headerSender = "Sender"
|
||||
headerReplyTo = "Reply-To"
|
||||
headerReturnPath = "Return-Path"
|
||||
headerTo = "To"
|
||||
headerCc = "Cc"
|
||||
headerBcc = "Bcc"
|
||||
)
|
||||
|
||||
type email struct {
|
||||
from string
|
||||
sender string
|
||||
replyTo string
|
||||
returnPath string
|
||||
|
||||
to []string
|
||||
cc []string
|
||||
bcc []string
|
||||
}
|
||||
|
||||
func (e *email) SetFrom(mail string) {
|
||||
e.from = mail
|
||||
}
|
||||
|
||||
func (e *email) SetSender(mail string) {
|
||||
e.sender = mail
|
||||
}
|
||||
|
||||
func (e *email) SetReplyTo(mail string) {
|
||||
e.replyTo = mail
|
||||
}
|
||||
|
||||
func (e *email) SetReturnPath(mail string) {
|
||||
e.returnPath = mail
|
||||
}
|
||||
|
||||
func (e *email) GetFrom() string {
|
||||
if e.from != "" {
|
||||
return e.from
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *email) GetSender() string {
|
||||
if e.sender != "" {
|
||||
return e.sender
|
||||
}
|
||||
|
||||
if e.replyTo != "" {
|
||||
return e.replyTo
|
||||
}
|
||||
|
||||
if e.returnPath != "" {
|
||||
return e.returnPath
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *email) GetReplyTo() string {
|
||||
if e.replyTo != "" {
|
||||
return e.replyTo
|
||||
}
|
||||
|
||||
if e.sender != "" {
|
||||
return e.sender
|
||||
}
|
||||
|
||||
if e.returnPath != "" {
|
||||
return e.returnPath
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *email) GetReturnPath() string {
|
||||
if e.returnPath != "" {
|
||||
return e.returnPath
|
||||
}
|
||||
|
||||
if e.sender != "" {
|
||||
return e.sender
|
||||
}
|
||||
|
||||
if e.replyTo != "" {
|
||||
return e.replyTo
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *email) GetRecipients(rt recipientType) []string {
|
||||
switch rt {
|
||||
case RecipientTo:
|
||||
return e.to
|
||||
case RecipientCC:
|
||||
return e.cc
|
||||
case RecipientBCC:
|
||||
return e.bcc
|
||||
}
|
||||
|
||||
return make([]string, 0)
|
||||
}
|
||||
|
||||
func (e *email) SetRecipients(rt recipientType, rcpt ...string) {
|
||||
switch rt {
|
||||
case RecipientTo:
|
||||
e.to = make([]string, 0)
|
||||
case RecipientCC:
|
||||
e.cc = make([]string, 0)
|
||||
case RecipientBCC:
|
||||
e.bcc = make([]string, 0)
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
e.AddRecipients(rt, rcpt...)
|
||||
}
|
||||
|
||||
func (e *email) AddRecipients(rt recipientType, rcpt ...string) {
|
||||
for _, s := range rcpt {
|
||||
switch rt {
|
||||
case RecipientTo:
|
||||
if !slices.Contains(e.to, s) {
|
||||
e.to = append(e.to, s)
|
||||
}
|
||||
case RecipientCC:
|
||||
if !slices.Contains(e.cc, s) {
|
||||
e.cc = append(e.cc, s)
|
||||
}
|
||||
case RecipientBCC:
|
||||
if !slices.Contains(e.bcc, s) {
|
||||
e.bcc = append(e.bcc, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *email) getHeader(h func(key string, values ...string)) {
|
||||
h(headerFrom, e.GetFrom())
|
||||
h(headerSender, e.GetSender())
|
||||
h(headerReplyTo, e.GetReplyTo())
|
||||
h(headerReturnPath, e.GetReturnPath())
|
||||
h(headerTo, e.GetRecipients(RecipientTo)...)
|
||||
h(headerCc, e.GetRecipients(RecipientCC)...)
|
||||
h(headerBcc, e.GetRecipients(RecipientBCC)...)
|
||||
}
|
||||
257
mail/sender/email_test.go
Normal file
257
mail/sender/email_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package sender_test
|
||||
|
||||
import (
|
||||
libsnd "github.com/nabbar/golib/mail/sender"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Email Address Operations", func() {
|
||||
var (
|
||||
mail libsnd.Mail
|
||||
email libsnd.Email
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
mail = newMail()
|
||||
email = mail.Email()
|
||||
})
|
||||
|
||||
Describe("From Address", func() {
|
||||
It("should set and get from address", func() {
|
||||
email.SetFrom("sender@example.com")
|
||||
Expect(email.GetFrom()).To(Equal("sender@example.com"))
|
||||
})
|
||||
|
||||
It("should handle empty from address", func() {
|
||||
email.SetFrom("")
|
||||
Expect(email.GetFrom()).To(Equal(""))
|
||||
})
|
||||
|
||||
It("should override previous from address", func() {
|
||||
email.SetFrom("first@example.com")
|
||||
email.SetFrom("second@example.com")
|
||||
Expect(email.GetFrom()).To(Equal("second@example.com"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Sender Address", func() {
|
||||
It("should set and get sender address", func() {
|
||||
email.SetSender("sender@example.com")
|
||||
Expect(email.GetSender()).To(Equal("sender@example.com"))
|
||||
})
|
||||
|
||||
It("should fallback to replyTo when sender not set", func() {
|
||||
email.SetReplyTo("reply@example.com")
|
||||
Expect(email.GetSender()).To(Equal("reply@example.com"))
|
||||
})
|
||||
|
||||
It("should fallback to returnPath when sender and replyTo not set", func() {
|
||||
email.SetReturnPath("return@example.com")
|
||||
Expect(email.GetSender()).To(Equal("return@example.com"))
|
||||
})
|
||||
|
||||
It("should prefer sender over other addresses", func() {
|
||||
email.SetSender("sender@example.com")
|
||||
email.SetReplyTo("reply@example.com")
|
||||
email.SetReturnPath("return@example.com")
|
||||
Expect(email.GetSender()).To(Equal("sender@example.com"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReplyTo Address", func() {
|
||||
It("should set and get replyTo address", func() {
|
||||
email.SetReplyTo("reply@example.com")
|
||||
Expect(email.GetReplyTo()).To(Equal("reply@example.com"))
|
||||
})
|
||||
|
||||
It("should fallback to sender when replyTo not set", func() {
|
||||
email.SetSender("sender@example.com")
|
||||
Expect(email.GetReplyTo()).To(Equal("sender@example.com"))
|
||||
})
|
||||
|
||||
It("should fallback to returnPath when replyTo and sender not set", func() {
|
||||
email.SetReturnPath("return@example.com")
|
||||
Expect(email.GetReplyTo()).To(Equal("return@example.com"))
|
||||
})
|
||||
|
||||
It("should prefer replyTo over other addresses", func() {
|
||||
email.SetReplyTo("reply@example.com")
|
||||
email.SetSender("sender@example.com")
|
||||
email.SetReturnPath("return@example.com")
|
||||
Expect(email.GetReplyTo()).To(Equal("reply@example.com"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReturnPath Address", func() {
|
||||
It("should set and get returnPath address", func() {
|
||||
email.SetReturnPath("return@example.com")
|
||||
Expect(email.GetReturnPath()).To(Equal("return@example.com"))
|
||||
})
|
||||
|
||||
It("should fallback to sender when returnPath not set", func() {
|
||||
email.SetSender("sender@example.com")
|
||||
Expect(email.GetReturnPath()).To(Equal("sender@example.com"))
|
||||
})
|
||||
|
||||
It("should fallback to replyTo when returnPath and sender not set", func() {
|
||||
email.SetReplyTo("reply@example.com")
|
||||
Expect(email.GetReturnPath()).To(Equal("reply@example.com"))
|
||||
})
|
||||
|
||||
It("should prefer returnPath over other addresses", func() {
|
||||
email.SetReturnPath("return@example.com")
|
||||
email.SetSender("sender@example.com")
|
||||
email.SetReplyTo("reply@example.com")
|
||||
Expect(email.GetReturnPath()).To(Equal("return@example.com"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("To Recipients", func() {
|
||||
It("should add To recipients", func() {
|
||||
email.AddRecipients(libsnd.RecipientTo, "to1@example.com", "to2@example.com")
|
||||
recipients := email.GetRecipients(libsnd.RecipientTo)
|
||||
Expect(recipients).To(HaveLen(2))
|
||||
Expect(recipients).To(ContainElements("to1@example.com", "to2@example.com"))
|
||||
})
|
||||
|
||||
It("should set To recipients (replace all)", func() {
|
||||
email.AddRecipients(libsnd.RecipientTo, "old1@example.com", "old2@example.com")
|
||||
email.SetRecipients(libsnd.RecipientTo, "new1@example.com")
|
||||
recipients := email.GetRecipients(libsnd.RecipientTo)
|
||||
Expect(recipients).To(HaveLen(1))
|
||||
Expect(recipients).To(ContainElement("new1@example.com"))
|
||||
})
|
||||
|
||||
It("should not add duplicate To recipients", func() {
|
||||
email.AddRecipients(libsnd.RecipientTo, "to@example.com")
|
||||
email.AddRecipients(libsnd.RecipientTo, "to@example.com")
|
||||
recipients := email.GetRecipients(libsnd.RecipientTo)
|
||||
Expect(recipients).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should handle empty To recipients", func() {
|
||||
recipients := email.GetRecipients(libsnd.RecipientTo)
|
||||
Expect(recipients).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CC Recipients", func() {
|
||||
It("should add CC recipients", func() {
|
||||
email.AddRecipients(libsnd.RecipientCC, "cc1@example.com", "cc2@example.com")
|
||||
recipients := email.GetRecipients(libsnd.RecipientCC)
|
||||
Expect(recipients).To(HaveLen(2))
|
||||
Expect(recipients).To(ContainElements("cc1@example.com", "cc2@example.com"))
|
||||
})
|
||||
|
||||
It("should set CC recipients (replace all)", func() {
|
||||
email.AddRecipients(libsnd.RecipientCC, "old@example.com")
|
||||
email.SetRecipients(libsnd.RecipientCC, "new@example.com")
|
||||
recipients := email.GetRecipients(libsnd.RecipientCC)
|
||||
Expect(recipients).To(HaveLen(1))
|
||||
Expect(recipients).To(ContainElement("new@example.com"))
|
||||
})
|
||||
|
||||
It("should not add duplicate CC recipients", func() {
|
||||
email.AddRecipients(libsnd.RecipientCC, "cc@example.com")
|
||||
email.AddRecipients(libsnd.RecipientCC, "cc@example.com")
|
||||
recipients := email.GetRecipients(libsnd.RecipientCC)
|
||||
Expect(recipients).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("BCC Recipients", func() {
|
||||
It("should add BCC recipients", func() {
|
||||
email.AddRecipients(libsnd.RecipientBCC, "bcc1@example.com", "bcc2@example.com")
|
||||
recipients := email.GetRecipients(libsnd.RecipientBCC)
|
||||
Expect(recipients).To(HaveLen(2))
|
||||
Expect(recipients).To(ContainElements("bcc1@example.com", "bcc2@example.com"))
|
||||
})
|
||||
|
||||
It("should set BCC recipients (replace all)", func() {
|
||||
email.AddRecipients(libsnd.RecipientBCC, "old@example.com")
|
||||
email.SetRecipients(libsnd.RecipientBCC, "new@example.com")
|
||||
recipients := email.GetRecipients(libsnd.RecipientBCC)
|
||||
Expect(recipients).To(HaveLen(1))
|
||||
Expect(recipients).To(ContainElement("new@example.com"))
|
||||
})
|
||||
|
||||
It("should not add duplicate BCC recipients", func() {
|
||||
email.AddRecipients(libsnd.RecipientBCC, "bcc@example.com")
|
||||
email.AddRecipients(libsnd.RecipientBCC, "bcc@example.com")
|
||||
recipients := email.GetRecipients(libsnd.RecipientBCC)
|
||||
Expect(recipients).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Mixed Recipients", func() {
|
||||
It("should handle all recipient types independently", func() {
|
||||
email.AddRecipients(libsnd.RecipientTo, "to@example.com")
|
||||
email.AddRecipients(libsnd.RecipientCC, "cc@example.com")
|
||||
email.AddRecipients(libsnd.RecipientBCC, "bcc@example.com")
|
||||
|
||||
Expect(email.GetRecipients(libsnd.RecipientTo)).To(HaveLen(1))
|
||||
Expect(email.GetRecipients(libsnd.RecipientCC)).To(HaveLen(1))
|
||||
Expect(email.GetRecipients(libsnd.RecipientBCC)).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should allow same email in different recipient types", func() {
|
||||
email.AddRecipients(libsnd.RecipientTo, "same@example.com")
|
||||
email.AddRecipients(libsnd.RecipientCC, "same@example.com")
|
||||
email.AddRecipients(libsnd.RecipientBCC, "same@example.com")
|
||||
|
||||
Expect(email.GetRecipients(libsnd.RecipientTo)).To(ContainElement("same@example.com"))
|
||||
Expect(email.GetRecipients(libsnd.RecipientCC)).To(ContainElement("same@example.com"))
|
||||
Expect(email.GetRecipients(libsnd.RecipientBCC)).To(ContainElement("same@example.com"))
|
||||
})
|
||||
|
||||
It("should add multiple recipients in one call", func() {
|
||||
email.AddRecipients(libsnd.RecipientTo, "to1@example.com", "to2@example.com", "to3@example.com")
|
||||
recipients := email.GetRecipients(libsnd.RecipientTo)
|
||||
Expect(recipients).To(HaveLen(3))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Integration with Mail Headers", func() {
|
||||
It("should reflect from address in mail headers", func() {
|
||||
email.SetFrom("from@example.com")
|
||||
headers := mail.GetHeaders()
|
||||
Expect(headers.Get("From")).To(Equal("from@example.com"))
|
||||
})
|
||||
|
||||
It("should reflect recipients in mail headers", func() {
|
||||
email.AddRecipients(libsnd.RecipientTo, "to@example.com")
|
||||
email.AddRecipients(libsnd.RecipientCC, "cc@example.com")
|
||||
headers := mail.GetHeaders()
|
||||
Expect(headers["To"]).To(ContainElement("to@example.com"))
|
||||
Expect(headers["Cc"]).To(ContainElement("cc@example.com"))
|
||||
})
|
||||
})
|
||||
})
|
||||
151
mail/sender/encoding.go
Normal file
151
mail/sender/encoding.go
Normal file
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package sender
|
||||
|
||||
import "strings"
|
||||
|
||||
// Encoding defines the transfer encoding method for email body content.
|
||||
//
|
||||
// Transfer encoding is used to represent 8-bit data in a 7-bit environment,
|
||||
// ensuring that email content can be safely transmitted through all SMTP servers.
|
||||
// Different encoding methods offer different trade-offs between size and
|
||||
// readability.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// mail.SetEncoding(sender.EncodingBase64)
|
||||
//
|
||||
// See RFC 2045 for more details on MIME content transfer encodings.
|
||||
type Encoding uint8
|
||||
|
||||
const (
|
||||
// EncodingNone indicates no transfer encoding is applied to the message body.
|
||||
// The content is sent as-is, which is suitable for 7-bit ASCII text.
|
||||
//
|
||||
// Use this for simple ASCII-only emails without special characters.
|
||||
// Not recommended for binary data or text with extended characters.
|
||||
//
|
||||
// Example:
|
||||
// mail.SetEncoding(sender.EncodingNone)
|
||||
EncodingNone Encoding = iota
|
||||
|
||||
// EncodingBinary is functionally equivalent to EncodingNone but explicitly
|
||||
// declares the content as binary data. This encoding is rarely used in practice
|
||||
// as many mail servers don't support true 8-bit binary transmission.
|
||||
//
|
||||
// Use this only if you specifically need to indicate binary content without
|
||||
// actually encoding it.
|
||||
EncodingBinary
|
||||
|
||||
// EncodingBase64 encodes the message body using Base64 encoding (RFC 2045).
|
||||
// This encoding converts binary data into ASCII text using a 64-character alphabet.
|
||||
//
|
||||
// Characteristics:
|
||||
// - Safe for all email servers and intermediate systems
|
||||
// - Increases size by approximately 33%
|
||||
// - Ideal for binary attachments and non-ASCII text
|
||||
// - Most commonly used encoding for email attachments
|
||||
//
|
||||
// Use this for:
|
||||
// - Email attachments (images, documents, etc.)
|
||||
// - HTML content with non-ASCII characters
|
||||
// - When maximum compatibility is required
|
||||
//
|
||||
// Example:
|
||||
// mail.SetEncoding(sender.EncodingBase64)
|
||||
EncodingBase64
|
||||
|
||||
// EncodingQuotedPrintable encodes the message body using Quoted-Printable encoding (RFC 2045).
|
||||
// This encoding represents special characters using "=" followed by their hex value.
|
||||
//
|
||||
// Characteristics:
|
||||
// - Preserves readability for ASCII text
|
||||
// - Minimal size increase for mostly ASCII content
|
||||
// - Efficient for text with occasional special characters
|
||||
// - Lines longer than 76 characters are soft-wrapped
|
||||
//
|
||||
// Use this for:
|
||||
// - Text emails with occasional non-ASCII characters
|
||||
// - HTML emails with mostly ASCII content
|
||||
// - When human readability of encoded content is important
|
||||
//
|
||||
// Example:
|
||||
// mail.SetEncoding(sender.EncodingQuotedPrintable)
|
||||
EncodingQuotedPrintable
|
||||
)
|
||||
|
||||
// String returns a human-readable string representation of the Encoding.
|
||||
//
|
||||
// Returns:
|
||||
// - "None" for EncodingNone
|
||||
// - "Binary" for EncodingBinary
|
||||
// - "Base 64" for EncodingBase64
|
||||
// - "Quoted Printable" for EncodingQuotedPrintable
|
||||
// - Defaults to "None" for unknown values
|
||||
//
|
||||
// This method is useful for logging, configuration display, and debugging.
|
||||
func (e Encoding) String() string {
|
||||
switch e {
|
||||
case EncodingBinary:
|
||||
return "Binary"
|
||||
case EncodingBase64:
|
||||
return "Base 64"
|
||||
case EncodingQuotedPrintable:
|
||||
return "Quoted Printable"
|
||||
case EncodingNone:
|
||||
return "None"
|
||||
}
|
||||
return EncodingNone.String()
|
||||
}
|
||||
|
||||
// ParseEncoding converts a string representation into an Encoding value.
|
||||
// The comparison is case-insensitive for flexibility.
|
||||
//
|
||||
// Parameters:
|
||||
// - s: String representation of the encoding. Valid values are:
|
||||
// "None", "Binary", "Base 64", "Quoted Printable" (case-insensitive)
|
||||
//
|
||||
// Returns:
|
||||
// - The corresponding Encoding value
|
||||
// - EncodingNone if the string doesn't match any known encoding
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// encoding := sender.ParseEncoding("Base 64") // Returns EncodingBase64
|
||||
// encoding := sender.ParseEncoding("base 64") // Also returns EncodingBase64
|
||||
// encoding := sender.ParseEncoding("unknown") // Returns EncodingNone
|
||||
func ParseEncoding(s string) Encoding {
|
||||
switch strings.ToUpper(s) {
|
||||
case strings.ToUpper(EncodingBinary.String()):
|
||||
return EncodingBinary
|
||||
case strings.ToUpper(EncodingBase64.String()):
|
||||
return EncodingBase64
|
||||
case strings.ToUpper(EncodingQuotedPrintable.String()):
|
||||
return EncodingQuotedPrintable
|
||||
default:
|
||||
return EncodingNone
|
||||
}
|
||||
}
|
||||
185
mail/sender/error.go
Normal file
185
mail/sender/error.go
Normal file
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
liberr "github.com/nabbar/golib/errors"
|
||||
)
|
||||
|
||||
const pkgName = "golib/mail"
|
||||
|
||||
// Error codes specific to the mail/sender package.
|
||||
// These codes are used with the github.com/nabbar/golib/errors package
|
||||
// to provide structured, traceable error handling with parent error wrapping.
|
||||
//
|
||||
// All error codes in this package start from liberr.MinPkgMail to avoid
|
||||
// conflicts with other packages in the golib ecosystem.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// if err := config.Validate(); err != nil {
|
||||
// if libErr, ok := err.(liberr.Error); ok {
|
||||
// if libErr.Code() == sender.ErrorMailConfigInvalid {
|
||||
// // Handle configuration error specifically
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// See github.com/nabbar/golib/errors for more information on error handling.
|
||||
const (
|
||||
// ErrorParamEmpty indicates that required parameters were not provided.
|
||||
// This typically occurs when nil or empty values are passed to functions
|
||||
// that require valid input.
|
||||
//
|
||||
// Common causes:
|
||||
// - Passing nil SMTP client to Sender.Send()
|
||||
// - Empty configuration values
|
||||
// - Missing required fields
|
||||
ErrorParamEmpty liberr.CodeError = iota + liberr.MinPkgMail
|
||||
|
||||
// ErrorMailConfigInvalid indicates that the email configuration validation failed.
|
||||
// This error is returned when Config.Validate() detects invalid settings.
|
||||
//
|
||||
// Common causes:
|
||||
// - Missing required fields (Charset, Subject, Encoding, Priority, From)
|
||||
// - Invalid email address format
|
||||
// - Invalid encoding or priority strings
|
||||
//
|
||||
// Check the parent error for specific validation details.
|
||||
ErrorMailConfigInvalid
|
||||
|
||||
// ErrorMailIORead indicates a failure reading data from an io.Reader.
|
||||
// This typically occurs when reading email body content or attachment data.
|
||||
//
|
||||
// Common causes:
|
||||
// - File read errors for attachments
|
||||
// - Network issues when reading from streams
|
||||
// - Closed or invalid readers
|
||||
ErrorMailIORead
|
||||
|
||||
// ErrorMailIOWrite indicates a failure writing data to an io.Writer.
|
||||
// This typically occurs during email message generation.
|
||||
//
|
||||
// Common causes:
|
||||
// - Disk space issues
|
||||
// - Permission errors
|
||||
// - Network failures during transmission
|
||||
ErrorMailIOWrite
|
||||
|
||||
// ErrorMailDateParsing indicates that a date string could not be parsed.
|
||||
// This error occurs in Mail.SetDateString() when the provided date string
|
||||
// doesn't match the specified layout format.
|
||||
//
|
||||
// Common causes:
|
||||
// - Incorrect date format string
|
||||
// - Mismatched layout and date string
|
||||
// - Invalid date values
|
||||
//
|
||||
// Use time.RFC822, time.RFC1123Z, or other standard formats.
|
||||
ErrorMailDateParsing
|
||||
|
||||
// ErrorMailSmtpClient indicates an error communicating with the SMTP server.
|
||||
// This error occurs during SMTP connection checks or send operations.
|
||||
//
|
||||
// Common causes:
|
||||
// - SMTP server unavailable or unreachable
|
||||
// - Network connectivity issues
|
||||
// - Authentication failures
|
||||
// - TLS/SSL handshake errors
|
||||
//
|
||||
// See github.com/nabbar/golib/mail/smtp for SMTP client configuration.
|
||||
ErrorMailSmtpClient
|
||||
|
||||
// ErrorMailSenderInit indicates that the SMTP email sender could not be initialized.
|
||||
// This error occurs in Mail.Sender() when creating the email message structure.
|
||||
//
|
||||
// Common causes:
|
||||
// - Invalid email addresses (malformed format)
|
||||
// - Missing required fields (from, recipients)
|
||||
// - Address format validation failures
|
||||
//
|
||||
// The parent error contains specific details about what validation failed.
|
||||
ErrorMailSenderInit
|
||||
|
||||
// ErrorFileOpenCreate indicates that a file could not be opened or created.
|
||||
// This typically occurs when adding attachments from file paths.
|
||||
//
|
||||
// Common causes:
|
||||
// - File does not exist
|
||||
// - Insufficient permissions
|
||||
// - Invalid file path
|
||||
// - File is locked by another process
|
||||
//
|
||||
// Used in Config.NewMailer() when processing attachment files.
|
||||
ErrorFileOpenCreate
|
||||
)
|
||||
|
||||
// init registers all error codes with the liberr error system.
|
||||
// This ensures that error codes are unique across the entire golib ecosystem
|
||||
// and provides error message lookup functionality.
|
||||
//
|
||||
// Panics if there is an error code collision with another package.
|
||||
func init() {
|
||||
if liberr.ExistInMapMessage(ErrorParamEmpty) {
|
||||
panic(fmt.Errorf("error code collision with package %s", pkgName))
|
||||
}
|
||||
liberr.RegisterIdFctMessage(ErrorParamEmpty, getMessage)
|
||||
}
|
||||
|
||||
// getMessage returns the error message for a given error code.
|
||||
// This function is used by the liberr package to provide human-readable
|
||||
// error messages while maintaining structured error codes.
|
||||
//
|
||||
// Parameters:
|
||||
// - code: The error code to get the message for
|
||||
//
|
||||
// Returns:
|
||||
// - The error message string, or liberr.NullMessage if the code is unknown
|
||||
func getMessage(code liberr.CodeError) (message string) {
|
||||
switch code {
|
||||
case ErrorParamEmpty:
|
||||
return "given parameters is empty"
|
||||
case ErrorMailConfigInvalid:
|
||||
return "config is invalid"
|
||||
case ErrorMailIORead:
|
||||
return "cannot read bytes from io source"
|
||||
case ErrorMailIOWrite:
|
||||
return "cannot write given string to IO resource"
|
||||
case ErrorMailDateParsing:
|
||||
return "error occurs while trying to parse a date string"
|
||||
case ErrorMailSmtpClient:
|
||||
return "error occurs while to checking connection with SMTP server"
|
||||
case ErrorMailSenderInit:
|
||||
return "error occurs while to preparing SMTP Email sender"
|
||||
case ErrorFileOpenCreate:
|
||||
return "cannot open/create file"
|
||||
}
|
||||
|
||||
return liberr.NullMessage
|
||||
}
|
||||
486
mail/sender/errors_test.go
Normal file
486
mail/sender/errors_test.go
Normal file
@@ -0,0 +1,486 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package sender_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
smtpsv "github.com/emersion/go-smtp"
|
||||
liberr "github.com/nabbar/golib/errors"
|
||||
libsnd "github.com/nabbar/golib/mail/sender"
|
||||
libsmtp "github.com/nabbar/golib/mail/smtp"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Error Handling", func() {
|
||||
var (
|
||||
mail libsnd.Mail
|
||||
ctx context.Context
|
||||
cnl context.CancelFunc
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx, cnl = context.WithTimeout(testCtx, 10*time.Second)
|
||||
mail = newMailWithBasicConfig()
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if cnl != nil {
|
||||
cnl()
|
||||
}
|
||||
})
|
||||
|
||||
Describe("Error Code Definitions", func() {
|
||||
It("should have ErrorParamEmpty defined", func() {
|
||||
err := libsnd.ErrorParamEmpty.Error(nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("given parameters is empty"))
|
||||
})
|
||||
|
||||
It("should have ErrorMailConfigInvalid defined", func() {
|
||||
err := libsnd.ErrorMailConfigInvalid.Error(nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("config is invalid"))
|
||||
})
|
||||
|
||||
It("should have ErrorMailIORead defined", func() {
|
||||
err := libsnd.ErrorMailIORead.Error(nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("cannot read bytes from io source"))
|
||||
})
|
||||
|
||||
It("should have ErrorMailIOWrite defined", func() {
|
||||
err := libsnd.ErrorMailIOWrite.Error(nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("cannot write given string to IO resource"))
|
||||
})
|
||||
|
||||
It("should have ErrorMailDateParsing defined", func() {
|
||||
err := libsnd.ErrorMailDateParsing.Error(nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("error occurs while trying to parse a date string"))
|
||||
})
|
||||
|
||||
It("should have ErrorMailSmtpClient defined", func() {
|
||||
err := libsnd.ErrorMailSmtpClient.Error(nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("error occurs while to checking connection with SMTP server"))
|
||||
})
|
||||
|
||||
It("should have ErrorMailSenderInit defined", func() {
|
||||
err := libsnd.ErrorMailSenderInit.Error(nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("error occurs while to preparing SMTP Email sender"))
|
||||
})
|
||||
|
||||
It("should have ErrorFileOpenCreate defined", func() {
|
||||
err := libsnd.ErrorFileOpenCreate.Error(nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("cannot open/create file"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Error Wrapping", func() {
|
||||
It("should wrap parent error in ErrorParamEmpty", func() {
|
||||
parentErr := fmt.Errorf("parent error")
|
||||
err := libsnd.ErrorParamEmpty.Error(parentErr)
|
||||
Expect(err).To(HaveOccurred())
|
||||
// Check that the error is properly created with parent
|
||||
if libErr, ok := err.(liberr.Error); ok {
|
||||
Expect(libErr.HasParent()).To(BeTrue())
|
||||
}
|
||||
})
|
||||
|
||||
It("should wrap parent error in ErrorMailConfigInvalid", func() {
|
||||
parentErr := fmt.Errorf("validation failed")
|
||||
err := libsnd.ErrorMailConfigInvalid.Error(parentErr)
|
||||
Expect(err).To(HaveOccurred())
|
||||
if libErr, ok := err.(liberr.Error); ok {
|
||||
Expect(libErr.HasParent()).To(BeTrue())
|
||||
}
|
||||
})
|
||||
|
||||
It("should wrap parent error in ErrorMailIORead", func() {
|
||||
parentErr := fmt.Errorf("read failed")
|
||||
err := libsnd.ErrorMailIORead.Error(parentErr)
|
||||
Expect(err).To(HaveOccurred())
|
||||
if libErr, ok := err.(liberr.Error); ok {
|
||||
Expect(libErr.HasParent()).To(BeTrue())
|
||||
}
|
||||
})
|
||||
|
||||
It("should wrap parent error in ErrorMailIOWrite", func() {
|
||||
parentErr := fmt.Errorf("write failed")
|
||||
err := libsnd.ErrorMailIOWrite.Error(parentErr)
|
||||
Expect(err).To(HaveOccurred())
|
||||
if libErr, ok := err.(liberr.Error); ok {
|
||||
Expect(libErr.HasParent()).To(BeTrue())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DateTime Parsing Errors", func() {
|
||||
It("should return error for invalid date format", func() {
|
||||
err := mail.SetDateString(time.RFC1123Z, "not-a-date")
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
// Check if it's an error (the specific error code may vary)
|
||||
Expect(err.Error()).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("should return error for mismatched layout", func() {
|
||||
err := mail.SetDateString(time.RFC1123, "Mon, 02 Jan 2006 15:04:05 -0700")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for empty date string", func() {
|
||||
err := mail.SetDateString(time.RFC1123Z, "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for partial date string", func() {
|
||||
err := mail.SetDateString(time.RFC1123Z, "Mon, 02 Jan")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Sender Creation Errors", func() {
|
||||
It("should handle sender creation with minimal config", func() {
|
||||
mail := libsnd.New()
|
||||
mail.Email().SetFrom("from@example.com")
|
||||
mail.Email().AddRecipients(libsnd.RecipientTo, "to@example.com")
|
||||
|
||||
// Even without body, should create sender
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
if sender != nil {
|
||||
defer sender.Close()
|
||||
}
|
||||
})
|
||||
|
||||
It("should create sender even without recipients (validation happens at send)", func() {
|
||||
mail := libsnd.New()
|
||||
mail.Email().SetFrom("from@example.com")
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
if sender != nil {
|
||||
defer sender.Close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Send Operation Errors", func() {
|
||||
var (
|
||||
smtpServer *smtpsv.Server
|
||||
smtpClient libsmtp.SMTP
|
||||
backend *testBackend
|
||||
host string
|
||||
port int
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
backend = &testBackend{requireAuth: false, messages: make([]testMessage, 0)}
|
||||
var err error
|
||||
smtpServer, host, port, err = startTestSMTPServer(backend, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
smtpClient = newTestSMTPClient(host, port)
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if smtpClient != nil {
|
||||
smtpClient.Close()
|
||||
}
|
||||
if smtpServer != nil {
|
||||
_ = smtpServer.Close()
|
||||
}
|
||||
})
|
||||
|
||||
It("should return error when from address is invalid", func() {
|
||||
mail.Email().SetFrom("abc") // Invalid email format
|
||||
body := newReadCloser("test body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
|
||||
// Creating sender should fail with invalid email
|
||||
_, err := mail.Sender()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error when from address is empty", func() {
|
||||
mail.Email().SetFrom("")
|
||||
body := newReadCloser("test body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer sender.Close()
|
||||
|
||||
err = sender.Send(ctx, smtpClient)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error when no recipients", func() {
|
||||
mail.Email().SetRecipients(libsnd.RecipientTo) // Clear all
|
||||
body := newReadCloser("test body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer sender.Close()
|
||||
|
||||
err = sender.Send(ctx, smtpClient)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error when recipient address is invalid", func() {
|
||||
mail.Email().SetRecipients(libsnd.RecipientTo, "abc") // Invalid email
|
||||
body := newReadCloser("test body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
|
||||
// Creating sender should fail with invalid email
|
||||
_, err := mail.Sender()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error when SMTP server is unavailable", func() {
|
||||
// Close the server to simulate connection failure
|
||||
_ = smtpServer.Close()
|
||||
smtpServer = nil
|
||||
|
||||
body := newReadCloser("test body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer sender.Close()
|
||||
|
||||
err = sender.Send(ctx, smtpClient)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Config Validation Errors", func() {
|
||||
It("should return error for missing charset", func() {
|
||||
cfg := libsnd.Config{
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "from@example.com",
|
||||
}
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for missing subject", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "from@example.com",
|
||||
}
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for missing encoding", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Priority: "Normal",
|
||||
From: "from@example.com",
|
||||
}
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for missing priority", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
From: "from@example.com",
|
||||
}
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for missing from", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
}
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for invalid from email", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "not-an-email",
|
||||
}
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for invalid sender email", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "from@example.com",
|
||||
Sender: "not-an-email",
|
||||
}
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for invalid replyTo email", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "from@example.com",
|
||||
ReplyTo: "not-an-email",
|
||||
}
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for invalid To email", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "from@example.com",
|
||||
To: []string{"valid@example.com", "invalid"},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for invalid Cc email", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "from@example.com",
|
||||
Cc: []string{"invalid"},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for invalid Bcc email", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "from@example.com",
|
||||
Bcc: []string{"invalid"},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for non-existent attachment file", func() {
|
||||
cfg := libsnd.Config{
|
||||
Charset: "UTF-8",
|
||||
Subject: "Test",
|
||||
Encoding: "None",
|
||||
Priority: "Normal",
|
||||
From: "from@example.com",
|
||||
Attach: []libsnd.ConfigFile{
|
||||
{
|
||||
Name: "file.txt",
|
||||
Mime: "text/plain",
|
||||
Path: "/non/existent/file.txt",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Validation may pass, but NewMailer should fail
|
||||
_, err := cfg.NewMailer()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Error Code Registration", func() {
|
||||
It("should have all error codes registered", func() {
|
||||
codes := []liberr.CodeError{
|
||||
libsnd.ErrorParamEmpty,
|
||||
libsnd.ErrorMailConfigInvalid,
|
||||
libsnd.ErrorMailIORead,
|
||||
libsnd.ErrorMailIOWrite,
|
||||
libsnd.ErrorMailDateParsing,
|
||||
libsnd.ErrorMailSmtpClient,
|
||||
libsnd.ErrorMailSenderInit,
|
||||
libsnd.ErrorFileOpenCreate,
|
||||
}
|
||||
|
||||
for _, code := range codes {
|
||||
err := code.Error(nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).ToNot(BeEmpty())
|
||||
}
|
||||
})
|
||||
|
||||
It("should have unique error codes", func() {
|
||||
codes := []liberr.CodeError{
|
||||
libsnd.ErrorParamEmpty,
|
||||
libsnd.ErrorMailConfigInvalid,
|
||||
libsnd.ErrorMailIORead,
|
||||
libsnd.ErrorMailIOWrite,
|
||||
libsnd.ErrorMailDateParsing,
|
||||
libsnd.ErrorMailSmtpClient,
|
||||
libsnd.ErrorMailSenderInit,
|
||||
libsnd.ErrorFileOpenCreate,
|
||||
}
|
||||
|
||||
seen := make(map[liberr.CodeError]bool)
|
||||
for _, code := range codes {
|
||||
Expect(seen[code]).To(BeFalse(), "Duplicate error code: %v", code)
|
||||
seen[code] = true
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
84
mail/sender/file.go
Normal file
84
mail/sender/file.go
Normal file
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package sender
|
||||
|
||||
import "io"
|
||||
|
||||
// File represents an attachment or inline file that can be added to an email message.
|
||||
//
|
||||
// Files can be attached to emails in two ways:
|
||||
// - As regular attachments that appear in the attachment list
|
||||
// - As inline attachments embedded in the email body (e.g., images in HTML)
|
||||
//
|
||||
// The file data is provided as an io.ReadCloser, allowing for efficient streaming
|
||||
// of large files and automatic cleanup after the email is sent.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// file, _ := os.Open("document.pdf")
|
||||
// attachment := sender.NewFile("document.pdf", "application/pdf", file)
|
||||
// mail.AddAttachment("document.pdf", "application/pdf", file, false)
|
||||
//
|
||||
// See Mail.AddAttachment for adding files to an email.
|
||||
type File struct {
|
||||
name string // Filename as it will appear in the email
|
||||
mime string // MIME type of the file (e.g., "application/pdf", "image/png")
|
||||
data io.ReadCloser // File content as a readable stream
|
||||
}
|
||||
|
||||
// NewFile creates a new File instance representing an email attachment.
|
||||
//
|
||||
// Parameters:
|
||||
// - name: The filename as it should appear in the email (e.g., "report.pdf")
|
||||
// - mime: The MIME type of the file (e.g., "application/pdf", "image/png", "text/plain")
|
||||
// - data: An io.ReadCloser providing the file content. The caller is responsible
|
||||
// for opening the file, but the email sender will handle closing it.
|
||||
//
|
||||
// Returns a configured File instance that can be added to a Mail object.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// file, err := os.Open("photo.jpg")
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// attachment := sender.NewFile("photo.jpg", "image/jpeg", file)
|
||||
// mail.AddAttachment("photo.jpg", "image/jpeg", file, false)
|
||||
//
|
||||
// Common MIME types:
|
||||
// - "text/plain" - Plain text files
|
||||
// - "text/html" - HTML files
|
||||
// - "application/pdf" - PDF documents
|
||||
// - "image/jpeg", "image/png", "image/gif" - Images
|
||||
// - "application/zip" - ZIP archives
|
||||
// - "application/octet-stream" - Generic binary data
|
||||
func NewFile(name string, mime string, data io.ReadCloser) File {
|
||||
return File{
|
||||
name: name,
|
||||
mime: mime,
|
||||
data: data,
|
||||
}
|
||||
}
|
||||
548
mail/sender/interface.go
Normal file
548
mail/sender/interface.go
Normal file
@@ -0,0 +1,548 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
// Package sender provides a high-level API for composing and sending emails via SMTP.
|
||||
//
|
||||
// This package simplifies email composition by providing an intuitive interface for:
|
||||
// - Setting email headers (subject, priority, encoding, etc.)
|
||||
// - Managing recipients (To, CC, BCC)
|
||||
// - Adding body content (plain text and/or HTML)
|
||||
// - Attaching files (regular and inline attachments)
|
||||
// - Sending emails through SMTP servers
|
||||
//
|
||||
// # Basic Usage
|
||||
//
|
||||
// Creating and sending a simple email:
|
||||
//
|
||||
// import (
|
||||
// "github.com/nabbar/golib/mail/sender"
|
||||
// "github.com/nabbar/golib/mail/smtp"
|
||||
// "strings"
|
||||
// )
|
||||
//
|
||||
// // Create a new email
|
||||
// mail := sender.New()
|
||||
// mail.SetSubject("Hello World")
|
||||
// mail.SetCharset("UTF-8")
|
||||
// mail.SetEncoding(sender.EncodingBase64)
|
||||
// mail.SetPriority(sender.PriorityNormal)
|
||||
//
|
||||
// // Set sender and recipients
|
||||
// mail.Email().SetFrom("sender@example.com")
|
||||
// mail.Email().AddRecipients(sender.RecipientTo, "recipient@example.com")
|
||||
//
|
||||
// // Add body content
|
||||
// body := strings.NewReader("This is the email body")
|
||||
// mail.SetBody(sender.ContentPlainText, io.NopCloser(body))
|
||||
//
|
||||
// // Send the email
|
||||
// smtpClient, _ := smtp.New(smtpConfig, nil)
|
||||
// sender, _ := mail.Sender()
|
||||
// err := sender.SendClose(ctx, smtpClient)
|
||||
//
|
||||
// # Configuration-Based Usage
|
||||
//
|
||||
// Create emails from configuration:
|
||||
//
|
||||
// config := sender.Config{
|
||||
// Charset: "UTF-8",
|
||||
// Subject: "Welcome",
|
||||
// Encoding: "Base 64",
|
||||
// Priority: "Normal",
|
||||
// From: "noreply@example.com",
|
||||
// To: []string{"user@example.com"},
|
||||
// }
|
||||
//
|
||||
// mail, err := config.NewMailer()
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// # Advanced Features
|
||||
//
|
||||
// Multiple body parts (text + HTML):
|
||||
//
|
||||
// mail.SetBody(sender.ContentPlainText, io.NopCloser(strings.NewReader("Plain text version")))
|
||||
// mail.AddBody(sender.ContentHTML, io.NopCloser(strings.NewReader("<p>HTML version</p>")))
|
||||
//
|
||||
// File attachments:
|
||||
//
|
||||
// file, _ := os.Open("document.pdf")
|
||||
// mail.AddAttachment("document.pdf", "application/pdf", file, false)
|
||||
//
|
||||
// Inline attachments (for HTML emails):
|
||||
//
|
||||
// image, _ := os.Open("logo.png")
|
||||
// mail.AddAttachment("logo.png", "image/png", image, true)
|
||||
//
|
||||
// # SMTP Integration
|
||||
//
|
||||
// This package integrates with github.com/nabbar/golib/mail/smtp for sending emails.
|
||||
// See that package for SMTP client configuration, TLS settings, and authentication.
|
||||
//
|
||||
// # Error Handling
|
||||
//
|
||||
// All errors implement github.com/nabbar/golib/errors.Error interface, providing:
|
||||
// - Structured error codes
|
||||
// - Parent error wrapping
|
||||
// - Stack traces
|
||||
//
|
||||
// See error.go for specific error codes and their meanings.
|
||||
//
|
||||
// # Thread Safety
|
||||
//
|
||||
// Mail and Email objects are NOT thread-safe. Create separate instances
|
||||
// for concurrent operations, or use the Clone() method:
|
||||
//
|
||||
// mail1 := mail.Clone()
|
||||
// mail2 := mail.Clone()
|
||||
// // mail1 and mail2 can now be used independently
|
||||
package sender
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/textproto"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mail defines the main interface for composing email messages.
|
||||
//
|
||||
// This interface provides methods for setting all aspects of an email, including:
|
||||
// - Metadata (charset, subject, priority, encoding, date)
|
||||
// - Headers (custom and standard)
|
||||
// - Body content (plain text and/or HTML)
|
||||
// - Attachments (regular and inline)
|
||||
// - Recipients and sender information (via Email interface)
|
||||
//
|
||||
// Create a new Mail instance using New():
|
||||
//
|
||||
// mail := sender.New()
|
||||
//
|
||||
// Mail objects are not thread-safe. Use Clone() for concurrent operations.
|
||||
type Mail interface {
|
||||
// Clone creates a deep copy of the Mail object.
|
||||
// The cloned mail can be modified independently without affecting the original.
|
||||
//
|
||||
// This is useful for:
|
||||
// - Sending similar emails to different recipients
|
||||
// - Concurrent email operations
|
||||
// - Template-based email generation
|
||||
//
|
||||
// Returns a new Mail instance with all fields copied.
|
||||
Clone() Mail
|
||||
|
||||
// SetCharset sets the character encoding for the email (e.g., "UTF-8", "ISO-8859-1").
|
||||
// Default is "UTF-8", which supports all Unicode characters.
|
||||
//
|
||||
// Common charsets:
|
||||
// - "UTF-8" (recommended, universal support)
|
||||
// - "ISO-8859-1" (Western European)
|
||||
// - "ISO-8859-15" (Western European with Euro symbol)
|
||||
SetCharset(charset string)
|
||||
|
||||
// GetCharset returns the current character encoding setting.
|
||||
GetCharset() string
|
||||
|
||||
// SetPriority sets the urgency level of the email.
|
||||
// See Priority type for available values (Normal, Low, High).
|
||||
//
|
||||
// Example:
|
||||
// mail.SetPriority(sender.PriorityHigh)
|
||||
SetPriority(p Priority)
|
||||
|
||||
// GetPriority returns the current priority setting.
|
||||
GetPriority() Priority
|
||||
|
||||
// SetSubject sets the email subject line.
|
||||
// The subject appears in the recipient's inbox and email client.
|
||||
//
|
||||
// Example:
|
||||
// mail.SetSubject("Monthly Report - January 2024")
|
||||
SetSubject(subject string)
|
||||
|
||||
// GetSubject returns the current subject line.
|
||||
GetSubject() string
|
||||
|
||||
// SetEncoding sets the transfer encoding for the email body.
|
||||
// See Encoding type for available values (None, Binary, Base64, QuotedPrintable).
|
||||
//
|
||||
// Recommended encodings:
|
||||
// - EncodingBase64: For binary data and non-ASCII text
|
||||
// - EncodingQuotedPrintable: For mostly ASCII text with occasional special characters
|
||||
//
|
||||
// Example:
|
||||
// mail.SetEncoding(sender.EncodingBase64)
|
||||
SetEncoding(enc Encoding)
|
||||
|
||||
// GetEncoding returns the current transfer encoding setting.
|
||||
GetEncoding() Encoding
|
||||
|
||||
// SetDateTime sets the email date/time using a time.Time value.
|
||||
// If not set, the sending time will be used.
|
||||
//
|
||||
// Example:
|
||||
// mail.SetDateTime(time.Now())
|
||||
SetDateTime(datetime time.Time)
|
||||
|
||||
// GetDateTime returns the current date/time setting.
|
||||
// Returns zero time if not explicitly set.
|
||||
GetDateTime() time.Time
|
||||
|
||||
// SetDateString parses and sets the email date/time from a string.
|
||||
// The layout parameter follows time.Parse format (e.g., time.RFC1123Z).
|
||||
//
|
||||
// Returns ErrorMailDateParsing if the string cannot be parsed.
|
||||
//
|
||||
// Example:
|
||||
// err := mail.SetDateString(time.RFC1123Z, "Mon, 02 Jan 2006 15:04:05 -0700")
|
||||
SetDateString(layout, datetime string) error
|
||||
|
||||
// GetDateString returns the date/time as a formatted string.
|
||||
// Returns empty string if date is not set.
|
||||
GetDateString() string
|
||||
|
||||
// AddHeader adds a custom header to the email.
|
||||
// Multiple values can be added for the same header key.
|
||||
//
|
||||
// Standard headers (To, From, Subject, etc.) are set via other methods.
|
||||
// Use this for custom headers like "X-Custom-ID", "Reply-To", etc.
|
||||
//
|
||||
// Example:
|
||||
// mail.AddHeader("X-Campaign-ID", "2024-Q1-Newsletter")
|
||||
// mail.AddHeader("X-Mailer", "MyApp/1.0")
|
||||
AddHeader(key string, values ...string)
|
||||
|
||||
// GetHeader retrieves all values for a specific header key.
|
||||
// Returns empty slice if the header doesn't exist.
|
||||
GetHeader(key string) []string
|
||||
|
||||
// GetHeaders returns all email headers as a textproto.MIMEHeader.
|
||||
// This includes both custom headers and standard headers set by the package.
|
||||
GetHeaders() textproto.MIMEHeader
|
||||
|
||||
// SetBody sets the email body, replacing any existing body of the same content type.
|
||||
// The body is provided as an io.ReadCloser for efficient streaming.
|
||||
//
|
||||
// Parameters:
|
||||
// - ct: Content type (ContentPlainText or ContentHTML)
|
||||
// - body: Body content as an io.ReadCloser
|
||||
//
|
||||
// Example:
|
||||
// body := io.NopCloser(strings.NewReader("Email content"))
|
||||
// mail.SetBody(sender.ContentPlainText, body)
|
||||
SetBody(ct ContentType, body io.ReadCloser)
|
||||
|
||||
// AddBody adds an additional body part without removing existing ones.
|
||||
// Use this to provide both plain text and HTML versions of the email.
|
||||
//
|
||||
// Best practice: Always include a plain text version for maximum compatibility.
|
||||
//
|
||||
// Example:
|
||||
// mail.SetBody(sender.ContentPlainText, plainBody)
|
||||
// mail.AddBody(sender.ContentHTML, htmlBody)
|
||||
AddBody(ct ContentType, body io.ReadCloser)
|
||||
|
||||
// GetBody returns all body parts added to the email.
|
||||
GetBody() []Body
|
||||
|
||||
// SetAttachment sets or replaces an attachment with the given name.
|
||||
// If an attachment with the same name exists, it will be replaced.
|
||||
//
|
||||
// Parameters:
|
||||
// - name: Filename as it appears in the email
|
||||
// - mime: MIME type (e.g., "application/pdf", "image/png")
|
||||
// - data: File content as io.ReadCloser
|
||||
// - inline: true for inline attachments (embedded in HTML), false for regular attachments
|
||||
//
|
||||
// The data ReadCloser will be closed automatically after sending.
|
||||
SetAttachment(name string, mime string, data io.ReadCloser, inline bool)
|
||||
|
||||
// AddAttachment adds an attachment without checking for duplicates.
|
||||
// Multiple attachments with the same name can exist.
|
||||
//
|
||||
// Parameters match SetAttachment.
|
||||
//
|
||||
// Example:
|
||||
// file, _ := os.Open("report.pdf")
|
||||
// mail.AddAttachment("report.pdf", "application/pdf", file, false)
|
||||
AddAttachment(name string, mime string, data io.ReadCloser, inline bool)
|
||||
|
||||
// AttachFile is a convenience method for adding file attachments.
|
||||
// The filename is extracted from the filepath parameter.
|
||||
//
|
||||
// Deprecated: Use AddAttachment directly for better control over the filename.
|
||||
AttachFile(filepath string, data io.ReadCloser, inline bool)
|
||||
|
||||
// GetAttachment returns all attachments of the specified type.
|
||||
//
|
||||
// Parameters:
|
||||
// - inline: true to get inline attachments, false for regular attachments
|
||||
//
|
||||
// Returns a slice of File objects.
|
||||
GetAttachment(inline bool) []File
|
||||
|
||||
// Email returns the Email interface for managing sender and recipient addresses.
|
||||
// Use this to set From, To, CC, BCC, ReplyTo, and other address fields.
|
||||
//
|
||||
// Example:
|
||||
// mail.Email().SetFrom("sender@example.com")
|
||||
// mail.Email().AddRecipients(sender.RecipientTo, "user@example.com")
|
||||
Email() Email
|
||||
|
||||
// Sender creates a Sender instance that can send the email via SMTP.
|
||||
//
|
||||
// This method validates the email structure (addresses, recipients) and
|
||||
// prepares it for transmission. Returns ErrorMailSenderInit if validation fails.
|
||||
//
|
||||
// Example:
|
||||
// sender, err := mail.Sender()
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// defer sender.Close()
|
||||
// err = sender.Send(ctx, smtpClient)
|
||||
//
|
||||
// Returns a Sender instance and nil error on success, or nil and an error on failure.
|
||||
Sender() (Sender, error)
|
||||
}
|
||||
|
||||
// New creates a new Mail instance with default settings.
|
||||
//
|
||||
// The returned Mail is initialized with:
|
||||
// - Charset: UTF-8
|
||||
// - Encoding: EncodingNone
|
||||
// - MIME-Version: 1.0 header
|
||||
// - Empty recipient lists
|
||||
// - No attachments or body content
|
||||
//
|
||||
// After creation, configure the email by setting:
|
||||
// - Subject, priority, and encoding
|
||||
// - Sender and recipient addresses (via Email())
|
||||
// - Body content and attachments
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// mail := sender.New()
|
||||
// mail.SetSubject("Welcome")
|
||||
// mail.SetEncoding(sender.EncodingBase64)
|
||||
// mail.Email().SetFrom("noreply@example.com")
|
||||
// mail.Email().AddRecipients(sender.RecipientTo, "user@example.com")
|
||||
//
|
||||
// Returns a new Mail instance ready for configuration.
|
||||
func New() Mail {
|
||||
m := &mail{
|
||||
headers: make(textproto.MIMEHeader),
|
||||
charset: "UTF-8",
|
||||
encoding: EncodingNone,
|
||||
address: &email{
|
||||
from: "",
|
||||
sender: "",
|
||||
replyTo: "",
|
||||
returnPath: "",
|
||||
to: make([]string, 0),
|
||||
cc: make([]string, 0),
|
||||
bcc: make([]string, 0),
|
||||
},
|
||||
attach: make([]File, 0),
|
||||
inline: make([]File, 0),
|
||||
body: make([]Body, 0),
|
||||
}
|
||||
|
||||
m.headers.Set("MIME-Version", "1.0")
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// Clone creates a deep copy of the mail object.
|
||||
// Implementation of the Mail.Clone() interface method.
|
||||
//
|
||||
// All fields are copied including:
|
||||
// - Headers, charset, subject, date
|
||||
// - Priority and encoding settings
|
||||
// - Email addresses (from, sender, replyTo, returnPath, recipients)
|
||||
// - Attachments and inline files
|
||||
// - Body content
|
||||
//
|
||||
// Note: File readers (attachments and body) are shared between the original
|
||||
// and clone. If you need independent file access, reopen the files.
|
||||
//
|
||||
// Returns a new Mail instance with copied values.
|
||||
func (m *mail) Clone() Mail {
|
||||
return &mail{
|
||||
date: m.date,
|
||||
attach: m.attach,
|
||||
inline: m.inline,
|
||||
body: m.body,
|
||||
charset: m.charset,
|
||||
subject: m.subject,
|
||||
headers: m.headers,
|
||||
address: &email{
|
||||
from: m.address.from,
|
||||
sender: m.address.sender,
|
||||
replyTo: m.address.replyTo,
|
||||
returnPath: m.address.returnPath,
|
||||
to: m.address.to,
|
||||
cc: m.address.cc,
|
||||
bcc: m.address.bcc,
|
||||
},
|
||||
encoding: m.encoding,
|
||||
priority: m.priority,
|
||||
}
|
||||
}
|
||||
|
||||
// Email defines the interface for managing email addresses.
|
||||
//
|
||||
// This interface handles all address-related operations:
|
||||
// - From: The sender's email address (required)
|
||||
// - Sender: Optional explicit sender (if different from From)
|
||||
// - ReplyTo: Where replies should be sent (defaults to From)
|
||||
// - ReturnPath: Bounce address (defaults to From)
|
||||
// - Recipients: To, CC, and BCC recipient lists
|
||||
//
|
||||
// Access via Mail.Email():
|
||||
//
|
||||
// mail.Email().SetFrom("sender@example.com")
|
||||
// mail.Email().AddRecipients(sender.RecipientTo, "user1@example.com", "user2@example.com")
|
||||
// mail.Email().AddRecipients(sender.RecipientCC, "manager@example.com")
|
||||
//
|
||||
// # Address Fallback Behavior
|
||||
//
|
||||
// If optional address fields are not set, they fall back to other addresses:
|
||||
// - Sender: Falls back to ReplyTo, then ReturnPath, then From
|
||||
// - ReplyTo: Falls back to Sender, then ReturnPath, then From
|
||||
// - ReturnPath: Falls back to ReplyTo, then Sender, then From
|
||||
//
|
||||
// This ensures that email headers always have valid addresses even if
|
||||
// only From is explicitly set.
|
||||
type Email interface {
|
||||
// SetFrom sets the primary sender email address.
|
||||
// This is the address that appears in the "From" header and is required.
|
||||
//
|
||||
// The From address is used as a fallback for Sender, ReplyTo, and ReturnPath
|
||||
// if those fields are not explicitly set.
|
||||
//
|
||||
// Example:
|
||||
// mail.Email().SetFrom("noreply@example.com")
|
||||
SetFrom(mail string)
|
||||
|
||||
// GetFrom returns the From email address.
|
||||
GetFrom() string
|
||||
|
||||
// SetSender sets the actual sender email address if different from From.
|
||||
// This appears in the "Sender" header and is used when sending on behalf of someone.
|
||||
//
|
||||
// Use cases:
|
||||
// - Mailing list managers sending on behalf of list members
|
||||
// - Automated systems sending on behalf of users
|
||||
// - Delegation scenarios
|
||||
//
|
||||
// If not set, falls back to ReplyTo, then ReturnPath, then From.
|
||||
//
|
||||
// Example:
|
||||
// mail.Email().SetFrom("user@example.com")
|
||||
// mail.Email().SetSender("system@example.com") // System sending for user
|
||||
SetSender(mail string)
|
||||
|
||||
// GetSender returns the Sender address, with fallback to other addresses if not set.
|
||||
GetSender() string
|
||||
|
||||
// SetReplyTo sets where replies to this email should be sent.
|
||||
// This appears in the "Reply-To" header.
|
||||
//
|
||||
// Use cases:
|
||||
// - Directing replies to a different address than From
|
||||
// - Reply-to addresses for no-reply emails
|
||||
// - Department or team email addresses
|
||||
//
|
||||
// If not set, falls back to Sender, then ReturnPath, then From.
|
||||
//
|
||||
// Example:
|
||||
// mail.Email().SetFrom("noreply@example.com")
|
||||
// mail.Email().SetReplyTo("support@example.com") // Replies go to support
|
||||
SetReplyTo(mail string)
|
||||
|
||||
// GetReplyTo returns the ReplyTo address, with fallback to other addresses if not set.
|
||||
GetReplyTo() string
|
||||
|
||||
// SetReturnPath sets the return path (bounce address) for the email.
|
||||
// This is used for delivery failure notifications.
|
||||
//
|
||||
// Use cases:
|
||||
// - Dedicated bounce processing addresses
|
||||
// - Separating bounce handling from regular email
|
||||
// - Email deliverability monitoring
|
||||
//
|
||||
// If not set, falls back to ReplyTo, then Sender, then From.
|
||||
//
|
||||
// Example:
|
||||
// mail.Email().SetFrom("noreply@example.com")
|
||||
// mail.Email().SetReturnPath("bounces@example.com") // Bounces go here
|
||||
SetReturnPath(mail string)
|
||||
|
||||
// GetReturnPath returns the ReturnPath address, with fallback to other addresses if not set.
|
||||
GetReturnPath() string
|
||||
|
||||
// SetRecipients replaces all recipients of the specified type with the provided list.
|
||||
// This removes any existing recipients of that type.
|
||||
//
|
||||
// Use this when you want to completely replace the recipient list.
|
||||
// For adding to existing recipients, use AddRecipients instead.
|
||||
//
|
||||
// Parameters:
|
||||
// - rt: Recipient type (RecipientTo, RecipientCC, or RecipientBCC)
|
||||
// - rcpt: Email addresses (can be empty to clear all recipients)
|
||||
//
|
||||
// Example:
|
||||
// mail.Email().SetRecipients(sender.RecipientTo, "user1@example.com", "user2@example.com")
|
||||
// mail.Email().SetRecipients(sender.RecipientCC) // Clear all CC recipients
|
||||
SetRecipients(rt recipientType, rcpt ...string)
|
||||
|
||||
// AddRecipients adds recipients to the specified type without removing existing ones.
|
||||
// Duplicate addresses are automatically prevented.
|
||||
//
|
||||
// Use this when you want to add recipients while preserving existing ones.
|
||||
//
|
||||
// Parameters:
|
||||
// - rt: Recipient type (RecipientTo, RecipientCC, or RecipientBCC)
|
||||
// - rcpt: Email addresses to add
|
||||
//
|
||||
// Example:
|
||||
// mail.Email().AddRecipients(sender.RecipientTo, "user@example.com")
|
||||
// mail.Email().AddRecipients(sender.RecipientTo, "another@example.com") // Both preserved
|
||||
// mail.Email().AddRecipients(sender.RecipientCC, "manager@example.com")
|
||||
AddRecipients(rt recipientType, rcpt ...string)
|
||||
|
||||
// GetRecipients returns all recipients of the specified type.
|
||||
//
|
||||
// Parameters:
|
||||
// - rt: Recipient type (RecipientTo, RecipientCC, or RecipientBCC)
|
||||
//
|
||||
// Returns a slice of email addresses, or empty slice if none exist.
|
||||
//
|
||||
// Example:
|
||||
// toList := mail.Email().GetRecipients(sender.RecipientTo)
|
||||
// ccList := mail.Email().GetRecipients(sender.RecipientCC)
|
||||
GetRecipients(rt recipientType) []string
|
||||
}
|
||||
261
mail/sender/mail.go
Normal file
261
mail/sender/mail.go
Normal file
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"io"
|
||||
mime2 "mime"
|
||||
"net/textproto"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DateTimeLayout = time.RFC1123Z
|
||||
|
||||
mimeDownload = "application/octet-stream"
|
||||
headerMimeVersion = "MIME-Version"
|
||||
headerDate = "Date"
|
||||
headerSubject = "Subject"
|
||||
)
|
||||
|
||||
type mail struct {
|
||||
date time.Time
|
||||
attach []File
|
||||
inline []File
|
||||
body []Body
|
||||
charset string
|
||||
subject string
|
||||
headers textproto.MIMEHeader
|
||||
address *email
|
||||
encoding Encoding
|
||||
priority Priority
|
||||
}
|
||||
|
||||
func (m *mail) Email() Email {
|
||||
return m.address
|
||||
}
|
||||
|
||||
func (m *mail) SetCharset(charset string) {
|
||||
m.charset = charset
|
||||
}
|
||||
|
||||
func (m *mail) GetCharset() string {
|
||||
return m.charset
|
||||
}
|
||||
|
||||
func (m *mail) SetPriority(p Priority) {
|
||||
m.priority = p
|
||||
}
|
||||
|
||||
func (m *mail) GetPriority() Priority {
|
||||
return m.priority
|
||||
}
|
||||
|
||||
func (m *mail) SetSubject(subject string) {
|
||||
m.subject = subject
|
||||
}
|
||||
|
||||
func (m *mail) GetSubject() string {
|
||||
return m.subject
|
||||
}
|
||||
|
||||
func (m *mail) SetEncoding(enc Encoding) {
|
||||
m.encoding = enc
|
||||
}
|
||||
|
||||
func (m *mail) GetEncoding() Encoding {
|
||||
return m.encoding
|
||||
}
|
||||
|
||||
func (m *mail) SetDateTime(datetime time.Time) {
|
||||
m.date = datetime
|
||||
}
|
||||
|
||||
func (m *mail) GetDateTime() time.Time {
|
||||
return m.date
|
||||
}
|
||||
|
||||
func (m *mail) SetDateString(layout, datetime string) error {
|
||||
if t, e := time.Parse(layout, datetime); e != nil {
|
||||
return ErrorMailDateParsing.Error(e)
|
||||
} else {
|
||||
m.date = t
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mail) GetDateString() string {
|
||||
return m.date.Format(DateTimeLayout)
|
||||
}
|
||||
|
||||
func (m *mail) AddHeader(key string, values ...string) {
|
||||
m.headers = m.addHeader(m.headers, key, values...)
|
||||
}
|
||||
|
||||
func (m *mail) addHeader(h textproto.MIMEHeader, key string, values ...string) textproto.MIMEHeader {
|
||||
for _, v := range values {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(h.Values(key)) > 0 {
|
||||
h.Add(key, v)
|
||||
} else {
|
||||
h.Set(key, v)
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *mail) GetHeader(key string) []string {
|
||||
switch key {
|
||||
case headerMimeVersion:
|
||||
return []string{"1.0"}
|
||||
case headerDate:
|
||||
return []string{m.GetDateString()}
|
||||
case headerSubject:
|
||||
return []string{m.GetSubject()}
|
||||
case headerPriority:
|
||||
return []string{m.priority.headerPriority()}
|
||||
case headerMSMailPriority:
|
||||
return []string{m.priority.headerMSMailPriority()}
|
||||
case headerImportance:
|
||||
return []string{m.priority.headerImportance()}
|
||||
case headerFrom:
|
||||
return []string{m.address.GetFrom()}
|
||||
case headerSender:
|
||||
return []string{m.address.GetSender()}
|
||||
case headerReplyTo:
|
||||
return []string{m.address.GetReplyTo()}
|
||||
case headerReturnPath:
|
||||
return []string{m.address.GetReturnPath()}
|
||||
case headerTo:
|
||||
return m.address.GetRecipients(RecipientTo)
|
||||
case headerCc:
|
||||
return m.address.GetRecipients(RecipientCC)
|
||||
case headerBcc:
|
||||
return m.address.GetRecipients(RecipientBCC)
|
||||
|
||||
}
|
||||
|
||||
return m.headers.Values(key)
|
||||
}
|
||||
|
||||
func (m *mail) GetHeaders() textproto.MIMEHeader {
|
||||
h := make(textproto.MIMEHeader)
|
||||
h.Set(headerMimeVersion, "1.0")
|
||||
h.Set(headerDate, m.GetDateString())
|
||||
h.Set(headerSubject, m.GetSubject())
|
||||
|
||||
m.priority.getHeader(func(key string, values ...string) {
|
||||
h = m.addHeader(h, key, values...)
|
||||
})
|
||||
|
||||
m.address.getHeader(func(key string, values ...string) {
|
||||
h = m.addHeader(h, key, values...)
|
||||
})
|
||||
|
||||
for k := range m.headers {
|
||||
h = m.addHeader(h, k, m.headers.Values(k)...)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *mail) SetBody(ct ContentType, body io.ReadCloser) {
|
||||
m.body = make([]Body, 0)
|
||||
m.body = append(m.body, NewBody(ct, body))
|
||||
}
|
||||
|
||||
func (m *mail) AddBody(ct ContentType, body io.ReadCloser) {
|
||||
for i, b := range m.body {
|
||||
if b.contentType == ct {
|
||||
m.body[i] = NewBody(ct, body)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m.body = append(m.body, NewBody(ct, body))
|
||||
}
|
||||
|
||||
func (m *mail) GetBody() []Body {
|
||||
return m.body
|
||||
}
|
||||
|
||||
func (m *mail) SetAttachment(name string, mime string, data io.ReadCloser, inline bool) {
|
||||
if inline {
|
||||
m.inline = make([]File, 0)
|
||||
m.inline = append(m.inline, NewFile(name, mime, data))
|
||||
} else {
|
||||
m.attach = make([]File, 0)
|
||||
m.attach = append(m.attach, NewFile(name, mime, data))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mail) AddAttachment(name string, mime string, data io.ReadCloser, inline bool) {
|
||||
if inline {
|
||||
|
||||
for i, f := range m.attach {
|
||||
if name == f.name {
|
||||
m.inline[i] = NewFile(name, mime, data)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m.inline = append(m.inline, NewFile(name, mime, data))
|
||||
|
||||
} else {
|
||||
|
||||
for i, f := range m.attach {
|
||||
if name == f.name {
|
||||
m.attach[i] = NewFile(name, mime, data)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m.attach = append(m.attach, NewFile(name, mime, data))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mail) AttachFile(filePath string, data io.ReadCloser, inline bool) {
|
||||
mime := mime2.TypeByExtension(filepath.Ext(filePath))
|
||||
|
||||
if mime == "" {
|
||||
mime = mimeDownload
|
||||
}
|
||||
|
||||
m.AddAttachment(filepath.Base(filePath), mime, data, inline)
|
||||
}
|
||||
|
||||
func (m *mail) GetAttachment(inline bool) []File {
|
||||
if inline {
|
||||
return m.inline
|
||||
}
|
||||
|
||||
return m.attach
|
||||
}
|
||||
342
mail/sender/mail_test.go
Normal file
342
mail/sender/mail_test.go
Normal file
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package sender_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
libsnd "github.com/nabbar/golib/mail/sender"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Mail Operations", func() {
|
||||
var mail libsnd.Mail
|
||||
|
||||
BeforeEach(func() {
|
||||
mail = newMail()
|
||||
})
|
||||
|
||||
Describe("Mail Creation", func() {
|
||||
It("should create a new mail instance", func() {
|
||||
Expect(mail).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should have default charset UTF-8", func() {
|
||||
Expect(mail.GetCharset()).To(Equal("UTF-8"))
|
||||
})
|
||||
|
||||
It("should have default encoding EncodingNone", func() {
|
||||
Expect(mail.GetEncoding()).To(Equal(libsnd.EncodingNone))
|
||||
})
|
||||
|
||||
It("should have an Email interface", func() {
|
||||
Expect(mail.Email()).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Charset Operations", func() {
|
||||
It("should set and get charset", func() {
|
||||
mail.SetCharset("ISO-8859-1")
|
||||
Expect(mail.GetCharset()).To(Equal("ISO-8859-1"))
|
||||
})
|
||||
|
||||
It("should handle empty charset", func() {
|
||||
mail.SetCharset("")
|
||||
Expect(mail.GetCharset()).To(Equal(""))
|
||||
})
|
||||
|
||||
It("should handle unicode charset", func() {
|
||||
mail.SetCharset("UTF-16")
|
||||
Expect(mail.GetCharset()).To(Equal("UTF-16"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Subject Operations", func() {
|
||||
It("should set and get subject", func() {
|
||||
mail.SetSubject("Test Subject")
|
||||
Expect(mail.GetSubject()).To(Equal("Test Subject"))
|
||||
})
|
||||
|
||||
It("should handle empty subject", func() {
|
||||
mail.SetSubject("")
|
||||
Expect(mail.GetSubject()).To(Equal(""))
|
||||
})
|
||||
|
||||
It("should handle subject with special characters", func() {
|
||||
subject := "Tëst Sübject with Émojis 🎉"
|
||||
mail.SetSubject(subject)
|
||||
Expect(mail.GetSubject()).To(Equal(subject))
|
||||
})
|
||||
|
||||
It("should handle long subject", func() {
|
||||
subject := string(make([]byte, 1000))
|
||||
mail.SetSubject(subject)
|
||||
Expect(mail.GetSubject()).To(Equal(subject))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Priority Operations", func() {
|
||||
It("should set and get normal priority", func() {
|
||||
mail.SetPriority(libsnd.PriorityNormal)
|
||||
Expect(mail.GetPriority()).To(Equal(libsnd.PriorityNormal))
|
||||
})
|
||||
|
||||
It("should set and get low priority", func() {
|
||||
mail.SetPriority(libsnd.PriorityLow)
|
||||
Expect(mail.GetPriority()).To(Equal(libsnd.PriorityLow))
|
||||
})
|
||||
|
||||
It("should set and get high priority", func() {
|
||||
mail.SetPriority(libsnd.PriorityHigh)
|
||||
Expect(mail.GetPriority()).To(Equal(libsnd.PriorityHigh))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Encoding Operations", func() {
|
||||
It("should set and get encoding none", func() {
|
||||
mail.SetEncoding(libsnd.EncodingNone)
|
||||
Expect(mail.GetEncoding()).To(Equal(libsnd.EncodingNone))
|
||||
})
|
||||
|
||||
It("should set and get encoding binary", func() {
|
||||
mail.SetEncoding(libsnd.EncodingBinary)
|
||||
Expect(mail.GetEncoding()).To(Equal(libsnd.EncodingBinary))
|
||||
})
|
||||
|
||||
It("should set and get encoding base64", func() {
|
||||
mail.SetEncoding(libsnd.EncodingBase64)
|
||||
Expect(mail.GetEncoding()).To(Equal(libsnd.EncodingBase64))
|
||||
})
|
||||
|
||||
It("should set and get encoding quoted-printable", func() {
|
||||
mail.SetEncoding(libsnd.EncodingQuotedPrintable)
|
||||
Expect(mail.GetEncoding()).To(Equal(libsnd.EncodingQuotedPrintable))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DateTime Operations", func() {
|
||||
It("should set and get datetime", func() {
|
||||
now := time.Now()
|
||||
mail.SetDateTime(now)
|
||||
Expect(mail.GetDateTime()).To(BeTemporally("~", now, time.Second))
|
||||
})
|
||||
|
||||
It("should format datetime string", func() {
|
||||
now := time.Now()
|
||||
mail.SetDateTime(now)
|
||||
dateStr := mail.GetDateString()
|
||||
Expect(dateStr).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("should parse datetime from string", func() {
|
||||
dateStr := "Mon, 02 Jan 2006 15:04:05 -0700"
|
||||
err := mail.SetDateString(time.RFC1123Z, dateStr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mail.GetDateString()).To(Equal(dateStr))
|
||||
})
|
||||
|
||||
It("should return error for invalid datetime string", func() {
|
||||
err := mail.SetDateString(time.RFC1123Z, "invalid-date")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Header Operations", func() {
|
||||
It("should add custom header", func() {
|
||||
mail.AddHeader("X-Custom-Header", "custom-value")
|
||||
headers := mail.GetHeaders()
|
||||
Expect(headers.Get("X-Custom-Header")).To(Equal("custom-value"))
|
||||
})
|
||||
|
||||
It("should add multiple values to same header", func() {
|
||||
mail.AddHeader("X-Custom", "value1")
|
||||
mail.AddHeader("X-Custom", "value2")
|
||||
values := mail.GetHeader("X-Custom")
|
||||
Expect(values).To(HaveLen(2))
|
||||
Expect(values).To(ContainElements("value1", "value2"))
|
||||
})
|
||||
|
||||
It("should skip empty header values", func() {
|
||||
mail.AddHeader("X-Test", "")
|
||||
values := mail.GetHeader("X-Test")
|
||||
Expect(values).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should get all headers", func() {
|
||||
mail.SetSubject("Test")
|
||||
mail.SetDateTime(time.Now())
|
||||
headers := mail.GetHeaders()
|
||||
Expect(headers).ToNot(BeNil())
|
||||
Expect(headers.Get("Subject")).To(Equal("Test"))
|
||||
Expect(headers.Get("MIME-Version")).To(Equal("1.0"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Body Operations", func() {
|
||||
It("should set plain text body", func() {
|
||||
body := newReadCloser("Test plain text body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
bodies := mail.GetBody()
|
||||
Expect(bodies).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should set HTML body", func() {
|
||||
body := newReadCloser("<html><body>Test HTML</body></html>")
|
||||
mail.SetBody(libsnd.ContentHTML, body)
|
||||
bodies := mail.GetBody()
|
||||
Expect(bodies).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should replace body with SetBody", func() {
|
||||
body1 := newReadCloser("First body")
|
||||
body2 := newReadCloser("Second body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body1)
|
||||
mail.SetBody(libsnd.ContentPlainText, body2)
|
||||
bodies := mail.GetBody()
|
||||
Expect(bodies).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should add alternative body", func() {
|
||||
plainBody := newReadCloser("Plain text")
|
||||
htmlBody := newReadCloser("<html>HTML</html>")
|
||||
mail.SetBody(libsnd.ContentPlainText, plainBody)
|
||||
mail.AddBody(libsnd.ContentHTML, htmlBody)
|
||||
bodies := mail.GetBody()
|
||||
Expect(bodies).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("should replace existing body type with AddBody", func() {
|
||||
body1 := newReadCloser("First plain body")
|
||||
body2 := newReadCloser("Second plain body")
|
||||
mail.AddBody(libsnd.ContentPlainText, body1)
|
||||
mail.AddBody(libsnd.ContentPlainText, body2)
|
||||
bodies := mail.GetBody()
|
||||
Expect(bodies).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Attachment Operations", func() {
|
||||
It("should add regular attachment", func() {
|
||||
data := newReadCloser("attachment data")
|
||||
mail.AddAttachment("test.txt", "text/plain", data, false)
|
||||
attachments := mail.GetAttachment(false)
|
||||
Expect(attachments).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should add inline attachment", func() {
|
||||
data := newReadCloser("inline data")
|
||||
mail.AddAttachment("image.png", "image/png", data, true)
|
||||
inlines := mail.GetAttachment(true)
|
||||
Expect(inlines).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should set attachment (replace all)", func() {
|
||||
data1 := newReadCloser("data1")
|
||||
data2 := newReadCloser("data2")
|
||||
mail.AddAttachment("file1.txt", "text/plain", data1, false)
|
||||
mail.SetAttachment("file2.txt", "text/plain", data2, false)
|
||||
attachments := mail.GetAttachment(false)
|
||||
Expect(attachments).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should replace attachment with same name", func() {
|
||||
data1 := newReadCloser("old data")
|
||||
data2 := newReadCloser("new data")
|
||||
mail.AddAttachment("test.txt", "text/plain", data1, false)
|
||||
mail.AddAttachment("test.txt", "text/plain", data2, false)
|
||||
attachments := mail.GetAttachment(false)
|
||||
Expect(attachments).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should attach file by path", func() {
|
||||
data := newReadCloser("file content")
|
||||
mail.AttachFile("/path/to/document.pdf", data, false)
|
||||
attachments := mail.GetAttachment(false)
|
||||
Expect(attachments).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should detect mime type from file extension", func() {
|
||||
data := newReadCloser("image data")
|
||||
mail.AttachFile("/path/to/image.jpg", data, false)
|
||||
attachments := mail.GetAttachment(false)
|
||||
Expect(attachments).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should separate inline and regular attachments", func() {
|
||||
regularData := newReadCloser("regular")
|
||||
inlineData := newReadCloser("inline")
|
||||
mail.AddAttachment("file.txt", "text/plain", regularData, false)
|
||||
mail.AddAttachment("image.png", "image/png", inlineData, true)
|
||||
Expect(mail.GetAttachment(false)).To(HaveLen(1))
|
||||
Expect(mail.GetAttachment(true)).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Clone Operations", func() {
|
||||
It("should clone mail instance", func() {
|
||||
mail.SetSubject("Original")
|
||||
mail.SetCharset("UTF-8")
|
||||
mail.Email().SetFrom("original@example.com")
|
||||
|
||||
cloned := mail.Clone()
|
||||
Expect(cloned).ToNot(BeNil())
|
||||
Expect(cloned.GetSubject()).To(Equal("Original"))
|
||||
Expect(cloned.GetCharset()).To(Equal("UTF-8"))
|
||||
Expect(cloned.Email().GetFrom()).To(Equal("original@example.com"))
|
||||
})
|
||||
|
||||
It("should create independent clone", func() {
|
||||
mail.SetSubject("Original")
|
||||
cloned := mail.Clone()
|
||||
|
||||
// Modify original
|
||||
mail.SetSubject("Modified")
|
||||
|
||||
// Clone should not be affected
|
||||
Expect(cloned.GetSubject()).To(Equal("Original"))
|
||||
})
|
||||
|
||||
It("should clone all mail properties", func() {
|
||||
now := time.Now()
|
||||
mail.SetDateTime(now)
|
||||
mail.SetSubject("Test")
|
||||
mail.SetCharset("ISO-8859-1")
|
||||
mail.SetEncoding(libsnd.EncodingBase64)
|
||||
mail.SetPriority(libsnd.PriorityHigh)
|
||||
mail.AddHeader("X-Custom", "value")
|
||||
|
||||
cloned := mail.Clone()
|
||||
Expect(cloned.GetDateTime()).To(BeTemporally("~", now, time.Second))
|
||||
Expect(cloned.GetSubject()).To(Equal("Test"))
|
||||
Expect(cloned.GetCharset()).To(Equal("ISO-8859-1"))
|
||||
Expect(cloned.GetEncoding()).To(Equal(libsnd.EncodingBase64))
|
||||
Expect(cloned.GetPriority()).To(Equal(libsnd.PriorityHigh))
|
||||
})
|
||||
})
|
||||
})
|
||||
237
mail/sender/priority.go
Normal file
237
mail/sender/priority.go
Normal file
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package sender
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
// Email priority header names used by various email clients
|
||||
headerImportance = "Importance" // Standard importance header (RFC 2156)
|
||||
headerMSMailPriority = "X-MSMail-Priority" // Microsoft-specific priority header
|
||||
headerPriority = "X-Priority" // X-Priority header (widely supported)
|
||||
)
|
||||
|
||||
// Priority defines the urgency level of an email message.
|
||||
//
|
||||
// Email priority is indicated through multiple headers to ensure compatibility
|
||||
// with different email clients. The priority setting affects how the email
|
||||
// is displayed to recipients but does NOT affect delivery speed or routing.
|
||||
//
|
||||
// Priority levels help recipients identify which emails need immediate attention:
|
||||
// - High: Urgent messages requiring immediate action
|
||||
// - Normal: Standard messages (default)
|
||||
// - Low: Non-urgent messages that can be handled later
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// mail.SetPriority(sender.PriorityHigh) // For urgent notifications
|
||||
// mail.SetPriority(sender.PriorityNormal) // For standard communications
|
||||
// mail.SetPriority(sender.PriorityLow) // For newsletters or bulk emails
|
||||
//
|
||||
// Note: Priority is a suggestion to email clients; the actual treatment
|
||||
// depends on the recipient's email client and settings.
|
||||
type Priority uint8
|
||||
|
||||
const (
|
||||
// PriorityNormal represents standard email priority (default).
|
||||
// This is the most commonly used priority level and should be used for
|
||||
// regular business correspondence and standard notifications.
|
||||
//
|
||||
// When set to Normal, priority headers are typically omitted from the email,
|
||||
// allowing the email client to use its default display behavior.
|
||||
//
|
||||
// Example:
|
||||
// mail.SetPriority(sender.PriorityNormal)
|
||||
PriorityNormal Priority = iota
|
||||
|
||||
// PriorityLow represents low priority email.
|
||||
// Use this for non-urgent messages such as:
|
||||
// - Newsletters and marketing materials
|
||||
// - Automated reports that don't require immediate attention
|
||||
// - Bulk notifications
|
||||
// - Informational updates
|
||||
//
|
||||
// Low priority emails are often displayed with a down arrow or other indicator
|
||||
// in email clients, signaling to recipients that they can be handled later.
|
||||
//
|
||||
// Email headers set:
|
||||
// - X-Priority: 5 (Lowest)
|
||||
// - Importance: Low
|
||||
// - X-MSMail-Priority: Low
|
||||
//
|
||||
// Example:
|
||||
// mail.SetPriority(sender.PriorityLow)
|
||||
PriorityLow
|
||||
|
||||
// PriorityHigh represents high priority email.
|
||||
// Use this for urgent messages such as:
|
||||
// - Critical system alerts
|
||||
// - Time-sensitive notifications
|
||||
// - Emergency communications
|
||||
// - Deadline reminders
|
||||
//
|
||||
// High priority emails are typically displayed with an exclamation mark or
|
||||
// other visual indicator in email clients.
|
||||
//
|
||||
// Important: Use high priority sparingly. Overuse can lead to recipients
|
||||
// ignoring priority flags or filtering your emails.
|
||||
//
|
||||
// Email headers set:
|
||||
// - X-Priority: 1 (Highest)
|
||||
// - Importance: High
|
||||
// - X-MSMail-Priority: High
|
||||
//
|
||||
// Example:
|
||||
// mail.SetPriority(sender.PriorityHigh)
|
||||
PriorityHigh
|
||||
)
|
||||
|
||||
// String returns a human-readable string representation of the Priority.
|
||||
//
|
||||
// Returns:
|
||||
// - "Normal" for PriorityNormal
|
||||
// - "Low" for PriorityLow
|
||||
// - "High" for PriorityHigh
|
||||
// - Defaults to "Normal" for unknown values
|
||||
func (p Priority) String() string {
|
||||
switch p {
|
||||
case PriorityLow:
|
||||
return "Low"
|
||||
case PriorityHigh:
|
||||
return "High"
|
||||
case PriorityNormal:
|
||||
return "Normal"
|
||||
}
|
||||
|
||||
return PriorityNormal.String()
|
||||
}
|
||||
|
||||
// headerPriority returns the value for the X-Priority header.
|
||||
// This header uses numeric values: 1 (Highest) to 5 (Lowest).
|
||||
//
|
||||
// Returns:
|
||||
// - "5 (Lowest)" for PriorityLow
|
||||
// - "1 (Highest)" for PriorityHigh
|
||||
// - "" (empty) for PriorityNormal (header is omitted)
|
||||
func (p Priority) headerPriority() string {
|
||||
switch p {
|
||||
case PriorityLow:
|
||||
return "5 (Lowest)"
|
||||
case PriorityHigh:
|
||||
return "1 (Highest)"
|
||||
case PriorityNormal:
|
||||
return ""
|
||||
}
|
||||
|
||||
return PriorityNormal.headerPriority()
|
||||
}
|
||||
|
||||
// headerImportance returns the value for the Importance header (RFC 2156).
|
||||
// This is a standard header recognized by most email clients.
|
||||
//
|
||||
// Returns:
|
||||
// - "Low" for PriorityLow
|
||||
// - "High" for PriorityHigh
|
||||
// - "" (empty) for PriorityNormal (header is omitted)
|
||||
func (p Priority) headerImportance() string {
|
||||
switch p {
|
||||
case PriorityLow:
|
||||
return PriorityLow.String()
|
||||
case PriorityHigh:
|
||||
return PriorityHigh.String()
|
||||
case PriorityNormal:
|
||||
return ""
|
||||
}
|
||||
|
||||
return PriorityNormal.headerImportance()
|
||||
}
|
||||
|
||||
// headerMSMailPriority returns the value for the X-MSMail-Priority header.
|
||||
// This is a Microsoft-specific header used by Outlook and other MS email clients.
|
||||
//
|
||||
// Returns:
|
||||
// - "Low" for PriorityLow
|
||||
// - "High" for PriorityHigh
|
||||
// - "" (empty) for PriorityNormal (header is omitted)
|
||||
func (p Priority) headerMSMailPriority() string {
|
||||
switch p {
|
||||
case PriorityLow:
|
||||
return PriorityLow.String()
|
||||
case PriorityHigh:
|
||||
return PriorityHigh.String()
|
||||
case PriorityNormal:
|
||||
return ""
|
||||
}
|
||||
|
||||
return PriorityNormal.headerMSMailPriority()
|
||||
}
|
||||
|
||||
// getHeader calls the provided function with all priority-related headers.
|
||||
// This is used internally to add priority headers to the email.
|
||||
//
|
||||
// The function adds multiple headers for maximum compatibility across
|
||||
// different email clients (Outlook, Thunderbird, Gmail, etc.).
|
||||
//
|
||||
// Parameters:
|
||||
// - h: A callback function that receives header key-value pairs
|
||||
func (p Priority) getHeader(h func(key string, values ...string)) {
|
||||
for k, f := range map[string]func() string{
|
||||
headerPriority: p.headerPriority,
|
||||
headerMSMailPriority: p.headerMSMailPriority,
|
||||
headerImportance: p.headerImportance,
|
||||
} {
|
||||
if v := f(); k != "" && v != "" {
|
||||
h(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePriority converts a string representation into a Priority value.
|
||||
// The comparison is case-insensitive for flexibility.
|
||||
//
|
||||
// Parameters:
|
||||
// - s: String representation of the priority. Valid values are:
|
||||
// "Normal", "Low", "High" (case-insensitive)
|
||||
//
|
||||
// Returns:
|
||||
// - The corresponding Priority value
|
||||
// - PriorityNormal if the string doesn't match any known priority
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// priority := sender.ParsePriority("High") // Returns PriorityHigh
|
||||
// priority := sender.ParsePriority("high") // Also returns PriorityHigh
|
||||
// priority := sender.ParsePriority("unknown") // Returns PriorityNormal
|
||||
func ParsePriority(s string) Priority {
|
||||
switch strings.ToUpper(s) {
|
||||
case strings.ToUpper(PriorityLow.String()):
|
||||
return PriorityLow
|
||||
case strings.ToUpper(PriorityHigh.String()):
|
||||
return PriorityHigh
|
||||
default:
|
||||
return PriorityNormal
|
||||
}
|
||||
}
|
||||
100
mail/sender/recipientType.go
Normal file
100
mail/sender/recipientType.go
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package sender
|
||||
|
||||
// recipientType defines the different categories of email recipients.
|
||||
//
|
||||
// Email recipients can be classified into three main categories, each with
|
||||
// different visibility and intent:
|
||||
// - To: Primary recipients who are expected to act on the email
|
||||
// - CC: Secondary recipients who should be kept informed
|
||||
// - BCC: Hidden recipients who receive the email without other recipients knowing
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// mail.Email().AddRecipients(sender.RecipientTo, "user@example.com")
|
||||
// mail.Email().AddRecipients(sender.RecipientCC, "manager@example.com")
|
||||
// mail.Email().AddRecipients(sender.RecipientBCC, "archive@example.com")
|
||||
type recipientType uint8
|
||||
|
||||
const (
|
||||
// RecipientTo represents primary recipients of the email.
|
||||
// These recipients appear in the "To" field of the email header and are
|
||||
// typically the main audience who should act on or respond to the email.
|
||||
//
|
||||
// All To recipients can see each other's email addresses in the email header.
|
||||
//
|
||||
// Example:
|
||||
// mail.Email().AddRecipients(sender.RecipientTo, "user@example.com", "team@example.com")
|
||||
RecipientTo recipientType = iota
|
||||
|
||||
// RecipientCC represents "Carbon Copy" recipients.
|
||||
// These recipients appear in the "Cc" field of the email header and are
|
||||
// secondary recipients who should be kept informed but are not the primary
|
||||
// audience. They typically don't need to take action on the email.
|
||||
//
|
||||
// All recipients (To and CC) can see CC addresses in the email header.
|
||||
//
|
||||
// Example:
|
||||
// mail.Email().AddRecipients(sender.RecipientCC, "manager@example.com")
|
||||
RecipientCC
|
||||
|
||||
// RecipientBCC represents "Blind Carbon Copy" recipients.
|
||||
// These recipients receive the email but their addresses are NOT visible
|
||||
// to any other recipients. This is useful for:
|
||||
// - Protecting recipient privacy in bulk emails
|
||||
// - Sending copies to archives without other recipients knowing
|
||||
// - Including supervisors without making their oversight obvious
|
||||
//
|
||||
// BCC recipients can only see themselves and the To/CC recipients.
|
||||
// Other recipients cannot see that BCC recipients exist.
|
||||
//
|
||||
// Example:
|
||||
// mail.Email().AddRecipients(sender.RecipientBCC, "archive@example.com", "audit@example.com")
|
||||
RecipientBCC
|
||||
)
|
||||
|
||||
// String returns a human-readable string representation of the recipientType.
|
||||
//
|
||||
// Returns:
|
||||
// - "To" for RecipientTo
|
||||
// - "Cc" for RecipientCC (note: proper capitalization for email headers)
|
||||
// - "Bcc" for RecipientBCC (note: proper capitalization for email headers)
|
||||
// - Defaults to "To" for unknown values
|
||||
//
|
||||
// The returned strings match standard email header field names.
|
||||
func (r recipientType) String() string {
|
||||
switch r {
|
||||
case RecipientTo:
|
||||
return "To"
|
||||
case RecipientCC:
|
||||
return "Cc"
|
||||
case RecipientBCC:
|
||||
return "Bcc"
|
||||
}
|
||||
|
||||
return RecipientTo.String()
|
||||
}
|
||||
383
mail/sender/send_test.go
Normal file
383
mail/sender/send_test.go
Normal file
@@ -0,0 +1,383 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package sender_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
smtpsv "github.com/emersion/go-smtp"
|
||||
libsnd "github.com/nabbar/golib/mail/sender"
|
||||
libsmtp "github.com/nabbar/golib/mail/smtp"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Sender Operations", func() {
|
||||
var (
|
||||
mail libsnd.Mail
|
||||
ctx context.Context
|
||||
cnl context.CancelFunc
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx, cnl = context.WithTimeout(testCtx, 10*time.Second)
|
||||
mail = newMailWithBasicConfig()
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if cnl != nil {
|
||||
cnl()
|
||||
}
|
||||
})
|
||||
|
||||
Describe("Sender Creation", func() {
|
||||
It("should create sender from mail", func() {
|
||||
body := newReadCloser("Test email body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
|
||||
if sender != nil {
|
||||
defer sender.Close()
|
||||
}
|
||||
})
|
||||
|
||||
It("should create sender with HTML body", func() {
|
||||
body := newReadCloser("<html><body><h1>Test</h1></body></html>")
|
||||
mail.SetBody(libsnd.ContentHTML, body)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
|
||||
if sender != nil {
|
||||
defer sender.Close()
|
||||
}
|
||||
})
|
||||
|
||||
It("should create sender with multiple body parts", func() {
|
||||
plainBody := newReadCloser("Plain text version")
|
||||
htmlBody := newReadCloser("<html><body>HTML version</body></html>")
|
||||
mail.SetBody(libsnd.ContentPlainText, plainBody)
|
||||
mail.AddBody(libsnd.ContentHTML, htmlBody)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
|
||||
if sender != nil {
|
||||
defer sender.Close()
|
||||
}
|
||||
})
|
||||
|
||||
It("should create sender with attachments", func() {
|
||||
body := newReadCloser("Email with attachment")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
|
||||
attachment := newReadCloser("Attachment content")
|
||||
mail.AddAttachment("file.txt", "text/plain", attachment, false)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
|
||||
if sender != nil {
|
||||
defer sender.Close()
|
||||
}
|
||||
})
|
||||
|
||||
It("should create sender with inline attachments", func() {
|
||||
body := newReadCloser("Email with inline image")
|
||||
mail.SetBody(libsnd.ContentHTML, body)
|
||||
|
||||
inline := newReadCloser("Image data")
|
||||
mail.AddAttachment("logo.png", "image/png", inline, true)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
|
||||
if sender != nil {
|
||||
defer sender.Close()
|
||||
}
|
||||
})
|
||||
|
||||
It("should handle different priorities", func() {
|
||||
body := newReadCloser("High priority email")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
mail.SetPriority(libsnd.PriorityHigh)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
|
||||
if sender != nil {
|
||||
defer sender.Close()
|
||||
}
|
||||
})
|
||||
|
||||
It("should handle different encodings", func() {
|
||||
body := newReadCloser("Base64 encoded email")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
mail.SetEncoding(libsnd.EncodingBase64)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
|
||||
if sender != nil {
|
||||
defer sender.Close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Send with Real SMTP", func() {
|
||||
var (
|
||||
smtpServer *smtpsv.Server
|
||||
smtpClient libsmtp.SMTP
|
||||
backend *testBackend
|
||||
host string
|
||||
port int
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
backend = &testBackend{requireAuth: false, messages: make([]testMessage, 0)}
|
||||
var err error
|
||||
smtpServer, host, port, err = startTestSMTPServer(backend, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
smtpClient = newTestSMTPClient(host, port)
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if smtpClient != nil {
|
||||
smtpClient.Close()
|
||||
}
|
||||
if smtpServer != nil {
|
||||
_ = smtpServer.Close()
|
||||
}
|
||||
})
|
||||
|
||||
It("should send email successfully", func() {
|
||||
body := newReadCloser("Test email body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
defer sender.Close()
|
||||
|
||||
err = sender.Send(ctx, smtpClient)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(backend.messages).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should send and close", func() {
|
||||
body := newReadCloser("Test email body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
|
||||
err = sender.SendClose(ctx, smtpClient)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(backend.messages).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("should handle multiple sends from same sender", func() {
|
||||
body := newReadCloser("Test email body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
defer sender.Close()
|
||||
|
||||
// Send multiple times
|
||||
for i := 0; i < 3; i++ {
|
||||
err = sender.Send(ctx, smtpClient)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
Expect(backend.messages).To(HaveLen(3))
|
||||
})
|
||||
|
||||
It("should return error when from address is invalid", func() {
|
||||
body := newReadCloser("Test email body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
mail.Email().SetFrom("") // Invalid from
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
defer sender.Close()
|
||||
|
||||
err = sender.Send(ctx, smtpClient)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error when no recipients", func() {
|
||||
body := newReadCloser("Test email body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
mail.Email().SetRecipients(libsnd.RecipientTo) // Clear recipients
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
defer sender.Close()
|
||||
|
||||
err = sender.Send(ctx, smtpClient)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should include all recipient types", func() {
|
||||
body := newReadCloser("Test email body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
mail.Email().AddRecipients(libsnd.RecipientCC, "cc@example.com")
|
||||
mail.Email().AddRecipients(libsnd.RecipientBCC, "bcc@example.com")
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
defer sender.Close()
|
||||
|
||||
err = sender.Send(ctx, smtpClient)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(backend.messages).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Sender Lifecycle", func() {
|
||||
It("should close sender properly", func() {
|
||||
body := newReadCloser("Test email body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
|
||||
err = sender.Close()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should handle multiple close calls", func() {
|
||||
body := newReadCloser("Test email body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
|
||||
_ = sender.Close()
|
||||
err = sender.Close()
|
||||
// Should not panic or cause issues
|
||||
_ = err
|
||||
})
|
||||
|
||||
It("should clean up resources with SendClose", func() {
|
||||
body := newReadCloser("Test email body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
|
||||
// SendClose will close the sender
|
||||
// We just check that it works without SMTP client for this test
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Context Handling", func() {
|
||||
var (
|
||||
smtpServer *smtpsv.Server
|
||||
smtpClient libsmtp.SMTP
|
||||
backend *testBackend
|
||||
host string
|
||||
port int
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
backend = &testBackend{requireAuth: false, messages: make([]testMessage, 0)}
|
||||
var err error
|
||||
smtpServer, host, port, err = startTestSMTPServer(backend, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
smtpClient = newTestSMTPClient(host, port)
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if smtpClient != nil {
|
||||
smtpClient.Close()
|
||||
}
|
||||
if smtpServer != nil {
|
||||
_ = smtpServer.Close()
|
||||
}
|
||||
})
|
||||
|
||||
It("should respect context cancellation", func() {
|
||||
body := newReadCloser("Test email body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
defer sender.Close()
|
||||
|
||||
cancelCtx, cancel := context.WithCancel(ctx)
|
||||
cancel() // Cancel immediately
|
||||
|
||||
err = sender.Send(cancelCtx, smtpClient)
|
||||
// May or may not error depending on timing
|
||||
_ = err
|
||||
})
|
||||
|
||||
It("should respect context timeout", func() {
|
||||
body := newReadCloser("Test email body")
|
||||
mail.SetBody(libsnd.ContentPlainText, body)
|
||||
|
||||
sender, err := mail.Sender()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sender).ToNot(BeNil())
|
||||
defer sender.Close()
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 1*time.Nanosecond)
|
||||
defer cancel()
|
||||
|
||||
time.Sleep(10 * time.Millisecond) // Ensure timeout
|
||||
|
||||
err = sender.Send(timeoutCtx, smtpClient)
|
||||
// May or may not error depending on implementation
|
||||
_ = err
|
||||
})
|
||||
})
|
||||
})
|
||||
266
mail/sender/sender.go
Normal file
266
mail/sender/sender.go
Normal file
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
libfpg "github.com/nabbar/golib/file/progress"
|
||||
libsmtp "github.com/nabbar/golib/mail/smtp"
|
||||
simple "github.com/xhit/go-simple-mail/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
_MinSizeAddr = 4
|
||||
)
|
||||
|
||||
type Sender interface {
|
||||
Close() error
|
||||
Send(ctx context.Context, cli libsmtp.SMTP) error
|
||||
SendClose(ctx context.Context, cli libsmtp.SMTP) error
|
||||
}
|
||||
|
||||
type sender struct {
|
||||
data libfpg.Progress
|
||||
from string
|
||||
rcpt []string
|
||||
}
|
||||
|
||||
// nolint #gocognit
|
||||
func (m *mail) Sender() (snd Sender, err error) {
|
||||
e := simple.NewMSG()
|
||||
f := make([]libfpg.Progress, 0)
|
||||
|
||||
switch m.GetPriority() {
|
||||
case PriorityHigh:
|
||||
e.SetPriority(simple.PriorityHigh)
|
||||
case PriorityLow:
|
||||
e.SetPriority(simple.PriorityLow)
|
||||
}
|
||||
|
||||
if e.Error != nil {
|
||||
return nil, ErrorMailSenderInit.Error(e.Error)
|
||||
}
|
||||
|
||||
switch m.GetEncoding() {
|
||||
case EncodingNone, EncodingBinary:
|
||||
e.Encoding = simple.EncodingNone
|
||||
case EncodingBase64:
|
||||
e.Encoding = simple.EncodingBase64
|
||||
case EncodingQuotedPrintable:
|
||||
e.Encoding = simple.EncodingQuotedPrintable
|
||||
}
|
||||
|
||||
if e.Error != nil {
|
||||
return nil, ErrorMailSenderInit.Error(e.Error)
|
||||
}
|
||||
|
||||
e.Charset = m.GetCharset()
|
||||
|
||||
e.SetSubject(m.GetSubject())
|
||||
if e.Error != nil {
|
||||
return nil, ErrorMailSenderInit.Error(e.Error)
|
||||
}
|
||||
|
||||
e.SetDate(m.date.Format("2006-01-02 15:04:05 MST"))
|
||||
if e.Error != nil {
|
||||
return nil, ErrorMailSenderInit.Error(e.Error)
|
||||
}
|
||||
|
||||
if r := m.Email().GetFrom(); len(r) > 0 {
|
||||
e.SetFrom(r)
|
||||
}
|
||||
if e.Error != nil {
|
||||
return nil, ErrorMailSenderInit.Error(e.Error)
|
||||
}
|
||||
|
||||
if r := m.Email().GetReplyTo(); len(r) > 0 {
|
||||
e.SetReplyTo(r)
|
||||
}
|
||||
if e.Error != nil {
|
||||
return nil, ErrorMailSenderInit.Error(e.Error)
|
||||
}
|
||||
|
||||
if r := m.Email().GetReturnPath(); len(r) > 0 {
|
||||
e.SetReturnPath(r)
|
||||
}
|
||||
if e.Error != nil {
|
||||
return nil, ErrorMailSenderInit.Error(e.Error)
|
||||
}
|
||||
|
||||
if r := m.Email().GetSender(); len(r) > 0 {
|
||||
e.SetSender(r)
|
||||
}
|
||||
if e.Error != nil {
|
||||
return nil, ErrorMailSenderInit.Error(e.Error)
|
||||
}
|
||||
|
||||
if r := m.address.GetRecipients(RecipientTo); len(r) > 0 {
|
||||
e.AddTo(r...)
|
||||
if e.Error != nil {
|
||||
return nil, ErrorMailSenderInit.Error(e.Error)
|
||||
}
|
||||
}
|
||||
|
||||
if r := m.address.GetRecipients(RecipientCC); len(r) > 0 {
|
||||
e.AddCc(r...)
|
||||
if e.Error != nil {
|
||||
return nil, ErrorMailSenderInit.Error(e.Error)
|
||||
}
|
||||
}
|
||||
|
||||
if r := m.address.GetRecipients(RecipientBCC); len(r) > 0 {
|
||||
e.AddBcc(r...)
|
||||
if e.Error != nil {
|
||||
return nil, ErrorMailSenderInit.Error(e.Error)
|
||||
}
|
||||
}
|
||||
|
||||
if len(m.attach) > 0 {
|
||||
for _, i := range m.attach {
|
||||
if t, er := libfpg.Temp(""); er != nil {
|
||||
return nil, ErrorFileOpenCreate.Error(er)
|
||||
} else if _, er := t.ReadFrom(i.data); er != nil {
|
||||
return nil, ErrorMailIORead.Error(er)
|
||||
} else if e.AddAttachment(t.Path(), i.name); e.Error != nil {
|
||||
return nil, ErrorMailSenderInit.Error(e.Error)
|
||||
} else {
|
||||
f = append(f, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(m.inline) > 0 {
|
||||
for _, i := range m.inline {
|
||||
if t, er := libfpg.Temp(""); er != nil {
|
||||
return nil, ErrorFileOpenCreate.Error(er)
|
||||
} else if _, er := t.ReadFrom(i.data); er != nil {
|
||||
return nil, ErrorMailIORead.Error(er)
|
||||
} else if e.AddInline(t.Path(), i.name); e.Error != nil {
|
||||
return nil, ErrorMailSenderInit.Error(e.Error)
|
||||
} else {
|
||||
f = append(f, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(m.body) > 0 {
|
||||
for i, b := range m.body {
|
||||
var (
|
||||
buf = bytes.NewBuffer(make([]byte, 0))
|
||||
enc = simple.TextPlain
|
||||
)
|
||||
|
||||
if b.contentType == ContentHTML {
|
||||
enc = simple.TextHTML
|
||||
}
|
||||
|
||||
if _, er := buf.ReadFrom(b.body); er != nil {
|
||||
return nil, ErrorMailIORead.Error(er)
|
||||
} else if i > 0 {
|
||||
e.AddAlternative(enc, buf.String())
|
||||
} else {
|
||||
e.SetBody(enc, buf.String())
|
||||
}
|
||||
if e.Error != nil {
|
||||
return nil, ErrorMailSenderInit.Error(e.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s := &sender{}
|
||||
|
||||
defer func() {
|
||||
if err != nil || snd == nil {
|
||||
_ = s.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
s.from = m.Email().GetFrom()
|
||||
s.rcpt = make([]string, 0)
|
||||
s.rcpt = append(s.rcpt, m.Email().GetRecipients(RecipientTo)...)
|
||||
s.rcpt = append(s.rcpt, m.Email().GetRecipients(RecipientCC)...)
|
||||
s.rcpt = append(s.rcpt, m.Email().GetRecipients(RecipientBCC)...)
|
||||
|
||||
if tmp, er := libfpg.Temp(""); er != nil {
|
||||
return nil, ErrorFileOpenCreate.Error(er)
|
||||
} else if _, er = tmp.WriteString(e.GetMessage()); er != nil {
|
||||
return nil, ErrorMailIOWrite.Error(er)
|
||||
} else if e.Error != nil {
|
||||
return nil, ErrorMailSenderInit.Error(e.Error)
|
||||
} else if _, er = tmp.Seek(0, io.SeekStart); er != nil {
|
||||
return nil, ErrorMailIOWrite.Error(er)
|
||||
} else {
|
||||
s.data = tmp
|
||||
snd = s
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sender) SendClose(ctx context.Context, cli libsmtp.SMTP) error {
|
||||
defer func() {
|
||||
_ = s.Close()
|
||||
}()
|
||||
|
||||
if e := s.Send(ctx, cli); e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sender) Send(ctx context.Context, cli libsmtp.SMTP) error {
|
||||
if e := cli.Check(ctx); e != nil {
|
||||
return ErrorMailSmtpClient.Error(e)
|
||||
}
|
||||
|
||||
if len(s.from) < _MinSizeAddr {
|
||||
//nolint #goerr113
|
||||
return ErrorParamEmpty.Error(fmt.Errorf("parameters 'from' is not valid"))
|
||||
} else if len(s.rcpt) < 1 || len(s.rcpt[0]) < _MinSizeAddr {
|
||||
//nolint #goerr113
|
||||
return ErrorParamEmpty.Error(fmt.Errorf("parameters 'receipient' is not valid"))
|
||||
}
|
||||
|
||||
e := cli.Send(ctx, s.from, s.rcpt, s.data)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
if _, err := s.data.Seek(0, io.SeekStart); err != nil {
|
||||
return ErrorMailIOWrite.Error(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sender) Close() error {
|
||||
return s.data.Close()
|
||||
}
|
||||
356
mail/sender/sender_suite_test.go
Normal file
356
mail/sender/sender_suite_test.go
Normal file
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package sender_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
saslsv "github.com/emersion/go-sasl"
|
||||
smtpsv "github.com/emersion/go-smtp"
|
||||
libtls "github.com/nabbar/golib/certificates"
|
||||
certca "github.com/nabbar/golib/certificates/ca"
|
||||
libsnd "github.com/nabbar/golib/mail/sender"
|
||||
libsmtp "github.com/nabbar/golib/mail/smtp"
|
||||
smtpcfg "github.com/nabbar/golib/mail/smtp/config"
|
||||
smtptp "github.com/nabbar/golib/mail/smtp/tlsmode"
|
||||
libptc "github.com/nabbar/golib/network/protocol"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var (
|
||||
// Global context for all tests
|
||||
testCtx context.Context
|
||||
testCancel context.CancelFunc
|
||||
|
||||
// Test SMTP server
|
||||
testSMTPHost = "localhost"
|
||||
testSMTPUser = "testuser"
|
||||
testSMTPPass = "testpass"
|
||||
|
||||
srvTLS, cliTLS = createTLSConfig()
|
||||
)
|
||||
|
||||
// TestSender is the entry point for the Ginkgo test suite
|
||||
func TestSender(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Mail/Sender Package Suite")
|
||||
}
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
testCtx, testCancel = context.WithCancel(context.Background())
|
||||
})
|
||||
|
||||
var _ = AfterSuite(func() {
|
||||
if testCancel != nil {
|
||||
testCancel()
|
||||
}
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
|
||||
// newMail creates a new mail instance for testing
|
||||
func newMail() libsnd.Mail {
|
||||
return libsnd.New()
|
||||
}
|
||||
|
||||
// newMailWithBasicConfig creates a mail with basic configuration
|
||||
func newMailWithBasicConfig() libsnd.Mail {
|
||||
m := libsnd.New()
|
||||
m.SetSubject("Test Subject")
|
||||
m.SetCharset("UTF-8")
|
||||
m.SetEncoding(libsnd.EncodingBase64)
|
||||
m.SetPriority(libsnd.PriorityNormal)
|
||||
m.Email().SetFrom("sender@example.com")
|
||||
m.Email().AddRecipients(libsnd.RecipientTo, "recipient@example.com")
|
||||
return m
|
||||
}
|
||||
|
||||
// newReadCloser creates a ReadCloser from a string
|
||||
func newReadCloser(content string) io.ReadCloser {
|
||||
return io.NopCloser(strings.NewReader(content))
|
||||
}
|
||||
|
||||
// getFreePort returns a free TCP port
|
||||
func getFreePort() int {
|
||||
addr, err := net.ResolveTCPAddr(libptc.NetworkTCP.Code(), "localhost:0")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
lstn, err := net.ListenTCP(libptc.NetworkTCP.Code(), addr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
defer func() {
|
||||
_ = lstn.Close()
|
||||
}()
|
||||
|
||||
return lstn.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// createTLSConfig creates a TLS configuration for testing
|
||||
func createTLSConfig() (serverConfig, clientConfig libtls.TLSConfig) {
|
||||
certPEM, keyPEM := generateSelfSignedCert()
|
||||
|
||||
// Server config
|
||||
serverConfig = libtls.New()
|
||||
err := serverConfig.AddCertificatePairString(string(keyPEM), string(certPEM))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Client config with server cert as CA
|
||||
ca, err := certca.Parse(string(certPEM))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
clientConfig = libtls.New()
|
||||
if !clientConfig.AddRootCA(ca) {
|
||||
panic("failed to add root CA")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// generateSelfSignedCert generates a self-signed certificate for testing
|
||||
func generateSelfSignedCert() (certPEM, keyPEM []byte) {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
notBefore := time.Now()
|
||||
notAfter := notBefore.Add(24 * time.Hour)
|
||||
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Test Co"},
|
||||
CommonName: "localhost",
|
||||
},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// testBackend implements smtpsv.Backend
|
||||
type testBackend struct {
|
||||
requireAuth bool
|
||||
messages []testMessage
|
||||
}
|
||||
|
||||
func (b *testBackend) NewSession(_ *smtpsv.Conn) (smtpsv.Session, error) {
|
||||
return &testSession{backend: b}, nil
|
||||
}
|
||||
|
||||
func getNewServer(backend *testBackend, useTLS bool) *smtpsv.Server {
|
||||
s := smtpsv.NewServer(backend)
|
||||
s.Addr = fmt.Sprintf("localhost:%d", getFreePort())
|
||||
s.Domain = "localhost"
|
||||
s.ReadTimeout = 10 * time.Second
|
||||
s.WriteTimeout = 10 * time.Second
|
||||
s.MaxMessageBytes = 1024 * 1024
|
||||
s.MaxRecipients = 50
|
||||
|
||||
if useTLS {
|
||||
s.TLSConfig = srvTLS.TlsConfig("")
|
||||
s.AllowInsecureAuth = false
|
||||
} else {
|
||||
s.AllowInsecureAuth = true
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// startTestSMTPServer starts a test SMTP server and returns server, host, port
|
||||
func startTestSMTPServer(backend *testBackend, useTLS bool) (*smtpsv.Server, string, int, error) {
|
||||
srv := getNewServer(backend, useTLS)
|
||||
|
||||
if useTLS {
|
||||
go func() {
|
||||
_ = srv.ListenAndServeTLS()
|
||||
}()
|
||||
} else {
|
||||
go func() {
|
||||
_ = srv.ListenAndServe()
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for server to be ready
|
||||
waitForServerRunning(srv.Addr, 5*time.Second)
|
||||
|
||||
// Extract host and port
|
||||
if i := strings.Split(srv.Addr, ":"); len(i) != 2 {
|
||||
return nil, "", 0, fmt.Errorf("invalid server address: %s", srv.Addr)
|
||||
} else if p, e := strconv.Atoi(i[1]); e != nil {
|
||||
return nil, "", 0, fmt.Errorf("invalid server address: %s", srv.Addr)
|
||||
} else {
|
||||
return srv, i[0], p, nil
|
||||
}
|
||||
}
|
||||
|
||||
// waitForServerRunning waits for the server to be running
|
||||
func waitForServerRunning(address string, timeout time.Duration) {
|
||||
ctx, cancel := context.WithTimeout(testCtx, timeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
Fail(fmt.Sprintf("Timeout waiting for server to start at %s after %v", address, timeout))
|
||||
return
|
||||
case <-ticker.C:
|
||||
if c, e := net.DialTimeout("tcp", address, 100*time.Millisecond); e == nil {
|
||||
_ = c.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type testMessage struct {
|
||||
From string
|
||||
To []string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type testSession struct {
|
||||
backend *testBackend
|
||||
from string
|
||||
to []string
|
||||
authenticated bool
|
||||
}
|
||||
|
||||
func (s *testSession) AuthMechanisms() []string {
|
||||
return []string{saslsv.Plain}
|
||||
}
|
||||
|
||||
func (s *testSession) Auth(mech string) (saslsv.Server, error) {
|
||||
return saslsv.NewPlainServer(func(identity, username, password string) error {
|
||||
if username != testSMTPUser || password != testSMTPPass {
|
||||
return fmt.Errorf("invalid credentials")
|
||||
}
|
||||
s.authenticated = true
|
||||
return nil
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *testSession) Mail(from string, _ *smtpsv.MailOptions) error {
|
||||
if s.backend.requireAuth && !s.authenticated {
|
||||
return fmt.Errorf("authentication required")
|
||||
}
|
||||
if strings.Contains(from, "\n") || strings.Contains(from, "\r") {
|
||||
return fmt.Errorf("invalid from address")
|
||||
}
|
||||
s.from = from
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *testSession) Rcpt(to string, _ *smtpsv.RcptOptions) error {
|
||||
if strings.Contains(to, "\n") || strings.Contains(to, "\r") {
|
||||
return fmt.Errorf("invalid to address")
|
||||
}
|
||||
s.to = append(s.to, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *testSession) Data(r io.Reader) error {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.backend.messages = append(s.backend.messages, testMessage{
|
||||
From: s.from,
|
||||
To: s.to,
|
||||
Data: data,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *testSession) Reset() {
|
||||
s.from = ""
|
||||
s.to = nil
|
||||
}
|
||||
|
||||
func (s *testSession) Logout() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// newTestConfig creates a test SMTP config
|
||||
func newTestConfig(host string, port int, tlsMode smtptp.TLSMode) smtpcfg.Config {
|
||||
dsn := fmt.Sprintf("tcp(%s:%d)/%s", host, port, tlsMode.String())
|
||||
model := smtpcfg.ConfigModel{DSN: dsn}
|
||||
cfg, err := model.Config()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return cfg
|
||||
}
|
||||
|
||||
// newTestSMTPClient creates a real test SMTP client
|
||||
func newTestSMTPClient(host string, port int) libsmtp.SMTP {
|
||||
cfg := newTestConfig(host, port, smtptp.TLSNone)
|
||||
cli, err := libsmtp.New(cfg, cliTLS.TlsConfig(""))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(cli).ToNot(BeNil())
|
||||
return cli
|
||||
}
|
||||
297
mail/sender/types_test.go
Normal file
297
mail/sender/types_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package sender_test
|
||||
|
||||
import (
|
||||
libsnd "github.com/nabbar/golib/mail/sender"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Type Definitions", func() {
|
||||
Describe("Encoding Type", func() {
|
||||
It("should return correct string for EncodingNone", func() {
|
||||
Expect(libsnd.EncodingNone.String()).To(Equal("None"))
|
||||
})
|
||||
|
||||
It("should return correct string for EncodingBinary", func() {
|
||||
Expect(libsnd.EncodingBinary.String()).To(Equal("Binary"))
|
||||
})
|
||||
|
||||
It("should return correct string for EncodingBase64", func() {
|
||||
Expect(libsnd.EncodingBase64.String()).To(Equal("Base 64"))
|
||||
})
|
||||
|
||||
It("should return correct string for EncodingQuotedPrintable", func() {
|
||||
Expect(libsnd.EncodingQuotedPrintable.String()).To(Equal("Quoted Printable"))
|
||||
})
|
||||
|
||||
It("should parse encoding from string", func() {
|
||||
tests := map[string]libsnd.Encoding{
|
||||
"None": libsnd.EncodingNone,
|
||||
"none": libsnd.EncodingNone,
|
||||
"NONE": libsnd.EncodingNone,
|
||||
"Binary": libsnd.EncodingBinary,
|
||||
"binary": libsnd.EncodingBinary,
|
||||
"BINARY": libsnd.EncodingBinary,
|
||||
"Base 64": libsnd.EncodingBase64,
|
||||
"base 64": libsnd.EncodingBase64,
|
||||
"BASE 64": libsnd.EncodingBase64,
|
||||
"Quoted Printable": libsnd.EncodingQuotedPrintable,
|
||||
"quoted printable": libsnd.EncodingQuotedPrintable,
|
||||
"QUOTED PRINTABLE": libsnd.EncodingQuotedPrintable,
|
||||
}
|
||||
|
||||
for input, expected := range tests {
|
||||
result := libsnd.ParseEncoding(input)
|
||||
Expect(result).To(Equal(expected), "Failed for input: %s", input)
|
||||
}
|
||||
})
|
||||
|
||||
It("should return EncodingNone for unknown encoding", func() {
|
||||
Expect(libsnd.ParseEncoding("unknown")).To(Equal(libsnd.EncodingNone))
|
||||
Expect(libsnd.ParseEncoding("")).To(Equal(libsnd.EncodingNone))
|
||||
Expect(libsnd.ParseEncoding("invalid-encoding")).To(Equal(libsnd.EncodingNone))
|
||||
})
|
||||
|
||||
It("should handle case-insensitive parsing", func() {
|
||||
Expect(libsnd.ParseEncoding("bAsE 64")).To(Equal(libsnd.EncodingBase64))
|
||||
Expect(libsnd.ParseEncoding("QuOtEd PrInTaBlE")).To(Equal(libsnd.EncodingQuotedPrintable))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Priority Type", func() {
|
||||
It("should return correct string for PriorityNormal", func() {
|
||||
Expect(libsnd.PriorityNormal.String()).To(Equal("Normal"))
|
||||
})
|
||||
|
||||
It("should return correct string for PriorityLow", func() {
|
||||
Expect(libsnd.PriorityLow.String()).To(Equal("Low"))
|
||||
})
|
||||
|
||||
It("should return correct string for PriorityHigh", func() {
|
||||
Expect(libsnd.PriorityHigh.String()).To(Equal("High"))
|
||||
})
|
||||
|
||||
It("should parse priority from string", func() {
|
||||
tests := map[string]libsnd.Priority{
|
||||
"Normal": libsnd.PriorityNormal,
|
||||
"normal": libsnd.PriorityNormal,
|
||||
"NORMAL": libsnd.PriorityNormal,
|
||||
"Low": libsnd.PriorityLow,
|
||||
"low": libsnd.PriorityLow,
|
||||
"LOW": libsnd.PriorityLow,
|
||||
"High": libsnd.PriorityHigh,
|
||||
"high": libsnd.PriorityHigh,
|
||||
"HIGH": libsnd.PriorityHigh,
|
||||
}
|
||||
|
||||
for input, expected := range tests {
|
||||
result := libsnd.ParsePriority(input)
|
||||
Expect(result).To(Equal(expected), "Failed for input: %s", input)
|
||||
}
|
||||
})
|
||||
|
||||
It("should return PriorityNormal for unknown priority", func() {
|
||||
Expect(libsnd.ParsePriority("unknown")).To(Equal(libsnd.PriorityNormal))
|
||||
Expect(libsnd.ParsePriority("")).To(Equal(libsnd.PriorityNormal))
|
||||
Expect(libsnd.ParsePriority("medium")).To(Equal(libsnd.PriorityNormal))
|
||||
})
|
||||
|
||||
It("should handle case-insensitive parsing", func() {
|
||||
Expect(libsnd.ParsePriority("HiGh")).To(Equal(libsnd.PriorityHigh))
|
||||
Expect(libsnd.ParsePriority("LoW")).To(Equal(libsnd.PriorityLow))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ContentType Type", func() {
|
||||
It("should return correct string for ContentPlainText", func() {
|
||||
Expect(libsnd.ContentPlainText.String()).To(Equal("Plain Text"))
|
||||
})
|
||||
|
||||
It("should return correct string for ContentHTML", func() {
|
||||
Expect(libsnd.ContentHTML.String()).To(Equal("HTML"))
|
||||
})
|
||||
|
||||
It("should default to ContentPlainText for invalid type", func() {
|
||||
// Cast an invalid value to ContentType
|
||||
invalid := libsnd.ContentType(99)
|
||||
Expect(invalid.String()).To(Equal("Plain Text"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RecipientType Type", func() {
|
||||
It("should return correct string for RecipientTo", func() {
|
||||
Expect(libsnd.RecipientTo.String()).To(Equal("To"))
|
||||
})
|
||||
|
||||
It("should return correct string for RecipientCC", func() {
|
||||
Expect(libsnd.RecipientCC.String()).To(Equal("Cc"))
|
||||
})
|
||||
|
||||
It("should return correct string for RecipientBCC", func() {
|
||||
Expect(libsnd.RecipientBCC.String()).To(Equal("Bcc"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Body and File Types", func() {
|
||||
It("should create Body with ContentPlainText", func() {
|
||||
body := libsnd.NewBody(libsnd.ContentPlainText, newReadCloser("test"))
|
||||
Expect(body).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should create Body with ContentHTML", func() {
|
||||
body := libsnd.NewBody(libsnd.ContentHTML, newReadCloser("<html>test</html>"))
|
||||
Expect(body).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should create File with all parameters", func() {
|
||||
file := libsnd.NewFile("test.txt", "text/plain", newReadCloser("content"))
|
||||
Expect(file).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should create File with empty content", func() {
|
||||
file := libsnd.NewFile("empty.txt", "text/plain", newReadCloser(""))
|
||||
Expect(file).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should create File with various mime types", func() {
|
||||
mimeTypes := []string{
|
||||
"text/plain",
|
||||
"text/html",
|
||||
"application/pdf",
|
||||
"image/png",
|
||||
"application/octet-stream",
|
||||
}
|
||||
|
||||
for _, mime := range mimeTypes {
|
||||
file := libsnd.NewFile("file", mime, newReadCloser("data"))
|
||||
Expect(file).ToNot(BeNil())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Type Constants", func() {
|
||||
It("should have distinct Encoding values", func() {
|
||||
encodings := []libsnd.Encoding{
|
||||
libsnd.EncodingNone,
|
||||
libsnd.EncodingBinary,
|
||||
libsnd.EncodingBase64,
|
||||
libsnd.EncodingQuotedPrintable,
|
||||
}
|
||||
|
||||
// Check all are unique
|
||||
seen := make(map[libsnd.Encoding]bool)
|
||||
for _, e := range encodings {
|
||||
Expect(seen[e]).To(BeFalse(), "Duplicate encoding value: %v", e)
|
||||
seen[e] = true
|
||||
}
|
||||
})
|
||||
|
||||
It("should have distinct Priority values", func() {
|
||||
priorities := []libsnd.Priority{
|
||||
libsnd.PriorityNormal,
|
||||
libsnd.PriorityLow,
|
||||
libsnd.PriorityHigh,
|
||||
}
|
||||
|
||||
seen := make(map[libsnd.Priority]bool)
|
||||
for _, p := range priorities {
|
||||
Expect(seen[p]).To(BeFalse(), "Duplicate priority value: %v", p)
|
||||
seen[p] = true
|
||||
}
|
||||
})
|
||||
|
||||
It("should have distinct ContentType values", func() {
|
||||
types := []libsnd.ContentType{
|
||||
libsnd.ContentPlainText,
|
||||
libsnd.ContentHTML,
|
||||
}
|
||||
|
||||
seen := make(map[libsnd.ContentType]bool)
|
||||
for _, t := range types {
|
||||
Expect(seen[t]).To(BeFalse(), "Duplicate content type value: %v", t)
|
||||
seen[t] = true
|
||||
}
|
||||
})
|
||||
|
||||
It("should have distinct RecipientType values", func() {
|
||||
types := []interface{}{
|
||||
libsnd.RecipientTo,
|
||||
libsnd.RecipientCC,
|
||||
libsnd.RecipientBCC,
|
||||
}
|
||||
|
||||
// Just check they're defined
|
||||
Expect(types).To(HaveLen(3))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Type Round-Trip", func() {
|
||||
It("should round-trip Encoding through string", func() {
|
||||
encodings := []libsnd.Encoding{
|
||||
libsnd.EncodingNone,
|
||||
libsnd.EncodingBinary,
|
||||
libsnd.EncodingBase64,
|
||||
libsnd.EncodingQuotedPrintable,
|
||||
}
|
||||
|
||||
for _, e := range encodings {
|
||||
str := e.String()
|
||||
parsed := libsnd.ParseEncoding(str)
|
||||
Expect(parsed).To(Equal(e), "Round-trip failed for %v", e)
|
||||
}
|
||||
})
|
||||
|
||||
It("should round-trip Priority through string", func() {
|
||||
priorities := []libsnd.Priority{
|
||||
libsnd.PriorityNormal,
|
||||
libsnd.PriorityLow,
|
||||
libsnd.PriorityHigh,
|
||||
}
|
||||
|
||||
for _, p := range priorities {
|
||||
str := p.String()
|
||||
parsed := libsnd.ParsePriority(str)
|
||||
Expect(parsed).To(Equal(p), "Round-trip failed for %v", p)
|
||||
}
|
||||
})
|
||||
|
||||
It("should maintain ContentType values", func() {
|
||||
types := []libsnd.ContentType{
|
||||
libsnd.ContentPlainText,
|
||||
libsnd.ContentHTML,
|
||||
}
|
||||
|
||||
for _, t := range types {
|
||||
str := t.String()
|
||||
Expect(str).ToNot(BeEmpty())
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user