commit 70d1d6bce8e7331724dd480782fc74607160afc3 Author: Oliver Jakoubek Date: Tue Nov 18 17:54:34 2025 +0100 Initial release 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 +}