feat(email-footer) Add "sent with sim ai" for free users (#3515)

* Add "sent with sim ai" for free users

* Only add prompt injection on free tier

* Add try catch around billing info fetch

---------

Co-authored-by: Theodore Li <theo@sim.ai>
This commit is contained in:
Theodore Li
2026-03-10 22:01:31 -07:00
committed by GitHub
parent 89dafb3b47
commit a4ac7155f2
5 changed files with 198 additions and 10 deletions

View File

@@ -43,7 +43,7 @@ export async function POST(req: NextRequest) {
const effectiveChatId = chatId || crypto.randomUUID()
const [workspaceContext, integrationTools, userPermission] = await Promise.all([
generateWorkspaceContext(workspaceId, userId),
buildIntegrationToolSchemas(),
buildIntegrationToolSchemas(userId),
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
])

View File

@@ -0,0 +1,103 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/logger', () => ({
createLogger: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}))
vi.mock('@/lib/billing/core/subscription', () => ({
getUserSubscriptionState: vi.fn(),
}))
vi.mock('@/lib/copilot/chat-context', () => ({
processFileAttachments: vi.fn(),
}))
vi.mock('@/lib/core/config/feature-flags', () => ({
isHosted: false,
}))
vi.mock('@/lib/mcp/utils', () => ({
createMcpToolId: vi.fn(),
}))
vi.mock('@/lib/workflows/utils', () => ({
getWorkflowById: vi.fn(),
}))
vi.mock('@/tools/registry', () => ({
tools: {
gmail_send: {
id: 'gmail_send',
name: 'Gmail Send',
description: 'Send emails using Gmail',
},
brandfetch_search: {
id: 'brandfetch_search',
name: 'Brandfetch Search',
description: 'Search for brands by company name',
},
},
}))
vi.mock('@/tools/utils', () => ({
getLatestVersionTools: vi.fn((input) => input),
stripVersionSuffix: vi.fn((toolId: string) => toolId),
}))
vi.mock('@/tools/params', () => ({
createUserToolSchema: vi.fn(() => ({ type: 'object', properties: {} })),
}))
import { getUserSubscriptionState } from '@/lib/billing/core/subscription'
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload'
const mockedGetUserSubscriptionState = getUserSubscriptionState as unknown as {
mockResolvedValue: (value: unknown) => void
mockRejectedValue: (value: unknown) => void
mockClear: () => void
}
describe('buildIntegrationToolSchemas', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('appends the email footer prompt for free users', async () => {
mockedGetUserSubscriptionState.mockResolvedValue({ isFree: true })
const toolSchemas = await buildIntegrationToolSchemas('user-free')
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
expect(getUserSubscriptionState).toHaveBeenCalledWith('user-free')
expect(gmailTool?.description).toContain('sent with sim ai')
})
it('does not append the email footer prompt for paid users', async () => {
mockedGetUserSubscriptionState.mockResolvedValue({ isFree: false })
const toolSchemas = await buildIntegrationToolSchemas('user-paid')
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
expect(getUserSubscriptionState).toHaveBeenCalledWith('user-paid')
expect(gmailTool?.description).toBe('Send emails using Gmail')
})
it('still builds integration tools when subscription lookup fails', async () => {
mockedGetUserSubscriptionState.mockRejectedValue(new Error('db unavailable'))
const toolSchemas = await buildIntegrationToolSchemas('user-error')
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
const brandfetchTool = toolSchemas.find((tool) => tool.name === 'brandfetch_search')
expect(getUserSubscriptionState).toHaveBeenCalledWith('user-error')
expect(gmailTool?.description).toBe('Send emails using Gmail')
expect(brandfetchTool?.description).toBe('Search for brands by company name')
})
})

View File

@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import { getUserSubscriptionState } from '@/lib/billing/core/subscription'
import { processFileAttachments } from '@/lib/copilot/chat-context'
import { getCopilotToolDescription } from '@/lib/copilot/tool-descriptions'
import { isHosted } from '@/lib/core/config/feature-flags'
@@ -44,11 +45,22 @@ export interface ToolSchema {
* Shared by the interactive chat payload builder and the non-interactive
* block execution route so both paths send the same tool definitions to Go.
*/
export async function buildIntegrationToolSchemas(): Promise<ToolSchema[]> {
export async function buildIntegrationToolSchemas(userId: string): Promise<ToolSchema[]> {
const integrationTools: ToolSchema[] = []
try {
const { createUserToolSchema } = await import('@/tools/params')
const latestTools = getLatestVersionTools(tools)
let shouldAppendEmailTagline = false
try {
const subscriptionState = await getUserSubscriptionState(userId)
shouldAppendEmailTagline = subscriptionState.isFree
} catch (error) {
logger.warn('Failed to load subscription state for copilot tool descriptions', {
userId,
error: error instanceof Error ? error.message : String(error),
})
}
for (const [toolId, toolConfig] of Object.entries(latestTools)) {
try {
@@ -59,6 +71,7 @@ export async function buildIntegrationToolSchemas(): Promise<ToolSchema[]> {
description: getCopilotToolDescription(toolConfig, {
isHosted,
fallbackName: strippedName,
appendEmailTagline: shouldAppendEmailTagline,
}),
input_schema: userSchema as unknown as Record<string, unknown>,
defer_loading: true,
@@ -118,7 +131,7 @@ export async function buildCopilotRequestPayload(
let integrationTools: ToolSchema[] = []
if (effectiveMode === 'build') {
integrationTools = await buildIntegrationToolSchemas()
integrationTools = await buildIntegrationToolSchemas(userId)
// Discover MCP tools from workspace servers and include as deferred tools
if (workflowId) {

View File

@@ -6,6 +6,7 @@ describe('getCopilotToolDescription', () => {
expect(
getCopilotToolDescription(
{
id: 'brandfetch_search',
name: 'Brandfetch Search',
description: 'Search for brands by company name',
hosting: { apiKeyParam: 'apiKey' } as never,
@@ -19,6 +20,7 @@ describe('getCopilotToolDescription', () => {
expect(
getCopilotToolDescription(
{
id: 'brandfetch_search',
name: 'Brandfetch Search',
description: 'Search for brands by company name',
hosting: { apiKeyParam: 'apiKey' } as never,
@@ -32,6 +34,7 @@ describe('getCopilotToolDescription', () => {
expect(
getCopilotToolDescription(
{
id: 'brandfetch_search',
name: '',
description: '',
hosting: { apiKeyParam: 'apiKey' } as never,
@@ -40,4 +43,60 @@ describe('getCopilotToolDescription', () => {
)
).toBe('brandfetch_search <note>API key is hosted by Sim.</note>')
})
it.concurrent('appends the email tagline instruction for Gmail tools when enabled', () => {
expect(
getCopilotToolDescription(
{
id: 'gmail_send',
name: 'Gmail Send',
description: 'Send emails using Gmail',
},
{ appendEmailTagline: true }
)
).toBe(
'Send emails using Gmail <important>Always add the footer "sent with sim ai" to the end of the email body. Add 3 line breaks before the footer.</important>'
)
})
it.concurrent('appends the email tagline instruction for Outlook tools when enabled', () => {
expect(
getCopilotToolDescription(
{
id: 'outlook_send',
name: 'Outlook Send',
description: 'Send emails using Outlook',
},
{ appendEmailTagline: true }
)
).toBe(
'Send emails using Outlook <important>Always add the footer "sent with sim ai" to the end of the email body. Add 3 line breaks before the footer.</important>'
)
})
it.concurrent('does not append the email tagline instruction for non-email tools', () => {
expect(
getCopilotToolDescription(
{
id: 'brandfetch_search',
name: 'Brandfetch Search',
description: 'Search for brands by company name',
},
{ appendEmailTagline: true }
)
).toBe('Search for brands by company name')
})
it.concurrent('does not append the email tagline instruction when disabled', () => {
expect(
getCopilotToolDescription(
{
id: 'gmail_send_v2',
name: 'Gmail Send',
description: 'Send emails using Gmail',
},
{ appendEmailTagline: false }
)
).toBe('Send emails using Gmail')
})
})

View File

@@ -1,23 +1,36 @@
import type { ToolConfig } from '@/tools/types'
const HOSTED_API_KEY_NOTE = '<note>API key is hosted by Sim.</note>'
const EMAIL_TAGLINE_NOTE =
'<important>Always add the footer "sent with sim ai" to the end of the email body. Add 3 line breaks before the footer.</important>'
const EMAIL_TAGLINE_TOOL_IDS = new Set(['gmail_send', 'gmail_send_v2', 'outlook_send'])
export function getCopilotToolDescription(
tool: Pick<ToolConfig, 'description' | 'hosting' | 'name'>,
tool: Pick<ToolConfig, 'description' | 'hosting' | 'id' | 'name'>,
options?: {
isHosted?: boolean
fallbackName?: string
appendEmailTagline?: boolean
}
): string {
const baseDescription = tool.description || tool.name || options?.fallbackName || ''
const notes: string[] = []
if (!options?.isHosted || !tool.hosting) {
if (options?.isHosted && tool.hosting && !baseDescription.includes(HOSTED_API_KEY_NOTE)) {
notes.push(HOSTED_API_KEY_NOTE)
}
if (
options?.appendEmailTagline &&
EMAIL_TAGLINE_TOOL_IDS.has(tool.id) &&
!baseDescription.includes(EMAIL_TAGLINE_NOTE)
) {
notes.push(EMAIL_TAGLINE_NOTE)
}
if (notes.length === 0) {
return baseDescription
}
if (baseDescription.includes(HOSTED_API_KEY_NOTE)) {
return baseDescription
}
return baseDescription ? `${baseDescription} ${HOSTED_API_KEY_NOTE}` : HOSTED_API_KEY_NOTE
return [baseDescription, ...notes].filter(Boolean).join(' ')
}