Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
270a9dd
chore: scaffold email integration feature branch
nick-inkeep Feb 15, 2026
8ecdfc9
[EMAIL-001] Create packages/email with env schema, transport factory,…
nick-inkeep Feb 15, 2026
f638d53
[EMAIL-002] Implement email theme and shared components
nick-inkeep Feb 15, 2026
2efad53
[EMAIL-003] Implement invitation and password-reset email templates
nick-inkeep Feb 15, 2026
8396dc8
[EMAIL-004] Implement sendEmail and createEmailService with barrel ex…
nick-inkeep Feb 15, 2026
1b67e91
[EMAIL-005] Add email-send-status-store to agents-core
nick-inkeep Feb 15, 2026
4de4e65
[EMAIL-006] Wire BetterAuth callbacks to email service
nick-inkeep Feb 15, 2026
71c67db
[EMAIL-007] Add Mailpit to docker-compose and SMTP env vars to .env.e…
nick-inkeep Feb 15, 2026
0300ab6
[EMAIL-008] Expose isSmtpConfigured to manage-ui via RuntimeConfig
nick-inkeep Feb 15, 2026
4afddc8
[EMAIL-009] Add forgot-password page and link on login page gated by …
nick-inkeep Feb 16, 2026
1bc303b
[EMAIL-010] Update invitation dialog and members table for conditiona…
nick-inkeep Feb 16, 2026
df804f4
style: auto-format with biome
github-actions[bot] Feb 16, 2026
b62217e
chore: add changeset for email integration
nick-inkeep Feb 16, 2026
26b622f
fix: add SMTP transport timeouts to prevent API response hangs
nick-inkeep Feb 16, 2026
18177da
fix: address PR review feedback — authz, logging, mailpit pin, licens…
nick-inkeep Feb 16, 2026
89335b1
fix: improve forgot-password UX — link placement, email prefill, redi…
nick-inkeep Feb 16, 2026
000be7c
docs: add email configuration docs and improve accessibility
nick-inkeep Feb 16, 2026
713029d
docs: fix Mailpit instructions to reflect default docker-compose config
nick-inkeep Feb 16, 2026
98201e2
fix: prevent cross-tenant invitation email status disclosure
nick-inkeep Feb 16, 2026
11195d3
style: auto-format with biome
github-actions[bot] Feb 16, 2026
3ae03d8
fix: improve invitation UX — cleaner email URL, auto-copy link, bette…
nick-inkeep Feb 16, 2026
ca08349
refactor: rename @inkeep/email to @inkeep/agents-email
nick-inkeep Feb 16, 2026
bf9b104
fix: shorten email fallback link label to "Link:"
nick-inkeep Feb 16, 2026
09090ba
fix: left-align logo in email templates
nick-inkeep Feb 16, 2026
12bb8c2
docs: specify 30-minute expiry for password reset links
nick-inkeep Feb 16, 2026
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
6 changes: 6 additions & 0 deletions .changeset/roasted-cyan-asp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@inkeep/agents-core": patch
"@inkeep/agents-api": patch
---

Add email integration for BetterAuth callbacks (invitation and password reset emails via SMTP)
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,27 @@ INKEEP_AGENTS_MANAGE_API_BYPASS_SECRET=test-bypass-secret-for-ci
# Used to generate the installation URL for GitHub App
# GITHUB_APP_NAME=your-github-app-name

# ============ EMAIL CONFIGURATION (Optional) ============
# When not configured, invitation links and password reset links
# are shown in the UI for manual sharing. Email is not required.

# Option 1: Resend (recommended for cloud deployments)
# RESEND_API_KEY=re_xxxxx

# Option 2: Generic SMTP (self-hosted, Mailgun, SendGrid, etc.)
SMTP_HOST=localhost
SMTP_PORT=1025
# SMTP_USER=
# SMTP_PASSWORD=
# SMTP_SECURE=false

# From address and display name
SMTP_FROM_ADDRESS=notifications@updates.inkeep.com
SMTP_FROM_NAME=Inkeep

# Reply-to address (optional, defaults to from address)
# SMTP_REPLY_TO=support@inkeep.com

# ============ FEATURE FLAGS ============
# Enable Work Apps section in the dashboard (Slack, etc.)
# On Inkeep Cloud, this is managed automatically per tenant.
Expand Down
1 change: 1 addition & 0 deletions agents-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@inkeep/agents-manage-mcp": "workspace:^",
"@inkeep/agents-mcp": "workspace:^",
"@inkeep/agents-work-apps": "workspace:^",
"@inkeep/agents-email": "workspace:*",
"@modelcontextprotocol/sdk": "^1.25.2",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.64.1",
Expand Down
40 changes: 39 additions & 1 deletion agents-api/src/domains/manage/routes/invitations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { createApiError, getPendingInvitationsByEmail } from '@inkeep/agents-core';
import {
createApiError,
getEmailSendStatus,
getPendingInvitationsByEmail,
} from '@inkeep/agents-core';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import runDbClient from '../../../data/db/runDbClient';
Expand Down Expand Up @@ -104,6 +108,40 @@ invitationsRoutes.get('/verify', async (c) => {
// Require authentication for remaining routes
invitationsRoutes.use('*', sessionAuth());

// Internal route - not exposed in OpenAPI spec
invitationsRoutes.get('/:id/email-status', async (c) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: Add internal route comment for consistency

Issue: This endpoint follows the internal route pattern (plain Hono routing, no OpenAPI schema) but lacks the comment that peer endpoints have.

Why: Other internal routes in this file (like /pending at line 125) have a comment indicating they're internal. This helps maintainers understand the route is not part of the public API contract.

Fix:

Suggested change
invitationsRoutes.get('/:id/email-status', async (c) => {
// Internal route - not exposed in OpenAPI spec
invitationsRoutes.get('/:id/email-status', async (c) => {

Refs:

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 37e4fe4.

const invitationId = c.req.param('id');
const session = c.get('session');
const auth = c.get('auth');

if (!auth || !session) {
return c.json({ emailSent: false });
}

const activeMember = await auth.api.getActiveMember({
headers: c.req.raw.headers,
});

if (!activeMember || (activeMember.role !== 'admin' && activeMember.role !== 'owner')) {
throw createApiError({
code: 'forbidden',
message: 'Not authorized to view invitation email status',
});
}

const status = getEmailSendStatus(invitationId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: Cross-tenant invitation status disclosure

Issue: The endpoint verifies the caller is admin/owner of their active organization (line 121-130), but does not verify the invitation ID belongs to that organization. An admin from Org A can query email status for invitations belonging to Org B.

Why: While the exposed data is limited (emailSent: boolean, optional error string with 5-minute TTL), this allows cross-tenant probing. Error messages may leak SMTP configuration details (e.g., "connection refused", server addresses).

Fix: Before calling getEmailSendStatus(), verify the invitation belongs to the caller's org:

// Option 1: Look up invitation (adds DB query)
const invitation = await auth.api.getInvitation?.({ query: { id: invitationId } });
if (!invitation || invitation.organizationId !== activeMember.organizationId) {
  return c.json({ emailSent: false });
}

// Option 2: Store orgId in status bridge (requires email-send-status-store change)

Refs:

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4567c8d. Added organizationId to the email status store and verify it matches the caller's active organization before returning status. The response also strips organizationId to avoid leaking it — only emailSent and error are returned.


if (!status) {
return c.json({ emailSent: false });
}

if (status.organizationId && status.organizationId !== activeMember.organizationId) {
return c.json({ emailSent: false });
}

return c.json({ emailSent: status.emailSent, error: status.error });
});

// GET /api/invitations/pending?email=user@example.com - Get pending invitations for an email
// Internal route - not exposed in OpenAPI spec
invitationsRoutes.get('/pending', async (c) => {
Expand Down
15 changes: 12 additions & 3 deletions agents-api/src/factory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { CredentialStore, ServerConfig } from '@inkeep/agents-core';
import { CredentialStoreRegistry, createDefaultCredentialStores } from '@inkeep/agents-core';
import type { SSOProviderConfig, UserAuthConfig } from '@inkeep/agents-core/auth';
import type {
EmailServiceConfig,
SSOProviderConfig,
UserAuthConfig,
} from '@inkeep/agents-core/auth';
import { createAuth } from '@inkeep/agents-core/auth';
import { createAgentsHono } from './createApp';
import runDbClient from './data/db/runDbClient';
Expand All @@ -20,14 +24,18 @@ const defaultConfig: ServerConfig = {
},
};

export function createAgentsAuth(userAuthConfig?: UserAuthConfig) {
export function createAgentsAuth(
userAuthConfig?: UserAuthConfig,
emailService?: EmailServiceConfig
) {
return createAuth({
baseURL: env.INKEEP_AGENTS_API_URL || `http://localhost:3002`,
secret: env.BETTER_AUTH_SECRET || 'development-secret-change-in-production',
dbClient: runDbClient,
...(env.AUTH_COOKIE_DOMAIN && { cookieDomain: env.AUTH_COOKIE_DOMAIN }),
...(userAuthConfig?.ssoProviders && { ssoProviders: userAuthConfig.ssoProviders }),
...(userAuthConfig?.socialProviders && { socialProviders: userAuthConfig.socialProviders }),
...(emailService && { emailService }),
});
}

Expand All @@ -36,11 +44,12 @@ export function createAgentsApp(config?: {
credentialStores?: CredentialStore[];
auth?: UserAuthConfig;
sandboxConfig?: SandboxConfig;
emailService?: EmailServiceConfig;
}) {
const serverConfig = config?.serverConfig ?? defaultConfig;
const stores = config?.credentialStores ?? createDefaultCredentialStores();
const registry = new CredentialStoreRegistry(stores);
const auth = createAgentsAuth(config?.auth);
const auth = createAgentsAuth(config?.auth, config?.emailService);

return createAgentsHono({
serverConfig,
Expand Down
18 changes: 12 additions & 6 deletions agents-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type ServerConfig,
} from '@inkeep/agents-core';
import type { SSOProviderConfig } from '@inkeep/agents-core/auth';
import { createEmailService } from '@inkeep/agents-email';
import { Hono } from 'hono';
import { createAgentsHono } from './createApp';
import { createAgentsAuth } from './factory';
Expand Down Expand Up @@ -85,12 +86,17 @@ const socialProviders =
}
: undefined;

export const auth = createAgentsAuth({
ssoProviders: ssoProviders.filter(
(p: SSOProviderConfig | null): p is SSOProviderConfig => p !== null
),
socialProviders,
});
const emailService = createEmailService();

export const auth = createAgentsAuth(
{
ssoProviders: ssoProviders.filter(
(p: SSOProviderConfig | null): p is SSOProviderConfig => p !== null
),
socialProviders,
},
emailService
);

// Create default credential stores
const defaultStores = createDefaultCredentialStores();
Expand Down
139 changes: 139 additions & 0 deletions agents-docs/content/deployment/(docker)/email.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
---
title: Configure Email
sidebarTitle: Email
description: Set up SMTP email for team invitations and password resets
icon: LuMail
---

Configure email delivery so the platform can send team invitation emails and password reset links. Without email configured, these features degrade gracefully — invitation links are shown directly in the UI and password reset is disabled.

<Note>
For a feature overview of invitations and password reset, see [Access Control](/visual-builder/access-control).
</Note>

## How It Works

The email system uses a priority-based transport selection:

| Priority | Transport | When used |
|----------|-----------|-----------|
| 1 | **Resend** | `RESEND_API_KEY` is set |
| 2 | **Generic SMTP** | `SMTP_HOST` is set (and no Resend key) |
| 3 | **Disabled** | Neither is set — email features are turned off |

Both transports require `SMTP_FROM_ADDRESS` to be set. If the from address is missing, email is disabled even when a transport is configured.

## Environment Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `SMTP_FROM_ADDRESS` | Yes | Sender email address (e.g., `noreply@example.com`) |
| `SMTP_FROM_NAME` | No | Sender display name (e.g., `Inkeep`). Defaults to the address. |
| `SMTP_REPLY_TO` | No | Reply-to address. Defaults to the from address. |

### Option A: Resend (Recommended for Production)

[Resend](https://resend.com) provides a managed email delivery service with high deliverability.

| Variable | Required | Description |
|----------|----------|-------------|
| `RESEND_API_KEY` | Yes | Your Resend API key |

```dotenv title=".env"
RESEND_API_KEY=re_your_api_key
SMTP_FROM_ADDRESS=notifications@yourdomain.com
SMTP_FROM_NAME=Inkeep
```

<Tip>
When using Resend, you don't need to configure any SMTP host or port variables — the system connects to Resend's SMTP relay automatically.
</Tip>

### Option B: Generic SMTP

Use any SMTP server (Amazon SES, SendGrid, Gmail, self-hosted Postfix, etc.).

| Variable | Required | Description |
|----------|----------|-------------|
| `SMTP_HOST` | Yes | SMTP server hostname |
| `SMTP_PORT` | No | Server port. Default: `587` |
| `SMTP_SECURE` | No | Use TLS/SSL. Auto-detected from port if not set (`true` for port 465). |
| `SMTP_USER` | No | SMTP authentication username |
| `SMTP_PASSWORD` | No | SMTP authentication password |

```dotenv title=".env"
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=apikey
SMTP_PASSWORD=your-smtp-password
SMTP_FROM_ADDRESS=noreply@example.com
SMTP_FROM_NAME=Inkeep
```

## Local Development with Mailpit

[Mailpit](https://mailpit.axigen.com/) captures outgoing email locally without sending it to real recipients. This is the recommended setup for development and testing.

Mailpit is included in the default Docker Compose configuration. Configure the SMTP environment variables to point to it:

<Steps>
<Step>
### Configure environment variables

```dotenv title=".env"
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_SECURE=false
SMTP_FROM_ADDRESS=noreply@inkeep.local
SMTP_FROM_NAME=Inkeep
```
</Step>
<Step>
### Start services and verify

```bash
docker compose up -d
```

Open [http://localhost:8025](http://localhost:8025) to view the Mailpit inbox. Any emails sent by the platform (invitations, password resets) will appear here.
</Step>
</Steps>

## What Happens Without Email

When email is not configured, the platform adjusts its behavior:

| Feature | With email | Without email |
|---------|-----------|---------------|
| **Team invitations** | Invitation email sent to the recipient | Invitation link shown directly in the UI |
| **Password reset** | Reset link emailed to the user | "Forgot password?" link hidden from the sign-in page |

No errors are thrown — the UI adapts automatically based on whether email is available.

## Verifying Email Configuration

After configuring your environment variables, restart services and test:

1. Go to **Settings** in the sidebar
2. Click **Invite** and send a test invitation
3. Verify the email arrives (in Mailpit for local dev, or the real inbox for production)

If using Mailpit, open [http://localhost:8025](http://localhost:8025) to see the captured email.

## Troubleshooting

### Emails not being sent

- Verify `SMTP_FROM_ADDRESS` is set — this is required for all transports
- Check the API server logs for `[email]` warnings about missing configuration
- For Resend: confirm your API key is valid and the from address domain is verified in Resend
- For generic SMTP: verify the host, port, and credentials are correct

### Invitation link shown instead of email

This is expected behavior when email is not configured. Set up SMTP or Resend to enable email delivery.

### "Password reset unavailable" message

The forgot-password page shows this when email is not configured. Configure email to enable self-service password resets.
1 change: 1 addition & 0 deletions agents-docs/content/deployment/(docker)/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"pages": [
"docker-local",
"authentication",
"email",
"azure-vm",
"gcp-compute-engine",
"gcp-cloud-run",
Expand Down
28 changes: 25 additions & 3 deletions agents-docs/content/visual-builder/access-control.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,34 @@ Each organization operates as an isolated tenant:
| **Admin** | Full access to all projects and settings, can add members |
| **Member** | Access determined by project-level roles |

### Managing Team Members
### Inviting Team Members

1. Go to **Settings** in the left sidebar
2. View current members and their roles
3. Click **Add** to add new team members
4. Select a role (Admin or Member) for the new team member
3. Click **Invite** to invite a new team member
4. Enter the member's email and select a role (Admin or Member)
5. Click **Send Invitation**

If email is enabled for your tenant, the invited user receives a branded email with a link to accept the invitation and set up their account. The invitation expires after 7 days.

If email is not configured, the invitation link is displayed directly in the UI — copy and share it manually with the team member.

<Tip>
For self-hosted deployments, email requires SMTP configuration. See [Configure Email](/deployment/email) for setup instructions.
</Tip>

## Password Reset

If email is enabled for your tenant, users can reset their password through a self-service flow:

1. On the sign-in page, click **Forgot password?**
2. Enter the email address associated with your account
3. Check your inbox for a password reset link
4. Click the link and set a new password

The reset link expires after 30 minutes. If you don't receive the email, check your spam folder.

If email is not configured for your tenant, contact your organization administrator to reset your password.

## Project Roles & Permissions

Expand Down
Loading