carlyemail 0.6.5 → 0.7.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/README.md CHANGED
@@ -170,12 +170,23 @@ import { createEmailHandler } from "carlyemail/webhooks";
170
170
 
171
171
  const handleEmail = createEmailHandler({
172
172
  secret: env.CARLYEMAIL_WEBHOOK_SECRET,
173
+ inbox: "frontdesk@carlyemail.com",
174
+ allowFrom: ["sam@gmail.com"],
173
175
  async onEmail(event) {
174
176
  console.log(event.message);
175
177
  },
176
178
  });
179
+
180
+ export default {
181
+ fetch: (request, env, ctx) => handleEmail(request, env, ctx),
182
+ };
177
183
  ```
178
184
 
185
+ Only `message.received` reaches `onEmail`: spam, blocked and unauthenticated
186
+ mail does not, nor does mail from the inbox itself, a sender outside
187
+ `allowFrom`, or a redelivery. The handler answers 202 and runs `onEmail` after,
188
+ through `ctx.waitUntil` when it is given one. A bad signature gets 401.
189
+
179
190
  ## Licence
180
191
 
181
192
  MIT — see `LICENSE`. That covers **this CLI only**. The CarlyEmail service it
package/carlyemail.js CHANGED
@@ -18,7 +18,7 @@ import { basename, extname, join } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { randomBytes } from "node:crypto";
20
20
 
21
- export const VERSION = "0.6.5";
21
+ export const VERSION = "0.7.0";
22
22
 
23
23
  const DEFAULT_API = "https://api.carlyemail.com";
24
24
  const CONFIG_DIR = join(homedir(), ".carlyemail");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carlyemail",
3
- "version": "0.6.5",
3
+ "version": "0.7.0",
4
4
  "description": "Real email inboxes your agent can send, receive and reply from. SDK and CLI.",
5
5
  "keywords": [
6
6
  "email",
package/sdk.d.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  // Generated by scripts/build_sdk.py from the OpenAPI spec. Do not edit.
2
2
 
3
+ export interface Actor {
4
+ type: string;
5
+ member_id?: string | null;
6
+ api_key_id?: string | null;
7
+ name?: string | null;
8
+ email?: string | null;
9
+ }
10
+
3
11
  export interface AgentSigninRequest {
4
12
  human_email: string;
5
13
  }
@@ -164,6 +172,10 @@ export interface CreateListEntryRequest {
164
172
  reason?: string | null;
165
173
  }
166
174
 
175
+ export interface CreateNoteRequest {
176
+ text: string;
177
+ }
178
+
167
179
  export interface CreatePodRequest {
168
180
  name?: string | null;
169
181
  client_id?: string | null;
@@ -191,6 +203,14 @@ export interface DomainOut {
191
203
  created_at: string;
192
204
  }
193
205
 
206
+ export interface DraftInProgress {
207
+ draft_id: string;
208
+ created_by?: Actor | null;
209
+ updated_by?: Actor | null;
210
+ send_at?: string | null;
211
+ updated_at: string;
212
+ }
213
+
194
214
  export interface DraftOut {
195
215
  inbox_id: string;
196
216
  draft_id: string;
@@ -210,6 +230,8 @@ export interface DraftOut {
210
230
  references?: Array<string>;
211
231
  send_status?: string | null;
212
232
  send_at?: string | null;
233
+ created_by?: Actor | null;
234
+ updated_by?: Actor | null;
213
235
  updated_at: string;
214
236
  created_at: string;
215
237
  }
@@ -270,6 +292,7 @@ export interface InboxEventOut {
270
292
  event_type: "label.added" | "label.removed";
271
293
  message_id?: string | null;
272
294
  label?: string | null;
295
+ actor?: Actor | null;
273
296
  event_at: string;
274
297
  created_at: string;
275
298
  }
@@ -365,6 +388,11 @@ export interface ListMessagesResponse {
365
388
  messages: Array<MessageItem>;
366
389
  }
367
390
 
391
+ export interface ListNotesResponse {
392
+ count: number;
393
+ notes: Array<NoteOut>;
394
+ }
395
+
368
396
  export interface ListPodsResponse {
369
397
  count: number;
370
398
  limit?: number | null;
@@ -418,6 +446,7 @@ export interface MessageItem {
418
446
  preview?: string | null;
419
447
  attachments?: Array<AttachmentOut>;
420
448
  size?: number;
449
+ sent_by?: Actor | null;
421
450
  updated_at: string;
422
451
  created_at: string;
423
452
  }
@@ -437,6 +466,7 @@ export interface MessageOut {
437
466
  preview?: string | null;
438
467
  attachments?: Array<AttachmentOut>;
439
468
  size?: number;
469
+ sent_by?: Actor | null;
440
470
  updated_at: string;
441
471
  created_at: string;
442
472
  text?: string | null;
@@ -449,6 +479,15 @@ export interface MessageOut {
449
479
  authentication?: Record<string, string>;
450
480
  }
451
481
 
482
+ export interface NoteOut {
483
+ note_id: string;
484
+ thread_id: string;
485
+ inbox_id: string;
486
+ text: string;
487
+ author?: Actor | null;
488
+ created_at: string;
489
+ }
490
+
452
491
  export interface OrganizationChoice {
453
492
  organization_id: string;
454
493
  name?: string | null;
@@ -575,6 +614,7 @@ export interface ThreadItem {
575
614
  last_message_id?: string | null;
576
615
  message_count?: number;
577
616
  size?: number;
617
+ assignee?: Actor | null;
578
618
  updated_at: string;
579
619
  created_at: string;
580
620
  }
@@ -594,9 +634,12 @@ export interface ThreadOut {
594
634
  last_message_id?: string | null;
595
635
  message_count?: number;
596
636
  size?: number;
637
+ assignee?: Actor | null;
597
638
  updated_at: string;
598
639
  created_at: string;
599
640
  messages?: Array<MessageOut>;
641
+ notes?: Array<NoteOut>;
642
+ drafts?: Array<DraftInProgress>;
600
643
  }
601
644
 
602
645
  export interface UpdateDomainRequest {
@@ -648,11 +691,13 @@ export interface UpdateMessageResponse {
648
691
  export interface UpdateThreadRequest {
649
692
  add_labels?: Array<string> | null;
650
693
  remove_labels?: Array<string> | null;
694
+ assignee_id?: string | null;
651
695
  }
652
696
 
653
697
  export interface UpdateThreadResponse {
654
698
  thread_id: string;
655
699
  labels: Array<string>;
700
+ assignee?: Actor | null;
656
701
  }
657
702
 
658
703
  export interface UpdateWebhookHeadersRequest {
@@ -779,13 +824,16 @@ export declare class Messages {
779
824
  }
780
825
 
781
826
  export declare class Threads {
782
- list(inboxId: string, query?: { limit?: number | null; pageToken?: string | null; labels?: Array<string> | null; before?: string | null; after?: string | null; ascending?: boolean; senders?: Array<string> | null; recipients?: Array<string> | null; subject?: string | null }): Promise<ListThreadsResponse>;
827
+ list(inboxId: string, query?: { limit?: number | null; pageToken?: string | null; labels?: Array<string> | null; before?: string | null; after?: string | null; ascending?: boolean; senders?: Array<string> | null; recipients?: Array<string> | null; subject?: string | null; assignee?: string | null }): Promise<ListThreadsResponse>;
783
828
  search(inboxId: string, query?: { q?: string; limit?: number | null; pageToken?: string | null; before?: string | null; after?: string | null }): Promise<ListThreadsResponse>;
784
829
  getAttachment(inboxId: string, threadId: string, attachmentId: string): Promise<AttachmentResponse>;
785
830
  get(inboxId: string, threadId: string): Promise<ThreadOut>;
786
831
  update(inboxId: string, threadId: string, body: UpdateThreadRequest): Promise<UpdateThreadResponse>;
787
832
  delete(inboxId: string, threadId: string): Promise<void>;
788
- listOrg(query?: { limit?: number | null; pageToken?: string | null; labels?: Array<string> | null; before?: string | null; after?: string | null; ascending?: boolean; includeSpam?: boolean; includeBlocked?: boolean; includeUnauthenticated?: boolean; includeTrash?: boolean; senders?: Array<string> | null; recipients?: Array<string> | null; subject?: string | null }): Promise<ListThreadsResponse>;
833
+ listNotes(inboxId: string, threadId: string): Promise<ListNotesResponse>;
834
+ addNote(inboxId: string, threadId: string, body: CreateNoteRequest): Promise<NoteOut>;
835
+ deleteNote(inboxId: string, threadId: string, noteId: string): Promise<void>;
836
+ listOrg(query?: { limit?: number | null; pageToken?: string | null; labels?: Array<string> | null; before?: string | null; after?: string | null; ascending?: boolean; includeSpam?: boolean; includeBlocked?: boolean; includeUnauthenticated?: boolean; includeTrash?: boolean; senders?: Array<string> | null; recipients?: Array<string> | null; subject?: string | null; assignee?: string | null }): Promise<ListThreadsResponse>;
789
837
  searchOrg(query?: { q?: string; limit?: number | null; pageToken?: string | null; before?: string | null; after?: string | null }): Promise<ListThreadsResponse>;
790
838
  getOrg(threadId: string): Promise<ThreadOut>;
791
839
  updateOrg(threadId: string, body: UpdateThreadRequest): Promise<UpdateThreadResponse>;
@@ -865,7 +913,7 @@ export declare class Pods {
865
913
  getInbox(inboxId: string, podId: string): Promise<InboxOut>;
866
914
  updateInbox(inboxId: string, podId: string, body: UpdateInboxRequest): Promise<InboxOut>;
867
915
  deleteInbox(inboxId: string, podId: string, query?: { force?: boolean }): Promise<void>;
868
- listThreads(podId: string, query?: { limit?: number | null; pageToken?: string | null; labels?: Array<string> | null; before?: string | null; after?: string | null; ascending?: boolean; includeSpam?: boolean; includeBlocked?: boolean; includeUnauthenticated?: boolean; includeTrash?: boolean; senders?: Array<string> | null; recipients?: Array<string> | null; subject?: string | null }): Promise<ListThreadsResponse>;
916
+ listThreads(podId: string, query?: { limit?: number | null; pageToken?: string | null; labels?: Array<string> | null; before?: string | null; after?: string | null; ascending?: boolean; includeSpam?: boolean; includeBlocked?: boolean; includeUnauthenticated?: boolean; includeTrash?: boolean; senders?: Array<string> | null; recipients?: Array<string> | null; subject?: string | null; assignee?: string | null }): Promise<ListThreadsResponse>;
869
917
  searchThreads(podId: string, query?: { q?: string; limit?: number | null; pageToken?: string | null; before?: string | null; after?: string | null }): Promise<ListThreadsResponse>;
870
918
  getThread(threadId: string, podId: string): Promise<ThreadOut>;
871
919
  updateThread(threadId: string, podId: string, body: UpdateThreadRequest): Promise<UpdateThreadResponse>;
package/sdk.js CHANGED
@@ -251,7 +251,7 @@ class Threads {
251
251
 
252
252
  /** List Threads */
253
253
  list(inboxId, query = {}) {
254
- return this.$.request("GET", `/v0/inboxes/${encode(inboxId)}/threads`, { query: { "limit": query.limit, "page_token": query.pageToken, "labels": query.labels, "before": query.before, "after": query.after, "ascending": query.ascending, "senders": query.senders, "recipients": query.recipients, "subject": query.subject } });
254
+ return this.$.request("GET", `/v0/inboxes/${encode(inboxId)}/threads`, { query: { "limit": query.limit, "page_token": query.pageToken, "labels": query.labels, "before": query.before, "after": query.after, "ascending": query.ascending, "senders": query.senders, "recipients": query.recipients, "subject": query.subject, "assignee": query.assignee } });
255
255
  }
256
256
 
257
257
  /** Search Threads */
@@ -279,9 +279,24 @@ class Threads {
279
279
  return this.$.request("DELETE", `/v0/inboxes/${encode(inboxId)}/threads/${encode(threadId)}`);
280
280
  }
281
281
 
282
+ /** List Notes */
283
+ listNotes(inboxId, threadId) {
284
+ return this.$.request("GET", `/v0/inboxes/${encode(inboxId)}/threads/${encode(threadId)}/notes`);
285
+ }
286
+
287
+ /** Add Note */
288
+ addNote(inboxId, threadId, body) {
289
+ return this.$.request("POST", `/v0/inboxes/${encode(inboxId)}/threads/${encode(threadId)}/notes`, { body });
290
+ }
291
+
292
+ /** Delete Note */
293
+ deleteNote(inboxId, threadId, noteId) {
294
+ return this.$.request("DELETE", `/v0/inboxes/${encode(inboxId)}/threads/${encode(threadId)}/notes/${encode(noteId)}`);
295
+ }
296
+
282
297
  /** List Threads */
283
298
  listOrg(query = {}) {
284
- return this.$.request("GET", `/v0/threads`, { query: { "limit": query.limit, "page_token": query.pageToken, "labels": query.labels, "before": query.before, "after": query.after, "ascending": query.ascending, "include_spam": query.includeSpam, "include_blocked": query.includeBlocked, "include_unauthenticated": query.includeUnauthenticated, "include_trash": query.includeTrash, "senders": query.senders, "recipients": query.recipients, "subject": query.subject } });
299
+ return this.$.request("GET", `/v0/threads`, { query: { "limit": query.limit, "page_token": query.pageToken, "labels": query.labels, "before": query.before, "after": query.after, "ascending": query.ascending, "include_spam": query.includeSpam, "include_blocked": query.includeBlocked, "include_unauthenticated": query.includeUnauthenticated, "include_trash": query.includeTrash, "senders": query.senders, "recipients": query.recipients, "subject": query.subject, "assignee": query.assignee } });
285
300
  }
286
301
 
287
302
  /** Search Threads */
@@ -609,7 +624,7 @@ class Pods {
609
624
 
610
625
  /** List Threads */
611
626
  listThreads(podId, query = {}) {
612
- return this.$.request("GET", `/v0/pods/${encode(podId)}/threads`, { query: { "limit": query.limit, "page_token": query.pageToken, "labels": query.labels, "before": query.before, "after": query.after, "ascending": query.ascending, "include_spam": query.includeSpam, "include_blocked": query.includeBlocked, "include_unauthenticated": query.includeUnauthenticated, "include_trash": query.includeTrash, "senders": query.senders, "recipients": query.recipients, "subject": query.subject } });
627
+ return this.$.request("GET", `/v0/pods/${encode(podId)}/threads`, { query: { "limit": query.limit, "page_token": query.pageToken, "labels": query.labels, "before": query.before, "after": query.after, "ascending": query.ascending, "include_spam": query.includeSpam, "include_blocked": query.includeBlocked, "include_unauthenticated": query.includeUnauthenticated, "include_trash": query.includeTrash, "senders": query.senders, "recipients": query.recipients, "subject": query.subject, "assignee": query.assignee } });
613
628
  }
614
629
 
615
630
  /** Search Threads */
package/webhooks.d.ts CHANGED
@@ -28,8 +28,31 @@ export declare function verifyWebhook(
28
28
  options?: VerifyWebhookOptions,
29
29
  ): Promise<CarlyEmailEvent>;
30
30
 
31
- export declare function createEmailHandler(options: {
31
+ export declare const RECEIVED: "message.received";
32
+ export declare const SPAM: "message.received.spam";
33
+ export declare const BLOCKED: "message.received.blocked";
34
+ export declare const UNAUTHENTICATED: "message.received.unauthenticated";
35
+
36
+ export interface CreateEmailHandlerOptions {
32
37
  secret: string;
33
38
  onEmail: (event: CarlyEmailEvent, request: Request) => unknown | Promise<unknown>;
39
+ /** The address being answered, so mail it sent itself is dropped. */
40
+ inbox?: string;
41
+ /** Who may write: `emma@example.com` or `@example.com`. Empty means anyone. */
42
+ allowFrom?: string[] | string;
43
+ /** Event types that reach `onEmail`, matched exactly. Defaults to `message.received`. */
44
+ eventTypes?: string[];
34
45
  toleranceSeconds?: number;
35
- }): (request: Request) => Promise<Response>;
46
+ /** Event ids held in memory to ignore redeliveries. Defaults to 2048. */
47
+ remember?: number;
48
+ /**
49
+ * `true` (the default) answers 202 and runs `onEmail` afterwards, handing it
50
+ * to a `waitUntil` found among the handler's later arguments. `false` runs it
51
+ * first and lets an exception become a 5xx so the delivery retries.
52
+ */
53
+ background?: boolean;
54
+ }
55
+
56
+ export declare function createEmailHandler(
57
+ options: CreateEmailHandlerOptions,
58
+ ): (request: Request, ...context: unknown[]) => Promise<Response>;
package/webhooks.js CHANGED
@@ -81,30 +81,108 @@ export async function verifyWebhook(secret, body, headers, options = {}) {
81
81
  throw new WebhookVerificationError("webhook signature does not match");
82
82
  }
83
83
 
84
+ // The four ways a message arrives. They are separate event types rather than a
85
+ // flag on one type, so a subscription to RECEIVED alone never sees the other
86
+ // three. Match them exactly: `startsWith("message.received")` is true of all
87
+ // four, and hands spam and forged senders straight to the model.
88
+ export const RECEIVED = "message.received";
89
+ export const SPAM = "message.received.spam";
90
+ export const BLOCKED = "message.received.blocked";
91
+ export const UNAUTHENTICATED = "message.received.unauthenticated";
92
+
93
+ function addressOf(from) {
94
+ const value = String(from ?? "").trim();
95
+ const angled = value.match(/<([^<>]*)>\s*$/);
96
+ return (angled ? angled[1] : value).trim().toLowerCase();
97
+ }
98
+
84
99
  /**
85
100
  * Build a fetch-compatible handler with a Cloudflare-style `onEmail(event)`
86
- * callback. Handler errors are allowed through so the delivery gets a 5xx and
87
- * retries; only verification errors are turned into terminal 400 responses.
101
+ * callback, making the same decisions as the Python `create_email_router`:
102
+ * verify the raw body, admit only the event types asked for (exactly), drop
103
+ * the inbox's own mail, check the sender, ignore a redelivery, and answer the
104
+ * delivery before the agent runs.
105
+ *
106
+ * A model turn can outlast the delivery timeout, and a timed-out delivery is
107
+ * retried, so by default the handler answers 202 and runs `onEmail` after. Pass
108
+ * the runtime's context as a later argument (`handle(request, env, ctx)` in a
109
+ * Worker) and the run goes to its `waitUntil`. `background: false` runs
110
+ * `onEmail` first and lets an exception become a 5xx so the delivery retries.
88
111
  */
89
- export function createEmailHandler({ secret, onEmail, toleranceSeconds = 300 }) {
112
+ export function createEmailHandler({
113
+ secret,
114
+ onEmail,
115
+ inbox = "",
116
+ allowFrom = [],
117
+ eventTypes = [RECEIVED],
118
+ toleranceSeconds = 300,
119
+ remember = 2048,
120
+ background = true,
121
+ }) {
90
122
  if (!secret) throw new Error("createEmailHandler requires a webhook secret");
91
123
  if (typeof onEmail !== "function") throw new TypeError("createEmailHandler requires onEmail");
92
124
 
93
- return async function handle(request) {
125
+ const ownAddress = inbox.toLowerCase();
126
+ const allowed = new Set(
127
+ (typeof allowFrom === "string" ? allowFrom.split(",") : allowFrom)
128
+ .map((entry) => entry.trim().toLowerCase())
129
+ .filter(Boolean),
130
+ );
131
+ const admitted = new Set(eventTypes);
132
+ // Bounded and in memory, so it resets with the isolate. Redeliveries cluster
133
+ // in the minutes after a failure, which this covers; record `event_id`
134
+ // somewhere durable if acting twice on one email is costly.
135
+ const seen = new Set();
136
+
137
+ function allows(from) {
138
+ if (!allowed.size) return true;
139
+ // Compared whole, never as a substring: `emma@example.com.attacker.net`
140
+ // is a domain anyone can register and sign mail from.
141
+ const address = addressOf(from);
142
+ if (!address.includes("@")) return false;
143
+ return allowed.has(address) || allowed.has(`@${address.split("@").pop()}`);
144
+ }
145
+
146
+ function firstSighting(eventId) {
147
+ if (seen.has(eventId)) return false;
148
+ seen.add(eventId);
149
+ if (seen.size > remember) seen.delete(seen.values().next().value);
150
+ return true;
151
+ }
152
+
153
+ return async function handle(request, ...context) {
94
154
  const body = new Uint8Array(await request.arrayBuffer());
95
155
  let event;
96
156
  try {
97
157
  event = await verifyWebhook(secret, body, request.headers, { toleranceSeconds });
98
158
  } catch (error) {
159
+ // 401 and nothing else: an unverified caller learns the URL exists.
99
160
  if (error instanceof WebhookVerificationError) {
100
- return new Response(error.message, { status: 400 });
161
+ return new Response(error.message, { status: 401 });
101
162
  }
102
163
  throw error;
103
164
  }
104
165
 
105
- if (event.event_type?.startsWith("message.received")) {
166
+ const message = event?.message;
167
+ if (
168
+ !admitted.has(event?.event_type) ||
169
+ typeof message !== "object" ||
170
+ message === null ||
171
+ (ownAddress && addressOf(message.from) === ownAddress) ||
172
+ !allows(message.from) ||
173
+ (event.event_id && !firstSighting(event.event_id))
174
+ ) {
175
+ return new Response(null, { status: 204 });
176
+ }
177
+
178
+ if (!background) {
106
179
  await onEmail(event, request);
180
+ return new Response(null, { status: 204 });
107
181
  }
108
- return new Response(null, { status: 204 });
182
+ const run = Promise.resolve()
183
+ .then(() => onEmail(event, request))
184
+ .catch((error) => console.error("carlyemail: onEmail failed", error));
185
+ context.find((item) => typeof item?.waitUntil === "function")?.waitUntil(run);
186
+ return new Response(null, { status: 202 });
109
187
  };
110
188
  }