diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json
index 2b3f9e19a1..7d8cf706be 100644
--- a/apps/sim/app/(landing)/integrations/data/integrations.json
+++ b/apps/sim/app/(landing)/integrations/data/integrations.json
@@ -168,35 +168,35 @@
"operations": [
{
"name": "List Bases",
- "description": ""
+ "description": "List all bases the authenticated user has access to"
},
{
"name": "List Tables",
- "description": ""
+ "description": "List all tables and their schema in an Airtable base"
},
{
"name": "Get Base Schema",
- "description": ""
+ "description": "Get the schema of all tables, fields, and views in an Airtable base"
},
{
"name": "List Records",
- "description": ""
+ "description": "Read records from an Airtable table"
},
{
"name": "Get Record",
- "description": ""
+ "description": "Retrieve a single record from an Airtable table by its ID"
},
{
"name": "Create Records",
- "description": ""
+ "description": "Write new records to an Airtable table"
},
{
"name": "Update Record",
- "description": ""
+ "description": "Update an existing record in an Airtable table by ID"
},
{
"name": "Update Multiple Records",
- "description": ""
+ "description": "Update multiple existing records in an Airtable table"
}
],
"operationCount": 8,
@@ -287,11 +287,11 @@
},
{
"name": "Delete Index",
- "description": ""
+ "description": "Delete an entire Algolia index and all its records"
},
{
"name": "Copy/Move Index",
- "description": ""
+ "description": "Copy or move an Algolia index to a new destination"
},
{
"name": "Clear Records",
@@ -609,7 +609,7 @@
},
{
"name": "Add to Sequence",
- "description": ""
+ "description": "Add contacts to an Apollo sequence"
},
{
"name": "Create Task",
@@ -904,7 +904,7 @@
},
{
"name": "Assert Record (Upsert)",
- "description": "Upsert a record in Attio \u2014 creates it if no match is found, updates it if a match exists"
+ "description": "Upsert a record in Attio — creates it if no match is found, updates it if a match exists"
},
{
"name": "List Notes",
@@ -1399,7 +1399,7 @@
},
{
"name": "List Event Types",
- "description": ""
+ "description": "Retrieve a list of all event types"
},
{
"name": "Update Event Type",
@@ -1508,7 +1508,7 @@
},
{
"name": "List Event Types",
- "description": ""
+ "description": "Retrieve a list of all event types for a user or organization"
},
{
"name": "Get Event Type",
@@ -1758,11 +1758,11 @@
"operations": [
{
"name": "Read Page",
- "description": ""
+ "description": "Retrieve content from Confluence pages using the Confluence API."
},
{
"name": "Create Page",
- "description": ""
+ "description": "Create a new page in a Confluence space."
},
{
"name": "Update Page",
@@ -1770,7 +1770,7 @@
},
{
"name": "Delete Page",
- "description": ""
+ "description": "Delete a Confluence page. By default moves to trash; use purge=true to permanently delete."
},
{
"name": "List Pages in Space",
@@ -2504,7 +2504,7 @@
"slug": "dspy",
"name": "DSPy",
"description": "Run predictions using self-hosted DSPy programs",
- "longDescription": "Integrate with your self-hosted DSPy programs for LLM-powered predictions. Supports Predict, Chain of Thought, and ReAct agents. DSPy is the framework for programming\u2014not prompting\u2014language models.",
+ "longDescription": "Integrate with your self-hosted DSPy programs for LLM-powered predictions. Supports Predict, Chain of Thought, and ReAct agents. DSPy is the framework for programming—not prompting—language models.",
"bgColor": "#E0E0E0",
"iconName": "DsPyIcon",
"docsUrl": "https://docs.sim.ai/tools/dspy",
@@ -2635,15 +2635,15 @@
},
{
"name": "Create Index",
- "description": ""
+ "description": "Create a new index with optional settings and mappings."
},
{
"name": "Delete Index",
- "description": ""
+ "description": "Delete an index and all its documents. This operation is irreversible."
},
{
"name": "Get Index Info",
- "description": ""
+ "description": "Retrieve index information including settings, mappings, and aliases."
},
{
"name": "List Indices",
@@ -3668,51 +3668,51 @@
"operations": [
{
"name": "Send Email",
- "description": ""
+ "description": "Send Gmail messages"
},
{
"name": "Read Email",
- "description": ""
+ "description": "Read Gmail messages"
},
{
"name": "Draft Email",
- "description": ""
+ "description": "Draft emails using Gmail"
},
{
"name": "Search Email",
- "description": ""
+ "description": "Search emails in Gmail"
},
{
"name": "Move Email",
- "description": ""
+ "description": "Move emails between Gmail labels/folders"
},
{
"name": "Mark as Read",
- "description": ""
+ "description": "Mark a Gmail message as read"
},
{
"name": "Mark as Unread",
- "description": ""
+ "description": "Mark a Gmail message as unread"
},
{
"name": "Archive Email",
- "description": ""
+ "description": "Archive a Gmail message (remove from inbox)"
},
{
"name": "Unarchive Email",
- "description": ""
+ "description": "Unarchive a Gmail message (move back to inbox)"
},
{
"name": "Delete Email",
- "description": ""
+ "description": "Delete a Gmail message (move to trash)"
},
{
"name": "Add Label",
- "description": ""
+ "description": "Add label(s) to a Gmail message"
},
{
"name": "Remove Label",
- "description": ""
+ "description": "Remove label(s) from a Gmail message"
}
],
"operationCount": 12,
@@ -4080,7 +4080,7 @@
},
{
"name": "Create File",
- "description": ""
+ "description": "Upload a file to Google Drive with complete metadata returned"
},
{
"name": "Upload File",
@@ -4453,31 +4453,31 @@
},
{
"name": "Clear Data",
- "description": ""
+ "description": "Clear values from a specific range in a Google Sheets spreadsheet"
},
{
"name": "Get Spreadsheet Info",
- "description": ""
+ "description": "Get metadata about a Google Sheets spreadsheet including title and sheet list"
},
{
"name": "Create Spreadsheet",
- "description": ""
+ "description": "Create a new Google Sheets spreadsheet"
},
{
"name": "Batch Read",
- "description": ""
+ "description": "Read multiple ranges from a Google Sheets spreadsheet in a single request"
},
{
"name": "Batch Update",
- "description": ""
+ "description": "Update multiple ranges in a Google Sheets spreadsheet in a single request"
},
{
"name": "Batch Clear",
- "description": ""
+ "description": "Clear multiple ranges in a Google Sheets spreadsheet in a single request"
},
{
"name": "Copy Sheet",
- "description": ""
+ "description": "Copy a sheet from one spreadsheet to another"
}
],
"operationCount": 11,
@@ -4540,7 +4540,7 @@
},
{
"name": "Reorder Slides",
- "description": ""
+ "description": "Move one or more slides to a new position in a Google Slides presentation"
},
{
"name": "Create Table",
@@ -4807,7 +4807,7 @@
},
{
"name": "List Meeting Types",
- "description": ""
+ "description": "List all meeting types in the workspace"
},
{
"name": "Create Webhook",
@@ -5062,13 +5062,9 @@
"iconName": "HubspotIcon",
"docsUrl": "https://docs.sim.ai/tools/hubspot",
"operations": [
- {
- "name": "Get Users",
- "description": "Retrieve all users from HubSpot account"
- },
{
"name": "Get Contacts",
- "description": ""
+ "description": "Retrieve all contacts from HubSpot account with pagination support"
},
{
"name": "Create Contact",
@@ -5084,7 +5080,7 @@
},
{
"name": "Get Companies",
- "description": ""
+ "description": "Retrieve all companies from HubSpot account with pagination support"
},
{
"name": "Create Company",
@@ -5100,10 +5096,90 @@
},
{
"name": "Get Deals",
- "description": ""
+ "description": "Retrieve all deals from HubSpot account with pagination support"
+ },
+ {
+ "name": "Create Deal",
+ "description": "Create a new deal in HubSpot. Requires at least a dealname property"
+ },
+ {
+ "name": "Update Deal",
+ "description": "Update an existing deal in HubSpot by ID"
+ },
+ {
+ "name": "Search Deals",
+ "description": "Search for deals in HubSpot using filters, sorting, and queries"
+ },
+ {
+ "name": "Get Tickets",
+ "description": "Retrieve all tickets from HubSpot account with pagination support"
+ },
+ {
+ "name": "Create Ticket",
+ "description": "Create a new ticket in HubSpot. Requires subject and hs_pipeline_stage properties"
+ },
+ {
+ "name": "Update Ticket",
+ "description": "Update an existing ticket in HubSpot by ID"
+ },
+ {
+ "name": "Search Tickets",
+ "description": "Search for tickets in HubSpot using filters, sorting, and queries"
+ },
+ {
+ "name": "Get Line Items",
+ "description": "Retrieve all line items from HubSpot account with pagination support"
+ },
+ {
+ "name": "Create Line Item",
+ "description": "Create a new line item in HubSpot. Requires at least a name property"
+ },
+ {
+ "name": "Update Line Item",
+ "description": "Update an existing line item in HubSpot by ID"
+ },
+ {
+ "name": "Get Quotes",
+ "description": "Retrieve all quotes from HubSpot account with pagination support"
+ },
+ {
+ "name": "Get Appointments",
+ "description": "Retrieve all appointments from HubSpot account with pagination support"
+ },
+ {
+ "name": "Create Appointment",
+ "description": "Create a new appointment in HubSpot"
+ },
+ {
+ "name": "Update Appointment",
+ "description": "Update an existing appointment in HubSpot by ID"
+ },
+ {
+ "name": "Get Carts",
+ "description": "Retrieve all carts from HubSpot account with pagination support"
+ },
+ {
+ "name": "List Owners",
+ "description": "Retrieve all owners from HubSpot account with pagination support"
+ },
+ {
+ "name": "Get Marketing Events",
+ "description": "Retrieve all marketing events from HubSpot account with pagination support"
+ },
+ {
+ "name": "Get Lists",
+ "description": "Search and retrieve lists from HubSpot account"
+ },
+ {
+ "name": "Create List",
+ "description": "Create a new list in HubSpot. Specify the object type and processing type (MANUAL or DYNAMIC)"
+ },
+ {
+ "name": "Get Users",
+ "description": "Retrieve all users from HubSpot account"
}
],
- "operationCount": 10,
+ "operationCount": 29,
"triggers": [
{
"id": "hubspot_contact_created",
@@ -5611,7 +5687,7 @@
},
{
"name": "Update Ticket",
- "description": ""
+ "description": "Update a ticket in Intercom (change state, assignment, attributes)"
},
{
"name": "Create Message",
@@ -5619,59 +5695,59 @@
},
{
"name": "List Admins",
- "description": ""
+ "description": "Fetch a list of all admins for the workspace"
},
{
"name": "Close Conversation",
- "description": ""
+ "description": "Close a conversation in Intercom"
},
{
"name": "Open Conversation",
- "description": ""
+ "description": "Open a closed or snoozed conversation in Intercom"
},
{
"name": "Snooze Conversation",
- "description": ""
+ "description": "Snooze a conversation to reopen at a future time"
},
{
"name": "Assign Conversation",
- "description": ""
+ "description": "Assign a conversation to an admin or team in Intercom"
},
{
"name": "List Tags",
- "description": ""
+ "description": "Fetch a list of all tags in the workspace"
},
{
"name": "Create Tag",
- "description": ""
+ "description": "Create a new tag or update an existing tag name"
},
{
"name": "Tag Contact",
- "description": ""
+ "description": "Add a tag to a specific contact"
},
{
"name": "Untag Contact",
- "description": ""
+ "description": "Remove a tag from a specific contact"
},
{
"name": "Tag Conversation",
- "description": ""
+ "description": "Add a tag to a specific conversation"
},
{
"name": "Create Note",
- "description": ""
+ "description": "Add a note to a specific contact"
},
{
"name": "Create Event",
- "description": ""
+ "description": "Track a custom event for a contact in Intercom"
},
{
"name": "Attach Contact to Company",
- "description": ""
+ "description": "Attach a contact to a company in Intercom"
},
{
"name": "Detach Contact from Company",
- "description": ""
+ "description": "Remove a contact from a company in Intercom"
}
],
"operationCount": 31,
@@ -5721,7 +5797,7 @@
"operations": [
{
"name": "Read Issue",
- "description": ""
+ "description": "Retrieve detailed information about a specific Jira issue"
},
{
"name": "Update Issue",
@@ -5733,19 +5809,19 @@
},
{
"name": "Delete Issue",
- "description": ""
+ "description": "Delete a Jira issue"
},
{
"name": "Assign Issue",
- "description": ""
+ "description": "Assign a Jira issue to a user"
},
{
"name": "Transition Issue",
- "description": ""
+ "description": "Move a Jira issue between workflow statuses (e.g., To Do -> In Progress)"
},
{
"name": "Search Issues",
- "description": ""
+ "description": "Search for Jira issues using JQL (Jira Query Language)"
},
{
"name": "Add Comment",
@@ -5793,11 +5869,11 @@
},
{
"name": "Create Issue Link",
- "description": ""
+ "description": "Create a link relationship between two Jira issues"
},
{
"name": "Delete Issue Link",
- "description": ""
+ "description": "Delete a link between two Jira issues"
},
{
"name": "Add Watcher",
@@ -5867,87 +5943,87 @@
"operations": [
{
"name": "Get Service Desks",
- "description": ""
+ "description": "Get all service desks from Jira Service Management"
},
{
"name": "Get Request Types",
- "description": ""
+ "description": "Get request types for a service desk in Jira Service Management"
},
{
"name": "Create Request",
- "description": ""
+ "description": "Create a new service request in Jira Service Management"
},
{
"name": "Get Request",
- "description": ""
+ "description": "Get a single service request from Jira Service Management"
},
{
"name": "Get Requests",
- "description": ""
+ "description": "Get multiple service requests from Jira Service Management"
},
{
"name": "Add Comment",
- "description": ""
+ "description": "Add a comment (public or internal) to a service request in Jira Service Management"
},
{
"name": "Get Comments",
- "description": ""
+ "description": "Get comments for a service request in Jira Service Management"
},
{
"name": "Get Customers",
- "description": ""
+ "description": "Get customers for a service desk in Jira Service Management"
},
{
"name": "Add Customer",
- "description": ""
+ "description": "Add customers to a service desk in Jira Service Management"
},
{
"name": "Get Organizations",
- "description": ""
+ "description": "Get organizations for a service desk in Jira Service Management"
},
{
"name": "Create Organization",
- "description": ""
+ "description": "Create a new organization in Jira Service Management"
},
{
"name": "Add Organization",
- "description": ""
+ "description": "Add an organization to a service desk in Jira Service Management"
},
{
"name": "Get Queues",
- "description": ""
+ "description": "Get queues for a service desk in Jira Service Management"
},
{
"name": "Get SLA",
- "description": ""
+ "description": "Get SLA information for a service request in Jira Service Management"
},
{
"name": "Get Transitions",
- "description": ""
+ "description": "Get available transitions for a service request in Jira Service Management"
},
{
"name": "Transition Request",
- "description": ""
+ "description": "Transition a service request to a new status in Jira Service Management"
},
{
"name": "Get Participants",
- "description": ""
+ "description": "Get participants for a request in Jira Service Management"
},
{
"name": "Add Participants",
- "description": ""
+ "description": "Add participants to a request in Jira Service Management"
},
{
"name": "Get Approvals",
- "description": ""
+ "description": "Get approvals for a request in Jira Service Management"
},
{
"name": "Answer Approval",
- "description": ""
+ "description": "Approve or decline an approval request in Jira Service Management"
},
{
"name": "Get Request Type Fields",
- "description": ""
+ "description": "Get the fields required to create a request of a specific type in Jira Service Management"
}
],
"operationCount": 21,
@@ -7823,7 +7899,7 @@
},
{
"name": "Create File",
- "description": ""
+ "description": "Upload a file to OneDrive"
},
{
"name": "Upload File",
@@ -7862,39 +7938,39 @@
"operations": [
{
"name": "Send Email",
- "description": ""
+ "description": "Send emails using Outlook"
},
{
"name": "Draft Email",
- "description": ""
+ "description": "Draft emails using Outlook"
},
{
"name": "Read Email",
- "description": ""
+ "description": "Read emails from Outlook"
},
{
"name": "Forward Email",
- "description": ""
+ "description": "Forward an existing Outlook message to specified recipients"
},
{
"name": "Move Email",
- "description": ""
+ "description": "Move emails between Outlook folders"
},
{
"name": "Mark as Read",
- "description": ""
+ "description": "Mark an Outlook message as read"
},
{
"name": "Mark as Unread",
- "description": ""
+ "description": "Mark an Outlook message as unread"
},
{
"name": "Delete Email",
- "description": ""
+ "description": "Delete an Outlook message (move to Deleted Items)"
},
{
"name": "Copy Email",
- "description": ""
+ "description": "Copy an Outlook message to another folder"
}
],
"operationCount": 9,
@@ -7960,15 +8036,15 @@
"operations": [
{
"name": "Search",
- "description": ""
+ "description": "Search the web using Parallel AI. Provides comprehensive search results with intelligent processing and content extraction."
},
{
"name": "Extract from URLs",
- "description": ""
+ "description": "Extract targeted information from specific URLs using Parallel AI. Processes provided URLs to pull relevant content based on your objective."
},
{
"name": "Deep Research",
- "description": ""
+ "description": "Conduct comprehensive deep research across the web using Parallel AI. Synthesizes information from multiple sources with citations. Can take up to 45 minutes to complete."
}
],
"operationCount": 3,
@@ -8018,7 +8094,7 @@
"operations": [
{
"name": "Generate Embeddings",
- "description": ""
+ "description": "Generate embeddings from text using Pinecone"
},
{
"name": "Upsert Text",
@@ -8499,15 +8575,15 @@
"operations": [
{
"name": "Upsert",
- "description": ""
+ "description": "Insert or update points in a Qdrant collection"
},
{
"name": "Search",
- "description": ""
+ "description": "Search for similar vectors in a Qdrant collection"
},
{
"name": "Fetch",
- "description": ""
+ "description": "Fetch points by ID from a Qdrant collection"
}
],
"operationCount": 3,
@@ -8943,7 +9019,7 @@
},
{
"name": "List Leave Types",
- "description": ""
+ "description": "List company leave types configured in Rippling"
},
{
"name": "Create Group",
@@ -9125,7 +9201,7 @@
},
{
"name": "List Report Types",
- "description": ""
+ "description": "Get a list of available report types"
},
{
"name": "List Dashboards",
@@ -9456,7 +9532,7 @@
},
{
"name": "Read List",
- "description": ""
+ "description": "Get metadata (and optionally columns/items) for a SharePoint list"
},
{
"name": "Update List",
@@ -9633,11 +9709,11 @@
"operations": [
{
"name": "Send Message",
- "description": ""
+ "description": "Send messages to Slack channels or direct messages. Supports Slack mrkdwn formatting."
},
{
"name": "Send Ephemeral Message",
- "description": ""
+ "description": "Send an ephemeral message visible only to a specific user in a channel. Optionally reply in a thread. The message does not persist across sessions."
},
{
"name": "Create Canvas",
@@ -9645,7 +9721,7 @@
},
{
"name": "Read Messages",
- "description": ""
+ "description": "Read the latest messages from Slack channels. Retrieve conversation history with filtering options."
},
{
"name": "Get Message",
@@ -9677,19 +9753,19 @@
},
{
"name": "Update Message",
- "description": ""
+ "description": "Update a message previously sent by the bot in Slack"
},
{
"name": "Delete Message",
- "description": ""
+ "description": "Delete a message previously sent by the bot in Slack"
},
{
"name": "Add Reaction",
- "description": ""
+ "description": "Add an emoji reaction to a Slack message"
},
{
"name": "Remove Reaction",
- "description": ""
+ "description": "Remove an emoji reaction from a Slack message"
},
{
"name": "Get Channel Info",
@@ -10530,67 +10606,67 @@
"operations": [
{
"name": "Get",
- "description": ""
+ "description": "Get the value of a key from Upstash Redis."
},
{
"name": "Set",
- "description": ""
+ "description": "Set the value of a key in Upstash Redis with an optional expiration time in seconds."
},
{
"name": "Delete",
- "description": ""
+ "description": "Delete a key from Upstash Redis."
},
{
"name": "List Keys",
- "description": ""
+ "description": "List keys matching a pattern in Upstash Redis. Defaults to listing all keys (*)."
},
{
"name": "HSET",
- "description": ""
+ "description": "Set a field in a hash stored at a key in Upstash Redis."
},
{
"name": "HGET",
- "description": ""
+ "description": "Get the value of a field in a hash stored at a key in Upstash Redis."
},
{
"name": "HGETALL",
- "description": ""
+ "description": "Get all fields and values of a hash stored at a key in Upstash Redis."
},
{
"name": "INCR",
- "description": ""
+ "description": "Atomically increment the integer value of a key by one in Upstash Redis. If the key does not exist, it is set to 0 before incrementing."
},
{
"name": "INCRBY",
- "description": ""
+ "description": "Increment the integer value of a key by a given amount. Use a negative value to decrement. If the key does not exist, it is set to 0 before the operation."
},
{
"name": "EXISTS",
- "description": ""
+ "description": "Check if a key exists in Upstash Redis. Returns true if the key exists, false otherwise."
},
{
"name": "SETNX",
- "description": ""
+ "description": "Set the value of a key only if it does not already exist. Returns true if the key was set, false if it already existed."
},
{
"name": "LPUSH",
- "description": ""
+ "description": "Prepend a value to the beginning of a list in Upstash Redis. Creates the list if it does not exist."
},
{
"name": "LRANGE",
- "description": ""
+ "description": "Get a range of elements from a list in Upstash Redis. Use 0 and -1 for start and stop to get all elements."
},
{
"name": "EXPIRE",
- "description": ""
+ "description": "Set a timeout on a key in Upstash Redis. After the timeout, the key is deleted."
},
{
"name": "TTL",
- "description": ""
+ "description": "Get the remaining time to live of a key in Upstash Redis. Returns -1 if the key has no expiration, -2 if the key does not exist."
},
{
"name": "Command",
- "description": ""
+ "description": "Execute an arbitrary Redis command against Upstash Redis. Pass the full command as a JSON array (e.g., ["
}
],
"operationCount": 16,
@@ -10911,23 +10987,23 @@
"operations": [
{
"name": "List Items",
- "description": ""
+ "description": "List all items from a Webflow CMS collection"
},
{
"name": "Get Item",
- "description": ""
+ "description": "Get a single item from a Webflow CMS collection"
},
{
"name": "Create Item",
- "description": ""
+ "description": "Create a new item in a Webflow CMS collection"
},
{
"name": "Update Item",
- "description": ""
+ "description": "Update an existing item in a Webflow CMS collection"
},
{
"name": "Delete Item",
- "description": ""
+ "description": "Delete an item from a Webflow CMS collection"
}
],
"operationCount": 5,
diff --git a/apps/sim/app/(landing)/integrations/layout.tsx b/apps/sim/app/(landing)/integrations/layout.tsx
index 9691c27ebe..8b523aa9ff 100644
--- a/apps/sim/app/(landing)/integrations/layout.tsx
+++ b/apps/sim/app/(landing)/integrations/layout.tsx
@@ -1,15 +1,17 @@
import { getNavBlogPosts } from '@/lib/blog/registry'
+import { getBaseUrl } from '@/lib/core/utils/urls'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export default async function IntegrationsLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()
+ const url = getBaseUrl()
const orgJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Sim',
- url: 'https://sim.ai',
- logo: 'https://sim.ai/logo/primary/small.png',
+ url,
+ logo: `${url}/logo/primary/small.png`,
sameAs: ['https://x.com/simdotai'],
}
@@ -17,10 +19,10 @@ export default async function IntegrationsLayout({ children }: { children: React
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Sim',
- url: 'https://sim.ai',
+ url,
potentialAction: {
'@type': 'SearchAction',
- target: 'https://sim.ai/search?q={search_term_string}',
+ target: `${url}/search?q={search_term_string}`,
'query-input': 'required name=search_term_string',
},
}
diff --git a/apps/sim/app/(landing)/integrations/page.tsx b/apps/sim/app/(landing)/integrations/page.tsx
index b4d8243c3d..65a6526236 100644
--- a/apps/sim/app/(landing)/integrations/page.tsx
+++ b/apps/sim/app/(landing)/integrations/page.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
+import { getBaseUrl } from '@/lib/core/utils/urls'
import { IntegrationGrid } from './components/integration-grid'
import { RequestIntegrationModal } from './components/request-integration-modal'
import { blockTypeToIconMap } from './data/icon-mapping'
@@ -15,6 +16,8 @@ const INTEGRATION_COUNT = allIntegrations.length
*/
const TOP_NAMES = [...new Set(POPULAR_WORKFLOWS.flatMap((p) => [p.from, p.to]))].slice(0, 6)
+const baseUrl = getBaseUrl()
+
export const metadata: Metadata = {
title: 'Integrations',
description: `Connect ${INTEGRATION_COUNT}+ apps and services with Sim's AI workflow automation. Build intelligent pipelines with ${TOP_NAMES.join(', ')}, and more.`,
@@ -28,16 +31,26 @@ export const metadata: Metadata = {
openGraph: {
title: 'Integrations for AI Workflow Automation | Sim',
description: `Connect ${INTEGRATION_COUNT}+ apps with Sim. Build AI-powered pipelines that link ${TOP_NAMES.join(', ')}, and every tool your team uses.`,
- url: 'https://sim.ai/integrations',
+ url: `${baseUrl}/integrations`,
type: 'website',
- images: [{ url: 'https://sim.ai/opengraph-image.png', width: 1200, height: 630 }],
+ images: [
+ {
+ url: `${baseUrl}/opengraph-image.png`,
+ width: 1200,
+ height: 630,
+ alt: 'Sim Integrations for AI Workflow Automation',
+ },
+ ],
},
twitter: {
card: 'summary_large_image',
title: 'Integrations | Sim',
description: `Connect ${INTEGRATION_COUNT}+ apps with Sim's AI workflow automation.`,
+ images: [
+ { url: `${baseUrl}/opengraph-image.png`, alt: 'Sim Integrations for AI Workflow Automation' },
+ ],
},
- alternates: { canonical: 'https://sim.ai/integrations' },
+ alternates: { canonical: `${baseUrl}/integrations` },
}
export default function IntegrationsPage() {
@@ -45,12 +58,12 @@ export default function IntegrationsPage() {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
- { '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
+ { '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
{
'@type': 'ListItem',
position: 2,
name: 'Integrations',
- item: 'https://sim.ai/integrations',
+ item: `${baseUrl}/integrations`,
},
],
}
@@ -60,7 +73,7 @@ export default function IntegrationsPage() {
'@type': 'ItemList',
name: 'Sim AI Workflow Integrations',
description: `Complete list of ${INTEGRATION_COUNT}+ integrations available in Sim for building AI-powered workflow automation.`,
- url: 'https://sim.ai/integrations',
+ url: `${baseUrl}/integrations`,
numberOfItems: INTEGRATION_COUNT,
itemListElement: allIntegrations.map((integration, index) => ({
'@type': 'ListItem',
@@ -69,7 +82,7 @@ export default function IntegrationsPage() {
'@type': 'SoftwareApplication',
name: integration.name,
description: integration.description,
- url: `https://sim.ai/integrations/${integration.slug}`,
+ url: `${baseUrl}/integrations/${integration.slug}`,
applicationCategory: 'BusinessApplication',
featureList: integration.operations.map((o) => o.name),
},
diff --git a/apps/sim/app/api/demo-requests/route.ts b/apps/sim/app/api/demo-requests/route.ts
new file mode 100644
index 0000000000..8ca458f6cb
--- /dev/null
+++ b/apps/sim/app/api/demo-requests/route.ts
@@ -0,0 +1,106 @@
+import { createLogger } from '@sim/logger'
+import { type NextRequest, NextResponse } from 'next/server'
+import { env } from '@/lib/core/config/env'
+import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
+import { RateLimiter } from '@/lib/core/rate-limiter'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { getEmailDomain } from '@/lib/core/utils/urls'
+import { sendEmail } from '@/lib/messaging/email/mailer'
+import { getFromEmailAddress } from '@/lib/messaging/email/utils'
+import {
+ demoRequestSchema,
+ getDemoRequestRegionLabel,
+ getDemoRequestUserCountLabel,
+} from '@/app/(home)/components/demo-request/consts'
+
+const logger = createLogger('DemoRequestAPI')
+const rateLimiter = new RateLimiter()
+
+const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = {
+ maxTokens: 10,
+ refillRate: 5,
+ refillIntervalMs: 60_000,
+}
+
+export async function POST(req: NextRequest) {
+ const requestId = generateRequestId()
+
+ try {
+ const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
+ const storageKey = `public:demo-request:${ip}`
+
+ const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
+ storageKey,
+ PUBLIC_ENDPOINT_RATE_LIMIT
+ )
+
+ if (!allowed) {
+ logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
+ return NextResponse.json(
+ { error: 'Too many requests. Please try again later.' },
+ {
+ status: 429,
+ headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) },
+ }
+ )
+ }
+
+ const body = await req.json()
+ const validationResult = demoRequestSchema.safeParse(body)
+
+ if (!validationResult.success) {
+ logger.warn(`[${requestId}] Invalid demo request data`, {
+ errors: validationResult.error.format(),
+ })
+ return NextResponse.json(
+ { error: 'Invalid request data', details: validationResult.error.format() },
+ { status: 400 }
+ )
+ }
+
+ const { firstName, lastName, companyEmail, phoneNumber, region, userCount, details } =
+ validationResult.data
+
+ logger.info(`[${requestId}] Processing demo request`, {
+ email: `${companyEmail.substring(0, 3)}***`,
+ region,
+ userCount,
+ })
+
+ const emailText = `Demo request submitted
+Submitted: ${new Date().toISOString()}
+Name: ${firstName} ${lastName}
+Email: ${companyEmail}
+Phone: ${phoneNumber ?? 'Not provided'}
+Region: ${getDemoRequestRegionLabel(region)}
+Users: ${getDemoRequestUserCountLabel(userCount)}
+
+Details:
+${details}
+`
+
+ const emailResult = await sendEmail({
+ to: [`enterprise@${env.EMAIL_DOMAIN || getEmailDomain()}`],
+ subject: `[DEMO REQUEST] ${firstName} ${lastName}`,
+ text: emailText,
+ from: getFromEmailAddress(),
+ replyTo: companyEmail,
+ emailType: 'transactional',
+ })
+
+ if (!emailResult.success) {
+ logger.error(`[${requestId}] Error sending demo request email`, emailResult.message)
+ return NextResponse.json({ error: 'Failed to submit request' }, { status: 500 })
+ }
+
+ logger.info(`[${requestId}] Demo request email sent successfully`)
+
+ return NextResponse.json(
+ { success: true, message: 'Thanks! Our team will reach out shortly.' },
+ { status: 201 }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] Error processing demo request`, error)
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
+ }
+}
diff --git a/apps/sim/app/api/help/integration-request/route.ts b/apps/sim/app/api/help/integration-request/route.ts
index c6773d2f68..929ae36730 100644
--- a/apps/sim/app/api/help/integration-request/route.ts
+++ b/apps/sim/app/api/help/integration-request/route.ts
@@ -7,7 +7,10 @@ import { RateLimiter } from '@/lib/core/rate-limiter'
import { generateRequestId } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
-import { getFromEmailAddress } from '@/lib/messaging/email/utils'
+import {
+ getFromEmailAddress,
+ NO_EMAIL_HEADER_CONTROL_CHARS_REGEX,
+} from '@/lib/messaging/email/utils'
const logger = createLogger('IntegrationRequestAPI')
@@ -20,7 +23,12 @@ const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = {
}
const integrationRequestSchema = z.object({
- integrationName: z.string().min(1, 'Integration name is required').max(200),
+ integrationName: z
+ .string()
+ .trim()
+ .min(1, 'Integration name is required')
+ .max(200)
+ .regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
email: z.string().email('A valid email is required'),
useCase: z.string().max(2000).optional(),
})
diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts
index 2c2e24ddb7..a028bd4005 100644
--- a/apps/sim/app/api/help/route.ts
+++ b/apps/sim/app/api/help/route.ts
@@ -7,12 +7,19 @@ import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
-import { getFromEmailAddress } from '@/lib/messaging/email/utils'
+import {
+ getFromEmailAddress,
+ NO_EMAIL_HEADER_CONTROL_CHARS_REGEX,
+} from '@/lib/messaging/email/utils'
const logger = createLogger('HelpAPI')
const helpFormSchema = z.object({
- subject: z.string().min(1, 'Subject is required'),
+ subject: z
+ .string()
+ .trim()
+ .min(1, 'Subject is required')
+ .regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
message: z.string().min(1, 'Message is required'),
type: z.enum(['bug', 'feedback', 'feature_request', 'other']),
})
diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx
index c4d5db0301..a58f0c25cb 100644
--- a/apps/sim/app/layout.tsx
+++ b/apps/sim/app/layout.tsx
@@ -3,11 +3,7 @@ import Script from 'next/script'
import { PublicEnvScript } from 'next-runtime-env'
import { BrandedLayout } from '@/components/branded-layout'
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
-import {
- generateBrandedMetadata,
- generateStructuredData,
- generateThemeCSS,
-} from '@/ee/whitelabeling'
+import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling'
import '@/app/_styles/globals.css'
import { OneDollarStats } from '@/components/analytics/onedollarstats'
import { isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/feature-flags'
@@ -21,8 +17,6 @@ import { season } from '@/app/_styles/fonts/season/season'
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
- maximumScale: 1,
- userScalable: false,
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#0c0c0c' },
@@ -32,7 +26,6 @@ export const viewport: Viewport = {
export const metadata: Metadata = generateBrandedMetadata()
export default function RootLayout({ children }: { children: React.ReactNode }) {
- const structuredData = generateStructuredData()
const themeCSS = generateThemeCSS()
return (
@@ -76,14 +69,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
strategy='lazyOnload'
/>
)}
- {/* Structured Data for SEO */}
-
-
{/*
Workspace layout dimensions: set CSS vars before hydration to avoid layout jump.
diff --git a/apps/sim/app/sitemap.ts b/apps/sim/app/sitemap.ts
index a3553f13d7..b1a56d264f 100644
--- a/apps/sim/app/sitemap.ts
+++ b/apps/sim/app/sitemap.ts
@@ -1,6 +1,7 @@
import type { MetadataRoute } from 'next'
import { getAllPostMeta } from '@/lib/blog/registry'
import { getBaseUrl } from '@/lib/core/utils/urls'
+import integrations from '@/app/(landing)/integrations/data/integrations.json'
export default async function sitemap(): Promise
{
const baseUrl = getBaseUrl()
@@ -24,6 +25,10 @@ export default async function sitemap(): Promise {
// url: `${baseUrl}/templates`,
// lastModified: now,
// },
+ {
+ url: `${baseUrl}/integrations`,
+ lastModified: now,
+ },
{
url: `${baseUrl}/changelog`,
lastModified: now,
@@ -44,5 +49,10 @@ export default async function sitemap(): Promise {
lastModified: new Date(p.updated ?? p.date),
}))
- return [...staticPages, ...blogPages]
+ const integrationPages: MetadataRoute.Sitemap = integrations.map((i) => ({
+ url: `${baseUrl}/integrations/${i.slug}`,
+ lastModified: now,
+ }))
+
+ return [...staticPages, ...blogPages, ...integrationPages]
}
diff --git a/apps/sim/blocks/blocks/upstash.ts b/apps/sim/blocks/blocks/upstash.ts
index 040377d962..854c99fccb 100644
--- a/apps/sim/blocks/blocks/upstash.ts
+++ b/apps/sim/blocks/blocks/upstash.ts
@@ -256,7 +256,42 @@ export const UpstashBlock: BlockConfig = {
if (params.increment !== undefined) {
params.increment = Number(params.increment)
}
- return `upstash_redis_${params.operation}`
+ switch (params.operation) {
+ case 'get':
+ return 'upstash_redis_get'
+ case 'set':
+ return 'upstash_redis_set'
+ case 'delete':
+ return 'upstash_redis_delete'
+ case 'keys':
+ return 'upstash_redis_keys'
+ case 'command':
+ return 'upstash_redis_command'
+ case 'hset':
+ return 'upstash_redis_hset'
+ case 'hget':
+ return 'upstash_redis_hget'
+ case 'hgetall':
+ return 'upstash_redis_hgetall'
+ case 'incr':
+ return 'upstash_redis_incr'
+ case 'incrby':
+ return 'upstash_redis_incrby'
+ case 'exists':
+ return 'upstash_redis_exists'
+ case 'setnx':
+ return 'upstash_redis_setnx'
+ case 'lpush':
+ return 'upstash_redis_lpush'
+ case 'lrange':
+ return 'upstash_redis_lrange'
+ case 'expire':
+ return 'upstash_redis_expire'
+ case 'ttl':
+ return 'upstash_redis_ttl'
+ default:
+ throw new Error(`Unknown operation: ${params.operation}`)
+ }
},
},
},
diff --git a/apps/sim/components/emcn/components/form-field/form-field.tsx b/apps/sim/components/emcn/components/form-field/form-field.tsx
new file mode 100644
index 0000000000..69b9c5c529
--- /dev/null
+++ b/apps/sim/components/emcn/components/form-field/form-field.tsx
@@ -0,0 +1,28 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { Label } from '@/components/emcn'
+
+export interface FormFieldProps {
+ label: ReactNode
+ children: ReactNode
+ htmlFor?: string
+ optional?: boolean
+ error?: ReactNode
+}
+
+/**
+ * Standard labeled field wrapper for forms and modals.
+ */
+export function FormField({ label, children, htmlFor, optional = false, error }: FormFieldProps) {
+ return (
+
+
+ {children}
+ {error ?
{error}
: null}
+
+ )
+}
diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts
index 269510bda0..1d8afcef3f 100644
--- a/apps/sim/components/emcn/components/index.ts
+++ b/apps/sim/components/emcn/components/index.ts
@@ -56,6 +56,7 @@ export {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from './dropdown-menu/dropdown-menu'
+export { FormField, type FormFieldProps } from './form-field/form-field'
export { Input, type InputProps, inputVariants } from './input/input'
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from './input-otp/input-otp'
export { Label } from './label/label'
diff --git a/apps/sim/lib/messaging/email/mailer.test.ts b/apps/sim/lib/messaging/email/mailer.test.ts
index 327c8f4965..18e720b626 100644
--- a/apps/sim/lib/messaging/email/mailer.test.ts
+++ b/apps/sim/lib/messaging/email/mailer.test.ts
@@ -1,21 +1,11 @@
import { createEnvMock, loggerMock } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
-/**
- * Tests for the mailer module.
- *
- * Note: Due to bun test runner's module loading behavior, the Resend and Azure
- * clients are initialized at module load time. These tests mock the actual
- * Resend and EmailClient classes to return mock implementations that our
- * mock functions can intercept.
- */
-
const mockSend = vi.fn()
const mockBatchSend = vi.fn()
const mockAzureBeginSend = vi.fn()
const mockAzurePollUntilDone = vi.fn()
-// Mock the Resend module - returns an object with emails.send
vi.mock('resend', () => {
return {
Resend: vi.fn().mockImplementation(() => ({
@@ -29,7 +19,6 @@ vi.mock('resend', () => {
}
})
-// Mock Azure Communication Email - returns an object with beginSend
vi.mock('@azure/communication-email', () => {
return {
EmailClient: vi.fn().mockImplementation(() => ({
@@ -38,13 +27,11 @@ vi.mock('@azure/communication-email', () => {
}
})
-// Mock unsubscribe module
vi.mock('@/lib/messaging/email/unsubscribe', () => ({
isUnsubscribed: vi.fn(),
generateUnsubscribeToken: vi.fn(),
}))
-// Mock env with valid API keys so the clients get initialized
vi.mock('@/lib/core/config/env', () =>
createEnvMock({
RESEND_API_KEY: 'test-api-key',
@@ -55,28 +42,23 @@ vi.mock('@/lib/core/config/env', () =>
})
)
-// Mock URL utilities
vi.mock('@/lib/core/utils/urls', () => ({
getEmailDomain: vi.fn().mockReturnValue('sim.ai'),
getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'),
getBaseDomain: vi.fn().mockReturnValue('test.sim.ai'),
}))
-// Mock the utils module (getFromEmailAddress)
vi.mock('@/lib/messaging/email/utils', () => ({
getFromEmailAddress: vi.fn().mockReturnValue('Sim '),
+ hasEmailHeaderControlChars: vi.fn().mockImplementation((value: string) => /[\r\n]/.test(value)),
+ EMAIL_HEADER_CONTROL_CHARS_REGEX: /[\r\n]/,
+ NO_EMAIL_HEADER_CONTROL_CHARS_REGEX: /^[^\r\n]*$/,
}))
vi.mock('@sim/logger', () => loggerMock)
-// Import after mocks are set up
-import {
- type EmailType,
- hasEmailService,
- sendBatchEmails,
- sendEmail,
-} from '@/lib/messaging/email/mailer'
-import { generateUnsubscribeToken, isUnsubscribed } from '@/lib/messaging/email/unsubscribe'
+import { type EmailType, hasEmailService, sendBatchEmails, sendEmail } from './mailer'
+import { generateUnsubscribeToken, isUnsubscribed } from './unsubscribe'
describe('mailer', () => {
const testEmailOptions = {
@@ -90,7 +72,6 @@ describe('mailer', () => {
;(isUnsubscribed as Mock).mockResolvedValue(false)
;(generateUnsubscribeToken as Mock).mockReturnValue('mock-token-123')
- // Mock successful Resend response
mockSend.mockResolvedValue({
data: { id: 'test-email-id' },
error: null,
@@ -101,7 +82,6 @@ describe('mailer', () => {
error: null,
})
- // Mock successful Azure response
mockAzurePollUntilDone.mockResolvedValue({
status: 'Succeeded',
id: 'azure-email-id',
@@ -114,7 +94,6 @@ describe('mailer', () => {
describe('hasEmailService', () => {
it('should return true when email service is configured', () => {
- // The mailer module initializes with mocked env that has valid API keys
const result = hasEmailService()
expect(typeof result).toBe('boolean')
})
@@ -128,7 +107,6 @@ describe('mailer', () => {
})
expect(result.success).toBe(true)
- // Should not check unsubscribe status for transactional emails
expect(isUnsubscribed).not.toHaveBeenCalled()
})
@@ -175,6 +153,34 @@ describe('mailer', () => {
expect(result.success).toBe(true)
})
+ it('should sanitize CRLF characters in subjects before sending', async () => {
+ const result = await sendEmail({
+ to: 'test@example.com',
+ subject: 'Hello\r\nBcc: attacker@evil.com',
+ text: 'Plain text content',
+ })
+
+ expect(result.success).toBe(true)
+ expect(mockSend).toHaveBeenCalledWith(
+ expect.objectContaining({
+ subject: 'Hello Bcc: attacker@evil.com',
+ })
+ )
+ })
+
+ it('should reject reply-to values containing header control characters', async () => {
+ const result = await sendEmail({
+ to: 'test@example.com',
+ subject: 'Test Subject',
+ text: 'Plain text content',
+ replyTo: 'user@example.com\r\nBcc: attacker@evil.com',
+ })
+
+ expect(result.success).toBe(false)
+ expect(result.message).toBe('Failed to send email')
+ expect(mockSend).not.toHaveBeenCalled()
+ })
+
it('should handle multiple recipients as array', async () => {
const recipients = ['user1@example.com', 'user2@example.com', 'user3@example.com']
const result = await sendEmail({
@@ -184,12 +190,10 @@ describe('mailer', () => {
})
expect(result.success).toBe(true)
- // Should use first recipient for unsubscribe check
expect(isUnsubscribed).toHaveBeenCalledWith('user1@example.com', 'marketing')
})
it('should handle general exceptions gracefully', async () => {
- // Mock an unexpected error before any email service call
;(isUnsubscribed as Mock).mockRejectedValue(new Error('Database connection failed'))
const result = await sendEmail({
@@ -222,6 +226,23 @@ describe('mailer', () => {
expect(result.results.length).toBeGreaterThanOrEqual(0)
})
+ it('should sanitize CRLF characters in batch email subjects', async () => {
+ await sendBatchEmails({
+ emails: [
+ {
+ ...testEmailOptions,
+ subject: 'Batch\r\nCc: attacker@evil.com',
+ },
+ ],
+ })
+
+ expect(mockBatchSend).toHaveBeenCalledWith([
+ expect.objectContaining({
+ subject: 'Batch Cc: attacker@evil.com',
+ }),
+ ])
+ })
+
it('should handle transactional emails without unsubscribe check', async () => {
const batchEmails = [
{ ...testEmailOptions, to: 'user1@example.com', emailType: 'transactional' as EmailType },
@@ -230,7 +251,6 @@ describe('mailer', () => {
await sendBatchEmails({ emails: batchEmails })
- // Should not check unsubscribe for transactional emails
expect(isUnsubscribed).not.toHaveBeenCalled()
})
})
diff --git a/apps/sim/lib/messaging/email/mailer.ts b/apps/sim/lib/messaging/email/mailer.ts
index 9d3c7b3337..0a7a8dbc6e 100644
--- a/apps/sim/lib/messaging/email/mailer.ts
+++ b/apps/sim/lib/messaging/email/mailer.ts
@@ -4,7 +4,7 @@ import { Resend } from 'resend'
import { env } from '@/lib/core/config/env'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { generateUnsubscribeToken, isUnsubscribed } from '@/lib/messaging/email/unsubscribe'
-import { getFromEmailAddress } from '@/lib/messaging/email/utils'
+import { getFromEmailAddress, hasEmailHeaderControlChars } from '@/lib/messaging/email/utils'
const logger = createLogger('Mailer')
@@ -57,6 +57,17 @@ interface ProcessedEmailData {
replyTo?: string
}
+interface PreparedEmailHeaderData {
+ to: string | string[]
+ subject: string
+ senderEmail: string
+ replyTo?: string
+}
+
+function sanitizeEmailSubject(subject: string): string {
+ return subject.replace(/[\r\n]+/g, ' ').trim()
+}
+
const resendApiKey = env.RESEND_API_KEY
const azureConnectionString = env.AZURE_ACS_CONNECTION_STRING
@@ -172,17 +183,14 @@ function addUnsubscribeData(
async function processEmailData(options: EmailOptions): Promise {
const {
to,
- subject,
html,
text,
- from,
emailType = 'transactional',
includeUnsubscribe = true,
attachments,
- replyTo,
} = options
- const senderEmail = from || getFromEmailAddress()
+ const preparedHeaders = prepareEmailHeaders(options)
let finalHtml = html
let finalText = text
@@ -197,14 +205,43 @@ async function processEmailData(options: EmailOptions): Promise ({
getEmailDomain: vi.fn().mockReturnValue('fallback.com'),
}))
-import { getFromEmailAddress } from './utils'
-
describe('getFromEmailAddress', () => {
it('should return the configured FROM_EMAIL_ADDRESS', () => {
const result = getFromEmailAddress()
@@ -36,7 +40,6 @@ describe('getFromEmailAddress', () => {
it('should contain an @ symbol in the email', () => {
const result = getFromEmailAddress()
- // Either contains @ directly or in angle brackets
expect(result.includes('@')).toBe(true)
})
@@ -46,3 +49,21 @@ describe('getFromEmailAddress', () => {
expect(result1).toBe(result2)
})
})
+
+describe('email header safety', () => {
+ it('rejects CRLF characters consistently', () => {
+ const injectedHeader = 'Hello\r\nBcc: attacker@example.com'
+
+ expect(EMAIL_HEADER_CONTROL_CHARS_REGEX.test(injectedHeader)).toBe(true)
+ expect(hasEmailHeaderControlChars(injectedHeader)).toBe(true)
+ expect(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX.test(injectedHeader)).toBe(false)
+ })
+
+ it('allows plain header content', () => {
+ const safeHeader = 'Product feedback'
+
+ expect(EMAIL_HEADER_CONTROL_CHARS_REGEX.test(safeHeader)).toBe(false)
+ expect(hasEmailHeaderControlChars(safeHeader)).toBe(false)
+ expect(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX.test(safeHeader)).toBe(true)
+ })
+})
diff --git a/apps/sim/lib/messaging/email/utils.ts b/apps/sim/lib/messaging/email/utils.ts
index 2c26737e10..b7fa5060ab 100644
--- a/apps/sim/lib/messaging/email/utils.ts
+++ b/apps/sim/lib/messaging/email/utils.ts
@@ -1,6 +1,14 @@
import { env } from '@/lib/core/config/env'
import { getEmailDomain } from '@/lib/core/utils/urls'
+export const EMAIL_HEADER_CONTROL_CHARS_REGEX = /[\r\n]/
+
+export const NO_EMAIL_HEADER_CONTROL_CHARS_REGEX = /^[^\r\n]*$/
+
+export function hasEmailHeaderControlChars(value: string): boolean {
+ return EMAIL_HEADER_CONTROL_CHARS_REGEX.test(value)
+}
+
/**
* Get the from email address, preferring FROM_EMAIL_ADDRESS over EMAIL_DOMAIN
*/
diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts
index ddbad423d3..9f6b4387fa 100755
--- a/scripts/generate-docs.ts
+++ b/scripts/generate-docs.ts
@@ -123,7 +123,7 @@ async function generateIconMapping(): Promise> {
// For icon mapping, we need ALL blocks including hidden ones
// because V2 blocks inherit icons from legacy blocks via spread
// First, extract the primary icon from the file (usually the legacy block's icon)
- const primaryIcon = extractIconName(fileContent)
+ const primaryIcon = extractIconNameFromContent(fileContent)
// Find all block exports and their types
const exportRegex = /export\s+const\s+(\w+)Block\s*:\s*BlockConfig[^=]*=\s*\{/g
@@ -315,6 +315,49 @@ function extractOperationsFromContent(blockContent: string): { label: string; id
return []
}
+/**
+ * Extract a mapping from operation id → tool id by scanning switch/case/return
+ * patterns in a block file. Handles both simple returns and ternary returns
+ * (for ternaries, takes the last quoted tool-like string, which is typically
+ * the default/list variant). Also picks up named helper functions referenced
+ * from tools.config.tool (e.g. selectGmailToolId).
+ */
+function extractSwitchCaseToolMapping(fileContent: string): Map {
+ const mapping = new Map()
+ const caseRegex = /\bcase\s+['"]([^'"]+)['"]\s*:/g
+ let caseMatch: RegExpExecArray | null
+
+ while ((caseMatch = caseRegex.exec(fileContent)) !== null) {
+ const opId = caseMatch[1]
+ if (mapping.has(opId)) continue
+
+ const searchStart = caseMatch.index + caseMatch[0].length
+ const searchEnd = Math.min(searchStart + 300, fileContent.length)
+ const segment = fileContent.substring(searchStart, searchEnd)
+
+ const returnIdx = segment.search(/\breturn\b/)
+ if (returnIdx === -1) continue
+
+ const afterReturn = segment.substring(returnIdx + 'return'.length)
+ // Limit scope to before the next case/default to avoid capturing sibling cases
+ const nextCaseIdx = afterReturn.search(/\bcase\b|\bdefault\b/)
+ const returnScope = nextCaseIdx > 0 ? afterReturn.substring(0, nextCaseIdx) : afterReturn
+
+ const toolMatches = [...returnScope.matchAll(/['"]([a-z][a-z0-9_]+)['"]/g)]
+ // Take the last tool-like string (underscore = tool ID pattern); for ternaries this
+ // is the fallback/list variant
+ const toolId = toolMatches
+ .map((m) => m[1])
+ .filter((id) => id.includes('_'))
+ .pop()
+ if (toolId) {
+ mapping.set(opId, toolId)
+ }
+ }
+
+ return mapping
+}
+
/**
* Scan all tool files under apps/sim/tools/ and build a map from tool ID to description.
* Used to enrich operation entries with descriptions.
@@ -331,7 +374,8 @@ async function buildToolDescriptionMap(): Promise {
try {
const toolFiles = await glob(`${toolsDir}/**/*.ts`)
for (const file of toolFiles) {
- if (file.endsWith('index.ts') || file.endsWith('types.ts')) continue
+ const basename = path.basename(file)
+ if (basename === 'index.ts' || basename === 'types.ts') continue
const content = fs.readFileSync(file, 'utf-8')
// Find every `id: 'tool_id'` occurrence in the file. For each, search
@@ -512,6 +556,7 @@ async function writeIntegrationsJson(iconMapping: Record): Promi
for (const blockFile of blockFiles) {
const fileContent = fs.readFileSync(blockFile, 'utf-8')
+ const switchCaseMap = extractSwitchCaseToolMapping(fileContent)
const configs = extractAllBlockConfigs(fileContent)
for (const config of configs) {
@@ -542,16 +587,33 @@ async function writeIntegrationsJson(iconMapping: Record): Promi
const rawOps: { label: string; id: string }[] = (config as any).operations || []
// Enrich each operation with a description from the tool registry.
- // Primary lookup: derive toolId as `{baseType}_{operationId}` and check
- // the map directly. Fallback: some blocks use short op IDs that don't
- // match tool IDs (e.g. Slack uses "send" while the tool ID is
- // "slack_message"). In that case, find the tool in tools.access whose
- // name exactly matches the operation label.
+ // Lookup order:
+ // 1. Derive toolId as `{baseType}_{operationId}` and check directly.
+ // 2. Check switch/case mapping parsed from tools.config.tool (handles
+ // cases where op IDs differ from tool IDs, e.g. get_carts → list_carts,
+ // or send_gmail → gmail_send).
+ // 3. Find the tool in tools.access whose name exactly matches the label.
const toolsAccess: string[] = (config as any).tools?.access || []
const operations: OperationInfo[] = rawOps.map(({ label, id }) => {
const toolId = `${baseType}_${id}`
let opDesc = toolDescMap.get(toolId) || toolDescMap.get(id) || ''
+ if (!opDesc) {
+ const switchMappedId = switchCaseMap.get(id)
+ if (switchMappedId) {
+ opDesc = toolDescMap.get(switchMappedId) || ''
+ // Also check versioned variants in tools.access (e.g. gmail_send_v2)
+ if (!opDesc) {
+ for (const tId of toolsAccess) {
+ if (tId === switchMappedId || tId.startsWith(`${switchMappedId}_v`)) {
+ opDesc = toolDescMap.get(tId) || ''
+ if (opDesc) break
+ }
+ }
+ }
+ }
+ }
+
if (!opDesc && toolsAccess.length > 0) {
for (const tId of toolsAccess) {
if (toolNameMap.get(tId)?.toLowerCase() === label.toLowerCase()) {
@@ -575,9 +637,7 @@ async function writeIntegrationsJson(iconMapping: Record): Promi
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
- // Detect auth type from the original block file content
- const blockFileContent = fs.readFileSync(blockFile, 'utf-8')
- const authType = extractAuthType(blockFileContent)
+ const authType = extractAuthType(fileContent)
integrations.push({
type: blockType,
@@ -618,7 +678,7 @@ function extractAllBlockConfigs(fileContent: string): BlockConfig[] {
const configs: BlockConfig[] = []
// First, extract the primary icon from the file (for V2 blocks that inherit via spread)
- const primaryIcon = extractIconName(fileContent)
+ const primaryIcon = extractIconNameFromContent(fileContent)
// Find all block exports in the file
const exportRegex = /export\s+const\s+(\w+)Block\s*:\s*BlockConfig[^=]*=\s*\{/g
@@ -770,10 +830,7 @@ function extractBlockConfigFromContent(
extractEnumPropertyFromContent(blockContent, 'integrationType') ||
baseConfig?.integrationType ||
null
- const tags =
- extractArrayPropertyFromContent(blockContent, 'tags') ||
- baseConfig?.tags ||
- null
+ const tags = extractArrayPropertyFromContent(blockContent, 'tags') || baseConfig?.tags || null
return {
type: blockType,
@@ -977,209 +1034,7 @@ function extractOutputsFromContent(content: string): Record {
function extractToolsAccessFromContent(content: string): string[] {
const accessMatch = content.match(/access\s*:\s*\[\s*([^\]]+)\s*\]/)
if (!accessMatch) return []
-
- const accessContent = accessMatch[1]
- const tools: string[] = []
-
- const toolMatches = accessContent.match(/['"]([^'"]+)['"]/g)
- if (toolMatches) {
- toolMatches.forEach((toolText) => {
- const match = toolText.match(/['"]([^'"]+)['"]/)
- if (match) {
- tools.push(match[1])
- }
- })
- }
-
- return tools
-}
-
-// Legacy function for backward compatibility (icon mapping, etc.)
-function extractBlockConfig(fileContent: string): BlockConfig | null {
- const configs = extractAllBlockConfigs(fileContent)
- // Return first non-hidden block for legacy code paths
- return configs.length > 0 ? configs[0] : null
-}
-
-function findBlockType(content: string, blockName: string): string {
- const blockExportRegex = new RegExp(
- `export\\s+const\\s+${blockName}Block\\s*:[^{]*{[\\s\\S]*?type\\s*:\\s*['"]([^'"]+)['"][\\s\\S]*?}`,
- 'i'
- )
- const blockExportMatch = content.match(blockExportRegex)
- if (blockExportMatch) return blockExportMatch[1]
-
- const exportMatch = content.match(new RegExp(`export\\s+const\\s+${blockName}Block\\s*:`))
- if (exportMatch) {
- const afterExport = content.substring(exportMatch.index! + exportMatch[0].length)
-
- const blockStartMatch = afterExport.match(/{/)
- if (blockStartMatch) {
- const blockStart = blockStartMatch.index!
-
- let braceCount = 1
- let blockEnd = blockStart + 1
-
- while (blockEnd < afterExport.length && braceCount > 0) {
- if (afterExport[blockEnd] === '{') braceCount++
- else if (afterExport[blockEnd] === '}') braceCount--
- blockEnd++
- }
-
- const blockContent = afterExport.substring(blockStart, blockEnd)
- const typeMatch = blockContent.match(/type\s*:\s*['"]([^'"]+)['"]/)
- if (typeMatch) return typeMatch[1]
- }
- }
-
- return blockName
- .replace(/([A-Z])/g, '_$1')
- .toLowerCase()
- .replace(/^_/, '')
-}
-
-function extractStringProperty(content: string, propName: string): string | null {
- const singleQuoteMatch = content.match(new RegExp(`${propName}\\s*:\\s*'(.*?)'`, 'm'))
- if (singleQuoteMatch) return singleQuoteMatch[1]
-
- const doubleQuoteMatch = content.match(new RegExp(`${propName}\\s*:\\s*"(.*?)"`, 'm'))
- if (doubleQuoteMatch) return doubleQuoteMatch[1]
-
- const templateMatch = content.match(new RegExp(`${propName}\\s*:\\s*\`([^\`]+)\``, 's'))
- if (templateMatch) {
- let templateContent = templateMatch[1]
-
- templateContent = templateContent.replace(
- /\$\{[^}]*shouldEnableURLInput[^}]*\?[^:]*:[^}]*\}/g,
- 'Upload files directly. '
- )
- templateContent = templateContent.replace(/\$\{[^}]*shouldEnableURLInput[^}]*\}/g, 'false')
-
- templateContent = templateContent.replace(/\$\{[^}]+\}/g, '')
-
- templateContent = templateContent.replace(/\s+/g, ' ').trim()
-
- return templateContent
- }
-
- return null
-}
-
-function extractIconName(content: string): string | null {
- const iconMatch = content.match(/icon\s*:\s*(\w+Icon)/)
- return iconMatch ? iconMatch[1] : null
-}
-
-function extractOutputs(content: string): Record {
- const outputsStart = content.search(/outputs\s*:\s*{/)
- if (outputsStart === -1) return {}
-
- const openBracePos = content.indexOf('{', outputsStart)
- if (openBracePos === -1) return {}
-
- let braceCount = 1
- let pos = openBracePos + 1
-
- while (pos < content.length && braceCount > 0) {
- if (content[pos] === '{') {
- braceCount++
- } else if (content[pos] === '}') {
- braceCount--
- }
- pos++
- }
-
- if (braceCount === 0) {
- const outputsContent = content.substring(openBracePos + 1, pos - 1).trim()
- const outputs: Record = {}
-
- const fieldRegex = /(\w+)\s*:\s*{/g
- let match
- const fieldPositions: Array<{ name: string; start: number }> = []
-
- while ((match = fieldRegex.exec(outputsContent)) !== null) {
- fieldPositions.push({
- name: match[1],
- start: match.index + match[0].length - 1,
- })
- }
-
- fieldPositions.forEach((field) => {
- const startPos = field.start
- let braceCount = 1
- let endPos = startPos + 1
-
- while (endPos < outputsContent.length && braceCount > 0) {
- if (outputsContent[endPos] === '{') {
- braceCount++
- } else if (outputsContent[endPos] === '}') {
- braceCount--
- }
- endPos++
- }
-
- if (braceCount === 0) {
- const fieldContent = outputsContent.substring(startPos + 1, endPos - 1).trim()
-
- const typeMatch = fieldContent.match(/type\s*:\s*['"](.*?)['"]/)
- const description = extractDescription(fieldContent)
-
- if (typeMatch) {
- outputs[field.name] = {
- type: typeMatch[1],
- description: description || `${field.name} output from the block`,
- }
- }
- }
- })
-
- if (Object.keys(outputs).length > 0) {
- return outputs
- }
-
- const flatFieldMatches = outputsContent.match(/(\w+)\s*:\s*['"](.*?)['"]/g)
-
- if (flatFieldMatches && flatFieldMatches.length > 0) {
- flatFieldMatches.forEach((fieldMatch) => {
- const fieldParts = fieldMatch.match(/(\w+)\s*:\s*['"](.*?)['"]/)
- if (fieldParts) {
- const fieldName = fieldParts[1]
- const fieldType = fieldParts[2]
-
- outputs[fieldName] = {
- type: fieldType,
- description: `${fieldName} output from the block`,
- }
- }
- })
-
- if (Object.keys(outputs).length > 0) {
- return outputs
- }
- }
- }
-
- return {}
-}
-
-function extractToolsAccess(content: string): string[] {
- const accessMatch = content.match(/access\s*:\s*\[\s*([^\]]+)\s*\]/)
- if (!accessMatch) return []
-
- const accessContent = accessMatch[1]
- const tools: string[] = []
-
- const toolMatches = accessContent.match(/['"]([^'"]+)['"]/g)
- if (toolMatches) {
- toolMatches.forEach((toolText) => {
- const match = toolText.match(/['"]([^'"]+)['"]/)
- if (match) {
- tools.push(match[1])
- }
- })
- }
-
- return tools
+ return [...accessMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1])
}
/**