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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ apikey*
*token*
!*_test.go
!internal/cli/auth/token.go
!internal/cli/rpc/token.go

# Secret files
secrets.json
Expand Down
1 change: 1 addition & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ The `init` command walks you through:
2. Selecting or creating an application
3. Generating and activating an API key
4. Syncing existing email accounts
5. Optionally registering a free `<subdomain>.nylas.email` domain for Agent Accounts (SSO login required)

Run `nylas init` again after partial setup — it skips completed steps.

Expand Down
22 changes: 15 additions & 7 deletions docs/RPC.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ capability surface to a thin client (for example a desktop app). The CLI binary
engine — it holds the credentials, runs the live pollers, and owns all business logic.
Clients are thin: they send requests and render the streamed results and notifications.

- **Endpoint:** `ws://127.0.0.1:7368/ws`
- **Endpoint:** `ws://127.0.0.1:7369/ws`
- **Protocol:** JSON-RPC 2.0 (bidirectional) over WebSocket
- **Auth:** per-session bearer token, loopback-only bind
- **Surface:** ~108 methods across 18 domains + live push notifications
Expand Down Expand Up @@ -33,18 +33,26 @@ Clients are thin: they send requests and render the streamed results and notific
Start the server:

```bash
nylas rpc serve # binds 127.0.0.1:7368
nylas rpc serve # binds 127.0.0.1:7369
nylas rpc serve --addr 127.0.0.1:9000
```

On first run the server generates a session token, stores it in the OS keyring, and
prints how to authenticate. To use a known token (headless / scripting):
prints how to authenticate. Print the current token (generates one if none exists):

```bash
nylas rpc token # prints the token
nylas rpc token --json # {"token":"…"}
nylas rpc token --copy # copy to clipboard
```

To inject a known token instead (headless / scripting):

```bash
NYLAS_WS_TOKEN=my-secret nylas rpc serve
```

Connect a WebSocket client to `ws://127.0.0.1:7368/ws` with the token, then send a request:
Connect a WebSocket client to `ws://127.0.0.1:7369/ws` with the token, then send a request:

```json
{ "jsonrpc": "2.0", "id": 1, "method": "email.list", "params": { "limit": 10 } }
Expand Down Expand Up @@ -128,8 +136,8 @@ The server holds live Nylas credentials, so the local socket is a real trust bou

| Flag / Env | Purpose | Default |
|---|---|---|
| `--addr` | bind address | `127.0.0.1:7368` |
| `NYLAS_WS_ADDR` | bind address (env; `--addr` wins) | `127.0.0.1:7368` |
| `--addr` | bind address | `127.0.0.1:7369` |
| `NYLAS_WS_ADDR` | bind address (env; `--addr` wins) | `127.0.0.1:7369` |
| `--allow-remote` | permit a non-loopback bind (warns) | `false` |
| `NYLAS_WS_TOKEN` | inject the session token (headless/CI) | auto-generated, keyring-brokered |
| `NYLAS_DISABLE_KEYRING` | store token/creds in `~/.config/nylas` instead of the keyring | `false` |
Expand Down Expand Up @@ -365,7 +373,7 @@ the `NYLAS_WS_POLL_*` env vars (see [Configuration](#configuration)).
Authenticate, list mail, and subscribe to live notifications (pseudocode):

```js
const ws = new WebSocket("ws://127.0.0.1:7368/ws", {
const ws = new WebSocket("ws://127.0.0.1:7369/ws", {
headers: { Authorization: `Bearer ${token}` },
});

Expand Down
27 changes: 27 additions & 0 deletions docs/commands/rpc-examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# RPC server — Node examples

Zero-dependency Node clients for the `nylas rpc serve` JSON-RPC WebSocket server
(see [`../../RPC.md`](../../RPC.md) for the full protocol and method reference).

These use Node's built-in global `WebSocket` (Node 21+), so there's nothing to install.

## Run

Start the server in one terminal:

```bash
nylas rpc serve # binds 127.0.0.1:7369
```

Then run an example — each script fetches the token itself via `nylas rpc token`
(or honors `NYLAS_WS_TOKEN` if you set it):

```bash
node list-sweep.js
node read-thread.js
```

| Script | What it does |
|--------|--------------|
| `list-sweep.js` | Calls every `*.list` method + a follow-up `get`, prints a pass/fail summary |
| `read-thread.js` | Finds a multi-message thread and expands each message |
82 changes: 82 additions & 0 deletions docs/commands/rpc-examples/list-sweep.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Sweep every list method over one connection and print a pass/fail summary.
// Run: node list-sweep.js (token comes from NYLAS_WS_TOKEN or `nylas rpc token`)
const { execFileSync } = require("node:child_process");

const token = process.env.NYLAS_WS_TOKEN || execFileSync("nylas", ["rpc", "token"]).toString().trim();
const ws = new WebSocket(`ws://127.0.0.1:7369/ws?token=${token}`);

// [method, params, result-field-to-count]
const calls = [
["config.read", null, null],
["grant.list", null, "grants"],
["calendar.list", null, "calendars"],
["email.list", { limit: 3 }, "messages"],
["thread.list", { limit: 3 }, "threads"],
["contact.list", { limit: 3 }, "contacts"],
["contact.group.list", null, "groups"],
["email.folder.list", null, "folders"],
["draft.list", { limit: 3 }, "drafts"],
["notetaker.list", null, "notetakers"],
["template.list", null, "templates"],
["audit.list", { limit: 3 }, "entries"],
["agentAccount.list", null, "accounts"],
["email.signature.list", null, "signatures"],
["email.scheduled.list", null, "scheduled"],
];

let id = 0;
const pending = new Map();
function call(method, params) {
return new Promise((resolve) => {
const myId = ++id;
pending.set(myId, resolve);
const req = { jsonrpc: "2.0", id: myId, method };
if (params) req.params = params;
ws.send(JSON.stringify(req));
});
}

ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data.toString());
const resolve = pending.get(msg.id);
pending.delete(msg.id);
if (typeof resolve === "function") resolve(msg);
});

ws.addEventListener("open", async () => {
console.log("connected\n");
let firstCalendarId = null;
let firstMessageId = null;

for (const [method, params, field] of calls) {
const msg = await call(method, params);
if (msg.error) {
console.log(`✗ ${method} ERROR ${msg.error.code}: ${msg.error.message}`);
continue;
}
if (field && Array.isArray(msg.result?.[field])) {
const arr = msg.result[field];
console.log(`✓ ${method} → ${arr.length} ${field}`);
if (method === "calendar.list" && arr[0]) firstCalendarId = arr[0].id;
if (method === "email.list" && arr[0]) firstMessageId = arr[0].id;
} else {
console.log(`✓ ${method} → { ${Object.keys(msg.result || {}).join(", ")} }`);
}
}

// follow-up get-by-id calls using ids captured above
for (const [method, params] of [
["calendar.get", firstCalendarId && { calendar_id: firstCalendarId }],
["email.get", firstMessageId && { message_id: firstMessageId }],
]) {
if (!params) continue;
const msg = await call(method, params);
if (msg.error) console.log(`✗ ${method} ERROR ${msg.error.message}`);
else console.log(`✓ ${method} → { ${Object.keys(msg.result || {}).join(", ")} }`);
}

ws.close();
});

ws.addEventListener("close", () => console.log("\nclosed"));
ws.addEventListener("error", (e) => { console.log("ws error:", e.message || e); process.exit(1); });
72 changes: 72 additions & 0 deletions docs/commands/rpc-examples/read-thread.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Find a multi-message thread and expand every message in it.
// Run: node read-thread.js (token comes from NYLAS_WS_TOKEN or `nylas rpc token`)
const { execFileSync } = require("node:child_process");

const token = process.env.NYLAS_WS_TOKEN || execFileSync("nylas", ["rpc", "token"]).toString().trim();
const ws = new WebSocket(`ws://127.0.0.1:7369/ws?token=${token}`);

let id = 0;
const pending = new Map();
function call(method, params) {
return new Promise((resolve) => {
const myId = ++id;
pending.set(myId, resolve);
const req = { jsonrpc: "2.0", id: myId, method };
if (params) req.params = params;
ws.send(JSON.stringify(req));
});
}

ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data.toString());
const resolve = pending.get(msg.id);
pending.delete(msg.id);
if (typeof resolve === "function") resolve(msg);
});

ws.addEventListener("open", async () => {
const list = await call("thread.list", { limit: 50 });
const threads = list.result?.threads || [];
console.log(`scanned ${threads.length} threads`);

const multi = threads
.map((t) => ({ t, n: (t.message_ids || []).length }))
.filter((x) => x.n > 1)
.sort((a, b) => b.n - a.n);

if (multi.length === 0) {
console.log("no multi-message threads in the first 50");
ws.close();
return;
}

const { t, n } = multi[0];
console.log(`\n=== thread ${t.id} (${n} messages) ===`);
console.log("subject :", t.subject);
console.log("participants:", (t.participants || []).map((p) => p.email || p.name).join(", "));
console.log("unread :", t.unread, "| starred:", t.starred);

const got = await call("thread.get", { thread_id: t.id });
const mids = got.result?.message_ids || t.message_ids || [];
console.log(`\nexpanding ${mids.length} messages:\n`);

let idx = 0;
for (const mid of mids) {
idx++;
const m = await call("email.get", { message_id: mid });
if (m.error) {
console.log(` [${idx}] ${mid} ERROR ${m.error.message}`);
continue;
}
const r = m.result;
console.log(` [${idx}/${mids.length}] ${r.date}`);
console.log(` from : ${(r.from || []).map((f) => f.email).join(", ")}`);
console.log(` subject: ${r.subject}`);
console.log(` snippet: ${(r.snippet || "").replace(/\s+/g, " ").slice(0, 130)}`);
console.log("");
}
ws.close();
});

ws.addEventListener("close", () => console.log("closed"));
ws.addEventListener("error", (e) => { console.log("ws error:", e.message || e); process.exit(1); });
5 changes: 5 additions & 0 deletions internal/cli/dashboard/exports.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,8 @@ func ActivateAPIKey(apiKey, clientID, region, orgID string) error {
func GetActiveOrgID() (string, error) {
return getActiveOrgID()
}

// CreateDomainService creates the dashboard domain management service (exported for setup wizard).
func CreateDomainService() (*dashboardapp.DomainService, error) {
return newDomainService()
}
7 changes: 7 additions & 0 deletions internal/cli/dashboard/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ func createAppService() (*dashboardapp.AppService, error) {

// createDomainService creates the dashboard domain management service.
func createDomainService() (domainService, error) {
return newDomainService()
}

// newDomainService wires the concrete dashboard domain service. Shared by the
// unexported factory (which adapts it to the domainService interface for test
// mocking) and the exported CreateDomainService used by the setup wizard.
func newDomainService() (*dashboardapp.DomainService, error) {
dpopSvc, secretStore, err := createDPoPService()
if err != nil {
return nil, err
Expand Down
1 change: 1 addition & 0 deletions internal/cli/rpc/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ func NewRPCCmd() *cobra.Command {
}

cmd.AddCommand(newServeCmd())
cmd.AddCommand(newTokenCmd())

return cmd
}
2 changes: 1 addition & 1 deletion internal/cli/rpc/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (

const (
envWSAddr = "NYLAS_WS_ADDR"
defaultAddr = "127.0.0.1:7368"
defaultAddr = "127.0.0.1:7369"

envPollFast = "NYLAS_WS_POLL_FAST"
envPollIdle = "NYLAS_WS_POLL_IDLE"
Expand Down
54 changes: 54 additions & 0 deletions internal/cli/rpc/token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package rpc

import (
"fmt"
"os"

"github.com/spf13/cobra"

"github.com/nylas/cli/internal/adapters/config"
"github.com/nylas/cli/internal/adapters/keyring"
"github.com/nylas/cli/internal/adapters/rpcserver"
"github.com/nylas/cli/internal/cli/common"
)

func newTokenCmd() *cobra.Command {
var copyToClipboard bool

cmd := &cobra.Command{
Use: "token",
Short: "Show or copy the RPC session token",
Long: "Print the JSON-RPC WebSocket session token used to authenticate against " +
"'nylas rpc serve'. Resolves the same way the server does: NYLAS_WS_TOKEN if set, " +
"otherwise the keyring, generating and persisting one if none exists.",
RunE: func(cmd *cobra.Command, args []string) error {
store, err := keyring.NewSecretStore(config.DefaultConfigDir())
if err != nil {
return fmt.Errorf("open secret store: %w", err)
}
token, err := rpcserver.ResolveToken(store, os.Getenv)
if err != nil {
return err
}

if jsonOutput, _ := cmd.Root().PersistentFlags().GetBool("json"); jsonOutput {
return common.PrintJSON(map[string]string{"token": token})
}

if copyToClipboard {
if err := common.CopyToClipboard(token); err != nil {
return common.WrapWriteError("clipboard", err)
}
_, _ = common.Green.Println("✓ RPC token copied to clipboard")
return nil
}

fmt.Println(token)
return nil
},
}

cmd.Flags().BoolVarP(&copyToClipboard, "copy", "c", false, "Copy to clipboard")

return cmd
}
21 changes: 21 additions & 0 deletions internal/cli/rpc/token_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package rpc

import "testing"

// The token command is a thin wrapper over rpcserver.ResolveToken (covered in
// internal/adapters/rpcserver/auth_test.go). This verifies it's wired into the
// rpc command with the documented --copy flag so scripts can rely on it.
func TestTokenCmd_Wiring(t *testing.T) {
root := NewRPCCmd()

cmd, _, err := root.Find([]string{"token"})
if err != nil {
t.Fatalf("find token subcommand: %v", err)
}
if cmd.Use != "token" {
t.Fatalf("Use = %q, want %q", cmd.Use, "token")
}
if cmd.Flags().Lookup("copy") == nil {
t.Fatal("token command missing --copy flag")
}
}
1 change: 1 addition & 0 deletions internal/cli/setup/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type SetupStatus struct {
HasGrants bool
ActiveAppID string
ActiveAppRegion string
AgentDomain string
}

// IsFirstRun returns true when the CLI has never been configured.
Expand Down
1 change: 1 addition & 0 deletions internal/cli/setup/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ This wizard walks you through:
2. Selecting or creating an application
3. Generating and activating an API key
4. Syncing existing email accounts
5. Optionally registering a free <subdomain>.nylas.email domain for Agent Accounts

Already have an API key? Skip the wizard:
nylas init --api-key <your-key>`,
Expand Down
Loading
Loading