diff --git a/apps/sim/app/(landing)/careers/careers.tsx b/apps/sim/app/(landing)/careers/careers.tsx new file mode 100644 index 00000000000..8cd927f390f --- /dev/null +++ b/apps/sim/app/(landing)/careers/careers.tsx @@ -0,0 +1,93 @@ +import { Suspense } from 'react' +import type { SearchParams } from 'nuqs/server' +import { getAshbyJobs } from '@/lib/ashby/jobs' +import { + filterPostings, + groupByDepartment, + hasActiveFilters, + JobBoard, + JobGroups, +} from '@/app/(landing)/careers/components/job-board' +import { careersSearchParamsCache } from '@/app/(landing)/careers/search-params' +import { TrustedBy } from '@/app/(landing)/components/trusted-by' + +interface CareersProps { + searchParams: Promise +} + +/** + * The careers page — a mission-led hero above the live open-roles board. Roles + * are pulled from Sim's public Ashby job board at build/revalidate time + * ({@link getAshbyJobs}) and server-rendered in full, so every posting is in the + * crawlable HTML; the interactive {@link JobBoard} hydrates on top to add + * Team/Location filtering. + * + * Both sections share the landing gutter — capped and centered at `max-w-[1446px]` + * with the navbar-aligned `px-12 max-lg:px-8 max-sm:px-5` so the headline starts on + * the same vertical line as the wordmark. The hero carries the single `

` + * (containing "Sim" and "AI workspace") plus an sr-only product summary for AI + * citation (landing CLAUDE.md → GEO); the roles section owns its own `

`. + * + * Because {@link JobBoard} reads the URL via nuqs (`useSearchParams`), it sits under + * a `` boundary. The page parses the same `?team=`/`?location=` query on + * the server ({@link careersSearchParamsCache}) and pre-filters the fallback to + * match, so a deep-linked filter renders the correct roles server-side — the list + * never flashes unfiltered before the client board hydrates. + */ +export default async function Careers({ searchParams }: CareersProps) { + const { team, location } = await careersSearchParamsCache.parse(searchParams) + const postings = await getAshbyJobs() + const fallbackGroups = groupByDepartment(filterPostings(postings, team, location)) + + return ( +
+
+

+ Careers at Sim, the open-source AI workspace where teams build, deploy, and manage AI + agents. Sim is hiring engineers, designers, and go-to-market builders to help teams + automate real work across 1,000+ integrations and every major LLM — visually, + conversationally, or with code. +

+ +

+ Help build Sim, the AI workspace for teams. +

+

+ Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. We're + a small, high-agency team shipping fast to thousands of builders. If you want to own real + work and shape the workspace teams live in, we'd love to meet you. +

+
+ +
+

+ Open roles +

+ + + } + > + + + + +
+
+ ) +} diff --git a/apps/sim/app/(landing)/careers/components/job-board/index.ts b/apps/sim/app/(landing)/careers/components/job-board/index.ts new file mode 100644 index 00000000000..df3091dfa1d --- /dev/null +++ b/apps/sim/app/(landing)/careers/components/job-board/index.ts @@ -0,0 +1,2 @@ +export { JobBoard } from './job-board' +export { filterPostings, groupByDepartment, hasActiveFilters, JobGroups } from './job-groups' diff --git a/apps/sim/app/(landing)/careers/components/job-board/job-board.tsx b/apps/sim/app/(landing)/careers/components/job-board/job-board.tsx new file mode 100644 index 00000000000..70e3478cc18 --- /dev/null +++ b/apps/sim/app/(landing)/careers/components/job-board/job-board.tsx @@ -0,0 +1,74 @@ +'use client' + +import { ChipSelect, type ChipSelectOption } from '@sim/emcn' +import { useQueryStates } from 'nuqs' +import type { CareerPosting } from '@/lib/ashby/jobs' +import { + filterPostings, + groupByDepartment, + hasActiveFilters, + JobGroups, +} from '@/app/(landing)/careers/components/job-board/job-groups' +import { + ALL_FILTER_VALUE, + careersParsers, + careersUrlKeys, +} from '@/app/(landing)/careers/search-params' + +interface JobBoardProps { + postings: CareerPosting[] +} + +/** Builds `{ label, value }` options for a filter, with an "All" row at the top. */ +function toFilterOptions(values: string[], allLabel: string): ChipSelectOption[] { + return [ + { label: allLabel, value: ALL_FILTER_VALUE }, + ...values.map((value) => ({ label: value, value })), + ] +} + +/** Distinct, alphabetically sorted values from a list. */ +function uniqueSorted(values: string[]): string[] { + return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b)) +} + +/** + * The interactive open-roles board — the single `'use client'` leaf on the + * careers page. Every posting is server-rendered into the HTML (via the static + * {@link JobGroups} Suspense fallback in `careers.tsx`), so all roles stay + * crawlable; this leaf hydrates on top to add Team/Location filtering. Filter + * state lives in the URL via nuqs (`?team=`/`?location=`) so a filtered view is + * shareable and survives reload/back-forward. The filter set is small and + * static, so filtering reads the instant URL value directly (no debounce). + */ +export function JobBoard({ postings }: JobBoardProps) { + const [{ team, location }, setFilters] = useQueryStates(careersParsers, careersUrlKeys) + + const teamOptions = toFilterOptions(uniqueSorted(postings.map((p) => p.department)), 'All teams') + const locationOptions = toFilterOptions( + uniqueSorted(postings.map((p) => p.location).filter(Boolean)), + 'All locations' + ) + const groups = groupByDepartment(filterPostings(postings, team, location)) + + return ( +
+
+ setFilters({ team: value })} + aria-label='Filter roles by team' + /> + setFilters({ location: value })} + aria-label='Filter roles by location' + /> +
+ + +
+ ) +} diff --git a/apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx b/apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx new file mode 100644 index 00000000000..885f4ae487f --- /dev/null +++ b/apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx @@ -0,0 +1,156 @@ +import { cn } from '@sim/emcn' +import { ArrowRight } from '@sim/emcn/icons' +import type { CareerPosting } from '@/lib/ashby/jobs' +import { ALL_FILTER_VALUE } from '@/app/(landing)/careers/search-params' + +export interface DepartmentGroup { + department: string + postings: CareerPosting[] +} + +/** + * Narrows postings to a selected Team and Location, treating {@link ALL_FILTER_VALUE} + * as "any". Shared by the server-rendered fallback and the client board so a + * deep-linked filter resolves to the exact same set on both sides. + */ +export function filterPostings( + postings: CareerPosting[], + team: string, + location: string +): CareerPosting[] { + return postings.filter( + (posting) => + (team === ALL_FILTER_VALUE || posting.department === team) && + (location === ALL_FILTER_VALUE || posting.location === location) + ) +} + +/** Whether either the Team or Location filter is narrowing the board. */ +export function hasActiveFilters(team: string, location: string): boolean { + return team !== ALL_FILTER_VALUE || location !== ALL_FILTER_VALUE +} + +/** Empty-state copy: distinguishes a truly empty board from a filtered-to-zero view. */ +const NO_OPEN_ROLES_MESSAGE = 'No open roles right now — check back soon.' +const NO_MATCHING_ROLES_MESSAGE = + 'No roles match these filters right now. Try clearing them, or check back soon.' + +/** + * Buckets postings by department, preserving their incoming order (the fetcher + * pre-sorts by department then title). Shared by the interactive board and its + * static Suspense fallback so the two can never render a different grouping. + */ +export function groupByDepartment(postings: CareerPosting[]): DepartmentGroup[] { + const byDepartment = new Map() + for (const posting of postings) { + const bucket = byDepartment.get(posting.department) + if (bucket) bucket.push(posting) + else byDepartment.set(posting.department, [posting]) + } + return Array.from(byDepartment, ([department, items]) => ({ department, postings: items })) +} + +interface JobGroupsProps { + groups: DepartmentGroup[] + /** + * Whether a Team/Location filter is active. Selects the empty-state copy so an + * unfiltered empty board ("no open roles") never reads as a filtered miss ("no + * matches") — and the server fallback and client board always agree. + */ + filtersActive?: boolean +} + +/** + * The presentational open-roles list: one labeled section per department, each a + * list of {@link JobRow}s. Server-safe (no client hooks) so it renders both as + * the static Suspense fallback and inside the client {@link JobBoard}. + */ +export function JobGroups({ groups, filtersActive = false }: JobGroupsProps) { + if (groups.length === 0) { + return ( +

+ {filtersActive ? NO_MATCHING_ROLES_MESSAGE : NO_OPEN_ROLES_MESSAGE} +

+ ) + } + + return ( +
+ {groups.map((group) => ( +
+

{group.department}

+
    + {group.postings.map((posting) => ( +
  • + +
  • + ))} +
+
+ ))} +
+ ) +} + +interface JobRowProps { + posting: CareerPosting +} + +/** + * A single role row: title over a metadata line, with an "Apply" affordance that + * links out to the posting on Ashby. The whole row is the link target; hovering + * tints the row and advances the arrow. The metadata values are de-duplicated + * because a remote posting normalizes both `location` and `workplaceType` to + * "Remote", which would otherwise render "Remote · Remote" and collide as keys. + */ +function JobRow({ posting }: JobRowProps) { + const meta = Array.from( + new Set( + [ + posting.location, + posting.employmentType, + posting.workplaceType, + posting.compensationSummary, + ].filter((value): value is string => Boolean(value)) + ) + ) + + return ( + +
+

+ {posting.title} +

+
+ {meta.map((item, index) => ( + + {index > 0 && ( + + · + + )} + {item} + + ))} +
+
+ + + Apply + + +
+ ) +} diff --git a/apps/sim/app/(landing)/careers/page.tsx b/apps/sim/app/(landing)/careers/page.tsx new file mode 100644 index 00000000000..b9a3e5ad55f --- /dev/null +++ b/apps/sim/app/(landing)/careers/page.tsx @@ -0,0 +1,17 @@ +import type { SearchParams } from 'nuqs/server' +import { buildLandingMetadata } from '@/lib/landing/seo' +import Careers from '@/app/(landing)/careers/careers' + +export const revalidate = 3600 + +export const metadata = buildLandingMetadata({ + title: 'Careers at Sim — Build the AI workspace for teams', + description: + 'Join Sim, the open-source AI workspace where teams build, deploy, and manage AI agents. See open engineering, design, and go-to-market roles.', + path: '/careers', + keywords: 'Sim careers, Sim jobs, AI workspace jobs, AI agent engineering jobs, open source jobs', +}) + +export default function Page({ searchParams }: { searchParams: Promise }) { + return +} diff --git a/apps/sim/app/(landing)/careers/search-params.ts b/apps/sim/app/(landing)/careers/search-params.ts new file mode 100644 index 00000000000..282c0c8835e --- /dev/null +++ b/apps/sim/app/(landing)/careers/search-params.ts @@ -0,0 +1,35 @@ +import { createSearchParamsCache, parseAsString } from 'nuqs/server' + +/** + * Sentinel value for an inactive filter — matches every posting. Namespaced with + * underscores so it can never collide with a real Ashby department or location + * value (e.g. a team literally named "all"). + */ +export const ALL_FILTER_VALUE = '__all__' + +/** + * Co-located, typed URL query params for the careers job board's Team and + * Location filters. Shareable, deep-linkable view-state over an already-rendered + * list, so it lives in the URL (nuqs) — never in a store. The values are dynamic + * (departments/locations come from the live board), so plain string parsers with + * an `all` sentinel default rather than a fixed literal set. + */ +export const careersParsers = { + team: parseAsString.withDefault(ALL_FILTER_VALUE), + location: parseAsString.withDefault(ALL_FILTER_VALUE), +} as const + +/** Clean URLs, no back-stack churn — the filters are a passive view switch. */ +export const careersUrlKeys = { + history: 'replace', + shallow: true, + clearOnDefault: true, +} as const + +/** + * Server-side reader for the same parser map. The page parses the request's + * query with this so the statically-rendered fallback is filtered to match a + * deep-linked `?team=`/`?location=` URL — the roles never flash unfiltered before + * the client board hydrates. + */ +export const careersSearchParamsCache = createSearchParamsCache(careersParsers) diff --git a/apps/sim/app/(landing)/components/footer/footer.tsx b/apps/sim/app/(landing)/components/footer/footer.tsx index 3a3e131403b..7be79223df4 100644 --- a/apps/sim/app/(landing)/components/footer/footer.tsx +++ b/apps/sim/app/(landing)/components/footer/footer.tsx @@ -41,8 +41,9 @@ const RESOURCES_LINKS: FooterItem[] = [ { label: 'Blog', href: '/blog' }, { label: 'Docs', href: 'https://docs.sim.ai', external: true }, { label: 'Partners', href: '/partners' }, - { label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true }, + { label: 'Careers', href: '/careers' }, { label: 'Changelog', href: '/changelog' }, + { label: 'Contact', href: '/contact' }, ] /** Top model providers, sourced from the catalog so labels/hrefs never drift. */ diff --git a/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx new file mode 100644 index 00000000000..70a2c3a9e5c --- /dev/null +++ b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx @@ -0,0 +1,341 @@ +'use client' + +import { type ReactNode, useId, useRef, useState } from 'react' +import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' +import { Chip, ChipDropdown, ChipInput, ChipTextarea, Label } from '@sim/emcn' +import { Check } from '@sim/emcn/icons' +import { toError } from '@sim/utils/errors' +import { + CONTACT_TOPIC_OPTIONS, + type ContactRequestPayload, + contactRequestSchema, +} from '@/lib/api/contracts/contact' +import { flattenFieldErrors } from '@/lib/api/contracts/primitives' +import { getEnv } from '@/lib/core/config/env' +import { captureClientEvent } from '@/lib/posthog/client' +import { useSubmitContact } from '@/hooks/queries/contact' + +/** + * Field control height — slightly taller than the 30px in-app chip default and + * just under the 36px auth field, so the form reads as a roomy landing surface. + * Applied to each control's `className`, the sanctioned way to own only a chip + * field's height (mirrors the demo form). + */ +const FIELD_HEIGHT = 'h-[34px]' + +/** Build-time-inlined Turnstile site key; absent when captcha isn't configured. */ +const TURNSTILE_SITE_KEY = getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY') + +type ContactField = keyof ContactRequestPayload +type ContactErrors = Partial> + +interface ContactFormState { + name: string + email: string + company: string + topic: ContactRequestPayload['topic'] | '' + subject: string + message: string +} + +const INITIAL_STATE: ContactFormState = { + name: '', + email: '', + company: '', + topic: '', + subject: '', + message: '', +} + +interface ContactFieldProps { + label: string + /** Set for native controls (inputs/textarea) to associate the label by `id`. */ + htmlFor?: string + required?: boolean + error?: string + /** The control. Dropdowns (no `htmlFor`) are wrapped in a labeled group. */ + children: ReactNode +} + +/** + * A labeled field row matching the chip field rhythm (`gap-[9px]`, muted label, + * caption-sized error). Native controls associate via `htmlFor`/`id`; controls + * that can't take a label `id` (the dropdown) become a `role='group'` named by + * the label instead, so every field has an accessible name. + */ +function ContactField({ label, htmlFor, required, error, children }: ContactFieldProps) { + const labelId = useId() + const isGroup = htmlFor === undefined + return ( +
+ + {children} + {error ?

{error}

: null} +
+ ) +} + +/** + * The `/contact` form — rendered inside the card chrome owned by the page, so it + * returns just its heading and fields. Fields are hand-composed at the slightly + * taller {@link FIELD_HEIGHT}, stacked at the platform `gap-4` rhythm with no + * divider lines, mirroring the demo booking form. + * + * On submit it validates against the shared {@link contactRequestSchema}, runs an + * invisible Turnstile challenge (falling back gracefully when the widget is + * unavailable), and posts through {@link useSubmitContact}, which emails the help + * inbox and sends the visitor a confirmation. A honeypot `website` field and the + * captcha token ride along on the payload. A successful submit swaps the card to a + * confirmation state. + */ +export function ContactForm() { + const turnstileRef = useRef(null) + + const contactMutation = useSubmitContact() + + const [form, setForm] = useState(INITIAL_STATE) + const [errors, setErrors] = useState({}) + const [isSubmitting, setIsSubmitting] = useState(false) + const [website, setWebsite] = useState('') + const [widgetLoaded, setWidgetLoaded] = useState(false) + + function updateField( + field: TField, + value: ContactFormState[TField] + ) { + setForm((prev) => ({ ...prev, [field]: value })) + setErrors((prev) => { + if (!prev[field as ContactField]) { + return prev + } + const nextErrors = { ...prev } + delete nextErrors[field as ContactField] + return nextErrors + }) + if (contactMutation.isError) { + contactMutation.reset() + } + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + if (contactMutation.isPending || isSubmitting) return + setIsSubmitting(true) + + const parsed = contactRequestSchema.safeParse({ + ...form, + company: form.company || undefined, + }) + + if (!parsed.success) { + setErrors(flattenFieldErrors(parsed.error)) + setIsSubmitting(false) + return + } + + let captchaToken: string | undefined + const widget = turnstileRef.current + + if (TURNSTILE_SITE_KEY && widgetLoaded && widget) { + try { + widget.reset() + widget.execute() + captchaToken = await widget.getResponsePromise(30_000) + } catch { + captchaToken = undefined + } + } + + contactMutation.mutate( + { ...parsed.data, website, captchaToken }, + { + onSuccess: () => { + captureClientEvent('landing_contact_submitted', { topic: parsed.data.topic }) + setForm(INITIAL_STATE) + setErrors({}) + }, + onError: () => { + turnstileRef.current?.reset() + }, + onSettled: () => { + setIsSubmitting(false) + }, + } + ) + } + + const isBusy = contactMutation.isPending || isSubmitting + + const submitError = contactMutation.isError + ? toError(contactMutation.error).message || 'Failed to send message. Please try again.' + : null + + if (contactMutation.isSuccess) { + return ( +
+
+ +
+

Message received

+

+ Thanks for reaching out. Our team will get back to you shortly. +

+ +
+ ) + } + + return ( + <> +

+ Send us a message +

+

+ Ask a question, request an integration, or get help — we'll get back to you shortly. +

+ +
+ + +
+ + updateField('name', event.target.value)} + error={Boolean(errors.name)} + placeholder='Jane Doe' + autoComplete='name' + /> + + + updateField('email', event.target.value)} + error={Boolean(errors.email)} + placeholder='jane@acme.co' + autoComplete='email' + /> + +
+ +
+ + updateField('company', event.target.value)} + error={Boolean(errors.company)} + placeholder='Acme Inc.' + autoComplete='organization' + /> + + + updateField('topic', value as ContactRequestPayload['topic'])} + options={CONTACT_TOPIC_OPTIONS} + placeholder='Select a topic' + /> + +
+ + + updateField('subject', event.target.value)} + error={Boolean(errors.subject)} + placeholder='How can we help?' + /> + + + + updateField('message', event.target.value)} + error={Boolean(errors.message)} + placeholder='Share details so we can help as quickly as possible.' + rows={4} + /> + + + {TURNSTILE_SITE_KEY ? ( + setWidgetLoaded(true)} + onError={() => setWidgetLoaded(false)} + onUnsupported={() => setWidgetLoaded(false)} + /> + ) : null} + + {submitError ? ( +

+ {submitError} +

+ ) : null} + + + {isBusy ? 'Sending…' : 'Send message'} + + + + ) +} diff --git a/apps/sim/app/(landing)/contact/components/contact-form/index.ts b/apps/sim/app/(landing)/contact/components/contact-form/index.ts new file mode 100644 index 00000000000..ab5151da9c7 --- /dev/null +++ b/apps/sim/app/(landing)/contact/components/contact-form/index.ts @@ -0,0 +1 @@ +export { ContactForm } from './contact-form' diff --git a/apps/sim/app/(landing)/contact/contact.tsx b/apps/sim/app/(landing)/contact/contact.tsx new file mode 100644 index 00000000000..ff15713e77d --- /dev/null +++ b/apps/sim/app/(landing)/contact/contact.tsx @@ -0,0 +1,75 @@ +import { chipBorderShadowRing, cn } from '@sim/emcn' +import { TrustedBy } from '@/app/(landing)/components/trusted-by' +import { ContactForm } from '@/app/(landing)/contact/components/contact-form' + +/** + * Contact page — mirrors the demo page's two-column split: value proposition and + * customer proof on the left, the message form in a content-height card on the + * right. + * + * The section is a two-column CSS grid capped and centered at the shared + * `max-w-[1446px]` with the navbar-aligned `px-12` gutter, so the headline starts + * on the same vertical line as the wordmark. The desktop split is `xl:grid-cols-2` + * with `xl:gap-x-0` — the columns split at the exact horizontal center, so the + * right card occupies the same rectangle as the hero's right panel. The card is + * inset from the section's top and bottom by 32px (`xl:pt-8`/`xl:pb-8`), spans both + * rows (`xl:row-span-2`), and its content drives the column height — the left + * column stretches to match, bottom-anchoring the logos to the card's lower edge. + * + * Three grid children, ordered in the DOM as headline → form → logos so the + * COLLAPSE below `xl` (single column) yields the best mobile reading order: value + * proposition first, the form immediately after it, then the customer logos as + * reinforcing social proof. On desktop the headline cell adds `xl:pt-[80px]` so its + * text sits on the hero's line, while the card top stays on the higher `top-8` + * line. The customer proof reuses the shared {@link TrustedBy} block, + * bottom-anchored (`xl:row-start-2 xl:self-end`). The gutter follows the navbar + * convention (`px-12 max-lg:px-8 max-sm:px-5`), and `max-sm` drops to the smallest + * type scale. + * + * Carries an sr-only product summary for AI citation (landing CLAUDE.md → GEO). + */ +export default function Contact() { + return ( +
+
+
+

+ Get in touch with Sim, the open-source AI workspace where teams build, deploy, and + manage AI agents and workflows. Ask a question, request an integration, or get help from + the team — send a message and we'll get back to you shortly. +

+ +

+ Get in touch with Sim,
+ the AI agent workspace. +

+

+ Ask a question, request an integration, or get help from the team. Tell us what you need + and we'll get back to you shortly. +

+
+ +
+
+ +
+
+ + +
+
+ ) +} diff --git a/apps/sim/app/(landing)/contact/page.tsx b/apps/sim/app/(landing)/contact/page.tsx new file mode 100644 index 00000000000..3876509d709 --- /dev/null +++ b/apps/sim/app/(landing)/contact/page.tsx @@ -0,0 +1,18 @@ +import { buildLandingMetadata } from '@/lib/landing/seo' +import Contact from '@/app/(landing)/contact/contact' + +export const revalidate = 3600 + +const TITLE = 'Contact Us | Sim, the AI Workspace' +const DESCRIPTION = + 'Get in touch with Sim, the open-source AI workspace where teams build, deploy, and manage AI agents. Ask a question, request an integration, or get help from the team.' + +export const metadata = buildLandingMetadata({ + title: TITLE, + description: DESCRIPTION, + path: '/contact', +}) + +export default function Page() { + return +} diff --git a/apps/sim/app/api/contact/route.ts b/apps/sim/app/api/contact/route.ts new file mode 100644 index 00000000000..2b610ec2114 --- /dev/null +++ b/apps/sim/app/api/contact/route.ts @@ -0,0 +1,191 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { renderHelpConfirmationEmail } from '@/components/emails' +import { + getContactTopicLabel, + mapContactTopicToHelpType, + submitContactContract, +} from '@/lib/api/contracts/contact' +import { parseRequest } from '@/lib/api/server' +import { env } from '@/lib/core/config/env' +import type { TokenBucketConfig } from '@/lib/core/rate-limiter' +import { RateLimiter } from '@/lib/core/rate-limiter' +import { isTurnstileConfigured, verifyTurnstileToken } from '@/lib/core/security/turnstile' +import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { getEmailDomain } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { sendEmail } from '@/lib/messaging/email/mailer' +import { getFromEmailAddress } from '@/lib/messaging/email/utils' + +const logger = createLogger('ContactAPI') +const rateLimiter = new RateLimiter() + +const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 10, + refillRate: 5, + refillIntervalMs: 60_000, +} + +const CAPTCHA_UNAVAILABLE_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 3, + refillRate: 1, + refillIntervalMs: 60_000, +} + +const SUCCESS_RESPONSE = { success: true, message: "Thanks — we'll be in touch soon." } +const TOO_MANY_REQUESTS_RESPONSE = { error: 'Too many requests. Please try again later.' } + +/** + * Public contact-form endpoint: per-IP rate limit, honeypot drop, captcha, then a + * help-inbox notification plus a best-effort visitor confirmation. + * + * Captcha is server-authoritative — a valid Turnstile token is the only way past + * the stricter fallback bucket, so a caller cannot opt out of the challenge. A + * missing token (widget could not load) or a Cloudflare transport error falls + * back to the tighter no-captcha bucket rather than a free pass; an outright + * invalid token is rejected. That backstop is enforced `failClosed`, so an + * unavailable limiter rejects token-less submits instead of admitting them. No + * `expectedHostname` is pinned: the site key is already domain-bound in + * Cloudflare, and a single-host pin would reject valid self-hosted/preview/apex + * tokens. + */ +export const POST = withRouteHandler(async (req: NextRequest) => { + const requestId = generateRequestId() + + try { + const ip = getClientIp(req) + const storageKey = `public:contact:${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(TOO_MANY_REQUESTS_RESPONSE, { + status: 429, + headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) }, + }) + } + + const parsed = await parseRequest(submitContactContract, req, {}) + if (!parsed.success) { + logger.warn(`[${requestId}] Invalid contact request data`) + return parsed.response + } + + const { name, email, company, topic, subject, message, website, captchaToken } = + parsed.data.body + + if (typeof website === 'string' && website.trim().length > 0) { + logger.warn(`[${requestId}] Honeypot triggered, discarding`, { ip }) + return NextResponse.json(SUCCESS_RESPONSE, { status: 201 }) + } + + if (isTurnstileConfigured()) { + let captchaVerified = false + const token = + typeof captchaToken === 'string' && captchaToken.length > 0 ? captchaToken : null + + if (token) { + const verification = await verifyTurnstileToken({ token, remoteIp: ip }) + if (verification.success) { + captchaVerified = true + } else if (!verification.transportError) { + logger.warn(`[${requestId}] Captcha verification failed`, { + ip, + errorCodes: verification.errorCodes, + }) + return NextResponse.json( + { error: 'Captcha verification failed. Please try again.' }, + { status: 400 } + ) + } else { + logger.warn( + `[${requestId}] Captcha transport error, falling back to no-captcha rate limit`, + { ip } + ) + } + } + + if (!captchaVerified) { + const nocaptchaKey = `public:contact:nocaptcha:${ip}` + const { allowed: nocaptchaAllowed } = await rateLimiter.checkRateLimitDirect( + nocaptchaKey, + CAPTCHA_UNAVAILABLE_RATE_LIMIT, + { failClosed: true } + ) + if (!nocaptchaAllowed) { + logger.warn(`[${requestId}] Rate limit rejected (no-captcha) for IP ${ip}`) + return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 }) + } + } + } + + const topicLabel = getContactTopicLabel(topic) + + logger.info(`[${requestId}] Processing contact request`, { + email: `${email.substring(0, 3)}***`, + topic, + }) + + const emailText = `Contact form submission +Submitted: ${new Date().toISOString()} +Topic: ${topicLabel} +Name: ${name} +Email: ${email} +Company: ${company ?? 'Not provided'} + +Subject: ${subject} + +Message: +${message} +` + + const helpInboxDomain = env.EMAIL_DOMAIN || getEmailDomain() + const emailResult = await sendEmail({ + to: [`help@${helpInboxDomain}`], + subject: `[CONTACT:${topic.toUpperCase()}] ${subject}`, + text: emailText, + from: getFromEmailAddress(), + replyTo: email, + emailType: 'transactional', + }) + + if (!emailResult.success) { + logger.error(`[${requestId}] Error sending contact request email`, emailResult.message) + return NextResponse.json({ error: 'Failed to send message' }, { status: 500 }) + } + + logger.info(`[${requestId}] Contact request email sent successfully`) + + try { + const confirmationHtml = await renderHelpConfirmationEmail( + mapContactTopicToHelpType(topic), + 0 + ) + + await sendEmail({ + to: [email], + subject: `We've received your message: ${subject}`, + html: confirmationHtml, + from: getFromEmailAddress(), + replyTo: `help@${helpInboxDomain}`, + emailType: 'transactional', + }) + } catch (err) { + logger.warn(`[${requestId}] Failed to send contact confirmation email`, err) + } + + return NextResponse.json(SUCCESS_RESPONSE, { status: 201 }) + } catch (error) { + if (error instanceof Error && error.message.includes('not configured')) { + logger.error(`[${requestId}] Email service configuration error`, error) + return NextResponse.json({ error: 'Email service configuration error.' }, { status: 500 }) + } + + logger.error(`[${requestId}] Error processing contact request`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/sitemap.ts b/apps/sim/app/sitemap.ts index 150caf3ba7c..417a9bc28a8 100644 --- a/apps/sim/app/sitemap.ts +++ b/apps/sim/app/sitemap.ts @@ -42,6 +42,12 @@ export default async function sitemap(): Promise { { url: `${baseUrl}/demo`, }, + { + url: `${baseUrl}/contact`, + }, + { + url: `${baseUrl}/careers`, + }, { url: `${baseUrl}/enterprise`, }, diff --git a/apps/sim/emails/broadcasts/july-1.html b/apps/sim/emails/broadcasts/july-1.html index 4019490bfee..cb211c3fe89 100644 --- a/apps/sim/emails/broadcasts/july-1.html +++ b/apps/sim/emails/broadcasts/july-1.html @@ -47,12 +47,12 @@ } - +
A ton of new things shipped in Sim over the last few months. Here's the rundown.
- +
@@ -436,8 +436,8 @@

- - Discord + + LinkedIn