From 70d1d6bce8e7331724dd480782fc74607160afc3 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Tue, 18 Nov 2025 17:54:34 +0100 Subject: [PATCH] Initial release --- .gitignore | 2 + LICENSE | 21 +++++ README.md | 225 ++++++++++++++++++++++++++++++++++++++++++++++++++++ client.go | 83 +++++++++++++++++++ errors.go | 36 +++++++++ go.mod | 3 + message.go | 147 ++++++++++++++++++++++++++++++++++ options.go | 26 ++++++ response.go | 33 ++++++++ 9 files changed, 576 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 client.go create mode 100644 errors.go create mode 100644 go.mod create mode 100644 message.go create mode 100644 options.go create mode 100644 response.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a95508 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +.vscode/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..df28f57 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..12d8d7e --- /dev/null +++ b/README.md @@ -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("

Hello!

This is a test message.

") + + // 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("

Newsletter

Your monthly update...

"). + 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)) \ No newline at end of file diff --git a/client.go b/client.go new file mode 100644 index 0000000..e56babb --- /dev/null +++ b/client.go @@ -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 +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..9d3e22a --- /dev/null +++ b/errors.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bf8c1e1 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.beautifulmachines.dev/jakoubek/sendamatic + +go 1.25.4 diff --git a/message.go b/message.go new file mode 100644 index 0000000..279a877 --- /dev/null +++ b/message.go @@ -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 +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..322aad9 --- /dev/null +++ b/options.go @@ -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 + } +} diff --git a/response.go b/response.go new file mode 100644 index 0000000..40af7d8 --- /dev/null +++ b/response.go @@ -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 +}