Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand All @@ -369,7 +375,8 @@
"lucide-react": ">=1",
"react": ">=18",
"react-konva": ">=18",
"react-router": ">=7"
"react-router": ">=7",
"resend": ">=6"
},
"peerDependenciesMeta": {
"@huggingface/transformers": {
Expand Down Expand Up @@ -407,6 +414,9 @@
},
"@radix-ui/react-dialog": {
"optional": true
},
"resend": {
"optional": true
}
},
"dependencies": {
Expand Down
40 changes: 40 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 63 additions & 0 deletions src/teams/resend.ts
Original file line number Diff line number Diff line change
@@ -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 <noreply@gtm.tangle.tools>`. */
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' }
}
}
}
63 changes: 63 additions & 0 deletions tests/teams/resend.test.ts
Original file line number Diff line number Diff line change
@@ -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 <x@x.com>' })
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 <x@x.com>', apiKey: 'key' })
expect(await send(input)).toEqual({ succeeded: true })
expect(mockSend).toHaveBeenCalledOnce()
const arg = mockSend.mock.calls[0]![0]
expect(arg.from).toBe('X <x@x.com>')
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 <x@x.com>', 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 <x@x.com>', apiKey: 'key' })
expect(await send(input)).toEqual({ succeeded: false, error: 'network down' })
})
})
3 changes: 2 additions & 1 deletion tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down