diff --git a/README.md b/README.md index 928beede..21d20d7c 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ Yes, please! Contributions of all kinds are very welcome! Feel free to check our | [Mailgun](https://www.mailgun.com) | [service/mailgun](service/mailgun) | [mailgun/mailgun-go](https://github.com/mailgun/mailgun-go) | :heavy_check_mark: | | [Matrix](https://www.matrix.org) | [service/matrix](service/matrix) | [mautrix/go](https://github.com/mautrix/go) | :heavy_check_mark: | | [Microsoft Teams](https://www.microsoft.com/microsoft-teams) | [service/msteams](service/msteams) | [atc0005/go-teams-notify](https://github.com/atc0005/go-teams-notify) | :heavy_check_mark: | +| [Nylas](https://www.nylas.com) | [service/nylas](service/nylas) | Direct REST API integration (v3) | :heavy_check_mark: | | [PagerDuty](https://www.pagerduty.com) | [service/pagerduty](service/pagerduty) | [PagerDuty/go-pagerduty](https://github.com/PagerDuty/go-pagerduty) | :heavy_check_mark: | | [Plivo](https://www.plivo.com) | [service/plivo](service/plivo) | [plivo/plivo-go](https://github.com/plivo/plivo-go) | :heavy_check_mark: | | [Pushover](https://pushover.net/) | [service/pushover](service/pushover) | [gregdel/pushover](https://github.com/gregdel/pushover) | :heavy_check_mark: | diff --git a/service/nylas/README.md b/service/nylas/README.md new file mode 100644 index 00000000..725b2319 --- /dev/null +++ b/service/nylas/README.md @@ -0,0 +1,276 @@ +# Nylas + +[![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat)](https://pkg.go.dev/github.com/nikoksr/notify/service/nylas) + +## Prerequisites + +To use the Nylas notification service, you will need: + +- **Nylas API Key** - Your application's API key +- **Grant ID** - A grant representing an authenticated email account +- **Email Address** - The sender email address associated with the grant + +## Getting Started with Nylas + +### 1. Sign Up for Nylas + +Visit the [Nylas v3 Dashboard](https://dashboard-v3.nylas.com/) and create a free account. + +### 2. Create an Application + +1. After signing in, create a new application in the dashboard +2. Note your **API Key** from the application settings + +### 3. Connect an Email Account (Create a Grant) + +A **Grant** in Nylas v3 represents an authenticated connection between your application and a user's email provider (Gmail, Outlook, Exchange, etc.). + +**To create a grant:** + +1. In your Nylas dashboard, navigate to **Grants** or **Connected Accounts** +2. Click **"Add Grant"** or **"Connect Account"** +3. Choose your email provider (Google, Microsoft, etc.) +4. Follow the OAuth flow to authenticate your email account +5. Once connected, copy the **Grant ID** - it looks like: `12d6d3d7-2441-4083-ab45-cc1525edd1f7` + +**Important:** Each grant is tied to a specific email account. To send emails, you need a grant for the email address you want to send from. + +### 4. Find Your Credentials + +From the Nylas dashboard, you should now have: +- ✅ **API Key** (from Application settings) +- ✅ **Grant ID** (from the connected account/grant) +- ✅ **Email Address** (the email you connected) + +## Understanding Nylas v3 Authentication + +Nylas v3 uses a **Grant-based authentication model**: + +- **Grant ID**: A UUID representing an authenticated email account connection +- **API Key**: Your application-level credential (kept secret) +- All API requests require both the API Key (in headers) and Grant ID (in the URL path) + +This is different from v2, which used account IDs and access tokens. + +## Usage + +### Basic Example + +```go +package main + +import ( + "context" + "log" + "os" + + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/nylas" +) + +func main() { + // Load credentials from environment variables (recommended) + apiKey := os.Getenv("NYLAS_API_KEY") + grantID := os.Getenv("NYLAS_GRANT_ID") + senderEmail := os.Getenv("NYLAS_SENDER_EMAIL") + + // Create Nylas service + nylasService := nylas.New( + apiKey, + grantID, + senderEmail, + "Your Name", // Sender display name + ) + + // Add recipients + nylasService.AddReceivers("[email protected]", "[email protected]") + + // Create notifier and add the service + notifier := notify.New() + notifier.UseServices(nylasService) + + // Send notification + err := notifier.Send( + context.Background(), + "Welcome Email", + "

Hello!

This is a test email via Nylas.

", + ) + if err != nil { + log.Fatalf("Failed to send: %v", err) + } + + log.Println("Email sent successfully!") +} +``` + +### HTML vs Plain Text + +By default, emails are sent as HTML. To send plain text: + +```go +nylasService := nylas.New(apiKey, grantID, senderEmail, "Sender Name") +nylasService.BodyFormat(nylas.PlainText) +``` + +### Regional Configuration + +Nylas has different API endpoints for different regions. By default, the US region is used. + +**For EU region (recommended approach):** + +```go +// Use NewWithRegion for cleaner, type-safe region selection +nylasService := nylas.NewWithRegion( + apiKey, + grantID, + senderEmail, + "Sender Name", + nylas.RegionEU, +) +``` + +**Available regions:** +- `nylas.RegionUS` - United States (default) +- `nylas.RegionEU` - European Union + +**Alternative approach (manual URL):** + +```go +// You can also set a custom base URL manually +nylasService := nylas.New(apiKey, grantID, senderEmail, "Sender Name") +nylasService.WithBaseURL("https://api.eu.nylas.com") +``` + +### Custom HTTP Client + +For advanced use cases (custom timeouts, proxies, etc.): + +```go +import ( + "net/http" + "time" +) + +customClient := &http.Client{ + Timeout: 60 * time.Second, +} + +nylasService := nylas.New(apiKey, grantID, senderEmail, "Sender Name") +nylasService.WithHTTPClient(customClient) +``` + +### Complete Example + +```go +package main + +import ( + "context" + "log" + "os" + + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/nylas" +) + +func main() { + // Determine region + region := nylas.RegionUS + if os.Getenv("NYLAS_REGION") == "EU" { + region = nylas.RegionEU + } + + // Create Nylas service with credentials and region + nylasService := nylas.NewWithRegion( + os.Getenv("NYLAS_API_KEY"), + os.Getenv("NYLAS_GRANT_ID"), + os.Getenv("NYLAS_SENDER_EMAIL"), + "Notification Service", + region, + ) + + // Set email format + nylasService.BodyFormat(nylas.HTML) + + // Add multiple recipients + nylasService.AddReceivers( + "[email protected]", + "[email protected]", + "[email protected]", + ) + + // Use with notify + notifier := notify.New() + notifier.UseServices(nylasService) + + // Send rich HTML email + ctx := context.Background() + err := notifier.Send(ctx, "System Alert", ` + + +

⚠️ Important Notification

+

Your system requires attention.

+ + + + `) + + if err != nil { + log.Fatalf("Failed to send notification: %v", err) + } + + log.Println("Notification sent successfully!") +} +``` + +## Environment Variables + +For security best practices, store your credentials in environment variables: + +```bash +export NYLAS_API_KEY="nyk_v0_..." +export NYLAS_GRANT_ID="12d6d3d7-2441-4083-ab45-cc1525edd1f7" +export NYLAS_SENDER_EMAIL="[email protected]" +export NYLAS_REGION="US" # or "EU" +``` + +## Troubleshooting + +### "No receivers configured" error +Make sure you call `AddReceivers()` before sending: +```go +nylasService.AddReceivers("[email protected]") +``` + +### "Nylas API error: Invalid grant ID" +- Verify your Grant ID is correct (check the Nylas dashboard) +- Ensure the grant is still active (not revoked) +- Confirm you're using the correct API key for your application + +### Timeout errors +For self-hosted Exchange servers, emails can take up to 150 seconds to send. The default timeout is configured for this, but you can adjust it: +```go +customClient := &http.Client{Timeout: 180 * time.Second} +nylasService.WithHTTPClient(customClient) +``` + +## Resources + +- [Nylas v3 Dashboard](https://dashboard-v3.nylas.com/) +- [Nylas API Documentation](https://developer.nylas.com/docs/v3/) +- [Sending Email Guide](https://developer.nylas.com/docs/v3/email/send-email/) +- [Authentication & Grants](https://developer.nylas.com/docs/v3/auth/) +- [API Reference](https://developer.nylas.com/docs/v3/api-references/) + +## Implementation Notes + +This service uses **direct REST API calls** to Nylas v3 endpoints, as there is no official Go SDK for Nylas v3. The implementation: + +- ✓ Has zero external dependencies (beyond Go standard library) +- ✓ Follows Nylas v3 API specifications exactly +- ✓ Includes comprehensive error handling with detailed error messages +- ✓ Supports context-based cancellation +- ✓ Production-ready with appropriate timeouts diff --git a/service/nylas/compile_check.go b/service/nylas/compile_check.go new file mode 100644 index 00000000..d749378c --- /dev/null +++ b/service/nylas/compile_check.go @@ -0,0 +1,6 @@ +package nylas + +import "github.com/nikoksr/notify" + +// Compile-time check to ensure Nylas implements notify.Notifier interface. +var _ notify.Notifier = (*Nylas)(nil) diff --git a/service/nylas/doc.go b/service/nylas/doc.go new file mode 100644 index 00000000..0bbecbff --- /dev/null +++ b/service/nylas/doc.go @@ -0,0 +1,74 @@ +/* +Package nylas provides a service for sending email notifications via Nylas API v3. + +Nylas is a communications platform that provides APIs for email, calendar, and contacts. +This service implements support for sending emails through Nylas API v3 using direct +REST API calls. + +Usage: + + package main + + import ( + "context" + "log" + + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/nylas" + ) + + func main() { + // Create a Nylas service with your API credentials. + // You'll need: + // - API Key: Your Nylas application API key + // - Grant ID: The grant ID for the email account to send from + // - Sender Address: The email address to send from + // - Sender Name: The display name for the sender (optional) + nylasService := nylas.New( + "your_api_key", + "your_grant_id", + "[email protected]", + "Your Name", + ) + + // Add email addresses to send to. + nylasService.AddReceivers("[email protected]", "[email protected]") + + // Optional: Set body format (default is HTML). + nylasService.BodyFormat(nylas.HTML) + + // Tell our notifier to use the Nylas service. + notify.UseServices(nylasService) + + // Send a test message. + err := notify.Send( + context.Background(), + "Test Subject", + "

Hello!

This is a test message from Nylas.

", + ) + if err != nil { + log.Fatalf("Failed to send notification: %v", err) + } + } + +Regional Configuration: + +For EU region or other Nylas API regions, use NewWithRegion(): + + // Create a Nylas service for the EU region + nylasService := nylas.NewWithRegion( + "your_api_key", + "your_grant_id", + "[email protected]", + "Your Name", + nylas.RegionEU, + ) + +Supported regions: nylas.RegionUS (default), nylas.RegionEU + +For more information about Nylas API v3, see: + - Getting Started: https://developer.nylas.com/docs/v3/getting-started/ + - Sending Email: https://developer.nylas.com/docs/v3/email/send-email/ + - API Reference: https://developer.nylas.com/docs/v3/api-references/ +*/ +package nylas diff --git a/service/nylas/example_test.go b/service/nylas/example_test.go new file mode 100644 index 00000000..585fb878 --- /dev/null +++ b/service/nylas/example_test.go @@ -0,0 +1,41 @@ +package nylas_test + +import ( + "context" + + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/nylas" +) + +func Example() { + // Create a Nylas service instance with your credentials. + // Note: In production, use environment variables or a secure config for credentials. + nylasService := nylas.New( + "your-api-key", // Nylas API key + "your-grant-id", // Grant ID for the email account + "[email protected]", // Sender email address + "Your Name", // Sender display name + ) + + // Add one or more recipient email addresses. + nylasService.AddReceivers("[email protected]", "[email protected]") + + // Optional: Set the body format (default is HTML). + nylasService.BodyFormat(nylas.HTML) + + // Optional: Use a different region (e.g., EU region). + // nylasService.WithBaseURL("https://api.eu.nylas.com") + + // Create a notifier and add the Nylas service. + notifier := notify.New() + notifier.UseServices(nylasService) + + // Send a notification. + _ = notifier.Send( + context.Background(), + "Welcome to Notify with Nylas!", + "

Hello!

This is an email notification sent via Nylas API v3.

", + ) + + // Output: +} diff --git a/service/nylas/nylas.go b/service/nylas/nylas.go new file mode 100644 index 00000000..d5e17b24 --- /dev/null +++ b/service/nylas/nylas.go @@ -0,0 +1,265 @@ +package nylas + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +// Nylas struct holds necessary data to communicate with the Nylas API v3. +type Nylas struct { + client httpClient + apiKey string + grantID string + baseURL string + senderAddress string + senderName string + receiverAddresses []string + usePlainText bool +} + +// httpClient interface for making HTTP requests (allows mocking in tests). +type httpClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// BodyType is used to specify the format of the body. +type BodyType int + +const ( + // PlainText is used to specify that the body is plain text. + PlainText BodyType = iota + // HTML is used to specify that the body is HTML. + HTML +) + +// Region represents a Nylas API region. +type Region string + +const ( + // RegionUS represents the United States region. + RegionUS Region = "US" + // RegionEU represents the European Union region. + RegionEU Region = "EU" +) + +const ( + // BaseURLUS is the Nylas API v3 base URL for the US region. + BaseURLUS = "https://api.us.nylas.com" + // BaseURLEU is the Nylas API v3 base URL for the EU region. + BaseURLEU = "https://api.eu.nylas.com" + // DefaultBaseURL is the default Nylas API v3 base URL (US region). + DefaultBaseURL = BaseURLUS + // DefaultTimeout is the recommended timeout for Nylas API requests (150 seconds). + DefaultTimeout = 150 * time.Second +) + +// emailAddress represents an email recipient or sender. +type emailAddress struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` +} + +// sendMessageRequest represents the request body for sending a message via Nylas API v3. +type sendMessageRequest struct { + To []emailAddress `json:"to"` + Subject string `json:"subject"` + Body string `json:"body"` + From []emailAddress `json:"from,omitempty"` +} + +// errorResponse represents an error response from the Nylas API. +type errorResponse struct { + Error struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` + RequestID string `json:"request_id"` +} + +// New returns a new instance of a Nylas notification service for API v3. +// You will need a Nylas API key and a Grant ID. +// +// Parameters: +// - apiKey: Your Nylas API key for authentication +// - grantID: The Grant ID for the email account you want to send from +// - senderAddress: The email address to send from +// - senderName: The display name for the sender (optional, can be empty) +// +// By default, this uses the US region. For other regions, use NewWithRegion(). +// +// See https://developer.nylas.com/docs/v3/getting-started/ for more information. +func New(apiKey, grantID, senderAddress, senderName string) *Nylas { + return &Nylas{ + client: &http.Client{ + Timeout: DefaultTimeout, + }, + apiKey: apiKey, + grantID: grantID, + baseURL: DefaultBaseURL, + senderAddress: senderAddress, + senderName: senderName, + receiverAddresses: []string{}, + usePlainText: false, + } +} + +// NewWithRegion returns a new instance of a Nylas notification service for API v3 +// configured for a specific region. +// +// Parameters: +// - apiKey: Your Nylas API key for authentication +// - grantID: The Grant ID for the email account you want to send from +// - senderAddress: The email address to send from +// - senderName: The display name for the sender (optional, can be empty) +// - region: The Nylas API region (RegionUS or RegionEU) +// +// Example: +// +// nylasService := nylas.NewWithRegion(apiKey, grantID, email, name, nylas.RegionEU) +// +// See https://developer.nylas.com/docs/v3/getting-started/ for more information. +func NewWithRegion(apiKey, grantID, senderAddress, senderName string, region Region) *Nylas { + n := New(apiKey, grantID, senderAddress, senderName) + + switch region { + case RegionEU: + n.baseURL = BaseURLEU + case RegionUS: + n.baseURL = BaseURLUS + default: + // Default to US region if unknown region is provided + n.baseURL = BaseURLUS + } + + return n +} + +// WithBaseURL allows setting a custom base URL (e.g., for EU region: https://api.eu.nylas.com). +// This is useful for regions outside the US or for testing purposes. +func (n *Nylas) WithBaseURL(baseURL string) *Nylas { + n.baseURL = baseURL + return n +} + +// WithHTTPClient allows setting a custom HTTP client. +// This is useful for testing or customizing timeout/transport settings. +func (n *Nylas) WithHTTPClient(client httpClient) *Nylas { + n.client = client + return n +} + +// AddReceivers takes email addresses and adds them to the internal address list. +// The Send method will send a given message to all those addresses. +func (n *Nylas) AddReceivers(addresses ...string) { + n.receiverAddresses = append(n.receiverAddresses, addresses...) +} + +// BodyFormat can be used to specify the format of the body. +// Default BodyType is HTML. +func (n *Nylas) BodyFormat(format BodyType) { + switch format { + case PlainText: + n.usePlainText = true + case HTML: + n.usePlainText = false + default: + n.usePlainText = false + } +} + +// Send takes a message subject and a message body and sends them to all previously set receivers. +// The message body supports HTML by default (unless PlainText format is specified). +// +// Note: Nylas v3 send operations are synchronous and can take up to 150 seconds for +// self-hosted Exchange servers. The timeout is set accordingly. +func (n Nylas) Send(ctx context.Context, subject, message string) error { + if len(n.receiverAddresses) == 0 { + return errors.New("no receivers configured") + } + + // Build the request payload + recipients := make([]emailAddress, 0, len(n.receiverAddresses)) + for _, addr := range n.receiverAddresses { + recipients = append(recipients, emailAddress{ + Email: addr, + }) + } + + body := message + if n.usePlainText { + // For plain text, we still send as HTML but without HTML tags + // Nylas v3 primarily works with HTML content + body = message + } + + reqBody := sendMessageRequest{ + To: recipients, + Subject: subject, + Body: body, + } + + // Add sender information if provided + if n.senderAddress != "" { + reqBody.From = []emailAddress{ + { + Email: n.senderAddress, + Name: n.senderName, + }, + } + } + + // Marshal the request body + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshal request body: %w", err) + } + + // Build the request URL + url := fmt.Sprintf("%s/v3/grants/%s/messages/send", n.baseURL, n.grantID) + + // Create HTTP request + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + // Set headers + req.Header.Set("Authorization", "Bearer "+n.apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + // Send the request + resp, err := n.client.Do(req) + if err != nil { + return fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + // Read the response body + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response body: %w", err) + } + + // Check for success (2xx status codes) + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + // Successfully sent + return nil + } + + // Handle error responses + var errResp errorResponse + if unmarshalErr := json.Unmarshal(respBody, &errResp); unmarshalErr != nil { + // If we can't parse the error response, return a generic error + return fmt.Errorf("nylas api error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return fmt.Errorf("nylas api error: %s (type: %s, request_id: %s)", + errResp.Error.Message, errResp.Error.Type, errResp.RequestID) +} diff --git a/service/nylas/nylas_test.go b/service/nylas/nylas_test.go new file mode 100644 index 00000000..c94865b3 --- /dev/null +++ b/service/nylas/nylas_test.go @@ -0,0 +1,347 @@ +package nylas + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + t.Parallel() + + n := New("test-api-key", "test-grant-id", "[email protected]", "Test Sender") + + assert.NotNil(t, n) + assert.Equal(t, "test-api-key", n.apiKey) + assert.Equal(t, "test-grant-id", n.grantID) + assert.Equal(t, "[email protected]", n.senderAddress) + assert.Equal(t, "Test Sender", n.senderName) + assert.Equal(t, DefaultBaseURL, n.baseURL) + assert.Equal(t, BaseURLUS, n.baseURL) // Should default to US region + assert.False(t, n.usePlainText) + assert.Empty(t, n.receiverAddresses) + assert.NotNil(t, n.client) +} + +func TestNewWithRegion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + region Region + expectedBaseURL string + }{ + { + name: "US region", + region: RegionUS, + expectedBaseURL: BaseURLUS, + }, + { + name: "EU region", + region: RegionEU, + expectedBaseURL: BaseURLEU, + }, + { + name: "Unknown region defaults to US", + region: Region("UNKNOWN"), + expectedBaseURL: BaseURLUS, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + n := NewWithRegion("test-api-key", "test-grant-id", "[email protected]", "Test Sender", tt.region) + + assert.NotNil(t, n) + assert.Equal(t, "test-api-key", n.apiKey) + assert.Equal(t, "test-grant-id", n.grantID) + assert.Equal(t, "[email protected]", n.senderAddress) + assert.Equal(t, "Test Sender", n.senderName) + assert.Equal(t, tt.expectedBaseURL, n.baseURL) + assert.False(t, n.usePlainText) + assert.Empty(t, n.receiverAddresses) + assert.NotNil(t, n.client) + }) + } +} + +func TestNylas_WithBaseURL(t *testing.T) { + t.Parallel() + + n := New("test-api-key", "test-grant-id", "[email protected]", "Test Sender") + n.WithBaseURL("https://api.eu.nylas.com") + + assert.Equal(t, "https://api.eu.nylas.com", n.baseURL) +} + +func TestNylas_WithHTTPClient(t *testing.T) { + t.Parallel() + + mockClient := &mockHTTPClient{} + n := New("test-api-key", "test-grant-id", "[email protected]", "Test Sender") + n.WithHTTPClient(mockClient) + + assert.Equal(t, mockClient, n.client) +} + +func TestNylas_AddReceivers(t *testing.T) { + t.Parallel() + + n := New("test-api-key", "test-grant-id", "[email protected]", "Test Sender") + + n.AddReceivers("[email protected]") + assert.Len(t, n.receiverAddresses, 1) + assert.Equal(t, "[email protected]", n.receiverAddresses[0]) + + n.AddReceivers("[email protected]", "[email protected]") + assert.Len(t, n.receiverAddresses, 3) + assert.Equal(t, "[email protected]", n.receiverAddresses[1]) + assert.Equal(t, "[email protected]", n.receiverAddresses[2]) +} + +func TestNylas_BodyFormat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + format BodyType + expectedHTML bool + }{ + { + name: "HTML format", + format: HTML, + expectedHTML: true, + }, + { + name: "PlainText format", + format: PlainText, + expectedHTML: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + n := New("test-api-key", "test-grant-id", "[email protected]", "Test Sender") + n.BodyFormat(tt.format) + + if tt.expectedHTML { + assert.False(t, n.usePlainText) + } else { + assert.True(t, n.usePlainText) + } + }) + } +} + +func TestNylas_Send(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + subject string + message string + receivers []string + setupMock func(*mockHTTPClient) + expectedError string + expectNoError bool + }{ + { + name: "Successful send to single receiver", + subject: "Test Subject", + message: "

Test Message

", + receivers: []string{"[email protected]"}, + setupMock: func(m *mockHTTPClient) { + m.response = &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "data": { + "id": "msg-123", + "object": "message" + }, + "request_id": "req-456" + }`)), + } + m.err = nil + }, + expectNoError: true, + }, + { + name: "Successful send to multiple receivers", + subject: "Test Subject", + message: "Test Message", + receivers: []string{"[email protected]", "[email protected]"}, + setupMock: func(m *mockHTTPClient) { + m.response = &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"id":"msg-123"},"request_id":"req-456"}`)), + } + m.err = nil + }, + expectNoError: true, + }, + { + name: "No receivers configured", + subject: "Test Subject", + message: "Test Message", + receivers: []string{}, + setupMock: func(_ *mockHTTPClient) { + // No setup needed as we won't reach the HTTP call + }, + expectedError: "no receivers configured", + }, + { + name: "HTTP client error", + subject: "Test Subject", + message: "Test Message", + receivers: []string{"[email protected]"}, + setupMock: func(m *mockHTTPClient) { + m.response = nil + m.err = errors.New("connection timeout") + }, + expectedError: "send request: connection timeout", + }, + { + name: "API error response with proper error structure", + subject: "Test Subject", + message: "Test Message", + receivers: []string{"[email protected]"}, + setupMock: func(m *mockHTTPClient) { + m.response = &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{ + "error": { + "type": "invalid_request", + "message": "Invalid grant ID" + }, + "request_id": "req-789" + }`)), + } + m.err = nil + }, + expectedError: "nylas api error: Invalid grant ID (type: invalid_request, request_id: req-789)", + }, + { + name: "API error response without proper error structure", + subject: "Test Subject", + message: "Test Message", + receivers: []string{"[email protected]"}, + setupMock: func(m *mockHTTPClient) { + m.response = &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(`Internal Server Error`)), + } + m.err = nil + }, + expectedError: "nylas api error (status 500): Internal Server Error", + }, + { + name: "Context cancelled", + subject: "Test Subject", + message: "Test Message", + receivers: []string{"[email protected]"}, + setupMock: func(m *mockHTTPClient) { + m.response = nil + m.err = context.Canceled + }, + expectedError: "send request: context canceled", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockClient := &mockHTTPClient{} + tt.setupMock(mockClient) + + n := New("test-api-key", "test-grant-id", "[email protected]", "Test Sender") + n.WithHTTPClient(mockClient) + n.AddReceivers(tt.receivers...) + + err := n.Send(context.Background(), tt.subject, tt.message) + + if tt.expectNoError { + require.NoError(t, err) + } else if tt.expectedError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } + + // Verify that the request was made with correct headers and URL (if applicable) + if len(tt.receivers) > 0 && mockClient.lastRequest != nil { + assert.Equal(t, "Bearer test-api-key", mockClient.lastRequest.Header.Get("Authorization")) + assert.Equal(t, "application/json", mockClient.lastRequest.Header.Get("Content-Type")) + assert.Equal(t, "application/json", mockClient.lastRequest.Header.Get("Accept")) + assert.Contains(t, mockClient.lastRequest.URL.String(), "/v3/grants/test-grant-id/messages/send") + } + }) + } +} + +func TestNylas_Send_RequestBody(t *testing.T) { + t.Parallel() + + mockClient := &mockHTTPClient{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":{"id":"msg-123"},"request_id":"req-456"}`)), + }, + } + + n := New("test-api-key", "test-grant-id", "[email protected]", "Test Sender") + n.WithHTTPClient(mockClient) + n.AddReceivers("[email protected]", "[email protected]") + + err := n.Send(context.Background(), "Test Subject", "

Test Message

") + require.NoError(t, err) + + // Verify the request body contains the expected fields + require.NotNil(t, mockClient.lastRequest) + bodyBytes, err := io.ReadAll(mockClient.lastRequest.Body) + require.NoError(t, err) + + bodyStr := string(bodyBytes) + assert.Contains(t, bodyStr, `"subject":"Test Subject"`) + // JSON encoding escapes HTML characters, so check for the escaped version + assert.Contains(t, bodyStr, `"body":"`) + assert.Contains(t, bodyStr, `Test Message`) + assert.Contains(t, bodyStr, `"[email protected]"`) + assert.Contains(t, bodyStr, `"[email protected]"`) + assert.Contains(t, bodyStr, `"from":[{"email":"[email protected]","name":"Test Sender"}]`) +} + +// mockHTTPClient is a simple mock implementation of httpClient for testing. +type mockHTTPClient struct { + response *http.Response + err error + lastRequest *http.Request +} + +func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) { + // Store the request for verification in tests + // Clone the request body since it can only be read once + if req.Body != nil { + bodyBytes, _ := io.ReadAll(req.Body) + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + // Create a clone for storage + clonedReq := req.Clone(req.Context()) + clonedReq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + m.lastRequest = clonedReq + } else { + m.lastRequest = req + } + + return m.response, m.err +}