{"protocol":"clank-doc/1","frameworkVersion":"0.12.0","slug":"invitations","title":"Invitations and email delivery","description":"Clank has two intentionally different invitation scopes:","group":{"id":"deploy","title":"Deploy and operate"},"url":"https://docs.clank.run/docs/invitations","source":"docs/invitations.md","headings":["Recipient experience","Configure Resend","Use any HTTPS mail gateway","Programmatic self hosting","Delivery state","Durability and security model","Troubleshooting"],"tableOfContents":[{"id":"recipient-experience","title":"Recipient experience","level":2},{"id":"configure-resend","title":"Configure Resend","level":2},{"id":"use-any-https-mail-gateway","title":"Use any HTTPS mail gateway","level":2},{"id":"programmatic-self-hosting","title":"Programmatic self-hosting","level":2},{"id":"delivery-state","title":"Delivery state","level":2},{"id":"durability-and-security-model","title":"Durability and security model","level":2},{"id":"troubleshooting","title":"Troubleshooting","level":2}],"markdown":"# Invitations and email delivery\n\nClank has two intentionally different invitation scopes:\n\n- **Workspace** grants one `admin`, `developer`, or `viewer` role in an existing workspace.\n- **Personal** lets a platform administrator invite a new person to create an isolated account and\n  personal workspace without joining the administrator's workspace.\n\nBoth are email-bound, single-use, expiring capabilities. An owner or administrator chooses the\nscope in **Workspace → People**. When email delivery is configured, Clank sends the secure link\nautomatically. Otherwise the same screen shows a copy-once token for manual delivery.\n\n## Recipient experience\n\nAn invitation email links to:\n\n```text\nhttps://clank.run/invite#token=clnki_…\n```\n\nThe token is in the URL fragment, not the query string. Browsers do not include fragments in HTTP\nrequests or `Referer` headers. The console copies the token into the correct form and immediately\nremoves the fragment from browser history.\n\n- A person without an account creates the exact email-bound account and joins in one transaction.\n- A signed-in person sees the existing **Join another workspace** confirmation with the token\n  already filled in.\n- A personal invitation creates only the recipient's account and personal workspace.\n- Reissuing or revoking an invitation immediately invalidates every older link.\n\nNo password or browser session is sent by email.\n\n## Configure Resend\n\nThe production launcher talks to Resend over HTTPS directly; the framework adds no Resend SDK or\nruntime dependency.\n\n```sh\nCLANK_RESEND_API_KEY=re_…\nCLANK_EMAIL_FROM=noreply@example.com\nCLANK_EMAIL_FROM_NAME=Clank\nCLANK_EMAIL_REPLY_TO=support@example.com\n```\n\nVerify the sender domain with the provider before inviting real users. The optional request and\noutbox controls are:\n\n```sh\nCLANK_EMAIL_TIMEOUT_MS=10000\nCLANK_EMAIL_REQUEST_RETRIES=2\nCLANK_INVITATION_DELIVERY_INTERVAL_MS=30000\nCLANK_INVITATION_DELIVERY_BATCH_SIZE=20\nCLANK_INVITATION_DELIVERY_CONCURRENCY=2\nCLANK_INVITATION_RETRY_BASE_MS=30000\nCLANK_INVITATION_MAX_ATTEMPTS=6\nCLANK_INVITATION_DELIVERY_LEASE_MS=60000\n```\n\nResend supports the `Idempotency-Key` header on `POST /emails`; Clank binds that key to the stable\ninvitation ID. See Resend's [send-email API](https://resend.com/docs/api-reference/emails/send-email)\nand [idempotency documentation](https://resend.com/docs/dashboard/emails/idempotency-keys).\n\n## Use any HTTPS mail gateway\n\nSet a provider-neutral endpoint instead of a Resend key:\n\n```sh\nCLANK_EMAIL_DELIVERY_URL=https://mail-gateway.example.com/send\nCLANK_EMAIL_DELIVERY_TOKEN=<gateway bearer token>\nCLANK_EMAIL_FROM=noreply@example.com\nCLANK_EMAIL_FROM_NAME=Clank\n```\n\nThe gateway receives Clank's normalized `EmailMessage` JSON. The request includes\n`Content-Type: application/json`, an optional bearer token, and the same `Idempotency-Key` value.\nReturn any successful HTTP status and optionally `{ \"id\": \"provider-message-id\" }`. Redirects are\nnot followed, the URL must be HTTPS, and only `429` and server errors are retried by the transport.\n\nConfigure either `CLANK_RESEND_API_KEY` or `CLANK_EMAIL_DELIVERY_URL`, never both. If neither is\npresent, Clank stays in explicit **Manual token delivery** mode.\n\n## Programmatic self-hosting\n\n`openPlatform` accepts any `EmailService`:\n\n```ts\nimport {\n  createResendEmailService,\n  openPlatform,\n} from \"@clank.run/framework\";\n\nconst platform = await openPlatform({\n  dataDirectory: \"/var/lib/clank\",\n  publicUrl: \"https://deploy.example.com\",\n  invitations: {\n    email: createResendEmailService({\n      apiKey: process.env.RESEND_API_KEY!,\n    }),\n    from: {\n      email: \"noreply@example.com\",\n      name: \"Clank\",\n    },\n    replyTo: {\n      email: \"support@example.com\",\n    },\n  },\n});\n```\n\n`createHttpEmailService` is the programmatic provider-neutral alternative. `openFileEmailService`\nis appropriate for local application development, but it is not real invitation delivery and\nshould not be used as a production mail provider.\n\n## Delivery state\n\nPending invitations show one of:\n\n| State | Meaning |\n| --- | --- |\n| Manual token delivery | No email driver is configured. |\n| Email queued | The encrypted outbox is waiting or the first provider attempt is in progress. |\n| Email retrying | A provider call failed or an old worker lease was reclaimed. |\n| Email sent | The provider accepted the message. |\n| Email failed | The bounded attempt limit was reached; reissue the invitation to try again. |\n\nCreating an invitation always returns its token once as a fallback. Lists never return tokens,\nhashes, ciphertext, provider error text, or provider credentials.\n\n## Durability and security model\n\nThe invitation row stores only a SHA-256 token hash. A separate outbox temporarily stores the token\ninside the same AES-256-GCM envelope used for platform secrets. This gives Clank enough information\nto retry after a restart without making the ordinary invitation record reversible.\n\nDelivery has these invariants:\n\n- invitation creation and outbox enqueue commit in one SQLite transaction;\n- multiple control planes use expiring transactional leases, so only one claims a message;\n- every retry preserves one invitation-scoped idempotency key;\n- a crashed worker's expired lease is reclaimed;\n- successful delivery erases the encrypted token immediately;\n- acceptance, replacement, revocation, and expiry cancel pending work and erase the encrypted\n  token;\n- a late provider response is fenced and cannot change a cancelled delivery back to sent;\n- public APIs store only generic failure state while the detailed provider error goes to the\n  private operator error hook; and\n- terminal delivery metadata is bounded and pruned.\n\nEmail is not a revocation channel: a message already accepted by the provider cannot be recalled.\nRevoking or replacing its server-side invitation makes the link unusable.\n\n## Troubleshooting\n\n1. Check the pending invitation's delivery state in **Workspace → People**.\n2. Confirm exactly one provider variable and `CLANK_EMAIL_FROM` are present.\n3. Verify the sender domain and provider account permissions.\n4. Read private control-plane logs for the provider failure; Clank intentionally keeps that text\n   out of tenant APIs.\n5. If the state is **Email failed**, reissue the invitation. The old token is revoked atomically.\n6. Keep the copy-once token only as a short-lived fallback; do not put it in tickets or application\n   logs.\n"}