feat(messaging): bulk email campaigns (v1)

Adds a one-shot bulk-email-campaign feature on top of the existing
EmailingDomain (AWS SES) infrastructure.

Data model
- New messageCampaign workspace object: name, subject, bodyTemplate
  (rich text), fromAddress, replyTo, status, scheduledAt, sentAt,
  sentCount, bouncedCount, failedCount, recipientSource, position,
  searchVector, messages relation.
- Extends message with four nullable outbound fields: deliveryStatus,
  providerMessageId, sourceType, sourceCampaign (MANY_TO_ONE). Inbound
  synced messages leave these null and are unaffected.
- Extends shared enums: MessageChannelType.WORKSPACE_TRANSACTIONAL,
  ConnectedAccountProvider.WORKSPACE_TRANSACTIONAL, and
  MessageChannelSyncStage.NOT_APPLICABLE. Two fast instance commands
  extend the matching Postgres enums.

Backend send flow
- MessageChannelMetadataService.findOrCreateWorkspaceTransactionalChannel
  lazily provisions a synthetic ConnectedAccount + MessageChannel pair
  per workspace (modeled on createEmailGroupChannel).
- MessagingCampaignService.startCampaign validates the verified emailing
  domain + from-address, snapshots recipient emails from Person.emails,
  materializes one messageThread + message (status=QUEUED,
  sourceType=CAMPAIGN) per recipient, and enqueues a per-recipient send
  job on the email queue. Hard cap of 1000 recipients enforced in the
  input DTO.
- MessagingCampaignSendRecipientJob calls EmailingDomainService.sendEmail
  (existing SES driver), persists deliveryStatus + providerMessageId,
  increments campaign counters, and finalizes campaign status when every
  recipient message reaches a terminal state.
- sendMessageCampaign GraphQL mutation, permission-guarded on
  SEND_EMAIL_TOOL.

Frontend
- New STANDARD_COMMAND_MENU_ITEMS entry sendCampaignToPerson (record
  selection, Person only). New EngineComponentKey.SEND_CAMPAIGN.
- SendCampaignCommand resolves the selected People (respects view-filter
  exclusions like ComposeEmailCommand) and opens the compose side panel.
- SidePanelComposeCampaignPage: side-panel drawer with verified-domain
  picker, from/reply-to, subject, and a BlockNote rich-text body. On
  send, serializes body to HTML via editor.blocksToFullHTML; the backend
  job forwards HTML to SES and derives a plain-text fallback.
- New SidePanelPages.ComposeCampaign and supporting jotai atom families
  for recipient IDs and default subject.

Standard nav / view
- Standard view, view-fields, and navigation menu entry for the
  Campaigns section so the record-index page is reachable from the left
  nav.

Out of scope (for follow-ups)
- Bounce / Complaint SNS webhook handling that updates message
  deliveryStatus and increments bouncedCount.
- "Source: Campaign" pill on the Person Messages tab.
- Retry/backoff config on the per-recipient send job.
- Merge variables, scheduling UI.
This commit is contained in:
Félix Malfait
2026-05-28 13:31:42 +02:00
parent 5ffc9abdb1
commit 2398f151df
50 changed files with 2239 additions and 1 deletions
@@ -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',
@@ -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:
@@ -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
}
}
`;
@@ -0,0 +1,11 @@
import gql from 'graphql-tag';
export const FIND_VERIFIED_EMAILING_DOMAINS = gql`
query FindVerifiedEmailingDomains {
getEmailingDomains {
id
domain
status
}
}
`;
@@ -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<boolean> => {
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 };
};
@@ -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<FindVerifiedEmailingDomainsResult>(
FIND_VERIFIED_EMAILING_DOMAINS,
);
const verifiedDomains = (data?.getEmailingDomains ?? []).filter(
(domain) => domain.status === 'VERIFIED',
);
return { verifiedDomains, loading, error };
};
@@ -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]: <ReplyToEmailThreadCommand />,
[EngineComponentKey.COMPOSE_EMAIL]: <ComposeEmailCommand />,
[EngineComponentKey.SEND_CAMPAIGN]: <SendCampaignCommand />,
// Deprecated keys kept for backward compatibility until migration runs
[EngineComponentKey.DELETE_SINGLE_RECORD]: <DeleteRecordsCommand />,
@@ -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 (
<HeadlessEngineCommandWrapperEffect
execute={handleExecute}
ready={!loading}
/>
);
};
@@ -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, React.ReactNode>(
[SidePanelPages.NavigationMenuAddItem, <SidePanelNewSidebarItemPage />],
[SidePanelPages.CommandMenuEdit, <SidePanelCommandMenuItemEditPage />],
[SidePanelPages.ComposeEmail, <SidePanelComposeEmailPage />],
[SidePanelPages.ComposeCampaign, <SidePanelComposeCampaignPage />],
],
);
@@ -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 };
};
@@ -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 (
<StyledContainer>
<StyledContent>
<StyledRecipientCount>
{t`Sending to ${recipientPersonIds.length} recipient(s)`}
</StyledRecipientCount>
{!domainsLoading && verifiedDomains.length === 0 && (
<StyledEmptyDomainsMessage>
{t`No verified sending domain. Add one in Settings → Emailing Domains first.`}
</StyledEmptyDomainsMessage>
)}
<StyledField>
{t`Campaign name`}
<StyledInput
type="text"
placeholder={t`Internal label, e.g. "Q3 Newsletter"`}
value={name}
onChange={(event) => setName(event.target.value)}
/>
</StyledField>
<StyledField>
{t`Sending domain`}
<StyledSelect
value={emailingDomainId}
onChange={(event) => setEmailingDomainId(event.target.value)}
disabled={verifiedDomains.length === 0}
>
{verifiedDomains.map((domain) => (
<option key={domain.id} value={domain.id}>
{domain.domain}
</option>
))}
</StyledSelect>
</StyledField>
<StyledField>
{t`From`}
<StyledInput
type="email"
placeholder={t`someone@your-verified-domain.com`}
value={fromAddress}
onChange={(event) => setFromAddress(event.target.value)}
/>
</StyledField>
<StyledField>
{t`Reply-to (optional)`}
<StyledInput
type="email"
placeholder={t`Replies go to this address`}
value={replyTo}
onChange={(event) => setReplyTo(event.target.value)}
/>
</StyledField>
<StyledField>
{t`Subject`}
<StyledInput
type="text"
value={subject}
onChange={(event) => setSubject(event.target.value)}
/>
</StyledField>
<StyledField>
{t`Body`}
<StyledBodyEditorWrapper>
<BlockEditor editor={editor} onChange={handleEditorChange} />
</StyledBodyEditorWrapper>
</StyledField>
</StyledContent>
<SidePanelFooter
actions={[
<Button
key="cancel"
size="small"
variant="secondary"
title={t`Cancel`}
onClick={goBackFromSidePanel}
/>,
<Button
key="send"
size="small"
variant="primary"
accent="blue"
title={sending ? t`Sending…` : t`Send campaign`}
Icon={IconSend}
onClick={handleSend}
disabled={!canSend}
/>,
]}
/>
</StyledContainer>
);
};
@@ -0,0 +1,9 @@
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const composeCampaignDefaultSubjectComponentState =
createAtomComponentState<string>({
key: 'side-panel/compose-campaign-default-subject',
defaultValue: '',
componentInstanceContext: SidePanelPageComponentInstanceContext,
});
@@ -0,0 +1,9 @@
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const composeCampaignRecipientPersonIdsComponentState =
createAtomComponentState<string[]>({
key: 'side-panel/compose-campaign-recipient-person-ids',
defaultValue: [],
componentInstanceContext: SidePanelPageComponentInstanceContext,
});
@@ -31,6 +31,7 @@ const PROVIDERS_ICON_MAPPING = {
[ConnectedAccountProvider.OIDC]: IconMail,
[ConnectedAccountProvider.SAML]: IconMail,
[ConnectedAccountProvider.EMAIL_GROUP]: IconMail,
[ConnectedAccountProvider.WORKSPACE_TRANSACTIONAL]: IconMail,
// App-managed connections aren't email accounts; this case is unreachable
// for the EMAIL source but the lookup type still requires every provider.
[ConnectedAccountProvider.APP]: IconMail,
@@ -0,0 +1,35 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.9.0', 1799100000000)
export class AddWorkspaceTransactionalChannelTypeFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TYPE "core"."messageChannel_type_enum" RENAME TO "messageChannel_type_enum_old"',
);
await queryRunner.query(
"CREATE TYPE \"core\".\"messageChannel_type_enum\" AS ENUM('EMAIL', 'SMS', 'EMAIL_GROUP', 'WORKSPACE_TRANSACTIONAL')",
);
await queryRunner.query(
'ALTER TABLE "core"."messageChannel" ALTER COLUMN "type" TYPE "core"."messageChannel_type_enum" USING "type"::"text"::"core"."messageChannel_type_enum"',
);
await queryRunner.query('DROP TYPE "core"."messageChannel_type_enum_old"');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"CREATE TYPE \"core\".\"messageChannel_type_enum_old\" AS ENUM('EMAIL', 'SMS', 'EMAIL_GROUP')",
);
await queryRunner.query(
'ALTER TABLE "core"."messageChannel" ALTER COLUMN "type" TYPE "core"."messageChannel_type_enum_old" USING "type"::"text"::"core"."messageChannel_type_enum_old"',
);
await queryRunner.query('DROP TYPE "core"."messageChannel_type_enum"');
await queryRunner.query(
'ALTER TYPE "core"."messageChannel_type_enum_old" RENAME TO "messageChannel_type_enum"',
);
}
}
@@ -0,0 +1,39 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.9.0', 1799100010000)
export class AddNotApplicableMessageChannelSyncStageFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TYPE "core"."messageChannel_syncstage_enum" RENAME TO "messageChannel_syncstage_enum_old"',
);
await queryRunner.query(
"CREATE TYPE \"core\".\"messageChannel_syncstage_enum\" AS ENUM('PENDING_CONFIGURATION', 'MESSAGE_LIST_FETCH_PENDING', 'MESSAGE_LIST_FETCH_SCHEDULED', 'MESSAGE_LIST_FETCH_ONGOING', 'MESSAGES_IMPORT_PENDING', 'MESSAGES_IMPORT_SCHEDULED', 'MESSAGES_IMPORT_ONGOING', 'FAILED', 'NOT_APPLICABLE')",
);
await queryRunner.query(
'ALTER TABLE "core"."messageChannel" ALTER COLUMN "syncStage" TYPE "core"."messageChannel_syncstage_enum" USING "syncStage"::"text"::"core"."messageChannel_syncstage_enum"',
);
await queryRunner.query(
'DROP TYPE "core"."messageChannel_syncstage_enum_old"',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"CREATE TYPE \"core\".\"messageChannel_syncstage_enum_old\" AS ENUM('PENDING_CONFIGURATION', 'MESSAGE_LIST_FETCH_PENDING', 'MESSAGE_LIST_FETCH_SCHEDULED', 'MESSAGE_LIST_FETCH_ONGOING', 'MESSAGES_IMPORT_PENDING', 'MESSAGES_IMPORT_SCHEDULED', 'MESSAGES_IMPORT_ONGOING', 'FAILED')",
);
await queryRunner.query(
'ALTER TABLE "core"."messageChannel" ALTER COLUMN "syncStage" TYPE "core"."messageChannel_syncstage_enum_old" USING "syncStage"::"text"::"core"."messageChannel_syncstage_enum_old"',
);
await queryRunner.query(
'DROP TYPE "core"."messageChannel_syncstage_enum"',
);
await queryRunner.query(
'ALTER TYPE "core"."messageChannel_syncstage_enum_old" RENAME TO "messageChannel_syncstage_enum"',
);
}
}
@@ -57,6 +57,8 @@ import { FinalizeRolePermissionFlagCutoverFastInstanceCommand } from 'src/databa
import { AddSubFieldNameToIndexFieldMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1798200000000-add-sub-field-name-to-index-field-metadata';
import { DropFieldMetadataIsUniqueColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1798300000000-drop-field-metadata-is-unique-column';
import { MigrateAiModelPreferencesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences';
import { AddWorkspaceTransactionalChannelTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-fast-1799100000000-add-workspace-transactional-channel-type';
import { AddNotApplicableMessageChannelSyncStageFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-fast-1799100010000-add-not-applicable-message-channel-sync-stage';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -116,4 +118,6 @@ export const INSTANCE_COMMANDS = [
AddSubFieldNameToIndexFieldMetadataFastInstanceCommand,
DropFieldMetadataIsUniqueColumnFastInstanceCommand,
MigrateAiModelPreferencesSlowInstanceCommand,
AddWorkspaceTransactionalChannelTypeFastInstanceCommand,
AddNotApplicableMessageChannelSyncStageFastInstanceCommand,
];
@@ -1152,6 +1152,7 @@ export class AuthService {
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return [];
case ConnectedAccountProvider.EMAIL_GROUP:
case ConnectedAccountProvider.WORKSPACE_TRANSACTIONAL:
case ConnectedAccountProvider.APP:
return [];
default:
@@ -49,6 +49,7 @@ export enum EngineComponentKey {
FRONT_COMPONENT_RENDERER = 'FRONT_COMPONENT_RENDERER',
REPLY_TO_EMAIL_THREAD = 'REPLY_TO_EMAIL_THREAD',
COMPOSE_EMAIL = 'COMPOSE_EMAIL',
SEND_CAMPAIGN = 'SEND_CAMPAIGN',
// TODO: Remove deprecated keys once upgrade:1-21:refactor-navigation-commands has run on all workspaces
// Deprecated: replaced by NAVIGATION engine key with payload
@@ -202,6 +202,59 @@ export class MessageChannelMetadataService {
return this.repository.findOneOrFail({ where: { id, workspaceId } });
}
async findOrCreateWorkspaceTransactionalChannel({
workspaceId,
userWorkspaceId,
handle,
}: {
workspaceId: string;
userWorkspaceId: string;
handle?: string;
}): Promise<MessageChannelDTO> {
const existing = await this.repository.findOne({
where: {
workspaceId,
type: MessageChannelType.WORKSPACE_TRANSACTIONAL,
},
});
if (existing) {
return existing;
}
// Synthetic, workspace-scoped sender channel — the actual from-address is
// set per message via EmailingDomainService.sendEmail. We use a stable
// placeholder handle so the row is identifiable without claiming a real
// mailbox.
const effectiveHandle = handle ?? `transactional@${workspaceId}.local`;
const connectedAccount = await this.connectedAccountMetadataService.create({
workspaceId,
handle: effectiveHandle,
provider: ConnectedAccountProvider.WORKSPACE_TRANSACTIONAL,
userWorkspaceId,
accessToken: null,
refreshToken: null,
visibility: 'workspace',
});
return this.create({
workspaceId,
handle: effectiveHandle,
connectedAccountId: connectedAccount.id,
type: MessageChannelType.WORKSPACE_TRANSACTIONAL,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
syncStage: MessageChannelSyncStage.NOT_APPLICABLE,
syncStatus: MessageChannelSyncStatus.NOT_SYNCED,
isSyncEnabled: false,
isContactAutoCreationEnabled: false,
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy.NONE,
excludeGroupEmails: false,
excludeNonProfessionalEmails: false,
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction.NONE,
});
}
async createEmailGroupChannel({
handle,
userWorkspaceId,
@@ -965,6 +965,24 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
engineComponentKey: EngineComponentKey.COMPOSE_EMAIL,
hotKeys: null,
},
sendCampaignToPerson: {
universalIdentifier: '9bb9ffc4-d85b-42a1-845f-09978df195a8',
label: 'Send Campaign',
icon: 'IconSend',
isPinned: false,
// After composeEmailToPerson so it sits below the per-person Send Email
// action in the menu.
position: 63.5,
shortLabel: 'Send Campaign',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'numberOfSelectedRecords >= 1 and permissionFlags.SEND_EMAIL_TOOL',
availabilityObjectMetadataUniversalIdentifier:
STANDARD_OBJECTS.person.universalIdentifier,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.SEND_CAMPAIGN,
hotKeys: null,
},
composeEmailToCompany: {
universalIdentifier: 'a76d3ab8-4c3a-4e5d-8a4a-1f5d6e7f8a90',
label: 'Send Email',
@@ -45,12 +45,19 @@ export const STANDARD_NAVIGATION_MENU_ITEMS = {
STANDARD_OBJECTS.dashboard.views.allDashboards.universalIdentifier,
position: 5,
},
allCampaigns: {
universalIdentifier: '20202020-b00b-4b0b-8b0b-c0aba11c000b',
type: NavigationMenuItemType.OBJECT,
viewUniversalIdentifier:
STANDARD_OBJECTS.messageCampaign.views.allCampaigns.universalIdentifier,
position: 6,
},
workflowsFolder: {
universalIdentifier: '20202020-b007-4b07-8b07-c0aba11c0007',
type: NavigationMenuItemType.FOLDER,
name: 'Workflows',
icon: 'IconSettingsAutomation',
position: 6,
position: 7,
},
workflowsFolderAllWorkflows: {
universalIdentifier: '20202020-b008-4b08-8b08-c0aba11c0008',
@@ -89,6 +96,7 @@ export const STANDARD_NAVIGATION_MENU_ITEM_DEFAULT_COLORS: Partial<
allOpportunities: 'red',
workflowsFolder: 'orange',
allDashboards: 'gray',
allCampaigns: 'blue',
workflowsFolderAllWorkflows: 'gray',
workflowsFolderAllWorkflowRuns: 'gray',
workflowsFolderAllWorkflowVersions: 'gray',
@@ -12,6 +12,7 @@ import { buildCalendarEventParticipantStandardFlatFieldMetadatas } from 'src/eng
import { buildCalendarEventStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-calendar-event-standard-flat-field-metadata.util';
import { buildCompanyStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-company-standard-flat-field-metadata.util';
import { buildDashboardStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-dashboard-standard-flat-field-metadata.util';
import { buildMessageCampaignStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util';
import { buildMessageChannelMessageAssociationMessageFolderStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-channel-message-association-message-folder-standard-flat-field-metadata.util';
import { buildMessageChannelMessageAssociationStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-channel-message-association-standard-flat-field-metadata.util';
import { buildMessageParticipantStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-participant-standard-flat-field-metadata.util';
@@ -46,6 +47,7 @@ const STANDARD_FLAT_FIELD_METADATA_BUILDERS_BY_OBJECT_NAME = {
company: buildCompanyStandardFlatFieldMetadatas,
dashboard: buildDashboardStandardFlatFieldMetadatas,
message: buildMessageStandardFlatFieldMetadatas,
messageCampaign: buildMessageCampaignStandardFlatFieldMetadatas,
messageChannelMessageAssociation:
buildMessageChannelMessageAssociationStandardFlatFieldMetadatas,
messageChannelMessageAssociationMessageFolder:
@@ -0,0 +1,479 @@
import { msg } from '@lingui/core/macro';
import { i18nLabel } from 'src/engine/workspace-manager/twenty-standard-application/utils/i18n-label.util';
import {
DateDisplayFormat,
FieldMetadataType,
RelationType,
} from 'twenty-shared/types';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type';
import {
type CreateStandardFieldArgs,
createStandardFieldFlatMetadata,
} from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-field-flat-metadata.util';
import { createStandardRelationFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-relation-field-flat-metadata.util';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { SEARCH_FIELDS_FOR_MESSAGE_CAMPAIGN } from 'src/modules/messaging/common/standard-objects/message-campaign.workspace-entity';
export const buildMessageCampaignStandardFlatFieldMetadatas = ({
now,
objectName,
workspaceId,
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
}: Omit<
CreateStandardFieldArgs<'messageCampaign', FieldMetadataType>,
'context'
>): Record<
AllStandardObjectFieldName<'messageCampaign'>,
FlatFieldMetadata
> => ({
id: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'id',
type: FieldMetadataType.UUID,
label: i18nLabel(msg`Id`),
description: i18nLabel(msg`Id`),
icon: 'Icon123',
isSystem: true,
isNullable: false,
isUIReadOnly: true,
defaultValue: 'uuid',
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
createdAt: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'createdAt',
type: FieldMetadataType.DATE_TIME,
label: i18nLabel(msg`Creation date`),
description: i18nLabel(msg`Creation date`),
icon: 'IconCalendar',
isSystem: true,
isNullable: false,
isUIReadOnly: true,
defaultValue: 'now',
settings: { displayFormat: DateDisplayFormat.RELATIVE },
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
updatedAt: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'updatedAt',
type: FieldMetadataType.DATE_TIME,
label: i18nLabel(msg`Last update`),
description: i18nLabel(msg`Last time the record was changed`),
icon: 'IconCalendarClock',
isSystem: true,
isNullable: false,
isUIReadOnly: true,
defaultValue: 'now',
settings: { displayFormat: DateDisplayFormat.RELATIVE },
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
deletedAt: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'deletedAt',
type: FieldMetadataType.DATE_TIME,
label: i18nLabel(msg`Deleted at`),
description: i18nLabel(msg`Date when the record was deleted`),
icon: 'IconCalendarMinus',
isSystem: true,
isNullable: true,
isUIReadOnly: true,
settings: { displayFormat: DateDisplayFormat.RELATIVE },
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
// Campaign-specific fields
name: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'name',
type: FieldMetadataType.TEXT,
label: i18nLabel(msg`Name`),
description: i18nLabel(msg`Campaign name (internal label)`),
icon: 'IconSend',
isNullable: false,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
subject: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'subject',
type: FieldMetadataType.TEXT,
label: i18nLabel(msg`Subject`),
description: i18nLabel(msg`Email subject line`),
icon: 'IconMessage',
isNullable: true,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
bodyTemplate: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'bodyTemplate',
type: FieldMetadataType.RICH_TEXT,
label: i18nLabel(msg`Body`),
description: i18nLabel(msg`Email body template`),
icon: 'IconFilePencil',
isNullable: true,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
fromAddress: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'fromAddress',
type: FieldMetadataType.TEXT,
label: i18nLabel(msg`From address`),
description: i18nLabel(
msg`Sender address — must belong to a verified emailing domain.`,
),
icon: 'IconAt',
isNullable: true,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
replyTo: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'replyTo',
type: FieldMetadataType.TEXT,
label: i18nLabel(msg`Reply-to`),
description: i18nLabel(msg`Address replies are routed to.`),
icon: 'IconCornerUpLeft',
isNullable: true,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
status: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'status',
type: FieldMetadataType.SELECT,
label: i18nLabel(msg`Status`),
description: i18nLabel(msg`Campaign lifecycle status`),
icon: 'IconProgressCheck',
isNullable: false,
defaultValue: "'DRAFT'",
options: [
{
id: '2d21ebd9-add3-4260-9ae0-35f542362cf4',
value: 'DRAFT',
label: i18nLabel(msg`Draft`),
position: 0,
color: 'gray',
},
{
id: 'c670b151-af5b-420c-99af-9da518d7400c',
value: 'SCHEDULED',
label: i18nLabel(msg`Scheduled`),
position: 1,
color: 'blue',
},
{
id: 'ea8ad637-3d59-47e7-b949-baaacc73ae39',
value: 'SENDING',
label: i18nLabel(msg`Sending`),
position: 2,
color: 'yellow',
},
{
id: 'a8580e91-e64c-4cba-9fb9-1a0109d7888d',
value: 'SENT',
label: i18nLabel(msg`Sent`),
position: 3,
color: 'green',
},
{
id: '67f21bd9-0271-48d0-9887-64bb0a807d32',
value: 'FAILED',
label: i18nLabel(msg`Failed`),
position: 4,
color: 'red',
},
],
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
scheduledAt: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'scheduledAt',
type: FieldMetadataType.DATE_TIME,
label: i18nLabel(msg`Scheduled for`),
description: i18nLabel(msg`When the campaign is scheduled to send`),
icon: 'IconCalendarTime',
isNullable: true,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
sentAt: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'sentAt',
type: FieldMetadataType.DATE_TIME,
label: i18nLabel(msg`Sent at`),
description: i18nLabel(msg`When the campaign finished sending`),
icon: 'IconSend',
isNullable: true,
isUIReadOnly: true,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
sentCount: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'sentCount',
type: FieldMetadataType.NUMBER,
label: i18nLabel(msg`Sent`),
description: i18nLabel(msg`Number of recipients the email was sent to`),
icon: 'IconMail',
isNullable: false,
isUIReadOnly: true,
defaultValue: 0,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
bouncedCount: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'bouncedCount',
type: FieldMetadataType.NUMBER,
label: i18nLabel(msg`Bounced`),
description: i18nLabel(msg`Number of recipients whose delivery bounced`),
icon: 'IconMailX',
isNullable: false,
isUIReadOnly: true,
defaultValue: 0,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
failedCount: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'failedCount',
type: FieldMetadataType.NUMBER,
label: i18nLabel(msg`Failed`),
description: i18nLabel(msg`Number of recipients the send failed for`),
icon: 'IconAlertTriangle',
isNullable: false,
isUIReadOnly: true,
defaultValue: 0,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
recipientSource: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'recipientSource',
type: FieldMetadataType.SELECT,
label: i18nLabel(msg`Recipient source`),
description: i18nLabel(
msg`How recipients were chosen — ad-hoc record selection in v1.`,
),
icon: 'IconUsersGroup',
isNullable: false,
defaultValue: "'RECORD_SELECTION'",
options: [
{
id: '11cc1d5f-7b5d-4cbe-9de3-2fdda42b0d3a',
value: 'RECORD_SELECTION',
label: i18nLabel(msg`Record selection`),
position: 0,
color: 'blue',
},
],
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
createdBy: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'createdBy',
type: FieldMetadataType.ACTOR,
label: i18nLabel(msg`Created by`),
description: i18nLabel(msg`The creator of the record`),
icon: 'IconCreativeCommonsSa',
isSystem: true,
isUIReadOnly: true,
isNullable: false,
defaultValue: {
source: "'MANUAL'",
name: "'System'",
workspaceMemberId: null,
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
updatedBy: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'updatedBy',
type: FieldMetadataType.ACTOR,
label: i18nLabel(msg`Updated by`),
description: i18nLabel(
msg`The workspace member who last updated the record`,
),
icon: 'IconUserCircle',
isSystem: true,
isUIReadOnly: true,
isNullable: false,
defaultValue: {
source: "'MANUAL'",
name: "'System'",
workspaceMemberId: null,
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
position: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'position',
type: FieldMetadataType.POSITION,
label: i18nLabel(msg`Position`),
description: i18nLabel(msg`Campaign record position`),
icon: 'IconHierarchy2',
isSystem: true,
isNullable: false,
defaultValue: 0,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
searchVector: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'searchVector',
type: FieldMetadataType.TS_VECTOR,
label: i18nLabel(msg`Search vector`),
description: i18nLabel(msg`Field used for full-text search`),
icon: 'IconSearch',
isSystem: true,
isNullable: true,
settings: {
generatedType: 'STORED',
asExpression: getTsVectorColumnExpressionFromFields(
SEARCH_FIELDS_FOR_MESSAGE_CAMPAIGN,
),
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
// Relation fields
messages: createStandardRelationFieldFlatMetadata({
objectName,
workspaceId,
context: {
type: FieldMetadataType.RELATION,
morphId: null,
fieldName: 'messages',
label: i18nLabel(msg`Recipients`),
description: i18nLabel(
msg`Per-recipient message rows produced by this campaign`,
),
icon: 'IconMail',
isNullable: true,
targetObjectName: 'message',
targetFieldName: 'sourceCampaign',
settings: {
relationType: RelationType.ONE_TO_MANY,
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
});
@@ -334,4 +334,134 @@ export const buildMessageStandardFlatFieldMetadatas = ({
twentyStandardApplicationId,
now,
}),
deliveryStatus: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'deliveryStatus',
type: FieldMetadataType.SELECT,
label: i18nLabel(msg`Delivery status`),
description: i18nLabel(
msg`Status of delivery for outbound messages (null for inbound).`,
),
icon: 'IconSend',
isNullable: true,
isUIReadOnly: true,
options: [
{
id: '20202020-d4e1-4001-9d01-d0d0d0d0d001',
value: 'QUEUED',
label: i18nLabel(msg`Queued`),
position: 0,
color: 'gray',
},
{
id: '20202020-d4e1-4001-9d01-d0d0d0d0d002',
value: 'SENT',
label: i18nLabel(msg`Sent`),
position: 1,
color: 'green',
},
{
id: '20202020-d4e1-4001-9d01-d0d0d0d0d003',
value: 'BOUNCED',
label: i18nLabel(msg`Bounced`),
position: 2,
color: 'orange',
},
{
id: '20202020-d4e1-4001-9d01-d0d0d0d0d004',
value: 'FAILED',
label: i18nLabel(msg`Failed`),
position: 3,
color: 'red',
},
],
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
providerMessageId: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'providerMessageId',
type: FieldMetadataType.TEXT,
label: i18nLabel(msg`Provider message ID`),
description: i18nLabel(
msg`Identifier returned by the email provider; used to correlate bounce/delivery webhooks.`,
),
icon: 'IconHash',
isNullable: true,
isUIReadOnly: true,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
sourceType: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'sourceType',
type: FieldMetadataType.SELECT,
label: i18nLabel(msg`Source`),
description: i18nLabel(
msg`What produced this message — null for synced inbox emails.`,
),
icon: 'IconRoute',
isNullable: true,
isUIReadOnly: true,
options: [
{
id: '20202020-d4e1-4003-9d03-d0d0d0d0d101',
value: 'CAMPAIGN',
label: i18nLabel(msg`Campaign`),
position: 0,
color: 'blue',
},
{
id: '20202020-d4e1-4003-9d03-d0d0d0d0d102',
value: 'WORKFLOW_RUN',
label: i18nLabel(msg`Workflow Run`),
position: 1,
color: 'purple',
},
],
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
sourceCampaign: createStandardRelationFieldFlatMetadata({
objectName,
workspaceId,
context: {
type: FieldMetadataType.RELATION,
morphId: null,
fieldName: 'sourceCampaign',
label: i18nLabel(msg`Source campaign`),
description: i18nLabel(
msg`The campaign that produced this outbound message.`,
),
icon: 'IconSend',
isNullable: true,
isUIReadOnly: true,
targetObjectName: 'messageCampaign',
targetFieldName: 'messages',
settings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: RelationOnDeleteAction.SET_NULL,
joinColumnName: 'sourceCampaignId',
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
});
@@ -366,6 +366,36 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = {
twentyStandardApplicationId,
now,
}),
messageCampaign: ({
now,
workspaceId,
standardObjectMetadataRelatedEntityIds,
twentyStandardApplicationId,
dependencyFlatEntityMaps,
}: Omit<
CreateStandardObjectArgs<'messageCampaign'>,
'context' | 'objectName'
>) =>
createStandardObjectFlatMetadata({
objectName: 'messageCampaign',
dependencyFlatEntityMaps,
context: {
universalIdentifier:
STANDARD_OBJECTS.messageCampaign.universalIdentifier,
nameSingular: 'messageCampaign',
namePlural: 'messageCampaigns',
labelSingular: i18nLabel(msg`Campaign`),
labelPlural: i18nLabel(msg`Campaigns`),
description: i18nLabel(msg`A bulk email campaign sent to many people`),
icon: 'IconSend',
isSearchable: true,
labelIdentifierFieldMetadataName: 'name',
},
workspaceId,
standardObjectMetadataRelatedEntityIds,
twentyStandardApplicationId,
now,
}),
note: ({
now,
workspaceId,
@@ -10,6 +10,7 @@ import { computeStandardCalendarEventParticipantViewFields } from 'src/engine/wo
import { computeStandardCalendarEventViewFields } from 'src/engine/workspace-manager/twenty-standard-application/utils/view-field/compute-standard-calendar-event-view-fields.util';
import { computeStandardCompanyViewFields } from 'src/engine/workspace-manager/twenty-standard-application/utils/view-field/compute-standard-company-view-fields.util';
import { computeStandardDashboardViewFields } from 'src/engine/workspace-manager/twenty-standard-application/utils/view-field/compute-standard-dashboard-view-fields.util';
import { computeStandardMessageCampaignViewFields } from 'src/engine/workspace-manager/twenty-standard-application/utils/view-field/compute-standard-message-campaign-view-fields.util';
import { computeStandardMessageChannelMessageAssociationMessageFolderViewFields } from 'src/engine/workspace-manager/twenty-standard-application/utils/view-field/compute-standard-message-channel-message-association-message-folder-view-fields.util';
import { computeStandardMessageChannelMessageAssociationViewFields } from 'src/engine/workspace-manager/twenty-standard-application/utils/view-field/compute-standard-message-channel-message-association-view-fields.util';
import { computeStandardMessageParticipantViewFields } from 'src/engine/workspace-manager/twenty-standard-application/utils/view-field/compute-standard-message-participant-view-fields.util';
@@ -43,6 +44,7 @@ const STANDARD_FLAT_VIEW_FIELD_METADATA_BUILDERS_BY_OBJECT_NAME = {
company: computeStandardCompanyViewFields,
dashboard: computeStandardDashboardViewFields,
message: computeStandardMessageViewFields,
messageCampaign: computeStandardMessageCampaignViewFields,
messageChannelMessageAssociation:
computeStandardMessageChannelMessageAssociationViewFields,
messageChannelMessageAssociationMessageFolder:
@@ -0,0 +1,96 @@
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import {
createStandardViewFieldFlatMetadata,
type CreateStandardViewFieldArgs,
} from 'src/engine/workspace-manager/twenty-standard-application/utils/view-field/create-standard-view-field-flat-metadata.util';
export const computeStandardMessageCampaignViewFields = (
args: Omit<CreateStandardViewFieldArgs<'messageCampaign'>, 'context'>,
): Record<string, FlatViewField> => {
return {
allCampaignsName: createStandardViewFieldFlatMetadata({
...args,
objectName: 'messageCampaign',
context: {
viewName: 'allCampaigns',
viewFieldName: 'name',
fieldName: 'name',
position: 0,
isVisible: true,
size: 240,
},
}),
allCampaignsStatus: createStandardViewFieldFlatMetadata({
...args,
objectName: 'messageCampaign',
context: {
viewName: 'allCampaigns',
viewFieldName: 'status',
fieldName: 'status',
position: 1,
isVisible: true,
size: 120,
},
}),
allCampaignsSubject: createStandardViewFieldFlatMetadata({
...args,
objectName: 'messageCampaign',
context: {
viewName: 'allCampaigns',
viewFieldName: 'subject',
fieldName: 'subject',
position: 2,
isVisible: true,
size: 280,
},
}),
allCampaignsSentCount: createStandardViewFieldFlatMetadata({
...args,
objectName: 'messageCampaign',
context: {
viewName: 'allCampaigns',
viewFieldName: 'sentCount',
fieldName: 'sentCount',
position: 3,
isVisible: true,
size: 100,
},
}),
allCampaignsBouncedCount: createStandardViewFieldFlatMetadata({
...args,
objectName: 'messageCampaign',
context: {
viewName: 'allCampaigns',
viewFieldName: 'bouncedCount',
fieldName: 'bouncedCount',
position: 4,
isVisible: true,
size: 120,
},
}),
allCampaignsSentAt: createStandardViewFieldFlatMetadata({
...args,
objectName: 'messageCampaign',
context: {
viewName: 'allCampaigns',
viewFieldName: 'sentAt',
fieldName: 'sentAt',
position: 5,
isVisible: true,
size: 160,
},
}),
allCampaignsCreatedBy: createStandardViewFieldFlatMetadata({
...args,
objectName: 'messageCampaign',
context: {
viewName: 'allCampaigns',
viewFieldName: 'createdBy',
fieldName: 'createdBy',
position: 6,
isVisible: true,
size: 150,
},
}),
};
};
@@ -10,6 +10,7 @@ import { computeStandardCalendarEventParticipantViews } from 'src/engine/workspa
import { computeStandardCalendarEventViews } from 'src/engine/workspace-manager/twenty-standard-application/utils/view/compute-standard-calendar-event-views.util';
import { computeStandardCompanyViews } from 'src/engine/workspace-manager/twenty-standard-application/utils/view/compute-standard-company-views.util';
import { computeStandardDashboardViews } from 'src/engine/workspace-manager/twenty-standard-application/utils/view/compute-standard-dashboard-views.util';
import { computeStandardMessageCampaignViews } from 'src/engine/workspace-manager/twenty-standard-application/utils/view/compute-standard-message-campaign-views.util';
import { computeStandardMessageChannelMessageAssociationMessageFolderViews } from 'src/engine/workspace-manager/twenty-standard-application/utils/view/compute-standard-message-channel-message-association-message-folder-views.util';
import { computeStandardMessageChannelMessageAssociationViews } from 'src/engine/workspace-manager/twenty-standard-application/utils/view/compute-standard-message-channel-message-association-views.util';
import { computeStandardMessageParticipantViews } from 'src/engine/workspace-manager/twenty-standard-application/utils/view/compute-standard-message-participant-views.util';
@@ -43,6 +44,7 @@ const STANDARD_FLAT_VIEW_METADATA_BUILDERS_BY_OBJECT_NAME = {
company: computeStandardCompanyViews,
dashboard: computeStandardDashboardViews,
message: computeStandardMessageViews,
messageCampaign: computeStandardMessageCampaignViews,
messageChannelMessageAssociation:
computeStandardMessageChannelMessageAssociationViews,
messageChannelMessageAssociationMessageFolder:
@@ -0,0 +1,26 @@
import { ViewType, ViewKey } from 'twenty-shared/types';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import {
createStandardViewFlatMetadata,
type CreateStandardViewArgs,
} from 'src/engine/workspace-manager/twenty-standard-application/utils/view/create-standard-view-flat-metadata.util';
export const computeStandardMessageCampaignViews = (
args: Omit<CreateStandardViewArgs<'messageCampaign'>, 'context'>,
): Record<string, FlatView> => {
return {
allCampaigns: createStandardViewFlatMetadata({
...args,
objectName: 'messageCampaign',
context: {
viewName: 'allCampaigns',
name: 'All {objectLabelPlural}',
type: ViewType.TABLE,
key: ViewKey.INDEX,
position: 0,
icon: 'IconSend',
},
}),
};
};
@@ -44,6 +44,7 @@ export class EmailAliasManagerService {
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.EMAIL_GROUP:
case ConnectedAccountProvider.WORKSPACE_TRANSACTIONAL:
case ConnectedAccountProvider.APP:
handleAliases = [];
break;
@@ -155,6 +155,7 @@ export class ConnectedAccountRefreshTokensService {
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.EMAIL_GROUP:
case ConnectedAccountProvider.WORKSPACE_TRANSACTIONAL:
return true;
default:
return assertUnreachable(
@@ -188,6 +189,7 @@ export class ConnectedAccountRefreshTokensService {
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.EMAIL_GROUP:
case ConnectedAccountProvider.WORKSPACE_TRANSACTIONAL:
throw new ConnectedAccountRefreshAccessTokenException(
`Token refresh is not supported for ${connectedAccount.provider} provider for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED,
@@ -29,6 +29,11 @@ const createMockMessage = (
deletedAt: null,
createdAt: '2024-03-20T09:00:00Z',
updatedAt: '2024-03-20T09:00:00Z',
deliveryStatus: null,
providerMessageId: null,
sourceType: null,
sourceCampaign: null,
sourceCampaignId: null,
});
describe('ApplyMessagesVisibilityRestrictionsService', () => {
@@ -0,0 +1,34 @@
import { type ActorMetadata, FieldMetadataType } from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
const NAME_FIELD_NAME = 'name';
const SUBJECT_FIELD_NAME = 'subject';
export const SEARCH_FIELDS_FOR_MESSAGE_CAMPAIGN: FieldTypeAndNameMetadata[] = [
{ name: NAME_FIELD_NAME, type: FieldMetadataType.TEXT },
{ name: SUBJECT_FIELD_NAME, type: FieldMetadataType.TEXT },
];
export class MessageCampaignWorkspaceEntity extends BaseWorkspaceEntity {
name: string;
subject: string | null;
bodyTemplate: string | null;
fromAddress: string | null;
replyTo: string | null;
status: string;
scheduledAt: Date | null;
sentAt: Date | null;
sentCount: number;
bouncedCount: number;
failedCount: number;
recipientSource: string;
position: number;
createdBy: ActorMetadata;
updatedBy: ActorMetadata;
messages: EntityRelation<MessageWorkspaceEntity[]>;
searchVector: string;
}
@@ -3,6 +3,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageCampaignWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-campaign.workspace-entity';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
import { type MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
@@ -24,4 +25,11 @@ export class MessageWorkspaceEntity extends BaseWorkspaceEntity {
messageChannelMessageAssociations: EntityRelation<
MessageChannelMessageAssociationWorkspaceEntity[]
>;
// Outbound delivery tracking — null for inbound/synced messages, populated
// when this row was sent through the workspace transactional channel.
deliveryStatus: string | null;
providerMessageId: string | null;
sourceType: string | null;
sourceCampaign: EntityRelation<MessageCampaignWorkspaceEntity> | null;
sourceCampaignId: string | null;
}
@@ -13,6 +13,12 @@ export type Message = Omit<
| 'messageThreadId'
| 'messageFolders'
| 'id'
// Outbound delivery fields aren't meaningful for synced inbound messages
| 'deliveryStatus'
| 'providerMessageId'
| 'sourceType'
| 'sourceCampaign'
| 'sourceCampaignId'
> & {
attachments: {
filename: string;
@@ -0,0 +1,55 @@
import { Field, InputType } from '@nestjs/graphql';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsEmail,
IsOptional,
IsString,
IsUUID,
Length,
} from 'class-validator';
export const MAX_CAMPAIGN_RECIPIENTS = 1000;
@InputType()
export class SendMessageCampaignInput {
@Field({ description: 'Internal campaign name (label).' })
@IsString()
@Length(1, 200)
name: string;
@Field({ description: 'Email subject line.' })
@IsString()
@Length(1, 998)
subject: string;
@Field({
description: 'Rich-text body template (BlockNote JSON or HTML string).',
})
@IsString()
bodyTemplate: string;
@Field({ description: 'From address — must belong to the emailing domain.' })
@IsEmail()
fromAddress: string;
@Field({ nullable: true, description: 'Reply-to address (optional).' })
@IsOptional()
@IsEmail()
replyTo?: string;
@Field({
description: 'ID of the verified EmailingDomain to send through.',
})
@IsUUID('4')
emailingDomainId: string;
@Field(() => [String], { description: 'Person IDs to send to.' })
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(MAX_CAMPAIGN_RECIPIENTS)
@IsUUID('4', { each: true })
recipientPersonIds: string[];
}
@@ -0,0 +1,18 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class SendMessageCampaignOutputDTO {
@Field()
campaignId: string;
@Field(() => Int, {
description: 'Number of recipients the campaign was queued for.',
})
queuedRecipientCount: number;
@Field(() => Int, {
description:
'Number of selected People with no usable email — these were skipped.',
})
skippedRecipientCount: number;
}
@@ -0,0 +1,49 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum MessagingCampaignExceptionCode {
EMAILING_DOMAIN_NOT_FOUND = 'EMAILING_DOMAIN_NOT_FOUND',
EMAILING_DOMAIN_NOT_VERIFIED = 'EMAILING_DOMAIN_NOT_VERIFIED',
FROM_ADDRESS_DOMAIN_MISMATCH = 'FROM_ADDRESS_DOMAIN_MISMATCH',
NO_RECIPIENTS_WITH_EMAIL = 'NO_RECIPIENTS_WITH_EMAIL',
CAMPAIGN_NOT_FOUND = 'CAMPAIGN_NOT_FOUND',
MESSAGE_NOT_FOUND = 'MESSAGE_NOT_FOUND',
}
const getMessagingCampaignExceptionUserFriendlyMessage = (
code: MessagingCampaignExceptionCode,
): MessageDescriptor => {
switch (code) {
case MessagingCampaignExceptionCode.EMAILING_DOMAIN_NOT_FOUND:
return msg`Emailing domain not found.`;
case MessagingCampaignExceptionCode.EMAILING_DOMAIN_NOT_VERIFIED:
return msg`Emailing domain has not been verified yet.`;
case MessagingCampaignExceptionCode.FROM_ADDRESS_DOMAIN_MISMATCH:
return msg`From address must match the verified emailing domain.`;
case MessagingCampaignExceptionCode.NO_RECIPIENTS_WITH_EMAIL:
return msg`None of the selected recipients have an email address.`;
case MessagingCampaignExceptionCode.CAMPAIGN_NOT_FOUND:
return msg`Campaign not found.`;
case MessagingCampaignExceptionCode.MESSAGE_NOT_FOUND:
return msg`Message not found.`;
default:
return assertUnreachable(code);
}
};
export class MessagingCampaignException extends CustomException<MessagingCampaignExceptionCode> {
constructor(
message: string,
code: MessagingCampaignExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getMessagingCampaignExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,209 @@
import { Logger, Scope } from '@nestjs/common';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type MessageCampaignWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-campaign.workspace-entity';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
export type MessagingCampaignSendRecipientJobData = {
workspaceId: string;
campaignId: string;
messageId: string;
emailingDomainId: string;
messageChannelId: string;
personId: string;
toAddress: string;
};
@Processor({
queueName: MessageQueue.emailQueue,
scope: Scope.REQUEST,
})
export class MessagingCampaignSendRecipientJob {
private readonly logger = new Logger(MessagingCampaignSendRecipientJob.name);
constructor(
private readonly emailingDomainService: EmailingDomainService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
@Process(MessagingCampaignSendRecipientJob.name)
async handle(data: MessagingCampaignSendRecipientJobData): Promise<void> {
const {
workspaceId,
campaignId,
messageId,
emailingDomainId,
toAddress,
} = data;
const messageRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageWorkspaceEntity>(
workspaceId,
'message',
{ shouldBypassPermissionChecks: true },
);
const campaignRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageCampaignWorkspaceEntity>(
workspaceId,
'messageCampaign',
{ shouldBypassPermissionChecks: true },
);
const message = await messageRepository.findOne({
where: { id: messageId },
});
if (!message) {
this.logger.warn(
`Campaign message ${messageId} not found in workspace ${workspaceId}; skipping send`,
);
return;
}
const campaign = await campaignRepository.findOne({
where: { id: campaignId },
});
if (!campaign) {
this.logger.warn(
`Campaign ${campaignId} not found in workspace ${workspaceId}; skipping send`,
);
return;
}
const html = campaign.bodyTemplate ?? '';
// SES requires a plain-text body alongside the HTML. The frontend's
// BlockNote editor produces HTML; we strip tags for the text fallback.
// Good enough for v1; a proper html-to-text pass can come later.
const text = stripHtmlTags(html);
try {
const result = await this.emailingDomainService.sendEmail(
workspaceId,
emailingDomainId,
{
from: campaign.fromAddress ?? '',
to: [toAddress],
subject: campaign.subject ?? '',
text,
html,
replyTo: campaign.replyTo ? [campaign.replyTo] : undefined,
},
);
await messageRepository.update(
{ id: messageId },
{
deliveryStatus: 'SENT',
providerMessageId: result.messageId,
},
);
await campaignRepository.increment(
{ id: campaignId },
'sentCount',
1,
);
} catch (error) {
this.logger.error(
`Campaign ${campaignId} send to ${toAddress} failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
await messageRepository.update(
{ id: messageId },
{
deliveryStatus: 'FAILED',
},
);
await campaignRepository.increment(
{ id: campaignId },
'failedCount',
1,
);
}
await this.maybeFinalizeCampaign({
workspaceId,
campaignId,
});
}
// Mark the campaign as SENT (or FAILED if 100% bounce/fail) once every
// recipient message has reached a terminal status. Cheap to call on every
// send; the query is indexed on sourceCampaignId.
private async maybeFinalizeCampaign({
workspaceId,
campaignId,
}: {
workspaceId: string;
campaignId: string;
}): Promise<void> {
const messageRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageWorkspaceEntity>(
workspaceId,
'message',
{ shouldBypassPermissionChecks: true },
);
const campaignRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageCampaignWorkspaceEntity>(
workspaceId,
'messageCampaign',
{ shouldBypassPermissionChecks: true },
);
const messages = await messageRepository.find({
where: { sourceCampaignId: campaignId },
select: ['id', 'deliveryStatus'],
});
const stillInFlight = messages.some(
(message) =>
message.deliveryStatus === 'QUEUED' ||
message.deliveryStatus === null,
);
if (stillInFlight) {
return;
}
const allFailed = messages.every(
(message) =>
message.deliveryStatus === 'FAILED' ||
message.deliveryStatus === 'BOUNCED',
);
await campaignRepository.update(
{ id: campaignId },
{
status: allFailed ? 'FAILED' : 'SENT',
sentAt: new Date(),
},
);
}
}
// Minimal HTML → text fallback for SES's required text body. Replaces block
// tags with newlines, strips remaining tags, decodes common entities,
// collapses whitespace. Not a full html-to-text — good enough for v1.
const stripHtmlTags = (html: string): string => {
return html
.replace(/<(p|div|br|li|h[1-6])[^>]*>/gi, '\n')
.replace(/<\/(p|div|li|h[1-6])>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/\n{3,}/g, '\n\n')
.trim();
};
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
import { MessageChannelMetadataModule } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.module';
import { MessagingCampaignSendRecipientJob } from 'src/modules/messaging/message-outbound-manager/jobs/messaging-campaign-send-recipient.job';
import { MessageCampaignResolver } from 'src/modules/messaging/message-outbound-manager/resolvers/message-campaign.resolver';
import { MessagingCampaignService } from 'src/modules/messaging/message-outbound-manager/services/messaging-campaign.service';
@Module({
imports: [
EmailingDomainModule,
MessageChannelMetadataModule,
// MessageQueueModule is @Global; the @InjectMessageQueue decorator works
// without an explicit import here.
TypeOrmModule.forFeature([EmailingDomainEntity]),
],
providers: [
MessageCampaignResolver,
MessagingCampaignService,
MessagingCampaignSendRecipientJob,
],
})
export class MessageCampaignModule {}
@@ -0,0 +1,51 @@
import {
Logger,
UseFilters,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SendMessageCampaignInput } from 'src/modules/messaging/message-outbound-manager/dtos/send-message-campaign.input';
import { SendMessageCampaignOutputDTO } from 'src/modules/messaging/message-outbound-manager/dtos/send-message-campaign.output';
import { MessagingCampaignService } from 'src/modules/messaging/message-outbound-manager/services/messaging-campaign.service';
@MetadataResolver()
@UsePipes(ResolverValidationPipe)
@UseFilters(AuthGraphqlApiExceptionFilter)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.SEND_EMAIL_TOOL),
)
export class MessageCampaignResolver {
private readonly logger = new Logger(MessageCampaignResolver.name);
constructor(
private readonly messagingCampaignService: MessagingCampaignService,
) {}
@Mutation(() => SendMessageCampaignOutputDTO)
async sendMessageCampaign(
@Args('input') input: SendMessageCampaignInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<SendMessageCampaignOutputDTO> {
const result = await this.messagingCampaignService.startCampaign({
workspaceId: workspace.id,
userWorkspaceId,
input,
});
return result;
}
}
@@ -0,0 +1,221 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { MessageChannelMetadataService } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type MessageCampaignWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-campaign.workspace-entity';
import { type MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
import { type PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import {
MessagingCampaignException,
MessagingCampaignExceptionCode,
} from 'src/modules/messaging/message-outbound-manager/exceptions/messaging-campaign.exception';
import { type SendMessageCampaignInput } from 'src/modules/messaging/message-outbound-manager/dtos/send-message-campaign.input';
import {
MessagingCampaignSendRecipientJob,
type MessagingCampaignSendRecipientJobData,
} from 'src/modules/messaging/message-outbound-manager/jobs/messaging-campaign-send-recipient.job';
type StartCampaignResult = {
campaignId: string;
queuedRecipientCount: number;
skippedRecipientCount: number;
};
@Injectable()
export class MessagingCampaignService {
private readonly logger = new Logger(MessagingCampaignService.name);
constructor(
@InjectRepository(EmailingDomainEntity)
private readonly emailingDomainRepository: Repository<EmailingDomainEntity>,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly messageChannelMetadataService: MessageChannelMetadataService,
@InjectMessageQueue(MessageQueue.emailQueue)
private readonly emailQueueService: MessageQueueService,
) {}
async startCampaign({
workspaceId,
userWorkspaceId,
input,
}: {
workspaceId: string;
userWorkspaceId: string;
input: SendMessageCampaignInput;
}): Promise<StartCampaignResult> {
const emailingDomain = await this.findVerifiedEmailingDomainOrThrow({
workspaceId,
emailingDomainId: input.emailingDomainId,
});
this.assertFromAddressMatchesDomain(
input.fromAddress,
emailingDomain.domain,
);
const channel =
await this.messageChannelMetadataService.findOrCreateWorkspaceTransactionalChannel(
{
workspaceId,
userWorkspaceId,
},
);
const personRepository =
await this.globalWorkspaceOrmManager.getRepository<PersonWorkspaceEntity>(
workspaceId,
'person',
{ shouldBypassPermissionChecks: true },
);
const people = await personRepository.find({
where: { id: In(input.recipientPersonIds) },
select: ['id', 'emails'],
});
const recipientsWithEmail = people
.map((person) => ({
personId: person.id,
email: person.emails?.primaryEmail ?? null,
}))
.filter(
(recipient): recipient is { personId: string; email: string } =>
recipient.email !== null && recipient.email.length > 0,
);
if (recipientsWithEmail.length === 0) {
throw new MessagingCampaignException(
'None of the selected recipients have a primary email address',
MessagingCampaignExceptionCode.NO_RECIPIENTS_WITH_EMAIL,
);
}
const skippedRecipientCount =
input.recipientPersonIds.length - recipientsWithEmail.length;
const campaignRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageCampaignWorkspaceEntity>(
workspaceId,
'messageCampaign',
);
const messageThreadRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageThreadWorkspaceEntity>(
workspaceId,
'messageThread',
{ shouldBypassPermissionChecks: true },
);
const messageRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageWorkspaceEntity>(
workspaceId,
'message',
{ shouldBypassPermissionChecks: true },
);
const campaign = await campaignRepository.save({
name: input.name,
subject: input.subject,
bodyTemplate: input.bodyTemplate,
fromAddress: input.fromAddress,
replyTo: input.replyTo ?? null,
status: 'SENDING',
sentCount: 0,
bouncedCount: 0,
failedCount: 0,
recipientSource: 'RECORD_SELECTION',
});
// One thread + one message per recipient. Lets replies (if reply-to is a
// synced mailbox) thread naturally, and avoids cross-recipient leakage.
const messageIds: string[] = [];
for (const recipient of recipientsWithEmail) {
const thread = await messageThreadRepository.save({});
const message = await messageRepository.save({
subject: input.subject,
text: input.bodyTemplate,
messageThreadId: thread.id,
deliveryStatus: 'QUEUED',
sourceType: 'CAMPAIGN',
sourceCampaignId: campaign.id,
});
messageIds.push(message.id);
await this.emailQueueService.add<MessagingCampaignSendRecipientJobData>(
MessagingCampaignSendRecipientJob.name,
{
workspaceId,
campaignId: campaign.id,
messageId: message.id,
emailingDomainId: emailingDomain.id,
messageChannelId: channel.id,
personId: recipient.personId,
toAddress: recipient.email,
},
);
}
this.logger.log(
`Campaign ${campaign.id} queued ${messageIds.length} recipients (skipped ${skippedRecipientCount})`,
);
return {
campaignId: campaign.id,
queuedRecipientCount: messageIds.length,
skippedRecipientCount,
};
}
private async findVerifiedEmailingDomainOrThrow({
workspaceId,
emailingDomainId,
}: {
workspaceId: string;
emailingDomainId: string;
}): Promise<EmailingDomainEntity> {
const emailingDomain = await this.emailingDomainRepository.findOne({
where: { id: emailingDomainId, workspaceId },
});
if (!emailingDomain) {
throw new MessagingCampaignException(
`Emailing domain ${emailingDomainId} not found`,
MessagingCampaignExceptionCode.EMAILING_DOMAIN_NOT_FOUND,
);
}
if (emailingDomain.status !== EmailingDomainStatus.VERIFIED) {
throw new MessagingCampaignException(
`Emailing domain ${emailingDomain.domain} is not verified (status: ${emailingDomain.status})`,
MessagingCampaignExceptionCode.EMAILING_DOMAIN_NOT_VERIFIED,
);
}
return emailingDomain;
}
private assertFromAddressMatchesDomain(
fromAddress: string,
domain: string,
): void {
const addressDomain = fromAddress.split('@')[1]?.toLowerCase();
if (addressDomain !== domain.toLowerCase()) {
throw new MessagingCampaignException(
`From address ${fromAddress} does not match verified domain ${domain}`,
MessagingCampaignExceptionCode.FROM_ADDRESS_DOMAIN_MISMATCH,
);
}
}
}
@@ -48,6 +48,7 @@ export class MessagingMessageOutboundService {
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.APP:
case ConnectedAccountProvider.WORKSPACE_TRANSACTIONAL:
throw new Error(
`Provider ${connectedAccount.provider} does not support sending messages`,
);
@@ -83,6 +84,7 @@ export class MessagingMessageOutboundService {
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.APP:
case ConnectedAccountProvider.WORKSPACE_TRANSACTIONAL:
throw new Error(
`Provider ${connectedAccount.provider} does not support creating drafts`,
);
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { MessagingBlocklistManagerModule } from 'src/modules/messaging/blocklist-manager/messaging-blocklist-manager.module';
import { MessagingMessageCleanerModule } from 'src/modules/messaging/message-cleaner/messaging-message-cleaner.module';
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
import { MessageCampaignModule } from 'src/modules/messaging/message-outbound-manager/message-campaign.module';
import { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module';
import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/messaging-monitoring.module';
@@ -10,6 +11,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
imports: [
MessagingImportManagerModule,
MessagingMessageCleanerModule,
MessageCampaignModule,
MessageParticipantManagerModule,
MessagingBlocklistManagerModule,
MessagingMonitoringModule,
@@ -1137,6 +1137,105 @@ export const STANDARD_OBJECTS = {
},
},
},
messageCampaign: {
universalIdentifier: '2ea0a595-f426-4672-8495-ade5cde19e38',
fields: {
id: { universalIdentifier: '86410c97-8f91-43cb-a96e-aa709d97efe4' },
createdAt: {
universalIdentifier: '51bac4fb-7d1b-4b78-9058-d1dfbc40ef3f',
},
updatedAt: {
universalIdentifier: 'b7aad27b-8281-4bc4-83fe-6513964ced43',
},
deletedAt: {
universalIdentifier: '87fa63e1-6d2c-453f-a896-f0403f49bd9c',
},
name: {
universalIdentifier: 'cf6eb1bf-b40d-4bca-82bd-6587b9c64040',
},
subject: {
universalIdentifier: 'a7ade487-de58-4d28-864e-1e5d547d07a6',
},
bodyTemplate: {
universalIdentifier: '09713db2-3233-4902-b553-475a1700e5c1',
},
fromAddress: {
universalIdentifier: '5485a0dd-f666-4bea-8c03-356b2a3a1b47',
},
replyTo: {
universalIdentifier: '0d514e60-1ec9-47c6-83b6-a974874baa10',
},
status: {
universalIdentifier: 'fa72a8b4-d204-4634-a0b0-dffa10d0b2ff',
},
scheduledAt: {
universalIdentifier: '984acbd1-21fc-4387-aebc-8d480724a9bd',
},
sentAt: {
universalIdentifier: '4a1ba5e3-d2c7-4750-b0fe-94e8d9f368ff',
},
sentCount: {
universalIdentifier: 'f44a575f-a4f2-4149-8b96-a474c6c6a3ae',
},
bouncedCount: {
universalIdentifier: '9c04f54d-82e0-42b3-8ecb-ae6752f00b3e',
},
failedCount: {
universalIdentifier: '0ff620aa-702b-4dfd-80c1-f33672dd68b7',
},
recipientSource: {
universalIdentifier: '39f19f8e-d35a-40fb-9004-15076eb4999d',
},
createdBy: {
universalIdentifier: 'ca40d8bf-39cd-48ad-8cd6-0451893aa6d1',
},
updatedBy: {
universalIdentifier: 'd6eb8c1b-4628-4bc5-bfb6-b09a8c5fcb42',
},
position: {
universalIdentifier: 'b0f2088a-8df0-46f7-9f6e-340002c37e3d',
},
searchVector: {
universalIdentifier: 'c255199d-4ac6-40c1-bf85-d7f385befed4',
},
messages: {
universalIdentifier: 'c1ebad47-224b-4f34-b710-7ee78d191780',
},
},
indexes: {
searchVectorGinIndex: {
universalIdentifier: 'a3f37119-6790-4720-a062-3467543a3e37',
},
},
views: {
allCampaigns: {
universalIdentifier: '2a2a63fc-dccb-4cef-b80d-530a715c0b24',
viewFields: {
name: {
universalIdentifier: '069cb01f-d937-45c0-aaf9-1653918df54e',
},
status: {
universalIdentifier: '9742578f-38a0-4129-a769-3fba98a90a29',
},
subject: {
universalIdentifier: 'cef060d7-bfd2-4bf7-9e42-cbe021375ace',
},
sentCount: {
universalIdentifier: 'a68d4285-7271-4bb2-a76d-ed4b85d3d52b',
},
bouncedCount: {
universalIdentifier: 'e9197ee8-a3b5-4222-a27e-4481c68f3a5f',
},
sentAt: {
universalIdentifier: '080f3510-9e0d-40d6-97c0-17cfbee0c70b',
},
createdBy: {
universalIdentifier: 'f836b7db-3848-44ae-ae77-b7738bcdde31',
},
},
},
},
},
message: {
universalIdentifier: '20202020-3f6b-4425-80ab-e468899ab4b2',
fields: {
@@ -1179,6 +1278,18 @@ export const STANDARD_OBJECTS = {
searchVector: {
universalIdentifier: '529b6008-4a12-4d48-bbc3-26a3f199bafd',
},
deliveryStatus: {
universalIdentifier: 'b626a3f0-fd37-4e2e-b720-cd889fb82c88',
},
providerMessageId: {
universalIdentifier: '15d2c367-6200-4143-996f-4877efe77a6a',
},
sourceType: {
universalIdentifier: 'befa3d30-e27b-4771-9ca0-0bce163a891c',
},
sourceCampaign: {
universalIdentifier: '8bafc415-ae6d-4b91-abde-1b29ee3261df',
},
},
indexes: {
messageThreadIdIndex: {
@@ -5,5 +5,6 @@ export enum ConnectedAccountProvider {
OIDC = 'oidc',
SAML = 'saml',
EMAIL_GROUP = 'email_group',
WORKSPACE_TRANSACTIONAL = 'workspace_transactional',
APP = 'app',
}
@@ -7,4 +7,5 @@ export enum MessageChannelSyncStage {
MESSAGES_IMPORT_SCHEDULED = 'MESSAGES_IMPORT_SCHEDULED',
MESSAGES_IMPORT_ONGOING = 'MESSAGES_IMPORT_ONGOING',
FAILED = 'FAILED',
NOT_APPLICABLE = 'NOT_APPLICABLE',
}
@@ -2,4 +2,5 @@ export enum MessageChannelType {
EMAIL = 'EMAIL',
SMS = 'SMS',
EMAIL_GROUP = 'EMAIL_GROUP',
WORKSPACE_TRANSACTIONAL = 'WORKSPACE_TRANSACTIONAL',
}
@@ -28,4 +28,5 @@ export enum SidePanelPages {
CommandMenuEdit = 'command-menu-edit',
PageLayoutRecordPageWidgetTypeSelect = 'page-layout-record-page-widget-type-select',
ComposeEmail = 'compose-email',
ComposeCampaign = 'compose-campaign',
}