Initial release

This commit is contained in:
Oliver Jakoubek 2025-11-18 17:54:34 +01:00
commit 70d1d6bce8
9 changed files with 576 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.idea/
.vscode/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Oliver Jakoubek, Beautiful Machines
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.

225
README.md Normal file
View file

@ -0,0 +1,225 @@
# Sendamatic Go Client
A Go client library for the [Sendamatic](https://www.sendamatic.net) email delivery API.
## Features
- Simple and idiomatic Go API
- Context support for timeouts and cancellation
- Fluent message builder interface
- Support for HTML and plain text emails
- File attachments with automatic base64 encoding
- Custom headers
- Multiple recipients (To, CC, BCC)
- Comprehensive error handling
## Installation
```bash
go get code.beautifulmachines.dev/jakoubek/sendamatic
```
## Quick Start
```go
package main
import (
"context"
"log"
"code.beautifulmachines.dev/jakoubek/sendamatic"
)
func main() {
// Create client
client := sendamatic.NewClient("your-user-id", "your-password")
// Build message
msg := sendamatic.NewMessage().
SetSender("sender@example.com").
AddTo("recipient@example.com").
SetSubject("Hello from Sendamatic").
SetTextBody("This is a test message.").
SetHTMLBody("<h1>Hello!</h1><p>This is a test message.</p>")
// Send email
resp, err := client.Send(context.Background(), msg)
if err != nil {
log.Fatal(err)
}
log.Printf("Email sent successfully: %d", resp.StatusCode)
}
```
## Usage Examples
### Basic Email
```go
msg := sendamatic.NewMessage().
SetSender("sender@example.com").
AddTo("recipient@example.com").
SetSubject("Hello World").
SetTextBody("This is a plain text email.")
resp, err := client.Send(context.Background(), msg)
```
### HTML Email with Multiple Recipients
```go
msg := sendamatic.NewMessage().
SetSender("newsletter@example.com").
AddTo("user1@example.com").
AddTo("user2@example.com").
AddCC("manager@example.com").
AddBCC("archive@example.com").
SetSubject("Monthly Newsletter").
SetHTMLBody("<h1>Newsletter</h1><p>Your monthly update...</p>").
SetTextBody("Newsletter - Your monthly update...")
```
### Email with Attachments
```go
// From file path
msg := sendamatic.NewMessage().
SetSender("sender@example.com").
AddTo("recipient@example.com").
SetSubject("Invoice").
SetTextBody("Please find your invoice attached.")
err := msg.AttachFileFromPath("./invoice.pdf", "application/pdf")
if err != nil {
log.Fatal(err)
}
// Or from byte slice
pdfData := []byte{...}
msg.AttachFile("invoice.pdf", "application/pdf", pdfData)
```
### Custom Headers
```go
msg := sendamatic.NewMessage().
SetSender("sender@example.com").
AddTo("recipient@example.com").
SetSubject("Custom Headers").
SetTextBody("Email with custom headers").
AddHeader("Reply-To", "support@example.com").
AddHeader("X-Priority", "1")
```
### With Timeout
```go
client := sendamatic.NewClient(
"user-id",
"password",
sendamatic.WithTimeout(45*time.Second),
)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := client.Send(ctx, msg)
```
### Custom HTTP Client
```go
httpClient := &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
},
}
client := sendamatic.NewClient(
"user-id",
"password",
sendamatic.WithHTTPClient(httpClient),
)
```
## Configuration Options
The client supports various configuration options via the functional options pattern:
```go
client := sendamatic.NewClient(
"user-id",
"password",
sendamatic.WithBaseURL("https://custom.api.url"),
sendamatic.WithTimeout(60*time.Second),
sendamatic.WithHTTPClient(customHTTPClient),
)
```
## Response Handling
The `SendResponse` provides methods to check the delivery status:
```go
resp, err := client.Send(ctx, msg)
if err != nil {
log.Fatal(err)
}
// Check overall success
if resp.IsSuccess() {
log.Println("Email sent successfully")
}
// Check individual recipient status
for email := range resp.Recipients {
if status, ok := resp.GetStatus(email); ok {
log.Printf("Recipient %s: status %d", email, status)
}
if msgID, ok := resp.GetMessageID(email); ok {
log.Printf("Message ID: %s", msgID)
}
}
```
## Error Handling
The library provides typed errors for better error handling:
```go
resp, err := client.Send(ctx, msg)
if err != nil {
var apiErr *sendamatic.APIError
if errors.As(err, &apiErr) {
log.Printf("API error (status %d): %s", apiErr.StatusCode, apiErr.Message)
if apiErr.ValidationErrors != "" {
log.Printf("Validation: %s", apiErr.ValidationErrors)
}
} else {
log.Printf("Other error: %v", err)
}
}
```
## Requirements
- Go 1.22 or higher
- Valid Sendamatic account with API credentials
## API Credentials
Your API credentials consist of:
- **User ID**: Your Mail Credential User ID
- **Password**: Your Mail Credential Password
Find these in your Sendamatic dashboard under Mail Credentials.
## Documentation
For detailed API documentation, visit:
- [Sendamatic API Documentation](https://docs.sendamatic.net/api/send/)
- [Sendamatic Website](https://www.sendamatic.net)
## License
MIT License - see [LICENSE](LICENSE) file for details.
## Contributing
Contributions are welcome! Please feel free to submit issues or pull requests.
## Author
Oliver Jakoubek ([info@jakoubek.net](mailto:info@jakoubek.net))

83
client.go Normal file
View file

@ -0,0 +1,83 @@
package sendamatic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
defaultBaseURL = "https://send.api.sendamatic.net"
defaultTimeout = 30 * time.Second
)
type Client struct {
apiKey string
baseURL string
httpClient *http.Client
}
func NewClient(userID, password string, opts ...Option) *Client {
c := &Client{
apiKey: fmt.Sprintf("%s-%s", userID, password),
baseURL: defaultBaseURL,
httpClient: &http.Client{
Timeout: defaultTimeout,
},
}
// Optionen anwenden
for _, opt := range opts {
opt(c)
}
return c
}
// Send versendet eine E-Mail über die Sendamatic API
func (c *Client) Send(ctx context.Context, msg *Message) (*SendResponse, error) {
if err := msg.Validate(); err != nil {
return nil, fmt.Errorf("message validation failed: %w", err)
}
payload, err := json.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("failed to marshal message: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/send", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", c.apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Fehlerbehandlung für 4xx und 5xx
if resp.StatusCode >= 400 {
return nil, parseErrorResponse(resp.StatusCode, body)
}
var sendResp SendResponse
if err := json.Unmarshal(body, &sendResp.Recipients); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
sendResp.StatusCode = resp.StatusCode
return &sendResp, nil
}

36
errors.go Normal file
View file

@ -0,0 +1,36 @@
package sendamatic
import (
"encoding/json"
"fmt"
)
// APIError repräsentiert einen API-Fehler
type APIError struct {
StatusCode int `json:"-"`
Message string `json:"error"`
ValidationErrors string `json:"validation_errors,omitempty"`
JSONPath string `json:"json_path,omitempty"`
Sender string `json:"sender,omitempty"`
SMTPCode int `json:"smtp_code,omitempty"`
}
func (e *APIError) Error() string {
if e.ValidationErrors != "" {
return fmt.Sprintf("sendamatic api error (status %d): %s (path: %s)",
e.StatusCode, e.ValidationErrors, e.JSONPath)
}
return fmt.Sprintf("sendamatic api error (status %d): %s", e.StatusCode, e.Message)
}
func parseErrorResponse(statusCode int, body []byte) error {
var apiErr APIError
apiErr.StatusCode = statusCode
if err := json.Unmarshal(body, &apiErr); err != nil {
// Fallback, falls JSON nicht parsebar ist
apiErr.Message = string(body)
}
return &apiErr
}

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module code.beautifulmachines.dev/jakoubek/sendamatic
go 1.25.4

147
message.go Normal file
View file

@ -0,0 +1,147 @@
package sendamatic
import (
"encoding/base64"
"errors"
"os"
)
// Message repräsentiert eine E-Mail-Nachricht
type Message struct {
To []string `json:"to"`
CC []string `json:"cc,omitempty"`
BCC []string `json:"bcc,omitempty"`
Sender string `json:"sender"`
Subject string `json:"subject"`
TextBody string `json:"text_body,omitempty"`
HTMLBody string `json:"html_body,omitempty"`
Headers []Header `json:"headers,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
}
// Header repräsentiert einen benutzerdefinierten E-Mail-Header
type Header struct {
Header string `json:"header"`
Value string `json:"value"`
}
// Attachment repräsentiert einen E-Mail-Anhang
type Attachment struct {
Filename string `json:"filename"`
Data string `json:"data"` // Base64-kodiert
MimeType string `json:"mimetype"`
}
// NewMessage erstellt eine neue Message
func NewMessage() *Message {
return &Message{
To: []string{},
CC: []string{},
BCC: []string{},
Headers: []Header{},
Attachments: []Attachment{},
}
}
// AddTo fügt einen Empfänger hinzu
func (m *Message) AddTo(email string) *Message {
m.To = append(m.To, email)
return m
}
// AddCC fügt einen CC-Empfänger hinzu
func (m *Message) AddCC(email string) *Message {
m.CC = append(m.CC, email)
return m
}
// AddBCC fügt einen BCC-Empfänger hinzu
func (m *Message) AddBCC(email string) *Message {
m.BCC = append(m.BCC, email)
return m
}
// SetSender setzt den Absender
func (m *Message) SetSender(email string) *Message {
m.Sender = email
return m
}
// SetSubject setzt den Betreff
func (m *Message) SetSubject(subject string) *Message {
m.Subject = subject
return m
}
// SetTextBody setzt den Text-Körper
func (m *Message) SetTextBody(body string) *Message {
m.TextBody = body
return m
}
// SetHTMLBody setzt den HTML-Körper
func (m *Message) SetHTMLBody(body string) *Message {
m.HTMLBody = body
return m
}
// AddHeader fügt einen benutzerdefinierten Header hinzu
func (m *Message) AddHeader(name, value string) *Message {
m.Headers = append(m.Headers, Header{
Header: name,
Value: value,
})
return m
}
// AttachFile fügt eine Datei als Anhang hinzu
func (m *Message) AttachFile(filename, mimeType string, data []byte) *Message {
m.Attachments = append(m.Attachments, Attachment{
Filename: filename,
Data: base64.StdEncoding.EncodeToString(data),
MimeType: mimeType,
})
return m
}
// AttachFileFromPath lädt eine Datei vom Dateisystem und fügt sie als Anhang hinzu
func (m *Message) AttachFileFromPath(path, mimeType string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
// Extrahiere Dateinamen aus Pfad
filename := path
if idx := len(path) - 1; idx >= 0 {
for i := idx; i >= 0; i-- {
if path[i] == '/' || path[i] == '\\' {
filename = path[i+1:]
break
}
}
}
m.AttachFile(filename, mimeType, data)
return nil
}
// Validate prüft, ob die Message gültig ist
func (m *Message) Validate() error {
if len(m.To) == 0 {
return errors.New("at least one recipient required")
}
if len(m.To) > 255 {
return errors.New("maximum 255 recipients allowed")
}
if m.Sender == "" {
return errors.New("sender is required")
}
if m.Subject == "" {
return errors.New("subject is required")
}
if m.TextBody == "" && m.HTMLBody == "" {
return errors.New("either text_body or html_body is required")
}
return nil
}

26
options.go Normal file
View file

@ -0,0 +1,26 @@
package sendamatic
import (
"net/http"
"time"
)
type Option func(*Client)
func WithBaseURL(baseURL string) Option {
return func(c *Client) {
c.baseURL = baseURL
}
}
func WithHTTPClient(client *http.Client) Option {
return func(c *Client) {
c.httpClient = client
}
}
func WithTimeout(timeout time.Duration) Option {
return func(c *Client) {
c.httpClient.Timeout = timeout
}
}

33
response.go Normal file
View file

@ -0,0 +1,33 @@
package sendamatic
// SendResponse repräsentiert die Antwort auf einen Send-Request
type SendResponse struct {
StatusCode int
Recipients map[string][2]interface{} // Email -> [StatusCode, MessageID]
}
// IsSuccess prüft, ob die gesamte Sendung erfolgreich war
func (r *SendResponse) IsSuccess() bool {
return r.StatusCode == 200
}
// GetMessageID gibt die Message-ID für einen Empfänger zurück
func (r *SendResponse) GetMessageID(email string) (string, bool) {
if info, ok := r.Recipients[email]; ok && len(info) >= 2 {
if msgID, ok := info[1].(string); ok {
return msgID, true
}
}
return "", false
}
// GetStatus gibt den Status-Code für einen Empfänger zurück
func (r *SendResponse) GetStatus(email string) (int, bool) {
if info, ok := r.Recipients[email]; ok && len(info) >= 1 {
// Die API gibt float64 zurück, da JSON numbers als float64 dekodiert werden
if status, ok := info[0].(float64); ok {
return int(status), true
}
}
return 0, false
}