Compare commits

...

34 Commits

Author SHA1 Message Date
Félix Malfait 6ce47f3e7e Merge remote-tracking branch 'origin/main' into feat/marketing-emails-rename
# Conflicts:
#	packages/twenty-client-sdk/src/metadata/generated/types.ts
#	packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module.ts
2026-06-07 15:24:05 +02:00
Félix Malfait 53cff75465 feat(emailing): implement campaign send to resolved recipients 2026-06-07 15:21:36 +02:00
Félix Malfait c54ca5c608 feat(emailing): per-recipient CRM visibility for campaign sends
Reuse the messaging stack so a campaign send is materialized as one
message per recipient on the workspace email-group channel: recipients,
per-recipient delivery status, replies and bounces surface on the
contact and in the campaign.

- nullable message.messageCampaign + deliveryStatus, messageCampaign.messages
- materialize QUEUED messages, send asynchronously on the email queue
- SES bounce/complaint -> per-message BOUNCED/COMPLAINED
- keep shared email-group senders out of member-offboarding cleanup
- rename marketing objects: Segment -> List, Subscription -> Topic
  Subscription, Broadcast -> Campaign
- extract getDomainFromEmail helper (fix first-vs-last @ extraction)
2026-06-07 11:20:19 +02:00
neo773 2ba147b631 revert dev-seeder-metadata.service.ts 2026-06-04 02:23:17 +05:30
neo773 4439029782 remove redundant section 2026-06-04 02:11:32 +05:30
neo773 70de1d929d fix(emailing): exclude soft-deleted rows from subscription/membership unique indexes
A soft-deleted messageSubscription / messageSegmentMember row kept occupying
the (person, topic) / (person, segment) unique index, so re-subscribing or
re-adding to a segment after removal threw a duplicate entry error. Make the
unique indexes partial with WHERE "deletedAt" IS NULL.
2026-06-03 16:52:08 +05:30
neo773 430f3cb7c6 feat(emailing): topics/segments junction picker on person
person.messageSubscriptions/segmentMemberships and messageSegment.members stay
plain one-to-many in the builder (so the standard-app synchronize never hits the
junction-target ordering bug), and the dev-seeder applies junctionTargetFieldId
after build via updateOneField, so the topic/segment picker reproduces on reset.
2026-06-03 03:55:17 +05:30
neo773 4d17b1179f fix(emailing): make message objects valid standard objects
Browsable objects (messageTopic/Segment/Broadcast) become timelineActivity
targets so writes don't fail with a missing target column; the pure join
objects (messageSubscription/SegmentMember) are system. Follows the same
target pattern every non-system standard object uses.
2026-06-03 03:55:03 +05:30
neo773 eb213069d2 feat(emailing): per-topic suppression for granular unsubscribe
Unsubscribe deletes the subscription and records a per-topic messageSuppression
(topic relation added) so the opt-out blocks segment/view sends too, not just
topic-list sends. Resubscribe lifts it. Global suppression stays topic-null.
2026-06-03 03:54:48 +05:30
neo773 5732c1de2a fix(emailing): make messageSegmentMember a system object
Join objects must be system (matches noteTarget/messageChannelMessageAssociation).
Non-system objects emit timeline activities, but messageSegmentMember is not a
timelineActivity target, so writes threw "field targetMessageSegmentMemberId is
missing". The junction picker still works on a system join.
2026-06-02 22:28:32 +05:30
neo773 3d491ed370 feat(emailing): send broadcast to a segment's members
Add optional segmentId to the broadcast composer and sendMessageBroadcast
input. Recipients resolve segment members first, then a Person view, then
topic subscribers. Additive - recipientViewId still works.
2026-06-02 22:28:02 +05:30
neo773 641df0a271 feat(emailing): add campaign composer side panel command
Compose-campaign command opens a side panel composer to send a message
broadcast to a topic. Registers the command menu item via workspace upgrade.
2026-06-02 21:22:08 +05:30
neo773 fcab418fba feat(emailing): granular topic unsubscribe preferences page
Public preferences page lets recipients toggle individual topic
subscriptions instead of all-or-nothing. HTML escaped on render.
2026-06-02 21:21:57 +05:30
neo773 702e02e590 feat(emailing): add segments and junction relation display
Add messageSegment and messageSegmentMember join object for hand-picked
segment membership. Surface Topics and Segments on the person record page and
Members on the segment record page as native junction relation cards via
settings.junctionTargetFieldId on the through relations - no custom UI.
2026-06-02 21:21:32 +05:30
neo773 74e12df09c refactor(emailing): rename email* objects to message* prefix
Channel-agnostic naming ahead of SMS/text support: emailList->messageTopic,
emailListSubscription->messageSubscription, emailCampaign->messageBroadcast.
Regenerate client-sdk and front metadata graphql types.
2026-06-02 21:21:08 +05:30
neo773 3980094e02 refactor(emailing): rename email* objects to message* prefix
Drop the email* prefix for a channel-agnostic message* namespace:
- emailList -> messageTopic
- emailListSubscription -> messageSubscription
- emailCampaign -> messageBroadcast (listId -> topicId)
- emailGroupSuppressionList -> messageSuppression

Entities, services, enum types, DTOs, flat-metadata builders and the
front settings section renamed; STANDARD_OBJECTS keys updated.
2026-06-02 17:25:11 +05:30
neo773 295d924db8 revert(emailing): remove campaign composer tab
Restore the side-panel email composer to the transactional-only
version on main. Drops the marketing-tab UI (mode tabs, campaign
composer, extracted footer) and the campaign send hooks/mutation
that only existed to support the tab.
2026-06-02 16:49:18 +05:30
neo773 39837b9f0f wip 2026-06-02 15:58:26 +05:30
neo773 d94d69e6a7 feat(emailing): add marketing campaign composer
Split the compose-email side panel into Transactional and Marketing
modes via a segmented tab bar. Marketing reuses the connected-account
sender, picks a list, and sends through the sendEmailCampaign mutation.
2026-06-02 15:58:25 +05:30
neo773 93b87a5362 feat(emailing): add email lists management settings page
Add a custom Email Lists section on settings/email to create lists and
add or remove members via the existing SingleRecordPicker.
2026-06-02 15:58:24 +05:30
neo773 925bb9596c chore(emailing): regenerate graphql types for sendEmailCampaign 2026-06-02 15:58:22 +05:30
neo773 9a1735c4e3 feat(emailing): backfill email objects on existing workspaces
Replace the add-email-group-suppressed-recipient instance migration with
a 2-9 workspace command that backfills the email suppression and list
standard objects into existing workspaces.
2026-06-02 15:58:01 +05:30
neo773 18dc6fb313 feat(emailing): add campaign send to a list
Add the sendEmailCampaign mutation and EmailCampaignService: resolve a
list's subscribed members, drop suppressed and unsubscribed addresses,
send per recipient via SES with an unsubscribe footer, and roll up the
sent/failed counts onto the campaign record.
2026-06-02 15:58:00 +05:30
neo773 30154aec47 feat(emailing): make unsubscribe list-aware
Carry the emailListId in the unsubscribe token so a one-click
unsubscribe from a campaign removes the recipient from that list only,
falling back to global suppression when no person matches. Also make
hostname provisioning idempotent by adopting an already-registered
Cloudflare hostname instead of failing.
2026-06-02 15:57:59 +05:30
neo773 b184f5c25f feat(emailing): add email list subscription service
Manage per-list subscription state (subscribe, unsubscribe,
unsubscribeByEmail) and expose the addresses unsubscribed from a list so
campaign sends can drop them.
2026-06-02 15:57:58 +05:30
neo773 287c9c8a67 refactor(emailing): model suppression by reason and source
Replace the suppressed-recipient entity and scope constants with a
workspace-object backed suppression keyed by reason (BOUNCE, COMPLAINT,
UNSUBSCRIBE) and source (WEBHOOK, SYSTEM, MANUAL, IMPORT), with blocking
driven by message category. Update the SES inbound/outbound handlers.
2026-06-02 15:57:57 +05:30
neo773 bcf633986f feat(emailing): add email suppression, list, subscription and campaign standard objects
Define the workspace standard objects backing the email marketing
feature: emailGroupSuppressionList, emailList, emailListSubscription and
emailCampaign, with their flat object/field/index metadata builders and
the person.emailListSubscriptions inverse relation.
2026-06-02 15:57:56 +05:30
neo773 0accf11b39 refactor(emailing-domain): black-box sendEmail + split sender from lifecycle
- Rewrite sendEmail as compute-then-assemble-once: assertDomainCanSend, selectDeliverableRecipients, buildUnsubscribeContent black boxes feed one explicit driver-call literal (no input threading)
- Split EmailingDomainSenderService out of EmailingDomainService (439 -> 224/209 lines); move unsubscribe-hostname sync + dns-records merge into UnsubscribeHostnameService
- Leaf unsubscribe utils (urls/headers/text-footer/html-footer), one export each
- EMPTY_UNSUBSCRIBE_CONTENT no-op removes assembly ternaries; rewire resolver + outbound driver callers
2026-06-02 15:57:54 +05:30
neo773 b62c941595 feat(emailing-domain): unsubscribe host provisioning + records, drop SES contact list
- Provision per-tenant unsubscribe custom hostname via DnsManagerService on verify (EE)
- Surface unsubscribe DNS records alongside SES records in the setup table
- Harden unsubscribe token validation (strict format) in the controller
- Route inbound mail by intent (unsubscribe vs import) instead of try/return-bool
- Remove SES ListManagement contact list (self-hosted unsubscribe, hit 1-per-account cap)
2026-06-02 15:57:53 +05:30
neo773 07b660e2d7 migrate 2026-06-02 15:57:52 +05:30
neo773 af6c31470a unsub 2026-06-02 15:57:51 +05:30
neo773 77755da8fc nit rename EmailGroupSendType to EmailGroupMessageCategory 2026-06-02 15:57:49 +05:30
neo773 b9cc5589b8 fix(emailing-domain): shorten suppression unique constraint to fit 63-char limit
The constraint name exceeded Postgres' 63-char identifier limit, so it was
truncated in the DB and migrate:generate kept emitting a rename (CI drift).
Shorten it, regenerate the migration off a main baseline, and simplify
suppress() back to an idempotent upsert with an empty-address guard.
2026-06-02 15:57:47 +05:30
neo773 59ef653626 feat(emailing-domain): per-workspace email suppression with SES events
Replace SES account-level suppression with an app-owned, per-workspace list.

- EmailGroupSuppressedRecipient entity keyed (workspaceId, emailAddress, scope);
  scope GLOBAL (hard bounce/complaint) vs CAMPAIGN (unsubscribe), isSuppressed
  flag keeps resubscribe non-destructive.
- Handle SES Email Bounced/Complaint EventBridge events -> suppress recipients.
- Scope-aware getSuppressedAddresses(sendType); email-group sends as CAMPAIGN.
- sendEmail filters to/cc/bcc, returns delivered recipients so persistence and
  contact-creation skip suppressed addresses; throws ALL_RECIPIENTS_SUPPRESSED.
- Drop SES per-workspace contact list from the send path (1-list-per-account
  limit); rely on app-owned suppression.
- Fix AI email tool channel lookup for email_group accounts.
2026-06-02 15:57:44 +05:30
133 changed files with 7929 additions and 1175 deletions
@@ -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
@@ -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>
);
};
@@ -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,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 />,
@@ -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 />],
],
);
@@ -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 };
};
@@ -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>
);
};
@@ -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"');
}
}
@@ -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,
],
@@ -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}`,
);
}
}
@@ -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}`,
);
}
}
@@ -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,
];
@@ -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;
@@ -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,
],
};
@@ -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;
@@ -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: '',
};
@@ -0,0 +1 @@
export const UNSUBSCRIBE_HOSTNAME_PREFIX = 'unsubscribe';
@@ -0,0 +1 @@
export const UNSUBSCRIBE_MAILBOX_LOCAL_PART = 'unsubscribe';
@@ -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 +0,0 @@
export const AWS_SES_MARKETING_TOPIC_NAME = 'marketing';
@@ -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,
]);
});
@@ -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' },
@@ -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();
@@ -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({
@@ -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;
@@ -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;
@@ -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[] };
};
@@ -0,0 +1,5 @@
export enum UnsubscribeHostnameStatus {
PENDING = 'PENDING',
ACTIVE = 'ACTIVE',
FAILED = 'FAILED',
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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,
@@ -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(
@@ -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);
}
}
@@ -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.
@@ -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),
};
}
}
@@ -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(
@@ -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);
}
}
@@ -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;
}
}
@@ -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));
}
}
@@ -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}`;
}
}
@@ -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);
}
}
@@ -0,0 +1,5 @@
export type DeliverableRecipients = {
to: string[];
cc?: string[];
bcc?: string[];
};
@@ -0,0 +1,4 @@
export enum EmailGroupMessageCategory {
TRANSACTIONAL = 'TRANSACTIONAL',
CAMPAIGN = 'CAMPAIGN',
}
@@ -0,0 +1,5 @@
export enum MessageSuppressionReason {
BOUNCE = 'BOUNCE',
COMPLAINT = 'COMPLAINT',
UNSUBSCRIBE = 'UNSUBSCRIBE',
}
@@ -0,0 +1,6 @@
export enum MessageSuppressionSource {
WEBHOOK = 'WEBHOOK',
SYSTEM = 'SYSTEM',
MANUAL = 'MANUAL',
IMPORT = 'IMPORT',
}
@@ -0,0 +1,6 @@
export enum MessageTopicSubscriptionSource {
FORM = 'FORM',
IMPORT = 'IMPORT',
API = 'API',
MANUAL = 'MANUAL',
}
@@ -0,0 +1,4 @@
export enum MessageTopicSubscriptionStatus {
SUBSCRIBED = 'SUBSCRIBED',
UNSUBSCRIBED = 'UNSUBSCRIBED',
}
@@ -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;
};
@@ -0,0 +1,4 @@
export type SubscribedTopic = {
topicId: string;
topicName: string | null;
};
@@ -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;
};
@@ -0,0 +1,4 @@
export type UnsubscribeUrls = {
httpsUrl: string;
mailtoUrl: string;
};
@@ -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 },
});
});
});
@@ -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' },
];
@@ -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>`;
@@ -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>`;
};
@@ -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>`;
@@ -0,0 +1,2 @@
export const buildUnsubscribeTextFooter = (httpsUrl: string): string =>
`\n\n--\nUnsubscribe: ${httpsUrl}`;
@@ -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}`,
});
@@ -0,0 +1,10 @@
const HTML_ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
export const escapeHtml = (value: string): string =>
value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
@@ -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,
@@ -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,
],
@@ -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;
@@ -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,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;
}
}
@@ -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);
};
}
@@ -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);
}
}
@@ -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;
};
};
};
@@ -0,0 +1 @@
export type SesInboundMailIntent = 'UNSUBSCRIBE' | 'IMPORT';
@@ -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';
};
@@ -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;
};
@@ -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 &&
@@ -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
@@ -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,
@@ -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',
@@ -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 = {
@@ -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,
@@ -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,
@@ -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,
},
},
}),
};
};
@@ -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,
}),
});
@@ -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,
}),
});
@@ -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,
}),
});
@@ -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,
}),
});
@@ -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,
}),
});
@@ -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,
}),
});
@@ -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,
@@ -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,
@@ -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;
}
@@ -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,
@@ -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,
}),
});
@@ -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,
}),
});
@@ -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