diff --git a/package.json b/package.json index c133e52..03383f2 100644 --- a/package.json +++ b/package.json @@ -254,6 +254,11 @@ "import": "./dist/teams/invitations-api.js", "default": "./dist/teams/invitations-api.js" }, + "./teams/resend": { + "types": "./dist/teams/resend.d.ts", + "import": "./dist/teams/resend.js", + "default": "./dist/teams/resend.js" + }, "./teams-react": { "types": "./dist/teams-react/index.d.ts", "import": "./dist/teams-react/index.js", @@ -351,6 +356,7 @@ "react-dom": "^19.2.7", "react-konva": "^19.2.5", "react-router": "^7.15.1", + "resend": "^6.12.4", "tsup": "^8.0.0", "typescript": "^5.7.0", "vitest": "^3.0.0" @@ -369,7 +375,8 @@ "lucide-react": ">=1", "react": ">=18", "react-konva": ">=18", - "react-router": ">=7" + "react-router": ">=7", + "resend": ">=6" }, "peerDependenciesMeta": { "@huggingface/transformers": { @@ -407,6 +414,9 @@ }, "@radix-ui/react-dialog": { "optional": true + }, + "resend": { + "optional": true } }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 993cd5f..056ed73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: react-router: specifier: ^7.15.1 version: 7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + resend: + specifier: ^6.12.4 + version: 6.14.0 tsup: specifier: ^8.0.0 version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0) @@ -1208,6 +1211,9 @@ packages: '@scure/bip39@2.2.0': resolution: {integrity: sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1911,6 +1917,9 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2398,6 +2407,9 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + postal-mime@2.7.4: + resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==} + postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -2579,6 +2591,15 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resend@6.14.0: + resolution: {integrity: sha512-jVdpUgOoWGLjaP64lo8KwzHT9gY4w6Dl8c36CIb2F+ayYOMLr3khqs8xrNjXM2k19b+lPoj0VWQFhVNLiToBjA==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -2646,6 +2667,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -3857,6 +3881,8 @@ snapshots: '@noble/hashes': 2.2.0 '@scure/base': 2.2.0 + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@tangle-network/agent-eval@0.79.0(@tangle-network/sandbox@0.9.0(viem@2.52.0(typescript@5.9.3)(zod@4.4.3)))(typescript@5.9.3)': @@ -4410,6 +4436,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-sha256@1.3.0: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -5136,6 +5164,8 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + postal-mime@2.7.4: {} + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.15)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 @@ -5359,6 +5389,11 @@ snapshots: require-from-string@2.0.2: {} + resend@6.14.0: + dependencies: + postal-mime: 2.7.4 + standardwebhooks: 1.0.0 + resolve-from@5.0.0: {} reusify@1.1.0: {} @@ -5434,6 +5469,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + std-env@3.10.0: {} string_decoder@1.3.0: diff --git a/src/teams/resend.ts b/src/teams/resend.ts new file mode 100644 index 0000000..e2b56dc --- /dev/null +++ b/src/teams/resend.ts @@ -0,0 +1,63 @@ +/** + * Opt-in Resend transport for the teams invitations `sendInvitationEmail` seam. + * Most adopters back the seam with Resend and hand-roll the same wrapper — and + * the same mistake: `resend.emails.send()` returns `{ data, error }` and does NOT + * throw on an API-level failure (unverified domain, rate limit, bad recipient), + * so a `try/catch` alone records a failed send as a success. This helper sends + * through the shared `renderInvitationEmail` template and returns a typed failure + * on BOTH a thrown error AND a non-null `result.error`. + * + * `resend` is an OPTIONAL peer, imported only here: apps not on Resend keep + * passing a raw seam, and the package core never pulls a mail dependency. + */ + +import { Resend } from 'resend' +import { renderInvitationEmail } from './invitations' +import type { SendInvitationEmailSeam } from './invitations-api' + +export interface ResendInvitationSenderOptions { + /** RFC-5322 From header, e.g. `GTM Agent `. */ + from: string + /** Resend API key. Defaults to `process.env.RESEND_API_KEY`. */ + apiKey?: string +} + +/** + * Build a `sendInvitationEmail` seam backed by Resend. Wire it into + * `createInvitationsApi({ sendInvitationEmail: createResendInvitationSender({ from }) })`. + * The client is built lazily on first send; with no key the seam fails typed + * (the invitation is still created — emailStatus becomes 'failed'). + */ +export function createResendInvitationSender(opts: ResendInvitationSenderOptions): SendInvitationEmailSeam { + let client: Resend | null = null + + function getClient(): Resend | null { + if (client) return client + const key = opts.apiKey ?? (typeof process !== 'undefined' ? process.env.RESEND_API_KEY : undefined) + if (!key) return null + client = new Resend(key) + return client + } + + return async (input) => { + const resend = getClient() + if (!resend) return { succeeded: false, error: 'RESEND_API_KEY is not configured' } + + const msg = renderInvitationEmail(input, { fromAddress: opts.from }) + try { + const result = await resend.emails.send({ + from: msg.from, + to: input.to, + subject: msg.subject, + html: msg.html, + text: msg.text, + }) + // Resend reports API-level failures in `result.error` WITHOUT throwing — a + // try/catch alone would mark a failed send as a success. + if (result.error) return { succeeded: false, error: result.error.message } + return { succeeded: true } + } catch (err) { + return { succeeded: false, error: err instanceof Error ? err.message : 'Invitation email failed to send' } + } + } +} diff --git a/tests/teams/resend.test.ts b/tests/teams/resend.test.ts new file mode 100644 index 0000000..21deb3a --- /dev/null +++ b/tests/teams/resend.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// vitest hoists vi.mock above imports; factory-referenced vars must be `mock`-prefixed. +const mockSend = vi.fn() +vi.mock('resend', () => ({ Resend: vi.fn(() => ({ emails: { send: mockSend } })) })) + +import { Resend } from 'resend' +import { createResendInvitationSender } from '../../src/teams/resend' + +const input = { + to: 'invitee@x.com', + workspaceName: 'Acme', + inviterEmail: 'boss@x.com', + permission: 'editor' as const, + inviteUrl: 'https://app/invite/inv_x', + expiresAt: new Date('2026-01-08T00:00:00.000Z'), +} + +describe('createResendInvitationSender', () => { + beforeEach(() => mockSend.mockClear()) + + it('fails typed when no API key is configured (and never calls Resend)', async () => { + const prev = process.env.RESEND_API_KEY + delete process.env.RESEND_API_KEY + try { + const send = createResendInvitationSender({ from: 'X ' }) + expect(await send(input)).toEqual({ succeeded: false, error: 'RESEND_API_KEY is not configured' }) + expect(mockSend).not.toHaveBeenCalled() + } finally { + if (prev !== undefined) process.env.RESEND_API_KEY = prev + } + }) + + it('succeeds when Resend returns no error, sending the rendered template', async () => { + mockSend.mockImplementation(async () => ({ data: { id: 'e1' }, error: null })) + const send = createResendInvitationSender({ from: 'X ', apiKey: 'key' }) + expect(await send(input)).toEqual({ succeeded: true }) + expect(mockSend).toHaveBeenCalledOnce() + const arg = mockSend.mock.calls[0]![0] + expect(arg.from).toBe('X ') + expect(arg.to).toBe('invitee@x.com') + expect(arg.subject).toContain('boss@x.com') + expect(arg.html).toContain('https://app/invite/inv_x') + expect(typeof arg.text).toBe('string') + }) + + it('fails typed when Resend reports result.error WITHOUT throwing', async () => { + mockSend.mockImplementation(async () => ({ data: null, error: { name: 'validation_error', message: 'domain not verified' } })) + const send = createResendInvitationSender({ from: 'X ', apiKey: 'key' }) + expect(await send(input)).toEqual({ succeeded: false, error: 'domain not verified' }) + }) + + it('fails typed when the send throws', async () => { + // A plain throwing `send` (not a vi.fn) for this case: vitest's mock + // instrumentation otherwise surfaces the already-caught throw as a spurious + // failure. The seam still catches it and returns the typed outcome. + vi.mocked(Resend).mockImplementationOnce( + () => ({ emails: { send: () => { throw new Error('network down') } } }) as unknown as Resend, + ) + const send = createResendInvitationSender({ from: 'X ', apiKey: 'key' }) + expect(await send(input)).toEqual({ succeeded: false, error: 'network down' }) + }) +}) diff --git a/tsup.config.ts b/tsup.config.ts index 44d8c52..4caedbb 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ 'teams/drizzle': 'src/teams/drizzle.ts', 'teams/members-api': 'src/teams/members-api.ts', 'teams/invitations-api': 'src/teams/invitations-api.ts', + 'teams/resend': 'src/teams/resend.ts', 'teams-react/index': 'src/teams-react/index.ts', 'teams-react/lazy': 'src/teams-react/lazy.tsx', 'intakes/index': 'src/intakes/index.ts', @@ -64,7 +65,7 @@ export default defineConfig({ sourcemap: true, clean: true, target: 'es2022', - external: ['react', 'react/jsx-runtime', 'konva', 'react-konva', '@tangle-network/agent-integrations', '@tangle-network/agent-integrations/catalog', '@tangle-network/agent-eval', '@tangle-network/agent-knowledge', '@tangle-network/agent-runtime', '@tangle-network/sandbox', 'drizzle-orm', 'drizzle-orm/*', '@huggingface/transformers', '@tangle-network/sandbox-ui', '@tangle-network/sandbox-ui/*', '@tangle-network/ui', '@tangle-network/ui/*', 'lucide-react', 'react-router', '@radix-ui/react-dialog'], + external: ['react', 'react/jsx-runtime', 'konva', 'react-konva', '@tangle-network/agent-integrations', '@tangle-network/agent-integrations/catalog', '@tangle-network/agent-eval', '@tangle-network/agent-knowledge', '@tangle-network/agent-runtime', '@tangle-network/sandbox', 'drizzle-orm', 'drizzle-orm/*', '@huggingface/transformers', '@tangle-network/sandbox-ui', '@tangle-network/sandbox-ui/*', '@tangle-network/ui', '@tangle-network/ui/*', 'lucide-react', 'react-router', '@radix-ui/react-dialog', 'resend'], // tokens.css is shipped raw (the ./styles subpath); copy it next to the // built theme entries so `import '@tangle-network/agent-app/styles'` resolves. onSuccess: 'cp src/theme/tokens.css dist/theme/tokens.css && cp src/studio-react/studio.css dist/studio-react/studio.css',