Merge remote-tracking branch 'origin/main' into fix/unibox-forward-original-message

# Conflicts:
#	docs/content/docs/api/reference/unibox.mdx
#	docs/content/docs/guides/unibox.mdx
#	docs/public/openapi.json
#	web/src/components/app/unibox/ReplyComposer.tsx
#	web/src/components/app/unibox/replyComposerDraft.test.tsx
This commit is contained in:
Matthew Meszaros
2026-09-23 21:48:54 -07:00
44 changed files with 1918 additions and 583 deletions
+2 -1
View File
@@ -183,7 +183,8 @@ The [inbox vendor](/guides/mailbox-import/#import-from-an-inbox-vendor) routes (
| `code` | Status | Meaning |
|--------|--------|---------|
| `mailbox_vendor_unauthorized` | 400 | The vendor did not accept the API key, or it has no access to the workspace or organization entered. A saved connection is marked invalid until the key is updated |
| `mailbox_vendor_unauthorized` | 400 | The vendor did not accept the API key. A saved connection is marked invalid until the key is updated |
| `mailbox_vendor_no_workspace` | 400 | The key is accepted but the vendor lists no workspace or organization for it, so there is nothing to read. Create one at the vendor, then connect again |
| `mailbox_vendor_invalid_fields` | 400 | A field the vendor requires is empty, or a field holds control characters. `GET /emails/vendors/catalog` lists each vendor's fields |
| `mailbox_vendor_unknown` | 400 | `vendor` is not one of the supported vendors |
| `mailbox_vendor_rate_limited` | 429 | The vendor is rate limiting Warmbly's requests. Try again in a minute |
+10 -8
View File
@@ -83,7 +83,7 @@ Returns the number of unread conversations in the Inbox folder, optionally scope
`GET /unibox/overview`
Rolls up the scope rail and top metric strip in one call: unread, today, week, snoozed, awaiting-reply, and pending-scheduled counts, plus per-folder, per-mailbox, per-tag, and per-conversation-label breakdowns. The `folders` array always lists all six canonical folders, zero-filled, in sidebar order; the headline counts exclude `spam` and `trash`. All counts are threads, not messages. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`.
Rolls up the scope rail and top metric strip in one call: unread, today, week, snoozed, awaiting-reply, and pending-scheduled counts, plus per-folder, per-mailbox, per-tag, and per-conversation-label breakdowns. The `folders` array always lists all six canonical folders, zero-filled, in sidebar order; the headline counts exclude `spam` and `trash`. All counts are threads, not messages, except `scheduled_pending`, which counts the sends queued from the organization's mailboxes against the workspace cap in `scheduled_pending_max`. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`.
### Response
@@ -96,7 +96,7 @@ Rolls up the scope rail and top metric strip in one call: unread, today, week, s
"snoozed": 3,
"awaiting_reply": 9,
"scheduled_pending": 2,
"scheduled_pending_max": 50,
"scheduled_pending_max": 10000,
"folders": [
{ "folder": "inbox", "unread": 31, "total": 812 },
{ "folder": "sent", "unread": 0, "total": 402 },
@@ -296,21 +296,21 @@ Echoes the request back.
`POST /unibox/reply`
Sends or schedules a reply or a forward from one of your mailboxes. The send is routed through the per-mailbox scheduler according to `send_mode`. Requires an active organization. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`.
Sends or schedules a reply or a forward from any mailbox in the organization. The send is routed through the per-mailbox scheduler according to `send_mode`. Requires an active organization. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`.
### Request body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email_account_id` | string | Yes | UUID of the sending mailbox. |
| `email_account_id` | string | Yes | UUID of the sending mailbox. Any active mailbox in the organization, not only the one holding the thread. |
| `to` | string[] | Yes | Recipient addresses (at least one). |
| `cc` | string[] | No | CC addresses. |
| `bcc` | string[] | No | BCC addresses. |
| `subject` | string | Yes | Subject line. |
| `body_html` | string | No | HTML body. |
| `body_plain` | string | No | Plain-text body. |
| `in_reply_to` | string[] | No | Message-ID(s) this reply threads under. |
| `thread_id` | string | No | Thread to thread the reply into. |
| `in_reply_to` | string[] | No | Message-ID(s) this reply threads under. When omitted, the newest Message-ID in `thread_id` is used. |
| `thread_id` | string | No | The conversation being answered. |
| `send_mode` | string | No | `instant` (default), `smart` (next mailbox gap), or `scheduled` (use `scheduled_at`). |
| `scheduled_at` | string | No | RFC 3339 timestamp. Required when `send_mode` is `scheduled`; must be in the future. |
| `forward_message_id` | string | No | UUID of a stored message (the `id` from `GET /unibox/:id`) to forward. See [Forwarding a message](#forwarding-a-message). |
@@ -339,6 +339,8 @@ Sends or schedules a reply or a forward from one of your mailboxes. The send is
Instant sends are held for the sender's undo window (5 to 120 seconds, default 30) before they actually leave, so `scheduled_at` is that far in the future. Within the window the send can still be cancelled with `DELETE /unibox/scheduled/:task_id`.
Only Gmail has a provider thread handle, and it belongs to the mailbox that holds the thread. So the reply files into the Gmail thread `thread_id` names only when `email_account_id` holds a message in it; a mailbox that already replied into the conversation from outside continues the thread that reply started. Otherwise it goes out with the same In-Reply-To and References headers and the conversation's subject, which keeps it in the same conversation for the recipient, and starts a conversation of its own in the sending mailbox. The queued send still belongs to `thread_id`, so it is listed with that conversation's scheduled sends.
### Forwarding a message
Set `forward_message_id` to forward a message from the inbox. Warmbly attaches the stored message itself, so `body_html` and `body_plain` are an optional note and can be left empty. The message is copied when the request is accepted, so a scheduled forward still carries it if the original is later deleted. What goes out, in both the HTML and the plain-text part:
@@ -442,7 +444,7 @@ Un-snoozes a thread immediately. Idempotent: deleting a snooze that does not exi
`GET /unibox/scheduled`
Returns the outbound emails you have queued but not yet sent. Pass `thread_id` to scope to a single conversation (used to render queued replies inline); the response shape is identical either way. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`.
Returns the outbound emails queued from the organization's mailboxes but not yet sent, whichever member queued them. An API key limited to certain mailboxes sees only their sends. Pass `thread_id` to scope to a single conversation (used to render queued replies inline); the response shape is identical either way. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
@@ -475,7 +477,7 @@ The response wraps the items in a `data` array. Each item is a preview of the qu
`DELETE /unibox/scheduled/:task_id`
Cancels a pending scheduled send before it fires. The queued task is marked cancelled and short-circuits to a no-op when its run time arrives. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`.
Cancels a pending scheduled send from any of the organization's mailboxes (for an API key limited to certain mailboxes, from those) before it fires. The queued task is marked cancelled and short-circuits to a no-op when its run time arrives. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
+2 -2
View File
@@ -109,11 +109,11 @@ Personas are derived from a hash of the contact's address, so behavior is stable
The simulator also plays two inbox vendors, so connecting a vendor account, importing its mailboxes and managing its domains work with no vendor account. `make sandbox` starts it and points the backend at it with `MAILVENDOR_SANDBOX_URL=http://127.0.0.1:18099`. To run only the mock next to your own backend, use `go run ./cmd/sandbox -vendors-only` and start the backend with the same variable. The backend ignores it unless `APP_ENV` is `dev`.
Connect either vendor from **Add account > Inbox vendor** with any API key and workspace ID:
Connect either vendor from **Add account > Inbox vendor** with any API key. The InboxKit mock has two workspaces, so its mailboxes show which one each sits in:
| Vendor | Mailboxes | Domains |
|--------|-----------|---------|
| InboxKit | eight Google Workspace mailboxes on `sunrise-outbound.test` and `trysunrise.test`. Only a password comes back, as with the real API, and `.test` has no mail servers, so their rows ask for server settings; **Fix** on a row can point it at the sandbox's Mailpit (`localhost:11025`) and Dovecot (`localhost:10993`) | forwarding and DNS writes, so **Sending domains** can set the root forward and the tracking record in one click |
| InboxKit | eight Google Workspace mailboxes, on `sunrise-outbound.test` in the "Sunrise Outbound" workspace and `trysunrise.test` in "Sunrise Trials". Only a password comes back, as with the real API, and `.test` has no mail servers, so their rows ask for server settings; **Fix** on a row can point it at the sandbox's Mailpit (`localhost:11025`) and Dovecot (`localhost:10993`) | forwarding and DNS writes, so **Sending domains** can set the root forward and the tracking record in one click |
| Mailforge | four mailboxes on `sunrisehq.test`, hosted on the sandbox's Mailpit and Dovecot, so they import and connect for real | forwarding only |
The mock keeps its state in memory, so a restart puts every domain back the way it started.
+14 -12
View File
@@ -107,24 +107,26 @@ Proton Mail offers no IMAP or SMTP sign-in to other apps; only its Bridge does.
## Import from an inbox vendor
Choose **Add account**, then **Inbox vendor**, pick the vendor and enter what it asks for. Warmbly checks the key with the vendor before saving it, lists the mailboxes in that vendor account (marking the ones already in the workspace), and imports the ones you pick, or every one not yet connected.
Choose **Add account**, then **Inbox vendor**, pick the vendor and paste an API key. That is all any vendor asks for. Warmbly checks the key with the vendor before saving it, lists the mailboxes the key reaches (marking the ones already in the workspace), and imports the ones you pick, or every one not yet connected.
| Vendor | Asks for | Where to find the key |
| --- | --- | --- |
| InboxKit | API key and workspace ID (a UUID) | Settings > API & Integrations in the InboxKit dashboard |
| Zapmail | API key; optionally a workspace ID (blank is your primary workspace) and a mailbox type (`GOOGLE` or `MICROSOFT`, blank imports both) | Settings > Integrations > API in the Zapmail dashboard |
| Mailforge | API key | Settings > API keys in the Mailforge dashboard |
| Infraforge | API key; optionally a workspace ID (blank imports every workspace) | Settings > API keys in the Infraforge dashboard |
| Maildoso | API key (a personal access token) | Settings > API Keys in the Maildoso dashboard |
| Cheap Inboxes | API key, starting with `ci_live_` | Integrations > API in the Cheap Inboxes dashboard |
| ScaledMail | API key and organization ID | Settings in the ScaledMail dashboard |
A key that reaches several workspaces (or, at ScaledMail, organizations) lists the mailboxes of all of them, so there is no workspace ID to look up. Above the list, a workspace switcher narrows it to one workspace and shows how many of each you have picked, and **Select … in** picks every mailbox in that workspace that is not connected yet in one click. Pick the ones you want; the rest stay at the vendor.
| Vendor | Where to find the key |
| --- | --- |
| InboxKit | Settings > API & Integrations in the InboxKit dashboard |
| Zapmail | Settings > Integrations > API in the Zapmail dashboard. Google and Microsoft mailboxes are both listed |
| Mailforge | Settings > API keys in the Mailforge dashboard |
| Infraforge | Settings > API keys in the Infraforge dashboard |
| Maildoso | Settings > API Keys in the Maildoso dashboard (a personal access token) |
| Cheap Inboxes | Integrations > API in the Cheap Inboxes dashboard (starts with `ci_live_`) |
| ScaledMail | Settings in the ScaledMail dashboard |
One vendor account lists up to 10,000 mailboxes.
- **Credentials are read when each row is worked.** Picking mailboxes stores only which ones you picked. The password, app password and servers of each are fetched from the vendor at the moment its row connects, and the row is then judged exactly like a file row: Google mailboxes need the app password the vendor holds (or [an admin grant](#connect-a-whole-google-workspace-domain) over the domain), and Microsoft mailboxes wait for sign-in unless [a grant](#connect-a-whole-microsoft-365-organization) covers them.
- **The key is sealed and never shown again.** The API key and IDs are sealed with the workspace's own encryption key. The list of connections shows each one's vendor, label, status and mailbox count, never the key. **Update** replaces the key after checking the new one with the vendor.
- **The key is sealed and never shown again.** The API key is sealed with the workspace's own encryption key. The list of connections shows each one's vendor, label, status and mailbox count, never the key. **Update** replaces the key after checking the new one with the vendor.
- **Passwords the vendor rotates are picked up.** An SMTP and IMAP mailbox that came from a vendor and has stopped with an unresolved connection error is looked at every 15 minutes. When the vendor now holds a different password for it, Warmbly verifies that password against the server and reconnects the mailbox with it. Each mailbox is tried at most once every 6 hours, so a password that still fails is not retried on every pass.
- **A refused key marks the connection.** When the vendor stops accepting the key, the connection shows as invalid with the vendor's reason, and rows that needed it fail as `vendor_unauthorized` until the key is updated.
- **A refused key marks the connection.** A key the vendor accepts but that reaches no workspace is refused with `mailbox_vendor_no_workspace`, since there is nothing to list. When the vendor stops accepting the key, the connection shows as invalid with the vendor's reason, and rows that needed it fail as `vendor_unauthorized` until the key is updated.
- **Deleting the connection keeps the mailboxes.** They stay connected and keep sending; they are no longer reconnected automatically when the vendor rotates a password.
## Connect a whole Google Workspace domain
+15 -7
View File
@@ -51,7 +51,7 @@ The rail is two short groups and then your mailboxes. The first group is where y
| Spam | Second | Junked at the provider |
| Trash | Second | Deleted |
**Scheduled** sits between Sent and Archive. It is a view rather than a folder, listing replies queued to send later, and it reads naturally next to Sent.
**Scheduled** sits between Sent and Archive. It is a view rather than a folder, listing every send queued from the workspace's mailboxes, whichever teammate queued it, and it reads naturally next to Sent.
Opening **Inbox** from the main navigation starts in the Inbox folder. Sent messages live in **Sent**; choose **All mail** when you want inbound and outbound messages together. An Inbox conversation still shows its complete history, including your replies, when you open it.
@@ -72,7 +72,7 @@ The initial import that runs when a mailbox is first connected covers Inbox, Sen
| Awaiting reply | You wrote last and they have not answered |
| Agent drafts | Conversations where the [inbox agent](/guides/inbox-agent/) has a reply waiting for review |
| Snoozed | Snoozed for later |
| Scheduled | Replies queued to send later |
| Scheduled | Sends queued from the workspace's mailboxes |
Below those sit the premade **Views**, which answer the questions a pipeline turns on. They are built on the labels [automatic inbox tagging](/guides/inbox-tagging/) writes, exist from the moment the workspace is created, and need nothing set up:
@@ -102,7 +102,7 @@ Each row is a conversation, not a message, with a count next to the sender when
The list loads more conversations as you reach the end of it, and it keeps its place: opening a conversation, or leaving the inbox and coming back, returns you to the row you were on rather than the top of the list.
Threading applies on both sides. A reply you send carries the provider's thread id, which nests it in your own mailbox, and an `In-Reply-To` header naming the last message in the conversation, which is what nests it for the recipient. Their mail client only ever sees the header: a thread id means nothing outside the mailbox that issued it.
Threading applies on both sides. A reply carries an `In-Reply-To` header naming the last message in the conversation, which is what nests it for the recipient. From a Gmail mailbox that holds the conversation it also carries Gmail's thread id, which nests it in your own mailbox. The recipient's mail client only ever sees the header: a thread id means nothing outside the mailbox that issued it, so a reply sent [from another mailbox](#choosing-the-sending-mailbox) goes without one.
<Callout type="info" title="Keyboard navigation">
`j` / `k` move between rows, `Enter` opens, `Esc` closes, `/` focuses search, `c` labels. Press `?` for the full list.
@@ -172,15 +172,23 @@ Unlike read state, Archive and Delete are Warmbly's own filing. The message keep
## Replying
Reply, forward, or hover any single message to reply to it specifically. The composer appears when you ask for it or return to a conversation with a saved reply or forward. Replies go from the mailbox that owns the thread, so conversations stay on one account. You can apply a saved **template** or **Insert booking link**.
Reply, forward, or hover any single message to reply to it specifically. The composer appears when you ask for it or return to a conversation with a saved reply or forward. You can apply a saved **template** or **Insert booking link**.
### Choosing the sending mailbox
**From** starts on the mailbox that holds the message, so a reply you do not touch goes out exactly as before. Click it to send from any other active mailbox in the workspace: the same searchable picker as [Compose](#composing), with each mailbox's budget for today, auth health and history with the recipient. There is no Auto here; a reply always names its mailbox. The signature preview follows the mailbox you pick. When the mailbox holding the message is no longer active, the composer says so and **Send** stays off until you pick one that is.
A reply from a different mailbox keeps the conversation's subject and its In-Reply-To and References headers, so the recipient sees it in the same conversation. A provider thread belongs to the mailbox that holds it, so unless the mailbox you pick already has a message in this conversation, the first such reply starts a conversation of its own there (later replies from it continue that one), and the recipient's answer arrives in that mailbox. A note under **From** says so, with **Switch back** to return to the original mailbox. A forward has no thread to keep, so it simply leaves from the mailbox you pick.
### Forwarding
**Forward** sends the message you picked to someone new. The subject gets `Fwd:` and the To line starts empty. What you type is an optional note; you can send a forward without one. The recipient gets your note, then your mailbox signature (when signature sync is on), then a `Forwarded message` block with the original From, Date, Subject, To and Cc lines and the original message underneath, formatting included. The Date line uses the sending mailbox's time zone.
**Forward** sends the message you picked to someone new. The subject gets `Fwd:` and the To line starts empty. What you type is an optional note; you can send a forward without one. The recipient gets your note, then the sending mailbox's signature (when signature sync is on), then a `Forwarded message` block with the original From, Date, Subject, To and Cc lines and the original message underneath, formatting included. The Date line uses the sending mailbox's time zone.
The composer shows the message it will forward under your note; expand **Forwarded message** to read it before you send. Attachments on the original are not forwarded. If only a preview of the original is stored (the thread shows "Only a preview of this message is stored"), the composer says so, and the forward carries that preview. Forwarding one of your own tracked sends puts its links back to their real destinations, so the new recipient's clicks do not count against the original send.
Replies and forwards save after a short typing pause, and save the latest edits when you close the composer, leave the conversation, or reload. Returning to the thread reopens a saved draft, including one addressed to an older message. **Discard** removes it; successfully queuing a send removes it too. Undoing a send restores the message for editing.
### Reply drafts
Replies and forwards save after a short typing pause, and save the latest edits when you close the composer, leave the conversation, or reload. Returning to the thread reopens a saved draft, including one addressed to an older message, with the mailbox you picked. **Discard** removes it; successfully queuing a send removes it too. Undoing a send restores the message for editing, from the same mailbox.
These reply drafts are personal to your signed-in user and workspace in this browser. They do not sync to other devices, appear in the mailbox's **Drafts** folder, or travel in a [workspace export](/guides/workspace-export-import/). Clearing browser data removes them. **Draft saved** confirms browser storage succeeded; **Draft not saved** means you should copy your text before leaving.
@@ -225,7 +233,7 @@ With a recipient set, **History** slides out every conversation you have had wit
| Control | Behavior |
|---------|----------|
| **Undo send** | Instant sends wait 30s by default (adjustable 5 to 120s under Settings, Profile). A header countdown offers **Cancel**, which returns the email to the composer intact. Applies to API sends with `send_mode: instant` too. |
| **Schedule** | Queue a send for later. It appears inline in the thread as a dashed card and in the **Scheduled** view, cancellable any time. A usage meter warns as you approach the queued-send cap. |
| **Schedule** | Queue a send for later. It appears inline in the thread as a dashed card and in the **Scheduled** view, where anyone in the workspace with Unibox access can cancel it. A usage meter warns as the workspace approaches its queued-send cap. |
| **Snooze** | Clears a conversation until a time you pick, up to 90 days out, with presets or a custom time. **Un-snooze now** brings it back immediately. |
Scheduled and smart-queued sends skip the undo window, since they already wait. Cancel them from the **Scheduled** view, or via `DELETE /unibox/scheduled/:task_id`.
@@ -128,7 +128,7 @@ A few things belong to the instance rather than the workspace and are left out o
[Mailbox imports](/guides/mailbox-import/) do not travel either, finished or running: an import is work the source instance is doing, and its rows hold credentials in flight. The mailboxes an import connected are ordinary mailboxes and move with the Workspace group, and the saved column mappings come too, so the same file maps itself on the destination. Let a running import finish before exporting: rows it has not connected yet are not in the archive.
Reply and forward drafts kept in your browser are also excluded. Finish or copy them before moving to another instance. See [replying in Unibox](/guides/unibox/#replying).
Reply and forward drafts kept in your browser are also excluded. Finish or copy them before moving to another instance. See [reply drafts](/guides/unibox/#reply-drafts).
## Limits
+10 -5
View File
@@ -9118,7 +9118,7 @@
"post": {
"operationId": "unibox_reply",
"summary": "Reply from the inbox",
"description": "Sends or schedules a reply or forward from one of your mailboxes, routed through the per-mailbox scheduler according to `send_mode`. With `forward_message_id` the stored message is attached under the body and the mailbox signature, so the body is an optional note. Requires an active organization.",
"description": "Sends or schedules a reply or forward from any mailbox in the organization, routed through the per-mailbox scheduler according to `send_mode`. Requires an active organization. `thread_id` names the conversation; the Gmail thread handle is only used by a mailbox holding a message in that thread (or continuing a thread its own earlier reply started), and a reply from any other mailbox threads for the recipient on In-Reply-To and References alone. With `forward_message_id` the stored message is attached under the body and the mailbox signature, so the body is an optional note.",
"tags": [
"unibox"
],
@@ -9431,7 +9431,7 @@
"get": {
"operationId": "unibox_list_scheduled",
"summary": "List scheduled sends",
"description": "Outbound emails you have queued but not yet sent. Pass `thread_id` to scope to a single conversation; the response shape is identical either way.",
"description": "Outbound emails queued from the organization's mailboxes but not yet sent, whichever member queued them. An API key limited to certain mailboxes sees only their sends. Pass `thread_id` to scope to a single conversation; the response shape is identical either way.",
"tags": [
"unibox"
],
@@ -9509,7 +9509,7 @@
"delete": {
"operationId": "unibox_cancel_scheduled",
"summary": "Cancel a scheduled send",
"description": "Cancels a pending scheduled send before it fires. The queued task is marked cancelled and short-circuits to a no-op when its run time arrives.",
"description": "Cancels a pending scheduled send from any of the organization's mailboxes (for an API key limited to certain mailboxes, from those) before it fires. The queued task is marked cancelled and short-circuits to a no-op when its run time arrives.",
"tags": [
"unibox"
],
@@ -30148,7 +30148,7 @@
"email_account_id": {
"type": "string",
"format": "uuid",
"description": "UUID of the sending mailbox."
"description": "UUID of the sending mailbox. Any active mailbox in the organization; it does not have to be the one holding the thread."
},
"to": {
"type": "array",
@@ -30187,7 +30187,8 @@
"description": "Message-ID(s) this reply threads under."
},
"thread_id": {
"type": "string"
"type": "string",
"description": "The conversation being answered. Resolves In-Reply-To when that is omitted, and is used as the provider thread only by a mailbox holding a message in it."
},
"send_mode": {
"type": "string",
@@ -37030,6 +37031,10 @@
"status": {
"type": "string"
},
"workspace": {
"type": "string",
"description": "The vendor workspace or organization holding the mailbox, when the vendor has them."
},
"connected": {
"type": "boolean"
},
+13 -15
View File
@@ -697,10 +697,10 @@ func (h *Handler) DeleteUniboxSnooze(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// ListUniboxScheduled returns every pending email task the user has
// queued: what the "Scheduled" scope in the dashboard reads from.
// When `thread_id` is set we scope the response to a single thread,
// which the ThreadView uses to render queued replies inline. The
// ListUniboxScheduled returns every pending email task queued from the
// organization's mailboxes: what the "Scheduled" scope in the dashboard
// reads from. When `thread_id` is set we scope the response to a single
// thread, which the ThreadView uses to render queued replies inline. The
// response shape is identical either way so the same client + hook
// handle both forms.
// GET /unibox/scheduled
@@ -709,15 +709,14 @@ func (h *Handler) ListUniboxScheduled(c *gin.Context) {
if !h.gateUnibox(c) {
return
}
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
if threadID := c.Query("thread_id"); threadID != "" {
items, xerr := h.UniboxService.ListScheduledByThread(c.Request.Context(), uid, threadID)
items, xerr := h.UniboxService.ListScheduledByThread(c.Request.Context(), *orgID, threadID, middleware.GetAPIKeyAllowedEmailAccounts(c))
if xerr != nil {
errx.Handle(c, xerr)
return
@@ -726,7 +725,7 @@ func (h *Handler) ListUniboxScheduled(c *gin.Context) {
return
}
items, xerr := h.UniboxService.ListScheduled(c.Request.Context(), uid)
items, xerr := h.UniboxService.ListScheduled(c.Request.Context(), *orgID, middleware.GetAPIKeyAllowedEmailAccounts(c))
if xerr != nil {
errx.Handle(c, xerr)
return
@@ -742,10 +741,9 @@ func (h *Handler) CancelUniboxScheduled(c *gin.Context) {
if !h.gateUnibox(c) {
return
}
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
taskID, err := uuid.Parse(c.Param("task_id"))
@@ -754,7 +752,7 @@ func (h *Handler) CancelUniboxScheduled(c *gin.Context) {
return
}
if xerr := h.UniboxService.CancelScheduled(c.Request.Context(), uid, taskID); xerr != nil {
if xerr := h.UniboxService.CancelScheduled(c.Request.Context(), *orgID, taskID, middleware.GetAPIKeyAllowedEmailAccounts(c)); xerr != nil {
errx.Handle(c, xerr)
return
}
+3 -3
View File
@@ -68,7 +68,7 @@ func (d Deps) registerInboxActionTools(r *Registry) {
r.Register(Tool{
Name: "list_scheduled_sends",
Description: "List the user's queued (not-yet-sent) outbound messages, with their task ids.",
Description: "List the workspace's queued (not-yet-sent) outbound messages, with their task ids.",
InputSchema: objectSchema(map[string]any{}),
Risk: generation.RiskRead,
RequiredOrgPerm: models.PermAccessUnibox,
@@ -206,7 +206,7 @@ func (d Deps) listScheduledSends(ctx context.Context, inv Invocation, _ json.Raw
if err := d.requireUnibox(ctx, inv); err != nil {
return "", err
}
items, xerr := d.Unibox.ListScheduled(ctx, inv.UserID)
items, xerr := d.Unibox.ListScheduled(ctx, inv.OrgID, nil)
if xerr != nil {
return "", fromErrx(xerr)
}
@@ -227,7 +227,7 @@ func (d Deps) cancelScheduledSend(ctx context.Context, inv Invocation, args json
if err != nil {
return "", err
}
if xerr := d.Unibox.CancelScheduled(ctx, inv.UserID, tid); xerr != nil {
if xerr := d.Unibox.CancelScheduled(ctx, inv.OrgID, tid, nil); xerr != nil {
return "", fromErrx(xerr)
}
d.logAudit(ctx, inv, models.AuditActionUpdate, models.AuditEntityUnibox, &tid, nil)
+5 -5
View File
@@ -192,7 +192,7 @@ func (s *emailSendService) SendEmail(ctx context.Context, userID, orgID, account
// Redis INCR; checked first because it's faster than a SELECT
// COUNT and rejects bursts before they touch the DB.
//
// Layer 2 (pending-count) — MaxPendingScheduledSendsPerUser
// Layer 2 (pending-count) — MaxPendingScheduledSendsPerOrg
// bounds total queued state, so the DB doesn't accumulate
// terabytes of pending message bodies even from a user who
// schedules slowly over months.
@@ -212,11 +212,11 @@ func (s *emailSendService) SendEmail(ctx context.Context, userID, orgID, account
}
}
if s.taskRepo != nil {
pending, perr := s.taskRepo.CountScheduledForUser(ctx, userID)
if perr == nil && pending >= int64(config.MaxPendingScheduledSendsPerUser) {
pending, perr := s.taskRepo.CountScheduledInOrg(ctx, orgID)
if perr == nil && pending >= int64(config.MaxPendingScheduledSendsPerOrg) {
return nil, errx.New(errx.TooManyRequests, fmt.Sprintf(
"you have %d scheduled sends queued (max %d). Cancel some from the Scheduled view before adding more.",
pending, config.MaxPendingScheduledSendsPerUser,
"this workspace has %d scheduled sends queued (max %d). Cancel some from the Scheduled view before adding more.",
pending, config.MaxPendingScheduledSendsPerOrg,
))
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ func Limits() []LimitGroup {
Title: "Scheduled and undo sends",
Entries: []LimitEntry{
{"New scheduled sends", n(config.DailyThrottleNewScheduledSends), "per user/day", "Rolling 24 hour window."},
{"Pending scheduled sends", n(config.MaxPendingScheduledSendsPerUser), "per user", "Queued at once. Bounds queue storage rather than cost."},
{"Pending scheduled sends", n(config.MaxPendingScheduledSendsPerOrg), "per workspace", "Queued at once. Bounds queue storage rather than cost."},
{"Undo send window", n(config.UndoSendSecondsDefault), "seconds", "Default hold before an instant send leaves. Range " + n(config.UndoSendSecondsMin) + " to " + n(config.UndoSendSecondsMax) + "."},
},
},
+2 -2
View File
@@ -31,7 +31,7 @@ func (s *uniboxService) Overview(ctx context.Context, orgID, userID uuid.UUID) (
// overview is more important than the badge — so we log via
// the error reporter and continue with a zero count.
if s.taskRepo != nil {
if n, err := s.taskRepo.CountScheduledForUser(ctx, userID); err == nil {
if n, err := s.taskRepo.CountScheduledInOrg(ctx, orgID); err == nil {
o.ScheduledPending = n
} else {
errs.CaptureException(err)
@@ -41,6 +41,6 @@ func (s *uniboxService) Overview(ctx context.Context, orgID, userID uuid.UUID) (
// dashboard render "N / max" so the user sees where they are
// before they hit the wall. When this moves to per-plan, this is
// the only line that needs a feature-gate lookup.
o.ScheduledPendingMax = int64(config.MaxPendingScheduledSendsPerUser)
o.ScheduledPendingMax = int64(config.MaxPendingScheduledSendsPerOrg)
return o, nil
}
+9 -9
View File
@@ -29,11 +29,11 @@ const ScheduledThreadListMax = 50
// to ship across the wire and short enough to render as one line.
const snippetMaxLen = 240
// ListScheduled returns the user's pending email tasks: every queued
// ListScheduled returns the organization's pending email tasks: every queued
// outbound message that hasn't fired yet, ordered by next-to-fire.
// The view is read-only — cancel is a separate explicit action.
func (s *uniboxService) ListScheduled(ctx context.Context, userID uuid.UUID) ([]models.UniboxScheduledItem, *errx.Error) {
rows, err := s.taskRepo.ListScheduledForUser(ctx, userID, ScheduledListMax)
func (s *uniboxService) ListScheduled(ctx context.Context, orgID uuid.UUID, accountIDs []uuid.UUID) ([]models.UniboxScheduledItem, *errx.Error) {
rows, err := s.taskRepo.ListScheduledInOrg(ctx, orgID, accountIDs, ScheduledListMax)
if err != nil {
errs.CaptureException(err)
return nil, errx.InternalError()
@@ -61,12 +61,12 @@ func (s *uniboxService) ListScheduled(ctx context.Context, userID uuid.UUID) ([]
// ListScheduledByThread returns pending queued sends for the given
// thread. Empty threadID is rejected up front so a malformed query
// can't silently fall back to the full per-user list.
func (s *uniboxService) ListScheduledByThread(ctx context.Context, userID uuid.UUID, threadID string) ([]models.UniboxScheduledItem, *errx.Error) {
// can't silently fall back to the full list.
func (s *uniboxService) ListScheduledByThread(ctx context.Context, orgID uuid.UUID, threadID string, accountIDs []uuid.UUID) ([]models.UniboxScheduledItem, *errx.Error) {
if threadID == "" {
return nil, errx.New(errx.BadRequest, "thread_id is required")
}
rows, err := s.taskRepo.ListScheduledForUserByThread(ctx, userID, threadID, ScheduledThreadListMax)
rows, err := s.taskRepo.ListScheduledInOrgByThread(ctx, orgID, threadID, accountIDs, ScheduledThreadListMax)
if err != nil {
errs.CaptureException(err)
return nil, errx.InternalError()
@@ -110,14 +110,14 @@ func (s *uniboxService) ListScheduledByThread(ctx context.Context, userID uuid.U
// DeleteTask fails for any reason — GCP outage, network blip,
// already-fired — the handler's status check still short-circuits
// the dispatch into a harmless no-op, so the user is always safe.
func (s *uniboxService) CancelScheduled(ctx context.Context, userID, taskID uuid.UUID) *errx.Error {
cloudTaskName, cancelled, err := s.taskRepo.CancelScheduledByUser(ctx, taskID, userID)
func (s *uniboxService) CancelScheduled(ctx context.Context, orgID, taskID uuid.UUID, accountIDs []uuid.UUID) *errx.Error {
cloudTaskName, cancelled, err := s.taskRepo.CancelScheduledInOrg(ctx, taskID, orgID, accountIDs)
if err != nil {
errs.CaptureException(err)
return errx.InternalError()
}
if !cancelled {
// Either: task doesn't exist, isn't this user's, or already
// Either: task doesn't exist, isn't this workspace's, or already
// left the pending state (fired / failed / cancelled). All
// three look the same from the caller's perspective.
return errx.New(errx.NotFound, "no pending scheduled send for this id")
+8 -6
View File
@@ -68,12 +68,14 @@ type UniboxService interface {
// flip status to 'cancelled' and let the queued Cloud Task fire as
// a no-op (handler short-circuits on non-pending status). Avoids
// per-cancel API calls against Cloud Tasks.
ListScheduled(ctx context.Context, userID uuid.UUID) ([]models.UniboxScheduledItem, *errx.Error)
// ListScheduledByThread returns the user's pending queued sends
// for a single thread. ThreadView calls this so queued replies
// render inline alongside already-sent messages.
ListScheduledByThread(ctx context.Context, userID uuid.UUID, threadID string) ([]models.UniboxScheduledItem, *errx.Error)
CancelScheduled(ctx context.Context, userID, taskID uuid.UUID) *errx.Error
// All three are scoped to the organization, whose mailboxes send them, and
// a non-empty accountIDs (an API key's allowlist) narrows them further.
ListScheduled(ctx context.Context, orgID uuid.UUID, accountIDs []uuid.UUID) ([]models.UniboxScheduledItem, *errx.Error)
// ListScheduledByThread returns the pending queued sends for a single
// thread. ThreadView calls this so queued replies render inline
// alongside already-sent messages.
ListScheduledByThread(ctx context.Context, orgID uuid.UUID, threadID string, accountIDs []uuid.UUID) ([]models.UniboxScheduledItem, *errx.Error)
CancelScheduled(ctx context.Context, orgID, taskID uuid.UUID, accountIDs []uuid.UUID) *errx.Error
// ThreadGrounding and AddressGrounding return message text for AI prompts:
// the stored body when it exists, the preview snippet as the fallback.
+8 -2
View File
@@ -34,6 +34,7 @@ const (
ErrIDInvalidFields = "mailbox_vendor_invalid_fields"
ErrIDRateLimited = "mailbox_vendor_rate_limited"
ErrIDUnavailable = "mailbox_vendor_unavailable"
ErrIDNoWorkspace = "mailbox_vendor_no_workspace"
)
// Mailboxes is the mailbox store's side.
@@ -125,6 +126,9 @@ func (s *Service) Catalog() []Vendor {
for _, d := range ds {
v := Vendor{ID: d.ID, Label: d.Label, Website: d.Website, KeyHelpURL: d.KeyHelpURL}
for _, f := range d.Fields {
if f.Legacy {
continue
}
v.Fields = append(v.Fields, VendorField{Key: f.Key, Label: f.Label, Secret: f.Secret, Required: f.Required, Help: f.Help})
}
out = append(out, v)
@@ -137,7 +141,9 @@ func vendorError(label string, err error) *errx.Error {
switch {
case errors.Is(err, mailvendor.ErrUnauthorized):
return errx.NewWithIdentifier(errx.BadRequest, mailboximport.ErrIDVendorUnauthorized,
label+" did not accept this API key. Check that it is current and has access to the workspace or organization entered.")
label+" did not accept this API key. Check that it is current and was copied in full.")
case errors.Is(err, mailvendor.ErrNoWorkspace):
return errx.NewWithIdentifier(errx.BadRequest, ErrIDNoWorkspace, label+" shows no workspace for this API key. Create one at "+label+", then try again.")
case errors.Is(err, mailvendor.ErrRateLimited):
return errx.NewWithIdentifier(errx.TooManyRequests, ErrIDRateLimited, label+" is rate limiting requests. Try again in a minute.")
case errors.Is(err, mailvendor.ErrInvalidConfig):
@@ -374,7 +380,7 @@ func (s *Service) Mailboxes(ctx context.Context, orgID, id uuid.UUID) ([]models.
}
out = append(out, models.VendorMailbox{
ID: m.ID, Email: m.Email, Name: strings.TrimSpace(m.FirstName + " " + m.LastName),
Domain: m.Domain, Provider: m.Provider, Status: m.Status,
Domain: m.Domain, Provider: m.Provider, Status: m.Status, Workspace: m.Workspace,
})
emails = append(emails, m.Email)
}
+10 -10
View File
@@ -459,24 +459,24 @@ const (
// to 200 inbound messages a day couldn't hit it organically).
DailyThrottleNewScheduledSends = 1000
// MaxPendingScheduledSendsPerUser caps how many pending scheduled
// email sends one user can have queued at once. The DAILY rate
// (DailyThrottleNewScheduledSends) is the primary abuse defense;
// this is the DB-bloat defense — each pending row carries a body
// (~5KB), so capping pending count keeps total scheduled-queue
// storage bounded per user.
// MaxPendingScheduledSendsPerOrg caps how many pending scheduled
// email sends one workspace can have queued at once, across all of
// its mailboxes. The DAILY rate (DailyThrottleNewScheduledSends) is
// the primary abuse defense; this is the DB-bloat defense — each
// pending row carries a body (~5KB), so capping pending count keeps
// total scheduled-queue storage bounded per workspace.
//
// 10,000 is generous: a user scheduling 100 sends/day for the next
// 10,000 is generous: a team scheduling 100 sends/day for the next
// 100 days hits this exactly once. The combination of "1K new/day"
// + "10K total pending" means a legitimate user cannot organically
// + "10K total pending" means legitimate use cannot organically
// hit either, while a scripted attacker is bounded on both axes.
//
// Cloud Tasks cost is negligible at this size — at $0.40/M
// operations, 10K pending = 20K ops = $0.008/user even at the
// operations, 10K pending = 20K ops = $0.008/workspace even at the
// hardest abuse. The cap exists for DB sanity, not cost.
//
// Future: per-plan ceiling lookup. Today: single backstop.
MaxPendingScheduledSendsPerUser = 10000
MaxPendingScheduledSendsPerOrg = 10000
// Undo send: instant sends are queued this many seconds in the
// future so the sender can still cancel. Per-user setting stored in
+1
View File
@@ -344,6 +344,7 @@ type VendorMailbox struct {
Domain string `json:"domain"`
Provider string `json:"provider"`
Status string `json:"status"`
Workspace string `json:"workspace,omitempty"`
Connected bool `json:"connected"`
AccountID *uuid.UUID `json:"email_account_id,omitempty"`
}
+1 -1
View File
@@ -453,7 +453,7 @@ type UniboxOverview struct {
AwaitingAgentDraft int64 `json:"awaiting_agent_draft"`
ScheduledPending int64 `json:"scheduled_pending"`
// ScheduledPendingMax is the hard cap on pending scheduled email
// tasks per user. The dashboard shows current/max so the user
// tasks per workspace. The dashboard shows current/max so the user
// sees how close they are to the limit before hitting it.
ScheduledPendingMax int64 `json:"scheduled_pending_max"`
Folders []UniboxFolderOverview `json:"folders"`
+45 -27
View File
@@ -32,6 +32,14 @@ func decodeBody(t *testing.T, r seenRequest) map[string]any {
// testDomain is a domain every vendor's calls accept.
var testDomain = Domain{ID: "7", Name: "acme.io"}
// testDomainFor is testDomain as the vendor lists it; workspace-scoped vendors carry the workspace in the id.
func testDomainFor(vendor string) Domain {
if vendor == VendorInboxKit || vendor == VendorScaledMail || vendor == VendorZapmail {
return Domain{ID: "ws-1:7", Name: "acme.io"}
}
return testDomain
}
func TestDomainCapabilitiesAllVendors(t *testing.T) {
want := map[string]DomainCapabilities{
VendorInboxKit: {Forwarding: true, ForwardingRemove: true, DNS: true, DNSTypes: allDNSTypes()},
@@ -66,11 +74,11 @@ func TestDomainCallsUnauthorizedAllVendors(t *testing.T) {
dm := domainManager(t, newTestClient(t, d.ID, fieldsFor(d.ID), srv.URL, nil))
runs := map[string]func() error{
"domains": func() error { _, err := dm.Domains(context.Background()); return err },
"forwarding": func() error { return dm.SetForwarding(context.Background(), testDomain, "https://acme.com") },
"forwarding": func() error { return dm.SetForwarding(context.Background(), testDomainFor(d.ID), "https://acme.com") },
}
if dm.DomainCapabilities().DNS {
runs["dns"] = func() error {
return dm.UpsertDNSRecord(context.Background(), testDomain, DNSRecord{Type: "TXT", Name: "_warmbly.acme.io", Value: "warmbly-verify=abc"})
return dm.UpsertDNSRecord(context.Background(), testDomainFor(d.ID), DNSRecord{Type: "TXT", Name: "_warmbly.acme.io", Value: "warmbly-verify=abc"})
}
}
for name, run := range runs {
@@ -211,6 +219,8 @@ func TestInboxKitDomains(t *testing.T) {
{"_id":"60f7b8c9e4b0b8c9e4b0b8c4","host":"@","type":"A","value":"192.0.2.2","ttl":3600}]}}`
srv := newRecorder(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/api/workspaces/list":
writeJSON(w, 200, `{"error":false,"workspaces":[{"uid":"`+ws+`","name":"Main"}]}`)
case "/v1/api/domains/list":
var body struct{ Page int }
_ = json.NewDecoder(r.Body).Decode(&body)
@@ -233,18 +243,18 @@ func TestInboxKitDomains(t *testing.T) {
writeJSON(w, 404, `{}`)
}
})
dm := domainManager(t, newTestClient(t, VendorInboxKit, map[string]string{FieldAPIKey: testKey, FieldWorkspaceID: ws}, srv.URL, nil))
dm := domainManager(t, newTestClient(t, VendorInboxKit, map[string]string{FieldAPIKey: testKey}, srv.URL, nil))
ctx := context.Background()
got, err := dm.Domains(ctx)
if err != nil {
t.Fatal(err)
}
want := []Domain{{ID: "3340bae3-53c3-4ab8-9c61-1d8205b86c70", Name: "example.com", Forwarding: "https://example.org"}, {ID: "u2", Name: "acme.io"}}
want := []Domain{{ID: ws + ":3340bae3-53c3-4ab8-9c61-1d8205b86c70", Name: "example.com", Forwarding: "https://example.org"}, {ID: ws + ":u2", Name: "acme.io"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Domains = %+v", got)
}
d := Domain{ID: "u2", Name: "acme.io"}
d := got[1]
if err := dm.SetForwarding(ctx, d, "https://acme.com"); err != nil {
t.Fatal(err)
}
@@ -260,14 +270,20 @@ func TestInboxKitDomains(t *testing.T) {
t.Fatal(err)
}
// A new CNAME on a domain with no records yet.
if err := dm.UpsertDNSRecord(ctx, Domain{ID: "fresh", Name: "acme.io"}, DNSRecord{Type: "CNAME", Name: "track.acme.io", Value: "track.warmbly.com"}); err != nil {
if err := dm.UpsertDNSRecord(ctx, Domain{ID: ws + ":fresh", Name: "acme.io"}, DNSRecord{Type: "CNAME", Name: "track.acme.io", Value: "track.warmbly.com"}); err != nil {
t.Fatal(err)
}
// A domain id without its workspace cannot be addressed, so nothing is sent.
if err := dm.SetForwarding(ctx, Domain{ID: "u2", Name: "acme.io"}, "https://acme.com"); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("unscoped id: %v", err)
}
reqs := srv.requests()
for _, r := range reqs {
assertHeader(t, r, "Authorization", "Bearer "+testKey)
assertHeader(t, r, "X-Workspace-Id", ws)
if r.Path != "/v1/api/workspaces/list" {
assertHeader(t, r, "X-Workspace-Id", ws)
}
}
var writes []map[string]any
var paths []string
@@ -305,7 +321,7 @@ func TestInboxKitDNSWithoutRecordIDs(t *testing.T) {
writeJSON(w, 200, `{"error":false,"dns_record":{"records":[{"host":"_warmbly","type":"TXT","value":"warmbly-verify=old"}]}}`)
})
dm := domainManager(t, newTestClient(t, VendorInboxKit, fieldsFor(VendorInboxKit), srv.URL, nil))
err := dm.UpsertDNSRecord(context.Background(), Domain{ID: "u2", Name: "acme.io"}, DNSRecord{Type: "TXT", Name: "_warmbly.acme.io", Value: "warmbly-verify=new"})
err := dm.UpsertDNSRecord(context.Background(), Domain{ID: "ws-1:u2", Name: "acme.io"}, DNSRecord{Type: "TXT", Name: "_warmbly.acme.io", Value: "warmbly-verify=new"})
if !errors.Is(err, ErrUnsupported) {
t.Fatalf("err = %v, want ErrUnsupported", err)
}
@@ -313,7 +329,7 @@ func TestInboxKitDNSWithoutRecordIDs(t *testing.T) {
t.Fatalf("%d requests, want only the list", n)
}
// An identical record needs no id: nothing is written.
if err := dm.UpsertDNSRecord(context.Background(), Domain{ID: "u2", Name: "acme.io"}, DNSRecord{Type: "TXT", Name: "_warmbly.acme.io", Value: "warmbly-verify=old"}); err != nil {
if err := dm.UpsertDNSRecord(context.Background(), Domain{ID: "ws-1:u2", Name: "acme.io"}, DNSRecord{Type: "TXT", Name: "_warmbly.acme.io", Value: "warmbly-verify=old"}); err != nil {
t.Fatalf("no-op: %v", err)
}
}
@@ -323,7 +339,7 @@ func TestInboxKitDomainErrorEnvelope(t *testing.T) {
writeJSON(w, 200, `{"error":true,"message":"Domain not found"}`)
})
dm := domainManager(t, newTestClient(t, VendorInboxKit, fieldsFor(VendorInboxKit), srv.URL, nil))
if err := dm.SetForwarding(context.Background(), testDomain, "https://acme.com"); err == nil {
if err := dm.SetForwarding(context.Background(), testDomainFor(VendorInboxKit), "https://acme.com"); err == nil {
t.Fatal("SetForwarding accepted an error envelope")
}
}
@@ -331,6 +347,8 @@ func TestInboxKitDomainErrorEnvelope(t *testing.T) {
func TestZapmailDomains(t *testing.T) {
srv := newRecorder(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/workspaces":
writeJSON(w, 200, `{"status":200,"data":[{"id":"ws-123","name":"Main"}]}`)
case "/v2/domains":
if r.Header.Get("x-service-provider") == "MICROSOFT" {
writeJSON(w, 200, `{"status":200,"message":"ok","data":{"totalSearchedCount":1,"currentPage":1,"nextPage":null,"totalPages":1,"domains":[{"id":"ms-d","domain":"contoso.co","status":"ACTIVE","forwardTo":null}]}}`)
@@ -350,20 +368,20 @@ func TestZapmailDomains(t *testing.T) {
writeJSON(w, 404, `{}`)
}
})
dm := domainManager(t, newTestClient(t, VendorZapmail, map[string]string{FieldAPIKey: testKey, FieldWorkspaceID: "ws-123"}, srv.URL, nil))
dm := domainManager(t, newTestClient(t, VendorZapmail, map[string]string{FieldAPIKey: testKey}, srv.URL, nil))
ctx := context.Background()
got, err := dm.Domains(ctx)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, []Domain{{ID: "g-d", Name: "acme.io", Forwarding: "acme.com"}, {ID: "ms-d", Name: "contoso.co"}}) {
if !reflect.DeepEqual(got, []Domain{{ID: "ws-123:g-d", Name: "acme.io", Forwarding: "acme.com"}, {ID: "ws-123:ms-d", Name: "contoso.co"}}) {
t.Fatalf("Domains = %+v", got)
}
d := Domain{ID: "g-d", Name: "acme.io"}
d := got[0]
if err := dm.SetForwarding(ctx, d, "https://acme.com"); err != nil {
t.Fatal(err)
}
if err := dm.SetForwarding(ctx, Domain{ID: "ms-d", Name: "contoso.co"}, ""); err != nil {
if err := dm.SetForwarding(ctx, got[1], ""); err != nil {
t.Fatal(err)
}
if err := dm.UpsertDNSRecord(ctx, d, DNSRecord{Type: "CNAME", Name: "track.acme.io", Value: "track.warmbly.com", TTL: 60}); err != nil {
@@ -376,7 +394,12 @@ func TestZapmailDomains(t *testing.T) {
t.Fatal(err)
}
// The workspace list comes first; drop it so the indexes below read the domain calls.
reqs := srv.requests()
if reqs[0].Path != "/v2/workspaces" {
t.Fatalf("first call = %s", reqs[0].Path)
}
reqs = reqs[1:]
for _, r := range reqs {
assertHeader(t, r, "x-auth-zapmail", testKey)
assertHeader(t, r, "x-workspace-key", "ws-123")
@@ -432,20 +455,13 @@ func TestForgeDomains(t *testing.T) {
writeJSON(w, 404, `{"code":404,"message":"Domain not found"}`)
}
})
fields := map[string]string{FieldAPIKey: testKey}
if vendor == VendorInfraforge {
fields[FieldWorkspaceID] = "wks_1"
}
dm := domainManager(t, newTestClient(t, vendor, fields, srv.URL, nil))
dm := domainManager(t, newTestClient(t, vendor, map[string]string{FieldAPIKey: testKey}, srv.URL, nil))
ctx := context.Background()
got, err := dm.Domains(ctx)
if err != nil {
t.Fatal(err)
}
want := []Domain{{ID: "dom_1", Name: "acme.io", Forwarding: "https://acme.com"}}
if vendor == VendorMailforge {
want = append(want, Domain{ID: "dom_2", Name: "other.com"})
}
want := []Domain{{ID: "dom_1", Name: "acme.io", Forwarding: "https://acme.com"}, {ID: "dom_2", Name: "other.com"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Domains = %+v", got)
}
@@ -615,6 +631,8 @@ func TestScaledMailDomains(t *testing.T) {
const org = "recORG000000001"
srv := newRecorder(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/organizations":
writeJSON(w, 200, `[{"id":"`+org+`","name":"Acme"}]`)
case r.URL.Path == "/domains":
writeJSON(w, 200, `{"total":2,"domains":[{"id":"recDOM1","domain":"outreach-one.com","tag":"","redirect":"https://example.com","order_type":"google","status":"Active"},{"id":"recDOM2","domain":"outreach-two.com","redirect":"","order_type":"outlook","status":"Active"}]}`)
case strings.HasPrefix(r.URL.Path, "/swap-redirect/"):
@@ -623,10 +641,10 @@ func TestScaledMailDomains(t *testing.T) {
writeJSON(w, 404, `{"error":"Domain not found"}`)
}
})
dm := domainManager(t, newTestClient(t, VendorScaledMail, map[string]string{FieldAPIKey: testKey, FieldOrganizationID: org}, srv.URL, nil))
dm := domainManager(t, newTestClient(t, VendorScaledMail, map[string]string{FieldAPIKey: testKey}, srv.URL, nil))
ctx := context.Background()
got, err := dm.Domains(ctx)
if err != nil || !reflect.DeepEqual(got, []Domain{{ID: "recDOM1", Name: "outreach-one.com", Forwarding: "https://example.com"}, {ID: "recDOM2", Name: "outreach-two.com"}}) {
if err != nil || !reflect.DeepEqual(got, []Domain{{ID: org + ":recDOM1", Name: "outreach-one.com", Forwarding: "https://example.com"}, {ID: org + ":recDOM2", Name: "outreach-two.com"}}) {
t.Fatalf("Domains = %+v, %v", got, err)
}
if err := dm.SetForwarding(ctx, got[0], "https://mybrand.com/landing"); err != nil {
@@ -642,9 +660,9 @@ func TestScaledMailDomains(t *testing.T) {
if err := dm.SetForwarding(ctx, got[1], ""); err != nil {
t.Fatal(err)
}
reqs := srv.requests()
reqs := srv.requests()[1:]
if len(reqs) != 3 {
t.Fatalf("%d requests, want 3", len(reqs))
t.Fatalf("%d requests after the organization list, want 3", len(reqs))
}
for _, r := range reqs[1:] {
if r.Method != http.MethodPost || r.Path != "/swap-redirect/outreach-one.com" || r.q("organization_id") != org {
+46 -14
View File
@@ -12,27 +12,59 @@ import (
// Mailforge (https://api.mailforge.ai/swagger/doc.json) and Infraforge
// (https://api.infraforge.ai/public/swagger/doc.json) share one API shape.
// The key reaches every workspace of the account, and the mailbox list spans them all.
type forge struct {
vendor string
t *transport
workspace string
cache credCache
dnsMu sync.Mutex
vendor string
t *transport
cache credCache
dnsMu sync.Mutex
workspaces workspaceCache
}
func newForge(vendor, base, workspace string, vals map[string]string, o options) *forge {
func newForge(vendor, base string, vals map[string]string, o options) *forge {
key := vals[FieldAPIKey]
// Both take the raw key, with no Bearer prefix.
auth := func(h http.Header) { h.Set("Authorization", key) }
return &forge{vendor: vendor, t: newTransport(vendor, base, o, auth, 0), workspace: workspace}
return &forge{vendor: vendor, t: newTransport(vendor, base, o, auth, 0)}
}
func (c *forge) Vendor() string { return c.vendor }
// listWorkspaces reads GET /workspaces; a body in another shape yields no names rather than an error.
func (c *forge) listWorkspaces(ctx context.Context) ([]workspace, error) {
var raw json.RawMessage
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/workspaces"}, &raw); err != nil {
return nil, err
}
var res []struct {
ID string `json:"id"`
Name string `json:"name"`
}
_ = json.Unmarshal(raw, &res)
out := make([]workspace, 0, len(res))
for _, w := range res {
out = append(out, workspace{ID: w.ID, Name: w.Name})
}
return out, nil
}
func (c *forge) Verify(ctx context.Context) error {
return c.t.do(ctx, call{method: http.MethodGet, path: "/workspaces"}, nil)
}
// workspaceNames labels mailboxes by workspace; it is cosmetic, so a failed read leaves them unlabelled.
func (c *forge) workspaceNames(ctx context.Context) map[string]string {
wss, err := c.workspaces.get(ctx, c.listWorkspaces)
if err != nil {
return nil
}
names := make(map[string]string, len(wss))
for _, w := range wss {
names[w.ID] = w.Name
}
return names
}
type forgeMailbox struct {
ID string `json:"id"`
Email string `json:"email"`
@@ -40,6 +72,7 @@ type forgeMailbox struct {
LastName string `json:"lastName"`
Domain string `json:"domain"`
Status string `json:"status"`
WorkspaceID string `json:"workspaceId"`
Credentials *struct {
IMAPHost string `json:"imapHost"`
IMAPPort int `json:"imapPort"`
@@ -84,16 +117,14 @@ func (m forgeMailbox) mailbox() Mailbox {
}
}
// List reads every mailbox with credentials in one unpaginated call.
// List reads every workspace's mailboxes with credentials in one unpaginated call.
func (c *forge) List(ctx context.Context) ([]Mailbox, error) {
q := url.Values{"with_credentials": {"true"}}
if c.workspace != "" {
q.Set("workspace_id", c.workspace)
}
var res []forgeMailbox
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/mailboxes", query: q, limit: listBodyLimit}, &res); err != nil {
return nil, err
}
names := c.workspaceNames(ctx)
out := make([]Mailbox, 0, min(len(res), MaxMailboxes))
for _, m := range res {
if m.ID == "" {
@@ -102,7 +133,9 @@ func (c *forge) List(ctx context.Context) ([]Mailbox, error) {
if cr, ok := m.credentials(); ok {
c.cache.put(m.ID, cr)
}
out = append(out, m.mailbox())
mb := m.mailbox()
mb.Workspace = names[m.WorkspaceID]
out = append(out, mb)
if len(out) >= MaxMailboxes {
break
}
@@ -137,7 +170,6 @@ type forgeDomain struct {
SLD string `json:"sld"`
TLD string `json:"tld"`
ForwardToDomain string `json:"forwardToDomain"`
WorkspaceID string `json:"workspaceId"`
}
// Domains reads GET /domains in one unpaginated call.
@@ -159,7 +191,7 @@ func (c *forge) Domains(ctx context.Context) ([]Domain, error) {
}
out := make([]Domain, 0, min(len(res), MaxDomains))
for _, d := range res {
if d.ID == "" || (c.workspace != "" && d.WorkspaceID != "" && d.WorkspaceID != c.workspace) {
if d.ID == "" {
continue
}
name := d.SLD
+176 -90
View File
@@ -9,23 +9,62 @@ import (
)
// InboxKit: https://docs.inboxkit.com/llms.txt
// Every call but the workspace list is scoped by X-Workspace-Id, so the client reads all of the key's workspaces.
type inboxKit struct {
t *transport
t *transport
workspaces workspaceCache
}
const inboxKitPageSize = 100
func newInboxKit(vals map[string]string, o options) *inboxKit {
key, ws := vals[FieldAPIKey], vals[FieldWorkspaceID]
auth := func(h http.Header) {
h.Set("Authorization", "Bearer "+key)
h.Set("X-Workspace-Id", ws)
}
return &inboxKit{t: newTransport(VendorInboxKit, "https://api.inboxkit.com", o, auth, 0)}
return &inboxKit{t: newTransport(VendorInboxKit, "https://api.inboxkit.com", o, bearer(vals[FieldAPIKey]), 0)}
}
func (c *inboxKit) Vendor() string { return VendorInboxKit }
func inboxKitHeader(ws string) http.Header {
h := http.Header{}
h.Set("X-Workspace-Id", ws)
return h
}
// listWorkspaces reads GET /v1/api/workspaces/list, the one call the key alone answers.
func (c *inboxKit) listWorkspaces(ctx context.Context) ([]workspace, error) {
var res struct {
inboxKitEnvelope
Workspaces []struct {
UID string `json:"uid"`
Name string `json:"name"`
} `json:"workspaces"`
}
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/v1/api/workspaces/list"}, &res); err != nil {
return nil, err
}
if err := res.err(); err != nil {
return nil, err
}
out := make([]workspace, 0, len(res.Workspaces))
for _, w := range res.Workspaces {
if w.UID != "" {
out = append(out, workspace{ID: w.UID, Name: w.Name})
}
}
return out, nil
}
// all is every workspace the key reaches; a key that reaches none can read nothing.
func (c *inboxKit) all(ctx context.Context) ([]workspace, error) {
wss, err := c.workspaces.get(ctx, c.listWorkspaces)
if err != nil {
return nil, err
}
if len(wss) == 0 {
return nil, vendorErr(VendorInboxKit, http.StatusOK, "no workspace", ErrNoWorkspace)
}
return wss, nil
}
type inboxKitList struct {
Error bool `json:"error"`
Mailboxes []struct {
@@ -41,10 +80,10 @@ type inboxKitList struct {
CurrentPage int `json:"current_page"`
}
func (c *inboxKit) page(ctx context.Context, page, limit int) (inboxKitList, error) {
func (c *inboxKit) page(ctx context.Context, ws string, page, limit int) (inboxKitList, error) {
var out inboxKitList
body := map[string]any{"page": page, "limit": limit}
if err := c.t.do(ctx, call{method: http.MethodPost, path: "/v1/api/mailboxes/list", body: body}, &out); err != nil {
if err := c.t.do(ctx, call{method: http.MethodPost, path: "/v1/api/mailboxes/list", body: body, header: inboxKitHeader(ws)}, &out); err != nil {
return out, err
}
if out.Error {
@@ -53,62 +92,92 @@ func (c *inboxKit) page(ctx context.Context, page, limit int) (inboxKitList, err
return out, nil
}
// Verify lists one mailbox, which checks the key and the workspace together.
// Verify reads the workspace list, which checks the key and that it reaches one.
func (c *inboxKit) Verify(ctx context.Context) error {
_, err := c.page(ctx, 1, 1)
wss, err := c.listWorkspaces(ctx)
if err == nil && len(wss) == 0 {
err = vendorErr(VendorInboxKit, http.StatusOK, "no workspace", ErrNoWorkspace)
}
return err
}
// List pages through every workspace's mailboxes; ids carry their workspace.
func (c *inboxKit) List(ctx context.Context) ([]Mailbox, error) {
wss, err := c.all(ctx)
if err != nil {
return nil, err
}
var out []Mailbox
for page := 1; page <= maxPages; page++ {
res, err := c.page(ctx, page, inboxKitPageSize)
if err != nil {
return nil, err
}
for _, m := range res.Mailboxes {
email := m.Username
if m.DomainName != "" {
email = m.Username + "@" + m.DomainName
err = eachWorkspace(wss, func(ws workspace) error {
for page := 1; page <= maxPages; page++ {
res, err := c.page(ctx, ws.ID, page, inboxKitPageSize)
if err != nil {
return err
}
out = append(out, Mailbox{
ID: m.UID,
Email: email,
FirstName: m.FirstName,
LastName: m.LastName,
Domain: m.DomainName,
Provider: normalizeProvider(m.Platform),
Status: m.Status,
})
if len(out) >= MaxMailboxes {
return out, nil
for _, m := range res.Mailboxes {
email := m.Username
if m.DomainName != "" {
email = m.Username + "@" + m.DomainName
}
out = append(out, Mailbox{
ID: scoped(ws.ID, m.UID),
Email: email,
FirstName: m.FirstName,
LastName: m.LastName,
Domain: m.DomainName,
Provider: normalizeProvider(m.Platform),
Status: m.Status,
Workspace: ws.Name,
})
if len(out) >= MaxMailboxes {
return errStop
}
}
if len(res.Mailboxes) == 0 || page >= res.Pages {
break
}
}
if len(res.Mailboxes) == 0 || page >= res.Pages {
break
}
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// Credentials reads show-credentials; InboxKit's list carries none.
// Credentials reads show-credentials in the mailbox's workspace; an id stored without one is looked up in each.
func (c *inboxKit) Credentials(ctx context.Context, m Mailbox) (Credentials, error) {
ws, uid := unscoped(m.ID)
if ws != "" {
return c.show(ctx, ws, uid, m.Email)
}
wss, err := c.all(ctx)
if err != nil {
return Credentials{}, err
}
return findCredentials(VendorInboxKit, wss, func(w workspace) (Credentials, error) {
return c.show(ctx, w.ID, uid, m.Email)
})
}
func (c *inboxKit) show(ctx context.Context, ws, uid, email string) (Credentials, error) {
q := url.Values{}
if m.ID != "" {
q.Set("uid", m.ID)
if uid != "" {
q.Set("uid", uid)
} else {
q.Set("email", m.Email)
q.Set("email", email)
}
var res struct {
Error bool `json:"error"`
Password string `json:"password"`
AppPassword string `json:"app_password"`
}
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/v1/api/mailboxes/show-credentials", query: q}, &res); err != nil {
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/v1/api/mailboxes/show-credentials", query: q, header: inboxKitHeader(ws)}, &res); err != nil {
return Credentials{}, err
}
// An error envelope here means the workspace does not hold the mailbox.
if res.Error {
return Credentials{}, vendorErr(VendorInboxKit, http.StatusOK, "vendor reported an error", nil)
return Credentials{}, vendorErr(VendorInboxKit, http.StatusOK, "not found", ErrNotFound)
}
return Credentials{Password: res.Password, AppPassword: res.AppPassword}, nil
}
@@ -131,57 +200,77 @@ func (c *inboxKit) DomainCapabilities() DomainCapabilities {
return DomainCapabilities{Forwarding: true, ForwardingRemove: true, DNS: true, DNSTypes: allDNSTypes()}
}
// Domains pages through POST /v1/api/domains/list.
// Domains pages through POST /v1/api/domains/list in every workspace; ids carry their workspace.
func (c *inboxKit) Domains(ctx context.Context) ([]Domain, error) {
wss, err := c.all(ctx)
if err != nil {
return nil, err
}
var out []Domain
for page := 1; page <= maxPages; page++ {
var res struct {
inboxKitEnvelope
Domains []struct {
UID string `json:"uid"`
Name string `json:"name"`
TLD string `json:"tld"`
ForwardingURL string `json:"forwarding_url"`
} `json:"domains"`
Pages int `json:"pages"`
}
body := map[string]any{"page": page, "limit": inboxKitDomainPageSize}
if err := c.t.do(ctx, call{method: http.MethodPost, path: "/v1/api/domains/list", body: body}, &res); err != nil {
return nil, err
}
if err := res.err(); err != nil {
return nil, err
}
for _, d := range res.Domains {
name := d.Name
// The Domain schema describes name as the label without its TLD; the list example carries both.
if name != "" && !strings.Contains(name, ".") && d.TLD != "" {
name += "." + strings.TrimPrefix(d.TLD, ".")
err = eachWorkspace(wss, func(ws workspace) error {
for page := 1; page <= maxPages; page++ {
var res struct {
inboxKitEnvelope
Domains []struct {
UID string `json:"uid"`
Name string `json:"name"`
TLD string `json:"tld"`
ForwardingURL string `json:"forwarding_url"`
} `json:"domains"`
Pages int `json:"pages"`
}
out = append(out, Domain{ID: d.UID, Name: name, Forwarding: d.ForwardingURL})
if len(out) >= MaxDomains {
return out, nil
body := map[string]any{"page": page, "limit": inboxKitDomainPageSize}
if err := c.t.do(ctx, call{method: http.MethodPost, path: "/v1/api/domains/list", body: body, header: inboxKitHeader(ws.ID)}, &res); err != nil {
return err
}
if err := res.err(); err != nil {
return err
}
for _, d := range res.Domains {
name := d.Name
// The Domain schema describes name as the label without its TLD; the list example carries both.
if name != "" && !strings.Contains(name, ".") && d.TLD != "" {
name += "." + strings.TrimPrefix(d.TLD, ".")
}
out = append(out, Domain{ID: scoped(ws.ID, d.UID), Name: name, Forwarding: d.ForwardingURL})
if len(out) >= MaxDomains {
return errStop
}
}
if len(res.Domains) == 0 || page >= res.Pages {
break
}
}
if len(res.Domains) == 0 || page >= res.Pages {
break
}
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// inboxKitDomain splits a listed domain's id into its workspace and uid.
func inboxKitDomain(d Domain) (ws, uid string, err error) {
ws, uid = unscoped(d.ID)
if ws == "" || uid == "" {
return "", "", invalid(VendorInboxKit, "domain id is required")
}
return ws, uid, nil
}
// SetForwarding calls POST /v1/api/domains/forwarding; an empty forwarding_url removes it.
func (c *inboxKit) SetForwarding(ctx context.Context, d Domain, target string) error {
target, err := forwardingTarget(VendorInboxKit, target, c.DomainCapabilities())
if err != nil {
return err
}
if d.ID == "" {
return invalid(VendorInboxKit, "domain id is required")
ws, uid, err := inboxKitDomain(d)
if err != nil {
return err
}
var res inboxKitEnvelope
body := map[string]any{"uids": []string{d.ID}, "forwarding_url": target}
if err := c.t.do(ctx, call{method: http.MethodPost, path: "/v1/api/domains/forwarding", body: body}, &res); err != nil {
body := map[string]any{"uids": []string{uid}, "forwarding_url": target}
if err := c.t.do(ctx, call{method: http.MethodPost, path: "/v1/api/domains/forwarding", body: body, header: inboxKitHeader(ws)}, &res); err != nil {
return err
}
return res.err()
@@ -197,12 +286,9 @@ type inboxKitRecord struct {
TTL *int `json:"ttl,omitempty"`
}
// inboxKitTarget names the domain by uid, else by name; the DNS endpoints take either.
func inboxKitTarget(d Domain, domain string) map[string]any {
if d.ID != "" {
return map[string]any{"uid": d.ID}
}
return map[string]any{"domain": domain}
// inboxKitTarget names the domain by uid.
func inboxKitTarget(uid string) map[string]any {
return map[string]any{"uid": uid}
}
// UpsertDNSRecord reads /v1/api/dns/list, then adds, updates or deletes through /v1/api/dns/{add,update,delete}.
@@ -211,19 +297,19 @@ func (c *inboxKit) UpsertDNSRecord(ctx context.Context, d Domain, r DNSRecord) e
if err != nil {
return err
}
q := url.Values{}
if d.ID != "" {
q.Set("uid", d.ID)
} else {
q.Set("domain", domain)
ws, uid, err := inboxKitDomain(d)
if err != nil {
return err
}
h := inboxKitHeader(ws)
q := url.Values{"uid": {uid}}
var list struct {
inboxKitEnvelope
DNSRecord struct {
Records []inboxKitRecord `json:"records"`
} `json:"dns_record"`
}
err = c.t.do(ctx, call{method: http.MethodGet, path: "/v1/api/dns/list", query: q}, &list)
err = c.t.do(ctx, call{method: http.MethodGet, path: "/v1/api/dns/list", query: q, header: h}, &list)
// A 404 also means "no records yet"; a missing domain fails again on the write.
if err != nil && !errors.Is(err, ErrNotFound) {
return err
@@ -250,10 +336,10 @@ func (c *inboxKit) UpsertDNSRecord(ctx context.Context, d Domain, r DNSRecord) e
path = "/v1/api/dns/update"
rec.RecordID = existing[p.update].ID
}
body := inboxKitTarget(d, domain)
body := inboxKitTarget(uid)
body["records"] = []inboxKitRecord{rec}
var res inboxKitEnvelope
if err := c.t.do(ctx, call{method: http.MethodPost, path: path, body: body}, &res); err != nil {
if err := c.t.do(ctx, call{method: http.MethodPost, path: path, body: body, header: h}, &res); err != nil {
return err
}
if err := res.err(); err != nil {
@@ -266,10 +352,10 @@ func (c *inboxKit) UpsertDNSRecord(ctx context.Context, d Domain, r DNSRecord) e
for i, idx := range p.remove {
ids[i] = existing[idx].ID
}
del := inboxKitTarget(d, domain)
del := inboxKitTarget(uid)
del["record_ids"] = ids
res = inboxKitEnvelope{}
if err := c.t.do(ctx, call{method: http.MethodPost, path: "/v1/api/dns/delete", body: del}, &res); err != nil {
if err := c.t.do(ctx, call{method: http.MethodPost, path: "/v1/api/dns/delete", body: del, header: h}, &res); err != nil {
return err
}
return res.err()
+19 -26
View File
@@ -24,10 +24,8 @@ const (
// Field keys a descriptor may ask for.
const (
FieldAPIKey = "api_key"
FieldWorkspaceID = "workspace_id"
FieldOrganizationID = "organization_id"
FieldServiceProvider = "service_provider"
FieldAPIKey = "api_key"
FieldOrganizationID = "organization_id"
)
// Provider values on Mailbox.
@@ -53,6 +51,7 @@ var (
ErrNotFound = errors.New("mailvendor: mailbox not found")
ErrUnknownVendor = errors.New("mailvendor: unknown vendor")
ErrInvalidConfig = errors.New("mailvendor: invalid configuration")
ErrNoWorkspace = errors.New("mailvendor: key reaches no workspace")
)
// Field is a credential the customer must enter, keyed by Key.
@@ -62,6 +61,8 @@ type Field struct {
Secret bool
Required bool
Help string
// Legacy fields are read from connections saved before the value was discovered, and never asked for.
Legacy bool
}
// Descriptor describes a vendor and what connecting it asks for.
@@ -84,6 +85,8 @@ type Mailbox struct {
Domain string
Provider string
Status string
// Workspace is the name of the vendor workspace holding the mailbox, empty where the vendor has none.
Workspace string
}
// Endpoint is one server a mailbox connects to.
@@ -145,23 +148,16 @@ var descriptors = []Descriptor{
Label: "InboxKit",
Website: "https://inboxkit.com",
KeyHelpURL: "https://app.inboxkit.com/settings/api",
Fields: []Field{
apiKeyField("Settings > API & Integrations in the InboxKit dashboard."),
{Key: FieldWorkspaceID, Label: "Workspace ID", Required: true, Help: "The workspace whose mailboxes to import (a UUID)."},
},
Verified: true,
Fields: []Field{apiKeyField("Settings > API & Integrations in the InboxKit dashboard. Mailboxes from every workspace the key reaches are listed.")},
Verified: true,
},
{
ID: VendorZapmail,
Label: "Zapmail",
Website: "https://zapmail.ai",
KeyHelpURL: "https://docs.zapmail.ai/zapmail-docs-825990m0",
Fields: []Field{
apiKeyField("Settings > Integrations > API in the Zapmail dashboard."),
{Key: FieldWorkspaceID, Label: "Workspace ID", Help: "Leave blank for your primary workspace."},
{Key: FieldServiceProvider, Label: "Mailbox type", Help: "GOOGLE or MICROSOFT. Leave blank to import both."},
},
Verified: true,
Fields: []Field{apiKeyField("Settings > Integrations > API in the Zapmail dashboard. Mailboxes from every workspace the key reaches are listed.")},
Verified: true,
},
{
ID: VendorMailforge,
@@ -176,11 +172,8 @@ var descriptors = []Descriptor{
Label: "Infraforge",
Website: "https://infraforge.ai",
KeyHelpURL: "https://api.infraforge.ai/public/swagger/index.html",
Fields: []Field{
apiKeyField("Settings > API keys in the Infraforge dashboard."),
{Key: FieldWorkspaceID, Label: "Workspace ID", Help: "Leave blank to import every workspace."},
},
Verified: true,
Fields: []Field{apiKeyField("Settings > API keys in the Infraforge dashboard. Mailboxes from every workspace are listed.")},
Verified: true,
},
{
ID: VendorMaildoso,
@@ -204,8 +197,8 @@ var descriptors = []Descriptor{
Website: "https://scaledmail.com",
KeyHelpURL: "https://app.scaledmail.com/settings",
Fields: []Field{
apiKeyField("Settings in the ScaledMail dashboard."),
{Key: FieldOrganizationID, Label: "Organization ID", Required: true, Help: "The organization whose mailboxes to import."},
apiKeyField("Settings in the ScaledMail dashboard. Mailboxes from every organization the key reaches are listed."),
{Key: FieldOrganizationID, Label: "Organization ID", Legacy: true},
},
Verified: true,
},
@@ -244,7 +237,7 @@ func New(vendor string, fields map[string]string, opts ...Option) (Client, error
vals := make(map[string]string, len(d.Fields))
for _, f := range d.Fields {
v := strings.TrimSpace(fields[f.Key])
if v == "" && f.Required {
if v == "" && f.Required && !f.Legacy {
return nil, fmt.Errorf("%w: %s: %s is required", ErrInvalidConfig, vendor, f.Label)
}
if strings.ContainsFunc(v, func(r rune) bool { return r < 0x20 || r == 0x7f }) {
@@ -263,11 +256,11 @@ func New(vendor string, fields map[string]string, opts ...Option) (Client, error
case VendorInboxKit:
return newInboxKit(vals, o), nil
case VendorZapmail:
return newZapmail(vals, o)
return newZapmail(vals, o), nil
case VendorMailforge:
return newForge(VendorMailforge, "https://api.mailforge.ai/public", "", vals, o), nil
return newForge(VendorMailforge, "https://api.mailforge.ai/public", vals, o), nil
case VendorInfraforge:
return newForge(VendorInfraforge, "https://api.infraforge.ai/public", vals[FieldWorkspaceID], vals, o), nil
return newForge(VendorInfraforge, "https://api.infraforge.ai/public", vals, o), nil
case VendorMaildoso:
return newMaildoso(vals, o), nil
case VendorCheapInboxes:
+12 -10
View File
@@ -99,10 +99,8 @@ func writeJSON(w http.ResponseWriter, status int, body string) {
// fieldsFor returns a complete, valid field set for a vendor.
func fieldsFor(vendor string) map[string]string {
f := map[string]string{FieldAPIKey: testKey}
switch vendor {
case VendorInboxKit:
f[FieldWorkspaceID] = "6f1c2d3e-0000-4000-8000-000000000001"
case VendorScaledMail:
if vendor == VendorScaledMail {
// A legacy organization id keeps ScaledMail usable against a server that lists none.
f[FieldOrganizationID] = "recORG000000001"
}
return f
@@ -119,6 +117,12 @@ func TestDescriptorsStableAndComplete(t *testing.T) {
if len(d.Fields) == 0 || d.Fields[0].Key != FieldAPIKey || !d.Fields[0].Secret || !d.Fields[0].Required {
t.Errorf("%s: first field must be the required, secret api_key", d.ID)
}
// Anything a vendor's API can discover is never asked for.
for _, f := range d.Fields[1:] {
if !f.Legacy {
t.Errorf("%s: asks for %s beyond the API key", d.ID, f.Key)
}
}
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Descriptors order = %v, want %v", got, want)
@@ -140,8 +144,8 @@ func TestNewValidatesFields(t *testing.T) {
if _, err := New("nope", nil); !errors.Is(err, ErrUnknownVendor) {
t.Fatalf("unknown vendor: %v", err)
}
if _, err := New(VendorInboxKit, map[string]string{FieldAPIKey: testKey}); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("missing workspace: %v", err)
if _, err := New(VendorInboxKit, map[string]string{}); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("missing key: %v", err)
}
if _, err := New(VendorScaledMail, map[string]string{FieldAPIKey: " "}); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("blank key: %v", err)
@@ -150,9 +154,6 @@ func TestNewValidatesFields(t *testing.T) {
if !errors.Is(err, ErrInvalidConfig) || strings.Contains(err.Error(), testKey) {
t.Fatalf("control characters: %v", err)
}
if _, err := New(VendorZapmail, map[string]string{FieldAPIKey: testKey, FieldServiceProvider: "yahoo"}); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("zapmail provider: %v", err)
}
for _, d := range Descriptors() {
c, err := New(d.ID, fieldsFor(d.ID))
if err != nil {
@@ -236,7 +237,8 @@ func TestRateLimitRetryAllVendors(t *testing.T) {
writeJSON(w, http.StatusTooManyRequests, `{"status":429,"message":"Too many requests"}`)
return
}
writeJSON(w, http.StatusOK, `{}`)
// One workspace, for the vendors whose Verify needs the key to reach one.
writeJSON(w, http.StatusOK, `{"workspaces":[{"uid":"ws-1","name":"Main"}]}`)
})
s := &sleeps{}
c := newTestClient(t, d.ID, fieldsFor(d.ID), srv.URL, s)
+169 -49
View File
@@ -2,16 +2,20 @@ package mailvendor
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
)
// ScaledMail: https://api.scaledmail.com/llms.txt
// Every call names an organization_id, so the client reads all of the key's organizations.
type scaledMail struct {
t *transport
org string
cache credCache
t *transport
orgs workspaceCache
// legacyOrg is an organization id a connection saved before organizations were discovered.
legacyOrg string
cache credCache
}
// scaledMailPerSecond is the documented limit; going over it earns a temporary block.
@@ -19,13 +23,90 @@ const scaledMailPerSecond = 5
func newScaledMail(vals map[string]string, o options) *scaledMail {
t := newTransport(VendorScaledMail, "https://server.scaledmail.com/api/v1", o, bearer(vals[FieldAPIKey]), scaledMailPerSecond)
return &scaledMail{t: t, org: vals[FieldOrganizationID]}
return &scaledMail{t: t, legacyOrg: vals[FieldOrganizationID]}
}
func (c *scaledMail) Vendor() string { return VendorScaledMail }
// listOrganizations reads GET /organizations. Its response is undocumented, so any list of objects with an id is read.
func (c *scaledMail) listOrganizations(ctx context.Context) ([]workspace, error) {
var raw json.RawMessage
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/organizations"}, &raw); err != nil {
return nil, err
}
out := scaledMailOrgs(raw)
if len(out) == 0 && c.legacyOrg != "" {
out = []workspace{{ID: c.legacyOrg}}
}
return out, nil
}
type scaledMailOrg struct {
ID string `json:"id"`
UnderscoreID string `json:"_id"`
OrganizationID string `json:"organization_id"`
Name string `json:"name"`
}
func (o scaledMailOrg) workspace() workspace {
return workspace{ID: firstNonEmpty(o.ID, o.OrganizationID, o.UnderscoreID), Name: o.Name}
}
// scaledMailOrgs reads a top-level array, or the arrays and single objects one level inside an object.
func scaledMailOrgs(raw json.RawMessage) []workspace {
var out []workspace
seen := map[string]bool{}
add := func(o scaledMailOrg) {
if w := o.workspace(); w.ID != "" && !seen[w.ID] {
seen[w.ID] = true
out = append(out, w)
}
}
var list []scaledMailOrg
if json.Unmarshal(raw, &list) == nil {
for _, o := range list {
add(o)
}
return out
}
var obj map[string]json.RawMessage
if json.Unmarshal(raw, &obj) != nil {
return nil
}
for _, v := range obj {
var inner []scaledMailOrg
if json.Unmarshal(v, &inner) == nil {
for _, o := range inner {
add(o)
}
continue
}
var one scaledMailOrg
if json.Unmarshal(v, &one) == nil {
add(one)
}
}
return out
}
func (c *scaledMail) all(ctx context.Context) ([]workspace, error) {
orgs, err := c.orgs.get(ctx, c.listOrganizations)
if err != nil {
return nil, err
}
if len(orgs) == 0 {
return nil, vendorErr(VendorScaledMail, http.StatusOK, "no organization", ErrNoWorkspace)
}
return orgs, nil
}
// Verify reads the organizations, which checks the key and that it reaches one.
func (c *scaledMail) Verify(ctx context.Context) error {
return c.t.do(ctx, call{method: http.MethodGet, path: "/organizations"}, nil)
orgs, err := c.listOrganizations(ctx)
if err == nil && len(orgs) == 0 {
err = vendorErr(VendorScaledMail, http.StatusOK, "no organization", ErrNoWorkspace)
}
return err
}
type scaledMailDomain struct {
@@ -44,23 +125,23 @@ type scaledMailMailbox struct {
Password string `json:"mailbox_password"`
}
func (c *scaledMail) domains(ctx context.Context) ([]scaledMailDomain, error) {
func (c *scaledMail) domains(ctx context.Context, org string) ([]scaledMailDomain, error) {
var res struct {
Domains []scaledMailDomain `json:"domains"`
}
q := url.Values{"organization_id": {c.org}}
q := url.Values{"organization_id": {org}}
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/domains", query: q}, &res); err != nil {
return nil, err
}
return res.Domains, nil
}
func (c *scaledMail) mailboxes(ctx context.Context, domainID string) ([]scaledMailMailbox, error) {
func (c *scaledMail) mailboxes(ctx context.Context, org, domainID string) ([]scaledMailMailbox, error) {
var res struct {
// Null when the domain belongs to another organization.
Mailboxes []scaledMailMailbox `json:"mailboxes"`
}
q := url.Values{"organization_id": {c.org}, "password": {"true"}}
q := url.Values{"organization_id": {org}, "password": {"true"}}
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/mailboxes/" + url.PathEscape(domainID), query: q}, &res); err != nil {
return nil, err
}
@@ -77,54 +158,79 @@ func (m scaledMailMailbox) email(domain string) string {
return ""
}
// List reads the domains, then each domain's mailboxes with their passwords.
// List reads each organization's domains, then each domain's mailboxes with their passwords.
func (c *scaledMail) List(ctx context.Context) ([]Mailbox, error) {
domains, err := c.domains(ctx)
orgs, err := c.all(ctx)
if err != nil {
return nil, err
}
var out []Mailbox
for _, d := range domains {
if d.ID == "" {
continue
}
mbs, err := c.mailboxes(ctx, d.ID)
err = eachWorkspace(orgs, func(org workspace) error {
domains, err := c.domains(ctx, org.ID)
if err != nil {
return nil, err
return err
}
for _, m := range mbs {
email := m.email(d.Domain)
if email == "" {
for _, d := range domains {
if d.ID == "" {
continue
}
// ScaledMail has no mailbox id; the address is the stable key.
id := strings.ToLower(email)
c.cache.put(id, Credentials{Password: m.Password})
out = append(out, Mailbox{
ID: id,
Email: email,
FirstName: m.FirstName,
LastName: m.LastName,
Domain: firstNonEmpty(d.Domain, domainOf(email)),
Provider: normalizeProvider(m.OrderType),
Status: m.Status,
})
if len(out) >= MaxMailboxes {
return out, nil
mbs, err := c.mailboxes(ctx, org.ID, d.ID)
if err != nil {
return err
}
for _, m := range mbs {
email := m.email(d.Domain)
if email == "" {
continue
}
// ScaledMail has no mailbox id; the address is the stable key.
id := scoped(org.ID, strings.ToLower(email))
c.cache.put(id, Credentials{Password: m.Password})
out = append(out, Mailbox{
ID: id,
Email: email,
FirstName: m.FirstName,
LastName: m.LastName,
Domain: firstNonEmpty(d.Domain, domainOf(email)),
Provider: normalizeProvider(m.OrderType),
Status: m.Status,
Workspace: org.Name,
})
if len(out) >= MaxMailboxes {
return errStop
}
}
}
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// Credentials comes from List's cache, else re-reads the mailbox's domain.
// Credentials comes from List's cache, else re-reads the mailbox's domain in its organization.
func (c *scaledMail) Credentials(ctx context.Context, m Mailbox) (Credentials, error) {
id := strings.ToLower(firstNonEmpty(m.ID, m.Email))
if cr, ok := c.cache.get(id); ok {
if cr, ok := c.cache.get(m.ID); ok {
return cr, nil
}
domain := strings.ToLower(firstNonEmpty(m.Domain, domainOf(id)))
domains, err := c.domains(ctx)
org, id := unscoped(m.ID)
addr := strings.ToLower(firstNonEmpty(id, m.Email))
domain := strings.ToLower(firstNonEmpty(m.Domain, domainOf(addr)))
if org != "" {
return c.find(ctx, org, domain, addr)
}
orgs, err := c.all(ctx)
if err != nil {
return Credentials{}, err
}
return findCredentials(VendorScaledMail, orgs, func(o workspace) (Credentials, error) {
return c.find(ctx, o.ID, domain, addr)
})
}
func (c *scaledMail) find(ctx context.Context, org, domain, addr string) (Credentials, error) {
domains, err := c.domains(ctx, org)
if err != nil {
return Credentials{}, err
}
@@ -132,12 +238,12 @@ func (c *scaledMail) Credentials(ctx context.Context, m Mailbox) (Credentials, e
if !strings.EqualFold(d.Domain, domain) || d.ID == "" {
continue
}
mbs, err := c.mailboxes(ctx, d.ID)
mbs, err := c.mailboxes(ctx, org, d.ID)
if err != nil {
return Credentials{}, err
}
for _, mb := range mbs {
if strings.EqualFold(mb.email(d.Domain), id) {
if strings.EqualFold(mb.email(d.Domain), addr) {
return Credentials{Password: mb.Password}, nil
}
}
@@ -150,18 +256,28 @@ func (c *scaledMail) DomainCapabilities() DomainCapabilities {
return DomainCapabilities{Forwarding: true, ForwardingRemove: true, ForwardingReviewed: true}
}
// Domains reads GET /domains, which lists the organization's active domains in one call.
// Domains reads GET /domains, which lists one organization's active domains in one call, for each organization.
func (c *scaledMail) Domains(ctx context.Context) ([]Domain, error) {
domains, err := c.domains(ctx)
orgs, err := c.all(ctx)
if err != nil {
return nil, err
}
out := make([]Domain, 0, min(len(domains), MaxDomains))
for _, d := range domains {
out = append(out, Domain{ID: d.ID, Name: d.Domain, Forwarding: d.Redirect})
if len(out) >= MaxDomains {
break
var out []Domain
err = eachWorkspace(orgs, func(org workspace) error {
domains, err := c.domains(ctx, org.ID)
if err != nil {
return err
}
for _, d := range domains {
out = append(out, Domain{ID: scoped(org.ID, d.ID), Name: d.Domain, Forwarding: d.Redirect})
if len(out) >= MaxDomains {
return errStop
}
}
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
@@ -189,7 +305,11 @@ func (c *scaledMail) SetForwarding(ctx context.Context, d Domain, target string)
if strings.EqualFold(strings.TrimRight(withScheme(d.Forwarding), "/"), strings.TrimRight(withScheme(target), "/")) {
return nil
}
q := url.Values{"organization_id": {c.org}}
org, _ := unscoped(d.ID)
if org == "" {
return invalid(VendorScaledMail, "domain id is required")
}
q := url.Values{"organization_id": {org}}
body := map[string]string{"new_redirect": target}
return c.t.do(ctx, call{method: http.MethodPost, path: "/swap-redirect/" + url.PathEscape(name), query: q, body: body}, nil)
}
+183 -48
View File
@@ -43,23 +43,34 @@ func inboxKitMailbox(uid, user, domain, platform string) string {
}
func TestInboxKit(t *testing.T) {
const ws = "6f1c2d3e-0000-4000-8000-000000000001"
const ws1, ws2 = "6f1c2d3e-0000-4000-8000-000000000001", "6f1c2d3e-0000-4000-8000-000000000002"
srv := newRecorder(t, func(w http.ResponseWriter, r *http.Request) {
ws := r.Header.Get("X-Workspace-Id")
switch r.URL.Path {
case "/v1/api/workspaces/list":
writeJSON(w, 200, `{"error":false,"message":"Workspaces retrieved successfully","workspaces":[
{"uid":"`+ws1+`","name":"Outbound","team":"t1","webhook_url":null,"domains":2},
{"uid":"`+ws2+`","name":"Trials","team":"t1","webhook_url":null,"domains":1}]}`)
case "/v1/api/mailboxes/list":
var body struct{ Page, Limit int }
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Page == 1 {
switch {
case ws == ws2:
writeJSON(w, 200, `{"error":false,"mailboxes":[`+
inboxKitMailbox("9c9c9c9c-2222-4333-8444-555566667777", "sam", "trials.io", "GOOGLE")+
`],"total":1,"pages":1,"current_page":1,"limit":100}`)
case body.Page == 1:
writeJSON(w, 200, `{"error":false,"message":"Mailboxes retrieved successfully","mailboxes":[`+
inboxKitMailbox("e88ae415-fe99-4831-b3fe-cdf2e7e25925", "marvinkassulke", "myzyng.net", "GOOGLE")+
`],"total":2,"pages":2,"current_page":1,"limit":1}`)
return
default:
writeJSON(w, 200, `{"error":false,"message":"Mailboxes retrieved successfully","mailboxes":[`+
inboxKitMailbox("0b7c7a8a-1111-4222-8333-444455556666", "jane", "acme.io", "MICROSOFT")+
`],"total":2,"pages":2,"current_page":2,"limit":1}`)
}
writeJSON(w, 200, `{"error":false,"message":"Mailboxes retrieved successfully","mailboxes":[`+
inboxKitMailbox("0b7c7a8a-1111-4222-8333-444455556666", "jane", "acme.io", "MICROSOFT")+
`],"total":2,"pages":2,"current_page":2,"limit":1}`)
case "/v1/api/mailboxes/show-credentials":
if r.URL.Query().Get("uid") == "gone" {
// Only ws2 holds the mailbox a stored, unscoped id names.
if uid := r.URL.Query().Get("uid"); uid == "gone" || (uid == "legacy-uid" && ws != ws2) {
writeJSON(w, 404, `{"error":true,"message":"Mailbox not found"}`)
return
}
@@ -68,20 +79,27 @@ func TestInboxKit(t *testing.T) {
writeJSON(w, 404, `{}`)
}
})
c := newTestClient(t, VendorInboxKit, map[string]string{FieldAPIKey: testKey, FieldWorkspaceID: ws}, srv.URL, nil)
c := newTestClient(t, VendorInboxKit, map[string]string{FieldAPIKey: testKey}, srv.URL, nil)
if err := c.Verify(context.Background()); err != nil {
t.Fatalf("Verify: %v", err)
}
got := mustList(t, c)
want := []Mailbox{
{ID: "e88ae415-fe99-4831-b3fe-cdf2e7e25925", Email: "marvinkassulke@myzyng.net", FirstName: "marvin", LastName: "kassulke", Domain: "myzyng.net", Provider: ProviderGoogle, Status: "active"},
{ID: "0b7c7a8a-1111-4222-8333-444455556666", Email: "jane@acme.io", FirstName: "marvin", LastName: "kassulke", Domain: "acme.io", Provider: ProviderMicrosoft, Status: "active"},
{ID: ws1 + ":e88ae415-fe99-4831-b3fe-cdf2e7e25925", Email: "marvinkassulke@myzyng.net", FirstName: "marvin", LastName: "kassulke", Domain: "myzyng.net", Provider: ProviderGoogle, Status: "active", Workspace: "Outbound"},
{ID: ws1 + ":0b7c7a8a-1111-4222-8333-444455556666", Email: "jane@acme.io", FirstName: "marvin", LastName: "kassulke", Domain: "acme.io", Provider: ProviderMicrosoft, Status: "active", Workspace: "Outbound"},
{ID: ws2 + ":9c9c9c9c-2222-4333-8444-555566667777", Email: "sam@trials.io", FirstName: "marvin", LastName: "kassulke", Domain: "trials.io", Provider: ProviderGoogle, Status: "active", Workspace: "Trials"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("List = %+v\nwant %+v", got, want)
}
cr := mustCreds(t, c, got[0])
cr := mustCreds(t, c, got[2])
if cr != (Credentials{Password: "pw-12345", AppPassword: "abcd efgh ijkl mnop"}) {
t.Fatalf("Credentials = %+v", cr)
}
if cr := mustCreds(t, c, Mailbox{ID: "legacy-uid", Email: "old@trials.io"}); cr.Password != "pw-12345" {
t.Fatalf("legacy id Credentials = %+v", cr)
}
if _, err := c.Credentials(context.Background(), Mailbox{ID: "gone"}); !errors.Is(err, ErrNotFound) {
t.Fatalf("gone: %v", err)
}
@@ -89,13 +107,86 @@ func TestInboxKit(t *testing.T) {
reqs := srv.requests()
for _, r := range reqs {
assertHeader(t, r, "Authorization", "Bearer "+testKey)
assertHeader(t, r, "X-Workspace-Id", ws)
if r.Path == "/v1/api/workspaces/list" {
if r.Header.Get("X-Workspace-Id") != "" {
t.Errorf("workspace list sent X-Workspace-Id")
}
continue
}
if ws := r.Header.Get("X-Workspace-Id"); ws != ws1 && ws != ws2 {
t.Errorf("%s: X-Workspace-Id = %q", r.Path, ws)
}
}
if reqs[0].Method != http.MethodPost || !strings.Contains(reqs[0].Body, `"page":1`) || !strings.Contains(reqs[1].Body, `"page":2`) {
t.Fatalf("list requests = %+v", reqs[:2])
var shows []string
for _, r := range reqs {
if r.Path == "/v1/api/mailboxes/show-credentials" {
shows = append(shows, r.Header.Get("X-Workspace-Id")+"/"+r.q("uid"))
}
}
if reqs[2].Query["uid"][0] != want[0].ID {
t.Fatalf("show-credentials query = %v", reqs[2].Query)
wantShows := []string{ws2 + "/9c9c9c9c-2222-4333-8444-555566667777", ws1 + "/legacy-uid", ws2 + "/legacy-uid", ws1 + "/gone", ws2 + "/gone"}
if !reflect.DeepEqual(shows, wantShows) {
t.Fatalf("show-credentials calls = %v\nwant %v", shows, wantShows)
}
}
// A workspace the key may not read is skipped while another answers.
func TestInboxKitSkipsRefusedWorkspace(t *testing.T) {
srv := newRecorder(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v1/api/workspaces/list":
writeJSON(w, 200, `{"error":false,"workspaces":[{"uid":"ws-a","name":"A"},{"uid":"ws-b","name":"B"}]}`)
case r.Header.Get("X-Workspace-Id") == "ws-a":
writeJSON(w, 403, `{"error":true,"message":"Forbidden"}`)
default:
writeJSON(w, 200, `{"error":false,"mailboxes":[`+inboxKitMailbox("u1", "amy", "b.io", "GOOGLE")+`],"pages":1}`)
}
})
c := newTestClient(t, VendorInboxKit, fieldsFor(VendorInboxKit), srv.URL, nil)
got := mustList(t, c)
if len(got) != 1 || got[0].ID != "ws-b:u1" || got[0].Workspace != "B" {
t.Fatalf("List = %+v", got)
}
}
// A key that reaches no workspace can read nothing, so it is refused.
func TestInboxKitNoWorkspace(t *testing.T) {
srv := newRecorder(t, func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, `{"error":false,"workspaces":[]}`)
})
c := newTestClient(t, VendorInboxKit, fieldsFor(VendorInboxKit), srv.URL, nil)
if err := c.Verify(context.Background()); !errors.Is(err, ErrNoWorkspace) {
t.Fatalf("Verify = %v, want ErrNoWorkspace", err)
}
if _, err := c.List(context.Background()); !errors.Is(err, ErrNoWorkspace) {
t.Fatalf("List = %v, want ErrNoWorkspace", err)
}
}
// A workspace that fails, rather than misses, is reported instead of a missing mailbox.
func TestFindCredentialsKeepsFailures(t *testing.T) {
wss := []workspace{{ID: "a"}, {ID: "b"}}
boom := vendorErr("v", 500, "server error", nil)
miss := vendorErr("v", 404, "not found", ErrNotFound)
_, err := findCredentials("v", wss, func(w workspace) (Credentials, error) {
if w.ID == "a" {
return Credentials{}, boom
}
return Credentials{}, miss
})
if !errors.Is(err, boom) || errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want the server error", err)
}
if _, err := findCredentials("v", wss, func(workspace) (Credentials, error) { return Credentials{}, miss }); !errors.Is(err, ErrNotFound) {
t.Fatalf("all misses = %v, want ErrNotFound", err)
}
cr, err := findCredentials("v", wss, func(w workspace) (Credentials, error) {
if w.ID == "a" {
return Credentials{}, boom
}
return Credentials{Password: "p"}, nil
})
if err != nil || cr.Password != "p" {
t.Fatalf("found after a failure = %+v, %v", cr, err)
}
}
@@ -120,8 +211,8 @@ func TestZapmail(t *testing.T) {
{"id":"d3","domain":"contoso.co","status":"ACTIVE","mailboxes":[{"id":"ms-1","username":"amy","email":"amy@contoso.co","firstName":"Amy","lastName":"Lee","password":"pw-ms","appPassword":null,"secret":null,"status":"ACTIVE","domain":"contoso.co"}]}]}}`
srv := newRecorder(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/users":
writeJSON(w, 200, `{"status":200,"message":"ok","data":{}}`)
case "/v2/workspaces":
writeJSON(w, 200, `{"status":200,"message":"Workspaces fetched successfully","data":[{"id":"ws-123","name":"Main","domainCount":"15","mailboxCount":"8"}]}`)
case "/v2/mailboxes/list":
switch {
case r.Header.Get("x-service-provider") == "MICROSOFT":
@@ -141,16 +232,15 @@ func TestZapmail(t *testing.T) {
writeJSON(w, 404, `{}`)
}
})
fields := map[string]string{FieldAPIKey: testKey, FieldWorkspaceID: "ws-123"}
c := newTestClient(t, VendorZapmail, fields, srv.URL, nil)
c := newTestClient(t, VendorZapmail, map[string]string{FieldAPIKey: testKey}, srv.URL, nil)
if err := c.Verify(context.Background()); err != nil {
t.Fatalf("Verify: %v", err)
}
got := mustList(t, c)
want := []Mailbox{
{ID: "abcd1234-5678-90ef-ghij-klmn12345678", Email: "john.doe@example1.com", FirstName: "John", LastName: "Doe", Domain: "example1.com", Provider: ProviderGoogle, Status: "ACTIVE"},
{ID: "xyz1234-5678-90ab-cdef-ghijk9876543", Email: "jane.smith@example2.net", FirstName: "Jane", LastName: "Smith", Domain: "example2.net", Provider: ProviderGoogle, Status: "IN_PROGRESS"},
{ID: "ms-1", Email: "amy@contoso.co", FirstName: "Amy", LastName: "Lee", Domain: "contoso.co", Provider: ProviderMicrosoft, Status: "ACTIVE"},
{ID: "ws-123:abcd1234-5678-90ef-ghij-klmn12345678", Email: "john.doe@example1.com", FirstName: "John", LastName: "Doe", Domain: "example1.com", Provider: ProviderGoogle, Status: "ACTIVE", Workspace: "Main"},
{ID: "ws-123:xyz1234-5678-90ab-cdef-ghijk9876543", Email: "jane.smith@example2.net", FirstName: "Jane", LastName: "Smith", Domain: "example2.net", Provider: ProviderGoogle, Status: "IN_PROGRESS", Workspace: "Main"},
{ID: "ws-123:ms-1", Email: "amy@contoso.co", FirstName: "Amy", LastName: "Lee", Domain: "contoso.co", Provider: ProviderMicrosoft, Status: "ACTIVE", Workspace: "Main"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("List = %+v\nwant %+v", got, want)
@@ -172,14 +262,18 @@ func TestZapmail(t *testing.T) {
reqs := srv.requests()
for _, r := range reqs {
assertHeader(t, r, "x-auth-zapmail", testKey)
assertHeader(t, r, "x-workspace-key", "ws-123")
if r.Path != "/v2/workspaces" {
assertHeader(t, r, "x-workspace-key", "ws-123")
}
if r.Header.Get("Authorization") != "" {
t.Errorf("%s sent an Authorization header", r.Path)
}
}
var providers []string
for _, r := range reqs[1:listCalls] {
providers = append(providers, r.Header.Get("x-service-provider")+"/"+r.q("page"))
for _, r := range reqs[:listCalls] {
if r.Path == "/v2/mailboxes/list" {
providers = append(providers, r.Header.Get("x-service-provider")+"/"+r.q("page"))
}
}
if !reflect.DeepEqual(providers, []string{"GOOGLE/1", "GOOGLE/2", "MICROSOFT/1"}) {
t.Fatalf("list passes = %v", providers)
@@ -199,17 +293,30 @@ func TestZapmailForbiddenEnvelope(t *testing.T) {
}
}
func TestZapmailSingleProviderNoWorkspace(t *testing.T) {
srv := newRecorder(t, func(w http.ResponseWriter, _ *http.Request) {
// With no workspace listed, calls go to the key's primary workspace, for both providers.
func TestZapmailNoWorkspacesUsesPrimary(t *testing.T) {
srv := newRecorder(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/workspaces" {
writeJSON(w, 200, `{"status":200,"data":[]}`)
return
}
writeJSON(w, 200, `{"status":200,"data":{"currentPage":1,"totalPages":1,"domains":[]}}`)
})
c := newTestClient(t, VendorZapmail, map[string]string{FieldAPIKey: testKey, FieldServiceProvider: "microsoft"}, srv.URL, nil)
c := newTestClient(t, VendorZapmail, fieldsFor(VendorZapmail), srv.URL, nil)
if got := mustList(t, c); len(got) != 0 {
t.Fatalf("List = %+v", got)
}
reqs := srv.requests()
if len(reqs) != 1 || reqs[0].Header.Get("x-service-provider") != "MICROSOFT" || reqs[0].Header.Get("x-workspace-key") != "" {
t.Fatalf("requests = %+v", reqs)
var seen []string
for _, r := range srv.requests() {
if r.Path == "/v2/mailboxes/list" {
if r.Header.Get("x-workspace-key") != "" {
t.Errorf("sent x-workspace-key %q", r.Header.Get("x-workspace-key"))
}
seen = append(seen, r.Header.Get("x-service-provider"))
}
}
if !reflect.DeepEqual(seen, []string{"GOOGLE", "MICROSOFT"}) {
t.Fatalf("providers = %v", seen)
}
}
@@ -236,17 +343,13 @@ func TestForgeVendors(t *testing.T) {
writeJSON(w, 404, `{"code":404,"message":"Mailbox not found"}`)
}
})
fields := map[string]string{FieldAPIKey: testKey}
if vendor == VendorInfraforge {
fields[FieldWorkspaceID] = "wks_70my6ggvn5csfw3o27ojq"
}
c := newTestClient(t, vendor, fields, srv.URL, nil)
c := newTestClient(t, vendor, map[string]string{FieldAPIKey: testKey}, srv.URL, nil)
if err := c.Verify(context.Background()); err != nil {
t.Fatalf("Verify: %v", err)
}
got := mustList(t, c)
want := []Mailbox{
{ID: "mbx_1duj5a6534j37kzook2l9", Email: "jondoe@example.com", FirstName: "Jon", LastName: "Doe", Domain: "example.com", Provider: ProviderSMTP, Status: "active"},
{ID: "mbx_1duj5a6534j37kzook2l9", Email: "jondoe@example.com", FirstName: "Jon", LastName: "Doe", Domain: "example.com", Provider: ProviderSMTP, Status: "active", Workspace: "Main"},
{ID: "mbx_2", Email: "amy@example.org", FirstName: "Amy", LastName: "Lee", Domain: "example.org", Provider: ProviderSMTP, Status: "pending"},
}
if !reflect.DeepEqual(got, want) {
@@ -281,8 +384,8 @@ func TestForgeVendors(t *testing.T) {
if list.Query["with_credentials"][0] != "true" {
t.Fatalf("list query = %v", list.Query)
}
if ws := list.q("workspace_id"); (vendor == VendorInfraforge) != (ws == "wks_70my6ggvn5csfw3o27ojq") {
t.Fatalf("%s workspace_id = %q", vendor, ws)
if ws := list.q("workspace_id"); ws != "" {
t.Fatalf("%s workspace_id = %q, want every workspace", vendor, ws)
}
})
}
@@ -414,7 +517,7 @@ func TestScaledMail(t *testing.T) {
srv := newRecorder(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/organizations":
writeJSON(w, 200, `{"organizations":[{"id":"recORG000000001"}]}`)
writeJSON(w, 200, `{"organizations":[{"id":"recORG000000001","name":"Acme"}]}`)
case "/domains":
writeJSON(w, 200, `{"total":2,"domains":[
{"id":"recDOM1","domain":"outreach-one.com","tag":"campaign-q3","redirect":"https://example.com","order_type":"google","order_id":"recORD1","payment_id":"recPAY1","domain_provider":"Scaledmail","user_id":"recUSR1","total_mailboxes":2,"mailbox":[{"first_name":"Jane","last_name":"Doe","alias":"jane"}],"status":"Active"},
@@ -430,15 +533,15 @@ func TestScaledMail(t *testing.T) {
}
})
s := &sleeps{}
c := newTestClient(t, VendorScaledMail, map[string]string{FieldAPIKey: testKey, FieldOrganizationID: org}, srv.URL, s)
c := newTestClient(t, VendorScaledMail, map[string]string{FieldAPIKey: testKey}, srv.URL, s)
if err := c.Verify(context.Background()); err != nil {
t.Fatalf("Verify: %v", err)
}
got := mustList(t, c)
want := []Mailbox{
{ID: "jane@outreach-one.com", Email: "jane@outreach-one.com", FirstName: "Jane", LastName: "Doe", Domain: "outreach-one.com", Provider: ProviderGoogle, Status: "Active"},
{ID: "joe@outreach-one.com", Email: "joe@outreach-one.com", FirstName: "Joe", LastName: "Roe", Domain: "outreach-one.com", Provider: ProviderGoogle, Status: "Active"},
{ID: "kim@outreach-two.com", Email: "kim@outreach-two.com", FirstName: "Kim", LastName: "Park", Domain: "outreach-two.com", Provider: ProviderMicrosoft, Status: "Active"},
{ID: org + ":jane@outreach-one.com", Email: "jane@outreach-one.com", FirstName: "Jane", LastName: "Doe", Domain: "outreach-one.com", Provider: ProviderGoogle, Status: "Active", Workspace: "Acme"},
{ID: org + ":joe@outreach-one.com", Email: "joe@outreach-one.com", FirstName: "Joe", LastName: "Roe", Domain: "outreach-one.com", Provider: ProviderGoogle, Status: "Active", Workspace: "Acme"},
{ID: org + ":kim@outreach-two.com", Email: "kim@outreach-two.com", FirstName: "Kim", LastName: "Park", Domain: "outreach-two.com", Provider: ProviderMicrosoft, Status: "Active", Workspace: "Acme"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("List = %+v\nwant %+v", got, want)
@@ -447,11 +550,14 @@ func TestScaledMail(t *testing.T) {
t.Fatalf("Credentials = %+v", cr)
}
// A fresh client has no cache, so it re-reads the mailbox's domain.
fresh := newTestClient(t, VendorScaledMail, map[string]string{FieldAPIKey: testKey, FieldOrganizationID: org}, srv.URL, nil)
if cr := mustCreds(t, fresh, Mailbox{ID: "kim@outreach-two.com", Email: "kim@outreach-two.com", Domain: "outreach-two.com"}); cr.Password != "Outl00k!" {
// A fresh client has no cache, so it re-reads the mailbox's domain, with or without the organization in the id.
fresh := newTestClient(t, VendorScaledMail, map[string]string{FieldAPIKey: testKey}, srv.URL, nil)
if cr := mustCreds(t, fresh, Mailbox{ID: org + ":kim@outreach-two.com", Email: "kim@outreach-two.com", Domain: "outreach-two.com"}); cr.Password != "Outl00k!" {
t.Fatalf("fetched Credentials = %+v", cr)
}
if cr := mustCreds(t, fresh, Mailbox{ID: "kim@outreach-two.com", Email: "kim@outreach-two.com"}); cr.Password != "Outl00k!" {
t.Fatalf("legacy id Credentials = %+v", cr)
}
if _, err := fresh.Credentials(context.Background(), Mailbox{Email: "nobody@outreach-two.com"}); !errors.Is(err, ErrNotFound) {
t.Fatalf("unknown mailbox: %v", err)
}
@@ -480,6 +586,35 @@ func TestScaledMail(t *testing.T) {
}
}
func TestScaledMailOrganizationShapes(t *testing.T) {
for body, want := range map[string][]workspace{
`[{"id":"recA","name":"A"},{"id":"recB"}]`: {{ID: "recA", Name: "A"}, {ID: "recB"}},
`{"data":[{"organization_id":"recC","name":"C"}]}`: {{ID: "recC", Name: "C"}},
`{"organization":{"_id":"recD"},"message":"ok"}`: {{ID: "recD"}},
`{"organizations":[{"id":"recE"},{"id":"recE"},{"name":"x"}]}`: {{ID: "recE"}},
`{"message":"ok"}`: nil,
} {
if got := scaledMailOrgs([]byte(body)); !reflect.DeepEqual(got, want) {
t.Errorf("scaledMailOrgs(%s) = %+v, want %+v", body, got, want)
}
}
}
func TestScaledMailNoOrganization(t *testing.T) {
srv := newRecorder(t, func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, `{"organizations":[]}`)
})
c := newTestClient(t, VendorScaledMail, map[string]string{FieldAPIKey: testKey}, srv.URL, nil)
if err := c.Verify(context.Background()); !errors.Is(err, ErrNoWorkspace) {
t.Fatalf("Verify = %v, want ErrNoWorkspace", err)
}
// A connection saved with an organization id keeps using it.
legacy := newTestClient(t, VendorScaledMail, map[string]string{FieldAPIKey: testKey, FieldOrganizationID: "recOLD"}, srv.URL, nil)
if err := legacy.Verify(context.Background()); err != nil {
t.Fatalf("legacy Verify = %v", err)
}
}
func TestListCapsAtMax(t *testing.T) {
var sb strings.Builder
sb.WriteString("[")
+116
View File
@@ -0,0 +1,116 @@
package mailvendor
import (
"context"
"errors"
"net/http"
"strings"
"sync"
"time"
)
// workspace is one vendor workspace (or organization) the key reaches.
type workspace struct {
ID string
Name string
}
// workspaceTTL bounds how long a discovered workspace list is reused, so one added at the vendor shows up.
const workspaceTTL = 5 * time.Minute
// workspaceCache holds the workspaces a key reaches; vendors that scope every call to one are read across all of them.
type workspaceCache struct {
mu sync.Mutex
list []workspace
at time.Time
}
func (w *workspaceCache) get(ctx context.Context, fetch func(context.Context) ([]workspace, error)) ([]workspace, error) {
w.mu.Lock()
if w.at.IsZero() || time.Since(w.at) >= workspaceTTL {
w.mu.Unlock()
list, err := fetch(ctx)
if err != nil {
return nil, err
}
w.mu.Lock()
w.list, w.at = list, time.Now()
}
out := append([]workspace(nil), w.list...)
w.mu.Unlock()
return out, nil
}
// scopeSep joins a workspace id to an id the vendor only resolves inside that workspace.
const scopeSep = ":"
func scoped(ws, id string) string {
if ws == "" || id == "" {
return id
}
return ws + scopeSep + id
}
// unscoped splits a scoped id; an id stored before scoping comes back with no workspace.
func unscoped(id string) (ws, rest string) {
if i := strings.Index(id, scopeSep); i > 0 {
return id[:i], id[i+len(scopeSep):]
}
return "", id
}
// findCredentials asks each workspace in turn, for an id stored before ids carried their workspace.
// Only a miss moves on quietly; any other failure is returned when no workspace holds the mailbox.
func findCredentials(vendor string, wss []workspace, fn func(workspace) (Credentials, error)) (Credentials, error) {
var refused, failed error
read := 0
for _, ws := range wss {
cr, err := fn(ws)
switch {
case err == nil:
return cr, nil
case errors.Is(err, ErrRateLimited), errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
return Credentials{}, err
case errors.Is(err, ErrUnauthorized):
refused = err
case errors.Is(err, ErrNotFound):
read++
default:
failed = err
read++
}
}
switch {
case failed != nil:
return Credentials{}, failed
case read == 0 && refused != nil:
return Credentials{}, refused
}
return Credentials{}, vendorErr(vendor, http.StatusOK, "not found", ErrNotFound)
}
// errStop ends eachWorkspace early without an error, once a cap is reached.
var errStop = errors.New("mailvendor: stop")
// eachWorkspace runs fn in every workspace. One the key may not read is skipped, unless the key can read none.
func eachWorkspace(wss []workspace, fn func(workspace) error) error {
var refused error
read := 0
for _, ws := range wss {
err := fn(ws)
switch {
case err == nil:
read++
case errors.Is(err, errStop):
return nil
case errors.Is(err, ErrUnauthorized):
refused = err
default:
return err
}
}
if read == 0 && refused != nil {
return refused
}
return nil
}
+206 -130
View File
@@ -2,46 +2,34 @@ package mailvendor
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
)
// Zapmail: https://docs.zapmail.ai/llms.txt
// Calls are scoped by workspace and by service provider, so the client reads every workspace for both providers.
type zapmail struct {
t *transport
workspace string
providers []string
cache credCache
domains domainProvider
t *transport
workspaces workspaceCache
cache credCache
domains domainProvider
}
// zapmailProviders are the service providers Zapmail lists separately.
var zapmailProviders = []string{"GOOGLE", "MICROSOFT"}
const (
zapmailPageSize = 100
// zapmailPerSecond is the documented general limit.
zapmailPerSecond = 5
)
func newZapmail(vals map[string]string, o options) (*zapmail, error) {
func newZapmail(vals map[string]string, o options) *zapmail {
key := vals[FieldAPIKey]
var providers []string
switch sp := strings.ToUpper(vals[FieldServiceProvider]); sp {
case "":
providers = []string{"GOOGLE", "MICROSOFT"}
case "GOOGLE", "MICROSOFT":
providers = []string{sp}
default:
return nil, fmt.Errorf("%w: %s: Mailbox type must be GOOGLE or MICROSOFT", ErrInvalidConfig, VendorZapmail)
}
auth := func(h http.Header) { h.Set("x-auth-zapmail", key) }
return &zapmail{
t: newTransport(VendorZapmail, "https://api.zapmail.ai/api", o, auth, zapmailPerSecond),
workspace: vals[FieldWorkspaceID],
providers: providers,
}, nil
return &zapmail{t: newTransport(VendorZapmail, "https://api.zapmail.ai/api", o, auth, zapmailPerSecond)}
}
func (c *zapmail) Vendor() string { return VendorZapmail }
@@ -58,10 +46,10 @@ func (e zapmailEnvelope) err() error {
return statusErr(VendorZapmail, e.Status)
}
func (c *zapmail) header(provider string) http.Header {
func (c *zapmail) header(ws, provider string) http.Header {
h := http.Header{}
if c.workspace != "" {
h.Set("x-workspace-key", c.workspace)
if ws != "" {
h.Set("x-workspace-key", ws)
}
if provider != "" {
h.Set("x-service-provider", provider)
@@ -69,12 +57,52 @@ func (c *zapmail) header(provider string) http.Header {
return h
}
func (c *zapmail) Verify(ctx context.Context) error {
var res zapmailEnvelope
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/v2/users", header: c.header("")}, &res); err != nil {
return err
// listWorkspaces pages through GET /v2/workspaces.
func (c *zapmail) listWorkspaces(ctx context.Context) ([]workspace, error) {
var out []workspace
for page := 1; page <= maxPages; page++ {
var res struct {
zapmailEnvelope
Data []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"data"`
}
q := url.Values{"page": {strconv.Itoa(page)}, "limit": {strconv.Itoa(zapmailPageSize)}}
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/v2/workspaces", query: q}, &res); err != nil {
return nil, err
}
if err := res.err(); err != nil {
return nil, err
}
for _, w := range res.Data {
if w.ID != "" {
out = append(out, workspace{ID: w.ID, Name: w.Name})
}
}
if len(res.Data) < zapmailPageSize {
break
}
}
return res.err()
return out, nil
}
// all is every workspace the key reaches; with none listed, calls go to the key's primary workspace.
func (c *zapmail) all(ctx context.Context) ([]workspace, error) {
wss, err := c.workspaces.get(ctx, c.listWorkspaces)
if err != nil {
return nil, err
}
if len(wss) == 0 {
return []workspace{{}}, nil
}
return wss, nil
}
// Verify reads the workspace list, which checks the key.
func (c *zapmail) Verify(ctx context.Context) error {
_, err := c.listWorkspaces(ctx)
return err
}
type zapmailMailbox struct {
@@ -107,66 +135,80 @@ func (m zapmailMailbox) email() string {
return ""
}
// List pages through every workspace's mailboxes, once per service provider; ids carry their workspace.
func (c *zapmail) List(ctx context.Context) ([]Mailbox, error) {
wss, err := c.all(ctx)
if err != nil {
return nil, err
}
var out []Mailbox
seen := make(map[string]bool)
for _, provider := range c.providers {
for page := 1; page <= maxPages; page++ {
var res struct {
zapmailEnvelope
Data struct {
CurrentPage int `json:"currentPage"`
NextPage int `json:"nextPage"`
TotalPages int `json:"totalPages"`
Domains []struct {
Domain string `json:"domain"`
Mailboxes []zapmailMailbox `json:"mailboxes"`
} `json:"domains"`
} `json:"data"`
}
q := url.Values{"page": {strconv.Itoa(page)}, "limit": {strconv.Itoa(zapmailPageSize)}}
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/v2/mailboxes/list", query: q, header: c.header(provider)}, &res); err != nil {
return nil, err
}
if err := res.err(); err != nil {
return nil, err
}
for _, d := range res.Data.Domains {
for _, m := range d.Mailboxes {
if m.ID == "" || seen[m.ID] {
continue
}
seen[m.ID] = true
c.cache.put(m.ID, m.credentials())
out = append(out, Mailbox{
ID: m.ID,
Email: m.email(),
FirstName: m.FirstName,
LastName: m.LastName,
Domain: firstNonEmpty(m.Domain, d.Domain),
Provider: normalizeProvider(provider),
Status: m.Status,
})
if len(out) >= MaxMailboxes {
return out, nil
err = eachWorkspace(wss, func(ws workspace) error {
for _, provider := range zapmailProviders {
for page := 1; page <= maxPages; page++ {
var res struct {
zapmailEnvelope
Data struct {
CurrentPage int `json:"currentPage"`
NextPage int `json:"nextPage"`
TotalPages int `json:"totalPages"`
Domains []struct {
Domain string `json:"domain"`
Mailboxes []zapmailMailbox `json:"mailboxes"`
} `json:"domains"`
} `json:"data"`
}
q := url.Values{"page": {strconv.Itoa(page)}, "limit": {strconv.Itoa(zapmailPageSize)}}
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/v2/mailboxes/list", query: q, header: c.header(ws.ID, provider)}, &res); err != nil {
return err
}
if err := res.err(); err != nil {
return err
}
for _, d := range res.Data.Domains {
for _, m := range d.Mailboxes {
id := scoped(ws.ID, m.ID)
if m.ID == "" || seen[id] {
continue
}
seen[id] = true
c.cache.put(id, m.credentials())
out = append(out, Mailbox{
ID: id,
Email: m.email(),
FirstName: m.FirstName,
LastName: m.LastName,
Domain: firstNonEmpty(m.Domain, d.Domain),
Provider: normalizeProvider(provider),
Status: m.Status,
Workspace: ws.Name,
})
if len(out) >= MaxMailboxes {
return errStop
}
}
}
}
more := res.Data.TotalPages > page || (res.Data.TotalPages == 0 && res.Data.NextPage > page)
if len(res.Data.Domains) == 0 || !more {
break
more := res.Data.TotalPages > page || (res.Data.TotalPages == 0 && res.Data.NextPage > page)
if len(res.Data.Domains) == 0 || !more {
break
}
}
}
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// Credentials comes from List's cache, else from the mailbox detail endpoint.
// Credentials comes from List's cache, else from the mailbox detail endpoint in the mailbox's workspace.
func (c *zapmail) Credentials(ctx context.Context, m Mailbox) (Credentials, error) {
if cr, ok := c.cache.get(m.ID); ok {
return cr, nil
}
if m.ID == "" {
ws, id := unscoped(m.ID)
if id == "" {
return Credentials{}, vendorErr(VendorZapmail, 0, "mailbox id is required", ErrNotFound)
}
provider := ""
@@ -176,14 +218,27 @@ func (c *zapmail) Credentials(ctx context.Context, m Mailbox) (Credentials, erro
case ProviderMicrosoft:
provider = "MICROSOFT"
}
if ws != "" {
return c.detail(ctx, ws, provider, id)
}
wss, err := c.all(ctx)
if err != nil {
return Credentials{}, err
}
return findCredentials(VendorZapmail, wss, func(w workspace) (Credentials, error) {
return c.detail(ctx, w.ID, provider, id)
})
}
func (c *zapmail) detail(ctx context.Context, ws, provider, id string) (Credentials, error) {
var res struct {
zapmailEnvelope
Data struct {
Mailbox *zapmailMailbox `json:"mailbox"`
} `json:"data"`
}
q := url.Values{"id": {m.ID}}
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/v2/mailboxes", query: q, header: c.header(provider)}, &res); err != nil {
q := url.Values{"id": {id}}
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/v2/mailboxes", query: q, header: c.header(ws, provider)}, &res); err != nil {
return Credentials{}, err
}
if err := res.err(); err != nil {
@@ -220,75 +275,96 @@ func (c *zapmail) DomainCapabilities() DomainCapabilities {
return DomainCapabilities{Forwarding: true, ForwardingRemove: true, DNS: true, DNSTypes: allDNSTypes()}
}
// Domains pages through GET /v2/domains once per service provider.
// Domains pages through GET /v2/domains in every workspace, once per service provider; ids carry their workspace.
func (c *zapmail) Domains(ctx context.Context) ([]Domain, error) {
wss, err := c.all(ctx)
if err != nil {
return nil, err
}
var out []Domain
seen := make(map[string]bool)
for _, provider := range c.providers {
for page := 1; page <= maxPages; page++ {
var res struct {
zapmailEnvelope
Data struct {
NextPage int `json:"nextPage"`
TotalPages int `json:"totalPages"`
Domains []struct {
ID string `json:"id"`
Domain string `json:"domain"`
ForwardTo *string `json:"forwardTo"`
} `json:"domains"`
} `json:"data"`
}
q := url.Values{"page": {strconv.Itoa(page)}, "limit": {strconv.Itoa(zapmailPageSize)}}
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/v2/domains", query: q, header: c.header(provider)}, &res); err != nil {
return nil, err
}
if err := res.err(); err != nil {
return nil, err
}
for _, d := range res.Data.Domains {
if d.ID == "" || seen[d.ID] {
continue
err = eachWorkspace(wss, func(ws workspace) error {
for _, provider := range zapmailProviders {
for page := 1; page <= maxPages; page++ {
var res struct {
zapmailEnvelope
Data struct {
NextPage int `json:"nextPage"`
TotalPages int `json:"totalPages"`
Domains []struct {
ID string `json:"id"`
Domain string `json:"domain"`
ForwardTo *string `json:"forwardTo"`
} `json:"domains"`
} `json:"data"`
}
seen[d.ID] = true
c.domains.put(d.ID, provider)
dom := Domain{ID: d.ID, Name: d.Domain}
if d.ForwardTo != nil {
dom.Forwarding = *d.ForwardTo
q := url.Values{"page": {strconv.Itoa(page)}, "limit": {strconv.Itoa(zapmailPageSize)}}
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/v2/domains", query: q, header: c.header(ws.ID, provider)}, &res); err != nil {
return err
}
out = append(out, dom)
if len(out) >= MaxDomains {
return out, nil
if err := res.err(); err != nil {
return err
}
for _, d := range res.Data.Domains {
id := scoped(ws.ID, d.ID)
if d.ID == "" || seen[id] {
continue
}
seen[id] = true
c.domains.put(id, provider)
dom := Domain{ID: id, Name: d.Domain}
if d.ForwardTo != nil {
dom.Forwarding = *d.ForwardTo
}
out = append(out, dom)
if len(out) >= MaxDomains {
return errStop
}
}
more := res.Data.TotalPages > page || (res.Data.TotalPages == 0 && res.Data.NextPage > page)
if len(res.Data.Domains) == 0 || !more {
break
}
}
more := res.Data.TotalPages > page || (res.Data.TotalPages == 0 && res.Data.NextPage > page)
if len(res.Data.Domains) == 0 || !more {
break
}
}
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// zapmailDomain is a listed domain's workspace, vendor id and the headers its calls send.
func (c *zapmail) zapmailDomain(d Domain) (id string, h http.Header, err error) {
ws, id := unscoped(d.ID)
if id == "" {
return "", nil, invalid(VendorZapmail, "domain id is required")
}
h = c.header(ws, c.domains.get(d.ID))
// The remove-forwarding page names the workspace header x-workspace-id, so both are sent.
if ws != "" {
h.Set("x-workspace-id", ws)
}
return id, h, nil
}
// SetForwarding calls POST /v2/domains/forwarding, or POST /v2/domains/remove-forwarding for an empty target.
func (c *zapmail) SetForwarding(ctx context.Context, d Domain, target string) error {
target, err := forwardingTarget(VendorZapmail, target, c.DomainCapabilities())
if err != nil {
return err
}
if d.ID == "" {
return invalid(VendorZapmail, "domain id is required")
id, h, err := c.zapmailDomain(d)
if err != nil {
return err
}
h := c.header(c.domains.get(d.ID))
var cl call
if target == "" {
// This endpoint's page names the workspace header x-workspace-id, so both are sent.
if c.workspace != "" {
h.Set("x-workspace-id", c.workspace)
}
cl = call{method: http.MethodPost, path: "/v2/domains/remove-forwarding", body: map[string]any{"domainId": d.ID}, header: h}
cl = call{method: http.MethodPost, path: "/v2/domains/remove-forwarding", body: map[string]any{"domainId": id}, header: h}
} else {
// contains and tagIds are filters that widen the target set, so they are left out: only this domain changes.
cl = call{method: http.MethodPost, path: "/v2/domains/forwarding", body: map[string]any{"domainIds": []string{d.ID}, "forwardTo": target}, header: h}
cl = call{method: http.MethodPost, path: "/v2/domains/forwarding", body: map[string]any{"domainIds": []string{id}, "forwardTo": target}, header: h}
}
var res zapmailEnvelope
if err := c.t.do(ctx, cl, &res); err != nil {
@@ -303,10 +379,10 @@ func (c *zapmail) UpsertDNSRecord(ctx context.Context, d Domain, r DNSRecord) er
if err != nil {
return err
}
if d.ID == "" {
return invalid(VendorZapmail, "domain id is required")
id, h, err := c.zapmailDomain(d)
if err != nil {
return err
}
h := c.header(c.domains.get(d.ID))
var list struct {
zapmailEnvelope
Data struct {
@@ -318,7 +394,7 @@ func (c *zapmail) UpsertDNSRecord(ctx context.Context, d Domain, r DNSRecord) er
} `json:"records"`
} `json:"data"`
}
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/v2/dns/", query: url.Values{"id": {d.ID}}, header: h}, &list); err != nil {
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/v2/dns/", query: url.Values{"id": {id}}, header: h}, &list); err != nil {
return err
}
if err := list.err(); err != nil {
@@ -340,12 +416,12 @@ func (c *zapmail) UpsertDNSRecord(ctx context.Context, d Domain, r DNSRecord) er
if p.update >= 0 {
// The documented body also names a zoneId, which no documented response carries; it is left out.
cl = call{method: http.MethodPut, path: "/v2/dns", header: h, body: map[string]any{
"assignedDomainId": d.ID, "dnsRecordId": existing[p.update].ID,
"assignedDomainId": id, "dnsRecordId": existing[p.update].ID,
"host": host, "value": r.Value, "recordType": r.Type,
}}
} else {
cl = call{method: http.MethodPost, path: "/v2/dns", header: h, body: map[string]any{
"assignedDomainId": d.ID,
"assignedDomainId": id,
"records": []map[string]string{{"host": host, "value": r.Value, "recordType": r.Type}},
}}
}
@@ -357,7 +433,7 @@ func (c *zapmail) UpsertDNSRecord(ctx context.Context, d Domain, r DNSRecord) er
return err
}
for _, idx := range p.remove {
q := url.Values{"id": {existing[idx].ID}, "assignedDomainId": {d.ID}}
q := url.Values{"id": {existing[idx].ID}, "assignedDomainId": {id}}
res = zapmailEnvelope{}
if err := c.t.do(ctx, call{method: http.MethodDelete, path: "/v2/dns", query: q, header: h}, &res); err != nil {
return err
@@ -78,7 +78,7 @@ func TestLiveEmailTaskForwardRoundTrip(t *testing.T) {
if got.ForwardedHTML != tc.html || got.ForwardedPlain != tc.plain {
t.Fatalf("forwarded message did not survive: html %q plain %q", got.ForwardedHTML, got.ForwardedPlain)
}
listed, err := repo.ListScheduledForUser(ctx, user, 50)
listed, err := repo.ListScheduledInOrg(ctx, org, nil, 50)
if err != nil {
t.Fatalf("list scheduled: %v", err)
}
+76 -36
View File
@@ -71,7 +71,7 @@ type TaskFailure struct {
}
// ScheduledEmailItem is the join shape returned by
// ListScheduledForUser — task + email_task + sender mailbox columns,
// ListScheduledInOrg — task + email_task + sender mailbox columns,
// shaped for the dashboard's "Scheduled" view.
type ScheduledEmailItem struct {
TaskID uuid.UUID
@@ -176,29 +176,34 @@ type TaskRepository interface {
// Update campaign task with contact/sequence IDs (for tracking)
UpdateCampaignTaskTracking(ctx context.Context, taskID, contactID, sequenceID uuid.UUID) error
// ListScheduledForUser returns every pending email task scheduled
// for the user's mailboxes, ordered by next-to-fire. Used by the
// unibox "Scheduled" view.
ListScheduledForUser(ctx context.Context, userID uuid.UUID, limit int) ([]ScheduledEmailItem, error)
// ListScheduledForUserByThread is the same query scoped to a
// ListScheduledInOrg returns every pending email task scheduled
// from the organization's mailboxes, ordered by next-to-fire. Used
// by the unibox "Scheduled" view.
// accountIDs, when non-empty, narrows every scheduled read and the cancel
// to those mailboxes (an API key's allowlist).
ListScheduledInOrg(ctx context.Context, orgID uuid.UUID, accountIDs []uuid.UUID, limit int) ([]ScheduledEmailItem, error)
// ListScheduledInOrgByThread is the same query scoped to a
// single email thread. ThreadView uses it to render queued sends
// inline alongside already-sent messages so the user can see (and
// cancel) what's about to fire on the conversation they're
// reading.
ListScheduledForUserByThread(ctx context.Context, userID uuid.UUID, threadID string, limit int) ([]ScheduledEmailItem, error)
// CountScheduledForUser returns the number of pending email tasks
ListScheduledInOrgByThread(ctx context.Context, orgID uuid.UUID, threadID string, accountIDs []uuid.UUID, limit int) ([]ScheduledEmailItem, error)
// CountScheduledInOrg returns the number of pending email tasks
// currently scheduled (regardless of fire time). Used for the
// scope-rail counter.
CountScheduledForUser(ctx context.Context, userID uuid.UUID) (int64, error)
// CancelScheduledByUser flips a pending email task to status
// 'cancelled' only when (a) it belongs to a mailbox the user owns,
// (b) it's still pending. Returns (cloudTaskName, ok, err) — the
// scope-rail counter and the pending-send cap.
CountScheduledInOrg(ctx context.Context, orgID uuid.UUID) (int64, error)
// CancelScheduledInOrg flips a pending email task to status
// 'cancelled' only when (a) it sends from one of the organization's
// mailboxes, (b) it's still pending. Returns (cloudTaskName, ok, err) — the
// Cloud Task resource name is included so the caller can issue a
// best-effort DeleteTask to clean the queue. The handler still
// short-circuits on a non-pending status, so a failed DeleteTask
// just degrades to a harmless no-op dispatch — never a real send.
// `ok` distinguishes 404 (no row updated) from 200.
CancelScheduledByUser(ctx context.Context, taskID, userID uuid.UUID) (cloudTaskName *string, ok bool, err error)
CancelScheduledInOrg(ctx context.Context, taskID, orgID uuid.UUID, accountIDs []uuid.UUID) (cloudTaskName *string, ok bool, err error)
// ProviderThreadForMailbox returns the provider thread the mailbox itself
// holds for a unibox conversation, or "" when it holds none.
ProviderThreadForMailbox(ctx context.Context, emailAccountID uuid.UUID, threadID string) (string, error)
}
type taskRepository struct {
@@ -1090,10 +1095,10 @@ func (r *taskRepository) UpdateCampaignTaskTracking(ctx context.Context, taskID,
return err
}
// ListScheduledForUser returns user-initiated email tasks still in
// 'pending' state, ordered by scheduled_at. Joins tasks → email_tasks
// ListScheduledInOrg returns the organization's user-initiated email tasks
// still in 'pending' state, ordered by scheduled_at. Joins tasks → email_tasks
// → email_accounts so callers don't need three lookups per row.
func (r *taskRepository) ListScheduledForUser(ctx context.Context, userID uuid.UUID, limit int) ([]ScheduledEmailItem, error) {
func (r *taskRepository) ListScheduledInOrg(ctx context.Context, orgID uuid.UUID, accountIDs []uuid.UUID, limit int) ([]ScheduledEmailItem, error) {
if limit <= 0 || limit > 500 {
limit = 200
}
@@ -1116,13 +1121,14 @@ func (r *taskRepository) ListScheduledForUser(ctx context.Context, userID uuid.U
FROM tasks t
INNER JOIN email_tasks et ON et.task_id = t.id
INNER JOIN email_accounts ea ON ea.id = t.email_account_id
WHERE ea.user_id = $1
WHERE ea.organization_id = $1
AND t.task_type = 'email'
AND t.status = 'pending'
AND (COALESCE(cardinality($3::uuid[]), 0) = 0 OR ea.id = ANY($3::uuid[]))
ORDER BY t.scheduled_at ASC NULLS LAST, t.created_at ASC
LIMIT $2
`
rows, err := r.db.Query(ctx, query, userID, limit)
rows, err := r.db.Query(ctx, query, orgID, limit, accountIDs)
if err != nil {
return nil, err
}
@@ -1157,11 +1163,11 @@ func (r *taskRepository) ListScheduledForUser(ctx context.Context, userID uuid.U
return items, rows.Err()
}
// ListScheduledForUserByThread is ListScheduledForUser scoped to a
// single thread. Same join + ownership enforcement, plus an extra
// ListScheduledInOrgByThread is ListScheduledInOrg scoped to a
// single thread. Same join + tenant enforcement, plus an extra
// thread_id filter. Empty threadID is treated as "no rows" so the
// caller can't accidentally fall back to the full list.
func (r *taskRepository) ListScheduledForUserByThread(ctx context.Context, userID uuid.UUID, threadID string, limit int) ([]ScheduledEmailItem, error) {
func (r *taskRepository) ListScheduledInOrgByThread(ctx context.Context, orgID uuid.UUID, threadID string, accountIDs []uuid.UUID, limit int) ([]ScheduledEmailItem, error) {
if threadID == "" {
return []ScheduledEmailItem{}, nil
}
@@ -1187,14 +1193,15 @@ func (r *taskRepository) ListScheduledForUserByThread(ctx context.Context, userI
FROM tasks t
INNER JOIN email_tasks et ON et.task_id = t.id
INNER JOIN email_accounts ea ON ea.id = t.email_account_id
WHERE ea.user_id = $1
WHERE ea.organization_id = $1
AND t.task_type = 'email'
AND t.status = 'pending'
AND et.thread_id = $2
AND (COALESCE(cardinality($4::uuid[]), 0) = 0 OR ea.id = ANY($4::uuid[]))
ORDER BY t.scheduled_at ASC NULLS LAST, t.created_at ASC
LIMIT $3
`
rows, err := r.db.Query(ctx, query, userID, threadID, limit)
rows, err := r.db.Query(ctx, query, orgID, threadID, limit, accountIDs)
if err != nil {
return nil, err
}
@@ -1229,30 +1236,31 @@ func (r *taskRepository) ListScheduledForUserByThread(ctx context.Context, userI
return items, rows.Err()
}
// CountScheduledForUser returns how many email tasks are pending across
// every mailbox the user owns. Cheap enough to fold into the overview
// CountScheduledInOrg returns how many email tasks are pending across
// every mailbox in the organization. Cheap enough to fold into the overview
// payload.
func (r *taskRepository) CountScheduledForUser(ctx context.Context, userID uuid.UUID) (int64, error) {
func (r *taskRepository) CountScheduledInOrg(ctx context.Context, orgID uuid.UUID) (int64, error) {
query := `
SELECT COUNT(*)
FROM tasks t
INNER JOIN email_accounts ea ON ea.id = t.email_account_id
WHERE ea.user_id = $1
WHERE ea.organization_id = $1
AND t.task_type = 'email'
AND t.status = 'pending'
`
var n int64
err := r.db.QueryRow(ctx, query, userID).Scan(&n)
err := r.db.QueryRow(ctx, query, orgID).Scan(&n)
return n, err
}
// CancelScheduledByUser flips a single pending email task to
// 'cancelled', enforcing ownership through the email_accounts join.
// CancelScheduledInOrg flips a single pending email task to
// 'cancelled', enforcing the tenant through the email_accounts join.
// Returns the Cloud Task resource name (if the row had one) so the
// caller can issue a best-effort DeleteTask to clean up the GCP
// queue. ok=false when the task either doesn't exist, isn't owned by
// this user, isn't an email task, or already left the pending state.
func (r *taskRepository) CancelScheduledByUser(ctx context.Context, taskID, userID uuid.UUID) (*string, bool, error) {
// queue. ok=false when the task either doesn't exist, sends from
// another organization's mailbox or one outside accountIDs, isn't an
// email task, or already left the pending state.
func (r *taskRepository) CancelScheduledInOrg(ctx context.Context, taskID, orgID uuid.UUID, accountIDs []uuid.UUID) (*string, bool, error) {
query := `
UPDATE tasks t
SET status = 'cancelled',
@@ -1260,13 +1268,14 @@ func (r *taskRepository) CancelScheduledByUser(ctx context.Context, taskID, user
FROM email_accounts ea
WHERE t.id = $1
AND t.email_account_id = ea.id
AND ea.user_id = $2
AND ea.organization_id = $2
AND t.task_type = 'email'
AND t.status = 'pending'
AND (COALESCE(cardinality($3::uuid[]), 0) = 0 OR ea.id = ANY($3::uuid[]))
RETURNING t.cloud_task_name
`
var cloudTaskName *string
err := r.db.QueryRow(ctx, query, taskID, userID).Scan(&cloudTaskName)
err := r.db.QueryRow(ctx, query, taskID, orgID, accountIDs).Scan(&cloudTaskName)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
@@ -1276,6 +1285,37 @@ func (r *taskRepository) CancelScheduledByUser(ctx context.Context, taskID, user
return cloudTaskName, true, nil
}
// ProviderThreadForMailbox returns the provider thread the mailbox holds for a
// unibox conversation: the conversation's own id when the mailbox has a message
// in it (synced, or sent and not yet synced back), else the thread its latest
// earlier reply into the conversation landed in, else "".
func (r *taskRepository) ProviderThreadForMailbox(ctx context.Context, emailAccountID uuid.UUID, threadID string) (string, error) {
threadID = strings.TrimSpace(threadID)
if threadID == "" {
return "", nil
}
var handle string
err := r.db.QueryRow(ctx, `
SELECT CASE
WHEN EXISTS (SELECT 1 FROM unibox_emails WHERE email_id = $1 AND thread_id = $2)
OR EXISTS (SELECT 1 FROM tasks WHERE email_account_id = $1 AND status = 'completed' AND thread_id = $2)
THEN $2
ELSE COALESCE((
SELECT t.thread_id
FROM tasks t
JOIN email_tasks et ON et.task_id = t.id
WHERE t.email_account_id = $1
AND t.task_type = 'email'
AND t.status = 'completed'
AND t.thread_id <> ''
AND et.thread_id = $2
ORDER BY t.completed_at DESC NULLS LAST
LIMIT 1
), '')
END`, emailAccountID, threadID).Scan(&handle)
return handle, err
}
// MarkDirectOpened records the first open and lets a later human open replace a machine open.
func (r *taskRepository) MarkDirectOpened(ctx context.Context, taskID uuid.UUID, at time.Time, machine bool) (bool, error) {
const query = `
@@ -0,0 +1,174 @@
package repository
import (
"context"
"testing"
"time"
"github.com/google/uuid"
)
// Queued sends leave from organization mailboxes, so the workspace that owns
// the mailbox lists, counts and cancels them, whichever member connected it,
// and no other workspace can. An API key's mailbox allowlist narrows the list
// and the cancel to its own mailboxes.
func TestLiveScheduledSendsAreScopedToTheMailboxOrganization(t *testing.T) {
_, pool := liveContactDB(t)
ctx := context.Background()
owner, org, foreignOrg, mailbox := uuid.New(), uuid.New(), uuid.New(), uuid.New()
const thread = "thread-scheduled-scope"
exec := func(query string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, query, args...); err != nil {
t.Fatalf("fixture: %v", err)
}
}
exec(`INSERT INTO users (id, first_name, last_name, email) VALUES ($1, 'Sched', 'Scope', $2)`,
owner, "sched-"+uuid.NewString()+"@example.test")
exec(`INSERT INTO organizations (id, name, owner_user_id) VALUES ($1, 'Sched', $2), ($3, 'Foreign', $2)`,
org, owner, foreignOrg)
exec(`INSERT INTO email_accounts (id, user_id, organization_id, email, name, signature_plain, signature_html, provider)
VALUES ($1, $2, $3, $4, 'Sched', '', '', 'smtp_imap')`,
mailbox, owner, org, "sched-"+uuid.NewString()+"@example.test")
t.Cleanup(func() {
for _, step := range []struct {
query string
arg uuid.UUID
}{
{`DELETE FROM tasks WHERE email_account_id = $1`, mailbox},
{`DELETE FROM email_accounts WHERE id = $1`, mailbox},
{`DELETE FROM organizations WHERE owner_user_id = $1`, owner},
{`DELETE FROM users WHERE id = $1`, owner},
} {
if _, err := pool.Exec(context.Background(), step.query, step.arg); err != nil {
t.Errorf("cleanup: %v", err)
}
}
})
repo := NewTaskRepository(pool)
at := time.Now().Add(time.Hour)
taskID := uuid.New()
threadID := thread
if err := repo.CreateEmailTaskFull(ctx,
&Task{ID: taskID, TaskType: "email", EmailAccountID: mailbox, Status: "pending", ScheduledAt: &at},
&EmailTask{TaskID: taskID, To: []string{"them@example.test"}, Subject: "Re: Hi", Body: "Later", BodyPlain: "Later", ThreadID: &threadID, SendMode: "scheduled"},
); err != nil {
t.Fatalf("queue send: %v", err)
}
for _, tc := range []struct {
org uuid.UUID
want int
}{{org, 1}, {foreignOrg, 0}} {
all, err := repo.ListScheduledInOrg(ctx, tc.org, nil, 50)
if err != nil || len(all) != tc.want {
t.Errorf("list for %s = %d rows (err %v), want %d", tc.org, len(all), err, tc.want)
}
inThread, err := repo.ListScheduledInOrgByThread(ctx, tc.org, thread, nil, 50)
if err != nil || len(inThread) != tc.want {
t.Errorf("thread list for %s = %d rows (err %v), want %d", tc.org, len(inThread), err, tc.want)
}
n, err := repo.CountScheduledInOrg(ctx, tc.org)
if err != nil || n != int64(tc.want) {
t.Errorf("count for %s = %d (err %v), want %d", tc.org, n, err, tc.want)
}
}
elsewhere := []uuid.UUID{uuid.New()}
if rows, err := repo.ListScheduledInOrg(ctx, org, elsewhere, 50); err != nil || len(rows) != 0 {
t.Errorf("list outside the allowlist = %d rows (err %v), want none", len(rows), err)
}
if rows, err := repo.ListScheduledInOrgByThread(ctx, org, thread, elsewhere, 50); err != nil || len(rows) != 0 {
t.Errorf("thread list outside the allowlist = %d rows (err %v), want none", len(rows), err)
}
if rows, err := repo.ListScheduledInOrg(ctx, org, []uuid.UUID{mailbox}, 50); err != nil || len(rows) != 1 {
t.Errorf("list inside the allowlist = %d rows (err %v), want 1", len(rows), err)
}
if _, ok, err := repo.CancelScheduledInOrg(ctx, taskID, foreignOrg, nil); err != nil || ok {
t.Fatalf("foreign cancel ok=%v err=%v, want refused", ok, err)
}
if _, ok, err := repo.CancelScheduledInOrg(ctx, taskID, org, elsewhere); err != nil || ok {
t.Fatalf("cancel outside the allowlist ok=%v err=%v, want refused", ok, err)
}
if _, ok, err := repo.CancelScheduledInOrg(ctx, taskID, org, nil); err != nil || !ok {
t.Fatalf("workspace cancel ok=%v err=%v, want cancelled", ok, err)
}
}
// A conversation id is a mailbox's own provider handle once the mailbox holds a
// message in it, synced back or only recorded from its own send. A mailbox that
// replied into the conversation from outside keeps using the thread that reply
// landed in.
func TestLiveProviderThreadForMailbox(t *testing.T) {
_, pool := liveContactDB(t)
ctx := context.Background()
owner, org, holder, stranger := uuid.New(), uuid.New(), uuid.New(), uuid.New()
exec := func(query string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, query, args...); err != nil {
t.Fatalf("fixture: %v", err)
}
}
exec(`INSERT INTO users (id, first_name, last_name, email) VALUES ($1, 'Held', 'Thread', $2)`,
owner, "held-"+uuid.NewString()+"@example.test")
exec(`INSERT INTO organizations (id, name, owner_user_id) VALUES ($1, 'Held', $2)`, org, owner)
outsider := uuid.New()
for _, id := range []uuid.UUID{holder, stranger, outsider} {
exec(`INSERT INTO email_accounts (id, user_id, organization_id, email, name, signature_plain, signature_html, provider)
VALUES ($1, $2, $3, $4, 'Held', '', '', 'gmail')`, id, owner, org, "held-"+uuid.NewString()+"@example.test")
}
t.Cleanup(func() {
c := context.Background()
for _, q := range []string{
`DELETE FROM unibox_emails WHERE email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)`,
`DELETE FROM tasks WHERE email_account_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)`,
`DELETE FROM email_accounts WHERE organization_id = $1`,
`DELETE FROM organizations WHERE id = $1`,
} {
if _, err := pool.Exec(c, q, org); err != nil {
t.Errorf("cleanup: %v", err)
}
}
if _, err := pool.Exec(c, `DELETE FROM users WHERE id = $1`, owner); err != nil {
t.Errorf("cleanup: %v", err)
}
})
exec(`INSERT INTO unibox_emails (id, user_id, email_id, thread_id, subject, folder, seen)
VALUES ($1, $2, $3, 'synced-thread', 'Hi', 'inbox', true)`, uuid.New(), owner, holder)
exec(`INSERT INTO tasks (id, task_type, email_account_id, status, message_id, thread_id, completed_at)
VALUES ($1, 'email', $2, 'completed', '<sent@example.test>', 'sent-thread', NOW())`, uuid.New(), holder)
repo := NewTaskRepository(pool)
// The outsider's earlier reply into synced-thread landed in its own thread.
earlier := uuid.New()
conversation := "synced-thread"
if err := repo.CreateEmailTaskFull(ctx,
&Task{ID: earlier, TaskType: "email", EmailAccountID: outsider, Status: "completed"},
&EmailTask{TaskID: earlier, To: []string{"them@example.test"}, Subject: "Re: Hi", Body: "x", BodyPlain: "x", ThreadID: &conversation, SendMode: "instant"},
); err != nil {
t.Fatalf("earlier reply: %v", err)
}
exec(`UPDATE tasks SET thread_id = 'outsider-thread', completed_at = NOW() WHERE id = $1`, earlier)
for _, tc := range []struct {
mailbox uuid.UUID
thread string
want string
}{
{holder, "synced-thread", "synced-thread"},
{holder, "sent-thread", "sent-thread"},
{stranger, "synced-thread", ""},
{stranger, "sent-thread", ""},
{outsider, "synced-thread", "outsider-thread"},
{holder, "", ""},
} {
handle, err := repo.ProviderThreadForMailbox(ctx, tc.mailbox, tc.thread)
if err != nil || handle != tc.want {
t.Errorf("thread(%s, %q) = %q (err %v), want %q", tc.mailbox, tc.thread, handle, err, tc.want)
}
}
}
+57 -17
View File
@@ -15,7 +15,9 @@ import (
// at <url>/inboxkit and <url>/mailforge. Any API key is accepted.
//
// InboxKit holds Google Workspace mailboxes (password only, as the real API
// returns) and lets its domains be forwarded and their DNS written. Mailforge
// returns) across two workspaces, scopes every call but the workspace list by
// X-Workspace-Id as the real API does, and lets its domains be forwarded and
// their DNS written. Mailforge
// hosts its own SMTP/IMAP, pointed here at the sandbox's mailpit and dovecot so
// its mailboxes import and connect for real, and only forwards domains.
type VendorMock struct {
@@ -34,8 +36,8 @@ type endpoint struct {
}
type mockDomain struct {
ID, Vendor, Name, Forwarding string
Records []mockRecord
ID, Vendor, Workspace, Name, Forwarding string
Records []mockRecord
}
type mockRecord struct {
@@ -46,7 +48,13 @@ type mockRecord struct {
}
type mockBox struct {
ID, Vendor, User, Domain, First, Last, Platform string
ID, Vendor, Workspace, User, Domain, First, Last, Platform string
}
// inboxKitWorkspaces are the mock InboxKit account's workspaces, by uid.
var inboxKitWorkspaces = []struct{ UID, Name string }{
{"6f1c2d3e-0000-4000-8000-000000000001", "Sunrise Outbound"},
{"6f1c2d3e-0000-4000-8000-000000000002", "Sunrise Trials"},
}
// NewVendorMock seeds both vendors; smtp and imap are where Mailforge's mailboxes connect.
@@ -58,9 +66,9 @@ func NewVendorMock(cfg Config) *VendorMock {
latency: cfg.VendorLatency,
}
for _, d := range []mockDomain{
{ID: "ik-d1", Vendor: "inboxkit", Name: "sunrise-outbound.test", Forwarding: "https://sunriselabs.test"},
{ID: "ik-d2", Vendor: "inboxkit", Name: "trysunrise.test"},
{ID: "mf-d1", Vendor: "mailforge", Name: "sunrisehq.test"},
{ID: "ik-d1", Vendor: "inboxkit", Workspace: inboxKitWorkspaces[0].UID, Name: "sunrise-outbound.test", Forwarding: "https://sunriselabs.test"},
{ID: "ik-d2", Vendor: "inboxkit", Workspace: inboxKitWorkspaces[1].UID, Name: "trysunrise.test"},
{ID: "mf-d1", Vendor: "mailforge", Workspace: "mf-ws", Name: "sunrisehq.test"},
} {
d := d
m.domains[d.ID] = &d
@@ -68,9 +76,9 @@ func NewVendorMock(cfg Config) *VendorMock {
people := [][2]string{{"Ava", "Stone"}, {"Leo", "Park"}, {"Mia", "Chen"}, {"Noah", "Reed"}}
for i, p := range people {
m.boxes = append(m.boxes,
mockBox{ID: "ik-" + strconv.Itoa(i+1), Vendor: "inboxkit", User: strings.ToLower(p[0]), Domain: "sunrise-outbound.test", First: p[0], Last: p[1], Platform: "google"},
mockBox{ID: "ik-" + strconv.Itoa(i+11), Vendor: "inboxkit", User: strings.ToLower(p[0]) + "." + strings.ToLower(p[1]), Domain: "trysunrise.test", First: p[0], Last: p[1], Platform: "google"},
mockBox{ID: "mf-" + strconv.Itoa(i+1), Vendor: "mailforge", User: strings.ToLower(p[0]), Domain: "sunrisehq.test", First: p[0], Last: p[1], Platform: "smtp"},
mockBox{ID: "ik-" + strconv.Itoa(i+1), Vendor: "inboxkit", Workspace: inboxKitWorkspaces[0].UID, User: strings.ToLower(p[0]), Domain: "sunrise-outbound.test", First: p[0], Last: p[1], Platform: "google"},
mockBox{ID: "ik-" + strconv.Itoa(i+11), Vendor: "inboxkit", Workspace: inboxKitWorkspaces[1].UID, User: strings.ToLower(p[0]) + "." + strings.ToLower(p[1]), Domain: "trysunrise.test", First: p[0], Last: p[1], Platform: "google"},
mockBox{ID: "mf-" + strconv.Itoa(i+1), Vendor: "mailforge", Workspace: "mf-ws", User: strings.ToLower(p[0]), Domain: "sunrisehq.test", First: p[0], Last: p[1], Platform: "smtp"},
)
}
return m
@@ -102,42 +110,74 @@ func (m *VendorMock) inboxKit(w http.ResponseWriter, r *http.Request, path strin
defer m.mu.Unlock()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if path == "/v1/api/workspaces/list" {
out := make([]map[string]any, 0, len(inboxKitWorkspaces))
for _, ws := range inboxKitWorkspaces {
out = append(out, map[string]any{"uid": ws.UID, "name": ws.Name})
}
writeMock(w, http.StatusOK, map[string]any{"error": false, "workspaces": out})
return
}
ws := r.Header.Get("X-Workspace-Id")
known := false
for _, k := range inboxKitWorkspaces {
known = known || k.UID == ws
}
if !known {
writeMock(w, http.StatusBadRequest, map[string]any{"error": true, "message": "Workspace not found"})
return
}
domain := func(uid string) *mockDomain {
if d := m.domains[uid]; d != nil && d.Vendor == "inboxkit" && d.Workspace == ws {
return d
}
return nil
}
switch path {
case "/v1/api/mailboxes/list":
var out []map[string]any
for _, b := range m.boxes {
if b.Vendor == "inboxkit" {
if b.Vendor == "inboxkit" && b.Workspace == ws {
out = append(out, map[string]any{"uid": b.ID, "domain_name": b.Domain, "first_name": b.First, "last_name": b.Last,
"username": b.User, "platform": b.Platform, "status": "active"})
}
}
writeMock(w, http.StatusOK, map[string]any{"error": false, "mailboxes": out, "pages": 1, "current_page": 1})
case "/v1/api/mailboxes/show-credentials":
writeMock(w, http.StatusOK, map[string]any{"error": false, "password": "sandbox", "app_password": ""})
uid := r.URL.Query().Get("uid")
for _, b := range m.boxes {
if b.Vendor == "inboxkit" && b.Workspace == ws && b.ID == uid {
writeMock(w, http.StatusOK, map[string]any{"error": false, "password": "sandbox", "app_password": ""})
return
}
}
writeMock(w, http.StatusNotFound, map[string]any{"error": true, "message": "Mailbox not found"})
case "/v1/api/domains/list":
var out []map[string]any
for _, d := range m.vendorDomains("inboxkit") {
out = append(out, map[string]any{"uid": d.ID, "name": d.Name, "forwarding_url": d.Forwarding})
if d.Workspace == ws {
out = append(out, map[string]any{"uid": d.ID, "name": d.Name, "forwarding_url": d.Forwarding})
}
}
writeMock(w, http.StatusOK, map[string]any{"error": false, "domains": out, "pages": 1})
case "/v1/api/domains/forwarding":
target, _ := body["forwarding_url"].(string)
uids, _ := body["uids"].([]any)
for _, u := range uids {
if d, ok := m.domains[toString(u)]; ok && d.Vendor == "inboxkit" {
if d := domain(toString(u)); d != nil {
d.Forwarding = target
}
}
writeMock(w, http.StatusOK, map[string]any{"error": false})
case "/v1/api/dns/list":
d := m.domains[r.URL.Query().Get("uid")]
d := domain(r.URL.Query().Get("uid"))
if d == nil {
writeMock(w, http.StatusNotFound, map[string]any{"error": true})
return
}
writeMock(w, http.StatusOK, map[string]any{"error": false, "dns_record": map[string]any{"records": d.Records}})
case "/v1/api/dns/add", "/v1/api/dns/update", "/v1/api/dns/delete":
d := m.domains[toString(body["uid"])]
d := domain(toString(body["uid"]))
if d == nil {
writeMock(w, http.StatusNotFound, map[string]any{"error": true})
return
@@ -231,7 +271,7 @@ func (m *VendorMock) mailforge(w http.ResponseWriter, r *http.Request, path stri
func (m *VendorMock) forgeBox(b mockBox) map[string]any {
email := b.User + "@" + b.Domain
return map[string]any{
"id": b.ID, "email": email, "firstName": b.First, "lastName": b.Last, "domain": b.Domain, "status": "active",
"id": b.ID, "email": email, "firstName": b.First, "lastName": b.Last, "domain": b.Domain, "status": "active", "workspaceId": b.Workspace,
"credentials": map[string]any{
"imapHost": m.imap.Host, "imapPort": m.imap.Port, "imapUsername": email, "imapPassword": "sandbox",
"smtpHost": m.smtp.Host, "smtpPort": m.smtp.Port, "smtpUsername": email, "smtpPassword": "sandbox",
@@ -0,0 +1,93 @@
package tasks
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/warmbly/warmbly/internal/repository"
"github.com/warmbly/warmbly/internal/tasks/proto"
)
// A unibox reply can leave from a mailbox other than the one holding the
// conversation. The provider thread handle belongs to the mailbox that holds
// it, so only that mailbox may send with it; any other one still threads for
// the recipient through In-Reply-To.
func TestLiveUniboxReplyUsesTheThreadHandleOnlyFromTheMailboxHoldingIt(t *testing.T) {
handle := liveCampaignDB(t)
f := newCampaignSendFixture(t, handle.Pool)
other := f.otherMailbox(t, handle.Pool)
// Gmail is the one provider with a thread handle to send.
if _, err := handle.Pool.Exec(context.Background(),
`UPDATE email_accounts SET provider = 'gmail' WHERE id = ANY($1)`, []uuid.UUID{f.mailbox, other}); err != nil {
t.Fatalf("make the mailboxes gmail: %v", err)
}
const thread, parent = "gmail-thread-670", "<parent-670@test.local>"
holdThread(t, handle.Pool, f.user, f.mailbox, thread, parent)
for _, tc := range []struct {
name string
mailbox uuid.UUID
wantThread string
}{
{"same mailbox keeps the handle", f.mailbox, thread},
{"another mailbox drops it", other, ""},
} {
t.Run(tc.name, func(t *testing.T) {
sender := &recordingSender{}
svc := liveCampaignService(t, handle, sender)
taskID := queueUniboxReply(t, handle.Pool, tc.mailbox, thread, parent)
if xerr := svc.HandleUserEmailTask(&proto.ProcessTask{TaskId: taskID.String()}); xerr != nil {
t.Fatalf("handle reply: %v", xerr)
}
msg := sender.message(t, 0)
if msg.ThreadID != tc.wantThread {
t.Errorf("thread handle = %q, want %q", msg.ThreadID, tc.wantThread)
}
if msg.InReplyTo != parent {
t.Errorf("in_reply_to = %q, want %q so the recipient still threads it", msg.InReplyTo, parent)
}
})
}
}
// holdThread stores one synced message in the thread for the mailbox.
func holdThread(t *testing.T, pool *pgxpool.Pool, user, mailbox uuid.UUID, thread, messageID string) {
t.Helper()
id := uuid.New()
if _, err := pool.Exec(context.Background(), `
INSERT INTO unibox_emails (id, user_id, email_id, thread_id, message_id, subject, folder, seen)
VALUES ($1, $2, $3, $4, $5, 'Pricing', 'inbox', true)`, id, user, mailbox, thread, messageID); err != nil {
t.Fatalf("hold thread: %v", err)
}
t.Cleanup(func() {
if _, err := pool.Exec(context.Background(), `DELETE FROM unibox_emails WHERE id = $1`, id); err != nil {
t.Errorf("cleanup unibox email: %v", err)
}
})
}
// queueUniboxReply writes the task POST /unibox/reply creates, due now.
func queueUniboxReply(t *testing.T, pool *pgxpool.Pool, mailbox uuid.UUID, thread, inReplyTo string) uuid.UUID {
t.Helper()
now := time.Now()
id := uuid.New()
if err := repository.NewTaskRepository(pool).CreateEmailTaskFull(context.Background(),
&repository.Task{ID: id, TaskType: "email", EmailAccountID: mailbox, Status: "pending", ScheduledAt: &now},
&repository.EmailTask{
TaskID: id, To: []string{"them@test.local"}, InReplyTo: []string{inReplyTo},
Subject: "Re: Pricing", Body: "Sure", BodyPlain: "Sure", ThreadID: &thread, SendMode: "instant",
}); err != nil {
t.Fatalf("queue reply: %v", err)
}
t.Cleanup(func() {
if _, err := pool.Exec(context.Background(), `DELETE FROM tasks WHERE id = $1`, id); err != nil {
t.Errorf("cleanup reply task: %v", err)
}
})
return id
}
+13
View File
@@ -162,6 +162,19 @@ func (s *tasksService) HandleUserEmailTask(task *proto.ProcessTask) *errx.Error
if emailTask.ThreadID != nil {
threadID = *emailTask.ThreadID
}
// The row names the conversation; only Gmail has a provider handle, and a
// mailbox may only use its own. Without one the reply threads on In-Reply-To.
if threadID != "" && account.Provider != string(models.InboxProviderGoogle) {
threadID = ""
} else if threadID != "" {
handle, herr := s.taskRepo.ProviderThreadForMailbox(ctx, account.ID, threadID)
if herr != nil {
// Gmail refusing a handle it does not know is retried without one.
log.Warn().Err(herr).Str("task_id", taskID.String()).Msg("user_email: could not resolve the mailbox's own thread; keeping the conversation's")
} else {
threadID = handle
}
}
// STEP 9: Build EmailMessage and send via worker
emailMsg := EmailMessage{
+1 -1
View File
@@ -145,7 +145,7 @@ describe("unibox desktop layout (#473)", SUITE, () => {
await settle();
act(() => useOutboxStore.getState().setReplyRestore({
threadId: "thread-4", messageId: "no-longer-here", mode: "forward",
to: ["x@example.com"], cc: [], bcc: [], subject: "Fwd: Subject 4", body: "FYI",
to: ["x@example.com"], cc: [], bcc: [], subject: "Fwd: Subject 4", body: "FYI", emailAccountId: "mbox-1",
}));
await openThread("Subject 4");
expect(screen.queryByPlaceholderText(/Add a note/)).toBeNull();
@@ -1,8 +1,9 @@
// The mailbox picker the vendor and admin-grant imports share: search, a
// filter for what is connected already, checkbox rows and paging. The caller
// owns the selection so the wizard's floating bar can act on it.
// filter for what is connected already, a workspace switcher when the rows
// span several, checkbox rows and paging. The caller owns the selection so the
// wizard's floating bar can act on it.
import React from "react";
import { ChevronLeftIcon, ChevronRightIcon, MailIcon, RefreshCwIcon } from "lucide-react";
import { ChevronLeftIcon, ChevronRightIcon, LayersIcon, MailIcon, RefreshCwIcon } from "lucide-react";
import { SearchInput } from "@/components/ui/field";
import { CheckSquare } from "@/components/ui/check-square";
import ProviderLogo from "@/components/app/emails/ProviderLogo";
@@ -15,6 +16,8 @@ export interface PickItem {
email: string;
name: string;
domain?: string;
/** A second line under the domain, such as the vendor workspace the mailbox sits in. */
group?: string;
/** Shows a Google / Microsoft / SMTP badge when set. */
provider?: "google" | "microsoft" | "smtp" | "";
status?: { label: string; cls: string };
@@ -52,6 +55,7 @@ export default function PickTable({
noun,
loadingLogo,
loadingSteps,
groupLabel = "Workspace",
}: {
items: PickItem[] | undefined;
/** Whose mailboxes are being listed, and what is happening while they are. */
@@ -64,38 +68,55 @@ export default function PickTable({
selected: Set<string>;
setSelected: React.Dispatch<React.SetStateAction<Set<string>>>;
noun: { one: string; many: string };
/** What PickItem.group names, for the switcher shown when rows span several. */
groupLabel?: string;
}) {
const [query, setQuery] = React.useState("");
const [filter, setFilter] = React.useState<Filter>("all");
const [group, setGroup] = React.useState<string | null>(null);
const [page, setPage] = React.useState(0);
const all = React.useMemo(() => items ?? [], [items]);
// Each group with its rows, in the order the source lists them; only worth a switcher with two or more.
const groups = React.useMemo(() => {
const by = new Map<string, PickItem[]>();
for (const i of all) {
if (!i.group) continue;
const list = by.get(i.group);
if (list) list.push(i);
else by.set(i.group, [i]);
}
return by.size > 1 ? [...by.entries()].map(([name, rows]) => ({ name, rows })) : [];
}, [all]);
// A group that disappears on reload drops back to every row.
const activeGroup = group !== null && groups.some((g) => g.name === group) ? group : null;
const inScope = React.useMemo(() => (activeGroup === null ? all : all.filter((i) => i.group === activeGroup)), [all, activeGroup]);
const showProvider = all.some((i) => i.provider !== undefined);
const counts = React.useMemo(
() => ({
all: all.length,
new: all.filter((i) => !i.connected).length,
moving: all.filter((i) => i.connected && i.upgrade).length,
connected: all.filter((i) => i.connected).length,
all: inScope.length,
new: inScope.filter((i) => !i.connected).length,
moving: inScope.filter((i) => i.connected && i.upgrade).length,
connected: inScope.filter((i) => i.connected).length,
}),
[all],
[inScope],
);
const q = query.trim().toLowerCase();
const shown = React.useMemo(
() =>
all.filter((i) => {
inScope.filter((i) => {
if (filter === "new" && i.connected) return false;
if (filter === "connected" && !i.connected) return false;
if (filter === "moving" && !(i.connected && i.upgrade)) return false;
if (!q) return true;
return i.email.toLowerCase().includes(q) || i.name.toLowerCase().includes(q) || (i.domain ?? "").toLowerCase().includes(q);
return [i.email, i.name, i.domain ?? "", i.group ?? ""].some((v) => v.toLowerCase().includes(q));
}),
[all, filter, q],
[inScope, filter, q],
);
// A new search or filter starts from the first page.
React.useEffect(() => setPage(0), [q, filter]);
// A new search, filter or group starts from the first page.
React.useEffect(() => setPage(0), [q, filter, activeGroup]);
const pages = Math.max(1, Math.ceil(shown.length / PAGE_SIZE));
const at = Math.min(page, pages - 1);
@@ -114,6 +135,19 @@ export default function PickTable({
});
};
// What "select the group" picks: rows that would be new here, or that move onto this source.
const wanted = (i: PickItem) => !i.disabledReason && (!i.connected || !!i.upgrade);
const setGroupPicked = (rows: PickItem[], on: boolean) =>
setSelected((prev) => {
const next = new Set(prev);
for (const i of rows) {
if (!wanted(i)) continue;
if (on) next.add(i.id);
else next.delete(i.id);
}
return next;
});
const toggleShown = () =>
setSelected((prev) => {
const next = new Set(prev);
@@ -194,6 +228,19 @@ export default function PickTable({
)}
</div>
{groups.length > 0 && (
<GroupSwitcher
label={groupLabel}
groups={groups}
total={all.length}
active={activeGroup}
onPick={setGroup}
selected={selected}
wanted={wanted}
onPickAll={setGroupPicked}
/>
)}
<div className="relative rounded-md border border-slate-200 overflow-hidden">
<RefreshBar active={!!fetching} />
<div
@@ -248,7 +295,12 @@ export default function PickTable({
<span className="block text-[12px] text-slate-800 truncate">{item.email}</span>
{item.name && <span className="block text-[11px] text-slate-500 truncate">{item.name}</span>}
</span>
<span className="hidden sm:block text-[11.5px] text-slate-600 truncate">{item.domain || "-"}</span>
<span className="hidden sm:block min-w-0">
<span className="block text-[11.5px] text-slate-600 truncate">{item.domain || "-"}</span>
{item.group && activeGroup === null && (
<span className="block text-[11px] text-slate-400 truncate">{item.group}</span>
)}
</span>
{showProvider && (
<span className="hidden sm:block">
<ProviderBadge provider={item.provider} />
@@ -298,3 +350,77 @@ export default function PickTable({
</div>
);
}
// GroupSwitcher narrows the table to one workspace and picks or clears a whole
// workspace in one click, with how many of each are picked already.
function GroupSwitcher({
label,
groups,
total,
active,
onPick,
selected,
wanted,
onPickAll,
}: {
label: string;
groups: { name: string; rows: PickItem[] }[];
total: number;
active: string | null;
onPick: (group: string | null) => void;
selected: Set<string>;
wanted: (i: PickItem) => boolean;
onPickAll: (rows: PickItem[], on: boolean) => void;
}) {
const current = groups.find((g) => g.name === active);
const pickable = current ? current.rows.filter(wanted) : [];
const picked = pickable.filter((i) => selected.has(i.id)).length;
const chip = (key: string | null, name: string, count: number, pickedHere: number) => {
const on = active === key;
return (
<button
key={key ?? "__all"}
type="button"
onClick={() => onPick(key)}
aria-pressed={on}
className={cn(
"shrink-0 h-6 px-2 rounded-md border text-[11.5px] font-medium inline-flex items-center gap-1.5 transition-colors max-w-[220px]",
on ? "border-sky-200 bg-sky-50 text-sky-700" : "border-slate-200 bg-white text-slate-600 hover:text-slate-900 hover:bg-slate-50",
)}
>
<span className="truncate">{name}</span>
<span className="tabular-nums opacity-70">{count.toLocaleString()}</span>
{pickedHere > 0 && (
<span className="tabular-nums h-4 min-w-4 px-1 rounded bg-sky-600 text-white text-[10px] leading-4 text-center">
{pickedHere.toLocaleString()}
</span>
)}
</button>
);
};
return (
<div className="mb-2 rounded-md border border-slate-200 bg-slate-50/50 px-2.5 py-2">
<div className="flex items-center gap-1.5 mb-1.5">
<LayersIcon className="w-3 h-3 text-slate-400" />
<span className="text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em]">
{groups.length} {label.toLowerCase()}s
</span>
{current && pickable.length > 0 && (
<button
type="button"
onClick={() => onPickAll(current.rows, picked < pickable.length)}
className="ml-auto h-6 px-2 rounded-md text-[11.5px] font-medium text-sky-700 hover:bg-sky-50 inline-flex items-center gap-1 transition-colors"
>
{picked < pickable.length
? `Select ${(pickable.length - picked).toLocaleString()} in ${current.name}`
: `Clear ${current.name}`}
</button>
)}
</div>
<div className="flex items-center gap-1 overflow-x-auto overflow-y-hidden no-scrollbar">
{chip(null, `All ${label.toLowerCase()}s`, total, 0)}
{groups.map((g) => chip(g.name, g.name, g.rows.length, g.rows.filter((i) => selected.has(i.id)).length))}
</div>
</div>
);
}
@@ -28,12 +28,13 @@ function statusPill(status: string): PickItem["status"] {
return { label, cls: BAD_STATUS.test(s) ? "text-amber-700 bg-amber-50" : "text-slate-600 bg-slate-100" };
}
function toItem(m: VendorMailbox): PickItem {
function toItem(m: VendorMailbox, labelWorkspace: boolean): PickItem {
return {
id: m.id,
email: m.email,
name: m.name,
domain: m.domain,
group: labelWorkspace ? m.workspace : undefined,
provider: m.provider,
status: statusPill(m.status),
connected: m.connected,
@@ -60,7 +61,11 @@ export default function VendorImportWizard({
const connections = React.useMemo(() => conns.data?.data ?? [], [conns.data]);
const conn = connections.find((c) => c.id === connId) ?? null;
const mailboxes = boxes.data?.data;
const items = React.useMemo(() => mailboxes?.map(toItem), [mailboxes]);
// The workspace is only worth a line when the key reaches more than one.
const items = React.useMemo(() => {
const several = new Set((mailboxes ?? []).map((m) => m.workspace ?? "")).size > 1;
return mailboxes?.map((m) => toItem(m, several));
}, [mailboxes]);
const sourceIssue = !conn
? "Pick a vendor account, or connect one."
+109 -18
View File
@@ -13,6 +13,10 @@
//
// A forward sends only its message's id; the server attaches the message, so the note is optional.
//
// From defaults to the mailbox holding the message and can be switched to any
// active mailbox; the backend keeps the provider thread only for a mailbox
// that holds it, so a switched reply threads on its headers alone.
//
// ⌘+Enter sends instantly. Each schedule preset calls /unibox/reply
// with send_mode="scheduled" plus the concrete scheduled_at.
@@ -38,6 +42,8 @@ import TemplatePickerContent from "./TemplatePicker";
import InsertBookingLink from "./InsertBookingLink";
import ContactRecipientField from "./compose/ContactRecipientField";
import ForwardedMessage from "./ForwardedMessage";
import MailboxPicker from "./compose/MailboxPicker";
import useComposeCandidates from "@/lib/api/hooks/app/unibox/useComposeCandidates";
import useUniboxOverview from "@/lib/api/hooks/app/unibox/useUniboxOverview";
import { resolveSendAt, useOutboxStore } from "@/hooks/useOutboxStore";
import { useUserProfile } from "@/hooks/context/user";
@@ -181,9 +187,23 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
const [bcc, setBcc] = React.useState<string[]>(restored?.bcc ?? []);
const [showCc, setShowCc] = React.useState((restored?.cc.length ?? 0) > 0);
const [showBcc, setShowBcc] = React.useState((restored?.bcc.length ?? 0) > 0);
// The mailbox holding the message is the default sender; picking another
// one is a per-draft override.
const threadAccountId = replyTo.account_id ?? "";
// A saved pick whose mailbox has since gone falls back to the thread's own.
const accountsRef = React.useRef(accounts);
accountsRef.current = accounts;
const resolveSender = React.useCallback(
(id: string | undefined) =>
id && (accountsRef.current.length === 0 || accountsRef.current.some((a) => a.id === id))
? id
: threadAccountId,
[threadAccountId],
);
const [accountId, setAccountId] = React.useState(() => resolveSender(restored?.email_account_id));
const [isSending, setIsSending] = React.useState(false);
const draft = useReplyDraft(draftKey, { to, cc, bcc, subject, body }, {
to: initial.to, cc: [], bcc: [], subject: initial.subject, body: "",
const draft = useReplyDraft(draftKey, { to, cc, bcc, subject, body, email_account_id: accountId }, {
to: initial.to, cc: [], bcc: [], subject: initial.subject, body: "", email_account_id: threadAccountId,
});
const closeKeepingDraft = () => {
if (!draft.flush()) {
@@ -240,13 +260,31 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
setShowCc(seed.cc.length > 0);
setShowBcc(seed.bcc.length > 0);
setBody(seed.body);
}, [seed, resumeDraft]);
setAccountId(resolveSender(seed.email_account_id));
}, [seed, resumeDraft, resolveSender]);
// Resolve the sending mailbox from the target message's
// account_id. We look it up in the global emails store so we have
// the full Inbox record (signature_html, signature_plain, etc).
const accountId = replyTo.account_id ?? "";
// The full Inbox record (signature_html, signature_plain, etc) of the
// chosen sender, from the global emails store.
const mailbox = accounts.find((a) => a.id === accountId);
const switchedMailbox = !!threadAccountId && accountId !== threadAccountId;
// A queued send from an inactive or removed mailbox cannot leave, so Send
// waits for a sender that can.
const senderProblem = !accountId
? null
: mailbox
? mailbox.status !== "active"
? `${mailbox.email} is not active, so it cannot send. Pick another mailbox in From, or reconnect it under Emails.`
: null
: accounts.length > 0
? "This mailbox is no longer connected. Pick another mailbox in From."
: null;
const threadMailbox = accounts.find((a) => a.id === threadAccountId);
// Scored like compose (history with the recipient, today's budget, auth),
// fetched once From is opened: most replies keep the default mailbox.
const [wantCandidates, setWantCandidates] = React.useState(false);
const primary = to.length > 0 ? bareEmail(to[0]) : "";
const candidatesQ = useComposeCandidates(primary, wantCandidates);
const templatesQuery = useTemplates();
@@ -261,7 +299,8 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
const trimmedBody = body.trim();
// A forward's note is optional: the forwarded message is the content.
const hasContent = !!trimmedBody || mode === "forward";
const canSend = hasContent && to.length > 0 && to.every(looksLikeEmail) && !!accountId && !isSending;
const canSend =
hasContent && to.length > 0 && to.every(looksLikeEmail) && !!accountId && !senderProblem && !isSending;
const send = async (scheduledAt?: Date) => {
if (!canSend && !isSending) {
@@ -281,9 +320,13 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
toast.error("Couldn't resolve the sending mailbox");
return;
}
if (senderProblem) {
toast.error(senderProblem);
return;
}
}
const submittedDraft = { to, cc, bcc, subject, body };
const submittedDraft = { to, cc, bcc, subject, body, email_account_id: accountId };
draft.flush();
setIsSending(true);
const sentSubject = subject.trim() || (mode === "forward" ? "Fwd:" : "Re:");
@@ -325,6 +368,7 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
bcc,
subject: sentSubject,
body: trimmedBody,
emailAccountId: accountId,
},
});
} else {
@@ -533,21 +577,68 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC
)}
<HeaderRow label="From">
{mailbox ? (
<div className="inline-flex items-center gap-2 min-w-0">
<span className="text-[12.5px] text-slate-800 truncate">
{mailbox.name || mailbox.email}
</span>
<span className="font-mono text-[10.5px] text-slate-400 min-w-0 truncate" title={mailbox.email}>
{mailbox.email}
</span>
</div>
{accountId ? (
<MailboxPicker
value={accountId}
autoTag={null}
allowAuto={false}
onChange={(next) => setAccountId(next)}
onOpen={() => setWantCandidates(true)}
candidates={candidatesQ.data}
loading={candidatesQ.isPending}
/>
) : (
<span className="text-[12px] text-amber-700">
No sending mailbox resolved
</span>
)}
</HeaderRow>
<AnimatePresence initial={false}>
{senderProblem && (
<motion.div
key="inactive"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.16, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<div
role="status"
className="px-4 py-1.5 flex items-start gap-1.5 border-b border-amber-100 bg-amber-50/60 text-[11px] text-amber-800"
>
<InfoIcon className="w-3 h-3 mt-px shrink-0 text-amber-600" />
<span className="min-w-0 flex-1 leading-snug">{senderProblem}</span>
</div>
</motion.div>
)}
{!senderProblem && switchedMailbox && mode === "reply" && (
<motion.div
key="switched"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.16, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<div className="px-4 py-1.5 flex items-start gap-1.5 border-b border-slate-100 bg-slate-50/60 text-[11px] text-slate-500">
<InfoIcon className="w-3 h-3 mt-px shrink-0 text-slate-400" />
<span className="min-w-0 flex-1 leading-snug">
Replying from another mailbox. It stays in the same conversation for the
recipient, and their answer comes back to {mailbox?.email ?? "this mailbox"}.
</span>
<button
type="button"
onClick={() => setAccountId(threadAccountId)}
title={threadMailbox ? `Reply from ${threadMailbox.email}` : "Reply from the original mailbox"}
className="shrink-0 text-[11px] font-medium text-sky-700 hover:text-sky-800 transition-colors"
>
Switch back
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="flex items-center gap-2 px-4 border-b border-slate-100">
<input
@@ -214,6 +214,7 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
bcc: pendingRestore.bcc,
subject: pendingRestore.subject,
body: pendingRestore.body,
email_account_id: pendingRestore.emailAccountId,
});
}, [pendingRestore, threadId, openReply]);
@@ -4,6 +4,7 @@
// resolved pick. The menu is a compact, searchable list (filterable by
// mailbox tag) so orgs with dozens of mailboxes can still pick in a second;
// per-row detail lives in the title tooltip instead of bloating the rows.
// Replies and forwards reuse it with allowAuto off: they always name a mailbox.
import React from "react";
import { createPortal } from "react-dom";
@@ -33,11 +34,23 @@ interface MailboxPickerProps {
candidates: ComposeCandidatesResponse | undefined;
// True while candidates refetch for a new recipient.
loading?: boolean;
/** Offer the Auto row. Off where the sender is always an explicit mailbox. */
allowAuto?: boolean;
/** Called as the menu opens, so candidates can be fetched only when wanted. */
onOpen?: () => void;
}
const PANEL_WIDTH = 300;
export default function MailboxPicker({ value, autoTag, onChange, candidates, loading }: MailboxPickerProps) {
export default function MailboxPicker({
value,
autoTag,
onChange,
candidates,
loading,
allowAuto = true,
onOpen,
}: MailboxPickerProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const [tagFilter, setTagFilter] = React.useState<string | null>(null);
@@ -45,6 +58,7 @@ export default function MailboxPicker({ value, autoTag, onChange, candidates, lo
// overflow, so the menu can't render inside it).
const [anchor, setAnchor] = React.useState<{ top: number; left: number; up: boolean } | null>(null);
const boxRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
useClickOutside(boxRef, () => setOpen(false));
const storeEmails = useAppStore((s) => s.emails);
@@ -102,6 +116,9 @@ export default function MailboxPicker({ value, autoTag, onChange, candidates, lo
? (scopedBest(autoTag) ?? recommended)
: recommended
: accounts.find((a) => a.id === value);
// An explicit pick outside the candidates (still loading, or no longer
// active) is named from the store rather than shown as missing.
const stored = !selected && value !== "auto" ? storeEmails.find((e) => e.id === value) : undefined;
const autoTagTitle = autoTag ? storeTags.find((t) => t.id === autoTag)?.title : undefined;
// Every defined tag is offered (not just ones already in use) so a tag
@@ -129,8 +146,12 @@ export default function MailboxPicker({ value, autoTag, onChange, candidates, lo
return (
<div ref={boxRef} className="relative min-w-0 flex-1">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((o) => !o)}
onClick={() => {
if (!open) onOpen?.();
setOpen(!open);
}}
className="group max-w-full inline-flex items-center gap-1.5 h-6 pl-1 pr-1 -ml-1 rounded-md hover:bg-slate-50 transition-colors min-w-0"
>
{value === "auto" ? (
@@ -157,6 +178,20 @@ export default function MailboxPicker({ value, autoTag, onChange, candidates, lo
{selected.email}
</span>
</>
) : stored ? (
<>
<span className="text-[12.5px] text-slate-900 font-medium truncate">
{stored.name || stored.email}
</span>
<span className="font-mono text-[10.5px] text-slate-500 truncate">
{stored.email}
</span>
{stored.status !== "active" && (
<span className="h-4 px-1 rounded bg-amber-50 text-amber-700 text-[9.5px] font-medium uppercase tracking-wide shrink-0">
inactive
</span>
)}
</>
) : (
<span className="text-[12px] text-amber-700">mailbox unavailable</span>
)}
@@ -182,6 +217,14 @@ export default function MailboxPicker({ value, autoTag, onChange, candidates, lo
: { top: anchor.top }),
}}
className="max-w-[calc(100vw-16px)] rounded-lg border border-slate-200 bg-white shadow-xl overflow-hidden"
onKeyDown={(e) => {
// Innermost layer only: the composer around it keeps its own Escape.
if (e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
setOpen(false);
triggerRef.current?.focus();
}}
>
{/* Search + tag filter header: one compact row */}
<div className="px-1.5 pt-1.5 pb-1 border-b border-slate-100 flex items-center gap-1">
@@ -215,7 +258,7 @@ export default function MailboxPicker({ value, autoTag, onChange, candidates, lo
</div>
{/* Auto: scoped to the active tag filter when one is set */}
{(() => {
{allowAuto && (() => {
const filterTag = tagFilter
? usedTags.find((t) => t.id === tagFilter)
: undefined;
@@ -255,7 +298,9 @@ export default function MailboxPicker({ value, autoTag, onChange, candidates, lo
})()}
{/* Candidates, best first */}
<AnimatedHeight className="border-t border-slate-100 max-h-52 overflow-y-auto">
<AnimatedHeight
className={cn("max-h-52 overflow-y-auto", allowAuto && "border-t border-slate-100")}
>
{loading && accounts.length === 0 ? (
<>
<CandidateSkeletonRow />
@@ -14,6 +14,11 @@ import React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
// Exit animations never finish in jsdom; closed layers unmount at once instead.
vi.mock("framer-motion", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("react-hot-toast", () => ({
default: { success: () => {}, error: () => {} },
}));
@@ -32,8 +37,31 @@ vi.mock("@/hooks/context/user", () => ({
useUserProfile: () => ({ user: { id: "u1", email: "me@example.com", name: "Me" } }),
}));
vi.mock("@/stores", () => ({
useAppStore: (sel: (s: { emails: unknown[]; currentOrganization: { id: string } }) => unknown) =>
sel({ currentOrganization: { id: "org1" }, emails: [{ id: "acc1", email: "me@example.com", signature_html: "", signature_plain: "" }] }),
useAppStore: (sel: (s: { emails: unknown[]; tags: unknown[]; currentOrganization: { id: string } }) => unknown) =>
sel({
currentOrganization: { id: "org1" },
tags: [],
emails: [
{ id: "acc1", email: "me@example.com", status: "active", tags: [], signature_html: "", signature_plain: "" },
{ id: "acc2", email: "other@example.com", status: "active", tags: [], signature_html: "", signature_plain: "Other signature", signature_sync: true },
{ id: "acc3", email: "gone@example.com", status: "inactive", tags: [], signature_html: "", signature_plain: "" },
],
}),
}));
vi.mock("@/lib/api/hooks/app/unibox/useComposeCandidates", () => ({
default: () => ({
isPending: false,
data: {
accounts: ["me@example.com", "other@example.com"].map((email, i) => ({
id: `acc${i + 1}`, email, name: "", provider: "gmail", auth_state: "passing", warmup_active: false,
daily_limit: 50, sent_today: 0, remaining_today: 50, history_messages: 0, score: 1, reasons: [], recommended: i === 0,
})),
recommended_account_id: "acc1",
recommended_reason: "",
contact: null,
suppression: null,
},
}),
}));
vi.mock("@/hooks/useOutboxStore", () => ({
useOutboxStore: (sel: (s: { add: () => void }) => unknown) => sel({ add: () => {} }),
@@ -75,10 +103,10 @@ const draftKey = () => replyDraftKey("u1", "org1", "t1", "msg-1", "reply");
// The thread maps its payload through `toUniboxEmail` on every render, so each
// render hands the composer a new object for the same message. This builds one.
function message() {
function message(accountId = "acc1") {
return {
id: "msg-1",
account_id: "acc1",
account_id: accountId,
from: "Them <them@example.com>",
subject: "Quarterly numbers",
to: [],
@@ -90,6 +118,15 @@ function body() {
return screen.getByPlaceholderText(/write/i) as HTMLTextAreaElement;
}
function fromTrigger(email: string) {
return screen.getAllByText(email)[0].closest("button") as HTMLButtonElement;
}
function pickSender(current: string, next: string) {
fireEvent.click(fromTrigger(current));
fireEvent.click(screen.getByText(next));
}
describe("reply composer drafts", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -277,6 +314,64 @@ describe("reply composer drafts", () => {
view.unmount();
});
it("sends from the mailbox picked in From and keeps the conversation", async () => {
render(<ReplyComposer threadId="t1" replyTo={message()} mode="reply" onClose={() => {}} />);
expect(screen.queryByText(/Replying from another mailbox/)).toBeNull();
pickSender("me@example.com", "other@example.com");
expect(screen.getByText(/Replying from another mailbox/)).toBeInTheDocument();
expect(screen.getByText("Other signature")).toBeInTheDocument();
fireEvent.change(body(), { target: { value: "From the other address" } });
await act(async () => fireEvent.keyDown(body(), { key: "Enter", ctrlKey: true }));
expect(sendReply).toHaveBeenCalledWith(expect.objectContaining({ email_account_id: "acc2", thread_id: "t1" }));
});
it("keeps the picked mailbox in the draft and in a restored undo-send", () => {
const view = render(<ReplyComposer threadId="t1" replyTo={message()} mode="reply" onClose={() => {}} />);
pickSender("me@example.com", "other@example.com");
view.unmount();
expect(JSON.parse(localStorage.getItem(draftKey()) ?? "{}")).toMatchObject({ email_account_id: "acc2" });
const reopened = render(<ReplyComposer threadId="t1" replyTo={message()} mode="reply" onClose={() => {}} />);
expect(fromTrigger("other@example.com")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Switch back" }));
expect(fromTrigger("me@example.com")).toBeInTheDocument();
const seed = { to: ["them@example.com"], cc: [], bcc: [], subject: "Re: x", body: "restored", email_account_id: "acc2" };
reopened.rerender(<ReplyComposer threadId="t1" replyTo={message()} mode="reply" seed={seed} onClose={() => {}} />);
expect(fromTrigger("other@example.com")).toBeInTheDocument();
});
it("will not send from an inactive mailbox and says how to fix it", async () => {
render(<ReplyComposer threadId="t1" replyTo={message("acc3")} mode="reply" onClose={() => {}} />);
fireEvent.change(body(), { target: { value: "Still here?" } });
expect(screen.getByRole("status")).toHaveTextContent("gone@example.com is not active");
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
await act(async () => fireEvent.keyDown(body(), { key: "Enter", ctrlKey: true }));
expect(sendReply).not.toHaveBeenCalled();
pickSender("gone@example.com", "other@example.com");
expect(screen.queryByRole("status")).toBeNull();
await act(async () => fireEvent.keyDown(body(), { key: "Enter", ctrlKey: true }));
expect(sendReply).toHaveBeenCalledWith(expect.objectContaining({ email_account_id: "acc2" }));
});
it("falls back to the thread's mailbox when a saved one is gone", () => {
localStorage.setItem(draftKey(), JSON.stringify({ to: ["them@example.com"], cc: [], bcc: [], subject: "Re: x", body: "saved", email_account_id: "acc9" }));
render(<ReplyComposer threadId="t1" replyTo={message()} mode="reply" onClose={() => {}} />);
expect(body()).toHaveValue("saved");
expect(fromTrigger("me@example.com")).toBeInTheDocument();
expect(screen.queryByRole("status")).toBeNull();
});
it("closes only the mailbox menu on Escape", () => {
const onClose = vi.fn();
render(<ReplyComposer threadId="t1" replyTo={message()} mode="forward" onClose={onClose} />);
fireEvent.click(fromTrigger("me@example.com"));
const search = screen.getByPlaceholderText("Search mailboxes…");
expect(screen.queryByText("Auto")).toBeNull();
fireEvent.keyDown(search, { key: "Escape" });
expect(screen.queryByPlaceholderText("Search mailboxes…")).toBeNull();
expect(fromTrigger("me@example.com")).toHaveFocus();
expect(onClose).not.toHaveBeenCalled();
});
it("does not claim a failed save succeeded or close away the unsaved text", () => {
vi.spyOn(localStorage, "setItem").mockImplementation(() => { throw new Error("quota"); });
const onClose = vi.fn();
+2
View File
@@ -18,6 +18,8 @@ export interface OutboxReplyPayload {
bcc: string[];
subject: string;
body: string;
/** The mailbox the reply was queued from, restored with it. */
emailAccountId: string;
}
export interface OutboxEntry {
@@ -45,6 +45,8 @@ export interface VendorMailbox {
provider: VendorMailboxProvider;
/** The vendor's own status for the mailbox, as it reports it. */
status: string;
/** The vendor workspace or organization holding it, where the vendor has them. */
workspace?: string;
connected: boolean;
email_account_id?: string;
}
@@ -43,10 +43,10 @@ export default interface UniboxOverview {
awaiting_reply: number;
/** Threads with a pending inbox-agent draft awaiting human review. */
awaiting_agent_draft: number;
/** Pending outbound email tasks queued by the user. */
/** Pending outbound email tasks queued from the workspace's mailboxes. */
scheduled_pending: number;
/**
* Hard cap on how many pending scheduled sends one user can queue
* Hard cap on how many pending scheduled sends one workspace can queue
* at once. Each pending send occupies a Cloud Tasks queue slot the
* platform pays for, so the cap protects against runaway abuse.
* Dashboard renders `scheduled_pending / scheduled_pending_max` so
+5
View File
@@ -6,6 +6,8 @@ export interface ReplySeed {
bcc: string[];
subject: string;
body: string;
/** The sending mailbox when the draft chose one; absent means the thread's own. */
email_account_id?: string;
}
export function replyDraftKey(userId: string, orgId: string, threadId: string, messageId: string, mode: ReplyMode): string {
@@ -28,6 +30,9 @@ export function loadReplyDraft(key: string): ReplySeed | null {
bcc: recipients(d.bcc),
subject: typeof d.subject === "string" ? d.subject : "",
body: d.body,
...(typeof d.email_account_id === "string" && d.email_account_id
? { email_account_id: d.email_account_id }
: {}),
};
} catch {
return null;