# Email and notification APIs

> Reference: Gmail init and sync, drafts, threads, labels, attachments, notification preferences, unread state, and unsubscribe routes.

- Repository: macro-inc/macro
- GitHub: https://github.com/macro-inc/macro
- Human docs: https://grok-wiki.com/public/docs/macro-inc-macro-bb988e1a448e
- Complete Markdown: https://grok-wiki.com/public/docs/macro-inc-macro-bb988e1a448e/llms-full.txt

## Source Files

- `js/app/packages/service-clients/service-email/openapi.json`
- `js/app/packages/service-clients/service-email/client.ts`
- `rust/cloud-storage/email_service/src/openapi.rs`
- `js/app/packages/service-clients/service-notification/openapi.json`
- `js/app/packages/service-clients/service-notification/client.ts`
- `rust/cloud-storage/notification_service/src/openapi.rs`

---

---
title: "Email and notification APIs"
description: "Reference: Gmail init and sync, drafts, threads, labels, attachments, notification preferences, unread state, and unsubscribe routes."
---

Macro exposes email and notification HTTP APIs through Rust `utoipa` OpenAPI specs, generated TypeScript schemas, and handwritten frontend clients: `emailClient` uses `SERVER_HOSTS['email-service']`, and `notificationServiceClient` uses `SERVER_HOSTS['notification-service']`.

## Runtime surface

| Area | Frontend client | OpenAPI artifact | Rust OpenAPI binary |
| --- | --- | --- | --- |
| Email | `js/app/packages/service-clients/service-email/client.ts` | `js/app/packages/service-clients/service-email/openapi.json` | `email_service_openapi` |
| Notifications | `js/app/packages/service-clients/service-notification/client.ts` | `js/app/packages/service-clients/service-notification/openapi.json` | `notification_service_openapi` |

Both clients call `fetchWithToken`, so callers receive `neverthrow` `Result` values and get token refresh behavior on unauthorized cookie-based requests. In development, `VITE_LOCAL_SERVERS=ALL` maps email to `http://localhost:8087` and notifications to `http://localhost:8089`; otherwise the remote service hosts are used.

<Note>
Generated schemas are refreshed from local Rust OpenAPI binaries with `cd js/app && bun run gen-api email-service` or `bun run gen-api notification-service`.
</Note>

## Email API

### Gmail initialization and backfill

`POST /email/init` initializes Gmail email for the authenticated user. It registers a Gmail watch, upserts an `email_links` row, stores the Gmail history id, creates a backfill job, and enqueues the initial backfill operation.

<ParamField body="link_id" type="uuid" optional>
Optional query parameter from a `/link/gmail` flow. When supplied, init uses the linked email recorded on the in-progress link instead of the JWT email.
</ParamField>

<ResponseField name="link_id" type="uuid">
The accessible email link id.
</ResponseField>

<ResponseField name="backfill_job_id" type="uuid | undefined">
Present when init enqueues a new backfill job. It can be absent when init delegates to an existing child inbox.
</ResponseField>

Common backfill routes:

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/email/backfill/gmail/active` | Return the active backfill job or `204` when none exists. |
| `GET` | `/email/backfill/gmail/{id}` | Return a backfill job by id for the current email link. |
| `DELETE` | `/email/backfill/gmail` | Cancel a job by JSON body `{ "job_id": "..." }`. |

`/email/init` limits non-`@macro.com` users to three recent jobs in the last 24 hours and rejects users already initialized for the same Gmail link.

### Sync lifecycle

| Client method | Route | Status |
| --- | --- | --- |
| `emailClient.init({ linkId })` | `POST /email/init?link_id=...` | Supported. |
| `emailClient.stopSync()` | `DELETE /email/sync` | Supported; enqueues asynchronous link deletion with reason `ManuallyDisabled`. |
| `emailClient.startSync()` | `POST /email/sync` | Client method exists, but the Rust router and OpenAPI currently expose only `DELETE /email/sync`. Use init to enable sync unless the server route is added. |

## Threads and unread state

Thread listing uses cursor pagination:

```ts
await emailClient.getPreviews({
  view: 'inbox',
  limit: 20,
  sort_method: 'viewed_updated',
  cursor,
});
```

| Route | Inputs | Response |
| --- | --- | --- |
| `GET /email/threads/previews/cursor/{view}` | `view`, optional `limit`, `sort_method`, `cursor` | `{ items, next_cursor? }` |
| `GET /email/threads/{thread_id}` | optional `offset` default `0`, `limit` default `5`, max `100` | `{ thread }` |
| `GET /email/threads/{id}/messages` | optional `since`, `limit` | `ParsedMessage[]` |
| `POST /email/threads/{id}/seen` | thread id | `EmptyResponse` |
| `PATCH /email/threads/{id}/archived` | `{ value: boolean }` | `EmptyResponse` |
| `PATCH /email/threads/{id}/labels` | `{ label_id, value }` | updated thread labels |
| `PATCH /email/threads/{thread_id}/project` | `{ projectId: string | null }` | previous project id |

Email unread state is persisted on the thread and message records and mirrored to Gmail labels. `POST /email/threads/{id}/seen` upserts user history, marks the thread and unread messages as read, removes the `UNREAD` label locally, then enqueues Gmail operations to remove the provider `UNREAD` label.

Supported preview `view` values include `inbox`, `sent`, `drafts`, `starred`, `all`, `important`, `other`, and `user:<label>`.

## Drafts, sending, and scheduling

Draft creation and sending use `ApiDraftInput`.

```ts
await emailClient.createDraft({
  draft: {
    subject: 'Review',
    to: [{ email_address: 'person@example.com' }],
    body_text: 'Please review this.',
  },
});

await emailClient.sendMessage({
  message: {
    subject: 'Review',
    to: [{ email_address: 'person@example.com' }],
    body_text: 'Please review this.',
  },
});
```

| Operation | Route | Body |
| --- | --- | --- |
| Create draft | `POST /email/drafts` | `{ draft: ApiDraftInput, send_time? }` |
| Delete draft | `DELETE /email/drafts/{id}` | none |
| Send message | `POST /email/messages` | `{ message: ApiDraftInput }` |
| Schedule draft | `PUT /email/drafts/scheduled/{id}` | scheduled send request |
| Unschedule draft | `DELETE /email/drafts/scheduled/{message_id}` | none |
| List scheduled drafts | `GET /email/drafts/scheduled?offset=&limit=` | pagination params |

`ApiDraftInput` requires `subject` and supports `to`, `cc`, `bcc`, `body_html`, `body_macro`, `body_text`, `provider_id`, `provider_thread_id`, `thread_db_id`, `replying_to_id`, `headers_json`, and `db_id`.

## Attachments

| Route | Purpose |
| --- | --- |
| `GET /email/attachments/{id}` | Return an attachment wrapper. |
| `GET /email/attachments/{id}/document_id` | Return or create the Macro document id for an email attachment. |
| `POST /email/drafts/{id}/attachments` | Create a draft attachment record and return a presigned upload URL. |
| `DELETE /email/drafts/{id}/attachments/{attachment_id}` | Remove a draft attachment. |
| `POST /email/drafts/{id}/forwarded-attachments` | Attach an existing email attachment to a draft. |
| `DELETE /email/drafts/{id}/forwarded-attachments/{attachment_id}` | Remove a forwarded attachment. |

`AddDraftAttachmentRequest` requires:

<ParamField body="file_name" type="string" required />
<ParamField body="sha" type="string" required>
SHA-256 hex string. The server validates exactly 64 ASCII hex characters.
</ParamField>
<ParamField body="size" type="number" required>
Raw byte size. The server rejects non-positive sizes and enforces an 18 MB safe raw attachment limit.
</ParamField>

The response contains `attachment_id`, `upload_url`, and `content_type`.

## Labels, filters, contacts, and settings

| Feature | Routes |
| --- | --- |
| Labels | `GET /email/labels`, `POST /email/labels`, `DELETE /email/labels/{id}` |
| Message labels | `PATCH /email/messages/labels` with `{ message_ids, label_id, value }` |
| Thread labels | `PATCH /email/threads/{id}/labels` with `{ label_id, value }` |
| Filters | `GET /email/filters`, `PUT /email/filters`, `DELETE /email/filters/{id}` |
| Contacts | `GET /email/contacts`, `POST /email/contacts/block`, `POST /email/contacts/unblock` |
| Links | `GET /email/links` |
| Settings | `PATCH /email/settings` with `{ settings }` |

The generated `Settings` schema currently exposes `signature_on_replies_forwards`.

## Notification API

### User notifications

The notification service mounts the internal router both under `/{version}` and unversioned paths. The OpenAPI annotations describe read routes under `/v1/user_notifications` and bulk mutation/delete routes under `/v2/user_notifications`, while the frontend client currently calls unversioned `/user_notifications` paths.

| Client method | Route used by client | Shape |
| --- | --- | --- |
| `userNotifications({ limit, cursor })` | `GET /user_notifications` | `{ items, next_cursor? }` |
| `getUserNotificationById(id)` | `GET /user_notifications/{id}` | `ApiUserNotification` |
| `bulkGetUserNotificationsByEventItemId(...)` | `POST /user_notifications/item/bulk` | body `{ eventItemIds }` |
| `markNotificationAsSeen(...)` | `PATCH /user_notifications/bulk/seen` | body `{ notificationIds }` |
| `markNotificationAsDone(...)` | `PATCH /user_notifications/bulk/done` | body `{ notificationIds }` |
| `bulkMarkNotificationAsUndone(...)` | `PATCH /user_notifications/bulk/undone` | body `{ notificationIds }` |
| `markNotificationEntityAsSeen(...)` | `PATCH /user_notifications/item/{event_item_id}/seen` | no body |
| `markNotificationEntityAsDone(...)` | `PATCH /user_notifications/item/{event_item_id}/done` | no body |

`ApiUserNotification` includes `id`, `owner_id`, `entity_id`, `entity_type`, `notification_event_type`, `notification_metadata`, `sent`, `done`, `viewed_at`, `created_at`, `updated_at`, optional `deleted_at`, and optional `sender_id`.

Notification unread state is represented by `viewed_at`; task/action completion is represented separately by `done`.

### Notification preferences

OpenAPI exposes notification-type preferences:

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/v1/user_notifications/preferences` | Return `{ disabled_types: string[] }`. |
| `PUT` | `/v1/user_notifications/preferences/{notification_event_type}/disable` | Disable a notification event type. |
| `PUT` | `/v1/user_notifications/preferences/{notification_event_type}/enable` | Re-enable a notification event type. |

The handwritten `notificationServiceClient` does not currently wrap these preference endpoints.

### Device registration

The notification client exposes push device registration helpers:

```ts
await notificationServiceClient.registerDevice({
  deviceType: 'ios',
  token: '<push-token>',
});

await notificationServiceClient.unregisterDevice({
  deviceType: 'ios',
  token: '<push-token>',
});
```

The local API router nests the device router at `/device`; the OpenAPI artifact includes the `DeviceRequest` schema but does not list device paths.

## Unsubscribe routes

| Route | Purpose |
| --- | --- |
| `GET /unsubscribe` | Return `UserUnsubscribe[]`, each with `item_id` and `item_type`. |
| `POST /unsubscribe/item/{item_type}/{item_id}` | Unsubscribe the current user from one notification item. |
| `DELETE /unsubscribe/item/{item_type}/{item_id}` | Remove one item-level unsubscribe. |
| `POST /unsubscribe/mute` | Mute all notifications for the current user. |
| `DELETE /unsubscribe/mute` | Unmute global notifications; manually muted items remain muted. |
| `POST /unsubscribe/email` | OpenAPI handler for email unsubscribe. |

<Warning>
The notification Rust router currently registers `/unsubscribe/email` with `GET`, while the handler annotation and generated OpenAPI declare `POST /unsubscribe/email`. The frontend client does not expose an `unsubscribeEmail` wrapper. Verify the runtime method before adding callers.
</Warning>

## Client return pattern

Both handwritten clients return `Result` objects:

```ts
const result = await emailClient.getThread({
  thread_id,
  offset: 0,
  limit: 20,
});

if (result.isErr()) {
  // ResultError<FetchWithTokenErrorCode>[]
  return;
}

const { thread } = result.value;
```

Use generated schema types from `./generated/schemas` for request and response bodies, but prefer the handwritten `emailClient` and `notificationServiceClient` for application code because they apply service host selection and authenticated fetch behavior.

## Next

- Update OpenAPI/client drift before adding new sync, device, or unsubscribe-email callers.
- Regenerate schemas after Rust handler or `utoipa` annotation changes with `bun run gen-api <service-name>`.
