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:
@@ -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:
|
||||
|
||||
+11
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const FIND_VERIFIED_EMAILING_DOMAINS = gql`
|
||||
query FindVerifiedEmailingDomains {
|
||||
getEmailingDomains {
|
||||
id
|
||||
domain
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
+68
@@ -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 };
|
||||
};
|
||||
+25
@@ -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 };
|
||||
};
|
||||
+2
@@ -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 />,
|
||||
|
||||
+50
@@ -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 />],
|
||||
],
|
||||
);
|
||||
|
||||
+51
@@ -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 };
|
||||
};
|
||||
+268
@@ -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>
|
||||
);
|
||||
};
|
||||
+9
@@ -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,
|
||||
});
|
||||
+9
@@ -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,
|
||||
|
||||
+35
@@ -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"',
|
||||
);
|
||||
}
|
||||
}
|
||||
+39
@@ -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"',
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -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:
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+53
@@ -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,
|
||||
|
||||
+18
@@ -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',
|
||||
|
||||
+9
-1
@@ -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',
|
||||
|
||||
+2
@@ -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:
|
||||
|
||||
+479
@@ -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,
|
||||
}),
|
||||
});
|
||||
+130
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
+30
@@ -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,
|
||||
|
||||
+2
@@ -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:
|
||||
|
||||
+96
@@ -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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
+2
@@ -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:
|
||||
|
||||
+26
@@ -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',
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
+1
@@ -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;
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+5
@@ -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', () => {
|
||||
|
||||
+34
@@ -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;
|
||||
}
|
||||
+8
@@ -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;
|
||||
|
||||
+55
@@ -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[];
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+49
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
+209
@@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
};
|
||||
+25
@@ -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 {}
|
||||
+51
@@ -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;
|
||||
}
|
||||
}
|
||||
+221
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user