added MCP / Agent

This commit is contained in:
Hieu Nguyen (Jack) committed 2026-09-27 13:57:31 +07:00
1 parent eca1e143bf
commit da9b803bdb
182 files changed
+8609 -386

No files matched your search

+3
View File
@@ -9,3 +9,6 @@ CF_TOKEN=your_api_token_with_zone_email_permissions
# Legacy Global API Key alternative. Use this only instead of CF_TOKEN.
# CF_EMAIL=you@example.com
# CF_API_KEY=your_global_api_key
# The Worker uses the AI binding in wrangler.jsonc. Override its chat/draft model if needed.
# AI_MODEL=@cf/moonshotai/kimi-k2.5
+251
View File
@@ -0,0 +1,251 @@
# Agentic email and MCP implementation plan
Status: initial implementation added September 24, 2026. The feature contract and acceptance checklist below describe the intended complete design. The implementation status at the end distinguishes shipped code from remaining work. No build or validation commands were run, per the project's `AGENTS.md` instruction.
## Goal and recommended scope
Add a built-in email assistant, automatic draft replies for eligible inbound mail, and an MCP server for external assistants. Reuse Mailflare's mail storage, composer, search, permissions, and delivery services. Every agent-originated send must require a human to review and explicitly approve the exact outgoing message.
Recommended first release:
- A mailbox-aware side panel with nine email tools, persistent private conversations, streaming responses, visible tool activity, and links into the existing composer.
- Opt-in auto-drafting per mailbox, with one designated reviewer and durable background jobs.
- An authenticated `/mcp` endpoint exposing the same mail operations, plus mailbox discovery and draft updates. External assistants request a send review; approval happens in Mailflare.
- Cloudflare and Docker support through shared application services and runtime-specific model/queue adapters.
Keep autonomous sending, arbitrary third-party MCP connections, attachment interpretation, web browsing, permanent email deletion, bulk actions, and collaborative shared chat out of the first release.
## What the reference actually implements
Source snapshot: Cloudflare's [agentic-inbox at commit `48039bb`](https://github.com/cloudflare/agentic-inbox/tree/48039bb6785af34e592c2966f87cde2b255c4c80). The implementation is more precise than the README's description of “9 email tools … and sending.”
| Area | Observed behavior | Adaptation for Mailflare |
| --- | --- | --- |
| Built-in agent | `EmailAgent` extends `AIChatAgent`; nine tools, none of which sends email. Uses Workers AI, streaming chat, and a five-step limit. | Keep the nine-tool draft-first workflow; provide explicit send review through the UI. |
| Auto-draft | Inbound storage triggers `/onNewEmail` through `ctx.waitUntil`. The agent reads the email/thread, uses fresh model context, and saves a draft. | Use recoverable jobs after successful intake; do not make model availability part of mail delivery. |
| Prompt and history | Mailbox prompt settings live in R2; agent history lives in a mailbox-named Durable Object. | Store settings/history in D1 or SQLite; private conversations belong to a user and mailbox. |
| Draft quality | Scans inbound/thread text for prompt injection and uses a second model to remove commentary from drafts. | Treat scans as optional defense; enforce authority in code and reject malformed/empty drafts. |
| MCP | `EmailMCP` registers 13 tools, including direct send and permanent deletion. Shared helper functions serve both MCP and chat. | Share one authorized service layer; omit permanent deletion and replace direct sending with review requests. |
| Authentication | Cloudflare Access is the single trust boundary; authorized users can operate on all mailboxes. | Preserve Mailflare's account, delegation, disabled-account, and mailbox checks on every operation. |
| UI | Agent panel displays streaming Markdown, tool activity, and a composer handoff; MCP panel displays connection information. | Follow existing Mailflare components, navigation, and composer behavior. |
These observations come from the pinned [agent implementation](https://github.com/cloudflare/agentic-inbox/blob/48039bb6785af34e592c2966f87cde2b255c4c80/workers/agent/index.ts), [MCP implementation](https://github.com/cloudflare/agentic-inbox/blob/48039bb6785af34e592c2966f87cde2b255c4c80/workers/mcp/index.ts), [shared tools](https://github.com/cloudflare/agentic-inbox/blob/48039bb6785af34e592c2966f87cde2b255c4c80/workers/lib/tools.ts), [inbound handler](https://github.com/cloudflare/agentic-inbox/blob/48039bb6785af34e592c2966f87cde2b255c4c80/workers/index.ts), [authentication/routing](https://github.com/cloudflare/agentic-inbox/blob/48039bb6785af34e592c2966f87cde2b255c4c80/workers/app.ts), and [agent UI](https://github.com/cloudflare/agentic-inbox/blob/48039bb6785af34e592c2966f87cde2b255c4c80/app/components/AgentPanel.tsx).
Important distinction: the reference MCP send descriptions ask the caller to obtain confirmation, but their handlers accept message content and call delivery without a server-side approval record. That does not enforce the approval requirement needed here. Its built-in agent avoids this by having no send tool.
## Existing Mailflare foundations and integration gaps
| Existing area | Relevant files | Implication |
| --- | --- | --- |
| Next.js/React UI | `src/app/(dashboard)/layout.tsx`, `src/components/messages/message-split-layout.tsx` | Mount one agent provider/panel in the dashboard shell; avoid duplicating it for each folder. |
| Composer and drafts | `src/components/compose/`, `src/app/api/drafts/`, `src/app/(dashboard)/drafts/` | AI drafts should be ordinary `messages` rows with `status = draft`, supplemented with provenance. |
| Draft ownership | `src/app/api/drafts/utils.ts`, `src/app/api/drafts/[id]/utils.ts` | Draft APIs currently enforce individual ownership. Auto-drafts need an explicit reviewer, not a mailbox-wide shared draft assumption. |
| Mailbox access and sender identity | `src/lib/mailboxes/access.ts`, `src/lib/email/sender.ts` | Reuse read/send/manage capabilities, domain identities, and send-on-behalf display rules. Admin status does not automatically grant mailbox access here. |
| Read/thread/search | `src/lib/email/inbound.ts`, `src/lib/email/thread-view.ts`, `src/lib/search/`, `src/app/api/messages/route.ts` | Reuse authorized reads and search conditions. Thread lookup currently takes a message ID and caps results at 200. |
| Inbound processing | `worker.ts`, `src/lib/email/inbound.ts`, `src/lib/email/intake.ts` | Attach auto-draft scheduling to the common processor used by Cloudflare and Node/relay intake. |
| Existing automatic replies | `src/lib/email/auto-reply.ts` | Out-of-office replies already send automatically; AI auto-drafts must be a separate setting and execution path. |
| Delivery | `src/app/api/send/route.ts`, `src/lib/email/send.ts` | Immediate sends call the provider directly; scheduled sends use `OUTBOUND_QUEUE`. An approval/deduplication layer must precede either path. |
| External API authentication | `src/lib/api/key-auth.ts`, `src/lib/api/scopes.ts` | Reuse key hashing/authentication, but add MCP-specific scopes and mailbox restrictions. Existing scopes are `send`, `read`, `jmap`, and `domains`. |
| Realtime | `src/lib/realtime/`, `src/hooks/message-realtime-utils.ts` | Extend the event union and query invalidation for draft/job changes; current notifications are `new_message`. |
| Runtime portability | `src/lib/runtime.ts`, `server/runtime/env.ts`, `server/runtime/queue.ts`, `server/runtime/scheduler.ts` | Node queue timers are in-memory. Persist AI jobs and recover them after restart. |
| Schema/deployment | `src/db/schema/index.ts`, `drizzle/migrations/`, `wrangler*.jsonc`, `env.d.ts` | Add additive migrations, model configuration, queue bindings, and documented runtime configuration. |
There are no AI SDK/Agents/MCP dependencies in the inspected `package.json`.
## Feature details
### 1. Built-in assistant panel
- Add an Assistant button beside the existing dashboard header controls. Open a resizable right panel on desktop and an accessible drawer on narrow screens; preserve room for message details and the floating composer.
- Display the selected mailbox and optional selected-email context above chat. Require a specific mailbox when the user is viewing an aggregate inbox. Switching mailboxes switches conversation scope and cancels the old stream.
- Provide starter actions such as “Summarize this thread,” “Find emails about…,” and “Draft a reply.” Only fetch full message bodies when needed. Reading through the assistant must not implicitly mark mail as read.
- Persist conversations per `(userId, mailboxId, conversationId)`. Include streamed text, tool status, errors, and draft references. Other delegates must not see private chat history.
- Offer stop, retry, new conversation, and delete history. Store the user turn before starting generation; persist tool effects/results as they happen. On interruption, show the saved partial result and reconcile completed tools before retrying.
- Show tools as readable activity cards, for example “Reading thread” and “Draft created.” Show failure reasons without dumping raw headers, storage keys, or credentials.
- Draft cards offer Open in composer, Regenerate, and Discard. Regenerate creates a new suggestion or requires explicit replacement; it must not overwrite a human-edited draft.
- Render chat Markdown without raw HTML or automatic remote images. Generate email text separately from chat commentary; convert text safely for the existing rich-text composer and apply signatures once.
### 2. Nine built-in email tools
Use one schema/handler registry with adapters for the model and MCP. Chat binds its mailbox on the server; the model cannot switch it by inventing an argument. MCP accepts a mailbox ID and validates it against the authenticated principal's allowed mailboxes.
| Tool | Inputs and result | Required behavior |
| --- | --- | --- |
| `list_emails` | Folder/status filter, cursor, bounded limit; returns metadata and next cursor. | Authorized mailbox only; default 20, maximum 50. Show only the caller's drafts. |
| `get_email` | Message ID; returns text body, metadata, attachment metadata, and truncation information. | Verify message belongs to the active mailbox; omit attachment bytes and internal storage keys. |
| `get_thread` | Anchor message ID; returns chronological messages and continuation/truncation information. | Adapt existing thread service; enforce mailbox and draft-owner boundaries on every returned row. |
| `search_emails` | Query, optional folder, cursor; returns snippets and IDs. | Reuse Mailflare search semantics and indexes; no model-supplied SQL. |
| `draft_email` | Recipients, subject, body; returns draft ID and composer link. | Require sender permission; resolve From server-side. Save under the requesting user. |
| `draft_reply` | Source message ID, body, reply/reply-all mode; returns linked draft. | Derive recipients and RFC threading headers server-side. Handle Reply-To, aliases, and self-address exclusion using shared reply logic. |
| `mark_email_read` | Message ID and read boolean; returns updated state. | Apply existing Mailflare mutation permissions; add unread support in the shared service if needed. |
| `move_email` | Message ID and allowed destination; returns updated state. | Reuse existing move/status policy. Validate custom-folder ownership. Never manufacture sent/draft state or permanently delete. |
| `discard_draft` | Draft ID and expected revision; returns discarded state. | Caller-owned draft only; require an explicit user action before discarding human-edited content. |
Read tools require `canRead`; draft creation requires `canSendOnBehalf` or stronger and the existing sender resolver. For read/move mutations, carry through the actual existing per-operation permission checks rather than treating readable mail as universally writable. Each handler rechecks access when it runs, including after a long model response.
Return structured results such as IDs, operation status, revision, pagination, and safe display text. Use stable errors such as `not_found`, `permission_denied`, `conflict`, `rate_limited`, and `provider_unavailable`.
### 3. Explicit send approval
The nine model tools cannot invoke `sendEmail`, enqueue outbound jobs, approve messages, or change scheduling. A user asking “send it” in chat opens a review action; natural-language assent is not a reusable authorization token.
1. Persist a draft revision before presenting approval. Show From, To, CC, BCC, subject, body, attachment list, and any scheduled delivery time.
2. Create a pending approval bound to the user, mailbox, draft ID/revision, and a server-calculated hash of the complete outgoing payload, including attachment identifiers/content hashes and threading headers.
3. An authenticated browser action labeled **Confirm and send** approves that snapshot. Require same-origin/CSRF protection and fresh mailbox/sender authorization. MCP keys and model tools cannot call the approval action successfully.
4. Atomically claim the approval and create one durable delivery command with a unique approval ID. Reject expired, cancelled, already-claimed, or stale approvals. Editing the draft or attachments invalidates approval and requires a new review.
5. Dispatch that immutable command through the existing delivery service. Record the resulting message/job ID and consume the draft only after a durable handoff. Repeated clicks return the existing delivery status.
Use a short approval lifetime, initially 15 minutes. Treat scheduled AI mail the same way; changing its schedule requires renewed approval. Do not expose automatic scheduling in the initial agent tools.
The current provider-send path is not an exactly-once transaction with the database. Add command-level deduplication, and handle a timeout after provider acceptance as an **unknown delivery outcome** requiring reconciliation; do not blindly retry and risk duplicate email. Audit the reviewer separately from the AI run/MCP key that proposed the draft.
Apply this flow to the AI draft card, MCP review requests, and AI drafts opened in the composer. Enforce the draft provenance/revision checks server-side in the relevant send entry points. Existing manual-email and out-of-office workflows retain their current semantics; MCP credentials must not have legacy `send`, `jmap`, or wildcard privileges that could bypass review.
### 4. Auto-draft on inbound email
Settings: enabled (default off), custom writing instructions, designated reviewer (default mailbox owner), allowed folders, optional sender filters, and daily draft limit. Only the mailbox owner or a delegate with management permission may change mailbox-wide settings. A reviewer must be enabled and retain read/send permission.
Initial eligibility: newly received, successfully stored inbound messages in the configured inbox/folders. Skip spam, trash, blocked senders, imported historical messages, self-mail, mailing lists, bounces, no-reply senders, and automatic responses. Reuse/extract the existing auto-reply header checks without changing out-of-office behavior. Skip suspicious messages or failed spam analysis by default. Default to skipping AI auto-drafts when mailbox out-of-office replies are enabled; explain that policy in settings.
Processing flow:
1. Complete MIME parsing, routing, spam classification, thread assignment, and attachment storage.
2. Persist an eligible auto-draft job with a unique `(mailboxId, sourceMessageId, jobKind)` key. Persist enough intake state to repair the gap between mail storage and job creation. A queue payload carries only the job ID.
3. Dispatch independently of inbound delivery. Claim jobs with an expiring lease and bounded retries; Cloudflare Queue redelivery and Node restarts must not create duplicate drafts.
4. Recheck mailbox configuration, source status, reviewer access, existing replies/drafts, and daily budget. Serialize work per mailbox/thread and mark obsolete jobs superseded when newer messages make them stale.
5. Read the latest message and bounded recent thread context into a fresh model run, independent of private chat history. Signal truncation; do not claim to have read the full conversation when limits were reached.
6. Give this background run only source/thread read access and permission to create one reply draft for that source. It cannot search unrelated conversations, move mail, delete drafts, choose arbitrary recipients, or send.
7. Validate structured draft output, sanitize it, resolve recipients/threading deterministically, and save a standard draft owned by the reviewer with AI provenance. Do not turn arbitrary model commentary into a fallback email.
8. Publish a draft-ready event to the reviewer, with an inbox badge/card linking to the composer. Sending follows the approval flow above. Record skipped/failed jobs with a reason and an explicit retry action.
Keep all mail delivery successful even if generation, scanning, notification, or queue dispatch fails. A durable recovery sweep must recover pending dispatches and expired leases. The current inbound duplicate check returns immediately for an already stored `(mailboxId, rawR2Key)`; modify that branch narrowly to reconcile missing AI work, otherwise retries can permanently miss auto-drafts. Avoid triggering work for historical/imported messages during recovery by persisting an intake eligibility marker. R2 writes and SQL writes are not one transaction: represent incomplete intake explicitly and reconcile before marking a job ready.
Initial limits: one active generation per mailbox, five model steps per interactive turn, at most 20 recent thread messages and a bounded text/token budget, three background attempts with backoff, and 25 automatic drafts per mailbox per day. Make these configurable server-side and tune them from measured use.
### 5. MCP integration
Expose an MCP **server** for external clients; the built-in assistant calls the shared service layer directly. Connecting the built-in assistant to arbitrary external MCP servers is separate future work.
- Serve `/mcp` over HTTPS with the official SDK's Streamable HTTP implementation. Prefer request-scoped/stateless transport. Pin a supported protocol/SDK combination and document client compatibility instead of hand-writing JSON-RPC or copying the reference's transport unchanged. MCP transport/version semantics have evolved; consult the [transport specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports) during implementation.
- MVP authentication: dedicated revocable Bearer API keys for clients that support custom headers. Reuse key verification, but require an explicit MCP credential kind/opt-in and mailbox allowlist; existing keys gain no new rights automatically. This is a limited compatibility mode, not a claim of universal OAuth-based MCP compatibility.
- Add `mcp:read`, `mcp:draft`, `mcp:organize`, and `mcp:request-send` scopes. Require the matching scope plus current user/mailbox permission. `list_mailboxes` returns the intersection of current access and the key allowlist. Carry key ID in auth context for revocation/auditing; it is absent from the current `ApiAuthResult`.
- Reject mixed MCP/legacy-send/JMAP/wildcard credentials in this mode. Do not accept browser session cookies as MCP authentication. Never put keys in URLs or chat messages.
- Expose the nine shared tools plus `list_mailboxes`, `update_draft` with expected revision, `request_send`, and `get_send_request`. Map each tool to its scope, and check scope in execution as well as discovery.
- `request_send(draftId, expectedRevision)` returns `pending_approval`, an approval ID, and a Mailflare review URL. `get_send_request` returns pending/sent/failed/expired status to the originating authorized principal. Neither operation grants approval or sends directly. Document this intentional difference from upstream `send_email`/`send_reply`.
- Validate Origin when present, bound inputs/results and request rates, and authenticate every request. If compatibility requires stateful sessions, bind each session to the same user/key and recheck access; a session ID is not authorization.
- Add a connection screen showing the endpoint, supported client/auth mode, selected mailboxes, scopes, last use, and revocation. Show newly generated secrets only once; generated examples use placeholders after that.
OAuth discovery/authorization for clients without header-based credential setup is a later milestone. Follow the applicable MCP authorization specification rather than passing session cookies or Cloudflare admin tokens to clients.
## Architecture and storage
Recommended architecture is a portable AI SDK service using existing Next.js streaming routes, with D1/SQLite persistence. Use Workers AI on Cloudflare and a configured server-side HTTP model provider on Docker. This avoids introducing a second mailbox database or two different chat persistence implementations.
Cloudflare's `AIChatAgent` is a valid alternative when Durable Object persistence and resumable streaming are the priority; those capabilities are provided by [`@cloudflare/ai-chat`](https://developers.cloudflare.com/agents/communication-channels/chat/chat-agents/). It would require a separate Node adapter and additional authorization around agent instance routing. Defer that dependency in the initial portable implementation; automatic stream resumption is not promised by the proposed plain HTTP streaming approach.
```text
Assistant panel -> authenticated chat route -> bounded model/tool loop
External client -> /mcp + scoped API key ----> shared authorized mail tools
|
existing D1/SQLite + R2/files
Inbound processor -> durable AI job -> runtime dispatcher -> reply draft
Human review -> revision-bound approval -> durable send command -> delivery
```
Proposed additive schema:
| Entity | Main fields / constraints |
| --- | --- |
| `mailbox_agent_settings` | Mailbox PK, enabled, autoDraftEnabled, reviewerUserId, instructions, version, filters, dailyLimit, updatedAt. |
| `agent_conversations` | ID, userId, mailboxId, title, createdAt, updatedAt; scoped history index. |
| `agent_chat_messages` | Conversation ID, sequence, role, validated parts JSON, runId, status; unique sequence and client message ID for deduplication. |
| `agent_runs` | Actor/reviewer, mailbox, conversation/source message, mode, status, model, settings version, timestamps, token usage, safe error; atomic budget reservation. |
| `agent_jobs` | Source message/mailbox, kind, reviewer, status, attempts, nextAttemptAt, leaseUntil, runId, draftId, skip reason; unique inbound job key. |
| `agent_draft_metadata` | Draft message PK, origin (`chat`, `auto`, `mcp`), source message/run/key, revision, humanEditedAt, stale/superseded marker. |
| `agent_send_approvals` | Principal, mailbox, draft/revision, payload hash/snapshot reference, expiry, status, reviewedBy, commandId; single-use claim. |
| `agent_send_commands` | Unique approval ID, immutable payload reference, dispatch state, lease, message/job ID, delivery outcome and timestamps. |
| API key extension | Credential kind and mailbox allowlist, preferably a join table; scopes remain explicit. |
| Intake recovery marker | Source message, intake completion, eligibility/settings snapshot, and dispatch reconciliation state. |
Reuse `messages` for draft bodies and existing attachment storage; do not duplicate email bodies in job payloads. Add revision tracking on every AI draft mutation path, including composer autosave and attachments. Personal manual drafts need not acquire AI approval semantics.
Delete conversation history on user request; use a configurable retention policy (proposed default 30 days for completed chat/run details). Keep minimal audit/approval records according to the existing installation's retention policy. Include new tables in backup/restore; pause recovered jobs after restore until configuration and reviewer access have been reconciled.
## Implementation map
New code should follow the repository convention: helper functions in separate files in the same folder (`utils.ts` or purpose-specific modules), type definitions in adjacent `types.d.ts`. Inspect and preserve all manual edits before touching existing integration points; do not reformat adjacent code.
| Location | Planned responsibility |
| --- | --- |
| `src/lib/agent/` | Tool registry/schemas, authorized adapters, model loop/provider adapter, prompt construction, context limits, run persistence, draft validation. |
| `src/lib/agent/jobs/` | Eligibility, durable job claims, generation, reconciliation, budget handling; adjacent types. |
| `src/lib/agent/approvals/` | Snapshot hashing, revision checks, browser approval, durable delivery commands. |
| `src/lib/mcp/` | Server registration, transport adapter, dedicated-key authentication, scope enforcement, MCP result formatting. |
| `src/app/mcp/route.ts` | Thin Next.js entry point for `/mcp`; keep compatible with OpenNext and Node. |
| `src/app/api/agent/` | Chat stream, conversations/history, settings, job retry/status, send review/confirmation endpoints. |
| `src/components/agent/` | Provider, toggle/panel, chat messages, tool cards, draft cards, approval UI, settings and connection details. |
| Existing dashboard/composer/settings files | Mount UI, provide selected-mail context, open generated drafts, route AI draft sends through review. |
| Existing draft/read/search/send services | Extract only the functionality needed for reuse; preserve established permissions and manual behavior. |
| `src/lib/email/inbound.ts`, `worker.ts`, `worker-utils.ts` | Intake/recovery hook and explicit dispatch of AI jobs. Current worker fallback treats unknown queue payloads as outbound: replace that ambiguity before adding a job kind. |
| `server/runtime/` | Durable-job startup recovery and periodic dispatcher; HTTP model configuration; retain current email transport adapters. |
| `src/lib/realtime/`, relevant hooks | Draft/job event types and authorized notifications/query invalidation on both runtimes. |
| Schema/migrations/config/docs | Additive schema, AI queue and provider configuration, MCP scopes/key UI, deployment and self-hosting instructions. |
Dependencies to evaluate and pin during implementation: `ai`, its React integration, `workers-ai-provider`, an HTTP model-provider adapter, the official MCP TypeScript SDK, and a Markdown renderer if needed. Mailflare uses Zod 4; the reference uses Zod 3, so do not copy its dependency versions or tool typings blindly. No model/provider credentials enter client bundles. On Cloudflare, add the [`AI` binding](https://developers.cloudflare.com/workers-ai/configuration/bindings/) and a dedicated agent queue; use a frequent recovery schedule alongside the existing backup cron. Docker uses persisted jobs with its local scheduler and server-only provider credentials. Missing AI configuration disables generation gracefully; authorized MCP reads/draft CRUD can still work without a model.
## Delivery sequence and acceptance criteria
These are acceptance criteria for the feature; they have not been verified in this change.
### Phase 1 — Authorized mail service and persistence
Add schema, principal context, dedicated MCP credentials, revision-aware draft operations, and the shared nine-tool registry. Resolve reply identity/threading and reviewer ownership before wiring a model.
Acceptance: inaccessible mailbox/message IDs never return data; read-only delegates cannot draft/send; each delegate's personal drafts remain private; aliases and send-on-behalf identity follow existing behavior; stale revisions fail without overwriting user edits.
### Phase 2 — Interactive agent and review flow
Add provider configuration, bounded tool loop, private persistent history, panel/composer integration, approval snapshots, and durable send commands. Make the feature opt-in and initially enable it for a single mailbox.
Acceptance: users can summarize/search/draft with visible tool results; history survives refresh; cancellation leaves recoverable state; no model tool sends; edits invalidate approval; repeated approval clicks create one command; provider-acceptance uncertainty never causes blind resend. Verify usability on narrow and wide layouts during implementation.
### Phase 3 — MCP
Add transport and connection settings using the shared services. Document the supported protocol versions and authentication mode with actual client examples once integration is exercised.
Acceptance: scoped clients discover and use allowed tools; revoked/disabled principals lose access; cross-mailbox IDs and legacy send endpoints cannot bypass scope; a send request remains pending until a Mailflare browser approval; MCP reading/drafting works without a configured AI model.
### Phase 4 — Auto-drafting
Add mailbox settings, intake eligibility/reconciliation, background generation, notifications, skip reasons, retry, and daily limits. Roll out to a small mailbox set before enabling broadly.
Acceptance: one eligible inbound message produces at most one initial AI draft; queue redelivery/restart does not duplicate it; a crash between storage and dispatch is recovered; spam/imports/automatic responses are skipped; newer thread activity and human edits are preserved; failed generation does not fail inbound mail; auto-drafts never enqueue outgoing email.
### Phase 5 — Runtime and rollout completion
Complete Cloudflare and Docker configuration, model budgets, usage/error visibility, retention, backup/restore handling, and operational docs. Exercise the authorization, approval, retry, and interruption scenarios above when implementation work is authorized; avoid unrelated cleanup.
Acceptance: both deployments support the documented feature set, with helpful disabled states when providers are unavailable. Disabling the feature stops new runs/dispatches, expires pending agent approvals, and leaves existing drafts editable. Already confirmed delivery commands require an explicit cancellation decision rather than silently disappearing.
## Proposed defaults and later decisions
- Enable nothing automatically on upgrade. Mailbox owners explicitly enable the assistant/auto-drafts and choose a reviewer.
- Keep AI instructions additive to mandatory server policy; custom prompts cannot grant tools, broaden mailbox access, or remove approval.
- Treat all email content and tool results as untrusted data. Only the app establishes recipients, authority, and approval. Model-based injection scans cannot replace these controls.
- Show the configured provider and explain that selected email/thread content is sent to it when enabling AI. Log IDs, timing, usage, and error categories rather than message bodies or credentials.
- Choose the production model after evaluating tool calling, reply quality, latency, and cost. The reference's Kimi model is a research observation, not a required or permanently available default.
- Later decisions: OAuth MCP compatibility, attachment analysis, shared team draft ownership, optional Cloudflare `AIChatAgent` transport, and broader model-provider selection. None should weaken explicit approval for agent-originated delivery.
## Implementation status (September 24, 2026)
The initial implementation adds the mailbox-scoped assistant panel, nine shared email tools, private persisted chat with streaming text and tool activity, AI draft provenance, reviewer-bound send approval, automatic reply-draft jobs, and a Bearer-key MCP server. Agent send requests use a 15-minute payload snapshot and an authenticated browser confirmation. The database prevents two approvals for one draft from both reaching claimed or delivered status. The composer routes agent drafts through review; model tools and MCP tools have no direct send operation. Cloudflare and Node runtime configuration, migrations, and operator documentation are included. All settings default off.
The current implementation is **not yet a verified production release**. The following parts of the design remain incomplete or need integration exercise:
- No build, typecheck, migration exercise, runtime test, or browser review was run, as instructed by `AGENTS.md`. Validate the MCP SDK adapter, AI provider adapters, Cloudflare queue binding, Node startup, and migration before deployment.
- Auto-draft jobs are durable and lease-based, with one active lease per mailbox and a daily completed-draft limit. There is no separate atomic model-cost reservation, and an unusually long generation that outlives its lease still needs reconciliation. The current plain-text model output is checked for emptiness and common commentary prefixes, not parsed as a structured reply.
- There is no separate immutable send-command/outbox table. A claimed approval calls the existing delivery service once, and an uncertain provider outcome remains `unknown` for manual reconciliation. This avoids automatic duplicate retries but does not provide transactional exactly-once delivery.
- The assistant panel has a fixed width. Auto-draft cards can open, request a new draft through chat, or discard the current draft; richer tool progress, mobile accessibility review, and multi-mailbox selection in the MCP key UI remain to be completed. The MCP key API supports explicit scopes and mailbox allowlists; the UI supports scope selection for the current mailbox.
- Optional sender/folder filters, suspicious-content scanning, intake-completion markers, model usage audit/budget reservation, and restore-time pause/reconciliation are not implemented. Default eligibility is limited to newly received inbox mail and skips known automated/self/spam/error cases.
- MCP uses scoped Bearer keys for clients that can set headers. OAuth-based client authorization remains a later milestone.
+21 -3
View File
@@ -1,6 +1,6 @@
# API and integrations
Mailflare exposes APIs for domain management and sending email. Authentication and mailbox permissions still apply to these routes.
Mailflare exposes APIs for domain, account, and mailbox management, and for sending email. Authentication and mailbox permissions still apply to these routes.
## Domain management
@@ -18,7 +18,7 @@ The hostname must be the apex of a zone available to the configured Cloudflare c
### Domain management over the API
The same operations are available to scripts through API keys with the `domains` scope, using `Authorization: Bearer <key>`:
The same operations are available to scripts through admin API keys with the `domains` scope, using `Authorization: Bearer <key>`. Create these keys in Admin > API keys. The owner must retain the admin role; personal mail keys from Settings cannot grant domain access.
| Mailflare route | Purpose |
| --- | --- |
@@ -31,6 +31,24 @@ The same operations are available to scripts through API keys with the `domains`
`GET /api/v1/domains` returns `{ domains, dns }`, where `dns[id].auth` reports `ok` / `missing` / `unknown` for MX, SPF, DKIM and DMARC. `GET /api/v1/domains/[id]/dns` returns the full audit, including the names queried and the values found. The `setup` route provisions MX/SPF through Email Routing, DKIM through the sending subdomain, and a `v=DMARC1; p=none` TXT for DMARC; on a self-hosted install where DNS is managed manually it returns an error, since Mailflare cannot write the zone.
## Account and mailbox management
Admin > API keys can also grant the `accounts` and `mailboxes` scopes independently. These routes use `Authorization: Bearer <key>` and require the key owner to retain the admin role. Account management requires a Team license; creating a shared mailbox also requires a Team license. Each key can access only accounts created by its owner and mailboxes owned by those accounts or the admin.
Admin API keys cannot read or send mail. Enable **Allow MCP access** when creating an admin key to use its selected `domains`, `accounts`, and `mailboxes` permissions through `/mcp`. The `manage_domains`, `manage_accounts`, and `manage_mailboxes` tools expose the corresponding management actions below. Admin MCP keys do not expose mail tools. Use Settings > API keys for mail and mail MCP access.
| Scope | Mailflare route | Purpose |
| --- | --- | --- |
| `accounts` | `GET /api/v1/accounts` | List managed accounts |
| `accounts` | `POST /api/v1/accounts` | Create an account and its mailbox (`{ username, domainId, password, role? }`) |
| `accounts` | `GET /api/v1/accounts/[id]` | Get a managed account |
| `accounts` | `PATCH /api/v1/accounts/[id]` | Update an account (`{ name, role, disabled, canManageMailboxes, forwardingEmail?, password? }`) |
| `mailboxes` | `GET /api/v1/mailboxes` | List managed mailboxes |
| `mailboxes` | `POST /api/v1/mailboxes` | Create a mailbox (`{ domainId, localPart, displayName?, type?, ownerUserId? }`) |
| `mailboxes` | `GET /api/v1/mailboxes/[id]` | Get a managed mailbox |
| `mailboxes` | `PATCH /api/v1/mailboxes/[id]` | Update mailbox settings |
| `mailboxes` | `DELETE /api/v1/mailboxes/[id]` | Delete a mailbox and its routing rule |
## Sending email
Send email through `POST /api/v1/send`. `to`, `cc` and `bcc` accept either a comma-separated header string or an array of addresses; each entry may carry a display name (`"Maya Chen" <maya@example.net>`). A message can reach up to 50 recipients across the three fields. Attachments are optional and use Base64-encoded content:
@@ -74,7 +92,7 @@ The dashboard composer accepts up to 10 attachments, with a 10 MB limit per file
## JMAP
Mailflare serves [JMAP](https://jmap.io) (RFC 8620 core and RFC 8621 mail, plus submission) so external mail apps can read and send mail. Discovery is at `/.well-known/jmap`, which redirects to `/jmap/session`. Authenticate with an API key that has the `jmap` scope, either as `Authorization: Bearer <key>` or as the password of HTTP Basic auth (the username is ignored). Settings > Account > Email apps mints such a key.
Mailflare serves [JMAP](https://jmap.io) (RFC 8620 core and RFC 8621 mail, plus submission) so external mail apps can read and send mail. Discovery is at `/.well-known/jmap`, which redirects to `/jmap/session`. Authenticate with an API key that has the `jmap` scope, either as `Authorization: Bearer <key>` or as the password of HTTP Basic auth (the username is ignored). Settings > Account > App passwords mints such a key.
The account id is the user id. Each Mailflare mailbox appears as a top-level JMAP Mailbox with system children (`inbox`, `drafts`, `sent`, `archive`, `junk`, `trash`) and one child per user folder; a message belongs to exactly one of them. Supported methods: `Mailbox/get|query|set` (folders only), `Thread/get`, `Email/get|query|set|import`, `SearchSnippet/get`, `Identity/get`, `EmailSubmission/set`, and `Core/echo`. `Email/copy` and `Email/parse` are not implemented. `*/changes` return `cannotCalculateChanges`, so clients re-query on a state change; `/jmap/eventsource` pushes state changes by polling. `Email/set` creates drafts, updates `$seen` and `$flagged`, moves between mailboxes, and destroys (to Trash first, then permanently). `EmailSubmission/set` sends a draft and reports it destroyed, since the sent copy is a new message. Blob download and upload follow the Session's `downloadUrl` and `uploadUrl`.
+8
View File
@@ -79,6 +79,14 @@ Deploy the complete Worker with `npm run deploy` whenever the cron trigger is ad
After upgrading an existing installation and confirming the cron trigger is active, the old Workflow can be removed with `npx wrangler workflows delete mailflare-database-backup`. Deleting it also removes its historical Workflow instances; backup files in R2 and rows in Mailflare's backup history are unaffected.
## Email assistant and MCP
The assistant uses the Workers AI `AI` binding and a separate `mailflare-agent` queue. Provision the queue in the Cloudflare account before deploying a configuration that declares it, and apply migration `0032_add_agentic_mail.sql` before opening the new UI on an existing database. The five-minute cron recovers pending auto-draft work; the 02:00 UTC cron still runs backups.
In the inbox, open **Assistant → Settings** for a mailbox, select its reviewer, and enable the assistant. Auto-drafting is a separate opt-in. It skips spam, automated mail, and mailboxes with out-of-office replies enabled. Generated replies appear as ordinary drafts assigned to the reviewer. The reviewer must open the draft and confirm the exact content before delivery.
The assistant panel no longer exposes MCP key management. External MCP clients can still connect to `https://<your-mailflare-origin>/mcp` with a mailbox-scoped Bearer key created through the authenticated `/api/agent/mcp-keys` endpoint. Keys can be listed and revoked through that endpoint; a new key is shown only once. The server uses Streamable HTTP and accepts clients that can set a Bearer header. Its `request_send` tool returns a Mailflare review URL; the MCP key cannot confirm or deliver messages directly. MCP does not require Workers AI for read and draft tools.
## Updating Mailflare
The **Update Mailflare** button in the admin dashboard dispatches `.github/workflows/deploy-update.yml` in the installation repository. The workflow replaces the installation branch's complete tracked tree with the latest upstream source, commits that replacement, and pushes it. This avoids merge conflicts between independently created installation and upstream histories. Target-only committed files and code changes are intentionally removed; repository variables, secrets, and other GitHub or Cloudflare configuration remain unchanged. A connected Cloudflare Git integration then builds and deploys the change.
+9
View File
@@ -79,6 +79,9 @@ and the DNS page shows what to set by hand.
| `CF_ACCOUNT_ID`, `CF_TOKEN` | unset | Cloudflare Email Sending, and zone management if the token allows |
| `INBOUND_WEBHOOK_SECRET` | unset | Enables `/api/inbound` for the relay Worker |
| `TURNSTILE_SECRET_KEY` | unset | Bot protection on login and reset forms (`NEXT_PUBLIC_TURNSTILE_SITE_KEY` at build time) |
| `AI_BASE_URL` | unset | OpenAI-compatible model API base URL for the assistant |
| `AI_API_KEY` | unset | Server-only key for that model API |
| `AI_MODEL` | `gpt-4o-mini` | Model ID supported by the configured API |
## Operations
@@ -92,6 +95,12 @@ and the DNS page shows what to set by hand.
before it is queued, so a restart never loses a message; at worst one
stays unparsed until it is re-imported.
## Email assistant and MCP
Set `AI_BASE_URL`, `AI_API_KEY`, and `AI_MODEL` in the container environment to configure the built-in assistant. These values stay on the server. Assistant chat is available by default when a provider is configured; a mailbox manager can change its writing instructions and availability through the settings button in the assistant panel. Automatic reply drafts remain off until enabled there. Auto-draft work is recorded in SQLite and retried after a restart by the local scheduler. AI failure does not reject inbound mail. Out-of-office auto-replies and AI auto-drafts are separate features; turn off out-of-office replies before enabling auto-drafts for a mailbox.
The MCP endpoint is `/mcp`. Create a dedicated mailbox-scoped Bearer key in **Assistant → MCP** and give the key to a client that supports custom HTTP headers. The endpoint uses Streamable HTTP; `request_send` gives the client a review URL, and only an authenticated Mailflare browser session can confirm delivery. MCP read and draft tools remain available when no AI model is configured.
## Running without Docker
```bash
@@ -0,0 +1,29 @@
CREATE TABLE `mailbox_agent_settings` (`mailbox_id` text PRIMARY KEY NOT NULL REFERENCES `mailboxes`(`id`) ON DELETE cascade, `enabled` integer NOT NULL DEFAULT 1, `auto_draft_enabled` integer NOT NULL DEFAULT 0, `reviewer_user_id` text REFERENCES `users`(`id`) ON DELETE set null, `instructions` text NOT NULL DEFAULT '', `daily_limit` integer NOT NULL DEFAULT 25, `updated_at` integer NOT NULL);
--> statement-breakpoint
CREATE TABLE `agent_conversations` (`id` text PRIMARY KEY NOT NULL, `user_id` text NOT NULL REFERENCES `users`(`id`) ON DELETE cascade, `mailbox_id` text NOT NULL REFERENCES `mailboxes`(`id`) ON DELETE cascade, `title` text NOT NULL DEFAULT 'New conversation', `created_at` integer NOT NULL, `updated_at` integer NOT NULL);
--> statement-breakpoint
CREATE INDEX `agent_conversations_owner_idx` ON `agent_conversations` (`user_id`, `mailbox_id`, `updated_at`);
--> statement-breakpoint
CREATE TABLE `agent_chat_messages` (`id` text PRIMARY KEY NOT NULL, `conversation_id` text NOT NULL REFERENCES `agent_conversations`(`id`) ON DELETE cascade, `role` text NOT NULL, `content` text NOT NULL, `tool_name` text, `created_at` integer NOT NULL);
--> statement-breakpoint
CREATE INDEX `agent_chat_messages_conversation_idx` ON `agent_chat_messages` (`conversation_id`, `created_at`);
--> statement-breakpoint
CREATE TABLE `agent_jobs` (`id` text PRIMARY KEY NOT NULL, `mailbox_id` text NOT NULL REFERENCES `mailboxes`(`id`) ON DELETE cascade, `source_message_id` text NOT NULL REFERENCES `messages`(`id`) ON DELETE cascade, `reviewer_user_id` text NOT NULL REFERENCES `users`(`id`) ON DELETE cascade, `status` text NOT NULL DEFAULT 'pending', `attempts` integer NOT NULL DEFAULT 0, `next_attempt_at` integer NOT NULL, `lease_until` integer, `draft_id` text REFERENCES `messages`(`id`) ON DELETE set null, `reason` text, `created_at` integer NOT NULL);
--> statement-breakpoint
CREATE UNIQUE INDEX `agent_jobs_source_idx` ON `agent_jobs` (`mailbox_id`, `source_message_id`);
--> statement-breakpoint
CREATE INDEX `agent_jobs_due_idx` ON `agent_jobs` (`status`, `next_attempt_at`);
--> statement-breakpoint
CREATE TABLE `agent_draft_metadata` (`draft_id` text PRIMARY KEY NOT NULL REFERENCES `messages`(`id`) ON DELETE cascade, `mailbox_id` text NOT NULL REFERENCES `mailboxes`(`id`) ON DELETE cascade, `origin` text NOT NULL, `source_message_id` text REFERENCES `messages`(`id`) ON DELETE set null, `revision` integer NOT NULL DEFAULT 1, `human_edited_at` integer, `created_at` integer NOT NULL);
--> statement-breakpoint
CREATE TABLE `agent_send_approvals` (`id` text PRIMARY KEY NOT NULL, `draft_id` text NOT NULL REFERENCES `messages`(`id`) ON DELETE cascade, `mailbox_id` text NOT NULL REFERENCES `mailboxes`(`id`) ON DELETE cascade, `user_id` text NOT NULL REFERENCES `users`(`id`) ON DELETE cascade, `request_key_id` text REFERENCES `api_keys`(`id`) ON DELETE set null, `revision` integer NOT NULL, `payload_hash` text NOT NULL, `status` text NOT NULL DEFAULT 'pending', `claimed_at` integer, `message_id` text, `expires_at` integer NOT NULL, `created_at` integer NOT NULL);
--> statement-breakpoint
CREATE INDEX `agent_send_approvals_user_idx` ON `agent_send_approvals` (`user_id`, `status`);
--> statement-breakpoint
CREATE UNIQUE INDEX `agent_send_approvals_delivery_idx` ON `agent_send_approvals` (`draft_id`) WHERE `status` IN ('claimed', 'sent', 'unknown');
--> statement-breakpoint
CREATE TABLE `mcp_key_mailboxes` (`key_id` text NOT NULL REFERENCES `api_keys`(`id`) ON DELETE cascade, `mailbox_id` text NOT NULL REFERENCES `mailboxes`(`id`) ON DELETE cascade);
--> statement-breakpoint
CREATE UNIQUE INDEX `mcp_key_mailbox_idx` ON `mcp_key_mailboxes` (`key_id`, `mailbox_id`);
ALTER TABLE `api_keys` ADD `kind` text NOT NULL DEFAULT 'legacy';
--> statement-breakpoint
@@ -0,0 +1,9 @@
ALTER TABLE `app_settings` ADD `agent_provider` text;
--> statement-breakpoint
ALTER TABLE `app_settings` ADD `agent_preset` text;
--> statement-breakpoint
ALTER TABLE `app_settings` ADD `agent_base_url` text;
--> statement-breakpoint
ALTER TABLE `app_settings` ADD `agent_api_key` text;
--> statement-breakpoint
ALTER TABLE `app_settings` ADD `agent_model` text;
+14
View File
@@ -225,6 +225,20 @@
"when": 1788850800003,
"tag": "0031_add_password_reset_and_mfa",
"breakpoints": true
},
{
"idx": 32,
"version": "6",
"when": 1788850800004,
"tag": "0032_add_agentic_mail",
"breakpoints": true
},
{
"idx": 33,
"version": "6",
"when": 1788850800005,
"tag": "0033_add_agent_provider_settings",
"breakpoints": true
}
]
}
Vendored
+6
View File
@@ -3,6 +3,11 @@ interface CloudflareEnv {
EMAIL: SendEmail;
BUCKET: R2Bucket;
INBOUND_QUEUE: Queue<import("./src/lib/email/inbound").InboundQueueMessage>;
AGENT_QUEUE?: Queue<{ kind: "agent.draft"; jobId: string }>;
AI?: Ai;
AI_MODEL?: string;
AI_BASE_URL?: string;
AI_API_KEY?: string;
// The outbound queue also carries webhook retries so that scheduled redelivery needs no extra binding.
OUTBOUND_QUEUE: Queue<
| import("./src/lib/email/send").OutboundQueueMessage
@@ -15,6 +20,7 @@ interface CloudflareEnv {
import("./src/lib/realtime/hub").RealtimeHub
>;
LOGIN_RATE_LIMIT?: RateLimit;
AGENT_RATE_LIMIT?: RateLimit;
CF_TOKEN?: string;
CF_API_KEY?: string;
CF_EMAIL?: string;
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
# Mailflare — Good company for your inbox
15 seconds · 1920 × 1080 · 60 fps · original synthesized stereo soundtrack
| Time | Picture | Copy |
| --- | --- | --- |
| 0–2.5s | Oversized type rises from masks. A blue envelope opens into a smiling note. Orbital accents and a lime notification pop on the beat. | Email. With a smile. |
| 2.5–5s | Warm human portrait and blue agent glyph bounce into a split composition, connected by a moving message. | For humans. And agents. |
| 5–10s | A stylized Mailflare inbox assembles. An agent drafts a reply; the cursor selects Review & send, then confirms delivery. | Feels familiar. Does more. / Draft. Review. Send. |
| 10–12.5s | A custom-domain address slides into a giant pill. Graphic rules orbit a confident ownership statement. | Your domain. Your rules. |
| 12.5–15s | Envelope wipe resolves into the Mailflare mark and a spacious cobalt end card. | mailflare / Email for humans. And their agents. / Make room for better email. |
## Product grounding
Read `README.md`, `mailflare-landing/app/page.tsx`, `docs/api.md`, and `src/components/agent/send-review.tsx`, and viewed the existing inbox screenshot and app icon before writing the copy. The UI in the film is an animated product illustration with fictional sample mail. The human confirmation step reflects the implementation's review-before-send flow.
## Render
Run `python3 render.py` from this directory. Requires Pillow and FFmpeg. Functions are in `utils.py`. No application files or previous video assets are modified.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+3
View File
@@ -0,0 +1,3 @@
from utils import render
render()
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.
+480
View File
@@ -0,0 +1,480 @@
import math
import random
import struct
import subprocess
import wave
from functools import lru_cache
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parent
W, H, S, FPS = 1920, 1080, 2, 60
CREAM = '#F5F3EB'
INK = '#172824'
BLUE = '#2458ED'
LIME = '#D9F876'
PEACH = '#F8AF8E'
MUTED = '#72817B'
WHITE = '#FFFFFF'
FONT = '/System/Library/Fonts/Supplemental/Arial.ttf'
BOLD = '/System/Library/Fonts/Supplemental/Arial Bold.ttf'
def clamp(v):
return max(0, min(1, v))
def ease(v):
return 1 - (1 - clamp(v)) ** 4
def smooth(v):
v = clamp(v)
return v * v * (3 - 2 * v)
def pop(v):
v = clamp(v)
return 1 + 2.1 * (v - 1) ** 3 + 1.1 * (v - 1) ** 2
def mix(a, b, v):
return a + (b - a) * v
@lru_cache(maxsize=120)
def font(size, bold=False):
return ImageFont.truetype(BOLD if bold else FONT, round(size * S))
def txt(im, x, y, value, size, color=INK, bold=False, anchor='lt'):
ImageDraw.Draw(im).text((round(x * S), round(y * S)), value, font=font(size, bold), fill=color, anchor=anchor, stroke_width=0)
def box(im, coords, fill, radius=0, outline=None, width=1):
ImageDraw.Draw(im).rounded_rectangle(tuple(round(v * S) for v in coords), radius=round(radius * S), fill=fill, outline=outline, width=round(width * S))
def line(im, coords, color, width=1):
ImageDraw.Draw(im).line(tuple(round(v * S) for v in coords), fill=color, width=max(1, round(width * S)), joint='curve')
def circle(im, x, y, r, fill=None, outline=None, width=1):
ImageDraw.Draw(im).ellipse(tuple(round(v * S) for v in (x-r, y-r, x+r, y+r)), fill=fill, outline=outline, width=max(1, round(width * S)))
def poly(im, points, fill):
ImageDraw.Draw(im).polygon([(round(x * S), round(y * S)) for x, y in points], fill=fill)
def arc(im, bounds, start, end, fill, width=3):
ImageDraw.Draw(im).arc(tuple(round(v*S) for v in bounds), start, end, fill=fill, width=round(width*S))
def new(bg=CREAM):
return Image.new('RGB', (W, H), bg)
def layer():
return Image.new('RGBA', (W, H), (0, 0, 0, 0))
def paste(im, item, x=0, y=0, scale=1, rotation=0, opacity=1):
if scale != 1:
item = item.resize((max(1, round(item.width*scale)), max(1, round(item.height*scale))), Image.Resampling.BICUBIC)
if rotation:
item = item.rotate(rotation, Image.Resampling.BICUBIC, expand=True)
if opacity < 1:
item = item.convert('RGBA')
item.putalpha(item.getchannel('A').point(lambda a: int(a*clamp(opacity))))
im.paste(item, (round(x*S-item.width/2), round(y*S-item.height/2)), item if item.mode == 'RGBA' else None)
def reveal(im, x, y, value, size, t, delay=0, color=INK, bold=True):
p = ease((t-delay)/0.65)
if p <= 0:
return
patch = Image.new('RGBA', (W, round(size*1.32*S)))
txt(patch, 0, (1-p)*size*1.45, value, size, color, bold)
im.paste(patch, (round(x*S), round(y*S)), patch)
def spark(im, x, y, r, color=BLUE, rot=0):
points = []
for i in range(8):
a = rot + math.pi*i/4
rr = r if i%2 == 0 else r*0.25
points.append((x+math.cos(a)*rr, y+math.sin(a)*rr))
poly(im, points, color)
def check(im, x, y, size, color=INK):
line(im, (x-size*.5, y, x-size*.1, y+size*.4, x+size*.65, y-size*.45), color, max(1.5,size*.15))
def envelope(im, x, y, w, color=BLUE, opened=0):
h = w*.7
box(im, (x-w/2,y-h/2,x+w/2,y+h/2), color, w*.08)
poly(im, [(x-w*.45,y-h*.43),(x,y-h*.43-w*.28*opened),(x+w*.45,y-h*.43),(x,y+h*.13)], '#96CBFF')
if opened:
yy = y-w*.3*opened
box(im,(x-w*.32,yy-h*.22,x+w*.32,yy+h*.27),CREAM,w*.03)
circle(im,x-w*.1,yy,w*.016,INK)
circle(im,x+w*.1,yy,w*.016,INK)
arc(im,(x-w*.09,yy,x+w*.09,yy+w*.10),0,180,INK,w*.015)
poly(im,[(x-w*.5,y-h*.39),(x+w*.03,y+h*.12),(x-w*.5,y+h*.5)],'#22B7E4')
poly(im,[(x+w*.5,y-h*.39),(x-w*.04,y+h*.12),(x+w*.5,y+h*.5)],'#0493D1')
poly(im,[(x-w*.5,y+h*.5),(x,y-h*.03),(x+w*.5,y+h*.5)],'#1EA7DF')
def brand(im, x=42, y=33, dark=False):
envelope(im,x+10,y+8,21)
txt(im,x+30,y-2,'mailflare',18,WHITE if dark else INK,True)
def footer(im, index, dark=False):
color = '#A7BFFE' if dark else MUTED
txt(im,42,505,'GOOD COMPANY FOR YOUR INBOX',9,color,True)
txt(im,918,505,f'0{index} / 05',9,color,True,'rt')
def portrait(im,x,y,r,t,agent=False):
if agent:
box(im,(x-r,y-r,x+r,y+r),BLUE,r*.3)
box(im,(x-r*.65,y-r*.32,x+r*.65,y+r*.47),CREAM,r*.22)
for dx in [-.28,.28]:
box(im,(x+r*dx-r*.06,y-r*.1,x+r*dx+r*.06,y+r*.13),INK,r*.05)
line(im,(x,y-r*.36,x,y-r*.67),CREAM,3)
circle(im,x,y-r*.73,r*.1,LIME)
arc(im,(x-r*.21,y+r*.07,x+r*.21,y+r*.3),0,180,INK,2)
else:
circle(im,x,y,r,PEACH)
arc(im,(x-r*.48,y-r*.77,x+r*.50,y+r*.1),185,343,INK,r*.19)
for dx in [-.26,.26]:
circle(im,x+r*dx,y-r*.01,r*.045,INK)
arc(im,(x-r*.27,y-r*.03,x+r*.27,y+r*.44),0,180,INK,r*.05)
circle(im,x-r*.47,y+r*.22,r*.10,'#EE8A72')
circle(im,x+r*.47,y+r*.22,r*.10,'#EE8A72')
def opening(t):
im=new()
brand(im)
footer(im,1)
# A faint oversized orbit gives the typography a physical stage.
circle(im,760,267,197,None,'#E0E4D8',1)
circle(im,760,267,159,None,'#E0E4D8',1)
reveal(im,54,150,'Email.',102,t)
reveal(im,54,266,'With a smile.',65,t,.22)
p=ease((t-.55)/.6)
txt(im,57,366+18*(1-p),'A little more human. A lot more possible.',17,MUTED)
card=Image.new('RGBA',(560,540))
# Drawing helpers use the same two-pixel unit inside local assets.
envelope(card,140,145,184,opened=ease((t-.55)/.8))
paste(im,card,748,281+math.sin(t*3)*7,max(.01,pop(t/.8)),rotation=-9+4*math.sin(t*2))
spark(im,635,131,23*pop((t-.4)/.5),BLUE,t*.5)
spark(im,873,385,17*pop((t-.6)/.5),BLUE,-t*.7)
q=pop((t-.85)/.45)
circle(im,849,180,26*q,LIME)
if q>.5:
txt(im,849,181,'1',22,INK,True,'mm')
line(im,(57,419,149+42*ease((t-.75)/.7),419),BLUE,4)
return im
def partners(t):
im=new()
box(im,(480,0,960,540),'#E9EEFF')
brand(im)
footer(im,2)
p=pop(t/.6)
portrait(im,254,219+12*math.sin(t*3),67*p,t)
portrait(im,706,219-12*math.sin(t*3),67*pop((t-.12)/.6),t,True)
for k in range(19):
xx=354+k*14
circle(im,xx,220,1.7,'#A9B8A9')
e=layer()
envelope(e,480,220,53)
xx=mix(364,597,(math.sin(t*2.8-1.5)+1)/2)
im.paste(e,(round((xx-480)*S),round(9*math.sin(t*5)*S)),e)
reveal(im,89,332,'For humans.',49,t,.08)
reveal(im,525,332,'And agents.',49,t,.26,BLUE)
txt(im,254,414,'Your people. Your conversations.',14,MUTED,anchor='mt')
txt(im,706,414,'A helping hand, built right in.',14,'#687CA5',anchor='mt')
spark(im,359,113,13,BLUE,t)
spark(im,823,302,14,BLUE,-t)
return im
def cursor(im,x,y,pressed=False):
poly(im,[(x+2,y+3),(x+2,y+26),(x+9,y+20),(x+15,y+31),(x+21,y+27),(x+14,y+17),(x+25,y+15)],'#182924')
poly(im,[(x,y),(x,y+22),(x+7,y+16),(x+14,y+28),(x+18,y+25),(x+11,y+14),(x+22,y+13)],WHITE)
if pressed:
circle(im,x+2,y+3,18,None,LIME,2)
def inbox(t):
im=new('#E7EDFF')
brand(im)
footer(im,3)
reveal(im,46,80,'Feels familiar. Does more.',40,t,0)
ui=layer()
box(ui,(60,152,900,477),'#CFD8F1',19)
box(ui,(54,144,894,469),WHITE,19)
box(ui,(54,144,220,469),'#F4F7FF',19)
envelope(ui,80,169,18)
txt(ui,98,160,'mailflare',14,INK,True)
box(ui,(70,199,200,237),'#DBE7FF',12)
txt(ui,89,210,'+ Compose',13,BLUE,True)
for i,label in enumerate(['Inbox','Starred','Snoozed','Sent','Drafts']):
yy=265+i*32
if i==0:
box(ui,(64,254,210,280),'#DEE8FD',12)
circle(ui,82,yy+2,3,BLUE if i==0 else '#ABB6C7')
txt(ui,96,yy-5,label,12,INK,i==0)
box(ui,(237,158,874,195),'#F1F4FA',17)
circle(ui,255,175,5,None,'#68798E',1.5)
line(ui,(259,179,263,183),'#68798E',1.5)
txt(ui,275,168,'Search your mail',12,'#8290A1')
txt(ui,243,215,'Inbox',20,INK,True)
txt(ui,870,219,'hello@your.studio',11,MUTED,anchor='rt')
entries=[('M','Maya','A little launch magic','Ready when you are.','#F8AF8E'),('J','Jamie','Coffee next week?','Tuesday sounds good.','#D9F876'),('S','Studio','The ideas are in','Three directions to explore.','#DBD2F9'),('A','Alex','That looks wonderful','Thanks for the lovely work.','#BDEBEE')]
for i,(initial,name,subject,body,color) in enumerate(entries):
p=ease((t-.13-i*.06)/.45)
y=252+i*50+(1-p)*20
if i==0:
box(ui,(234,y-5,880,y+43),'#ECF1FF',9)
circle(ui,254,y+17,13,color)
txt(ui,254,y+17,initial,11,INK,True,'mm')
txt(ui,278,y+2,name,11,INK,True)
txt(ui,365,y+2,subject,12,INK,True)
txt(ui,365,y+20,body,10,MUTED)
if i>0:
line(ui,(240,y+43,878,y+43),'#EEF0F5')
p=ease(t/.7)
scale=.90+.10*p
paste(im,ui,480,270+(1-p)*110,scale)
# The agent's draft is staged as a readable foreground interaction.
a=ease((t-1)/.5)
if a>0:
panel=layer()
box(panel,(462,233,891,465),'#C0CCE8',17)
box(panel,(454,225,883,457),WHITE,17,outline='#D0DCFA')
circle(panel,478,248,12,BLUE)
spark(panel,478,248,7,LIME,math.pi/4)
txt(panel,499,239,'Your agent',14,INK,True)
box(panel,(773,237,865,260),'#EEF5E3',10)
txt(panel,819,244,'DRAFT READY',8,INK,True,'mt')
line(panel,(472,273,866,273),'#EBEEF3')
txt(panel,476,287,'Re: A little launch magic',12,INK,True)
sentence='Hi Maya, everything is ready for launch.'
count=round(len(sentence)*clamp((t-1.55)/.6))
txt(panel,476,318,sentence[:count],12,'#58655F')
if t>1.95:
txt(panel,476,338,'Excited to share this with the world!',12,'#58655F')
line(panel,(472,375,866,375),'#EBEEF3')
txt(panel,476,399,'A draft from your agent. A final say from you.',10,MUTED)
box(panel,(713,419,864,447),BLUE,13)
txt(panel,788,428,'Review & send',11,WHITE,True,'mt')
paste(im,panel,480+28*(1-a),270+50*(1-a),opacity=a)
if t>2.4:
p=ease((t-2.4)/.55)
cursor(im,mix(913,811,p),mix(485,430,p),2.98<t<3.16)
# Explicit review before delivery, matching Mailflare's confirmation flow.
r=ease((t-3.08)/.24)
if r>0 and t<4.18:
panel=layer()
box(panel,(498,280,870,454),CREAM,15,outline='#C9D8EC')
txt(panel,522,300,'Looks good?',23,INK,True)
txt(panel,522,338,'To Maya · Re: A little launch magic',12,MUTED)
box(panel,(677,396,846,433),BLUE,17)
txt(panel,760,408,'Confirm and send',12,WHITE,True,'mt')
paste(im,panel,480,270+16*(1-r),opacity=r)
cursor(im,815,417,3.88<t<4.1)
if t>=4.18:
z=pop((t-4.18)/.35)
toast=layer()
box(toast,(595,371,856,440),LIME,20)
circle(toast,626,405,14,INK)
check(toast,626,405,10,LIME)
txt(toast,654,389,'Sent. Nice teamwork.',15,INK,True)
txt(toast,654,413,'One inbox. Better together.',10,INK)
paste(im,toast,480,270+18*(1-z),opacity=min(1,z))
return im
def ownership(t):
im=new(LIME)
brand(im)
footer(im,4)
# Big concentric shapes stay outside the reading area.
for i in range(4):
circle(im,905,293,125+i*44+12*math.sin(t*1.4),None,'#B9D969',1)
reveal(im,55,128,'Your domain.',76,t)
reveal(im,55,222,'Your rules.',76,t,.15)
p=pop((t-.35)/.6)
tag=Image.new('RGBA',(1190,174))
box(tag,(0,0,586,79),INK,39)
envelope(tag,42,39,31)
txt(tag,79,23,'hello@your.studio',31,CREAM,True)
circle(tag,547,39,16,LIME)
check(tag,547,39,12)
paste(im,tag,352,390+30*(1-p),max(.01,p),rotation=-2+1.5*ease(t/.8))
p=ease((t-.7)/.5)
txt(im,62,458+12*(1-p),'Custom domains. Shared inboxes. You’re at home.',16,INK)
spark(im,799,154,48*pop(t/.6),BLUE,t*.6)
spark(im,855,362,21,BLUE,-t)
return im
def closing(t):
im=new(BLUE)
# Deliberately hold the completed end card for a comfortable reading beat.
for i in range(3):
circle(im,482,239,225+i*70+min(t,1)*8,None,'#3869EF')
p=pop(t/.6)
mark=Image.new('RGBA',(256,256))
box(mark,(0,0,128,128),CREAM,32)
envelope(mark,64,72,87,opened=.5)
paste(im,mark,480,134,max(.01,p),rotation=-9*(1-ease(t/.7)))
# Centered title is masked upward with the same timing as the opening.
title='mailflare'
tw=ImageDraw.Draw(im).textlength(title,font=font(80,True))/S
reveal(im,(960-tw)/2,227,title,80,t,.15,CREAM)
q=ease((t-.3)/.65)
txt(im,480,343+(1-q)*24,'Email for humans. And their agents.',24,CREAM,False,'mt')
if t>.55:
b=pop((t-.55)/.5)
badge=Image.new('RGBA',(700,108))
box(badge,(0,0,346,50),LIME,25)
txt(badge,173,16,'Make room for better email. ↗',17,INK,True,'mt')
paste(im,badge,480,427,max(.01,b))
spark(im,202,252,19,CREAM,t*.25)
spark(im,761,189,27,LIME,-t*.25)
txt(im,480,505,'YOUR DOMAIN. YOUR INBOX.',10,'#BDCEFF',True,'mt')
return im
def frame(t):
scenes=[opening,partners,inbox,ownership,closing]
starts=[0,2.5,5,10,12.5]
index=max(i for i,start in enumerate(starts) if t>=start)
local=t-starts[index]
im=scenes[index](local)
# Fast traveling flap wipes bridge scenes like a message being handed over.
if index and local<.24:
p=clamp(local/.24)
previous=scenes[index-1](starts[index]-starts[index-1]+local)
edge=round(W*p)
im.paste(previous.crop((edge,0,W,H)),(edge,0))
poly(im,[(960*p-55,0),(960*p+15,0),(960*p+75,270),(960*p+15,540),(960*p-55,540),(960*p+5,270)],BLUE if index!=4 else LIME)
return im
def soundtrack():
sr=44100
n=sr*15
# Pure Python synthesis keeps this source portable without music assets.
left=[0.0]*n
right=[0.0]*n
rng=random.Random(29)
chords=[(146.832,184.997,220.0),(123.471,146.832,184.997),(97.999,123.471,146.832),(110.0,138.591,164.814)]
for beat in range(30):
start=int(beat*.5*sr)
notes=chords[(beat//8)%4]
# Warm, short kick and crisp offbeat snare.
for j in range(int(.22*sr)):
k=start+j
if k>=n:
break
s=j/sr
kick=.32*math.sin(2*math.pi*(49*s+6*(1-math.exp(-s*36))))*math.exp(-s*23)
snare=(rng.random()*2-1)*math.exp(-s*40)*.13 if beat%2 else 0
v=kick+snare
left[k]+=v
right[k]+=v
for sub in range(2):
start2=start+int(sub*.25*sr)
for j in range(int(.055*sr)):
k=start2+j
if k>=n:
break
s=j/sr
v=(rng.random()*2-1)*math.exp(-s*85)*.035
left[k]+=v*.7
right[k]+=v
# Eighth-note bell motif; chord changes give the short film a finish.
for sub in range(2):
onset=start+int(sub*.25*sr)
freq=notes[(beat+sub)%3]*(4 if (beat+sub)%4==0 else 2)
for j in range(int(.36*sr)):
k=onset+j
if k>=n:
break
s=j/sr
env=min(1,s/.004)*math.exp(-s*13)
v=.065*env*(math.sin(2*math.pi*freq*s)+.25*math.sin(2*math.pi*freq*2*s))
left[k]+=v*(.8 if sub else 1)
right[k]+=v*(1 if sub else .8)
echo=k+int(.125*sr)
if echo<n:
right[echo]+=v*.22
freq=notes[0]/2
for j in range(int(.43*sr)):
k=start+j
if k>=n:
break
s=j/sr
v=.115*min(1,s/.008)*math.exp(-s*7)*math.sin(2*math.pi*freq*s)
left[k]+=v
right[k]+=v
for onset in [2.43,4.93,9.93,12.43]:
start=int(onset*sr)
for j in range(int(.25*sr)):
k=start+j
if k>=n:
break
u=j/(.25*sr)
v=(rng.random()*2-1)*math.sin(math.pi*u)**3*.10
left[k]+=v*(1-u)
right[k]+=v*u
for onset,freq in [(9.2,880),(9.32,1174.66),(12.7,587.33)]:
for j in range(int(.4*sr)):
k=int(onset*sr)+j
if k<n:
s=j/sr
v=.10*math.sin(2*math.pi*freq*s)*math.exp(-s*12)*min(1,s/.003)
left[k]+=v
right[k]+=v
audio=bytearray()
for i,(l,r) in enumerate(zip(left,right)):
fade=min(1,i/(sr*.02))*smooth((n-i)/(sr*.48))
audio.extend(struct.pack('<hh',round(math.tanh(l*1.7)*26000*fade),round(math.tanh(r*1.7)*26000*fade)))
path=ROOT/'soundtrack.wav'
with wave.open(str(path),'wb') as out:
out.setnchannels(2)
out.setsampwidth(2)
out.setframerate(sr)
out.writeframes(audio)
return path
def render():
audio=soundtrack()
output=ROOT/'mailflare-human-and-agent.mp4'
command=['ffmpeg','-y','-hide_banner','-loglevel','error','-f','rawvideo','-vcodec','rawvideo','-pix_fmt','rgb24','-s',f'{W}x{H}','-r',str(FPS),'-i','-','-i',str(audio),'-c:v','libx264','-preset','fast','-crf','18','-pix_fmt','yuv420p','-c:a','aac','-b:a','256k','-ar','44100','-t','15','-movflags','+faststart',str(output)]
proc=subprocess.Popen(command,stdin=subprocess.PIPE)
for i in range(15*FPS):
im=frame(i/FPS)
proc.stdin.write(im.tobytes())
if i in [90,240,450,660,840]:
im.save(ROOT/f'scene-{i//FPS:02}.png')
if i%(FPS*3)==0:
print(f'Rendered {i//FPS}/15 seconds',flush=True)
proc.stdin.close()
if proc.wait():
raise RuntimeError('FFmpeg did not complete the video export.')
frame(14).save(ROOT/'poster.png')
print(output,flush=True)
+48
View File
@@ -0,0 +1,48 @@
# Mailflare — Make it yours
15 seconds · 1920 × 1080 · 60 fps · stereo
## Creative direction
A friendly pearl-and-cobalt robot introduces a world of professional email. Large kinetic typography leads into a layered inbox, expands into a connected team, and resolves into the Mailflare mark. The actual Mailflare envelope mark is redrawn as vector geometry for crisp motion.
The character waves, points, blinks, floats, and speaks with mouth animation driven by the narration amplitude. Glass highlights, soft contact shadows, parallax, spring entrances, shutter sampling, and light sweeps supply depth. The original electronic score includes synchronized message sounds, transition sweeps, and a final resolving chord.
## Timeline and narration
| Time | Picture | Voice |
| --- | --- | --- |
| 0–3s | Presenter enters; generic email transforms into `hello@mailflare.co` | “Your business deserves a better address.” |
| 3–7s | Inbox assembles, messages arrive, reply is sent | “Mailflare gives you professional email on your own domain.” |
| 7–11s | Domain connects hello, support, hieu, and team; management controls appear | “Mailboxes, aliases, your whole team. One place.” |
| 11–15s | Kinetic slogan resolves into the Mailflare product hero | “Your domain. Your email. Mailflare.” |
Supporting text covers individuals, developers, startups, teams, permissions, and simple setup. UI is an illustrative product visualization.
## Files
- `mailflare-launch.mp4`: finished film, H.264 / AAC
- `poster.png`: final hero still
- `scene-*.png`: scene stills
- `soundtrack.wav`: full voice and original music mix
- `narration.wav`: clean ElevenLabs voiceover
- `score.wav`: original music and sound design
- `voice-*.mp3` and `voice-*.json`: narration takes and timing
- `utils.mjs`: editable animation, typography, character, and rendering functions
- `render.mjs`: render entry point
- `audio_utils.py`: audio construction functions
- `audio.py`: audio entry point
## Re-render
Requires Node.js, Python 3, FFmpeg, and `@napi-rs/canvas`. This production uses the isolated dependency directory `/tmp/mailflare-film-tools` and the project's existing Geist font. Recreate the tool directory if needed:
```sh
mkdir -p /tmp/mailflare-film-tools
npm install --prefix /tmp/mailflare-film-tools @napi-rs/canvas
python3 media/launch-film/audio.py
node media/launch-film/render.mjs
ffmpeg -y -i media/launch-film/picture.mp4 -i media/launch-film/soundtrack.wav -map 0:v -map 1:a -c:v copy -c:a aac -b:a 320k -t 15 -movflags +faststart media/launch-film/mailflare-launch.mp4
```
The ElevenLabs API key is not stored in these files. The voice is Brian, generated with Eleven Multilingual v2. New voice takes require a key with text-to-speech permission.
+3
View File
@@ -0,0 +1,3 @@
from audio_utils import build_audio
build_audio()
+101
View File
@@ -0,0 +1,101 @@
import array
import json
import math
import random
import subprocess
import wave
from pathlib import Path
ROOT = Path(__file__).resolve().parent
RATE = 48000
SECONDS = 15
STARTS = [0.34, 3.38, 7.35, 11.82]
def narration():
command = ['ffmpeg', '-y', '-loglevel', 'error']
for i in range(4):
command += ['-i', str(ROOT / f'voice-{i+1}.mp3')]
filters = []
for i, start in enumerate(STARTS):
filters.append(f'[{i}:a]adelay={round(start*1000)}:all=1[v{i}]')
filters.append('[v0][v1][v2][v3]amix=inputs=4:normalize=0,apad,atrim=duration=15,loudnorm=I=-16:TP=-2:LRA=8,aresample=48000[a]')
command += ['-filter_complex', ';'.join(filters), '-map', '[a]', '-ac', '1', str(ROOT / 'narration.wav')]
subprocess.run(command, check=True)
with wave.open(str(ROOT / 'narration.wav')) as source:
pcm = array.array('h', source.readframes(source.getnframes()))
levels = []
for n in range(900):
block = pcm[n*800:(n+1)*800]
energy = math.sqrt(sum(x*x for x in block)/max(1,len(block)))/32768
levels.append(min(1, energy*9))
(ROOT / 'speech-envelope.json').write_text(json.dumps(levels))
def add_note(track, start, duration, frequency, gain, pan=0, kind='bell'):
first = round(start*RATE)
count = min(round(duration*RATE), len(track)//2-first)
for i in range(max(0,count)):
t=i/RATE
if kind=='bell':
env=min(1,t/.008)*math.exp(-t*5)*min(1,(duration-t)/.1)
val=(math.sin(2*math.pi*frequency*t)+.22*math.sin(2*math.pi*frequency*2.002*t))*env
elif kind=='bass':
env=min(1,t/.018)*min(1,(duration-t)/.12)
val=(math.sin(2*math.pi*frequency*t)+.15*math.sin(2*math.pi*frequency*2*t))*env
elif kind=='kick':
val=math.sin(2*math.pi*(45*t+6*(1-math.exp(-t*30))))*math.exp(-t*14)
elif kind=='pad':
env=min(1,t/.3)*min(1,(duration-t)/.5)
val=(math.sin(2*math.pi*frequency*t)+.3*math.sin(2*math.pi*frequency*1.003*t))*.6*env
else:
env=math.sin(math.pi*t/duration)**2
val=(random.random()*2-1)*env*.55
at=(first+i)*2
track[at] += val*gain*(1-pan*.35)
track[at+1] += val*gain*(1+pan*.35)
def score():
random.seed(48)
track=array.array('f',[0.0])*(RATE*SECONDS*2)
# Original 120 BPM score: soft pulse, glass arpeggios, and a resolving major ninth.
chords = [[146.832,220,293.665,369.994], [123.471,185,246.942,311.127], [97.999,146.832,195.998,246.942], [110,164.814,220,277.183]]
for section,notes in enumerate(chords):
start=[0,3,7,11][section]
duration=[3.3,4.3,4.2,4][section]
for j,note in enumerate(notes):
add_note(track,start,duration,note,.017,(j-1.5)/2,'pad')
for beat in range(math.ceil(duration*2)):
at=start+beat*.5
if at<14.4:
add_note(track,at,.36,notes[0]/2,.045,0,'bass')
add_note(track,at,.22,50,.038,0,'kick')
for step in range(math.ceil(duration*4)):
at=start+.125+step*.25
if at<14.2:add_note(track,at,.7,notes[[0,2,1,3,2,1,3,2][step%8]]*4,.017,math.sin(step)*.9)
for at in [2.75,6.75,10.75]:
add_note(track,at,.49,0,.072,-.5,'noise')
add_note(track,at+.24,.45,73.416,.063,0,'bass')
for i,at in enumerate([1.48,3.7,4.1,4.55,5.2,7.55,7.73,7.91,8.1,8.6,12.6]):
add_note(track,at,.25,[1174.66,1479.98,1760,2217.46][i%4],.030,(-1)**i*.55)
for j,freq in enumerate([293.665,440,587.33,739.99,880]):
add_note(track,12.55+j*.055,2.4,freq,.023,(j-2)/3)
pcm=array.array('h')
for i,value in enumerate(track):
t=i/(RATE*2)
fade=min(1,t/.15)*min(1,max(0,(15-t)/.8))
pcm.append(round(max(-1,min(1,value*fade))*32767))
with wave.open(str(ROOT/'score.wav'),'wb') as out:
out.setnchannels(2);out.setsampwidth(2);out.setframerate(RATE);out.writeframes(pcm.tobytes())
def mix():
subprocess.run(['ffmpeg','-y','-loglevel','error','-i',str(ROOT/'narration.wav'),'-i',str(ROOT/'score.wav'),'-filter_complex','[0:a]asplit=2[voice][control];[1:a][control]sidechaincompress=threshold=0.03:ratio=3:attack=15:release=180[bed];[voice][bed]amix=inputs=2:normalize=0,alimiter=limit=0.92,afade=t=out:st=14.65:d=0.35[a]','-map','[a]','-ar','48000','-ac','2',str(ROOT/'soundtrack.wav')],check=True)
def build_audio():
narration()
score()
mix()
print('Narration, speech animation envelope, and original score created.',flush=True)
+17
View File
@@ -0,0 +1,17 @@
1
00:00:00,340 --> 00:00:02,616
Your business deserves a better address.
2
00:00:03,380 --> 00:00:06,259
Mailflare gives you professional email
on your own domain.
3
00:00:07,350 --> 00:00:10,369
Mailboxes, aliases, your whole team.
One place.
4
00:00:11,820 --> 00:00:13,910
Your domain. Your email. Mailflare.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

+3
View File
@@ -0,0 +1,3 @@
import { render } from './utils.mjs';
await render();
Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0009754233869245476, 0.0012030609325900806, 0.0020193215907891777, 0.19211330921689335, 0.8380191153428334, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0.9959270192160958, 0.7624755218009072, 0.6582812572720708, 0.8358564132069453, 1, 1, 1, 1, 1, 1, 1, 1, 0.8884917890252773, 0.546390620561582, 0.6319392902289285, 1, 1, 1, 1, 1, 1, 1, 0.9107320461861799, 0.3776612382690502, 0.038898401958951556, 0.029892917985987436, 0.3730210052565194, 0.563533809096117, 1, 1, 1, 1, 1, 1, 1, 0.9894469966793475, 1, 1, 1, 1, 1, 1, 1, 1, 0.7147717910500613, 0.7639314060590138, 0.7306155898548958, 1, 0.9442269262139438, 0.8350058167091337, 1, 1, 1, 1, 0.8728672180923891, 0.6491559206032642, 0.545600215023877, 0.5558806514697515, 1, 1, 1, 1, 1, 0.6358138322453096, 0.9314743141076045, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0.960192089418514, 0.67386139379399, 0.5412875916583522, 0.6586219825401325, 0.5106364228816225, 1, 1, 1, 1, 1, 1, 1, 0.754077792751721, 0.6319880526315843, 0.5997646224787345, 0.6617479199616992, 0.8119721776508416, 0.3028458240888362, 0.5651760185449868, 0.935915196297469, 0.9405880444716324, 0.7859477414332247, 0.6402626392905124, 0.6301560648722764, 0.5138704219932989, 0.4103966734965755, 0.30432006434596987, 0.11614839802427847, 0.03601429178233075, 0.009705660778910474, 0.0031243200605497057, 0.0011857713859116158, 0.0010198477385366816, 0.001057211121672428, 0.0010258860753502017, 0.001054844844525413, 0.0009674637835122644, 0.0009810624158578772, 0.0008921108149426602, 0.0009206172598806721, 0.0009002129489083952, 0.0008945913242590768, 0.0009637039736275448, 0.0009876244590288502, 0.0008518279639582123, 0.0009067361273917159, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0035126464862627552, 0.38219604218051256, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0.9524627656402184, 0.993441478956389, 0.8023639084068935, 0.7787311729601645, 0.7115121394308725, 0.39169755807726414, 0.31935850959914747, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0.8733881726040662, 0.9547009952796546, 0.8440039522801006, 0.7990873457288121, 1, 1, 1, 1, 1, 0.9532343279862181, 0.9361220670197917, 0.8469417811389457, 0.7844103577603256, 1, 1, 1, 1, 0.8768015171817091, 0.44990394309102, 0.2259106000139392, 0.1805687806332842, 0.39326803225360296, 0.7146160029323562, 1, 1, 0.8858412200156448, 0.6709623570320776, 0.3911692519938163, 0.3622869352386557, 0.6571805056047216, 0.524370460625631, 1, 1, 1, 1, 1, 0.9732126723431095, 0.8838524509822984, 0.8671075324552429, 0.7926494020356957, 0.6668497022507961, 0.5307965129992981, 0.28187203489633833, 0.8777324225776594, 1, 1, 1, 1, 1, 1, 0.6893234612702103, 0.6492899336273944, 0.8131740988235412, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0.8209089962034674, 0.5392023187274295, 0.5146328916952385, 0.893633822587456, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0.926168868777922, 0.5699914990681298, 1, 1, 1, 1, 0.6189474042395622, 1, 1, 0.9049832391461078, 0.9994577380553104, 1, 1, 0.967976301855582, 0.9205034948049079, 0.8875160273356977, 0.6935914809236292, 0.7036654606469612, 0.6451985663624009, 0.5714996059960318, 0.5585976298810605, 0.38294375853813756, 0.19561181715168202, 0.12710302977002563, 0.06410016871031936, 0.02389783450799559, 0.004934167922535601, 0.002389881502266698, 0.001422234968453792, 0.0010603727650907206, 0.0009608131246789932, 0.0009837020719690919, 0.0009893892253644075, 0.0009658542285745979, 0.0010388563039374155, 0.0010876776950866404, 0.0010134943519996787, 0.0010106526210740166, 0.0010494675205742505, 0.0009141931348764438, 0.000680021762867795, 0.0007765471951513514, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0009139352306382661, 0.0007023279321414775, 0.02037455297388163, 0.9270235812969712, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0.7011879581485336, 0.8220344066170664, 0.8174019798600235, 1, 1, 1, 1, 1, 1, 1, 1, 0.7567217045182911, 0.5156029580292595, 0.4335423773379302, 0.6869649262394395, 0.8905540508103799, 0.7648821575297838, 0.5959861671275675, 0.3848990273826381, 0.5930012764668922, 0.47680628157966914, 0.4012983961131799, 0.5292952301313911, 0.736744235345441, 0.7110534870722466, 0.2545472122144649, 0.43536488945738205, 0.1638741450934137, 0.5110665068667963, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0.8611954320844543, 0.9928503980295408, 1, 0.9743920018364891, 0.8217610952548264, 0.6455966777344361, 0.4097784359928149, 0.31814991375513185, 0.5883592882724258, 0.704959283415101, 0.64662666Line truncated
+208
View File
@@ -0,0 +1,208 @@
import { createRequire } from 'node:module';
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
const require = createRequire('/tmp/mailflare-film-tools/package.json');
const { createCanvas, GlobalFonts } = require('@napi-rs/canvas');
const ROOT = dirname(fileURLToPath(import.meta.url));
const W = 1920, H = 1080, FPS = 60;
const C = { ink: '#111D3C', blue: '#185BFF', cyan: '#69DEFF', pale: '#F1F5FF', muted: '#7787A5', white: '#FFFFFF', navy: '#07122F', green: '#64EDB4' };
GlobalFonts.registerFromPath(join(ROOT, '../../node_modules/next/dist/next-devtools/server/font/geist-latin.woff2'), 'Geist');
GlobalFonts.registerFromPath('/System/Library/Fonts/Menlo.ttc', 'Mono');
const canvas = createCanvas(W, H), ctx = canvas.getContext('2d');
const voice = JSON.parse(readFileSync(join(ROOT, 'speech-envelope.json')));
export function clamp(v) { return Math.max(0, Math.min(1, v)); }
export function lerp(a, b, p) { return a + (b - a) * p; }
export function ease(v) { return 1 - (1 - clamp(v)) ** 4; }
export function smooth(v) { v = clamp(v); return v * v * (3 - 2 * v); }
export function spring(v) { v = clamp(v); return 1 - Math.exp(-7.5 * v) * Math.cos(9.5 * v); }
export function rr(x,y,w,h,r,fill,stroke=null,lw=1) {
ctx.beginPath(); ctx.roundRect(x,y,w,h,r); if(fill){ctx.fillStyle=fill;ctx.fill();} if(stroke){ctx.strokeStyle=stroke;ctx.lineWidth=lw;ctx.stroke();}
}
export function line(points,color,width=2) {
ctx.beginPath(); points.forEach(([x,y],i)=>i?ctx.lineTo(x,y):ctx.moveTo(x,y));ctx.strokeStyle=color;ctx.lineWidth=width;ctx.lineCap='round';ctx.lineJoin='round';ctx.stroke();
}
export function ellipse(x,y,rx,ry,fill) {ctx.beginPath();ctx.ellipse(x,y,Math.max(.01,rx),Math.max(.01,ry),0,0,Math.PI*2);ctx.fillStyle=fill;ctx.fill();}
export function text(s,x,y,size=30,color=C.ink,weight=500,align='left') {
ctx.font=`${Math.round(weight/100)*100} ${size}px Geist`;ctx.textAlign=align;ctx.textBaseline='middle';ctx.fillStyle=color;ctx.fillText(s,x,y);
}
export function mono(s,x,y,size=22,color=C.muted,align='left') {ctx.font=`${size}px Mono`;ctx.textAlign=align;ctx.textBaseline='middle';ctx.fillStyle=color;ctx.fillText(s,x,y);}
export function gradient(x,y,x2,y2,colors) {const g=ctx.createLinearGradient(x,y,x2,y2);colors.forEach((c,i)=>g.addColorStop(i/(colors.length-1),c));return g;}
export function halo(x,y,r,color) {const g=ctx.createRadialGradient(x,y,0,x,y,r);g.addColorStop(0,color);g.addColorStop(1,`${color.slice(0,7)}00`);ctx.fillStyle=g;ctx.fillRect(x-r,y-r,r*2,r*2);}
export function shadow(alpha=.18,blur=50,dy=25) {ctx.shadowColor=`rgba(12,35,92,${alpha})`;ctx.shadowBlur=blur;ctx.shadowOffsetY=dy;}
export function clearShadow() {ctx.shadowColor='transparent';ctx.shadowBlur=0;ctx.shadowOffsetY=0;}
export function check(x,y,s,color=C.blue) {line([[x-s*.4,y],[x-s*.08,y+s*.3],[x+s*.5,y-s*.4]],color,s*.14);}
export function arrow(x,y,s,color=C.blue) {line([[x-s*.6,y],[x+s*.4,y]],color,3);line([[x,y-s*.4],[x+s*.4,y],[x,y+s*.4]],color,3);}
export function envelope(x,y,s,color=C.blue,open=0) {
ctx.save();ctx.translate(x,y);ctx.scale(s/100,s/100);
ctx.fillStyle=color;ctx.beginPath();ctx.moveTo(-47,-21);ctx.lineTo(0,-53-open*12);ctx.lineTo(47,-21);ctx.lineTo(47,39);ctx.lineTo(-47,39);ctx.closePath();ctx.fill();
rr(-35,-25,70,56,4,'#FFF');
ctx.fillStyle='#42C5EE';ctx.beginPath();ctx.moveTo(-47,-20);ctx.lineTo(47,39);ctx.lineTo(-47,39);ctx.fill();
ctx.fillStyle='#139FD8';ctx.beginPath();ctx.moveTo(47,-20);ctx.lineTo(-47,39);ctx.lineTo(47,39);ctx.fill();ctx.restore();
}
export function spark(x,y,s,color=C.blue,angle=0) {ctx.save();ctx.translate(x,y);ctx.rotate(angle);ctx.fillStyle=color;ctx.beginPath();for(let i=0;i<8;i++){let a=i*Math.PI/4,r=i%2?s*.23:s; i?ctx.lineTo(Math.cos(a)*r,Math.sin(a)*r):ctx.moveTo(Math.cos(a)*r,Math.sin(a)*r);}ctx.closePath();ctx.fill();ctx.restore();}
export function pill(s,x,y,w,dark=false,icon=false) {rr(x,y,w,48,24,dark?'#14264A':'#FFFFFFCC',dark?'#38527C':'#D9E3F7');if(icon){ellipse(x+24,y+24,4,4,C.green);}text(s,x+(icon?40:w/2),y+24,19,dark?'#BED4F9':C.ink,550,icon?'left':'center');}
export function reveal(s,x,y,size,t,delay=0,color=C.ink,weight=650,align='left') {
let p=ease((t-delay)/.62);if(p<=0)return;ctx.save();ctx.beginPath();ctx.rect(0,y-size*.7,W,size*1.45);ctx.clip();text(s,x,y+(1-p)*size*1.5,size,color,weight,align);ctx.restore();
}
export function chrome(t,n,dark=false) {
const col=dark?'#F4F8FF':C.ink;ctx.save();ctx.globalAlpha=ease(t/.4);envelope(102,79,41);text('Mailflare',137,78,28,col,670);mono('MAKE IT YOURS',1817,79,16,dark?'#A2B9E8':C.muted,'right');
text('PROFESSIONAL EMAIL. PERSONAL BY DESIGN.',83,1028,14,dark?'#7188B3':'#8A99B7',600);mono(`0${n} / 04`,1837,1028,16,dark?'#A2B9E8':C.muted,'right');ctx.restore();
}
export function background(t,dark=false,blue=false) {
ctx.fillStyle=blue?'#1454F3':dark?C.navy:'#F4F7FE';ctx.fillRect(0,0,W,H);
if(blue){halo(300+80*Math.sin(t),150,1100,'#468CFF');halo(1670,1000,900,'#092681');}
else if(dark){halo(1000,650,850,'#123C8B');halo(1750,100,550,'#153151');}
else {halo(1550+60*Math.sin(t*.6),380,780,'#C6D8FF');halo(200,1000,650,'#DAEFFF');}
ctx.save();ctx.globalAlpha=dark?.1:.055;ctx.strokeStyle=dark?'#A2C3FF':'#3D73CA';ctx.lineWidth=1;
for(let x=-100;x<W+100;x+=80){ctx.beginPath();ctx.moveTo(x+Math.sin(t*.2)*25,0);ctx.lineTo(x+Math.sin(t*.2)*25,H);ctx.stroke();}
for(let y=0;y<H;y+=80){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}ctx.restore();
for(let i=0;i<28;i++){const x=(i*379.41+Math.sin(t*.35+i)*40)%W,y=(i*163.59-t*(5+i%6)+H*2)%H;ellipse(x,y,i%3===0?2:1,i%3===0?2:1,dark?'#40619B55':'#6186BA44');}
}
export function limb(x,y,length,angle,hand=false) {
ctx.save();ctx.translate(x,y);ctx.rotate(angle);shadow(.16,16,8);rr(-length/2,-21,length,42,21,gradient(0,-21,0,21,['#FFFFFF','#CAD9F1','#91AAD5']));clearShadow();
if(hand){ellipse(length/2+4,0,31,33,gradient(length/2-20,-25,length/2+27,26,['#FFF','#E6EEFE','#9DBBE8']));line([[length/2+17,-14],[length/2+22,-6]],'#AEC3E6',3);}
ctx.restore();
}
export function robot(x,y,s,t,gesture=0,dark=false) {
const bob=Math.sin(t*3.1)*9, talk=voice[Math.min(voice.length-1,Math.floor(t*60))]||0;
ctx.save();ctx.translate(x,y);ctx.scale(s,s);
ellipse(0,267,167,24,dark?'#01092344':'#173E7C15');halo(0,270,210,dark?'#2881FF18':'#658EFF19');
ctx.translate(0,bob);ctx.rotate(Math.sin(t*2)*.025);
const wave=Math.sin(t*8)*.18;
limb(-127,80,117,1.2+Math.sin(t*3)*.1,true);
const reach=gesture===1?-.95+wave:gesture===2?-.4: .9+Math.sin(t*3+1)*.13;
limb(139,gesture===1?3:77,126,reach,true);
shadow(.22,40,20);rr(-101,33,202,161,66,gradient(-110,30,90,185,['#7DB8FF','#246AF9','#113CB7']));clearShadow();
rr(-84,45,140,20,10,'#BCD9FF44');
ellipse(-54,195,41,31,gradient(-80,180,-36,223,['#EFF5FF','#B5CAED']));ellipse(55,195,41,31,gradient(30,180,86,223,['#EFF5FF','#B5CAED']));
rr(-26,72,52,50,14,'#EDF5FF');envelope(0,97,33);
rr(-43,-3,86,46,20,'#B4CDF2');
shadow(.28,47,17);rr(-154,-207,308,236,77,gradient(-130,-205,164,64,['#FFFFFF','#EFF5FF','#BCD1EF']));clearShadow();
rr(-139,-195,266,54,35,gradient(0,-195,0,-141,['#FFFFFF','#F8FAFF00']));
ctx.strokeStyle='#FFFFFF';ctx.lineWidth=3;ctx.beginPath();ctx.roundRect(-151,-204,302,229,74);ctx.stroke();
rr(-125,-160,250,141,44,gradient(-100,-160,95,-10,['#25365A','#09162F','#06102C']));
rr(-105,-151,166,10,5,'#FFFFFF16');
let blink=1-Math.pow(Math.max(0,Math.cos(t*2.1)),28)*.94;
const pupil=gesture===2?9:Math.sin(t*1.4)*4;
for(const ex of [-51,51]){ctx.save();ctx.shadowBlur=20;ctx.shadowColor='#61DEFF';rr(ex-13+pupil,-113,25,Math.max(4,37*blink),12,'#8EEBFF');ctx.restore();ellipse(ex-6+pupil,-105,3,3,'#FFF');}
if(talk>.09){ellipse(0,-54,14+talk*5,4+talk*18,'#83DAFF');ellipse(0,-48+talk*2,8,3,'#E6FCFF');}
else {ctx.beginPath();ctx.ellipse(0,-61,19,13,0,.2,Math.PI-.2);ctx.strokeStyle='#82DCFF';ctx.lineWidth=5;ctx.stroke();}
ellipse(-95,-66,13,5,'#458BBD33');ellipse(95,-66,13,5,'#458BBD33');
rr(-13,-252,26,52,12,gradient(-13,0,13,0,['#BFD3EF','#FFF','#ABBFE2']));
ctx.save();ctx.shadowBlur=35;ctx.shadowColor='#53BFFF';ellipse(0,-255,19,19,gradient(-15,-271,18,-239,['#BAF6FF','#4ACDFF','#2588F3']));ctx.restore();ellipse(-6,-262,5,5,'#F2FFFF');
ellipse(-156,-90,12,34,'#BDCEF0');ellipse(156,-90,12,34,'#8AA9DE');
ctx.restore();
}
export function floatMail(x,y,s,t,angle=0,dark=false) {ctx.save();ctx.translate(x,y+Math.sin(t*3+x)*8);ctx.rotate(angle);shadow(.16,35,13);rr(-s*.7,-s*.55,s*1.4,s*1.1,s*.22,dark?'#183864':'#FFFFFF');clearShadow();envelope(0,2,s*.75);ctx.restore();}
export function sceneIntro(t) {
background(t);chrome(t,1);let p=spring(t/.8);
ctx.save();ctx.translate(1510,590);ctx.rotate(-.35+t*.045);ctx.strokeStyle='#C5D8F5';ctx.lineWidth=2;ctx.beginPath();ctx.ellipse(0,0,325,260,0,0,Math.PI*2);ctx.stroke();ctx.restore();
floatMail(1330,290,73,t,-.23);floatMail(1762,676,83,t,.2);spark(1730,332,24,C.blue,t*.35);
pill('YOUR NEXT CHAPTER',142,228,247);
reveal('You mean',136,357,121,t,.08);reveal('business.',136,489,121,t,.2,C.blue);
let a=ease((t-.7)/.55);ctx.save();ctx.globalAlpha=a;ctx.translate(0,(1-a)*50);shadow(.1,45,20);rr(140,604,1100,124,33,'#FFFFFF','#DDE6F8',1.5);clearShadow();envelope(202,669,52);
const swap=smooth((t-1.25)/.48);text('hello@',250,665,60,C.ink,580);ctx.save();ctx.beginPath();ctx.rect(442,619,630,100);ctx.clip();ctx.globalAlpha=1-swap;text('gmail.com',442,665-swap*75,60,'#8493AF',500);ctx.globalAlpha=swap;text('mailflare.co',442,742-swap*77,60,C.blue,620);ctx.restore();
ctx.globalAlpha=a*swap;ellipse(1172,666,27,27,'#E1F7EE');check(1172,666,26,'#1B9B6D');ctx.restore();
reveal('An address that’s unmistakably yours.',146,789,30,t,1.65,C.muted,450);
robot(1530,570+(1-p)*740,1.04*p,t,1);
const pulse=clamp((t-1.45)/.7);if(pulse>0&&pulse<1){ctx.save();ctx.globalAlpha=(1-pulse)*.6;ctx.strokeStyle=C.blue;ctx.lineWidth=2;ctx.beginPath();ctx.roundRect(140-pulse*25,604-pulse*25,1100+pulse*50,124+pulse*50,33+pulse*25);ctx.stroke();ctx.restore();}
mono('01 / OWN YOUR ADDRESS',147,887,18,'#6D86B2');
}
export function search(x,y,w) {rr(x,y,w,42,12,'#F2F5FA');ctx.strokeStyle='#8190A9';ctx.lineWidth=2;ctx.beginPath();ctx.arc(x+23,y+19,6,0,Math.PI*2);ctx.stroke();line([[x+28,y+24],[x+33,y+29]],'#8190A9',2);text('Search your mail',x+48,y+21,16,'#8D99AD',450);mono('⌘ K',x+w-19,y+22,12,'#8D99AD','right');}
export function navIcon(index,x,y,color) {
ctx.save();ctx.translate(x,y);
if(index===0){rr(-9,-7,18,14,3,null,color,1.7);line([[-9,-1],[-3,-1],[-1,3],[3,3],[5,-1],[9,-1]],color,1.7);}
else if(index===1){const points=[];for(let i=0;i<10;i++){const a=i*Math.PI/5-Math.PI/2,r=i%2?4:10;points.push([Math.cos(a)*r,Math.sin(a)*r]);}points.push(points[0]);line(points,color,1.7);}
else if(index===2){line([[-10,-7],[10,0],[-10,8],[-5,0],[-10,-7]],color,1.7);line([[-5,0],[10,0]],color,1.7);}
else if(index===3){rr(-7,-10,14,20,2,null,color,1.7);line([[-3,-4],[3,-4]],color,1.7);line([[-3,1],[3,1]],color,1.7);line([[-3,6],[1,6]],color,1.7);}
else {rr(-9,-5,18,14,2,null,color,1.7);rr(-10,-9,20,5,1,null,color,1.7);line([[-3,1],[3,1]],color,1.7);}
ctx.restore();
}
export function inbox(t) {
const w=1420,h=586;shadow(.17,65,30);rr(0,0,w,h,25,'#FFF','#D4DFF2',1.5);clearShadow();
ctx.save();ctx.beginPath();ctx.roundRect(0,0,w,h,25);ctx.clip();rr(0,0,230,h,0,'#F4F7FC');
envelope(37,39,31);text('Mailflare',64,39,23,C.ink,650);rr(20,89,188,49,14,'#175BFF');text('+ Compose',114,113,19,'#FFF',550,'center');
const labels=['Inbox','Starred','Sent','Drafts','Archive'];for(let i=0;i<5;i++){if(i===0)rr(16,164+i*48,197,42,10,'#E2EBFF');navIcon(i,43,185+i*48,i===0?C.blue:'#7C8BA3');text(labels[i],68,185+i*48,18,i===0?C.blue:'#64718B',i===0?650:450);if(i===0)text(t>.8?'4':'3',190,185,15,C.blue,650,'right');}
text('MAILBOXES',29,455,11,'#8A96AC',650);ellipse(39,492,5,5,C.blue);text('hello@mailflare.co',54,492,15,'#627493');ellipse(39,531,5,5,'#5DC0A1');text('support@mailflare.co',54,531,15,'#627493');
line([[230,0],[230,h]],'#DFE6F1',1);text('Inbox',258,39,27,C.ink,630);search(940,20,448);line([[230,76],[w,76]],'#E7ECF5',1);
const names=['Alex Morgan','Sam from Studio','Design team','Hieu Nguyen'];const subjects=['Let’s build something great.','Welcome to your new workspace','Brand assets, delivered.','Ready when you are.'];
const previews=['Everything you need to get started.','Your next big idea starts here.','The latest files are all here.','See you on the other side.'];
for(let i=0;i<4;i++){let p=ease((t-.2-i*.16)/.5);ctx.save();ctx.globalAlpha=p;ctx.translate((1-p)*160,0);const y=95+i*111;if(i===0)rr(244,y,519,102,14,'#EAF0FF');ellipse(275,y+26,5,5,i===0?C.blue:'#B6C4D9');text(names[i],295,y+24,17,C.ink,650);text(i===0?'Now':`${i*4}m`,740,y+24,13,'#8B98AC',450,'right');text(subjects[i],263,y+55,19,C.ink,520);text(previews[i],263,y+80,15,'#8794AA',420);ctx.restore();}
line([[780,76],[780,h]],'#E2E9F4',1);let r=ease((t-.65)/.7);ctx.save();ctx.globalAlpha=r;ctx.translate(0,(1-r)*35);
ellipse(839,126,23,23,'#CCE0FF');text('A',839,126,20,C.blue,650,'center');text('Alex Morgan',878,118,20,C.ink,650);text('alex@studio.co',878,144,14,'#8591A5',420);pill('TO YOU',1244,106,125);
text('Let’s build something great.',812,215,31,C.ink,600);text('Hey Hieu,',812,281,20,C.ink,500);text('Your next chapter starts with a hello.',812,327,20,'#758399',420);text('Looking forward to working together.',812,365,20,'#758399',420);
rr(811,405,445,62,14,'#F4F7FC','#E2EAF5');rr(825,417,37,37,9,'#E0EAFF');text('↓',843,435,22,C.blue,600,'center');text('Project brief.pdf',880,437,17,C.ink,540);text('2.4 MB',1234,437,13,'#8593AD',400,'right');
rr(810,496,113,44,12,C.blue);text('↩ Reply',866,518,17,'#FFF',500,'center');text('Forward ↗',950,518,17,'#8391A9',500);ctx.restore();ctx.restore();
}
export function sceneInbox(t,global) {
background(global);chrome(1,2);reveal('Meet your new inbox.',102,205,85,t,.02);pill('SEND. RECEIVE. DONE.',1438,177,367);
const p=spring(t/.85);ctx.save();ctx.translate(105+(1-p)*-190,309+(1-p)*420);ctx.translate(710,293);ctx.rotate((1-p)*-.09+.004*Math.sin(t));ctx.scale(.98+.02*p,.98+.02*p);ctx.translate(-710,-293);inbox(t);ctx.restore();
robot(1660,708+180*(1-ease((t-.25)/.7)),.59,global,2);
let arrive=ease((t-.8)/.55);ctx.save();ctx.globalAlpha=arrive*(1-smooth((t-2.9)/.5));ctx.translate(1270+(1-arrive)*480,282-(1-arrive)*120);ctx.rotate((1-arrive)*.25);shadow(.15,40,16);rr(0,0,393,77,19,'#FFF','#D5E2F5');clearShadow();ellipse(40,38,23,23,'#E7F8F0');check(40,38,22,'#1F9E71');text('You’ve got good company.',77,30,18,C.ink,600);text('New message · hello@mailflare.co',77,53,14,'#8797B3',420);ctx.restore();
const sent=ease((t-2.15)/.5);ctx.save();ctx.globalAlpha=sent;rr(1200,820+(1-sent)*70,268,63,18,'#155BFF');text('Message sent',1263,852+(1-sent)*70,21,'#FFF',550);check(1234,852+(1-sent)*70,24,'#B0EDFF');ctx.restore();
reveal('Your domain. Every conversation.',107,962,28,t,.85,'#6C7D9B',450);
pill('ONE CLEAN WORKSPACE',1466,926,344);
}
export function teamCard(x,y,address,role,initial,color,t,delay,alias=false) {
let p=spring((t-delay)/.65);if(p<=0)return;ctx.save();ctx.translate(x+(x<800?-120:120)*(1-p),y+40*(1-p));ctx.scale(.93+.07*p,.93+.07*p);ctx.globalAlpha=clamp(p);shadow(.25,45,22);rr(0,0,440,153,25,gradient(0,0,440,153,['#1D3359','#142544']),'#435B83',1.5);clearShadow();
ellipse(53,55,29,29,color);text(initial,53,55,23,'#FFF',650,'center');text(address,100,53,34,'#EDF6FF',580);text('mailflare.co',100,89,18,'#7E9FCF',450);rr(24,113,200,1,0,'#365075');text(role,27,134,15,'#97B5DD',500);ellipse(402,131,5,5,C.green);text(alias?'ALIAS':'ACTIVE',388,132,11,'#7CD3B0',600,'right');ctx.restore();
}
export function wire(x,y,x2,y2,t,delay) {
let p=ease((t-delay)/.75);ctx.save();ctx.globalAlpha=p;ctx.beginPath();ctx.moveTo(x,y);ctx.bezierCurveTo((x+x2)/2,y,(x+x2)/2,y2,x2,y2);ctx.strokeStyle='#3D67A8';ctx.lineWidth=2;ctx.stroke();
const q=(t*.55+delay)%1;let xx=(1-q)**3*x+3*(1-q)**2*q*(x+x2)/2+3*(1-q)*q*q*(x+x2)/2+q**3*x2,yy=(1-q)**3*y+3*(1-q)**2*q*y+3*(1-q)*q*q*y2+q**3*y2;
ctx.shadowColor='#71DDFF';ctx.shadowBlur=18;ellipse(xx,yy,5,5,'#91E6FF');ctx.restore();
}
export function sceneTeam(t,global) {
background(global,true);chrome(1,3,true);reveal('One domain. Your whole team.',960,213,80,t,.03,'#FFF',610,'center');
reveal('Built for individuals, developers, startups, and teams.',960,287,26,t,.18,'#8CA9D5',450,'center');
wire(590,434,745,480,t,.2);wire(590,665,745,490,t,.42);wire(1330,434,1175,480,t,.35);wire(1330,665,1175,490,t,.57);
const p=spring((t-.13)/.7);ctx.save();ctx.translate(960,482);ctx.scale(p,p);shadow(.45,75,20);rr(-215,-54,430,108,27,gradient(-215,-54,215,54,['#3277FF','#0E48D6']),'#669FFF',2);clearShadow();envelope(-157,0,52);text('mailflare.co',-108,1,39,'#FFF',580);check(175,0,23,'#99EDCC');ctx.restore();
teamCard(149,357,'hello@','Primary mailbox','H','#4085DF',t,.15);teamCard(149,588,'support@','Routes to your support team','S','#8D70DE',t,.43,true);
teamCard(1330,357,'hieu@','Owner · full access','H','#269889',t,.29);teamCard(1330,588,'team@','Shared mailbox','T','#CB8755',t,.57);
robot(960,743,.52,global,t>2?1:2,true);
const items=['Domains','Users','Aliases','Permissions'];for(let i=0;i<4;i++){let a=ease((t-.8-i*.14)/.5);ctx.save();ctx.globalAlpha=a;pill(items[i],545+i*211,911+(1-a)*35,194,true,true);ctx.restore();}
reveal('Simple setup. No mail-server maze.',960,995,21,t,1.85,'#B6D1F9',450,'center');
spark(1222,684,17,'#63C7FF',t*.35);spark(705,365,12,'#63C7FF',t*-.45);
}
export function sceneHero(t,global) {
background(global,false,true);chrome(1,4,true);
ctx.save();ctx.globalAlpha=.11;ctx.strokeStyle='#D7EEFF';ctx.lineWidth=1.5;for(let i=0;i<4;i++){ctx.beginPath();ctx.ellipse(950,540,450+i*185,270+i*115,-.18,0,Math.PI*2);ctx.stroke();}ctx.restore();
let phraseOut=1-smooth((t-1.2)/.45);ctx.save();ctx.globalAlpha=phraseOut;reveal('Your domain.',960,427,129,t,.04,'#FFF',630,'center');reveal('Your email.',960,570,129,t,.22,'#A8D8FF',630,'center');ctx.restore();
let p=spring((t-1.22)/.9);if(p>0){ctx.save();ctx.globalAlpha=clamp(p);ctx.translate(930,473+(1-p)*70);ctx.scale(.88+.12*p,.88+.12*p);shadow(.22,60,20);rr(-483,-83,163,163,42,'#FFF');clearShadow();envelope(-402,1,121);text('Mailflare',-273,4,152,'#FFF',660);ctx.restore();}
reveal('Professional email for individuals and teams.',960,652,37,t,1.52,'#D8E8FF',450,'center');
let url=ease((t-1.75)/.55);ctx.save();ctx.globalAlpha=url;shadow(.1,40,15);rr(788,750+(1-url)*40,344,81,40,'#FFFFFF');clearShadow();text('mailflare.co',947,791+(1-url)*40,30,C.blue,600,'center');arrow(1090,791+(1-url)*40,23);ctx.restore();
const rp=ease((t-1.58)/.75);robot(1655+350*(1-rp),750,.51,global,1,true);
for(let i=0;i<6;i++){let a=i*Math.PI/3+global*.16;let dist=340+80*i/6;let xx=960+Math.cos(a)*dist*1.55,yy=515+Math.sin(a)*dist;ctx.save();ctx.globalAlpha=.2*clamp(p);spark(xx,yy,8+i%3*3,'#DDF4FF',a);ctx.restore();}
reveal('YOUR BUSINESS. BEAUTIFULLY ADDRESSED.',960,926,18,t,2.1,'#A5CBFF',550,'center');
}
export function transition(t,at) {
const u=(t-at)/.52;if(u<0||u>1)return;
ctx.save();ctx.translate(lerp(-2500,2600,smooth(u)),0);ctx.transform(1,0,-.3,1,0,0);
const g=gradient(0,0,1100,0,['#2B79FF00','#8BE3FF','#FFFFFF','#195BFF','#175BFF00']);ctx.fillStyle=g;ctx.fillRect(0,-40,1100,H+80);ctx.restore();
}
export function frame(t) {
ctx.resetTransform();ctx.globalAlpha=1;ctx.filter='none';clearShadow();
if(t<3)sceneIntro(t);else if(t<7)sceneInbox(t-3,t);else if(t<11)sceneTeam(t-7,t);else sceneHero(t-11,t);
transition(t,2.75);transition(t,6.75);transition(t,10.75);
const g=ctx.createRadialGradient(960,520,450,960,520,1170);g.addColorStop(0,'#08142F00');g.addColorStop(1,'#08142F13');ctx.fillStyle=g;ctx.fillRect(0,0,W,H);
}
export async function render() {
const out=join(ROOT,'picture.mp4');
const encoder=spawn('ffmpeg',['-y','-loglevel','error','-f','rawvideo','-pixel_format','rgba','-video_size',`${W}x${H}`,'-framerate',`${FPS}`,'-i','pipe:0','-an','-c:v','libx264','-preset','fast','-crf','17','-pix_fmt','yuv420p','-movflags','+faststart',out],{stdio:['pipe','inherit','inherit']});
const sample=createCanvas(W,H),sc=sample.getContext('2d');
for(let n=0;n<15*FPS;n++){
const t=n/FPS;frame(t);
if([120,300,540,840].includes(n))writeFileSync(join(ROOT,`scene-${n}.png`),canvas.toBuffer('image/png'));
if(n===840)writeFileSync(join(ROOT,'poster.png'),canvas.toBuffer('image/png'));
// Two shutter samples keep fast entrances and whip transitions soft at 60 fps.
sc.globalAlpha=1;sc.drawImage(canvas,0,0);frame(t+1/FPS*.38);sc.globalAlpha=.42;sc.drawImage(canvas,0,0);
if(!encoder.stdin.write(Buffer.from(sc.getImageData(0,0,W,H).data)))await once(encoder.stdin,'drain');
if(n%120===0)process.stdout.write(`Rendered ${n}/${15*FPS} frames\n`);
}
encoder.stdin.end();const [code]=await once(encoder,'close');if(code!==0)throw new Error(`Encoder exited ${code}`);process.stdout.write('Picture render complete.\n');
}
+1
View File
@@ -0,0 +1 @@
{"text": "Your business deserves a better address.", "alignment": {"characters": [" ", "Y", "o", "u", "r", " ", "b", "u", "s", "i", "n", "e", "s", "s", " ", "d", "e", "s", "e", "r", "v", "e", "s", " ", "a", " ", "b", "e", "t", "t", "e", "r", " ", "a", "d", "d", "r", "e", "s", "s", ".", " "], "character_start_times_seconds": [0.0, 0.046, 0.104, 0.139, 0.186, 0.244, 0.313, 0.36, 0.43, 0.488, 0.522, 0.557, 0.592, 0.639, 0.685, 0.731, 0.766, 0.836, 0.894, 0.952, 0.998, 1.033, 1.08, 1.115, 1.149, 1.173, 1.231, 1.265, 1.3, 1.347, 1.393, 1.428, 1.463, 1.498, 1.533, 1.579, 1.625, 1.695, 1.765, 1.823, 1.927, 2.043], "character_end_times_seconds": [0.046, 0.104, 0.139, 0.186, 0.244, 0.313, 0.36, 0.43, 0.488, 0.522, 0.557, 0.592, 0.639, 0.685, 0.731, 0.766, 0.836, 0.894, 0.952, 0.998, 1.033, 1.08, 1.115, 1.149, 1.173, 1.231, 1.265, 1.3, 1.347, 1.393, 1.428, 1.463, 1.498, 1.533, 1.579, 1.625, 1.695, 1.765, 1.823, 1.927, 2.043, 2.276]}}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"text": "Mailflare gives you professional email on your own domain.", "alignment": {"characters": [" ", "M", "a", "i", "l", "f", "l", "a", "r", "e", " ", "g", "i", "v", "e", "s", " ", "y", "o", "u", " ", "p", "r", "o", "f", "e", "s", "s", "i", "o", "n", "a", "l", " ", "e", "m", "a", "i", "l", " ", "o", "n", " ", "y", "o", "u", "r", " ", "o", "w", "n", " ", "d", "o", "m", "a", "i", "n", ".", " "], "character_start_times_seconds": [0.0, 0.046, 0.104, 0.163, 0.209, 0.29, 0.36, 0.418, 0.488, 0.534, 0.557, 0.592, 0.627, 0.662, 0.685, 0.72, 0.743, 0.766, 0.789, 0.813, 0.836, 0.894, 0.929, 0.964, 1.01, 1.068, 1.126, 1.173, 1.207, 1.242, 1.265, 1.3, 1.335, 1.37, 1.44, 1.544, 1.614, 1.672, 1.718, 1.765, 1.834, 1.881, 1.916, 1.95, 1.974, 1.997, 2.02, 2.055, 2.09, 2.113, 2.148, 2.194, 2.229, 2.264, 2.334, 2.38, 2.426, 2.485, 2.566, 2.635], "character_end_times_seconds": [0.046, 0.104, 0.163, 0.209, 0.29, 0.36, 0.418, 0.488, 0.534, 0.557, 0.592, 0.627, 0.662, 0.685, 0.72, 0.743, 0.766, 0.789, 0.813, 0.836, 0.894, 0.929, 0.964, 1.01, 1.068, 1.126, 1.173, 1.207, 1.242, 1.265, 1.3, 1.335, 1.37, 1.44, 1.544, 1.614, 1.672, 1.718, 1.765, 1.834, 1.881, 1.916, 1.95, 1.974, 1.997, 2.02, 2.055, 2.09, 2.113, 2.148, 2.194, 2.229, 2.264, 2.334, 2.38, 2.426, 2.485, 2.566, 2.635, 2.879]}}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"text": "Mailboxes, aliases, your whole team. One place.", "alignment": {"characters": [" ", "M", "a", "i", "l", "b", "o", "x", "e", "s", ",", " ", "a", "l", "i", "a", "s", "e", "s", ",", " ", "y", "o", "u", "r", " ", "w", "h", "o", "l", "e", " ", "t", "e", "a", "m", ".", " ", "O", "n", "e", " ", "p", "l", "a", "c", "e", ".", " "], "character_start_times_seconds": [0.0, 0.058, 0.104, 0.151, 0.209, 0.279, 0.348, 0.453, 0.522, 0.627, 0.662, 0.697, 0.731, 0.836, 0.906, 0.975, 1.08, 1.126, 1.231, 1.265, 1.3, 1.335, 1.37, 1.405, 1.451, 1.498, 1.556, 1.591, 1.637, 1.695, 1.741, 1.776, 1.811, 1.858, 1.927, 1.985, 2.055, 2.125, 2.229, 2.299, 2.368, 2.403, 2.438, 2.485, 2.543, 2.612, 2.67, 2.74, 2.798], "character_end_times_seconds": [0.058, 0.104, 0.151, 0.209, 0.279, 0.348, 0.453, 0.522, 0.627, 0.662, 0.697, 0.731, 0.836, 0.906, 0.975, 1.08, 1.126, 1.231, 1.265, 1.3, 1.335, 1.37, 1.405, 1.451, 1.498, 1.556, 1.591, 1.637, 1.695, 1.741, 1.776, 1.811, 1.858, 1.927, 1.985, 2.055, 2.125, 2.229, 2.299, 2.368, 2.403, 2.438, 2.485, 2.543, 2.612, 2.67, 2.74, 2.798, 3.019]}}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"text": "Your domain. Your email. Mailflare.", "alignment": {"characters": [" ", "Y", "o", "u", "r", " ", "d", "o", "m", "a", "i", "n", ".", " ", "Y", "o", "u", "r", " ", "e", "m", "a", "i", "l", ".", " ", "M", "a", "i", "l", "f", "l", "a", "r", "e", ".", " "], "character_start_times_seconds": [0.0, 0.058, 0.093, 0.128, 0.163, 0.197, 0.244, 0.279, 0.337, 0.383, 0.43, 0.488, 0.522, 0.546, 0.58, 0.604, 0.627, 0.662, 0.697, 0.743, 0.813, 0.882, 0.929, 0.975, 1.045, 1.115, 1.254, 1.312, 1.358, 1.416, 1.486, 1.556, 1.625, 1.707, 1.741, 1.811, 1.869], "character_end_times_seconds": [0.058, 0.093, 0.128, 0.163, 0.197, 0.244, 0.279, 0.337, 0.383, 0.43, 0.488, 0.522, 0.546, 0.58, 0.604, 0.627, 0.662, 0.697, 0.743, 0.813, 0.882, 0.929, 0.975, 1.045, 1.115, 1.254, 1.312, 1.358, 1.416, 1.486, 1.556, 1.625, 1.707, 1.741, 1.811, 1.869, 2.09]}}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
from video_utils import render_video
render_video()
Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+669
View File
@@ -0,0 +1,669 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Mailflare showreel</title>
<style>
@font-face { font-family: Inter; src: url("file:///Users/mac/Library/Fonts/Inter-Regular.ttf"); font-weight: 400; }
@font-face { font-family: Inter; src: url("file:///Users/mac/Library/Fonts/Inter-Medium.ttf"); font-weight: 500; }
@font-face { font-family: Inter; src: url("file:///Users/mac/Library/Fonts/Inter-SemiBold.ttf"); font-weight: 600; }
@font-face { font-family: Inter; src: url("file:///Users/mac/Library/Fonts/Inter-Bold.ttf"); font-weight: 700; }
@font-face { font-family: Inter; src: url("file:///Users/mac/Library/Fonts/Inter-ExtraBold.ttf"); font-weight: 800; }
@font-face { font-family: Inter; src: url("file:///Users/mac/Library/Fonts/Inter-Black.ttf"); font-weight: 900; }
* { box-sizing: border-box; }
html, body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #05070c;
font-family: Inter, system-ui, sans-serif;
color: #12151c;
}
#world { position: relative; width: 1920px; height: 1080px; overflow: hidden; }
#bg { position: absolute; inset: 0; }
.orb { position: absolute; border-radius: 50%; filter: blur(30px); }
#grid {
position: absolute; inset: -10%;
background-image:
linear-gradient(rgba(140,170,220,0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(140,170,220,0.05) 1px, transparent 1px);
background-size: 88px 88px;
mask-image: radial-gradient(ellipse at 50% 45%, #000 20%, transparent 72%);
}
#vignette {
position: absolute; inset: 0;
background: radial-gradient(ellipse at 50% 45%, transparent 40%, rgba(0,0,0,0.55) 100%);
pointer-events: none;
}
#stage { position: absolute; inset: 0; transform-origin: 50% 48%; }
.character {
position: absolute;
right: -20px;
bottom: -90px;
height: 1020px;
z-index: 6;
pointer-events: none;
transform-origin: 60% 100%;
}
#shadow {
position: absolute;
right: 180px;
bottom: 18px;
width: 420px;
height: 46px;
border-radius: 50%;
background: rgba(0,0,0,0.45);
filter: blur(16px);
z-index: 4;
}
#copy { position: absolute; left: 108px; top: 168px; width: 1040px; z-index: 7; }
.eyebrow {
display: flex; align-items: center; gap: 14px;
font-size: 15px; font-weight: 600; letter-spacing: 0.22em;
color: #9db7ff; text-transform: uppercase;
}
.eyebrow .rule { width: 42px; height: 1px; background: #7ea2ff; }
#lines { margin-top: 28px; }
.line { display: block; font-weight: 750; font-weight: 800; letter-spacing: -0.045em; color: white; line-height: 0.96; }
.line span { display: inline-block; }
.w { display: inline-block; will-change: transform; }
#l1 { font-size: 92px; }
#l2 { font-size: 92px; margin-top: 6px; }
#l3 { font-size: 40px; margin-top: 22px; color: #b7c3d6; font-weight: 500; letter-spacing: -0.03em; }
.gmail {
position: relative;
display: inline-block;
margin-left: 10px;
padding: 2px 12px 4px;
border-radius: 12px;
background: rgba(255,255,255,0.06);
color: #ffd0d4;
font-weight: 650;
font-weight: 600;
}
.gmail .strike {
position: absolute; left: 8px; right: 8px; top: 54%;
height: 3px; border-radius: 2px; background: #ff5d6c;
transform: scaleX(0); transform-origin: left center;
}
#address {
position: absolute;
left: 108px;
top: 620px;
z-index: 8;
display: flex;
align-items: center;
font-size: 64px;
font-weight: 700;
letter-spacing: -0.04em;
color: white;
}
#address .local { color: white; }
#address .domain { color: #7eb0ff; }
#address .caret {
width: 3px; height: 58px; margin-left: 4px;
background: #7eb0ff; border-radius: 2px;
}
#glowring {
position: absolute;
left: 70px; top: 560px;
width: 820px; height: 180px;
border-radius: 40px;
background: radial-gradient(ellipse at 30% 50%, rgba(47,124,246,0.45), transparent 70%);
filter: blur(18px);
z-index: 3;
}
#window {
position: absolute;
left: 72px;
top: 128px;
width: 1240px;
height: 760px;
border-radius: 28px;
background: #f7f8fb;
box-shadow:
0 50px 100px rgba(0,0,0,0.45),
0 0 0 1px rgba(255,255,255,0.14);
overflow: hidden;
z-index: 5;
transform-origin: 50% 60%;
}
.chrome {
height: 54px;
display: flex; align-items: center; gap: 8px;
padding: 0 18px;
background: #fff;
border-bottom: 1px solid #e6eaf1;
}
.dot { width: 11px; height: 11px; border-radius: 50%; }
.chrome .title {
margin-left: 14px;
font-size: 13px; font-weight: 600; color: #8b95a7; letter-spacing: 0.01em;
}
.chip-addr {
margin-left: auto;
font-size: 13px; font-weight: 650; font-weight: 600;
color: #0b57d0;
background: #e8f0fe;
padding: 6px 12px;
border-radius: 999px;
}
.body { display: flex; height: 706px; }
.side {
width: 228px;
background: #f3f6fb;
border-right: 1px solid #e6eaf1;
padding: 18px 12px;
}
.compose {
height: 42px; border-radius: 12px;
background: #0b57d0; color: white;
font-weight: 650; font-weight: 600; font-size: 14px;
display: flex; align-items: center; justify-content: center; gap: 8px;
box-shadow: 0 8px 16px rgba(11,87,208,0.25);
}
.nav { margin-top: 16px; display: flex; flex-direction: column; gap: 4px; }
.nav .item {
height: 38px; border-radius: 10px;
display: flex; align-items: center; gap: 10px;
padding: 0 12px;
font-size: 14px; color: #3c4658; font-weight: 500;
}
.nav .item.on { background: #e7f0ff; color: #0b57d0; font-weight: 650; font-weight: 600; }
.nav .count { margin-left: auto; font-size: 12px; font-weight: 700; color: #0b57d0; }
.nav .ico { width: 8px; height: 8px; border-radius: 50%; background: #c5ceda; }
.nav .item.on .ico { background: #0b57d0; }
.main { flex: 1; padding: 16px 18px 18px; position: relative; }
.search {
height: 44px; border-radius: 12px;
background: #eef2f8;
display: flex; align-items: center; gap: 10px;
padding: 0 14px;
color: #5c6b82; font-size: 15px;
}
.search .lens {
width: 14px; height: 14px; border: 2px solid #8aa; border-radius: 50%; position: relative;
}
.search .lens:after {
content: ""; position: absolute; width: 6px; height: 2px; background: #8aa;
right: -5px; bottom: -2px; transform: rotate(40deg);
}
.search b { color: #12151c; font-weight: 600; }
.rows { margin-top: 12px; }
.row {
height: 78px;
display: grid;
grid-template-columns: 44px 1fr auto;
gap: 12px;
align-items: center;
border-bottom: 1px solid #eef1f6;
position: relative;
}
.row .av {
width: 40px; height: 40px; border-radius: 50%;
display: grid; place-items: center;
color: white; font-weight: 700; font-size: 13px;
}
.row .who { font-weight: 700; font-size: 15px; color: #161a22; }
.row .sub { font-size: 13.5px; color: #2a3344; font-weight: 600; margin-top: 1px; }
.row .pre { font-size: 12.5px; color: #8b95a7; margin-top: 2px; }
.row .when { font-size: 12px; color: #8b95a7; font-weight: 500; }
.row .unread {
position: absolute; right: 4px; bottom: 16px;
width: 8px; height: 8px; border-radius: 50%; background: #0b57d0;
}
.row.hot { background: linear-gradient(90deg, #eef4ff, transparent 80%); border-radius: 12px; }
#toast {
position: absolute;
right: 28px; top: 118px;
background: #12151c; color: white;
border-radius: 14px;
padding: 10px 14px;
font-size: 13px;
box-shadow: 0 16px 40px rgba(0,0,0,0.28);
z-index: 6;
display: flex; align-items: center; gap: 8px;
}
#toast i { width: 8px; height: 8px; border-radius: 50%; background: #3d8bff; display: block; }
#cursor {
position: absolute; z-index: 9; width: 18px; height: 18px;
}
#pane-mail, #pane-domain { position: absolute; left: 246px; right: 0; top: 54px; bottom: 0; padding: 16px 18px 18px; }
#pane-domain { padding: 8px 8px 16px; }
.node {
position: absolute;
min-width: 168px;
background: white;
border-radius: 16px;
padding: 12px 14px 12px 12px;
box-shadow: 0 16px 40px rgba(20,40,80,0.12), 0 0 0 1px rgba(20,40,80,0.06);
display: flex; align-items: center; gap: 10px;
}
.node .av { width: 36px; height: 36px; border-radius: 12px; display: grid; place-items: center; color: white; font-weight: 700; }
.node b { display: block; font-size: 18px; letter-spacing: -0.03em; }
.node em { font-style: normal; font-size: 12px; color: #6d7b90; font-weight: 600; }
#hub {
position: absolute;
left: 430px; top: 250px;
width: 230px; height: 92px;
border-radius: 22px;
background: #0e1420;
color: white;
display: flex; align-items: center; gap: 12px;
padding: 0 16px;
box-shadow: 0 20px 50px rgba(11,87,208,0.35);
}
#hub .mark { width: 46px; height: 46px; }
#hub .name { font-weight: 700; font-size: 16px; letter-spacing: -0.03em; }
#hub .sub { font-size: 12px; color: #9eb0cc; }
#wires { position: absolute; inset: 0; width: 100%; height: 100%; }
.checks { position: absolute; right: 22px; top: 78px; display: flex; flex-direction: column; gap: 8px; }
.check {
width: 176px; height: 36px; border-radius: 10px;
background: white;
box-shadow: 0 0 0 1px #e6eaf1;
display: flex; align-items: center; gap: 8px;
padding: 0 10px;
font-size: 13px; font-weight: 600; color: #1c2430;
}
.check .box {
width: 16px; height: 16px; border-radius: 5px; background: #0b57d0;
color: white; font-size: 11px; display: grid; place-items: center;
}
#pills {
position: absolute; left: 36px; bottom: 18px;
display: flex; gap: 8px;
}
.pill {
padding: 7px 12px; border-radius: 999px;
background: #eef3fb; color: #3c4d6b;
font-size: 12px; font-weight: 650; font-weight: 600;
}
.pill.on { background: #0b57d0; color: white; }
#setup {
position: absolute; left: 36px; top: 22px;
font-size: 12px; font-weight: 650; font-weight: 600;
color: #0b57d0; background: #e7f0fe;
padding: 6px 10px; border-radius: 999px;
}
#finale {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
z-index: 8;
}
#finale .giant {
position: absolute;
font-size: 112px; font-weight: 800; letter-spacing: -0.05em;
color: white; text-align: center;
}
#lockup {
position: absolute;
display: flex; flex-direction: column; align-items: center;
text-align: center;
}
#lockup .mark { width: 92px; height: 92px; filter: drop-shadow(0 16px 30px rgba(47,124,246,0.45)); }
#lockup h1 {
position: relative;
margin: 22px 0 0;
font-size: 92px; font-weight: 800; letter-spacing: -0.055em;
color: white; line-height: 1;
overflow: hidden;
}
#lockup .tag {
margin-top: 16px;
font-size: 28px; font-weight: 500; color: #c5d0e2; letter-spacing: -0.02em;
}
#lockup .url {
margin-top: 22px;
font-size: 18px; font-weight: 600; color: #dce7ff;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.06);
padding: 8px 16px; border-radius: 999px;
}
#sweep {
position: absolute; top: 0; bottom: 0; left: 0; width: 90px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.75), transparent);
pointer-events: none;
}
#grain {
position: absolute; inset: -20%;
pointer-events: none;
opacity: 0.18;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.55'/></svg>");
}
</style>
</head>
<body>
<div id="world">
<div id="bg">
<div class="orb" id="orbA"></div>
<div class="orb" id="orbB"></div>
<div id="grid"></div>
</div>
<div id="stage">
<div id="shadow"></div>
<img class="character" id="charA" alt="" src="assets/character-a.png" />
<img class="character" id="charB" alt="" src="assets/character-b.png" />
<img class="character" id="charC" alt="" src="assets/character-c.png" />
<div id="copy">
<div class="eyebrow" id="eyebrow"><span class="rule"></span>Mailflare</div>
<div id="lines">
<div class="line" id="l1"></div>
<div class="line" id="l2"></div>
<div class="line" id="l3"></div>
</div>
</div>
<div id="glowring"></div>
<div id="address"><span id="addrText"></span><span class="caret" id="caret"></span></div>
<div id="window">
<div class="chrome">
<span class="dot" style="background:#ff5f57"></span>
<span class="dot" style="background:#febc2e"></span>
<span class="dot" style="background:#28c840"></span>
<span class="title" id="winTitle">mailflare · inbox</span>
<span class="chip-addr" id="winChip">hello@mailflare.co</span>
</div>
<div class="body">
<div class="side">
<div class="compose" id="compose">+ &nbsp;Compose</div>
<div class="nav" id="nav"></div>
</div>
</div>
<div id="pane-mail">
<div class="search" id="searchbox"><span class="lens"></span><span id="query">Search mail</span></div>
<div class="rows" id="rows"></div>
</div>
<div id="pane-domain">
<div id="setup">Live in minutes · no mail server</div>
<svg id="wires"></svg>
<div id="hub">
<img class="mark" src="assets/icon.png" alt="" />
<div><div class="name">mailflare.co</div><div class="sub">Custom domain</div></div>
</div>
<div id="nodes"></div>
<div class="checks" id="checks"></div>
<div id="pills"></div>
</div>
<div id="toast"><i></i><span>New message · hello@mailflare.co</span></div>
<div id="cursor"></div>
</div>
<div id="finale">
<div class="giant" id="g1">Your domain.</div>
<div class="giant" id="g2">Your email.</div>
<div id="lockup">
<img class="mark" id="endMark" src="assets/icon.png" alt="" />
<h1 id="word">Mailflare<div id="sweep"></div></h1>
<div class="tag">Professional email for individuals and teams.</div>
<div class="url">mailflare.co</div>
</div>
</div>
</div>
<div id="vignette"></div>
<div id="grain"></div>
</div>
<script>
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const seg = (t, a, b) => clamp((t - a) / (b - a), 0, 1);
const lerp = (a, b, p) => a + (b - a) * p;
const ease = (p) => 1 - Math.pow(1 - clamp(p, 0, 1), 3);
const back = (p) => {
p = clamp(p, 0, 1);
const c1 = 1.35, c3 = c1 + 1;
return 1 + c3 * Math.pow(p - 1, 3) + c1 * Math.pow(p - 1, 2);
};
const L1 = 0.28, L2 = 3.63, L3 = 6.57, L4 = 11.11;
const $ = (id) => document.getElementById(id);
const words = [
["Your", L1 + 0.094], ["business", L1 + 0.212],
["deserves", L1 + 0.613], ["better", L1 + 1.061],
];
$("l1").innerHTML = words.slice(0, 2).map(([w, t]) => `<span class="w" data-t="${t}">${w}</span>`).join(" ");
$("l2").innerHTML = words.slice(2).map(([w, t]) => `<span class="w" data-t="${t}">${w}</span>`).join(" ");
$("l3").innerHTML = `
<span class="w" data-t="${L1 + 1.38}">than</span>
<span class="w" data-t="${L1 + 1.521}">an</span>
<span class="gmail w" data-t="${L1 + 1.60}" id="gmail">@gmail.com<i class="strike" id="strike"></i></span>
`;
const nav = [
["Inbox", "4", true],
["Starred", "", false],
["Sent", "", false],
["Drafts", "2", false],
["Spam", "", false],
["Archive", "", false],
];
$("nav").innerHTML = nav.map(([n, c, on]) =>
`<div class="item${on ? " on" : ""}" data-nav="${n}"><span class="ico"></span>${n}${c ? `<span class="count">${c}</span>` : ""}</div>`
).join("");
const messages = [
["MC", "#5b8def", "Maya Chen", "Product launch tomorrow", "Can we share the final update?", "9:41 AM", true],
["NL", "#14b8c9", "Northline", "Your domain is connected", "Mail is ready on your domain.", "8:12 AM", false],
["TP", "#e0a15a", "Theo Park", "One more thing", "Thanks for the thoughtful note.", "Yesterday", false],
["SN", "#a78bfa", "Studio Notes", "A fresh start for Monday", "Ideas worth opening.", "Mon", false],
];
function rowHTML(m, hot) {
return `<div class="row${hot ? " hot" : ""}">
<div class="av" style="background:${m[1]}">${m[0]}</div>
<div><div class="who">${m[2]}</div><div class="sub">${m[3]}</div><div class="pre">${m[4]}</div></div>
<div class="when">${m[5]}</div>
${m[6] ? `<span class="unread"></span>` : ""}
</div>`;
}
$("rows").innerHTML = messages.map((m, i) => rowHTML(m, i === 0)).join("");
const nodes = [
["hello@", "Mailbox", "#0b57d0", "H", 36, 150, L3 + 0.39],
["support@", "Alias", "#14b8c9", "S", 430, 78, L3 + 1.51],
["hieu@", "Member", "#e0a15a", "H", 36, 400, L3 + 2.15],
["team@", "Team", "#7c6cf0", "T", 430, 400, L3 + 3.11],
];
$("nodes").innerHTML = nodes.map((n, i) =>
`<div class="node" id="node${i}" style="left:${n[4]}px;top:${n[5]}px">
<div class="av" style="background:${n[2]}">${n[3]}</div>
<div><b>${n[0]}</b><em>${n[1]}</em></div>
</div>`
).join("");
const checks = ["Users", "Aliases", "Domains", "Permissions"];
$("checks").innerHTML = checks.map((c, i) =>
`<div class="check" id="check${i}"><span class="box">✓</span>${c}</div>`
).join("");
$("pills").innerHTML = ["Individuals", "Startups", "Teams"].map((p, i) =>
`<span class="pill${i === 2 ? " on" : ""}" id="pill${i}">${p}</span>`
).join("");
const ADDR = "hello@mailflare.co";
const cursor = $("cursor");
cursor.innerHTML = `<svg width="18" height="18" viewBox="0 0 18 18"><path d="M2 1.5l4.2 13.2 2.2-5.1 5.3-1.2z" fill="#12151c" stroke="#fff" stroke-width="1"/></svg>`;
function show(el, o, tf, blur) {
el.style.opacity = o;
if (tf != null) el.style.transform = tf;
el.style.filter = blur > 0.2 ? `blur(${blur}px)` : "none";
}
function wordPop(el, t) {
const at = Number(el.dataset.t);
const p = back(seg(t, at, at + 0.38));
const o = ease(seg(t, at, at + 0.16));
el.style.opacity = o;
el.style.transform = `translateY(${lerp(28, 0, p)}px)`;
el.style.filter = `blur(${lerp(6, 0, ease(seg(t, at, at + 0.22)))}px)`;
}
window.seek = function (t) {
const bg = `radial-gradient(900px 620px at ${50 + Math.sin(t * 0.7) * 8}% 42%, #17315f 0%, #0a1020 46%, #05070c 100%)`;
$("bg").style.background = bg;
$("orbA").style.cssText = `left:${280 + Math.sin(t) * 30}px;top:${80 + Math.cos(t * 0.8) * 20}px;width:520px;height:520px;background:rgba(47,110,246,${0.28 + 0.06 * Math.sin(t)});`;
$("orbB").style.cssText = `right:80px;bottom:-40px;width:420px;height:420px;background:rgba(20,180,200,0.16);`;
$("grid").style.transform = `translate(${-(t * 18) % 88}px, ${(t * 8) % 88}px)`;
let camS = lerp(1.05, 1, ease(seg(t, 0, 2.4)));
let camX = 0;
if (t > 3.3 && t < 6.7) camS = lerp(1.02, 1, ease(seg(t, 3.3, 4)));
if (t >= 6.5 && t < 10.9) camS = lerp(1, 1.035, ease(seg(t, 6.5, 7.3)));
if (t >= 10.7) {
const p = ease(seg(t, 10.7, 11.5));
camS = lerp(1.035, 0.98, p);
camS = lerp(camS, 1, ease(seg(t, 11.6, 12.6)));
}
$("stage").style.transform = `translate(${camX}px,0) scale(${camS})`;
const enter = back(seg(t, 0.08, 0.7));
const poseB = ease(seg(t, 3.35, 3.7));
const poseC = ease(seg(t, 10.85, 11.25));
const charOut = ease(seg(t, 12.15, 12.7));
const bob = Math.sin(t * 2.2) * 6;
const base = `translateY(${lerp(70, bob, enter)}px) scale(${lerp(0.94, 1, enter)})`;
show($("charA"), (1 - poseB) * enter * (1 - charOut), base, (1 - enter) * 10 + poseB * 8);
show($("charB"), poseB * (1 - poseC) * (1 - charOut), `translateX(${lerp(40, 0, poseB)}px) translateY(${bob}px) scale(${lerp(1.04, 0.96, poseB)})`, (1 - poseB) * 8 + poseC * 8);
show($("charC"), poseC * (1 - charOut), `translateY(${lerp(30, bob, poseC)}px) scale(${lerp(0.9, 0.86, poseC)})`, (1 - poseC) * 8);
$("shadow").style.opacity = String(0.55 * enter * (1 - charOut));
$("shadow").style.transform = `scale(${lerp(0.8, 1, enter)})`;
const copyOut = ease(seg(t, 3.25, 3.7));
$("copy").style.opacity = String((1 - copyOut) * ease(seg(t, 0.15, 0.4)));
$("copy").style.transform = `translateY(${lerp(16, 0, ease(seg(t, 0.15, 0.6)))}px)`;
$("copy").style.filter = copyOut ? `blur(${copyOut * 8}px)` : "none";
document.querySelectorAll(".w").forEach((el) => wordPop(el, t));
const strike = ease(seg(t, L1 + 1.9, L1 + 2.35));
$("strike").style.transform = `scaleX(${strike})`;
$("gmail").style.color = strike > 0.6 ? "#8d97a8" : "#ffd0d4";
const addrIn = ease(seg(t, 2.28, 2.7));
const addrOut = ease(seg(t, 3.3, 3.65));
const n = Math.round(lerp(0, ADDR.length, ease(seg(t, 2.32, 2.95))));
const typed = ADDR.slice(0, n);
const at = typed.indexOf("@");
$("addrText").innerHTML = at < 0
? `<span class="local">${typed}</span>`
: `<span class="local">${typed.slice(0, at)}</span><span class="domain">${typed.slice(at)}</span>`;
show($("address"), addrIn * (1 - addrOut), `translateY(${lerp(24, 0, back(seg(t, 2.28, 2.75)))}px)`, addrOut * 6);
$("caret").style.opacity = n < ADDR.length ? String(0.3 + 0.7 * Math.abs(Math.sin(t * 10))) : "0";
$("glowring").style.opacity = String(0.9 * addrIn * (1 - addrOut));
const winIn = back(seg(t, 3.45, 4.05));
const winOut = ease(seg(t, 10.75, 11.45));
show($("window"), winIn * (1 - winOut), `translateY(${lerp(80, 0, winIn)}px) rotateX(${lerp(14, 0, winIn)}deg) scale(${lerp(0.94, 1, winIn) * lerp(1, 0.72, winOut)})`, winOut * 10);
$("window").style.transformOrigin = "50% 40%";
const domain = ease(seg(t, 6.5, 6.95));
$("pane-mail").style.opacity = String(1 - domain);
$("pane-mail").style.transform = `translateY(${domain * -12}px)`;
$("pane-domain").style.opacity = String(domain);
$("pane-domain").style.transform = `translateY(${(1 - domain) * 16}px)`;
$("winTitle").textContent = domain > 0.5 ? "mailflare · workspace" : "mailflare · inbox";
$("winChip").style.opacity = String(ease(seg(t, 3.7, 4.0)));
const q = "is:unread";
const qn = Math.round(lerp(0, q.length, ease(seg(t, 4.15, 5.05))));
$("query").innerHTML = qn === 0 ? "Search mail" : `<b>${q.slice(0, qn)}</b>`;
$("searchbox").style.boxShadow = qn ? "0 0 0 2px rgba(11,87,208,0.55)" : "none";
const rows = [...$("rows").children];
rows.forEach((row, i) => {
const at = 3.85 + i * 0.12;
const p = back(seg(t, at, at + 0.4));
row.style.opacity = String(ease(seg(t, at, at + 0.2)));
row.style.transform = `translateX(${lerp(36, 0, p)}px)`;
});
const count = $("nav").querySelector('[data-nav="Inbox"] .count');
if (t > 5.15) count.textContent = "5";
const toastP = ease(seg(t, 5.05, 5.35)) * (1 - ease(seg(t, 6.15, 6.45)));
show($("toast"), toastP, `translateY(${lerp(-16, 0, back(seg(t, 5.05, 5.45)))}px)`, 0);
const spots = [[250, 250], [430, 210], [560, 150], [300, 360]];
const cursorPhase = t < 4.3 ? 0 : t < 4.9 ? 1 : t < 5.5 ? 2 : 3;
const cp = ease(seg(t, [4.2, 4.55, 5.0, 5.6][cursorPhase], [4.5, 4.9, 5.35, 6.1][cursorPhase]));
const prev = spots[Math.max(0, cursorPhase - 1)];
const next = spots[cursorPhase];
const cx = lerp(prev[0], next[0], cursorPhase === 0 ? 1 : cp);
const cy = lerp(prev[1], next[1], cursorPhase === 0 ? 1 : cp);
$("cursor").style.opacity = String(ease(seg(t, 4.15, 4.4)) * (1 - domain));
$("cursor").style.transform = `translate(${cx}px, ${cy}px)`;
nodes.forEach((n, i) => {
const p = back(seg(t, n[6], n[6] + 0.45));
const el = $("node" + i);
el.style.opacity = String(ease(seg(t, n[6], n[6] + 0.2)) * domain);
el.style.transform = `translateY(${lerp(22, 0, p)}px) scale(${lerp(0.86, 1, p)})`;
});
const hubP = back(seg(t, L3 + 0.05, L3 + 0.45));
$("hub").style.opacity = String(ease(seg(t, L3 + 0.05, L3 + 0.25)) * domain);
$("hub").style.transform = `scale(${lerp(0.8, 1, hubP)})`;
const svg = $("wires");
const hub = { x: 545, y: 296 };
const anchors = [[150, 190], [540, 130], [150, 440], [540, 440]];
svg.innerHTML = anchors.map((a, i) => {
const p = ease(seg(t, nodes[i][6], nodes[i][6] + 0.4));
return `<line x1="${hub.x}" y1="${hub.y}" x2="${lerp(hub.x, a[0], p)}" y2="${lerp(hub.y, a[1], p)}" stroke="rgba(11,87,208,0.35)" stroke-width="2" />`;
}).join("");
checks.forEach((c, i) => {
const at = L3 + 2.45 + i * 0.16;
const p = back(seg(t, at, at + 0.36));
const el = $("check" + i);
el.style.opacity = String(ease(seg(t, at, at + 0.16)));
el.style.transform = `translateX(${lerp(24, 0, p)}px)`;
});
$("setup").style.opacity = String(ease(seg(t, L3 + 0.2, L3 + 0.5)));
["pill0", "pill1", "pill2"].forEach((id, i) => {
const at = L3 + 3.35 + i * 0.12;
const p = back(seg(t, at, at + 0.34));
const el = $(id);
el.style.opacity = String(ease(seg(t, at, at + 0.16)));
el.style.transform = `translateY(${lerp(10, 0, p)}px)`;
});
const navItems = [...$("nav").children];
navItems.forEach((item, i) => {
const at = 3.6 + i * 0.06;
const p = back(seg(t, at, at + 0.32));
item.style.opacity = String(ease(seg(t, at, at + 0.16)));
item.style.transform = `translateX(${lerp(-16, 0, p)}px)`;
});
$("compose").style.opacity = String(ease(seg(t, 3.55, 3.8)));
$("compose").style.transform = `scale(${lerp(0.9, 1, back(seg(t, 3.55, 3.95)))})`;
const g1 = ease(seg(t, 11.2, 11.45)) * (1 - ease(seg(t, 12.05, 12.35)));
const g2 = ease(seg(t, 12.15, 12.4)) * (1 - ease(seg(t, 12.9, 13.15)));
show($("g1"), g1, `translateY(${lerp(30, 0, back(seg(t, 11.2, 11.6)))}px) scale(${lerp(1.08, 1, ease(seg(t, 11.2, 11.7)))})`, (1 - g1) * 8);
show($("g2"), g2, `translateY(${lerp(30, 0, back(seg(t, 12.15, 12.55)))}px)`, (1 - g2) * 8);
const lock = ease(seg(t, 12.95, 13.25));
const lockP = back(seg(t, 12.95, 13.5));
$("lockup").style.opacity = String(lock);
$("lockup").style.transform = `scale(${lerp(0.92, 1, lockP)})`;
$("endMark").style.transform = `scale(${lerp(0.4, 1, back(seg(t, 12.9, 13.35)))})`;
$("endMark").style.transformOrigin = "50% 50%";
const track = lerp(0.18, -0.055, ease(seg(t, 12.98, 13.45)));
$("word").style.letterSpacing = track + "em";
$("word").style.opacity = String(ease(seg(t, 13.0, 13.28)));
$("lockup").querySelector(".tag").style.opacity = String(ease(seg(t, 13.4, 13.75)));
$("lockup").querySelector(".tag").style.transform = `translateY(${lerp(10, 0, ease(seg(t, 13.4, 13.8)))}px)`;
$("lockup").querySelector(".url").style.opacity = String(ease(seg(t, 13.75, 14.1)));
$("lockup").querySelector(".url").style.transform = `translateY(${lerp(8, 0, ease(seg(t, 13.75, 14.15)))}px)`;
const sweep = seg(t, 13.45, 14.15);
$("sweep").style.opacity = String(sweep > 0 && sweep < 1 ? 0.9 : 0);
$("sweep").style.transform = `translateX(${lerp(-80, 460, sweep)}px)`;
};
window.seek(0);
</script>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
import { createRequire } from "node:module";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire("/tmp/showreel-render/package.json");
const puppeteer = require("puppeteer-core");
const dir = path.dirname(fileURLToPath(import.meta.url));
const html = path.join(dir, "film.html");
const chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const arg = process.argv.find((a) => a.startsWith("--times="));
const times = arg
? arg.slice("--times=".length).split(",").map(Number)
: null;
const outDir = process.argv.find((a) => a.startsWith("--out="))?.slice("--out=".length)
|| "/tmp/showreel-frames";
mkdirSync(outDir, { recursive: true });
const browser = await puppeteer.launch({
executablePath: chrome,
headless: "new",
args: ["--hide-scrollbars", "--disable-lcd-text", "--force-color-profile=srgb"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
await page.goto("file://" + html, { waitUntil: "load" });
await page.evaluate(() => document.fonts.ready);
const frames = times ?? Array.from({ length: 15 * 30 }, (_, i) => i / 30);
for (let i = 0; i < frames.length; i++) {
const t = frames[i];
await page.evaluate((time) => window.seek(time), t);
const name = times
? `proof-${String(t).replace(".", "_")}.jpg`
: `f${String(i).padStart(4, "0")}.jpg`;
await page.screenshot({
path: path.join(outDir, name),
type: "jpeg",
quality: 92,
});
if (!times && i % 30 === 0) process.stdout.write(`t=${t.toFixed(2)}\n`);
}
await browser.close();
console.log(outDir);
+386
View File
@@ -0,0 +1,386 @@
import math
import os
import struct
import subprocess
import tempfile
import wave
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter, ImageFont
W, H, FPS, DURATION = 1280, 720, 24, 15
ROOT = Path(__file__).resolve().parent.parent
OUT = Path(__file__).resolve().parent / "mailflare-motion.mp4"
BLUE = (38, 112, 246)
NAVY = (9, 25, 55)
INK = (17, 34, 62)
PALE = (244, 248, 255)
FONT = "/System/Library/Fonts/Supplemental/Arial.ttf"
BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
def f(size, bold=False):
return ImageFont.truetype(BOLD if bold else FONT, size)
def clamp(x):
return max(0.0, min(1.0, x))
def ease(x):
x = clamp(x)
return 1 - (1 - x) ** 3
def smooth(x):
x = clamp(x)
return x * x * (3 - 2 * x)
def phase(t, start, end):
return ease((t - start) / (end - start))
def alpha(t, start, end, fade=0.28):
return smooth((t - start) / fade) * (1 - smooth((t - end + fade) / fade))
def mix(a, b, p):
return a + (b - a) * p
def rgba(color, a=255):
return (*color, int(255 * clamp(a)))
def text(draw, xy, message, size, color, bold=False, anchor=None, spacing=0):
draw.text(xy, message, font=f(size, bold), fill=color, anchor=anchor, spacing=spacing)
def round_box(draw, box, radius, fill, outline=None, width=1):
draw.rounded_rectangle(tuple(int(v) for v in box), radius=int(radius), fill=fill, outline=outline, width=width)
def shadow_card(size, radius=28, blur=28, opacity=52):
layer = Image.new("RGBA", (size[0] + blur * 4, size[1] + blur * 4))
d = ImageDraw.Draw(layer)
b = blur * 2
d.rounded_rectangle((b, b, b + size[0], b + size[1]), radius, fill=(15, 42, 90, opacity))
return layer.filter(ImageFilter.GaussianBlur(blur))
def glow(img, xy, radius, color, strength=130):
layer = Image.new("RGBA", (W, H))
d = ImageDraw.Draw(layer)
x, y = xy
d.ellipse((x - radius, y - radius, x + radius, y + radius), fill=(*color, strength))
img.alpha_composite(layer.filter(ImageFilter.GaussianBlur(radius * 0.85)))
def envelope(draw, cx, cy, scale, color=BLUE, open_amount=0):
w, h = 88 * scale, 60 * scale
x, y = cx - w / 2, cy - h / 2
draw.rounded_rectangle((x, y, x + w, y + h), 9 * scale, fill=color)
draw.polygon([(x + 4 * scale, y + 5 * scale), (cx, y + 34 * scale), (x + w - 4 * scale, y + 5 * scale)], fill=(221, 244, 255))
draw.polygon([(x + 4 * scale, y + h - 5 * scale), (cx, y + 29 * scale), (x + w - 4 * scale, y + h - 5 * scale)], fill=(15, 167, 224))
if open_amount:
draw.polygon([(x + 4 * scale, y + 5 * scale), (cx, y - 23 * scale * open_amount), (x + w - 4 * scale, y + 5 * scale)], fill=(239, 252, 255))
def base_dark():
im = Image.new("RGBA", (W, H))
d = ImageDraw.Draw(im)
for y in range(H):
p = y / H
d.line((0, y, W, y), fill=(int(8 + p * 3), int(20 + p * 17), int(49 + p * 29), 255))
return im
def base_light():
im = Image.new("RGBA", (W, H))
d = ImageDraw.Draw(im)
for y in range(H):
p = y / H
d.line((0, y, W, y), fill=(int(248 - 6 * p), int(251 - 5 * p), 255, 255))
return im
def draw_brand(draw, x, y, size=28, dark=False):
envelope(draw, x + size / 2, y + size / 2, size / 88)
text(draw, (x + size + 12, y + size / 2), "mailflare", int(size * 0.72), (255, 255, 255) if dark else INK, True, "lm")
def draw_ui(t):
im = Image.new("RGBA", (936, 532), (255, 255, 255, 255))
d = ImageDraw.Draw(im)
round_box(d, (0, 0, 935, 531), 24, (255, 255, 255), (219, 230, 246), 2)
round_box(d, (0, 0, 935, 53), 24, (247, 250, 255))
d.rectangle((0, 30, 935, 53), fill=(247, 250, 255))
d.line((0, 53, 935, 53), fill=(229, 235, 245), width=1)
for i, c in enumerate(((255, 111, 111), (255, 192, 86), (66, 207, 128))):
d.ellipse((21 + i * 18, 20, 30 + i * 18, 29), fill=c)
text(d, (468, 28), "mailflare / inbox", 14, (117, 133, 159), True, "mm")
d.rectangle((0, 54, 187, 531), fill=(247, 249, 254))
draw_brand(d, 19, 75, 25)
round_box(d, (20, 126, 163, 169), 18, (220, 234, 255))
text(d, (48, 147), "+ Compose", 15, (29, 81, 161), True, "lm")
nav = [("Inbox", "18"), ("Starred", ""), ("Snoozed", ""), ("Sent", ""), ("Drafts", "4")]
for i, (label, count) in enumerate(nav):
yy = 196 + i * 46
if i == 0:
round_box(d, (12, yy - 17, 176, yy + 19), 15, (225, 237, 255))
d.ellipse((30, yy - 4, 38, yy + 4), fill=BLUE if i == 0 else (157, 169, 189))
text(d, (50, yy), label, 15, (26, 52, 92) if i == 0 else (107, 121, 145), i == 0, "lm")
if count:
text(d, (151, yy), count, 13, BLUE, True, "mm")
round_box(d, (211, 75, 902, 122), 22, (238, 244, 253))
d.ellipse((232, 90, 248, 106), outline=(107, 132, 170), width=2)
d.line((246, 104, 253, 111), fill=(107, 132, 170), width=2)
text(d, (266, 98), "Search mail", 15, (132, 149, 174), False, "lm")
text(d, (221, 153), "Priority inbox", 24, INK, True, "lm")
text(d, (878, 153), "1–4 of 18", 12, (132, 149, 174), False, "rm")
messages = [
("M", "Maya Chen", "Product launch tomorrow", "Can we share the final update?", "9:41 AM"),
("N", "Northline", "Your domain is connected", "Mail is ready for hello@northline.dev", "8:52 AM"),
("T", "Theo Park", "One more thing", "Thanks for the thoughtful note!", "Yesterday"),
("S", "Studio Notes", "A fresh start for Monday", "Ideas worth opening.", "Mon"),
]
for i, (initial, sender, subject, preview, time) in enumerate(messages):
yy = 188 + i * 82
if i == 0 and t > 3.65:
round_box(d, (201, yy - 1, 925, yy + 79), 14, (237, 245, 255))
d.line((203, yy + 79, 925, yy + 79), fill=(236, 240, 247))
d.ellipse((221, yy + 14, 259, yy + 52), fill=[(124, 157, 239), (75, 199, 221), (232, 172, 111), (174, 145, 230)][i])
text(d, (240, yy + 33), initial, 15, (255, 255, 255), True, "mm")
text(d, (273, yy + 15), sender, 16, INK, True)
text(d, (273, yy + 39), subject, 14, (50, 65, 90), True)
text(d, (273, yy + 60), preview, 12, (143, 156, 177))
text(d, (882, yy + 17), time, 12, (130, 145, 166), False, "ra")
if i == 0:
d.ellipse((903, yy + 43, 912, yy + 52), fill=BLUE)
return im
def feature_card(im, box, title, sub, symbol, amount):
x, y, w, h = box
lift = 24 * (1 - ease(amount))
y += lift
d = ImageDraw.Draw(im)
round_box(d, (x, y, x + w, y + h), 26, rgba((255, 255, 255), amount), rgba((161, 196, 251), amount * 0.45), 2)
round_box(d, (x + 20, y + 20, x + 68, y + 68), 14, rgba((222, 237, 255), amount))
text(d, (x + 44, y + 45), symbol, 25, rgba(BLUE, amount), True, "mm")
text(d, (x + 20, y + 94), title, 19, rgba(INK, amount), True)
text(d, (x + 20, y + 124), sub, 13, rgba((113, 135, 166), amount))
def render_frame(t, dark, light, ui, logo):
if t < 2.65:
im = dark.copy()
d = ImageDraw.Draw(im)
glow(im, (920, 210), 240, (33, 105, 240), 100)
glow(im, (180, 660), 180, (15, 184, 219), 65)
d = ImageDraw.Draw(im)
for i in range(18):
x = (i * 139 + t * (34 + i % 4 * 13)) % (W + 100) - 50
y = (i * 107 + math.sin(t * 1.8 + i) * 13) % H
d.ellipse((x, y, x + 2, y + 2), fill=(98, 166, 251, 80))
p = phase(t, 0.08, 0.72)
x = mix(1400, 880, p)
y = mix(-90, 335, p) + math.sin(t * 3.3) * 7
d.ellipse((x - 152, y - 152, x + 152, y + 152), outline=(65, 141, 241, 95), width=2)
d.ellipse((x - 112, y - 112, x + 112, y + 112), outline=(65, 141, 241, 75), width=2)
envelope(d, x, y, 2.25, (28, 146, 232), phase(t, 0.65, 1.15))
for k in range(3):
xx = x + 170 + k * 35 + (1 - p) * 180
d.line((xx, y - 30 + k * 30, xx + 75, y - 30 + k * 30), fill=(39, 141, 252, int(140 * p)), width=3)
text(d, (85 - 60 * (1 - phase(t, 0.1, 0.5)), 128), "INTRODUCING MAILFLARE", 17, (108, 185, 255), True)
reveal = int(len("Email, with momentum.") * phase(t, 0.25, 1.48))
text(d, (80, 204), "Email, with", 69, (255, 255, 255), True)
text(d, (80, 292), "momentum."[:max(0, reveal - 12)], 74, (91, 177, 255), True)
line_p = phase(t, 1.23, 1.8)
d.rounded_rectangle((83, 403, 83 + 550 * line_p, 408), radius=2, fill=(79, 173, 254))
text(d, (85, 450), "A familiar inbox. A more capable workflow.", 24, (174, 199, 230))
if t > 2.25:
im = crossfade(im, light, phase(t, 2.25, 2.65))
return im
if t < 5.65:
im = light.copy()
d = ImageDraw.Draw(im)
draw_brand(d, 57, 36, 33)
text(d, (1222, 56), "THE INBOX", 13, BLUE, True, "rm")
text(d, (640, 114), "Feels like home.", 48, INK, True, "mm")
text(d, (640, 160), "Everything in its place. Every message in reach.", 20, (109, 133, 164), False, "mm")
p = phase(t, 2.66, 3.25)
s = mix(0.89, 1.0, p)
panel = ui.resize((int(ui.width * s), int(ui.height * s)), Image.Resampling.LANCZOS)
px = int((W - panel.width) / 2)
py = int(206 + 72 * (1 - p))
sh = shadow_card((panel.width, panel.height), 25, 25, 50)
im.alpha_composite(sh, (px - 50, py - 43))
im.alpha_composite(panel, (px, py))
d = ImageDraw.Draw(im)
if t > 4.35:
q = phase(t, 4.35, 4.85)
x = 945 + 180 * (1 - q)
y = 548
round_box(d, (x, y, x + 264, y + 71), 20, (37, 112, 246))
text(d, (x + 22, y + 24), "✦ Mail, organized", 19, (255, 255, 255), True)
text(d, (x + 22, y + 49), "Search · stars · snooze", 12, (219, 235, 255))
return im
if t < 9.25:
im = light.copy()
d = ImageDraw.Draw(im)
draw_brand(d, 57, 36, 33)
text(d, (1222, 56), "PEOPLE + AGENTS", 13, BLUE, True, "rm")
text(d, (640, 116), "Human touch. Agent speed.", 47, INK, True, "mm")
text(d, (640, 158), "A thoughtful reply starts here.", 19, (108, 133, 164), False, "mm")
p = phase(t, 5.7, 6.22)
y = 226 + 50 * (1 - p)
sh = shadow_card((475, 350), 30, 26, 43)
im.alpha_composite(sh, (55, int(y) - 45))
round_box(d, (110, y, 585, y + 350), 29, (255, 255, 255), (224, 233, 246), 2)
d.ellipse((138, y + 31, 184, y + 77), fill=(122, 155, 239))
text(d, (161, y + 54), "M", 19, (255, 255, 255), True, "mm")
text(d, (198, y + 32), "Maya Chen", 19, INK, True)
text(d, (198, y + 57), "To: hello@northline.dev", 13, (132, 151, 178))
d.line((139, y + 97, 551, y + 97), fill=(228, 235, 245), width=2)
text(d, (140, y + 128), "Product launch tomorrow", 23, INK, True)
text(d, (140, y + 179), "Hi team, can we share the final", 17, (76, 96, 126))
text(d, (140, y + 208), "update with everyone tomorrow?", 17, (76, 96, 126))
text(d, (140, y + 251), "Thanks, Maya", 17, (76, 96, 126))
d.line((570, y + 170, 685, y + 170), fill=(66, 142, 252), width=4)
d.polygon([(685, y + 170), (674, y + 162), (674, y + 178)], fill=(66, 142, 252))
ap = phase(t, 6.45, 7.0)
ax = 695 + 90 * (1 - ap)
round_box(d, (ax, y, ax + 475, y + 350), 29, (255, 255, 255), (207, 225, 249), 2)
round_box(d, (ax + 24, y + 24, ax + 78, y + 78), 16, (225, 237, 255))
text(d, (ax + 51, y + 51), "✦", 28, BLUE, True, "mm")
text(d, (ax + 93, y + 33), "Mailflare assistant", 18, INK, True)
text(d, (ax + 93, y + 59), "Working with your inbox", 13, (126, 147, 176))
d.line((ax + 24, y + 99, ax + 450, y + 99), fill=(230, 238, 249), width=2)
text(d, (ax + 27, y + 125), "Draft a warm reply to Maya", 18, (53, 78, 113), True)
q = phase(t, 7.06, 7.62)
round_box(d, (ax + 24, y + 168, ax + 450, y + 276), 18, (239, 246, 255))
text(d, (ax + 45, y + 191), "DRAFT READY", 12, BLUE, True)
text(d, (ax + 45, y + 221), "Absolutely, Maya — the update", 17, INK)
text(d, (ax + 45, y + 247), "is ready to share tomorrow…", 17, INK)
if q > 0:
round_box(d, (ax + 24, y + 292, ax + 249, y + 329), 17, rgba(BLUE, q))
text(d, (ax + 136, y + 311), "Review to send →", 15, rgba((255, 255, 255), q), True, "mm")
if t > 8.08:
ring = (t - 8.08) * 80
d.ellipse((ax + 136 - ring, y + 311 - ring, ax + 136 + ring, y + 311 + ring), outline=(38, 112, 246, max(0, int(145 - ring * 2))), width=3)
text(d, (640, 643), "The agent prepares. You approve.", 20, (84, 111, 148), True, "mm")
if t > 8.95:
im = crossfade(im, dark, phase(t, 8.95, 9.25))
return im
if t < 12.3:
im = dark.copy()
glow(im, (662, 330), 210, (23, 99, 246), 72)
d = ImageDraw.Draw(im)
draw_brand(d, 57, 36, 33, True)
text(d, (1222, 56), "YOUR WORKFLOW", 13, (107, 179, 255), True, "rm")
text(d, (640, 130), "One home for every address.", 47, (255, 255, 255), True, "mm")
text(d, (640, 177), "Built around your domain. Ready for your tools.", 19, (164, 191, 226), False, "mm")
pp = [phase(t, 9.5 + i * 0.17, 10.07 + i * 0.17) for i in range(4)]
positions = [(114, 280), (383, 318), (652, 280), (921, 318)]
cards = [
("Custom domains", "Your identity, everywhere", "@"),
("Smart routing", "Mail lands where it should", "↗"),
("API + webhooks", "Connect your stack", "⌘"),
("Agent access", "MCP-ready workflows", "✦"),
]
for i, (x, y) in enumerate(positions):
feature_card(im, (x, y, 245, 205), *cards[i], pp[i])
d = ImageDraw.Draw(im)
p = phase(t, 11.35, 11.8)
round_box(d, (418, 599, 862, 645), 22, rgba((30, 75, 139), p))
text(d, (640, 622), "Self-hosted • Cloudflare-powered", 17, rgba((194, 223, 252), p), True, "mm")
if t > 12.0:
blue = Image.new("RGBA", (W, H), (29, 103, 236, 255))
im = crossfade(im, blue, phase(t, 12.0, 12.3))
return im
im = Image.new("RGBA", (W, H), (29, 103, 236, 255))
glow(im, (640, 330), 245, (90, 184, 255), 50)
d = ImageDraw.Draw(im)
for i in range(28):
a = i * 2 * math.pi / 28 + t * 0.18
r = 320 + 13 * math.sin(t * 2 + i)
x = 640 + math.cos(a) * r
y = 333 + math.sin(a) * r
d.ellipse((x - 2, y - 2, x + 2, y + 2), fill=(135, 202, 255, 92))
p = phase(t, 12.42, 13.03)
scale = mix(0.72, 1.0, p) + 0.018 * math.sin(t * 3)
icon = logo.resize((int(120 * scale), int(120 * scale)), Image.Resampling.LANCZOS)
im.alpha_composite(icon, (int(640 - icon.width / 2), int(187 - icon.height / 2)))
d = ImageDraw.Draw(im)
text(d, (640, 322 + 38 * (1 - p)), "mailflare", 81, (255, 255, 255), True, "mm")
q = phase(t, 13.1, 13.75)
text(d, (640, 416), "Email for humans + agents.", 27, rgba((225, 241, 255), q), False, "mm")
d.rounded_rectangle((640 - 88 * q, 470, 640 + 88 * q, 474), radius=2, fill=rgba((170, 227, 255), q))
text(d, (640, 526), "YOUR MAIL. YOUR MOMENTUM.", 15, rgba((202, 231, 255), phase(t, 13.7, 14.25)), True, "mm")
return im
def crossfade(a, b, p):
return Image.blend(a, b, clamp(p))
def make_audio(path):
rate = 44100
beats = (0.14, 0.72, 1.34, 2.65, 3.45, 4.38, 5.68, 6.54, 7.45, 8.12, 9.26, 9.78, 10.26, 10.75, 11.25, 12.34, 13.15)
with wave.open(path, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(rate)
chunk = bytearray()
for i in range(rate * DURATION):
t = i / rate
section = 0.5 + 0.5 * math.sin(t * math.pi / DURATION)
pad = (math.sin(2 * math.pi * 110 * t) + 0.53 * math.sin(2 * math.pi * 164.81 * t) + 0.31 * math.sin(2 * math.pi * 220 * t)) * 0.035 * section
rhythm = 0.0
for b in beats:
dt = t - b
if 0 <= dt < 0.28:
rhythm += math.sin(2 * math.pi * (108 - 55 * dt) * dt) * math.exp(-24 * dt) * 0.18
if 0 <= dt < 0.10:
rhythm += math.sin(2 * math.pi * 920 * dt) * math.exp(-48 * dt) * 0.024
shimmer = math.sin(2 * math.pi * 440 * t) * math.sin(2 * math.pi * 0.7 * t) * 0.009
sample = max(-1, min(1, pad + rhythm + shimmer))
chunk += struct.pack("<h", int(sample * 32767))
if len(chunk) > 65536:
wav.writeframes(chunk)
chunk.clear()
if chunk:
wav.writeframes(chunk)
def render_video():
dark, light = base_dark(), base_light()
ui = draw_ui(4.0)
logo = Image.open(ROOT / "public/icon-96.png").convert("RGBA")
with tempfile.TemporaryDirectory() as tmp:
silent = os.path.join(tmp, "silent.mp4")
audio = os.path.join(tmp, "sound.wav")
encoder = subprocess.Popen([
"ffmpeg", "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24",
"-s", f"{W}x{H}", "-r", str(FPS), "-i", "-", "-an", "-c:v", "libx264",
"-preset", "medium", "-crf", "17", "-pix_fmt", "yuv420p", "-movflags", "+faststart", silent
], stdin=subprocess.PIPE)
for n in range(FPS * DURATION):
frame = render_frame(n / FPS, dark, light, ui, logo).convert("RGB")
encoder.stdin.write(frame.tobytes())
encoder.stdin.close()
encoder.wait()
make_audio(audio)
subprocess.run([
"ffmpeg", "-y", "-loglevel", "error", "-i", silent, "-i", audio,
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-t", str(DURATION),
"-movflags", "+faststart", str(OUT)
], check=True)
print(OUT)
+2724 -5
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -57,6 +57,9 @@
},
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@ai-sdk/openai-compatible": "^3.0.55",
"@ai-sdk/react": "^4.0.116",
"@modelcontextprotocol/server": "^2.1.0",
"@opennextjs/cloudflare": "^1.19.9",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -67,6 +70,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@tanstack/react-query": "^5.100.11",
"ai": "^7.0.113",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^13.0.3",
"class-variance-authority": "^0.7.1",
@@ -82,8 +86,12 @@
"qrcode": "^1.5.4",
"react": "^19.1.7",
"react-dom": "^19.1.7",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sass": "^1.105.0",
"smtp-server": "^3.19.12",
"tailwind-merge": "^3.6.0",
"workers-ai-provider": "^4.0.0",
"zod": "^4.4.3"
},
"devDependencies": {
+5
View File
@@ -6,6 +6,7 @@ import { WebSocketServer } from "ws";
import { getUserFromSession } from "@/lib/auth/session";
import { getSessionTokenFromRequest } from "@/lib/realtime/utils";
import { processInboundMessage } from "@/lib/email/inbound";
import { processAgentDraftJob } from "@/lib/agent/jobs/utils";
import { processOutboundQueue, type OutboundQueueMessage } from "@/lib/email/send";
import { processWebhookRetry, type WebhookRetryMessage } from "@/lib/email/webhooks";
import { isInboundQueueMessage, isWebhookRetryMessage } from "../worker-utils";
@@ -37,6 +38,9 @@ async function main() {
if (isWebhookRetryMessage(body)) await processWebhookRetry(env, body as WebhookRetryMessage);
else await processOutboundQueue(env, body as OutboundQueueMessage);
});
runtime.agentQueue.setConsumer(async (body) => {
if (typeof body === "object" && body !== null && (body as { kind?: unknown }).kind === "agent.draft" && typeof (body as { jobId?: unknown }).jobId === "string") await processAgentDraftJob(env, (body as { jobId: string }).jobId);
});
const app = next({ dev, dir: process.cwd(), hostname: host, port });
const handle = app.getRequestHandler();
@@ -87,6 +91,7 @@ async function main() {
stopScheduler();
runtime.inboundQueue.stop();
runtime.outboundQueue.stop();
runtime.agentQueue.stop();
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 5000).unref();
};
+7
View File
@@ -36,6 +36,7 @@ export function createNodeRuntime(): NodeRuntime {
const mailer = openMailer(mailerConfig());
const inboundQueue = openQueue("mailflare-inbound");
const outboundQueue = openQueue("mailflare-outbound");
const agentQueue = openQueue("mailflare-agent");
const realtime = new RealtimeHubRegistry();
const publicDir = resolve(optional("PUBLIC_DIR") ?? "./public");
@@ -45,11 +46,16 @@ export function createNodeRuntime(): NodeRuntime {
EMAIL: mailer,
INBOUND_QUEUE: inboundQueue,
OUTBOUND_QUEUE: outboundQueue,
AGENT_QUEUE: agentQueue,
AI_MODEL: optional("AI_MODEL"),
AI_BASE_URL: optional("AI_BASE_URL"),
AI_API_KEY: optional("AI_API_KEY"),
REALTIME: realtime.namespace(),
ASSETS: openAssets(publicDir),
IMAGES: undefined as unknown as CloudflareEnv["IMAGES"],
WORKER_SELF_REFERENCE: undefined as unknown as CloudflareEnv["WORKER_SELF_REFERENCE"],
LOGIN_RATE_LIMIT: openRateLimiter(20, 60),
AGENT_RATE_LIMIT: openRateLimiter(120, 60),
CF_TOKEN: optional("CF_TOKEN"),
CF_API_KEY: optional("CF_API_KEY"),
CF_EMAIL: optional("CF_EMAIL"),
@@ -70,6 +76,7 @@ export function createNodeRuntime(): NodeRuntime {
mailer: mailer as unknown as Mailer,
inboundQueue: inboundQueue as unknown as InProcessQueue,
outboundQueue: outboundQueue as unknown as InProcessQueue,
agentQueue: agentQueue as unknown as InProcessQueue,
realtime,
};
}
+2
View File
@@ -1,9 +1,11 @@
import { runScheduledDatabaseBackup } from "@/lib/backups/runner";
import { runAgentMaintenance } from "@/lib/agent/maintenance";
/** Fire the daily 02:00 UTC backup, matching the cron trigger in wrangler.jsonc. */
export function startScheduler(env: CloudflareEnv) {
let lastRunDay = "";
const timer = setInterval(() => {
runAgentMaintenance(env).catch((error) => console.error("Agent maintenance failed", error));
const now = new Date();
const day = now.toISOString().slice(0, 10);
if (now.getUTCHours() !== 2 || lastRunDay === day) return;
+1
View File
@@ -10,5 +10,6 @@ export type NodeRuntime = {
mailer: Mailer;
inboundQueue: InProcessQueue;
outboundQueue: InProcessQueue;
agentQueue: InProcessQueue;
realtime: RealtimeHubRegistry;
};
+13 -7
View File
@@ -1,9 +1,15 @@
import Link from "next/link";
import { BadgeDollarSign, Globe2, KeyRound, Mail, Palette, Settings, Users, Webhook } from "lucide-react";
import { BadgeDollarSign, Bot, Globe2, KeyRound, Mail, Palette, Settings, Users, Webhook } from "lucide-react";
import { AdminUpdateCard } from "@/components/admin-update-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const sections = [
{
href: "/agent",
title: "Agent",
description: "Choose the email assistant's AI provider and model.",
icon: Bot,
},
{
href: "/mailboxes",
title: "Mailboxes",
@@ -34,12 +40,12 @@ const sections = [
description: "Add and manage user accounts with a Team license.",
icon: Users,
},
// {
// href: "/api-keys",
// title: "API Keys",
// description: "Manage API credentials for programmatic access.",
// icon: KeyRound,
// },
{
href: "/api-keys",
title: "Admin API keys",
description: "Manage API access to domains, accounts, and mailboxes.",
icon: KeyRound,
},
// {
// href: "/webhooks",
// title: "Webhooks",
+97
View File
@@ -0,0 +1,97 @@
"use client";
import { useEffect, useState } from "react";
import { Bot } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { DEFAULT_CLOUDFLARE_MODEL } from "@/lib/agent/provider-constants";
import type { AgentModelOption, AgentProviderPreset } from "@/lib/agent/provider-types";
import type { AgentAdminForm } from "./types";
import { baseUrlForPreset, loadAgentAdminSettings, loadAgentModels, PROVIDER_PRESETS, saveAgentAdminSettings } from "./utils";
const initialForm: AgentAdminForm = { provider: "cloudflare", preset: "openai", baseUrl: "", apiKey: "", model: DEFAULT_CLOUDFLARE_MODEL };
export default function AdminAgentPage() {
const [form, setForm] = useState<AgentAdminForm>(initialForm);
const [loaded, setLoaded] = useState(false);
const [cloudflareAvailable, setCloudflareAvailable] = useState(false);
const [hasSavedKey, setHasSavedKey] = useState(false);
const [savedEndpoint, setSavedEndpoint] = useState("");
const [models, setModels] = useState<AgentModelOption[]>([]);
const [modelSource, setModelSource] = useState<"catalog" | "suggested" | null>(null);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelError, setModelError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
let active = true;
void loadAgentAdminSettings().then((data) => {
if (!active) return;
setForm({ provider: data.config.provider, preset: data.config.preset, baseUrl: data.config.baseUrl, apiKey: "", model: data.config.model });
setCloudflareAvailable(data.cloudflareAvailable);
setHasSavedKey(data.config.hasApiKey);
setSavedEndpoint(`${data.config.preset}|${data.config.baseUrl}`);
setLoaded(true);
}).catch((error) => { if (active) setStatus(error instanceof Error ? error.message : "Could not load agent settings"); });
return () => { active = false; };
}, []);
const canUseSavedKey = hasSavedKey && savedEndpoint === `${form.preset}|${form.baseUrl}`;
useEffect(() => {
if (!loaded) return;
setModels([]);
setModelSource(null);
setModelError(null);
if (form.provider === "compatible" && (!form.baseUrl || (!form.apiKey.trim() && !canUseSavedKey))) {
setModelError(form.preset === "custom" && !form.baseUrl ? "Enter an HTTPS base URL to load models." : "Enter an API key to load models.");
return;
}
const controller = new AbortController();
const timer = window.setTimeout(() => {
setModelsLoading(true);
void loadAgentModels(form, controller.signal).then((data) => {
setModels(data.models);
setModelSource(data.source);
}).catch((error) => {
if (!controller.signal.aborted) setModelError(error instanceof Error ? error.message : "Could not load models");
}).finally(() => { if (!controller.signal.aborted) setModelsLoading(false); });
}, form.provider === "cloudflare" ? 0 : 500);
return () => { window.clearTimeout(timer); controller.abort(); };
}, [loaded, form.provider, form.preset, form.baseUrl, form.apiKey, canUseSavedKey]);
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
setStatus(null);
try {
const data = await saveAgentAdminSettings(form);
setForm((current) => ({ ...current, apiKey: "" }));
setHasSavedKey(data.config.hasApiKey);
setSavedEndpoint(`${data.config.preset}|${data.config.baseUrl}`);
setStatus("Agent provider saved. New chats and auto-drafts will use this model.");
} catch (error) {
setStatus(error instanceof Error ? error.message : "Could not save agent settings");
} finally { setSaving(false); }
}
return <div className="space-y-6">
<div><h1 className="text-3xl font-medium text-neutral-900">Agent</h1><p className="mt-2 text-sm text-neutral-500">Choose the AI provider and model used by the email assistant for chat and drafts.</p></div>
<Card className="rounded-3xl border-0 bg-white p-6">
<CardHeader className="py-0"><CardTitle className="flex items-center gap-2"><Bot className="h-5 w-5" />AI provider</CardTitle><CardDescription>Email content is sent to the selected provider when someone uses the assistant. Sending a draft still requires human approval.</CardDescription></CardHeader>
<CardContent className="pt-6"><form onSubmit={submit} className="space-y-5">
<div className="space-y-2"><Label htmlFor="agent-provider">Provider</Label><select id="agent-provider" className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm" value={form.provider} disabled={!loaded} onChange={(event) => setForm((current) => ({ ...current, provider: event.target.value as AgentAdminForm["provider"], baseUrl: event.target.value === "compatible" ? baseUrlForPreset(current.preset, current.baseUrl) : "", model: event.target.value === "cloudflare" ? DEFAULT_CLOUDFLARE_MODEL : "" }))}><option value="cloudflare">Cloudflare Workers AI</option><option value="compatible">OpenAI-compatible provider</option></select>{form.provider === "cloudflare" && !cloudflareAvailable && <p className="text-xs text-amber-700">Cloudflare Workers AI requires an AI binding on this installation.</p>}</div>
{form.provider === "compatible" && <>
<div className="space-y-2"><Label htmlFor="agent-preset">Provider template</Label><select id="agent-preset" className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm" value={form.preset} onChange={(event) => { const preset = event.target.value as AgentProviderPreset; setForm((current) => ({ ...current, preset, baseUrl: baseUrlForPreset(preset), apiKey: "", model: "" })); }}>{PROVIDER_PRESETS.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}</select></div>
<div className="space-y-2"><Label htmlFor="agent-url">API base URL</Label><Input id="agent-url" type="url" value={form.baseUrl} readOnly={form.preset !== "custom"} placeholder="https://provider.example/v1" onChange={(event) => setForm((current) => ({ ...current, baseUrl: event.target.value, model: "" }))} required /></div>
<div className="space-y-2"><Label htmlFor="agent-key">API key</Label><Input id="agent-key" type="password" value={form.apiKey} autoComplete="new-password" placeholder={canUseSavedKey ? "Saved key (leave blank to keep)" : "Enter API key"} onChange={(event) => setForm((current) => ({ ...current, apiKey: event.target.value }))} /><p className="text-xs text-neutral-500">{canUseSavedKey ? "An API key is saved on the server. Enter a new one to replace it." : "The API key is stored on the server and is never shown again."}</p></div>
</>}
<div className="space-y-2"><Label htmlFor="agent-model">Model</Label><select id="agent-model" className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm" value={models.some((item) => item.id === form.model) ? form.model : ""} onChange={(event) => setForm((current) => ({ ...current, model: event.target.value }))} disabled={!models.length}><option value="">{modelsLoading ? "Loading models…" : models.length ? "Choose a model" : "Models unavailable"}</option>{models.map((item) => <option key={item.id} value={item.id}>{item.name === item.id ? item.id : `${item.name} · ${item.id}`}</option>)}</select>{modelSource === "suggested" && <p className="text-xs text-neutral-500">Showing suggested Cloudflare models because a full catalog is unavailable.</p>}{modelError && <p className="text-xs text-amber-700">{modelError}</p>}<Label htmlFor="agent-model-id" className="block pt-2 text-xs text-neutral-500">Or enter a model ID</Label><Input id="agent-model-id" value={form.model} onChange={(event) => setForm((current) => ({ ...current, model: event.target.value }))} placeholder={form.provider === "cloudflare" ? DEFAULT_CLOUDFLARE_MODEL : "provider/model-id"} required /><p className="text-xs text-neutral-500">Choose a model that supports tool calling so the assistant can read mail and create drafts.</p></div>
{status && <p role="status" className="text-sm text-neutral-700">{status}</p>}
<Button type="submit" disabled={!loaded || saving || !form.model.trim() || (form.provider === "cloudflare" && !cloudflareAvailable) || (form.provider === "compatible" && (!form.baseUrl || (!form.apiKey.trim() && !canUseSavedKey))) }>{saving ? "Saving…" : "Save agent settings"}</Button>
</form></CardContent>
</Card>
</div>;
}
+6
View File
@@ -0,0 +1,6 @@
import type { AgentModelOption, AgentProviderKind, AgentProviderPreset, AgentProviderPublicConfig } from "@/lib/agent/provider-types";
export type AgentAdminConfig = AgentProviderPublicConfig;
export type AgentAdminSettingsResponse = { config: AgentAdminConfig; cloudflareAvailable: boolean; error?: string };
export type AgentAdminModelsResponse = { models: AgentModelOption[]; source: "catalog" | "suggested"; error?: string };
export type AgentAdminForm = { provider: AgentProviderKind; preset: AgentProviderPreset; baseUrl: string; apiKey: string; model: string };
+36
View File
@@ -0,0 +1,36 @@
import { authFetch } from "@/lib/auth/client";
import { PROVIDER_BASE_URLS } from "@/lib/agent/provider-constants";
import type { AgentProviderPreset } from "@/lib/agent/provider-types";
import type { AgentAdminForm, AgentAdminModelsResponse, AgentAdminSettingsResponse } from "./types";
export const PROVIDER_PRESETS: { id: AgentProviderPreset; label: string }[] = [
{ id: "openai", label: "OpenAI" },
{ id: "openrouter", label: "OpenRouter" },
{ id: "groq", label: "Groq" },
{ id: "custom", label: "Custom endpoint" },
];
export function baseUrlForPreset(preset: AgentProviderPreset, current = "") {
return preset === "custom" ? current : PROVIDER_BASE_URLS[preset];
}
export async function loadAgentAdminSettings(): Promise<AgentAdminSettingsResponse> {
const response = await authFetch("/api/admin/agent");
const data = await response.json() as AgentAdminSettingsResponse;
if (!response.ok) throw new Error(data.error || "Could not load agent settings");
return data;
}
export async function loadAgentModels(form: AgentAdminForm, signal?: AbortSignal): Promise<AgentAdminModelsResponse> {
const response = await authFetch("/api/admin/agent/models", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form), signal });
const data = await response.json() as AgentAdminModelsResponse;
if (!response.ok) throw new Error(data.error || "Could not load models");
return data;
}
export async function saveAgentAdminSettings(form: AgentAdminForm): Promise<AgentAdminSettingsResponse> {
const response = await authFetch("/api/admin/agent", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
const data = await response.json() as AgentAdminSettingsResponse;
if (!response.ok) throw new Error(data.error || "Could not save agent settings");
return data;
}
+60 -56
View File
@@ -2,10 +2,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { KeyRound, Plus } from "lucide-react";
import { Copy, KeyRound, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@@ -17,131 +16,135 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { CardGridSkeleton } from "@/components/page-skeletons";
import { List, ListRow } from "@/components/ui/list";
import { SectionRowSkeleton } from "@/components/page-skeletons";
import { authFetch } from "@/lib/auth/client";
import { API_KEY_SCOPES, type ApiKeyScope } from "@/lib/api/scopes";
import type { AdminApiKeyScope } from "@/lib/api/scopes-types";
import type { ApiKey } from "./types";
import { parseApiKeyScopes } from "./utils";
import { ADMIN_KEY_PERMISSIONS, parseApiKeyScopes } from "./utils";
export default function ApiKeysPage() {
const qc = useQueryClient();
const [name, setName] = useState("");
const [scopes, setScopes] = useState<ApiKeyScope[]>(["send", "read"]);
const [scopes, setScopes] = useState<AdminApiKeyScope[]>(["domains"]);
const [mcpAllowed, setMcpAllowed] = useState(false);
const [newKey, setNewKey] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["api-keys"],
queryKey: ["admin-api-keys"],
queryFn: async () => {
const res = await authFetch("/api/api-keys");
const res = await authFetch("/api/admin/api-keys");
if (!res.ok) throw new Error("Could not load admin API keys");
return (await res.json()) as { apiKeys: ApiKey[] };
},
});
const create = useMutation({
mutationFn: async () => {
const res = await authFetch("/api/api-keys", {
const res = await authFetch("/api/admin/api-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, scopes }),
body: JSON.stringify({ name: name.trim(), scopes, mcpAllowed }),
});
const json = (await res.json()) as { key?: string };
if (!res.ok) throw new Error("Failed");
const json = (await res.json()) as { key?: string; error?: string };
if (!res.ok || !json.key) throw new Error(typeof json.error === "string" ? json.error : "Could not create API key");
setNewKey(json.key ?? null);
setCopied(false);
setName("");
},
onSuccess: () => {
setCreateOpen(false);
setScopes(["send", "read"]);
qc.invalidateQueries({ queryKey: ["api-keys"] });
setScopes(["domains"]);
qc.invalidateQueries({ queryKey: ["admin-api-keys"] });
},
});
const revoke = useMutation({
mutationFn: async (id: string) => {
const res = await authFetch(`/api/admin/api-keys?id=${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok) throw new Error("Could not revoke admin API key");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-api-keys"] }),
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold">API Keys</h1>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<h1 className="text-3xl font-medium">Admin API keys</h1>
<Dialog open={createOpen} onOpenChange={(open) => { if (create.isPending) return; setCreateOpen(open); if (!open) { setNewKey(null); setCopied(false); } }}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4" />
New API key
</Button>
</DialogTrigger>
<DialogContent>
<DialogContent className="max-h-[calc(100vh-4rem)] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create API key</DialogTitle>
<DialogDescription>Choose what this key is allowed to do.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<DialogTitle>{newKey ? "API key created" : "Create API key"}</DialogTitle>
<DialogDescription>{newKey ? "Copy this key now. It will only be shown once." : "Name your key and choose the access it needs."}</DialogDescription>
</DialogHeader>
{!newKey && <div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Production app" />
<Label htmlFor="admin-key-name">Key name</Label>
<Input id="admin-key-name" value={name} onChange={(e) => setName(e.target.value)} maxLength={100} placeholder="Production app" />
</div>
<div className="space-y-2">
<Label>Scopes</Label>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{API_KEY_SCOPES.map((scope) => (
<label key={scope} className="flex items-center gap-2 text-sm">
<label className="flex items-start gap-3 text-sm"><Checkbox checked={mcpAllowed} onChange={(event) => setMcpAllowed(event.target.checked)} /><span><strong>Allow MCP access</strong><span className="mt-1 block text-neutral-500">Use this key with an MCP client to manage only the admin areas selected below. It cannot read or send mail.</span></span></label>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Permissions</legend>
<div className="space-y-2">
{ADMIN_KEY_PERMISSIONS.map((scope) => (
<label key={scope.value} className="flex items-start gap-3 text-sm">
<Checkbox
checked={scopes.includes(scope)}
checked={scopes.includes(scope.value)}
className="mt-1"
onChange={(event) =>
setScopes((current) =>
event.target.checked
? [...current, scope]
: current.filter((item) => item !== scope),
? [...current, scope.value]
: current.filter((item) => item !== scope.value),
)
}
/>
{scope}
<span><strong>{scope.label}</strong><span className="block text-neutral-500">{scope.description}</span></span>
</label>
))}
</div>
</div>
</fieldset>
{create.isError && (
<p className="text-sm text-red-600">{(create.error as Error).message}</p>
)}
<Button
onClick={() => create.mutate()}
disabled={!name || scopes.length === 0 || create.isPending}
disabled={!name.trim() || scopes.length === 0 || create.isPending}
>
{create.isPending ? "Creating..." : "Create key"}
</Button>
</div>
</div>}
{newKey && <div className="space-y-2 rounded-xl border border-blue-200 bg-blue-50 p-4"><code className="block break-all rounded bg-white p-2 text-xs">{newKey}</code><div className="flex gap-2"><Button type="button" size="sm" variant="outline" onClick={() => void navigator.clipboard.writeText(newKey).then(() => setCopied(true))}><Copy className="h-4 w-4" />{copied ? "Copied" : "Copy key"}</Button><Button type="button" size="sm" variant="ghost" onClick={() => { setCreateOpen(false); setNewKey(null); setCopied(false); }}>Done</Button></div></div>}
</DialogContent>
</Dialog>
</div>
{newKey && (
<Card className="border-blue-600/10 bg-blue-400/10">
<CardContent className="pt-6">
<p className="text-sm font-medium text-blue-600">Copy your key now:</p>
<code className="block mt-2 text-xs break-all font-bold">{newKey}</code>
</CardContent>
</Card>
)}
<section className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-500">{(data?.apiKeys ?? []).length} total</span>
</div>
{revoke.isError && <p role="alert" className="text-sm text-red-600">{revoke.error.message}</p>}
{isLoading && (
<CardGridSkeleton />
<SectionRowSkeleton />
)}
{!isLoading && (data?.apiKeys ?? []).length === 0 && (
<p className="rounded-lg border border-neutral-200 bg-white px-4 py-3 text-sm text-neutral-500">
<p className="rounded-2xl bg-white px-5 py-4 text-sm text-neutral-500">
No API keys yet
</p>
)}
<div className="grid gap-3">
<List>
{(data?.apiKeys ?? []).map((key) => (
<div
<ListRow
key={key.id}
className="flex min-h-24 items-start gap-3 rounded-lg border border-neutral-200 bg-white p-4 shadow-sm shadow-neutral-100"
className="px-5 py-4"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-600">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-100 text-blue-700">
<KeyRound className="h-5 w-5" />
</span>
<span className="min-w-0 flex-1 space-y-2">
<span className="block truncate text-sm font-semibold text-neutral-900">{key.name}</span>
<span className="flex min-w-0 flex-wrap items-center gap-2"><strong className="truncate text-sm font-semibold text-neutral-900">{key.name}</strong>{key.kind === "mcp" && <Badge variant="outline">MCP</Badge>}</span>
<span className="block truncate no-font-mono text-sm text-neutral-500">{key.prefix}...</span>
<span className="flex flex-wrap gap-1">
{parseApiKeyScopes(key.scopes).map((scope) => (
@@ -151,9 +154,10 @@ export default function ApiKeysPage() {
))}
</span>
</span>
</div>
<Button type="button" size="sm" variant="outline" disabled={revoke.isPending} onClick={() => { if (window.confirm(`Revoke “${key.name}”? Apps using this key will lose access immediately.`)) revoke.mutate(key.id); }}><Trash2 className="h-4 w-4" />Revoke</Button>
</ListRow>
))}
</div>
</List>
</section>
</div>
);
+1
View File
@@ -2,6 +2,7 @@ export type ApiKey = {
id: string;
name: string;
prefix: string;
kind: "legacy" | "mcp";
scopes: string;
createdAt?: string;
lastUsedAt?: string | null;
+8
View File
@@ -1,3 +1,11 @@
import type { AdminApiKeyScope } from "@/lib/api/scopes-types";
export const ADMIN_KEY_PERMISSIONS: { value: AdminApiKeyScope; label: string; description: string }[] = [
{ value: "domains", label: "Manage domains", description: "Add and remove domains, and manage their DNS setup." },
{ value: "accounts", label: "Manage accounts", description: "Create and update accounts. Requires a Team license." },
{ value: "mailboxes", label: "Manage mailboxes", description: "Create, update, and remove mailboxes." },
];
export function parseApiKeyScopes(scopes: string): string[] {
try {
const parsed = JSON.parse(scopes);
+1 -1
View File
@@ -35,7 +35,7 @@ export default function BrandingPage() {
<CardDescription>This installation continues to use the original Mailflare name, app icon, and favicon.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 pt-6 sm:flex-row">
<Button asChild><a href="https://app.paymug.co/buy/mailflare-pro" target="_blank" rel="noopener noreferrer">Buy Pro · $19 <ExternalLink className="h-4 w-4" /></a></Button>
<Button asChild><a href="https://app.paymug.co/buy/mailflare-pro" target="_blank" rel="noopener noreferrer">Buy Pro · $29 <ExternalLink className="h-4 w-4" /></a></Button>
<Button asChild variant="outline"><a href="https://app.paymug.co/buy/mailflare-team" target="_blank" rel="noopener noreferrer">Buy Team · from $249 <ExternalLink className="h-4 w-4" /></a></Button>
</CardContent>
</Card>
+1 -1
View File
@@ -7,7 +7,7 @@ import type { ActivatableLicensePlan, LicenseAction, LicenseResponse } from "./t
export const LICENSE_PLANS: LicensePlan[] = [
{
name: "Pro",
price: 19,
price: 29,
originalPrice: 39,
description: "A one-time license for one account, including one year of product updates.",
features: ["Custom branding", "All future Pro features", "Keep the licensed version forever"],
@@ -0,0 +1,28 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { authFetch } from "@/lib/auth/client";
import { SendReview } from "@/components/agent/send-review";
import type { AgentReviewResponse } from "@/components/agent/send-review-types";
export default function AgentReviewPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const [request, setRequest] = useState<AgentReviewResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void authFetch(`/api/agent/approvals/${id}`).then(async (response) => {
const data = await response.json() as AgentReviewResponse;
if (!response.ok) throw new Error(data.error || "Could not load review");
setRequest(data);
}).catch((cause) => setError(cause instanceof Error ? cause.message : "Could not load review"));
}, [id]);
return <div className="p-8">
<h1 className="text-2xl font-semibold">AI draft review</h1>
{error && <p className="mt-4 text-red-600">{error}</p>}
{!error && !request && <p className="mt-4">Loading review…</p>}
{request && (request.status !== "pending" || request.stale || !request.snapshot) && <p className="mt-4">This review is {request.stale ? "out of date" : request.status}. Open the draft and request a new review if needed.</p>}
{request?.status === "pending" && !request.stale && request.snapshot && <SendReview approvalId={id} snapshot={request.snapshot} onClose={() => router.push("/drafts")} onSent={() => router.push("/sent")} />}
</div>;
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { authFetch } from "@/lib/auth/client";
const emailPath = /^\/(?:inbox|sent|archived|spam|trash|starred|snoozed|drafts)\/[^/]+$|^\/folders\/[^/]+\/[^/]+$/;
export function useDashboardState() {
const pathname = usePathname();
const router = useRouter();
const [assistantOpen, setAssistantOpen] = useState(false);
const [assistantFullSize, setAssistantFullSize] = useState(false);
const [storagePrefix, setStoragePrefix] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void authFetch("/api/auth/me", { redirectOnUnauthorized: false }).then(async (response) => {
if (!response.ok) return;
const data = await response.json() as { user?: { id?: string } };
if (cancelled || !data.user?.id) return;
const prefix = `mailflare-dashboard:${data.user.id}`;
try {
setAssistantOpen(localStorage.getItem(`${prefix}:assistant-open`) === "true");
setAssistantFullSize(localStorage.getItem(`${prefix}:assistant-full-size`) === "true");
if (window.location.pathname === "/inbox") {
const savedEmail = localStorage.getItem(`${prefix}:current-email`);
if (savedEmail && emailPath.test(savedEmail)) router.replace(savedEmail);
}
} catch { /* Storage is optional. */ }
setStoragePrefix(prefix);
}).catch(() => undefined);
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!storagePrefix) return;
try {
localStorage.setItem(`${storagePrefix}:assistant-open`, String(assistantOpen));
localStorage.setItem(`${storagePrefix}:assistant-full-size`, String(assistantFullSize));
} catch { /* Storage is optional. */ }
}, [storagePrefix, assistantOpen, assistantFullSize]);
useEffect(() => {
if (!storagePrefix || !emailPath.test(pathname)) return;
try { localStorage.setItem(`${storagePrefix}:current-email`, pathname); }
catch { /* Storage is optional. */ }
}, [storagePrefix, pathname]);
return { assistantOpen, setAssistantOpen, assistantFullSize, setAssistantFullSize };
}
+147 -131
View File
@@ -1,9 +1,9 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useParams, usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { ArrowLeft, Cloud, ExternalLink } from "lucide-react";
import { ArrowLeft, Cloud, Columns2, ExternalLink, List, Rows2 } from "lucide-react";
import dayjs from "dayjs";
import { MarkAsRead } from "@/components/mark-read";
import { useSelectedMailbox } from "@/components/mailbox-provider";
@@ -21,6 +21,8 @@ import { ThreadMessageActions } from "@/components/messages/thread-message-actio
import { SpamScoreDetails } from "@/components/messages/spam-score-details";
import { useMessageThread } from "@/components/messages/use-message-thread";
import { useLatestMessagesFirst } from "@/components/messages/use-latest-messages-first";
import { useMessageListVisibility } from "@/components/messages/message-list-visibility";
import { Tooltip } from "@/components/ui/tooltip";
import { getMessageBackHref } from "@/components/message-actions/utils";
import { getEmailAddress, getEmailDisplayName, splitEmailAddressList } from "@/lib/email/address";
import type { MessageAttachment, MessageDetailResponse } from "./types";
@@ -36,9 +38,12 @@ import {
import { extractCloudAttachments } from "./cloud-attachment-utils";
import { sanitizeEmailHtml } from "./email-html-sanitizer";
import { collapseQuotedEmailHtml } from "./quote-collapse-utils";
import clsx from "clsx";
import { useAssistantOpen } from "@/components/agent/assistant-open-state";
export default function MessageDetailPage() {
const params = useParams<{ messageId: string }>();
const pathname = usePathname();
const { selectedMailbox, mailboxes } = useSelectedMailbox();
const messageId = params.messageId;
const [data, setData] = useState<MessageDetailResponse | null>(null);
@@ -47,8 +52,11 @@ export default function MessageDetailPage() {
useState<MessageAttachment | null>(null);
const [threadExpanded, setThreadExpanded] = useState(false);
const [latestMessagesFirst] = useLatestMessagesFirst();
const { visible: messageListVisible, toggle: toggleMessageList } = useMessageListVisibility();
usePageLoading(loading);
const thread = useMessageThread(messageId, data?.message?.threadId);
const assistantVisible = useAssistantOpen();
const isAnyPanelVisible = messageListVisible || assistantVisible;
useEffect(() => {
let cancelled = false;
@@ -139,8 +147,10 @@ export default function MessageDetailPage() {
return (
<div className="h-full overflow-y-auto overscroll-contain scrollbar-gutter-stable">
{!message.read && <MarkAsRead messageId={message.id} />}
<div className="flex pt-3 pb-2.75 items-center justify-between px-2 border-b border-neutral-200 sticky top-0 bg-white z-40">
<div className="flex-1" />
<div className="flex py-2 h-14 items-center justify-between px-2 border-b border-neutral-200 sticky top-0 bg-white z-40 gap-4">
<Tooltip label={ assistantVisible ? null : messageListVisible ? "Hide email list" : "Show email list"} className="hidden lg:inline-flex"><button type="button" className={clsx( assistantVisible ? "opacity-40" : messageListVisible ? "" : "opacity-60 hover:opacity-100", !assistantVisible && "hover:bg-neutral-100 hover:text-neutral-900", "relative z-10 shrink-0 rounded-full p-2 text-neutral-600 duration-200")} onClick={toggleMessageList} disabled={assistantVisible} aria-label={messageListVisible ? "Hide email list" : "Show email list"} aria-pressed={messageListVisible}><Columns2 size={18} /></button></Tooltip>
<Tooltip label="Show email list" className="inline-flex lg:hidden"><Link href={pathname.slice(0, pathname.lastIndexOf("/")) || "/inbox"} className="relative z-10 inline-flex shrink-0 rounded-full p-2 text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900" aria-label="Show email list"><List size={18} /></Link></Tooltip>
<div className="min-w-0 flex-1" />
{/* <div className="flex items-center flex-row gap-6">
<Link
href={getMessageBackHref(message.direction, message.status)}
@@ -166,17 +176,20 @@ export default function MessageDetailPage() {
bodyHtml={body?.htmlBody}
/>
</div>
<div className="px-6 pb-2 pt-4">
<h1 className="text-2xl text-neutral-900">
{message.subject ?? "(no subject)"}
</h1>
<h1 className={clsx(!isAnyPanelVisible ? "pl-16" : "pl-10","pr-6 pb-2 pt-4 text-2xl font-medium text-neutral-900")} title={message.subject ?? "(no subject)"}>
{message.subject ?? "(no subject)"}
</h1>
<div className="px-6">
<div className="mx-auto w-full max-w-[640px]">
<SpamScoreDetails
score={message.spamScore}
verdict={message.spamVerdict}
signals={message.spamSignals}
analysisError={message.spamAnalysisError}
/>
</div>
</div>
<SpamScoreDetails
score={message.spamScore}
verdict={message.spamVerdict}
signals={message.spamSignals}
analysisError={message.spamAnalysisError}
/>
<ConversationThread
currentMessageId={message.id}
position={latestMessagesFirst ? "after" : "before"}
@@ -189,132 +202,135 @@ export default function MessageDetailPage() {
expandedAll={threadExpanded}
onExpandedAllChange={setThreadExpanded}
/>
<article className="px-6 py-4">
<div className="flex items-start justify-between pb-5">
<div className="flex min-w-0 items-start gap-3">
<ContactAvatar
mailboxId={message.mailboxId}
address={message.fromAddr}
name={fromName}
hasManagedAvatar={message.direction === "inbound"}
managedAvatarUrl={message.direction === "outbound" && message.mailboxId
? `/api/mailboxes/${message.mailboxId}/avatar`
: undefined}
className="mt-2"
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-neutral-900 mt-1.25 flex flex-row items-center gap-1 w-full overflow-hidden">
<b className="whitespace-nowrap">
{message.direction === "inbound" ? (
<ContactDetailsTrigger
mailboxId={message.mailboxId}
address={message.fromAddr}
name={fromName}
/>
<article className={clsx("px-6 py-4", !isAnyPanelVisible && "pl-12")}>
<div className="w-full">
<div className="flex items-start justify-between pb-5 pl-4">
<div className="flex min-w-0 items-start gap-3">
<ContactAvatar
mailboxId={message.mailboxId}
address={message.fromAddr}
name={fromName}
hasManagedAvatar={message.direction === "inbound"}
managedAvatarUrl={message.direction === "outbound" && message.mailboxId
? `/api/mailboxes/${message.mailboxId}/avatar`
: undefined}
className="mt-2"
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-neutral-900 mt-1.25 flex flex-row items-center gap-1 w-full overflow-hidden">
<b className="whitespace-nowrap">
{message.direction === "inbound" ? (
<ContactDetailsTrigger
mailboxId={message.mailboxId}
address={message.fromAddr}
name={fromName}
/>
) : (
fromName
)}
</b>{" "}
<span className="text-neutral-500 text-xs flex-1 overflow-hidden text-ellipsis min-w-0">&lt;{fromAddress}&gt;</span>
</p>
<p className="text-xs text-neutral-500">
to{" "}
{message.direction === "inbound" && toEntries.length <= 1 ? (
toName
) : (
fromName
<RecipientList
entries={toEntries}
mailboxId={message.mailboxId}
firstName={message.direction === "outbound" ? toName : undefined}
/>
)}
</b>{" "}
<span className="text-neutral-500 text-xs flex-1 overflow-hidden text-ellipsis min-w-0">&lt;{fromAddress}&gt;</span>
</p>
<p className="text-xs text-neutral-500">
to{" "}
{message.direction === "inbound" && toEntries.length <= 1 ? (
toName
) : (
<RecipientList
entries={toEntries}
mailboxId={message.mailboxId}
firstName={message.direction === "outbound" ? toName : undefined}
/>
</p>
{ccEntries.length > 0 && (
<p className="text-xs text-neutral-500">
cc <RecipientList entries={ccEntries} mailboxId={message.mailboxId} />
</p>
)}
{bccEntries.length > 0 && (
<p className="text-xs text-neutral-500">
bcc <RecipientList entries={bccEntries} mailboxId={message.mailboxId} />
</p>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<p className="text-xs">
{dayjs(message.createdAt).format("MMM DD, YYYY, hh:mmA")}
</p>
{ccEntries.length > 0 && (
<p className="text-xs text-neutral-500">
cc <RecipientList entries={ccEntries} mailboxId={message.mailboxId} />
</p>
)}
{bccEntries.length > 0 && (
<p className="text-xs text-neutral-500">
bcc <RecipientList entries={bccEntries} mailboxId={message.mailboxId} />
</p>
)}
<ThreadMessageActions
message={currentThreadMessage}
mailboxId={message.mailboxId}
ownAddress={ownAddress}
ownAddresses={ownAddresses}
starOnly={!thread.messages.some((threadMessage) => threadMessage.id === message.id) || !thread.messages.some((threadMessage) => threadMessage.id !== message.id)}
/>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<p className="text-xs">
{dayjs(message.createdAt).format("MMM DD, YYYY, hh:mmA")}
</p>
<ThreadMessageActions
message={currentThreadMessage}
mailboxId={message.mailboxId}
ownAddress={ownAddress}
ownAddresses={ownAddresses}
/>
<div className="prose max-w-none pl-16 text-neutral-900">
{htmlBody ? (
<div className="email-body mx-auto" dangerouslySetInnerHTML={{ __html: htmlBody }} />
) : (
<pre className="whitespace-pre-wrap text-sm text mx-auto">
{cloudAttachmentResult.content}
</pre>
)}
{quotedHtml && <QuotedEmailToggle html={quotedHtml} />}
{bodyDisplay.quotedContent.map((quotedContent) => (
<PreviousMessage
key={`${quotedContent.dateLine}-${quotedContent.content.slice(0, 24)}`}
message={quotedContent}
/>
))}
</div>
</div>
<div className="prose max-w-none text-neutral-900">
{htmlBody ? (
<div className="email-body mx-auto" dangerouslySetInnerHTML={{ __html: htmlBody }} />
) : (
<pre className="whitespace-pre-wrap text-sm text mx-auto">
{cloudAttachmentResult.content}
</pre>
{cloudAttachmentResult.attachments.length > 0 && (
<section className="mt-8 border-t border-neutral-100 py-6 pl-16">
<h2 className="mb-3 text-sm font-semibold text-neutral-900">
Cloud files ({cloudAttachmentResult.attachments.length})
</h2>
<div className="grid gap-2 sm:grid-cols-2">
{cloudAttachmentResult.attachments.map((attachment) => (
<a
key={attachment.id}
href={attachment.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-3 rounded-lg border border-neutral-200 p-3 text-left hover:border-blue-200 hover:bg-blue-50/40"
>
<Cloud className="h-5 w-5 shrink-0 text-blue-600" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-neutral-900">
{attachment.filename}
</span>
<span className="block text-xs text-neutral-500">
Open from {attachment.provider}
</span>
</span>
<ExternalLink className="h-4 w-4 shrink-0 text-neutral-400" />
</a>
))}
</div>
</section>
)}
{attachments.length > 0 && (
<section className="mt-8 border-t border-neutral-100 py-6 pl-16">
<h2 className="mb-3 text-sm font-semibold text-neutral-900">
Attachments ({attachments.length})
</h2>
<div className="grid gap-2 sm:grid-cols-2">
{attachments.map((attachment) => (
<MessageAttachmentCard
key={attachment.id}
attachment={attachment}
messageId={message.id}
onPreview={setPreviewAttachment}
/>
))}
</div>
</section>
)}
{quotedHtml && <QuotedEmailToggle html={quotedHtml} />}
{bodyDisplay.quotedContent.map((quotedContent) => (
<PreviousMessage
key={`${quotedContent.dateLine}-${quotedContent.content.slice(0, 24)}`}
message={quotedContent}
/>
))}
</div>
{cloudAttachmentResult.attachments.length > 0 && (
<section className="mt-8 border-t border-neutral-100 py-6">
<h2 className="mb-3 text-sm font-semibold text-neutral-900">
Cloud files ({cloudAttachmentResult.attachments.length})
</h2>
<div className="grid gap-2 sm:grid-cols-2">
{cloudAttachmentResult.attachments.map((attachment) => (
<a
key={attachment.id}
href={attachment.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-3 rounded-lg border border-neutral-200 p-3 text-left hover:border-blue-200 hover:bg-blue-50/40"
>
<Cloud className="h-5 w-5 shrink-0 text-blue-600" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-neutral-900">
{attachment.filename}
</span>
<span className="block text-xs text-neutral-500">
Open from {attachment.provider}
</span>
</span>
<ExternalLink className="h-4 w-4 shrink-0 text-neutral-400" />
</a>
))}
</div>
</section>
)}
{attachments.length > 0 && (
<section className="mt-8 border-t border-neutral-100 py-6">
<h2 className="mb-3 text-sm font-semibold text-neutral-900">
Attachments ({attachments.length})
</h2>
<div className="grid gap-2 sm:grid-cols-2">
{attachments.map((attachment) => (
<MessageAttachmentCard
key={attachment.id}
attachment={attachment}
messageId={message.id}
onPreview={setPreviewAttachment}
/>
))}
</div>
</section>
)}
</article>
<ConversationThread
currentMessageId={message.id}
+19 -4
View File
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { HelpCircle } from "lucide-react";
import { HelpCircle, Sparkles } from "lucide-react";
import { AuthGuard } from "@/components/auth/auth-guard";
import { ComposeProvider } from "@/components/compose/compose-context";
import { FloatingComposer } from "@/components/compose/floating-composer";
@@ -9,17 +9,24 @@ import { MailSearchInput } from "@/components/mail-search/mail-search-input";
import { MailSearchProvider } from "@/components/mail-search/mail-search-context";
import { MailboxProvider } from "@/components/mailbox-provider";
import { MailboxSelector } from "@/components/mailbox-selector";
import { AgentPanel } from "@/components/agent/agent-panel";
import { AssistantOpenContext } from "@/components/agent/assistant-open-state";
import { Button } from "@/components/ui/button";
import { LicenseIndicator } from "@/components/license-indicator";
import { DashboardNav } from "@/components/dashboard-nav";
import { SidebarProvider } from "@/components/sidebar-state";
import { SidebarResizeBoundary } from "@/components/sidebar-resize-boundary";
import { ShortcutsProvider } from "@/components/shortcuts";
import clsx from "clsx";
import { useDashboardState } from "./dashboard-state";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const { assistantOpen, setAssistantOpen, assistantFullSize, setAssistantFullSize } = useDashboardState();
return (
<AuthGuard>
<SidebarProvider>
@@ -45,11 +52,19 @@ export default function DashboardLayout({
<HelpCircle className="h-5 w-5" />
</Link>
<LicenseIndicator />
<Button type="button" variant="ghost" size="sm" className={assistantOpen ? "bg-blue-50 text-blue-700" : "text-neutral-600"} onClick={() => { setAssistantOpen((current) => !current); setAssistantFullSize(false); }} aria-label={assistantOpen ? "Close email assistant" : "Open email assistant"} aria-expanded={assistantOpen} aria-controls="email-assistant-panel"><Sparkles className="h-5 w-5" /></Button>
<MailboxSelector />
</header>
<main className="min-h-0 flex-1 overflow-y-auto overscroll-contain rounded-tl-3xl bg-white scrollbar-gutter-stable">
{children}
</main>
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
<AssistantOpenContext.Provider value={assistantOpen}>
<main className={clsx("rounded-t-3xl min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain bg-white scrollbar-gutter-stable")} aria-hidden={assistantOpen && assistantFullSize} inert={assistantOpen && assistantFullSize}>
{children}
</main>
</AssistantOpenContext.Provider>
<aside className={clsx(assistantFullSize ? "pl-0" : "pl-4", `min-h-0 min-w-0 shrink-0 overflow-hidden transition-[width] duration-300 ease-in-out motion-reduce:transition-none pr-2 pb-2`, assistantOpen ? "" : "opacity-0")} style={{ width: assistantOpen ? assistantFullSize ? "100%" : "min(390px, 100%)" : "0px" }} aria-hidden={!assistantOpen} inert={!assistantOpen}>
<AgentPanel open={assistantOpen} fullSize={assistantFullSize} onToggleFullSize={() => setAssistantFullSize((current) => !current)} onClose={() => { setAssistantOpen(false); setAssistantFullSize(false); }} />
</aside>
</div>
</div>
<FloatingComposer />
</div>
@@ -0,0 +1,5 @@
import { ApiKeysSettings } from "@/components/settings/api-keys-settings";
export default function ApiKeysSettingsPage() {
return <div className="py-4"><ApiKeysSettings /></div>;
}
@@ -0,0 +1,15 @@
import { EmailClientsSettings } from "@/components/settings/email-clients-settings";
export default function EmailAppsSettingsPage() {
return <div className="space-y-8 py-4">
<section className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-neutral-900">App Passwords</h1>
<p className="mt-1 text-sm text-neutral-500">Use your mail from a desktop or mobile app over JMAP.</p>
</div>
<div className="space-y-4 rounded-3xl bg-white p-6">
<EmailClientsSettings />
</div>
</section>
</div>;
}
@@ -0,0 +1,27 @@
import { ChangePasswordForm } from "@/components/settings/change-password-form";
import { MfaSettings } from "@/components/settings/mfa-settings";
export default function SecuritySettingsPage() {
return <div className="space-y-8 py-4">
<section className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-neutral-900">Security</h1>
<p className="mt-1 text-sm text-neutral-500">Manage how you sign in to your account.</p>
</div>
<div className="space-y-4 rounded-3xl bg-white p-6">
<div>
<h2 className="text-lg font-semibold text-neutral-900">Change password</h2>
<p className="mt-1 text-sm text-neutral-500">Use at least 8 characters for your new password.</p>
</div>
<ChangePasswordForm />
</div>
<div className="space-y-4 rounded-3xl bg-white p-6">
<div>
<h2 className="text-lg font-semibold text-neutral-900">Two-factor authentication</h2>
<p className="mt-1 text-sm text-neutral-500">Require a code from an authenticator app when signing in.</p>
</div>
<MfaSettings />
</div>
</section>
</div>;
}
+1
View File
@@ -0,0 +1 @@
export { POST } from "./utils";
+28
View File
@@ -0,0 +1,28 @@
import { z } from "zod";
import { getEnv } from "@/lib/cloudflare";
import { requireSessionUser } from "@/lib/api/auth";
import { listCloudflareAgentModels, listCompatibleAgentModels } from "@/lib/agent/provider-models";
import { hasValidSessionMutationOrigin } from "@/lib/auth/origin";
const requestSchema = z.object({
provider: z.enum(["cloudflare", "compatible"]),
preset: z.enum(["openai", "openrouter", "groq", "custom"]),
baseUrl: z.string().max(500),
apiKey: z.string().max(2_000).optional(),
});
export async function POST(request: Request) {
const env = getEnv();
const session = await requireSessionUser(env, request);
if (session.error) return session.error;
if (session.user.role !== "admin") return Response.json({ error: "Forbidden" }, { status: 403 });
if (!hasValidSessionMutationOrigin(request)) return Response.json({ error: "Invalid origin" }, { status: 403 });
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return Response.json({ error: "Invalid provider details" }, { status: 400 });
try {
const result = parsed.data.provider === "cloudflare" ? await listCloudflareAgentModels(env) : await listCompatibleAgentModels(env, parsed.data);
return Response.json(result, { headers: { "Cache-Control": "no-store" } });
} catch (error) {
return Response.json({ error: error instanceof Error ? error.message : "Unable to load models" }, { status: 400 });
}
}
+1
View File
@@ -0,0 +1 @@
export { GET, PUT } from "./utils";
+59
View File
@@ -0,0 +1,59 @@
import { z } from "zod";
import { getDb } from "@/db";
import { appSettings } from "@/db/schema";
import { getEnv } from "@/lib/cloudflare";
import { requireSessionUser } from "@/lib/api/auth";
import { getAgentProviderConfig, getAgentProviderPublicConfig, resolveAgentBaseUrl } from "@/lib/agent/provider";
import { AGENT_SETTINGS_ID, DEFAULT_CLOUDFLARE_MODEL } from "@/lib/agent/provider-constants";
import { hasValidSessionMutationOrigin } from "@/lib/auth/origin";
const providerSchema = z.object({
provider: z.enum(["cloudflare", "compatible"]),
preset: z.enum(["openai", "openrouter", "groq", "custom"]),
baseUrl: z.string().max(500),
apiKey: z.string().max(2_000).optional(),
model: z.string().trim().min(1).max(200),
});
async function authorize(request: Request) {
const env = getEnv();
const session = await requireSessionUser(env, request);
if (session.error) return { env, error: session.error };
if (session.user.role !== "admin") return { env, error: Response.json({ error: "Forbidden" }, { status: 403 }) };
return { env, error: null };
}
export async function GET(request: Request) {
const access = await authorize(request);
if (access.error) return access.error;
return Response.json({ config: await getAgentProviderPublicConfig(access.env), cloudflareAvailable: !!access.env.AI }, { headers: { "Cache-Control": "no-store" } });
}
export async function PUT(request: Request) {
const access = await authorize(request);
if (access.error) return access.error;
if (!hasValidSessionMutationOrigin(request)) return Response.json({ error: "Invalid origin" }, { status: 403 });
const parsed = providerSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return Response.json({ error: "Invalid provider settings" }, { status: 400 });
const input = parsed.data;
if (input.provider === "cloudflare" && !access.env.AI) return Response.json({ error: "Cloudflare Workers AI binding is unavailable" }, { status: 400 });
let baseUrl: string | null = null;
let apiKey: string | null = null;
if (input.provider === "compatible") {
try { baseUrl = resolveAgentBaseUrl(input.preset, input.baseUrl); }
catch { return Response.json({ error: "Enter a valid HTTPS provider base URL" }, { status: 400 }); }
const current = await getAgentProviderConfig(access.env);
apiKey = input.apiKey?.trim() || (current.provider === "compatible" && current.preset === input.preset && current.baseUrl === baseUrl ? current.apiKey : "") || null;
if (!apiKey) return Response.json({ error: "Enter an API key for this provider" }, { status: 400 });
}
const values = {
agentProvider: input.provider,
agentPreset: input.provider === "compatible" ? input.preset : null,
agentBaseUrl: baseUrl,
agentApiKey: apiKey,
agentModel: input.provider === "cloudflare" ? input.model || DEFAULT_CLOUDFLARE_MODEL : input.model,
updatedAt: new Date(),
};
await getDb(access.env).insert(appSettings).values({ id: AGENT_SETTINGS_ID, ...values }).onConflictDoUpdate({ target: appSettings.id, set: values });
return Response.json({ config: await getAgentProviderPublicConfig(access.env), cloudflareAvailable: !!access.env.AI }, { headers: { "Cache-Control": "no-store" } });
}
+1
View File
@@ -0,0 +1 @@
export { GET, POST, DELETE } from "./utils";
+75
View File
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { getEnv } from "@/lib/cloudflare";
import { getDb } from "@/db";
import { agentSendApprovals, apiKeys } from "@/db/schema";
import { requireSessionUser } from "@/lib/api/auth";
import { generateApiKey, parseScopes, scopesToJson } from "@/lib/api-keys";
import { ADMIN_API_KEY_SCOPES } from "@/lib/api/scopes";
import { newId } from "@/lib/ids";
const adminScopes = new Set<string>(ADMIN_API_KEY_SCOPES);
const createKeySchema = z.object({
name: z.string().trim().min(1).max(100),
scopes: z.array(z.enum([...ADMIN_API_KEY_SCOPES])).min(1),
mcpAllowed: z.boolean().default(false),
});
async function authorize(request: Request) {
const env = getEnv();
const session = await requireSessionUser(env, request);
if (session.error) return { env, user: null, error: session.error };
if (session.user.role !== "admin") return { env, user: null, error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
return { env, user: session.user, error: null };
}
export async function GET(request: Request) {
const access = await authorize(request);
if (access.error) return access.error;
const rows = await getDb(access.env).select({
id: apiKeys.id,
name: apiKeys.name,
prefix: apiKeys.prefix,
kind: apiKeys.kind,
scopes: apiKeys.scopes,
createdAt: apiKeys.createdAt,
lastUsedAt: apiKeys.lastUsedAt,
}).from(apiKeys).where(eq(apiKeys.userId, access.user!.id));
return NextResponse.json({ apiKeys: rows.map((row) => ({ ...row, scopes: scopesToJson(parseScopes(row.scopes).filter((scope) => adminScopes.has(scope))) })).filter((row) => parseScopes(row.scopes).length > 0) });
}
export async function POST(request: Request) {
const access = await authorize(request);
if (access.error) return access.error;
if (request.headers.get("Origin") !== new URL(request.url).origin) return NextResponse.json({ error: "Invalid origin" }, { status: 403 });
const parsed = createKeySchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const db = getDb(access.env);
const { fullKey, prefix, hash } = generateApiKey();
const id = newId("key");
await db.insert(apiKeys).values({
id,
kind: parsed.data.mcpAllowed ? "mcp" : "legacy",
userId: access.user!.id,
name: parsed.data.name,
prefix,
keyHash: hash,
scopes: scopesToJson([...new Set(parsed.data.scopes)]),
});
return NextResponse.json({ key: fullKey });
}
export async function DELETE(request: Request) {
const access = await authorize(request);
if (access.error) return access.error;
if (request.headers.get("Origin") !== new URL(request.url).origin) return NextResponse.json({ error: "Invalid origin" }, { status: 403 });
const id = new URL(request.url).searchParams.get("id");
if (!id) return NextResponse.json({ error: "Key required" }, { status: 400 });
const db = getDb(access.env);
const [key] = await db.select({ id: apiKeys.id, scopes: apiKeys.scopes }).from(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.userId, access.user!.id))).limit(1);
if (!key || !parseScopes(key.scopes).some((scope) => adminScopes.has(scope))) return NextResponse.json({ error: "Key not found" }, { status: 404 });
await db.update(agentSendApprovals).set({ status: "cancelled" }).where(and(eq(agentSendApprovals.requestKeyId, id), eq(agentSendApprovals.status, "pending")));
await db.delete(apiKeys).where(eq(apiKeys.id, id));
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,18 @@
import { z } from "zod";
import { getEnv } from "@/lib/cloudflare";
import { getCurrentUser } from "@/lib/auth/cookies";
import { hasValidSessionMutationOrigin } from "@/lib/auth/origin";
import { confirmAgentAction } from "@/lib/agent/actions";
const schema = z.object({ toolMessageId: z.string().min(1) });
export async function POST(request: Request) {
const env = getEnv();
const user = await getCurrentUser(env, request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
if (!hasValidSessionMutationOrigin(request)) return Response.json({ error: "Invalid origin" }, { status: 403 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return Response.json({ error: "Invalid request" }, { status: 400 });
try { return Response.json(await confirmAgentAction(env, user, parsed.data.toolMessageId)); }
catch (error) { return Response.json({ error: error instanceof Error ? error.message : "Could not approve action" }, { status: 409 }); }
}
@@ -0,0 +1,15 @@
import { getEnv } from "@/lib/cloudflare";
import { getCurrentUser } from "@/lib/auth/cookies";
import { confirmAgentSend } from "@/lib/agent/approvals/utils";
import { hasValidSessionMutationOrigin } from "@/lib/auth/origin";
type Params = { params: Promise<{ id: string }> };
export async function POST(request: Request, { params }: Params) {
const env = getEnv();
const user = await getCurrentUser(env, request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
if (!hasValidSessionMutationOrigin(request)) return Response.json({ error: "Invalid origin" }, { status: 403 });
try { return Response.json(await confirmAgentSend(env, user, (await params).id)); }
catch (error) { return Response.json({ error: error instanceof Error ? error.message : "Send could not be confirmed" }, { status: 409 }); }
}
+13
View File
@@ -0,0 +1,13 @@
import { getEnv } from "@/lib/cloudflare";
import { getCurrentUser } from "@/lib/auth/cookies";
import { getAgentSendRequest } from "@/lib/agent/approvals/utils";
type Params = { params: Promise<{ id: string }> };
export async function GET(request: Request, { params }: Params) {
const env = getEnv();
const user = await getCurrentUser(env, request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
try { return Response.json(await getAgentSendRequest(env, user, (await params).id)); }
catch { return Response.json({ error: "Approval not found" }, { status: 404 }); }
}
+18
View File
@@ -0,0 +1,18 @@
import { z } from "zod";
import { getEnv } from "@/lib/cloudflare";
import { getCurrentUser } from "@/lib/auth/cookies";
import { requestAgentSend } from "@/lib/agent/approvals/utils";
import { hasValidSessionMutationOrigin } from "@/lib/auth/origin";
const schema = z.object({ draftId: z.string().min(1), expectedRevision: z.number().int().positive() });
export async function POST(request: Request) {
const env = getEnv();
const user = await getCurrentUser(env, request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
if (!hasValidSessionMutationOrigin(request)) return Response.json({ error: "Invalid origin" }, { status: 403 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return Response.json({ error: "Invalid request" }, { status: 400 });
try { return Response.json(await requestAgentSend(env, user, parsed.data.draftId, parsed.data.expectedRevision)); }
catch (error) { return Response.json({ error: error instanceof Error ? error.message : "Could not create approval" }, { status: 409 }); }
}
+27
View File
@@ -0,0 +1,27 @@
import { z } from "zod";
import { getEnv } from "@/lib/cloudflare";
import { getCurrentUser } from "@/lib/auth/cookies";
import { createAgentChatStream } from "@/lib/agent/chat";
import { allowAgentRequest } from "@/lib/agent/rate-limit";
import { readJsonBody } from "@/lib/http/request";
import { hasValidSessionMutationOrigin } from "@/lib/auth/origin";
import { agentProviderErrorMessage } from "@/lib/agent/errors";
const schema = z.object({ mailboxId: z.string().min(1), conversationId: z.string().nullish(), text: z.string().trim().min(1).max(4_000) });
export async function POST(request: Request) {
const env = getEnv();
const user = await getCurrentUser(env, request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
if (!hasValidSessionMutationOrigin(request)) return Response.json({ error: "Invalid origin" }, { status: 403 });
if (!await allowAgentRequest(env, `chat:${user.id}`)) return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
const raw = await readJsonBody(request, 8_000).catch(() => null);
const parsed = schema.safeParse(raw);
if (!parsed.success) return Response.json({ error: "Invalid request" }, { status: 400 });
try {
const { conversationId, stream } = await createAgentChatStream({ env, user, mailboxId: parsed.data.mailboxId, origin: "chat" }, parsed.data.text, parsed.data.conversationId ?? undefined, request.signal);
return new Response(stream, { headers: { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-store", "X-Conversation-Id": conversationId } });
} catch (error) {
return Response.json({ error: agentProviderErrorMessage(error) }, { status: 400 });
}
}
@@ -0,0 +1,30 @@
import { and, eq } from "drizzle-orm";
import { getDb } from "@/db";
import { agentConversations } from "@/db/schema";
import { getEnv } from "@/lib/cloudflare";
import { getCurrentUser } from "@/lib/auth/cookies";
import { getAgentChatHistory } from "@/lib/agent/chat";
import { hasValidSessionMutationOrigin } from "@/lib/auth/origin";
type Params = { params: Promise<{ id: string }> };
export async function GET(request: Request, { params }: Params) {
const env = getEnv();
const user = await getCurrentUser(env, request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const mailboxId = new URL(request.url).searchParams.get("mailboxId");
if (!mailboxId) return Response.json({ error: "Mailbox required" }, { status: 400 });
try { return Response.json({ messages: await getAgentChatHistory({ env, user, mailboxId, origin: "chat" }, id) }); }
catch { return Response.json({ error: "Conversation not found" }, { status: 404 }); }
}
export async function DELETE(request: Request, { params }: Params) {
const env = getEnv();
const user = await getCurrentUser(env, request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
if (!hasValidSessionMutationOrigin(request)) return Response.json({ error: "Invalid origin" }, { status: 403 });
const { id } = await params;
await getDb(env).delete(agentConversations).where(and(eq(agentConversations.id, id), eq(agentConversations.userId, user.id)));
return Response.json({ ok: true });
}
+16
View File
@@ -0,0 +1,16 @@
import { getEnv } from "@/lib/cloudflare";
import { getCurrentUser } from "@/lib/auth/cookies";
import { listAgentConversations } from "@/lib/agent/chat";
export async function GET(request: Request) {
const env = getEnv();
const user = await getCurrentUser(env, request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const mailboxId = new URL(request.url).searchParams.get("mailboxId");
if (!mailboxId) return Response.json({ error: "Mailbox required" }, { status: 400 });
try {
return Response.json({ conversations: await listAgentConversations({ env, user, mailboxId, origin: "chat" }) });
} catch {
return Response.json({ error: "Mailbox not found" }, { status: 404 });
}
}
@@ -0,0 +1,24 @@
import { and, eq } from "drizzle-orm";
import { getDb } from "@/db";
import { agentJobs } from "@/db/schema";
import { getEnv } from "@/lib/cloudflare";
import { getCurrentUser } from "@/lib/auth/cookies";
import { getMailboxAccessLevel } from "@/lib/mailboxes/access";
import { hasValidSessionMutationOrigin } from "@/lib/auth/origin";
type Params = { params: Promise<{ id: string }> };
export async function POST(request: Request, { params }: Params) {
const env = getEnv();
const user = await getCurrentUser(env, request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
if (!hasValidSessionMutationOrigin(request)) return Response.json({ error: "Invalid origin" }, { status: 403 });
const { id } = await params;
const db = getDb(env);
const [job] = await db.select().from(agentJobs).where(eq(agentJobs.id, id)).limit(1);
if (!job || job.reviewerUserId !== user.id || !(await getMailboxAccessLevel(db, user, job.mailboxId))?.canSendOnBehalf) return Response.json({ error: "Job not found" }, { status: 404 });
if (job.status !== "failed") return Response.json({ error: "Job cannot be retried" }, { status: 409 });
await db.update(agentJobs).set({ status: "pending", attempts: 0, nextAttemptAt: new Date(), leaseUntil: null, reason: null }).where(and(eq(agentJobs.id, id), eq(agentJobs.status, "failed")));
await env.AGENT_QUEUE?.send({ kind: "agent.draft", jobId: id });
return Response.json({ ok: true });
}
+16
View File
@@ -0,0 +1,16 @@
import { and, desc, eq } from "drizzle-orm";
import { getDb } from "@/db";
import { agentJobs } from "@/db/schema";
import { getEnv } from "@/lib/cloudflare";
import { getCurrentUser } from "@/lib/auth/cookies";
import { getMailboxAccessLevel } from "@/lib/mailboxes/access";
export async function GET(request: Request) {
const env = getEnv();
const user = await getCurrentUser(env, request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const mailboxId = new URL(request.url).searchParams.get("mailboxId");
if (!mailboxId || !(await getMailboxAccessLevel(getDb(env), user, mailboxId))?.canRead) return Response.json({ error: "Mailbox not found" }, { status: 404 });
const rows = await getDb(env).select({ id: agentJobs.id, status: agentJobs.status, reason: agentJobs.reason, draftId: agentJobs.draftId, sourceMessageId: agentJobs.sourceMessageId, createdAt: agentJobs.createdAt }).from(agentJobs).where(and(eq(agentJobs.mailboxId, mailboxId), eq(agentJobs.reviewerUserId, user.id))).orderBy(desc(agentJobs.createdAt)).limit(30);
return Response.json({ jobs: rows });
}
+56
View File
@@ -0,0 +1,56 @@
import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { getDb } from "@/db";
import { agentSendApprovals, apiKeys, mcpKeyMailboxes } from "@/db/schema";
import { getEnv } from "@/lib/cloudflare";
import { getCurrentUser } from "@/lib/auth/cookies";
import { generateApiKey, parseScopes, scopesToJson } from "@/lib/api-keys";
import { ADMIN_API_KEY_SCOPES } from "@/lib/api/scopes";
import { getMailboxAccessLevel } from "@/lib/mailboxes/access";
import { newId } from "@/lib/ids";
import { hasValidSessionMutationOrigin } from "@/lib/auth/origin";
const createSchema = z.object({ name: z.string().trim().min(1).max(100), mailboxIds: z.array(z.string()).min(1).max(30), scopes: z.array(z.enum(["mcp:read", "mcp:draft", "mcp:organize", "mcp:request-send"])).min(1) });
const ADMIN_SCOPES = new Set<string>(ADMIN_API_KEY_SCOPES);
export async function GET(request: Request) {
const env = getEnv();
const user = await getCurrentUser(env, request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const db = getDb(env);
const rows = await db.select({ id: apiKeys.id, name: apiKeys.name, prefix: apiKeys.prefix, scopes: apiKeys.scopes, lastUsedAt: apiKeys.lastUsedAt, createdAt: apiKeys.createdAt }).from(apiKeys).where(and(eq(apiKeys.userId, user.id), eq(apiKeys.kind, "mcp")));
const keys = rows.filter((key) => !parseScopes(key.scopes).some((scope) => ADMIN_SCOPES.has(scope)));
const allowed = keys.length ? await db.select().from(mcpKeyMailboxes).where(inArray(mcpKeyMailboxes.keyId, keys.map((key) => key.id))) : [];
return Response.json({ keys: keys.map((key) => ({ ...key, mailboxIds: allowed.filter((row) => row.keyId === key.id).map((row) => row.mailboxId) })) });
}
export async function POST(request: Request) {
const env = getEnv();
const user = await getCurrentUser(env, request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
if (!hasValidSessionMutationOrigin(request)) return Response.json({ error: "Invalid origin" }, { status: 403 });
const parsed = createSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return Response.json({ error: "Invalid key request" }, { status: 400 });
const db = getDb(env);
const mailboxIds = [...new Set(parsed.data.mailboxIds)];
for (const mailboxId of mailboxIds) if (!(await getMailboxAccessLevel(db, user, mailboxId))?.canRead) return Response.json({ error: "Mailbox not found" }, { status: 403 });
const { fullKey, prefix, hash } = generateApiKey();
const id = newId("key");
await db.insert(apiKeys).values({ id, kind: "mcp", userId: user.id, name: parsed.data.name, prefix, keyHash: hash, scopes: scopesToJson([...new Set(parsed.data.scopes)]) });
await db.insert(mcpKeyMailboxes).values(mailboxIds.map((mailboxId) => ({ keyId: id, mailboxId })));
return Response.json({ id, name: parsed.data.name, prefix, key: fullKey, mailboxIds, scopes: parsed.data.scopes });
}
export async function DELETE(request: Request) {
const env = getEnv();
const user = await getCurrentUser(env, request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
if (!hasValidSessionMutationOrigin(request)) return Response.json({ error: "Invalid origin" }, { status: 403 });
const id = new URL(request.url).searchParams.get("id");
if (!id) return Response.json({ error: "Key required" }, { status: 400 });
const [owned] = await getDb(env).select({ id: apiKeys.id }).from(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.userId, user.id), eq(apiKeys.kind, "mcp"))).limit(1);
if (!owned) return Response.json({ error: "Key not found" }, { status: 404 });
await getDb(env).update(agentSendApprovals).set({ status: "cancelled" }).where(and(eq(agentSendApprovals.requestKeyId, id), eq(agentSendApprovals.status, "pending")));
await getDb(env).delete(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.userId, user.id), eq(apiKeys.kind, "mcp")));
return Response.json({ ok: true });
}
+51
View File
@@ -0,0 +1,51 @@
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { getDb } from "@/db";
import { agentJobs, agentSendApprovals, mailboxAgentSettings, users } from "@/db/schema";
import { getEnv } from "@/lib/cloudflare";
import { getCurrentUser } from "@/lib/auth/cookies";
import { getMailboxAccessLevel } from "@/lib/mailboxes/access";
import { getAgentProviderPublicConfig } from "@/lib/agent/provider";
import { hasValidSessionMutationOrigin } from "@/lib/auth/origin";
const schema = z.object({ mailboxId: z.string().min(1), enabled: z.boolean(), autoDraftEnabled: z.boolean(), reviewerUserId: z.string().nullable().optional(), instructions: z.string().max(4_000), dailyLimit: z.number().int().min(1).max(100) });
export async function GET(request: Request) {
const env = getEnv();
const user = await getCurrentUser(env, request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const mailboxId = new URL(request.url).searchParams.get("mailboxId");
if (!mailboxId) return Response.json({ error: "Mailbox required" }, { status: 400 });
const db = getDb(env);
const access = await getMailboxAccessLevel(db, user, mailboxId);
if (!access?.canRead) return Response.json({ error: "Mailbox not found" }, { status: 404 });
const [settings] = await db.select().from(mailboxAgentSettings).where(eq(mailboxAgentSettings.mailboxId, mailboxId)).limit(1);
const reviewers: { id: string; name: string; email: string }[] = [];
if (access.canManage) {
const candidates = await db.select({ id: users.id, name: users.name, email: users.email, disabled: users.disabled }).from(users).limit(200);
for (const candidate of candidates) if (!candidate.disabled && (await getMailboxAccessLevel(db, { id: candidate.id, role: "user" }, mailboxId))?.canSendOnBehalf) reviewers.push({ id: candidate.id, name: candidate.name, email: candidate.email });
}
const agentProvider = await getAgentProviderPublicConfig(env);
const provider = agentProvider.configured ? { kind: agentProvider.provider === "cloudflare" ? "Cloudflare Workers AI" : agentProvider.preset === "custom" ? "OpenAI-compatible API" : agentProvider.preset, model: agentProvider.model } : null;
return Response.json({ settings: settings ?? { mailboxId, enabled: true, autoDraftEnabled: false, reviewerUserId: access.mailbox.userId, instructions: "", dailyLimit: 25 }, canManage: access.canManage, canConfigureProvider: user.role === "admin", providerConfigured: agentProvider.configured, provider, autoReplyEnabled: access.mailbox.autoReplyEnabled, reviewers });
}
export async function PUT(request: Request) {
const env = getEnv();
const user = await getCurrentUser(env, request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
if (!hasValidSessionMutationOrigin(request)) return Response.json({ error: "Invalid origin" }, { status: 403 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return Response.json({ error: "Invalid settings" }, { status: 400 });
const db = getDb(env);
const access = await getMailboxAccessLevel(db, user, parsed.data.mailboxId);
if (!access?.canManage) return Response.json({ error: "Mailbox not found or permission denied" }, { status: 403 });
const reviewerId = parsed.data.reviewerUserId || access.mailbox.userId;
const [reviewer] = await db.select().from(users).where(eq(users.id, reviewerId)).limit(1);
if (!reviewer || reviewer.disabled || !(await getMailboxAccessLevel(db, reviewer, parsed.data.mailboxId))?.canSendOnBehalf) return Response.json({ error: "Reviewer needs mailbox send permission" }, { status: 400 });
if (parsed.data.autoDraftEnabled && access.mailbox.autoReplyEnabled) return Response.json({ error: "Disable the out-of-office auto-reply before enabling AI drafts" }, { status: 400 });
await db.insert(mailboxAgentSettings).values({ ...parsed.data, reviewerUserId: reviewerId }).onConflictDoUpdate({ target: mailboxAgentSettings.mailboxId, set: { enabled: parsed.data.enabled, autoDraftEnabled: parsed.data.autoDraftEnabled, reviewerUserId: reviewerId, instructions: parsed.data.instructions, dailyLimit: parsed.data.dailyLimit, updatedAt: new Date() } });
if (!parsed.data.enabled || !parsed.data.autoDraftEnabled) await db.update(agentJobs).set({ status: "skipped", reason: "Auto-drafting disabled" }).where(and(eq(agentJobs.mailboxId, parsed.data.mailboxId), eq(agentJobs.status, "pending")));
if (!parsed.data.enabled) await db.update(agentSendApprovals).set({ status: "cancelled" }).where(and(eq(agentSendApprovals.mailboxId, parsed.data.mailboxId), eq(agentSendApprovals.status, "pending")));
return Response.json({ ok: true });
}
+26 -7
View File
@@ -1,16 +1,16 @@
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { getEnv } from "@/lib/cloudflare";
import { getDb } from "@/db";
import { apiKeys } from "@/db/schema";
import { agentSendApprovals, apiKeys, mcpKeyMailboxes } from "@/db/schema";
import { requireUser } from "@/lib/auth/cookies";
import { generateApiKey, scopesToJson } from "@/lib/api-keys";
import { generateApiKey, parseScopes, scopesToJson } from "@/lib/api-keys";
import { API_KEY_SCOPES } from "@/lib/api/scopes";
import { newId } from "@/lib/ids";
const createKeySchema = z.object({
name: z.string().min(1),
name: z.string().trim().min(1).max(100),
scopes: z.array(z.enum([...API_KEY_SCOPES])).min(1),
});
@@ -23,19 +23,23 @@ export async function GET(request: Request) {
id: apiKeys.id,
name: apiKeys.name,
prefix: apiKeys.prefix,
kind: apiKeys.kind,
scopes: apiKeys.scopes,
createdAt: apiKeys.createdAt,
lastUsedAt: apiKeys.lastUsedAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, user.id));
return NextResponse.json({ apiKeys: rows });
const mcpIds = rows.filter((row) => row.kind === "mcp").map((row) => row.id);
const allowed = mcpIds.length ? await db.select().from(mcpKeyMailboxes).where(inArray(mcpKeyMailboxes.keyId, mcpIds)) : [];
return NextResponse.json({ apiKeys: rows.filter((row) => !parseScopes(row.scopes).some((scope) => ["domains", "accounts", "mailboxes"].includes(scope))).map((row) => ({ ...row, mailboxIds: allowed.filter((item) => item.keyId === row.id).map((item) => item.mailboxId) })) });
}
export async function POST(request: Request) {
const env = getEnv();
const user = await requireUser(env, request);
const parsed = createKeySchema.safeParse(await request.json());
if (request.headers.get("Origin") !== new URL(request.url).origin) return NextResponse.json({ error: "Invalid origin" }, { status: 403 });
const parsed = createKeySchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
@@ -49,8 +53,23 @@ export async function POST(request: Request) {
name: parsed.data.name,
prefix,
keyHash: hash,
scopes: scopesToJson(parsed.data.scopes),
scopes: scopesToJson([...new Set(parsed.data.scopes)]),
});
return NextResponse.json({ id, name: parsed.data.name, prefix, key: fullKey });
}
export async function DELETE(request: Request) {
const env = getEnv();
const user = await requireUser(env, request);
if (request.headers.get("Origin") !== new URL(request.url).origin) return NextResponse.json({ error: "Invalid origin" }, { status: 403 });
const id = new URL(request.url).searchParams.get("id");
if (!id) return NextResponse.json({ error: "Key required" }, { status: 400 });
const db = getDb(env);
const [key] = await db.select({ id: apiKeys.id, scopes: apiKeys.scopes }).from(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.userId, user.id))).limit(1);
if (!key) return NextResponse.json({ error: "Key not found" }, { status: 404 });
if (parseScopes(key.scopes).some((scope) => ["domains", "accounts", "mailboxes"].includes(scope))) return NextResponse.json({ error: "Manage this key in Admin API keys" }, { status: 403 });
await db.update(agentSendApprovals).set({ status: "cancelled" }).where(and(eq(agentSendApprovals.requestKeyId, id), eq(agentSendApprovals.status, "pending")));
await db.delete(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.userId, user.id)));
return NextResponse.json({ ok: true });
}
@@ -1,8 +1,8 @@
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { getEnv } from "@/lib/cloudflare";
import { getDb } from "@/db";
import { messages } from "@/db/schema";
import { agentDraftMetadata, messages } from "@/db/schema";
import { requireUser } from "@/lib/auth/cookies";
import { deleteMessageAttachment } from "@/lib/email/attachments";
import { userOwnsDraft } from "../../../utils";
@@ -27,5 +27,6 @@ export async function DELETE(request: Request, { params }: DraftAttachmentRouteP
if (!removed) {
return NextResponse.json({ error: "Attachment not found" }, { status: 404 });
}
await db.update(agentDraftMetadata).set({ revision: sql`${agentDraftMetadata.revision} + 1`, humanEditedAt: new Date() }).where(eq(agentDraftMetadata.draftId, id));
return NextResponse.json({ ok: true });
}
+9 -3
View File
@@ -1,8 +1,8 @@
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { getEnv } from "@/lib/cloudflare";
import { getDb } from "@/db";
import { messages } from "@/db/schema";
import { agentDraftMetadata, messages } from "@/db/schema";
import { requireUser } from "@/lib/auth/cookies";
import { buildSnippet } from "@/lib/email/parse";
import type { DraftPayload, DraftRouteParams } from "./types";
@@ -25,7 +25,8 @@ export async function GET(request: Request, { params }: DraftRouteParams) {
}
const attachments = await listMessageAttachments(env, id);
return NextResponse.json({ draft: { ...draft, attachments } });
const [agent] = await db.select().from(agentDraftMetadata).where(eq(agentDraftMetadata.draftId, id)).limit(1);
return NextResponse.json({ draft: { ...draft, attachments, agent: agent ? { revision: agent.revision, origin: agent.origin } : null } });
}
export async function PATCH(request: Request, { params }: DraftRouteParams) {
@@ -45,6 +46,10 @@ export async function PATCH(request: Request, { params }: DraftRouteParams) {
if (!userOwnsDraft(draft, user.id)) {
return NextResponse.json({ error: "Draft not found" }, { status: 404 });
}
const [agent] = await db.select({ mailboxId: agentDraftMetadata.mailboxId }).from(agentDraftMetadata).where(eq(agentDraftMetadata.draftId, id)).limit(1);
if (agent && input.mailboxId !== agent.mailboxId) {
return NextResponse.json({ error: "Agent draft mailbox cannot be changed" }, { status: 409 });
}
const sender = await getDraftSender(env, user.id, input);
if ("error" in sender) {
return NextResponse.json({ error: sender.error }, { status: 403 });
@@ -66,6 +71,7 @@ export async function PATCH(request: Request, { params }: DraftRouteParams) {
htmlBody: html || null,
})
.where(eq(messages.id, id));
await db.update(agentDraftMetadata).set({ revision: sql`${agentDraftMetadata.revision} + 1`, humanEditedAt: new Date() }).where(eq(agentDraftMetadata.draftId, id));
return NextResponse.json({ draft: { id } });
}
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more