carlyemail 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/carlyemail.js +497 -0
- package/package.json +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SWH Labs LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# carlyemail
|
|
2
|
+
|
|
3
|
+
Real email inboxes your agent can send, receive and reply from.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx carlyemail signup --human-email you@example.com --username my-agent
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
That returns a working address and an API key. Confirm the emailed code and it
|
|
10
|
+
can send:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx carlyemail verify 123456
|
|
14
|
+
npx carlyemail send \
|
|
15
|
+
--from my-agent@agents.carlyemail.com \
|
|
16
|
+
--to someone@example.com \
|
|
17
|
+
--subject "Hello" \
|
|
18
|
+
--text "Sent by an agent with its own address."
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Install it properly if you use it often:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install -g carlyemail
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
| | |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `signup --human-email <e> --username <u>` | Create an account and an inbox |
|
|
32
|
+
| `verify <code>` | Confirm the owner email |
|
|
33
|
+
| `whoami` | Identity and scope of the current key |
|
|
34
|
+
| `inboxes` | List inboxes |
|
|
35
|
+
| `create --username <u>` | Create an inbox |
|
|
36
|
+
| `delete <inbox> --yes` | Delete an inbox and its mail |
|
|
37
|
+
| `send --from <i> --to <a> --subject <s> --text <t>` | Send an email |
|
|
38
|
+
| `messages <inbox>` | List messages |
|
|
39
|
+
| `plan` | Current plan, with usage against every limit |
|
|
40
|
+
| `upgrade <plan>` | A Stripe checkout link |
|
|
41
|
+
| `billing` | Invoices, card changes, cancellation |
|
|
42
|
+
| `mcp` | The MCP endpoint, for Claude and other clients |
|
|
43
|
+
|
|
44
|
+
## Scripting
|
|
45
|
+
|
|
46
|
+
`--json` prints the API response untouched — the same shape the
|
|
47
|
+
[API reference](https://docs.carlyemail.com/api-reference) documents, not a
|
|
48
|
+
reshaped subset:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
carlyemail inboxes --json | jq -r '.inboxes[].email'
|
|
52
|
+
carlyemail plan --json | jq '.organization.storage_bytes'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Failures still exit `1` under `--json`, and the error is still printed, so
|
|
56
|
+
`set -e` behaves.
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
The key is saved to `~/.carlyemail/config.json`, written `0600` — it can read
|
|
61
|
+
and send your mail, so it is not left group-readable.
|
|
62
|
+
|
|
63
|
+
Two environment variables override it, and the environment always wins:
|
|
64
|
+
|
|
65
|
+
| | |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `CARLYEMAIL_API_KEY` | Use this key instead of the saved one |
|
|
68
|
+
| `CARLYEMAIL_API_URL` | Point at a different deployment |
|
|
69
|
+
|
|
70
|
+
That ordering is what makes CI and containers work without touching the saved
|
|
71
|
+
state, and it means running one command as a different account is a prefix
|
|
72
|
+
rather than a login.
|
|
73
|
+
|
|
74
|
+
## Notes
|
|
75
|
+
|
|
76
|
+
**Sign-up is idempotent by owner email, and rotates the key.** Running it again
|
|
77
|
+
for an address that already has an account issues a new key and **revokes the
|
|
78
|
+
previous one**. It is how you recover a lost key; it is not a way to add a
|
|
79
|
+
second inbox. Use `create` for that.
|
|
80
|
+
|
|
81
|
+
**`delete` requires `--yes`.** It removes the inbox and every message in it,
|
|
82
|
+
and there is no undo.
|
|
83
|
+
|
|
84
|
+
**Exit codes are meaningful.** `0` on success, `1` on any failure, so
|
|
85
|
+
`carlyemail send … || handle-it` behaves in a script.
|
|
86
|
+
|
|
87
|
+
**Errors carry their fix.** The API answers with a message, the thing that
|
|
88
|
+
clears it, and a documentation link; all three are printed.
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
✗ Inbox limit reached (3 on the free plan).
|
|
92
|
+
Upgrade the plan, or delete an inbox you no longer need.
|
|
93
|
+
https://docs.carlyemail.com/inboxes
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Requirements
|
|
97
|
+
|
|
98
|
+
Node 18 or newer. No dependencies — this is one file that uses Node's built-in
|
|
99
|
+
`fetch`, so `npx` never resolves a tree and never fails for a reason unrelated
|
|
100
|
+
to the thing you asked for.
|
|
101
|
+
|
|
102
|
+
## Licence
|
|
103
|
+
|
|
104
|
+
MIT — see `LICENSE`. That covers **this CLI only**. The CarlyEmail service it
|
|
105
|
+
talks to is a separate, proprietary product; a permissive licence on an HTTP
|
|
106
|
+
client grants nothing over the API behind it.
|
|
107
|
+
|
|
108
|
+
## Links
|
|
109
|
+
|
|
110
|
+
- [Documentation](https://docs.carlyemail.com)
|
|
111
|
+
- [API reference](https://docs.carlyemail.com/api-reference)
|
|
112
|
+
- [Pricing](https://carlyemail.com/pricing)
|
|
113
|
+
- [Support](https://docs.carlyemail.com/support)
|
package/carlyemail.js
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CarlyEmail command line.
|
|
3
|
+
//
|
|
4
|
+
// One file, no dependencies, no build step. That is a deliberate constraint
|
|
5
|
+
// rather than minimalism for its own sake: this is the first thing a new user
|
|
6
|
+
// runs, usually through `npx`, and every dependency is a chance for that first
|
|
7
|
+
// command to fail on somebody's machine for a reason that has nothing to do
|
|
8
|
+
// with us. Node 18+ has `fetch` built in, which is the only thing a REST client
|
|
9
|
+
// actually needs.
|
|
10
|
+
//
|
|
11
|
+
// The CLI is a thin shell over the same public API the docs describe. It holds
|
|
12
|
+
// no logic the API does not — if a command here disagrees with the server, the
|
|
13
|
+
// server is right.
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, realpathSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
export const VERSION = "0.1.0";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_API = "https://api.carlyemail.com";
|
|
23
|
+
const CONFIG_DIR = join(homedir(), ".carlyemail");
|
|
24
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------- output
|
|
27
|
+
|
|
28
|
+
const isTTY = process.stdout.isTTY;
|
|
29
|
+
const paint = (code, s) => (isTTY ? `[${code}m${s}[0m` : s);
|
|
30
|
+
const dim = (s) => paint(2, s);
|
|
31
|
+
const bold = (s) => paint(1, s);
|
|
32
|
+
|
|
33
|
+
export const ok = (s) => `${paint(32, "✓")} ${s}`;
|
|
34
|
+
export const arrow = (s) => `${dim("→")} ${s}`;
|
|
35
|
+
|
|
36
|
+
/** A credential is shown once, at creation, and never echoed again. */
|
|
37
|
+
export function maskKey(key) {
|
|
38
|
+
if (!key || key.length < 12) return "(hidden)";
|
|
39
|
+
return `${key.slice(0, 9)}${"·".repeat(8)}${key.slice(-4)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------- config
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Where the API key lives between commands.
|
|
46
|
+
*
|
|
47
|
+
* The environment wins over the file, always. That is what lets CI, a
|
|
48
|
+
* container, or a second account run without touching the machine's saved
|
|
49
|
+
* state — and it means `CARLYEMAIL_API_KEY=... carlyemail whoami` does the
|
|
50
|
+
* obvious thing rather than silently using somebody else's key.
|
|
51
|
+
*/
|
|
52
|
+
export function loadConfig(env = process.env, file = CONFIG_FILE) {
|
|
53
|
+
const config = { api_url: DEFAULT_API };
|
|
54
|
+
if (existsSync(file)) {
|
|
55
|
+
try {
|
|
56
|
+
Object.assign(config, JSON.parse(readFileSync(file, "utf8")));
|
|
57
|
+
} catch {
|
|
58
|
+
// A corrupt config must not brick every command. `signup` rewrites it.
|
|
59
|
+
process.stderr.write(dim(`warning: ${file} is not valid JSON; ignoring it\n`));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (env.CARLYEMAIL_API_KEY) config.api_key = env.CARLYEMAIL_API_KEY;
|
|
63
|
+
if (env.CARLYEMAIL_API_URL) config.api_url = env.CARLYEMAIL_API_URL;
|
|
64
|
+
return config;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Written 0600, and the directory 0700.
|
|
69
|
+
*
|
|
70
|
+
* This file holds a credential that can read and send someone's mail. The
|
|
71
|
+
* default umask would leave it group- and world-readable on a shared machine,
|
|
72
|
+
* which is not a state anybody would choose if asked.
|
|
73
|
+
*/
|
|
74
|
+
export function saveConfig(config, file = CONFIG_FILE, dir = CONFIG_DIR) {
|
|
75
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
76
|
+
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
77
|
+
chmodSync(file, 0o600); // existing files keep their old mode without this
|
|
78
|
+
return file;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------- errors
|
|
82
|
+
|
|
83
|
+
export class ApiError extends Error {
|
|
84
|
+
constructor(status, body) {
|
|
85
|
+
const message = (body && body.message) || `request failed with ${status}`;
|
|
86
|
+
super(message);
|
|
87
|
+
this.status = status;
|
|
88
|
+
this.code = body && body.code;
|
|
89
|
+
this.fix = body && body.fix;
|
|
90
|
+
this.docs = body && body.docs;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The API answers with `{name, code, message, fix, docs}`. The `fix` field
|
|
95
|
+
* is the actionable half and printing only `message` throws it away — which
|
|
96
|
+
* matters most for the plan limits, where the message says what ran out and
|
|
97
|
+
* the fix says what clears it.
|
|
98
|
+
*/
|
|
99
|
+
render() {
|
|
100
|
+
const lines = [`${paint(31, "✗")} ${this.message}`];
|
|
101
|
+
if (this.fix) lines.push(` ${this.fix}`);
|
|
102
|
+
if (this.docs) lines.push(dim(` ${this.docs}`));
|
|
103
|
+
return lines.join("\n");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
class UsageError extends Error {}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------- transport
|
|
110
|
+
|
|
111
|
+
export async function request(config, method, path, { body, auth = true, fetchImpl = fetch } = {}) {
|
|
112
|
+
const headers = { accept: "application/json" };
|
|
113
|
+
if (body !== undefined) headers["content-type"] = "application/json";
|
|
114
|
+
if (auth) {
|
|
115
|
+
if (!config.api_key) {
|
|
116
|
+
throw new UsageError(
|
|
117
|
+
"No API key. Run `carlyemail signup` first, or set CARLYEMAIL_API_KEY."
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
headers.authorization = `Bearer ${config.api_key}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let response;
|
|
124
|
+
try {
|
|
125
|
+
response = await fetchImpl(`${config.api_url}${path}`, {
|
|
126
|
+
method,
|
|
127
|
+
headers,
|
|
128
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
129
|
+
});
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
throw new Error(`could not reach ${config.api_url}: ${cause.message}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (response.status === 204) return null;
|
|
135
|
+
|
|
136
|
+
const text = await response.text();
|
|
137
|
+
let parsed = null;
|
|
138
|
+
if (text) {
|
|
139
|
+
try {
|
|
140
|
+
parsed = JSON.parse(text);
|
|
141
|
+
} catch {
|
|
142
|
+
// A gateway or proxy failing in front of us answers HTML, not our
|
|
143
|
+
// envelope. Say so rather than printing a page of markup.
|
|
144
|
+
if (!response.ok) throw new ApiError(response.status, { message: text.slice(0, 200) });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!response.ok) throw new ApiError(response.status, parsed);
|
|
148
|
+
return parsed;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------- arguments
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `--flag value`, `--flag=value`, and bare positionals. No parser library,
|
|
155
|
+
* because the grammar is this small and a dependency here costs more than it
|
|
156
|
+
* saves.
|
|
157
|
+
*/
|
|
158
|
+
export function parseArgs(argv) {
|
|
159
|
+
const flags = {};
|
|
160
|
+
const positional = [];
|
|
161
|
+
for (let i = 0; i < argv.length; i++) {
|
|
162
|
+
const arg = argv[i];
|
|
163
|
+
if (!arg.startsWith("--")) {
|
|
164
|
+
positional.push(arg);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const equals = arg.indexOf("=");
|
|
168
|
+
if (equals !== -1) {
|
|
169
|
+
flags[arg.slice(2, equals)] = arg.slice(equals + 1);
|
|
170
|
+
} else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
|
|
171
|
+
flags[arg.slice(2)] = argv[++i];
|
|
172
|
+
} else {
|
|
173
|
+
flags[arg.slice(2)] = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { flags, positional };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function required(flags, name) {
|
|
180
|
+
const value = flags[name];
|
|
181
|
+
if (typeof value !== "string" || !value) {
|
|
182
|
+
throw new UsageError(`--${name} is required`);
|
|
183
|
+
}
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------- commands
|
|
188
|
+
|
|
189
|
+
const commands = {};
|
|
190
|
+
const define = (name, summary, usage, run) => {
|
|
191
|
+
commands[name] = { name, summary, usage, run };
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Print either the raw API payload or a human rendering of it.
|
|
196
|
+
*
|
|
197
|
+
* `--json` exists because this is a CLI for people building agents, and the
|
|
198
|
+
* second thing they do after running a command by hand is put it in a script.
|
|
199
|
+
* Making them re-parse a table we formatted would be a strange thing to ask
|
|
200
|
+
* when we already had the JSON.
|
|
201
|
+
*
|
|
202
|
+
* The payload is passed through untouched — not a reshaped subset — so what a
|
|
203
|
+
* script sees matches the API reference rather than our idea of the useful
|
|
204
|
+
* fields.
|
|
205
|
+
*/
|
|
206
|
+
function emit(ctx, payload, render) {
|
|
207
|
+
if (ctx.flags.json) {
|
|
208
|
+
ctx.print(JSON.stringify(payload, null, 2));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
render();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
define(
|
|
215
|
+
"signup",
|
|
216
|
+
"Create an account and an inbox",
|
|
217
|
+
"carlyemail signup --human-email you@example.com --username my-agent",
|
|
218
|
+
async (ctx) => {
|
|
219
|
+
const human_email = required(ctx.flags, "human-email");
|
|
220
|
+
const username = required(ctx.flags, "username");
|
|
221
|
+
|
|
222
|
+
const out = await request(ctx.config, "POST", "/v0/agent/sign-up", {
|
|
223
|
+
auth: false,
|
|
224
|
+
body: { human_email, username, source: "cli" },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Saved before anything else is printed: the key is returned exactly once
|
|
228
|
+
// and is unrecoverable afterwards, so losing it to a later crash would
|
|
229
|
+
// cost the account.
|
|
230
|
+
const saved = saveConfig(
|
|
231
|
+
{ ...ctx.config, api_key: out.api_key, organization_id: out.organization_id },
|
|
232
|
+
ctx.configFile,
|
|
233
|
+
ctx.configDir
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
ctx.print(ok(bold(out.inbox_id)));
|
|
237
|
+
ctx.print(ok(`key saved to ${saved}`));
|
|
238
|
+
ctx.print("");
|
|
239
|
+
ctx.print(arrow(`check ${human_email} for a 6-digit code, then:`));
|
|
240
|
+
ctx.print(` carlyemail verify <code>`);
|
|
241
|
+
ctx.print("");
|
|
242
|
+
ctx.print(dim("Until it is confirmed the account can read its own mail but not send."));
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
define("verify", "Confirm the owner email with the code", "carlyemail verify 123456", async (ctx) => {
|
|
247
|
+
const code = ctx.positional[0] || ctx.flags.code;
|
|
248
|
+
if (!code) throw new UsageError("the 6-digit code is required: carlyemail verify 123456");
|
|
249
|
+
|
|
250
|
+
await request(ctx.config, "POST", "/v0/agent/verify", { body: { otp_code: String(code) } });
|
|
251
|
+
ctx.print(ok("verified — this account can send now"));
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
define("whoami", "Show the key's identity and scope", "carlyemail whoami", async (ctx) => {
|
|
255
|
+
const me = await request(ctx.config, "GET", "/v0/auth/me");
|
|
256
|
+
emit(ctx, me, () => {
|
|
257
|
+
ctx.print(`${dim("organization")} ${me.organization_id ?? "—"}`);
|
|
258
|
+
ctx.print(`${dim("scope")} ${me.scope_type ?? "—"}${me.scope_id ? dim(` ${me.scope_id}`) : ""}`);
|
|
259
|
+
// Worth its own line rather than folded into scope: a key pinned to one
|
|
260
|
+
// inbox cannot reach a sibling, and "why does this 404" is the question it
|
|
261
|
+
// answers.
|
|
262
|
+
if (me.inbox_id) ctx.print(`${dim("inbox")} ${me.inbox_id} ${dim("(this key sees only this inbox)")}`);
|
|
263
|
+
ctx.print(`${dim("key")} ${maskKey(ctx.config.api_key)} ${dim(me.api_key_id ?? "")}`);
|
|
264
|
+
ctx.print(`${dim("api")} ${ctx.config.api_url}`);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
define("inboxes", "List inboxes", "carlyemail inboxes", async (ctx) => {
|
|
269
|
+
const out = await request(ctx.config, "GET", "/v0/inboxes");
|
|
270
|
+
emit(ctx, out, () => {
|
|
271
|
+
if (!out.inboxes.length) {
|
|
272
|
+
ctx.print(dim("no inboxes"));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
for (const inbox of out.inboxes) {
|
|
276
|
+
ctx.print(`${bold(inbox.email)}${inbox.display_name ? dim(` ${inbox.display_name}`) : ""}`);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
define("create", "Create an inbox", "carlyemail create --username support", async (ctx) => {
|
|
282
|
+
const username = ctx.positional[0] || required(ctx.flags, "username");
|
|
283
|
+
const body = { username };
|
|
284
|
+
if (typeof ctx.flags.domain === "string") body.domain = ctx.flags.domain;
|
|
285
|
+
const inbox = await request(ctx.config, "POST", "/v0/inboxes", { body });
|
|
286
|
+
emit(ctx, inbox, () => ctx.print(ok(bold(inbox.email))));
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
define("delete", "Delete an inbox and its mail", "carlyemail delete support@carlyemail.com", async (ctx) => {
|
|
290
|
+
const inbox = ctx.positional[0];
|
|
291
|
+
if (!inbox) throw new UsageError("which inbox? carlyemail delete <inbox>");
|
|
292
|
+
if (!ctx.flags.yes) {
|
|
293
|
+
throw new UsageError(
|
|
294
|
+
`this deletes ${inbox} and every message in it. Re-run with --yes to confirm.`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
await request(ctx.config, "DELETE", `/v0/inboxes/${encodeURIComponent(inbox)}`);
|
|
298
|
+
ctx.print(ok(`deleted ${inbox}`));
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
define(
|
|
302
|
+
"send",
|
|
303
|
+
"Send an email",
|
|
304
|
+
'carlyemail send --from me@carlyemail.com --to you@example.com --subject Hi --text "Hello"',
|
|
305
|
+
async (ctx) => {
|
|
306
|
+
const from = required(ctx.flags, "from");
|
|
307
|
+
const body = {
|
|
308
|
+
to: required(ctx.flags, "to").split(",").map((s) => s.trim()),
|
|
309
|
+
subject: typeof ctx.flags.subject === "string" ? ctx.flags.subject : undefined,
|
|
310
|
+
text: typeof ctx.flags.text === "string" ? ctx.flags.text : undefined,
|
|
311
|
+
html: typeof ctx.flags.html === "string" ? ctx.flags.html : undefined,
|
|
312
|
+
};
|
|
313
|
+
const sent = await request(
|
|
314
|
+
ctx.config,
|
|
315
|
+
"POST",
|
|
316
|
+
`/v0/inboxes/${encodeURIComponent(from)}/messages/send`,
|
|
317
|
+
{ body }
|
|
318
|
+
);
|
|
319
|
+
emit(ctx, sent, () => ctx.print(ok(`sent ${dim(sent.message_id ?? "")}`)));
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
define("messages", "List messages in an inbox", "carlyemail messages me@carlyemail.com", async (ctx) => {
|
|
324
|
+
const inbox = ctx.positional[0];
|
|
325
|
+
if (!inbox) throw new UsageError("which inbox? carlyemail messages <inbox>");
|
|
326
|
+
const out = await request(
|
|
327
|
+
ctx.config,
|
|
328
|
+
"GET",
|
|
329
|
+
`/v0/inboxes/${encodeURIComponent(inbox)}/messages`
|
|
330
|
+
);
|
|
331
|
+
emit(ctx, out, () => {
|
|
332
|
+
if (!out.messages.length) {
|
|
333
|
+
ctx.print(dim("no messages"));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
for (const m of out.messages) {
|
|
337
|
+
const when = (m.timestamp || "").slice(0, 16).replace("T", " ");
|
|
338
|
+
ctx.print(`${dim(when)} ${bold(m.subject || "(no subject)")}`);
|
|
339
|
+
ctx.print(`${" ".repeat(18)}${dim(`from ${m.from ?? m.from_address ?? "?"}`)}`);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
define("plan", "Show the current plan and its limits", "carlyemail plan", async (ctx) => {
|
|
345
|
+
const [billing, org] = await Promise.all([
|
|
346
|
+
request(ctx.config, "GET", "/v0/billing"),
|
|
347
|
+
request(ctx.config, "GET", "/v0/organizations"),
|
|
348
|
+
]);
|
|
349
|
+
if (ctx.flags.json) {
|
|
350
|
+
ctx.print(JSON.stringify({ billing, organization: org }, null, 2));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const cap = (n) => (n === null || n === undefined ? "unlimited" : String(n));
|
|
354
|
+
const gb = (b) => (b === null || b === undefined ? "unlimited" : `${(b / 1024 ** 3).toFixed(1)} GB`);
|
|
355
|
+
|
|
356
|
+
ctx.print(`${bold(billing.plan)} ${dim(`$${billing.price_usd_month}/mo`)}`);
|
|
357
|
+
ctx.print("");
|
|
358
|
+
ctx.print(` ${dim("inboxes")} ${org.inbox_count} / ${cap(org.inbox_limit)}`);
|
|
359
|
+
ctx.print(` ${dim("domains")} ${org.domain_count} / ${cap(org.domain_limit)}`);
|
|
360
|
+
ctx.print(` ${dim("pods")} ${org.pod_count} / ${cap(org.pod_limit)}`);
|
|
361
|
+
ctx.print(` ${dim("webhooks")} ${org.webhook_count} / ${cap(org.webhook_limit)}`);
|
|
362
|
+
ctx.print(` ${dim("storage")} ${gb(org.storage_bytes)} / ${gb(org.storage_limit_bytes)}`);
|
|
363
|
+
ctx.print(` ${dim("email")} ${cap(billing.monthly_emails)} a month`);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
define("upgrade", "Get a checkout link for a paid plan", "carlyemail upgrade developer", async (ctx) => {
|
|
367
|
+
const plan = ctx.positional[0] || ctx.flags.plan;
|
|
368
|
+
if (!plan) throw new UsageError("which plan? carlyemail upgrade developer|startup");
|
|
369
|
+
const out = await request(ctx.config, "POST", "/v0/billing/checkout", { body: { plan } });
|
|
370
|
+
emit(ctx, out, () => {
|
|
371
|
+
ctx.print(arrow("open this to upgrade:"));
|
|
372
|
+
ctx.print(out.checkout_url);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
define("billing", "Open the billing portal (invoices, card, cancel)", "carlyemail billing", async (ctx) => {
|
|
377
|
+
const out = await request(ctx.config, "POST", "/v0/billing/portal");
|
|
378
|
+
emit(ctx, out, () => {
|
|
379
|
+
ctx.print(arrow("invoices, card changes and cancellation:"));
|
|
380
|
+
ctx.print(out.portal_url);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
define("mcp", "Print the MCP endpoint for Claude and other clients", "carlyemail mcp", (ctx) => {
|
|
385
|
+
const endpoint = `${ctx.config.api_url}/mcp`;
|
|
386
|
+
ctx.print(bold(endpoint));
|
|
387
|
+
ctx.print("");
|
|
388
|
+
ctx.print("Add it as a custom connector. It speaks OAuth with dynamic client");
|
|
389
|
+
ctx.print("registration, so there is no client_id to configure.");
|
|
390
|
+
ctx.print("");
|
|
391
|
+
ctx.print(dim("An API key works too: Authorization: Bearer <key>"));
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------- help
|
|
395
|
+
|
|
396
|
+
export function helpText() {
|
|
397
|
+
const width = Math.max(...Object.keys(commands).map((n) => n.length));
|
|
398
|
+
const lines = [
|
|
399
|
+
bold("carlyemail") + dim(` ${VERSION}`),
|
|
400
|
+
"",
|
|
401
|
+
"Real email inboxes your agent can send, receive and reply from.",
|
|
402
|
+
"",
|
|
403
|
+
bold("Commands"),
|
|
404
|
+
];
|
|
405
|
+
for (const c of Object.values(commands)) {
|
|
406
|
+
lines.push(` ${c.name.padEnd(width + 2)}${c.summary}`);
|
|
407
|
+
}
|
|
408
|
+
lines.push(
|
|
409
|
+
"",
|
|
410
|
+
bold("Getting started"),
|
|
411
|
+
" carlyemail signup --human-email you@example.com --username my-agent",
|
|
412
|
+
" carlyemail verify 123456",
|
|
413
|
+
" carlyemail send --from my-agent@agents.carlyemail.com \\",
|
|
414
|
+
' --to someone@example.com --subject Hi --text "Hello from an agent"',
|
|
415
|
+
"",
|
|
416
|
+
bold("Options"),
|
|
417
|
+
" --json" + dim(" print the raw API response instead of a table"),
|
|
418
|
+
"",
|
|
419
|
+
bold("Configuration"),
|
|
420
|
+
` ${CONFIG_FILE}` + dim(" (written 0600)"),
|
|
421
|
+
" CARLYEMAIL_API_KEY" + dim(" overrides the saved key"),
|
|
422
|
+
" CARLYEMAIL_API_URL" + dim(" overrides the API base URL"),
|
|
423
|
+
"",
|
|
424
|
+
dim(" https://docs.carlyemail.com")
|
|
425
|
+
);
|
|
426
|
+
return lines.join("\n");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------- entry
|
|
430
|
+
|
|
431
|
+
export async function main(argv, { env = process.env, print = console.log, configFile, configDir } = {}) {
|
|
432
|
+
const { flags, positional } = parseArgs(argv);
|
|
433
|
+
const name = positional.shift();
|
|
434
|
+
|
|
435
|
+
if (!name || name === "help" || flags.help) {
|
|
436
|
+
print(helpText());
|
|
437
|
+
return 0;
|
|
438
|
+
}
|
|
439
|
+
if (name === "version" || flags.version) {
|
|
440
|
+
print(VERSION);
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const command = commands[name];
|
|
445
|
+
if (!command) {
|
|
446
|
+
// Suggest rather than just refuse: a wrong guess at a command name is the
|
|
447
|
+
// most common thing a first-time user does.
|
|
448
|
+
const near = Object.keys(commands).filter((c) => c.startsWith(name[0]));
|
|
449
|
+
print(`${paint(31, "✗")} unknown command "${name}"`);
|
|
450
|
+
if (near.length) print(dim(` did you mean: ${near.join(", ")}?`));
|
|
451
|
+
print(dim(" carlyemail help"));
|
|
452
|
+
return 1;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const config = loadConfig(env, configFile);
|
|
456
|
+
try {
|
|
457
|
+
await command.run({ config, flags, positional, print, configFile, configDir });
|
|
458
|
+
return 0;
|
|
459
|
+
} catch (error) {
|
|
460
|
+
if (error instanceof ApiError) {
|
|
461
|
+
print(error.render());
|
|
462
|
+
} else if (error instanceof UsageError) {
|
|
463
|
+
print(`${paint(31, "✗")} ${error.message}`);
|
|
464
|
+
print(dim(` ${command.usage}`));
|
|
465
|
+
} else {
|
|
466
|
+
print(`${paint(31, "✗")} ${error.message}`);
|
|
467
|
+
}
|
|
468
|
+
return 1;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Whether this file was run as a program rather than imported.
|
|
474
|
+
*
|
|
475
|
+
* The obvious version — comparing `import.meta.url` to
|
|
476
|
+
* `file://${process.argv[1]}` — is wrong in exactly the case that matters.
|
|
477
|
+
* `npm install` puts a **symlink** in `node_modules/.bin`, so `argv[1]` is the
|
|
478
|
+
* link and `import.meta.url` is its target; the strings never match and the
|
|
479
|
+
* CLI exits 0 having done nothing. It works perfectly from a checkout and is
|
|
480
|
+
* silently inert once published, which is the worst shape a bug can take.
|
|
481
|
+
*
|
|
482
|
+
* `realpathSync` resolves the link. `fileURLToPath` is used rather than
|
|
483
|
+
* trimming `file://` by hand because that is also wrong on Windows, where the
|
|
484
|
+
* URL carries a drive letter.
|
|
485
|
+
*/
|
|
486
|
+
function invokedDirectly() {
|
|
487
|
+
if (!process.argv[1]) return false;
|
|
488
|
+
try {
|
|
489
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
490
|
+
} catch {
|
|
491
|
+
return false; // argv[1] is not a path we can resolve — treat as imported
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (invokedDirectly()) {
|
|
496
|
+
main(process.argv.slice(2)).then((code) => process.exit(code));
|
|
497
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "carlyemail",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Real email inboxes your agent can send, receive and reply from.",
|
|
5
|
+
"keywords": ["email", "agent", "ai", "inbox", "smtp", "mcp", "cli"],
|
|
6
|
+
"homepage": "https://carlyemail.com",
|
|
7
|
+
"bugs": "https://docs.carlyemail.com/support",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/shirschfield/carlyemail.git",
|
|
11
|
+
"directory": "cli"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "SWH Labs LLC",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"carlyemail": "./carlyemail.js"
|
|
18
|
+
},
|
|
19
|
+
"exports": "./carlyemail.js",
|
|
20
|
+
"files": ["carlyemail.js", "README.md", "LICENSE"],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test"
|
|
26
|
+
}
|
|
27
|
+
}
|