Skip to content

Commit c96080e

Browse files
authored
TW-4827: add stored signatures support (#48)
1 parent dbf4419 commit c96080e

35 files changed

+2112
-41
lines changed

docs/COMMANDS.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ nylas email send ... --sign --encrypt # Sign AND encryp
194194
nylas email send --list-gpg-keys # List available GPG signing keys
195195
nylas email send --to EMAIL --template-id TPL --template-data '{}' # Send using a hosted template
196196
nylas email send --template-id TPL --template-data-file data.json --render-only
197+
nylas email send --to EMAIL --subject SUBJECT --body BODY --signature-id SIG # Send with stored signature
197198
nylas email search --query "QUERY" # Search emails
198199
nylas email delete <message-id> # Delete email
199200
nylas email mark read <message-id> # Mark as read
@@ -237,6 +238,7 @@ nylas email templates show <template-id> # Show template details
237238
nylas email templates update <template-id> [flags] # Update template
238239
nylas email templates delete <template-id> # Delete template
239240
nylas email templates use <template-id> --to EMAIL # Send using template
241+
nylas email templates use <template-id> --to EMAIL --signature-id SIG # Send using template + stored signature
240242
```
241243

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

312-
**Flags:** `--to`, `--cc`, `--bcc`, `--subject`, `--body`, `--reply-to`, `--attach`
316+
**Flags:** `--to`, `--cc`, `--bcc`, `--subject`, `--body`, `--reply-to`, `--attach`, `--signature-id`
317+
318+
---
319+
320+
## Signatures
321+
322+
```bash
323+
nylas email signatures list [grant-id] # List stored signatures
324+
nylas email signatures show <signature-id> [grant-id] # Show signature details
325+
nylas email signatures create [grant-id] --name NAME --body BODY # Create signature
326+
nylas email signatures create [grant-id] --name NAME --body-file FILE
327+
nylas email signatures update <signature-id> [grant-id] [flags] # Update signature
328+
nylas email signatures delete <signature-id> [grant-id] --yes # Delete signature
329+
```
313330

314331
---
315332

docs/commands/email.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ nylas email send --to "to@example.com" --template-id tpl_123 \
103103

104104
# Preview a hosted template render without sending
105105
nylas email send --template-id tpl_123 --template-data-file ./data.json --render-only
106+
107+
# Send with a stored signature
108+
nylas email send --to "to@example.com" --subject "Subject" --body "Body" \
109+
--signature-id sig_123
106110
```
107111

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

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

189+
`--signature-id` can't be combined with `--sign` or `--encrypt`, because stored signatures are only supported on the standard JSON send and draft endpoints.
190+
191+
### Signatures
192+
193+
Manage stored signatures on a grant and reuse them from send and draft commands:
194+
195+
```bash
196+
# List signatures for a grant
197+
nylas email signatures list [grant-id]
198+
199+
# Show a specific signature
200+
nylas email signatures show <signature-id> [grant-id]
201+
202+
# Create a signature
203+
nylas email signatures create [grant-id] --name "Work" --body-file ./signature.html
204+
205+
# Update a signature
206+
nylas email signatures update <signature-id> [grant-id] --name "Work Updated" --body "<p>Updated</p>"
207+
208+
# Delete a signature
209+
nylas email signatures delete <signature-id> [grant-id] --yes
210+
211+
# Create a draft with a stored signature
212+
nylas email drafts create --to "to@example.com" --subject "Draft" --body "Body" \
213+
--signature-id sig_123
214+
215+
# Send a draft with a stored signature
216+
nylas email drafts send <draft-id> --signature-id sig_123
217+
218+
# Use a local template and append a stored signature
219+
nylas email templates use <template-id> --to "to@example.com" --signature-id sig_123
220+
```
221+
184222
**Reading encrypted/signed emails:**
185223

186224
```bash

internal/adapters/nylas/client.go

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,38 @@ func (c *HTTPClient) doRequest(ctx context.Context, req *http.Request) (*http.Re
252252
return nil, lastErr
253253
}
254254

255+
func (c *HTTPClient) doRequestNoRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
256+
req.Header.Set("User-Agent", version.UserAgent())
257+
258+
// Apply rate limiting - wait for permission to proceed
259+
if err := c.rateLimiter.Wait(ctx); err != nil {
260+
return nil, fmt.Errorf("rate limiter: %w", err)
261+
}
262+
263+
// Ensure context has timeout
264+
ctxWithTimeout, cancel := c.ensureContext(ctx)
265+
266+
// Execute request
267+
resp, err := c.httpClient.Do(req.WithContext(ctxWithTimeout))
268+
if err != nil {
269+
cancel()
270+
271+
// Don't mask parent context cancellation.
272+
if ctx.Err() != nil {
273+
return nil, ctx.Err()
274+
}
275+
276+
return nil, fmt.Errorf("%w: %v", domain.ErrNetworkError, err)
277+
}
278+
279+
resp.Body = &cancelOnCloseBody{
280+
ReadCloser: resp.Body,
281+
cancel: cancel,
282+
}
283+
284+
return resp, nil
285+
}
286+
255287
type cancelOnCloseBody struct {
256288
io.ReadCloser
257289
cancel context.CancelFunc
@@ -327,6 +359,17 @@ func (c *HTTPClient) doJSONRequestInternal(
327359
body any,
328360
withAuth bool,
329361
acceptedStatuses ...int,
362+
) (*http.Response, error) {
363+
return c.doJSONRequestInternalWithRetry(ctx, method, url, body, withAuth, true, acceptedStatuses...)
364+
}
365+
366+
func (c *HTTPClient) doJSONRequestInternalWithRetry(
367+
ctx context.Context,
368+
method, url string,
369+
body any,
370+
withAuth bool,
371+
retry bool,
372+
acceptedStatuses ...int,
330373
) (*http.Response, error) {
331374
// Default accepted statuses
332375
if len(acceptedStatuses) == 0 {
@@ -358,8 +401,13 @@ func (c *HTTPClient) doJSONRequestInternal(
358401
c.setAuthHeader(req)
359402
}
360403

361-
// Execute request with rate limiting
362-
resp, err := c.doRequest(ctx, req)
404+
// Execute request with the configured retry policy.
405+
var resp *http.Response
406+
if retry {
407+
resp, err = c.doRequest(ctx, req)
408+
} else {
409+
resp, err = c.doRequestNoRetry(ctx, req)
410+
}
363411
if err != nil {
364412
return nil, err
365413
}
@@ -411,6 +459,15 @@ func (c *HTTPClient) doJSONRequest(
411459
return c.doJSONRequestInternal(ctx, method, url, body, true, acceptedStatuses...)
412460
}
413461

462+
func (c *HTTPClient) doJSONRequestNoRetry(
463+
ctx context.Context,
464+
method, url string,
465+
body any,
466+
acceptedStatuses ...int,
467+
) (*http.Response, error) {
468+
return c.doJSONRequestInternalWithRetry(ctx, method, url, body, true, false, acceptedStatuses...)
469+
}
470+
414471
// decodeJSONResponse decodes a JSON response body into the provided struct.
415472
// It properly closes the response body after reading.
416473
//
@@ -440,7 +497,7 @@ func (c *HTTPClient) doJSONRequestNoAuth(
440497
body any,
441498
acceptedStatuses ...int,
442499
) (*http.Response, error) {
443-
return c.doJSONRequestInternal(ctx, method, url, body, false, acceptedStatuses...)
500+
return c.doJSONRequestInternalWithRetry(ctx, method, url, body, false, true, acceptedStatuses...)
444501
}
445502

446503
// validateRequired validates that a required field is not empty.

internal/adapters/nylas/client_mock_methods_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ func TestMockClient_Drafts(t *testing.T) {
195195
})
196196

197197
t.Run("SendDraft", func(t *testing.T) {
198-
msg, err := mock.SendDraft(ctx, "grant-123", "draft-456")
198+
msg, err := mock.SendDraft(ctx, "grant-123", "draft-456", nil)
199199
require.NoError(t, err)
200200
assert.NotEmpty(t, msg.ID)
201201
assert.True(t, mock.SendDraftCalled)

internal/adapters/nylas/demo_drafts.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,6 @@ func (d *DemoClient) DeleteDraft(ctx context.Context, grantID, draftID string) e
4343
}
4444

4545
// SendDraft simulates sending a draft.
46-
func (d *DemoClient) SendDraft(ctx context.Context, grantID, draftID string) (*domain.Message, error) {
46+
func (d *DemoClient) SendDraft(ctx context.Context, grantID, draftID string, req *domain.SendDraftRequest) (*domain.Message, error) {
4747
return &domain.Message{ID: "sent-from-draft", Subject: "Sent Draft"}, nil
4848
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package nylas
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/nylas/cli/internal/domain"
8+
)
9+
10+
func (d *DemoClient) GetSignatures(ctx context.Context, grantID string) ([]domain.Signature, error) {
11+
now := time.Now()
12+
return []domain.Signature{
13+
{
14+
ID: "sig-demo-work",
15+
Name: "Work",
16+
Body: "<div><strong>Demo User</strong><br/>Developer Advocate</div>",
17+
CreatedAt: now.Add(-24 * time.Hour),
18+
UpdatedAt: now.Add(-2 * time.Hour),
19+
},
20+
{
21+
ID: "sig-demo-mobile",
22+
Name: "Mobile",
23+
Body: "<div>Sent from my phone</div>",
24+
CreatedAt: now.Add(-48 * time.Hour),
25+
UpdatedAt: now.Add(-6 * time.Hour),
26+
},
27+
}, nil
28+
}
29+
30+
func (d *DemoClient) GetSignature(ctx context.Context, grantID, signatureID string) (*domain.Signature, error) {
31+
signatures, err := d.GetSignatures(ctx, grantID)
32+
if err != nil {
33+
return nil, err
34+
}
35+
for _, signature := range signatures {
36+
if signature.ID == signatureID {
37+
return &signature, nil
38+
}
39+
}
40+
return nil, domain.ErrSignatureNotFound
41+
}
42+
43+
func (d *DemoClient) CreateSignature(ctx context.Context, grantID string, req *domain.CreateSignatureRequest) (*domain.Signature, error) {
44+
now := time.Now()
45+
return &domain.Signature{
46+
ID: "sig-demo-new",
47+
Name: req.Name,
48+
Body: req.Body,
49+
CreatedAt: now,
50+
UpdatedAt: now,
51+
}, nil
52+
}
53+
54+
func (d *DemoClient) UpdateSignature(ctx context.Context, grantID, signatureID string, req *domain.UpdateSignatureRequest) (*domain.Signature, error) {
55+
signature := &domain.Signature{
56+
ID: signatureID,
57+
Name: "Work",
58+
Body: "<div><strong>Demo User</strong><br/>Developer Advocate</div>",
59+
CreatedAt: time.Now().Add(-24 * time.Hour),
60+
UpdatedAt: time.Now(),
61+
}
62+
if req.Name != nil {
63+
signature.Name = *req.Name
64+
}
65+
if req.Body != nil {
66+
signature.Body = *req.Body
67+
}
68+
return signature, nil
69+
}
70+
71+
func (d *DemoClient) DeleteSignature(ctx context.Context, grantID, signatureID string) error {
72+
return nil
73+
}

internal/adapters/nylas/drafts.go

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ func (c *HTTPClient) CreateDraft(ctx context.Context, grantID string, req *domai
9898

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

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

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

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

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

312-
req, err := http.NewRequestWithContext(ctx, "POST", queryURL, nil)
315+
var bodyReader io.Reader
316+
if req != nil && req.SignatureID != "" {
317+
body, err := json.Marshal(map[string]string{"signature_id": req.SignatureID})
318+
if err != nil {
319+
return nil, fmt.Errorf("failed to marshal send draft request: %w", err)
320+
}
321+
bodyReader = bytes.NewReader(body)
322+
}
323+
324+
httpReq, err := http.NewRequestWithContext(ctx, "POST", queryURL, bodyReader)
313325
if err != nil {
314326
return nil, err
315327
}
316-
c.setAuthHeader(req)
317-
req.Header.Set("Content-Type", "application/json")
328+
c.setAuthHeader(httpReq)
329+
httpReq.Header.Set("Content-Type", "application/json")
318330

319-
resp, err := c.doRequest(ctx, req)
331+
resp, err := c.doRequest(ctx, httpReq)
320332
if err != nil {
321333
return nil, fmt.Errorf("%w: %v", domain.ErrNetworkError, err)
322334
}

0 commit comments

Comments
 (0)