Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ce47f3e7e | |||
| 53cff75465 | |||
| c54ca5c608 | |||
| 2ba147b631 | |||
| 4439029782 | |||
| 70de1d929d | |||
| 430f3cb7c6 | |||
| 4d17b1179f | |||
| eb213069d2 | |||
| 5732c1de2a | |||
| 3d491ed370 | |||
| 641df0a271 | |||
| fcab418fba | |||
| 702e02e590 | |||
| 74e12df09c | |||
| 3980094e02 | |||
| 295d924db8 | |||
| 39837b9f0f | |||
| d94d69e6a7 | |||
| 93b87a5362 | |||
| 925bb9596c | |||
| 9a1735c4e3 | |||
| 18dc6fb313 | |||
| 30154aec47 | |||
| b184f5c25f | |||
| 287c9c8a67 | |||
| bcf633986f | |||
| 0accf11b39 | |||
| b62c941595 | |||
| 07b660e2d7 | |||
| af6c31470a | |||
| 77755da8fc | |||
| b9cc5589b8 | |||
| 59ef653626 |
@@ -398,6 +398,7 @@ enum EngineComponentKey {
|
||||
FRONT_COMPONENT_RENDERER
|
||||
REPLY_TO_EMAIL_THREAD
|
||||
COMPOSE_EMAIL
|
||||
COMPOSE_CAMPAIGN
|
||||
GO_TO_PEOPLE
|
||||
GO_TO_COMPANIES
|
||||
GO_TO_DASHBOARDS
|
||||
@@ -1463,6 +1464,12 @@ enum EmailingDomainStatus {
|
||||
TEMPORARY_FAILURE
|
||||
}
|
||||
|
||||
type SendMessageCampaignOutputDTO {
|
||||
campaignId: String!
|
||||
sentCount: Int!
|
||||
failedCount: Int!
|
||||
}
|
||||
|
||||
type SendEmailViaDomainOutput {
|
||||
messageId: String!
|
||||
}
|
||||
@@ -3230,6 +3237,7 @@ type Mutation {
|
||||
deleteEmailingDomain(id: String!): Boolean!
|
||||
verifyEmailingDomain(id: String!): EmailingDomain!
|
||||
sendEmailViaEmailingDomain(input: SendEmailViaDomainInput!): SendEmailViaDomainOutput!
|
||||
sendMessageCampaign(input: SendMessageCampaignInput!): SendMessageCampaignOutputDTO!
|
||||
updateOneApplicationVariable(key: String!, value: String!, applicationId: UUID!): Boolean!
|
||||
createPageLayoutWidget(input: CreatePageLayoutWidgetInput!): PageLayoutWidget!
|
||||
updatePageLayoutWidget(id: String!, input: UpdatePageLayoutWidgetInput!): PageLayoutWidget!
|
||||
@@ -3804,6 +3812,15 @@ input SendEmailViaDomainInput {
|
||||
replyTo: [String!]
|
||||
}
|
||||
|
||||
input SendMessageCampaignInput {
|
||||
messageTopicId: String!
|
||||
recipientViewId: String
|
||||
listId: String
|
||||
subject: String!
|
||||
body: String!
|
||||
fromAddress: String!
|
||||
}
|
||||
|
||||
input CreatePageLayoutWidgetInput {
|
||||
pageLayoutTabId: UUID!
|
||||
title: String!
|
||||
|
||||
@@ -304,7 +304,7 @@ export interface CommandMenuItem {
|
||||
__typename: 'CommandMenuItem'
|
||||
}
|
||||
|
||||
export type EngineComponentKey = 'NAVIGATE_TO_NEXT_RECORD' | 'NAVIGATE_TO_PREVIOUS_RECORD' | 'CREATE_NEW_RECORD' | 'DELETE_RECORDS' | 'RESTORE_RECORDS' | 'DESTROY_RECORDS' | 'ADD_TO_FAVORITES' | 'REMOVE_FROM_FAVORITES' | 'EXPORT_NOTE_TO_PDF' | 'EXPORT_RECORDS' | 'UPDATE_MULTIPLE_RECORDS' | 'MERGE_MULTIPLE_RECORDS' | 'IMPORT_RECORDS' | 'EXPORT_VIEW' | 'SEE_DELETED_RECORDS' | 'CREATE_NEW_VIEW' | 'HIDE_DELETED_RECORDS' | 'EDIT_RECORD_PAGE_LAYOUT' | 'EDIT_DASHBOARD_LAYOUT' | 'SAVE_DASHBOARD_LAYOUT' | 'CANCEL_DASHBOARD_LAYOUT' | 'DUPLICATE_DASHBOARD' | 'ACTIVATE_WORKFLOW' | 'DEACTIVATE_WORKFLOW' | 'DISCARD_DRAFT_WORKFLOW' | 'TEST_WORKFLOW' | 'SEE_ACTIVE_VERSION_WORKFLOW' | 'SEE_RUNS_WORKFLOW' | 'SEE_VERSIONS_WORKFLOW' | 'ADD_NODE_WORKFLOW' | 'TIDY_UP_WORKFLOW' | 'DUPLICATE_WORKFLOW' | 'SEE_VERSION_WORKFLOW_RUN' | 'SEE_WORKFLOW_WORKFLOW_RUN' | 'STOP_WORKFLOW_RUN' | 'SEE_RUNS_WORKFLOW_VERSION' | 'SEE_WORKFLOW_WORKFLOW_VERSION' | 'USE_AS_DRAFT_WORKFLOW_VERSION' | 'SEE_VERSIONS_WORKFLOW_VERSION' | 'SEARCH_RECORDS' | 'SEARCH_RECORDS_FALLBACK' | 'ASK_AI' | 'VIEW_PREVIOUS_AI_CHATS' | 'NAVIGATION' | 'TRIGGER_WORKFLOW_VERSION' | 'FRONT_COMPONENT_RENDERER' | 'REPLY_TO_EMAIL_THREAD' | 'COMPOSE_EMAIL' | 'GO_TO_PEOPLE' | 'GO_TO_COMPANIES' | 'GO_TO_DASHBOARDS' | 'GO_TO_OPPORTUNITIES' | 'GO_TO_SETTINGS' | 'GO_TO_TASKS' | 'GO_TO_NOTES' | 'GO_TO_WORKFLOWS' | 'GO_TO_RUNS' | 'DELETE_SINGLE_RECORD' | 'DELETE_MULTIPLE_RECORDS' | 'RESTORE_SINGLE_RECORD' | 'RESTORE_MULTIPLE_RECORDS' | 'DESTROY_SINGLE_RECORD' | 'DESTROY_MULTIPLE_RECORDS' | 'EXPORT_FROM_RECORD_INDEX' | 'EXPORT_FROM_RECORD_SHOW' | 'EXPORT_MULTIPLE_RECORDS'
|
||||
export type EngineComponentKey = 'NAVIGATE_TO_NEXT_RECORD' | 'NAVIGATE_TO_PREVIOUS_RECORD' | 'CREATE_NEW_RECORD' | 'DELETE_RECORDS' | 'RESTORE_RECORDS' | 'DESTROY_RECORDS' | 'ADD_TO_FAVORITES' | 'REMOVE_FROM_FAVORITES' | 'EXPORT_NOTE_TO_PDF' | 'EXPORT_RECORDS' | 'UPDATE_MULTIPLE_RECORDS' | 'MERGE_MULTIPLE_RECORDS' | 'IMPORT_RECORDS' | 'EXPORT_VIEW' | 'SEE_DELETED_RECORDS' | 'CREATE_NEW_VIEW' | 'HIDE_DELETED_RECORDS' | 'EDIT_RECORD_PAGE_LAYOUT' | 'EDIT_DASHBOARD_LAYOUT' | 'SAVE_DASHBOARD_LAYOUT' | 'CANCEL_DASHBOARD_LAYOUT' | 'DUPLICATE_DASHBOARD' | 'ACTIVATE_WORKFLOW' | 'DEACTIVATE_WORKFLOW' | 'DISCARD_DRAFT_WORKFLOW' | 'TEST_WORKFLOW' | 'SEE_ACTIVE_VERSION_WORKFLOW' | 'SEE_RUNS_WORKFLOW' | 'SEE_VERSIONS_WORKFLOW' | 'ADD_NODE_WORKFLOW' | 'TIDY_UP_WORKFLOW' | 'DUPLICATE_WORKFLOW' | 'SEE_VERSION_WORKFLOW_RUN' | 'SEE_WORKFLOW_WORKFLOW_RUN' | 'STOP_WORKFLOW_RUN' | 'SEE_RUNS_WORKFLOW_VERSION' | 'SEE_WORKFLOW_WORKFLOW_VERSION' | 'USE_AS_DRAFT_WORKFLOW_VERSION' | 'SEE_VERSIONS_WORKFLOW_VERSION' | 'SEARCH_RECORDS' | 'SEARCH_RECORDS_FALLBACK' | 'ASK_AI' | 'VIEW_PREVIOUS_AI_CHATS' | 'NAVIGATION' | 'TRIGGER_WORKFLOW_VERSION' | 'FRONT_COMPONENT_RENDERER' | 'REPLY_TO_EMAIL_THREAD' | 'COMPOSE_EMAIL' | 'COMPOSE_CAMPAIGN' | 'GO_TO_PEOPLE' | 'GO_TO_COMPANIES' | 'GO_TO_DASHBOARDS' | 'GO_TO_OPPORTUNITIES' | 'GO_TO_SETTINGS' | 'GO_TO_TASKS' | 'GO_TO_NOTES' | 'GO_TO_WORKFLOWS' | 'GO_TO_RUNS' | 'DELETE_SINGLE_RECORD' | 'DELETE_MULTIPLE_RECORDS' | 'RESTORE_SINGLE_RECORD' | 'RESTORE_MULTIPLE_RECORDS' | 'DESTROY_SINGLE_RECORD' | 'DESTROY_MULTIPLE_RECORDS' | 'EXPORT_FROM_RECORD_INDEX' | 'EXPORT_FROM_RECORD_SHOW' | 'EXPORT_MULTIPLE_RECORDS'
|
||||
|
||||
export type CommandMenuItemAvailabilityType = 'GLOBAL' | 'GLOBAL_OBJECT_CONTEXT' | 'RECORD_SELECTION' | 'FALLBACK'
|
||||
|
||||
@@ -1115,6 +1115,13 @@ export type EmailingDomainDriver = 'AWS_SES'
|
||||
|
||||
export type EmailingDomainStatus = 'PENDING' | 'VERIFIED' | 'FAILED' | 'TEMPORARY_FAILURE'
|
||||
|
||||
export interface SendMessageCampaignOutputDTO {
|
||||
campaignId: Scalars['String']
|
||||
sentCount: Scalars['Int']
|
||||
failedCount: Scalars['Int']
|
||||
__typename: 'SendMessageCampaignOutputDTO'
|
||||
}
|
||||
|
||||
export interface SendEmailViaDomainOutput {
|
||||
messageId: Scalars['String']
|
||||
__typename: 'SendEmailViaDomainOutput'
|
||||
@@ -2754,6 +2761,7 @@ export interface Mutation {
|
||||
deleteEmailingDomain: Scalars['Boolean']
|
||||
verifyEmailingDomain: EmailingDomain
|
||||
sendEmailViaEmailingDomain: SendEmailViaDomainOutput
|
||||
sendMessageCampaign: SendMessageCampaignOutputDTO
|
||||
updateOneApplicationVariable: Scalars['Boolean']
|
||||
createPageLayoutWidget: PageLayoutWidget
|
||||
updatePageLayoutWidget: PageLayoutWidget
|
||||
@@ -4072,6 +4080,14 @@ export interface EmailingDomainGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface SendMessageCampaignOutputDTOGenqlSelection{
|
||||
campaignId?: boolean | number
|
||||
sentCount?: boolean | number
|
||||
failedCount?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface SendEmailViaDomainOutputGenqlSelection{
|
||||
messageId?: boolean | number
|
||||
__typename?: boolean | number
|
||||
@@ -5842,6 +5858,7 @@ export interface MutationGenqlSelection{
|
||||
deleteEmailingDomain?: { __args: {id: Scalars['String']} }
|
||||
verifyEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {id: Scalars['String']} })
|
||||
sendEmailViaEmailingDomain?: (SendEmailViaDomainOutputGenqlSelection & { __args: {input: SendEmailViaDomainInput} })
|
||||
sendMessageCampaign?: (SendMessageCampaignOutputDTOGenqlSelection & { __args: {input: SendMessageCampaignInput} })
|
||||
updateOneApplicationVariable?: { __args: {key: Scalars['String'], value: Scalars['String'], applicationId: Scalars['UUID']} }
|
||||
createPageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {input: CreatePageLayoutWidgetInput} })
|
||||
updatePageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String'], input: UpdatePageLayoutWidgetInput} })
|
||||
@@ -6145,6 +6162,8 @@ export interface GridPositionInput {row: Scalars['Float'],column: Scalars['Float
|
||||
|
||||
export interface SendEmailViaDomainInput {emailingDomainId: Scalars['String'],to: Scalars['String'][],cc?: (Scalars['String'][] | null),bcc?: (Scalars['String'][] | null),subject: Scalars['String'],text: Scalars['String'],html?: (Scalars['String'] | null),from: Scalars['String'],replyTo?: (Scalars['String'][] | null)}
|
||||
|
||||
export interface SendMessageCampaignInput {messageTopicId: Scalars['String'],recipientViewId?: (Scalars['String'] | null),listId?: (Scalars['String'] | null),subject: Scalars['String'],body: Scalars['String'],fromAddress: Scalars['String']}
|
||||
|
||||
export interface CreatePageLayoutWidgetInput {pageLayoutTabId: Scalars['UUID'],title: Scalars['String'],type: WidgetType,objectMetadataId?: (Scalars['UUID'] | null),gridPosition: GridPositionInput,position?: (Scalars['JSON'] | null),configuration: Scalars['JSON']}
|
||||
|
||||
export interface UpdatePageLayoutWidgetInput {pageLayoutTabId?: (Scalars['UUID'] | null),title?: (Scalars['String'] | null),type?: (WidgetType | null),objectMetadataId?: (Scalars['UUID'] | null),gridPosition?: (GridPositionInput | null),position?: (Scalars['JSON'] | null),configuration?: (Scalars['JSON'] | null),conditionalDisplay?: (Scalars['JSON'] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null)}
|
||||
@@ -7021,6 +7040,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const SendMessageCampaignOutputDTO_possibleTypes: string[] = ['SendMessageCampaignOutputDTO']
|
||||
export const isSendMessageCampaignOutputDTO = (obj?: { __typename?: any } | null): obj is SendMessageCampaignOutputDTO => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isSendMessageCampaignOutputDTO"')
|
||||
return SendMessageCampaignOutputDTO_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const SendEmailViaDomainOutput_possibleTypes: string[] = ['SendEmailViaDomainOutput']
|
||||
export const isSendEmailViaDomainOutput = (obj?: { __typename?: any } | null): obj is SendEmailViaDomainOutput => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isSendEmailViaDomainOutput"')
|
||||
@@ -8469,6 +8496,7 @@ export const enumEngineComponentKey = {
|
||||
FRONT_COMPONENT_RENDERER: 'FRONT_COMPONENT_RENDERER' as const,
|
||||
REPLY_TO_EMAIL_THREAD: 'REPLY_TO_EMAIL_THREAD' as const,
|
||||
COMPOSE_EMAIL: 'COMPOSE_EMAIL' as const,
|
||||
COMPOSE_CAMPAIGN: 'COMPOSE_CAMPAIGN' as const,
|
||||
GO_TO_PEOPLE: 'GO_TO_PEOPLE' as const,
|
||||
GO_TO_COMPANIES: 'GO_TO_COMPANIES' as const,
|
||||
GO_TO_DASHBOARDS: 'GO_TO_DASHBOARDS' as const,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+77
@@ -0,0 +1,77 @@
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { type useCampaignComposerState } from '@/activities/emails/hooks/useCampaignComposerState';
|
||||
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
|
||||
import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
|
||||
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
|
||||
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { type SelectOption } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledFieldsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
padding: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
type CampaignComposerFieldsProps = {
|
||||
campaignState: ReturnType<typeof useCampaignComposerState>;
|
||||
};
|
||||
|
||||
export const CampaignComposerFields = ({
|
||||
campaignState,
|
||||
}: CampaignComposerFieldsProps) => {
|
||||
const { data: accountsData } = useQuery<{
|
||||
myConnectedAccounts: { id: string; handle: string }[];
|
||||
}>(GET_MY_CONNECTED_ACCOUNTS);
|
||||
|
||||
const accountOptions: SelectOption<string>[] =
|
||||
accountsData?.myConnectedAccounts?.map((account) => ({
|
||||
label: account.handle,
|
||||
value: account.handle,
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<StyledFieldsContainer>
|
||||
<Select
|
||||
dropdownId="campaign-composer-from-account"
|
||||
label={t`From`}
|
||||
fullWidth
|
||||
value={campaignState.fromAddress}
|
||||
options={accountOptions}
|
||||
emptyOption={{ label: t`Select a sender`, value: '' }}
|
||||
onChange={campaignState.setFromAddress}
|
||||
/>
|
||||
<FormSingleRecordPicker
|
||||
label={t`To`}
|
||||
objectNameSingulars={['messageList']}
|
||||
defaultValue={campaignState.listId}
|
||||
onChange={campaignState.setListId}
|
||||
/>
|
||||
<FormSingleRecordPicker
|
||||
label={t`Topic`}
|
||||
objectNameSingulars={['messageTopic']}
|
||||
defaultValue={campaignState.messageTopicId}
|
||||
onChange={campaignState.setMessageTopicId}
|
||||
/>
|
||||
<FormTextFieldInput
|
||||
label={t`Subject`}
|
||||
defaultValue=""
|
||||
onChange={campaignState.setSubject}
|
||||
placeholder={t`Subject`}
|
||||
/>
|
||||
<FormAdvancedTextFieldInput
|
||||
defaultValue=""
|
||||
onChange={campaignState.setBody}
|
||||
placeholder={t`Type something or press "/" to see commands`}
|
||||
minHeight={120}
|
||||
maxWidth={600}
|
||||
contentType="html"
|
||||
/>
|
||||
</StyledFieldsContainer>
|
||||
);
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const SEND_MESSAGE_CAMPAIGN = gql`
|
||||
mutation SendMessageCampaign($input: SendMessageCampaignInput!) {
|
||||
sendMessageCampaign(input: $input) {
|
||||
campaignId
|
||||
queuedCount
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useSendMessageCampaign } from '@/activities/emails/hooks/useSendMessageCampaign';
|
||||
|
||||
type UseCampaignComposerStateArgs = {
|
||||
defaultSubject?: string;
|
||||
onSent?: () => void;
|
||||
};
|
||||
|
||||
export const useCampaignComposerState = ({
|
||||
defaultSubject = '',
|
||||
onSent,
|
||||
}: UseCampaignComposerStateArgs) => {
|
||||
const [messageTopicId, setMessageTopicId] = useState<string | null>(null);
|
||||
const [listId, setListId] = useState<string | null>(null);
|
||||
const [fromAddress, setFromAddress] = useState('');
|
||||
const [subject, setSubject] = useState(defaultSubject);
|
||||
const [body, setBody] = useState('');
|
||||
|
||||
const { sendMessageCampaign, loading } = useSendMessageCampaign();
|
||||
|
||||
const canSend =
|
||||
messageTopicId !== null &&
|
||||
fromAddress.trim().length > 0 &&
|
||||
subject.trim().length > 0 &&
|
||||
!loading;
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (
|
||||
messageTopicId === null ||
|
||||
fromAddress.trim().length === 0 ||
|
||||
subject.trim().length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await sendMessageCampaign({
|
||||
messageTopicId,
|
||||
listId: listId ?? undefined,
|
||||
subject,
|
||||
body,
|
||||
fromAddress: fromAddress.trim(),
|
||||
});
|
||||
|
||||
if (success) {
|
||||
onSent?.();
|
||||
}
|
||||
}, [
|
||||
messageTopicId,
|
||||
listId,
|
||||
fromAddress,
|
||||
subject,
|
||||
body,
|
||||
sendMessageCampaign,
|
||||
onSent,
|
||||
]);
|
||||
|
||||
return {
|
||||
messageTopicId,
|
||||
setMessageTopicId,
|
||||
listId,
|
||||
setListId,
|
||||
fromAddress,
|
||||
setFromAddress,
|
||||
subject,
|
||||
setSubject,
|
||||
body,
|
||||
setBody,
|
||||
handleSend,
|
||||
canSend,
|
||||
loading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { SEND_MESSAGE_CAMPAIGN } from '@/activities/emails/graphql/mutations/sendMessageCampaign';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
type SendMessageCampaignMutation,
|
||||
type SendMessageCampaignMutationVariables,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type SendMessageCampaignParams = {
|
||||
messageTopicId: string;
|
||||
listId?: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
fromAddress: string;
|
||||
};
|
||||
|
||||
export const useSendMessageCampaign = () => {
|
||||
const [sendMessageCampaignMutation, { loading }] = useMutation<
|
||||
SendMessageCampaignMutation,
|
||||
SendMessageCampaignMutationVariables
|
||||
>(SEND_MESSAGE_CAMPAIGN);
|
||||
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const sendMessageCampaign = useCallback(
|
||||
async (params: SendMessageCampaignParams): Promise<boolean> => {
|
||||
try {
|
||||
const result = await sendMessageCampaignMutation({
|
||||
variables: { input: params },
|
||||
});
|
||||
|
||||
const queued = result.data?.sendMessageCampaign;
|
||||
|
||||
if (queued) {
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Campaign queued to ${queued.queuedCount} recipient(s)`,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
enqueueErrorSnackBar({ message: t`Failed to send campaign` });
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
enqueueErrorSnackBar({ message: t`Failed to send campaign` });
|
||||
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[sendMessageCampaignMutation, enqueueSuccessSnackBar, enqueueErrorSnackBar],
|
||||
);
|
||||
|
||||
return { sendMessageCampaign, loading };
|
||||
};
|
||||
+2
@@ -2,6 +2,7 @@ import { HeadlessFrontComponentRendererEngineCommand } from '@/command-menu-item
|
||||
import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand';
|
||||
import { HeadlessOpenSidePanelPageEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessOpenSidePanelPageEngineCommand';
|
||||
import { NavigationEngineCommand } from '@/command-menu-item/engine-command/components/NavigationEngineCommand';
|
||||
import { ComposeCampaignCommand } from '@/command-menu-item/engine-command/global/components/ComposeCampaignCommand';
|
||||
import { ComposeEmailCommand } from '@/command-menu-item/engine-command/global/components/ComposeEmailCommand';
|
||||
import { DeleteRecordsCommand } from '@/command-menu-item/engine-command/record/components/DeleteRecordsCommand';
|
||||
import { DestroyRecordsCommand } from '@/command-menu-item/engine-command/record/components/DestroyRecordsCommand';
|
||||
@@ -248,6 +249,7 @@ export const ENGINE_COMPONENT_KEY_COMPONENT_MAP: Record<
|
||||
),
|
||||
[EngineComponentKey.REPLY_TO_EMAIL_THREAD]: <ReplyToEmailThreadCommand />,
|
||||
[EngineComponentKey.COMPOSE_EMAIL]: <ComposeEmailCommand />,
|
||||
[EngineComponentKey.COMPOSE_CAMPAIGN]: <ComposeCampaignCommand />,
|
||||
|
||||
// Deprecated keys kept for backward compatibility until migration runs
|
||||
[EngineComponentKey.DELETE_SINGLE_RECORD]: <DeleteRecordsCommand />,
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
|
||||
import { useOpenCampaignComposerInSidePanel } from '@/side-panel/hooks/useOpenCampaignComposerInSidePanel';
|
||||
|
||||
export const ComposeCampaignCommand = () => {
|
||||
const { openCampaignComposerInSidePanel } =
|
||||
useOpenCampaignComposerInSidePanel();
|
||||
|
||||
const handleExecute = () => {
|
||||
openCampaignComposerInSidePanel();
|
||||
};
|
||||
|
||||
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} ready />;
|
||||
};
|
||||
@@ -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 { SidePanelCampaignComposerPage } from '@/side-panel/pages/compose-campaign/components/SidePanelCampaignComposerPage';
|
||||
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, <SidePanelCampaignComposerPage />],
|
||||
],
|
||||
);
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { SidePanelPages } from 'twenty-shared/types';
|
||||
import { IconSend } from 'twenty-ui/display';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
export const useOpenCampaignComposerInSidePanel = () => {
|
||||
const { navigateSidePanelMenu } = useSidePanelMenu();
|
||||
|
||||
const openCampaignComposerInSidePanel = useCallback(() => {
|
||||
navigateSidePanelMenu({
|
||||
page: SidePanelPages.ComposeCampaign,
|
||||
pageTitle: t`New Campaign`,
|
||||
pageIcon: IconSend,
|
||||
pageId: v4(),
|
||||
});
|
||||
}, [navigateSidePanelMenu]);
|
||||
|
||||
return { openCampaignComposerInSidePanel };
|
||||
};
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { CampaignComposerFields } from '@/activities/emails/components/CampaignComposerFields';
|
||||
import { useCampaignComposerState } from '@/activities/emails/hooks/useCampaignComposerState';
|
||||
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
|
||||
import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
|
||||
import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { IconSend } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { getOsControlSymbol } from 'twenty-ui/utilities';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const StyledContent = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
export const SidePanelCampaignComposerPage = () => {
|
||||
const { goBackFromSidePanel } = useSidePanelHistory();
|
||||
|
||||
const campaignState = useCampaignComposerState({
|
||||
onSent: goBackFromSidePanel,
|
||||
});
|
||||
|
||||
const handleSendHotkey = useCallback(() => {
|
||||
if (campaignState.canSend) {
|
||||
campaignState.handleSend();
|
||||
}
|
||||
}, [campaignState]);
|
||||
|
||||
useHotkeysOnFocusedElement({
|
||||
keys: ['ctrl+Enter,meta+Enter'],
|
||||
callback: handleSendHotkey,
|
||||
focusId: SIDE_PANEL_FOCUS_ID,
|
||||
dependencies: [handleSendHotkey],
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledContent>
|
||||
<CampaignComposerFields campaignState={campaignState} />
|
||||
</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={t`Send campaign`}
|
||||
Icon={IconSend}
|
||||
hotkeys={[getOsControlSymbol(), '⏎']}
|
||||
onClick={campaignState.handleSend}
|
||||
disabled={!campaignState.canSend}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
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', 1780088214774)
|
||||
export class AddEmailingDomainUnsubscribeHostFastInstanceCommand implements FastInstanceCommand {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE "core"."emailingDomain" ADD "unsubscribeHostname" character varying');
|
||||
await queryRunner.query('ALTER TABLE "core"."emailingDomain" ADD "unsubscribeHostnameId" character varying');
|
||||
await queryRunner.query('CREATE TYPE "core"."emailingDomain_unsubscribehostnamestatus_enum" AS ENUM(\'PENDING\', \'ACTIVE\', \'FAILED\')');
|
||||
await queryRunner.query('ALTER TABLE "core"."emailingDomain" ADD "unsubscribeHostnameStatus" "core"."emailingDomain_unsubscribehostnamestatus_enum"');
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE "core"."emailingDomain" DROP COLUMN "unsubscribeHostnameStatus"');
|
||||
await queryRunner.query('DROP TYPE "core"."emailingDomain_unsubscribehostnamestatus_enum"');
|
||||
await queryRunner.query('ALTER TABLE "core"."emailingDomain" DROP COLUMN "unsubscribeHostnameId"');
|
||||
await queryRunner.query('ALTER TABLE "core"."emailingDomain" DROP COLUMN "unsubscribeHostname"');
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -2,13 +2,16 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { MigrateAiModelPreferencesCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000000000-migrate-ai-model-preferences.command';
|
||||
import { AddComposeCampaignCommandMenuItemCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000040000-add-compose-campaign-command-menu-item.command';
|
||||
import { BackfillEmailSuppressionAndListObjectsCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000030000-backfill-email-suppression-and-list-objects.command';
|
||||
import { BackfillFieldsWidgetNewFieldDefaultVisibilityCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000030000-backfill-fields-widget-new-field-default-visibility.command';
|
||||
import { AddWorkflowRunStepLogsFieldCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000035000-add-workflow-run-step-logs-field.command';
|
||||
import { MigrateAiModelPreferencesCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000000000-migrate-ai-model-preferences.command';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { TwentyStandardApplicationModule } from 'src/engine/workspace-manager/twenty-standard-application/twenty-standard-application.module';
|
||||
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
|
||||
|
||||
@Module({
|
||||
@@ -18,11 +21,13 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
WorkspaceIteratorModule,
|
||||
ApplicationModule,
|
||||
FieldMetadataModule,
|
||||
WorkspaceCacheModule,
|
||||
TwentyStandardApplicationModule,
|
||||
WorkspaceMigrationModule,
|
||||
],
|
||||
providers: [
|
||||
MigrateAiModelPreferencesCommand,
|
||||
BackfillEmailSuppressionAndListObjectsCommand,
|
||||
AddComposeCampaignCommandMenuItemCommand,
|
||||
AddWorkflowRunStepLogsFieldCommand,
|
||||
BackfillFieldsWidgetNewFieldDefaultVisibilityCommand,
|
||||
],
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { Command } from 'nest-commander';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { TwentyStandardApplicationService } from 'src/engine/workspace-manager/twenty-standard-application/services/twenty-standard-application.service';
|
||||
|
||||
@RegisteredWorkspaceCommand('2.9.0', 1799000030000)
|
||||
@Command({
|
||||
name: 'upgrade:2-9:backfill-email-suppression-and-list-objects',
|
||||
description:
|
||||
'Backfill emailGroupSuppressionList, emailList and emailListSubscription standard objects into existing workspace schemas',
|
||||
})
|
||||
export class BackfillEmailSuppressionAndListObjectsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly twentyStandardApplicationService: TwentyStandardApplicationService,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
if (isDryRun) {
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would synchronize twenty-standard application for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.twentyStandardApplicationService.synchronizeTwentyStandardApplicationOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Synchronized email suppression and list standard objects for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import { Command } from 'nest-commander';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant';
|
||||
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
|
||||
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
|
||||
|
||||
const COMPOSE_CAMPAIGN_UNIVERSAL_IDENTIFIER =
|
||||
STANDARD_COMMAND_MENU_ITEMS.composeCampaign.universalIdentifier;
|
||||
|
||||
@RegisteredWorkspaceCommand('2.9.0', 1799000040000)
|
||||
@Command({
|
||||
name: 'upgrade:2-9:add-compose-campaign-command-menu-item',
|
||||
description: 'Add the Compose Campaign command menu item to existing workspaces',
|
||||
})
|
||||
export class AddComposeCampaignCommandMenuItemCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
this.logger.log(
|
||||
`${isDryRun ? '[DRY RUN] ' : ''}Checking compose campaign command for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
const { twentyStandardFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatCommandMenuItemMaps',
|
||||
]);
|
||||
|
||||
const alreadyExists = isDefined(
|
||||
existingFlatCommandMenuItemMaps.byUniversalIdentifier[
|
||||
COMPOSE_CAMPAIGN_UNIVERSAL_IDENTIFIER
|
||||
],
|
||||
);
|
||||
|
||||
if (alreadyExists) {
|
||||
this.logger.log(
|
||||
`Compose campaign command already exists for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
|
||||
computeTwentyStandardApplicationAllFlatEntityMaps({
|
||||
now: new Date().toISOString(),
|
||||
workspaceId,
|
||||
twentyStandardApplicationId: twentyStandardFlatApplication.id,
|
||||
});
|
||||
|
||||
const itemToCreate =
|
||||
standardAllFlatEntityMaps.flatCommandMenuItemMaps.byUniversalIdentifier[
|
||||
COMPOSE_CAMPAIGN_UNIVERSAL_IDENTIFIER
|
||||
];
|
||||
|
||||
if (!isDefined(itemToCreate)) {
|
||||
this.logger.warn(
|
||||
`Compose campaign command not found in standard application for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDryRun) {
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would create compose campaign command for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
allFlatEntityOperationByMetadataName: {
|
||||
commandMenuItem: {
|
||||
flatEntityToCreate: [itemToCreate],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier:
|
||||
twentyStandardFlatApplication.universalIdentifier,
|
||||
},
|
||||
);
|
||||
|
||||
if (validateAndBuildResult.status === 'fail') {
|
||||
this.logger.error(
|
||||
`Failed to add compose campaign command:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
`Failed to add compose campaign command for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Successfully added compose campaign command for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -59,6 +59,7 @@ import { EmailingDomainTenantStatusAndGlobalUniquenessFastInstanceCommand } from
|
||||
import { AddLogicFunctionExecutionModeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-fast-1799000030000-add-logic-function-execution-mode';
|
||||
import { EncryptNonSecretApplicationVariableSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1798400000000-encrypt-non-secret-application-variable';
|
||||
import { MigrateAiModelPreferencesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences';
|
||||
import { AddEmailingDomainUnsubscribeHostFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-fast-1780088214774-add-emailing-domain-unsubscribe-host';
|
||||
|
||||
export const INSTANCE_COMMANDS = [
|
||||
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
|
||||
@@ -120,4 +121,5 @@ export const INSTANCE_COMMANDS = [
|
||||
AddLogicFunctionExecutionModeFastInstanceCommand,
|
||||
MigrateAiModelPreferencesSlowInstanceCommand,
|
||||
EncryptNonSecretApplicationVariableSlowInstanceCommand,
|
||||
AddEmailingDomainUnsubscribeHostFastInstanceCommand,
|
||||
];
|
||||
|
||||
+2
-1
@@ -27,6 +27,7 @@ import { isAsymmetricJwtHeader } from 'src/engine/core-modules/jwt/utils/is-asym
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
|
||||
import { isWorkDomain } from 'src/utils/is-work-email';
|
||||
|
||||
const APPROVED_ACCESS_DOMAIN_TOKEN_EXPIRES_IN = '7d';
|
||||
@@ -65,7 +66,7 @@ export class ApprovedAccessDomainService {
|
||||
);
|
||||
}
|
||||
|
||||
if (to.split('@')[1] !== approvedAccessDomain.domain) {
|
||||
if (getDomainFromEmail(to) !== approvedAccessDomain.domain) {
|
||||
throw new ApprovedAccessDomainException(
|
||||
'Approved access domain does not match email domain',
|
||||
ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL,
|
||||
|
||||
@@ -71,6 +71,7 @@ import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/worksp
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
|
||||
// import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-migration/constant/default-feature-flags';
|
||||
|
||||
@Injectable()
|
||||
@@ -896,7 +897,8 @@ export class AuthService {
|
||||
if (
|
||||
workspace?.approvedAccessDomains.some(
|
||||
(trustDomain) =>
|
||||
trustDomain.isValidated && trustDomain.domain === email.split('@')[1],
|
||||
trustDomain.isValidated &&
|
||||
trustDomain.domain === getDomainFromEmail(email),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { EmailGroupMessageCategory } from 'src/engine/core-modules/emailing-domain/types/email-group-message-category.type';
|
||||
import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type';
|
||||
|
||||
// Transactional sends ignore unsubscribe (only hard bounces and complaints block them);
|
||||
// marketing sends additionally honour a global unsubscribe.
|
||||
export const BLOCKING_REASONS_BY_MESSAGE_CATEGORY: Record<
|
||||
EmailGroupMessageCategory,
|
||||
MessageSuppressionReason[]
|
||||
> = {
|
||||
[EmailGroupMessageCategory.TRANSACTIONAL]: [
|
||||
MessageSuppressionReason.BOUNCE,
|
||||
MessageSuppressionReason.COMPLAINT,
|
||||
],
|
||||
[EmailGroupMessageCategory.CAMPAIGN]: [
|
||||
MessageSuppressionReason.BOUNCE,
|
||||
MessageSuppressionReason.COMPLAINT,
|
||||
MessageSuppressionReason.UNSUBSCRIBE,
|
||||
],
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// Per-recipient delivery status stored on `message.deliveryStatus` for campaign
|
||||
// sends (matches the SELECT options in the message field metadata).
|
||||
export const CAMPAIGN_MESSAGE_DELIVERY_STATUS = {
|
||||
QUEUED: 'QUEUED',
|
||||
SENT: 'SENT',
|
||||
FAILED: 'FAILED',
|
||||
BOUNCED: 'BOUNCED',
|
||||
COMPLAINED: 'COMPLAINED',
|
||||
} as const;
|
||||
|
||||
// Campaign-level lifecycle status stored on `messageCampaign.status`.
|
||||
export const CAMPAIGN_STATUS = {
|
||||
DRAFT: 'DRAFT',
|
||||
SCHEDULED: 'SCHEDULED',
|
||||
SENDING: 'SENDING',
|
||||
SENT: 'SENT',
|
||||
FAILED: 'FAILED',
|
||||
} as const;
|
||||
|
||||
// Job name for the per-recipient send job on the email queue. A string constant
|
||||
// (not the job class) so the enqueuing service and the processor don't import
|
||||
// each other.
|
||||
export const SEND_CAMPAIGN_EMAIL_JOB = 'SendCampaignEmailJob';
|
||||
|
||||
// Hard cap on recipients materialized + sent per campaign.
|
||||
export const MAX_CAMPAIGN_RECIPIENTS = 10000;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { type UnsubscribeContent } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-content.type';
|
||||
|
||||
export const EMPTY_UNSUBSCRIBE_CONTENT: UnsubscribeContent = {
|
||||
headers: [],
|
||||
textFooter: '',
|
||||
htmlFooter: '',
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const UNSUBSCRIBE_HOSTNAME_PREFIX = 'unsubscribe';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const UNSUBSCRIBE_MAILBOX_LOCAL_PART = 'unsubscribe';
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Header,
|
||||
HttpCode,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { MessageSuppressionService } from 'src/engine/core-modules/emailing-domain/services/message-suppression.service';
|
||||
import { MessageTopicSubscriptionService } from 'src/engine/core-modules/emailing-domain/services/message-topic-subscription.service';
|
||||
import {
|
||||
type UnsubscribeTokenPayload,
|
||||
UnsubscribeTokenService,
|
||||
} from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service';
|
||||
import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type';
|
||||
import { MessageSuppressionSource } from 'src/engine/core-modules/emailing-domain/types/message-suppression-source.type';
|
||||
import { buildUnsubscribePreferencesPage } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-preferences-page.util';
|
||||
import { buildUnsubscribeResultPage } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-result-page.util';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
|
||||
const UNSUBSCRIBE_TOKEN_FORMAT = /^[A-Za-z0-9_-]{1,512}\.[A-Za-z0-9_-]{1,86}$/;
|
||||
|
||||
const UPDATE_PREFERENCES_PATH = '/emailing/unsubscribe/preferences';
|
||||
const UNSUBSCRIBE_ALL_PATH = '/emailing/unsubscribe/all';
|
||||
|
||||
const HTML_CONTENT_TYPE = 'text/html; charset=utf-8';
|
||||
|
||||
type UnsubscribeFormBody = {
|
||||
t?: string;
|
||||
topicId?: string | string[];
|
||||
};
|
||||
|
||||
@Controller('emailing/unsubscribe')
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
export class UnsubscribeController {
|
||||
constructor(
|
||||
private readonly unsubscribeTokenService: UnsubscribeTokenService,
|
||||
private readonly messageSuppressionService: MessageSuppressionService,
|
||||
private readonly messageTopicSubscriptionService: MessageTopicSubscriptionService,
|
||||
) {}
|
||||
|
||||
// RFC 8058 one-click: mail clients POST here with no user interaction, so it
|
||||
// must unsubscribe immediately rather than render the preference page.
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
async handleOneClickUnsubscribe(@Query('t') token: string): Promise<void> {
|
||||
const payload = this.verifyTokenOrThrow(token);
|
||||
|
||||
await this.applyTokenUnsubscribe(payload);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Header('Content-Type', HTML_CONTENT_TYPE)
|
||||
async handlePreferencesPage(@Query('t') token: string): Promise<string> {
|
||||
const payload = this.verifyTokenOrThrow(token);
|
||||
|
||||
const topics = await this.messageTopicSubscriptionService.getSubscribedTopics({
|
||||
workspaceId: payload.workspaceId,
|
||||
emailAddress: payload.emailAddress,
|
||||
});
|
||||
|
||||
return buildUnsubscribePreferencesPage({
|
||||
token,
|
||||
topics,
|
||||
updatePath: UPDATE_PREFERENCES_PATH,
|
||||
unsubscribeAllPath: UNSUBSCRIBE_ALL_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('preferences')
|
||||
@Header('Content-Type', HTML_CONTENT_TYPE)
|
||||
async handleUpdatePreferences(
|
||||
@Body() body: UnsubscribeFormBody,
|
||||
): Promise<string> {
|
||||
const payload = this.verifyTokenOrThrow(body.t);
|
||||
|
||||
await this.messageTopicSubscriptionService.setSubscribedTopics({
|
||||
workspaceId: payload.workspaceId,
|
||||
emailAddress: payload.emailAddress,
|
||||
subscribedTopicIds: this.normalizeTopicIds(body.topicId),
|
||||
});
|
||||
|
||||
return buildUnsubscribeResultPage(
|
||||
'Preferences updated',
|
||||
'Your email preferences have been saved.',
|
||||
);
|
||||
}
|
||||
|
||||
@Post('all')
|
||||
@Header('Content-Type', HTML_CONTENT_TYPE)
|
||||
async handleUnsubscribeAll(
|
||||
@Body() body: UnsubscribeFormBody,
|
||||
): Promise<string> {
|
||||
const payload = this.verifyTokenOrThrow(body.t);
|
||||
|
||||
await this.suppressAll(payload);
|
||||
|
||||
return buildUnsubscribeResultPage(
|
||||
'You have been unsubscribed',
|
||||
'You will no longer receive marketing emails from this sender.',
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeTopicIds(topicId: string | string[] | undefined): string[] {
|
||||
if (Array.isArray(topicId)) {
|
||||
return topicId.filter(isNonEmptyString);
|
||||
}
|
||||
|
||||
return isNonEmptyString(topicId) ? [topicId] : [];
|
||||
}
|
||||
|
||||
private verifyTokenOrThrow(
|
||||
token: string | undefined,
|
||||
): UnsubscribeTokenPayload {
|
||||
if (!isNonEmptyString(token) || !UNSUBSCRIBE_TOKEN_FORMAT.test(token)) {
|
||||
throw new BadRequestException('Malformed unsubscribe token');
|
||||
}
|
||||
|
||||
const payload = this.unsubscribeTokenService.verify(token);
|
||||
|
||||
if (payload === null) {
|
||||
throw new BadRequestException('Invalid unsubscribe token');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private async applyTokenUnsubscribe(
|
||||
payload: UnsubscribeTokenPayload,
|
||||
): Promise<void> {
|
||||
if (isNonEmptyString(payload.messageTopicId)) {
|
||||
const unsubscribedFromList =
|
||||
await this.messageTopicSubscriptionService.unsubscribeByEmail({
|
||||
workspaceId: payload.workspaceId,
|
||||
emailAddress: payload.emailAddress,
|
||||
topicId: payload.messageTopicId,
|
||||
});
|
||||
|
||||
if (unsubscribedFromList) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.suppressAll(payload);
|
||||
}
|
||||
|
||||
private async suppressAll(payload: UnsubscribeTokenPayload): Promise<void> {
|
||||
await this.messageSuppressionService.suppress({
|
||||
workspaceId: payload.workspaceId,
|
||||
emailAddress: payload.emailAddress,
|
||||
reason: MessageSuppressionReason.UNSUBSCRIBE,
|
||||
source: MessageSuppressionSource.SYSTEM,
|
||||
});
|
||||
}
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
export const AWS_SES_MARKETING_TOPIC_NAME = 'marketing';
|
||||
-4
@@ -2,7 +2,6 @@ import {
|
||||
AlreadyExistsException,
|
||||
CreateConfigurationSetCommand,
|
||||
CreateConfigurationSetEventDestinationCommand,
|
||||
CreateContactListCommand,
|
||||
CreateTenantResourceAssociationCommand,
|
||||
PutEmailIdentityMailFromAttributesCommand,
|
||||
} from '@aws-sdk/client-sesv2';
|
||||
@@ -22,7 +21,6 @@ describe('AwsSesRegisterDomainService', () => {
|
||||
const provisionInput = {
|
||||
tenantName: 'twenty-workspace-ws1',
|
||||
configurationSetName: 'twenty-workspace-ws1',
|
||||
contactListName: 'twenty-workspace-ws1',
|
||||
};
|
||||
|
||||
const buildAlreadyExists = () =>
|
||||
@@ -56,7 +54,6 @@ describe('AwsSesRegisterDomainService', () => {
|
||||
expect(commandTypes).toEqual([
|
||||
CreateConfigurationSetCommand.name,
|
||||
CreateConfigurationSetEventDestinationCommand.name,
|
||||
CreateContactListCommand.name,
|
||||
CreateTenantResourceAssociationCommand.name,
|
||||
]);
|
||||
});
|
||||
@@ -75,7 +72,6 @@ describe('AwsSesRegisterDomainService', () => {
|
||||
expect(commandTypes).toEqual([
|
||||
CreateConfigurationSetCommand.name,
|
||||
CreateConfigurationSetEventDestinationCommand.name,
|
||||
CreateContactListCommand.name,
|
||||
CreateTenantResourceAssociationCommand.name,
|
||||
]);
|
||||
});
|
||||
|
||||
+2
-6
@@ -21,7 +21,6 @@ describe('AwsSesSendEmailService', () => {
|
||||
const baseContext = {
|
||||
tenantName: 'twenty-workspace-ws1',
|
||||
configurationSetName: 'twenty-workspace-ws1',
|
||||
contactListName: 'twenty-workspace-ws1',
|
||||
};
|
||||
|
||||
const setUp = () => {
|
||||
@@ -42,7 +41,7 @@ describe('AwsSesSendEmailService', () => {
|
||||
return { service, send, handleErrorService };
|
||||
};
|
||||
|
||||
it('should call SendEmail with tenant, config set, and list management options', async () => {
|
||||
it('should call SendEmail with tenant and config set', async () => {
|
||||
const { service, send } = setUp();
|
||||
|
||||
send.mockResolvedValue({ MessageId: 'msg-1' });
|
||||
@@ -59,11 +58,8 @@ describe('AwsSesSendEmailService', () => {
|
||||
Destination: { ToAddresses: ['user@example.com'] },
|
||||
ConfigurationSetName: 'twenty-workspace-ws1',
|
||||
TenantName: 'twenty-workspace-ws1',
|
||||
ListManagementOptions: {
|
||||
ContactListName: 'twenty-workspace-ws1',
|
||||
TopicName: 'marketing',
|
||||
},
|
||||
});
|
||||
expect(command.input.ListManagementOptions).toBeUndefined();
|
||||
expect(command.input.EmailTags).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ Name: 'workspace', Value: 'ws1' },
|
||||
|
||||
-14
@@ -6,7 +6,6 @@ import {
|
||||
CreateTenantCommand,
|
||||
CreateTenantResourceAssociationCommand,
|
||||
DeleteConfigurationSetCommand,
|
||||
DeleteContactListCommand,
|
||||
DeleteEmailIdentityCommand,
|
||||
DeleteTenantCommand,
|
||||
DeleteTenantResourceAssociationCommand,
|
||||
@@ -120,7 +119,6 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
|
||||
{
|
||||
tenantName,
|
||||
configurationSetName: this.buildConfigurationSetName(workspaceId),
|
||||
contactListName: this.buildContactListName(workspaceId),
|
||||
},
|
||||
this.config,
|
||||
);
|
||||
@@ -136,7 +134,6 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
|
||||
return this.awsSesSendEmailService.sendEmail(input, {
|
||||
tenantName: this.buildTenantName(input.workspaceId),
|
||||
configurationSetName: this.buildConfigurationSetName(input.workspaceId),
|
||||
contactListName: this.buildContactListName(input.workspaceId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -167,7 +164,6 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
|
||||
const sesClient = this.awsSesClientProvider.getSESClient();
|
||||
const tenantName = this.buildTenantName(workspaceId);
|
||||
const configurationSetName = this.buildConfigurationSetName(workspaceId);
|
||||
const contactListName = this.buildContactListName(workspaceId);
|
||||
const configurationSetArn = `arn:aws:ses:${this.config.region}:${this.config.accountId}:configuration-set/${configurationSetName}`;
|
||||
|
||||
await sesClient
|
||||
@@ -191,12 +187,6 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
|
||||
if (!(error instanceof NotFoundException)) throw error;
|
||||
});
|
||||
|
||||
await sesClient
|
||||
.send(new DeleteContactListCommand({ ContactListName: contactListName }))
|
||||
.catch((error) => {
|
||||
if (!(error instanceof NotFoundException)) throw error;
|
||||
});
|
||||
|
||||
await sesClient
|
||||
.send(new DeleteTenantCommand({ TenantName: tenantName }))
|
||||
.catch((error) => {
|
||||
@@ -212,10 +202,6 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
|
||||
return `${AWS_SES_RESOURCE_NAME_PREFIX}-${workspaceId}`;
|
||||
}
|
||||
|
||||
private buildContactListName(workspaceId: string): string {
|
||||
return `${AWS_SES_RESOURCE_NAME_PREFIX}-${workspaceId}`;
|
||||
}
|
||||
|
||||
private async ensureTenantExists(tenantName: string): Promise<void> {
|
||||
const sesClient = this.awsSesClientProvider.getSESClient();
|
||||
|
||||
|
||||
+1
-24
@@ -4,7 +4,6 @@ import {
|
||||
AlreadyExistsException,
|
||||
CreateConfigurationSetCommand,
|
||||
CreateConfigurationSetEventDestinationCommand,
|
||||
CreateContactListCommand,
|
||||
CreateTenantResourceAssociationCommand,
|
||||
PutEmailIdentityMailFromAttributesCommand,
|
||||
} from '@aws-sdk/client-sesv2';
|
||||
@@ -12,13 +11,11 @@ import { type AwsSesDriverConfig } from 'src/engine/core-modules/emailing-domain
|
||||
|
||||
import { AWS_SES_EVENT_BUS_NAME } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-event-bus-name.constant';
|
||||
import { AWS_SES_MAIL_FROM_SUBDOMAIN } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-mail-from-subdomain.constant';
|
||||
import { AWS_SES_MARKETING_TOPIC_NAME } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-marketing-topic-name.constant';
|
||||
import { AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
|
||||
|
||||
type ProvisionWorkspaceInput = {
|
||||
tenantName: string;
|
||||
configurationSetName: string;
|
||||
contactListName: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -42,7 +39,7 @@ export class AwsSesRegisterDomainService {
|
||||
ConfigurationSetName: input.configurationSetName,
|
||||
ReputationOptions: { ReputationMetricsEnabled: true },
|
||||
SendingOptions: { SendingEnabled: true },
|
||||
SuppressionOptions: { SuppressedReasons: ['BOUNCE', 'COMPLAINT'] },
|
||||
SuppressionOptions: { SuppressedReasons: [] },
|
||||
Tags: [{ Key: 'managed-by', Value: 'twenty' }],
|
||||
}),
|
||||
)
|
||||
@@ -79,26 +76,6 @@ export class AwsSesRegisterDomainService {
|
||||
}
|
||||
});
|
||||
|
||||
await sesClient
|
||||
.send(
|
||||
new CreateContactListCommand({
|
||||
ContactListName: input.contactListName,
|
||||
Topics: [
|
||||
{
|
||||
TopicName: AWS_SES_MARKETING_TOPIC_NAME,
|
||||
DisplayName: 'Marketing',
|
||||
DefaultSubscriptionStatus: 'OPT_IN',
|
||||
},
|
||||
],
|
||||
Tags: [{ Key: 'managed-by', Value: 'twenty' }],
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
if (!(error instanceof AlreadyExistsException)) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
await sesClient
|
||||
.send(
|
||||
new CreateTenantResourceAssociationCommand({
|
||||
|
||||
+14
-7
@@ -8,7 +8,6 @@ import {
|
||||
type EmailingDomainSendEmailResult,
|
||||
} from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
|
||||
|
||||
import { AWS_SES_MARKETING_TOPIC_NAME } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-marketing-topic-name.constant';
|
||||
import { AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
|
||||
import { AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-handle-error.service';
|
||||
import {
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
type SendEmailContext = {
|
||||
tenantName: string;
|
||||
configurationSetName: string;
|
||||
contactListName: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -56,6 +54,12 @@ export class AwsSesSendEmailService {
|
||||
ReplyToAddresses: input.replyTo,
|
||||
Content: {
|
||||
Simple: {
|
||||
Headers: isNonEmptyArray(input.headers)
|
||||
? input.headers.map((header) => ({
|
||||
Name: header.name,
|
||||
Value: header.value,
|
||||
}))
|
||||
: undefined,
|
||||
Subject: { Data: input.subject, Charset: 'UTF-8' },
|
||||
Body: {
|
||||
Text: { Data: input.text, Charset: 'UTF-8' },
|
||||
@@ -75,10 +79,6 @@ export class AwsSesSendEmailService {
|
||||
},
|
||||
ConfigurationSetName: context.configurationSetName,
|
||||
TenantName: context.tenantName,
|
||||
ListManagementOptions: {
|
||||
ContactListName: context.contactListName,
|
||||
TopicName: AWS_SES_MARKETING_TOPIC_NAME,
|
||||
},
|
||||
EmailTags: [
|
||||
{ Name: 'workspace', Value: input.workspaceId },
|
||||
{ Name: 'domain', Value: input.domain },
|
||||
@@ -97,7 +97,14 @@ export class AwsSesSendEmailService {
|
||||
`Sent email ${response.MessageId} from ${input.from} (tenant ${context.tenantName})`,
|
||||
);
|
||||
|
||||
return { messageId: response.MessageId };
|
||||
return {
|
||||
messageId: response.MessageId,
|
||||
deliveredRecipients: {
|
||||
to: input.to,
|
||||
cc: input.cc ?? [],
|
||||
bcc: input.bcc ?? [],
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof EmailingDomainDriverException) {
|
||||
throw error;
|
||||
|
||||
+6
@@ -11,6 +11,8 @@ export enum EmailingDomainDriverExceptionCode {
|
||||
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
|
||||
CONFIGURATION_ERROR = 'CONFIGURATION_ERROR',
|
||||
SENDING_SUSPENDED = 'SENDING_SUSPENDED',
|
||||
ALL_RECIPIENTS_SUPPRESSED = 'ALL_RECIPIENTS_SUPPRESSED',
|
||||
UNSUBSCRIBE_NOT_READY = 'UNSUBSCRIBE_NOT_READY',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
@@ -26,6 +28,10 @@ const getEmailingDomainDriverExceptionUserFriendlyMessage = (
|
||||
return msg`Email domain configuration error.`;
|
||||
case EmailingDomainDriverExceptionCode.SENDING_SUSPENDED:
|
||||
return msg`Sending is currently suspended for this email domain.`;
|
||||
case EmailingDomainDriverExceptionCode.ALL_RECIPIENTS_SUPPRESSED:
|
||||
return msg`All recipients are suppressed for this email domain.`;
|
||||
case EmailingDomainDriverExceptionCode.UNSUBSCRIBE_NOT_READY:
|
||||
return msg`Marketing sending is on hold until the unsubscribe domain is verified.`;
|
||||
case EmailingDomainDriverExceptionCode.TEMPORARY_ERROR:
|
||||
case EmailingDomainDriverExceptionCode.UNKNOWN:
|
||||
return STANDARD_ERROR_MESSAGE;
|
||||
|
||||
+13
@@ -1,9 +1,16 @@
|
||||
import { EmailGroupMessageCategory } from 'src/engine/core-modules/emailing-domain/types/email-group-message-category.type';
|
||||
|
||||
export type EmailingDomainAttachment = {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
export type EmailingDomainHeader = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type EmailingDomainEmailContent = {
|
||||
from: string;
|
||||
to: string[];
|
||||
@@ -14,6 +21,11 @@ export type EmailingDomainEmailContent = {
|
||||
html?: string;
|
||||
replyTo?: string[];
|
||||
attachments?: EmailingDomainAttachment[];
|
||||
headers?: EmailingDomainHeader[];
|
||||
messageCategory?: EmailGroupMessageCategory;
|
||||
// When a marketing email targets a specific list, recipients who unsubscribed
|
||||
// from that list are dropped in addition to the globally suppressed ones.
|
||||
messageTopicId?: string;
|
||||
};
|
||||
|
||||
export type EmailingDomainSendEmailInput = EmailingDomainEmailContent & {
|
||||
@@ -23,4 +35,5 @@ export type EmailingDomainSendEmailInput = EmailingDomainEmailContent & {
|
||||
|
||||
export type EmailingDomainSendEmailResult = {
|
||||
messageId: string;
|
||||
deliveredRecipients: { to: string[]; cc: string[]; bcc: string[] };
|
||||
};
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export enum UnsubscribeHostnameStatus {
|
||||
PENDING = 'PENDING',
|
||||
ACTIVE = 'ACTIVE',
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class CampaignSkippedRecipientsDTO {
|
||||
@Field(() => Int)
|
||||
noEmail: number;
|
||||
|
||||
@Field(() => Int)
|
||||
deduped: number;
|
||||
|
||||
@Field(() => Int)
|
||||
overCap: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class SendMessageCampaignOutputDTO {
|
||||
@Field(() => String)
|
||||
campaignId: string;
|
||||
|
||||
// Recipients materialized as QUEUED messages and enqueued for async sending.
|
||||
@Field(() => Int)
|
||||
queuedCount: number;
|
||||
|
||||
@Field(() => CampaignSkippedRecipientsDTO)
|
||||
skipped: CampaignSkippedRecipientsDTO;
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsEmail,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Length,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class SendMessageCampaignInput {
|
||||
@Field(() => String)
|
||||
@IsUUID('4')
|
||||
messageTopicId: string;
|
||||
|
||||
// Optional Person view whose filters resolve the recipients (dynamic audience).
|
||||
// When omitted, recipients are the people subscribed to the topic.
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
recipientViewId?: string;
|
||||
|
||||
// Optional list whose hand-picked members are the recipients (static audience).
|
||||
// Takes precedence over recipientViewId and topic subscribers when set.
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
listId?: string;
|
||||
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@Length(1, 998)
|
||||
subject: string;
|
||||
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
body: string;
|
||||
|
||||
@Field(() => String)
|
||||
@IsEmail()
|
||||
fromAddress: string;
|
||||
}
|
||||
+14
@@ -12,6 +12,7 @@ import {
|
||||
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
|
||||
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
|
||||
import { EmailingDomainTenantStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type';
|
||||
import { UnsubscribeHostnameStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/unsubscribe-hostname-status.type';
|
||||
import { VerificationRecord } from 'src/engine/core-modules/emailing-domain/drivers/types/verifications-record';
|
||||
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
|
||||
|
||||
@@ -59,4 +60,17 @@ export class EmailingDomainEntity extends WorkspaceRelatedEntity {
|
||||
nullable: false,
|
||||
})
|
||||
tenantStatus: EmailingDomainTenantStatus;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
unsubscribeHostname: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
unsubscribeHostnameId: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: Object.values(UnsubscribeHostnameStatus),
|
||||
nullable: true,
|
||||
})
|
||||
unsubscribeHostnameStatus: UnsubscribeHostnameStatus | null;
|
||||
}
|
||||
|
||||
+28
-1
@@ -1,8 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
|
||||
import { UnsubscribeController } from 'src/engine/core-modules/emailing-domain/controllers/unsubscribe.controller';
|
||||
import { AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
|
||||
import { AwsSesRegisterDomainService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-register-domain.service';
|
||||
import { AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-handle-error.service';
|
||||
@@ -11,9 +14,16 @@ import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-do
|
||||
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
|
||||
import { EmailingDomainResolver } from 'src/engine/core-modules/emailing-domain/emailing-domain.resolver';
|
||||
import { EmailingDomainWorkspaceCleanupJob } from 'src/engine/core-modules/emailing-domain/jobs/emailing-domain-workspace-cleanup.job';
|
||||
import { EmailingDomainSenderService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain-sender.service';
|
||||
import { MessageCampaignService } from 'src/engine/core-modules/emailing-domain/services/message-campaign.service';
|
||||
import { MessageSuppressionService } from 'src/engine/core-modules/emailing-domain/services/message-suppression.service';
|
||||
import { MessageTopicSubscriptionService } from 'src/engine/core-modules/emailing-domain/services/message-topic-subscription.service';
|
||||
import { EmailingDomainTenantStatusService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain-tenant-status.service';
|
||||
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
|
||||
import { UnsubscribeHostnameService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-hostname.service';
|
||||
import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
|
||||
@Module({
|
||||
@@ -22,11 +32,28 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
|
||||
NestjsQueryTypeOrmModule.forFeature([EmailingDomainEntity]),
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
DnsManagerModule,
|
||||
TypeOrmModule.forFeature([MessageChannelEntity]),
|
||||
],
|
||||
controllers: [UnsubscribeController],
|
||||
exports: [
|
||||
EmailingDomainService,
|
||||
EmailingDomainSenderService,
|
||||
EmailingDomainTenantStatusService,
|
||||
MessageSuppressionService,
|
||||
MessageTopicSubscriptionService,
|
||||
MessageCampaignService,
|
||||
UnsubscribeTokenService,
|
||||
],
|
||||
exports: [EmailingDomainService, EmailingDomainTenantStatusService],
|
||||
providers: [
|
||||
EmailingDomainService,
|
||||
EmailingDomainSenderService,
|
||||
EmailingDomainTenantStatusService,
|
||||
MessageSuppressionService,
|
||||
MessageTopicSubscriptionService,
|
||||
MessageCampaignService,
|
||||
UnsubscribeTokenService,
|
||||
UnsubscribeHostnameService,
|
||||
EmailingDomainResolver,
|
||||
EmailingDomainDriverFactory,
|
||||
EmailingDomainWorkspaceCleanupJob,
|
||||
|
||||
+30
-2
@@ -7,11 +7,16 @@ import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
|
||||
import { EmailingDomainDTO } from 'src/engine/core-modules/emailing-domain/dtos/emailing-domain.dto';
|
||||
import { SendMessageCampaignInput } from 'src/engine/core-modules/emailing-domain/dtos/send-message-campaign.input';
|
||||
import { SendMessageCampaignOutputDTO } from 'src/engine/core-modules/emailing-domain/dtos/send-message-campaign-output.dto';
|
||||
import { SendEmailViaDomainOutputDTO } from 'src/engine/core-modules/emailing-domain/dtos/send-email-via-domain-output.dto';
|
||||
import { SendEmailViaDomainInput } from 'src/engine/core-modules/emailing-domain/dtos/send-email-via-domain.input';
|
||||
import { MessageCampaignService } from 'src/engine/core-modules/emailing-domain/services/message-campaign.service';
|
||||
import { EmailingDomainSenderService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain-sender.service';
|
||||
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
|
||||
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 {
|
||||
FeatureFlagGuard,
|
||||
@@ -28,7 +33,11 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver(() => EmailingDomainDTO)
|
||||
export class EmailingDomainResolver {
|
||||
constructor(private readonly emailingDomainService: EmailingDomainService) {}
|
||||
constructor(
|
||||
private readonly emailingDomainService: EmailingDomainService,
|
||||
private readonly emailingDomainSenderService: EmailingDomainSenderService,
|
||||
private readonly messageCampaignService: MessageCampaignService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => EmailingDomainDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
|
||||
@@ -80,7 +89,7 @@ export class EmailingDomainResolver {
|
||||
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
|
||||
): Promise<SendEmailViaDomainOutputDTO> {
|
||||
const { emailingDomainId, ...content } = input;
|
||||
const result = await this.emailingDomainService.sendEmail(
|
||||
const result = await this.emailingDomainSenderService.sendEmail(
|
||||
currentWorkspace.id,
|
||||
emailingDomainId,
|
||||
content,
|
||||
@@ -89,6 +98,25 @@ export class EmailingDomainResolver {
|
||||
return { messageId: result.messageId };
|
||||
}
|
||||
|
||||
@Mutation(() => SendMessageCampaignOutputDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
|
||||
async sendMessageCampaign(
|
||||
@Args('input') input: SendMessageCampaignInput,
|
||||
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<SendMessageCampaignOutputDTO> {
|
||||
return this.messageCampaignService.send({
|
||||
workspaceId: currentWorkspace.id,
|
||||
userWorkspaceId,
|
||||
messageTopicId: input.messageTopicId,
|
||||
recipientViewId: input.recipientViewId,
|
||||
listId: input.listId,
|
||||
subject: input.subject,
|
||||
html: input.body,
|
||||
fromAddress: input.fromAddress,
|
||||
});
|
||||
}
|
||||
|
||||
@Query(() => [EmailingDomainDTO])
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
|
||||
async getEmailingDomains(
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { SEND_CAMPAIGN_EMAIL_JOB } from 'src/engine/core-modules/emailing-domain/constants/campaign.constant';
|
||||
import { MessageCampaignService } from 'src/engine/core-modules/emailing-domain/services/message-campaign.service';
|
||||
import { type SendCampaignEmailJobData } from 'src/engine/core-modules/emailing-domain/types/send-campaign-email-job-data.type';
|
||||
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';
|
||||
|
||||
@Processor(MessageQueue.emailQueue)
|
||||
export class SendCampaignEmailJob {
|
||||
constructor(
|
||||
private readonly messageCampaignService: MessageCampaignService,
|
||||
) {}
|
||||
|
||||
@Process(SEND_CAMPAIGN_EMAIL_JOB)
|
||||
async handle(data: SendCampaignEmailJobData): Promise<void> {
|
||||
await this.messageCampaignService.processSendJob(data);
|
||||
}
|
||||
}
|
||||
+84
-5
@@ -2,11 +2,17 @@ import { EmailingDomainDriverExceptionCode } from 'src/engine/core-modules/email
|
||||
import { type EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory';
|
||||
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
|
||||
import { EmailingDomainTenantStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type';
|
||||
import { type EmailingDomainEmailContent } from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
|
||||
import { type EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
|
||||
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
|
||||
import { EmailingDomainSenderService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain-sender.service';
|
||||
import { type MessageSuppressionService } from 'src/engine/core-modules/emailing-domain/services/message-suppression.service';
|
||||
import { type MessageTopicSubscriptionService } from 'src/engine/core-modules/emailing-domain/services/message-topic-subscription.service';
|
||||
import { type UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service';
|
||||
import { type MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
|
||||
import { type Repository } from 'typeorm';
|
||||
import { type WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
|
||||
describe('EmailingDomainService.sendEmail', () => {
|
||||
describe('EmailingDomainSenderService.sendEmail', () => {
|
||||
const buildEmailingDomain = (
|
||||
overrides: Partial<EmailingDomainEntity> = {},
|
||||
): EmailingDomainEntity =>
|
||||
@@ -19,14 +25,21 @@ describe('EmailingDomainService.sendEmail', () => {
|
||||
...overrides,
|
||||
}) as EmailingDomainEntity;
|
||||
|
||||
const buildEmailContent = () => ({
|
||||
const buildEmailContent = (
|
||||
overrides: Partial<EmailingDomainEmailContent> = {},
|
||||
): EmailingDomainEmailContent => ({
|
||||
from: 'hello@mail.example.com',
|
||||
to: ['user@example.com'],
|
||||
subject: 'Hi',
|
||||
text: 'Body',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setUp = (emailingDomain: EmailingDomainEntity) => {
|
||||
const setUp = (
|
||||
emailingDomain: EmailingDomainEntity,
|
||||
suppressedAddresses: string[] = [],
|
||||
listUnsubscribedAddresses: string[] = [],
|
||||
) => {
|
||||
const sendEmail = jest.fn().mockResolvedValue({ messageId: 'msg-1' });
|
||||
const repository = {
|
||||
findOne: jest.fn().mockResolvedValue(emailingDomain),
|
||||
@@ -34,7 +47,36 @@ describe('EmailingDomainService.sendEmail', () => {
|
||||
const factory = {
|
||||
getCurrentDriver: () => ({ sendEmail }),
|
||||
} as unknown as EmailingDomainDriverFactory;
|
||||
const service = new EmailingDomainService(repository, factory);
|
||||
const suppressionService = {
|
||||
getSuppressedAddresses: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Set(suppressedAddresses.map((address) => address.toLowerCase())),
|
||||
),
|
||||
} as unknown as MessageSuppressionService;
|
||||
const subscriptionService = {
|
||||
getAddressesUnsubscribedFromList: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Set(
|
||||
listUnsubscribedAddresses.map((address) => address.toLowerCase()),
|
||||
),
|
||||
),
|
||||
} as unknown as MessageTopicSubscriptionService;
|
||||
const unsubscribeTokenService = {
|
||||
sign: jest.fn().mockReturnValue('signed-token'),
|
||||
} as unknown as UnsubscribeTokenService;
|
||||
const messageChannelRepository = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as Repository<MessageChannelEntity>;
|
||||
const service = new EmailingDomainSenderService(
|
||||
repository,
|
||||
factory,
|
||||
suppressionService,
|
||||
subscriptionService,
|
||||
unsubscribeTokenService,
|
||||
messageChannelRepository,
|
||||
);
|
||||
|
||||
return { service, sendEmail };
|
||||
};
|
||||
@@ -77,6 +119,43 @@ describe('EmailingDomainService.sendEmail', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('removes suppressed recipients but still sends to deliverable ones', async () => {
|
||||
const { service, sendEmail } = setUp(buildEmailingDomain(), [
|
||||
'blocked@example.com',
|
||||
]);
|
||||
|
||||
await service.sendEmail(
|
||||
'ws1',
|
||||
'domain-1',
|
||||
buildEmailContent({
|
||||
to: ['user@example.com', 'Blocked@example.com'],
|
||||
cc: ['blocked@example.com'],
|
||||
bcc: ['keep@example.com'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: ['user@example.com'],
|
||||
cc: [],
|
||||
bcc: ['keep@example.com'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects with ALL_RECIPIENTS_SUPPRESSED when every primary recipient is suppressed, without calling the driver', async () => {
|
||||
const { service, sendEmail } = setUp(buildEmailingDomain(), [
|
||||
'user@example.com',
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.sendEmail('ws1', 'domain-1', buildEmailContent()),
|
||||
).rejects.toMatchObject({
|
||||
code: EmailingDomainDriverExceptionCode.ALL_RECIPIENTS_SUPPRESSED,
|
||||
});
|
||||
expect(sendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verification is a precondition for the tenant-status check: a domain that
|
||||
// has not been verified should surface a CONFIGURATION_ERROR rather than
|
||||
// leaking the tenant pause state to callers who couldn't have used it anyway.
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { MessageChannelType } from 'twenty-shared/types';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { EMPTY_UNSUBSCRIBE_CONTENT } from 'src/engine/core-modules/emailing-domain/constants/empty-unsubscribe-content.constant';
|
||||
import {
|
||||
EmailingDomainDriverException,
|
||||
EmailingDomainDriverExceptionCode,
|
||||
} from 'src/engine/core-modules/emailing-domain/drivers/exceptions/emailing-domain-driver.exception';
|
||||
import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory';
|
||||
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
|
||||
import { EmailingDomainTenantStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type';
|
||||
import {
|
||||
EmailingDomainSendEmailInput,
|
||||
type EmailingDomainEmailContent,
|
||||
type EmailingDomainSendEmailResult,
|
||||
} from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
|
||||
import { UnsubscribeHostnameStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/unsubscribe-hostname-status.type';
|
||||
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
|
||||
import { MessageSuppressionService } from 'src/engine/core-modules/emailing-domain/services/message-suppression.service';
|
||||
import { MessageTopicSubscriptionService } from 'src/engine/core-modules/emailing-domain/services/message-topic-subscription.service';
|
||||
import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service';
|
||||
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
|
||||
import { EmailGroupMessageCategory } from 'src/engine/core-modules/emailing-domain/types/email-group-message-category.type';
|
||||
import { type DeliverableRecipients } from 'src/engine/core-modules/emailing-domain/types/deliverable-recipients.type';
|
||||
import { type UnsubscribeContent } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-content.type';
|
||||
import { buildUnsubscribeHeaders } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-headers.util';
|
||||
import { buildUnsubscribeHtmlFooter } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-html-footer.util';
|
||||
import { buildUnsubscribeTextFooter } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-text-footer.util';
|
||||
import { buildUnsubscribeUrls } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-urls.util';
|
||||
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@Injectable()
|
||||
export class EmailingDomainSenderService {
|
||||
constructor(
|
||||
@InjectWorkspaceScopedRepository(EmailingDomainEntity)
|
||||
private readonly emailingDomainRepository: WorkspaceScopedRepository<EmailingDomainEntity>,
|
||||
private readonly emailingDomainDriverFactory: EmailingDomainDriverFactory,
|
||||
private readonly messageSuppressionService: MessageSuppressionService,
|
||||
private readonly messageTopicSubscriptionService: MessageTopicSubscriptionService,
|
||||
private readonly unsubscribeTokenService: UnsubscribeTokenService,
|
||||
@InjectRepository(MessageChannelEntity)
|
||||
private readonly messageChannelRepository: Repository<MessageChannelEntity>,
|
||||
) {}
|
||||
|
||||
async sendEmail(
|
||||
workspaceId: string,
|
||||
emailingDomainId: string,
|
||||
emailContent: EmailingDomainEmailContent,
|
||||
): Promise<EmailingDomainSendEmailResult> {
|
||||
const emailingDomain = await this.findEmailingDomainByIdOrThrow(
|
||||
workspaceId,
|
||||
emailingDomainId,
|
||||
);
|
||||
|
||||
this.assertDomainCanSend(emailingDomain, emailContent.from);
|
||||
|
||||
const messageCategory =
|
||||
emailContent.messageCategory ?? EmailGroupMessageCategory.TRANSACTIONAL;
|
||||
|
||||
const recipients = await this.selectDeliverableRecipients(
|
||||
workspaceId,
|
||||
emailingDomain,
|
||||
emailContent,
|
||||
messageCategory,
|
||||
);
|
||||
|
||||
const unsubscribe = this.buildUnsubscribeContent(
|
||||
workspaceId,
|
||||
emailingDomain,
|
||||
messageCategory,
|
||||
recipients.to[0],
|
||||
emailContent.messageTopicId,
|
||||
);
|
||||
|
||||
const replyTo = await this.resolveReplyTo(workspaceId, emailContent);
|
||||
|
||||
const emailToSend = {
|
||||
workspaceId,
|
||||
domain: emailingDomain.domain,
|
||||
from: emailContent.from,
|
||||
replyTo,
|
||||
to: recipients.to,
|
||||
cc: recipients.cc,
|
||||
bcc: recipients.bcc,
|
||||
subject: emailContent.subject,
|
||||
text: `${emailContent.text}${unsubscribe.textFooter}`,
|
||||
html: isNonEmptyString(emailContent.html)
|
||||
? `${emailContent.html}${unsubscribe.htmlFooter}`
|
||||
: emailContent.html,
|
||||
attachments: emailContent.attachments,
|
||||
headers: [...(emailContent.headers ?? []), ...unsubscribe.headers],
|
||||
} as EmailingDomainSendEmailInput;
|
||||
|
||||
return this.emailingDomainDriverFactory
|
||||
.getCurrentDriver()
|
||||
.sendEmail(emailToSend);
|
||||
}
|
||||
|
||||
private async resolveReplyTo(
|
||||
workspaceId: string,
|
||||
emailContent: EmailingDomainEmailContent,
|
||||
): Promise<string[] | undefined> {
|
||||
if (isDefined(emailContent.replyTo) && emailContent.replyTo.length > 0) {
|
||||
return emailContent.replyTo;
|
||||
}
|
||||
|
||||
const emailGroupChannel = await this.messageChannelRepository.findOne({
|
||||
where: {
|
||||
workspaceId,
|
||||
type: MessageChannelType.EMAIL_GROUP,
|
||||
connectedAccount: { handle: emailContent.from },
|
||||
},
|
||||
relations: { connectedAccount: true },
|
||||
});
|
||||
|
||||
const forwardingAddress = emailGroupChannel?.handle;
|
||||
|
||||
return isNonEmptyString(forwardingAddress)
|
||||
? [forwardingAddress]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private async findEmailingDomainByIdOrThrow(
|
||||
workspaceId: string,
|
||||
emailingDomainId: string,
|
||||
): Promise<EmailingDomainEntity> {
|
||||
const emailingDomain = await this.emailingDomainRepository.findOne(
|
||||
workspaceId,
|
||||
{ where: { id: emailingDomainId } },
|
||||
);
|
||||
|
||||
if (!isDefined(emailingDomain)) {
|
||||
throw new EmailingDomainDriverException(
|
||||
'Emailing domain not found',
|
||||
EmailingDomainDriverExceptionCode.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return emailingDomain;
|
||||
}
|
||||
|
||||
private assertDomainCanSend(
|
||||
emailingDomain: EmailingDomainEntity,
|
||||
fromAddress: string,
|
||||
): void {
|
||||
if (emailingDomain.status !== EmailingDomainStatus.VERIFIED) {
|
||||
throw new EmailingDomainDriverException(
|
||||
`Emailing domain is not verified (status: ${emailingDomain.status})`,
|
||||
EmailingDomainDriverExceptionCode.CONFIGURATION_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
if (emailingDomain.tenantStatus !== EmailingDomainTenantStatus.ACTIVE) {
|
||||
throw new EmailingDomainDriverException(
|
||||
`Sending is suspended for emailing domain ${emailingDomain.domain} (tenantStatus: ${emailingDomain.tenantStatus})`,
|
||||
EmailingDomainDriverExceptionCode.SENDING_SUSPENDED,
|
||||
);
|
||||
}
|
||||
|
||||
const fromAddressDomain = getDomainFromEmail(fromAddress)?.toLowerCase();
|
||||
|
||||
if (fromAddressDomain !== emailingDomain.domain.toLowerCase()) {
|
||||
throw new EmailingDomainDriverException(
|
||||
`From address ${fromAddress} does not match verified domain ${emailingDomain.domain}`,
|
||||
EmailingDomainDriverExceptionCode.CONFIGURATION_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async selectDeliverableRecipients(
|
||||
workspaceId: string,
|
||||
emailingDomain: EmailingDomainEntity,
|
||||
emailContent: EmailingDomainEmailContent,
|
||||
messageCategory: EmailGroupMessageCategory,
|
||||
): Promise<DeliverableRecipients> {
|
||||
const allRecipients = [
|
||||
...emailContent.to,
|
||||
...(emailContent.cc ?? []),
|
||||
...(emailContent.bcc ?? []),
|
||||
];
|
||||
|
||||
const suppressedAddresses =
|
||||
await this.messageSuppressionService.getSuppressedAddresses(
|
||||
workspaceId,
|
||||
allRecipients,
|
||||
messageCategory,
|
||||
);
|
||||
|
||||
const listUnsubscribedAddresses = await this.getListUnsubscribedAddresses(
|
||||
workspaceId,
|
||||
allRecipients,
|
||||
messageCategory,
|
||||
emailContent.messageTopicId,
|
||||
);
|
||||
|
||||
const isDeliverable = (address: string): boolean => {
|
||||
const normalizedAddress = address.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
!suppressedAddresses.has(normalizedAddress) &&
|
||||
!listUnsubscribedAddresses.has(normalizedAddress)
|
||||
);
|
||||
};
|
||||
|
||||
const to = emailContent.to.filter(isDeliverable);
|
||||
|
||||
if (to.length === 0) {
|
||||
throw new EmailingDomainDriverException(
|
||||
`All primary recipients are suppressed for emailing domain ${emailingDomain.domain}`,
|
||||
EmailingDomainDriverExceptionCode.ALL_RECIPIENTS_SUPPRESSED,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
to,
|
||||
cc: emailContent.cc?.filter(isDeliverable),
|
||||
bcc: emailContent.bcc?.filter(isDeliverable),
|
||||
};
|
||||
}
|
||||
|
||||
private async getListUnsubscribedAddresses(
|
||||
workspaceId: string,
|
||||
recipients: string[],
|
||||
messageCategory: EmailGroupMessageCategory,
|
||||
messageTopicId: string | undefined,
|
||||
): Promise<Set<string>> {
|
||||
if (
|
||||
messageCategory !== EmailGroupMessageCategory.CAMPAIGN ||
|
||||
!isNonEmptyString(messageTopicId)
|
||||
) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.messageTopicSubscriptionService.getAddressesUnsubscribedFromList(
|
||||
workspaceId,
|
||||
recipients,
|
||||
messageTopicId,
|
||||
);
|
||||
}
|
||||
|
||||
private buildUnsubscribeContent(
|
||||
workspaceId: string,
|
||||
emailingDomain: EmailingDomainEntity,
|
||||
messageCategory: EmailGroupMessageCategory,
|
||||
primaryRecipient: string,
|
||||
messageTopicId: string | undefined,
|
||||
): UnsubscribeContent {
|
||||
if (messageCategory !== EmailGroupMessageCategory.CAMPAIGN) {
|
||||
return EMPTY_UNSUBSCRIBE_CONTENT;
|
||||
}
|
||||
|
||||
if (
|
||||
emailingDomain.unsubscribeHostnameStatus !==
|
||||
UnsubscribeHostnameStatus.ACTIVE ||
|
||||
!isNonEmptyString(emailingDomain.unsubscribeHostname)
|
||||
) {
|
||||
throw new EmailingDomainDriverException(
|
||||
`Cannot send marketing email for ${emailingDomain.domain}: unsubscribe domain is not active (status: ${emailingDomain.unsubscribeHostnameStatus})`,
|
||||
EmailingDomainDriverExceptionCode.UNSUBSCRIBE_NOT_READY,
|
||||
);
|
||||
}
|
||||
|
||||
const token = this.unsubscribeTokenService.sign({
|
||||
workspaceId,
|
||||
emailAddress: primaryRecipient,
|
||||
...(isNonEmptyString(messageTopicId) ? { messageTopicId } : {}),
|
||||
});
|
||||
|
||||
const unsubscribeUrls = buildUnsubscribeUrls({
|
||||
unsubscribeHostname: emailingDomain.unsubscribeHostname,
|
||||
domain: emailingDomain.domain,
|
||||
token,
|
||||
});
|
||||
|
||||
return {
|
||||
headers: buildUnsubscribeHeaders(unsubscribeUrls),
|
||||
textFooter: buildUnsubscribeTextFooter(unsubscribeUrls.httpsUrl),
|
||||
htmlFooter: buildUnsubscribeHtmlFooter(unsubscribeUrls.httpsUrl),
|
||||
};
|
||||
}
|
||||
}
|
||||
+53
-56
@@ -7,15 +7,12 @@ import {
|
||||
import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory';
|
||||
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
|
||||
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
|
||||
import { EmailingDomainTenantStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type';
|
||||
import {
|
||||
type EmailingDomainEmailContent,
|
||||
type EmailingDomainSendEmailResult,
|
||||
} from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
|
||||
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
|
||||
import { UnsubscribeHostnameService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-hostname.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
|
||||
@Injectable()
|
||||
export class EmailingDomainService {
|
||||
private readonly logger = new Logger(EmailingDomainService.name);
|
||||
@@ -24,6 +21,7 @@ export class EmailingDomainService {
|
||||
@InjectWorkspaceScopedRepository(EmailingDomainEntity)
|
||||
private readonly emailingDomainRepository: WorkspaceScopedRepository<EmailingDomainEntity>,
|
||||
private readonly emailingDomainDriverFactory: EmailingDomainDriverFactory,
|
||||
private readonly unsubscribeHostnameService: UnsubscribeHostnameService,
|
||||
) {}
|
||||
|
||||
async createEmailingDomain(
|
||||
@@ -63,13 +61,32 @@ export class EmailingDomainService {
|
||||
const isVerifiedOnCreation =
|
||||
verificationResult.status === EmailingDomainStatus.VERIFIED;
|
||||
|
||||
return this.emailingDomainRepository.save(workspace.id, {
|
||||
domain,
|
||||
driver: driverType,
|
||||
status: verificationResult.status,
|
||||
verificationRecords: verificationResult.verificationRecords,
|
||||
verifiedAt: isVerifiedOnCreation ? new Date() : null,
|
||||
});
|
||||
const emailingDomain = await this.emailingDomainRepository.save(
|
||||
workspace.id,
|
||||
{
|
||||
domain,
|
||||
driver: driverType,
|
||||
status: verificationResult.status,
|
||||
verificationRecords: verificationResult.verificationRecords,
|
||||
verifiedAt: isVerifiedOnCreation ? new Date() : null,
|
||||
},
|
||||
);
|
||||
|
||||
if (isVerifiedOnCreation) {
|
||||
await this.unsubscribeHostnameService.sync(
|
||||
workspace.id,
|
||||
emailingDomain.id,
|
||||
{
|
||||
provision: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return this.unsubscribeHostnameService.withDnsRecords(
|
||||
await this.emailingDomainRepository.findOneOrFail(workspace.id, {
|
||||
where: { id: emailingDomain.id },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteEmailingDomain(
|
||||
@@ -81,6 +98,7 @@ export class EmailingDomainService {
|
||||
emailingDomainId,
|
||||
);
|
||||
|
||||
await this.unsubscribeHostnameService.deprovision(emailingDomain);
|
||||
await this.deleteRemoteEmailingDomain(emailingDomain);
|
||||
await this.emailingDomainRepository.delete(workspace.id, {
|
||||
id: emailingDomain.id,
|
||||
@@ -116,9 +134,18 @@ export class EmailingDomainService {
|
||||
async getEmailingDomains(
|
||||
workspace: WorkspaceEntity,
|
||||
): Promise<EmailingDomainEntity[]> {
|
||||
return this.emailingDomainRepository.find(workspace.id, {
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
const emailingDomains = await this.emailingDomainRepository.find(
|
||||
workspace.id,
|
||||
{
|
||||
order: { createdAt: 'DESC' },
|
||||
},
|
||||
);
|
||||
|
||||
return Promise.all(
|
||||
emailingDomains.map((emailingDomain) =>
|
||||
this.unsubscribeHostnameService.withDnsRecords(emailingDomain),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async verifyEmailingDomain(
|
||||
@@ -152,49 +179,19 @@ export class EmailingDomainService {
|
||||
},
|
||||
);
|
||||
|
||||
return this.emailingDomainRepository.findOneOrFail(workspace.id, {
|
||||
where: { id: emailingDomain.id },
|
||||
});
|
||||
}
|
||||
|
||||
async sendEmail(
|
||||
workspaceId: string,
|
||||
emailingDomainId: string,
|
||||
emailContent: EmailingDomainEmailContent,
|
||||
): Promise<EmailingDomainSendEmailResult> {
|
||||
const emailingDomain = await this.findEmailingDomainByIdOrThrow(
|
||||
workspaceId,
|
||||
emailingDomainId,
|
||||
await this.unsubscribeHostnameService.sync(
|
||||
workspace.id,
|
||||
emailingDomain.id,
|
||||
{
|
||||
provision: verificationResult.status === EmailingDomainStatus.VERIFIED,
|
||||
},
|
||||
);
|
||||
|
||||
if (emailingDomain.status !== EmailingDomainStatus.VERIFIED) {
|
||||
throw new EmailingDomainDriverException(
|
||||
`Emailing domain is not verified (status: ${emailingDomain.status})`,
|
||||
EmailingDomainDriverExceptionCode.CONFIGURATION_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
if (emailingDomain.tenantStatus !== EmailingDomainTenantStatus.ACTIVE) {
|
||||
throw new EmailingDomainDriverException(
|
||||
`Sending is suspended for emailing domain ${emailingDomain.domain} (tenantStatus: ${emailingDomain.tenantStatus})`,
|
||||
EmailingDomainDriverExceptionCode.SENDING_SUSPENDED,
|
||||
);
|
||||
}
|
||||
|
||||
const fromAddressDomain = emailContent.from.split('@')[1]?.toLowerCase();
|
||||
|
||||
if (fromAddressDomain !== emailingDomain.domain.toLowerCase()) {
|
||||
throw new EmailingDomainDriverException(
|
||||
`From address ${emailContent.from} does not match verified domain ${emailingDomain.domain}`,
|
||||
EmailingDomainDriverExceptionCode.CONFIGURATION_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
return this.emailingDomainDriverFactory.getCurrentDriver().sendEmail({
|
||||
...emailContent,
|
||||
workspaceId,
|
||||
domain: emailingDomain.domain,
|
||||
});
|
||||
return this.unsubscribeHostnameService.withDnsRecords(
|
||||
await this.emailingDomainRepository.findOneOrFail(workspace.id, {
|
||||
where: { id: emailingDomain.id },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async findEmailingDomainByIdOrThrow(
|
||||
|
||||
+647
@@ -0,0 +1,647 @@
|
||||
import { Injectable, Logger, type Type } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { In, type ObjectLiteral } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
CAMPAIGN_MESSAGE_DELIVERY_STATUS,
|
||||
CAMPAIGN_STATUS,
|
||||
MAX_CAMPAIGN_RECIPIENTS,
|
||||
SEND_CAMPAIGN_EMAIL_JOB,
|
||||
} from 'src/engine/core-modules/emailing-domain/constants/campaign.constant';
|
||||
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
|
||||
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
|
||||
import { EmailingDomainSenderService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain-sender.service';
|
||||
import { EmailGroupMessageCategory } from 'src/engine/core-modules/emailing-domain/types/email-group-message-category.type';
|
||||
import { type SendCampaignEmailJobData } from 'src/engine/core-modules/emailing-domain/types/send-campaign-email-job-data.type';
|
||||
import {
|
||||
type CampaignRecipient,
|
||||
type CampaignSkippedBreakdown,
|
||||
normalizeCampaignRecipients,
|
||||
type RawCampaignRecipient,
|
||||
} from 'src/engine/core-modules/emailing-domain/utils/normalize-campaign-recipients.util';
|
||||
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
|
||||
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 { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service';
|
||||
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { MessageCampaignWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-campaign.workspace-entity';
|
||||
import { MessageListMemberWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-list-member.workspace-entity';
|
||||
import { MessageTopicSubscriptionWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-topic-subscription.workspace-entity';
|
||||
import { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum';
|
||||
import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
|
||||
import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
|
||||
import { MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
|
||||
import { MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
|
||||
import { MessageParticipantRole } from 'twenty-shared/types';
|
||||
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
|
||||
import { createHtmlToTextConverter } from 'src/modules/messaging/message-import-manager/utils/create-html-to-text-converter.util';
|
||||
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
|
||||
|
||||
type SendCampaignArgs = {
|
||||
workspaceId: string;
|
||||
userWorkspaceId: string;
|
||||
messageTopicId: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
fromAddress: string;
|
||||
// When set, recipients come from this Person view's filters (dynamic audience).
|
||||
// Otherwise recipients are the people subscribed to the topic.
|
||||
recipientViewId?: string;
|
||||
// When set, recipients are the list's hand-picked members (static audience).
|
||||
// Takes precedence over recipientViewId and topic subscribers.
|
||||
listId?: string;
|
||||
};
|
||||
|
||||
type SendCampaignResult = {
|
||||
campaignId: string;
|
||||
queuedCount: number;
|
||||
skipped: CampaignSkippedBreakdown;
|
||||
};
|
||||
|
||||
const SUBSCRIBED_STATUS = 'SUBSCRIBED';
|
||||
const PERSON_OBJECT_NAME = 'person';
|
||||
|
||||
const toRawRecipient = (person: {
|
||||
id: string;
|
||||
emails?: { primaryEmail?: string | null } | null;
|
||||
}): RawCampaignRecipient => ({
|
||||
personId: person.id,
|
||||
email: person.emails?.primaryEmail ?? null,
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class MessageCampaignService {
|
||||
private readonly logger = new Logger(MessageCampaignService.name);
|
||||
private readonly htmlToText = createHtmlToTextConverter();
|
||||
|
||||
constructor(
|
||||
@InjectWorkspaceScopedRepository(EmailingDomainEntity)
|
||||
private readonly emailingDomainRepository: WorkspaceScopedRepository<EmailingDomainEntity>,
|
||||
private readonly emailingDomainSenderService: EmailingDomainSenderService,
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
@InjectMessageQueue(MessageQueue.emailQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
// Resolved lazily to avoid a module-load cycle: the record-crud/view and
|
||||
// message-channel graphs cannot be eagerly imported from this early-loaded
|
||||
// core module without a require-time TDZ.
|
||||
private readonly moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
// System-context, permission-bypassing repository for a workspace entity.
|
||||
private getWorkspaceRepository<T extends ObjectLiteral>(
|
||||
workspaceId: string,
|
||||
entity: Type<T>,
|
||||
) {
|
||||
return this.globalWorkspaceOrmManager.getRepository(workspaceId, entity, {
|
||||
shouldBypassPermissionChecks: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Orchestrates a campaign send: resolves the verified domain + email-group
|
||||
// channel, materializes one QUEUED message per recipient, and enqueues a send
|
||||
// job each. Sending happens asynchronously in the job, not in this request.
|
||||
async send({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
messageTopicId,
|
||||
subject,
|
||||
html,
|
||||
fromAddress,
|
||||
recipientViewId,
|
||||
listId,
|
||||
}: SendCampaignArgs): Promise<SendCampaignResult> {
|
||||
const fromDomain = getDomainFromEmail(fromAddress)?.toLowerCase();
|
||||
|
||||
const emailingDomain = await this.emailingDomainRepository.findOne(
|
||||
workspaceId,
|
||||
{ where: { domain: fromDomain, status: EmailingDomainStatus.VERIFIED } },
|
||||
);
|
||||
|
||||
if (emailingDomain === null) {
|
||||
throw new Error(
|
||||
`No verified emailing domain matches the from address ${fromAddress}`,
|
||||
);
|
||||
}
|
||||
|
||||
const messageChannelMetadataService = this.moduleRef.get(
|
||||
MessageChannelMetadataService,
|
||||
{ strict: false },
|
||||
);
|
||||
|
||||
const messageChannel =
|
||||
await messageChannelMetadataService.getOrCreateEmailGroupChannel({
|
||||
fromAddress,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const text = this.htmlToText(html);
|
||||
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
const usesFilter =
|
||||
isNonEmptyString(listId) || isNonEmptyString(recipientViewId);
|
||||
|
||||
const rawRecipients = isNonEmptyString(listId)
|
||||
? await this.resolveRecipientsFromList(workspaceId, listId)
|
||||
: isNonEmptyString(recipientViewId)
|
||||
? await this.resolveRecipientsFromView(workspaceId, recipientViewId)
|
||||
: await this.resolveSubscribedRecipients(
|
||||
workspaceId,
|
||||
messageTopicId,
|
||||
);
|
||||
|
||||
const { recipients, skipped } = normalizeCampaignRecipients(
|
||||
rawRecipients,
|
||||
MAX_CAMPAIGN_RECIPIENTS,
|
||||
);
|
||||
|
||||
const campaignRepository = await this.getWorkspaceRepository(
|
||||
workspaceId,
|
||||
MessageCampaignWorkspaceEntity,
|
||||
);
|
||||
|
||||
const { identifiers } = await campaignRepository.insert({
|
||||
name: subject,
|
||||
subject,
|
||||
bodyTemplate: html,
|
||||
fromAddress,
|
||||
status: CAMPAIGN_STATUS.SENDING,
|
||||
recipientSource: usesFilter ? 'FILTER' : 'LIST',
|
||||
recipientViewId: recipientViewId ?? null,
|
||||
topicId: messageTopicId,
|
||||
});
|
||||
const campaignId = identifiers[0].id;
|
||||
|
||||
const messageIds = await this.materializeCampaignMessages({
|
||||
workspaceId,
|
||||
campaignId,
|
||||
messageChannelId: messageChannel.id,
|
||||
fromAddress,
|
||||
recipients,
|
||||
subject,
|
||||
text,
|
||||
});
|
||||
|
||||
for (let index = 0; index < recipients.length; index += 1) {
|
||||
await this.messageQueueService.add<SendCampaignEmailJobData>(
|
||||
SEND_CAMPAIGN_EMAIL_JOB,
|
||||
{
|
||||
workspaceId,
|
||||
campaignId,
|
||||
messageId: messageIds[index],
|
||||
recipientEmail: recipients[index].email,
|
||||
emailingDomainId: emailingDomain.id,
|
||||
messageTopicId,
|
||||
fromAddress,
|
||||
subject,
|
||||
html,
|
||||
},
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
}
|
||||
|
||||
// Marks the campaign SENT immediately when nothing was queued
|
||||
// (zero-recipient case); otherwise the last send job finalizes it.
|
||||
await this.finalizeCampaignIfComplete(workspaceId, campaignId);
|
||||
|
||||
return { campaignId, queuedCount: recipients.length, skipped };
|
||||
},
|
||||
buildSystemAuthContext(workspaceId),
|
||||
);
|
||||
}
|
||||
|
||||
// Processes one recipient's send job: idempotent, sends via the emailing-domain
|
||||
// sender (keeps suppression + unsubscribe + reply-to), records per-message
|
||||
// status, and finalizes the campaign once nothing remains queued.
|
||||
async processSendJob(data: SendCampaignEmailJobData): Promise<void> {
|
||||
const {
|
||||
workspaceId,
|
||||
campaignId,
|
||||
messageId,
|
||||
recipientEmail,
|
||||
emailingDomainId,
|
||||
messageTopicId,
|
||||
fromAddress,
|
||||
subject,
|
||||
html,
|
||||
} = data;
|
||||
|
||||
const text = this.htmlToText(html);
|
||||
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
|
||||
const messageRepository = await this.getWorkspaceRepository(
|
||||
workspaceId,
|
||||
MessageWorkspaceEntity,
|
||||
);
|
||||
|
||||
const message = await messageRepository.findOne({
|
||||
where: { id: messageId },
|
||||
});
|
||||
|
||||
// Idempotency: BullMQ may retry. Only a still-QUEUED message is sent.
|
||||
if (
|
||||
message === null ||
|
||||
message.deliveryStatus !== CAMPAIGN_MESSAGE_DELIVERY_STATUS.QUEUED
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.emailingDomainSenderService.sendEmail(
|
||||
workspaceId,
|
||||
emailingDomainId,
|
||||
{
|
||||
from: fromAddress,
|
||||
to: [recipientEmail],
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
messageCategory: EmailGroupMessageCategory.CAMPAIGN,
|
||||
messageTopicId,
|
||||
},
|
||||
);
|
||||
|
||||
// Adopt the SES id as the message id so a reply (whose In-Reply-To
|
||||
// references it) threads back onto this send, mirroring the existing
|
||||
// email-group outbound model.
|
||||
await messageRepository.update(messageId, {
|
||||
deliveryStatus: CAMPAIGN_MESSAGE_DELIVERY_STATUS.SENT,
|
||||
headerMessageId: result.messageId,
|
||||
});
|
||||
|
||||
const associationRepository = await this.getWorkspaceRepository(
|
||||
workspaceId,
|
||||
MessageChannelMessageAssociationWorkspaceEntity,
|
||||
);
|
||||
|
||||
await associationRepository.update(
|
||||
{ messageId },
|
||||
{
|
||||
messageExternalId: result.messageId,
|
||||
messageThreadExternalId: result.messageId,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
await messageRepository.update(messageId, {
|
||||
deliveryStatus: CAMPAIGN_MESSAGE_DELIVERY_STATUS.FAILED,
|
||||
});
|
||||
this.logger.warn(
|
||||
`Campaign ${campaignId} failed for ${recipientEmail}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.finalizeCampaignIfComplete(workspaceId, campaignId);
|
||||
}, buildSystemAuthContext(workspaceId));
|
||||
}
|
||||
|
||||
// Correlates an SES bounce/complaint (by the send's message id) to the
|
||||
// campaign message and records BOUNCED/COMPLAINED, then refreshes the campaign
|
||||
// counts. No-op when the id doesn't match a campaign send.
|
||||
async recordDeliveryFailureByProviderMessageId({
|
||||
workspaceId,
|
||||
providerMessageId,
|
||||
deliveryStatus,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
providerMessageId: string;
|
||||
deliveryStatus: string;
|
||||
}): Promise<void> {
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
|
||||
const messageRepository = await this.getWorkspaceRepository(
|
||||
workspaceId,
|
||||
MessageWorkspaceEntity,
|
||||
);
|
||||
|
||||
const message = await messageRepository.findOne({
|
||||
where: { headerMessageId: providerMessageId },
|
||||
});
|
||||
|
||||
if (message === null || message.messageCampaignId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await messageRepository.update(message.id, { deliveryStatus });
|
||||
|
||||
const counts = await this.computeCampaignCounts(
|
||||
workspaceId,
|
||||
message.messageCampaignId,
|
||||
);
|
||||
|
||||
const campaignRepository = await this.getWorkspaceRepository(
|
||||
workspaceId,
|
||||
MessageCampaignWorkspaceEntity,
|
||||
);
|
||||
|
||||
await campaignRepository.update(message.messageCampaignId, counts);
|
||||
}, buildSystemAuthContext(workspaceId));
|
||||
}
|
||||
|
||||
// Materializes the campaign's outbound messages (QUEUED) + their threads,
|
||||
// channel associations and from/to participants, with each recipient's
|
||||
// personId set directly (no participant matching, no contact auto-creation).
|
||||
// Written as four bulk inserts in one transaction. Returns the message ids,
|
||||
// aligned with the recipients order. Must run inside an active workspace
|
||||
// context.
|
||||
private async materializeCampaignMessages({
|
||||
workspaceId,
|
||||
campaignId,
|
||||
messageChannelId,
|
||||
fromAddress,
|
||||
recipients,
|
||||
subject,
|
||||
text,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
campaignId: string;
|
||||
messageChannelId: string;
|
||||
fromAddress: string;
|
||||
recipients: CampaignRecipient[];
|
||||
subject: string;
|
||||
text: string;
|
||||
}): Promise<string[]> {
|
||||
const now = new Date();
|
||||
const rows = recipients.map((recipient) => ({
|
||||
recipient,
|
||||
threadId: v4(),
|
||||
messageId: v4(),
|
||||
// Temporary id; the send job overwrites it with the SES id so the stored
|
||||
// ids match what the recipient receives (reply-threading).
|
||||
temporaryExternalId: v4(),
|
||||
}));
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const messageThreadRepository = await this.getWorkspaceRepository(
|
||||
workspaceId,
|
||||
MessageThreadWorkspaceEntity,
|
||||
);
|
||||
const messageRepository = await this.getWorkspaceRepository(
|
||||
workspaceId,
|
||||
MessageWorkspaceEntity,
|
||||
);
|
||||
const associationRepository = await this.getWorkspaceRepository(
|
||||
workspaceId,
|
||||
MessageChannelMessageAssociationWorkspaceEntity,
|
||||
);
|
||||
const participantRepository = await this.getWorkspaceRepository(
|
||||
workspaceId,
|
||||
MessageParticipantWorkspaceEntity,
|
||||
);
|
||||
|
||||
const workspaceDataSource =
|
||||
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
|
||||
|
||||
if (!workspaceDataSource) {
|
||||
throw new Error(
|
||||
`No workspace datasource available for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
await workspaceDataSource.transaction(
|
||||
async (transactionManager: WorkspaceEntityManager) => {
|
||||
await messageThreadRepository.insert(
|
||||
rows.map((row) => ({ id: row.threadId })),
|
||||
transactionManager,
|
||||
);
|
||||
await messageRepository.insert(
|
||||
rows.map((row) => ({
|
||||
id: row.messageId,
|
||||
headerMessageId: row.temporaryExternalId,
|
||||
subject,
|
||||
text,
|
||||
receivedAt: now,
|
||||
messageThreadId: row.threadId,
|
||||
messageCampaignId: campaignId,
|
||||
deliveryStatus: CAMPAIGN_MESSAGE_DELIVERY_STATUS.QUEUED,
|
||||
})),
|
||||
transactionManager,
|
||||
);
|
||||
await associationRepository.insert(
|
||||
rows.map((row) => ({
|
||||
id: v4(),
|
||||
messageId: row.messageId,
|
||||
messageChannelId,
|
||||
messageExternalId: row.temporaryExternalId,
|
||||
messageThreadExternalId: row.temporaryExternalId,
|
||||
direction: MessageDirection.OUTGOING,
|
||||
})),
|
||||
transactionManager,
|
||||
);
|
||||
await participantRepository.insert(
|
||||
rows.flatMap((row) => [
|
||||
{
|
||||
id: v4(),
|
||||
messageId: row.messageId,
|
||||
role: MessageParticipantRole.FROM,
|
||||
handle: fromAddress,
|
||||
displayName: fromAddress,
|
||||
},
|
||||
{
|
||||
id: v4(),
|
||||
messageId: row.messageId,
|
||||
role: MessageParticipantRole.TO,
|
||||
handle: row.recipient.email,
|
||||
displayName: row.recipient.email,
|
||||
personId: row.recipient.personId,
|
||||
},
|
||||
]),
|
||||
transactionManager,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return rows.map((row) => row.messageId);
|
||||
}
|
||||
|
||||
// Derives the campaign counts from its messages and finalizes it once nothing
|
||||
// remains QUEUED. The status='SENDING' guard makes this a compare-and-swap so
|
||||
// concurrent jobs finalize exactly once. Must run inside a workspace context.
|
||||
private async finalizeCampaignIfComplete(
|
||||
workspaceId: string,
|
||||
campaignId: string,
|
||||
): Promise<void> {
|
||||
const messageRepository = await this.getWorkspaceRepository(
|
||||
workspaceId,
|
||||
MessageWorkspaceEntity,
|
||||
);
|
||||
|
||||
const queuedCount = await messageRepository.count({
|
||||
where: {
|
||||
messageCampaignId: campaignId,
|
||||
deliveryStatus: CAMPAIGN_MESSAGE_DELIVERY_STATUS.QUEUED,
|
||||
},
|
||||
});
|
||||
|
||||
if (queuedCount > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const counts = await this.computeCampaignCounts(workspaceId, campaignId);
|
||||
|
||||
const campaignRepository = await this.getWorkspaceRepository(
|
||||
workspaceId,
|
||||
MessageCampaignWorkspaceEntity,
|
||||
);
|
||||
|
||||
await campaignRepository.update(
|
||||
{ id: campaignId, status: CAMPAIGN_STATUS.SENDING },
|
||||
{
|
||||
status: CAMPAIGN_STATUS.SENT,
|
||||
sentAt: new Date(),
|
||||
...counts,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Counts a campaign's per-recipient outcomes from its messages.
|
||||
private async computeCampaignCounts(
|
||||
workspaceId: string,
|
||||
campaignId: string,
|
||||
): Promise<{ sentCount: number; failedCount: number; bouncedCount: number }> {
|
||||
const messageRepository = await this.getWorkspaceRepository(
|
||||
workspaceId,
|
||||
MessageWorkspaceEntity,
|
||||
);
|
||||
|
||||
const [sentCount, failedCount, bouncedCount] = await Promise.all([
|
||||
messageRepository.count({
|
||||
where: {
|
||||
messageCampaignId: campaignId,
|
||||
deliveryStatus: CAMPAIGN_MESSAGE_DELIVERY_STATUS.SENT,
|
||||
},
|
||||
}),
|
||||
messageRepository.count({
|
||||
where: {
|
||||
messageCampaignId: campaignId,
|
||||
deliveryStatus: CAMPAIGN_MESSAGE_DELIVERY_STATUS.FAILED,
|
||||
},
|
||||
}),
|
||||
messageRepository.count({
|
||||
where: {
|
||||
messageCampaignId: campaignId,
|
||||
deliveryStatus: In([
|
||||
CAMPAIGN_MESSAGE_DELIVERY_STATUS.BOUNCED,
|
||||
CAMPAIGN_MESSAGE_DELIVERY_STATUS.COMPLAINED,
|
||||
]),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { sentCount, failedCount, bouncedCount };
|
||||
}
|
||||
|
||||
// Resolves the recipients of a campaign from a saved Person view (list):
|
||||
// the view's filters are run server-side to produce the list of people.
|
||||
private async resolveRecipientsFromView(
|
||||
workspaceId: string,
|
||||
recipientViewId: string,
|
||||
): Promise<RawCampaignRecipient[]> {
|
||||
const viewQueryParamsService = this.moduleRef.get(ViewQueryParamsService, {
|
||||
strict: false,
|
||||
});
|
||||
const findRecordsService = this.moduleRef.get(FindRecordsService, {
|
||||
strict: false,
|
||||
});
|
||||
|
||||
const viewParams = await viewQueryParamsService.resolveViewToQueryParams(
|
||||
recipientViewId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (viewParams.objectNameSingular !== PERSON_OBJECT_NAME) {
|
||||
throw new Error(
|
||||
`Recipient view must target ${PERSON_OBJECT_NAME} records, got ${viewParams.objectNameSingular}`,
|
||||
);
|
||||
}
|
||||
|
||||
const output = await findRecordsService.execute({
|
||||
objectName: PERSON_OBJECT_NAME,
|
||||
filter: viewParams.filter,
|
||||
authContext: buildSystemAuthContext(workspaceId),
|
||||
});
|
||||
|
||||
if (!output.success || !output.result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const records = output.result.records as Array<{
|
||||
id: string;
|
||||
emails?: { primaryEmail?: string | null };
|
||||
}>;
|
||||
|
||||
return records.map(toRawRecipient);
|
||||
}
|
||||
|
||||
// Resolves the recipients of a campaign from a list's hand-picked members.
|
||||
private async resolveRecipientsFromList(
|
||||
workspaceId: string,
|
||||
listId: string,
|
||||
): Promise<RawCampaignRecipient[]> {
|
||||
const listMemberRepository = await this.getWorkspaceRepository(
|
||||
workspaceId,
|
||||
MessageListMemberWorkspaceEntity,
|
||||
);
|
||||
|
||||
const members = await listMemberRepository.find({
|
||||
where: { listId },
|
||||
});
|
||||
|
||||
return this.loadRecipientsByPersonIds(
|
||||
workspaceId,
|
||||
members.map((member) => member.personId),
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveSubscribedRecipients(
|
||||
workspaceId: string,
|
||||
messageTopicId: string,
|
||||
): Promise<RawCampaignRecipient[]> {
|
||||
const subscriptionRepository = await this.getWorkspaceRepository(
|
||||
workspaceId,
|
||||
MessageTopicSubscriptionWorkspaceEntity,
|
||||
);
|
||||
|
||||
const subscriptions = await subscriptionRepository.find({
|
||||
where: { topicId: messageTopicId, status: SUBSCRIBED_STATUS },
|
||||
});
|
||||
|
||||
return this.loadRecipientsByPersonIds(
|
||||
workspaceId,
|
||||
subscriptions.map((subscription) => subscription.personId),
|
||||
);
|
||||
}
|
||||
|
||||
private async loadRecipientsByPersonIds(
|
||||
workspaceId: string,
|
||||
personIds: string[],
|
||||
): Promise<RawCampaignRecipient[]> {
|
||||
if (personIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const personRepository = await this.getWorkspaceRepository(
|
||||
workspaceId,
|
||||
PersonWorkspaceEntity,
|
||||
);
|
||||
|
||||
const people = await personRepository.find({
|
||||
where: { id: In(personIds) },
|
||||
});
|
||||
|
||||
return people.map(toRawRecipient);
|
||||
}
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import { In, IsNull } from 'typeorm';
|
||||
|
||||
import { BLOCKING_REASONS_BY_MESSAGE_CATEGORY } from 'src/engine/core-modules/emailing-domain/constants/blocking-reasons-by-message-category.constant';
|
||||
import { EmailGroupMessageCategory } from 'src/engine/core-modules/emailing-domain/types/email-group-message-category.type';
|
||||
import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type';
|
||||
import { MessageSuppressionSource } from 'src/engine/core-modules/emailing-domain/types/message-suppression-source.type';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { MessageSuppressionWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-suppression.workspace-entity';
|
||||
|
||||
type SuppressArgs = {
|
||||
workspaceId: string;
|
||||
emailAddress: string;
|
||||
reason: MessageSuppressionReason;
|
||||
source: MessageSuppressionSource;
|
||||
providerEventId?: string | null;
|
||||
topicId?: string | null;
|
||||
};
|
||||
|
||||
const HARD_SUPPRESSION_REASONS = [
|
||||
MessageSuppressionReason.BOUNCE,
|
||||
MessageSuppressionReason.COMPLAINT,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class MessageSuppressionService {
|
||||
constructor(
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
) {}
|
||||
|
||||
async getSuppressedAddresses(
|
||||
workspaceId: string,
|
||||
emailAddresses: string[],
|
||||
messageCategory: EmailGroupMessageCategory,
|
||||
): Promise<Set<string>> {
|
||||
const normalizedAddresses = [
|
||||
...new Set(
|
||||
emailAddresses.map((emailAddress) => emailAddress.trim().toLowerCase()),
|
||||
),
|
||||
];
|
||||
|
||||
if (!isNonEmptyArray(normalizedAddresses)) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const suppressedRecipients =
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
const suppressionRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository(
|
||||
workspaceId,
|
||||
MessageSuppressionWorkspaceEntity,
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
return suppressionRepository.find({
|
||||
where: {
|
||||
emailAddress: In(normalizedAddresses),
|
||||
reason: In(BLOCKING_REASONS_BY_MESSAGE_CATEGORY[messageCategory]),
|
||||
topicId: IsNull(),
|
||||
},
|
||||
});
|
||||
},
|
||||
buildSystemAuthContext(workspaceId),
|
||||
);
|
||||
|
||||
return new Set(
|
||||
suppressedRecipients.map((recipient) => recipient.emailAddress),
|
||||
);
|
||||
}
|
||||
|
||||
async getTopicSuppressedAddresses(
|
||||
workspaceId: string,
|
||||
emailAddresses: string[],
|
||||
topicId: string,
|
||||
): Promise<Set<string>> {
|
||||
const normalizedAddresses = [
|
||||
...new Set(
|
||||
emailAddresses.map((emailAddress) => emailAddress.trim().toLowerCase()),
|
||||
),
|
||||
];
|
||||
|
||||
if (!isNonEmptyArray(normalizedAddresses)) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const suppressedRecipients =
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
const suppressionRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository(
|
||||
workspaceId,
|
||||
MessageSuppressionWorkspaceEntity,
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
return suppressionRepository.find({
|
||||
where: {
|
||||
emailAddress: In(normalizedAddresses),
|
||||
reason: MessageSuppressionReason.UNSUBSCRIBE,
|
||||
topicId,
|
||||
},
|
||||
});
|
||||
},
|
||||
buildSystemAuthContext(workspaceId),
|
||||
);
|
||||
|
||||
return new Set(
|
||||
suppressedRecipients.map((recipient) => recipient.emailAddress),
|
||||
);
|
||||
}
|
||||
|
||||
async suppress({
|
||||
workspaceId,
|
||||
emailAddress,
|
||||
reason,
|
||||
source,
|
||||
providerEventId = null,
|
||||
topicId = null,
|
||||
}: SuppressArgs): Promise<void> {
|
||||
const normalizedEmailAddress = emailAddress.trim().toLowerCase();
|
||||
|
||||
if (!isNonEmptyString(normalizedEmailAddress)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
|
||||
const suppressionRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository(
|
||||
workspaceId,
|
||||
MessageSuppressionWorkspaceEntity,
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const existingSuppression = await suppressionRepository.findOneBy({
|
||||
emailAddress: normalizedEmailAddress,
|
||||
topicId: isDefined(topicId) ? topicId : IsNull(),
|
||||
});
|
||||
|
||||
if (!isDefined(existingSuppression)) {
|
||||
await suppressionRepository.insert({
|
||||
emailAddress: normalizedEmailAddress,
|
||||
reason,
|
||||
source,
|
||||
providerEventId,
|
||||
topicId,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shouldEscalate(existingSuppression.reason, reason)) {
|
||||
await suppressionRepository.update(existingSuppression.id, {
|
||||
reason,
|
||||
source,
|
||||
providerEventId,
|
||||
});
|
||||
}
|
||||
}, buildSystemAuthContext(workspaceId));
|
||||
}
|
||||
|
||||
async removeTopicSuppression(
|
||||
workspaceId: string,
|
||||
emailAddress: string,
|
||||
topicId: string,
|
||||
): Promise<void> {
|
||||
const normalizedEmailAddress = emailAddress.trim().toLowerCase();
|
||||
|
||||
if (!isNonEmptyString(normalizedEmailAddress)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
|
||||
const suppressionRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository(
|
||||
workspaceId,
|
||||
MessageSuppressionWorkspaceEntity,
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const existingSuppression = await suppressionRepository.findOneBy({
|
||||
emailAddress: normalizedEmailAddress,
|
||||
reason: MessageSuppressionReason.UNSUBSCRIBE,
|
||||
topicId,
|
||||
});
|
||||
|
||||
if (isDefined(existingSuppression)) {
|
||||
await suppressionRepository.delete(existingSuppression.id);
|
||||
}
|
||||
}, buildSystemAuthContext(workspaceId));
|
||||
}
|
||||
|
||||
private shouldEscalate(
|
||||
existingReason: string,
|
||||
incomingReason: MessageSuppressionReason,
|
||||
): boolean {
|
||||
const existingIsHard = HARD_SUPPRESSION_REASONS.some(
|
||||
(hardReason) => hardReason === existingReason,
|
||||
);
|
||||
const incomingIsHard = HARD_SUPPRESSION_REASONS.includes(incomingReason);
|
||||
|
||||
return !existingIsHard && incomingIsHard;
|
||||
}
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
import { MessageSuppressionService } from 'src/engine/core-modules/emailing-domain/services/message-suppression.service';
|
||||
import { MessageTopicSubscriptionSource } from 'src/engine/core-modules/emailing-domain/types/message-topic-subscription-source.type';
|
||||
import { MessageTopicSubscriptionStatus } from 'src/engine/core-modules/emailing-domain/types/message-topic-subscription-status.type';
|
||||
import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type';
|
||||
import { MessageSuppressionSource } from 'src/engine/core-modules/emailing-domain/types/message-suppression-source.type';
|
||||
import { type SubscribedTopic } from 'src/engine/core-modules/emailing-domain/types/subscribed-topic.type';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { MessageTopicSubscriptionWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-topic-subscription.workspace-entity';
|
||||
import { MessageTopicWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-topic.workspace-entity';
|
||||
import { addPersonEmailFiltersToQueryBuilder } from 'src/modules/match-participant/utils/add-person-email-filters-to-query-builder';
|
||||
import { findPersonByPrimaryOrAdditionalEmail } from 'src/modules/match-participant/utils/find-person-by-primary-or-additional-email';
|
||||
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
|
||||
|
||||
type SubscribeArgs = {
|
||||
workspaceId: string;
|
||||
personId: string;
|
||||
topicId: string;
|
||||
source?: MessageTopicSubscriptionSource;
|
||||
};
|
||||
|
||||
type UnsubscribeArgs = {
|
||||
workspaceId: string;
|
||||
personId: string;
|
||||
topicId: string;
|
||||
};
|
||||
|
||||
type UnsubscribeByEmailArgs = {
|
||||
workspaceId: string;
|
||||
emailAddress: string;
|
||||
topicId: string;
|
||||
};
|
||||
|
||||
type SubscribedTopicsArgs = {
|
||||
workspaceId: string;
|
||||
emailAddress: string;
|
||||
};
|
||||
|
||||
type SetSubscribedTopicsArgs = {
|
||||
workspaceId: string;
|
||||
emailAddress: string;
|
||||
subscribedTopicIds: string[];
|
||||
};
|
||||
|
||||
type UpsertSubscriptionStatusArgs = {
|
||||
workspaceId: string;
|
||||
personId: string;
|
||||
topicId: string;
|
||||
status: MessageTopicSubscriptionStatus;
|
||||
source: MessageTopicSubscriptionSource;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MessageTopicSubscriptionService {
|
||||
constructor(
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
private readonly messageSuppressionService: MessageSuppressionService,
|
||||
) {}
|
||||
|
||||
async subscribe({
|
||||
workspaceId,
|
||||
personId,
|
||||
topicId,
|
||||
source = MessageTopicSubscriptionSource.MANUAL,
|
||||
}: SubscribeArgs): Promise<void> {
|
||||
await this.upsertSubscriptionStatus({
|
||||
workspaceId,
|
||||
personId,
|
||||
topicId,
|
||||
status: MessageTopicSubscriptionStatus.SUBSCRIBED,
|
||||
source,
|
||||
});
|
||||
|
||||
const emailAddress = await this.resolvePrimaryEmailByPersonId(
|
||||
workspaceId,
|
||||
personId,
|
||||
);
|
||||
|
||||
if (isDefined(emailAddress)) {
|
||||
await this.messageSuppressionService.removeTopicSuppression(
|
||||
workspaceId,
|
||||
emailAddress,
|
||||
topicId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async unsubscribe({
|
||||
workspaceId,
|
||||
personId,
|
||||
topicId,
|
||||
}: UnsubscribeArgs): Promise<void> {
|
||||
const emailAddress = await this.resolvePrimaryEmailByPersonId(
|
||||
workspaceId,
|
||||
personId,
|
||||
);
|
||||
|
||||
await this.deleteSubscription({ workspaceId, personId, topicId });
|
||||
|
||||
if (isDefined(emailAddress)) {
|
||||
await this.messageSuppressionService.suppress({
|
||||
workspaceId,
|
||||
emailAddress,
|
||||
reason: MessageSuppressionReason.UNSUBSCRIBE,
|
||||
source: MessageSuppressionSource.SYSTEM,
|
||||
topicId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async unsubscribeByEmail({
|
||||
workspaceId,
|
||||
emailAddress,
|
||||
topicId,
|
||||
}: UnsubscribeByEmailArgs): Promise<boolean> {
|
||||
const personId = await this.resolvePersonIdByEmail(
|
||||
workspaceId,
|
||||
emailAddress,
|
||||
);
|
||||
|
||||
if (!isDefined(personId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.unsubscribe({ workspaceId, personId, topicId });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async getSubscribedTopics({
|
||||
workspaceId,
|
||||
emailAddress,
|
||||
}: SubscribedTopicsArgs): Promise<SubscribedTopic[]> {
|
||||
const personId = await this.resolvePersonIdByEmail(
|
||||
workspaceId,
|
||||
emailAddress,
|
||||
);
|
||||
|
||||
if (!isDefined(personId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
const subscriptionRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository(
|
||||
workspaceId,
|
||||
MessageTopicSubscriptionWorkspaceEntity,
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const subscriptions = await subscriptionRepository.find({
|
||||
where: { personId, status: MessageTopicSubscriptionStatus.SUBSCRIBED },
|
||||
});
|
||||
|
||||
if (!isNonEmptyArray(subscriptions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const topicRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository(
|
||||
workspaceId,
|
||||
MessageTopicWorkspaceEntity,
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const topics = await topicRepository.find({
|
||||
where: {
|
||||
id: In(subscriptions.map((subscription) => subscription.topicId)),
|
||||
},
|
||||
});
|
||||
|
||||
const topicNameById = new Map(
|
||||
topics.map((topic) => [topic.id, topic.name]),
|
||||
);
|
||||
|
||||
return subscriptions.map((subscription) => ({
|
||||
topicId: subscription.topicId,
|
||||
topicName:
|
||||
topicNameById.get(subscription.topicId) ?? subscription.topicName,
|
||||
}));
|
||||
},
|
||||
buildSystemAuthContext(workspaceId),
|
||||
);
|
||||
}
|
||||
|
||||
// Public preference page can only narrow delivery: topics outside the
|
||||
// current subscribed set are ignored, never newly subscribed.
|
||||
async setSubscribedTopics({
|
||||
workspaceId,
|
||||
emailAddress,
|
||||
subscribedTopicIds,
|
||||
}: SetSubscribedTopicsArgs): Promise<boolean> {
|
||||
const personId = await this.resolvePersonIdByEmail(
|
||||
workspaceId,
|
||||
emailAddress,
|
||||
);
|
||||
|
||||
if (!isDefined(personId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keptTopicIds = new Set(subscribedTopicIds);
|
||||
|
||||
const currentlySubscribedTopics = await this.getSubscribedTopics({
|
||||
workspaceId,
|
||||
emailAddress,
|
||||
});
|
||||
|
||||
const topicIdsToUnsubscribe = currentlySubscribedTopics
|
||||
.map((topic) => topic.topicId)
|
||||
.filter((topicId) => !keptTopicIds.has(topicId));
|
||||
|
||||
for (const topicId of topicIdsToUnsubscribe) {
|
||||
await this.unsubscribe({ workspaceId, personId, topicId });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async resolvePersonIdByEmail(
|
||||
workspaceId: string,
|
||||
emailAddress: string,
|
||||
): Promise<string | undefined> {
|
||||
const normalizedAddress = emailAddress.trim().toLowerCase();
|
||||
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
const personRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository(
|
||||
workspaceId,
|
||||
PersonWorkspaceEntity,
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const matchedPeople = await addPersonEmailFiltersToQueryBuilder({
|
||||
queryBuilder: personRepository.createQueryBuilder('person'),
|
||||
emails: [normalizedAddress],
|
||||
}).getMany();
|
||||
|
||||
return findPersonByPrimaryOrAdditionalEmail({
|
||||
people: matchedPeople,
|
||||
email: normalizedAddress,
|
||||
})?.id;
|
||||
},
|
||||
buildSystemAuthContext(workspaceId),
|
||||
);
|
||||
}
|
||||
|
||||
async getAddressesUnsubscribedFromList(
|
||||
workspaceId: string,
|
||||
emailAddresses: string[],
|
||||
topicId: string,
|
||||
): Promise<Set<string>> {
|
||||
return this.messageSuppressionService.getTopicSuppressedAddresses(
|
||||
workspaceId,
|
||||
emailAddresses,
|
||||
topicId,
|
||||
);
|
||||
}
|
||||
|
||||
private async deleteSubscription({
|
||||
workspaceId,
|
||||
personId,
|
||||
topicId,
|
||||
}: UnsubscribeArgs): Promise<void> {
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
|
||||
const subscriptionRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository(
|
||||
workspaceId,
|
||||
MessageTopicSubscriptionWorkspaceEntity,
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const existingSubscription = await subscriptionRepository.findOneBy({
|
||||
personId,
|
||||
topicId,
|
||||
});
|
||||
|
||||
if (isDefined(existingSubscription)) {
|
||||
await subscriptionRepository.delete(existingSubscription.id);
|
||||
}
|
||||
}, buildSystemAuthContext(workspaceId));
|
||||
}
|
||||
|
||||
private async resolvePrimaryEmailByPersonId(
|
||||
workspaceId: string,
|
||||
personId: string,
|
||||
): Promise<string | undefined> {
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
const personRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository(
|
||||
workspaceId,
|
||||
PersonWorkspaceEntity,
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const person = await personRepository.findOneBy({ id: personId });
|
||||
|
||||
return person?.emails?.primaryEmail ?? undefined;
|
||||
},
|
||||
buildSystemAuthContext(workspaceId),
|
||||
);
|
||||
}
|
||||
|
||||
private async upsertSubscriptionStatus({
|
||||
workspaceId,
|
||||
personId,
|
||||
topicId,
|
||||
status,
|
||||
source,
|
||||
}: UpsertSubscriptionStatusArgs): Promise<void> {
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
|
||||
const subscriptionRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository(
|
||||
workspaceId,
|
||||
MessageTopicSubscriptionWorkspaceEntity,
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const existingSubscription = await subscriptionRepository.findOneBy({
|
||||
personId,
|
||||
topicId,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const statusChangeTimestamp =
|
||||
status === MessageTopicSubscriptionStatus.SUBSCRIBED
|
||||
? { subscribedAt: now }
|
||||
: { unsubscribedAt: now };
|
||||
|
||||
if (isDefined(existingSubscription)) {
|
||||
await subscriptionRepository.update(existingSubscription.id, {
|
||||
status,
|
||||
...statusChangeTimestamp,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const topicRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository(
|
||||
workspaceId,
|
||||
MessageTopicWorkspaceEntity,
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
const topic = await topicRepository.findOneBy({ id: topicId });
|
||||
|
||||
await subscriptionRepository.insert({
|
||||
personId,
|
||||
topicId,
|
||||
topicName: topic?.name ?? null,
|
||||
status,
|
||||
source,
|
||||
...statusChangeTimestamp,
|
||||
});
|
||||
}, buildSystemAuthContext(workspaceId));
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/* @license Enterprise */
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
DnsManagerException,
|
||||
DnsManagerExceptionCode,
|
||||
} from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
|
||||
import { UNSUBSCRIBE_HOSTNAME_PREFIX } from 'src/engine/core-modules/emailing-domain/constants/unsubscribe-hostname-prefix.constant';
|
||||
import { UnsubscribeHostnameStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/unsubscribe-hostname-status.type';
|
||||
import { type VerificationRecord } from 'src/engine/core-modules/emailing-domain/drivers/types/verifications-record';
|
||||
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
|
||||
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
|
||||
@Injectable()
|
||||
export class UnsubscribeHostnameService {
|
||||
private readonly logger = new Logger(UnsubscribeHostnameService.name);
|
||||
|
||||
constructor(
|
||||
@InjectWorkspaceScopedRepository(EmailingDomainEntity)
|
||||
private readonly emailingDomainRepository: WorkspaceScopedRepository<EmailingDomainEntity>,
|
||||
private readonly dnsManagerService: DnsManagerService,
|
||||
) {}
|
||||
|
||||
async provision(emailingDomain: EmailingDomainEntity): Promise<void> {
|
||||
if (isNonEmptyString(emailingDomain.unsubscribeHostnameId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hostname = this.buildHostname(emailingDomain.domain);
|
||||
|
||||
const unsubscribeHostnameId = await this.registerOrAdoptHostname(hostname);
|
||||
|
||||
await this.emailingDomainRepository.update(
|
||||
emailingDomain.workspaceId,
|
||||
{ id: emailingDomain.id },
|
||||
{
|
||||
unsubscribeHostname: hostname,
|
||||
unsubscribeHostnameId,
|
||||
unsubscribeHostnameStatus: UnsubscribeHostnameStatus.PENDING,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async registerOrAdoptHostname(hostname: string): Promise<string> {
|
||||
try {
|
||||
const createdHostname =
|
||||
await this.dnsManagerService.registerHostname(hostname);
|
||||
|
||||
return createdHostname.id;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof DnsManagerException &&
|
||||
error.code === DnsManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED
|
||||
) {
|
||||
const existingHostnameId =
|
||||
await this.dnsManagerService.getHostnameId(hostname);
|
||||
|
||||
if (isNonEmptyString(existingHostnameId)) {
|
||||
return existingHostnameId;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshStatus(emailingDomain: EmailingDomainEntity): Promise<void> {
|
||||
if (!isNonEmptyString(emailingDomain.unsubscribeHostname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isWorking = await this.dnsManagerService.isHostnameWorking(
|
||||
emailingDomain.unsubscribeHostname,
|
||||
);
|
||||
|
||||
await this.emailingDomainRepository.update(
|
||||
emailingDomain.workspaceId,
|
||||
{ id: emailingDomain.id },
|
||||
{
|
||||
unsubscribeHostnameStatus: isWorking
|
||||
? UnsubscribeHostnameStatus.ACTIVE
|
||||
: UnsubscribeHostnameStatus.PENDING,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async deprovision(emailingDomain: EmailingDomainEntity): Promise<void> {
|
||||
if (!isNonEmptyString(emailingDomain.unsubscribeHostname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dnsManagerService.deleteHostnameSilently(
|
||||
emailingDomain.unsubscribeHostname,
|
||||
);
|
||||
}
|
||||
|
||||
async sync(
|
||||
workspaceId: string,
|
||||
emailingDomainId: string,
|
||||
{ provision }: { provision: boolean },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const emailingDomain = await this.emailingDomainRepository.findOneOrFail(
|
||||
workspaceId,
|
||||
{ where: { id: emailingDomainId } },
|
||||
);
|
||||
|
||||
if (provision) {
|
||||
await this.provision(emailingDomain);
|
||||
}
|
||||
|
||||
await this.refreshStatus(
|
||||
await this.emailingDomainRepository.findOneOrFail(workspaceId, {
|
||||
where: { id: emailingDomainId },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to sync unsubscribe hostname for emailing domain ${emailingDomainId}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async withDnsRecords(
|
||||
emailingDomain: EmailingDomainEntity,
|
||||
): Promise<EmailingDomainEntity> {
|
||||
const unsubscribeRecords = await this.getDnsRecords(emailingDomain);
|
||||
|
||||
if (unsubscribeRecords.length === 0) {
|
||||
return emailingDomain;
|
||||
}
|
||||
|
||||
return {
|
||||
...emailingDomain,
|
||||
verificationRecords: [
|
||||
...(emailingDomain.verificationRecords ?? []),
|
||||
...unsubscribeRecords,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async getDnsRecords(
|
||||
emailingDomain: EmailingDomainEntity,
|
||||
): Promise<VerificationRecord[]> {
|
||||
if (!isNonEmptyString(emailingDomain.unsubscribeHostname)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const hostnameWithRecords =
|
||||
await this.dnsManagerService.getHostnameWithRecords(
|
||||
emailingDomain.unsubscribeHostname,
|
||||
);
|
||||
|
||||
if (!isDefined(hostnameWithRecords)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return hostnameWithRecords.records.map((record) => ({
|
||||
type: 'CNAME' as const,
|
||||
key: record.key,
|
||||
value: record.value,
|
||||
}));
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to read unsubscribe DNS records for ${emailingDomain.unsubscribeHostname}: ${error}`,
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private buildHostname(domain: string): string {
|
||||
return `${UNSUBSCRIBE_HOSTNAME_PREFIX}.${domain}`;
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
export type UnsubscribeTokenPayload = {
|
||||
workspaceId: string;
|
||||
emailAddress: string;
|
||||
messageTopicId?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UnsubscribeTokenService {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
sign(payload: UnsubscribeTokenPayload): string {
|
||||
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString(
|
||||
'base64url',
|
||||
);
|
||||
|
||||
return `${encodedPayload}.${this.computeSignature(encodedPayload)}`;
|
||||
}
|
||||
|
||||
verify(token: string): UnsubscribeTokenPayload | null {
|
||||
const [encodedPayload, signature] = token.split('.');
|
||||
|
||||
if (!isDefined(encodedPayload) || !isDefined(signature)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expectedSignature = this.computeSignature(encodedPayload);
|
||||
|
||||
if (!this.signaturesMatch(signature, expectedSignature)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = JSON.parse(
|
||||
Buffer.from(encodedPayload, 'base64url').toString('utf8'),
|
||||
);
|
||||
|
||||
if (
|
||||
typeof decoded?.workspaceId !== 'string' ||
|
||||
typeof decoded?.emailAddress !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId: decoded.workspaceId,
|
||||
emailAddress: decoded.emailAddress,
|
||||
...(typeof decoded?.messageTopicId === 'string'
|
||||
? { messageTopicId: decoded.messageTopicId }
|
||||
: {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private computeSignature(encodedPayload: string): string {
|
||||
return crypto
|
||||
.createHmac('sha256', this.twentyConfigService.get('APP_SECRET'))
|
||||
.update(encodedPayload)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
private signaturesMatch(candidate: string, expected: string): boolean {
|
||||
const candidateBuffer = Buffer.from(candidate);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
|
||||
if (candidateBuffer.length !== expectedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(candidateBuffer, expectedBuffer);
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type DeliverableRecipients = {
|
||||
to: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export enum EmailGroupMessageCategory {
|
||||
TRANSACTIONAL = 'TRANSACTIONAL',
|
||||
CAMPAIGN = 'CAMPAIGN',
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export enum MessageSuppressionReason {
|
||||
BOUNCE = 'BOUNCE',
|
||||
COMPLAINT = 'COMPLAINT',
|
||||
UNSUBSCRIBE = 'UNSUBSCRIBE',
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export enum MessageSuppressionSource {
|
||||
WEBHOOK = 'WEBHOOK',
|
||||
SYSTEM = 'SYSTEM',
|
||||
MANUAL = 'MANUAL',
|
||||
IMPORT = 'IMPORT',
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export enum MessageTopicSubscriptionSource {
|
||||
FORM = 'FORM',
|
||||
IMPORT = 'IMPORT',
|
||||
API = 'API',
|
||||
MANUAL = 'MANUAL',
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export enum MessageTopicSubscriptionStatus {
|
||||
SUBSCRIBED = 'SUBSCRIBED',
|
||||
UNSUBSCRIBED = 'UNSUBSCRIBED',
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
export type SendCampaignEmailJobData = {
|
||||
workspaceId: string;
|
||||
campaignId: string;
|
||||
// The already-materialized (QUEUED) message for this recipient.
|
||||
messageId: string;
|
||||
recipientEmail: string;
|
||||
emailingDomainId: string;
|
||||
messageTopicId: string;
|
||||
fromAddress: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type SubscribedTopic = {
|
||||
topicId: string;
|
||||
topicName: string | null;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { type EmailingDomainHeader } from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
|
||||
|
||||
export type UnsubscribeContent = {
|
||||
headers: EmailingDomainHeader[];
|
||||
textFooter: string;
|
||||
htmlFooter: string;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type UnsubscribeUrls = {
|
||||
httpsUrl: string;
|
||||
mailtoUrl: string;
|
||||
};
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
normalizeCampaignRecipients,
|
||||
type RawCampaignRecipient,
|
||||
} from 'src/engine/core-modules/emailing-domain/utils/normalize-campaign-recipients.util';
|
||||
|
||||
describe('normalizeCampaignRecipients', () => {
|
||||
it('drops people with no email and reports them', () => {
|
||||
const raw: RawCampaignRecipient[] = [
|
||||
{ personId: 'p1', email: 'a@example.com' },
|
||||
{ personId: 'p2', email: null },
|
||||
{ personId: 'p3', email: ' ' },
|
||||
];
|
||||
|
||||
const { recipients, skipped } = normalizeCampaignRecipients(raw, 100);
|
||||
|
||||
expect(recipients).toEqual([{ personId: 'p1', email: 'a@example.com' }]);
|
||||
expect(skipped).toEqual({ noEmail: 2, deduped: 0, overCap: 0 });
|
||||
});
|
||||
|
||||
it('dedupes by lowercased email, keeping the first occurrence', () => {
|
||||
const raw: RawCampaignRecipient[] = [
|
||||
{ personId: 'p1', email: 'A@Example.com' },
|
||||
{ personId: 'p2', email: 'a@example.com' },
|
||||
];
|
||||
|
||||
const { recipients, skipped } = normalizeCampaignRecipients(raw, 100);
|
||||
|
||||
expect(recipients).toEqual([{ personId: 'p1', email: 'a@example.com' }]);
|
||||
expect(skipped.deduped).toBe(1);
|
||||
});
|
||||
|
||||
it('caps the recipient count and reports the overflow', () => {
|
||||
const raw: RawCampaignRecipient[] = [
|
||||
{ personId: 'p1', email: 'a@example.com' },
|
||||
{ personId: 'p2', email: 'b@example.com' },
|
||||
{ personId: 'p3', email: 'c@example.com' },
|
||||
];
|
||||
|
||||
const { recipients, skipped } = normalizeCampaignRecipients(raw, 2);
|
||||
|
||||
expect(recipients).toHaveLength(2);
|
||||
expect(skipped.overCap).toBe(1);
|
||||
});
|
||||
|
||||
it('returns an empty result for no input', () => {
|
||||
expect(normalizeCampaignRecipients([], 100)).toEqual({
|
||||
recipients: [],
|
||||
skipped: { noEmail: 0, deduped: 0, overCap: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { type EmailingDomainHeader } from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
|
||||
import { type UnsubscribeUrls } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-urls.type';
|
||||
|
||||
export const buildUnsubscribeHeaders = ({
|
||||
httpsUrl,
|
||||
mailtoUrl,
|
||||
}: UnsubscribeUrls): EmailingDomainHeader[] => [
|
||||
{ name: 'List-Unsubscribe', value: `<${httpsUrl}>, <${mailtoUrl}>` },
|
||||
{ name: 'List-Unsubscribe-Post', value: 'List-Unsubscribe=One-Click' },
|
||||
];
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const buildUnsubscribeHtmlFooter = (httpsUrl: string): string =>
|
||||
`<hr style="margin-top:24px;border:none;border-top:1px solid #eee" /><p style="font-size:12px;color:#888">Don't want these emails? <a href="${httpsUrl}">Unsubscribe</a>.</p>`;
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { type SubscribedTopic } from 'src/engine/core-modules/emailing-domain/types/subscribed-topic.type';
|
||||
import { escapeHtml } from 'src/engine/core-modules/emailing-domain/utils/escape-html.util';
|
||||
|
||||
type BuildUnsubscribePreferencesPageArgs = {
|
||||
token: string;
|
||||
topics: SubscribedTopic[];
|
||||
updatePath: string;
|
||||
unsubscribeAllPath: string;
|
||||
};
|
||||
|
||||
const PAGE_STYLE = `body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#fafafa;margin:0;padding:48px 16px;color:#1a1a1a}.card{max-width:420px;margin:0 auto;background:#fff;border:1px solid #ededed;border-radius:16px;padding:40px 32px;text-align:center}h1{font-size:28px;font-weight:700;margin:0 0 8px}.subtitle{color:#888;margin:0 0 28px}.topics{text-align:left;margin:0 0 28px}.topic{display:flex;align-items:center;gap:12px;padding:10px 0;font-size:16px}.topic input{width:18px;height:18px;accent-color:#1a1a1a}button{width:100%;border-radius:10px;padding:14px;font-size:16px;font-weight:600;cursor:pointer;border:1px solid transparent}.primary{background:#1a1a1a;color:#fff}.divider{color:#aaa;margin:16px 0}.secondary{background:#fff;color:#1a1a1a;border:1px solid #ddd}`;
|
||||
|
||||
const buildTopicCheckbox = (topic: SubscribedTopic): string => {
|
||||
const label = escapeHtml(topic.topicName ?? 'Untitled topic');
|
||||
const value = escapeHtml(topic.topicId);
|
||||
|
||||
return `<label class="topic"><input type="checkbox" name="topicId" value="${value}" checked />${label}</label>`;
|
||||
};
|
||||
|
||||
export const buildUnsubscribePreferencesPage = ({
|
||||
token,
|
||||
topics,
|
||||
updatePath,
|
||||
unsubscribeAllPath,
|
||||
}: BuildUnsubscribePreferencesPageArgs): string => {
|
||||
const safeToken = escapeHtml(token);
|
||||
|
||||
const updateSection =
|
||||
topics.length > 0
|
||||
? `<form method="post" action="${updatePath}"><input type="hidden" name="t" value="${safeToken}" /><div class="topics">${topics
|
||||
.map(buildTopicCheckbox)
|
||||
.join(
|
||||
'',
|
||||
)}</div><button type="submit" class="primary">Update</button></form><p class="divider">Or</p>`
|
||||
: '';
|
||||
|
||||
return `<!doctype html><html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>Email preferences</title><style>${PAGE_STYLE}</style></head><body><div class="card"><h1>Do you want to unsubscribe?</h1><p class="subtitle">Confirm your preferences:</p>${updateSection}<form method="post" action="${unsubscribeAllPath}"><input type="hidden" name="t" value="${safeToken}" /><button type="submit" class="secondary">Unsubscribe All</button></form></div></body></html>`;
|
||||
};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { escapeHtml } from 'src/engine/core-modules/emailing-domain/utils/escape-html.util';
|
||||
|
||||
const PAGE_STYLE = `body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#fafafa;margin:0;padding:48px 16px;color:#1a1a1a;text-align:center}.card{max-width:420px;margin:0 auto;background:#fff;border:1px solid #ededed;border-radius:16px;padding:48px 32px}h1{font-size:24px;font-weight:700;margin:0 0 8px}p{color:#888;margin:0}`;
|
||||
|
||||
export const buildUnsubscribeResultPage = (
|
||||
title: string,
|
||||
message: string,
|
||||
): string =>
|
||||
`<!doctype html><html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml(
|
||||
title,
|
||||
)}</title><style>${PAGE_STYLE}</style></head><body><div class="card"><h1>${escapeHtml(
|
||||
title,
|
||||
)}</h1><p>${escapeHtml(message)}</p></div></body></html>`;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const buildUnsubscribeTextFooter = (httpsUrl: string): string =>
|
||||
`\n\n--\nUnsubscribe: ${httpsUrl}`;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { UNSUBSCRIBE_MAILBOX_LOCAL_PART } from 'src/engine/core-modules/emailing-domain/constants/unsubscribe-mailbox.constant';
|
||||
import { type UnsubscribeUrls } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-urls.type';
|
||||
|
||||
type BuildUnsubscribeUrlsArgs = {
|
||||
unsubscribeHostname: string;
|
||||
domain: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const buildUnsubscribeUrls = ({
|
||||
unsubscribeHostname,
|
||||
domain,
|
||||
token,
|
||||
}: BuildUnsubscribeUrlsArgs): UnsubscribeUrls => ({
|
||||
httpsUrl: `https://${unsubscribeHostname}/emailing/unsubscribe?t=${token}`,
|
||||
mailtoUrl: `mailto:${UNSUBSCRIBE_MAILBOX_LOCAL_PART}@${domain}?subject=${token}`,
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
const HTML_ESCAPES: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
|
||||
export const escapeHtml = (value: string): string =>
|
||||
value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export type RawCampaignRecipient = {
|
||||
personId: string;
|
||||
email: string | null;
|
||||
};
|
||||
|
||||
export type CampaignRecipient = {
|
||||
personId: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type CampaignSkippedBreakdown = {
|
||||
noEmail: number;
|
||||
deduped: number;
|
||||
overCap: number;
|
||||
};
|
||||
|
||||
// Turns resolved people into a deduped, capped recipient list and reports what
|
||||
// was dropped: people with no email, duplicate emails, and anyone past the cap.
|
||||
export const normalizeCampaignRecipients = (
|
||||
rawRecipients: RawCampaignRecipient[],
|
||||
maxRecipients: number,
|
||||
): { recipients: CampaignRecipient[]; skipped: CampaignSkippedBreakdown } => {
|
||||
const skipped: CampaignSkippedBreakdown = {
|
||||
noEmail: 0,
|
||||
deduped: 0,
|
||||
overCap: 0,
|
||||
};
|
||||
const seenEmails = new Set<string>();
|
||||
const recipients: CampaignRecipient[] = [];
|
||||
|
||||
for (const candidate of rawRecipients) {
|
||||
const normalizedEmail = candidate.email?.trim().toLowerCase();
|
||||
|
||||
if (!isNonEmptyString(normalizedEmail)) {
|
||||
skipped.noEmail += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenEmails.has(normalizedEmail)) {
|
||||
skipped.deduped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
seenEmails.add(normalizedEmail);
|
||||
|
||||
if (recipients.length >= maxRecipients) {
|
||||
skipped.overCap += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
recipients.push({ email: normalizedEmail, personId: candidate.personId });
|
||||
}
|
||||
|
||||
return { recipients, skipped };
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.modu
|
||||
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
|
||||
import { EmailModule } from 'src/engine/core-modules/email/email.module';
|
||||
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
|
||||
import { SendCampaignEmailJob } from 'src/engine/core-modules/emailing-domain/jobs/send-campaign-email.job';
|
||||
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
|
||||
import { EventLogIngestionModule } from 'src/engine/core-modules/event-logs/ingest/event-log-ingestion.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
@@ -86,6 +87,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
||||
CleanSuspendedWorkspacesJob,
|
||||
CleanOnboardingWorkspacesJob,
|
||||
EmailSenderJob,
|
||||
SendCampaignEmailJob,
|
||||
UpdateSubscriptionQuantityJob,
|
||||
HandleWorkspaceMemberDeletedJob,
|
||||
CleanWorkspaceDeletionWarningUserVarsJob,
|
||||
|
||||
+4
@@ -3,8 +3,10 @@ import { Module } from '@nestjs/common';
|
||||
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
|
||||
import { MessagingWebhooksController } from 'src/engine/core-modules/messaging-webhooks/messaging-webhooks.controller';
|
||||
import { SesInboundMailHandlerService } from 'src/engine/core-modules/messaging-webhooks/services/ses-inbound-mail-handler.service';
|
||||
import { SesInboundUnsubscribeHandlerService } from 'src/engine/core-modules/messaging-webhooks/services/ses-inbound-unsubscribe-handler.service';
|
||||
import { SesInboundWebhookRouterService } from 'src/engine/core-modules/messaging-webhooks/services/ses-inbound-webhook-router.service';
|
||||
import { SesOutboundSendingStateHandlerService } from 'src/engine/core-modules/messaging-webhooks/services/ses-outbound-sending-state-handler.service';
|
||||
import { SesOutboundSuppressionHandlerService } from 'src/engine/core-modules/messaging-webhooks/services/ses-outbound-suppression-handler.service';
|
||||
import { SesOutboundWebhookRouterService } from 'src/engine/core-modules/messaging-webhooks/services/ses-outbound-webhook-router.service';
|
||||
import { SnsSignatureVerifierService } from 'src/engine/core-modules/messaging-webhooks/services/sns-signature-verifier.service';
|
||||
import { SnsSubscriptionConfirmerService } from 'src/engine/core-modules/messaging-webhooks/services/sns-subscription-confirmer.service';
|
||||
@@ -17,7 +19,9 @@ import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty
|
||||
SnsSignatureVerifierService,
|
||||
SnsSubscriptionConfirmerService,
|
||||
SesInboundMailHandlerService,
|
||||
SesInboundUnsubscribeHandlerService,
|
||||
SesOutboundSendingStateHandlerService,
|
||||
SesOutboundSuppressionHandlerService,
|
||||
SesInboundWebhookRouterService,
|
||||
SesOutboundWebhookRouterService,
|
||||
],
|
||||
|
||||
+19
@@ -3,7 +3,9 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
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 { SesInboundUnsubscribeHandlerService } from 'src/engine/core-modules/messaging-webhooks/services/ses-inbound-unsubscribe-handler.service';
|
||||
import { type SesInboundNotification } from 'src/engine/core-modules/messaging-webhooks/types/sns-message.type';
|
||||
import { resolveInboundMailIntent } from 'src/engine/core-modules/messaging-webhooks/utils/resolve-inbound-mail-intent.util';
|
||||
import {
|
||||
MessagingInboundEmailImportJob,
|
||||
type MessagingInboundEmailImportJobData,
|
||||
@@ -16,11 +18,28 @@ export class SesInboundMailHandlerService {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.messagingQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
private readonly sesInboundUnsubscribeHandlerService: SesInboundUnsubscribeHandlerService,
|
||||
) {}
|
||||
|
||||
async handle(
|
||||
notification: SesInboundNotification,
|
||||
snsMessageId: string,
|
||||
): Promise<void> {
|
||||
switch (resolveInboundMailIntent(notification)) {
|
||||
case 'UNSUBSCRIBE':
|
||||
await this.sesInboundUnsubscribeHandlerService.handle(notification);
|
||||
|
||||
return;
|
||||
case 'IMPORT':
|
||||
await this.enqueueInboundEmailImport(notification, snsMessageId);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async enqueueInboundEmailImport(
|
||||
notification: SesInboundNotification,
|
||||
snsMessageId: string,
|
||||
): Promise<void> {
|
||||
const { receipt } = notification;
|
||||
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { MessageSuppressionService } from 'src/engine/core-modules/emailing-domain/services/message-suppression.service';
|
||||
import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service';
|
||||
import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type';
|
||||
import { MessageSuppressionSource } from 'src/engine/core-modules/emailing-domain/types/message-suppression-source.type';
|
||||
import { type SesInboundNotification } from 'src/engine/core-modules/messaging-webhooks/types/sns-message.type';
|
||||
|
||||
@Injectable()
|
||||
export class SesInboundUnsubscribeHandlerService {
|
||||
private readonly logger = new Logger(
|
||||
SesInboundUnsubscribeHandlerService.name,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly unsubscribeTokenService: UnsubscribeTokenService,
|
||||
private readonly messageSuppressionService: MessageSuppressionService,
|
||||
) {}
|
||||
|
||||
async handle(notification: SesInboundNotification): Promise<void> {
|
||||
const subject = notification.mail?.commonHeaders?.subject;
|
||||
|
||||
if (!isNonEmptyString(subject)) {
|
||||
this.logger.warn('Unsubscribe email received without a token subject');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = this.unsubscribeTokenService.verify(subject.trim());
|
||||
|
||||
if (!isDefined(payload)) {
|
||||
this.logger.warn('Unsubscribe email received with an invalid token');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.messageSuppressionService.suppress({
|
||||
workspaceId: payload.workspaceId,
|
||||
emailAddress: payload.emailAddress,
|
||||
reason: MessageSuppressionReason.UNSUBSCRIBE,
|
||||
source: MessageSuppressionSource.SYSTEM,
|
||||
});
|
||||
}
|
||||
}
|
||||
+3
-21
@@ -3,8 +3,8 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { EmailingDomainTenantStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type';
|
||||
import { EmailingDomainTenantStatusService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain-tenant-status.service';
|
||||
import { type SesEventBridgeNotification } from 'src/engine/core-modules/messaging-webhooks/types/ses-event-bridge-notification.type';
|
||||
import { parseWorkspaceIdFromAwsSesResourceArn } from 'src/engine/core-modules/messaging-webhooks/utils/parse-workspace-id-from-aws-ses-resource-arn.util';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import { resolveWorkspaceIdFromAwsSesResources } from 'src/engine/core-modules/messaging-webhooks/utils/resolve-workspace-id-from-aws-ses-resources.util';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@Injectable()
|
||||
export class SesOutboundSendingStateHandlerService {
|
||||
@@ -22,7 +22,7 @@ export class SesOutboundSendingStateHandlerService {
|
||||
? EmailingDomainTenantStatus.ACTIVE
|
||||
: EmailingDomainTenantStatus.PAUSED;
|
||||
|
||||
const workspaceId = this.resolveWorkspaceIdFromResources(event.resources);
|
||||
const workspaceId = resolveWorkspaceIdFromAwsSesResources(event.resources);
|
||||
|
||||
if (!isDefined(workspaceId)) {
|
||||
this.logger.warn(
|
||||
@@ -37,22 +37,4 @@ export class SesOutboundSendingStateHandlerService {
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
private resolveWorkspaceIdFromResources(
|
||||
resources: string[] | undefined,
|
||||
): string | null {
|
||||
if (!isNonEmptyArray(resources)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const resourceArn of resources) {
|
||||
const workspaceId = parseWorkspaceIdFromAwsSesResourceArn(resourceArn);
|
||||
|
||||
if (isDefined(workspaceId)) {
|
||||
return workspaceId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
import { CAMPAIGN_MESSAGE_DELIVERY_STATUS } from 'src/engine/core-modules/emailing-domain/constants/campaign.constant';
|
||||
import { MessageCampaignService } from 'src/engine/core-modules/emailing-domain/services/message-campaign.service';
|
||||
import { MessageSuppressionService } from 'src/engine/core-modules/emailing-domain/services/message-suppression.service';
|
||||
import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type';
|
||||
import { MessageSuppressionSource } from 'src/engine/core-modules/emailing-domain/types/message-suppression-source.type';
|
||||
import { type SesEventBridgeNotification } from 'src/engine/core-modules/messaging-webhooks/types/ses-event-bridge-notification.type';
|
||||
import { resolveWorkspaceIdFromAwsSesResources } from 'src/engine/core-modules/messaging-webhooks/utils/resolve-workspace-id-from-aws-ses-resources.util';
|
||||
|
||||
@Injectable()
|
||||
export class SesOutboundSuppressionHandlerService {
|
||||
private readonly logger = new Logger(
|
||||
SesOutboundSuppressionHandlerService.name,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly messageSuppressionService: MessageSuppressionService,
|
||||
private readonly messageCampaignService: MessageCampaignService,
|
||||
) {}
|
||||
|
||||
async handle(event: SesEventBridgeNotification): Promise<void> {
|
||||
const suppression = this.resolveSuppression(event);
|
||||
|
||||
if (!isDefined(suppression)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceId = resolveWorkspaceIdFromAwsSesResources(event.resources);
|
||||
|
||||
if (!isDefined(workspaceId)) {
|
||||
this.logger.warn(
|
||||
`Could not resolve workspaceId from SES ${event['detail-type']} event resources: ${JSON.stringify(event.resources)}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Record the per-recipient campaign outcome by correlating the SES send id.
|
||||
// Additive to the address-level suppression below.
|
||||
const providerMessageId = event.detail?.mail?.messageId;
|
||||
|
||||
if (isDefined(providerMessageId)) {
|
||||
await this.messageCampaignService.recordDeliveryFailureByProviderMessageId(
|
||||
{
|
||||
workspaceId,
|
||||
providerMessageId,
|
||||
deliveryStatus:
|
||||
event['detail-type'] === 'Email Complaint Received'
|
||||
? CAMPAIGN_MESSAGE_DELIVERY_STATUS.COMPLAINED
|
||||
: CAMPAIGN_MESSAGE_DELIVERY_STATUS.BOUNCED,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
suppression.emailAddresses.map((emailAddress) =>
|
||||
this.messageSuppressionService.suppress({
|
||||
workspaceId,
|
||||
emailAddress,
|
||||
reason: suppression.reason,
|
||||
source: MessageSuppressionSource.WEBHOOK,
|
||||
providerEventId: suppression.providerEventId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (results.some((result) => result.status === 'rejected')) {
|
||||
throw new Error(
|
||||
`Failed to suppress one or more recipients for ${event['detail-type']} event in workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSuppression(event: SesEventBridgeNotification): {
|
||||
reason: MessageSuppressionReason;
|
||||
emailAddresses: string[];
|
||||
providerEventId: string | null;
|
||||
} | null {
|
||||
if (event['detail-type'] === 'Email Bounced') {
|
||||
const bounce = event.detail?.bounce;
|
||||
|
||||
if (bounce?.bounceType !== 'Permanent') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const emailAddresses = this.extractRecipientAddresses(
|
||||
bounce.bouncedRecipients,
|
||||
);
|
||||
|
||||
if (!isNonEmptyArray(emailAddresses)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
reason: MessageSuppressionReason.BOUNCE,
|
||||
emailAddresses,
|
||||
providerEventId: bounce.feedbackId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (event['detail-type'] === 'Email Complaint Received') {
|
||||
const complaint = event.detail?.complaint;
|
||||
const emailAddresses = this.extractRecipientAddresses(
|
||||
complaint?.complainedRecipients,
|
||||
);
|
||||
|
||||
if (!isNonEmptyArray(emailAddresses)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
reason: MessageSuppressionReason.COMPLAINT,
|
||||
emailAddresses,
|
||||
providerEventId: complaint?.feedbackId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractRecipientAddresses = (
|
||||
recipients: { emailAddress: string }[] | undefined,
|
||||
): string[] => {
|
||||
return (recipients ?? [])
|
||||
.map((recipient) => recipient.emailAddress)
|
||||
.filter(isDefined);
|
||||
};
|
||||
}
|
||||
+11
@@ -6,6 +6,7 @@ import { isDefined, parseJson } from 'twenty-shared/utils';
|
||||
import { MessagingWebhookExceptionCode } from 'src/engine/core-modules/messaging-webhooks/messaging-webhook-exception-code.enum';
|
||||
import { MessagingWebhookException } from 'src/engine/core-modules/messaging-webhooks/messaging-webhook.exception';
|
||||
import { SesOutboundSendingStateHandlerService } from 'src/engine/core-modules/messaging-webhooks/services/ses-outbound-sending-state-handler.service';
|
||||
import { SesOutboundSuppressionHandlerService } from 'src/engine/core-modules/messaging-webhooks/services/ses-outbound-suppression-handler.service';
|
||||
import { SnsSignatureVerifierService } from 'src/engine/core-modules/messaging-webhooks/services/sns-signature-verifier.service';
|
||||
import { SnsSubscriptionConfirmerService } from 'src/engine/core-modules/messaging-webhooks/services/sns-subscription-confirmer.service';
|
||||
import { type SesEventBridgeNotification } from 'src/engine/core-modules/messaging-webhooks/types/ses-event-bridge-notification.type';
|
||||
@@ -18,6 +19,7 @@ export class SesOutboundWebhookRouterService {
|
||||
private readonly snsSignatureVerifierService: SnsSignatureVerifierService,
|
||||
private readonly snsSubscriptionConfirmerService: SnsSubscriptionConfirmerService,
|
||||
private readonly sesOutboundSendingStateHandlerService: SesOutboundSendingStateHandlerService,
|
||||
private readonly sesOutboundSuppressionHandlerService: SesOutboundSuppressionHandlerService,
|
||||
) {}
|
||||
|
||||
async route(rawBody: Buffer): Promise<void> {
|
||||
@@ -54,6 +56,15 @@ export class SesOutboundWebhookRouterService {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
event['detail-type'] === 'Email Bounced' ||
|
||||
event['detail-type'] === 'Email Complaint Received'
|
||||
) {
|
||||
await this.sesOutboundSuppressionHandlerService.handle(event);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sesOutboundSendingStateHandlerService.handle(event);
|
||||
}
|
||||
}
|
||||
|
||||
+25
-1
@@ -1,6 +1,16 @@
|
||||
export type SesEventBridgeDetailType =
|
||||
| 'Sending Status Enabled'
|
||||
| 'Sending Status Disabled'
|
||||
| 'Email Bounced'
|
||||
| 'Email Complaint Received';
|
||||
|
||||
export type SesEventBridgeRecipient = {
|
||||
emailAddress: string;
|
||||
};
|
||||
|
||||
export type SesEventBridgeNotification = {
|
||||
source: 'aws.ses';
|
||||
'detail-type': 'Sending Status Enabled' | 'Sending Status Disabled';
|
||||
'detail-type': SesEventBridgeDetailType;
|
||||
resources?: string[];
|
||||
detail?: {
|
||||
version?: string;
|
||||
@@ -11,5 +21,19 @@ export type SesEventBridgeNotification = {
|
||||
cause?: string;
|
||||
};
|
||||
};
|
||||
bounce?: {
|
||||
bounceType?: 'Permanent' | 'Transient' | 'Undetermined';
|
||||
feedbackId?: string;
|
||||
bouncedRecipients?: SesEventBridgeRecipient[];
|
||||
};
|
||||
complaint?: {
|
||||
feedbackId?: string;
|
||||
complainedRecipients?: SesEventBridgeRecipient[];
|
||||
};
|
||||
// SES carries the original send's id (= EmailingDomainSendEmailResult.messageId)
|
||||
// on bounce/complaint events, used to correlate the per-recipient message.
|
||||
mail?: {
|
||||
messageId?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type SesInboundMailIntent = 'UNSUBSCRIBE' | 'IMPORT';
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { UNSUBSCRIBE_MAILBOX_LOCAL_PART } from 'src/engine/core-modules/emailing-domain/constants/unsubscribe-mailbox.constant';
|
||||
import { type SesInboundMailIntent } from 'src/engine/core-modules/messaging-webhooks/types/ses-inbound-mail-intent.type';
|
||||
import { type SesInboundNotification } from 'src/engine/core-modules/messaging-webhooks/types/sns-message.type';
|
||||
|
||||
export const resolveInboundMailIntent = (
|
||||
notification: SesInboundNotification,
|
||||
): SesInboundMailIntent => {
|
||||
const isAddressedToUnsubscribeMailbox = (
|
||||
notification.receipt?.recipients ?? []
|
||||
).some(
|
||||
(recipient) =>
|
||||
recipient.split('@')[0]?.toLowerCase() === UNSUBSCRIBE_MAILBOX_LOCAL_PART,
|
||||
);
|
||||
|
||||
return isAddressedToUnsubscribeMailbox ? 'UNSUBSCRIBE' : 'IMPORT';
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
import { parseWorkspaceIdFromAwsSesResourceArn } from 'src/engine/core-modules/messaging-webhooks/utils/parse-workspace-id-from-aws-ses-resource-arn.util';
|
||||
|
||||
export const resolveWorkspaceIdFromAwsSesResources = (
|
||||
resources: string[] | undefined,
|
||||
): string | null => {
|
||||
if (!isNonEmptyArray(resources)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const resourceArn of resources) {
|
||||
const workspaceId = parseWorkspaceIdFromAwsSesResourceArn(resourceArn);
|
||||
|
||||
if (isDefined(workspaceId)) {
|
||||
return workspaceId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
+6
-3
@@ -362,9 +362,12 @@ export class EmailComposerService {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const messageChannel = connectedAccount.messageChannels.find(
|
||||
(channel) => channel.handle === connectedAccount.handle,
|
||||
);
|
||||
const messageChannel =
|
||||
connectedAccount.provider === ConnectedAccountProvider.EMAIL_GROUP
|
||||
? connectedAccount.messageChannels[0]
|
||||
: connectedAccount.messageChannels.find(
|
||||
(channel) => channel.handle === connectedAccount.handle,
|
||||
);
|
||||
|
||||
const isSmtpOnlyAccount =
|
||||
connectedAccount.provider === ConnectedAccountProvider.IMAP_SMTP_CALDAV &&
|
||||
|
||||
+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',
|
||||
COMPOSE_CAMPAIGN = 'COMPOSE_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
|
||||
|
||||
+33
@@ -262,6 +262,39 @@ export class MessageChannelMetadataService {
|
||||
return { messageChannel, forwardingAddress };
|
||||
}
|
||||
|
||||
// Resolves the workspace-owned email-group channel that sends from `fromAddress`
|
||||
// (its connectedAccount.handle), creating it on demand. Used by campaign sends to
|
||||
// persist + reply-thread their messages on a SHARE_EVERYTHING channel.
|
||||
async getOrCreateEmailGroupChannel({
|
||||
fromAddress,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
fromAddress: string;
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<MessageChannelDTO> {
|
||||
const existingChannel = await this.repository.findOne({
|
||||
where: {
|
||||
workspaceId,
|
||||
type: MessageChannelType.EMAIL_GROUP,
|
||||
connectedAccount: { handle: fromAddress },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingChannel) {
|
||||
return existingChannel;
|
||||
}
|
||||
|
||||
const { messageChannel } = await this.createEmailGroupChannel({
|
||||
handle: fromAddress,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return messageChannel;
|
||||
}
|
||||
|
||||
async delete({
|
||||
id,
|
||||
workspaceId,
|
||||
|
||||
+14
@@ -693,6 +693,20 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
engineComponentKey: EngineComponentKey.COMPOSE_EMAIL,
|
||||
hotKeys: null,
|
||||
},
|
||||
composeCampaign: {
|
||||
universalIdentifier: '30473656-e7cb-42e0-b198-6c4e8b906106',
|
||||
label: 'Compose Campaign',
|
||||
icon: 'IconSend',
|
||||
isPinned: false,
|
||||
position: 66,
|
||||
shortLabel: 'Campaign',
|
||||
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
|
||||
conditionalAvailabilityExpression: 'featureFlags.IS_EMAIL_GROUP_ENABLED',
|
||||
availabilityObjectMetadataUniversalIdentifier: null,
|
||||
frontComponentUniversalIdentifier: null,
|
||||
engineComponentKey: EngineComponentKey.COMPOSE_CAMPAIGN,
|
||||
hotKeys: null,
|
||||
},
|
||||
goToSettings: {
|
||||
universalIdentifier: 'ef9aba44-0068-453e-930a-f8c182af18ee',
|
||||
label: 'Go to Settings',
|
||||
|
||||
+4
@@ -85,6 +85,10 @@ export const VERTICAL_LIST_LAYOUT_POSITIONS = {
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
index: 3,
|
||||
},
|
||||
FIFTH: {
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
index: 4,
|
||||
},
|
||||
} as const satisfies Record<string, PageLayoutWidgetVerticalListPosition>;
|
||||
|
||||
export const CANVAS_LAYOUT_POSITIONS = {
|
||||
|
||||
+2
@@ -8,6 +8,7 @@ import {
|
||||
STANDARD_MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_MESSAGE_FOLDER_PAGE_LAYOUT_CONFIG,
|
||||
STANDARD_MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_PAGE_LAYOUT_CONFIG,
|
||||
STANDARD_MESSAGE_PARTICIPANT_PAGE_LAYOUT_CONFIG,
|
||||
STANDARD_MESSAGE_LIST_PAGE_LAYOUT_CONFIG,
|
||||
STANDARD_MESSAGE_THREAD_PAGE_LAYOUT_CONFIG,
|
||||
STANDARD_NOTE_PAGE_LAYOUT_CONFIG,
|
||||
STANDARD_OPPORTUNITY_PAGE_LAYOUT_CONFIG,
|
||||
@@ -34,6 +35,7 @@ export const STANDARD_PAGE_LAYOUTS = {
|
||||
messageChannelMessageAssociationMessageFolderRecordPage:
|
||||
STANDARD_MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_MESSAGE_FOLDER_PAGE_LAYOUT_CONFIG,
|
||||
messageParticipantRecordPage: STANDARD_MESSAGE_PARTICIPANT_PAGE_LAYOUT_CONFIG,
|
||||
messageListRecordPage: STANDARD_MESSAGE_LIST_PAGE_LAYOUT_CONFIG,
|
||||
messageThreadRecordPage: STANDARD_MESSAGE_THREAD_PAGE_LAYOUT_CONFIG,
|
||||
noteRecordPage: STANDARD_NOTE_PAGE_LAYOUT_CONFIG,
|
||||
opportunityRecordPage: STANDARD_OPPORTUNITY_PAGE_LAYOUT_CONFIG,
|
||||
|
||||
+12
@@ -13,6 +13,12 @@ import { buildCalendarEventStandardFlatFieldMetadatas } from 'src/engine/workspa
|
||||
import { buildCallRecordingStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-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 { buildMessageSuppressionStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-suppression-standard-flat-field-metadata.util';
|
||||
import { buildMessageTopicStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-topic-standard-flat-field-metadata.util';
|
||||
import { buildMessageTopicSubscriptionStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-topic-subscription-standard-flat-field-metadata.util';
|
||||
import { buildMessageListStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-list-standard-flat-field-metadata.util';
|
||||
import { buildMessageListMemberStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-list-member-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';
|
||||
@@ -47,6 +53,12 @@ const STANDARD_FLAT_FIELD_METADATA_BUILDERS_BY_OBJECT_NAME = {
|
||||
callRecording: buildCallRecordingStandardFlatFieldMetadatas,
|
||||
company: buildCompanyStandardFlatFieldMetadatas,
|
||||
dashboard: buildDashboardStandardFlatFieldMetadatas,
|
||||
messageCampaign: buildMessageCampaignStandardFlatFieldMetadatas,
|
||||
messageSuppression: buildMessageSuppressionStandardFlatFieldMetadatas,
|
||||
messageTopic: buildMessageTopicStandardFlatFieldMetadatas,
|
||||
messageTopicSubscription: buildMessageTopicSubscriptionStandardFlatFieldMetadatas,
|
||||
messageList: buildMessageListStandardFlatFieldMetadatas,
|
||||
messageListMember: buildMessageListMemberStandardFlatFieldMetadatas,
|
||||
message: buildMessageStandardFlatFieldMetadatas,
|
||||
messageChannelMessageAssociation:
|
||||
buildMessageChannelMessageAssociationStandardFlatFieldMetadatas,
|
||||
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { i18nLabel } from 'src/engine/workspace-manager/twenty-standard-application/utils/i18n-label.util';
|
||||
import {
|
||||
DateDisplayFormat,
|
||||
FieldMetadataType,
|
||||
RelationOnDeleteAction,
|
||||
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/emailing/standard-objects/message-campaign.workspace-entity';
|
||||
|
||||
export const buildMessageCampaignStandardFlatFieldMetadatas = ({
|
||||
now,
|
||||
objectName,
|
||||
workspaceId,
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
}: Omit<
|
||||
CreateStandardFieldArgs<'messageCampaign', FieldMetadataType>,
|
||||
'context'
|
||||
>): Record<
|
||||
AllStandardObjectFieldName<'messageCampaign'>,
|
||||
FlatFieldMetadata
|
||||
> => {
|
||||
const base = {
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
objectName,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
return {
|
||||
id: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'id',
|
||||
type: FieldMetadataType.UUID,
|
||||
label: i18nLabel(msg`Id`),
|
||||
description: i18nLabel(msg`Id`),
|
||||
icon: 'Icon123',
|
||||
isSystem: true,
|
||||
isNullable: false,
|
||||
isUIReadOnly: true,
|
||||
defaultValue: 'uuid',
|
||||
},
|
||||
}),
|
||||
createdAt: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
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 },
|
||||
},
|
||||
}),
|
||||
updatedAt: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
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 },
|
||||
},
|
||||
}),
|
||||
deletedAt: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
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 },
|
||||
},
|
||||
}),
|
||||
createdBy: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
updatedBy: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
position: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'position',
|
||||
type: FieldMetadataType.POSITION,
|
||||
label: i18nLabel(msg`Position`),
|
||||
description: i18nLabel(msg`Email campaign record position`),
|
||||
icon: 'IconHierarchy2',
|
||||
isSystem: true,
|
||||
isNullable: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
}),
|
||||
searchVector: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'searchVector',
|
||||
type: FieldMetadataType.TS_VECTOR,
|
||||
label: i18nLabel(msg`Search vector`),
|
||||
description: i18nLabel(msg`Field used for full-text search`),
|
||||
icon: 'IconSend',
|
||||
isSystem: true,
|
||||
isNullable: true,
|
||||
settings: {
|
||||
generatedType: 'STORED',
|
||||
asExpression: getTsVectorColumnExpressionFromFields(
|
||||
SEARCH_FIELDS_FOR_MESSAGE_CAMPAIGN,
|
||||
),
|
||||
},
|
||||
},
|
||||
}),
|
||||
name: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: i18nLabel(msg`Name`),
|
||||
description: i18nLabel(msg`Internal campaign name`),
|
||||
icon: 'IconSend',
|
||||
isNullable: true,
|
||||
},
|
||||
}),
|
||||
subject: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'subject',
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: i18nLabel(msg`Subject`),
|
||||
description: i18nLabel(msg`Email subject line`),
|
||||
icon: 'IconMail',
|
||||
isNullable: true,
|
||||
},
|
||||
}),
|
||||
bodyTemplate: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'bodyTemplate',
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: i18nLabel(msg`Body`),
|
||||
description: i18nLabel(msg`Email body sent to recipients`),
|
||||
icon: 'IconFileText',
|
||||
isNullable: true,
|
||||
},
|
||||
}),
|
||||
fromAddress: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'fromAddress',
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: i18nLabel(msg`From address`),
|
||||
description: i18nLabel(msg`Sender address for the campaign`),
|
||||
icon: 'IconAt',
|
||||
isNullable: true,
|
||||
},
|
||||
}),
|
||||
replyTo: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'replyTo',
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: i18nLabel(msg`Reply to`),
|
||||
description: i18nLabel(msg`Reply-to address`),
|
||||
icon: 'IconCornerUpLeft',
|
||||
isNullable: true,
|
||||
},
|
||||
}),
|
||||
status: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'status',
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: i18nLabel(msg`Status`),
|
||||
description: i18nLabel(msg`Campaign lifecycle status`),
|
||||
icon: 'IconProgress',
|
||||
isNullable: false,
|
||||
defaultValue: "'DRAFT'",
|
||||
options: [
|
||||
{
|
||||
id: '2bebe786-69e0-4673-8781-a85588b77c44',
|
||||
value: 'DRAFT',
|
||||
label: i18nLabel(msg`Draft`),
|
||||
position: 0,
|
||||
color: 'gray',
|
||||
},
|
||||
{
|
||||
id: 'dba0c513-d1dc-4c6a-980a-40795bdb0759',
|
||||
value: 'SCHEDULED',
|
||||
label: i18nLabel(msg`Scheduled`),
|
||||
position: 1,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
id: '575b9ed5-1123-480c-9821-c73410841347',
|
||||
value: 'SENDING',
|
||||
label: i18nLabel(msg`Sending`),
|
||||
position: 2,
|
||||
color: 'yellow',
|
||||
},
|
||||
{
|
||||
id: '0c311eae-0892-4319-84e6-b30e921dc01a',
|
||||
value: 'SENT',
|
||||
label: i18nLabel(msg`Sent`),
|
||||
position: 3,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
id: 'c309536c-ceb7-4510-8481-c2cbd88ffe96',
|
||||
value: 'FAILED',
|
||||
label: i18nLabel(msg`Failed`),
|
||||
position: 4,
|
||||
color: 'red',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
recipientSource: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'recipientSource',
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: i18nLabel(msg`Recipient source`),
|
||||
description: i18nLabel(msg`How recipients are resolved`),
|
||||
icon: 'IconUsers',
|
||||
isNullable: false,
|
||||
defaultValue: "'LIST'",
|
||||
options: [
|
||||
{
|
||||
id: '89f0301d-b168-4da3-b435-4bd5e969b604',
|
||||
value: 'LIST',
|
||||
label: i18nLabel(msg`Topic`),
|
||||
position: 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
id: '45268433-887f-4a4e-a873-d9f0498615a9',
|
||||
value: 'FILTER',
|
||||
label: i18nLabel(msg`Filter`),
|
||||
position: 1,
|
||||
color: 'purple',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
recipientViewId: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'recipientViewId',
|
||||
type: FieldMetadataType.UUID,
|
||||
label: i18nLabel(msg`Recipient view`),
|
||||
description: i18nLabel(
|
||||
msg`The Person view whose filters resolve the recipients`,
|
||||
),
|
||||
icon: 'IconFilter',
|
||||
isNullable: true,
|
||||
},
|
||||
}),
|
||||
scheduledAt: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'scheduledAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
label: i18nLabel(msg`Scheduled at`),
|
||||
description: i18nLabel(msg`When the campaign is scheduled to send`),
|
||||
icon: 'IconCalendarPlus',
|
||||
isNullable: true,
|
||||
settings: { displayFormat: DateDisplayFormat.RELATIVE },
|
||||
},
|
||||
}),
|
||||
sentAt: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'sentAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
label: i18nLabel(msg`Sent at`),
|
||||
description: i18nLabel(msg`When the campaign finished sending`),
|
||||
icon: 'IconSend',
|
||||
isNullable: true,
|
||||
settings: { displayFormat: DateDisplayFormat.RELATIVE },
|
||||
},
|
||||
}),
|
||||
sentCount: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'sentCount',
|
||||
type: FieldMetadataType.NUMBER,
|
||||
label: i18nLabel(msg`Sent count`),
|
||||
description: i18nLabel(msg`Number of emails sent`),
|
||||
icon: 'IconMailFast',
|
||||
isNullable: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
}),
|
||||
bouncedCount: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'bouncedCount',
|
||||
type: FieldMetadataType.NUMBER,
|
||||
label: i18nLabel(msg`Bounced count`),
|
||||
description: i18nLabel(msg`Number of emails that bounced`),
|
||||
icon: 'IconMailX',
|
||||
isNullable: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
}),
|
||||
failedCount: createStandardFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
fieldName: 'failedCount',
|
||||
type: FieldMetadataType.NUMBER,
|
||||
label: i18nLabel(msg`Failed count`),
|
||||
description: i18nLabel(msg`Number of emails that failed to send`),
|
||||
icon: 'IconAlertTriangle',
|
||||
isNullable: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
}),
|
||||
topic: createStandardRelationFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'topic',
|
||||
label: i18nLabel(msg`Topic`),
|
||||
description: i18nLabel(msg`The audience this campaign was sent to`),
|
||||
icon: 'IconMailbox',
|
||||
isNullable: true,
|
||||
targetObjectName: 'messageTopic',
|
||||
targetFieldName: 'campaigns',
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'topicId',
|
||||
},
|
||||
},
|
||||
}),
|
||||
timelineActivities: createStandardRelationFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'timelineActivities',
|
||||
label: i18nLabel(msg`Events`),
|
||||
description: i18nLabel(msg`Events linked to the campaign`),
|
||||
icon: 'IconTimelineEvent',
|
||||
isNullable: true,
|
||||
targetObjectName: 'timelineActivity',
|
||||
targetFieldName: 'targetMessageCampaign',
|
||||
settings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
},
|
||||
}),
|
||||
messages: createStandardRelationFieldFlatMetadata({
|
||||
...base,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'messages',
|
||||
label: i18nLabel(msg`Messages`),
|
||||
description: i18nLabel(msg`Messages sent as part of this campaign`),
|
||||
icon: 'IconMessage',
|
||||
isNullable: true,
|
||||
targetObjectName: 'message',
|
||||
targetFieldName: 'messageCampaign',
|
||||
settings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { i18nLabel } from 'src/engine/workspace-manager/twenty-standard-application/utils/i18n-label.util';
|
||||
import {
|
||||
DateDisplayFormat,
|
||||
FieldMetadataType,
|
||||
RelationOnDeleteAction,
|
||||
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';
|
||||
|
||||
export const buildMessageListMemberStandardFlatFieldMetadatas = ({
|
||||
now,
|
||||
objectName,
|
||||
workspaceId,
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
}: Omit<
|
||||
CreateStandardFieldArgs<'messageListMember', FieldMetadataType>,
|
||||
'context'
|
||||
>): Record<
|
||||
AllStandardObjectFieldName<'messageListMember'>,
|
||||
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,
|
||||
}),
|
||||
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`List member record position`),
|
||||
icon: 'IconHierarchy2',
|
||||
isSystem: true,
|
||||
isNullable: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
list: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'list',
|
||||
label: i18nLabel(msg`List`),
|
||||
description: i18nLabel(msg`The list the person belongs to`),
|
||||
icon: 'IconUsersGroup',
|
||||
isNullable: false,
|
||||
targetObjectName: 'messageList',
|
||||
targetFieldName: 'members',
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
joinColumnName: 'listId',
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
person: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'person',
|
||||
label: i18nLabel(msg`Person`),
|
||||
description: i18nLabel(msg`The person in the list`),
|
||||
icon: 'IconUser',
|
||||
isNullable: false,
|
||||
targetObjectName: 'person',
|
||||
targetFieldName: 'listMemberships',
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
joinColumnName: 'personId',
|
||||
},
|
||||
},
|
||||
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: 'IconUser',
|
||||
isSystem: true,
|
||||
isNullable: true,
|
||||
settings: {
|
||||
generatedType: 'STORED',
|
||||
asExpression: getTsVectorColumnExpressionFromFields([
|
||||
{ name: 'id', type: FieldMetadataType.UUID },
|
||||
]),
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
});
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
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_LIST } from 'src/modules/emailing/standard-objects/message-list.workspace-entity';
|
||||
|
||||
export const buildMessageListStandardFlatFieldMetadatas = ({
|
||||
now,
|
||||
objectName,
|
||||
workspaceId,
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
}: Omit<
|
||||
CreateStandardFieldArgs<'messageList', FieldMetadataType>,
|
||||
'context'
|
||||
>): Record<
|
||||
AllStandardObjectFieldName<'messageList'>,
|
||||
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,
|
||||
}),
|
||||
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`List record position`),
|
||||
icon: 'IconHierarchy2',
|
||||
isSystem: true,
|
||||
isNullable: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
name: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: i18nLabel(msg`Name`),
|
||||
description: i18nLabel(msg`The list name`),
|
||||
icon: 'IconUsersGroup',
|
||||
isNullable: true,
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
members: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'members',
|
||||
label: i18nLabel(msg`Members`),
|
||||
description: i18nLabel(msg`People in this list`),
|
||||
icon: 'IconUser',
|
||||
isNullable: true,
|
||||
targetObjectName: 'messageListMember',
|
||||
targetFieldName: 'list',
|
||||
settings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
timelineActivities: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'timelineActivities',
|
||||
label: i18nLabel(msg`Events`),
|
||||
description: i18nLabel(msg`Events linked to the list`),
|
||||
icon: 'IconTimelineEvent',
|
||||
isNullable: true,
|
||||
targetObjectName: 'timelineActivity',
|
||||
targetFieldName: 'targetMessageList',
|
||||
settings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
},
|
||||
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: 'IconUsersGroup',
|
||||
isSystem: true,
|
||||
isNullable: true,
|
||||
settings: {
|
||||
generatedType: 'STORED',
|
||||
asExpression: getTsVectorColumnExpressionFromFields(
|
||||
SEARCH_FIELDS_FOR_MESSAGE_LIST,
|
||||
),
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
});
|
||||
+83
@@ -334,4 +334,87 @@ export const buildMessageStandardFlatFieldMetadatas = ({
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
messageCampaign: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'messageCampaign',
|
||||
label: i18nLabel(msg`Campaign`),
|
||||
description: i18nLabel(
|
||||
msg`The campaign this message was sent as part of`,
|
||||
),
|
||||
icon: 'IconSend',
|
||||
isNullable: true,
|
||||
isUIReadOnly: true,
|
||||
targetObjectName: 'messageCampaign',
|
||||
targetFieldName: 'messages',
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'messageCampaignId',
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
deliveryStatus: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'deliveryStatus',
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: i18nLabel(msg`Delivery status`),
|
||||
description: i18nLabel(
|
||||
msg`Per-recipient delivery status for campaign sends`,
|
||||
),
|
||||
icon: 'IconMailFast',
|
||||
isNullable: true,
|
||||
isUIReadOnly: true,
|
||||
options: [
|
||||
{
|
||||
id: '6b189ac2-5054-45c0-a95b-25764e978d81',
|
||||
value: 'QUEUED',
|
||||
label: i18nLabel(msg`Queued`),
|
||||
position: 0,
|
||||
color: 'gray',
|
||||
},
|
||||
{
|
||||
id: 'af7390a3-bd35-480b-9bc2-6f7d8589b3d2',
|
||||
value: 'SENT',
|
||||
label: i18nLabel(msg`Sent`),
|
||||
position: 1,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
id: '39c934fc-01d7-48fa-9b79-8e19f75dab03',
|
||||
value: 'FAILED',
|
||||
label: i18nLabel(msg`Failed`),
|
||||
position: 2,
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
id: 'ade2b01f-8f10-43c6-ab3d-63b0d98ce40c',
|
||||
value: 'BOUNCED',
|
||||
label: i18nLabel(msg`Bounced`),
|
||||
position: 3,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
id: 'ae79b7bc-b416-4fd2-a366-ab8d91cb22da',
|
||||
value: 'COMPLAINED',
|
||||
label: i18nLabel(msg`Complained`),
|
||||
position: 4,
|
||||
color: 'purple',
|
||||
},
|
||||
],
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
});
|
||||
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { i18nLabel } from 'src/engine/workspace-manager/twenty-standard-application/utils/i18n-label.util';
|
||||
import {
|
||||
DateDisplayFormat,
|
||||
FieldMetadataType,
|
||||
RelationOnDeleteAction,
|
||||
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_SUPPRESSION } from 'src/modules/emailing/standard-objects/message-suppression.workspace-entity';
|
||||
|
||||
export const buildMessageSuppressionStandardFlatFieldMetadatas = ({
|
||||
now,
|
||||
objectName,
|
||||
workspaceId,
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
}: Omit<
|
||||
CreateStandardFieldArgs<'messageSuppression', FieldMetadataType>,
|
||||
'context'
|
||||
>): Record<
|
||||
AllStandardObjectFieldName<'messageSuppression'>,
|
||||
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,
|
||||
}),
|
||||
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`Email suppression 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: 'IconMailOff',
|
||||
isSystem: true,
|
||||
isNullable: true,
|
||||
settings: {
|
||||
generatedType: 'STORED',
|
||||
asExpression: getTsVectorColumnExpressionFromFields(
|
||||
SEARCH_FIELDS_FOR_MESSAGE_SUPPRESSION,
|
||||
),
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
emailAddress: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'emailAddress',
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: i18nLabel(msg`Email address`),
|
||||
description: i18nLabel(msg`The suppressed email address`),
|
||||
icon: 'IconMail',
|
||||
isNullable: false,
|
||||
isUIReadOnly: true,
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
reason: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'reason',
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: i18nLabel(msg`Reason`),
|
||||
description: i18nLabel(msg`Why the address was suppressed`),
|
||||
icon: 'IconAlertTriangle',
|
||||
isNullable: false,
|
||||
isUIReadOnly: true,
|
||||
options: [
|
||||
{
|
||||
id: 'c23c60de-207c-4cde-8e5b-f8fdaea1224c',
|
||||
value: 'BOUNCE',
|
||||
label: i18nLabel(msg`Bounce`),
|
||||
position: 0,
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
id: '5b206d86-ccef-4727-bdca-9e5ae6c36cd3',
|
||||
value: 'COMPLAINT',
|
||||
label: i18nLabel(msg`Complaint`),
|
||||
position: 1,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
id: '4c4bb767-03f0-43b9-845e-ba5805093418',
|
||||
value: 'UNSUBSCRIBE',
|
||||
label: i18nLabel(msg`Unsubscribe`),
|
||||
position: 2,
|
||||
color: 'gray',
|
||||
},
|
||||
],
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
source: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'source',
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: i18nLabel(msg`Source`),
|
||||
description: i18nLabel(msg`How the suppression was recorded`),
|
||||
icon: 'IconPlugConnected',
|
||||
isNullable: false,
|
||||
isUIReadOnly: true,
|
||||
defaultValue: "'WEBHOOK'",
|
||||
options: [
|
||||
{
|
||||
id: '9fc65756-7048-46fb-9ad9-94c322fd1934',
|
||||
value: 'WEBHOOK',
|
||||
label: i18nLabel(msg`Webhook`),
|
||||
position: 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
id: '73a27181-cecf-4387-95bd-401ab5a00e9e',
|
||||
value: 'SYSTEM',
|
||||
label: i18nLabel(msg`System`),
|
||||
position: 1,
|
||||
color: 'sky',
|
||||
},
|
||||
{
|
||||
id: '7388e245-3c29-416c-bb07-df4305d9fc21',
|
||||
value: 'MANUAL',
|
||||
label: i18nLabel(msg`Manual`),
|
||||
position: 2,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
id: 'a9a62a41-bc13-41f3-8bea-c92118635765',
|
||||
value: 'IMPORT',
|
||||
label: i18nLabel(msg`Import`),
|
||||
position: 3,
|
||||
color: 'purple',
|
||||
},
|
||||
],
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
providerEventId: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'providerEventId',
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: i18nLabel(msg`Provider event ID`),
|
||||
description: i18nLabel(
|
||||
msg`Identifier of the provider event that triggered the suppression`,
|
||||
),
|
||||
icon: 'IconHash',
|
||||
isNullable: true,
|
||||
isUIReadOnly: true,
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
topic: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'topic',
|
||||
label: i18nLabel(msg`Topic`),
|
||||
description: i18nLabel(
|
||||
msg`The topic this suppression applies to, or empty for a global suppression`,
|
||||
),
|
||||
icon: 'IconMailbox',
|
||||
isNullable: true,
|
||||
isUIReadOnly: true,
|
||||
targetObjectName: 'messageTopic',
|
||||
targetFieldName: 'suppressions',
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
joinColumnName: 'topicId',
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
});
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
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_TOPIC } from 'src/modules/emailing/standard-objects/message-topic.workspace-entity';
|
||||
|
||||
export const buildMessageTopicStandardFlatFieldMetadatas = ({
|
||||
now,
|
||||
objectName,
|
||||
workspaceId,
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
}: Omit<
|
||||
CreateStandardFieldArgs<'messageTopic', FieldMetadataType>,
|
||||
'context'
|
||||
>): Record<AllStandardObjectFieldName<'messageTopic'>, 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,
|
||||
}),
|
||||
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`Email list 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: 'IconMailbox',
|
||||
isSystem: true,
|
||||
isNullable: true,
|
||||
settings: {
|
||||
generatedType: 'STORED',
|
||||
asExpression: getTsVectorColumnExpressionFromFields(
|
||||
SEARCH_FIELDS_FOR_MESSAGE_TOPIC,
|
||||
),
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
name: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: i18nLabel(msg`Name`),
|
||||
description: i18nLabel(msg`The topic public display name`),
|
||||
icon: 'IconMailbox',
|
||||
isNullable: true,
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
description: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'description',
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: i18nLabel(msg`Description`),
|
||||
description: i18nLabel(
|
||||
msg`Public description shown on the preferences page`,
|
||||
),
|
||||
icon: 'IconFileDescription',
|
||||
isNullable: true,
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
subscriptionDefault: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'subscriptionDefault',
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: i18nLabel(msg`Defaults to`),
|
||||
description: i18nLabel(
|
||||
msg`Whether contacts are subscribed to this topic by default`,
|
||||
),
|
||||
icon: 'IconToggleRight',
|
||||
isNullable: false,
|
||||
defaultValue: "'OPT_IN'",
|
||||
options: [
|
||||
{
|
||||
id: '9c23720f-c250-44e3-bf35-d6f5a1fd2d47',
|
||||
value: 'OPT_IN',
|
||||
label: i18nLabel(msg`Opt-in`),
|
||||
position: 0,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
id: 'cae4baf5-b81f-4a6d-bcc3-8015ddaeed12',
|
||||
value: 'OPT_OUT',
|
||||
label: i18nLabel(msg`Opt-out`),
|
||||
position: 1,
|
||||
color: 'orange',
|
||||
},
|
||||
],
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
visibility: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'visibility',
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: i18nLabel(msg`Visibility`),
|
||||
description: i18nLabel(
|
||||
msg`Whether the topic is shown on the public preferences page`,
|
||||
),
|
||||
icon: 'IconEye',
|
||||
isNullable: false,
|
||||
defaultValue: "'PRIVATE'",
|
||||
options: [
|
||||
{
|
||||
id: 'c919e1ad-75a9-4e3b-a8ad-05fde89e6146',
|
||||
value: 'PUBLIC',
|
||||
label: i18nLabel(msg`Public`),
|
||||
position: 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
id: 'f8568bfb-93da-40c2-b70e-e1fbc9d503f1',
|
||||
value: 'PRIVATE',
|
||||
label: i18nLabel(msg`Private`),
|
||||
position: 1,
|
||||
color: 'gray',
|
||||
},
|
||||
],
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
subscriptions: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'subscriptions',
|
||||
label: i18nLabel(msg`Subscriptions`),
|
||||
description: i18nLabel(msg`People subscribed to this list`),
|
||||
icon: 'IconMailShare',
|
||||
isNullable: true,
|
||||
targetObjectName: 'messageTopicSubscription',
|
||||
targetFieldName: 'topic',
|
||||
settings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
suppressions: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'suppressions',
|
||||
label: i18nLabel(msg`Suppressions`),
|
||||
description: i18nLabel(msg`Addresses unsubscribed from this topic`),
|
||||
icon: 'IconMailOff',
|
||||
isUIReadOnly: true,
|
||||
isNullable: true,
|
||||
targetObjectName: 'messageSuppression',
|
||||
targetFieldName: 'topic',
|
||||
settings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
campaigns: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'campaigns',
|
||||
label: i18nLabel(msg`Campaigns`),
|
||||
description: i18nLabel(msg`Campaigns sent to this list`),
|
||||
icon: 'IconSend',
|
||||
isUIReadOnly: true,
|
||||
isNullable: true,
|
||||
targetObjectName: 'messageCampaign',
|
||||
targetFieldName: 'topic',
|
||||
settings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
timelineActivities: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'timelineActivities',
|
||||
label: i18nLabel(msg`Events`),
|
||||
description: i18nLabel(msg`Events linked to the topic`),
|
||||
icon: 'IconTimelineEvent',
|
||||
isNullable: true,
|
||||
targetObjectName: 'timelineActivity',
|
||||
targetFieldName: 'targetMessageTopic',
|
||||
settings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
});
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { i18nLabel } from 'src/engine/workspace-manager/twenty-standard-application/utils/i18n-label.util';
|
||||
import {
|
||||
DateDisplayFormat,
|
||||
FieldMetadataType,
|
||||
RelationOnDeleteAction,
|
||||
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';
|
||||
|
||||
export const buildMessageTopicSubscriptionStandardFlatFieldMetadatas = ({
|
||||
now,
|
||||
objectName,
|
||||
workspaceId,
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
}: Omit<
|
||||
CreateStandardFieldArgs<'messageTopicSubscription', FieldMetadataType>,
|
||||
'context'
|
||||
>): Record<
|
||||
AllStandardObjectFieldName<'messageTopicSubscription'>,
|
||||
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,
|
||||
}),
|
||||
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`Email list subscription record position`),
|
||||
icon: 'IconHierarchy2',
|
||||
isSystem: true,
|
||||
isNullable: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
topicName: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'topicName',
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: i18nLabel(msg`Topic`),
|
||||
description: i18nLabel(msg`The topic this subscription is for`),
|
||||
icon: 'IconMailbox',
|
||||
isNullable: true,
|
||||
isUIReadOnly: true,
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
status: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'status',
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: i18nLabel(msg`Status`),
|
||||
description: i18nLabel(msg`Subscription status for the topic`),
|
||||
icon: 'IconMailShare',
|
||||
isNullable: false,
|
||||
isUIReadOnly: true,
|
||||
defaultValue: "'SUBSCRIBED'",
|
||||
options: [
|
||||
{
|
||||
id: '72e1d22b-46d2-4714-893b-bc10492426a9',
|
||||
value: 'SUBSCRIBED',
|
||||
label: i18nLabel(msg`Subscribed`),
|
||||
position: 0,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
id: 'e4c6231e-d0fb-4a74-b221-7eefe24bc627',
|
||||
value: 'UNSUBSCRIBED',
|
||||
label: i18nLabel(msg`Unsubscribed`),
|
||||
position: 1,
|
||||
color: 'red',
|
||||
},
|
||||
],
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
subscribedAt: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'subscribedAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
label: i18nLabel(msg`Subscribed at`),
|
||||
description: i18nLabel(msg`When the person subscribed to the list`),
|
||||
icon: 'IconCalendarPlus',
|
||||
isNullable: true,
|
||||
isUIReadOnly: true,
|
||||
settings: { displayFormat: DateDisplayFormat.RELATIVE },
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
unsubscribedAt: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'unsubscribedAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
label: i18nLabel(msg`Unsubscribed at`),
|
||||
description: i18nLabel(msg`When the person unsubscribed from the list`),
|
||||
icon: 'IconCalendarMinus',
|
||||
isNullable: true,
|
||||
isUIReadOnly: true,
|
||||
settings: { displayFormat: DateDisplayFormat.RELATIVE },
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
source: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'source',
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: i18nLabel(msg`Source`),
|
||||
description: i18nLabel(msg`How the subscription was created`),
|
||||
icon: 'IconPlugConnected',
|
||||
isNullable: false,
|
||||
isUIReadOnly: true,
|
||||
defaultValue: "'MANUAL'",
|
||||
options: [
|
||||
{
|
||||
id: '81977410-0a1b-41de-b46a-d55734e56afd',
|
||||
value: 'FORM',
|
||||
label: i18nLabel(msg`Form`),
|
||||
position: 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
id: '03864238-94bb-4678-b8a8-f5d8a5f92973',
|
||||
value: 'IMPORT',
|
||||
label: i18nLabel(msg`Import`),
|
||||
position: 1,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
id: 'e6c2e5b7-56b4-4118-a912-1ce4391768f3',
|
||||
value: 'API',
|
||||
label: i18nLabel(msg`API`),
|
||||
position: 2,
|
||||
color: 'turquoise',
|
||||
},
|
||||
{
|
||||
id: 'aec7a73d-88d5-435a-926c-825ce9d29272',
|
||||
value: 'MANUAL',
|
||||
label: i18nLabel(msg`Manual`),
|
||||
position: 3,
|
||||
color: 'green',
|
||||
},
|
||||
],
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
topic: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'topic',
|
||||
label: i18nLabel(msg`Topic`),
|
||||
description: i18nLabel(msg`The list the person is subscribed to`),
|
||||
icon: 'IconMailbox',
|
||||
isNullable: false,
|
||||
targetObjectName: 'messageTopic',
|
||||
targetFieldName: 'subscriptions',
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
joinColumnName: 'topicId',
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
person: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'person',
|
||||
label: i18nLabel(msg`Person`),
|
||||
description: i18nLabel(msg`The subscribed person`),
|
||||
icon: 'IconUser',
|
||||
isNullable: false,
|
||||
targetObjectName: 'person',
|
||||
targetFieldName: 'messageTopicSubscriptions',
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
joinColumnName: 'personId',
|
||||
},
|
||||
},
|
||||
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: 'IconUser',
|
||||
isSystem: true,
|
||||
isNullable: true,
|
||||
settings: {
|
||||
generatedType: 'STORED',
|
||||
asExpression: getTsVectorColumnExpressionFromFields([
|
||||
{ name: 'id', type: FieldMetadataType.UUID },
|
||||
]),
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
});
|
||||
+46
@@ -490,6 +490,52 @@ export const buildPersonStandardFlatFieldMetadatas = ({
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
messageTopicSubscriptions: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'messageTopicSubscriptions',
|
||||
label: i18nLabel(msg`Topics`),
|
||||
description: i18nLabel(msg`Topics the contact is subscribed to`),
|
||||
icon: 'IconMailShare',
|
||||
isUIReadOnly: false,
|
||||
isNullable: true,
|
||||
targetObjectName: 'messageTopicSubscription',
|
||||
targetFieldName: 'person',
|
||||
settings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
listMemberships: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.RELATION,
|
||||
morphId: null,
|
||||
fieldName: 'listMemberships',
|
||||
label: i18nLabel(msg`Lists`),
|
||||
description: i18nLabel(msg`Lists the contact belongs to`),
|
||||
icon: 'IconUsersGroup',
|
||||
isUIReadOnly: false,
|
||||
isNullable: true,
|
||||
targetObjectName: 'messageListMember',
|
||||
targetFieldName: 'person',
|
||||
settings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
searchVector: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
|
||||
+75
@@ -536,6 +536,81 @@ export const buildTimelineActivityStandardFlatFieldMetadatas = ({
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
targetMessageList: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphId: STANDARD_OBJECTS.timelineActivity.morphIds.targetMorphId.morphId,
|
||||
fieldName: 'targetMessageList',
|
||||
label: i18nLabel(msg`Target`),
|
||||
description: i18nLabel(msg`Event target`),
|
||||
icon: 'IconArrowUpRight',
|
||||
isNullable: true,
|
||||
isUIReadOnly: true,
|
||||
targetObjectName: 'messageList',
|
||||
targetFieldName: 'timelineActivities',
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'targetMessageListId',
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
targetMessageTopic: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphId: STANDARD_OBJECTS.timelineActivity.morphIds.targetMorphId.morphId,
|
||||
fieldName: 'targetMessageTopic',
|
||||
label: i18nLabel(msg`Target`),
|
||||
description: i18nLabel(msg`Event target`),
|
||||
icon: 'IconArrowUpRight',
|
||||
isNullable: true,
|
||||
isUIReadOnly: true,
|
||||
targetObjectName: 'messageTopic',
|
||||
targetFieldName: 'timelineActivities',
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'targetMessageTopicId',
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
targetMessageCampaign: createStandardRelationFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphId: STANDARD_OBJECTS.timelineActivity.morphIds.targetMorphId.morphId,
|
||||
fieldName: 'targetMessageCampaign',
|
||||
label: i18nLabel(msg`Target`),
|
||||
description: i18nLabel(msg`Event target`),
|
||||
icon: 'IconArrowUpRight',
|
||||
isNullable: true,
|
||||
isUIReadOnly: true,
|
||||
targetObjectName: 'messageCampaign',
|
||||
targetFieldName: 'timelineActivities',
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'targetMessageCampaignId',
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
searchVector: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ const computeStandardViewObjectIds = <O extends AllStandardObjectName>({
|
||||
}): StandardObjectViewIds<O> | undefined => {
|
||||
const objectDefinition = STANDARD_OBJECTS[objectName];
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(objectDefinition, 'views')) {
|
||||
if (!('views' in objectDefinition)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -10,6 +10,12 @@ import { buildCalendarEventParticipantStandardFlatIndexMetadatas } from 'src/eng
|
||||
import { buildCallRecordingStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-call-recording-standard-flat-index-metadata.util';
|
||||
import { buildCompanyStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-company-standard-flat-index-metadata.util';
|
||||
import { buildDashboardStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-dashboard-standard-flat-index-metadata.util';
|
||||
import { buildMessageCampaignStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-campaign-standard-flat-index-metadata.util';
|
||||
import { buildMessageSuppressionStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-suppression-standard-flat-index-metadata.util';
|
||||
import { buildMessageTopicStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-topic-standard-flat-index-metadata.util';
|
||||
import { buildMessageTopicSubscriptionStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-topic-subscription-standard-flat-index-metadata.util';
|
||||
import { buildMessageListStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-list-standard-flat-index-metadata.util';
|
||||
import { buildMessageListMemberStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-list-member-standard-flat-index-metadata.util';
|
||||
import { buildMessageChannelMessageAssociationMessageFolderStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-channel-message-association-message-folder-standard-flat-index-metadata.util';
|
||||
import { buildMessageChannelMessageAssociationStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-channel-message-association-standard-flat-index-metadata.util';
|
||||
import { buildMessageParticipantStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-participant-standard-flat-index-metadata.util';
|
||||
@@ -42,6 +48,12 @@ const STANDARD_FLAT_INDEX_METADATA_BUILDERS_BY_OBJECT_NAME = {
|
||||
callRecording: buildCallRecordingStandardFlatIndexMetadatas,
|
||||
company: buildCompanyStandardFlatIndexMetadatas,
|
||||
dashboard: buildDashboardStandardFlatIndexMetadatas,
|
||||
messageCampaign: buildMessageCampaignStandardFlatIndexMetadatas,
|
||||
messageSuppression: buildMessageSuppressionStandardFlatIndexMetadatas,
|
||||
messageTopic: buildMessageTopicStandardFlatIndexMetadatas,
|
||||
messageTopicSubscription: buildMessageTopicSubscriptionStandardFlatIndexMetadatas,
|
||||
messageList: buildMessageListStandardFlatIndexMetadatas,
|
||||
messageListMember: buildMessageListMemberStandardFlatIndexMetadatas,
|
||||
message: buildMessageStandardFlatIndexMetadatas,
|
||||
messageChannelMessageAssociation:
|
||||
buildMessageChannelMessageAssociationStandardFlatIndexMetadatas,
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
|
||||
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
|
||||
import { type AllStandardObjectIndexName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-index-name.type';
|
||||
import {
|
||||
type CreateStandardIndexArgs,
|
||||
createStandardIndexFlatMetadata,
|
||||
} from 'src/engine/workspace-manager/twenty-standard-application/utils/index/create-standard-index-flat-metadata.util';
|
||||
|
||||
export const buildMessageCampaignStandardFlatIndexMetadatas = ({
|
||||
now,
|
||||
objectName,
|
||||
workspaceId,
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
}: Omit<CreateStandardIndexArgs<'messageCampaign'>, 'context'>): Record<
|
||||
AllStandardObjectIndexName<'messageCampaign'>,
|
||||
FlatIndexMetadata
|
||||
> => ({
|
||||
topicIdIndex: createStandardIndexFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
indexName: 'topicIdIndex',
|
||||
relatedFieldNames: ['topic'],
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
searchVectorGinIndex: createStandardIndexFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
indexName: 'searchVectorGinIndex',
|
||||
relatedFieldNames: ['searchVector'],
|
||||
indexType: IndexType.GIN,
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
|
||||
import { type AllStandardObjectIndexName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-index-name.type';
|
||||
import {
|
||||
type CreateStandardIndexArgs,
|
||||
createStandardIndexFlatMetadata,
|
||||
} from 'src/engine/workspace-manager/twenty-standard-application/utils/index/create-standard-index-flat-metadata.util';
|
||||
|
||||
export const buildMessageListMemberStandardFlatIndexMetadatas = ({
|
||||
now,
|
||||
objectName,
|
||||
workspaceId,
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
}: Omit<CreateStandardIndexArgs<'messageListMember'>, 'context'>): Record<
|
||||
AllStandardObjectIndexName<'messageListMember'>,
|
||||
FlatIndexMetadata
|
||||
> => ({
|
||||
listIdIndex: createStandardIndexFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
indexName: 'listIdIndex',
|
||||
relatedFieldNames: ['list'],
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
personListUniqueIndex: createStandardIndexFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
indexName: 'personListUniqueIndex',
|
||||
relatedFieldNames: ['person', 'list'],
|
||||
isUnique: true,
|
||||
indexWhereClause: '"deletedAt" IS NULL',
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
});
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
|
||||
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
|
||||
import { type AllStandardObjectIndexName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-index-name.type';
|
||||
import {
|
||||
type CreateStandardIndexArgs,
|
||||
createStandardIndexFlatMetadata,
|
||||
} from 'src/engine/workspace-manager/twenty-standard-application/utils/index/create-standard-index-flat-metadata.util';
|
||||
|
||||
export const buildMessageListStandardFlatIndexMetadatas = ({
|
||||
now,
|
||||
objectName,
|
||||
workspaceId,
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
}: Omit<CreateStandardIndexArgs<'messageList'>, 'context'>): Record<
|
||||
AllStandardObjectIndexName<'messageList'>,
|
||||
FlatIndexMetadata
|
||||
> => ({
|
||||
searchVectorGinIndex: createStandardIndexFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
indexName: 'searchVectorGinIndex',
|
||||
relatedFieldNames: ['searchVector'],
|
||||
indexType: IndexType.GIN,
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user