Skip to content

[FEAT] Shared createResendInvitationSender seam helper (fix silent send failures) #142

Description

@vutuanlinh2k2

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

  • src/teams/resend.ts exports createResendInvitationSender + ResendInvitationSenderOptions.
  • New subpath @tangle-network/agent-app/teams/resend wired in tsup.config.ts (entry) and package.json (exports).
  • resend added as an optional peer (peerDependencies + peerDependenciesMeta.resend.optional), externalized in the build; core (.) never imports it.
  • Returns a typed failure on no key, on a thrown error, AND on a non-null result.error; success otherwise.
  • Unit test (mock resend) covering: no-key, thrown error, result.error, and success paths.
  • pnpm build + pnpm typecheck + pnpm test green.

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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

Status
Not Started 🕧

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions