Summary
Add a small, opt-in shared helper createResendInvitationSender({ apiKey?, from }): SendInvitationEmailSeam behind a new subpath @tangle-network/agent-app/teams/resend, with resend as an optional peer dependency.
The teams invitations module exposes sendInvitationEmail as a per-app seam (so agent-app stays transport-agnostic). In practice every Resend-backed adopter hand-writes the same ~15-line wrapper — and both gtm-agent and creative-agent got it subtly wrong the same way: resend.emails.send() returns { data, error } and does not throw on API-level failures (unverified domain, rate limit, invalid recipient). Both apps only try/catch, so a failed send is silently recorded as emailStatus: 'sent'. Solving it once removes the footgun fleet-wide.
Proposed approach
New file src/teams/resend.ts (imports resend; renderInvitationEmail from the pure ./invitations leaf; SendInvitationEmailSeam/Input/Result as type-only imports from ./invitations-api):
export interface ResendInvitationSenderOptions {
/** Resend API key. Defaults to process.env.RESEND_API_KEY. */
apiKey?: string
/** RFC-5322 From header, e.g. 'GTM Agent <noreply@gtm.tangle.tools>'. */
from: string
}
export function createResendInvitationSender(opts: ResendInvitationSenderOptions): SendInvitationEmailSeam
The returned seam: lazily builds the Resend client (apiKey ?? RESEND_API_KEY; returns a typed failure when neither is set), renders via the existing renderInvitationEmail(input, { fromAddress: opts.from }), sends, and returns { succeeded: false, error } on both a thrown error and a non-null result.error — otherwise { succeeded: true }.
Adopters then replace their hand-rolled seam with:
sendInvitationEmail: createResendInvitationSender({ from: GTM_EMAIL_FROM })
Stays opt-in and provider-agnostic: resend is an optional peer (same pattern as drizzle-orm/react/konva); apps not on Resend keep passing a raw seam, and the core never pulls a mail dependency.
Out of scope (intentionally app-side): invite-URL origin derivation and the from-address value — both are already injected params (origin, fromAddress), so each app owns them per deployment.
Acceptance criteria
Context / alternatives
- Seam + types:
src/teams/invitations-api.ts:50-60 (SendInvitationEmailInput/Result/Seam), consumed via createInvitationsApi({ sendInvitationEmail }).
- Pure template:
src/teams/invitations.ts:83 renderInvitationEmail(input, { fromAddress }).
- The duplicated/buggy hand-rolled senders:
gtm-agent/src/lib/.server/invitations.ts and creative-agent/src/lib/.server/invitation-email.ts (both try { send } catch with no result.error check).
- Rejected: folding the send into
createInvitationsApi — it must stay transport-agnostic; the seam exists precisely to keep mail providers out of the core. The opt-in subpath preserves that.
- Follow-ups (separate, in each app, after publish): gtm + creative adopt
createResendInvitationSender and delete their hand-rolled seams.
Summary
Add a small, opt-in shared helper
createResendInvitationSender({ apiKey?, from }): SendInvitationEmailSeambehind a new subpath@tangle-network/agent-app/teams/resend, withresendas an optional peer dependency.The teams invitations module exposes
sendInvitationEmailas a per-app seam (so agent-app stays transport-agnostic). In practice every Resend-backed adopter hand-writes the same ~15-line wrapper — and both gtm-agent and creative-agent got it subtly wrong the same way:resend.emails.send()returns{ data, error }and does not throw on API-level failures (unverified domain, rate limit, invalid recipient). Both apps onlytry/catch, so a failed send is silently recorded asemailStatus: 'sent'. Solving it once removes the footgun fleet-wide.Proposed approach
New file
src/teams/resend.ts(importsresend;renderInvitationEmailfrom the pure./invitationsleaf;SendInvitationEmailSeam/Input/Resultas type-only imports from./invitations-api):The returned seam: lazily builds the Resend client (
apiKey ?? RESEND_API_KEY; returns a typed failure when neither is set), renders via the existingrenderInvitationEmail(input, { fromAddress: opts.from }), sends, and returns{ succeeded: false, error }on both a thrown error and a non-nullresult.error— otherwise{ succeeded: true }.Adopters then replace their hand-rolled seam with:
Stays opt-in and provider-agnostic:
resendis an optional peer (same pattern asdrizzle-orm/react/konva); apps not on Resend keep passing a raw seam, and the core never pulls a mail dependency.Out of scope (intentionally app-side): invite-URL origin derivation and the from-address value — both are already injected params (
origin,fromAddress), so each app owns them per deployment.Acceptance criteria
src/teams/resend.tsexportscreateResendInvitationSender+ResendInvitationSenderOptions.@tangle-network/agent-app/teams/resendwired intsup.config.ts(entry) andpackage.json(exports).resendadded as an optional peer (peerDependencies+peerDependenciesMeta.resend.optional), externalized in the build; core (.) never imports it.result.error; success otherwise.resend) covering: no-key, thrown error,result.error, and success paths.pnpm build+pnpm typecheck+pnpm testgreen.Context / alternatives
src/teams/invitations-api.ts:50-60(SendInvitationEmailInput/Result/Seam), consumed viacreateInvitationsApi({ sendInvitationEmail }).src/teams/invitations.ts:83renderInvitationEmail(input, { fromAddress }).gtm-agent/src/lib/.server/invitations.tsandcreative-agent/src/lib/.server/invitation-email.ts(bothtry { send } catchwith noresult.errorcheck).createInvitationsApi— it must stay transport-agnostic; the seam exists precisely to keep mail providers out of the core. The opt-in subpath preserves that.createResendInvitationSenderand delete their hand-rolled seams.