Skip to content

Commit a4ac715

Browse files
TheodoreSpeaksTheodore Li
andauthored
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>
1 parent 89dafb3 commit a4ac715

File tree

5 files changed

+196
-8
lines changed

5 files changed

+196
-8
lines changed

apps/sim/app/api/mothership/execute/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export async function POST(req: NextRequest) {
4343
const effectiveChatId = chatId || crypto.randomUUID()
4444
const [workspaceContext, integrationTools, userPermission] = await Promise.all([
4545
generateWorkspaceContext(workspaceId, userId),
46-
buildIntegrationToolSchemas(),
46+
buildIntegrationToolSchemas(userId),
4747
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
4848
])
4949

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('@sim/logger', () => ({
7+
createLogger: vi.fn(() => ({
8+
info: vi.fn(),
9+
warn: vi.fn(),
10+
error: vi.fn(),
11+
})),
12+
}))
13+
14+
vi.mock('@/lib/billing/core/subscription', () => ({
15+
getUserSubscriptionState: vi.fn(),
16+
}))
17+
18+
vi.mock('@/lib/copilot/chat-context', () => ({
19+
processFileAttachments: vi.fn(),
20+
}))
21+
22+
vi.mock('@/lib/core/config/feature-flags', () => ({
23+
isHosted: false,
24+
}))
25+
26+
vi.mock('@/lib/mcp/utils', () => ({
27+
createMcpToolId: vi.fn(),
28+
}))
29+
30+
vi.mock('@/lib/workflows/utils', () => ({
31+
getWorkflowById: vi.fn(),
32+
}))
33+
34+
vi.mock('@/tools/registry', () => ({
35+
tools: {
36+
gmail_send: {
37+
id: 'gmail_send',
38+
name: 'Gmail Send',
39+
description: 'Send emails using Gmail',
40+
},
41+
brandfetch_search: {
42+
id: 'brandfetch_search',
43+
name: 'Brandfetch Search',
44+
description: 'Search for brands by company name',
45+
},
46+
},
47+
}))
48+
49+
vi.mock('@/tools/utils', () => ({
50+
getLatestVersionTools: vi.fn((input) => input),
51+
stripVersionSuffix: vi.fn((toolId: string) => toolId),
52+
}))
53+
54+
vi.mock('@/tools/params', () => ({
55+
createUserToolSchema: vi.fn(() => ({ type: 'object', properties: {} })),
56+
}))
57+
58+
import { getUserSubscriptionState } from '@/lib/billing/core/subscription'
59+
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload'
60+
61+
const mockedGetUserSubscriptionState = getUserSubscriptionState as unknown as {
62+
mockResolvedValue: (value: unknown) => void
63+
mockRejectedValue: (value: unknown) => void
64+
mockClear: () => void
65+
}
66+
67+
describe('buildIntegrationToolSchemas', () => {
68+
beforeEach(() => {
69+
vi.clearAllMocks()
70+
})
71+
72+
it('appends the email footer prompt for free users', async () => {
73+
mockedGetUserSubscriptionState.mockResolvedValue({ isFree: true })
74+
75+
const toolSchemas = await buildIntegrationToolSchemas('user-free')
76+
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
77+
78+
expect(getUserSubscriptionState).toHaveBeenCalledWith('user-free')
79+
expect(gmailTool?.description).toContain('sent with sim ai')
80+
})
81+
82+
it('does not append the email footer prompt for paid users', async () => {
83+
mockedGetUserSubscriptionState.mockResolvedValue({ isFree: false })
84+
85+
const toolSchemas = await buildIntegrationToolSchemas('user-paid')
86+
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
87+
88+
expect(getUserSubscriptionState).toHaveBeenCalledWith('user-paid')
89+
expect(gmailTool?.description).toBe('Send emails using Gmail')
90+
})
91+
92+
it('still builds integration tools when subscription lookup fails', async () => {
93+
mockedGetUserSubscriptionState.mockRejectedValue(new Error('db unavailable'))
94+
95+
const toolSchemas = await buildIntegrationToolSchemas('user-error')
96+
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
97+
const brandfetchTool = toolSchemas.find((tool) => tool.name === 'brandfetch_search')
98+
99+
expect(getUserSubscriptionState).toHaveBeenCalledWith('user-error')
100+
expect(gmailTool?.description).toBe('Send emails using Gmail')
101+
expect(brandfetchTool?.description).toBe('Search for brands by company name')
102+
})
103+
})

apps/sim/lib/copilot/chat-payload.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { getUserSubscriptionState } from '@/lib/billing/core/subscription'
23
import { processFileAttachments } from '@/lib/copilot/chat-context'
34
import { getCopilotToolDescription } from '@/lib/copilot/tool-descriptions'
45
import { isHosted } from '@/lib/core/config/feature-flags'
@@ -44,11 +45,22 @@ export interface ToolSchema {
4445
* Shared by the interactive chat payload builder and the non-interactive
4546
* block execution route so both paths send the same tool definitions to Go.
4647
*/
47-
export async function buildIntegrationToolSchemas(): Promise<ToolSchema[]> {
48+
export async function buildIntegrationToolSchemas(userId: string): Promise<ToolSchema[]> {
4849
const integrationTools: ToolSchema[] = []
4950
try {
5051
const { createUserToolSchema } = await import('@/tools/params')
5152
const latestTools = getLatestVersionTools(tools)
53+
let shouldAppendEmailTagline = false
54+
55+
try {
56+
const subscriptionState = await getUserSubscriptionState(userId)
57+
shouldAppendEmailTagline = subscriptionState.isFree
58+
} catch (error) {
59+
logger.warn('Failed to load subscription state for copilot tool descriptions', {
60+
userId,
61+
error: error instanceof Error ? error.message : String(error),
62+
})
63+
}
5264

5365
for (const [toolId, toolConfig] of Object.entries(latestTools)) {
5466
try {
@@ -59,6 +71,7 @@ export async function buildIntegrationToolSchemas(): Promise<ToolSchema[]> {
5971
description: getCopilotToolDescription(toolConfig, {
6072
isHosted,
6173
fallbackName: strippedName,
74+
appendEmailTagline: shouldAppendEmailTagline,
6275
}),
6376
input_schema: userSchema as unknown as Record<string, unknown>,
6477
defer_loading: true,
@@ -118,7 +131,7 @@ export async function buildCopilotRequestPayload(
118131
let integrationTools: ToolSchema[] = []
119132

120133
if (effectiveMode === 'build') {
121-
integrationTools = await buildIntegrationToolSchemas()
134+
integrationTools = await buildIntegrationToolSchemas(userId)
122135

123136
// Discover MCP tools from workspace servers and include as deferred tools
124137
if (workflowId) {

apps/sim/lib/copilot/tool-descriptions.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ describe('getCopilotToolDescription', () => {
66
expect(
77
getCopilotToolDescription(
88
{
9+
id: 'brandfetch_search',
910
name: 'Brandfetch Search',
1011
description: 'Search for brands by company name',
1112
hosting: { apiKeyParam: 'apiKey' } as never,
@@ -19,6 +20,7 @@ describe('getCopilotToolDescription', () => {
1920
expect(
2021
getCopilotToolDescription(
2122
{
23+
id: 'brandfetch_search',
2224
name: 'Brandfetch Search',
2325
description: 'Search for brands by company name',
2426
hosting: { apiKeyParam: 'apiKey' } as never,
@@ -32,6 +34,7 @@ describe('getCopilotToolDescription', () => {
3234
expect(
3335
getCopilotToolDescription(
3436
{
37+
id: 'brandfetch_search',
3538
name: '',
3639
description: '',
3740
hosting: { apiKeyParam: 'apiKey' } as never,
@@ -40,4 +43,60 @@ describe('getCopilotToolDescription', () => {
4043
)
4144
).toBe('brandfetch_search <note>API key is hosted by Sim.</note>')
4245
})
46+
47+
it.concurrent('appends the email tagline instruction for Gmail tools when enabled', () => {
48+
expect(
49+
getCopilotToolDescription(
50+
{
51+
id: 'gmail_send',
52+
name: 'Gmail Send',
53+
description: 'Send emails using Gmail',
54+
},
55+
{ appendEmailTagline: true }
56+
)
57+
).toBe(
58+
'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>'
59+
)
60+
})
61+
62+
it.concurrent('appends the email tagline instruction for Outlook tools when enabled', () => {
63+
expect(
64+
getCopilotToolDescription(
65+
{
66+
id: 'outlook_send',
67+
name: 'Outlook Send',
68+
description: 'Send emails using Outlook',
69+
},
70+
{ appendEmailTagline: true }
71+
)
72+
).toBe(
73+
'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>'
74+
)
75+
})
76+
77+
it.concurrent('does not append the email tagline instruction for non-email tools', () => {
78+
expect(
79+
getCopilotToolDescription(
80+
{
81+
id: 'brandfetch_search',
82+
name: 'Brandfetch Search',
83+
description: 'Search for brands by company name',
84+
},
85+
{ appendEmailTagline: true }
86+
)
87+
).toBe('Search for brands by company name')
88+
})
89+
90+
it.concurrent('does not append the email tagline instruction when disabled', () => {
91+
expect(
92+
getCopilotToolDescription(
93+
{
94+
id: 'gmail_send_v2',
95+
name: 'Gmail Send',
96+
description: 'Send emails using Gmail',
97+
},
98+
{ appendEmailTagline: false }
99+
)
100+
).toBe('Send emails using Gmail')
101+
})
43102
})
Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,36 @@
11
import type { ToolConfig } from '@/tools/types'
22

33
const HOSTED_API_KEY_NOTE = '<note>API key is hosted by Sim.</note>'
4+
const EMAIL_TAGLINE_NOTE =
5+
'<important>Always add the footer "sent with sim ai" to the end of the email body. Add 3 line breaks before the footer.</important>'
6+
const EMAIL_TAGLINE_TOOL_IDS = new Set(['gmail_send', 'gmail_send_v2', 'outlook_send'])
47

58
export function getCopilotToolDescription(
6-
tool: Pick<ToolConfig, 'description' | 'hosting' | 'name'>,
9+
tool: Pick<ToolConfig, 'description' | 'hosting' | 'id' | 'name'>,
710
options?: {
811
isHosted?: boolean
912
fallbackName?: string
13+
appendEmailTagline?: boolean
1014
}
1115
): string {
1216
const baseDescription = tool.description || tool.name || options?.fallbackName || ''
17+
const notes: string[] = []
1318

14-
if (!options?.isHosted || !tool.hosting) {
15-
return baseDescription
19+
if (options?.isHosted && tool.hosting && !baseDescription.includes(HOSTED_API_KEY_NOTE)) {
20+
notes.push(HOSTED_API_KEY_NOTE)
21+
}
22+
23+
if (
24+
options?.appendEmailTagline &&
25+
EMAIL_TAGLINE_TOOL_IDS.has(tool.id) &&
26+
!baseDescription.includes(EMAIL_TAGLINE_NOTE)
27+
) {
28+
notes.push(EMAIL_TAGLINE_NOTE)
1629
}
1730

18-
if (baseDescription.includes(HOSTED_API_KEY_NOTE)) {
31+
if (notes.length === 0) {
1932
return baseDescription
2033
}
2134

22-
return baseDescription ? `${baseDescription} ${HOSTED_API_KEY_NOTE}` : HOSTED_API_KEY_NOTE
35+
return [baseDescription, ...notes].filter(Boolean).join(' ')
2336
}

0 commit comments

Comments
 (0)