From 6470c4b105f813383215c97e60f241ea6959b6f2 Mon Sep 17 00:00:00 2001 From: Qasim Date: Tue, 7 Apr 2026 19:00:23 -0400 Subject: [PATCH 1/2] Add hosted templates and workflows support --- cmd/nylas/main.go | 4 + docs/COMMANDS.md | 38 +- docs/commands/email.md | 46 +- docs/commands/templates.md | 13 + docs/commands/workflows.md | 24 +- internal/adapters/ai/ollama_client_test.go | 1 + .../conflict_resolver_advanced_test.go | 1 + .../focus_optimizer_test_advanced.go | 2 + .../analytics/focus_optimizer_test_basic.go | 1 + internal/adapters/browser/browser_test.go | 1 + internal/adapters/config/config_test.go | 2 + internal/adapters/gpg/encrypt_test.go | 2 + internal/adapters/gpg/service_test.go | 2 + internal/adapters/mcp/proxy_basic_test.go | 1 + internal/adapters/nylas/demo/base_test.go | 1 + .../nylas/demo_templates_workflows.go | 168 ++++++ .../adapters/nylas/messages_send_raw_test.go | 1 + internal/adapters/nylas/mock_client.go | 26 + .../nylas/mock_templates_workflows.go | 278 +++++++++ .../adapters/nylas/templates_workflows.go | 458 +++++++++++++++ .../nylas/templates_workflows_test.go | 380 +++++++++++++ .../timezone/service_advanced_test.go | 1 + .../utilities/timezone/service_basic_test.go | 1 + .../air/cache/cache_offline_settings_test.go | 2 + .../cache/cache_stores_email_contact_test.go | 1 + .../air/cache/cache_stores_queue_sync_test.go | 2 + internal/air/cache/settings_test.go | 1 + internal/air/handlers_bundles_test.go | 1 + internal/air/server_modules_test.go | 1 + internal/air/server_test.go | 1 + internal/cli/common/remote_resources.go | 116 ++++ internal/cli/common/remote_resources_test.go | 85 +++ internal/cli/email/metadata_test.go | 3 + internal/cli/email/send.go | 285 ++++++---- internal/cli/email/send_gpg_test.go | 1 + internal/cli/email/send_template.go | 182 ++++++ internal/cli/email/send_template_test.go | 318 +++++++++++ internal/cli/email/send_test.go | 16 + internal/cli/integration/email_send_test.go | 297 +++++++++- .../integration/templates_workflows_test.go | 526 ++++++++++++++++++ internal/cli/integration/test.go | 79 +++ internal/cli/mcp/assistants_test.go | 2 + .../cli/templatecmd/create_update_delete.go | 190 +++++++ internal/cli/templatecmd/helpers.go | 115 ++++ internal/cli/templatecmd/list.go | 57 ++ internal/cli/templatecmd/render.go | 121 ++++ internal/cli/templatecmd/show.go | 45 ++ internal/cli/templatecmd/template.go | 34 ++ internal/cli/templatecmd/template_test.go | 45 ++ internal/cli/workflow/create_update_delete.go | 227 ++++++++ internal/cli/workflow/helpers.go | 99 ++++ internal/cli/workflow/list_show.go | 93 ++++ internal/cli/workflow/workflow.go | 31 ++ internal/cli/workflow/workflow_test.go | 43 ++ internal/domain/ai_test.go | 1 + internal/domain/errors.go | 1 + internal/domain/templates_workflows.go | 155 ++++++ internal/ports/nylas.go | 1 + internal/ports/templates_workflows.go | 26 + internal/testutil/context_test.go | 3 + internal/tui/app_test_app.go | 3 + internal/tui/app_test_table.go | 2 + internal/tui/app_test_views_specific.go | 4 + internal/tui/calendar_test.go | 2 + internal/tui/commands_registry_test.go | 1 + internal/tui/compose_test.go | 5 + internal/tui/contact_form_test.go | 2 + internal/tui/drafts_test.go | 1 + internal/tui/event_form_test.go | 2 + internal/tui/folder_panel_test.go | 5 + internal/tui/theme_test_helpers.go | 2 + internal/tui/theme_test_loading.go | 3 + internal/tui/webhook_form_test.go | 2 + internal/ui/server_demo_test.go | 1 + 74 files changed, 4568 insertions(+), 125 deletions(-) create mode 100644 internal/adapters/nylas/demo_templates_workflows.go create mode 100644 internal/adapters/nylas/mock_templates_workflows.go create mode 100644 internal/adapters/nylas/templates_workflows.go create mode 100644 internal/adapters/nylas/templates_workflows_test.go create mode 100644 internal/cli/common/remote_resources.go create mode 100644 internal/cli/common/remote_resources_test.go create mode 100644 internal/cli/email/send_template.go create mode 100644 internal/cli/email/send_template_test.go create mode 100644 internal/cli/integration/templates_workflows_test.go create mode 100644 internal/cli/templatecmd/create_update_delete.go create mode 100644 internal/cli/templatecmd/helpers.go create mode 100644 internal/cli/templatecmd/list.go create mode 100644 internal/cli/templatecmd/render.go create mode 100644 internal/cli/templatecmd/show.go create mode 100644 internal/cli/templatecmd/template.go create mode 100644 internal/cli/templatecmd/template_test.go create mode 100644 internal/cli/workflow/create_update_delete.go create mode 100644 internal/cli/workflow/helpers.go create mode 100644 internal/cli/workflow/list_show.go create mode 100644 internal/cli/workflow/workflow.go create mode 100644 internal/cli/workflow/workflow_test.go create mode 100644 internal/domain/templates_workflows.go create mode 100644 internal/ports/templates_workflows.go diff --git a/cmd/nylas/main.go b/cmd/nylas/main.go index c7c7a74..978e015 100644 --- a/cmd/nylas/main.go +++ b/cmd/nylas/main.go @@ -25,9 +25,11 @@ import ( "github.com/nylas/cli/internal/cli/scheduler" "github.com/nylas/cli/internal/cli/setup" "github.com/nylas/cli/internal/cli/slack" + templatecmd "github.com/nylas/cli/internal/cli/templatecmd" "github.com/nylas/cli/internal/cli/timezone" "github.com/nylas/cli/internal/cli/update" "github.com/nylas/cli/internal/cli/webhook" + "github.com/nylas/cli/internal/cli/workflow" "github.com/nylas/cli/internal/ui" ) @@ -55,12 +57,14 @@ func main() { rootCmd.AddCommand(timezone.NewTimezoneCmd()) rootCmd.AddCommand(mcp.NewMCPCmd()) rootCmd.AddCommand(slack.NewSlackCmd()) + rootCmd.AddCommand(templatecmd.NewTemplateCmd()) rootCmd.AddCommand(demo.NewDemoCmd()) rootCmd.AddCommand(cli.NewTUICmd()) rootCmd.AddCommand(ui.NewUICmd()) rootCmd.AddCommand(air.NewAirCmd()) rootCmd.AddCommand(chat.NewChatCmd()) rootCmd.AddCommand(update.NewUpdateCmd()) + rootCmd.AddCommand(workflow.NewWorkflowCmd()) if err := cli.Execute(); err != nil { cli.LogAuditError(err) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 9bcafbc..6b35907 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -192,6 +192,8 @@ nylas email send ... --sign # Send GPG-signed nylas email send ... --encrypt # Send GPG-encrypted email nylas email send ... --sign --encrypt # Sign AND encrypt (recommended) 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 search --query "QUERY" # Search emails nylas email delete # Delete email nylas email mark read # Mark as read @@ -226,7 +228,7 @@ nylas email smart-compose --prompt "..." # AI-powered email generation --- -## Email Templates +## Local Email Templates ```bash nylas email templates list # List all templates @@ -243,6 +245,40 @@ nylas email templates use --to EMAIL # Send using template --- +## Hosted Templates + +```bash +nylas template list +nylas template create --name NAME --subject SUBJECT --body BODY +nylas template show +nylas template update [flags] +nylas template delete --yes +nylas template render --data '{}' +nylas template render-html --body "

{{x}}

" --engine mustache --data '{}' +``` + +**Scopes:** `--scope app` for application templates, `--scope grant --grant-id ` for grant-level templates. + +**Details:** `docs/commands/templates.md` + +--- + +## Hosted Workflows + +```bash +nylas workflow list +nylas workflow create --name NAME --template-id TPL --trigger-event booking.created +nylas workflow show +nylas workflow update [flags] +nylas workflow delete --yes +``` + +**Scopes:** `--scope app` for application workflows, `--scope grant --grant-id ` for grant-level workflows. + +**Details:** `docs/commands/workflows.md` + +--- + ## Folders & Threads ```bash diff --git a/docs/commands/email.md b/docs/commands/email.md index 4b5d066..5d468c9 100644 --- a/docs/commands/email.md +++ b/docs/commands/email.md @@ -96,6 +96,13 @@ nylas email send --to "to@example.com" --subject "Newsletter" --body "..." \ # Send with custom metadata nylas email send --to "to@example.com" --subject "Order Confirmation" --body "..." \ --metadata "order_id=12345" --metadata "customer_id=cust_abc" + +# Send using a hosted template +nylas email send --to "to@example.com" --template-id tpl_123 \ + --template-data '{"user":{"name":"Ada"}}' + +# Preview a hosted template render without sending +nylas email send --template-id tpl_123 --template-data-file ./data.json --render-only ``` **Tracking Options:** @@ -104,6 +111,15 @@ nylas email send --to "to@example.com" --subject "Order Confirmation" --body ".. - `--track-label` - Label for grouping tracked emails (for analytics) - `--metadata` - Custom key=value metadata pairs (can be specified multiple times) +**Hosted template options:** +- `--template-id` - Render and send a Nylas-hosted template +- `--template-scope` - Hosted template scope: `app` or `grant` +- `--template-grant-id` - Override the grant used for grant-scoped hosted templates +- `--template-data` - Inline JSON variables for hosted template rendering +- `--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) + **Example output (scheduled):** ```bash $ nylas email send --to "user@example.com" --subject "Reminder" --body "Don't forget!" --schedule 2h --yes @@ -118,6 +134,35 @@ Email preview: Scheduled to send: Mon Dec 16, 2024 4:30 PM PST ``` +### Hosted Templates + +Use top-level hosted templates with `nylas email send` when you want a shared, API-backed template instead of a local file-backed template. + +```bash +# Application-level hosted template +nylas email send \ + --to "to@example.com" \ + --template-id tpl_123 \ + --template-scope app \ + --template-data '{"user":{"name":"Ada"}}' + +# Grant-level hosted template +nylas email send \ + --to "to@example.com" \ + --template-id tpl_456 \ + --template-scope grant \ + --template-grant-id \ + --template-data-file ./data.json + +# Preview the final rendered subject/body without sending +nylas email send \ + --template-id tpl_123 \ + --template-data '{"user":{"name":"Ada"}}' \ + --render-only +``` + +For hosted template CRUD and standalone rendering, see `nylas template ...`. + ### GPG Signing and Encryption Sign and/or encrypt emails using GPG/PGP: @@ -469,4 +514,3 @@ fi - [Send email from the command line](https://cli.nylas.com/guides/send-email-from-terminal) — full guide with examples for Gmail, Outlook, Exchange, Yahoo, iCloud, and IMAP - [Give your AI coding agent an email address](https://cli.nylas.com/guides/give-ai-agent-email-address) — connect Claude Code, Cursor, or Codex CLI to your inbox - [Email deliverability from the CLI](https://cli.nylas.com/guides/email-deliverability-cli) — debug SPF, DKIM, and DMARC - diff --git a/docs/commands/templates.md b/docs/commands/templates.md index ff90e43..f4eb8d7 100644 --- a/docs/commands/templates.md +++ b/docs/commands/templates.md @@ -4,6 +4,19 @@ Manage locally-stored email templates for composing messages with variable subst Templates are stored in `~/.config/nylas/templates.json` and support `{{variable}}` syntax for placeholders. Variables are automatically extracted from the subject and body when templates are created or updated. +For API-backed hosted templates shared across an application or grant, use the top-level `nylas template ...` commands. + +--- + +### Hosted Templates Quick Reference + +```bash +nylas template list +nylas template create --name "Welcome" --subject "Hello {{user.name}}" --body "

Hello {{user.name}}

" +nylas template render --data '{"user":{"name":"Ada"}}' +nylas email send --to user@example.com --template-id --template-data '{"user":{"name":"Ada"}}' +``` + --- ### List Templates diff --git a/docs/commands/workflows.md b/docs/commands/workflows.md index 34a0847..6d81b60 100644 --- a/docs/commands/workflows.md +++ b/docs/commands/workflows.md @@ -1,3 +1,26 @@ +## Hosted Workflows + +Manage API-backed workflows that connect booking events to hosted templates. + +```bash +nylas workflow list +nylas workflow create --name "Booking Confirmation" --template-id tpl_123 --trigger-event booking.created +nylas workflow show +nylas workflow update --disabled +nylas workflow delete --yes +``` + +**Scopes:** +- `--scope app` for application-level workflows +- `--scope grant --grant-id ` for grant-level workflows + +**Typical flow:** +1. Create a hosted template with `nylas template create` +2. Create a workflow that points at that template with `nylas workflow create` +3. Verify the rendered email with `nylas email send --template-id ... --render-only` + +--- + ## Advanced Automation Workflows Complex automation workflows and integration patterns that span multiple Nylas CLI commands. @@ -621,4 +644,3 @@ def call_api(): - **API Reference:** https://developer.nylas.com/docs/api/v3/ --- - diff --git a/internal/adapters/ai/ollama_client_test.go b/internal/adapters/ai/ollama_client_test.go index 0bdcc9d..64115a3 100644 --- a/internal/adapters/ai/ollama_client_test.go +++ b/internal/adapters/ai/ollama_client_test.go @@ -24,6 +24,7 @@ func TestNewOllamaClient(t *testing.T) { if client == nil { t.Fatal("expected non-nil client") + return } if client.baseURL != "http://custom:8080" { diff --git a/internal/adapters/analytics/conflict_resolver_advanced_test.go b/internal/adapters/analytics/conflict_resolver_advanced_test.go index 796d189..cb02d65 100644 --- a/internal/adapters/analytics/conflict_resolver_advanced_test.go +++ b/internal/adapters/analytics/conflict_resolver_advanced_test.go @@ -237,6 +237,7 @@ func TestConflictResolver_GenerateRecommendations(t *testing.T) { // Verify the analysis structure is present if analysis == nil { t.Fatal("Expected analysis, got nil") + return } // Should have soft conflicts detected diff --git a/internal/adapters/analytics/focus_optimizer_test_advanced.go b/internal/adapters/analytics/focus_optimizer_test_advanced.go index 2962712..1b27815 100644 --- a/internal/adapters/analytics/focus_optimizer_test_advanced.go +++ b/internal/adapters/analytics/focus_optimizer_test_advanced.go @@ -387,6 +387,7 @@ func TestFocusOptimizer_AdaptSchedule(t *testing.T) { if change == nil { t.Fatal("Expected adaptive schedule change, got nil") + return } if change.Trigger != domain.TriggerMeetingOverload { @@ -436,6 +437,7 @@ func TestFocusOptimizer_OptimizeMeetingDuration(t *testing.T) { if optimization == nil { t.Fatal("Expected optimization, got nil") + return } if optimization.EventID != "event-123" { diff --git a/internal/adapters/analytics/focus_optimizer_test_basic.go b/internal/adapters/analytics/focus_optimizer_test_basic.go index d39158b..e1a9fca 100644 --- a/internal/adapters/analytics/focus_optimizer_test_basic.go +++ b/internal/adapters/analytics/focus_optimizer_test_basic.go @@ -56,6 +56,7 @@ func TestFocusOptimizer_AnalyzeFocusTimePatterns(t *testing.T) { if analysis == nil { t.Fatal("Expected analysis, got nil") + return } if analysis.UserEmail != "grant-123" { diff --git a/internal/adapters/browser/browser_test.go b/internal/adapters/browser/browser_test.go index a4304c4..7496d1c 100644 --- a/internal/adapters/browser/browser_test.go +++ b/internal/adapters/browser/browser_test.go @@ -126,6 +126,7 @@ func TestCreateCommand(t *testing.T) { if cmd == nil { t.Fatal("createCommand() returned nil") + return } // Verify the command was created (we can't test execution) diff --git a/internal/adapters/config/config_test.go b/internal/adapters/config/config_test.go index 22e2673..d4339bf 100644 --- a/internal/adapters/config/config_test.go +++ b/internal/adapters/config/config_test.go @@ -278,6 +278,7 @@ func TestMockConfigStore_LoadSave(t *testing.T) { } if config == nil { t.Fatal("Load() returned nil config") + return } // Modify and save @@ -310,6 +311,7 @@ func TestMockConfigStore_LoadWithNilConfig(t *testing.T) { } if config == nil { t.Fatal("Load() returned nil, expected default config") + return } // Should return default config diff --git a/internal/adapters/gpg/encrypt_test.go b/internal/adapters/gpg/encrypt_test.go index 5b89224..df6f106 100644 --- a/internal/adapters/gpg/encrypt_test.go +++ b/internal/adapters/gpg/encrypt_test.go @@ -139,6 +139,7 @@ func TestEncryptData_Integration(t *testing.T) { if result == nil { t.Fatal("EncryptData() returned nil result") + return } // Validate ciphertext @@ -201,6 +202,7 @@ func TestSignAndEncryptData_Integration(t *testing.T) { if result == nil { t.Fatal("SignAndEncryptData() returned nil result") + return } // Validate ciphertext diff --git a/internal/adapters/gpg/service_test.go b/internal/adapters/gpg/service_test.go index 0662cab..107603b 100644 --- a/internal/adapters/gpg/service_test.go +++ b/internal/adapters/gpg/service_test.go @@ -261,6 +261,7 @@ func TestSignData_Integration(t *testing.T) { if result == nil { t.Fatal("SignData() returned nil result") + return } // Validate signature @@ -535,6 +536,7 @@ func TestFindKeyByEmail_Integration(t *testing.T) { if foundKey == nil { t.Fatal("FindKeyByEmail() returned nil") + return } if foundKey.KeyID == "" { diff --git a/internal/adapters/mcp/proxy_basic_test.go b/internal/adapters/mcp/proxy_basic_test.go index 6d35354..516fe9b 100644 --- a/internal/adapters/mcp/proxy_basic_test.go +++ b/internal/adapters/mcp/proxy_basic_test.go @@ -38,6 +38,7 @@ func TestNewProxy(t *testing.T) { proxy := NewProxy("test-api-key", "us") if proxy == nil { t.Fatal("NewProxy returned nil") + return } if proxy.apiKey != "test-api-key" { t.Errorf("expected apiKey 'test-api-key', got '%s'", proxy.apiKey) diff --git a/internal/adapters/nylas/demo/base_test.go b/internal/adapters/nylas/demo/base_test.go index e85c8c5..9303a7d 100644 --- a/internal/adapters/nylas/demo/base_test.go +++ b/internal/adapters/nylas/demo/base_test.go @@ -114,6 +114,7 @@ func TestClient_ExchangeCode(t *testing.T) { if !tt.wantErr { if grant == nil { t.Fatal("expected non-nil grant") + return } if grant.ID != "demo-grant-id" { t.Errorf("expected ID 'demo-grant-id', got %q", grant.ID) diff --git a/internal/adapters/nylas/demo_templates_workflows.go b/internal/adapters/nylas/demo_templates_workflows.go new file mode 100644 index 0000000..f8f617a --- /dev/null +++ b/internal/adapters/nylas/demo_templates_workflows.go @@ -0,0 +1,168 @@ +package nylas + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +func (d *DemoClient) ListRemoteTemplates( + ctx context.Context, + scope domain.RemoteScope, + grantID string, + params *domain.CursorListParams, +) (*domain.RemoteTemplateListResponse, error) { + return &domain.RemoteTemplateListResponse{ + Data: []domain.RemoteTemplate{ + { + ID: "demo-template-001", + Engine: "mustache", + Name: "Demo Booking Confirmation", + Subject: "Booking confirmed for {{user.name}}", + Body: "

Hello {{user.name}}, your demo booking is confirmed.

", + }, + }, + }, nil +} + +func (d *DemoClient) GetRemoteTemplate( + ctx context.Context, + scope domain.RemoteScope, + grantID, templateID string, +) (*domain.RemoteTemplate, error) { + return &domain.RemoteTemplate{ + ID: templateID, + Engine: "mustache", + Name: "Demo Booking Confirmation", + Subject: "Booking confirmed for {{user.name}}", + Body: "

Hello {{user.name}}, your demo booking is confirmed.

", + }, nil +} + +func (d *DemoClient) CreateRemoteTemplate( + ctx context.Context, + scope domain.RemoteScope, + grantID string, + req *domain.CreateRemoteTemplateRequest, +) (*domain.RemoteTemplate, error) { + return &domain.RemoteTemplate{ + ID: "demo-template-new", + Engine: req.Engine, + Name: req.Name, + Subject: req.Subject, + Body: req.Body, + }, nil +} + +func (d *DemoClient) UpdateRemoteTemplate( + ctx context.Context, + scope domain.RemoteScope, + grantID, templateID string, + req *domain.UpdateRemoteTemplateRequest, +) (*domain.RemoteTemplate, error) { + return &domain.RemoteTemplate{ID: templateID, Name: "Updated Demo Template"}, nil +} + +func (d *DemoClient) DeleteRemoteTemplate( + ctx context.Context, + scope domain.RemoteScope, + grantID, templateID string, +) error { + return nil +} + +func (d *DemoClient) RenderRemoteTemplate( + ctx context.Context, + scope domain.RemoteScope, + grantID, templateID string, + req *domain.TemplateRenderRequest, +) (domain.TemplateRenderResult, error) { + return domain.TemplateRenderResult{ + "subject": "Demo booking confirmation", + "body": "

Hello Demo User

", + }, nil +} + +func (d *DemoClient) RenderRemoteTemplateHTML( + ctx context.Context, + scope domain.RemoteScope, + grantID string, + req *domain.TemplateRenderHTMLRequest, +) (domain.TemplateRenderResult, error) { + return domain.TemplateRenderResult{ + "html": "

Hello Demo User

", + }, nil +} + +func (d *DemoClient) ListWorkflows( + ctx context.Context, + scope domain.RemoteScope, + grantID string, + params *domain.CursorListParams, +) (*domain.RemoteWorkflowListResponse, error) { + return &domain.RemoteWorkflowListResponse{ + Data: []domain.RemoteWorkflow{ + { + ID: "demo-workflow-001", + Name: "Demo Booking Workflow", + TriggerEvent: "booking.created", + TemplateID: "demo-template-001", + Delay: 5, + IsEnabled: true, + }, + }, + }, nil +} + +func (d *DemoClient) GetWorkflow( + ctx context.Context, + scope domain.RemoteScope, + grantID, workflowID string, +) (*domain.RemoteWorkflow, error) { + return &domain.RemoteWorkflow{ + ID: workflowID, + Name: "Demo Booking Workflow", + TriggerEvent: "booking.created", + TemplateID: "demo-template-001", + Delay: 5, + IsEnabled: true, + }, nil +} + +func (d *DemoClient) CreateWorkflow( + ctx context.Context, + scope domain.RemoteScope, + grantID string, + req *domain.CreateRemoteWorkflowRequest, +) (*domain.RemoteWorkflow, error) { + enabled := true + if req.IsEnabled != nil { + enabled = *req.IsEnabled + } + return &domain.RemoteWorkflow{ + ID: "demo-workflow-new", + Name: req.Name, + TriggerEvent: req.TriggerEvent, + TemplateID: req.TemplateID, + Delay: req.Delay, + IsEnabled: enabled, + From: req.From, + }, nil +} + +func (d *DemoClient) UpdateWorkflow( + ctx context.Context, + scope domain.RemoteScope, + grantID, workflowID string, + req *domain.UpdateRemoteWorkflowRequest, +) (*domain.RemoteWorkflow, error) { + return &domain.RemoteWorkflow{ID: workflowID, Name: "Updated Demo Workflow"}, nil +} + +func (d *DemoClient) DeleteWorkflow( + ctx context.Context, + scope domain.RemoteScope, + grantID, workflowID string, +) error { + return nil +} diff --git a/internal/adapters/nylas/messages_send_raw_test.go b/internal/adapters/nylas/messages_send_raw_test.go index 9c2c5f0..23b8f3a 100644 --- a/internal/adapters/nylas/messages_send_raw_test.go +++ b/internal/adapters/nylas/messages_send_raw_test.go @@ -78,6 +78,7 @@ func TestSendRawMessage_Success(t *testing.T) { if msg == nil { t.Fatal("SendRawMessage() returned nil message") + return } if msg.ID != "msg-123" { t.Errorf("Expected message ID msg-123, got %s", msg.ID) diff --git a/internal/adapters/nylas/mock_client.go b/internal/adapters/nylas/mock_client.go index b06dd69..6cfd01f 100644 --- a/internal/adapters/nylas/mock_client.go +++ b/internal/adapters/nylas/mock_client.go @@ -44,6 +44,18 @@ type MockClient struct { ListAttachmentsCalled bool GetAttachmentCalled bool DownloadAttachmentCalled bool + ListRemoteTemplatesCalled bool + GetRemoteTemplateCalled bool + CreateRemoteTemplateCalled bool + UpdateRemoteTemplateCalled bool + DeleteRemoteTemplateCalled bool + RenderRemoteTemplateCalled bool + RenderTemplateHTMLCalled bool + ListWorkflowsCalled bool + GetWorkflowCalled bool + CreateWorkflowCalled bool + UpdateWorkflowCalled bool + DeleteWorkflowCalled bool LastGrantID string LastRedirectURI string LastAuthState string @@ -54,6 +66,8 @@ type MockClient struct { LastDraftID string LastFolderID string LastAttachmentID string + LastTemplateID string + LastWorkflowID string // Custom functions BuildAuthURLFunc func(provider domain.Provider, redirectURI, state, codeChallenge string) string @@ -86,6 +100,18 @@ type MockClient struct { ListAttachmentsFunc func(ctx context.Context, grantID, messageID string) ([]domain.Attachment, error) GetAttachmentFunc func(ctx context.Context, grantID, messageID, attachmentID string) (*domain.Attachment, error) DownloadAttachmentFunc func(ctx context.Context, grantID, messageID, attachmentID string) (io.ReadCloser, error) + ListRemoteTemplatesFunc func(ctx context.Context, scope domain.RemoteScope, grantID string, params *domain.CursorListParams) (*domain.RemoteTemplateListResponse, error) + GetRemoteTemplateFunc func(ctx context.Context, scope domain.RemoteScope, grantID, templateID string) (*domain.RemoteTemplate, error) + CreateRemoteTemplateFunc func(ctx context.Context, scope domain.RemoteScope, grantID string, req *domain.CreateRemoteTemplateRequest) (*domain.RemoteTemplate, error) + UpdateRemoteTemplateFunc func(ctx context.Context, scope domain.RemoteScope, grantID, templateID string, req *domain.UpdateRemoteTemplateRequest) (*domain.RemoteTemplate, error) + DeleteRemoteTemplateFunc func(ctx context.Context, scope domain.RemoteScope, grantID, templateID string) error + RenderRemoteTemplateFunc func(ctx context.Context, scope domain.RemoteScope, grantID, templateID string, req *domain.TemplateRenderRequest) (domain.TemplateRenderResult, error) + RenderTemplateHTMLFunc func(ctx context.Context, scope domain.RemoteScope, grantID string, req *domain.TemplateRenderHTMLRequest) (domain.TemplateRenderResult, error) + ListWorkflowsFunc func(ctx context.Context, scope domain.RemoteScope, grantID string, params *domain.CursorListParams) (*domain.RemoteWorkflowListResponse, error) + GetWorkflowFunc func(ctx context.Context, scope domain.RemoteScope, grantID, workflowID string) (*domain.RemoteWorkflow, error) + CreateWorkflowFunc func(ctx context.Context, scope domain.RemoteScope, grantID string, req *domain.CreateRemoteWorkflowRequest) (*domain.RemoteWorkflow, error) + UpdateWorkflowFunc func(ctx context.Context, scope domain.RemoteScope, grantID, workflowID string, req *domain.UpdateRemoteWorkflowRequest) (*domain.RemoteWorkflow, error) + DeleteWorkflowFunc func(ctx context.Context, scope domain.RemoteScope, grantID, workflowID string) error // Calendar functions GetCalendarsFunc func(ctx context.Context, grantID string) ([]domain.Calendar, error) diff --git a/internal/adapters/nylas/mock_templates_workflows.go b/internal/adapters/nylas/mock_templates_workflows.go new file mode 100644 index 0000000..20850b5 --- /dev/null +++ b/internal/adapters/nylas/mock_templates_workflows.go @@ -0,0 +1,278 @@ +package nylas + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +func (m *MockClient) ListRemoteTemplates( + ctx context.Context, + scope domain.RemoteScope, + grantID string, + params *domain.CursorListParams, +) (*domain.RemoteTemplateListResponse, error) { + m.ListRemoteTemplatesCalled = true + m.LastGrantID = grantID + if m.ListRemoteTemplatesFunc != nil { + return m.ListRemoteTemplatesFunc(ctx, scope, grantID, params) + } + + return &domain.RemoteTemplateListResponse{ + Data: []domain.RemoteTemplate{ + { + ID: "tpl-1", + Engine: "mustache", + Name: "Welcome Template", + Subject: "Welcome {{user.name}}", + Body: "

Hello {{user.name}}

", + }, + }, + }, nil +} + +func (m *MockClient) GetRemoteTemplate( + ctx context.Context, + scope domain.RemoteScope, + grantID, templateID string, +) (*domain.RemoteTemplate, error) { + m.GetRemoteTemplateCalled = true + m.LastGrantID = grantID + m.LastTemplateID = templateID + if m.GetRemoteTemplateFunc != nil { + return m.GetRemoteTemplateFunc(ctx, scope, grantID, templateID) + } + + return &domain.RemoteTemplate{ + ID: templateID, + Engine: "mustache", + Name: "Welcome Template", + Subject: "Welcome {{user.name}}", + Body: "

Hello {{user.name}}

", + }, nil +} + +func (m *MockClient) CreateRemoteTemplate( + ctx context.Context, + scope domain.RemoteScope, + grantID string, + req *domain.CreateRemoteTemplateRequest, +) (*domain.RemoteTemplate, error) { + m.CreateRemoteTemplateCalled = true + m.LastGrantID = grantID + if m.CreateRemoteTemplateFunc != nil { + return m.CreateRemoteTemplateFunc(ctx, scope, grantID, req) + } + + return &domain.RemoteTemplate{ + ID: "tpl-new", + Engine: req.Engine, + Name: req.Name, + Subject: req.Subject, + Body: req.Body, + }, nil +} + +func (m *MockClient) UpdateRemoteTemplate( + ctx context.Context, + scope domain.RemoteScope, + grantID, templateID string, + req *domain.UpdateRemoteTemplateRequest, +) (*domain.RemoteTemplate, error) { + m.UpdateRemoteTemplateCalled = true + m.LastGrantID = grantID + m.LastTemplateID = templateID + if m.UpdateRemoteTemplateFunc != nil { + return m.UpdateRemoteTemplateFunc(ctx, scope, grantID, templateID, req) + } + + template := &domain.RemoteTemplate{ID: templateID, Engine: "mustache", Name: "Updated Template"} + if req.Name != nil { + template.Name = *req.Name + } + if req.Subject != nil { + template.Subject = *req.Subject + } + if req.Body != nil { + template.Body = *req.Body + } + if req.Engine != nil { + template.Engine = *req.Engine + } + return template, nil +} + +func (m *MockClient) DeleteRemoteTemplate( + ctx context.Context, + scope domain.RemoteScope, + grantID, templateID string, +) error { + m.DeleteRemoteTemplateCalled = true + m.LastGrantID = grantID + m.LastTemplateID = templateID + if m.DeleteRemoteTemplateFunc != nil { + return m.DeleteRemoteTemplateFunc(ctx, scope, grantID, templateID) + } + return nil +} + +func (m *MockClient) RenderRemoteTemplate( + ctx context.Context, + scope domain.RemoteScope, + grantID, templateID string, + req *domain.TemplateRenderRequest, +) (domain.TemplateRenderResult, error) { + m.RenderRemoteTemplateCalled = true + m.LastGrantID = grantID + m.LastTemplateID = templateID + if m.RenderRemoteTemplateFunc != nil { + return m.RenderRemoteTemplateFunc(ctx, scope, grantID, templateID, req) + } + + return domain.TemplateRenderResult{ + "subject": "Welcome Nylas", + "body": "

Hello Nylas

", + }, nil +} + +func (m *MockClient) RenderRemoteTemplateHTML( + ctx context.Context, + scope domain.RemoteScope, + grantID string, + req *domain.TemplateRenderHTMLRequest, +) (domain.TemplateRenderResult, error) { + m.RenderTemplateHTMLCalled = true + m.LastGrantID = grantID + if m.RenderTemplateHTMLFunc != nil { + return m.RenderTemplateHTMLFunc(ctx, scope, grantID, req) + } + + return domain.TemplateRenderResult{ + "html": "

Hello Nylas

", + }, nil +} + +func (m *MockClient) ListWorkflows( + ctx context.Context, + scope domain.RemoteScope, + grantID string, + params *domain.CursorListParams, +) (*domain.RemoteWorkflowListResponse, error) { + m.ListWorkflowsCalled = true + m.LastGrantID = grantID + if m.ListWorkflowsFunc != nil { + return m.ListWorkflowsFunc(ctx, scope, grantID, params) + } + + return &domain.RemoteWorkflowListResponse{ + Data: []domain.RemoteWorkflow{ + { + ID: "wf-1", + Name: "Booking Confirmation", + TemplateID: "tpl-1", + TriggerEvent: "booking.created", + Delay: 1, + IsEnabled: true, + }, + }, + }, nil +} + +func (m *MockClient) GetWorkflow( + ctx context.Context, + scope domain.RemoteScope, + grantID, workflowID string, +) (*domain.RemoteWorkflow, error) { + m.GetWorkflowCalled = true + m.LastGrantID = grantID + m.LastWorkflowID = workflowID + if m.GetWorkflowFunc != nil { + return m.GetWorkflowFunc(ctx, scope, grantID, workflowID) + } + + return &domain.RemoteWorkflow{ + ID: workflowID, + Name: "Booking Confirmation", + TemplateID: "tpl-1", + TriggerEvent: "booking.created", + Delay: 1, + IsEnabled: true, + }, nil +} + +func (m *MockClient) CreateWorkflow( + ctx context.Context, + scope domain.RemoteScope, + grantID string, + req *domain.CreateRemoteWorkflowRequest, +) (*domain.RemoteWorkflow, error) { + m.CreateWorkflowCalled = true + m.LastGrantID = grantID + if m.CreateWorkflowFunc != nil { + return m.CreateWorkflowFunc(ctx, scope, grantID, req) + } + + enabled := true + if req.IsEnabled != nil { + enabled = *req.IsEnabled + } + + return &domain.RemoteWorkflow{ + ID: "wf-new", + Name: req.Name, + TemplateID: req.TemplateID, + TriggerEvent: req.TriggerEvent, + Delay: req.Delay, + IsEnabled: enabled, + From: req.From, + }, nil +} + +func (m *MockClient) UpdateWorkflow( + ctx context.Context, + scope domain.RemoteScope, + grantID, workflowID string, + req *domain.UpdateRemoteWorkflowRequest, +) (*domain.RemoteWorkflow, error) { + m.UpdateWorkflowCalled = true + m.LastGrantID = grantID + m.LastWorkflowID = workflowID + if m.UpdateWorkflowFunc != nil { + return m.UpdateWorkflowFunc(ctx, scope, grantID, workflowID, req) + } + + workflow := &domain.RemoteWorkflow{ID: workflowID, Name: "Updated Workflow"} + if req.Name != nil { + workflow.Name = *req.Name + } + if req.TemplateID != nil { + workflow.TemplateID = *req.TemplateID + } + if req.TriggerEvent != nil { + workflow.TriggerEvent = *req.TriggerEvent + } + if req.Delay != nil { + workflow.Delay = *req.Delay + } + if req.IsEnabled != nil { + workflow.IsEnabled = *req.IsEnabled + } + if req.From != nil { + workflow.From = req.From + } + return workflow, nil +} + +func (m *MockClient) DeleteWorkflow( + ctx context.Context, + scope domain.RemoteScope, + grantID, workflowID string, +) error { + m.DeleteWorkflowCalled = true + m.LastGrantID = grantID + m.LastWorkflowID = workflowID + if m.DeleteWorkflowFunc != nil { + return m.DeleteWorkflowFunc(ctx, scope, grantID, workflowID) + } + return nil +} diff --git a/internal/adapters/nylas/templates_workflows.go b/internal/adapters/nylas/templates_workflows.go new file mode 100644 index 0000000..07bd640 --- /dev/null +++ b/internal/adapters/nylas/templates_workflows.go @@ -0,0 +1,458 @@ +package nylas + +import ( + "context" + "fmt" + "net/url" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/util" +) + +type remoteTemplateResponse struct { + ID string `json:"id"` + GrantID string `json:"grant_id"` + AppID *string `json:"app_id"` + Engine string `json:"engine"` + Name string `json:"name"` + Subject string `json:"subject"` + Body string `json:"body"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + Object string `json:"object"` +} + +type workflowSenderResponse struct { + Name string `json:"name"` + Email string `json:"email"` +} + +type remoteWorkflowResponse struct { + ID string `json:"id"` + GrantID string `json:"grant_id"` + AppID *string `json:"app_id"` + IsEnabled bool `json:"is_enabled"` + Name string `json:"name"` + TriggerEvent string `json:"trigger_event"` + Delay int `json:"delay"` + TemplateID string `json:"template_id"` + From *workflowSenderResponse `json:"from"` + DateCreated int64 `json:"date_created"` + Object string `json:"object"` +} + +func (c *HTTPClient) ListRemoteTemplates( + ctx context.Context, + scope domain.RemoteScope, + grantID string, + params *domain.CursorListParams, +) (*domain.RemoteTemplateListResponse, error) { + baseURL, err := c.templatesBaseURL(scope, grantID) + if err != nil { + return nil, err + } + + params = normalizeCursorListParams(params) + queryURL := NewQueryBuilder(). + AddInt("limit", params.Limit). + Add("page_token", params.PageToken). + BuildURL(baseURL) + + var result struct { + Data []remoteTemplateResponse `json:"data"` + NextCursor string `json:"next_cursor,omitempty"` + RequestID string `json:"request_id,omitempty"` + } + if err := c.doGet(ctx, queryURL, &result); err != nil { + return nil, err + } + + return &domain.RemoteTemplateListResponse{ + Data: util.Map(result.Data, convertRemoteTemplate), + NextCursor: result.NextCursor, + RequestID: result.RequestID, + }, nil +} + +func (c *HTTPClient) GetRemoteTemplate( + ctx context.Context, + scope domain.RemoteScope, + grantID, templateID string, +) (*domain.RemoteTemplate, error) { + if err := validateRequired("template ID", templateID); err != nil { + return nil, err + } + + queryURL, err := c.templateURL(scope, grantID, templateID) + if err != nil { + return nil, err + } + + var result struct { + Data remoteTemplateResponse `json:"data"` + } + if err := c.doGetWithNotFound(ctx, queryURL, &result, domain.ErrTemplateNotFound); err != nil { + return nil, err + } + + template := convertRemoteTemplate(result.Data) + return &template, nil +} + +func (c *HTTPClient) CreateRemoteTemplate( + ctx context.Context, + scope domain.RemoteScope, + grantID string, + req *domain.CreateRemoteTemplateRequest, +) (*domain.RemoteTemplate, error) { + queryURL, err := c.templatesBaseURL(scope, grantID) + if err != nil { + return nil, err + } + + resp, err := c.doJSONRequest(ctx, "POST", queryURL, req) + if err != nil { + return nil, err + } + + var result struct { + Data remoteTemplateResponse `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + template := convertRemoteTemplate(result.Data) + return &template, nil +} + +func (c *HTTPClient) UpdateRemoteTemplate( + ctx context.Context, + scope domain.RemoteScope, + grantID, templateID string, + req *domain.UpdateRemoteTemplateRequest, +) (*domain.RemoteTemplate, error) { + if err := validateRequired("template ID", templateID); err != nil { + return nil, err + } + + queryURL, err := c.templateURL(scope, grantID, templateID) + if err != nil { + return nil, err + } + + resp, err := c.doJSONRequest(ctx, "PUT", queryURL, req) + if err != nil { + return nil, err + } + + var result struct { + Data remoteTemplateResponse `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + template := convertRemoteTemplate(result.Data) + return &template, nil +} + +func (c *HTTPClient) DeleteRemoteTemplate( + ctx context.Context, + scope domain.RemoteScope, + grantID, templateID string, +) error { + if err := validateRequired("template ID", templateID); err != nil { + return err + } + + queryURL, err := c.templateURL(scope, grantID, templateID) + if err != nil { + return err + } + + return c.doDelete(ctx, queryURL) +} + +func (c *HTTPClient) RenderRemoteTemplate( + ctx context.Context, + scope domain.RemoteScope, + grantID, templateID string, + req *domain.TemplateRenderRequest, +) (domain.TemplateRenderResult, error) { + if err := validateRequired("template ID", templateID); err != nil { + return nil, err + } + + queryURL, err := c.templateURL(scope, grantID, templateID) + if err != nil { + return nil, err + } + queryURL += "/render" + + resp, err := c.doJSONRequest(ctx, "POST", queryURL, req) + if err != nil { + return nil, err + } + + var result struct { + Data domain.TemplateRenderResult `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + return result.Data, nil +} + +func (c *HTTPClient) RenderRemoteTemplateHTML( + ctx context.Context, + scope domain.RemoteScope, + grantID string, + req *domain.TemplateRenderHTMLRequest, +) (domain.TemplateRenderResult, error) { + baseURL, err := c.templatesBaseURL(scope, grantID) + if err != nil { + return nil, err + } + + resp, err := c.doJSONRequest(ctx, "POST", baseURL+"/render", req) + if err != nil { + return nil, err + } + + var result struct { + Data domain.TemplateRenderResult `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + return result.Data, nil +} + +func (c *HTTPClient) ListWorkflows( + ctx context.Context, + scope domain.RemoteScope, + grantID string, + params *domain.CursorListParams, +) (*domain.RemoteWorkflowListResponse, error) { + baseURL, err := c.workflowsBaseURL(scope, grantID) + if err != nil { + return nil, err + } + + params = normalizeCursorListParams(params) + queryURL := NewQueryBuilder(). + AddInt("limit", params.Limit). + Add("page_token", params.PageToken). + BuildURL(baseURL) + + var result struct { + Data []remoteWorkflowResponse `json:"data"` + NextCursor string `json:"next_cursor,omitempty"` + RequestID string `json:"request_id,omitempty"` + } + if err := c.doGet(ctx, queryURL, &result); err != nil { + return nil, err + } + + return &domain.RemoteWorkflowListResponse{ + Data: util.Map(result.Data, convertRemoteWorkflow), + NextCursor: result.NextCursor, + RequestID: result.RequestID, + }, nil +} + +func (c *HTTPClient) GetWorkflow( + ctx context.Context, + scope domain.RemoteScope, + grantID, workflowID string, +) (*domain.RemoteWorkflow, error) { + if err := validateRequired("workflow ID", workflowID); err != nil { + return nil, err + } + + queryURL, err := c.workflowURL(scope, grantID, workflowID) + if err != nil { + return nil, err + } + + var result struct { + Data remoteWorkflowResponse `json:"data"` + } + if err := c.doGetWithNotFound(ctx, queryURL, &result, domain.ErrWorkflowNotFound); err != nil { + return nil, err + } + + workflow := convertRemoteWorkflow(result.Data) + return &workflow, nil +} + +func (c *HTTPClient) CreateWorkflow( + ctx context.Context, + scope domain.RemoteScope, + grantID string, + req *domain.CreateRemoteWorkflowRequest, +) (*domain.RemoteWorkflow, error) { + queryURL, err := c.workflowsBaseURL(scope, grantID) + if err != nil { + return nil, err + } + + resp, err := c.doJSONRequest(ctx, "POST", queryURL, req) + if err != nil { + return nil, err + } + + var result struct { + Data remoteWorkflowResponse `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + workflow := convertRemoteWorkflow(result.Data) + return &workflow, nil +} + +func (c *HTTPClient) UpdateWorkflow( + ctx context.Context, + scope domain.RemoteScope, + grantID, workflowID string, + req *domain.UpdateRemoteWorkflowRequest, +) (*domain.RemoteWorkflow, error) { + if err := validateRequired("workflow ID", workflowID); err != nil { + return nil, err + } + + queryURL, err := c.workflowURL(scope, grantID, workflowID) + if err != nil { + return nil, err + } + + resp, err := c.doJSONRequest(ctx, "PUT", queryURL, req) + if err != nil { + return nil, err + } + + var result struct { + Data remoteWorkflowResponse `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + workflow := convertRemoteWorkflow(result.Data) + return &workflow, nil +} + +func (c *HTTPClient) DeleteWorkflow( + ctx context.Context, + scope domain.RemoteScope, + grantID, workflowID string, +) error { + if err := validateRequired("workflow ID", workflowID); err != nil { + return err + } + + queryURL, err := c.workflowURL(scope, grantID, workflowID) + if err != nil { + return err + } + + return c.doDelete(ctx, queryURL) +} + +func (c *HTTPClient) templatesBaseURL(scope domain.RemoteScope, grantID string) (string, error) { + switch scope { + case domain.ScopeApplication: + return fmt.Sprintf("%s/v3/templates", c.baseURL), nil + case domain.ScopeGrant: + if err := validateRequired("grant ID", grantID); err != nil { + return "", err + } + return fmt.Sprintf("%s/v3/grants/%s/templates", c.baseURL, url.PathEscape(grantID)), nil + default: + return "", fmt.Errorf("%w: invalid scope %q", domain.ErrInvalidInput, scope) + } +} + +func (c *HTTPClient) templateURL(scope domain.RemoteScope, grantID, templateID string) (string, error) { + baseURL, err := c.templatesBaseURL(scope, grantID) + if err != nil { + return "", err + } + return baseURL + "/" + url.PathEscape(templateID), nil +} + +func (c *HTTPClient) workflowsBaseURL(scope domain.RemoteScope, grantID string) (string, error) { + switch scope { + case domain.ScopeApplication: + return fmt.Sprintf("%s/v3/workflows", c.baseURL), nil + case domain.ScopeGrant: + if err := validateRequired("grant ID", grantID); err != nil { + return "", err + } + return fmt.Sprintf("%s/v3/grants/%s/workflows", c.baseURL, url.PathEscape(grantID)), nil + default: + return "", fmt.Errorf("%w: invalid scope %q", domain.ErrInvalidInput, scope) + } +} + +func (c *HTTPClient) workflowURL(scope domain.RemoteScope, grantID, workflowID string) (string, error) { + baseURL, err := c.workflowsBaseURL(scope, grantID) + if err != nil { + return "", err + } + return baseURL + "/" + url.PathEscape(workflowID), nil +} + +func normalizeCursorListParams(params *domain.CursorListParams) *domain.CursorListParams { + if params == nil { + return &domain.CursorListParams{Limit: 50} + } + if params.Limit <= 0 { + params.Limit = 50 + } + return params +} + +func convertRemoteTemplate(t remoteTemplateResponse) domain.RemoteTemplate { + return domain.RemoteTemplate{ + ID: t.ID, + GrantID: t.GrantID, + AppID: t.AppID, + Engine: t.Engine, + Name: t.Name, + Subject: t.Subject, + Body: t.Body, + CreatedAt: unixToTime(t.CreatedAt), + UpdatedAt: unixToTime(t.UpdatedAt), + Object: t.Object, + } +} + +func convertRemoteWorkflow(w remoteWorkflowResponse) domain.RemoteWorkflow { + var from *domain.WorkflowSender + if w.From != nil { + from = &domain.WorkflowSender{ + Name: w.From.Name, + Email: w.From.Email, + } + } + + return domain.RemoteWorkflow{ + ID: w.ID, + GrantID: w.GrantID, + AppID: w.AppID, + IsEnabled: w.IsEnabled, + Name: w.Name, + TriggerEvent: w.TriggerEvent, + Delay: w.Delay, + TemplateID: w.TemplateID, + From: from, + DateCreated: unixToTime(w.DateCreated), + Object: w.Object, + } +} diff --git a/internal/adapters/nylas/templates_workflows_test.go b/internal/adapters/nylas/templates_workflows_test.go new file mode 100644 index 0000000..8647b93 --- /dev/null +++ b/internal/adapters/nylas/templates_workflows_test.go @@ -0,0 +1,380 @@ +package nylas + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/nylas/cli/internal/domain" +) + +func newTemplatesWorkflowTestClient(t *testing.T, handler http.HandlerFunc) (*HTTPClient, func()) { + t.Helper() + + server := httptest.NewServer(handler) + client := NewHTTPClient() + client.SetBaseURL(server.URL) + client.SetCredentials("", "", "test-api-key") + + return client, server.Close +} + +func TestListRemoteTemplates(t *testing.T) { + t.Run("application_scope", func(t *testing.T) { + client, cleanup := newTemplatesWorkflowTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/v3/templates" { + t.Fatalf("path = %s, want /v3/templates", r.URL.Path) + } + if got := r.URL.Query().Get("limit"); got != "25" { + t.Fatalf("limit = %q, want 25", got) + } + if got := r.URL.Query().Get("page_token"); got != "cursor-123" { + t.Fatalf("page_token = %q, want cursor-123", got) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "data": [{ + "id": "tpl-1", + "engine": "mustache", + "name": "Welcome", + "subject": "Welcome {{user.name}}", + "body": "

Hello {{user.name}}

", + "created_at": 1700000000, + "updated_at": 1700000100, + "object": "template" + }], + "next_cursor": "cursor-456", + "request_id": "req-1" + }`)) + }) + defer cleanup() + + resp, err := client.ListRemoteTemplates(context.Background(), domain.ScopeApplication, "", &domain.CursorListParams{ + Limit: 25, + PageToken: "cursor-123", + }) + if err != nil { + t.Fatalf("ListRemoteTemplates() error = %v", err) + } + if len(resp.Data) != 1 { + t.Fatalf("len(resp.Data) = %d, want 1", len(resp.Data)) + } + if resp.Data[0].ID != "tpl-1" { + t.Fatalf("template id = %q, want tpl-1", resp.Data[0].ID) + } + if resp.NextCursor != "cursor-456" { + t.Fatalf("next_cursor = %q, want cursor-456", resp.NextCursor) + } + }) + + t.Run("grant_scope_escapes_grant_id", func(t *testing.T) { + client, cleanup := newTemplatesWorkflowTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/grants/nyla@example.com/templates" { + t.Fatalf("path = %s, want grant-scoped path", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[]}`)) + }) + defer cleanup() + + if _, err := client.ListRemoteTemplates(context.Background(), domain.ScopeGrant, "nyla@example.com", nil); err != nil { + t.Fatalf("ListRemoteTemplates() error = %v", err) + } + }) +} + +func TestGetRemoteTemplate(t *testing.T) { + t.Run("success", func(t *testing.T) { + client, cleanup := newTemplatesWorkflowTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/templates/tpl-123" { + t.Fatalf("path = %s, want /v3/templates/tpl-123", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"id":"tpl-123","engine":"mustache","name":"Welcome","subject":"Hi","body":"

Hello

"}}`)) + }) + defer cleanup() + + template, err := client.GetRemoteTemplate(context.Background(), domain.ScopeApplication, "", "tpl-123") + if err != nil { + t.Fatalf("GetRemoteTemplate() error = %v", err) + } + if template.ID != "tpl-123" { + t.Fatalf("template.ID = %q, want tpl-123", template.ID) + } + }) + + t.Run("not_found", func(t *testing.T) { + client, cleanup := newTemplatesWorkflowTestClient(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":{"message":"not found"}}`, http.StatusNotFound) + }) + defer cleanup() + + _, err := client.GetRemoteTemplate(context.Background(), domain.ScopeApplication, "", "missing") + if !errors.Is(err, domain.ErrTemplateNotFound) { + t.Fatalf("GetRemoteTemplate() error = %v, want ErrTemplateNotFound", err) + } + }) +} + +func TestCreateAndUpdateRemoteTemplate(t *testing.T) { + t.Run("create", func(t *testing.T) { + client, cleanup := newTemplatesWorkflowTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Fatalf("content-type = %q, want json", ct) + } + + var req domain.CreateRemoteTemplateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.Name != "Booking confirmed message" { + t.Fatalf("req.Name = %q, want Booking confirmed message", req.Name) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"id":"tpl-new","engine":"mustache","name":"Booking confirmed message","subject":"Confirmed","body":"

Hello

"}}`)) + }) + defer cleanup() + + template, err := client.CreateRemoteTemplate(context.Background(), domain.ScopeApplication, "", &domain.CreateRemoteTemplateRequest{ + Name: "Booking confirmed message", + Subject: "Confirmed", + Body: "

Hello

", + Engine: "mustache", + }) + if err != nil { + t.Fatalf("CreateRemoteTemplate() error = %v", err) + } + if template.ID != "tpl-new" { + t.Fatalf("template.ID = %q, want tpl-new", template.ID) + } + }) + + t.Run("update", func(t *testing.T) { + client, cleanup := newTemplatesWorkflowTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Fatalf("method = %s, want PUT", r.Method) + } + if r.URL.Path != "/v3/grants/grant-123/templates/tpl-123" { + t.Fatalf("path = %s, want grant-scoped update path", r.URL.Path) + } + + var req domain.UpdateRemoteTemplateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.Name == nil || *req.Name != "Updated" { + t.Fatalf("updated name not sent correctly") + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"id":"tpl-123","engine":"mustache","name":"Updated","subject":"Confirmed","body":"

Hello

"}}`)) + }) + defer cleanup() + + name := "Updated" + template, err := client.UpdateRemoteTemplate(context.Background(), domain.ScopeGrant, "grant-123", "tpl-123", &domain.UpdateRemoteTemplateRequest{ + Name: &name, + }) + if err != nil { + t.Fatalf("UpdateRemoteTemplate() error = %v", err) + } + if template.Name != "Updated" { + t.Fatalf("template.Name = %q, want Updated", template.Name) + } + }) +} + +func TestDeleteAndRenderRemoteTemplate(t *testing.T) { + t.Run("delete", func(t *testing.T) { + client, cleanup := newTemplatesWorkflowTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Fatalf("method = %s, want DELETE", r.Method) + } + if r.URL.Path != "/v3/templates/tpl-123" { + t.Fatalf("path = %s, want /v3/templates/tpl-123", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + }) + defer cleanup() + + if err := client.DeleteRemoteTemplate(context.Background(), domain.ScopeApplication, "", "tpl-123"); err != nil { + t.Fatalf("DeleteRemoteTemplate() error = %v", err) + } + }) + + t.Run("render_template", func(t *testing.T) { + client, cleanup := newTemplatesWorkflowTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/templates/tpl-123/render" { + t.Fatalf("path = %s, want render path", r.URL.Path) + } + + var req domain.TemplateRenderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.Variables["user"] == nil { + t.Fatalf("variables missing from render request") + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"subject":"Welcome Nylas","body":"

Hello Nylas

"}}`)) + }) + defer cleanup() + + result, err := client.RenderRemoteTemplate(context.Background(), domain.ScopeApplication, "", "tpl-123", &domain.TemplateRenderRequest{ + Variables: map[string]any{"user": map[string]any{"name": "Nylas"}}, + }) + if err != nil { + t.Fatalf("RenderRemoteTemplate() error = %v", err) + } + if result["subject"] != "Welcome Nylas" { + t.Fatalf("result[subject] = %v, want Welcome Nylas", result["subject"]) + } + }) + + t.Run("render_html", func(t *testing.T) { + client, cleanup := newTemplatesWorkflowTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/grants/grant-123/templates/render" { + t.Fatalf("path = %s, want grant-scoped html render path", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"html":"

Hello Nylas

"}}`)) + }) + defer cleanup() + + result, err := client.RenderRemoteTemplateHTML(context.Background(), domain.ScopeGrant, "grant-123", &domain.TemplateRenderHTMLRequest{ + Body: "

Hello {{user.name}}

", + Engine: "mustache", + }) + if err != nil { + t.Fatalf("RenderRemoteTemplateHTML() error = %v", err) + } + if result["html"] != "

Hello Nylas

" { + t.Fatalf("result[html] = %v, want rendered html", result["html"]) + } + }) +} + +func TestWorkflowOperations(t *testing.T) { + t.Run("list", func(t *testing.T) { + client, cleanup := newTemplatesWorkflowTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/workflows" { + t.Fatalf("path = %s, want /v3/workflows", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "data": [{ + "id": "wf-1", + "grant_id": "grant-123", + "is_enabled": true, + "name": "Booking Confirmation", + "trigger_event": "booking.created", + "delay": 1, + "template_id": "tpl-123", + "date_created": 1700000000 + }], + "next_cursor": "cursor-123" + }`)) + }) + defer cleanup() + + resp, err := client.ListWorkflows(context.Background(), domain.ScopeApplication, "", &domain.CursorListParams{Limit: 10}) + if err != nil { + t.Fatalf("ListWorkflows() error = %v", err) + } + if len(resp.Data) != 1 { + t.Fatalf("len(resp.Data) = %d, want 1", len(resp.Data)) + } + if resp.Data[0].TemplateID != "tpl-123" { + t.Fatalf("template_id = %q, want tpl-123", resp.Data[0].TemplateID) + } + }) + + t.Run("create_update_get_delete", func(t *testing.T) { + step := 0 + client, cleanup := newTemplatesWorkflowTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch step { + case 0: + if r.Method != http.MethodPost || r.URL.Path != "/v3/grants/grant-123/workflows" { + t.Fatalf("unexpected create request: %s %s", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(`{"data":{"id":"wf-new","is_enabled":true,"name":"Booking Confirmation","trigger_event":"booking.created","delay":1,"template_id":"tpl-123","date_created":1700000000}}`)) + case 1: + if r.Method != http.MethodPut || r.URL.Path != "/v3/grants/grant-123/workflows/wf-new" { + t.Fatalf("unexpected update request: %s %s", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(`{"data":{"id":"wf-new","is_enabled":false,"name":"Updated Workflow","trigger_event":"booking.created","delay":5,"template_id":"tpl-123","date_created":1700000000}}`)) + case 2: + if r.Method != http.MethodGet || r.URL.Path != "/v3/grants/grant-123/workflows/wf-new" { + t.Fatalf("unexpected get request: %s %s", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(`{"data":{"id":"wf-new","is_enabled":false,"name":"Updated Workflow","trigger_event":"booking.created","delay":5,"template_id":"tpl-123","date_created":1700000000}}`)) + case 3: + if r.Method != http.MethodDelete || r.URL.Path != "/v3/grants/grant-123/workflows/wf-new" { + t.Fatalf("unexpected delete request: %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected request step %d", step) + } + step++ + }) + defer cleanup() + + enabled := true + created, err := client.CreateWorkflow(context.Background(), domain.ScopeGrant, "grant-123", &domain.CreateRemoteWorkflowRequest{ + Name: "Booking Confirmation", + TriggerEvent: "booking.created", + TemplateID: "tpl-123", + Delay: 1, + IsEnabled: &enabled, + }) + if err != nil { + t.Fatalf("CreateWorkflow() error = %v", err) + } + if created.ID != "wf-new" { + t.Fatalf("created.ID = %q, want wf-new", created.ID) + } + + updatedName := "Updated Workflow" + updatedDelay := 5 + disabled := false + updated, err := client.UpdateWorkflow(context.Background(), domain.ScopeGrant, "grant-123", "wf-new", &domain.UpdateRemoteWorkflowRequest{ + Name: &updatedName, + Delay: &updatedDelay, + IsEnabled: &disabled, + }) + if err != nil { + t.Fatalf("UpdateWorkflow() error = %v", err) + } + if updated.Name != "Updated Workflow" { + t.Fatalf("updated.Name = %q, want Updated Workflow", updated.Name) + } + + fetched, err := client.GetWorkflow(context.Background(), domain.ScopeGrant, "grant-123", "wf-new") + if err != nil { + t.Fatalf("GetWorkflow() error = %v", err) + } + if fetched.Delay != 5 { + t.Fatalf("fetched.Delay = %d, want 5", fetched.Delay) + } + + if err := client.DeleteWorkflow(context.Background(), domain.ScopeGrant, "grant-123", "wf-new"); err != nil { + t.Fatalf("DeleteWorkflow() error = %v", err) + } + }) +} diff --git a/internal/adapters/utilities/timezone/service_advanced_test.go b/internal/adapters/utilities/timezone/service_advanced_test.go index dd28b39..fb2de47 100644 --- a/internal/adapters/utilities/timezone/service_advanced_test.go +++ b/internal/adapters/utilities/timezone/service_advanced_test.go @@ -60,6 +60,7 @@ func TestService_GetTimeZoneInfo(t *testing.T) { if result == nil { t.Fatal("result is nil") + return } if result.Name != tt.zone { diff --git a/internal/adapters/utilities/timezone/service_basic_test.go b/internal/adapters/utilities/timezone/service_basic_test.go index db28cbc..a8b783c 100644 --- a/internal/adapters/utilities/timezone/service_basic_test.go +++ b/internal/adapters/utilities/timezone/service_basic_test.go @@ -177,6 +177,7 @@ func TestService_FindMeetingTime(t *testing.T) { if result == nil { t.Fatal("result is nil") + return } if len(result.TimeZones) != len(tt.req.TimeZones) { diff --git a/internal/air/cache/cache_offline_settings_test.go b/internal/air/cache/cache_offline_settings_test.go index 2360c7b..854d240 100644 --- a/internal/air/cache/cache_offline_settings_test.go +++ b/internal/air/cache/cache_offline_settings_test.go @@ -58,6 +58,7 @@ func TestOfflineQueue(t *testing.T) { } if peeked == nil { t.Fatal("Peek returned nil") + return } if peeked.Type != ActionMarkRead { t.Errorf("Peeked Type = %s, want %s", peeked.Type, ActionMarkRead) @@ -88,6 +89,7 @@ func TestOfflineQueue(t *testing.T) { } if dequeued == nil { t.Fatal("Dequeue returned nil") + return } if dequeued.Type != ActionMarkRead { t.Errorf("Dequeued Type = %s, want %s", dequeued.Type, ActionMarkRead) diff --git a/internal/air/cache/cache_stores_email_contact_test.go b/internal/air/cache/cache_stores_email_contact_test.go index 3e9b01b..9f9a977 100644 --- a/internal/air/cache/cache_stores_email_contact_test.go +++ b/internal/air/cache/cache_stores_email_contact_test.go @@ -131,6 +131,7 @@ func TestContactStoreGetByEmail(t *testing.T) { } if contact == nil { t.Fatal("GetByEmail returned nil") + return } if contact.DisplayName != "Alice" { t.Errorf("DisplayName = %s, want Alice", contact.DisplayName) diff --git a/internal/air/cache/cache_stores_queue_sync_test.go b/internal/air/cache/cache_stores_queue_sync_test.go index 8923d14..bc16361 100644 --- a/internal/air/cache/cache_stores_queue_sync_test.go +++ b/internal/air/cache/cache_stores_queue_sync_test.go @@ -185,6 +185,7 @@ func TestSyncStoreMarkSynced(t *testing.T) { } if state == nil { t.Fatal("State should exist") + return } if time.Since(state.LastSync) > time.Second { t.Error("LastSync should be recent") @@ -199,6 +200,7 @@ func TestSyncStoreMarkSynced(t *testing.T) { // Verify state2 exists and LastSync is within last second if state2 == nil { t.Fatal("State should still exist after second MarkSynced") + return } if time.Since(state2.LastSync) > time.Second { t.Error("LastSync should be recent after update") diff --git a/internal/air/cache/settings_test.go b/internal/air/cache/settings_test.go index de143c4..7d9baad 100644 --- a/internal/air/cache/settings_test.go +++ b/internal/air/cache/settings_test.go @@ -22,6 +22,7 @@ func TestLoadSettings_NewFile(t *testing.T) { if settings == nil { t.Fatal("LoadSettings() returned nil") + return } // Should have default values diff --git a/internal/air/handlers_bundles_test.go b/internal/air/handlers_bundles_test.go index 95f3223..381aac8 100644 --- a/internal/air/handlers_bundles_test.go +++ b/internal/air/handlers_bundles_test.go @@ -13,6 +13,7 @@ func TestNewBundleStore(t *testing.T) { if store == nil { t.Fatal("expected non-nil store") + return } if len(store.bundles) == 0 { diff --git a/internal/air/server_modules_test.go b/internal/air/server_modules_test.go index b4b245c..ad75b2c 100644 --- a/internal/air/server_modules_test.go +++ b/internal/air/server_modules_test.go @@ -248,6 +248,7 @@ func TestNewServer(t *testing.T) { if server == nil { t.Fatal("expected non-nil server") + return } if server.demoMode { diff --git a/internal/air/server_test.go b/internal/air/server_test.go index 1e5b18a..b141d56 100644 --- a/internal/air/server_test.go +++ b/internal/air/server_test.go @@ -15,6 +15,7 @@ func TestNewDemoServer(t *testing.T) { if server == nil { t.Fatal("expected non-nil server") + return } if !server.demoMode { diff --git a/internal/cli/common/remote_resources.go b/internal/cli/common/remote_resources.go new file mode 100644 index 0000000..5f62de0 --- /dev/null +++ b/internal/cli/common/remote_resources.go @@ -0,0 +1,116 @@ +package common + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "github.com/nylas/cli/internal/adapters/keyring" + "github.com/nylas/cli/internal/domain" +) + +// ResolveGrantIdentifier resolves a grant identifier, accepting either a grant ID or an email. +func ResolveGrantIdentifier(identifier string) (string, error) { + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return "", nil + } + if !containsAt(identifier) { + return identifier, nil + } + + secretStore, err := openSecretStore() + if err != nil { + return "", err + } + + grantStore := keyring.NewGrantStore(secretStore) + grant, err := grantStore.GetGrantByEmail(identifier) + if err != nil { + if errors.Is(err, domain.ErrGrantNotFound) { + return "", fmt.Errorf("no grant found for email: %s", identifier) + } + return "", wrapSecretStoreError(err) + } + + return grant.ID, nil +} + +// ResolveScopeGrantID resolves the grant ID when a command targets grant-scoped resources. +func ResolveScopeGrantID(scope domain.RemoteScope, grantID string) (string, error) { + if scope != domain.ScopeGrant { + return "", nil + } + if grantID == "" { + return GetGrantID(nil) + } + return ResolveGrantIdentifier(grantID) +} + +// LoadJSONFile decodes a JSON file into target. +func LoadJSONFile(path string, target any) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read %s: %w", path, err) + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("failed to parse %s: %w", path, err) + } + return nil +} + +// ReadJSONStringMap parses inline JSON or JSON from a file into a map. +func ReadJSONStringMap(value, file string) (map[string]any, error) { + if value != "" && file != "" { + return nil, NewUserError("only one of --data or --data-file may be used", "Choose either inline JSON or a JSON file") + } + + var data []byte + switch { + case file != "": + fileData, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", file, err) + } + data = fileData + case value != "": + data = []byte(value) + default: + return map[string]any{}, nil + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + return nil, NewUserError("invalid JSON data", "Provide a valid JSON object with --data or --data-file") + } + if result == nil { + result = map[string]any{} + } + return result, nil +} + +// ReadStringOrFile returns a string from an inline flag or file. +func ReadStringOrFile(name, value, file string, required bool) (string, error) { + if value != "" && file != "" { + return "", NewUserError( + fmt.Sprintf("only one of --%s or --%s-file may be used", name, name), + fmt.Sprintf("Choose either --%s or --%s-file", name, name), + ) + } + + if file != "" { + data, err := os.ReadFile(file) + if err != nil { + return "", fmt.Errorf("failed to read %s: %w", file, err) + } + return string(data), nil + } + + if required && value == "" { + return "", ValidateRequiredFlag("--"+name, value) + } + + return value, nil +} diff --git a/internal/cli/common/remote_resources_test.go b/internal/cli/common/remote_resources_test.go new file mode 100644 index 0000000..fd677f1 --- /dev/null +++ b/internal/cli/common/remote_resources_test.go @@ -0,0 +1,85 @@ +//go:build !integration + +package common + +import ( + "path/filepath" + "testing" + + "github.com/nylas/cli/internal/adapters/keyring" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveGrantIdentifier_WithEmail(t *testing.T) { + configDir := filepath.Join(t.TempDir(), "nylas") + t.Setenv("XDG_CONFIG_HOME", filepath.Dir(configDir)) + t.Setenv("HOME", t.TempDir()) + t.Setenv("NYLAS_DISABLE_KEYRING", "true") + t.Setenv("NYLAS_FILE_STORE_PASSPHRASE", "test-file-store-passphrase") + t.Setenv("NYLAS_API_KEY", "") + t.Setenv("NYLAS_GRANT_ID", "") + + store, err := keyring.NewEncryptedFileStore(configDir) + require.NoError(t, err) + + grantStore := keyring.NewGrantStore(store) + require.NoError(t, grantStore.SaveGrant(domain.GrantInfo{ + ID: "grant-123", + Email: "user@example.com", + })) + + grantID, err := ResolveGrantIdentifier("user@example.com") + + require.NoError(t, err) + assert.Equal(t, "grant-123", grantID) +} + +func TestResolveGrantIdentifier_WithEmailIgnoresEnvGrantFallback(t *testing.T) { + configDir := filepath.Join(t.TempDir(), "nylas") + t.Setenv("XDG_CONFIG_HOME", filepath.Dir(configDir)) + t.Setenv("HOME", t.TempDir()) + t.Setenv("NYLAS_DISABLE_KEYRING", "true") + t.Setenv("NYLAS_FILE_STORE_PASSPHRASE", "test-file-store-passphrase") + t.Setenv("NYLAS_API_KEY", "") + t.Setenv("NYLAS_GRANT_ID", "env-default-grant") + + store, err := keyring.NewEncryptedFileStore(configDir) + require.NoError(t, err) + + grantStore := keyring.NewGrantStore(store) + require.NoError(t, grantStore.SaveGrant(domain.GrantInfo{ + ID: "grant-email", + Email: "lookup@example.com", + })) + + grantID, err := ResolveGrantIdentifier("lookup@example.com") + + require.NoError(t, err) + assert.Equal(t, "grant-email", grantID) +} + +func TestResolveScopeGrantID_GrantScopeUsesGrantLookup(t *testing.T) { + configDir := filepath.Join(t.TempDir(), "nylas") + t.Setenv("XDG_CONFIG_HOME", filepath.Dir(configDir)) + t.Setenv("HOME", t.TempDir()) + t.Setenv("NYLAS_DISABLE_KEYRING", "true") + t.Setenv("NYLAS_FILE_STORE_PASSPHRASE", "test-file-store-passphrase") + t.Setenv("NYLAS_API_KEY", "") + t.Setenv("NYLAS_GRANT_ID", "") + + store, err := keyring.NewEncryptedFileStore(configDir) + require.NoError(t, err) + + grantStore := keyring.NewGrantStore(store) + require.NoError(t, grantStore.SaveGrant(domain.GrantInfo{ + ID: "grant-456", + Email: "grant@example.com", + })) + + grantID, err := ResolveScopeGrantID(domain.ScopeGrant, "grant@example.com") + + require.NoError(t, err) + assert.Equal(t, "grant-456", grantID) +} diff --git a/internal/cli/email/metadata_test.go b/internal/cli/email/metadata_test.go index 572eb6b..1de5067 100644 --- a/internal/cli/email/metadata_test.go +++ b/internal/cli/email/metadata_test.go @@ -9,6 +9,7 @@ func TestMetadataCommands(t *testing.T) { cmd := newMetadataCmd() if cmd == nil { t.Fatal("expected metadata command to exist") + return } if cmd.Use != "metadata" { t.Errorf("expected Use to be 'metadata', got %s", cmd.Use) @@ -19,6 +20,7 @@ func TestMetadataCommands(t *testing.T) { cmd := newMetadataShowCmd() if cmd == nil { t.Fatal("expected metadata show command to exist") + return } if cmd.Use != "show [grant-id]" { t.Errorf("expected Use to be 'show [grant-id]', got %s", cmd.Use) @@ -29,6 +31,7 @@ func TestMetadataCommands(t *testing.T) { cmd := newMetadataInfoCmd() if cmd == nil { t.Fatal("expected metadata info command to exist") + return } if cmd.Use != "info" { t.Errorf("expected Use to be 'info', got %s", cmd.Use) diff --git a/internal/cli/email/send.go b/internal/cli/email/send.go index 7ae5135..4363dc5 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 templateOpts hostedTemplateSendOptions cmd := &cobra.Command{ Use: "send [grant-id]", @@ -65,10 +66,22 @@ Supports email tracking: - --track-label: Add a label to identify tracked emails Supports custom metadata: -- --metadata key=value: Add custom key-value metadata (can be repeated)`, +- --metadata key=value: Add custom key-value metadata (can be repeated) + +Supports hosted templates: +- --template-id : Render and send a Nylas-hosted template +- --template-data : Provide template variables as inline JSON +- --template-data-file : Load template variables from a JSON file +- --render-only: Preview the rendered template without sending`, Example: ` # Send immediately nylas email send --to user@example.com --subject "Hello" --body "Hi there!" + # Send using a hosted template + nylas email send --to user@example.com --template-id tpl_123 --template-data '{"user":{"name":"Ada"}}' + + # Preview a hosted template render without sending + nylas email send --template-id tpl_123 --template-data-file data.json --render-only + # Send with GPG signature (uses default key from git config) nylas email send --to user@example.com --subject "Secure" --body "Signed email" --sign @@ -117,23 +130,23 @@ Supports custom metadata: } } - // Interactive mode (runs before WithClient) - if interactive || (len(to) == 0 && subject == "" && body == "") { + // Interactive mode (runs before client setup). + if interactive || (templateOpts.TemplateID == "" && len(to) == 0 && subject == "" && body == "") { reader := bufio.NewReader(os.Stdin) - if len(to) == 0 { + if len(to) == 0 && !templateOpts.RenderOnly { fmt.Print("To (comma-separated): ") input, _ := reader.ReadString('\n') to = parseEmails(strings.TrimSpace(input)) } - if subject == "" { + if templateOpts.TemplateID == "" && subject == "" { fmt.Print("Subject: ") subject, _ = reader.ReadString('\n') subject = strings.TrimSpace(subject) } - if body == "" { + if templateOpts.TemplateID == "" && body == "" { fmt.Println("Body (end with a line containing only '.'):") var bodyLines []string for { @@ -148,65 +161,43 @@ Supports custom metadata: } } - if len(to) == 0 { + if err := validateHostedTemplateSendOptions(templateOpts, subject, body); err != nil { + return err + } + + if len(to) == 0 && !templateOpts.RenderOnly { return common.NewUserError("at least one recipient is required", "Use --to to specify recipient email addresses") } - if subject == "" { + if templateOpts.TemplateID == "" && subject == "" { return common.NewUserError("subject is required", "Use --subject to specify the email subject") } - // Parse and validate recipients - toContacts, err := parseContacts(to) - if err != nil { - return common.WrapRecipientError("to", err) - } + var toContacts []domain.EmailParticipant + var ccContacts []domain.EmailParticipant + var bccContacts []domain.EmailParticipant - // Build request - req := &domain.SendMessageRequest{ - Subject: subject, - Body: body, - To: toContacts, + // Parse and validate recipients. + if len(to) > 0 { + var err error + toContacts, err = parseContacts(to) + if err != nil { + return common.WrapRecipientError("to", err) + } } - if len(cc) > 0 { - ccContacts, err := parseContacts(cc) + var err error + ccContacts, err = parseContacts(cc) if err != nil { return common.WrapRecipientError("cc", err) } - req.Cc = ccContacts } if len(bcc) > 0 { - bccContacts, err := parseContacts(bcc) + var err error + bccContacts, err = parseContacts(bcc) if err != nil { return common.WrapRecipientError("bcc", err) } - req.Bcc = bccContacts - } - if replyTo != "" { - req.ReplyToMsgID = replyTo - } - - // Add tracking options if specified - if trackOpens || trackLinks || trackLabel != "" { - req.TrackingOpts = &domain.TrackingOptions{ - Opens: trackOpens, - Links: trackLinks, - Label: trackLabel, - } - } - - // Parse metadata key=value pairs - if len(metadata) > 0 { - req.Metadata = make(map[string]string) - for _, m := range metadata { - parts := strings.SplitN(m, "=", 2) - if len(parts) == 2 { - req.Metadata[parts[0]] = parts[1] - } else { - return common.NewInputError(fmt.Sprintf("invalid metadata format: %s (expected key=value)", m)) - } - } } // Parse schedule time if provided @@ -217,85 +208,135 @@ Supports custom metadata: if parseErr != nil { return parseErr // parseScheduleTime already returns CLIError } - req.SendAt = scheduledTime.Unix() } - // Confirmation - fmt.Println("\nEmail preview:") - fmt.Printf(" To: %s\n", strings.Join(to, ", ")) - if len(cc) > 0 { - fmt.Printf(" Cc: %s\n", strings.Join(cc, ", ")) - } - if len(bcc) > 0 { - fmt.Printf(" Bcc: %s\n", strings.Join(bcc, ", ")) - } - fmt.Printf(" Subject: %s\n", subject) - if body != "" { - fmt.Printf(" Body: %s\n", common.Truncate(body, 50)) - } - if !scheduledTime.IsZero() { - fmt.Printf(" %s %s\n", common.Yellow.Sprint("Scheduled:"), scheduledTime.Format(common.DisplayWeekdayFullWithTZ)) + sendNeedsGrant, err := hostedTemplateSendNeedsGrant(templateOpts) + if err != nil { + return err } - if trackOpens || trackLinks { - tracking := []string{} - if trackOpens { - tracking = append(tracking, "opens") + + sendWithClient := func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + activeSubject := subject + activeBody := body + var templatePreviewLabel string + + if templateOpts.TemplateID != "" { + rendered, err := renderHostedTemplateForSend(ctx, client, grantID, templateOpts) + if err != nil { + return struct{}{}, err + } + + activeSubject = rendered.Subject + activeBody = rendered.Body + templatePreviewLabel = templateOpts.TemplateID + + if templateOpts.RenderOnly { + if jsonOutput { + return struct{}{}, common.PrintJSON(rendered.Result) + } + printHostedTemplatePreview(templateOpts.TemplateID, rendered.Subject, rendered.Body, to, cc, bcc) + return struct{}{}, nil + } } - if trackLinks { - tracking = append(tracking, "links") + + req := &domain.SendMessageRequest{ + Subject: activeSubject, + Body: activeBody, + To: toContacts, + Cc: ccContacts, + Bcc: bccContacts, } - fmt.Printf(" %s %s\n", common.Cyan.Sprint("Tracking:"), strings.Join(tracking, ", ")) - } - if len(metadata) > 0 { - fmt.Printf(" %s %s\n", common.Cyan.Sprint("Metadata:"), strings.Join(metadata, ", ")) - } - if sign { - var signingInfo string - if gpgKeyID != "" { - // Explicit key ID provided - signingInfo = fmt.Sprintf("key %s", gpgKeyID) - } else { - // Auto-detect from From address - fromEmail := "" - if len(toContacts) > 0 && len(req.From) > 0 { - fromEmail = req.From[0].Email + if replyTo != "" { + req.ReplyToMsgID = replyTo + } + if trackOpens || trackLinks || trackLabel != "" { + req.TrackingOpts = &domain.TrackingOptions{ + Opens: trackOpens, + Links: trackLinks, + Label: trackLabel, } - if fromEmail != "" { - signingInfo = fmt.Sprintf("as %s", fromEmail) - } else { - signingInfo = "default key from git config" + } + if len(metadata) > 0 { + req.Metadata = make(map[string]string) + for _, m := range metadata { + parts := strings.SplitN(m, "=", 2) + if len(parts) == 2 { + req.Metadata[parts[0]] = parts[1] + continue + } + return struct{}{}, common.NewInputError(fmt.Sprintf("invalid metadata format: %s (expected key=value)", m)) } } - fmt.Printf(" %s %s\n", common.Green.Sprint("GPG Signed:"), signingInfo) - } - if encrypt { - var encryptInfo string - if recipientKey != "" { - encryptInfo = fmt.Sprintf("with key %s", recipientKey) - } else { - encryptInfo = fmt.Sprintf("for %s (auto-fetch)", strings.Join(to, ", ")) + if !scheduledTime.IsZero() { + req.SendAt = scheduledTime.Unix() } - fmt.Printf(" %s %s\n", common.Blue.Sprint("GPG Encrypted:"), encryptInfo) - } - if !noConfirm { - if scheduledTime.IsZero() { - fmt.Print("\nSend this email? [y/N]: ") - } else { - fmt.Print("\nSchedule this email? [y/N]: ") + fmt.Println("\nEmail preview:") + if templatePreviewLabel != "" { + fmt.Printf(" Template: %s\n", templatePreviewLabel) + } + if len(to) > 0 { + fmt.Printf(" To: %s\n", strings.Join(to, ", ")) + } + if len(cc) > 0 { + fmt.Printf(" Cc: %s\n", strings.Join(cc, ", ")) + } + if len(bcc) > 0 { + fmt.Printf(" Bcc: %s\n", strings.Join(bcc, ", ")) + } + fmt.Printf(" Subject: %s\n", activeSubject) + if activeBody != "" { + fmt.Printf(" Body: %s\n", common.Truncate(activeBody, 50)) + } + if !scheduledTime.IsZero() { + fmt.Printf(" %s %s\n", common.Yellow.Sprint("Scheduled:"), scheduledTime.Format(common.DisplayWeekdayFullWithTZ)) + } + if trackOpens || trackLinks { + tracking := []string{} + if trackOpens { + tracking = append(tracking, "opens") + } + if trackLinks { + tracking = append(tracking, "links") + } + fmt.Printf(" %s %s\n", common.Cyan.Sprint("Tracking:"), strings.Join(tracking, ", ")) + } + if len(metadata) > 0 { + fmt.Printf(" %s %s\n", common.Cyan.Sprint("Metadata:"), strings.Join(metadata, ", ")) + } + if sign { + signingInfo := "default key from git config" + if gpgKeyID != "" { + signingInfo = fmt.Sprintf("key %s", gpgKeyID) + } + fmt.Printf(" %s %s\n", common.Green.Sprint("GPG Signed:"), signingInfo) + } + if encrypt { + var encryptInfo string + if recipientKey != "" { + encryptInfo = fmt.Sprintf("with key %s", recipientKey) + } else { + encryptInfo = fmt.Sprintf("for %s (auto-fetch)", strings.Join(to, ", ")) + } + fmt.Printf(" %s %s\n", common.Blue.Sprint("GPG Encrypted:"), encryptInfo) } - reader := bufio.NewReader(os.Stdin) - confirm, _ := reader.ReadString('\n') - confirm = strings.ToLower(strings.TrimSpace(confirm)) - if confirm != "y" && confirm != "yes" { - fmt.Println("Cancelled.") - return nil + if !noConfirm { + if scheduledTime.IsZero() { + fmt.Print("\nSend this email? [y/N]: ") + } else { + fmt.Print("\nSchedule this email? [y/N]: ") + } + + reader := bufio.NewReader(os.Stdin) + confirm, _ := reader.ReadString('\n') + confirm = strings.ToLower(strings.TrimSpace(confirm)) + if confirm != "y" && confirm != "yes" { + fmt.Println("Cancelled.") + return struct{}{}, nil + } } - } - // Send - _, sendErr := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { var msg *domain.Message var err error @@ -311,7 +352,7 @@ Supports custom metadata: } // GPG signing and/or encryption flow - msg, err = sendSecureEmail(ctx, client, grantID, req, gpgKeyID, recipientKey, toContacts, subject, body, sign, encrypt) + msg, err = sendSecureEmail(ctx, client, grantID, req, gpgKeyID, recipientKey, toContacts, activeSubject, activeBody, sign, encrypt) } else { // Standard flow var sendMsg string @@ -368,6 +409,15 @@ Supports custom metadata: } } return struct{}{}, nil + } + + if sendNeedsGrant { + _, sendErr := common.WithClient(args, sendWithClient) + return sendErr + } + + _, sendErr := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + return sendWithClient(ctx, client, "") }) return sendErr }, @@ -392,6 +442,13 @@ Supports custom metadata: 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(&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") + cmd.Flags().StringVar(&templateOpts.TemplateData, "template-data", "", "Inline JSON object with hosted template variables") + cmd.Flags().StringVar(&templateOpts.TemplateDataFile, "template-data-file", "", "Path to a JSON file with hosted template variables") + cmd.Flags().BoolVar(&templateOpts.RenderOnly, "render-only", false, "Render a hosted template preview without sending") + cmd.Flags().BoolVar(&templateOpts.Strict, "template-strict", true, "Fail if a hosted template references missing variables") return cmd } diff --git a/internal/cli/email/send_gpg_test.go b/internal/cli/email/send_gpg_test.go index 8ff5c5b..97fcc2a 100644 --- a/internal/cli/email/send_gpg_test.go +++ b/internal/cli/email/send_gpg_test.go @@ -87,6 +87,7 @@ func TestSendSignedEmail_MockClient(t *testing.T) { // If we got here, GPG is configured and signing worked if msg == nil { t.Fatal("sendSignedEmail() returned nil message") + return } if msg.ID != "test-msg-id" { t.Errorf("sendSignedEmail() message ID = %v, want test-msg-id", msg.ID) diff --git a/internal/cli/email/send_template.go b/internal/cli/email/send_template.go new file mode 100644 index 0000000..96d8e0f --- /dev/null +++ b/internal/cli/email/send_template.go @@ -0,0 +1,182 @@ +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" +) + +type hostedTemplateSendOptions struct { + TemplateID string + TemplateScope string + TemplateGrantID string + TemplateData string + TemplateDataFile string + RenderOnly bool + Strict bool +} + +type hostedTemplateSendResult struct { + GrantID string + Result domain.TemplateRenderResult + Subject string + Body string +} + +func validateHostedTemplateSendOptions(opts hostedTemplateSendOptions, subject, body string) error { + if opts.TemplateID == "" { + switch { + case opts.TemplateGrantID != "": + return common.NewUserError("`--template-grant-id` requires `--template-id`", "Use --template-id to render and send a hosted template") + case opts.TemplateData != "" || opts.TemplateDataFile != "": + return common.NewUserError("template data requires `--template-id`", "Use --template-id with --template-data or --template-data-file") + case opts.RenderOnly: + return common.NewUserError("`--render-only` requires `--template-id`", "Use --template-id to preview a hosted template render") + case opts.TemplateScope != "" && opts.TemplateScope != string(domain.ScopeApplication): + return common.NewUserError("`--template-scope` requires `--template-id`", "Use --template-id with --template-scope app or grant") + } + return nil + } + + if subject != "" || body != "" { + return common.NewUserError( + "`--template-id` cannot be combined with `--subject` or `--body`", + "Use the hosted template subject/body, or remove --template-id and provide raw content", + ) + } + + if opts.TemplateScope == "" { + opts.TemplateScope = string(domain.ScopeApplication) + } + + return nil +} + +func renderHostedTemplateForSend( + ctx context.Context, + client ports.NylasClient, + sendGrantID string, + opts hostedTemplateSendOptions, +) (*hostedTemplateSendResult, error) { + if opts.TemplateID == "" { + return nil, nil + } + if opts.TemplateScope == "" { + opts.TemplateScope = string(domain.ScopeApplication) + } + + scope, err := domain.ParseRemoteScope(opts.TemplateScope) + if err != nil { + return nil, common.NewUserError("invalid `--template-scope` value", "Use --template-scope app or --template-scope grant") + } + + renderGrantID := "" + if scope == domain.ScopeGrant { + renderGrantID, err = common.ResolveGrantIdentifier(opts.TemplateGrantID) + if err != nil { + return nil, err + } + if renderGrantID == "" { + renderGrantID = sendGrantID + } + if renderGrantID == "" { + return nil, common.NewUserError("grant-scoped templates require a grant ID", "Provide a send grant or set --template-grant-id") + } + } + + vars, err := common.ReadJSONStringMap(opts.TemplateData, opts.TemplateDataFile) + if err != nil { + return nil, err + } + + rendered, err := client.RenderRemoteTemplate(ctx, scope, renderGrantID, opts.TemplateID, &domain.TemplateRenderRequest{ + Strict: &opts.Strict, + Variables: vars, + }) + if err != nil { + return nil, fmt.Errorf("failed to render hosted template %s: %w", opts.TemplateID, err) + } + + subject, body, err := extractRenderedEmailContent(rendered) + if err != nil { + return nil, err + } + + return &hostedTemplateSendResult{ + GrantID: renderGrantID, + Result: rendered, + Subject: subject, + Body: body, + }, nil +} + +func hostedTemplateSendNeedsGrant(opts hostedTemplateSendOptions) (bool, error) { + if opts.TemplateID == "" || !opts.RenderOnly { + return true, nil + } + + scope := domain.ScopeApplication + if opts.TemplateScope != "" { + parsedScope, err := domain.ParseRemoteScope(opts.TemplateScope) + if err != nil { + return false, common.NewUserError("invalid `--template-scope` value", "Use --template-scope app or --template-scope grant") + } + scope = parsedScope + } + + if scope == domain.ScopeApplication { + return false, nil + } + + return strings.TrimSpace(opts.TemplateGrantID) == "", nil +} + +func extractRenderedEmailContent(result domain.TemplateRenderResult) (string, string, error) { + subject, _ := result["subject"].(string) + body, _ := result["body"].(string) + if body == "" { + if html, ok := result["html"].(string); ok { + body = html + } + } + + if strings.TrimSpace(subject) == "" { + return "", "", common.NewUserError("rendered template is missing a subject", "Ensure the hosted template returns a subject") + } + if strings.TrimSpace(body) == "" { + return "", "", common.NewUserError("rendered template is missing a body", "Ensure the hosted template returns a body") + } + + return subject, body, nil +} + +func printHostedTemplatePreview(templateID, subject, body string, to, cc, bcc []string) { + fmt.Println() + fmt.Println(strings.Repeat("─", 60)) + _, _ = common.BoldWhite.Printf("HOSTED TEMPLATE PREVIEW: %s\n", templateID) + fmt.Println(strings.Repeat("─", 60)) + + if len(to) > 0 { + fmt.Printf("To: %s\n", strings.Join(to, ", ")) + } + if len(cc) > 0 { + fmt.Printf("Cc: %s\n", strings.Join(cc, ", ")) + } + if len(bcc) > 0 { + fmt.Printf("Bcc: %s\n", strings.Join(bcc, ", ")) + } + fmt.Printf("Subject: %s\n", subject) + + fmt.Println() + _, _ = common.Dim.Println("Body:") + fmt.Println(strings.Repeat("─", 40)) + fmt.Println(body) + fmt.Println(strings.Repeat("─", 40)) + fmt.Println() + _, _ = common.Dim.Println("This is a preview. Remove --render-only to send the email.") + fmt.Println() +} diff --git a/internal/cli/email/send_template_test.go b/internal/cli/email/send_template_test.go new file mode 100644 index 0000000..d59f812 --- /dev/null +++ b/internal/cli/email/send_template_test.go @@ -0,0 +1,318 @@ +package email + +import ( + "context" + "path/filepath" + "testing" + + "github.com/nylas/cli/internal/adapters/keyring" + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" +) + +func TestValidateHostedTemplateSendOptions(t *testing.T) { + tests := []struct { + name string + opts hostedTemplateSendOptions + subject string + body string + wantErr bool + }{ + { + name: "no hosted template flags", + opts: hostedTemplateSendOptions{}, + subject: "Hello", + body: "World", + }, + { + name: "template with inline content is rejected", + opts: hostedTemplateSendOptions{TemplateID: "tpl-123"}, + subject: "Hello", + wantErr: true, + }, + { + name: "render only requires template id", + opts: hostedTemplateSendOptions{RenderOnly: true}, + wantErr: true, + }, + { + name: "template data requires template id", + opts: hostedTemplateSendOptions{TemplateData: `{"name":"Ada"}`}, + wantErr: true, + }, + { + name: "grant scope without template id is rejected", + opts: hostedTemplateSendOptions{TemplateScope: string(domain.ScopeGrant)}, + wantErr: true, + }, + { + name: "valid hosted template request", + opts: hostedTemplateSendOptions{ + TemplateID: "tpl-123", + TemplateScope: string(domain.ScopeApplication), + TemplateData: `{"user":{"name":"Ada"}}`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateHostedTemplateSendOptions(tt.opts, tt.subject, tt.body) + if (err != nil) != tt.wantErr { + t.Fatalf("validateHostedTemplateSendOptions() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestExtractRenderedEmailContent(t *testing.T) { + tests := []struct { + name string + result domain.TemplateRenderResult + wantSubject string + wantBody string + wantErr bool + }{ + { + name: "subject and body", + result: domain.TemplateRenderResult{ + "subject": "Hello Ada", + "body": "

Hello Ada

", + }, + wantSubject: "Hello Ada", + wantBody: "

Hello Ada

", + }, + { + name: "html fallback", + result: domain.TemplateRenderResult{ + "subject": "Hello Ada", + "html": "

Hello Ada

", + }, + wantSubject: "Hello Ada", + wantBody: "

Hello Ada

", + }, + { + name: "missing subject", + result: domain.TemplateRenderResult{ + "body": "

Hello Ada

", + }, + wantErr: true, + }, + { + name: "missing body", + result: domain.TemplateRenderResult{ + "subject": "Hello Ada", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + subject, body, err := extractRenderedEmailContent(tt.result) + if (err != nil) != tt.wantErr { + t.Fatalf("extractRenderedEmailContent() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if subject != tt.wantSubject { + t.Fatalf("subject = %q, want %q", subject, tt.wantSubject) + } + if body != tt.wantBody { + t.Fatalf("body = %q, want %q", body, tt.wantBody) + } + }) + } +} + +func TestRenderHostedTemplateForSend(t *testing.T) { + t.Run("defaults to app scope", func(t *testing.T) { + mockClient := &nylas.MockClient{ + RenderRemoteTemplateFunc: func(ctx context.Context, scope domain.RemoteScope, grantID, templateID string, req *domain.TemplateRenderRequest) (domain.TemplateRenderResult, error) { + if scope != domain.ScopeApplication { + t.Fatalf("scope = %q, want %q", scope, domain.ScopeApplication) + } + if grantID != "" { + t.Fatalf("grantID = %q, want empty", grantID) + } + if templateID != "tpl-app" { + t.Fatalf("templateID = %q, want %q", templateID, "tpl-app") + } + return domain.TemplateRenderResult{ + "subject": "Hello Ada", + "body": "

Hello Ada

", + }, nil + }, + } + + rendered, err := renderHostedTemplateForSend(context.Background(), mockClient, "", hostedTemplateSendOptions{ + TemplateID: "tpl-app", + TemplateData: `{"user":{"name":"Ada"}}`, + Strict: true, + }) + if err != nil { + t.Fatalf("renderHostedTemplateForSend() error = %v", err) + } + if rendered == nil { + t.Fatal("renderHostedTemplateForSend() returned nil result") + return + } + if rendered.Subject != "Hello Ada" { + t.Fatalf("subject = %q, want %q", rendered.Subject, "Hello Ada") + } + if rendered.Body != "

Hello Ada

" { + t.Fatalf("body = %q, want %q", rendered.Body, "

Hello Ada

") + } + }) + + t.Run("grant scope falls back to send grant", func(t *testing.T) { + mockClient := &nylas.MockClient{ + RenderRemoteTemplateFunc: func(ctx context.Context, scope domain.RemoteScope, grantID, templateID string, req *domain.TemplateRenderRequest) (domain.TemplateRenderResult, error) { + if scope != domain.ScopeGrant { + t.Fatalf("scope = %q, want %q", scope, domain.ScopeGrant) + } + if grantID != "grant-send" { + t.Fatalf("grantID = %q, want %q", grantID, "grant-send") + } + return domain.TemplateRenderResult{ + "subject": "Grant Hello", + "body": "

Grant Hello

", + }, nil + }, + } + + rendered, err := renderHostedTemplateForSend(context.Background(), mockClient, "grant-send", hostedTemplateSendOptions{ + TemplateID: "tpl-grant", + TemplateScope: string(domain.ScopeGrant), + Strict: true, + }) + if err != nil { + t.Fatalf("renderHostedTemplateForSend() error = %v", err) + } + if rendered == nil { + t.Fatal("renderHostedTemplateForSend() returned nil result") + return + } + if rendered.GrantID != "grant-send" { + t.Fatalf("GrantID = %q, want %q", rendered.GrantID, "grant-send") + } + }) + + t.Run("grant scope resolves template grant email", func(t *testing.T) { + configDir := filepath.Join(t.TempDir(), "nylas") + t.Setenv("XDG_CONFIG_HOME", filepath.Dir(configDir)) + t.Setenv("HOME", t.TempDir()) + t.Setenv("NYLAS_DISABLE_KEYRING", "true") + t.Setenv("NYLAS_FILE_STORE_PASSPHRASE", "test-file-store-passphrase") + t.Setenv("NYLAS_API_KEY", "") + t.Setenv("NYLAS_GRANT_ID", "") + + store, err := keyring.NewEncryptedFileStore(configDir) + if err != nil { + t.Fatalf("NewEncryptedFileStore() error = %v", err) + } + grantStore := keyring.NewGrantStore(store) + if err := grantStore.SaveGrant(domain.GrantInfo{ + ID: "grant-email-id", + Email: "lookup@example.com", + }); err != nil { + t.Fatalf("SaveGrant() error = %v", err) + } + + mockClient := &nylas.MockClient{ + RenderRemoteTemplateFunc: func(ctx context.Context, scope domain.RemoteScope, grantID, templateID string, req *domain.TemplateRenderRequest) (domain.TemplateRenderResult, error) { + if scope != domain.ScopeGrant { + t.Fatalf("scope = %q, want %q", scope, domain.ScopeGrant) + } + if grantID != "grant-email-id" { + t.Fatalf("grantID = %q, want %q", grantID, "grant-email-id") + } + return domain.TemplateRenderResult{ + "subject": "Grant Hello", + "body": "

Grant Hello

", + }, nil + }, + } + + rendered, err := renderHostedTemplateForSend(context.Background(), mockClient, "", hostedTemplateSendOptions{ + TemplateID: "tpl-grant", + TemplateScope: string(domain.ScopeGrant), + TemplateGrantID: "lookup@example.com", + Strict: true, + }) + if err != nil { + t.Fatalf("renderHostedTemplateForSend() error = %v", err) + } + if rendered == nil { + t.Fatal("renderHostedTemplateForSend() returned nil result") + return + } + if rendered.GrantID != "grant-email-id" { + t.Fatalf("GrantID = %q, want %q", rendered.GrantID, "grant-email-id") + } + }) +} + +func TestHostedTemplateSendNeedsGrant(t *testing.T) { + tests := []struct { + name string + opts hostedTemplateSendOptions + want bool + wantErr bool + }{ + { + name: "non render send needs grant", + opts: hostedTemplateSendOptions{TemplateID: "tpl-app"}, + want: true, + }, + { + name: "render only app scope does not need grant", + opts: hostedTemplateSendOptions{TemplateID: "tpl-app", RenderOnly: true}, + want: false, + }, + { + name: "render only grant scope with explicit template grant does not need send grant", + opts: hostedTemplateSendOptions{ + TemplateID: "tpl-grant", + TemplateScope: string(domain.ScopeGrant), + TemplateGrantID: "grant@example.com", + RenderOnly: true, + }, + want: false, + }, + { + name: "render only grant scope without template grant needs send grant", + opts: hostedTemplateSendOptions{ + TemplateID: "tpl-grant", + TemplateScope: string(domain.ScopeGrant), + RenderOnly: true, + }, + want: true, + }, + { + name: "invalid scope returns error", + opts: hostedTemplateSendOptions{ + TemplateID: "tpl-invalid", + TemplateScope: "invalid", + RenderOnly: true, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := hostedTemplateSendNeedsGrant(tt.opts) + if (err != nil) != tt.wantErr { + t.Fatalf("hostedTemplateSendNeedsGrant() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if got != tt.want { + t.Fatalf("hostedTemplateSendNeedsGrant() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/cli/email/send_test.go b/internal/cli/email/send_test.go index bb6f553..d54d59a 100644 --- a/internal/cli/email/send_test.go +++ b/internal/cli/email/send_test.go @@ -165,6 +165,13 @@ func TestSendCmd_FlagDefinitions(t *testing.T) { {name: "list-gpg-keys", shorthand: "", flagType: "bool"}, {name: "interactive", shorthand: "i", flagType: "bool"}, {name: "yes", shorthand: "y", flagType: "bool"}, + {name: "template-id", shorthand: "", flagType: "string"}, + {name: "template-scope", shorthand: "", flagType: "string"}, + {name: "template-grant-id", shorthand: "", flagType: "string"}, + {name: "template-data", shorthand: "", flagType: "string"}, + {name: "template-data-file", shorthand: "", flagType: "string"}, + {name: "render-only", shorthand: "", flagType: "bool"}, + {name: "template-strict", shorthand: "", flagType: "bool"}, } for _, expected := range expectedFlags { @@ -213,12 +220,21 @@ func TestSendCmd_UsageText(t *testing.T) { if !strings.Contains(usage, "--list-gpg-keys") { t.Error("Usage text should mention --list-gpg-keys flag") } + if !strings.Contains(usage, "--template-id") { + t.Error("Usage text should mention --template-id flag") + } + if !strings.Contains(usage, "--render-only") { + t.Error("Usage text should mention --render-only flag") + } // Validate examples include GPG example := cmd.Example if !strings.Contains(example, "gpg") && !strings.Contains(example, "sign") { t.Error("Examples should include GPG signing usage") } + if !strings.Contains(example, "--template-id") { + t.Error("Examples should include hosted template usage") + } } func TestSendCmd_CommandStructure(t *testing.T) { diff --git a/internal/cli/integration/email_send_test.go b/internal/cli/integration/email_send_test.go index 7e8b4e4..ae57af5 100644 --- a/internal/cli/integration/email_send_test.go +++ b/internal/cli/integration/email_send_test.go @@ -3,9 +3,15 @@ package integration import ( + "encoding/json" + "fmt" "os" + "path/filepath" "strings" "testing" + "time" + + "github.com/nylas/cli/internal/domain" ) // ============================================================================= @@ -18,10 +24,7 @@ func TestCLI_EmailSend(t *testing.T) { } skipIfMissingCreds(t) - email := testEmail - if email == "" { - email = "test@example.com" - } + email := getSendTargetEmail(t) stdout, stderr, err := runCLI("email", "send", "--to", email, @@ -171,6 +174,12 @@ func TestCLI_EmailSendHelp_Schedule(t *testing.T) { if !strings.Contains(stdout, "--schedule") { t.Errorf("Expected '--schedule' in send help, got: %s", stdout) } + if !strings.Contains(stdout, "--template-id") { + t.Errorf("Expected '--template-id' in send help, got: %s", stdout) + } + if !strings.Contains(stdout, "--render-only") { + t.Errorf("Expected '--render-only' in send help, got: %s", stdout) + } t.Logf("email send help output:\n%s", stdout) } @@ -202,10 +211,7 @@ func TestCLI_EmailSend_Scheduled(t *testing.T) { } skipIfMissingCreds(t) - email := testEmail - if email == "" { - email = "test@example.com" - } + email := getSendTargetEmail(t) // Schedule for 1 hour from now using duration format stdout, stderr, err := runCLI("email", "send", @@ -227,6 +233,281 @@ func TestCLI_EmailSend_Scheduled(t *testing.T) { t.Logf("email send scheduled output:\n%s", stdout) } +func TestCLI_EmailSend_RenderOnlyHostedTemplate(t *testing.T) { + skipIfMissingCreds(t) + + createStdout, createStderr, createErr := runCLIWithRateLimit(t, + "template", "create", + "--name", "Send Preview Integration Template", + "--subject", "Hosted Hello {{user.name}}", + "--body", "

Hello {{user.name}}

", + "--engine", "mustache", + "--json", + ) + if createErr != nil { + t.Fatalf("template create failed: %v\nstderr: %s", createErr, createStderr) + } + + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(createStdout)), &created); err != nil { + t.Fatalf("failed to parse template create output: %v\noutput: %s", err, createStdout) + } + if created.ID == "" { + t.Fatalf("template create did not return an id: %s", createStdout) + } + + t.Cleanup(func() { + if created.ID != "" { + _, _, _ = runCLI("template", "delete", created.ID, "--yes") + } + }) + + isolatedHome := t.TempDir() + stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, map[string]string{ + "NYLAS_GRANT_ID": "", + "XDG_CONFIG_HOME": isolatedHome, + "HOME": isolatedHome, + "NYLAS_DISABLE_KEYRING": "true", + }, + "email", "send", + "--template-id", created.ID, + "--template-data", `{"user":{"name":"Integration"}}`, + "--render-only", + ) + if err != nil { + t.Fatalf("email send --render-only failed: %v\nstderr: %s", err, stderr) + } + + if !strings.Contains(stdout, "Hosted Hello Integration") { + t.Fatalf("expected rendered subject in preview, got: %s", stdout) + } + if !strings.Contains(stdout, "

Hello Integration

") { + t.Fatalf("expected rendered body in preview, got: %s", stdout) + } +} + +func TestCLI_EmailSend_HostedTemplate(t *testing.T) { + if os.Getenv("NYLAS_TEST_SEND_EMAIL") != "true" { + t.Skip("Skipping send test - set NYLAS_TEST_SEND_EMAIL=true to enable") + } + skipIfMissingCreds(t) + + email := getSendTargetEmail(t) + + createStdout, createStderr, createErr := runCLIWithRateLimit(t, + "template", "create", + "--name", "Send Integration Template", + "--subject", "Hosted Send {{user.name}}", + "--body", "

Hello {{user.name}}, this message was sent from a hosted template.

", + "--engine", "mustache", + "--json", + ) + if createErr != nil { + t.Fatalf("template create failed: %v\nstderr: %s", createErr, createStderr) + } + + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(createStdout)), &created); err != nil { + t.Fatalf("failed to parse template create output: %v\noutput: %s", err, createStdout) + } + if created.ID == "" { + t.Fatalf("template create did not return an id: %s", createStdout) + } + + t.Cleanup(func() { + if created.ID != "" { + _, _, _ = runCLI("template", "delete", created.ID, "--yes") + } + }) + + stdout, stderr, err := runCLIWithRateLimit(t, + "email", "send", + "--to", email, + "--template-id", created.ID, + "--template-data", `{"user":{"name":"Integration"}}`, + "--yes", + testGrantID, + ) + if err != nil { + t.Fatalf("email send with hosted template failed: %v\nstderr: %s", err, stderr) + } + + if !strings.Contains(stdout, "sent") && !strings.Contains(stdout, "Message") && !strings.Contains(stdout, "✓") { + t.Fatalf("expected send confirmation in output, got: %s", stdout) + } +} + +func TestCLI_EmailSend_HostedTemplate_SelfRoundTrip(t *testing.T) { + if os.Getenv("NYLAS_TEST_SEND_EMAIL") != "true" { + t.Skip("Skipping self-send round-trip test - set NYLAS_TEST_SEND_EMAIL=true to enable") + } + skipIfMissingCreds(t) + + selfEmail := getGrantEmail(t) + token := fmt.Sprintf("self-check-%d", time.Now().UnixNano()) + renderedAt := time.Now().Format(time.RFC3339) + + createStdout, createStderr, createErr := runCLIWithRateLimit(t, + "template", "create", + "--name", "Hosted Self Round Trip "+token, + "--subject", "[CLI Self Check] {{token}} for {{name}}", + "--body", "

Hello {{name}},

This is a hosted-template self-check.

Token: {{token}}

Rendered at: {{ts}}

", + "--engine", "mustache", + "--json", + ) + if createErr != nil { + t.Fatalf("template create failed: %v\nstderr: %s", createErr, createStderr) + } + + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(createStdout)), &created); err != nil { + t.Fatalf("failed to parse template create output: %v\noutput: %s", err, createStdout) + } + if created.ID == "" { + t.Fatalf("template create did not return an id: %s", createStdout) + } + + t.Cleanup(func() { + if created.ID != "" { + _, _, _ = runCLI("template", "delete", created.ID, "--yes") + } + }) + + sendStdout, sendStderr, sendErr := runCLIWithRateLimit(t, + "email", "send", + "--to", selfEmail, + "--template-id", created.ID, + "--template-data", fmt.Sprintf(`{"name":"Qasim","token":"%s","ts":"%s"}`, token, renderedAt), + "--yes", + testGrantID, + ) + if sendErr != nil { + t.Fatalf("email send with hosted template failed: %v\nstderr: %s", sendErr, sendStderr) + } + + messageID := extractMessageID(sendStdout) + if messageID == "" { + t.Fatalf("failed to extract message ID from send output: %s", sendStdout) + } + + t.Cleanup(func() { + _, _, _ = runCLI("email", "delete", messageID, "--yes", testGrantID) + }) + + var delivered struct { + ID string `json:"id"` + Subject string `json:"subject"` + Body string `json:"body"` + Snippet string `json:"snippet"` + } + + var lastStdout string + var lastStderr string + var found bool + + for attempt := 1; attempt <= 10; attempt++ { + readStdout, readStderr, readErr := runCLIWithRateLimit(t, "email", "read", messageID, testGrantID, "--json") + lastStdout = readStdout + lastStderr = readStderr + + if readErr == nil { + if err := json.Unmarshal([]byte(strings.TrimSpace(readStdout)), &delivered); err != nil { + t.Fatalf("failed to parse email read output: %v\noutput: %s", err, readStdout) + } + + if strings.Contains(delivered.Subject, token) && + strings.Contains(delivered.Body, token) && + strings.Contains(delivered.Body, "hosted-template self-check") { + found = true + break + } + } + + time.Sleep(3 * time.Second) + } + + if !found { + t.Fatalf("self-send round trip did not verify rendered content\nlast stdout: %s\nlast stderr: %s", lastStdout, lastStderr) + } + + if delivered.ID != messageID { + t.Fatalf("read message ID = %q, want %q", delivered.ID, messageID) + } +} + +func TestCLI_EmailSend_RenderOnlyHostedTemplateGrantScopedFlags(t *testing.T) { + skipIfMissingCreds(t) + grantIdentifier := getGrantEmail(t) + envOverrides := newSeededGrantStoreEnv(t, domain.GrantInfo{ID: testGrantID, Email: grantIdentifier}) + + createStdout, createStderr, createErr := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, envOverrides, + "template", "create", + "--scope", "grant", + "--grant-id", grantIdentifier, + "--name", "Grant Hosted Send Preview Template", + "--subject", "Grant scoped preview", + "--body", "

Hello {{user.name}}

", + "--engine", "mustache", + "--json", + ) + if createErr != nil { + t.Fatalf("grant-scoped template create failed: %v\nstderr: %s", createErr, createStderr) + } + + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(createStdout)), &created); err != nil { + t.Fatalf("failed to parse template create output: %v\noutput: %s", err, createStdout) + } + if created.ID == "" { + t.Fatalf("template create did not return an id: %s", createStdout) + } + + t.Cleanup(func() { + if created.ID != "" { + _, _, _ = runCLIWithOverrides(2*time.Minute, envOverrides, + "template", "delete", created.ID, + "--scope", "grant", + "--grant-id", grantIdentifier, + "--yes", + ) + } + }) + + tempDir := t.TempDir() + dataPath := filepath.Join(tempDir, "send-template-data.json") + if err := os.WriteFile(dataPath, []byte(`{"user":{"name":"Grant Preview"}}`), 0o600); err != nil { + t.Fatalf("failed to write template data file: %v", err) + } + + stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, envOverrides, + "email", "send", + "--template-id", created.ID, + "--template-scope", "grant", + "--template-grant-id", grantIdentifier, + "--template-data-file", dataPath, + "--template-strict=false", + "--render-only", + ) + if err != nil { + t.Fatalf("grant-scoped email send --render-only failed: %v\nstderr: %s", err, stderr) + } + + if !strings.Contains(stdout, "Grant scoped preview") { + t.Fatalf("expected rendered subject in preview, got: %s", stdout) + } + if !strings.Contains(stdout, "

Hello Grant Preview

") { + t.Fatalf("expected rendered body in preview, got: %s", stdout) + } +} + // ============================================================================= // ADVANCED SEARCH COMMAND TESTS (Phase 3) // ============================================================================= diff --git a/internal/cli/integration/templates_workflows_test.go b/internal/cli/integration/templates_workflows_test.go new file mode 100644 index 0000000..b10f28b --- /dev/null +++ b/internal/cli/integration/templates_workflows_test.go @@ -0,0 +1,526 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/nylas/cli/internal/domain" +) + +func TestCLI_TemplateHelp(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + stdout, stderr, err := runCLI("template", "--help") + if err != nil { + t.Fatalf("template --help failed: %v\nstderr: %s", err, stderr) + } + + for _, expected := range []string{"list", "show", "create", "update", "delete", "render", "render-html"} { + if !strings.Contains(stdout, expected) { + t.Fatalf("expected help output to contain %q, got: %s", expected, stdout) + } + } +} + +func TestCLI_WorkflowHelp(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + stdout, stderr, err := runCLI("workflow", "--help") + if err != nil { + t.Fatalf("workflow --help failed: %v\nstderr: %s", err, stderr) + } + + for _, expected := range []string{"list", "show", "create", "update", "delete"} { + if !strings.Contains(stdout, expected) { + t.Fatalf("expected help output to contain %q, got: %s", expected, stdout) + } + } +} + +func TestCLI_TemplateCRUD(t *testing.T) { + skipIfMissingCreds(t) + + createStdout, createStderr, createErr := runCLIWithRateLimit(t, + "template", "create", + "--name", "Integration Template", + "--subject", "Hello {{user.name}}", + "--body", "

Hello {{user.name}}

", + "--engine", "mustache", + "--json", + ) + if createErr != nil { + t.Fatalf("template create failed: %v\nstderr: %s", createErr, createStderr) + } + + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(createStdout)), &created); err != nil { + t.Fatalf("failed to parse template create output: %v\noutput: %s", err, createStdout) + } + if created.ID == "" { + t.Fatalf("template create did not return an id: %s", createStdout) + } + + t.Cleanup(func() { + if created.ID == "" { + return + } + _, _, _ = runCLI("template", "delete", created.ID, "--yes") + }) + + showStdout, showStderr, showErr := runCLIWithRateLimit(t, "template", "show", created.ID, "--json") + if showErr != nil { + t.Fatalf("template show failed: %v\nstderr: %s", showErr, showStderr) + } + if !strings.Contains(showStdout, created.ID) { + t.Fatalf("expected template show output to contain %s, got: %s", created.ID, showStdout) + } + + updateStdout, updateStderr, updateErr := runCLIWithRateLimit(t, + "template", "update", created.ID, + "--name", "Integration Template Updated", + "--json", + ) + if updateErr != nil { + t.Fatalf("template update failed: %v\nstderr: %s", updateErr, updateStderr) + } + if !strings.Contains(updateStdout, "Integration Template Updated") { + t.Fatalf("expected updated template name in output, got: %s", updateStdout) + } + + renderStdout, renderStderr, renderErr := runCLIWithRateLimit(t, + "template", "render", created.ID, + "--data", `{"user":{"name":"Integration"}}`, + "--json", + ) + if renderErr != nil { + t.Fatalf("template render failed: %v\nstderr: %s", renderErr, renderStderr) + } + if !strings.Contains(renderStdout, "Integration") { + t.Fatalf("expected rendered template to contain substituted value, got: %s", renderStdout) + } + + deleteStdout, deleteStderr, deleteErr := runCLIWithRateLimit(t, "template", "delete", created.ID, "--yes") + if deleteErr != nil { + t.Fatalf("template delete failed: %v\nstderr: %s", deleteErr, deleteStderr) + } + if !strings.Contains(deleteStdout, "Template deleted") { + t.Fatalf("expected delete confirmation, got: %s", deleteStdout) + } + created.ID = "" +} + +func TestCLI_TemplateListAndGrantScopedFileFlows(t *testing.T) { + skipIfMissingCreds(t) + grantIdentifier := getGrantEmail(t) + envOverrides := newSeededGrantStoreEnv(t, domain.GrantInfo{ID: testGrantID, Email: grantIdentifier}) + + tempDir := t.TempDir() + bodyPath := filepath.Join(tempDir, "template-body.html") + if err := os.WriteFile(bodyPath, []byte("

Hello {{user.name}}

\n"), 0o600); err != nil { + t.Fatalf("failed to write template body file: %v", err) + } + + createStdout, createStderr, createErr := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, envOverrides, + "template", "create", + "--scope", "grant", + "--grant-id", grantIdentifier, + "--name", "Grant File Integration Template", + "--subject", "Grant Hello {{user.name}}", + "--body-file", bodyPath, + "--engine", "mustache", + "--json", + ) + if createErr != nil { + t.Fatalf("grant-scoped template create failed: %v\nstderr: %s", createErr, createStderr) + } + + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(createStdout)), &created); err != nil { + t.Fatalf("failed to parse template create output: %v\noutput: %s", err, createStdout) + } + if created.ID == "" { + t.Fatalf("template create did not return an id: %s", createStdout) + } + + t.Cleanup(func() { + if created.ID != "" { + _, _, _ = runCLIWithOverrides(2*time.Minute, envOverrides, + "template", "delete", created.ID, + "--scope", "grant", + "--grant-id", grantIdentifier, + "--yes", + ) + } + }) + + listStdout, listStderr, listErr := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, envOverrides, + "template", "list", + "--scope", "grant", + "--grant-id", grantIdentifier, + "--limit", "100", + "--json", + ) + if listErr != nil { + t.Fatalf("grant-scoped template list failed: %v\nstderr: %s", listErr, listStderr) + } + if !strings.Contains(listStdout, created.ID) { + t.Fatalf("expected template list output to contain %s, got: %s", created.ID, listStdout) + } + + dataPath := filepath.Join(tempDir, "template-data.json") + if err := os.WriteFile(dataPath, []byte(`{"user":{"name":"Grant Integration"}}`), 0o600); err != nil { + t.Fatalf("failed to write template data file: %v", err) + } + + renderStdout, renderStderr, renderErr := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, envOverrides, + "template", "render", created.ID, + "--scope", "grant", + "--grant-id", grantIdentifier, + "--data-file", dataPath, + "--json", + ) + if renderErr != nil { + t.Fatalf("grant-scoped template render failed: %v\nstderr: %s", renderErr, renderStderr) + } + if !strings.Contains(renderStdout, "Grant Hello Grant Integration") { + t.Fatalf("expected rendered subject in output, got: %s", renderStdout) + } + var renderResult struct { + Body string `json:"body"` + Subject string `json:"subject"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(renderStdout)), &renderResult); err != nil { + t.Fatalf("failed to parse render output: %v\noutput: %s", err, renderStdout) + } + if renderResult.Body != "

Hello Grant Integration

" { + t.Fatalf("rendered body = %q, want %q", renderResult.Body, "

Hello Grant Integration

") + } + + renderHTMLPath := filepath.Join(tempDir, "render-html-body.html") + if err := os.WriteFile(renderHTMLPath, []byte("
{{user.name}} via render-html
\n"), 0o600); err != nil { + t.Fatalf("failed to write render-html body file: %v", err) + } + + renderHTMLStdout, renderHTMLStderr, renderHTMLErr := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, envOverrides, + "template", "render-html", + "--scope", "grant", + "--grant-id", grantIdentifier, + "--body-file", renderHTMLPath, + "--engine", "mustache", + "--data-file", dataPath, + "--strict=false", + "--json", + ) + if renderHTMLErr != nil { + t.Fatalf("grant-scoped template render-html failed: %v\nstderr: %s", renderHTMLErr, renderHTMLStderr) + } + if !strings.Contains(renderHTMLStdout, "Grant Integration via render-html") { + t.Fatalf("expected rendered HTML output, got: %s", renderHTMLStdout) + } + + updateBodyPath := filepath.Join(tempDir, "template-body-updated.html") + if err := os.WriteFile(updateBodyPath, []byte("

Updated {{user.name}}

\n"), 0o600); err != nil { + t.Fatalf("failed to write updated template body file: %v", err) + } + + updateStdout, updateStderr, updateErr := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, envOverrides, + "template", "update", created.ID, + "--scope", "grant", + "--grant-id", grantIdentifier, + "--name", "Grant File Integration Template Updated", + "--body-file", updateBodyPath, + "--json", + ) + if updateErr != nil { + t.Fatalf("grant-scoped template update failed: %v\nstderr: %s", updateErr, updateStderr) + } + if !strings.Contains(updateStdout, "Grant File Integration Template Updated") { + t.Fatalf("expected updated template name in output, got: %s", updateStdout) + } + + showStdout, showStderr, showErr := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, envOverrides, + "template", "show", created.ID, + "--scope", "grant", + "--grant-id", grantIdentifier, + "--json", + ) + if showErr != nil { + t.Fatalf("grant-scoped template show failed: %v\nstderr: %s", showErr, showStderr) + } + if !strings.Contains(showStdout, "Grant File Integration Template Updated") { + t.Fatalf("expected updated template in show output, got: %s", showStdout) + } + + deleteStdout, deleteStderr, deleteErr := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, envOverrides, + "template", "delete", created.ID, + "--scope", "grant", + "--grant-id", grantIdentifier, + "--yes", + ) + if deleteErr != nil { + t.Fatalf("grant-scoped template delete failed: %v\nstderr: %s", deleteErr, deleteStderr) + } + if !strings.Contains(deleteStdout, "Template deleted") { + t.Fatalf("expected delete confirmation, got: %s", deleteStdout) + } + created.ID = "" +} + +func TestCLI_WorkflowCRUD(t *testing.T) { + skipIfMissingCreds(t) + + templateStdout, templateStderr, templateErr := runCLIWithRateLimit(t, + "template", "create", + "--name", "Workflow Integration Template", + "--subject", "Booking {{user.name}}", + "--body", "

Booking {{user.name}}

", + "--engine", "mustache", + "--json", + ) + if templateErr != nil { + t.Fatalf("template create for workflow failed: %v\nstderr: %s", templateErr, templateStderr) + } + + var template struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(templateStdout)), &template); err != nil { + t.Fatalf("failed to parse template create output: %v\noutput: %s", err, templateStdout) + } + if template.ID == "" { + t.Fatalf("expected template id, got: %s", templateStdout) + } + + t.Cleanup(func() { + if template.ID != "" { + _, _, _ = runCLI("template", "delete", template.ID, "--yes") + } + }) + + createStdout, createStderr, createErr := runCLIWithRateLimit(t, + "workflow", "create", + "--name", "Integration Workflow", + "--template-id", template.ID, + "--trigger-event", "booking.created", + "--delay", "1", + "--enabled", + "--json", + ) + if createErr != nil { + t.Fatalf("workflow create failed: %v\nstderr: %s", createErr, createStderr) + } + + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(createStdout)), &created); err != nil { + t.Fatalf("failed to parse workflow create output: %v\noutput: %s", err, createStdout) + } + if created.ID == "" { + t.Fatalf("workflow create did not return an id: %s", createStdout) + } + + t.Cleanup(func() { + if created.ID != "" { + _, _, _ = runCLI("workflow", "delete", created.ID, "--yes") + } + }) + + showStdout, showStderr, showErr := runCLIWithRateLimit(t, "workflow", "show", created.ID, "--json") + if showErr != nil { + t.Fatalf("workflow show failed: %v\nstderr: %s", showErr, showStderr) + } + if !strings.Contains(showStdout, created.ID) { + t.Fatalf("expected workflow show output to contain %s, got: %s", created.ID, showStdout) + } + + updateStdout, updateStderr, updateErr := runCLIWithRateLimit(t, + "workflow", "update", created.ID, + "--name", "Integration Workflow Updated", + "--disabled", + "--json", + ) + if updateErr != nil { + t.Fatalf("workflow update failed: %v\nstderr: %s", updateErr, updateStderr) + } + if !strings.Contains(updateStdout, "Integration Workflow Updated") { + t.Fatalf("expected updated workflow name in output, got: %s", updateStdout) + } + + deleteStdout, deleteStderr, deleteErr := runCLIWithRateLimit(t, "workflow", "delete", created.ID, "--yes") + if deleteErr != nil { + t.Fatalf("workflow delete failed: %v\nstderr: %s", deleteErr, deleteStderr) + } + if !strings.Contains(deleteStdout, "Workflow deleted") { + t.Fatalf("expected delete confirmation, got: %s", deleteStdout) + } + created.ID = "" +} + +func TestCLI_WorkflowGrantScopeFileAndListFlows(t *testing.T) { + skipIfMissingCreds(t) + grantIdentifier := getGrantEmail(t) + envOverrides := newSeededGrantStoreEnv(t, domain.GrantInfo{ID: testGrantID, Email: grantIdentifier}) + + templateStdout, templateStderr, templateErr := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, envOverrides, + "template", "create", + "--scope", "grant", + "--grant-id", grantIdentifier, + "--name", "Grant Workflow Template", + "--subject", "Grant Workflow {{user.name}}", + "--body", "

Grant Workflow {{user.name}}

", + "--engine", "mustache", + "--json", + ) + if templateErr != nil { + t.Fatalf("template create for workflow file test failed: %v\nstderr: %s", templateErr, templateStderr) + } + + var template struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(templateStdout)), &template); err != nil { + t.Fatalf("failed to parse template create output: %v\noutput: %s", err, templateStdout) + } + if template.ID == "" { + t.Fatalf("expected template id, got: %s", templateStdout) + } + + t.Cleanup(func() { + if template.ID != "" { + _, _, _ = runCLIWithOverrides(2*time.Minute, envOverrides, + "template", "delete", template.ID, + "--scope", "grant", + "--grant-id", grantIdentifier, + "--yes", + ) + } + }) + + tempDir := t.TempDir() + createPath := filepath.Join(tempDir, "workflow-create.json") + createPayload := []byte(`{ + "name": "Grant Workflow File Create", + "template_id": "` + template.ID + `", + "trigger_event": "booking.created", + "delay": 2, + "is_enabled": true +}`) + if err := os.WriteFile(createPath, createPayload, 0o600); err != nil { + t.Fatalf("failed to write workflow create file: %v", err) + } + + createStdout, createStderr, createErr := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, envOverrides, + "workflow", "create", + "--scope", "grant", + "--grant-id", grantIdentifier, + "--file", createPath, + "--json", + ) + if createErr != nil { + t.Fatalf("grant-scoped workflow create failed: %v\nstderr: %s", createErr, createStderr) + } + + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(createStdout)), &created); err != nil { + t.Fatalf("failed to parse workflow create output: %v\noutput: %s", err, createStdout) + } + if created.ID == "" { + t.Fatalf("workflow create did not return an id: %s", createStdout) + } + + t.Cleanup(func() { + if created.ID != "" { + _, _, _ = runCLIWithOverrides(2*time.Minute, envOverrides, + "workflow", "delete", created.ID, + "--scope", "grant", + "--grant-id", grantIdentifier, + "--yes", + ) + } + }) + + listStdout, listStderr, listErr := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, envOverrides, + "workflow", "list", + "--scope", "grant", + "--grant-id", grantIdentifier, + "--limit", "100", + "--json", + ) + if listErr != nil { + t.Fatalf("grant-scoped workflow list failed: %v\nstderr: %s", listErr, listStderr) + } + if !strings.Contains(listStdout, created.ID) { + t.Fatalf("expected workflow list output to contain %s, got: %s", created.ID, listStdout) + } + + updatePath := filepath.Join(tempDir, "workflow-update.json") + updatePayload := []byte(`{ + "name": "Grant Workflow File Updated", + "delay": 5, + "is_enabled": false +}`) + if err := os.WriteFile(updatePath, updatePayload, 0o600); err != nil { + t.Fatalf("failed to write workflow update file: %v", err) + } + + updateStdout, updateStderr, updateErr := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, envOverrides, + "workflow", "update", created.ID, + "--scope", "grant", + "--grant-id", grantIdentifier, + "--file", updatePath, + "--json", + ) + if updateErr != nil { + t.Fatalf("grant-scoped workflow update failed: %v\nstderr: %s", updateErr, updateStderr) + } + if !strings.Contains(updateStdout, "Grant Workflow File Updated") { + t.Fatalf("expected updated workflow name in output, got: %s", updateStdout) + } + + showStdout, showStderr, showErr := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, envOverrides, + "workflow", "show", created.ID, + "--scope", "grant", + "--grant-id", grantIdentifier, + "--json", + ) + if showErr != nil { + t.Fatalf("grant-scoped workflow show failed: %v\nstderr: %s", showErr, showStderr) + } + if !strings.Contains(showStdout, "Grant Workflow File Updated") { + t.Fatalf("expected updated workflow in show output, got: %s", showStdout) + } + if !strings.Contains(showStdout, `"is_enabled": false`) { + t.Fatalf("expected workflow show output to reflect disabled state, got: %s", showStdout) + } + + deleteStdout, deleteStderr, deleteErr := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, envOverrides, + "workflow", "delete", created.ID, + "--scope", "grant", + "--grant-id", grantIdentifier, + "--yes", + ) + if deleteErr != nil { + t.Fatalf("grant-scoped workflow delete failed: %v\nstderr: %s", deleteErr, deleteStderr) + } + if !strings.Contains(deleteStdout, "Workflow deleted") { + t.Fatalf("expected delete confirmation, got: %s", deleteStdout) + } + created.ID = "" +} diff --git a/internal/cli/integration/test.go b/internal/cli/integration/test.go index 75fd329..5a28a4e 100644 --- a/internal/cli/integration/test.go +++ b/internal/cli/integration/test.go @@ -66,6 +66,7 @@ import ( "testing" "time" + "github.com/nylas/cli/internal/adapters/keyring" "github.com/nylas/cli/internal/adapters/nylas" "github.com/nylas/cli/internal/domain" "golang.org/x/time/rate" @@ -229,6 +230,12 @@ func runCLIWithOverrides(timeout time.Duration, envOverrides map[string]string, return stdout.String(), stderr.String(), err } +func runCLIWithOverridesAndRateLimit(t *testing.T, timeout time.Duration, envOverrides map[string]string, args ...string) (string, string, error) { + t.Helper() + acquireRateLimit(t) + return runCLIWithOverrides(timeout, envOverrides, args...) +} + // runCLIWithRateLimit executes a CLI command with rate limiting. // Use this for commands that make API calls when running tests with t.Parallel(). // For offline commands (timezone, ai config, help), use runCLI directly. @@ -384,6 +391,78 @@ func getTestEmail() string { return getEnvOrDefault("NYLAS_TEST_EMAIL", "") } +// getGrantEmail resolves the mailbox email for the active integration grant. +func getGrantEmail(t *testing.T) string { + t.Helper() + skipIfMissingCreds(t) + acquireRateLimit(t) + + client := getTestClient() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + grant, err := client.GetGrant(ctx, testGrantID) + if err != nil { + t.Fatalf("failed to resolve grant email: %v", err) + return "" + } + if grant == nil || strings.TrimSpace(grant.Email) == "" { + t.Fatal("active grant does not expose an email address") + return "" + } + + return strings.TrimSpace(grant.Email) +} + +// getSendTargetEmail returns the configured test email or falls back to the grant mailbox. +func getSendTargetEmail(t *testing.T) string { + t.Helper() + + if email := strings.TrimSpace(getTestEmail()); email != "" { + return email + } + + return getGrantEmail(t) +} + +func newSeededGrantStoreEnv(t *testing.T, grant domain.GrantInfo) map[string]string { + t.Helper() + + configHome := t.TempDir() + configDir := filepath.Join(configHome, "nylas") + passphrase := "integration-test-file-store-passphrase" + + originalPassphrase, hadOriginalPassphrase := os.LookupEnv("NYLAS_FILE_STORE_PASSPHRASE") + if err := os.Setenv("NYLAS_FILE_STORE_PASSPHRASE", passphrase); err != nil { + t.Fatalf("failed to set seeded grant-store passphrase: %v", err) + } + defer func() { + if hadOriginalPassphrase { + _ = os.Setenv("NYLAS_FILE_STORE_PASSPHRASE", originalPassphrase) + return + } + _ = os.Unsetenv("NYLAS_FILE_STORE_PASSPHRASE") + }() + + store, err := keyring.NewEncryptedFileStore(configDir) + if err != nil { + t.Fatalf("failed to create seeded grant store: %v", err) + } + + grantStore := keyring.NewGrantStore(store) + if err := grantStore.SaveGrant(grant); err != nil { + t.Fatalf("failed to seed grant store: %v", err) + } + + return map[string]string{ + "NYLAS_GRANT_ID": "", + "NYLAS_DISABLE_KEYRING": "true", + "NYLAS_FILE_STORE_PASSPHRASE": passphrase, + "XDG_CONFIG_HOME": configHome, + "HOME": configHome, + } +} + // extractEventID extracts event ID from CLI output func extractEventID(output string) string { // Look for event ID patterns in output diff --git a/internal/cli/mcp/assistants_test.go b/internal/cli/mcp/assistants_test.go index 36a6f7c..7de7e9d 100644 --- a/internal/cli/mcp/assistants_test.go +++ b/internal/cli/mcp/assistants_test.go @@ -33,6 +33,7 @@ func TestGetAssistantByID(t *testing.T) { } if a == nil { t.Fatal("expected assistant, got nil") + return } if a.Name != tt.wantName { t.Errorf("expected name %s, got %s", tt.wantName, a.Name) @@ -89,6 +90,7 @@ func TestAssistant_IsProjectConfig(t *testing.T) { a := GetAssistantByID(tt.id) if a == nil { t.Fatalf("assistant %s not found", tt.id) + return } if a.IsProjectConfig() != tt.isProj { t.Errorf("IsProjectConfig() = %v, want %v", a.IsProjectConfig(), tt.isProj) diff --git a/internal/cli/templatecmd/create_update_delete.go b/internal/cli/templatecmd/create_update_delete.go new file mode 100644 index 0000000..eac951a --- /dev/null +++ b/internal/cli/templatecmd/create_update_delete.go @@ -0,0 +1,190 @@ +package templatecmd + +import ( + "context" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newCreateCmd() *cobra.Command { + var opts scopeOptions + var name string + var subject string + var body string + var bodyFile string + var engine string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a hosted template", + RunE: func(cmd *cobra.Command, _ []string) error { + scope, grantID, err := resolveScope(opts) + if err != nil { + return err + } + if err := common.ValidateRequiredFlag("--name", name); err != nil { + return err + } + if err := common.ValidateRequiredFlag("--subject", subject); err != nil { + return err + } + body, err = common.ReadStringOrFile("body", body, bodyFile, true) + if err != nil { + return err + } + if engine != "" { + if err := validateEngine(engine); err != nil { + return err + } + } + + ctx, cancel := common.CreateContext() + defer cancel() + + req := &domain.CreateRemoteTemplateRequest{ + Name: name, + Subject: subject, + Body: trimBody(body), + Engine: engine, + } + + return withClient(ctx, func(ctx context.Context, client ports.NylasClient) error { + template, err := client.CreateRemoteTemplate(ctx, scope, grantID, req) + if err != nil { + return common.WrapCreateError("template", err) + } + if common.IsStructuredOutput(cmd) { + return common.GetOutputWriter(cmd).Write(template) + } + printTemplate(template) + return nil + }) + }, + } + + addScopeFlags(cmd, &opts) + cmd.Flags().StringVar(&name, "name", "", "Template name") + cmd.Flags().StringVar(&subject, "subject", "", "Template subject") + cmd.Flags().StringVar(&body, "body", "", "Template body HTML") + cmd.Flags().StringVar(&bodyFile, "body-file", "", "Read template body HTML from a file") + cmd.Flags().StringVar(&engine, "engine", "", "Template engine") + + return cmd +} + +func newUpdateCmd() *cobra.Command { + var opts scopeOptions + var name string + var subject string + var body string + var bodyFile string + var engine string + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a hosted template", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + scope, grantID, err := resolveScope(opts) + if err != nil { + return err + } + body, err = common.ReadStringOrFile("body", body, bodyFile, false) + if err != nil { + return err + } + if err := common.ValidateAtLeastOne("template field", name, subject, body, engine); err != nil { + return err + } + if engine != "" { + if err := validateEngine(engine); err != nil { + return err + } + } + + req := &domain.UpdateRemoteTemplateRequest{} + if name != "" { + req.Name = &name + } + if subject != "" { + req.Subject = &subject + } + if body != "" { + body = trimBody(body) + req.Body = &body + } + if engine != "" { + req.Engine = &engine + } + + ctx, cancel := common.CreateContext() + defer cancel() + + return withClient(ctx, func(ctx context.Context, client ports.NylasClient) error { + template, err := client.UpdateRemoteTemplate(ctx, scope, grantID, args[0], req) + if err != nil { + return common.WrapUpdateError("template", err) + } + if common.IsStructuredOutput(cmd) { + return common.GetOutputWriter(cmd).Write(template) + } + printTemplate(template) + return nil + }) + }, + } + + addScopeFlags(cmd, &opts) + cmd.Flags().StringVar(&name, "name", "", "Updated template name") + cmd.Flags().StringVar(&subject, "subject", "", "Updated template subject") + cmd.Flags().StringVar(&body, "body", "", "Updated template body HTML") + cmd.Flags().StringVar(&bodyFile, "body-file", "", "Read updated template body HTML from a file") + cmd.Flags().StringVar(&engine, "engine", "", "Updated template engine") + + return cmd +} + +func newDeleteCmd() *cobra.Command { + var opts scopeOptions + var yes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a hosted template", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + scope, grantID, err := resolveScope(opts) + if err != nil { + return err + } + + if !yes { + return common.NewUserError( + "deletion requires confirmation", + "Re-run with --yes to delete the template", + ) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + return withClient(ctx, func(ctx context.Context, client ports.NylasClient) error { + if err := client.DeleteRemoteTemplate(ctx, scope, grantID, args[0]); err != nil { + return common.WrapDeleteError("template", err) + } + if !common.IsStructuredOutput(cmd) { + common.PrintSuccess("Template deleted") + } + return nil + }) + }, + } + + addScopeFlags(cmd, &opts) + common.AddYesFlag(cmd, &yes) + + return cmd +} diff --git a/internal/cli/templatecmd/helpers.go b/internal/cli/templatecmd/helpers.go new file mode 100644 index 0000000..77e5009 --- /dev/null +++ b/internal/cli/templatecmd/helpers.go @@ -0,0 +1,115 @@ +package templatecmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +var templateColumns = []ports.Column{ + {Header: "ID", Field: "ID", Width: -1}, + {Header: "Name", Field: "Name", Width: 28}, + {Header: "Engine", Field: "Engine", Width: 12}, + {Header: "Subject", Field: "Subject", Width: 48}, + {Header: "Updated", Field: "UpdatedAt", Width: 18}, +} + +func addScopeFlags(cmd *cobra.Command, opts *scopeOptions) { + cmd.Flags().StringVar(&opts.scope, "scope", string(domain.ScopeApplication), "Template scope: app or grant") + cmd.Flags().StringVar(&opts.grantID, "grant-id", "", "Grant ID or email for grant-scoped templates") +} + +func resolveScope(opts scopeOptions) (domain.RemoteScope, string, error) { + scope, err := domain.ParseRemoteScope(opts.scope) + if err != nil { + return "", "", common.NewUserError("invalid --scope value", "Use --scope app or --scope grant") + } + + grantID, err := common.ResolveScopeGrantID(scope, opts.grantID) + if err != nil { + return "", "", err + } + + return scope, grantID, nil +} + +func withClient( + ctx context.Context, + fn func(context.Context, ports.NylasClient) error, +) error { + client, err := common.GetNylasClient() + if err != nil { + return err + } + return fn(ctx, client) +} + +func printTemplate(template *domain.RemoteTemplate) { + fmt.Printf("ID: %s\n", template.ID) + fmt.Printf("Name: %s\n", template.Name) + fmt.Printf("Engine: %s\n", template.Engine) + fmt.Printf("Subject: %s\n", template.Subject) + if template.GrantID != "" { + fmt.Printf("Grant ID: %s\n", template.GrantID) + } + if template.AppID != nil && *template.AppID != "" { + fmt.Printf("App ID: %s\n", *template.AppID) + } + if !template.CreatedAt.IsZero() { + fmt.Printf("Created: %s\n", template.CreatedAt.Format("2006-01-02 15:04:05")) + } + if !template.UpdatedAt.IsZero() { + fmt.Printf("Updated: %s\n", template.UpdatedAt.Format("2006-01-02 15:04:05")) + } + if template.Object != "" { + fmt.Printf("Object: %s\n", template.Object) + } + fmt.Printf("\nBody:\n%s\n", template.Body) +} + +func printRenderResult(result domain.TemplateRenderResult) error { + if len(result) == 0 { + fmt.Println("Render completed with an empty response.") + return nil + } + + if subject, ok := result["subject"].(string); ok && subject != "" { + fmt.Printf("Subject:\n%s\n\n", subject) + } + if body, ok := result["body"].(string); ok && body != "" { + fmt.Printf("Body:\n%s\n", body) + return nil + } + if html, ok := result["html"].(string); ok && html != "" { + fmt.Printf("%s\n", html) + return nil + } + + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +func validateEngine(engine string) error { + return common.ValidateOneOf("engine", engine, domain.TemplateEngines()) +} + +func nextCursorNote(cmd *cobra.Command, nextCursor string) { + if nextCursor == "" || common.IsStructuredOutput(cmd) { + return + } + fmt.Printf("\nNext page token: %s\n", nextCursor) +} + +func trimBody(body string) string { + return strings.TrimRight(body, "\n") +} diff --git a/internal/cli/templatecmd/list.go b/internal/cli/templatecmd/list.go new file mode 100644 index 0000000..f2a9666 --- /dev/null +++ b/internal/cli/templatecmd/list.go @@ -0,0 +1,57 @@ +package templatecmd + +import ( + "context" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newListCmd() *cobra.Command { + var opts scopeOptions + var params domain.CursorListParams + + cmd := &cobra.Command{ + Use: "list", + Short: "List hosted templates", + RunE: func(cmd *cobra.Command, _ []string) error { + scope, grantID, err := resolveScope(opts) + if err != nil { + return err + } + + ctx, cancel := common.CreateContext() + defer cancel() + + return withClient(ctx, func(ctx context.Context, client ports.NylasClient) error { + resp, err := client.ListRemoteTemplates(ctx, scope, grantID, ¶ms) + if err != nil { + return common.WrapListError("templates", err) + } + + out := common.GetOutputWriter(cmd) + quiet, _ := cmd.Flags().GetBool("quiet") + format, _ := cmd.Flags().GetString("format") + if quiet || format == "quiet" { + return out.WriteList(resp.Data, templateColumns) + } + if common.IsStructuredOutput(cmd) { + return out.Write(resp) + } + if err := out.WriteList(resp.Data, templateColumns); err != nil { + return err + } + nextCursorNote(cmd, resp.NextCursor) + return nil + }) + }, + } + + addScopeFlags(cmd, &opts) + common.AddLimitFlag(cmd, ¶ms.Limit, 50) + common.AddPageTokenFlag(cmd, ¶ms.PageToken) + + return cmd +} diff --git a/internal/cli/templatecmd/render.go b/internal/cli/templatecmd/render.go new file mode 100644 index 0000000..b4eeec2 --- /dev/null +++ b/internal/cli/templatecmd/render.go @@ -0,0 +1,121 @@ +package templatecmd + +import ( + "context" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newRenderCmd() *cobra.Command { + var opts scopeOptions + var data string + var dataFile string + var strict bool + + cmd := &cobra.Command{ + Use: "render ", + Short: "Render a hosted template with variables", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + scope, grantID, err := resolveScope(opts) + if err != nil { + return err + } + variables, err := common.ReadJSONStringMap(data, dataFile) + if err != nil { + return err + } + + ctx, cancel := common.CreateContext() + defer cancel() + + return withClient(ctx, func(ctx context.Context, client ports.NylasClient) error { + result, err := client.RenderRemoteTemplate(ctx, scope, grantID, args[0], &domain.TemplateRenderRequest{ + Strict: &strict, + Variables: variables, + }) + if err != nil { + return common.WrapError(err) + } + if common.IsStructuredOutput(cmd) { + return common.GetOutputWriter(cmd).Write(result) + } + return printRenderResult(result) + }) + }, + } + + addScopeFlags(cmd, &opts) + cmd.Flags().StringVar(&data, "data", "", "Inline JSON object with template variables") + cmd.Flags().StringVar(&dataFile, "data-file", "", "Path to a JSON file with template variables") + cmd.Flags().BoolVar(&strict, "strict", true, "Fail when the template references missing variables") + + return cmd +} + +func newRenderHTMLCmd() *cobra.Command { + var opts scopeOptions + var body string + var bodyFile string + var data string + var dataFile string + var strict bool + var engine string + + cmd := &cobra.Command{ + Use: "render-html", + Short: "Render arbitrary template HTML with variables", + RunE: func(cmd *cobra.Command, _ []string) error { + scope, grantID, err := resolveScope(opts) + if err != nil { + return err + } + if err := common.ValidateRequiredFlag("--engine", engine); err != nil { + return err + } + if err := validateEngine(engine); err != nil { + return err + } + body, err = common.ReadStringOrFile("body", body, bodyFile, true) + if err != nil { + return err + } + variables, err := common.ReadJSONStringMap(data, dataFile) + if err != nil { + return err + } + + ctx, cancel := common.CreateContext() + defer cancel() + + return withClient(ctx, func(ctx context.Context, client ports.NylasClient) error { + result, err := client.RenderRemoteTemplateHTML(ctx, scope, grantID, &domain.TemplateRenderHTMLRequest{ + Body: trimBody(body), + Engine: engine, + Strict: &strict, + Variables: variables, + }) + if err != nil { + return common.WrapError(err) + } + if common.IsStructuredOutput(cmd) { + return common.GetOutputWriter(cmd).Write(result) + } + return printRenderResult(result) + }) + }, + } + + addScopeFlags(cmd, &opts) + cmd.Flags().StringVar(&body, "body", "", "Template HTML to render") + cmd.Flags().StringVar(&bodyFile, "body-file", "", "Path to a file containing template HTML") + cmd.Flags().StringVar(&engine, "engine", "", "Template engine") + cmd.Flags().StringVar(&data, "data", "", "Inline JSON object with template variables") + cmd.Flags().StringVar(&dataFile, "data-file", "", "Path to a JSON file with template variables") + cmd.Flags().BoolVar(&strict, "strict", true, "Fail when the template references missing variables") + + return cmd +} diff --git a/internal/cli/templatecmd/show.go b/internal/cli/templatecmd/show.go new file mode 100644 index 0000000..4356444 --- /dev/null +++ b/internal/cli/templatecmd/show.go @@ -0,0 +1,45 @@ +package templatecmd + +import ( + "context" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newShowCmd() *cobra.Command { + var opts scopeOptions + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show a hosted template", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + scope, grantID, err := resolveScope(opts) + if err != nil { + return err + } + + ctx, cancel := common.CreateContext() + defer cancel() + + return withClient(ctx, func(ctx context.Context, client ports.NylasClient) error { + template, err := client.GetRemoteTemplate(ctx, scope, grantID, args[0]) + if err != nil { + return common.WrapError(err) + } + + if common.IsStructuredOutput(cmd) { + return common.GetOutputWriter(cmd).Write(template) + } + + printTemplate(template) + return nil + }) + }, + } + + addScopeFlags(cmd, &opts) + return cmd +} diff --git a/internal/cli/templatecmd/template.go b/internal/cli/templatecmd/template.go new file mode 100644 index 0000000..5dfd03e --- /dev/null +++ b/internal/cli/templatecmd/template.go @@ -0,0 +1,34 @@ +package templatecmd + +import ( + "github.com/nylas/cli/internal/cli/common" + "github.com/spf13/cobra" +) + +type scopeOptions struct { + scope string + grantID string +} + +// NewTemplateCmd creates the hosted template command group. +func NewTemplateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "template", + Short: "Manage hosted templates", + Long: `Manage Nylas-hosted templates at the application or grant scope. + +Use --scope app for application-level templates and --scope grant to target +templates attached to a specific grant.`, + } + + common.AddOutputFlags(cmd) + cmd.AddCommand(newListCmd()) + cmd.AddCommand(newShowCmd()) + cmd.AddCommand(newCreateCmd()) + cmd.AddCommand(newUpdateCmd()) + cmd.AddCommand(newDeleteCmd()) + cmd.AddCommand(newRenderCmd()) + cmd.AddCommand(newRenderHTMLCmd()) + + return cmd +} diff --git a/internal/cli/templatecmd/template_test.go b/internal/cli/templatecmd/template_test.go new file mode 100644 index 0000000..1d1388a --- /dev/null +++ b/internal/cli/templatecmd/template_test.go @@ -0,0 +1,45 @@ +package templatecmd + +import "testing" + +func TestNewTemplateCmd(t *testing.T) { + cmd := NewTemplateCmd() + + if cmd.Use != "template" { + t.Fatalf("cmd.Use = %q, want template", cmd.Use) + } + + expected := []string{"list", "show", "create", "update", "delete", "render", "render-html"} + subcommands := make(map[string]bool, len(cmd.Commands())) + for _, sub := range cmd.Commands() { + subcommands[sub.Name()] = true + } + + for _, name := range expected { + if !subcommands[name] { + t.Fatalf("missing subcommand %q", name) + } + } +} + +func TestTemplateCreateCommandFlags(t *testing.T) { + cmd := newCreateCmd() + + for _, flag := range []string{"scope", "grant-id", "name", "subject", "body", "body-file", "engine"} { + if cmd.Flags().Lookup(flag) == nil { + t.Fatalf("missing flag %q", flag) + } + } +} + +func TestTemplateRenderCommands(t *testing.T) { + render := newRenderCmd() + if render.Flags().Lookup("data") == nil || render.Flags().Lookup("data-file") == nil { + t.Fatal("render command must support --data and --data-file") + } + + renderHTML := newRenderHTMLCmd() + if renderHTML.Flags().Lookup("body-file") == nil || renderHTML.Flags().Lookup("engine") == nil { + t.Fatal("render-html command must support --body-file and --engine") + } +} diff --git a/internal/cli/workflow/create_update_delete.go b/internal/cli/workflow/create_update_delete.go new file mode 100644 index 0000000..17f4bed --- /dev/null +++ b/internal/cli/workflow/create_update_delete.go @@ -0,0 +1,227 @@ +package workflow + +import ( + "context" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newCreateCmd() *cobra.Command { + var opts scopeOptions + var file string + var name string + var templateID string + var trigger string + var delay int + var enabled bool + var disabled bool + var fromName string + var fromEmail string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a hosted workflow", + RunE: func(cmd *cobra.Command, _ []string) error { + scope, grantID, err := resolveScope(opts) + if err != nil { + return err + } + + req := &domain.CreateRemoteWorkflowRequest{} + if file != "" { + if err := common.LoadJSONFile(file, req); err != nil { + return err + } + } else { + if err := common.ValidateRequiredFlag("--name", name); err != nil { + return err + } + if err := common.ValidateRequiredFlag("--template-id", templateID); err != nil { + return err + } + if err := common.ValidateRequiredFlag("--trigger-event", trigger); err != nil { + return err + } + if err := validateTrigger(trigger); err != nil { + return err + } + + req.Name = name + req.TemplateID = templateID + req.TriggerEvent = trigger + req.Delay = delay + + isEnabled, err := enabledPtr(enabled, disabled) + if err != nil { + return err + } + req.IsEnabled = isEnabled + req.From = senderFromFlags(fromName, fromEmail) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + return withClient(ctx, func(ctx context.Context, client ports.NylasClient) error { + workflow, err := client.CreateWorkflow(ctx, scope, grantID, req) + if err != nil { + return common.WrapCreateError("workflow", err) + } + if common.IsStructuredOutput(cmd) { + return common.GetOutputWriter(cmd).Write(workflow) + } + printWorkflow(workflow) + return nil + }) + }, + } + + addScopeFlags(cmd, &opts) + cmd.Flags().StringVar(&file, "file", "", "Path to a JSON workflow definition") + cmd.Flags().StringVar(&name, "name", "", "Workflow name") + cmd.Flags().StringVar(&templateID, "template-id", "", "Hosted template ID") + cmd.Flags().StringVar(&trigger, "trigger-event", "", "Workflow trigger event") + cmd.Flags().IntVar(&delay, "delay", 0, "Delay in minutes before the workflow sends") + cmd.Flags().BoolVar(&enabled, "enabled", false, "Create the workflow in an enabled state") + cmd.Flags().BoolVar(&disabled, "disabled", false, "Create the workflow in a disabled state") + cmd.Flags().StringVar(&fromName, "from-name", "", "Transactional sender display name") + cmd.Flags().StringVar(&fromEmail, "from-email", "", "Transactional sender email") + + return cmd +} + +func newUpdateCmd() *cobra.Command { + var opts scopeOptions + var file string + var name string + var templateID string + var trigger string + var delay int + var setDelay bool + var enabled bool + var disabled bool + var fromName string + var fromEmail string + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a hosted workflow", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + scope, grantID, err := resolveScope(opts) + if err != nil { + return err + } + + req := &domain.UpdateRemoteWorkflowRequest{} + if file != "" { + if err := common.LoadJSONFile(file, req); err != nil { + return err + } + } else { + if trigger != "" { + if err := validateTrigger(trigger); err != nil { + return err + } + } + isEnabled, err := enabledPtr(enabled, disabled) + if err != nil { + return err + } + if err := common.ValidateAtLeastOne("workflow field", name, templateID, trigger, fromName, fromEmail); err != nil && !setDelay && isEnabled == nil { + return err + } + + if name != "" { + req.Name = &name + } + if templateID != "" { + req.TemplateID = &templateID + } + if trigger != "" { + req.TriggerEvent = &trigger + } + if setDelay { + req.Delay = &delay + } + req.IsEnabled = isEnabled + if sender := senderFromFlags(fromName, fromEmail); sender != nil { + req.From = sender + } + } + + ctx, cancel := common.CreateContext() + defer cancel() + + return withClient(ctx, func(ctx context.Context, client ports.NylasClient) error { + workflow, err := client.UpdateWorkflow(ctx, scope, grantID, args[0], req) + if err != nil { + return common.WrapUpdateError("workflow", err) + } + if common.IsStructuredOutput(cmd) { + return common.GetOutputWriter(cmd).Write(workflow) + } + printWorkflow(workflow) + return nil + }) + }, + } + + addScopeFlags(cmd, &opts) + cmd.Flags().StringVar(&file, "file", "", "Path to a JSON workflow update definition") + cmd.Flags().StringVar(&name, "name", "", "Updated workflow name") + cmd.Flags().StringVar(&templateID, "template-id", "", "Updated hosted template ID") + cmd.Flags().StringVar(&trigger, "trigger-event", "", "Updated workflow trigger event") + cmd.Flags().IntVar(&delay, "delay", 0, "Updated workflow delay in minutes") + cmd.Flags().BoolVar(&enabled, "enabled", false, "Enable the workflow") + cmd.Flags().BoolVar(&disabled, "disabled", false, "Disable the workflow") + cmd.Flags().StringVar(&fromName, "from-name", "", "Updated transactional sender display name") + cmd.Flags().StringVar(&fromEmail, "from-email", "", "Updated transactional sender email") + cmd.Flags().BoolVar(&setDelay, "set-delay", false, "Apply the value from --delay during update") + + return cmd +} + +func newDeleteCmd() *cobra.Command { + var opts scopeOptions + var yes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a hosted workflow", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + scope, grantID, err := resolveScope(opts) + if err != nil { + return err + } + if !yes { + return common.NewUserError( + "deletion requires confirmation", + "Re-run with --yes to delete the workflow", + ) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + return withClient(ctx, func(ctx context.Context, client ports.NylasClient) error { + if err := client.DeleteWorkflow(ctx, scope, grantID, args[0]); err != nil { + return common.WrapDeleteError("workflow", err) + } + if !common.IsStructuredOutput(cmd) { + common.PrintSuccess("Workflow deleted") + } + return nil + }) + }, + } + + addScopeFlags(cmd, &opts) + common.AddYesFlag(cmd, &yes) + + return cmd +} diff --git a/internal/cli/workflow/helpers.go b/internal/cli/workflow/helpers.go new file mode 100644 index 0000000..ef142ea --- /dev/null +++ b/internal/cli/workflow/helpers.go @@ -0,0 +1,99 @@ +package workflow + +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" +) + +var workflowColumns = []ports.Column{ + {Header: "ID", Field: "ID", Width: -1}, + {Header: "Name", Field: "Name", Width: 30}, + {Header: "Trigger", Field: "TriggerEvent", Width: 22}, + {Header: "Template", Field: "TemplateID", Width: -1}, + {Header: "Delay", Field: "Delay", Width: 8}, + {Header: "Enabled", Field: "IsEnabled", Width: 8}, +} + +func addScopeFlags(cmd *cobra.Command, opts *scopeOptions) { + cmd.Flags().StringVar(&opts.scope, "scope", string(domain.ScopeApplication), "Workflow scope: app or grant") + cmd.Flags().StringVar(&opts.grantID, "grant-id", "", "Grant ID or email for grant-scoped workflows") +} + +func resolveScope(opts scopeOptions) (domain.RemoteScope, string, error) { + scope, err := domain.ParseRemoteScope(opts.scope) + if err != nil { + return "", "", common.NewUserError("invalid --scope value", "Use --scope app or --scope grant") + } + + grantID, err := common.ResolveScopeGrantID(scope, opts.grantID) + if err != nil { + return "", "", err + } + + return scope, grantID, nil +} + +func withClient( + ctx context.Context, + fn func(context.Context, ports.NylasClient) error, +) error { + client, err := common.GetNylasClient() + if err != nil { + return err + } + return fn(ctx, client) +} + +func printWorkflow(workflow *domain.RemoteWorkflow) { + fmt.Printf("ID: %s\n", workflow.ID) + fmt.Printf("Name: %s\n", workflow.Name) + fmt.Printf("Trigger: %s\n", workflow.TriggerEvent) + fmt.Printf("Template ID: %s\n", workflow.TemplateID) + fmt.Printf("Delay: %d minute(s)\n", workflow.Delay) + fmt.Printf("Enabled: %t\n", workflow.IsEnabled) + if workflow.From != nil { + fmt.Printf("Sender Name: %s\n", workflow.From.Name) + fmt.Printf("Sender Email: %s\n", workflow.From.Email) + } + if !workflow.DateCreated.IsZero() { + fmt.Printf("Created: %s\n", workflow.DateCreated.Format("2006-01-02 15:04:05")) + } +} + +func validateTrigger(trigger string) error { + return common.ValidateOneOf("trigger_event", trigger, domain.WorkflowTriggerEvents()) +} + +func nextCursorNote(cmd *cobra.Command, nextCursor string) { + if nextCursor == "" || common.IsStructuredOutput(cmd) { + return + } + fmt.Printf("\nNext page token: %s\n", nextCursor) +} + +func senderFromFlags(name, email string) *domain.WorkflowSender { + if name == "" && email == "" { + return nil + } + return &domain.WorkflowSender{Name: name, Email: email} +} + +func enabledPtr(enabled, disabled bool) (*bool, error) { + if enabled && disabled { + return nil, common.NewUserError("cannot use --enabled and --disabled together", "Choose only one enable flag") + } + if enabled { + value := true + return &value, nil + } + if disabled { + value := false + return &value, nil + } + return nil, nil +} diff --git a/internal/cli/workflow/list_show.go b/internal/cli/workflow/list_show.go new file mode 100644 index 0000000..2c92277 --- /dev/null +++ b/internal/cli/workflow/list_show.go @@ -0,0 +1,93 @@ +package workflow + +import ( + "context" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newListCmd() *cobra.Command { + var opts scopeOptions + var params domain.CursorListParams + + cmd := &cobra.Command{ + Use: "list", + Short: "List hosted workflows", + RunE: func(cmd *cobra.Command, _ []string) error { + scope, grantID, err := resolveScope(opts) + if err != nil { + return err + } + + ctx, cancel := common.CreateContext() + defer cancel() + + return withClient(ctx, func(ctx context.Context, client ports.NylasClient) error { + resp, err := client.ListWorkflows(ctx, scope, grantID, ¶ms) + if err != nil { + return common.WrapListError("workflows", err) + } + + out := common.GetOutputWriter(cmd) + quiet, _ := cmd.Flags().GetBool("quiet") + format, _ := cmd.Flags().GetString("format") + if quiet || format == "quiet" { + return out.WriteList(resp.Data, workflowColumns) + } + if common.IsStructuredOutput(cmd) { + return out.Write(resp) + } + if err := out.WriteList(resp.Data, workflowColumns); err != nil { + return err + } + nextCursorNote(cmd, resp.NextCursor) + return nil + }) + }, + } + + addScopeFlags(cmd, &opts) + common.AddLimitFlag(cmd, ¶ms.Limit, 50) + common.AddPageTokenFlag(cmd, ¶ms.PageToken) + + return cmd +} + +func newShowCmd() *cobra.Command { + var opts scopeOptions + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show a hosted workflow", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + scope, grantID, err := resolveScope(opts) + if err != nil { + return err + } + + ctx, cancel := common.CreateContext() + defer cancel() + + return withClient(ctx, func(ctx context.Context, client ports.NylasClient) error { + workflow, err := client.GetWorkflow(ctx, scope, grantID, args[0]) + if err != nil { + return common.WrapError(err) + } + + if common.IsStructuredOutput(cmd) { + return common.GetOutputWriter(cmd).Write(workflow) + } + + printWorkflow(workflow) + return nil + }) + }, + } + + addScopeFlags(cmd, &opts) + return cmd +} diff --git a/internal/cli/workflow/workflow.go b/internal/cli/workflow/workflow.go new file mode 100644 index 0000000..3850ad9 --- /dev/null +++ b/internal/cli/workflow/workflow.go @@ -0,0 +1,31 @@ +package workflow + +import ( + "github.com/nylas/cli/internal/cli/common" + "github.com/spf13/cobra" +) + +type scopeOptions struct { + scope string + grantID string +} + +// NewWorkflowCmd creates the hosted workflow command group. +func NewWorkflowCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "workflow", + Short: "Manage hosted workflows", + Long: `Manage Nylas-hosted workflows at the application or grant scope. + +Workflows connect booking events to hosted templates.`, + } + + common.AddOutputFlags(cmd) + cmd.AddCommand(newListCmd()) + cmd.AddCommand(newShowCmd()) + cmd.AddCommand(newCreateCmd()) + cmd.AddCommand(newUpdateCmd()) + cmd.AddCommand(newDeleteCmd()) + + return cmd +} diff --git a/internal/cli/workflow/workflow_test.go b/internal/cli/workflow/workflow_test.go new file mode 100644 index 0000000..e457038 --- /dev/null +++ b/internal/cli/workflow/workflow_test.go @@ -0,0 +1,43 @@ +package workflow + +import "testing" + +func TestNewWorkflowCmd(t *testing.T) { + cmd := NewWorkflowCmd() + + if cmd.Use != "workflow" { + t.Fatalf("cmd.Use = %q, want workflow", cmd.Use) + } + + expected := []string{"list", "show", "create", "update", "delete"} + subcommands := make(map[string]bool, len(cmd.Commands())) + for _, sub := range cmd.Commands() { + subcommands[sub.Name()] = true + } + + for _, name := range expected { + if !subcommands[name] { + t.Fatalf("missing subcommand %q", name) + } + } +} + +func TestWorkflowCreateCommandFlags(t *testing.T) { + cmd := newCreateCmd() + + for _, flag := range []string{"scope", "grant-id", "file", "name", "template-id", "trigger-event", "delay", "enabled", "disabled"} { + if cmd.Flags().Lookup(flag) == nil { + t.Fatalf("missing flag %q", flag) + } + } +} + +func TestWorkflowUpdateCommandFlags(t *testing.T) { + cmd := newUpdateCmd() + + for _, flag := range []string{"file", "set-delay", "from-name", "from-email"} { + if cmd.Flags().Lookup(flag) == nil { + t.Fatalf("missing flag %q", flag) + } + } +} diff --git a/internal/domain/ai_test.go b/internal/domain/ai_test.go index 1e98796..2677575 100644 --- a/internal/domain/ai_test.go +++ b/internal/domain/ai_test.go @@ -220,6 +220,7 @@ func TestDefaultAIConfig(t *testing.T) { if config == nil { t.Fatal("DefaultAIConfig() returned nil") + return } if config.DefaultProvider != "ollama" { diff --git a/internal/domain/errors.go b/internal/domain/errors.go index f46e0f0..5df7733 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -52,6 +52,7 @@ var ( ErrWebhookNotFound = errors.New("webhook not found") ErrNotetakerNotFound = errors.New("notetaker not found") ErrTemplateNotFound = errors.New("template not found") + ErrWorkflowNotFound = errors.New("workflow not found") ErrApplicationNotFound = errors.New("application not found") ErrCallbackURINotFound = errors.New("callback URI not found") ErrConnectorNotFound = errors.New("connector not found") diff --git a/internal/domain/templates_workflows.go b/internal/domain/templates_workflows.go new file mode 100644 index 0000000..b8a6f51 --- /dev/null +++ b/internal/domain/templates_workflows.go @@ -0,0 +1,155 @@ +package domain + +import "time" + +// RemoteScope identifies whether an operation targets app-level or grant-level resources. +type RemoteScope string + +const ( + ScopeApplication RemoteScope = "app" + ScopeGrant RemoteScope = "grant" +) + +// ParseRemoteScope validates and converts a CLI scope string. +func ParseRemoteScope(scope string) (RemoteScope, error) { + switch RemoteScope(scope) { + case ScopeApplication, ScopeGrant: + return RemoteScope(scope), nil + default: + return "", ErrInvalidInput + } +} + +// TemplateEngines returns the supported template engines from the Nylas API. +func TemplateEngines() []string { + return []string{"handlebars", "mustache", "nunjucks", "twig"} +} + +// WorkflowTriggerEvents returns the supported workflow trigger events from the Nylas API. +func WorkflowTriggerEvents() []string { + return []string{ + "booking.cancelled", + "booking.created", + "booking.pending", + "booking.reminder", + "booking.rescheduled", + } +} + +// CursorListParams configures cursor-based list requests. +type CursorListParams struct { + Limit int `json:"limit,omitempty"` + PageToken string `json:"page_token,omitempty"` +} + +// RemoteTemplate represents a Nylas-hosted template. +type RemoteTemplate struct { + ID string `json:"id"` + GrantID string `json:"grant_id,omitempty"` + AppID *string `json:"app_id,omitempty"` + Engine string `json:"engine"` + Name string `json:"name"` + Subject string `json:"subject"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + Object string `json:"object,omitempty"` +} + +// QuietField returns the field used by quiet output mode. +func (t RemoteTemplate) QuietField() string { + return t.ID +} + +// RemoteTemplateListResponse contains a page of hosted templates. +type RemoteTemplateListResponse struct { + Data []RemoteTemplate `json:"data"` + NextCursor string `json:"next_cursor,omitempty"` + RequestID string `json:"request_id,omitempty"` +} + +// CreateRemoteTemplateRequest creates a hosted template. +type CreateRemoteTemplateRequest struct { + Body string `json:"body"` + Engine string `json:"engine,omitempty"` + Name string `json:"name"` + Subject string `json:"subject"` +} + +// UpdateRemoteTemplateRequest updates a hosted template. +type UpdateRemoteTemplateRequest struct { + Body *string `json:"body,omitempty"` + Engine *string `json:"engine,omitempty"` + Name *string `json:"name,omitempty"` + Subject *string `json:"subject,omitempty"` +} + +// TemplateRenderRequest renders a stored template with variables. +type TemplateRenderRequest struct { + Strict *bool `json:"strict,omitempty"` + Variables map[string]any `json:"variables,omitempty"` +} + +// TemplateRenderHTMLRequest renders arbitrary template HTML with variables. +type TemplateRenderHTMLRequest struct { + Body string `json:"body"` + Engine string `json:"engine"` + Strict *bool `json:"strict,omitempty"` + Variables map[string]any `json:"variables,omitempty"` +} + +// TemplateRenderResult contains the raw render output from the Nylas API. +type TemplateRenderResult map[string]any + +// WorkflowSender configures the transactional sender for a workflow. +type WorkflowSender struct { + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` +} + +// RemoteWorkflow represents a Nylas-hosted workflow. +type RemoteWorkflow struct { + ID string `json:"id"` + GrantID string `json:"grant_id,omitempty"` + AppID *string `json:"app_id,omitempty"` + IsEnabled bool `json:"is_enabled"` + Name string `json:"name"` + TriggerEvent string `json:"trigger_event"` + Delay int `json:"delay"` + TemplateID string `json:"template_id"` + From *WorkflowSender `json:"from,omitempty"` + DateCreated time.Time `json:"date_created,omitempty"` + Object string `json:"object,omitempty"` +} + +// QuietField returns the field used by quiet output mode. +func (w RemoteWorkflow) QuietField() string { + return w.ID +} + +// RemoteWorkflowListResponse contains a page of hosted workflows. +type RemoteWorkflowListResponse struct { + Data []RemoteWorkflow `json:"data"` + NextCursor string `json:"next_cursor,omitempty"` + RequestID string `json:"request_id,omitempty"` +} + +// CreateRemoteWorkflowRequest creates a hosted workflow. +type CreateRemoteWorkflowRequest struct { + Delay int `json:"delay,omitempty"` + IsEnabled *bool `json:"is_enabled,omitempty"` + Name string `json:"name"` + TemplateID string `json:"template_id"` + TriggerEvent string `json:"trigger_event"` + From *WorkflowSender `json:"from,omitempty"` +} + +// UpdateRemoteWorkflowRequest updates a hosted workflow. +type UpdateRemoteWorkflowRequest struct { + Delay *int `json:"delay,omitempty"` + IsEnabled *bool `json:"is_enabled,omitempty"` + Name *string `json:"name,omitempty"` + TemplateID *string `json:"template_id,omitempty"` + TriggerEvent *string `json:"trigger_event,omitempty"` + From *WorkflowSender `json:"from,omitempty"` +} diff --git a/internal/ports/nylas.go b/internal/ports/nylas.go index ed26fae..75c8fa3 100644 --- a/internal/ports/nylas.go +++ b/internal/ports/nylas.go @@ -20,6 +20,7 @@ type NylasClient interface { SchedulerClient AdminClient TransactionalClient + TemplateWorkflowClient // Configuration methods SetRegion(region string) diff --git a/internal/ports/templates_workflows.go b/internal/ports/templates_workflows.go new file mode 100644 index 0000000..f155954 --- /dev/null +++ b/internal/ports/templates_workflows.go @@ -0,0 +1,26 @@ +package ports + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +// TemplateWorkflowClient defines the interface for hosted templates and workflows. +type TemplateWorkflowClient interface { + // Template operations + ListRemoteTemplates(ctx context.Context, scope domain.RemoteScope, grantID string, params *domain.CursorListParams) (*domain.RemoteTemplateListResponse, error) + GetRemoteTemplate(ctx context.Context, scope domain.RemoteScope, grantID, templateID string) (*domain.RemoteTemplate, error) + CreateRemoteTemplate(ctx context.Context, scope domain.RemoteScope, grantID string, req *domain.CreateRemoteTemplateRequest) (*domain.RemoteTemplate, error) + UpdateRemoteTemplate(ctx context.Context, scope domain.RemoteScope, grantID, templateID string, req *domain.UpdateRemoteTemplateRequest) (*domain.RemoteTemplate, error) + DeleteRemoteTemplate(ctx context.Context, scope domain.RemoteScope, grantID, templateID string) error + RenderRemoteTemplate(ctx context.Context, scope domain.RemoteScope, grantID, templateID string, req *domain.TemplateRenderRequest) (domain.TemplateRenderResult, error) + RenderRemoteTemplateHTML(ctx context.Context, scope domain.RemoteScope, grantID string, req *domain.TemplateRenderHTMLRequest) (domain.TemplateRenderResult, error) + + // Workflow operations + ListWorkflows(ctx context.Context, scope domain.RemoteScope, grantID string, params *domain.CursorListParams) (*domain.RemoteWorkflowListResponse, error) + GetWorkflow(ctx context.Context, scope domain.RemoteScope, grantID, workflowID string) (*domain.RemoteWorkflow, error) + CreateWorkflow(ctx context.Context, scope domain.RemoteScope, grantID string, req *domain.CreateRemoteWorkflowRequest) (*domain.RemoteWorkflow, error) + UpdateWorkflow(ctx context.Context, scope domain.RemoteScope, grantID, workflowID string, req *domain.UpdateRemoteWorkflowRequest) (*domain.RemoteWorkflow, error) + DeleteWorkflow(ctx context.Context, scope domain.RemoteScope, grantID, workflowID string) error +} diff --git a/internal/testutil/context_test.go b/internal/testutil/context_test.go index 065a625..c2b1943 100644 --- a/internal/testutil/context_test.go +++ b/internal/testutil/context_test.go @@ -82,6 +82,7 @@ func TestPointerHelpers(t *testing.T) { strPtr := testutil.StringPtr(str) if strPtr == nil { t.Fatal("StringPtr returned nil") + return } if *strPtr != str { t.Errorf("Expected %q, got %q", str, *strPtr) @@ -92,6 +93,7 @@ func TestPointerHelpers(t *testing.T) { boolPtr := testutil.BoolPtr(boolVal) if boolPtr == nil { t.Fatal("BoolPtr returned nil") + return } if *boolPtr != boolVal { t.Errorf("Expected %v, got %v", boolVal, *boolPtr) @@ -102,6 +104,7 @@ func TestPointerHelpers(t *testing.T) { intPtr := testutil.IntPtr(intVal) if intPtr == nil { t.Fatal("IntPtr returned nil") + return } if *intPtr != intVal { t.Errorf("Expected %d, got %d", intVal, *intPtr) diff --git a/internal/tui/app_test_app.go b/internal/tui/app_test_app.go index c569d64..c2d645c 100644 --- a/internal/tui/app_test_app.go +++ b/internal/tui/app_test_app.go @@ -60,6 +60,7 @@ func TestNewApp(t *testing.T) { if app == nil { t.Fatal("NewApp() returned nil") + return } if app.Application == nil { @@ -97,6 +98,7 @@ func TestNewAppWithThemes(t *testing.T) { app := NewApp(config) if app == nil { t.Fatalf("NewApp() with theme %q returned nil", theme) + return } if app.styles == nil { @@ -127,6 +129,7 @@ func TestAppStyles(t *testing.T) { if styles == nil { t.Fatal("Styles() returned nil") + return } // Verify styles are properly set diff --git a/internal/tui/app_test_table.go b/internal/tui/app_test_table.go index 31eb037..4f49bd2 100644 --- a/internal/tui/app_test_table.go +++ b/internal/tui/app_test_table.go @@ -62,6 +62,7 @@ func TestTableSelection(t *testing.T) { meta := table.SelectedMeta() if meta == nil { t.Fatal("SelectedMeta() returned nil") + return } if meta.ID != "1" { t.Errorf("SelectedMeta().ID = %q, want %q", meta.ID, "1") @@ -84,6 +85,7 @@ func TestTable_SelectedMeta(t *testing.T) { meta := table.SelectedMeta() if meta == nil { t.Fatal("SelectedMeta() returned nil") + return } if meta.ID != "id-1" { t.Errorf("SelectedMeta().ID = %q, want %q", meta.ID, "id-1") diff --git a/internal/tui/app_test_views_specific.go b/internal/tui/app_test_views_specific.go index fa34a1b..8548971 100644 --- a/internal/tui/app_test_views_specific.go +++ b/internal/tui/app_test_views_specific.go @@ -46,6 +46,7 @@ func TestStyles_DefaultStyles(t *testing.T) { if styles == nil { t.Fatal("DefaultStyles() returned nil") + return } // Verify some key colors are set @@ -158,6 +159,7 @@ func TestContactsView(t *testing.T) { if view == nil { t.Fatal("NewContactsView() returned nil") + return } if view.Name() != "contacts" { @@ -189,6 +191,7 @@ func TestWebhooksView(t *testing.T) { if view == nil { t.Fatal("NewWebhooksView() returned nil") + return } if view.Name() != "webhooks" { @@ -244,6 +247,7 @@ func TestDraftsView(t *testing.T) { if view == nil { t.Fatal("NewDraftsView() returned nil") + return } if view.Name() != "drafts" { diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index 8b6dff3..dd25e1f 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -15,6 +15,7 @@ func TestNewCalendarView(t *testing.T) { if view == nil { t.Fatal("NewCalendarView returned nil") + return } if view.viewMode != CalendarMonthView { @@ -280,6 +281,7 @@ func TestCalendarView_GetCurrentCalendar(t *testing.T) { cal = view.GetCurrentCalendar() if cal == nil { t.Fatal("GetCurrentCalendar() returned nil") + return } if cal.ID != "cal-2" { t.Errorf("GetCurrentCalendar().ID = %q, want %q", cal.ID, "cal-2") diff --git a/internal/tui/commands_registry_test.go b/internal/tui/commands_registry_test.go index f068578..bee9bd1 100644 --- a/internal/tui/commands_registry_test.go +++ b/internal/tui/commands_registry_test.go @@ -9,6 +9,7 @@ func TestNewCommandRegistry(t *testing.T) { if registry == nil { t.Fatal("NewCommandRegistry() returned nil") + return } // Verify commands were registered diff --git a/internal/tui/compose_test.go b/internal/tui/compose_test.go index 3b8b4c6..16dbb82 100644 --- a/internal/tui/compose_test.go +++ b/internal/tui/compose_test.go @@ -14,6 +14,7 @@ func TestNewComposeView(t *testing.T) { if view == nil { t.Fatal("NewComposeView returned nil") + return } if view.mode != ComposeModeNew { @@ -53,6 +54,7 @@ func TestNewComposeViewForDraft(t *testing.T) { if view == nil { t.Fatal("NewComposeViewForDraft returned nil") + return } if view.mode != ComposeModeDraft { @@ -86,6 +88,7 @@ func TestComposeViewReplyMode(t *testing.T) { if view == nil { t.Fatal("NewComposeView returned nil for reply mode") + return } if view.mode != ComposeModeReply { @@ -118,6 +121,7 @@ func TestComposeViewReplyAllMode(t *testing.T) { if view == nil { t.Fatal("NewComposeView returned nil for reply all mode") + return } if view.mode != ComposeModeReplyAll { @@ -138,6 +142,7 @@ func TestComposeViewForwardMode(t *testing.T) { if view == nil { t.Fatal("NewComposeView returned nil for forward mode") + return } if view.mode != ComposeModeForward { diff --git a/internal/tui/contact_form_test.go b/internal/tui/contact_form_test.go index c7f40ed..7dbcb7a 100644 --- a/internal/tui/contact_form_test.go +++ b/internal/tui/contact_form_test.go @@ -13,6 +13,7 @@ func TestNewContactFormCreate(t *testing.T) { if form == nil { t.Fatal("NewContactForm returned nil") + return } if form.mode != ContactFormCreate { @@ -51,6 +52,7 @@ func TestNewContactFormEdit(t *testing.T) { if form == nil { t.Fatal("NewContactForm returned nil") + return } if form.mode != ContactFormEdit { diff --git a/internal/tui/drafts_test.go b/internal/tui/drafts_test.go index a4466ab..72a92d5 100644 --- a/internal/tui/drafts_test.go +++ b/internal/tui/drafts_test.go @@ -15,6 +15,7 @@ func TestNewDraftsView(t *testing.T) { if view == nil { t.Fatal("NewDraftsView returned nil") + return } if view.BaseTableView == nil { diff --git a/internal/tui/event_form_test.go b/internal/tui/event_form_test.go index fce2de5..6e1560b 100644 --- a/internal/tui/event_form_test.go +++ b/internal/tui/event_form_test.go @@ -15,6 +15,7 @@ func TestNewEventFormCreate(t *testing.T) { if form == nil { t.Fatal("NewEventForm returned nil") + return } if form.mode != EventFormCreate { @@ -56,6 +57,7 @@ func TestNewEventFormEdit(t *testing.T) { if form == nil { t.Fatal("NewEventForm returned nil") + return } if form.mode != EventFormEdit { diff --git a/internal/tui/folder_panel_test.go b/internal/tui/folder_panel_test.go index 250e212..88958c1 100644 --- a/internal/tui/folder_panel_test.go +++ b/internal/tui/folder_panel_test.go @@ -16,6 +16,7 @@ func TestNewFolderPanel(t *testing.T) { if panel == nil { t.Fatal("NewFolderPanel returned nil") + return } if panel.list == nil { @@ -174,6 +175,7 @@ func TestFolderPanelGetSelectedFolder(t *testing.T) { folder := panel.GetSelectedFolder() if folder == nil { t.Fatal("GetSelectedFolder returned nil") + return } if folder.ID != "folder-1" { t.Errorf("GetSelectedFolder().ID = %q, want 'folder-1'", folder.ID) @@ -195,6 +197,7 @@ func TestFolderPanelGetFolderBySystemName(t *testing.T) { inbox := panel.GetFolderBySystemName(domain.FolderInbox) if inbox == nil { t.Fatal("GetFolderBySystemName(FolderInbox) returned nil") + return } if inbox.ID != "inbox-id" { t.Errorf("inbox.ID = %q, want 'inbox-id'", inbox.ID) @@ -204,6 +207,7 @@ func TestFolderPanelGetFolderBySystemName(t *testing.T) { sent := panel.GetFolderBySystemName(domain.FolderSent) if sent == nil { t.Fatal("GetFolderBySystemName(FolderSent) returned nil") + return } if sent.ID != "sent-id" { t.Errorf("sent.ID = %q, want 'sent-id'", sent.ID) @@ -245,6 +249,7 @@ func TestMessagesViewFolderIntegration(t *testing.T) { if view == nil { t.Fatal("NewMessagesView returned nil") + return } if view.folderPanel == nil { diff --git a/internal/tui/theme_test_helpers.go b/internal/tui/theme_test_helpers.go index f2ffd39..daec4b8 100644 --- a/internal/tui/theme_test_helpers.go +++ b/internal/tui/theme_test_helpers.go @@ -203,6 +203,7 @@ func TestDefaultStyles(t *testing.T) { if styles == nil { t.Fatal("DefaultStyles() returned nil") + return } // Verify base colors are set @@ -250,6 +251,7 @@ func TestThemeConfigToStyles(t *testing.T) { if styles == nil { t.Fatal("ToStyles() returned nil") + return } // Verify colors were applied diff --git a/internal/tui/theme_test_loading.go b/internal/tui/theme_test_loading.go index 4baf524..8cc4604 100644 --- a/internal/tui/theme_test_loading.go +++ b/internal/tui/theme_test_loading.go @@ -81,6 +81,7 @@ k9s: styles := config.ToStyles() if styles == nil { t.Fatal("ToStyles() returned nil") + return } // Verify colors were applied correctly @@ -198,6 +199,7 @@ func TestGetThemeStylesWithCustomTheme(t *testing.T) { unknownStyles := GetThemeStyles("nonexistent-theme-xyz") if unknownStyles == nil { t.Fatal("GetThemeStyles(nonexistent) returned nil") + return } // The unknown theme should fall back to default styles @@ -227,6 +229,7 @@ func TestGetThemeStylesLoadsCustomTheme(t *testing.T) { customStyles := GetThemeStyles("testcustom") if customStyles == nil { t.Fatal("GetThemeStyles(testcustom) returned nil") + return } // The testcustom theme should have the Tokyo Night colors diff --git a/internal/tui/webhook_form_test.go b/internal/tui/webhook_form_test.go index 5e8d2da..d7ca6aa 100644 --- a/internal/tui/webhook_form_test.go +++ b/internal/tui/webhook_form_test.go @@ -13,6 +13,7 @@ func TestNewWebhookFormCreate(t *testing.T) { if form == nil { t.Fatal("NewWebhookForm returned nil") + return } if form.mode != WebhookFormCreate { @@ -45,6 +46,7 @@ func TestNewWebhookFormEdit(t *testing.T) { if form == nil { t.Fatal("NewWebhookForm returned nil") + return } if form.mode != WebhookFormEdit { diff --git a/internal/ui/server_demo_test.go b/internal/ui/server_demo_test.go index 9f86e8f..b1e452d 100644 --- a/internal/ui/server_demo_test.go +++ b/internal/ui/server_demo_test.go @@ -287,6 +287,7 @@ func TestNewDemoServer(t *testing.T) { if server == nil { t.Fatal("NewDemoServer returned nil") + return } if server.addr != ":8080" { t.Errorf("Expected addr ':8080', got %q", server.addr) From e846e198ba507db7ed90fe2aaf891a5344ca3bbd Mon Sep 17 00:00:00 2001 From: Qasim Date: Wed, 8 Apr 2026 06:53:46 -0400 Subject: [PATCH 2/2] Fix hosted template scope handling and CI stability --- go.mod | 2 + .../adapters/webhookserver/server_test.go | 22 ++++++- internal/cli/common/remote_resources.go | 21 ++++++- internal/cli/common/remote_resources_test.go | 44 +++++++++++++ internal/cli/email/send.go | 17 ++++- internal/cli/email/send_template.go | 14 +++++ internal/cli/email/send_template_test.go | 5 ++ internal/cli/email/send_test.go | 63 +++++++++++++++++++ 8 files changed, 184 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index c5451cf..49487df 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/nylas/cli go 1.26.0 +toolchain go1.26.2 + require ( github.com/atotto/clipboard v0.1.4 github.com/fatih/color v1.18.0 diff --git a/internal/adapters/webhookserver/server_test.go b/internal/adapters/webhookserver/server_test.go index dad3bda..f4dc6bc 100644 --- a/internal/adapters/webhookserver/server_test.go +++ b/internal/adapters/webhookserver/server_test.go @@ -7,6 +7,8 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" + "net" "net/http" "net/http/httptest" "testing" @@ -39,8 +41,10 @@ func TestNewServer(t *testing.T) { } func TestServer_StartStop(t *testing.T) { + port := reserveTCPPort(t) + server := NewServer(ports.WebhookServerConfig{ - Port: 0, // Let OS pick a port + Port: port, Path: "/webhook", }) @@ -59,6 +63,22 @@ func TestServer_StartStop(t *testing.T) { require.NoError(t, err) } +func reserveTCPPort(t *testing.T) int { + t.Helper() + + listener, err := net.Listen("tcp", ":0") + require.NoError(t, err) + defer func() { + _ = listener.Close() + }() + + addr, ok := listener.Addr().(*net.TCPAddr) + require.True(t, ok, fmt.Sprintf("listener addr %T is not *net.TCPAddr", listener.Addr())) + require.NotZero(t, addr.Port) + + return addr.Port +} + func TestServer_GetStats(t *testing.T) { server := NewServer(ports.WebhookServerConfig{ Port: 3001, diff --git a/internal/cli/common/remote_resources.go b/internal/cli/common/remote_resources.go index 5f62de0..bbe01fc 100644 --- a/internal/cli/common/remote_resources.go +++ b/internal/cli/common/remote_resources.go @@ -41,12 +41,29 @@ func ResolveGrantIdentifier(identifier string) (string, error) { // ResolveScopeGrantID resolves the grant ID when a command targets grant-scoped resources. func ResolveScopeGrantID(scope domain.RemoteScope, grantID string) (string, error) { if scope != domain.ScopeGrant { + if strings.TrimSpace(grantID) != "" { + return "", NewUserError("`--grant-id` requires `--scope grant`", "Use --scope grant when targeting grant-scoped resources") + } return "", nil } if grantID == "" { - return GetGrantID(nil) + resolvedGrantID, err := GetGrantID(nil) + if err != nil { + return "", err + } + if AuditGrantHook != nil { + AuditGrantHook(resolvedGrantID) + } + return resolvedGrantID, nil + } + resolvedGrantID, err := ResolveGrantIdentifier(grantID) + if err != nil { + return "", err + } + if AuditGrantHook != nil { + AuditGrantHook(resolvedGrantID) } - return ResolveGrantIdentifier(grantID) + return resolvedGrantID, nil } // LoadJSONFile decodes a JSON file into target. diff --git a/internal/cli/common/remote_resources_test.go b/internal/cli/common/remote_resources_test.go index fd677f1..19f583b 100644 --- a/internal/cli/common/remote_resources_test.go +++ b/internal/cli/common/remote_resources_test.go @@ -83,3 +83,47 @@ func TestResolveScopeGrantID_GrantScopeUsesGrantLookup(t *testing.T) { require.NoError(t, err) assert.Equal(t, "grant-456", grantID) } + +func TestResolveScopeGrantID_AuditGrantHook(t *testing.T) { + originalHook := AuditGrantHook + t.Cleanup(func() { + AuditGrantHook = originalHook + }) + + var hookedGrantID string + AuditGrantHook = func(grantID string) { + hookedGrantID = grantID + } + + grantID, err := ResolveScopeGrantID(domain.ScopeGrant, "grant-789") + + require.NoError(t, err) + assert.Equal(t, "grant-789", grantID) + assert.Equal(t, "grant-789", hookedGrantID) +} + +func TestResolveScopeGrantID_AppScopeSkipsAuditGrantHook(t *testing.T) { + originalHook := AuditGrantHook + t.Cleanup(func() { + AuditGrantHook = originalHook + }) + + called := false + AuditGrantHook = func(string) { + called = true + } + + grantID, err := ResolveScopeGrantID(domain.ScopeApplication, "") + + require.NoError(t, err) + assert.Empty(t, grantID) + assert.False(t, called) +} + +func TestResolveScopeGrantID_AppScopeRejectsGrantID(t *testing.T) { + grantID, err := ResolveScopeGrantID(domain.ScopeApplication, "grant-789") + + require.Error(t, err) + assert.Empty(t, grantID) + assert.Contains(t, err.Error(), "`--grant-id` requires `--scope grant`") +} diff --git a/internal/cli/email/send.go b/internal/cli/email/send.go index 4363dc5..c85e528 100644 --- a/internal/cli/email/send.go +++ b/internal/cli/email/send.go @@ -131,7 +131,7 @@ Supports hosted templates: } // Interactive mode (runs before client setup). - if interactive || (templateOpts.TemplateID == "" && len(to) == 0 && subject == "" && body == "") { + if shouldUseInteractiveSendMode(interactive, to, subject, body, templateOpts) { reader := bufio.NewReader(os.Stdin) if len(to) == 0 && !templateOpts.RenderOnly { @@ -453,6 +453,21 @@ Supports hosted templates: return cmd } +func shouldUseInteractiveSendMode( + interactive bool, + to []string, + subject, body string, + templateOpts hostedTemplateSendOptions, +) bool { + if interactive { + return true + } + if templateOpts.TemplateID != "" { + return len(to) == 0 && !templateOpts.RenderOnly + } + return len(to) == 0 && subject == "" && body == "" +} + // parseEmails parses a comma-separated list of emails. func parseEmails(s string) []string { if s == "" { diff --git a/internal/cli/email/send_template.go b/internal/cli/email/send_template.go index 96d8e0f..0dc88e6 100644 --- a/internal/cli/email/send_template.go +++ b/internal/cli/email/send_template.go @@ -42,6 +42,20 @@ func validateHostedTemplateSendOptions(opts hostedTemplateSendOptions, subject, return nil } + if strings.TrimSpace(opts.TemplateGrantID) != "" { + scope := domain.ScopeApplication + if opts.TemplateScope != "" { + parsedScope, err := domain.ParseRemoteScope(opts.TemplateScope) + if err != nil { + return common.NewUserError("invalid `--template-scope` value", "Use --template-scope app or --template-scope grant") + } + scope = parsedScope + } + if scope != domain.ScopeGrant { + return common.NewUserError("`--template-grant-id` requires `--template-scope grant`", "Use --template-scope grant when rendering a grant-scoped hosted template") + } + } + if subject != "" || body != "" { return common.NewUserError( "`--template-id` cannot be combined with `--subject` or `--body`", diff --git a/internal/cli/email/send_template_test.go b/internal/cli/email/send_template_test.go index d59f812..e5f7c50 100644 --- a/internal/cli/email/send_template_test.go +++ b/internal/cli/email/send_template_test.go @@ -40,6 +40,11 @@ func TestValidateHostedTemplateSendOptions(t *testing.T) { opts: hostedTemplateSendOptions{TemplateData: `{"name":"Ada"}`}, wantErr: true, }, + { + name: "template grant id requires grant scope", + opts: hostedTemplateSendOptions{TemplateID: "tpl-123", TemplateGrantID: "grant-123"}, + wantErr: true, + }, { name: "grant scope without template id is rejected", opts: hostedTemplateSendOptions{TemplateScope: string(domain.ScopeGrant)}, diff --git a/internal/cli/email/send_test.go b/internal/cli/email/send_test.go index d54d59a..ecbdd6a 100644 --- a/internal/cli/email/send_test.go +++ b/internal/cli/email/send_test.go @@ -103,6 +103,69 @@ func TestSendCmd_ListGPGKeysFlag(t *testing.T) { } } +func TestShouldUseInteractiveSendMode(t *testing.T) { + tests := []struct { + name string + interactive bool + to []string + subject string + body string + templateOpts hostedTemplateSendOptions + want bool + }{ + { + name: "explicit interactive flag always prompts", + interactive: true, + want: true, + }, + { + name: "raw send with no content auto prompts", + want: true, + subject: "", + body: "", + }, + { + name: "raw send with recipient does not auto prompt", + to: []string{"user@example.com"}, + subject: "", + body: "", + want: false, + }, + { + name: "hosted template send without recipients auto prompts", + templateOpts: hostedTemplateSendOptions{ + TemplateID: "tpl-123", + }, + want: true, + }, + { + name: "hosted template render-only does not prompt", + templateOpts: hostedTemplateSendOptions{ + TemplateID: "tpl-123", + RenderOnly: true, + }, + want: false, + }, + { + name: "hosted template send with recipients does not prompt", + to: []string{"user@example.com"}, + templateOpts: hostedTemplateSendOptions{ + TemplateID: "tpl-123", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldUseInteractiveSendMode(tt.interactive, tt.to, tt.subject, tt.body, tt.templateOpts) + if got != tt.want { + t.Fatalf("shouldUseInteractiveSendMode() = %v, want %v", got, tt.want) + } + }) + } +} + func TestSendCmd_AutoSignConfig(t *testing.T) { // Create temp config directory tmpDir := t.TempDir()