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 + +[](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", + "
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", ` + + +Your system requires attention.
+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!", + "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: "