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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ nylas email send ... --sign --encrypt # Sign AND encryp
nylas email send --list-gpg-keys # List available GPG signing keys
nylas email send --to EMAIL --template-id TPL --template-data '{}' # Send using a hosted template
nylas email send --template-id TPL --template-data-file data.json --render-only
nylas email send --to EMAIL --subject SUBJECT --body BODY --signature-id SIG # Send with stored signature
nylas email search --query "QUERY" # Search emails
nylas email delete <message-id> # Delete email
nylas email mark read <message-id> # Mark as read
Expand Down Expand Up @@ -237,6 +238,7 @@ nylas email templates show <template-id> # Show template details
nylas email templates update <template-id> [flags] # Update template
nylas email templates delete <template-id> # Delete template
nylas email templates use <template-id> --to EMAIL # Send using template
nylas email templates use <template-id> --to EMAIL --signature-id SIG # Send using template + stored signature
```

**Variable syntax:** Use `{{variable}}` in subject/body for placeholders.
Expand Down Expand Up @@ -305,11 +307,26 @@ nylas email threads delete <thread-id> # Delete thread
nylas email drafts list # List drafts
nylas email drafts show <draft-id> # Show draft details
nylas email drafts create --to EMAIL --subject S # Create draft
nylas email drafts create --to EMAIL --subject S --signature-id SIG # Create draft with stored signature
nylas email drafts send <draft-id> # Send draft
nylas email drafts send <draft-id> --signature-id SIG # Send draft with stored signature
nylas email drafts delete <draft-id> # Delete draft
```

**Flags:** `--to`, `--cc`, `--bcc`, `--subject`, `--body`, `--reply-to`, `--attach`
**Flags:** `--to`, `--cc`, `--bcc`, `--subject`, `--body`, `--reply-to`, `--attach`, `--signature-id`

---

## Signatures

```bash
nylas email signatures list [grant-id] # List stored signatures
nylas email signatures show <signature-id> [grant-id] # Show signature details
nylas email signatures create [grant-id] --name NAME --body BODY # Create signature
nylas email signatures create [grant-id] --name NAME --body-file FILE
nylas email signatures update <signature-id> [grant-id] [flags] # Update signature
nylas email signatures delete <signature-id> [grant-id] --yes # Delete signature
```

---

Expand Down
38 changes: 38 additions & 0 deletions docs/commands/email.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ nylas email send --to "to@example.com" --template-id tpl_123 \

# Preview a hosted template render without sending
nylas email send --template-id tpl_123 --template-data-file ./data.json --render-only

# Send with a stored signature
nylas email send --to "to@example.com" --subject "Subject" --body "Body" \
--signature-id sig_123
```

**Tracking Options:**
Expand All @@ -119,6 +123,7 @@ nylas email send --template-id tpl_123 --template-data-file ./data.json --render
- `--template-data-file` - JSON file containing hosted template variables
- `--render-only` - Preview the rendered hosted template without sending
- `--template-strict` - Fail if the hosted template references missing variables (default: true)
- `--signature-id` - Append a stored signature when sending, creating a draft, or sending a draft

**Example output (scheduled):**
```bash
Expand Down Expand Up @@ -181,6 +186,39 @@ nylas email send --to "to@example.com" --subject "Secure" --body "..." --sign --
nylas email send --list-gpg-keys
```

`--signature-id` can't be combined with `--sign` or `--encrypt`, because stored signatures are only supported on the standard JSON send and draft endpoints.

### Signatures

Manage stored signatures on a grant and reuse them from send and draft commands:

```bash
# List signatures for a grant
nylas email signatures list [grant-id]

# Show a specific signature
nylas email signatures show <signature-id> [grant-id]

# Create a signature
nylas email signatures create [grant-id] --name "Work" --body-file ./signature.html

# Update a signature
nylas email signatures update <signature-id> [grant-id] --name "Work Updated" --body "<p>Updated</p>"

# Delete a signature
nylas email signatures delete <signature-id> [grant-id] --yes

# Create a draft with a stored signature
nylas email drafts create --to "to@example.com" --subject "Draft" --body "Body" \
--signature-id sig_123

# Send a draft with a stored signature
nylas email drafts send <draft-id> --signature-id sig_123

# Use a local template and append a stored signature
nylas email templates use <template-id> --to "to@example.com" --signature-id sig_123
```

**Reading encrypted/signed emails:**

```bash
Expand Down
63 changes: 60 additions & 3 deletions internal/adapters/nylas/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,38 @@ func (c *HTTPClient) doRequest(ctx context.Context, req *http.Request) (*http.Re
return nil, lastErr
}

func (c *HTTPClient) doRequestNoRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", version.UserAgent())

// Apply rate limiting - wait for permission to proceed
if err := c.rateLimiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("rate limiter: %w", err)
}

// Ensure context has timeout
ctxWithTimeout, cancel := c.ensureContext(ctx)

// Execute request
resp, err := c.httpClient.Do(req.WithContext(ctxWithTimeout))
if err != nil {
cancel()

// Don't mask parent context cancellation.
if ctx.Err() != nil {
return nil, ctx.Err()
}

return nil, fmt.Errorf("%w: %v", domain.ErrNetworkError, err)
}

resp.Body = &cancelOnCloseBody{
ReadCloser: resp.Body,
cancel: cancel,
}

return resp, nil
}

type cancelOnCloseBody struct {
io.ReadCloser
cancel context.CancelFunc
Expand Down Expand Up @@ -327,6 +359,17 @@ func (c *HTTPClient) doJSONRequestInternal(
body any,
withAuth bool,
acceptedStatuses ...int,
) (*http.Response, error) {
return c.doJSONRequestInternalWithRetry(ctx, method, url, body, withAuth, true, acceptedStatuses...)
}

func (c *HTTPClient) doJSONRequestInternalWithRetry(
ctx context.Context,
method, url string,
body any,
withAuth bool,
retry bool,
acceptedStatuses ...int,
) (*http.Response, error) {
// Default accepted statuses
if len(acceptedStatuses) == 0 {
Expand Down Expand Up @@ -358,8 +401,13 @@ func (c *HTTPClient) doJSONRequestInternal(
c.setAuthHeader(req)
}

// Execute request with rate limiting
resp, err := c.doRequest(ctx, req)
// Execute request with the configured retry policy.
var resp *http.Response
if retry {
resp, err = c.doRequest(ctx, req)
} else {
resp, err = c.doRequestNoRetry(ctx, req)
}
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -411,6 +459,15 @@ func (c *HTTPClient) doJSONRequest(
return c.doJSONRequestInternal(ctx, method, url, body, true, acceptedStatuses...)
}

func (c *HTTPClient) doJSONRequestNoRetry(
ctx context.Context,
method, url string,
body any,
acceptedStatuses ...int,
) (*http.Response, error) {
return c.doJSONRequestInternalWithRetry(ctx, method, url, body, true, false, acceptedStatuses...)
}

// decodeJSONResponse decodes a JSON response body into the provided struct.
// It properly closes the response body after reading.
//
Expand Down Expand Up @@ -440,7 +497,7 @@ func (c *HTTPClient) doJSONRequestNoAuth(
body any,
acceptedStatuses ...int,
) (*http.Response, error) {
return c.doJSONRequestInternal(ctx, method, url, body, false, acceptedStatuses...)
return c.doJSONRequestInternalWithRetry(ctx, method, url, body, false, true, acceptedStatuses...)
}

// validateRequired validates that a required field is not empty.
Expand Down
2 changes: 1 addition & 1 deletion internal/adapters/nylas/client_mock_methods_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ func TestMockClient_Drafts(t *testing.T) {
})

t.Run("SendDraft", func(t *testing.T) {
msg, err := mock.SendDraft(ctx, "grant-123", "draft-456")
msg, err := mock.SendDraft(ctx, "grant-123", "draft-456", nil)
require.NoError(t, err)
assert.NotEmpty(t, msg.ID)
assert.True(t, mock.SendDraftCalled)
Expand Down
2 changes: 1 addition & 1 deletion internal/adapters/nylas/demo_drafts.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,6 @@ func (d *DemoClient) DeleteDraft(ctx context.Context, grantID, draftID string) e
}

// SendDraft simulates sending a draft.
func (d *DemoClient) SendDraft(ctx context.Context, grantID, draftID string) (*domain.Message, error) {
func (d *DemoClient) SendDraft(ctx context.Context, grantID, draftID string, req *domain.SendDraftRequest) (*domain.Message, error) {
return &domain.Message{ID: "sent-from-draft", Subject: "Sent Draft"}, nil
}
73 changes: 73 additions & 0 deletions internal/adapters/nylas/demo_signatures.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package nylas

import (
"context"
"time"

"github.com/nylas/cli/internal/domain"
)

func (d *DemoClient) GetSignatures(ctx context.Context, grantID string) ([]domain.Signature, error) {
now := time.Now()
return []domain.Signature{
{
ID: "sig-demo-work",
Name: "Work",
Body: "<div><strong>Demo User</strong><br/>Developer Advocate</div>",
CreatedAt: now.Add(-24 * time.Hour),
UpdatedAt: now.Add(-2 * time.Hour),
},
{
ID: "sig-demo-mobile",
Name: "Mobile",
Body: "<div>Sent from my phone</div>",
CreatedAt: now.Add(-48 * time.Hour),
UpdatedAt: now.Add(-6 * time.Hour),
},
}, nil
}

func (d *DemoClient) GetSignature(ctx context.Context, grantID, signatureID string) (*domain.Signature, error) {
signatures, err := d.GetSignatures(ctx, grantID)
if err != nil {
return nil, err
}
for _, signature := range signatures {
if signature.ID == signatureID {
return &signature, nil
}
}
return nil, domain.ErrSignatureNotFound
}

func (d *DemoClient) CreateSignature(ctx context.Context, grantID string, req *domain.CreateSignatureRequest) (*domain.Signature, error) {
now := time.Now()
return &domain.Signature{
ID: "sig-demo-new",
Name: req.Name,
Body: req.Body,
CreatedAt: now,
UpdatedAt: now,
}, nil
}

func (d *DemoClient) UpdateSignature(ctx context.Context, grantID, signatureID string, req *domain.UpdateSignatureRequest) (*domain.Signature, error) {
signature := &domain.Signature{
ID: signatureID,
Name: "Work",
Body: "<div><strong>Demo User</strong><br/>Developer Advocate</div>",
CreatedAt: time.Now().Add(-24 * time.Hour),
UpdatedAt: time.Now(),
}
if req.Name != nil {
signature.Name = *req.Name
}
if req.Body != nil {
signature.Body = *req.Body
}
return signature, nil
}

func (d *DemoClient) DeleteSignature(ctx context.Context, grantID, signatureID string) error {
return nil
}
30 changes: 21 additions & 9 deletions internal/adapters/nylas/drafts.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func (c *HTTPClient) CreateDraft(ctx context.Context, grantID string, req *domai

// buildDraftPayload builds the common payload for draft creation requests.
// This consolidates the repeated payload building logic across draft creation methods.
func buildDraftPayload(req *domain.CreateDraftRequest) map[string]any {
func buildDraftPayload(req *domain.CreateDraftRequest, includeSignature bool) map[string]any {
payload := map[string]any{
"subject": req.Subject,
"body": req.Body,
Expand All @@ -118,6 +118,9 @@ func buildDraftPayload(req *domain.CreateDraftRequest) map[string]any {
if req.ReplyToMsgID != "" {
payload["reply_to_message_id"] = req.ReplyToMsgID
}
if includeSignature && req.SignatureID != "" {
payload["signature_id"] = req.SignatureID
}
if len(req.Metadata) > 0 {
payload["metadata"] = req.Metadata
}
Expand All @@ -128,7 +131,7 @@ func buildDraftPayload(req *domain.CreateDraftRequest) map[string]any {
func (c *HTTPClient) createDraftWithJSON(ctx context.Context, grantID string, req *domain.CreateDraftRequest) (*domain.Draft, error) {
queryURL := fmt.Sprintf("%s/v3/grants/%s/drafts", c.baseURL, grantID)

resp, err := c.doJSONRequest(ctx, "POST", queryURL, buildDraftPayload(req))
resp, err := c.doJSONRequest(ctx, "POST", queryURL, buildDraftPayload(req, true))
if err != nil {
return nil, err
}
Expand All @@ -153,7 +156,7 @@ func (c *HTTPClient) createDraftWithMultipart(ctx context.Context, grantID strin
writer := multipart.NewWriter(&buf)

// Add message as JSON field
messageJSON, err := json.Marshal(buildDraftPayload(req))
messageJSON, err := json.Marshal(buildDraftPayload(req, true))
if err != nil {
return nil, fmt.Errorf("failed to marshal message: %w", err)
}
Expand Down Expand Up @@ -221,7 +224,7 @@ func (c *HTTPClient) createDraftWithMultipart(ctx context.Context, grantID strin
// This is useful for large attachments or streaming file uploads.
func (c *HTTPClient) CreateDraftWithAttachmentFromReader(ctx context.Context, grantID string, req *domain.CreateDraftRequest, filename string, contentType string, reader io.Reader) (*domain.Draft, error) {
queryURL := fmt.Sprintf("%s/v3/grants/%s/drafts", c.baseURL, grantID)
payload := buildDraftPayload(req)
payload := buildDraftPayload(req, true)

// Use pipe to stream multipart data
pr, pw := io.Pipe()
Expand Down Expand Up @@ -306,17 +309,26 @@ func (c *HTTPClient) DeleteDraft(ctx context.Context, grantID, draftID string) e
}

// SendDraft sends a draft.
func (c *HTTPClient) SendDraft(ctx context.Context, grantID, draftID string) (*domain.Message, error) {
func (c *HTTPClient) SendDraft(ctx context.Context, grantID, draftID string, req *domain.SendDraftRequest) (*domain.Message, error) {
queryURL := fmt.Sprintf("%s/v3/grants/%s/drafts/%s", c.baseURL, grantID, draftID)

req, err := http.NewRequestWithContext(ctx, "POST", queryURL, nil)
var bodyReader io.Reader
if req != nil && req.SignatureID != "" {
body, err := json.Marshal(map[string]string{"signature_id": req.SignatureID})
if err != nil {
return nil, fmt.Errorf("failed to marshal send draft request: %w", err)
}
bodyReader = bytes.NewReader(body)
}

httpReq, err := http.NewRequestWithContext(ctx, "POST", queryURL, bodyReader)
if err != nil {
return nil, err
}
c.setAuthHeader(req)
req.Header.Set("Content-Type", "application/json")
c.setAuthHeader(httpReq)
httpReq.Header.Set("Content-Type", "application/json")

resp, err := c.doRequest(ctx, req)
resp, err := c.doRequest(ctx, httpReq)
if err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrNetworkError, err)
}
Expand Down
Loading
Loading