diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 23ec0cb7d8f..46ca356324a 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1526,6 +1526,7 @@ export enum EngineComponentKey { SAVE_DASHBOARD_LAYOUT = 'SAVE_DASHBOARD_LAYOUT', SEARCH_RECORDS = 'SEARCH_RECORDS', SEARCH_RECORDS_FALLBACK = 'SEARCH_RECORDS_FALLBACK', + SEND_CAMPAIGN = 'SEND_CAMPAIGN', SEE_ACTIVE_VERSION_WORKFLOW = 'SEE_ACTIVE_VERSION_WORKFLOW', SEE_DELETED_RECORDS = 'SEE_DELETED_RECORDS', SEE_RUNS_WORKFLOW = 'SEE_RUNS_WORKFLOW', diff --git a/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts b/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts index e22aa29f6e1..2990662e13f 100644 --- a/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts +++ b/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts @@ -28,6 +28,7 @@ export const getMissingDraftEmailScopes = ( case ConnectedAccountProvider.OIDC: case ConnectedAccountProvider.SAML: case ConnectedAccountProvider.EMAIL_GROUP: + case ConnectedAccountProvider.WORKSPACE_TRANSACTIONAL: case ConnectedAccountProvider.APP: return []; default: diff --git a/packages/twenty-front/src/modules/activities/campaigns/graphql/mutations/sendMessageCampaign.ts b/packages/twenty-front/src/modules/activities/campaigns/graphql/mutations/sendMessageCampaign.ts new file mode 100644 index 00000000000..13fa471826a --- /dev/null +++ b/packages/twenty-front/src/modules/activities/campaigns/graphql/mutations/sendMessageCampaign.ts @@ -0,0 +1,11 @@ +import gql from 'graphql-tag'; + +export const SEND_MESSAGE_CAMPAIGN = gql` + mutation SendMessageCampaign($input: SendMessageCampaignInput!) { + sendMessageCampaign(input: $input) { + campaignId + queuedRecipientCount + skippedRecipientCount + } + } +`; diff --git a/packages/twenty-front/src/modules/activities/campaigns/graphql/queries/findVerifiedEmailingDomains.ts b/packages/twenty-front/src/modules/activities/campaigns/graphql/queries/findVerifiedEmailingDomains.ts new file mode 100644 index 00000000000..038658ad54b --- /dev/null +++ b/packages/twenty-front/src/modules/activities/campaigns/graphql/queries/findVerifiedEmailingDomains.ts @@ -0,0 +1,11 @@ +import gql from 'graphql-tag'; + +export const FIND_VERIFIED_EMAILING_DOMAINS = gql` + query FindVerifiedEmailingDomains { + getEmailingDomains { + id + domain + status + } + } +`; diff --git a/packages/twenty-front/src/modules/activities/campaigns/hooks/useSendMessageCampaign.ts b/packages/twenty-front/src/modules/activities/campaigns/hooks/useSendMessageCampaign.ts new file mode 100644 index 00000000000..d02c0f6329b --- /dev/null +++ b/packages/twenty-front/src/modules/activities/campaigns/hooks/useSendMessageCampaign.ts @@ -0,0 +1,68 @@ +import { useMutation } from '@apollo/client/react'; +import { t } from '@lingui/core/macro'; +import { useCallback } from 'react'; + +import { SEND_MESSAGE_CAMPAIGN } from '@/activities/campaigns/graphql/mutations/sendMessageCampaign'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; + +type SendMessageCampaignInput = { + name: string; + subject: string; + bodyTemplate: string; + fromAddress: string; + replyTo?: string; + emailingDomainId: string; + recipientPersonIds: string[]; +}; + +type SendMessageCampaignResult = { + campaignId: string; + queuedRecipientCount: number; + skippedRecipientCount: number; +}; + +export const useSendMessageCampaign = () => { + const [sendCampaignMutation, { loading }] = useMutation< + { sendMessageCampaign: SendMessageCampaignResult }, + { input: SendMessageCampaignInput } + >(SEND_MESSAGE_CAMPAIGN); + + const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); + + const sendCampaign = useCallback( + async (input: SendMessageCampaignInput): Promise => { + try { + const result = await sendCampaignMutation({ + variables: { input }, + }); + + if (result.data?.sendMessageCampaign) { + const { queuedRecipientCount, skippedRecipientCount } = + result.data.sendMessageCampaign; + + enqueueSuccessSnackBar({ + message: + skippedRecipientCount > 0 + ? t`Campaign queued for ${queuedRecipientCount} recipients (${skippedRecipientCount} skipped — no email).` + : t`Campaign queued for ${queuedRecipientCount} recipients.`, + }); + return true; + } + + enqueueErrorSnackBar({ + message: t`Failed to send campaign`, + }); + return false; + } catch (error) { + enqueueErrorSnackBar({ + message: + error instanceof Error ? error.message : t`Failed to send campaign`, + }); + return false; + } + }, + [sendCampaignMutation, enqueueSuccessSnackBar, enqueueErrorSnackBar], + ); + + return { sendCampaign, loading }; +}; diff --git a/packages/twenty-front/src/modules/activities/campaigns/hooks/useVerifiedEmailingDomains.ts b/packages/twenty-front/src/modules/activities/campaigns/hooks/useVerifiedEmailingDomains.ts new file mode 100644 index 00000000000..3e79202319f --- /dev/null +++ b/packages/twenty-front/src/modules/activities/campaigns/hooks/useVerifiedEmailingDomains.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@apollo/client/react'; + +import { FIND_VERIFIED_EMAILING_DOMAINS } from '@/activities/campaigns/graphql/queries/findVerifiedEmailingDomains'; + +type EmailingDomain = { + id: string; + domain: string; + status: string; +}; + +type FindVerifiedEmailingDomainsResult = { + getEmailingDomains: EmailingDomain[]; +}; + +export const useVerifiedEmailingDomains = () => { + const { data, loading, error } = useQuery( + FIND_VERIFIED_EMAILING_DOMAINS, + ); + + const verifiedDomains = (data?.getEmailingDomains ?? []).filter( + (domain) => domain.status === 'VERIFIED', + ); + + return { verifiedDomains, loading, error }; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/constants/EngineComponentKeyHeadlessComponentMap.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/constants/EngineComponentKeyHeadlessComponentMap.tsx index 4f453e56b82..008e7581e24 100644 --- a/packages/twenty-front/src/modules/command-menu-item/engine-command/constants/EngineComponentKeyHeadlessComponentMap.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/constants/EngineComponentKeyHeadlessComponentMap.tsx @@ -4,6 +4,7 @@ import { HeadlessOpenSidePanelPageEngineCommand } from '@/command-menu-item/engi import { NavigationEngineCommand } from '@/command-menu-item/engine-command/components/NavigationEngineCommand'; import { ComposeEmailCommand } from '@/command-menu-item/engine-command/global/components/ComposeEmailCommand'; import { DeleteRecordsCommand } from '@/command-menu-item/engine-command/record/components/DeleteRecordsCommand'; +import { SendCampaignCommand } from '@/command-menu-item/engine-command/record/components/SendCampaignCommand'; import { DestroyRecordsCommand } from '@/command-menu-item/engine-command/record/components/DestroyRecordsCommand'; import { ExportRecordsCommand } from '@/command-menu-item/engine-command/record/components/ExportRecordsCommand'; import { RestoreRecordsCommand } from '@/command-menu-item/engine-command/record/components/RestoreRecordsCommand'; @@ -248,6 +249,7 @@ export const ENGINE_COMPONENT_KEY_COMPONENT_MAP: Record< ), [EngineComponentKey.REPLY_TO_EMAIL_THREAD]: , [EngineComponentKey.COMPOSE_EMAIL]: , + [EngineComponentKey.SEND_CAMPAIGN]: , // Deprecated keys kept for backward compatibility until migration runs [EngineComponentKey.DELETE_SINGLE_RECORD]: , diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/SendCampaignCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/SendCampaignCommand.tsx new file mode 100644 index 00000000000..4e6d04811ec --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/SendCampaignCommand.tsx @@ -0,0 +1,50 @@ +import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { useOpenComposeCampaignInSidePanel } from '@/side-panel/hooks/useOpenComposeCampaignInSidePanel'; + +export const SendCampaignCommand = () => { + const { openComposeCampaignInSidePanel } = + useOpenComposeCampaignInSidePanel(); + + const { objectMetadataItem, selectedRecords, graphqlFilter } = + useHeadlessCommandContextApi(); + + const objectNameSingular = objectMetadataItem?.nameSingular ?? null; + const isPerson = objectNameSingular === CoreObjectNameSingular.Person; + + // Campaign is bulk-only in v1 — selecting a single Person should still work, + // but in practice it's a tool for selections. + const { records: personRecords, loading } = useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.Person, + filter: graphqlFilter ?? undefined, + recordGqlFields: { id: true }, + limit: MAX_EMAIL_RECIPIENTS, + skip: !isPerson, + }); + + const recipientPersonIds = isPerson + ? personRecords.map((record) => record.id).filter(isDefined) + : selectedRecords.map((record) => record.id).filter(isDefined); + + const handleExecute = () => { + if (recipientPersonIds.length === 0) { + return; + } + + openComposeCampaignInSidePanel({ + recipientPersonIds, + }); + }; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/side-panel/constants/SidePanelPagesConfig.tsx b/packages/twenty-front/src/modules/side-panel/constants/SidePanelPagesConfig.tsx index efcebc7f12d..e0b081074ac 100644 --- a/packages/twenty-front/src/modules/side-panel/constants/SidePanelPagesConfig.tsx +++ b/packages/twenty-front/src/modules/side-panel/constants/SidePanelPagesConfig.tsx @@ -5,6 +5,7 @@ import { SidePanelNewSidebarItemPage } from '@/navigation-menu-item/edit/side-pa import { SidePanelAiChatThreadsPage } from '@/side-panel/pages/ai-chat-threads/components/SidePanelAiChatThreadsPage'; import { SidePanelAskAiPage } from '@/side-panel/pages/ask-ai/components/SidePanelAskAiPage'; import { SidePanelCalendarEventPage } from '@/side-panel/pages/calendar-event/components/SidePanelCalendarEventPage'; +import { SidePanelComposeCampaignPage } from '@/side-panel/pages/compose-campaign/components/SidePanelComposeCampaignPage'; import { SidePanelComposeEmailPage } from '@/side-panel/pages/compose-email/components/SidePanelComposeEmailPage'; import { SidePanelFrontComponentPage } from '@/side-panel/pages/front-component/components/SidePanelFrontComponentPage'; import { SidePanelDashboardChartSettings } from '@/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardChartSettings'; @@ -88,5 +89,6 @@ export const SIDE_PANEL_PAGES_CONFIG = new Map( [SidePanelPages.NavigationMenuAddItem, ], [SidePanelPages.CommandMenuEdit, ], [SidePanelPages.ComposeEmail, ], + [SidePanelPages.ComposeCampaign, ], ], ); diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useOpenComposeCampaignInSidePanel.ts b/packages/twenty-front/src/modules/side-panel/hooks/useOpenComposeCampaignInSidePanel.ts new file mode 100644 index 00000000000..9eaea48b7fb --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/hooks/useOpenComposeCampaignInSidePanel.ts @@ -0,0 +1,51 @@ +import { useCallback } from 'react'; + +import { useStore } from 'jotai'; +import { t } from '@lingui/core/macro'; +import { SidePanelPages } from 'twenty-shared/types'; +import { IconSend } from 'twenty-ui/display'; +import { v4 } from 'uuid'; + +import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; +import { composeCampaignDefaultSubjectComponentState } from '@/side-panel/pages/compose-campaign/states/composeCampaignDefaultSubjectComponentState'; +import { composeCampaignRecipientPersonIdsComponentState } from '@/side-panel/pages/compose-campaign/states/composeCampaignRecipientPersonIdsComponentState'; + +type OpenComposeCampaignParams = { + recipientPersonIds: string[]; + defaultSubject?: string; +}; + +export const useOpenComposeCampaignInSidePanel = () => { + const store = useStore(); + const { navigateSidePanelMenu } = useSidePanelMenu(); + + const openComposeCampaignInSidePanel = useCallback( + (params: OpenComposeCampaignParams) => { + const pageId = v4(); + + store.set( + composeCampaignRecipientPersonIdsComponentState.atomFamily({ + instanceId: pageId, + }), + params.recipientPersonIds, + ); + + store.set( + composeCampaignDefaultSubjectComponentState.atomFamily({ + instanceId: pageId, + }), + params.defaultSubject ?? '', + ); + + navigateSidePanelMenu({ + page: SidePanelPages.ComposeCampaign, + pageTitle: t`New Campaign`, + pageIcon: IconSend, + pageId, + }); + }, + [navigateSidePanelMenu, store], + ); + + return { openComposeCampaignInSidePanel }; +}; diff --git a/packages/twenty-front/src/modules/side-panel/pages/compose-campaign/components/SidePanelComposeCampaignPage.tsx b/packages/twenty-front/src/modules/side-panel/pages/compose-campaign/components/SidePanelComposeCampaignPage.tsx new file mode 100644 index 00000000000..b11c9708fef --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/compose-campaign/components/SidePanelComposeCampaignPage.tsx @@ -0,0 +1,268 @@ +import { useState } from 'react'; + +import '@blocknote/core/fonts/inter.css'; +import '@blocknote/mantine/style.css'; +import { useCreateBlockNote } from '@blocknote/react'; +import '@blocknote/react/style.css'; +import { styled } from '@linaria/react'; +import { t } from '@lingui/core/macro'; +import { IconSend } from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { useSendMessageCampaign } from '@/activities/campaigns/hooks/useSendMessageCampaign'; +import { useVerifiedEmailingDomains } from '@/activities/campaigns/hooks/useVerifiedEmailingDomains'; +import { BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema'; +import { BlockEditor } from '@/blocknote-editor/components/BlockEditor'; +import { composeCampaignDefaultSubjectComponentState } from '@/side-panel/pages/compose-campaign/states/composeCampaignDefaultSubjectComponentState'; +import { composeCampaignRecipientPersonIdsComponentState } from '@/side-panel/pages/compose-campaign/states/composeCampaignRecipientPersonIdsComponentState'; +import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory'; +import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + height: 100%; +`; + +const StyledContent = styled.div` + display: flex; + flex: 1; + flex-direction: column; + gap: ${themeCssVariables.spacing[3]}; + overflow-y: auto; + padding: ${themeCssVariables.spacing[4]}; +`; + +const StyledField = styled.label` + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[1]}; + color: ${themeCssVariables.font.color.secondary}; + font-size: ${themeCssVariables.font.size.sm}; + font-weight: ${themeCssVariables.font.weight.medium}; +`; + +const StyledInput = styled.input` + background: ${themeCssVariables.background.primary}; + border: 1px solid ${themeCssVariables.border.color.medium}; + border-radius: ${themeCssVariables.border.radius.sm}; + color: ${themeCssVariables.font.color.primary}; + font-family: inherit; + font-size: ${themeCssVariables.font.size.md}; + padding: ${themeCssVariables.spacing[2]}; + + &:focus { + border-color: ${themeCssVariables.color.blue}; + outline: none; + } +`; + +const StyledBodyEditorWrapper = styled.div` + background: ${themeCssVariables.background.primary}; + border: 1px solid ${themeCssVariables.border.color.medium}; + border-radius: ${themeCssVariables.border.radius.sm}; + min-height: 240px; + padding: ${themeCssVariables.spacing[2]}; +`; + +const StyledSelect = styled.select` + background: ${themeCssVariables.background.primary}; + border: 1px solid ${themeCssVariables.border.color.medium}; + border-radius: ${themeCssVariables.border.radius.sm}; + color: ${themeCssVariables.font.color.primary}; + font-family: inherit; + font-size: ${themeCssVariables.font.size.md}; + padding: ${themeCssVariables.spacing[2]}; +`; + +const StyledRecipientCount = styled.div` + color: ${themeCssVariables.font.color.tertiary}; + font-size: ${themeCssVariables.font.size.sm}; +`; + +const StyledEmptyDomainsMessage = styled.div` + color: ${themeCssVariables.font.color.danger}; + font-size: ${themeCssVariables.font.size.sm}; +`; + +export const SidePanelComposeCampaignPage = () => { + const recipientPersonIds = + useAtomComponentStateValue(composeCampaignRecipientPersonIdsComponentState) ?? + []; + const defaultSubject = + useAtomComponentStateValue(composeCampaignDefaultSubjectComponentState) ?? + ''; + + const { goBackFromSidePanel } = useSidePanelHistory(); + const { sendCampaign, loading: sending } = useSendMessageCampaign(); + const { verifiedDomains, loading: domainsLoading } = + useVerifiedEmailingDomains(); + + const [name, setName] = useState(''); + const [subject, setSubject] = useState(defaultSubject); + // Rendered HTML — kept in sync with the BlockNote editor via onChange. Sent + // as the campaign body (the backend forwards it to SES as the HTML body and + // derives a plain-text fallback for the SES `text` field). + const [bodyHtml, setBodyHtml] = useState(''); + const [fromAddress, setFromAddress] = useState(''); + const [replyTo, setReplyTo] = useState(''); + const [emailingDomainId, setEmailingDomainId] = useState( + verifiedDomains[0]?.id ?? '', + ); + + const editor = useCreateBlockNote({ + schema: BLOCK_SCHEMA, + domAttributes: { editor: { class: 'editor' } }, + placeholders: { default: t`Write your message…` }, + }); + + const handleEditorChange = () => { + setBodyHtml(editor.blocksToFullHTML(editor.document)); + }; + + // Auto-pick the first verified domain when it loads. + if (!emailingDomainId && verifiedDomains.length > 0) { + setEmailingDomainId(verifiedDomains[0].id); + } + + // The BlockNote editor always has at least one empty paragraph block, so an + // untouched editor produces non-empty HTML. Check the document's text + // content instead to detect a genuinely empty body. + const hasBodyContent = editor.document.some((block) => { + if (!Array.isArray(block.content)) return false; + return block.content.some( + (item) => + 'text' in item && + typeof item.text === 'string' && + item.text.trim().length > 0, + ); + }); + + const canSend = + !sending && + name.trim().length > 0 && + subject.trim().length > 0 && + hasBodyContent && + fromAddress.trim().length > 0 && + emailingDomainId.length > 0 && + recipientPersonIds.length > 0; + + const handleSend = async () => { + // Recompute HTML at send time in case onChange was missed during fast typing. + const finalHtml = editor.blocksToFullHTML(editor.document); + + const success = await sendCampaign({ + name: name.trim(), + subject: subject.trim(), + bodyTemplate: finalHtml, + fromAddress: fromAddress.trim(), + replyTo: replyTo.trim() ? replyTo.trim() : undefined, + emailingDomainId, + recipientPersonIds, + }); + + if (success) { + goBackFromSidePanel(); + } + }; + + return ( + + + + {t`Sending to ${recipientPersonIds.length} recipient(s)`} + + + {!domainsLoading && verifiedDomains.length === 0 && ( + + {t`No verified sending domain. Add one in Settings → Emailing Domains first.`} + + )} + + + {t`Campaign name`} + setName(event.target.value)} + /> + + + + {t`Sending domain`} + setEmailingDomainId(event.target.value)} + disabled={verifiedDomains.length === 0} + > + {verifiedDomains.map((domain) => ( + + ))} + + + + + {t`From`} + setFromAddress(event.target.value)} + /> + + + + {t`Reply-to (optional)`} + setReplyTo(event.target.value)} + /> + + + + {t`Subject`} + setSubject(event.target.value)} + /> + + + + {t`Body`} + + + + + + , +