diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 3ab2038..c4e0590 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -194,6 +194,7 @@ nylas email send ... --sign --encrypt # Sign AND encryp nylas email send --list-gpg-keys # List available GPG signing keys nylas email send --to EMAIL --template-id TPL --template-data '{}' # Send using a hosted template nylas email send --template-id TPL --template-data-file data.json --render-only +nylas email send --to EMAIL --subject SUBJECT --body BODY --signature-id SIG # Send with stored signature nylas email search --query "QUERY" # Search emails nylas email delete # Delete email nylas email mark read # Mark as read @@ -237,6 +238,7 @@ nylas email templates show # Show template details nylas email templates update [flags] # Update template nylas email templates delete # Delete template nylas email templates use --to EMAIL # Send using template +nylas email templates use --to EMAIL --signature-id SIG # Send using template + stored signature ``` **Variable syntax:** Use `{{variable}}` in subject/body for placeholders. @@ -305,11 +307,26 @@ nylas email threads delete # Delete thread nylas email drafts list # List drafts nylas email drafts show # Show draft details nylas email drafts create --to EMAIL --subject S # Create draft +nylas email drafts create --to EMAIL --subject S --signature-id SIG # Create draft with stored signature nylas email drafts send # Send draft +nylas email drafts send --signature-id SIG # Send draft with stored signature nylas email drafts delete # Delete draft ``` -**Flags:** `--to`, `--cc`, `--bcc`, `--subject`, `--body`, `--reply-to`, `--attach` +**Flags:** `--to`, `--cc`, `--bcc`, `--subject`, `--body`, `--reply-to`, `--attach`, `--signature-id` + +--- + +## Signatures + +```bash +nylas email signatures list [grant-id] # List stored signatures +nylas email signatures show [grant-id] # Show signature details +nylas email signatures create [grant-id] --name NAME --body BODY # Create signature +nylas email signatures create [grant-id] --name NAME --body-file FILE +nylas email signatures update [grant-id] [flags] # Update signature +nylas email signatures delete [grant-id] --yes # Delete signature +``` --- diff --git a/docs/commands/email.md b/docs/commands/email.md index 5d468c9..c624980 100644 --- a/docs/commands/email.md +++ b/docs/commands/email.md @@ -103,6 +103,10 @@ nylas email send --to "to@example.com" --template-id tpl_123 \ # Preview a hosted template render without sending nylas email send --template-id tpl_123 --template-data-file ./data.json --render-only + +# Send with a stored signature +nylas email send --to "to@example.com" --subject "Subject" --body "Body" \ + --signature-id sig_123 ``` **Tracking Options:** @@ -119,6 +123,7 @@ nylas email send --template-id tpl_123 --template-data-file ./data.json --render - `--template-data-file` - JSON file containing hosted template variables - `--render-only` - Preview the rendered hosted template without sending - `--template-strict` - Fail if the hosted template references missing variables (default: true) +- `--signature-id` - Append a stored signature when sending, creating a draft, or sending a draft **Example output (scheduled):** ```bash @@ -181,6 +186,39 @@ nylas email send --to "to@example.com" --subject "Secure" --body "..." --sign -- nylas email send --list-gpg-keys ``` +`--signature-id` can't be combined with `--sign` or `--encrypt`, because stored signatures are only supported on the standard JSON send and draft endpoints. + +### Signatures + +Manage stored signatures on a grant and reuse them from send and draft commands: + +```bash +# List signatures for a grant +nylas email signatures list [grant-id] + +# Show a specific signature +nylas email signatures show [grant-id] + +# Create a signature +nylas email signatures create [grant-id] --name "Work" --body-file ./signature.html + +# Update a signature +nylas email signatures update [grant-id] --name "Work Updated" --body "

Updated

" + +# Delete a signature +nylas email signatures delete [grant-id] --yes + +# Create a draft with a stored signature +nylas email drafts create --to "to@example.com" --subject "Draft" --body "Body" \ + --signature-id sig_123 + +# Send a draft with a stored signature +nylas email drafts send --signature-id sig_123 + +# Use a local template and append a stored signature +nylas email templates use --to "to@example.com" --signature-id sig_123 +``` + **Reading encrypted/signed emails:** ```bash diff --git a/internal/adapters/nylas/client.go b/internal/adapters/nylas/client.go index 7727f3c..42df487 100644 --- a/internal/adapters/nylas/client.go +++ b/internal/adapters/nylas/client.go @@ -252,6 +252,38 @@ func (c *HTTPClient) doRequest(ctx context.Context, req *http.Request) (*http.Re return nil, lastErr } +func (c *HTTPClient) doRequestNoRetry(ctx context.Context, req *http.Request) (*http.Response, error) { + req.Header.Set("User-Agent", version.UserAgent()) + + // Apply rate limiting - wait for permission to proceed + if err := c.rateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("rate limiter: %w", err) + } + + // Ensure context has timeout + ctxWithTimeout, cancel := c.ensureContext(ctx) + + // Execute request + resp, err := c.httpClient.Do(req.WithContext(ctxWithTimeout)) + if err != nil { + cancel() + + // Don't mask parent context cancellation. + if ctx.Err() != nil { + return nil, ctx.Err() + } + + return nil, fmt.Errorf("%w: %v", domain.ErrNetworkError, err) + } + + resp.Body = &cancelOnCloseBody{ + ReadCloser: resp.Body, + cancel: cancel, + } + + return resp, nil +} + type cancelOnCloseBody struct { io.ReadCloser cancel context.CancelFunc @@ -327,6 +359,17 @@ func (c *HTTPClient) doJSONRequestInternal( body any, withAuth bool, acceptedStatuses ...int, +) (*http.Response, error) { + return c.doJSONRequestInternalWithRetry(ctx, method, url, body, withAuth, true, acceptedStatuses...) +} + +func (c *HTTPClient) doJSONRequestInternalWithRetry( + ctx context.Context, + method, url string, + body any, + withAuth bool, + retry bool, + acceptedStatuses ...int, ) (*http.Response, error) { // Default accepted statuses if len(acceptedStatuses) == 0 { @@ -358,8 +401,13 @@ func (c *HTTPClient) doJSONRequestInternal( c.setAuthHeader(req) } - // Execute request with rate limiting - resp, err := c.doRequest(ctx, req) + // Execute request with the configured retry policy. + var resp *http.Response + if retry { + resp, err = c.doRequest(ctx, req) + } else { + resp, err = c.doRequestNoRetry(ctx, req) + } if err != nil { return nil, err } @@ -411,6 +459,15 @@ func (c *HTTPClient) doJSONRequest( return c.doJSONRequestInternal(ctx, method, url, body, true, acceptedStatuses...) } +func (c *HTTPClient) doJSONRequestNoRetry( + ctx context.Context, + method, url string, + body any, + acceptedStatuses ...int, +) (*http.Response, error) { + return c.doJSONRequestInternalWithRetry(ctx, method, url, body, true, false, acceptedStatuses...) +} + // decodeJSONResponse decodes a JSON response body into the provided struct. // It properly closes the response body after reading. // @@ -440,7 +497,7 @@ func (c *HTTPClient) doJSONRequestNoAuth( body any, acceptedStatuses ...int, ) (*http.Response, error) { - return c.doJSONRequestInternal(ctx, method, url, body, false, acceptedStatuses...) + return c.doJSONRequestInternalWithRetry(ctx, method, url, body, false, true, acceptedStatuses...) } // validateRequired validates that a required field is not empty. diff --git a/internal/adapters/nylas/client_mock_methods_test.go b/internal/adapters/nylas/client_mock_methods_test.go index ee2a707..9891515 100644 --- a/internal/adapters/nylas/client_mock_methods_test.go +++ b/internal/adapters/nylas/client_mock_methods_test.go @@ -195,7 +195,7 @@ func TestMockClient_Drafts(t *testing.T) { }) t.Run("SendDraft", func(t *testing.T) { - msg, err := mock.SendDraft(ctx, "grant-123", "draft-456") + msg, err := mock.SendDraft(ctx, "grant-123", "draft-456", nil) require.NoError(t, err) assert.NotEmpty(t, msg.ID) assert.True(t, mock.SendDraftCalled) diff --git a/internal/adapters/nylas/demo_drafts.go b/internal/adapters/nylas/demo_drafts.go index 37fd6d9..7dd7834 100644 --- a/internal/adapters/nylas/demo_drafts.go +++ b/internal/adapters/nylas/demo_drafts.go @@ -43,6 +43,6 @@ func (d *DemoClient) DeleteDraft(ctx context.Context, grantID, draftID string) e } // SendDraft simulates sending a draft. -func (d *DemoClient) SendDraft(ctx context.Context, grantID, draftID string) (*domain.Message, error) { +func (d *DemoClient) SendDraft(ctx context.Context, grantID, draftID string, req *domain.SendDraftRequest) (*domain.Message, error) { return &domain.Message{ID: "sent-from-draft", Subject: "Sent Draft"}, nil } diff --git a/internal/adapters/nylas/demo_signatures.go b/internal/adapters/nylas/demo_signatures.go new file mode 100644 index 0000000..b4fa121 --- /dev/null +++ b/internal/adapters/nylas/demo_signatures.go @@ -0,0 +1,73 @@ +package nylas + +import ( + "context" + "time" + + "github.com/nylas/cli/internal/domain" +) + +func (d *DemoClient) GetSignatures(ctx context.Context, grantID string) ([]domain.Signature, error) { + now := time.Now() + return []domain.Signature{ + { + ID: "sig-demo-work", + Name: "Work", + Body: "
Demo User
Developer Advocate
", + CreatedAt: now.Add(-24 * time.Hour), + UpdatedAt: now.Add(-2 * time.Hour), + }, + { + ID: "sig-demo-mobile", + Name: "Mobile", + Body: "
Sent from my phone
", + CreatedAt: now.Add(-48 * time.Hour), + UpdatedAt: now.Add(-6 * time.Hour), + }, + }, nil +} + +func (d *DemoClient) GetSignature(ctx context.Context, grantID, signatureID string) (*domain.Signature, error) { + signatures, err := d.GetSignatures(ctx, grantID) + if err != nil { + return nil, err + } + for _, signature := range signatures { + if signature.ID == signatureID { + return &signature, nil + } + } + return nil, domain.ErrSignatureNotFound +} + +func (d *DemoClient) CreateSignature(ctx context.Context, grantID string, req *domain.CreateSignatureRequest) (*domain.Signature, error) { + now := time.Now() + return &domain.Signature{ + ID: "sig-demo-new", + Name: req.Name, + Body: req.Body, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +func (d *DemoClient) UpdateSignature(ctx context.Context, grantID, signatureID string, req *domain.UpdateSignatureRequest) (*domain.Signature, error) { + signature := &domain.Signature{ + ID: signatureID, + Name: "Work", + Body: "
Demo User
Developer Advocate
", + CreatedAt: time.Now().Add(-24 * time.Hour), + UpdatedAt: time.Now(), + } + if req.Name != nil { + signature.Name = *req.Name + } + if req.Body != nil { + signature.Body = *req.Body + } + return signature, nil +} + +func (d *DemoClient) DeleteSignature(ctx context.Context, grantID, signatureID string) error { + return nil +} diff --git a/internal/adapters/nylas/drafts.go b/internal/adapters/nylas/drafts.go index 1c777ee..d4a8f95 100644 --- a/internal/adapters/nylas/drafts.go +++ b/internal/adapters/nylas/drafts.go @@ -98,7 +98,7 @@ func (c *HTTPClient) CreateDraft(ctx context.Context, grantID string, req *domai // buildDraftPayload builds the common payload for draft creation requests. // This consolidates the repeated payload building logic across draft creation methods. -func buildDraftPayload(req *domain.CreateDraftRequest) map[string]any { +func buildDraftPayload(req *domain.CreateDraftRequest, includeSignature bool) map[string]any { payload := map[string]any{ "subject": req.Subject, "body": req.Body, @@ -118,6 +118,9 @@ func buildDraftPayload(req *domain.CreateDraftRequest) map[string]any { if req.ReplyToMsgID != "" { payload["reply_to_message_id"] = req.ReplyToMsgID } + if includeSignature && req.SignatureID != "" { + payload["signature_id"] = req.SignatureID + } if len(req.Metadata) > 0 { payload["metadata"] = req.Metadata } @@ -128,7 +131,7 @@ func buildDraftPayload(req *domain.CreateDraftRequest) map[string]any { func (c *HTTPClient) createDraftWithJSON(ctx context.Context, grantID string, req *domain.CreateDraftRequest) (*domain.Draft, error) { queryURL := fmt.Sprintf("%s/v3/grants/%s/drafts", c.baseURL, grantID) - resp, err := c.doJSONRequest(ctx, "POST", queryURL, buildDraftPayload(req)) + resp, err := c.doJSONRequest(ctx, "POST", queryURL, buildDraftPayload(req, true)) if err != nil { return nil, err } @@ -153,7 +156,7 @@ func (c *HTTPClient) createDraftWithMultipart(ctx context.Context, grantID strin writer := multipart.NewWriter(&buf) // Add message as JSON field - messageJSON, err := json.Marshal(buildDraftPayload(req)) + messageJSON, err := json.Marshal(buildDraftPayload(req, true)) if err != nil { return nil, fmt.Errorf("failed to marshal message: %w", err) } @@ -221,7 +224,7 @@ func (c *HTTPClient) createDraftWithMultipart(ctx context.Context, grantID strin // This is useful for large attachments or streaming file uploads. func (c *HTTPClient) CreateDraftWithAttachmentFromReader(ctx context.Context, grantID string, req *domain.CreateDraftRequest, filename string, contentType string, reader io.Reader) (*domain.Draft, error) { queryURL := fmt.Sprintf("%s/v3/grants/%s/drafts", c.baseURL, grantID) - payload := buildDraftPayload(req) + payload := buildDraftPayload(req, true) // Use pipe to stream multipart data pr, pw := io.Pipe() @@ -306,17 +309,26 @@ func (c *HTTPClient) DeleteDraft(ctx context.Context, grantID, draftID string) e } // SendDraft sends a draft. -func (c *HTTPClient) SendDraft(ctx context.Context, grantID, draftID string) (*domain.Message, error) { +func (c *HTTPClient) SendDraft(ctx context.Context, grantID, draftID string, req *domain.SendDraftRequest) (*domain.Message, error) { queryURL := fmt.Sprintf("%s/v3/grants/%s/drafts/%s", c.baseURL, grantID, draftID) - req, err := http.NewRequestWithContext(ctx, "POST", queryURL, nil) + var bodyReader io.Reader + if req != nil && req.SignatureID != "" { + body, err := json.Marshal(map[string]string{"signature_id": req.SignatureID}) + if err != nil { + return nil, fmt.Errorf("failed to marshal send draft request: %w", err) + } + bodyReader = bytes.NewReader(body) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", queryURL, bodyReader) if err != nil { return nil, err } - c.setAuthHeader(req) - req.Header.Set("Content-Type", "application/json") + c.setAuthHeader(httpReq) + httpReq.Header.Set("Content-Type", "application/json") - resp, err := c.doRequest(ctx, req) + resp, err := c.doRequest(ctx, httpReq) if err != nil { return nil, fmt.Errorf("%w: %v", domain.ErrNetworkError, err) } diff --git a/internal/adapters/nylas/drafts_operations_test.go b/internal/adapters/nylas/drafts_operations_test.go index 0d278ba..3f60636 100644 --- a/internal/adapters/nylas/drafts_operations_test.go +++ b/internal/adapters/nylas/drafts_operations_test.go @@ -135,6 +135,7 @@ func TestHTTPClient_SendDraft(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/v3/grants/grant-123/drafts/draft-send", r.URL.Path) assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) response := map[string]any{ "data": map[string]any{ @@ -156,10 +157,45 @@ func TestHTTPClient_SendDraft(t *testing.T) { client.SetBaseURL(server.URL) ctx := context.Background() - message, err := client.SendDraft(ctx, "grant-123", "draft-send") + message, err := client.SendDraft(ctx, "grant-123", "draft-send", nil) require.NoError(t, err) assert.Equal(t, "msg-sent-123", message.ID) assert.Equal(t, "Sent Draft", message.Subject) assert.Equal(t, "This was sent", message.Body) } + +func TestHTTPClient_SendDraft_WithSignatureID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/grants/grant-123/drafts/draft-send", r.URL.Path) + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var body map[string]string + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, "sig-123", body["signature_id"]) + + response := map[string]any{ + "data": map[string]any{ + "id": "msg-sent-456", + "grant_id": "grant-123", + "subject": "Signed Draft", + }, + } + w.Header().Set("Content-Type", "application/json") + _ = 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() + message, err := client.SendDraft(ctx, "grant-123", "draft-send", &domain.SendDraftRequest{SignatureID: "sig-123"}) + + require.NoError(t, err) + assert.Equal(t, "msg-sent-456", message.ID) + assert.Equal(t, "Signed Draft", message.Subject) +} diff --git a/internal/adapters/nylas/drafts_test.go b/internal/adapters/nylas/drafts_test.go index 592c6e9..b168f74 100644 --- a/internal/adapters/nylas/drafts_test.go +++ b/internal/adapters/nylas/drafts_test.go @@ -62,6 +62,49 @@ func TestHTTPClient_CreateDraft_WithoutAttachments(t *testing.T) { assert.Equal(t, "Test Subject", draft.Subject) } +func TestHTTPClient_CreateDraft_WithSignatureID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/grants/grant-123/drafts", r.URL.Path) + assert.Equal(t, "POST", r.Method) + 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) + + assert.Equal(t, "sig-123", body["signature_id"]) + + response := map[string]any{ + "data": map[string]any{ + "id": "draft-sig-001", + "grant_id": "grant-123", + "subject": "Test Subject", + "body": "Test Body", + }, + } + w.Header().Set("Content-Type", "application/json") + _ = 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() + req := &domain.CreateDraftRequest{ + Subject: "Test Subject", + Body: "Test Body", + To: []domain.EmailParticipant{{Email: "test@example.com"}}, + SignatureID: "sig-123", + } + + draft, err := client.CreateDraft(ctx, "grant-123", req) + + require.NoError(t, err) + assert.Equal(t, "draft-sig-001", draft.ID) +} + func TestHTTPClient_CreateDraft_WithAttachments(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/v3/grants/grant-123/drafts", r.URL.Path) @@ -76,6 +119,7 @@ func TestHTTPClient_CreateDraft_WithAttachments(t *testing.T) { message := r.FormValue("message") assert.Contains(t, message, "Test Subject") assert.Contains(t, message, "Test Body") + assert.Contains(t, message, "\"signature_id\":\"sig-123\"") // Check for file field _, fileHeader, err := r.FormFile("file0") @@ -110,9 +154,10 @@ func TestHTTPClient_CreateDraft_WithAttachments(t *testing.T) { ctx := context.Background() req := &domain.CreateDraftRequest{ - Subject: "Test Subject", - Body: "Test Body", - To: []domain.EmailParticipant{{Email: "test@example.com"}}, + Subject: "Test Subject", + Body: "Test Body", + To: []domain.EmailParticipant{{Email: "test@example.com"}}, + SignatureID: "sig-123", Attachments: []domain.Attachment{ { Filename: "test.txt", @@ -261,6 +306,9 @@ func TestHTTPClient_CreateDraftWithAttachmentFromReader(t *testing.T) { err := r.ParseMultipartForm(10 << 20) require.NoError(t, err) + message := r.FormValue("message") + assert.Contains(t, message, "\"signature_id\":\"sig-stream\"") + // Verify file was uploaded file, header, err := r.FormFile("file") require.NoError(t, err) @@ -293,8 +341,9 @@ func TestHTTPClient_CreateDraftWithAttachmentFromReader(t *testing.T) { ctx := context.Background() req := &domain.CreateDraftRequest{ - Subject: "Stream Test", - Body: "Body", + Subject: "Stream Test", + Body: "Body", + SignatureID: "sig-stream", } reader := strings.NewReader("streamed content") diff --git a/internal/adapters/nylas/integration_send_test.go b/internal/adapters/nylas/integration_send_test.go index 4a4b12e..968cf80 100644 --- a/internal/adapters/nylas/integration_send_test.go +++ b/internal/adapters/nylas/integration_send_test.go @@ -99,7 +99,7 @@ func TestIntegration_SendDraft(t *testing.T) { t.Logf("Created draft: %s", draft.ID) // Send the draft - msg, err := client.SendDraft(ctx, grantID, draft.ID) + msg, err := client.SendDraft(ctx, grantID, draft.ID, nil) require.NoError(t, err) require.NotEmpty(t, msg.ID) t.Logf("Sent draft as message: %s", msg.ID) diff --git a/internal/adapters/nylas/integration_signatures_test.go b/internal/adapters/nylas/integration_signatures_test.go new file mode 100644 index 0000000..cedd5e9 --- /dev/null +++ b/internal/adapters/nylas/integration_signatures_test.go @@ -0,0 +1,213 @@ +//go:build integration +// +build integration + +package nylas_test + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/require" +) + +func TestIntegration_GetSignatures(t *testing.T) { + client, grantID := getTestClient(t) + ctx, cancel := createLongTestContext() + defer cancel() + + signatures, err := client.GetSignatures(ctx, grantID) + if err != nil { + skipIfProviderNotSupported(t, err) + } + require.NoError(t, err) + t.Logf("Listed %d signatures", len(signatures)) +} + +func TestIntegration_SignatureLifecycle(t *testing.T) { + if os.Getenv("NYLAS_TEST_DELETE") != "true" { + t.Skip("Skipping destructive signature lifecycle test - set NYLAS_TEST_DELETE=true to enable") + } + + client, grantID := getTestClient(t) + ctx, cancel := createLongTestContext() + defer cancel() + + signatures, err := client.GetSignatures(ctx, grantID) + if err != nil { + skipIfProviderNotSupported(t, err) + } + require.NoError(t, err) + if len(signatures) >= 10 { + t.Skip("Grant already has 10 signatures; skipping lifecycle test to avoid hard limit failures") + } + + marker := fmt.Sprintf("Integration Signature %d", time.Now().UnixNano()) + created, err := client.CreateSignature(ctx, grantID, &domain.CreateSignatureRequest{ + Name: "Integration Work", + Body: fmt.Sprintf("

%s

", marker), + }) + require.NoError(t, err) + require.NotNil(t, created) + t.Logf("Created signature %s", created.ID) + defer func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + _ = client.DeleteSignature(cleanupCtx, grantID, created.ID) + }() + + fetched, err := client.GetSignature(ctx, grantID, created.ID) + require.NoError(t, err) + require.NotNil(t, fetched) + require.Equal(t, created.ID, fetched.ID) + + updatedMarker := marker + " Updated" + updatedName := "Integration Updated" + updatedBody := fmt.Sprintf("

%s

", updatedMarker) + updated, err := client.UpdateSignature(ctx, grantID, created.ID, &domain.UpdateSignatureRequest{ + Name: &updatedName, + Body: stringPtr(updatedBody), + }) + require.NoError(t, err) + require.NotNil(t, updated) + require.Equal(t, updatedName, updated.Name) + require.Contains(t, updated.Body, updatedMarker) + + draftRecipient := os.Getenv("NYLAS_TEST_EMAIL") + if draftRecipient == "" { + draftRecipient = "test@example.com" + } + + draft, err := client.CreateDraft(ctx, grantID, &domain.CreateDraftRequest{ + Subject: fmt.Sprintf("Signature Draft %d", time.Now().Unix()), + Body: "

Base draft body

", + To: []domain.EmailParticipant{{Email: draftRecipient}}, + SignatureID: created.ID, + }) + require.NoError(t, err) + require.NotNil(t, draft) + t.Logf("Created signature-backed draft %s", draft.ID) + defer func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + _ = client.DeleteDraft(cleanupCtx, grantID, draft.ID) + }() + + storedDraft, err := client.GetDraft(ctx, grantID, draft.ID) + require.NoError(t, err) + require.NotNil(t, storedDraft) + require.True(t, + strings.Contains(storedDraft.Body, marker) || strings.Contains(storedDraft.Body, updatedMarker), + "expected draft body to contain signature marker, got %q", storedDraft.Body, + ) +} + +func TestIntegration_SendMessageWithSignature(t *testing.T) { + if os.Getenv("NYLAS_TEST_SEND_EMAIL") != "true" { + t.Skip("Skipping send email test - set NYLAS_TEST_SEND_EMAIL=true to enable") + } + if os.Getenv("NYLAS_TEST_DELETE") != "true" { + t.Skip("Skipping signature send test cleanup - set NYLAS_TEST_DELETE=true to enable") + } + + client, grantID := getTestClient(t) + ctx, cancel := createLongTestContext() + defer cancel() + + testEmail := os.Getenv("NYLAS_TEST_EMAIL") + if testEmail == "" { + t.Skip("NYLAS_TEST_EMAIL not set") + } + + signatures, err := client.GetSignatures(ctx, grantID) + if err != nil { + skipIfProviderNotSupported(t, err) + } + require.NoError(t, err) + if len(signatures) >= 10 { + t.Skip("Grant already has 10 signatures; skipping send test to avoid hard limit failures") + } + + marker := fmt.Sprintf("Send Signature %d", time.Now().UnixNano()) + signature, err := client.CreateSignature(ctx, grantID, &domain.CreateSignatureRequest{ + Name: "Integration Send", + Body: fmt.Sprintf("

%s

", marker), + }) + require.NoError(t, err) + defer func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + _ = client.DeleteSignature(cleanupCtx, grantID, signature.ID) + }() + + msg, err := client.SendMessage(ctx, grantID, &domain.SendMessageRequest{ + Subject: fmt.Sprintf("Signature Send %d", time.Now().Unix()), + Body: "

Body

", + To: []domain.EmailParticipant{{Email: testEmail}}, + SignatureID: signature.ID, + }) + require.NoError(t, err) + require.NotEmpty(t, msg.ID) +} + +func TestIntegration_SendDraftWithSignature(t *testing.T) { + if os.Getenv("NYLAS_TEST_SEND_EMAIL") != "true" { + t.Skip("Skipping send email test - set NYLAS_TEST_SEND_EMAIL=true to enable") + } + if os.Getenv("NYLAS_TEST_DELETE") != "true" { + t.Skip("Skipping signature send test cleanup - set NYLAS_TEST_DELETE=true to enable") + } + + client, grantID := getTestClient(t) + ctx, cancel := createLongTestContext() + defer cancel() + + testEmail := os.Getenv("NYLAS_TEST_EMAIL") + if testEmail == "" { + t.Skip("NYLAS_TEST_EMAIL not set") + } + + signatures, err := client.GetSignatures(ctx, grantID) + if err != nil { + skipIfProviderNotSupported(t, err) + } + require.NoError(t, err) + if len(signatures) >= 10 { + t.Skip("Grant already has 10 signatures; skipping send-draft test to avoid hard limit failures") + } + + signature, err := client.CreateSignature(ctx, grantID, &domain.CreateSignatureRequest{ + Name: "Integration Draft Send", + Body: fmt.Sprintf("

Draft send signature %d

", time.Now().UnixNano()), + }) + require.NoError(t, err) + defer func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + _ = client.DeleteSignature(cleanupCtx, grantID, signature.ID) + }() + + draft, err := client.CreateDraft(ctx, grantID, &domain.CreateDraftRequest{ + Subject: fmt.Sprintf("Send Draft Signature %d", time.Now().Unix()), + Body: "

Draft Body

", + To: []domain.EmailParticipant{{Email: testEmail}}, + }) + require.NoError(t, err) + defer func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + _ = client.DeleteDraft(cleanupCtx, grantID, draft.ID) + }() + + msg, err := client.SendDraft(ctx, grantID, draft.ID, &domain.SendDraftRequest{SignatureID: signature.ID}) + require.NoError(t, err) + require.NotEmpty(t, msg.ID) +} + +func stringPtr(value string) *string { + return &value +} diff --git a/internal/adapters/nylas/messages_send.go b/internal/adapters/nylas/messages_send.go index ceb27fd..6adc14f 100644 --- a/internal/adapters/nylas/messages_send.go +++ b/internal/adapters/nylas/messages_send.go @@ -44,6 +44,9 @@ func (c *HTTPClient) SendMessage(ctx context.Context, grantID string, req *domai if req.SendAt > 0 { payload["send_at"] = req.SendAt } + if req.SignatureID != "" { + payload["signature_id"] = req.SignatureID + } if len(req.Metadata) > 0 { payload["metadata"] = req.Metadata } diff --git a/internal/adapters/nylas/messages_send_test.go b/internal/adapters/nylas/messages_send_test.go index 20ca2d6..99fcbec 100644 --- a/internal/adapters/nylas/messages_send_test.go +++ b/internal/adapters/nylas/messages_send_test.go @@ -124,6 +124,18 @@ func TestHTTPClient_SendMessage(t *testing.T) { statusCode: http.StatusOK, wantErr: false, }, + { + name: "sends message with signature id", + request: &domain.SendMessageRequest{ + Subject: "With Signature", + Body: "Body", + To: []domain.EmailParticipant{{Email: "to@example.com"}}, + SignatureID: "sig-123", + }, + expectedFields: []string{"subject", "body", "to", "signature_id"}, + statusCode: http.StatusOK, + wantErr: false, + }, } for _, tt := range tests { @@ -140,6 +152,9 @@ func TestHTTPClient_SendMessage(t *testing.T) { for _, field := range tt.expectedFields { assert.Contains(t, body, field, "Missing field: %s", field) } + if tt.request.SignatureID != "" { + assert.Equal(t, tt.request.SignatureID, body["signature_id"]) + } response := map[string]any{ "data": map[string]any{ diff --git a/internal/adapters/nylas/mock_client.go b/internal/adapters/nylas/mock_client.go index d64af25..7ef7ce1 100644 --- a/internal/adapters/nylas/mock_client.go +++ b/internal/adapters/nylas/mock_client.go @@ -24,6 +24,11 @@ type MockClient struct { GetMessagesWithParamsCalled bool GetMessageCalled bool SendMessageCalled bool + GetSignaturesCalled bool + GetSignatureCalled bool + CreateSignatureCalled bool + UpdateSignatureCalled bool + DeleteSignatureCalled bool UpdateMessageCalled bool DeleteMessageCalled bool GetThreadsCalled bool @@ -75,6 +80,7 @@ type MockClient struct { LastCodeChallenge string LastCodeVerifier string LastMessageID string + LastSignatureID string LastThreadID string LastDraftID string LastFolderID string @@ -95,6 +101,11 @@ type MockClient struct { GetMessageFunc func(ctx context.Context, grantID, messageID string) (*domain.Message, error) SendMessageFunc func(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) SendRawMessageFunc func(ctx context.Context, grantID string, rawMIME []byte) (*domain.Message, error) + GetSignaturesFunc func(ctx context.Context, grantID string) ([]domain.Signature, error) + GetSignatureFunc func(ctx context.Context, grantID, signatureID string) (*domain.Signature, error) + CreateSignatureFunc func(ctx context.Context, grantID string, req *domain.CreateSignatureRequest) (*domain.Signature, error) + UpdateSignatureFunc func(ctx context.Context, grantID, signatureID string, req *domain.UpdateSignatureRequest) (*domain.Signature, error) + DeleteSignatureFunc func(ctx context.Context, grantID, signatureID string) error UpdateMessageFunc func(ctx context.Context, grantID, messageID string, req *domain.UpdateMessageRequest) (*domain.Message, error) DeleteMessageFunc func(ctx context.Context, grantID, messageID string) error GetThreadsFunc func(ctx context.Context, grantID string, params *domain.ThreadQueryParams) ([]domain.Thread, error) @@ -106,7 +117,7 @@ type MockClient struct { CreateDraftFunc func(ctx context.Context, grantID string, req *domain.CreateDraftRequest) (*domain.Draft, error) UpdateDraftFunc func(ctx context.Context, grantID, draftID string, req *domain.CreateDraftRequest) (*domain.Draft, error) DeleteDraftFunc func(ctx context.Context, grantID, draftID string) error - SendDraftFunc func(ctx context.Context, grantID, draftID string) (*domain.Message, error) + SendDraftFunc func(ctx context.Context, grantID, draftID string, req *domain.SendDraftRequest) (*domain.Message, error) GetFoldersFunc func(ctx context.Context, grantID string) ([]domain.Folder, error) GetFolderFunc func(ctx context.Context, grantID, folderID string) (*domain.Folder, error) CreateFolderFunc func(ctx context.Context, grantID string, req *domain.CreateFolderRequest) (*domain.Folder, error) diff --git a/internal/adapters/nylas/mock_drafts.go b/internal/adapters/nylas/mock_drafts.go index aee6e96..102e8a2 100644 --- a/internal/adapters/nylas/mock_drafts.go +++ b/internal/adapters/nylas/mock_drafts.go @@ -102,12 +102,12 @@ func (m *MockClient) DeleteDraft(ctx context.Context, grantID, draftID string) e } // SendDraft sends a draft. -func (m *MockClient) SendDraft(ctx context.Context, grantID, draftID string) (*domain.Message, error) { +func (m *MockClient) SendDraft(ctx context.Context, grantID, draftID string, req *domain.SendDraftRequest) (*domain.Message, error) { m.SendDraftCalled = true m.LastGrantID = grantID m.LastDraftID = draftID if m.SendDraftFunc != nil { - return m.SendDraftFunc(ctx, grantID, draftID) + return m.SendDraftFunc(ctx, grantID, draftID, req) } return &domain.Message{ ID: "sent-from-draft-id", diff --git a/internal/adapters/nylas/mock_signatures.go b/internal/adapters/nylas/mock_signatures.go new file mode 100644 index 0000000..d2cbf03 --- /dev/null +++ b/internal/adapters/nylas/mock_signatures.go @@ -0,0 +1,90 @@ +package nylas + +import ( + "context" + "time" + + "github.com/nylas/cli/internal/domain" +) + +func (m *MockClient) GetSignatures(ctx context.Context, grantID string) ([]domain.Signature, error) { + m.GetSignaturesCalled = true + m.LastGrantID = grantID + if m.GetSignaturesFunc != nil { + return m.GetSignaturesFunc(ctx, grantID) + } + return []domain.Signature{ + { + ID: "sig-123", + Name: "Work Signature", + Body: "

Best regards

", + CreatedAt: time.Unix(1704067200, 0), + UpdatedAt: time.Unix(1704067200, 0), + }, + }, nil +} + +func (m *MockClient) GetSignature(ctx context.Context, grantID, signatureID string) (*domain.Signature, error) { + m.GetSignatureCalled = true + m.LastGrantID = grantID + m.LastSignatureID = signatureID + if m.GetSignatureFunc != nil { + return m.GetSignatureFunc(ctx, grantID, signatureID) + } + return &domain.Signature{ + ID: signatureID, + Name: "Work Signature", + Body: "

Best regards

", + CreatedAt: time.Unix(1704067200, 0), + UpdatedAt: time.Unix(1704067200, 0), + }, nil +} + +func (m *MockClient) CreateSignature(ctx context.Context, grantID string, req *domain.CreateSignatureRequest) (*domain.Signature, error) { + m.CreateSignatureCalled = true + m.LastGrantID = grantID + if m.CreateSignatureFunc != nil { + return m.CreateSignatureFunc(ctx, grantID, req) + } + return &domain.Signature{ + ID: "sig-new", + Name: req.Name, + Body: req.Body, + CreatedAt: time.Unix(1704067200, 0), + UpdatedAt: time.Unix(1704067200, 0), + }, nil +} + +func (m *MockClient) UpdateSignature(ctx context.Context, grantID, signatureID string, req *domain.UpdateSignatureRequest) (*domain.Signature, error) { + m.UpdateSignatureCalled = true + m.LastGrantID = grantID + m.LastSignatureID = signatureID + if m.UpdateSignatureFunc != nil { + return m.UpdateSignatureFunc(ctx, grantID, signatureID, req) + } + + signature := &domain.Signature{ + ID: signatureID, + Name: "Work Signature", + Body: "

Best regards

", + CreatedAt: time.Unix(1704067200, 0), + UpdatedAt: time.Unix(1704068200, 0), + } + if req.Name != nil { + signature.Name = *req.Name + } + if req.Body != nil { + signature.Body = *req.Body + } + return signature, nil +} + +func (m *MockClient) DeleteSignature(ctx context.Context, grantID, signatureID string) error { + m.DeleteSignatureCalled = true + m.LastGrantID = grantID + m.LastSignatureID = signatureID + if m.DeleteSignatureFunc != nil { + return m.DeleteSignatureFunc(ctx, grantID, signatureID) + } + return nil +} diff --git a/internal/adapters/nylas/signatures.go b/internal/adapters/nylas/signatures.go new file mode 100644 index 0000000..a83722d --- /dev/null +++ b/internal/adapters/nylas/signatures.go @@ -0,0 +1,104 @@ +package nylas + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/util" +) + +type signatureResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Body string `json:"body"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// GetSignatures retrieves all signatures for a grant. +func (c *HTTPClient) GetSignatures(ctx context.Context, grantID string) ([]domain.Signature, error) { + queryURL := fmt.Sprintf("%s/v3/grants/%s/signatures", c.baseURL, grantID) + + var result struct { + Data []signatureResponse `json:"data"` + } + if err := c.doGet(ctx, queryURL, &result); err != nil { + return nil, err + } + + return util.Map(result.Data, convertSignature), nil +} + +// GetSignature retrieves a specific signature. +func (c *HTTPClient) GetSignature(ctx context.Context, grantID, signatureID string) (*domain.Signature, error) { + queryURL := fmt.Sprintf("%s/v3/grants/%s/signatures/%s", c.baseURL, grantID, signatureID) + + var result struct { + Data signatureResponse `json:"data"` + } + if err := c.doGetWithNotFound(ctx, queryURL, &result, domain.ErrSignatureNotFound); err != nil { + return nil, err + } + + signature := convertSignature(result.Data) + return &signature, nil +} + +// CreateSignature creates a new signature. +func (c *HTTPClient) CreateSignature(ctx context.Context, grantID string, req *domain.CreateSignatureRequest) (*domain.Signature, error) { + queryURL := fmt.Sprintf("%s/v3/grants/%s/signatures", c.baseURL, grantID) + + resp, err := c.doJSONRequestNoRetry(ctx, http.MethodPost, queryURL, req) + if err != nil { + return nil, err + } + + var result struct { + Data signatureResponse `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + signature := convertSignature(result.Data) + return &signature, nil +} + +// UpdateSignature updates an existing signature. +func (c *HTTPClient) UpdateSignature(ctx context.Context, grantID, signatureID string, req *domain.UpdateSignatureRequest) (*domain.Signature, error) { + queryURL := fmt.Sprintf("%s/v3/grants/%s/signatures/%s", c.baseURL, grantID, signatureID) + + resp, err := c.doJSONRequestNoRetry(ctx, http.MethodPut, queryURL, req, http.StatusOK) + if err != nil { + return nil, err + } + + var result struct { + Data signatureResponse `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + signature := convertSignature(result.Data) + return &signature, nil +} + +// DeleteSignature deletes a signature. +func (c *HTTPClient) DeleteSignature(ctx context.Context, grantID, signatureID string) error { + queryURL := fmt.Sprintf("%s/v3/grants/%s/signatures/%s", c.baseURL, grantID, signatureID) + return c.doDelete(ctx, queryURL) +} + +func convertSignature(s signatureResponse) domain.Signature { + return domain.Signature{ + ID: s.ID, + Name: s.Name, + Body: s.Body, + CreatedAt: time.Unix(s.CreatedAt, 0), + UpdatedAt: time.Unix(s.UpdatedAt, 0), + } +} diff --git a/internal/adapters/nylas/signatures_test.go b/internal/adapters/nylas/signatures_test.go new file mode 100644 index 0000000..759884d --- /dev/null +++ b/internal/adapters/nylas/signatures_test.go @@ -0,0 +1,324 @@ +//go:build !integration +// +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_GetSignatures(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/grants/grant-123/signatures", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + + response := map[string]any{ + "data": []map[string]any{ + { + "id": "sig-1", + "name": "Work", + "body": "

Best regards

", + "created_at": 1704067200, + "updated_at": 1704068200, + }, + { + "id": "sig-2", + "name": "Mobile", + "body": "

Sent from my phone

", + "created_at": 1704067200, + "updated_at": 1704069200, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + signatures, err := client.GetSignatures(context.Background(), "grant-123") + + require.NoError(t, err) + require.Len(t, signatures, 2) + assert.Equal(t, "sig-1", signatures[0].ID) + assert.Equal(t, "Work", signatures[0].Name) + assert.Equal(t, "sig-2", signatures[1].ID) +} + +func TestHTTPClient_GetSignature(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/grants/grant-123/signatures/sig-123", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + + response := map[string]any{ + "data": map[string]any{ + "id": "sig-123", + "name": "Work", + "body": "

Best regards

", + "created_at": 1704067200, + "updated_at": 1704068200, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + signature, err := client.GetSignature(context.Background(), "grant-123", "sig-123") + + require.NoError(t, err) + require.NotNil(t, signature) + assert.Equal(t, "sig-123", signature.ID) + assert.Equal(t, "Work", signature.Name) +} + +func TestHTTPClient_GetSignature_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]string{"message": "Signature not found"}, + }) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + signature, err := client.GetSignature(context.Background(), "grant-123", "missing") + + require.Error(t, err) + assert.Nil(t, signature) + assert.ErrorIs(t, err, domain.ErrSignatureNotFound) +} + +func TestHTTPClient_CreateSignature(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/grants/grant-123/signatures", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + var body map[string]string + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, "Work", body["name"]) + assert.Equal(t, "

Best regards

", body["body"]) + + response := map[string]any{ + "data": map[string]any{ + "id": "sig-new", + "name": body["name"], + "body": body["body"], + "created_at": 1704067200, + "updated_at": 1704067200, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + signature, err := client.CreateSignature(context.Background(), "grant-123", &domain.CreateSignatureRequest{ + Name: "Work", + Body: "

Best regards

", + }) + + require.NoError(t, err) + require.NotNil(t, signature) + assert.Equal(t, "sig-new", signature.ID) + assert.Equal(t, "Work", signature.Name) +} + +func TestHTTPClient_CreateSignature_DoesNotRetry(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + assert.Equal(t, "/v3/grants/grant-123/signatures", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]string{"message": "temporary signature write failure"}, + }) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + signature, err := client.CreateSignature(context.Background(), "grant-123", &domain.CreateSignatureRequest{ + Name: "Work", + Body: "

Best regards

", + }) + + require.Error(t, err) + assert.Nil(t, signature) + assert.Equal(t, 1, attempts) +} + +func TestHTTPClient_UpdateSignature(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/grants/grant-123/signatures/sig-123", r.URL.Path) + assert.Equal(t, http.MethodPut, r.Method) + + var body map[string]string + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, "Updated", body["name"]) + assert.Equal(t, "

Updated body

", body["body"]) + + response := map[string]any{ + "data": map[string]any{ + "id": "sig-123", + "name": body["name"], + "body": body["body"], + "created_at": 1704067200, + "updated_at": 1704069200, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + signature, err := client.UpdateSignature(context.Background(), "grant-123", "sig-123", &domain.UpdateSignatureRequest{ + Name: stringPtr("Updated"), + Body: stringPtr("

Updated body

"), + }) + + require.NoError(t, err) + require.NotNil(t, signature) + assert.Equal(t, "Updated", signature.Name) + assert.Equal(t, "

Updated body

", signature.Body) +} + +func TestHTTPClient_UpdateSignature_DoesNotRetry(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + assert.Equal(t, "/v3/grants/grant-123/signatures/sig-123", r.URL.Path) + assert.Equal(t, http.MethodPut, r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]string{"message": "temporary signature update failure"}, + }) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + signature, err := client.UpdateSignature(context.Background(), "grant-123", "sig-123", &domain.UpdateSignatureRequest{ + Name: stringPtr("Updated"), + Body: stringPtr("

Updated body

"), + }) + + require.Error(t, err) + assert.Nil(t, signature) + assert.Equal(t, 1, attempts) +} + +func TestHTTPClient_DeleteSignature(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/grants/grant-123/signatures/sig-123", r.URL.Path) + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + err := client.DeleteSignature(context.Background(), "grant-123", "sig-123") + + require.NoError(t, err) +} + +func TestMockClient_Signatures(t *testing.T) { + ctx := context.Background() + mock := nylas.NewMockClient() + + signatures, err := mock.GetSignatures(ctx, "grant-123") + require.NoError(t, err) + require.Len(t, signatures, 1) + assert.True(t, mock.GetSignaturesCalled) + + signature, err := mock.GetSignature(ctx, "grant-123", "sig-123") + require.NoError(t, err) + require.NotNil(t, signature) + assert.Equal(t, "sig-123", signature.ID) + assert.True(t, mock.GetSignatureCalled) + assert.Equal(t, "sig-123", mock.LastSignatureID) + + created, err := mock.CreateSignature(ctx, "grant-123", &domain.CreateSignatureRequest{ + Name: "New", + Body: "

Body

", + }) + require.NoError(t, err) + assert.Equal(t, "New", created.Name) + assert.True(t, mock.CreateSignatureCalled) + + updated, err := mock.UpdateSignature(ctx, "grant-123", "sig-123", &domain.UpdateSignatureRequest{ + Name: stringPtr("Updated"), + }) + require.NoError(t, err) + assert.Equal(t, "Updated", updated.Name) + assert.True(t, mock.UpdateSignatureCalled) + + err = mock.DeleteSignature(ctx, "grant-123", "sig-123") + require.NoError(t, err) + assert.True(t, mock.DeleteSignatureCalled) +} + +func TestDemoClient_Signatures(t *testing.T) { + ctx := context.Background() + client := nylas.NewDemoClient() + + signatures, err := client.GetSignatures(ctx, "demo-grant") + require.NoError(t, err) + require.Len(t, signatures, 2) + + signature, err := client.GetSignature(ctx, "demo-grant", "sig-demo-work") + require.NoError(t, err) + require.NotNil(t, signature) + assert.Equal(t, "sig-demo-work", signature.ID) + + missing, err := client.GetSignature(ctx, "demo-grant", "missing") + require.Error(t, err) + assert.Nil(t, missing) + assert.ErrorIs(t, err, domain.ErrSignatureNotFound) +} + +func stringPtr(value string) *string { + return &value +} diff --git a/internal/air/handlers_drafts.go b/internal/air/handlers_drafts.go index 08f7034..af8c5bf 100644 --- a/internal/air/handlers_drafts.go +++ b/internal/air/handlers_drafts.go @@ -215,7 +215,7 @@ func (s *Server) handleSendDraft(w http.ResponseWriter, r *http.Request, draftID ctx, cancel := s.withTimeout(r) defer cancel() - msg, err := s.nylasClient.SendDraft(ctx, grantID, draftID) + msg, err := s.nylasClient.SendDraft(ctx, grantID, draftID, nil) if err != nil { writeJSON(w, http.StatusInternalServerError, SendMessageResponse{ Success: false, diff --git a/internal/cli/email/drafts.go b/internal/cli/email/drafts.go index f80460a..2bf901b 100644 --- a/internal/cli/email/drafts.go +++ b/internal/cli/email/drafts.go @@ -107,6 +107,7 @@ func newDraftsCreateCmd() *cobra.Command { var body string var replyTo string var attachFiles []string + var signatureID string cmd := &cobra.Command{ Use: "create [grant-id]", @@ -146,11 +147,16 @@ func newDraftsCreateCmd() *cobra.Command { return struct{}{}, common.WrapRecipientError("to", err) } + if _, err := validateSignatureSelection(ctx, client, grantID, signatureID, nil); err != nil { + return struct{}{}, err + } + req := &domain.CreateDraftRequest{ Subject: subject, Body: body, To: toContacts, ReplyToMsgID: replyTo, + SignatureID: signatureID, } if len(cc) > 0 { @@ -191,6 +197,7 @@ func newDraftsCreateCmd() *cobra.Command { cmd.Flags().StringVarP(&subject, "subject", "s", "", "Email subject") cmd.Flags().StringVarP(&body, "body", "b", "", "Email body") cmd.Flags().StringVar(&replyTo, "reply-to", "", "Message ID to reply to") + cmd.Flags().StringVar(&signatureID, "signature-id", "", "Stored signature ID to append when creating the draft") cmd.Flags().StringSliceVarP(&attachFiles, "attach", "a", nil, "File paths to attach") return cmd @@ -326,6 +333,7 @@ func newDraftsShowCmd() *cobra.Command { func newDraftsSendCmd() *cobra.Command { var force bool + var signatureID string cmd := &cobra.Command{ Use: "send [grant-id]", @@ -342,11 +350,18 @@ func newDraftsSendCmd() *cobra.Command { return struct{}{}, common.WrapGetError("draft", err) } + if err := validateDraftSendSignatureSelection(ctx, client, grantID, draft, signatureID); err != nil { + return struct{}{}, err + } + // Confirmation if !force { fmt.Println("Send this draft?") fmt.Printf(" To: %s\n", common.FormatParticipants(draft.To)) fmt.Printf(" Subject: %s\n", draft.Subject) + if signatureID != "" { + fmt.Printf(" Signature: %s\n", signatureID) + } fmt.Print("\n[y/N]: ") var confirm string @@ -357,7 +372,7 @@ func newDraftsSendCmd() *cobra.Command { } } - msg, err := client.SendDraft(ctx, grantID, draftID) + msg, err := client.SendDraft(ctx, grantID, draftID, sendDraftRequest(signatureID)) if err != nil { return struct{}{}, common.WrapSendError("draft", err) } @@ -370,6 +385,7 @@ func newDraftsSendCmd() *cobra.Command { } cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") + cmd.Flags().StringVar(&signatureID, "signature-id", "", "Stored signature ID to append when sending a draft created without a stored signature") return cmd } diff --git a/internal/cli/email/email.go b/internal/cli/email/email.go index 61e95c5..57c4653 100644 --- a/internal/cli/email/email.go +++ b/internal/cli/email/email.go @@ -29,6 +29,7 @@ func NewEmailCmd() *cobra.Command { cmd.AddCommand(newMetadataCmd()) cmd.AddCommand(newAICmd()) cmd.AddCommand(newTemplatesCmd()) + cmd.AddCommand(newSignaturesCmd()) return cmd } diff --git a/internal/cli/email/email_basic_test.go b/internal/cli/email/email_basic_test.go index a17cbac..1df82c2 100644 --- a/internal/cli/email/email_basic_test.go +++ b/internal/cli/email/email_basic_test.go @@ -39,7 +39,7 @@ func TestNewEmailCmd(t *testing.T) { }) t.Run("has_required_subcommands", func(t *testing.T) { - expectedCmds := []string{"list", "read", "send", "search", "mark", "delete", "folders", "threads", "drafts"} + expectedCmds := []string{"list", "read", "send", "search", "mark", "delete", "folders", "threads", "drafts", "signatures"} cmdMap := make(map[string]bool) for _, sub := range cmd.Commands() { diff --git a/internal/cli/email/send.go b/internal/cli/email/send.go index c85e528..943a7e8 100644 --- a/internal/cli/email/send.go +++ b/internal/cli/email/send.go @@ -37,6 +37,7 @@ func newSendCmd() *cobra.Command { var listGPGKeys bool var encrypt bool var recipientKey string + var signatureID string var templateOpts hostedTemplateSendOptions cmd := &cobra.Command{ @@ -240,11 +241,12 @@ Supports hosted templates: } req := &domain.SendMessageRequest{ - Subject: activeSubject, - Body: activeBody, - To: toContacts, - Cc: ccContacts, - Bcc: bccContacts, + Subject: activeSubject, + Body: activeBody, + To: toContacts, + Cc: ccContacts, + Bcc: bccContacts, + SignatureID: signatureID, } if replyTo != "" { req.ReplyToMsgID = replyTo @@ -320,6 +322,9 @@ Supports hosted templates: } fmt.Printf(" %s %s\n", common.Blue.Sprint("GPG Encrypted:"), encryptInfo) } + if signatureID != "" { + fmt.Printf(" %s %s\n", common.Cyan.Sprint("Signature:"), signatureID) + } if !noConfirm { if scheduledTime.IsZero() { @@ -342,6 +347,17 @@ Supports hosted templates: // Get grant info to determine provider and email grant, grantErr := client.GetGrant(ctx, grantID) + if signatureID != "" { + if grantErr != nil { + return struct{}{}, common.WrapGetError("grant", grantErr) + } + if err := validateSendSignatureSupport(signatureID, sign, encrypt, grant); err != nil { + return struct{}{}, err + } + if _, err := validateSignatureSelection(ctx, client, grantID, signatureID, grant); err != nil { + return struct{}{}, err + } + } if sign || encrypt { if grantErr == nil && grant != nil && grant.Email != "" { @@ -442,6 +458,7 @@ Supports hosted templates: cmd.Flags().BoolVar(&listGPGKeys, "list-gpg-keys", false, "List available GPG signing keys and exit") cmd.Flags().BoolVar(&encrypt, "encrypt", false, "Encrypt email with recipient's GPG public key") cmd.Flags().StringVar(&recipientKey, "recipient-key", "", "Specific GPG key ID for encryption (auto-detected from recipient email if not specified)") + cmd.Flags().StringVar(&signatureID, "signature-id", "", "Stored signature ID to append when sending") cmd.Flags().StringVar(&templateOpts.TemplateID, "template-id", "", "Hosted template ID to render and send") cmd.Flags().StringVar(&templateOpts.TemplateScope, "template-scope", string(domain.ScopeApplication), "Hosted template scope: app or grant") cmd.Flags().StringVar(&templateOpts.TemplateGrantID, "template-grant-id", "", "Grant ID or email for grant-scoped hosted templates") diff --git a/internal/cli/email/send_test.go b/internal/cli/email/send_test.go index ecbdd6a..acc6659 100644 --- a/internal/cli/email/send_test.go +++ b/internal/cli/email/send_test.go @@ -226,6 +226,7 @@ func TestSendCmd_FlagDefinitions(t *testing.T) { {name: "sign", shorthand: "", flagType: "bool"}, {name: "gpg-key", shorthand: "", flagType: "string"}, {name: "list-gpg-keys", shorthand: "", flagType: "bool"}, + {name: "signature-id", shorthand: "", flagType: "string"}, {name: "interactive", shorthand: "i", flagType: "bool"}, {name: "yes", shorthand: "y", flagType: "bool"}, {name: "template-id", shorthand: "", flagType: "string"}, diff --git a/internal/cli/email/signatures.go b/internal/cli/email/signatures.go new file mode 100644 index 0000000..245c51f --- /dev/null +++ b/internal/cli/email/signatures.go @@ -0,0 +1,236 @@ +package email + +import ( + "context" + "fmt" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newSignaturesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "signatures", + Short: "Manage stored email signatures", + Long: "List, show, create, update, and delete stored email signatures for a grant.", + } + + cmd.AddCommand(newSignaturesListCmd()) + cmd.AddCommand(newSignaturesShowCmd()) + cmd.AddCommand(newSignaturesCreateCmd()) + cmd.AddCommand(newSignaturesUpdateCmd()) + cmd.AddCommand(newSignaturesDeleteCmd()) + + return cmd +} + +func newSignaturesListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list [grant-id]", + Short: "List stored signatures", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + _, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + signatures, err := client.GetSignatures(ctx, grantID) + if err != nil { + return struct{}{}, common.WrapGetError("signatures", err) + } + if common.IsStructuredOutput(cmd) { + out := common.GetOutputWriter(cmd) + return struct{}{}, out.Write(signatures) + } + if len(signatures) == 0 { + common.PrintEmptyState("signatures") + return struct{}{}, nil + } + out := common.GetOutputWriter(cmd) + return struct{}{}, out.WriteList(signatures, []ports.Column{ + {Header: "ID", Field: "ID", Width: 20}, + {Header: "Name", Field: "Name", Width: 24}, + {Header: "Updated", Field: "UpdatedAt", Width: 0}, + }) + }) + return err + }, + } + + return cmd +} + +func newSignaturesShowCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "show [grant-id]", + Short: "Show signature details", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + signatureID := args[0] + remainingArgs := args[1:] + + _, err := common.WithClient(remainingArgs, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + signature, err := client.GetSignature(ctx, grantID, signatureID) + if err != nil { + return struct{}{}, common.WrapGetError("signature", err) + } + if common.IsStructuredOutput(cmd) { + out := common.GetOutputWriter(cmd) + return struct{}{}, out.Write(signature) + } + printSignature(signature) + return struct{}{}, nil + }) + return err + }, + } + + return cmd +} + +func newSignaturesCreateCmd() *cobra.Command { + var name string + var body string + var bodyFile string + + cmd := &cobra.Command{ + Use: "create [grant-id]", + Short: "Create a stored signature", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := common.ValidateRequiredFlag("--name", name); err != nil { + return err + } + signatureBody, err := common.ReadStringOrFile("body", body, bodyFile, true) + if err != nil { + return err + } + + _, err = common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + signature, err := client.CreateSignature(ctx, grantID, &domain.CreateSignatureRequest{ + Name: name, + Body: signatureBody, + }) + if err != nil { + return struct{}{}, common.WrapCreateError("signature", err) + } + if common.IsStructuredOutput(cmd) { + out := common.GetOutputWriter(cmd) + return struct{}{}, out.Write(signature) + } + printSuccess("Signature created successfully!") + fmt.Printf(" ID: %s\n", signature.ID) + fmt.Printf(" Name: %s\n", signature.Name) + if preview := signaturePreview(signature.Body); preview != "" { + fmt.Printf(" Preview: %s\n", preview) + } + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().StringVarP(&name, "name", "n", "", "Signature name (required)") + cmd.Flags().StringVarP(&body, "body", "b", "", "Signature HTML body") + cmd.Flags().StringVar(&bodyFile, "body-file", "", "Path to a file containing the signature HTML body") + + return cmd +} + +func newSignaturesUpdateCmd() *cobra.Command { + var name string + var body string + var bodyFile string + + cmd := &cobra.Command{ + Use: "update [grant-id]", + Short: "Update a stored signature", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + signatureID := args[0] + remainingArgs := args[1:] + + signatureBody, err := common.ReadStringOrFile("body", body, bodyFile, false) + if err != nil { + return err + } + if err := common.ValidateAtLeastOne("signature update field", name, signatureBody); err != nil { + return err + } + + _, err = common.WithClient(remainingArgs, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + signature, err := client.UpdateSignature(ctx, grantID, signatureID, &domain.UpdateSignatureRequest{ + Name: optionalString(name), + Body: optionalString(signatureBody), + }) + if err != nil { + return struct{}{}, common.WrapUpdateError("signature", err) + } + if common.IsStructuredOutput(cmd) { + out := common.GetOutputWriter(cmd) + return struct{}{}, out.Write(signature) + } + printSuccess("Signature updated successfully!") + fmt.Printf(" ID: %s\n", signature.ID) + fmt.Printf(" Name: %s\n", signature.Name) + if preview := signaturePreview(signature.Body); preview != "" { + fmt.Printf(" Preview: %s\n", preview) + } + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().StringVarP(&name, "name", "n", "", "Updated signature name") + cmd.Flags().StringVarP(&body, "body", "b", "", "Updated signature HTML body") + cmd.Flags().StringVar(&bodyFile, "body-file", "", "Path to a file containing the updated signature HTML body") + + return cmd +} + +func newSignaturesDeleteCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "delete [grant-id]", + Short: "Delete a stored signature", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + signatureID := args[0] + remainingArgs := args[1:] + + _, err := common.WithClient(remainingArgs, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + signature, err := client.GetSignature(ctx, grantID, signatureID) + if err != nil { + return struct{}{}, common.WrapGetError("signature", err) + } + if !yes { + fmt.Printf("Delete signature %q (%s)? [y/N]: ", signature.Name, signature.ID) + var confirm string + _, _ = fmt.Scanln(&confirm) + if confirm != "y" && confirm != "Y" && confirm != "yes" { + fmt.Println("Cancelled.") + return struct{}{}, nil + } + } + if err := client.DeleteSignature(ctx, grantID, signatureID); err != nil { + return struct{}{}, common.WrapDeleteError("signature", err) + } + printSuccess("Signature deleted successfully!") + return struct{}{}, nil + }) + return err + }, + } + + common.AddYesFlag(cmd, &yes) + + return cmd +} + +func optionalString(value string) *string { + if value == "" { + return nil + } + return &value +} diff --git a/internal/cli/email/signatures_support.go b/internal/cli/email/signatures_support.go new file mode 100644 index 0000000..924716c --- /dev/null +++ b/internal/cli/email/signatures_support.go @@ -0,0 +1,156 @@ +package email + +import ( + "context" + "fmt" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +func validateSendSignatureSupport(signatureID string, sign, encrypt bool, grant *domain.Grant) error { + if signatureID == "" { + return nil + } + if sign || encrypt { + return common.NewUserError( + "`--signature-id` is not supported with GPG signing or encryption", + "Use standard JSON send mode without --sign/--encrypt, or add the signature HTML directly to the message body", + ) + } + if grant != nil && grant.Provider == domain.ProviderInbox { + return common.NewUserError( + "`--signature-id` is not supported for Inbox transactional sends", + "Inbox grants use the domain-based transactional send endpoint, which does not accept signature_id", + ) + } + return nil +} + +func validateSignatureSelection( + ctx context.Context, + client ports.NylasClient, + grantID, signatureID string, + grant *domain.Grant, +) ([]domain.Signature, error) { + if signatureID == "" { + return nil, nil + } + if grant == nil { + var err error + grant, err = client.GetGrant(ctx, grantID) + if err != nil { + return nil, common.WrapGetError("grant", err) + } + } + if err := validateSendSignatureSupport(signatureID, false, false, grant); err != nil { + return nil, err + } + + signatures, err := client.GetSignatures(ctx, grantID) + if err != nil { + return nil, common.WrapGetError("signatures", err) + } + for _, signature := range signatures { + if signature.ID == signatureID { + return signatures, nil + } + } + + return nil, common.NewUserError( + fmt.Sprintf("signature %q was not found for this grant", signatureID), + "List available signatures with: nylas email signatures list [grant-id]", + ) +} + +func validateDraftSendSignatureSelection( + ctx context.Context, + client ports.NylasClient, + grantID string, + draft *domain.Draft, + signatureID string, +) error { + signatures, err := validateSignatureSelection(ctx, client, grantID, signatureID, nil) + if err != nil || draft == nil || signatureID == "" { + return err + } + + if existing := findStoredSignatureInBody(draft.Body, signatures); existing != nil { + return common.NewUserError( + "`--signature-id` cannot be used when the draft body already contains a stored signature", + fmt.Sprintf( + "Draft %q already contains stored signature %q. Send it without --signature-id, or update the draft body before switching signatures.", + draft.ID, + existing.Name, + ), + ) + } + + return nil +} + +func findStoredSignatureInBody(body string, signatures []domain.Signature) *domain.Signature { + if strings.TrimSpace(body) == "" { + return nil + } + + normalizedBodyText := normalizeSignatureText(body) + for i := range signatures { + signature := &signatures[i] + if strings.TrimSpace(signature.Body) == "" { + continue + } + if strings.Contains(body, signature.Body) { + return signature + } + + normalizedSignatureText := normalizeSignatureText(signature.Body) + if normalizedSignatureText != "" && strings.Contains(normalizedBodyText, normalizedSignatureText) { + return signature + } + } + + return nil +} + +func normalizeSignatureText(body string) string { + text := common.StripHTML(body) + return strings.Join(strings.Fields(strings.ToLower(text)), " ") +} + +func sendDraftRequest(signatureID string) *domain.SendDraftRequest { + if signatureID == "" { + return nil + } + return &domain.SendDraftRequest{SignatureID: signatureID} +} + +func signaturePreview(body string) string { + if body == "" { + return "" + } + return common.Truncate(common.StripHTML(body), 80) +} + +func printSignature(signature *domain.Signature) { + fmt.Println("════════════════════════════════════════════════════════════") + _, _ = common.BoldWhite.Printf("Signature: %s\n", signature.Name) + fmt.Println("════════════════════════════════════════════════════════════") + fmt.Printf("ID: %s\n", signature.ID) + if !signature.CreatedAt.IsZero() { + fmt.Printf("Created: %s\n", signature.CreatedAt.Format(common.DisplayDateTime)) + } + if !signature.UpdatedAt.IsZero() { + fmt.Printf("Updated: %s\n", signature.UpdatedAt.Format(common.DisplayDateTime)) + } + if preview := signaturePreview(signature.Body); preview != "" { + fmt.Printf("Preview: %s\n", preview) + } + if signature.Body != "" { + fmt.Println("\nBody:") + fmt.Println("────────────────────────────────────────────────────────────") + fmt.Println(signature.Body) + } +} diff --git a/internal/cli/email/signatures_test.go b/internal/cli/email/signatures_test.go new file mode 100644 index 0000000..3642f6d --- /dev/null +++ b/internal/cli/email/signatures_test.go @@ -0,0 +1,237 @@ +package email + +import ( + "context" + "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 TestNewSignaturesCmd(t *testing.T) { + cmd := newSignaturesCmd() + + assert.Equal(t, "signatures", cmd.Use) + + subcommands := map[string]bool{} + for _, sub := range cmd.Commands() { + subcommands[sub.Name()] = true + } + + for _, expected := range []string{"list", "show", "create", "update", "delete"} { + assert.True(t, subcommands[expected], "missing signatures subcommand %q", expected) + } +} + +func TestSignaturesCommandFlags(t *testing.T) { + createCmd := newSignaturesCreateCmd() + require.NotNil(t, createCmd.Flags().Lookup("name")) + require.NotNil(t, createCmd.Flags().Lookup("body")) + require.NotNil(t, createCmd.Flags().Lookup("body-file")) + + updateCmd := newSignaturesUpdateCmd() + require.NotNil(t, updateCmd.Flags().Lookup("name")) + require.NotNil(t, updateCmd.Flags().Lookup("body")) + require.NotNil(t, updateCmd.Flags().Lookup("body-file")) + + deleteCmd := newSignaturesDeleteCmd() + require.NotNil(t, deleteCmd.Flags().Lookup("yes")) +} + +func TestValidateSendSignatureSupport(t *testing.T) { + tests := []struct { + name string + signatureID string + sign bool + encrypt bool + grant *domain.Grant + wantErr bool + }{ + { + name: "empty signature id is always allowed", + wantErr: false, + }, + { + name: "signing rejects stored signatures", + signatureID: "sig-123", + sign: true, + wantErr: true, + }, + { + name: "encrypting rejects stored signatures", + signatureID: "sig-123", + encrypt: true, + wantErr: true, + }, + { + name: "inbox provider rejects stored signatures", + signatureID: "sig-123", + grant: &domain.Grant{Provider: domain.ProviderInbox}, + wantErr: true, + }, + { + name: "standard provider allows stored signatures", + signatureID: "sig-123", + grant: &domain.Grant{Provider: domain.ProviderGoogle}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSendSignatureSupport(tt.signatureID, tt.sign, tt.encrypt, tt.grant) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func TestValidateSignatureSelection(t *testing.T) { + ctx := context.Background() + + t.Run("empty signature id skips lookups", func(t *testing.T) { + mock := nylas.NewMockClient() + + signatures, err := validateSignatureSelection(ctx, mock, "grant-123", "", nil) + + require.NoError(t, err) + assert.Nil(t, signatures) + assert.False(t, mock.GetGrantCalled) + assert.False(t, mock.GetSignaturesCalled) + }) + + t.Run("uses provided grant and returns matching signatures", func(t *testing.T) { + mock := nylas.NewMockClient() + mock.GetSignaturesFunc = func(ctx context.Context, grantID string) ([]domain.Signature, error) { + return []domain.Signature{ + {ID: "sig-123", Name: "Work", Body: "

Best regards

"}, + {ID: "sig-456", Name: "Other", Body: "

Thanks

"}, + }, nil + } + + signatures, err := validateSignatureSelection( + ctx, + mock, + "grant-123", + "sig-123", + &domain.Grant{Provider: domain.ProviderGoogle}, + ) + + require.NoError(t, err) + require.Len(t, signatures, 2) + assert.False(t, mock.GetGrantCalled) + assert.True(t, mock.GetSignaturesCalled) + }) + + t.Run("rejects inbox grants before listing signatures", func(t *testing.T) { + mock := nylas.NewMockClient() + + signatures, err := validateSignatureSelection( + ctx, + mock, + "grant-123", + "sig-123", + &domain.Grant{Provider: domain.ProviderInbox}, + ) + + require.Error(t, err) + assert.Nil(t, signatures) + assert.ErrorContains(t, err, "`--signature-id` is not supported for Inbox transactional sends") + assert.False(t, mock.GetGrantCalled) + assert.False(t, mock.GetSignaturesCalled) + }) + + t.Run("rejects unknown signatures", func(t *testing.T) { + mock := nylas.NewMockClient() + mock.GetSignaturesFunc = func(ctx context.Context, grantID string) ([]domain.Signature, error) { + return []domain.Signature{{ID: "sig-other", Name: "Other"}}, nil + } + + signatures, err := validateSignatureSelection(ctx, mock, "grant-123", "sig-missing", nil) + + require.Error(t, err) + assert.Nil(t, signatures) + assert.True(t, mock.GetGrantCalled) + assert.True(t, mock.GetSignaturesCalled) + assert.ErrorContains(t, err, `signature "sig-missing" was not found for this grant`) + }) +} + +func TestValidateDraftSendSignatureSelection(t *testing.T) { + ctx := context.Background() + + t.Run("empty signature id skips validation", func(t *testing.T) { + mock := nylas.NewMockClient() + + err := validateDraftSendSignatureSelection(ctx, mock, "grant-123", &domain.Draft{ + ID: "draft-123", + Body: "

Best regards

", + }, "") + + require.NoError(t, err) + assert.False(t, mock.GetGrantCalled) + assert.False(t, mock.GetSignaturesCalled) + }) + + t.Run("rejects exact stored signature match in body", func(t *testing.T) { + mock := nylas.NewMockClient() + mock.GetSignaturesFunc = func(ctx context.Context, grantID string) ([]domain.Signature, error) { + return []domain.Signature{{ID: "sig-123", Name: "Work", Body: "

Best regards

"}}, nil + } + + err := validateDraftSendSignatureSelection(ctx, mock, "grant-123", &domain.Draft{ + ID: "draft-123", + Body: "

Hello

Best regards

", + }, "sig-123") + + require.Error(t, err) + assert.ErrorContains(t, err, "already contains a stored signature") + }) + + t.Run("rejects normalized stored signature match in body", func(t *testing.T) { + mock := nylas.NewMockClient() + mock.GetSignaturesFunc = func(ctx context.Context, grantID string) ([]domain.Signature, error) { + return []domain.Signature{{ID: "sig-123", Name: "Work", Body: "

Best regards

"}}, nil + } + + err := validateDraftSendSignatureSelection(ctx, mock, "grant-123", &domain.Draft{ + ID: "draft-123", + Body: "
Hello
Best regards
", + }, "sig-123") + + require.Error(t, err) + assert.ErrorContains(t, err, "already contains a stored signature") + }) + + t.Run("allows send when body does not contain a stored signature", func(t *testing.T) { + mock := nylas.NewMockClient() + mock.GetSignaturesFunc = func(ctx context.Context, grantID string) ([]domain.Signature, error) { + return []domain.Signature{{ID: "sig-123", Name: "Work", Body: "

Best regards

"}}, nil + } + + err := validateDraftSendSignatureSelection(ctx, mock, "grant-123", &domain.Draft{ + ID: "draft-123", + Body: "

Hello

Talk soon

", + }, "sig-123") + + require.NoError(t, err) + }) +} + +func TestSendDraftRequest(t *testing.T) { + assert.Nil(t, sendDraftRequest("")) + + req := sendDraftRequest("sig-123") + require.NotNil(t, req) + assert.Equal(t, "sig-123", req.SignatureID) +} + +func TestSignaturePreview(t *testing.T) { + assert.Equal(t, "", signaturePreview("")) + assert.Contains(t, signaturePreview("

Hello world

"), "Hello world") +} diff --git a/internal/cli/email/templates_use.go b/internal/cli/email/templates_use.go index ce71f3d..41f6476 100644 --- a/internal/cli/email/templates_use.go +++ b/internal/cli/email/templates_use.go @@ -22,6 +22,7 @@ func newTemplatesUseCmd() *cobra.Command { var preview bool var noConfirm bool var jsonOutput bool + var signatureID string cmd := &cobra.Command{ Use: "use ", @@ -121,9 +122,10 @@ Use --preview to see the expanded template without sending.`, // Build request req := &domain.SendMessageRequest{ - Subject: expandedSubject, - Body: expandedBody, - To: toContacts, + Subject: expandedSubject, + Body: expandedBody, + To: toContacts, + SignatureID: signatureID, } if len(cc) > 0 { @@ -143,6 +145,10 @@ Use --preview to see the expanded template without sending.`, // Send email _, sendErr := common.WithClient(nil, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + if _, err := validateSignatureSelection(ctx, client, grantID, signatureID, nil); err != nil { + return struct{}{}, err + } + spinner := common.NewSpinner("Sending email...") spinner.Start() @@ -177,6 +183,7 @@ Use --preview to see the expanded template without sending.`, cmd.Flags().BoolVarP(&preview, "preview", "p", false, "Preview the expanded template without sending") cmd.Flags().BoolVarP(&noConfirm, "yes", "y", false, "Skip confirmation prompt") cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + cmd.Flags().StringVar(&signatureID, "signature-id", "", "Stored signature ID to append when sending") return cmd } diff --git a/internal/cli/integration/signatures_test.go b/internal/cli/integration/signatures_test.go new file mode 100644 index 0000000..116ca6f --- /dev/null +++ b/internal/cli/integration/signatures_test.go @@ -0,0 +1,256 @@ +//go:build integration + +package integration + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCLI_SignatureFlags_Help(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found - run 'go build -o bin/nylas ./cmd/nylas' first") + } + + tests := []struct { + name string + args []string + }{ + { + name: "email send help includes signature flag", + args: []string{"email", "send", "--help"}, + }, + { + name: "draft create help includes signature flag", + args: []string{"email", "drafts", "create", "--help"}, + }, + { + name: "draft send help includes signature flag", + args: []string{"email", "drafts", "send", "--help"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdout, stderr, err := runCLI(tt.args...) + if err != nil { + t.Fatalf("help command failed: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, "--signature-id") { + t.Fatalf("expected --signature-id in help output, got:\n%s", stdout) + } + }) + } +} + +func TestCLI_SignaturesList(t *testing.T) { + skipIfMissingCreds(t) + + stdout, stderr, err := runCLI("email", "signatures", "list", testGrantID) + skipIfProviderNotSupported(t, stderr) + + if err != nil { + t.Fatalf("signatures list failed: %v\nstderr: %s", err, stderr) + } + + if !strings.Contains(stdout, "No signatures found") && !strings.Contains(stdout, "UPDATED") && !strings.Contains(stdout, "ID") { + t.Errorf("Expected signatures list output, got: %s", stdout) + } +} + +func TestCLI_SignaturesLifecycle(t *testing.T) { + skipIfMissingCreds(t) + + if os.Getenv("NYLAS_TEST_DELETE") != "true" { + t.Skip("NYLAS_TEST_DELETE not set to 'true'") + } + + listStdout, listStderr, err := runCLI("email", "signatures", "list", testGrantID) + skipIfProviderNotSupported(t, listStderr) + if err != nil { + t.Fatalf("signatures list pre-check failed: %v\nstderr: %s", err, listStderr) + } + if strings.Count(listStdout, "\n") >= 11 && strings.Contains(listStdout, "UPDATED") { + t.Skip("Grant already has 10 signatures; skipping lifecycle test to avoid hard limit failures") + } + + bodyPath := filepath.Join(t.TempDir(), "signature.html") + body := fmt.Sprintf("

CLI Signature %d

", time.Now().UnixNano()) + if err := os.WriteFile(bodyPath, []byte(body), 0o600); err != nil { + t.Fatalf("failed to write signature body file: %v", err) + } + + name := fmt.Sprintf("CLI Signature %d", time.Now().Unix()) + var signatureID string + + t.Run("create", func(t *testing.T) { + stdout, stderr, err := runCLI("email", "signatures", "create", + "--name", name, + "--body-file", bodyPath, + testGrantID) + skipIfProviderNotSupported(t, stderr) + + if err != nil { + t.Fatalf("signatures create failed: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, "Signature created successfully") { + t.Fatalf("expected create success output, got: %s", stdout) + } + + signatureID = extractFieldValue(stdout, "ID:") + if signatureID == "" { + t.Fatalf("failed to extract signature ID from output: %s", stdout) + } + }) + + t.Run("show", func(t *testing.T) { + stdout, stderr, err := runCLI("email", "signatures", "show", signatureID, testGrantID) + skipIfProviderNotSupported(t, stderr) + if err != nil { + t.Fatalf("signatures show failed: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, name) { + t.Fatalf("expected signature name in output, got: %s", stdout) + } + }) + + updatedName := name + " Updated" + updatedBody := "

CLI Signature Updated

" + + t.Run("update", func(t *testing.T) { + stdout, stderr, err := runCLI("email", "signatures", "update", signatureID, + "--name", updatedName, + "--body", updatedBody, + testGrantID) + skipIfProviderNotSupported(t, stderr) + + if err != nil { + t.Fatalf("signatures update failed: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, "Signature updated successfully") { + t.Fatalf("expected update success output, got: %s", stdout) + } + if !strings.Contains(stdout, updatedName) { + t.Fatalf("expected updated name in output, got: %s", stdout) + } + }) + + t.Run("delete", func(t *testing.T) { + stdout, stderr, err := runCLI("email", "signatures", "delete", signatureID, "--yes", testGrantID) + skipIfProviderNotSupported(t, stderr) + if err != nil { + t.Fatalf("signatures delete failed: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, "Signature deleted successfully") { + t.Fatalf("expected delete success output, got: %s", stdout) + } + }) +} + +func TestCLI_DraftsSendRejectsDuplicateStoredSignature(t *testing.T) { + skipIfMissingCreds(t) + + if os.Getenv("NYLAS_TEST_DELETE") != "true" { + t.Skip("NYLAS_TEST_DELETE not set to 'true'") + } + + listStdout, listStderr, err := runCLI("email", "signatures", "list", testGrantID) + skipIfProviderNotSupported(t, listStderr) + if err != nil { + t.Fatalf("signatures list pre-check failed: %v\nstderr: %s", err, listStderr) + } + if strings.Count(listStdout, "\n") >= 11 && strings.Contains(listStdout, "UPDATED") { + t.Skip("Grant already has 10 signatures; skipping duplicate signature test to avoid hard limit failures") + } + + recipient := testEmail + if recipient == "" { + recipient = "test@example.com" + } + + name := fmt.Sprintf("CLI Duplicate Signature %d", time.Now().Unix()) + body := fmt.Sprintf("

CLI duplicate signature marker %d

", time.Now().UnixNano()) + + createSigStdout, createSigStderr, err := runCLI( + "email", "signatures", "create", + "--name", name, + "--body", body, + testGrantID, + ) + skipIfProviderNotSupported(t, createSigStderr) + if err != nil { + t.Fatalf("signatures create failed: %v\nstderr: %s", err, createSigStderr) + } + + signatureID := extractFieldValue(createSigStdout, "ID:") + if signatureID == "" { + t.Fatalf("failed to extract signature ID from output: %s", createSigStdout) + } + defer func() { + stdout, stderr, cleanupErr := runCLI("email", "signatures", "delete", signatureID, "--yes", testGrantID) + skipIfProviderNotSupported(t, stderr) + if cleanupErr != nil { + t.Fatalf("signatures delete failed: %v\nstderr: %s\nstdout: %s", cleanupErr, stderr, stdout) + } + }() + + createDraftStdout, createDraftStderr, err := runCLI( + "email", "drafts", "create", + "--to", recipient, + "--subject", fmt.Sprintf("Duplicate Signature Draft %d", time.Now().Unix()), + "--body", "This draft should keep a single stored signature", + "--signature-id", signatureID, + testGrantID, + ) + if err != nil { + t.Fatalf("draft create failed: %v\nstderr: %s", err, createDraftStderr) + } + + draftID := extractInlineID(createDraftStdout) + if draftID == "" { + t.Fatalf("failed to extract draft ID from output: %s", createDraftStdout) + } + defer func() { + stdout, stderr, cleanupErr := runCLI("email", "drafts", "delete", draftID, "--force", testGrantID) + if cleanupErr != nil { + t.Fatalf("draft delete failed: %v\nstderr: %s\nstdout: %s", cleanupErr, stderr, stdout) + } + }() + + _, stderr, err := runCLI( + "email", "drafts", "send", draftID, + "--signature-id", signatureID, + "--force", + testGrantID, + ) + require.Error(t, err) + assert.Contains(t, stderr, "already contains a stored signature") +} + +func extractFieldValue(output, prefix string) string { + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, prefix) { + return strings.TrimSpace(strings.TrimPrefix(trimmed, prefix)) + } + } + return "" +} + +func extractInlineID(output string) string { + if idx := strings.Index(output, "ID:"); idx != -1 { + value := strings.TrimSpace(output[idx+3:]) + if newline := strings.Index(value, "\n"); newline != -1 { + value = value[:newline] + } + return strings.TrimSpace(value) + } + return "" +} diff --git a/internal/domain/email.go b/internal/domain/email.go index 436c959..f219a4b 100644 --- a/internal/domain/email.go +++ b/internal/domain/email.go @@ -40,6 +40,15 @@ type Draft struct { UpdatedAt time.Time `json:"updated_at"` } +// Signature represents a stored email signature. +type Signature struct { + ID string `json:"id"` + Name string `json:"name"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // Folder represents an email folder/label. type Folder struct { ID string `json:"id"` @@ -91,6 +100,7 @@ type SendMessageRequest struct { TrackingOpts *TrackingOptions `json:"tracking_options,omitempty"` Attachments []Attachment `json:"attachments,omitempty"` SendAt int64 `json:"send_at,omitempty"` // Unix timestamp for scheduled sending + SignatureID string `json:"signature_id,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` // GPG Signature options (not sent to API, used internally for signing) @@ -181,9 +191,27 @@ type CreateDraftRequest struct { ReplyTo []EmailParticipant `json:"reply_to,omitempty"` ReplyToMsgID string `json:"reply_to_message_id,omitempty"` Attachments []Attachment `json:"attachments,omitempty"` + SignatureID string `json:"signature_id,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` } +// SendDraftRequest for sending a draft. +type SendDraftRequest struct { + SignatureID string `json:"signature_id,omitempty"` +} + +// CreateSignatureRequest for creating a new signature. +type CreateSignatureRequest struct { + Name string `json:"name"` + Body string `json:"body"` +} + +// UpdateSignatureRequest for updating an existing signature. +type UpdateSignatureRequest struct { + Name *string `json:"name,omitempty"` + Body *string `json:"body,omitempty"` +} + // CreateFolderRequest for creating a new folder. type CreateFolderRequest struct { Name string `json:"name"` diff --git a/internal/domain/email_request_test.go b/internal/domain/email_request_test.go index 871bfeb..1f1f823 100644 --- a/internal/domain/email_request_test.go +++ b/internal/domain/email_request_test.go @@ -208,8 +208,9 @@ func TestUpdateMessageRequest_Creation(t *testing.T) { func TestCreateDraftRequest_Creation(t *testing.T) { req := CreateDraftRequest{ - Subject: "Draft Email", - Body: "

Draft content

", + Subject: "Draft Email", + Body: "

Draft content

", + SignatureID: "sig-123", To: []EmailParticipant{ {Email: "to@example.com"}, }, @@ -234,6 +235,63 @@ func TestCreateDraftRequest_Creation(t *testing.T) { if req.ReplyToMsgID != "orig-msg-123" { t.Errorf("CreateDraftRequest.ReplyToMsgID = %q, want %q", req.ReplyToMsgID, "orig-msg-123") } + if req.SignatureID != "sig-123" { + t.Errorf("CreateDraftRequest.SignatureID = %q, want %q", req.SignatureID, "sig-123") + } +} + +func TestSignatureRequestTypes(t *testing.T) { + t.Run("send draft request", func(t *testing.T) { + req := SendDraftRequest{SignatureID: "sig-send"} + if req.SignatureID != "sig-send" { + t.Errorf("SendDraftRequest.SignatureID = %q, want %q", req.SignatureID, "sig-send") + } + }) + + t.Run("create signature request", func(t *testing.T) { + req := CreateSignatureRequest{ + Name: "Work", + Body: "

Best regards

", + } + if req.Name != "Work" { + t.Errorf("CreateSignatureRequest.Name = %q, want %q", req.Name, "Work") + } + if req.Body != "

Best regards

" { + t.Errorf("CreateSignatureRequest.Body = %q, want %q", req.Body, "

Best regards

") + } + }) + + t.Run("update signature request", func(t *testing.T) { + name := "Updated" + body := "

Updated

" + req := UpdateSignatureRequest{ + Name: &name, + Body: &body, + } + if req.Name == nil || *req.Name != "Updated" { + t.Fatalf("UpdateSignatureRequest.Name = %v, want %q", req.Name, "Updated") + } + if req.Body == nil || *req.Body != "

Updated

" { + t.Fatalf("UpdateSignatureRequest.Body = %v, want %q", req.Body, "

Updated

") + } + }) + + t.Run("signature model", func(t *testing.T) { + now := time.Now() + signature := Signature{ + ID: "sig-123", + Name: "Work", + Body: "

Best regards

", + CreatedAt: now, + UpdatedAt: now, + } + if signature.ID != "sig-123" { + t.Errorf("Signature.ID = %q, want %q", signature.ID, "sig-123") + } + if signature.Name != "Work" { + t.Errorf("Signature.Name = %q, want %q", signature.Name, "Work") + } + }) } // ============================================================================= diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 5b859a1..9210da4 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -47,6 +47,7 @@ var ( ErrMessageNotFound = errors.New("message not found") ErrFolderNotFound = errors.New("folder not found") ErrDraftNotFound = errors.New("draft not found") + ErrSignatureNotFound = errors.New("signature not found") ErrThreadNotFound = errors.New("thread not found") ErrAttachmentNotFound = errors.New("attachment not found") ErrWebhookNotFound = errors.New("webhook not found") diff --git a/internal/ports/messages.go b/internal/ports/messages.go index 88cd4c2..a23e1a2 100644 --- a/internal/ports/messages.go +++ b/internal/ports/messages.go @@ -34,6 +34,21 @@ type MessageClient interface { // SendRawMessage sends a raw RFC 822 MIME message. SendRawMessage(ctx context.Context, grantID string, rawMIME []byte) (*domain.Message, error) + // GetSignatures retrieves all signatures for a grant. + GetSignatures(ctx context.Context, grantID string) ([]domain.Signature, error) + + // GetSignature retrieves a specific signature. + GetSignature(ctx context.Context, grantID, signatureID string) (*domain.Signature, error) + + // CreateSignature creates a new signature. + CreateSignature(ctx context.Context, grantID string, req *domain.CreateSignatureRequest) (*domain.Signature, error) + + // UpdateSignature updates an existing signature. + UpdateSignature(ctx context.Context, grantID, signatureID string, req *domain.UpdateSignatureRequest) (*domain.Signature, error) + + // DeleteSignature deletes a signature. + DeleteSignature(ctx context.Context, grantID, signatureID string) error + // UpdateMessage updates an existing message. UpdateMessage(ctx context.Context, grantID, messageID string, req *domain.UpdateMessageRequest) (*domain.Message, error) @@ -99,7 +114,7 @@ type MessageClient interface { DeleteDraft(ctx context.Context, grantID, draftID string) error // SendDraft sends a draft as a message. - SendDraft(ctx context.Context, grantID, draftID string) (*domain.Message, error) + SendDraft(ctx context.Context, grantID, draftID string, req *domain.SendDraftRequest) (*domain.Message, error) // ================================ // FOLDER OPERATIONS diff --git a/internal/tui/compose_actions.go b/internal/tui/compose_actions.go index ba938f9..affd0cb 100644 --- a/internal/tui/compose_actions.go +++ b/internal/tui/compose_actions.go @@ -141,7 +141,7 @@ func (c *ComposeView) send() { return } // Then send the draft - _, err = c.app.config.Client.SendDraft(ctx, c.app.config.GrantID, c.draft.ID) + _, err = c.app.config.Client.SendDraft(ctx, c.app.config.GrantID, c.draft.ID, nil) } else { // Normal send flow req := &domain.SendMessageRequest{ diff --git a/internal/tui/drafts.go b/internal/tui/drafts.go index 7db4381..6573a43 100644 --- a/internal/tui/drafts.go +++ b/internal/tui/drafts.go @@ -203,7 +203,7 @@ func (v *DraftsView) sendDraft(draft *domain.Draft) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - _, err := v.app.config.Client.SendDraft(ctx, v.app.config.GrantID, draft.ID) + _, err := v.app.config.Client.SendDraft(ctx, v.app.config.GrantID, draft.ID, nil) if err != nil { v.app.Flash(FlashError, "Failed to send draft: %v", err) return