Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions internal/adapters/nylas/demo_transactional.go
Original file line number Diff line number Diff line change
@@ -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
}
23 changes: 23 additions & 0 deletions internal/adapters/nylas/mock_transactional.go
Original file line number Diff line number Diff line change
@@ -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
}
79 changes: 79 additions & 0 deletions internal/adapters/nylas/transactional.go
Original file line number Diff line number Diff line change
@@ -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
}
234 changes: 234 additions & 0 deletions internal/adapters/nylas/transactional_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
13 changes: 13 additions & 0 deletions internal/cli/common/string.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 ""
}
Loading