From 8bee1b2ea9bbdf1038f03afbd806b99cba383a69 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Mon, 12 Oct 2026 08:59:16 +0000
Subject: [PATCH] Show team invitations in the app, and make the invite email
read less like phishing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An invitation could only be accepted through the emailed link. preshy@ was
invited four times; Resend reports every one delivered to the profullstack.com
MX, yet none was seen — and he already has a verified account. The email was
the single point of failure.
- GET /api/v1/me/invitations lists pending invitations matching the signed-in
user's verified address; POST /api/v1/me/invitations/:id/accept joins without
a token. Unverified addresses and other people's ids are refused.
- The app shell shows a "X invited you to their team · Join" banner.
- The email no longer says "a@x.com invited you to join a@x.com workspace":
an unnamed (email-named) org reads "their team", the subject names the
inviter, Reply-To is the inviter, and it says existing accounts will find the
invitation on sign-in.
Co-Authored-By: Claude Opus 5.5
---
apps/api/src/app.ts | 50 +++++++++
apps/api/src/team.test.ts | 75 +++++++++++++
apps/api/src/team.ts | 112 ++++++++++++++++++--
apps/web/app/(app)/layout.tsx | 6 +-
apps/web/components/pending-invitations.tsx | 110 +++++++++++++++++++
packages/email/src/templates.ts | 23 +++-
6 files changed, 363 insertions(+), 13 deletions(-)
create mode 100644 apps/web/components/pending-invitations.tsx
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 124700f..d42533f 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -61,9 +61,11 @@ import {
} from './auth';
import {
acceptInvitation,
+ acceptInvitationForUser,
inviteMember,
isInvitableRole,
listInvitations,
+ listInvitationsForUser,
listMembers,
previewInvitation,
removeMember,
@@ -1604,6 +1606,7 @@ export function createApp(options: AppOptions): Hono {
invitationEmail(minted.email, link, {
organization: organization?.name ?? 'their team',
invitedBy: inviter?.name?.trim() || inviter?.email || 'A teammate',
+ ...(inviter?.email ? { replyTo: inviter.email } : {}),
}),
);
} catch (error) {
@@ -1734,6 +1737,53 @@ export function createApp(options: AppOptions): Hono {
});
});
+ /**
+ * Invitations addressed to the signed-in user, so joining does not depend on
+ * the email arriving. Matched on the account's verified address.
+ */
+ api.get('/me/invitations', async (c) => {
+ const actor = c.get('actor');
+ return c.json({ invitations: await listInvitationsForUser(c.get('db'), actor.userId) });
+ });
+
+ api.post('/me/invitations/:id/accept', async (c) => {
+ const actor = c.get('actor');
+ const db = c.get('db');
+
+ let accepted;
+ try {
+ accepted = await acceptInvitationForUser(db, c.req.param('id'), actor.userId);
+ } catch (error) {
+ if (error instanceof TeamError) throw ApiError.badRequest(error.message);
+ throw error;
+ }
+
+ const cookie = readCookie(c.req.header('cookie') ?? null, SESSION_COOKIE);
+ if (cookie && accepted.workspaceId) {
+ await switchSessionWorkspace(db, cookie, actor.userId, accepted.workspaceId);
+ }
+
+ await repo.audit(db, {
+ actorKind: 'user',
+ actorId: actor.userId,
+ eventType: 'team.invitation_accepted',
+ entityKind: 'user',
+ entityId: actor.userId,
+ detail: {
+ organizationId: accepted.organizationId,
+ role: accepted.role,
+ via: 'in_app',
+ },
+ });
+
+ return c.json({
+ joined: true,
+ organizationId: accepted.organizationId,
+ workspaceId: accepted.workspaceId,
+ role: accepted.role,
+ });
+ });
+
// ----------------------------------------------------------- workspaces
//
// `organizations → workspaces` has been in the schema since 0000, described
diff --git a/apps/api/src/team.test.ts b/apps/api/src/team.test.ts
index 9ebab34..6568b4d 100644
--- a/apps/api/src/team.test.ts
+++ b/apps/api/src/team.test.ts
@@ -3,8 +3,10 @@ import { now, queryAll, queryOne } from '@outreachgraph/db';
import { seedDatabase, SEED, type SeededDatabase } from './test-seed';
import {
acceptInvitation,
+ acceptInvitationForUser,
inviteMember,
listInvitations,
+ listInvitationsForUser,
listMembers,
previewInvitation,
removeMember,
@@ -169,6 +171,79 @@ describe('acceptInvitation', () => {
});
});
+describe('invitations for the signed-in user', () => {
+ test('an existing account sees and accepts its invitation without the link', async () => {
+ seeded = await seedDatabase('team-mine');
+ const { db } = seeded;
+ await addUser(db, 'usr_sam', 'sam@example.com');
+
+ const minted = await inviteMember(db, { ...INVITER, email: 'Sam@Example.com', role: 'admin' });
+
+ const mine = await listInvitationsForUser(db, 'usr_sam');
+ expect(mine.map((i) => i.id)).toEqual([minted.id]);
+ expect(mine[0]?.role).toBe('admin');
+
+ const accepted = await acceptInvitationForUser(db, minted.id, 'usr_sam');
+ expect(accepted.workspaceId).toBe(SEED.workspaceId);
+ expect(await listMembers(db, SEED.organizationId)).toHaveLength(2);
+
+ // Answered, and already a member: nothing left to offer.
+ expect(await listInvitationsForUser(db, 'usr_sam')).toHaveLength(0);
+ });
+
+ test('an unverified address neither sees nor accepts it', async () => {
+ seeded = await seedDatabase('team-mine-unverified');
+ const { db } = seeded;
+ await addUser(db, 'usr_sam', 'sam@example.com');
+ await db.execute({
+ sql: 'UPDATE users SET email_verified_at = NULL WHERE id = ?',
+ args: ['usr_sam'],
+ });
+
+ const minted = await inviteMember(db, { ...INVITER, email: 'sam@example.com', role: 'member' });
+
+ expect(await listInvitationsForUser(db, 'usr_sam')).toHaveLength(0);
+ await expect(acceptInvitationForUser(db, minted.id, 'usr_sam')).rejects.toBeInstanceOf(
+ TeamError,
+ );
+ expect(await listMembers(db, SEED.organizationId)).toHaveLength(1);
+ });
+
+ test("someone else's invitation is refused", async () => {
+ seeded = await seedDatabase('team-mine-other');
+ const { db } = seeded;
+ await addUser(db, 'usr_sam', 'sam@example.com');
+ await addUser(db, 'usr_eve', 'eve@example.com');
+
+ const minted = await inviteMember(db, { ...INVITER, email: 'sam@example.com', role: 'member' });
+
+ expect(await listInvitationsForUser(db, 'usr_eve')).toHaveLength(0);
+ await expect(acceptInvitationForUser(db, minted.id, 'usr_eve')).rejects.toBeInstanceOf(
+ TeamError,
+ );
+ });
+
+ test('withdrawn and expired invitations are not offered', async () => {
+ seeded = await seedDatabase('team-mine-stale');
+ const { db } = seeded;
+ await addUser(db, 'usr_sam', 'sam@example.com');
+
+ const minted = await inviteMember(db, { ...INVITER, email: 'sam@example.com', role: 'member' });
+ await db.execute({
+ sql: 'UPDATE invitations SET expires_at = ? WHERE id = ?',
+ args: ['2020-01-01T00:00:00.000Z', minted.id],
+ });
+ expect(await listInvitationsForUser(db, 'usr_sam')).toHaveLength(0);
+ await expect(acceptInvitationForUser(db, minted.id, 'usr_sam')).rejects.toBeInstanceOf(
+ TeamError,
+ );
+
+ const again = await inviteMember(db, { ...INVITER, email: 'sam@example.com', role: 'member' });
+ await revokeInvitation(db, SEED.organizationId, again.id);
+ expect(await listInvitationsForUser(db, 'usr_sam')).toHaveLength(0);
+ });
+});
+
describe('removeMember', () => {
test('removes a teammate', async () => {
seeded = await seedDatabase('team-remove');
diff --git a/apps/api/src/team.ts b/apps/api/src/team.ts
index 9e402fb..7ee627a 100644
--- a/apps/api/src/team.ts
+++ b/apps/api/src/team.ts
@@ -297,23 +297,119 @@ export async function acceptInvitation(
token: string,
userId: string,
): Promise {
- const hash = await hashToken(token);
+ const row = await queryOne(
+ db,
+ `SELECT id, organization_id, role, expires_at, accepted_at, revoked_at
+ FROM invitations WHERE token_hash = ?`,
+ [await hashToken(token)],
+ );
- const row = await queryOne<{
+ if (!row) throw new TeamError('that invitation link is not valid');
+ return consumeInvitation(db, row, userId);
+}
+
+export interface InvitationForMe {
+ readonly id: string;
+ readonly organizationId: string;
+ readonly organizationName: string;
+ readonly role: string;
+ readonly invitedBy: string | null;
+ readonly expiresAt: string;
+}
+
+/**
+ * Invitations waiting for the signed-in user, matched on their address.
+ *
+ * The email was the only way an invitation reached anybody, and one that lands
+ * in spam — or in a forwarding alias nobody reads — left an invitee who already
+ * has an account with no way to know they were asked. Showing it in the app
+ * closes that gap without involving the link at all.
+ *
+ * Only a **verified** address counts. Without that, registering someone else's
+ * address would be enough to walk into the team they were invited to.
+ * Organizations the user already belongs to are left out: accepting would be a
+ * no-op that looks like a bug.
+ */
+export async function listInvitationsForUser(
+ db: Client,
+ userId: string,
+): Promise {
+ const rows = await queryAll<{
id: string;
organization_id: string;
+ organization_name: string;
role: string;
+ invited_by_name: string | null;
+ invited_by_email: string | null;
expires_at: string;
- accepted_at: string | null;
- revoked_at: string | null;
}>(
db,
- `SELECT id, organization_id, role, expires_at, accepted_at, revoked_at
- FROM invitations WHERE token_hash = ?`,
- [hash],
+ `SELECT i.id, i.organization_id, o.name AS organization_name, i.role,
+ inviter.name AS invited_by_name, inviter.email AS invited_by_email, i.expires_at
+ FROM users me
+ JOIN invitations i ON lower(i.email) = lower(me.email)
+ JOIN organizations o ON o.id = i.organization_id
+ LEFT JOIN users inviter ON inviter.id = i.invited_by
+ WHERE me.id = ? AND me.email_verified_at IS NOT NULL
+ AND i.accepted_at IS NULL AND i.revoked_at IS NULL AND i.expires_at > ?
+ AND NOT EXISTS (
+ SELECT 1 FROM organization_members m
+ WHERE m.organization_id = i.organization_id AND m.user_id = me.id
+ )
+ ORDER BY i.created_at DESC`,
+ [userId, now()],
);
- if (!row) throw new TeamError('that invitation link is not valid');
+ return rows.map((row) => ({
+ id: row.id,
+ organizationId: row.organization_id,
+ organizationName: row.organization_name,
+ role: row.role,
+ invitedBy: row.invited_by_name?.trim() || row.invited_by_email,
+ expiresAt: row.expires_at,
+ }));
+}
+
+/**
+ * Accepts an invitation by id, for the user it was addressed to.
+ *
+ * The in-app counterpart of {@link acceptInvitation}. There is no token, so the
+ * verified address is what authorizes it — the opposite trade from the link,
+ * which deliberately does not check it. An invitation that is not yours reads
+ * as not valid rather than forbidden, so ids cannot be probed.
+ */
+export async function acceptInvitationForUser(
+ db: Client,
+ invitationId: string,
+ userId: string,
+): Promise {
+ const row = await queryOne(
+ db,
+ `SELECT i.id, i.organization_id, i.role, i.expires_at, i.accepted_at, i.revoked_at
+ FROM invitations i
+ JOIN users me ON lower(me.email) = lower(i.email)
+ WHERE i.id = ? AND me.id = ? AND me.email_verified_at IS NOT NULL`,
+ [invitationId, userId],
+ );
+
+ if (!row) throw new TeamError('that invitation is not valid');
+ return consumeInvitation(db, row, userId);
+}
+
+interface PendingRow {
+ id: string;
+ organization_id: string;
+ role: string;
+ expires_at: string;
+ accepted_at: string | null;
+ revoked_at: string | null;
+}
+
+async function consumeInvitation(
+ db: Client,
+ row: PendingRow,
+ userId: string,
+): Promise {
if (row.revoked_at) throw new TeamError('that invitation was withdrawn');
if (row.expires_at <= now()) throw new TeamError('that invitation has expired');
diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx
index ab6e523..ef05458 100644
--- a/apps/web/app/(app)/layout.tsx
+++ b/apps/web/app/(app)/layout.tsx
@@ -1,6 +1,7 @@
import Link from 'next/link';
import { BottomNav } from '../../components/bottom-nav';
import { BrandLockup } from '../../components/brand';
+import { PendingInvitations } from '../../components/pending-invitations';
/**
* The signed-in app shell: a narrow reading column and the bottom nav.
@@ -24,7 +25,10 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
{/* pb-28 reserves room for the fixed bottom nav plus the home indicator. */}
- {children}
+
+
+ {children}
+
>
);
diff --git a/apps/web/components/pending-invitations.tsx b/apps/web/components/pending-invitations.tsx
new file mode 100644
index 0000000..18e2456
--- /dev/null
+++ b/apps/web/components/pending-invitations.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useEffect, useState } from 'react';
+
+interface InvitationForMe {
+ id: string;
+ organizationName: string;
+ role: string;
+ invitedBy: string | null;
+}
+
+/**
+ * Team invitations addressed to the signed-in user.
+ *
+ * The emailed link used to be the only way in, so an invitation that landed in
+ * spam — or in an alias nobody reads — left someone who already had an account
+ * with no way to know they had been asked. The API matches these on the
+ * account's verified address, which is why accepting here needs no token.
+ *
+ * Renders nothing until there is something to show: a failed fetch is not
+ * worth a banner of its own on every page.
+ */
+export function PendingInvitations() {
+ const router = useRouter();
+ const [invitations, setInvitations] = useState([]);
+ const [busy, setBusy] = useState();
+ const [error, setError] = useState();
+
+ useEffect(() => {
+ let live = true;
+
+ fetch('/api/v1/me/invitations', { credentials: 'same-origin' })
+ .then((response) => (response.ok ? response.json() : { invitations: [] }))
+ .then((payload: { invitations?: InvitationForMe[] }) => {
+ if (live) setInvitations(payload.invitations ?? []);
+ })
+ .catch(() => undefined);
+
+ return () => {
+ live = false;
+ };
+ }, []);
+
+ async function join(id: string): Promise {
+ setBusy(id);
+ setError(undefined);
+
+ try {
+ const response = await fetch(`/api/v1/me/invitations/${encodeURIComponent(id)}/accept`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ });
+
+ if (!response.ok) {
+ const payload = await response.json().catch(() => ({}));
+ setError(payload?.error?.message ?? `that failed (${response.status})`);
+ setBusy(undefined);
+ return;
+ }
+
+ // Same as the emailed link: the API re-pins the session onto the
+ // workspace just joined, and a refresh shows it.
+ setInvitations((current) => current.filter((invitation) => invitation.id !== id));
+ setBusy(undefined);
+ router.replace('/inbox');
+ router.refresh();
+ } catch {
+ setError('could not reach the server');
+ setBusy(undefined);
+ }
+ }
+
+ if (invitations.length === 0) return null;
+
+ return (
+
+ {invitations.map((invitation) => (
+
+
+
+ {invitation.invitedBy ?? 'A teammate'} invited you to their team
+
+
+ {invitation.organizationName} · {invitation.role}
+
+
+
+
void join(invitation.id)}
+ disabled={busy !== undefined}
+ className="bg-accent shrink-0 rounded-xl px-4 py-2 text-sm font-medium text-white disabled:opacity-40"
+ >
+ {busy === invitation.id ? 'Joining…' : 'Join'}
+
+
+ ))}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
diff --git a/packages/email/src/templates.ts b/packages/email/src/templates.ts
index f33fe5d..140d3bc 100644
--- a/packages/email/src/templates.ts
+++ b/packages/email/src/templates.ts
@@ -118,15 +118,25 @@ export function passwordResetEmail(to: string, link: string): Message {
export function invitationEmail(
to: string,
link: string,
- context: { organization: string; invitedBy: string },
+ context: { organization: string; invitedBy: string; replyTo?: string },
): Message {
- const from = `${context.invitedBy} invited you to join ${context.organization} on OutreachGraph.`;
+ // Registration names an organization " workspace", and a sentence
+ // that repeats an address twice — "a@x.com invited you to join a@x.com
+ // workspace" — reads like phishing to people and filters alike. An
+ // organization nobody has named yet is just "their team".
+ const organization = context.organization.includes('@') ? 'their team' : context.organization;
+ const from = `${context.invitedBy} invited you to join ${organization} on OutreachGraph.`;
+ const existing =
+ 'Already have an OutreachGraph account with this address? Sign in and the ' +
+ 'invitation is waiting at the top of your inbox.';
const text = [
from,
'',
link,
'',
+ existing,
+ '',
'The link expires in 14 days. If you were not expecting this you can ignore',
'the message — nothing is shared with you until you open it, and nothing is',
'sent on your behalf.',
@@ -141,7 +151,9 @@ export function invitationEmail(
'style="width:48px;height:48px;border:0" />
'
: '',
`${escapeHtml(from)}
`,
- `${safe}
`,
+ `Accept the invitation
`,
+ `Or paste this link into your browser: ${safe}
`,
+ `${escapeHtml(existing)}
`,
'The link expires in 14 days. If you were not expecting this you can ignore ',
'the message — nothing is shared with you until you open it, and nothing is ',
'sent on your behalf.
',
@@ -149,8 +161,11 @@ export function invitationEmail(
return {
to,
- subject: `Join ${context.organization} on OutreachGraph`,
+ subject: `${context.invitedBy} invited you to OutreachGraph`,
text,
html,
+ // A reply goes to the person who asked, not to noreply@ — the first thing
+ // an unsure recipient does is ask "is this really you?".
+ ...(context.replyTo ? { replyTo: context.replyTo } : {}),
};
}