diff --git a/internal/adapters/nylas/demo_transactional.go b/internal/adapters/nylas/demo_transactional.go new file mode 100644 index 0000000..7006250 --- /dev/null +++ b/internal/adapters/nylas/demo_transactional.go @@ -0,0 +1,17 @@ +package nylas + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +// SendTransactionalMessage returns a mock sent message for demo mode. +func (d *DemoClient) SendTransactionalMessage(ctx context.Context, domainName string, req *domain.SendMessageRequest) (*domain.Message, error) { + return &domain.Message{ + ID: "demo-transactional-message-id", + Subject: req.Subject, + To: req.To, + Body: req.Body, + }, nil +} diff --git a/internal/adapters/nylas/mock_transactional.go b/internal/adapters/nylas/mock_transactional.go new file mode 100644 index 0000000..d1ff83f --- /dev/null +++ b/internal/adapters/nylas/mock_transactional.go @@ -0,0 +1,23 @@ +package nylas + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +// SendTransactionalMessageFunc allows customization of SendTransactionalMessage behavior in tests. +var SendTransactionalMessageFunc func(ctx context.Context, domainName string, req *domain.SendMessageRequest) (*domain.Message, error) + +// SendTransactionalMessage sends an email via the domain-based transactional endpoint. +func (m *MockClient) SendTransactionalMessage(ctx context.Context, domainName string, req *domain.SendMessageRequest) (*domain.Message, error) { + if SendTransactionalMessageFunc != nil { + return SendTransactionalMessageFunc(ctx, domainName, req) + } + return &domain.Message{ + ID: "sent-transactional-message-id", + Subject: req.Subject, + To: req.To, + Body: req.Body, + }, nil +} diff --git a/internal/adapters/nylas/transactional.go b/internal/adapters/nylas/transactional.go new file mode 100644 index 0000000..65f716c --- /dev/null +++ b/internal/adapters/nylas/transactional.go @@ -0,0 +1,79 @@ +package nylas + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/nylas/cli/internal/domain" +) + +// SendTransactionalMessage sends an email via the domain-based transactional endpoint. +// Used for Inbox provider grants: POST /v3/domains/{domain}/messages/send +func (c *HTTPClient) SendTransactionalMessage(ctx context.Context, domainName string, req *domain.SendMessageRequest) (*domain.Message, error) { + queryURL := fmt.Sprintf("%s/v3/domains/%s/messages/send", c.baseURL, domainName) + + payload := map[string]any{ + "subject": req.Subject, + "body": req.Body, + "to": convertContactsToAPI(req.To), + } + + if len(req.From) > 0 { + payload["from"] = convertContactsToAPI(req.From) + } + if len(req.Cc) > 0 { + payload["cc"] = convertContactsToAPI(req.Cc) + } + if len(req.Bcc) > 0 { + payload["bcc"] = convertContactsToAPI(req.Bcc) + } + if len(req.ReplyTo) > 0 { + payload["reply_to"] = convertContactsToAPI(req.ReplyTo) + } + if req.ReplyToMsgID != "" { + payload["reply_to_message_id"] = req.ReplyToMsgID + } + if req.TrackingOpts != nil { + payload["tracking_options"] = req.TrackingOpts + } + if req.SendAt > 0 { + payload["send_at"] = req.SendAt + } + if len(req.Metadata) > 0 { + payload["metadata"] = req.Metadata + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + httpReq, err := http.NewRequestWithContext(ctx, "POST", queryURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + c.setAuthHeader(httpReq) + + resp, err := c.doRequest(ctx, httpReq) + if err != nil { + return nil, fmt.Errorf("%w: %v", domain.ErrNetworkError, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { + return nil, c.parseError(resp) + } + + var result struct { + Data messageResponse `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + msg := convertMessage(result.Data) + return &msg, nil +} diff --git a/internal/adapters/nylas/transactional_test.go b/internal/adapters/nylas/transactional_test.go new file mode 100644 index 0000000..b23bcf7 --- /dev/null +++ b/internal/adapters/nylas/transactional_test.go @@ -0,0 +1,234 @@ +//go:build !integration + +package nylas_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHTTPClient_SendTransactionalMessage(t *testing.T) { + tests := []struct { + name string + domainName string + request *domain.SendMessageRequest + expectedFields []string + statusCode int + wantErr bool + }{ + { + name: "sends basic transactional message", + domainName: "qasim.nylas.email", + request: &domain.SendMessageRequest{ + Subject: "Transactional Email", + Body: "This is a transactional email body", + To: []domain.EmailParticipant{{Name: "Recipient", Email: "recipient@example.com"}}, + From: []domain.EmailParticipant{{Email: "info@qasim.nylas.email"}}, + }, + expectedFields: []string{"subject", "body", "to", "from"}, + statusCode: http.StatusOK, + wantErr: false, + }, + { + name: "sends transactional message with CC and BCC", + domainName: "test.nylas.email", + request: &domain.SendMessageRequest{ + Subject: "With CC", + Body: "Body", + To: []domain.EmailParticipant{{Email: "to@example.com"}}, + From: []domain.EmailParticipant{{Email: "sender@test.nylas.email"}}, + Cc: []domain.EmailParticipant{{Email: "cc@example.com"}}, + Bcc: []domain.EmailParticipant{{Email: "bcc@example.com"}}, + }, + expectedFields: []string{"subject", "body", "to", "from", "cc", "bcc"}, + statusCode: http.StatusOK, + wantErr: false, + }, + { + name: "sends scheduled transactional message", + domainName: "scheduled.nylas.email", + request: &domain.SendMessageRequest{ + Subject: "Scheduled Email", + Body: "Scheduled body", + To: []domain.EmailParticipant{{Email: "to@example.com"}}, + From: []domain.EmailParticipant{{Email: "noreply@scheduled.nylas.email"}}, + SendAt: 1704153600, + }, + expectedFields: []string{"subject", "body", "to", "from", "send_at"}, + statusCode: http.StatusAccepted, + wantErr: false, + }, + { + name: "sends transactional message with tracking", + domainName: "track.nylas.email", + request: &domain.SendMessageRequest{ + Subject: "Tracked Email", + Body: "Tracked body", + To: []domain.EmailParticipant{{Email: "to@example.com"}}, + From: []domain.EmailParticipant{{Email: "marketing@track.nylas.email"}}, + TrackingOpts: &domain.TrackingOptions{ + Opens: true, + Links: true, + Label: "campaign-1", + }, + }, + expectedFields: []string{"subject", "body", "to", "from", "tracking_options"}, + statusCode: http.StatusOK, + wantErr: false, + }, + { + name: "sends transactional message with metadata", + domainName: "meta.nylas.email", + request: &domain.SendMessageRequest{ + Subject: "With Metadata", + Body: "Body", + To: []domain.EmailParticipant{{Email: "to@example.com"}}, + From: []domain.EmailParticipant{{Email: "system@meta.nylas.email"}}, + Metadata: map[string]string{"campaign": "promo", "source": "cli"}, + }, + expectedFields: []string{"subject", "body", "to", "from", "metadata"}, + statusCode: http.StatusOK, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/v3/domains/"+tt.domainName+"/messages/send", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var body map[string]any + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + + for _, field := range tt.expectedFields { + assert.Contains(t, body, field, "Missing field: %s", field) + } + + response := map[string]any{ + "data": map[string]any{ + "id": "sent-transactional-msg-123", + "subject": tt.request.Subject, + "date": 1704067200, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + ctx := context.Background() + msg, err := client.SendTransactionalMessage(ctx, tt.domainName, tt.request) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, "sent-transactional-msg-123", msg.ID) + }) + } +} + +func TestHTTPClient_SendTransactionalMessage_ErrorHandling(t *testing.T) { + tests := []struct { + name string + statusCode int + response map[string]any + errContains string + }{ + { + name: "handles invalid domain", + statusCode: http.StatusNotFound, + response: map[string]any{ + "error": map[string]string{"message": "Domain not found"}, + }, + errContains: "Domain not found", + }, + { + name: "handles invalid recipient", + statusCode: http.StatusBadRequest, + response: map[string]any{ + "error": map[string]string{"message": "Invalid recipient email address"}, + }, + errContains: "Invalid recipient", + }, + { + name: "handles rate limit", + statusCode: http.StatusTooManyRequests, + response: map[string]any{ + "error": map[string]string{"message": "Rate limit exceeded"}, + }, + errContains: "Rate limit", + }, + { + name: "handles server error", + statusCode: http.StatusInternalServerError, + response: map[string]any{ + "error": map[string]string{"message": "Internal server error"}, + }, + errContains: "Internal server error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + _ = json.NewEncoder(w).Encode(tt.response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + client.SetMaxRetries(0) // Disable retries for error handling tests + + ctx := context.Background() + req := &domain.SendMessageRequest{ + Subject: "Test", + Body: "Body", + To: []domain.EmailParticipant{{Email: "to@example.com"}}, + From: []domain.EmailParticipant{{Email: "sender@test.nylas.email"}}, + } + _, err := client.SendTransactionalMessage(ctx, "test.nylas.email", req) + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + }) + } +} + +func TestMockClient_SendTransactionalMessage(t *testing.T) { + mockClient := nylas.NewMockClient() + + ctx := context.Background() + req := &domain.SendMessageRequest{ + Subject: "Test Subject", + Body: "Test Body", + To: []domain.EmailParticipant{{Email: "to@example.com"}}, + From: []domain.EmailParticipant{{Email: "from@test.nylas.email"}}, + } + + msg, err := mockClient.SendTransactionalMessage(ctx, "test.nylas.email", req) + require.NoError(t, err) + assert.Equal(t, "sent-transactional-message-id", msg.ID) + assert.Equal(t, "Test Subject", msg.Subject) +} diff --git a/internal/cli/common/string.go b/internal/cli/common/string.go index 1f2b59c..8b9d2a6 100644 --- a/internal/cli/common/string.go +++ b/internal/cli/common/string.go @@ -1,5 +1,7 @@ package common +import "strings" + // Truncate shortens a string to maxLen characters, adding "..." if truncated. func Truncate(s string, maxLen int) string { if len(s) <= maxLen { @@ -10,3 +12,14 @@ func Truncate(s string, maxLen int) string { } return s[:maxLen-3] + "..." } + +// ExtractDomain extracts the domain portion from an email address. +// For example, "info@qasim.nylas.email" returns "qasim.nylas.email". +// Returns empty string if the email format is invalid. +func ExtractDomain(email string) string { + parts := strings.Split(email, "@") + if len(parts) == 2 { + return parts[1] + } + return "" +} diff --git a/internal/cli/common/string_test.go b/internal/cli/common/string_test.go index c347e01..412e0b5 100644 --- a/internal/cli/common/string_test.go +++ b/internal/cli/common/string_test.go @@ -74,3 +74,66 @@ func TestTruncate(t *testing.T) { }) } } + +func TestExtractDomain(t *testing.T) { + tests := []struct { + name string + email string + want string + }{ + { + name: "standard email", + email: "user@example.com", + want: "example.com", + }, + { + name: "nylas inbox email", + email: "info@qasim.nylas.email", + want: "qasim.nylas.email", + }, + { + name: "subdomain email", + email: "test@subdomain.domain.com", + want: "subdomain.domain.com", + }, + { + name: "no @ symbol", + email: "invalidemail", + want: "", + }, + { + name: "multiple @ symbols", + email: "user@domain@extra.com", + want: "", + }, + { + name: "empty string", + email: "", + want: "", + }, + { + name: "only @ symbol", + email: "@", + want: "", + }, + { + name: "@ at start", + email: "@domain.com", + want: "domain.com", + }, + { + name: "@ at end", + email: "user@", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractDomain(tt.email) + if got != tt.want { + t.Errorf("ExtractDomain(%q) = %q, want %q", tt.email, got, tt.want) + } + }) + } +} diff --git a/internal/cli/email/send.go b/internal/cli/email/send.go index ca3d613..3644107 100644 --- a/internal/cli/email/send.go +++ b/internal/cli/email/send.go @@ -274,9 +274,10 @@ Supports custom metadata: var msg *domain.Message var err error + // Get grant info to determine provider and email + grant, grantErr := client.GetGrant(ctx, grantID) + if sign { - // Get grant info to determine From email address - grant, grantErr := client.GetGrant(ctx, grantID) if grantErr == nil && grant != nil && grant.Email != "" { // Populate From field with grant's email address req.From = []domain.EmailParticipant{ @@ -298,7 +299,24 @@ Supports custom metadata: spinner := common.NewSpinner(sendMsg) spinner.Start() - msg, err = client.SendMessage(ctx, grantID, req) + // Auto-detect inbox provider and use transactional endpoint + if grantErr == nil && grant != nil && grant.Provider == domain.ProviderInbox { + // Inbox provider - use domain-based transactional send + emailDomain := common.ExtractDomain(grant.Email) + if emailDomain == "" { + spinner.Stop() + return struct{}{}, common.NewUserError( + "could not extract domain from grant email", + "Ensure the grant has a valid email address", + ) + } + // Set From field for transactional send (required) + req.From = []domain.EmailParticipant{{Email: grant.Email}} + msg, err = client.SendTransactionalMessage(ctx, emailDomain, req) + } else { + // Standard provider (Google/Microsoft/IMAP) - use grant-based send + msg, err = client.SendMessage(ctx, grantID, req) + } spinner.Stop() } diff --git a/internal/ports/nylas.go b/internal/ports/nylas.go index 29d5642..5b2b67f 100644 --- a/internal/ports/nylas.go +++ b/internal/ports/nylas.go @@ -19,6 +19,7 @@ type NylasClient interface { InboundClient SchedulerClient AdminClient + TransactionalClient // Configuration methods SetRegion(region string) diff --git a/internal/ports/transactional.go b/internal/ports/transactional.go new file mode 100644 index 0000000..442e278 --- /dev/null +++ b/internal/ports/transactional.go @@ -0,0 +1,15 @@ +package ports + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +// TransactionalClient defines the interface for domain-based transactional email operations. +// This is used for Nylas Inbox provider grants which use domain-based endpoints instead of grant-based. +type TransactionalClient interface { + // SendTransactionalMessage sends an email via the domain-based transactional endpoint. + // Used for Inbox provider grants: POST /v3/domains/{domain}/messages/send + SendTransactionalMessage(ctx context.Context, domainName string, req *domain.SendMessageRequest) (*domain.Message, error) +}