[WIP] Feat/marketing emails (#21173)

Marketing/campaign emails on top of the emailing-domain (SES) feature:
send a broadcast to a hand-picked list, with per-customer-domain
unsubscribe links and opt-out-only **unsubscribe topics**.

## Model

Standard objects (workspace schema, flat-metadata):
- `messageCampaign` — a campaign send (subject, body template, from
address, status, list, optional unsubscribe topic).
- `messageList` + `messageListMember` — the hand-picked audience (person
↔ list join). A campaign's recipients are its list's members; everyone
is sendable unless suppressed.

Core entities (`core` schema, workspace-scoped — readable by the public
unsubscribe flow without a workspace context):
- `unsubscribeTopic` — an opt-out-only category (name, description,
visibility). There is no opt-in subscription state.
- `messageSuppression` — the single consent store: a row with
`unsubscribeTopicId` NULL is a global block; a row with an
`unsubscribeTopicId` and reason `UNSUBSCRIBE` is a per-topic opt-out.
Two partial unique indexes dedupe global vs per-topic rows (Postgres
treats NULLs as distinct).
- `emailingDomain` — the workspace's SES sending domain,
auto-provisioned when an email channel is added (and cleaned up when its
last channel is removed), with verification status + DNS records.

Campaign messages reuse the existing `message` / `messageThread` /
`messageParticipant` model — one outbound `message` per recipient with a
`deliveryStatus` state machine.

## Sending

- `sendMessageCampaign` resolves the audience **under the caller's
permissions**, creates the campaign, and enqueues a single fan-out job
(the request never materializes per-recipient rows or jobs).
- The fan-out job materializes one QUEUED message per recipient
(deterministic ids → idempotent re-runs, reconciles crash-orphaned rows)
and fans out per-recipient send jobs carrying **only ids**.
- Each send job renders per-recipient `{{variable}}` merge fields and
sends via `EmailingDomainSenderService`, which applies suppression
(global + per-topic) and the unsubscribe footer/headers. Suppressed
recipients are recorded `SKIPPED`.
- The campaign finalizes `SENT`, or `SENT_WITH_ERRORS` if any recipient
terminally failed.
- `previewMessageCampaignAudience` returns a pre-send breakdown (total /
without-email / duplicate / globally-unsubscribed / topic-unsubscribed /
sendable), shown as a hint under the composer pickers.

## Unsubscribe

- Encrypted (AES-256-GCM) token carrying workspaceId, address, optional
`unsubscribeTopicId`, `issuedAt`, and a `preview` flag.
- One-click POST (RFC 8058) + `mailto:` — topic-scoped when the token
carries a topic, global otherwise.
- Preferences page: a checkbox per visible topic (checked = still
receiving); submitting creates per-topic opt-outs for unchecked topics
and lifts re-checked ones (UNSUBSCRIBE only — never
`BOUNCE`/`COMPLAINT`, never a global block).
- A **Preview** action in settings opens the live page via a
preview-claim token; opt-out POSTs are no-ops for preview tokens, so
previewing never mutates state.
- SES webhooks: inbound unsubscribe + outbound bounce/complaint →
suppression (race-safe against at-least-once delivery, with reason
escalation that never downgrades).
- Per-customer unsubscribe hostname (Cloudflare DNS); sends are gated on
it being active, except in LOG/demo mode.

## Architecture

Campaign orchestration, suppression, the sender, the unsubscribe
controller, and the SES webhook handlers live in `src/modules/emailing`
+ `src/modules/messaging-webhooks` (the workspace-feature layer).
`core-modules/emailing-domain` keeps the SES driver, domain
provisioning, the `unsubscribeTopic` / `messageSuppression` core
entities, and the unsubscribe token/hostname plumbing. Domain creation
is validated (`CreateEmailingDomainInput` — domain-format regex,
lowercased) before any value reaches SES or the unsubscribe hostname.

## Frontend

- Campaign composer side panel (from / list / unsubscribe topic /
subject / body) with a live audience-preview hint.
- Email settings: email channels each showing their auto-provisioned
sending domain in a single section (status + DNS records + a "Check
verification" action), plus an **Unsubscribe Topics** section to
create/manage topics and preview the recipient page. A demo-mode banner
is shown when the LOG driver is active.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
neo773
2026-06-13 22:07:39 +05:30
committed by GitHub
parent 2c5da39dc5
commit 5d892bdfd0
238 changed files with 10433 additions and 3517 deletions
@@ -400,6 +400,7 @@ enum EngineComponentKey {
FRONT_COMPONENT_RENDERER
REPLY_TO_EMAIL_THREAD
COMPOSE_EMAIL
COMPOSE_CAMPAIGN
GO_TO_PEOPLE
GO_TO_COMPANIES
GO_TO_DASHBOARDS
@@ -1440,34 +1441,6 @@ type EnterpriseSubscriptionStatusDTO {
isCancellationScheduled: Boolean!
}
type VerificationRecord {
type: String!
key: String!
value: String!
priority: Float
}
type EmailingDomain {
id: UUID!
createdAt: DateTime!
updatedAt: DateTime!
domain: String!
status: EmailingDomainStatus!
verificationRecords: [VerificationRecord!]
verifiedAt: DateTime
}
enum EmailingDomainStatus {
PENDING
VERIFIED
FAILED
TEMPORARY_FAILURE
}
type SendEmailViaDomainOutput {
messageId: String!
}
type ApprovedAccessDomain {
id: UUID!
domain: String!
@@ -1964,7 +1937,7 @@ type ClientConfig {
isGoogleCalendarEnabled: Boolean!
isConfigVariablesInDbEnabled: Boolean!
isImapSmtpCaldavEnabled: Boolean!
isEmailGroupEnabled: Boolean!
isEmailingDomainInDemoMode: Boolean!
allowRequestsToTwentyIcons: Boolean!
calendarBookingPageId: String
isCloudflareIntegrationEnabled: Boolean!
@@ -2442,6 +2415,146 @@ type PublicDomain {
createdAt: DateTime!
}
type VerificationRecord {
type: String!
key: String!
value: String!
priority: Float
}
type EmailingDomain {
id: UUID!
createdAt: DateTime!
updatedAt: DateTime!
domain: String!
status: EmailingDomainStatus!
verificationRecords: [VerificationRecord!]
verifiedAt: DateTime
}
enum EmailingDomainStatus {
PENDING
VERIFIED
FAILED
TEMPORARY_FAILURE
}
type MessageChannel {
id: UUID!
visibility: MessageChannelVisibility!
handle: String!
type: MessageChannelType!
isContactAutoCreationEnabled: Boolean!
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy!
messageFolderImportPolicy: MessageFolderImportPolicy!
excludeNonProfessionalEmails: Boolean!
excludeGroupEmails: Boolean!
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction!
isSyncEnabled: Boolean!
syncedAt: DateTime
syncStatus: MessageChannelSyncStatus!
syncStage: MessageChannelSyncStage!
syncStageStartedAt: DateTime
throttleFailureCount: Float!
throttleRetryAfter: DateTime
connectedAccountId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
connectedAccount: ConnectedAccountPublicDTO
}
enum MessageChannelVisibility {
METADATA
SUBJECT
SHARE_EVERYTHING
}
enum MessageChannelType {
EMAIL
SMS
EMAIL_GROUP
}
enum MessageChannelContactAutoCreationPolicy {
SENT_AND_RECEIVED
SENT
NONE
}
enum MessageFolderImportPolicy {
ALL_FOLDERS
SELECTED_FOLDERS
}
enum MessageChannelPendingGroupEmailsAction {
GROUP_EMAILS_DELETION
GROUP_EMAILS_IMPORT
NONE
}
enum MessageChannelSyncStatus {
NOT_SYNCED
ONGOING
ACTIVE
FAILED_INSUFFICIENT_PERMISSIONS
FAILED_UNKNOWN
}
enum MessageChannelSyncStage {
PENDING_CONFIGURATION
MESSAGE_LIST_FETCH_PENDING
MESSAGE_LIST_FETCH_SCHEDULED
MESSAGE_LIST_FETCH_ONGOING
MESSAGES_IMPORT_PENDING
MESSAGES_IMPORT_SCHEDULED
MESSAGES_IMPORT_ONGOING
FAILED
}
type CreateEmailGroupChannelOutput {
messageChannel: MessageChannel!
forwardingAddress: String!
}
type CampaignAudiencePreviewDTO {
totalMembers: Int!
withoutEmail: Int!
duplicateEmails: Int!
globallyUnsubscribed: Int!
topicUnsubscribed: Int!
sendable: Int!
}
type SendEmailViaDomainOutput {
messageId: String!
}
type CampaignSkippedRecipientsDTO {
noEmail: Int!
deduped: Int!
overCap: Int!
}
type SendMessageCampaignOutputDTO {
campaignId: String!
queuedCount: Int!
skipped: CampaignSkippedRecipientsDTO!
}
type UnsubscribeTopic {
id: UUID!
createdAt: DateTime!
updatedAt: DateTime!
name: String
description: String
visibility: UnsubscribeTopicVisibility!
}
enum UnsubscribeTopicVisibility {
PUBLIC
PRIVATE
}
type AutocompleteResult {
text: String!
placeId: String!
@@ -2782,83 +2895,6 @@ enum CalendarChannelContactAutoCreationPolicy {
NONE
}
type MessageChannel {
id: UUID!
visibility: MessageChannelVisibility!
handle: String!
type: MessageChannelType!
isContactAutoCreationEnabled: Boolean!
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy!
messageFolderImportPolicy: MessageFolderImportPolicy!
excludeNonProfessionalEmails: Boolean!
excludeGroupEmails: Boolean!
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction!
isSyncEnabled: Boolean!
syncedAt: DateTime
syncStatus: MessageChannelSyncStatus!
syncStage: MessageChannelSyncStage!
syncStageStartedAt: DateTime
throttleFailureCount: Float!
throttleRetryAfter: DateTime
connectedAccountId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
connectedAccount: ConnectedAccountPublicDTO
}
enum MessageChannelVisibility {
METADATA
SUBJECT
SHARE_EVERYTHING
}
enum MessageChannelType {
EMAIL
SMS
EMAIL_GROUP
}
enum MessageChannelContactAutoCreationPolicy {
SENT_AND_RECEIVED
SENT
NONE
}
enum MessageFolderImportPolicy {
ALL_FOLDERS
SELECTED_FOLDERS
}
enum MessageChannelPendingGroupEmailsAction {
GROUP_EMAILS_DELETION
GROUP_EMAILS_IMPORT
NONE
}
enum MessageChannelSyncStatus {
NOT_SYNCED
ONGOING
ACTIVE
FAILED_INSUFFICIENT_PERMISSIONS
FAILED_UNKNOWN
}
enum MessageChannelSyncStage {
PENDING_CONFIGURATION
MESSAGE_LIST_FETCH_PENDING
MESSAGE_LIST_FETCH_SCHEDULED
MESSAGE_LIST_FETCH_ONGOING
MESSAGES_IMPORT_PENDING
MESSAGES_IMPORT_SCHEDULED
MESSAGES_IMPORT_ONGOING
FAILED
}
type CreateEmailGroupChannelOutput {
messageChannel: MessageChannel!
forwardingAddress: String!
}
type MessageFolder {
id: UUID!
name: String
@@ -2971,7 +3007,6 @@ type Query {
getPageLayoutTab(id: String!): PageLayoutTab!
getPageLayouts(objectMetadataId: String, pageLayoutType: PageLayoutType): [PageLayout!]!
getPageLayout(id: String!): PageLayout
getEmailingDomains: [EmailingDomain!]!
applicationConnectionProviders(applicationId: UUID!): [ApplicationConnectionProvider!]!
getPageLayoutWidgets(pageLayoutTabId: String!): [PageLayoutWidget!]!
getPageLayoutWidget(id: String!): PageLayoutWidget!
@@ -3008,6 +3043,12 @@ type Query {
): IndexConnection!
findManyAgents: [Agent!]!
findOneAgent(input: AgentIdInput!): Agent!
previewMessageCampaignAudience(input: PreviewMessageCampaignAudienceInput!): CampaignAudiencePreviewDTO!
unsubscribeTopics: [UnsubscribeTopic!]!
unsubscribePagePreviewUrl: String!
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
getEmailingDomains: [EmailingDomain!]!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
getRoles: [Role!]!
getToolIndex: [ToolIndexEntry!]!
getToolInputSchema(toolName: String!): JSON
@@ -3027,8 +3068,6 @@ type Query {
getViewGroups(viewId: String): [ViewGroup!]!
getViewGroup(id: String!): ViewGroup
myMessageFolders(messageChannelId: UUID): [MessageFolder!]!
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
minimalMetadata: MinimalMetadata!
appConnections(filter: ListAppConnectionsInput): [AppConnection!]!
@@ -3087,6 +3126,11 @@ input AgentIdInput {
id: UUID!
}
input PreviewMessageCampaignAudienceInput {
listId: String!
unsubscribeTopicId: String
}
input ListAppConnectionsInput {
providerName: String
userWorkspaceId: String
@@ -3227,10 +3271,6 @@ type Mutation {
resetPageLayoutToDefault(id: String!): PageLayout!
resetPageLayoutWidgetToDefault(id: String!): PageLayoutWidget!
resetPageLayoutTabToDefault(id: String!): PageLayoutTab!
createEmailingDomain(domain: String!): EmailingDomain!
deleteEmailingDomain(id: String!): Boolean!
verifyEmailingDomain(id: String!): EmailingDomain!
sendEmailViaEmailingDomain(input: SendEmailViaDomainInput!): SendEmailViaDomainOutput!
updateOneApplicationVariable(key: String!, value: String!, applicationId: UUID!): Boolean!
createPageLayoutWidget(input: CreatePageLayoutWidgetInput!): PageLayoutWidget!
updatePageLayoutWidget(id: String!, input: UpdatePageLayoutWidgetInput!): PageLayoutWidget!
@@ -3253,6 +3293,18 @@ type Mutation {
createOneAgent(input: CreateAgentInput!): Agent!
updateOneAgent(input: UpdateAgentInput!): Agent!
deleteOneAgent(input: AgentIdInput!): Agent!
sendEmailViaEmailingDomain(input: SendEmailViaDomainInput!): SendEmailViaDomainOutput!
sendMessageCampaign(input: SendMessageCampaignInput!): SendMessageCampaignOutputDTO!
createUnsubscribeTopic(input: CreateUnsubscribeTopicInput!): UnsubscribeTopic!
updateUnsubscribeTopic(input: UpdateUnsubscribeTopicInput!): UnsubscribeTopic!
deleteUnsubscribeTopic(id: String!): Boolean!
updateMessageChannel(input: UpdateMessageChannelInput!): MessageChannel!
createEmailGroupChannel(input: CreateEmailGroupChannelInput!): CreateEmailGroupChannelOutput!
deleteEmailGroupChannel(id: UUID!): MessageChannel!
createEmailingDomain(input: CreateEmailingDomainInput!): EmailingDomain!
deleteEmailingDomain(id: String!): Boolean!
verifyEmailingDomain(id: String!): EmailingDomain!
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
updateWorkspaceMemberRole(workspaceMemberId: UUID!, roleId: UUID!): WorkspaceMember!
createOneRole(createRoleInput: CreateRoleInput!): Role!
updateOneRole(updateRoleInput: UpdateRoleInput!): Role!
@@ -3278,10 +3330,6 @@ type Mutation {
destroyViewGroup(input: DestroyViewGroupInput!): ViewGroup!
updateMessageFolder(input: UpdateMessageFolderInput!): MessageFolder!
updateMessageFolders(input: UpdateMessageFoldersInput!): [MessageFolder!]!
updateMessageChannel(input: UpdateMessageChannelInput!): MessageChannel!
createEmailGroupChannel(input: CreateEmailGroupChannelInput!): CreateEmailGroupChannelOutput!
deleteEmailGroupChannel(id: UUID!): MessageChannel!
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
createChatThread: AgentChatThread!
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
@@ -3793,18 +3841,6 @@ input GridPositionInput {
columnSpan: Float!
}
input SendEmailViaDomainInput {
emailingDomainId: String!
to: [String!]!
cc: [String!]
bcc: [String!]
subject: String!
text: String!
html: String
from: String!
replyTo: [String!]
}
input CreatePageLayoutWidgetInput {
pageLayoutTabId: UUID!
title: String!
@@ -4022,6 +4058,62 @@ input UpdateAgentInput {
evaluationInputs: [String!]
}
input SendEmailViaDomainInput {
emailingDomainId: String!
to: [String!]!
cc: [String!]
bcc: [String!]
subject: String!
text: String!
html: String
from: String!
replyTo: [String!]
}
input SendMessageCampaignInput {
listId: String!
unsubscribeTopicId: String
subject: String!
body: String!
fromAddress: String!
}
input CreateUnsubscribeTopicInput {
name: String!
description: String
visibility: UnsubscribeTopicVisibility
}
input UpdateUnsubscribeTopicInput {
id: String!
name: String
description: String
visibility: UnsubscribeTopicVisibility
}
input UpdateMessageChannelInput {
id: UUID!
update: UpdateMessageChannelInputUpdates!
}
input UpdateMessageChannelInputUpdates {
visibility: MessageChannelVisibility
isContactAutoCreationEnabled: Boolean
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy
messageFolderImportPolicy: MessageFolderImportPolicy
isSyncEnabled: Boolean
excludeNonProfessionalEmails: Boolean
excludeGroupEmails: Boolean
}
input CreateEmailGroupChannelInput {
handle: String!
}
input CreateEmailingDomainInput {
domain: String!
}
input CreateRoleInput {
id: String
label: String!
@@ -4253,25 +4345,6 @@ input UpdateMessageFoldersInput {
update: UpdateMessageFolderInputUpdates!
}
input UpdateMessageChannelInput {
id: UUID!
update: UpdateMessageChannelInputUpdates!
}
input UpdateMessageChannelInputUpdates {
visibility: MessageChannelVisibility
isContactAutoCreationEnabled: Boolean
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy
messageFolderImportPolicy: MessageFolderImportPolicy
isSyncEnabled: Boolean
excludeNonProfessionalEmails: Boolean
excludeGroupEmails: Boolean
}
input CreateEmailGroupChannelInput {
handle: String!
}
input UpdateCalendarChannelInput {
id: UUID!
update: UpdateCalendarChannelInputUpdates!
@@ -305,7 +305,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' | 'RETRY_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' | 'RETRY_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'
@@ -1099,32 +1099,6 @@ export interface EnterpriseSubscriptionStatusDTO {
__typename: 'EnterpriseSubscriptionStatusDTO'
}
export interface VerificationRecord {
type: Scalars['String']
key: Scalars['String']
value: Scalars['String']
priority?: Scalars['Float']
__typename: 'VerificationRecord'
}
export interface EmailingDomain {
id: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
domain: Scalars['String']
status: EmailingDomainStatus
verificationRecords?: VerificationRecord[]
verifiedAt?: Scalars['DateTime']
__typename: 'EmailingDomain'
}
export type EmailingDomainStatus = 'PENDING' | 'VERIFIED' | 'FAILED' | 'TEMPORARY_FAILURE'
export interface SendEmailViaDomainOutput {
messageId: Scalars['String']
__typename: 'SendEmailViaDomainOutput'
}
export interface ApprovedAccessDomain {
id: Scalars['UUID']
domain: Scalars['String']
@@ -1592,7 +1566,7 @@ export interface ClientConfig {
isGoogleCalendarEnabled: Scalars['Boolean']
isConfigVariablesInDbEnabled: Scalars['Boolean']
isImapSmtpCaldavEnabled: Scalars['Boolean']
isEmailGroupEnabled: Scalars['Boolean']
isEmailingDomainInDemoMode: Scalars['Boolean']
allowRequestsToTwentyIcons: Scalars['Boolean']
calendarBookingPageId?: Scalars['String']
isCloudflareIntegrationEnabled: Scalars['Boolean']
@@ -2118,6 +2092,113 @@ export interface PublicDomain {
__typename: 'PublicDomain'
}
export interface VerificationRecord {
type: Scalars['String']
key: Scalars['String']
value: Scalars['String']
priority?: Scalars['Float']
__typename: 'VerificationRecord'
}
export interface EmailingDomain {
id: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
domain: Scalars['String']
status: EmailingDomainStatus
verificationRecords?: VerificationRecord[]
verifiedAt?: Scalars['DateTime']
__typename: 'EmailingDomain'
}
export type EmailingDomainStatus = 'PENDING' | 'VERIFIED' | 'FAILED' | 'TEMPORARY_FAILURE'
export interface MessageChannel {
id: Scalars['UUID']
visibility: MessageChannelVisibility
handle: Scalars['String']
type: MessageChannelType
isContactAutoCreationEnabled: Scalars['Boolean']
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy
messageFolderImportPolicy: MessageFolderImportPolicy
excludeNonProfessionalEmails: Scalars['Boolean']
excludeGroupEmails: Scalars['Boolean']
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction
isSyncEnabled: Scalars['Boolean']
syncedAt?: Scalars['DateTime']
syncStatus: MessageChannelSyncStatus
syncStage: MessageChannelSyncStage
syncStageStartedAt?: Scalars['DateTime']
throttleFailureCount: Scalars['Float']
throttleRetryAfter?: Scalars['DateTime']
connectedAccountId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
connectedAccount?: ConnectedAccountPublicDTO
__typename: 'MessageChannel'
}
export type MessageChannelVisibility = 'METADATA' | 'SUBJECT' | 'SHARE_EVERYTHING'
export type MessageChannelType = 'EMAIL' | 'SMS' | 'EMAIL_GROUP'
export type MessageChannelContactAutoCreationPolicy = 'SENT_AND_RECEIVED' | 'SENT' | 'NONE'
export type MessageFolderImportPolicy = 'ALL_FOLDERS' | 'SELECTED_FOLDERS'
export type MessageChannelPendingGroupEmailsAction = 'GROUP_EMAILS_DELETION' | 'GROUP_EMAILS_IMPORT' | 'NONE'
export type MessageChannelSyncStatus = 'NOT_SYNCED' | 'ONGOING' | 'ACTIVE' | 'FAILED_INSUFFICIENT_PERMISSIONS' | 'FAILED_UNKNOWN'
export type MessageChannelSyncStage = 'PENDING_CONFIGURATION' | 'MESSAGE_LIST_FETCH_PENDING' | 'MESSAGE_LIST_FETCH_SCHEDULED' | 'MESSAGE_LIST_FETCH_ONGOING' | 'MESSAGES_IMPORT_PENDING' | 'MESSAGES_IMPORT_SCHEDULED' | 'MESSAGES_IMPORT_ONGOING' | 'FAILED'
export interface CreateEmailGroupChannelOutput {
messageChannel: MessageChannel
forwardingAddress: Scalars['String']
__typename: 'CreateEmailGroupChannelOutput'
}
export interface CampaignAudiencePreviewDTO {
totalMembers: Scalars['Int']
withoutEmail: Scalars['Int']
duplicateEmails: Scalars['Int']
globallyUnsubscribed: Scalars['Int']
topicUnsubscribed: Scalars['Int']
sendable: Scalars['Int']
__typename: 'CampaignAudiencePreviewDTO'
}
export interface SendEmailViaDomainOutput {
messageId: Scalars['String']
__typename: 'SendEmailViaDomainOutput'
}
export interface CampaignSkippedRecipientsDTO {
noEmail: Scalars['Int']
deduped: Scalars['Int']
overCap: Scalars['Int']
__typename: 'CampaignSkippedRecipientsDTO'
}
export interface SendMessageCampaignOutputDTO {
campaignId: Scalars['String']
queuedCount: Scalars['Int']
skipped: CampaignSkippedRecipientsDTO
__typename: 'SendMessageCampaignOutputDTO'
}
export interface UnsubscribeTopic {
id: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
name?: Scalars['String']
description?: Scalars['String']
visibility: UnsubscribeTopicVisibility
__typename: 'UnsubscribeTopic'
}
export type UnsubscribeTopicVisibility = 'PUBLIC' | 'PRIVATE'
export interface AutocompleteResult {
text: Scalars['String']
placeId: Scalars['String']
@@ -2472,51 +2553,6 @@ export type CalendarChannelVisibility = 'METADATA' | 'SHARE_EVERYTHING'
export type CalendarChannelContactAutoCreationPolicy = 'AS_PARTICIPANT_AND_ORGANIZER' | 'AS_PARTICIPANT' | 'AS_ORGANIZER' | 'NONE'
export interface MessageChannel {
id: Scalars['UUID']
visibility: MessageChannelVisibility
handle: Scalars['String']
type: MessageChannelType
isContactAutoCreationEnabled: Scalars['Boolean']
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy
messageFolderImportPolicy: MessageFolderImportPolicy
excludeNonProfessionalEmails: Scalars['Boolean']
excludeGroupEmails: Scalars['Boolean']
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction
isSyncEnabled: Scalars['Boolean']
syncedAt?: Scalars['DateTime']
syncStatus: MessageChannelSyncStatus
syncStage: MessageChannelSyncStage
syncStageStartedAt?: Scalars['DateTime']
throttleFailureCount: Scalars['Float']
throttleRetryAfter?: Scalars['DateTime']
connectedAccountId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
connectedAccount?: ConnectedAccountPublicDTO
__typename: 'MessageChannel'
}
export type MessageChannelVisibility = 'METADATA' | 'SUBJECT' | 'SHARE_EVERYTHING'
export type MessageChannelType = 'EMAIL' | 'SMS' | 'EMAIL_GROUP'
export type MessageChannelContactAutoCreationPolicy = 'SENT_AND_RECEIVED' | 'SENT' | 'NONE'
export type MessageFolderImportPolicy = 'ALL_FOLDERS' | 'SELECTED_FOLDERS'
export type MessageChannelPendingGroupEmailsAction = 'GROUP_EMAILS_DELETION' | 'GROUP_EMAILS_IMPORT' | 'NONE'
export type MessageChannelSyncStatus = 'NOT_SYNCED' | 'ONGOING' | 'ACTIVE' | 'FAILED_INSUFFICIENT_PERMISSIONS' | 'FAILED_UNKNOWN'
export type MessageChannelSyncStage = 'PENDING_CONFIGURATION' | 'MESSAGE_LIST_FETCH_PENDING' | 'MESSAGE_LIST_FETCH_SCHEDULED' | 'MESSAGE_LIST_FETCH_ONGOING' | 'MESSAGES_IMPORT_PENDING' | 'MESSAGES_IMPORT_SCHEDULED' | 'MESSAGES_IMPORT_ONGOING' | 'FAILED'
export interface CreateEmailGroupChannelOutput {
messageChannel: MessageChannel
forwardingAddress: Scalars['String']
__typename: 'CreateEmailGroupChannelOutput'
}
export interface MessageFolder {
id: Scalars['UUID']
name?: Scalars['String']
@@ -2600,7 +2636,6 @@ export interface Query {
getPageLayoutTab: PageLayoutTab
getPageLayouts: PageLayout[]
getPageLayout?: PageLayout
getEmailingDomains: EmailingDomain[]
applicationConnectionProviders: ApplicationConnectionProvider[]
getPageLayoutWidgets: PageLayoutWidget[]
getPageLayoutWidget: PageLayoutWidget
@@ -2619,6 +2654,12 @@ export interface Query {
indexMetadatas: IndexConnection
findManyAgents: Agent[]
findOneAgent: Agent
previewMessageCampaignAudience: CampaignAudiencePreviewDTO
unsubscribeTopics: UnsubscribeTopic[]
unsubscribePagePreviewUrl: Scalars['String']
myMessageChannels: MessageChannel[]
getEmailingDomains: EmailingDomain[]
myConnectedAccounts: ConnectedAccountPublicDTO[]
getRoles: Role[]
getToolIndex: ToolIndexEntry[]
getToolInputSchema?: Scalars['JSON']
@@ -2629,8 +2670,6 @@ export interface Query {
getViewGroups: ViewGroup[]
getViewGroup?: ViewGroup
myMessageFolders: MessageFolder[]
myMessageChannels: MessageChannel[]
myConnectedAccounts: ConnectedAccountPublicDTO[]
myCalendarChannels: CalendarChannel[]
minimalMetadata: MinimalMetadata
appConnections: AppConnection[]
@@ -2756,10 +2795,6 @@ export interface Mutation {
resetPageLayoutToDefault: PageLayout
resetPageLayoutWidgetToDefault: PageLayoutWidget
resetPageLayoutTabToDefault: PageLayoutTab
createEmailingDomain: EmailingDomain
deleteEmailingDomain: Scalars['Boolean']
verifyEmailingDomain: EmailingDomain
sendEmailViaEmailingDomain: SendEmailViaDomainOutput
updateOneApplicationVariable: Scalars['Boolean']
createPageLayoutWidget: PageLayoutWidget
updatePageLayoutWidget: PageLayoutWidget
@@ -2782,6 +2817,18 @@ export interface Mutation {
createOneAgent: Agent
updateOneAgent: Agent
deleteOneAgent: Agent
sendEmailViaEmailingDomain: SendEmailViaDomainOutput
sendMessageCampaign: SendMessageCampaignOutputDTO
createUnsubscribeTopic: UnsubscribeTopic
updateUnsubscribeTopic: UnsubscribeTopic
deleteUnsubscribeTopic: Scalars['Boolean']
updateMessageChannel: MessageChannel
createEmailGroupChannel: CreateEmailGroupChannelOutput
deleteEmailGroupChannel: MessageChannel
createEmailingDomain: EmailingDomain
deleteEmailingDomain: Scalars['Boolean']
verifyEmailingDomain: EmailingDomain
deleteConnectedAccount: ConnectedAccountPublicDTO
updateWorkspaceMemberRole: WorkspaceMember
createOneRole: Role
updateOneRole: Role
@@ -2807,10 +2854,6 @@ export interface Mutation {
destroyViewGroup: ViewGroup
updateMessageFolder: MessageFolder
updateMessageFolders: MessageFolder[]
updateMessageChannel: MessageChannel
createEmailGroupChannel: CreateEmailGroupChannelOutput
deleteEmailGroupChannel: MessageChannel
deleteConnectedAccount: ConnectedAccountPublicDTO
updateCalendarChannel: CalendarChannel
createChatThread: AgentChatThread
sendChatMessage: SendChatMessageResult
@@ -4064,33 +4107,6 @@ export interface EnterpriseSubscriptionStatusDTOGenqlSelection{
__scalar?: boolean | number
}
export interface VerificationRecordGenqlSelection{
type?: boolean | number
key?: boolean | number
value?: boolean | number
priority?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EmailingDomainGenqlSelection{
id?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
domain?: boolean | number
status?: boolean | number
verificationRecords?: VerificationRecordGenqlSelection
verifiedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface SendEmailViaDomainOutputGenqlSelection{
messageId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ApprovedAccessDomainGenqlSelection{
id?: boolean | number
domain?: boolean | number
@@ -4566,7 +4582,7 @@ export interface ClientConfigGenqlSelection{
isGoogleCalendarEnabled?: boolean | number
isConfigVariablesInDbEnabled?: boolean | number
isImapSmtpCaldavEnabled?: boolean | number
isEmailGroupEnabled?: boolean | number
isEmailingDomainInDemoMode?: boolean | number
allowRequestsToTwentyIcons?: boolean | number
calendarBookingPageId?: boolean | number
isCloudflareIntegrationEnabled?: boolean | number
@@ -5155,6 +5171,104 @@ export interface PublicDomainGenqlSelection{
__scalar?: boolean | number
}
export interface VerificationRecordGenqlSelection{
type?: boolean | number
key?: boolean | number
value?: boolean | number
priority?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EmailingDomainGenqlSelection{
id?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
domain?: boolean | number
status?: boolean | number
verificationRecords?: VerificationRecordGenqlSelection
verifiedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MessageChannelGenqlSelection{
id?: boolean | number
visibility?: boolean | number
handle?: boolean | number
type?: boolean | number
isContactAutoCreationEnabled?: boolean | number
contactAutoCreationPolicy?: boolean | number
messageFolderImportPolicy?: boolean | number
excludeNonProfessionalEmails?: boolean | number
excludeGroupEmails?: boolean | number
pendingGroupEmailsAction?: boolean | number
isSyncEnabled?: boolean | number
syncedAt?: boolean | number
syncStatus?: boolean | number
syncStage?: boolean | number
syncStageStartedAt?: boolean | number
throttleFailureCount?: boolean | number
throttleRetryAfter?: boolean | number
connectedAccountId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
connectedAccount?: ConnectedAccountPublicDTOGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface CreateEmailGroupChannelOutputGenqlSelection{
messageChannel?: MessageChannelGenqlSelection
forwardingAddress?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface CampaignAudiencePreviewDTOGenqlSelection{
totalMembers?: boolean | number
withoutEmail?: boolean | number
duplicateEmails?: boolean | number
globallyUnsubscribed?: boolean | number
topicUnsubscribed?: boolean | number
sendable?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface SendEmailViaDomainOutputGenqlSelection{
messageId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface CampaignSkippedRecipientsDTOGenqlSelection{
noEmail?: boolean | number
deduped?: boolean | number
overCap?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface SendMessageCampaignOutputDTOGenqlSelection{
campaignId?: boolean | number
queuedCount?: boolean | number
skipped?: CampaignSkippedRecipientsDTOGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface UnsubscribeTopicGenqlSelection{
id?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
name?: boolean | number
description?: boolean | number
visibility?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface AutocompleteResultGenqlSelection{
text?: boolean | number
placeId?: boolean | number
@@ -5538,39 +5652,6 @@ export interface CalendarChannelGenqlSelection{
__scalar?: boolean | number
}
export interface MessageChannelGenqlSelection{
id?: boolean | number
visibility?: boolean | number
handle?: boolean | number
type?: boolean | number
isContactAutoCreationEnabled?: boolean | number
contactAutoCreationPolicy?: boolean | number
messageFolderImportPolicy?: boolean | number
excludeNonProfessionalEmails?: boolean | number
excludeGroupEmails?: boolean | number
pendingGroupEmailsAction?: boolean | number
isSyncEnabled?: boolean | number
syncedAt?: boolean | number
syncStatus?: boolean | number
syncStage?: boolean | number
syncStageStartedAt?: boolean | number
throttleFailureCount?: boolean | number
throttleRetryAfter?: boolean | number
connectedAccountId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
connectedAccount?: ConnectedAccountPublicDTOGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface CreateEmailGroupChannelOutputGenqlSelection{
messageChannel?: MessageChannelGenqlSelection
forwardingAddress?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MessageFolderGenqlSelection{
id?: boolean | number
name?: boolean | number
@@ -5655,7 +5736,6 @@ export interface QueryGenqlSelection{
getPageLayoutTab?: (PageLayoutTabGenqlSelection & { __args: {id: Scalars['String']} })
getPageLayouts?: (PageLayoutGenqlSelection & { __args?: {objectMetadataId?: (Scalars['String'] | null), pageLayoutType?: (PageLayoutType | null)} })
getPageLayout?: (PageLayoutGenqlSelection & { __args: {id: Scalars['String']} })
getEmailingDomains?: EmailingDomainGenqlSelection
applicationConnectionProviders?: (ApplicationConnectionProviderGenqlSelection & { __args: {applicationId: Scalars['UUID']} })
getPageLayoutWidgets?: (PageLayoutWidgetGenqlSelection & { __args: {pageLayoutTabId: Scalars['String']} })
getPageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String']} })
@@ -5686,6 +5766,12 @@ export interface QueryGenqlSelection{
filter: IndexFilter} })
findManyAgents?: AgentGenqlSelection
findOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
previewMessageCampaignAudience?: (CampaignAudiencePreviewDTOGenqlSelection & { __args: {input: PreviewMessageCampaignAudienceInput} })
unsubscribeTopics?: UnsubscribeTopicGenqlSelection
unsubscribePagePreviewUrl?: boolean | number
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
getEmailingDomains?: EmailingDomainGenqlSelection
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
getRoles?: RoleGenqlSelection
getToolIndex?: ToolIndexEntryGenqlSelection
getToolInputSchema?: { __args: {toolName: Scalars['String']} }
@@ -5702,8 +5788,6 @@ export interface QueryGenqlSelection{
getViewGroups?: (ViewGroupGenqlSelection & { __args?: {viewId?: (Scalars['String'] | null)} })
getViewGroup?: (ViewGroupGenqlSelection & { __args: {id: Scalars['String']} })
myMessageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
minimalMetadata?: MinimalMetadataGenqlSelection
appConnections?: (AppConnectionGenqlSelection & { __args?: {filter?: (ListAppConnectionsInput | null)} })
@@ -5760,6 +5844,8 @@ export interface AgentIdInput {
/** The id of the agent. */
id: Scalars['UUID']}
export interface PreviewMessageCampaignAudienceInput {listId: Scalars['String'],unsubscribeTopicId?: (Scalars['String'] | null)}
export interface ListAppConnectionsInput {providerName?: (Scalars['String'] | null),userWorkspaceId?: (Scalars['String'] | null),visibility?: (Scalars['String'] | null)}
export interface EventLogQueryInput {table: EventLogTable,filters?: (EventLogFiltersInput | null),first?: (Scalars['Int'] | null),after?: (Scalars['String'] | null)}
@@ -5852,10 +5938,6 @@ export interface MutationGenqlSelection{
resetPageLayoutToDefault?: (PageLayoutGenqlSelection & { __args: {id: Scalars['String']} })
resetPageLayoutWidgetToDefault?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String']} })
resetPageLayoutTabToDefault?: (PageLayoutTabGenqlSelection & { __args: {id: Scalars['String']} })
createEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {domain: Scalars['String']} })
deleteEmailingDomain?: { __args: {id: Scalars['String']} }
verifyEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {id: Scalars['String']} })
sendEmailViaEmailingDomain?: (SendEmailViaDomainOutputGenqlSelection & { __args: {input: SendEmailViaDomainInput} })
updateOneApplicationVariable?: { __args: {key: Scalars['String'], value: Scalars['String'], applicationId: Scalars['UUID']} }
createPageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {input: CreatePageLayoutWidgetInput} })
updatePageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String'], input: UpdatePageLayoutWidgetInput} })
@@ -5878,6 +5960,18 @@ export interface MutationGenqlSelection{
createOneAgent?: (AgentGenqlSelection & { __args: {input: CreateAgentInput} })
updateOneAgent?: (AgentGenqlSelection & { __args: {input: UpdateAgentInput} })
deleteOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
sendEmailViaEmailingDomain?: (SendEmailViaDomainOutputGenqlSelection & { __args: {input: SendEmailViaDomainInput} })
sendMessageCampaign?: (SendMessageCampaignOutputDTOGenqlSelection & { __args: {input: SendMessageCampaignInput} })
createUnsubscribeTopic?: (UnsubscribeTopicGenqlSelection & { __args: {input: CreateUnsubscribeTopicInput} })
updateUnsubscribeTopic?: (UnsubscribeTopicGenqlSelection & { __args: {input: UpdateUnsubscribeTopicInput} })
deleteUnsubscribeTopic?: { __args: {id: Scalars['String']} }
updateMessageChannel?: (MessageChannelGenqlSelection & { __args: {input: UpdateMessageChannelInput} })
createEmailGroupChannel?: (CreateEmailGroupChannelOutputGenqlSelection & { __args: {input: CreateEmailGroupChannelInput} })
deleteEmailGroupChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
createEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {input: CreateEmailingDomainInput} })
deleteEmailingDomain?: { __args: {id: Scalars['String']} }
verifyEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {id: Scalars['String']} })
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
updateWorkspaceMemberRole?: (WorkspaceMemberGenqlSelection & { __args: {workspaceMemberId: Scalars['UUID'], roleId: Scalars['UUID']} })
createOneRole?: (RoleGenqlSelection & { __args: {createRoleInput: CreateRoleInput} })
updateOneRole?: (RoleGenqlSelection & { __args: {updateRoleInput: UpdateRoleInput} })
@@ -5903,10 +5997,6 @@ export interface MutationGenqlSelection{
destroyViewGroup?: (ViewGroupGenqlSelection & { __args: {input: DestroyViewGroupInput} })
updateMessageFolder?: (MessageFolderGenqlSelection & { __args: {input: UpdateMessageFolderInput} })
updateMessageFolders?: (MessageFolderGenqlSelection & { __args: {input: UpdateMessageFoldersInput} })
updateMessageChannel?: (MessageChannelGenqlSelection & { __args: {input: UpdateMessageChannelInput} })
createEmailGroupChannel?: (CreateEmailGroupChannelOutputGenqlSelection & { __args: {input: CreateEmailGroupChannelInput} })
deleteEmailGroupChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
createChatThread?: AgentChatThreadGenqlSelection
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} })
@@ -6157,8 +6247,6 @@ export interface UpdatePageLayoutWidgetWithIdInput {id: Scalars['UUID'],pageLayo
export interface GridPositionInput {row: Scalars['Float'],column: Scalars['Float'],rowSpan: Scalars['Float'],columnSpan: 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 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)}
@@ -6225,6 +6313,22 @@ export interface CreateAgentInput {name?: (Scalars['String'] | null),label: Scal
export interface UpdateAgentInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),prompt?: (Scalars['String'] | null),modelId?: (Scalars['String'] | null),roleId?: (Scalars['UUID'] | null),responseFormat?: (Scalars['JSON'] | null),modelConfiguration?: (Scalars['JSON'] | null),evaluationInputs?: (Scalars['String'][] | null)}
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 {listId: Scalars['String'],unsubscribeTopicId?: (Scalars['String'] | null),subject: Scalars['String'],body: Scalars['String'],fromAddress: Scalars['String']}
export interface CreateUnsubscribeTopicInput {name: Scalars['String'],description?: (Scalars['String'] | null),visibility?: (UnsubscribeTopicVisibility | null)}
export interface UpdateUnsubscribeTopicInput {id: Scalars['String'],name?: (Scalars['String'] | null),description?: (Scalars['String'] | null),visibility?: (UnsubscribeTopicVisibility | null)}
export interface UpdateMessageChannelInput {id: Scalars['UUID'],update: UpdateMessageChannelInputUpdates}
export interface UpdateMessageChannelInputUpdates {visibility?: (MessageChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (MessageChannelContactAutoCreationPolicy | null),messageFolderImportPolicy?: (MessageFolderImportPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null),excludeNonProfessionalEmails?: (Scalars['Boolean'] | null),excludeGroupEmails?: (Scalars['Boolean'] | null)}
export interface CreateEmailGroupChannelInput {handle: Scalars['String']}
export interface CreateEmailingDomainInput {domain: Scalars['String']}
export interface CreateRoleInput {id?: (Scalars['String'] | null),label: Scalars['String'],description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),canUpdateAllSettings?: (Scalars['Boolean'] | null),canAccessAllTools?: (Scalars['Boolean'] | null),canReadAllObjectRecords?: (Scalars['Boolean'] | null),canUpdateAllObjectRecords?: (Scalars['Boolean'] | null),canSoftDeleteAllObjectRecords?: (Scalars['Boolean'] | null),canDestroyAllObjectRecords?: (Scalars['Boolean'] | null),canBeAssignedToUsers?: (Scalars['Boolean'] | null),canBeAssignedToAgents?: (Scalars['Boolean'] | null),canBeAssignedToApiKeys?: (Scalars['Boolean'] | null)}
export interface UpdateRoleInput {update: UpdateRolePayload,
@@ -6303,12 +6407,6 @@ export interface UpdateMessageFolderInputUpdates {isSynced?: (Scalars['Boolean']
export interface UpdateMessageFoldersInput {ids: Scalars['UUID'][],update: UpdateMessageFolderInputUpdates}
export interface UpdateMessageChannelInput {id: Scalars['UUID'],update: UpdateMessageChannelInputUpdates}
export interface UpdateMessageChannelInputUpdates {visibility?: (MessageChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (MessageChannelContactAutoCreationPolicy | null),messageFolderImportPolicy?: (MessageFolderImportPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null),excludeNonProfessionalEmails?: (Scalars['Boolean'] | null),excludeGroupEmails?: (Scalars['Boolean'] | null)}
export interface CreateEmailGroupChannelInput {handle: Scalars['String']}
export interface UpdateCalendarChannelInput {id: Scalars['UUID'],update: UpdateCalendarChannelInputUpdates}
export interface UpdateCalendarChannelInputUpdates {visibility?: (CalendarChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (CalendarChannelContactAutoCreationPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null)}
@@ -7019,30 +7117,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const VerificationRecord_possibleTypes: string[] = ['VerificationRecord']
export const isVerificationRecord = (obj?: { __typename?: any } | null): obj is VerificationRecord => {
if (!obj?.__typename) throw new Error('__typename is missing in "isVerificationRecord"')
return VerificationRecord_possibleTypes.includes(obj.__typename)
}
const EmailingDomain_possibleTypes: string[] = ['EmailingDomain']
export const isEmailingDomain = (obj?: { __typename?: any } | null): obj is EmailingDomain => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEmailingDomain"')
return EmailingDomain_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"')
return SendEmailViaDomainOutput_possibleTypes.includes(obj.__typename)
}
const ApprovedAccessDomain_possibleTypes: string[] = ['ApprovedAccessDomain']
export const isApprovedAccessDomain = (obj?: { __typename?: any } | null): obj is ApprovedAccessDomain => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApprovedAccessDomain"')
@@ -7979,6 +8053,78 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const VerificationRecord_possibleTypes: string[] = ['VerificationRecord']
export const isVerificationRecord = (obj?: { __typename?: any } | null): obj is VerificationRecord => {
if (!obj?.__typename) throw new Error('__typename is missing in "isVerificationRecord"')
return VerificationRecord_possibleTypes.includes(obj.__typename)
}
const EmailingDomain_possibleTypes: string[] = ['EmailingDomain']
export const isEmailingDomain = (obj?: { __typename?: any } | null): obj is EmailingDomain => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEmailingDomain"')
return EmailingDomain_possibleTypes.includes(obj.__typename)
}
const MessageChannel_possibleTypes: string[] = ['MessageChannel']
export const isMessageChannel = (obj?: { __typename?: any } | null): obj is MessageChannel => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMessageChannel"')
return MessageChannel_possibleTypes.includes(obj.__typename)
}
const CreateEmailGroupChannelOutput_possibleTypes: string[] = ['CreateEmailGroupChannelOutput']
export const isCreateEmailGroupChannelOutput = (obj?: { __typename?: any } | null): obj is CreateEmailGroupChannelOutput => {
if (!obj?.__typename) throw new Error('__typename is missing in "isCreateEmailGroupChannelOutput"')
return CreateEmailGroupChannelOutput_possibleTypes.includes(obj.__typename)
}
const CampaignAudiencePreviewDTO_possibleTypes: string[] = ['CampaignAudiencePreviewDTO']
export const isCampaignAudiencePreviewDTO = (obj?: { __typename?: any } | null): obj is CampaignAudiencePreviewDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isCampaignAudiencePreviewDTO"')
return CampaignAudiencePreviewDTO_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"')
return SendEmailViaDomainOutput_possibleTypes.includes(obj.__typename)
}
const CampaignSkippedRecipientsDTO_possibleTypes: string[] = ['CampaignSkippedRecipientsDTO']
export const isCampaignSkippedRecipientsDTO = (obj?: { __typename?: any } | null): obj is CampaignSkippedRecipientsDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isCampaignSkippedRecipientsDTO"')
return CampaignSkippedRecipientsDTO_possibleTypes.includes(obj.__typename)
}
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 UnsubscribeTopic_possibleTypes: string[] = ['UnsubscribeTopic']
export const isUnsubscribeTopic = (obj?: { __typename?: any } | null): obj is UnsubscribeTopic => {
if (!obj?.__typename) throw new Error('__typename is missing in "isUnsubscribeTopic"')
return UnsubscribeTopic_possibleTypes.includes(obj.__typename)
}
const AutocompleteResult_possibleTypes: string[] = ['AutocompleteResult']
export const isAutocompleteResult = (obj?: { __typename?: any } | null): obj is AutocompleteResult => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAutocompleteResult"')
@@ -8275,22 +8421,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const MessageChannel_possibleTypes: string[] = ['MessageChannel']
export const isMessageChannel = (obj?: { __typename?: any } | null): obj is MessageChannel => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMessageChannel"')
return MessageChannel_possibleTypes.includes(obj.__typename)
}
const CreateEmailGroupChannelOutput_possibleTypes: string[] = ['CreateEmailGroupChannelOutput']
export const isCreateEmailGroupChannelOutput = (obj?: { __typename?: any } | null): obj is CreateEmailGroupChannelOutput => {
if (!obj?.__typename) throw new Error('__typename is missing in "isCreateEmailGroupChannelOutput"')
return CreateEmailGroupChannelOutput_possibleTypes.includes(obj.__typename)
}
const MessageFolder_possibleTypes: string[] = ['MessageFolder']
export const isMessageFolder = (obj?: { __typename?: any } | null): obj is MessageFolder => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMessageFolder"')
@@ -8484,6 +8614,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,
@@ -8747,13 +8878,6 @@ export const enumPageLayoutType = {
STANDALONE_PAGE: 'STANDALONE_PAGE' as const
}
export const enumEmailingDomainStatus = {
PENDING: 'PENDING' as const,
VERIFIED: 'VERIFIED' as const,
FAILED: 'FAILED' as const,
TEMPORARY_FAILURE: 'TEMPORARY_FAILURE' as const
}
export const enumBillingPlanKey = {
PRO: 'PRO' as const,
ENTERPRISE: 'ENTERPRISE' as const
@@ -8869,35 +8993,11 @@ export const enumBillingEntitlementKey = {
AUDIT_LOGS: 'AUDIT_LOGS' as const
}
export const enumCalendarChannelSyncStatus = {
NOT_SYNCED: 'NOT_SYNCED' as const,
ONGOING: 'ONGOING' as const,
ACTIVE: 'ACTIVE' as const,
FAILED_INSUFFICIENT_PERMISSIONS: 'FAILED_INSUFFICIENT_PERMISSIONS' as const,
FAILED_UNKNOWN: 'FAILED_UNKNOWN' as const
}
export const enumCalendarChannelSyncStage = {
PENDING_CONFIGURATION: 'PENDING_CONFIGURATION' as const,
CALENDAR_EVENT_LIST_FETCH_PENDING: 'CALENDAR_EVENT_LIST_FETCH_PENDING' as const,
CALENDAR_EVENT_LIST_FETCH_SCHEDULED: 'CALENDAR_EVENT_LIST_FETCH_SCHEDULED' as const,
CALENDAR_EVENT_LIST_FETCH_ONGOING: 'CALENDAR_EVENT_LIST_FETCH_ONGOING' as const,
CALENDAR_EVENTS_IMPORT_PENDING: 'CALENDAR_EVENTS_IMPORT_PENDING' as const,
CALENDAR_EVENTS_IMPORT_SCHEDULED: 'CALENDAR_EVENTS_IMPORT_SCHEDULED' as const,
CALENDAR_EVENTS_IMPORT_ONGOING: 'CALENDAR_EVENTS_IMPORT_ONGOING' as const,
FAILED: 'FAILED' as const
}
export const enumCalendarChannelVisibility = {
METADATA: 'METADATA' as const,
SHARE_EVERYTHING: 'SHARE_EVERYTHING' as const
}
export const enumCalendarChannelContactAutoCreationPolicy = {
AS_PARTICIPANT_AND_ORGANIZER: 'AS_PARTICIPANT_AND_ORGANIZER' as const,
AS_PARTICIPANT: 'AS_PARTICIPANT' as const,
AS_ORGANIZER: 'AS_ORGANIZER' as const,
NONE: 'NONE' as const
export const enumEmailingDomainStatus = {
PENDING: 'PENDING' as const,
VERIFIED: 'VERIFIED' as const,
FAILED: 'FAILED' as const,
TEMPORARY_FAILURE: 'TEMPORARY_FAILURE' as const
}
export const enumMessageChannelVisibility = {
@@ -8948,6 +9048,42 @@ export const enumMessageChannelSyncStage = {
FAILED: 'FAILED' as const
}
export const enumUnsubscribeTopicVisibility = {
PUBLIC: 'PUBLIC' as const,
PRIVATE: 'PRIVATE' as const
}
export const enumCalendarChannelSyncStatus = {
NOT_SYNCED: 'NOT_SYNCED' as const,
ONGOING: 'ONGOING' as const,
ACTIVE: 'ACTIVE' as const,
FAILED_INSUFFICIENT_PERMISSIONS: 'FAILED_INSUFFICIENT_PERMISSIONS' as const,
FAILED_UNKNOWN: 'FAILED_UNKNOWN' as const
}
export const enumCalendarChannelSyncStage = {
PENDING_CONFIGURATION: 'PENDING_CONFIGURATION' as const,
CALENDAR_EVENT_LIST_FETCH_PENDING: 'CALENDAR_EVENT_LIST_FETCH_PENDING' as const,
CALENDAR_EVENT_LIST_FETCH_SCHEDULED: 'CALENDAR_EVENT_LIST_FETCH_SCHEDULED' as const,
CALENDAR_EVENT_LIST_FETCH_ONGOING: 'CALENDAR_EVENT_LIST_FETCH_ONGOING' as const,
CALENDAR_EVENTS_IMPORT_PENDING: 'CALENDAR_EVENTS_IMPORT_PENDING' as const,
CALENDAR_EVENTS_IMPORT_SCHEDULED: 'CALENDAR_EVENTS_IMPORT_SCHEDULED' as const,
CALENDAR_EVENTS_IMPORT_ONGOING: 'CALENDAR_EVENTS_IMPORT_ONGOING' as const,
FAILED: 'FAILED' as const
}
export const enumCalendarChannelVisibility = {
METADATA: 'METADATA' as const,
SHARE_EVERYTHING: 'SHARE_EVERYTHING' as const
}
export const enumCalendarChannelContactAutoCreationPolicy = {
AS_PARTICIPANT_AND_ORGANIZER: 'AS_PARTICIPANT_AND_ORGANIZER' as const,
AS_PARTICIPANT: 'AS_PARTICIPANT' as const,
AS_ORGANIZER: 'AS_ORGANIZER' as const,
NONE: 'NONE' as const
}
export const enumMessageFolderPendingSyncAction = {
FOLDER_DELETION: 'FOLDER_DELETION' as const,
NONE: 'NONE' as const
File diff suppressed because it is too large Load Diff
@@ -31,6 +31,7 @@ module.exports = {
'./src/modules/page-layout/widgets/**/graphql/**/*.{ts,tsx}',
'./src/modules/activities/emails/graphql/mutations/**/*.{ts,tsx}',
'./src/modules/activities/emails/graphql/metadata-queries/**/*.{ts,tsx}',
'./src/modules/dashboards/graphql/**/*.{ts,tsx}',
'./src/modules/page-layout/graphql/**/*.{ts,tsx}',
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
import { styled } from '@linaria/react';
import { useCampaignAudiencePreview } from '@/activities/emails/hooks/useCampaignAudiencePreview';
import { type useCampaignComposerState } from '@/activities/emails/hooks/useCampaignComposerState';
import { useUnsubscribeTopics } from '@/activities/emails/hooks/useUnsubscribeTopics';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
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 { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
import { Select } from '@/ui/input/components/Select';
import { t } from '@lingui/core/macro';
import { MessageChannelType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type SelectOption } from 'twenty-ui-deprecated/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]};
`;
const StyledHint = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.xs};
padding: ${themeCssVariables.spacing[1]} 0;
`;
type CampaignAudiencePreview = NonNullable<
ReturnType<typeof useCampaignAudiencePreview>
>;
const buildAudienceHint = (preview: CampaignAudiencePreview): string => {
const parts: string[] = [];
if (preview.withoutEmail > 0) {
parts.push(t`${preview.withoutEmail} without email`);
}
if (preview.duplicateEmails > 0) {
parts.push(t`${preview.duplicateEmails} duplicate`);
}
if (preview.globallyUnsubscribed > 0) {
parts.push(t`${preview.globallyUnsubscribed} unsubscribed from everything`);
}
if (preview.topicUnsubscribed > 0) {
parts.push(t`${preview.topicUnsubscribed} opted out of this topic`);
}
const breakdown = parts.length > 0 ? ` (${parts.join(', ')})` : '';
return (
t`${preview.totalMembers} in this list — ${preview.sendable} sendable` +
breakdown
);
};
type CampaignComposerFieldsProps = {
campaignState: ReturnType<typeof useCampaignComposerState>;
};
export const CampaignComposerFields = ({
campaignState,
}: CampaignComposerFieldsProps) => {
const { channels } = useMyMessageChannels();
const { unsubscribeTopics } = useUnsubscribeTopics();
const { createOneRecord: createMessageList } = useCreateOneRecord({
objectNameSingular: 'messageList',
});
const handleCreateList = async (searchInput?: string) => {
const listName = searchInput?.trim() ?? '';
const createdList = await createMessageList({
name: listName.length > 0 ? listName : t`Untitled list`,
});
if (isDefined(createdList)) {
campaignState.setListId(createdList.id);
}
};
const audiencePreview = useCampaignAudiencePreview({
listId: campaignState.listId,
unsubscribeTopicId: campaignState.unsubscribeTopicId,
});
const senderOptions: SelectOption<string>[] = channels
.filter((channel) => channel.type === MessageChannelType.EMAIL_GROUP)
.map((channel) => channel.connectedAccount?.handle)
.filter(isDefined)
.map((handle) => ({ label: handle, value: handle }));
const topicOptions: SelectOption<string>[] = unsubscribeTopics.map(
(topic) => ({
label: topic.name ?? t`Untitled topic`,
value: topic.id,
}),
);
return (
<StyledFieldsContainer>
<Select
dropdownId="campaign-composer-from-account"
label={t`From`}
fullWidth
value={campaignState.fromAddress}
options={senderOptions}
emptyOption={{ label: t`Select a sender`, value: '' }}
onChange={campaignState.setFromAddress}
/>
<FormSingleRecordPicker
label={t`To`}
objectNameSingulars={['messageList']}
defaultValue={campaignState.listId}
onChange={campaignState.setListId}
onCreate={handleCreateList}
/>
{isDefined(audiencePreview) && (
<StyledHint>{buildAudienceHint(audiencePreview)}</StyledHint>
)}
<Select
dropdownId="campaign-composer-unsubscribe-topic"
label={t`Unsubscribe topic`}
fullWidth
value={campaignState.unsubscribeTopicId ?? ''}
options={topicOptions}
emptyOption={{ label: t`No topic`, value: '' }}
onChange={(value) =>
campaignState.setUnsubscribeTopicId(value === '' ? null : value)
}
/>
<StyledHint>
{t`The unsubscribe topic this email belongs to. Recipients who opted out of it are skipped, and the unsubscribe link is scoped to it.`}
</StyledHint>
<FormTextFieldInput
label={t`Subject`}
defaultValue={campaignState.subject}
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>
);
};
@@ -1,92 +0,0 @@
import { styled } from '@linaria/react';
import { EmailComposerFields } from '@/activities/emails/components/EmailComposerFields';
import { useEmailComposerState } from '@/activities/emails/hooks/useEmailComposerState';
import { t } from '@lingui/core/macro';
import { IconArrowBackUp } from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
import { themeCssVariables } from 'twenty-ui-deprecated/theme-constants';
const StyledFooterWarning = styled.span`
color: ${themeCssVariables.color.red};
flex: 1;
font-size: ${themeCssVariables.font.size.xs};
`;
const StyledComposerContainer = styled.div`
background: ${themeCssVariables.background.primary};
display: flex;
flex-direction: column;
`;
const StyledFooter = styled.div`
align-items: center;
border-top: 1px solid ${themeCssVariables.border.color.light};
display: flex;
justify-content: flex-end;
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[4]};
`;
const StyledFooterActions = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
type EmailComposerProps = {
connectedAccountId: string;
defaultTo?: string;
defaultSubject?: string;
defaultInReplyTo?: string;
onClose?: () => void;
onSent?: () => void;
};
export const EmailComposer = ({
connectedAccountId,
defaultTo = '',
defaultSubject = '',
defaultInReplyTo,
onClose,
onSent,
}: EmailComposerProps) => {
const composerState = useEmailComposerState({
connectedAccountId,
defaultTo,
defaultSubject,
defaultInReplyTo,
onSent,
});
return (
<StyledComposerContainer>
<EmailComposerFields composerState={composerState} />
<StyledFooter>
{composerState.exceedsRecipientLimit && (
<StyledFooterWarning>
{t`Too many recipients (${composerState.recipientCount}/${composerState.maxRecipients}).`}
</StyledFooterWarning>
)}
<StyledFooterActions>
{onClose && (
<Button
size="small"
variant="secondary"
title={t`Cancel`}
onClick={onClose}
/>
)}
<Button
size="small"
variant="primary"
accent="blue"
title={t`Send`}
Icon={IconArrowBackUp}
onClick={composerState.handleSend}
disabled={!composerState.canSend}
/>
</StyledFooterActions>
</StyledFooter>
</StyledComposerContainer>
);
};
@@ -1,40 +0,0 @@
import { MessageThreadSubscriberDropdownAddSubscriberMenuItem } from '@/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriberMenuItem';
import { type MessageThreadSubscriber } from '@/activities/emails/types/MessageThreadSubscriber';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
export const MessageThreadSubscriberDropdownAddSubscriber = ({
existingSubscribers,
}: {
existingSubscribers: MessageThreadSubscriber[];
}) => {
const { records: workspaceMembersLeftToAdd } =
useFindManyRecords<WorkspaceMember>({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
filter: {
not: {
id: {
in: existingSubscribers.map(
({ workspaceMember }) => workspaceMember.id,
),
},
},
},
});
return (
<DropdownMenuItemsContainer>
<DropdownMenuSearchInput />
<DropdownMenuSeparator />
{workspaceMembersLeftToAdd.map((workspaceMember) => (
<MessageThreadSubscriberDropdownAddSubscriberMenuItem
workspaceMember={workspaceMember}
/>
))}
</DropdownMenuItemsContainer>
);
};
@@ -1,43 +0,0 @@
import { type MessageThreadSubscriber } from '@/activities/emails/types/MessageThreadSubscriber';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { IconPlus } from 'twenty-ui-deprecated/display';
import { MenuItemAvatar } from 'twenty-ui-deprecated/navigation';
export const MessageThreadSubscriberDropdownAddSubscriberMenuItem = ({
workspaceMember,
}: {
workspaceMember: WorkspaceMember;
}) => {
const text = `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`;
const { createOneRecord } = useCreateOneRecord<MessageThreadSubscriber>({
objectNameSingular: CoreObjectNameSingular.MessageThreadSubscriber,
});
const handleAddButtonClick = () => {
createOneRecord({
workspaceMember,
});
};
return (
<MenuItemAvatar
avatar={{
placeholder: workspaceMember.name.firstName,
avatarUrl: workspaceMember.avatarUrl,
placeholderColorSeed: workspaceMember.id,
size: 'md',
type: 'rounded',
}}
text={text}
iconButtons={[
{
Icon: IconPlus,
onClick: handleAddButtonClick,
},
]}
/>
);
};
@@ -0,0 +1,16 @@
import gql from 'graphql-tag';
export const PREVIEW_MESSAGE_CAMPAIGN_AUDIENCE = gql`
query PreviewMessageCampaignAudience(
$input: PreviewMessageCampaignAudienceInput!
) {
previewMessageCampaignAudience(input: $input) {
totalMembers
withoutEmail
duplicateEmails
globallyUnsubscribed
topicUnsubscribed
sendable
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const UNSUBSCRIBE_TOPICS = gql`
query UnsubscribeTopics {
unsubscribeTopics {
id
name
description
visibility
}
}
`;
@@ -0,0 +1,15 @@
import gql from 'graphql-tag';
export const SEND_MESSAGE_CAMPAIGN = gql`
mutation SendMessageCampaign($input: SendMessageCampaignInput!) {
sendMessageCampaign(input: $input) {
campaignId
queuedCount
skipped {
noEmail
deduped
overCap
}
}
}
`;
@@ -0,0 +1,129 @@
import { act, renderHook } from '@testing-library/react';
import { useCampaignComposerState } from '@/activities/emails/hooks/useCampaignComposerState';
import { useSendMessageCampaign } from '@/activities/emails/hooks/useSendMessageCampaign';
jest.mock('@/activities/emails/hooks/useSendMessageCampaign');
const sendMessageCampaignMock = jest.fn(
(): Promise<boolean> => Promise.resolve(true),
);
const mockedUseSendMessageCampaign = jest.mocked(useSendMessageCampaign);
const fillSendableFields = (result: {
current: ReturnType<typeof useCampaignComposerState>;
}) => {
act(() => {
result.current.setListId('list-1');
result.current.setFromAddress(' sender@example.com ');
result.current.setSubject('Hello');
});
};
describe('useCampaignComposerState', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedUseSendMessageCampaign.mockReturnValue({
sendMessageCampaign: sendMessageCampaignMock,
loading: false,
});
});
it('should start empty and not be sendable', () => {
const { result } = renderHook(() => useCampaignComposerState({}));
expect(result.current.listId).toBeNull();
expect(result.current.unsubscribeTopicId).toBeNull();
expect(result.current.canSend).toBe(false);
});
it('should become sendable once list, from address and subject are set', () => {
const { result } = renderHook(() => useCampaignComposerState({}));
fillSendableFields(result);
expect(result.current.canSend).toBe(true);
});
it('should not be sendable while a send is in flight', () => {
mockedUseSendMessageCampaign.mockReturnValue({
sendMessageCampaign: sendMessageCampaignMock,
loading: true,
});
const { result } = renderHook(() => useCampaignComposerState({}));
fillSendableFields(result);
expect(result.current.canSend).toBe(false);
});
it('should send trimmed values with the selected topic and call onSent on success', async () => {
sendMessageCampaignMock.mockResolvedValue(true);
const onSent = jest.fn();
const { result } = renderHook(() => useCampaignComposerState({ onSent }));
act(() => {
result.current.setUnsubscribeTopicId('topic-1');
result.current.setBody('Body');
});
fillSendableFields(result);
await act(async () => {
await result.current.handleSend();
});
expect(sendMessageCampaignMock).toHaveBeenCalledWith({
listId: 'list-1',
unsubscribeTopicId: 'topic-1',
subject: 'Hello',
body: 'Body',
fromAddress: 'sender@example.com',
});
expect(onSent).toHaveBeenCalledTimes(1);
});
it('should pass an undefined topic when none is selected', async () => {
sendMessageCampaignMock.mockResolvedValue(true);
const { result } = renderHook(() => useCampaignComposerState({}));
fillSendableFields(result);
await act(async () => {
await result.current.handleSend();
});
expect(sendMessageCampaignMock).toHaveBeenCalledWith(
expect.objectContaining({ unsubscribeTopicId: undefined }),
);
});
it('should not send when required fields are missing', async () => {
const { result } = renderHook(() => useCampaignComposerState({}));
await act(async () => {
await result.current.handleSend();
});
expect(sendMessageCampaignMock).not.toHaveBeenCalled();
});
it('should not call onSent when the send fails', async () => {
sendMessageCampaignMock.mockResolvedValue(false);
const onSent = jest.fn();
const { result } = renderHook(() => useCampaignComposerState({ onSent }));
fillSendableFields(result);
await act(async () => {
await result.current.handleSend();
});
expect(sendMessageCampaignMock).toHaveBeenCalled();
expect(onSent).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,35 @@
import { useQuery } from '@apollo/client/react';
import { isNonEmptyString } from '@sniptt/guards';
import { PREVIEW_MESSAGE_CAMPAIGN_AUDIENCE } from '@/activities/emails/graphql/metadata-queries/previewMessageCampaignAudience';
import {
type PreviewMessageCampaignAudienceQuery,
type PreviewMessageCampaignAudienceQueryVariables,
} from '~/generated-metadata/graphql';
type UseCampaignAudiencePreviewArgs = {
listId: string | null;
unsubscribeTopicId: string | null;
};
export const useCampaignAudiencePreview = ({
listId,
unsubscribeTopicId,
}: UseCampaignAudiencePreviewArgs) => {
const { data } = useQuery<
PreviewMessageCampaignAudienceQuery,
PreviewMessageCampaignAudienceQueryVariables
>(PREVIEW_MESSAGE_CAMPAIGN_AUDIENCE, {
skip: !isNonEmptyString(listId),
variables: {
input: {
listId: listId ?? '',
unsubscribeTopicId: unsubscribeTopicId ?? undefined,
},
},
fetchPolicy: 'cache-and-network',
});
return data?.previewMessageCampaignAudience ?? null;
};
@@ -0,0 +1,61 @@
import { useState } from 'react';
import { useSendMessageCampaign } from '@/activities/emails/hooks/useSendMessageCampaign';
type UseCampaignComposerStateArgs = {
onSent?: () => void;
};
export const useCampaignComposerState = ({
onSent,
}: UseCampaignComposerStateArgs) => {
const [unsubscribeTopicId, setUnsubscribeTopicId] = useState<string | null>(
null,
);
const [listId, setListId] = useState<string | null>(null);
const [fromAddress, setFromAddress] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const { sendMessageCampaign, loading } = useSendMessageCampaign();
const canSend =
listId !== null &&
fromAddress.trim().length > 0 &&
subject.trim().length > 0 &&
!loading;
const handleSend = async () => {
if (listId === null || !canSend) {
return;
}
const success = await sendMessageCampaign({
listId,
unsubscribeTopicId: unsubscribeTopicId ?? undefined,
subject,
body,
fromAddress: fromAddress.trim(),
});
if (success) {
onSent?.();
}
};
return {
unsubscribeTopicId,
setUnsubscribeTopicId,
listId,
setListId,
fromAddress,
setFromAddress,
subject,
setSubject,
body,
setBody,
handleSend,
canSend,
loading,
};
};
@@ -0,0 +1,76 @@
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 = {
listId: string;
unsubscribeTopicId?: 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) {
enqueueErrorSnackBar({ message: t`Failed to send campaign` });
return false;
}
const { queuedCount, skipped } = queued;
const skippedCount =
skipped.noEmail + skipped.deduped + skipped.overCap;
if (queuedCount === 0) {
enqueueErrorSnackBar({
message: t`No recipients to send to (${skippedCount} skipped)`,
});
return false;
}
enqueueSuccessSnackBar({
message:
skippedCount > 0
? t`Campaign queued to ${queuedCount} recipient(s), ${skippedCount} skipped`
: t`Campaign queued to ${queuedCount} recipient(s)`,
});
return true;
} catch (error) {
enqueueErrorSnackBar({
message:
error instanceof Error ? error.message : t`Failed to send campaign`,
});
return false;
}
},
[sendMessageCampaignMutation, enqueueSuccessSnackBar, enqueueErrorSnackBar],
);
return { sendMessageCampaign, loading };
};
@@ -0,0 +1,9 @@
import { useQuery } from '@apollo/client/react';
import { UnsubscribeTopicsDocument } from '~/generated-metadata/graphql';
export const useUnsubscribeTopics = () => {
const { data, loading } = useQuery(UnsubscribeTopicsDocument);
return { unsubscribeTopics: data?.unsubscribeTopics ?? [], loading };
};
@@ -1,7 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const emailThreadIdWhenEmailThreadWasClosedState = createAtomState<
string | null
>({
key: 'emailThreadIdWhenEmailThreadWasClosedState',
defaultValue: null,
});
@@ -128,6 +128,22 @@ const SettingsWorkspaceEmailGroupChannelDetail = lazy(() =>
),
);
const SettingsWorkspaceNewUnsubscribeTopic = lazy(() =>
import('~/pages/settings/email/SettingsWorkspaceNewUnsubscribeTopic').then(
(module) => ({
default: module.SettingsWorkspaceNewUnsubscribeTopic,
}),
),
);
const SettingsWorkspaceUnsubscribeTopicDetail = lazy(() =>
import('~/pages/settings/email/SettingsWorkspaceUnsubscribeTopicDetail').then(
(module) => ({
default: module.SettingsWorkspaceUnsubscribeTopicDetail,
}),
),
);
const SettingsSubdomainPage = lazy(() =>
import('~/pages/settings/domains/SettingsSubdomainPage').then((module) => ({
default: module.SettingsSubdomainPage,
@@ -435,22 +451,6 @@ const SettingsSecurityApprovedAccessDomain = lazy(() =>
),
);
const SettingsNewEmailingDomain = lazy(() =>
import('~/pages/settings/emailing-domains/SettingsNewEmailingDomain').then(
(module) => ({
default: module.SettingsNewEmailingDomain,
}),
),
);
const SettingsEmailingDomainDetail = lazy(() =>
import('~/pages/settings/emailing-domains/SettingsEmailingDomainDetail').then(
(module) => ({
default: module.SettingsEmailingDomainDetail,
}),
),
);
const SettingsAdmin = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdmin').then((module) => ({
default: module.SettingsAdmin,
@@ -656,6 +656,14 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.EmailGroupChannelDetail}
element={<SettingsWorkspaceEmailGroupChannelDetail />}
/>
<Route
path={SettingsPath.NewUnsubscribeTopic}
element={<SettingsWorkspaceNewUnsubscribeTopic />}
/>
<Route
path={SettingsPath.UnsubscribeTopicDetail}
element={<SettingsWorkspaceUnsubscribeTopicDetail />}
/>
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
<Route path={SettingsPath.Usage} element={<SettingsUsage />} />
<Route
@@ -670,14 +678,6 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.CustomDomain}
element={<SettingsCustomDomainPage />}
/>
<Route
path={SettingsPath.NewEmailingDomain}
element={<SettingsNewEmailingDomain />}
/>
<Route
path={SettingsPath.EmailingDomainDetail}
element={<SettingsEmailingDomainDetail />}
/>
<Route
path={SettingsPath.PublicDomain}
element={<SettingPublicDomain />}
@@ -13,8 +13,7 @@ import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/i
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
import { isCloudflareIntegrationEnabledState } from '@/client-config/states/isCloudflareIntegrationEnabledState';
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { isEmailGroupEnabledState } from '@/client-config/states/isEmailGroupEnabledState';
import { isEmailingDomainsEnabledState } from '@/client-config/states/isEmailingDomainsEnabledState';
import { isEmailingDomainInDemoModeState } from '@/client-config/states/isEmailingDomainInDemoModeState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState';
@@ -101,10 +100,8 @@ export const useClientConfig = (): UseClientConfigResult => {
const setCalendarBookingPageId = useSetAtomState(calendarBookingPageIdState);
const setIsEmailGroupEnabled = useSetAtomState(isEmailGroupEnabledState);
const setIsEmailingDomainsEnabled = useSetAtomState(
isEmailingDomainsEnabledState,
const setIsEmailingDomainInDemoMode = useSetAtomState(
isEmailingDomainInDemoModeState,
);
const setIsImapSmtpCaldavEnabled = useSetAtomState(
@@ -199,8 +196,9 @@ export const useClientConfig = (): UseClientConfigResult => {
setCalendarBookingPageId(clientConfig?.calendarBookingPageId ?? null);
setIsImapSmtpCaldavEnabled(clientConfig?.isImapSmtpCaldavEnabled);
setIsEmailGroupEnabled(clientConfig?.isEmailGroupEnabled ?? false);
setIsEmailingDomainsEnabled(clientConfig?.isEmailingDomainsEnabled);
setIsEmailingDomainInDemoMode(
clientConfig?.isEmailingDomainInDemoMode ?? false,
);
setAllowRequestsToTwentyIcons(clientConfig?.allowRequestsToTwentyIcons);
setIsCloudflareIntegrationEnabled(
clientConfig?.isCloudflareIntegrationEnabled,
@@ -238,9 +236,8 @@ export const useClientConfig = (): UseClientConfigResult => {
setIsDeveloperDefaultSignInPrefilled,
setIsEmailVerificationRequired,
setIsImapSmtpCaldavEnabled,
setIsEmailGroupEnabled,
setIsMultiWorkspaceEnabled,
setIsEmailingDomainsEnabled,
setIsEmailingDomainInDemoMode,
setIsClickHouseConfigured,
setIsCloudflareIntegrationEnabled,
setIsDDLLocked,
@@ -1,6 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isEmailGroupEnabledState = createAtomState<boolean>({
key: 'isEmailGroupEnabled',
export const isEmailingDomainInDemoModeState = createAtomState<boolean>({
key: 'isEmailingDomainInDemoMode',
defaultValue: false,
});
@@ -1,6 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isEmailingDomainsEnabledState = createAtomState<boolean>({
key: 'isEmailingDomainsEnabled',
defaultValue: false,
});
@@ -31,8 +31,7 @@ export type ClientConfig = {
isMicrosoftMessagingEnabled: boolean;
isMultiWorkspaceEnabled: boolean;
isImapSmtpCaldavEnabled: boolean;
isEmailGroupEnabled: boolean;
isEmailingDomainsEnabled: boolean;
isEmailingDomainInDemoMode: boolean;
isCloudflareIntegrationEnabled: boolean;
isClickHouseConfigured: boolean;
isWorkspaceSchemaDDLLocked: boolean;
@@ -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';
@@ -256,6 +257,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 />;
};
@@ -58,6 +58,7 @@ export type FormSingleRecordPickerProps = {
defaultValue?: RecordId | Variable | null;
onChange: (value: RecordId | Variable | null) => void;
onClear?: () => void;
onCreate?: (searchInput?: string) => void | Promise<void>;
objectNameSingulars: string[];
disabled?: boolean;
testId?: string;
@@ -70,6 +71,7 @@ export const FormSingleRecordPicker = ({
objectNameSingulars,
onChange,
onClear,
onCreate,
disabled,
testId,
VariablePicker,
@@ -137,6 +139,11 @@ export const FormSingleRecordPicker = ({
closeDropdown(dropdownId);
};
const handleCreateRecord = async (searchInput?: string) => {
await onCreate?.(searchInput);
closeDropdown(dropdownId);
};
const handleVariableTagInsert = (variable: string) => {
onChange?.(variable);
};
@@ -225,6 +232,7 @@ export const FormSingleRecordPicker = ({
EmptyIcon={IconForbid}
emptyLabel={t`No record`}
onCancel={() => closeDropdown(dropdownId)}
onCreate={isDefined(onCreate) ? handleCreateRecord : undefined}
onMorphItemSelected={handleMorphItemSelected}
objectNameSingulars={objectNameSingulars}
recordPickerInstanceId={dropdownId}
@@ -89,6 +89,8 @@ export const SettingsAccountsMessageChannelDetails = ({
const supportsFolderImportPolicy =
messageChannel.type === MessageChannelType.EMAIL;
const isGroupMailbox = messageChannel.type === MessageChannelType.EMAIL_GROUP;
return (
<StyledDetailsContainer>
{supportsFolderImportPolicy && (
@@ -103,21 +105,23 @@ export const SettingsAccountsMessageChannelDetails = ({
/>
</Section>
)}
<Section>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconUsers}
title={t`Exclude group emails`}
description={t`Don't sync emails from team@ support@ noreply@...`}
checked={messageChannel.excludeGroupEmails}
onChange={() =>
handleIsGroupEmailExcludedToggle(
!messageChannel.excludeGroupEmails,
)
}
/>
</Card>
</Section>
{!isGroupMailbox && (
<Section>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconUsers}
title={t`Exclude group emails`}
description={t`Don't sync emails from team@ support@ noreply@...`}
checked={messageChannel.excludeGroupEmails}
onChange={() =>
handleIsGroupEmailExcludedToggle(
!messageChannel.excludeGroupEmails,
)
}
/>
</Card>
</Section>
)}
<Section>
<H2Title
title={t`Visibility`}
@@ -39,14 +39,14 @@ export const SettingsAccountsNewEmailGroupChannel = () => {
}
} catch {
enqueueErrorSnackBar({
message: t`Failed to create email handle. Email handles may not be configured on this server.`,
message: t`Failed to create email channel. Email channels may not be configured on this server.`,
});
}
}, [createEmailGroupChannel, handle, navigate, enqueueErrorSnackBar, t]);
return (
<SettingsPageLayout
title={t`New Email Handle`}
title={t`New Email Channel`}
links={[
{
children: t`Workspace`,
@@ -56,7 +56,7 @@ export const SettingsAccountsNewEmailGroupChannel = () => {
children: t`Email`,
href: getSettingsPath(SettingsPath.WorkspaceEmail),
},
{ children: t`New Email Handle` },
{ children: t`New Email Channel` },
]}
actionButton={
<SaveAndCancelButtons
@@ -9,6 +9,7 @@ import {
import { CREATE_EMAIL_GROUP_CHANNEL } from '@/settings/accounts/graphql/mutations/createEmailGroupChannel';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import { GET_MY_MESSAGE_CHANNELS } from '@/settings/accounts/graphql/queries/getMyMessageChannels';
import { GET_ALL_EMAILING_DOMAINS } from '@/settings/emailing-domains/graphql/queries/getAllEmailingDomains';
type CreateEmailGroupChannelResult = {
createEmailGroupChannel: {
@@ -39,6 +40,7 @@ export const useCreateEmailGroupChannel = () => {
refetchQueries: [
{ query: GET_MY_CONNECTED_ACCOUNTS },
{ query: GET_MY_MESSAGE_CHANNELS },
{ query: GET_ALL_EMAILING_DOMAINS },
],
});
@@ -3,6 +3,7 @@ import { useMutation } from '@apollo/client/react';
import { DELETE_EMAIL_GROUP_CHANNEL } from '@/settings/accounts/graphql/mutations/deleteEmailGroupChannel';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import { GET_MY_MESSAGE_CHANNELS } from '@/settings/accounts/graphql/queries/getMyMessageChannels';
import { GET_ALL_EMAILING_DOMAINS } from '@/settings/emailing-domains/graphql/queries/getAllEmailingDomains';
type DeleteEmailGroupChannelResult = {
deleteEmailGroupChannel: {
@@ -22,6 +23,7 @@ export const useDeleteEmailGroupChannel = () => {
refetchQueries: [
{ query: GET_MY_CONNECTED_ACCOUNTS },
{ query: GET_MY_MESSAGE_CHANNELS },
{ query: GET_ALL_EMAILING_DOMAINS },
],
});
@@ -0,0 +1,22 @@
import { getEmailChannelDomain } from '@/settings/accounts/utils/getEmailChannelDomain';
describe('getEmailChannelDomain', () => {
it('should return the lowercased domain after the last @', () => {
expect(getEmailChannelDomain('Jane@Example.COM')).toBe('example.com');
});
it('should use the domain after the last @ when several are present', () => {
expect(getEmailChannelDomain('weird@local@Sub.Example.com')).toBe(
'sub.example.com',
);
});
it('should return undefined when there is no @', () => {
expect(getEmailChannelDomain('not-an-email')).toBeUndefined();
});
it('should return undefined for null or undefined input', () => {
expect(getEmailChannelDomain(null)).toBeUndefined();
expect(getEmailChannelDomain(undefined)).toBeUndefined();
});
});
@@ -0,0 +1,15 @@
export const getEmailChannelDomain = (
handle: string | null | undefined,
): string | undefined => {
if (handle === null || handle === undefined) {
return undefined;
}
const lastAtIndex = handle.lastIndexOf('@');
if (lastAtIndex === -1) {
return undefined;
}
return handle.slice(lastAtIndex + 1).toLowerCase();
};
@@ -1,4 +1,4 @@
import { type ComponentType } from 'react';
import { type ComponentType, type ReactNode } from 'react';
import { styled } from '@linaria/react';
@@ -48,6 +48,7 @@ export type SettingsTableListSectionColumn<Item> = {
type SettingsTableListSectionProps<Item extends { id: string }> = {
title: string;
description: string;
headerAdornment?: ReactNode;
items: Item[];
columns: SettingsTableListSectionColumn<Item>[];
gridAutoColumns: string;
@@ -61,6 +62,7 @@ export const SettingsTableListSection = <
>({
title,
description,
headerAdornment,
items,
columns,
gridAutoColumns,
@@ -69,7 +71,11 @@ export const SettingsTableListSection = <
onFooterButtonClick,
}: SettingsTableListSectionProps<Item>) => (
<Section>
<H2Title title={title} description={description} />
<H2Title
title={title}
description={description}
adornment={headerAdornment}
/>
{items.length > 0 && (
<Table>
<TableRow gridAutoColumns={gridAutoColumns}>
@@ -1,30 +0,0 @@
import { styled } from '@linaria/react';
import { type GetEmailingDomainsQuery } from '~/generated-metadata/graphql';
import {
IconMail,
OverflowingTextWithTooltip,
} from 'twenty-ui-deprecated/display';
import { themeCssVariables } from 'twenty-ui-deprecated/theme-constants';
const StyledNameCell = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.primary};
display: flex;
font-weight: ${themeCssVariables.font.weight.medium};
gap: ${themeCssVariables.spacing[2]};
min-width: 0;
`;
type SettingsEmailingDomainNameCellProps = {
item: GetEmailingDomainsQuery['getEmailingDomains'][0];
};
export const SettingsEmailingDomainNameCell = ({
item,
}: SettingsEmailingDomainNameCellProps) => (
<StyledNameCell>
<IconMail size={16} />
<OverflowingTextWithTooltip text={item.domain} />
</StyledNameCell>
);
@@ -1,17 +0,0 @@
import { type GetEmailingDomainsQuery } from '~/generated-metadata/graphql';
import { getColorByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusColor';
import { getTextByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusText';
import { Status } from 'twenty-ui-deprecated/display';
type SettingsEmailingDomainStatusCellProps = {
item: GetEmailingDomainsQuery['getEmailingDomains'][0];
};
export const SettingsEmailingDomainStatusCell = ({
item,
}: SettingsEmailingDomainStatusCellProps) => (
<Status
color={getColorByEmailingDomainStatus(item.status)}
text={getTextByEmailingDomainStatus(item.status)}
/>
);
@@ -1,70 +0,0 @@
import { SettingsDnsRecordsTable } from '@/settings/components/SettingsDnsRecordsTable';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { t } from '@lingui/core/macro';
import { H2Title, IconRefresh } from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
import { Section } from 'twenty-ui-deprecated/layout';
import { useMutation } from '@apollo/client/react';
import {
type EmailingDomain,
VerifyEmailingDomainDocument,
} from '~/generated-metadata/graphql';
type SettingsEmailingDomainVerificationRecordsProps = {
domain: EmailingDomain;
};
export const SettingsEmailingDomainVerificationRecords = ({
domain,
}: SettingsEmailingDomainVerificationRecordsProps) => {
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [verifyEmailingDomainMutation, { loading: isVerifying }] = useMutation(
VerifyEmailingDomainDocument,
);
if (!domain.verificationRecords || domain.verificationRecords.length === 0) {
return null;
}
const handleVerifyEmailingDomain = async () => {
try {
await verifyEmailingDomainMutation({
variables: {
id: domain.id,
},
});
enqueueSuccessSnackBar({
message: t`Started verification process`,
});
} catch (error) {
enqueueErrorSnackBar({
...(CombinedGraphQLErrors.is(error) ? { apolloError: error } : {}),
});
}
};
return (
<Section>
<H2Title
title={t`DNS Records`}
description={t`Add these records to verify your domain.`}
adornment={
<Button
onClick={handleVerifyEmailingDomain}
isLoading={isVerifying}
variant="secondary"
Icon={IconRefresh}
size="small"
title={t`Check verification`}
disabled={isVerifying}
/>
}
/>
<SettingsDnsRecordsTable records={domain.verificationRecords} />
</Section>
);
};
@@ -0,0 +1,45 @@
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useMutation } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { VerifyEmailingDomainDocument } from '~/generated-metadata/graphql';
import { IconRefresh } from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
type SettingsEmailingDomainVerifyButtonProps = {
emailingDomainId: string;
};
export const SettingsEmailingDomainVerifyButton = ({
emailingDomainId,
}: SettingsEmailingDomainVerifyButtonProps) => {
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [verifyEmailingDomain, { loading }] = useMutation(
VerifyEmailingDomainDocument,
);
const handleVerify = async () => {
try {
await verifyEmailingDomain({ variables: { id: emailingDomainId } });
enqueueSuccessSnackBar({ message: t`Started verification process` });
} catch (error) {
enqueueErrorSnackBar({
...(CombinedGraphQLErrors.is(error) ? { apolloError: error } : {}),
});
}
};
return (
<Button
onClick={handleVerify}
isLoading={loading}
variant="secondary"
Icon={IconRefresh}
size="small"
title={t`Check verification`}
disabled={loading}
/>
);
};
@@ -1,20 +0,0 @@
import { gql } from '@apollo/client';
export const CREATE_EMAILING_DOMAIN = gql`
mutation CreateEmailingDomain($domain: String!) {
createEmailingDomain(domain: $domain) {
id
domain
status
verifiedAt
verificationRecords {
type
key
value
priority
}
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,86 @@
import { useLazyQuery } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
import { useUnsubscribeTopics } from '@/activities/emails/hooks/useUnsubscribeTopics';
import { SettingsTableListSection } from '@/settings/components/SettingsTableListSection';
import { GET_UNSUBSCRIBE_PAGE_PREVIEW_URL } from '@/settings/unsubscribe-topics/graphql/queries/getUnsubscribePagePreviewUrl';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type UnsubscribeTopicsQuery,
UnsubscribeTopicVisibility,
} from '~/generated-metadata/graphql';
import { IconExternalLink, Status } from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
type UnsubscribeTopic = UnsubscribeTopicsQuery['unsubscribeTopics'][number];
export const SettingsWorkspaceUnsubscribeTopicSection = () => {
const { t } = useLingui();
const navigateSettings = useNavigateSettings();
const { unsubscribeTopics } = useUnsubscribeTopics();
const [getPreviewUrl] = useLazyQuery<{
unsubscribePagePreviewUrl: string;
}>(GET_UNSUBSCRIBE_PAGE_PREVIEW_URL);
// Open the tab synchronously on click (so it isn't popup-blocked), then point
// it at the freshly minted preview URL once the query resolves.
const handlePreview = () => {
const previewWindow = window.open('', '_blank');
void getPreviewUrl()
.then(({ data }) => {
const url = data?.unsubscribePagePreviewUrl;
if (isDefined(previewWindow) && isDefined(url)) {
previewWindow.location.href = url;
} else {
previewWindow?.close();
}
})
.catch(() => previewWindow?.close());
};
return (
<SettingsTableListSection<UnsubscribeTopic>
title={t`Unsubscribe Topics`}
description={t`Email categories recipients can opt out of.`}
headerAdornment={
<Button
title={t`Preview`}
variant="secondary"
size="small"
Icon={IconExternalLink}
onClick={handlePreview}
/>
}
items={unsubscribeTopics}
columns={[
{
label: t`Name`,
Cell: ({ item }) => <>{item.name ?? t`Untitled topic`}</>,
},
{
label: t`Visibility`,
Cell: ({ item }) =>
item.visibility === UnsubscribeTopicVisibility.PUBLIC ? (
<Status color="blue" text={t`Public`} />
) : (
<Status color="gray" text={t`Private`} />
),
},
]}
gridAutoColumns="1fr 1fr"
onRowClick={(topic) =>
navigateSettings(SettingsPath.UnsubscribeTopicDetail, {
unsubscribeTopicId: topic.id,
})
}
footerButtonLabel={t`Add unsubscribe topic`}
onFooterButtonClick={() =>
navigateSettings(SettingsPath.NewUnsubscribeTopic)
}
/>
);
};
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const CREATE_UNSUBSCRIBE_TOPIC = gql`
mutation CreateUnsubscribeTopic($input: CreateUnsubscribeTopicInput!) {
createUnsubscribeTopic(input: $input) {
id
name
description
visibility
}
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DELETE_UNSUBSCRIBE_TOPIC = gql`
mutation DeleteUnsubscribeTopic($id: String!) {
deleteUnsubscribeTopic(id: $id)
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const UPDATE_UNSUBSCRIBE_TOPIC = gql`
mutation UpdateUnsubscribeTopic($input: UpdateUnsubscribeTopicInput!) {
updateUnsubscribeTopic(input: $input) {
id
name
description
visibility
}
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const GET_UNSUBSCRIBE_PAGE_PREVIEW_URL = gql`
query UnsubscribePagePreviewUrl {
unsubscribePagePreviewUrl
}
`;
@@ -0,0 +1,23 @@
import { useMutation } from '@apollo/client/react';
import { UNSUBSCRIBE_TOPICS } from '@/activities/emails/graphql/metadata-queries/unsubscribeTopics';
import { CREATE_UNSUBSCRIBE_TOPIC } from '@/settings/unsubscribe-topics/graphql/mutations/createUnsubscribeTopic';
import {
type CreateUnsubscribeTopicInput,
type CreateUnsubscribeTopicMutation,
type CreateUnsubscribeTopicMutationVariables,
} from '~/generated-metadata/graphql';
export const useCreateUnsubscribeTopic = () => {
const [mutate, { loading, error }] = useMutation<
CreateUnsubscribeTopicMutation,
CreateUnsubscribeTopicMutationVariables
>(CREATE_UNSUBSCRIBE_TOPIC, {
refetchQueries: [{ query: UNSUBSCRIBE_TOPICS }],
});
const createUnsubscribeTopic = (input: CreateUnsubscribeTopicInput) =>
mutate({ variables: { input } });
return { createUnsubscribeTopic, loading, error };
};
@@ -0,0 +1,21 @@
import { useMutation } from '@apollo/client/react';
import { UNSUBSCRIBE_TOPICS } from '@/activities/emails/graphql/metadata-queries/unsubscribeTopics';
import { DELETE_UNSUBSCRIBE_TOPIC } from '@/settings/unsubscribe-topics/graphql/mutations/deleteUnsubscribeTopic';
import {
type DeleteUnsubscribeTopicMutation,
type DeleteUnsubscribeTopicMutationVariables,
} from '~/generated-metadata/graphql';
export const useDeleteUnsubscribeTopic = () => {
const [mutate, { loading, error }] = useMutation<
DeleteUnsubscribeTopicMutation,
DeleteUnsubscribeTopicMutationVariables
>(DELETE_UNSUBSCRIBE_TOPIC, {
refetchQueries: [{ query: UNSUBSCRIBE_TOPICS }],
});
const deleteUnsubscribeTopic = (id: string) => mutate({ variables: { id } });
return { deleteUnsubscribeTopic, loading, error };
};
@@ -0,0 +1,23 @@
import { useMutation } from '@apollo/client/react';
import { UNSUBSCRIBE_TOPICS } from '@/activities/emails/graphql/metadata-queries/unsubscribeTopics';
import { UPDATE_UNSUBSCRIBE_TOPIC } from '@/settings/unsubscribe-topics/graphql/mutations/updateUnsubscribeTopic';
import {
type UpdateUnsubscribeTopicInput,
type UpdateUnsubscribeTopicMutation,
type UpdateUnsubscribeTopicMutationVariables,
} from '~/generated-metadata/graphql';
export const useUpdateUnsubscribeTopic = () => {
const [mutate, { loading, error }] = useMutation<
UpdateUnsubscribeTopicMutation,
UpdateUnsubscribeTopicMutationVariables
>(UPDATE_UNSUBSCRIBE_TOPIC, {
refetchQueries: [{ query: UNSUBSCRIBE_TOPICS }],
});
const updateUnsubscribeTopic = (input: UpdateUnsubscribeTopicInput) =>
mutate({ variables: { input } });
return { updateUnsubscribeTopic, loading, error };
};
@@ -0,0 +1,35 @@
import { useQuery } from '@apollo/client/react';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { getEmailChannelDomain } from '@/settings/accounts/utils/getEmailChannelDomain';
import { GetEmailingDomainsDocument } from '~/generated-metadata/graphql';
import { getColorByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusColor';
import { getTextByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusText';
import { isDefined } from 'twenty-shared/utils';
import { Status } from 'twenty-ui-deprecated/display';
type SettingsWorkspaceEmailChannelDomainStatusCellProps = {
item: MessageChannel;
};
export const SettingsWorkspaceEmailChannelDomainStatusCell = ({
item,
}: SettingsWorkspaceEmailChannelDomainStatusCellProps) => {
const { data } = useQuery(GetEmailingDomainsDocument);
const channelDomain = getEmailChannelDomain(item.connectedAccount?.handle);
const emailingDomain = data?.getEmailingDomains?.find(
(domain) => domain.domain.toLowerCase() === channelDomain,
);
if (!isDefined(emailingDomain)) {
return null;
}
return (
<Status
color={getColorByEmailingDomainStatus(emailingDomain.status)}
text={getTextByEmailingDomainStatus(emailingDomain.status)}
/>
);
};
@@ -3,6 +3,7 @@ import { useLingui } from '@lingui/react/macro';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
import { SettingsTableListSection } from '@/settings/components/SettingsTableListSection';
import { SettingsWorkspaceEmailChannelDomainStatusCell } from '@/settings/workspace/components/SettingsWorkspaceEmailChannelDomainStatusCell';
import { SettingsWorkspaceEmailGroupForwardingCell } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupForwardingCell';
import { SettingsWorkspaceEmailGroupSourceCell } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupSourceCell';
import { MessageChannelType, SettingsPath } from 'twenty-shared/types';
@@ -19,7 +20,7 @@ export const SettingsWorkspaceEmailGroupSection = () => {
return (
<SettingsTableListSection<MessageChannel>
title={t`Email Handles`}
title={t`Email Channels`}
description={t`Shared addresses your workspace uses to send and receive email.`}
items={emailGroupChannels}
columns={[
@@ -28,14 +29,19 @@ export const SettingsWorkspaceEmailGroupSection = () => {
label: t`Forwarding address`,
Cell: SettingsWorkspaceEmailGroupForwardingCell,
},
{
label: t`Domain`,
align: 'right',
Cell: SettingsWorkspaceEmailChannelDomainStatusCell,
},
]}
gridAutoColumns="1fr 1fr"
gridAutoColumns="1fr 1fr 1fr"
onRowClick={(channel) =>
navigateSettings(SettingsPath.EmailGroupChannelDetail, {
messageChannelId: channel.id,
})
}
footerButtonLabel={t`Add email handle`}
footerButtonLabel={t`Add email channel`}
onFooterButtonClick={() =>
navigateSettings(SettingsPath.NewEmailGroupChannel)
}
@@ -1,48 +0,0 @@
import { useLingui } from '@lingui/react/macro';
import { useQuery } from '@apollo/client/react';
import { SettingsTableListSection } from '@/settings/components/SettingsTableListSection';
import { SettingsEmailingDomainNameCell } from '@/settings/emailing-domains/components/SettingsEmailingDomainNameCell';
import { SettingsEmailingDomainStatusCell } from '@/settings/emailing-domains/components/SettingsEmailingDomainStatusCell';
import { SettingsPath } from 'twenty-shared/types';
import {
type GetEmailingDomainsQuery,
GetEmailingDomainsDocument,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
type EmailingDomain = GetEmailingDomainsQuery['getEmailingDomains'][0];
export const SettingsWorkspaceEmailingDomainsSection = () => {
const { t } = useLingui();
const navigateSettings = useNavigateSettings();
const { data } = useQuery(GetEmailingDomainsDocument);
const emailingDomains = data?.getEmailingDomains ?? [];
return (
<SettingsTableListSection<EmailingDomain>
title={t`Emailing Domains`}
description={t`Verify domains so the workspace can send outbound email through them.`}
items={emailingDomains}
columns={[
{ label: t`Domain`, Cell: SettingsEmailingDomainNameCell },
{
label: t`Status`,
align: 'right',
Cell: SettingsEmailingDomainStatusCell,
},
]}
gridAutoColumns="1fr 1fr"
onRowClick={(emailingDomain) =>
navigateSettings(SettingsPath.EmailingDomainDetail, {
domainId: emailingDomain.id,
})
}
footerButtonLabel={t`Add emailing domain`}
onFooterButtonClick={() =>
navigateSettings(SettingsPath.NewEmailingDomain)
}
/>
);
};
@@ -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-deprecated/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,69 @@
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-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
import { getOsControlSymbol } from 'twenty-ui-deprecated/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,
});
useHotkeysOnFocusedElement({
keys: ['ctrl+Enter,meta+Enter'],
callback: campaignState.handleSend,
focusId: SIDE_PANEL_FOCUS_ID,
dependencies: [campaignState.handleSend],
});
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>
);
};
@@ -1,19 +1,31 @@
import { useLingui } from '@lingui/react/macro';
import { isEmailGroupEnabledState } from '@/client-config/states/isEmailGroupEnabledState';
import { billingState } from '@/client-config/states/billingState';
import { isEmailingDomainInDemoModeState } from '@/client-config/states/isEmailingDomainInDemoModeState';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
import { SettingsWorkspaceUnsubscribeTopicSection } from '@/settings/unsubscribe-topics/components/SettingsWorkspaceUnsubscribeTopicSection';
import { SettingsWorkspaceEmailGroupSection } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupSection';
import { SettingsWorkspaceEmailingDomainsSection } from '@/settings/workspace/components/SettingsWorkspaceEmailingDomainsSection';
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconArrowUp, IconLock } from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
import { Card } from 'twenty-ui-deprecated/layout';
import { themeCssVariables } from 'twenty-ui-deprecated/theme-constants';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const SettingsWorkspaceEmail = () => {
const { t } = useLingui();
const navigateSettings = useNavigateSettings();
const isEmailGroupEnabled = useAtomStateValue(isEmailGroupEnabledState);
const isEmailingDomainInDemoMode = useAtomStateValue(
isEmailingDomainInDemoModeState,
);
const billing = useAtomStateValue(billingState);
const isBillingEnabled = billing?.isBillingEnabled ?? false;
const isEmailGroupFeatureEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_EMAIL_GROUP_ENABLED,
);
@@ -22,8 +34,6 @@ export const SettingsWorkspaceEmail = () => {
return null;
}
const showEmailGroupSection = isEmailGroupEnabled;
return (
<SettingsPageLayout
title={t`Email`}
@@ -36,8 +46,36 @@ export const SettingsWorkspaceEmail = () => {
]}
>
<SettingsPageContainer>
{showEmailGroupSection && <SettingsWorkspaceEmailGroupSection />}
<SettingsWorkspaceEmailingDomainsSection />
{isEmailingDomainInDemoMode && (
<Card
rounded
backgroundColor={themeCssVariables.background.secondary}
>
<SettingsOptionCardContentButton
Icon={IconLock}
title={t`Emailing is in demo mode`}
description={t`Emails are logged, not sent. Sending requires the AWS SES driver with an Enterprise license, or Twenty Cloud.`}
Button={
<Button
title={t`Upgrade`}
variant="primary"
accent="blue"
size="small"
Icon={IconArrowUp}
onClick={() =>
navigateSettings(
isBillingEnabled
? SettingsPath.Billing
: SettingsPath.AdminPanelEnterprise,
)
}
/>
}
/>
</Card>
)}
<SettingsWorkspaceEmailGroupSection />
<SettingsWorkspaceUnsubscribeTopicSection />
</SettingsPageContainer>
</SettingsPageLayout>
);
@@ -1,3 +1,4 @@
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useParams } from 'react-router-dom';
@@ -5,7 +6,11 @@ import { useParams } from 'react-router-dom';
import { SettingsAccountsMessageChannelDetails } from '@/settings/accounts/components/SettingsAccountsMessageChannelDetails';
import { useDeleteEmailGroupChannel } from '@/settings/accounts/hooks/useDeleteEmailGroupChannel';
import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
import { getEmailChannelDomain } from '@/settings/accounts/utils/getEmailChannelDomain';
import { SettingsDnsRecordsTable } from '@/settings/components/SettingsDnsRecordsTable';
import { SettingsEmailingDomainVerifyButton } from '@/settings/emailing-domains/components/SettingsEmailingDomainVerifyButton';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
@@ -13,24 +18,24 @@ import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModa
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { MessageChannelType, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title, IconCopy, IconTrash } from 'twenty-ui-deprecated/display';
import { Loader } from 'twenty-ui-deprecated/feedback';
import { GetEmailingDomainsDocument } from '~/generated-metadata/graphql';
import {
H2Title,
IconCopy,
IconTrash,
Status,
} from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
import { Section } from 'twenty-ui-deprecated/layout';
import { themeCssVariables } from 'twenty-ui-deprecated/theme-constants';
import { NotFound } from '~/pages/not-found/NotFound';
import { getColorByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusColor';
import { getTextByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusText';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const DELETE_EMAIL_GROUP_MODAL_ID = 'delete-email-group-channel-modal';
const StyledLoadingContainer = styled.div`
align-items: center;
display: flex;
height: 200px;
justify-content: center;
`;
const StyledForwardingRow = styled.div`
display: flex;
flex-direction: row;
@@ -51,13 +56,10 @@ export const SettingsWorkspaceEmailGroupChannelDetail = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const { deleteEmailGroupChannel, loading: deleting } =
useDeleteEmailGroupChannel();
const { data: emailingDomainsData } = useQuery(GetEmailingDomainsDocument);
if (loading) {
return (
<StyledLoadingContainer>
<Loader />
</StyledLoadingContainer>
);
return <SettingsSkeletonLoader />;
}
const channel = channels.find(
@@ -73,13 +75,18 @@ export const SettingsWorkspaceEmailGroupChannelDetail = () => {
const sourceHandle = channel.connectedAccount.handle;
const forwardingAddress = channel.handle;
const channelDomain = getEmailChannelDomain(sourceHandle);
const emailingDomain = emailingDomainsData?.getEmailingDomains?.find(
(domain) => domain.domain.toLowerCase() === channelDomain,
);
const handleDelete = async () => {
try {
await deleteEmailGroupChannel(channel.id);
navigateSettings(SettingsPath.WorkspaceEmail);
} catch {
enqueueErrorSnackBar({
message: t`Failed to delete email handle.`,
message: t`Failed to delete email channel.`,
});
}
};
@@ -111,6 +118,28 @@ export const SettingsWorkspaceEmailGroupChannelDetail = () => {
}
>
<SettingsPageContainer>
{isDefined(emailingDomain) && (
<Section>
<H2Title
title={t`Sending domain`}
description={t`Outbound mail from this channel is sent through this domain. It must be verified before email can be delivered.`}
adornment={
<SettingsEmailingDomainVerifyButton
emailingDomainId={emailingDomain.id}
/>
}
/>
<Status
color={getColorByEmailingDomainStatus(emailingDomain.status)}
text={getTextByEmailingDomainStatus(emailingDomain.status)}
/>
{isDefined(emailingDomain.verificationRecords) && (
<SettingsDnsRecordsTable
records={emailingDomain.verificationRecords}
/>
)}
</Section>
)}
<Section>
<H2Title
title={t`Source address`}
@@ -153,7 +182,7 @@ export const SettingsWorkspaceEmailGroupChannelDetail = () => {
</SettingsPageContainer>
<ConfirmationModal
modalInstanceId={DELETE_EMAIL_GROUP_MODAL_ID}
title={t`Delete email handle`}
title={t`Delete email channel`}
subtitle={t`Are you sure you want to delete ${sourceHandle}? Inbound mail forwarded to this address and outbound replies from it will stop working.`}
onConfirmClick={handleDelete}
confirmButtonText={t`Delete`}
@@ -0,0 +1,144 @@
import { useLingui } from '@lingui/react/macro';
import { useCallback, useState } from 'react';
import { useCreateUnsubscribeTopic } from '@/settings/unsubscribe-topics/hooks/useCreateUnsubscribeTopic';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { FeatureFlagKey, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { UnsubscribeTopicVisibility } from '~/generated-metadata/graphql';
import { H2Title, IconEye } from 'twenty-ui-deprecated/display';
import { Card, Section } from 'twenty-ui-deprecated/layout';
import { NotFound } from '~/pages/not-found/NotFound';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const SettingsWorkspaceNewUnsubscribeTopic = () => {
const { t } = useLingui();
const navigate = useNavigateSettings();
const { enqueueErrorSnackBar } = useSnackBar();
const { createUnsubscribeTopic, loading } = useCreateUnsubscribeTopic();
const isEmailGroupEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_EMAIL_GROUP_ENABLED,
);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isPublic, setIsPublic] = useState(false);
const canSave = name.length > 0 && !loading;
const handleSave = useCallback(async () => {
try {
const result = await createUnsubscribeTopic({
name,
description: description || null,
visibility: isPublic
? UnsubscribeTopicVisibility.PUBLIC
: UnsubscribeTopicVisibility.PRIVATE,
});
const unsubscribeTopicId = result.data?.createUnsubscribeTopic.id;
if (unsubscribeTopicId) {
navigate(SettingsPath.UnsubscribeTopicDetail, {
unsubscribeTopicId,
});
} else {
navigate(SettingsPath.WorkspaceEmail);
}
} catch {
enqueueErrorSnackBar({
message: t`Failed to create unsubscribe topic.`,
});
}
}, [
createUnsubscribeTopic,
name,
description,
isPublic,
navigate,
enqueueErrorSnackBar,
t,
]);
if (!isEmailGroupEnabled) {
return <NotFound />;
}
return (
<SettingsPageLayout
title={t`New Unsubscribe Topic`}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.General),
},
{
children: t`Email`,
href: getSettingsPath(SettingsPath.WorkspaceEmail),
},
{ children: t`New Unsubscribe Topic` },
]}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!canSave}
isCancelDisabled={loading}
isLoading={loading}
onCancel={() => navigate(SettingsPath.WorkspaceEmail)}
onSave={handleSave}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Name`}
description={t`The name recipients see for this topic.`}
/>
<SettingsTextInput
instanceId="unsubscribe-topic-name"
label={t`Name`}
placeholder={t`Newsletters`}
value={name}
onChange={setName}
disabled={loading}
fullWidth
/>
</Section>
<Section>
<H2Title
title={t`Description`}
description={t`Optional context shown to recipients on the preferences page.`}
/>
<SettingsTextInput
instanceId="unsubscribe-topic-description"
label={t`Description`}
value={description}
onChange={setDescription}
disabled={loading}
fullWidth
/>
</Section>
<Section>
<H2Title
title={t`Visibility`}
description={t`Control whether recipients can find and manage this topic.`}
/>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconEye}
title={t`Listed on the unsubscribe page`}
description={t`Public topics appear on the recipient preferences page.`}
checked={isPublic}
onChange={setIsPublic}
/>
</Card>
</Section>
</SettingsPageContainer>
</SettingsPageLayout>
);
};
@@ -0,0 +1,197 @@
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useUnsubscribeTopics } from '@/activities/emails/hooks/useUnsubscribeTopics';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
import { useDeleteUnsubscribeTopic } from '@/settings/unsubscribe-topics/hooks/useDeleteUnsubscribeTopic';
import { useUpdateUnsubscribeTopic } from '@/settings/unsubscribe-topics/hooks/useUpdateUnsubscribeTopic';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { FeatureFlagKey, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { UnsubscribeTopicVisibility } from '~/generated-metadata/graphql';
import { H2Title, IconEye, IconTrash } from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
import { Card, Section } from 'twenty-ui-deprecated/layout';
import { NotFound } from '~/pages/not-found/NotFound';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const DELETE_UNSUBSCRIBE_TOPIC_MODAL_ID = 'delete-unsubscribe-topic-modal';
export const SettingsWorkspaceUnsubscribeTopicDetail = () => {
const { t } = useLingui();
const navigateSettings = useNavigateSettings();
const { unsubscribeTopicId } = useParams<{ unsubscribeTopicId: string }>();
const { unsubscribeTopics, loading } = useUnsubscribeTopics();
const { openModal } = useModal();
const { enqueueErrorSnackBar } = useSnackBar();
const { updateUnsubscribeTopic } = useUpdateUnsubscribeTopic();
const { deleteUnsubscribeTopic, loading: deleting } =
useDeleteUnsubscribeTopic();
const isEmailGroupEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_EMAIL_GROUP_ENABLED,
);
const unsubscribeTopic = unsubscribeTopics.find(
(topic) => topic.id === unsubscribeTopicId,
);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [seededTopicId, setSeededTopicId] = useState<string | null>(null);
useEffect(() => {
if (isDefined(unsubscribeTopic) && seededTopicId !== unsubscribeTopic.id) {
setSeededTopicId(unsubscribeTopic.id);
setName(unsubscribeTopic.name ?? '');
setDescription(unsubscribeTopic.description ?? '');
}
}, [unsubscribeTopic, seededTopicId]);
if (loading) {
return <SettingsSkeletonLoader />;
}
if (!isEmailGroupEnabled || !isDefined(unsubscribeTopic)) {
return <NotFound />;
}
const isPublic =
unsubscribeTopic.visibility === UnsubscribeTopicVisibility.PUBLIC;
const persist = async (
input: Parameters<typeof updateUnsubscribeTopic>[0],
) => {
try {
await updateUnsubscribeTopic(input);
} catch {
enqueueErrorSnackBar({
message: t`Failed to update unsubscribe topic.`,
});
}
};
const handleNameBlur = () => {
if (name !== (unsubscribeTopic.name ?? '')) {
persist({ id: unsubscribeTopic.id, name });
}
};
const handleDescriptionBlur = () => {
if (description !== (unsubscribeTopic.description ?? '')) {
persist({ id: unsubscribeTopic.id, description: description || null });
}
};
const handleVisibilityChange = (checked: boolean) => {
persist({
id: unsubscribeTopic.id,
visibility: checked
? UnsubscribeTopicVisibility.PUBLIC
: UnsubscribeTopicVisibility.PRIVATE,
});
};
const handleDelete = async () => {
try {
await deleteUnsubscribeTopic(unsubscribeTopic.id);
navigateSettings(SettingsPath.WorkspaceEmail);
} catch {
enqueueErrorSnackBar({
message: t`Failed to delete unsubscribe topic.`,
});
}
};
const topicName = unsubscribeTopic.name ?? t`Untitled topic`;
return (
<SettingsPageLayout
title={topicName}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.General),
},
{
children: t`Email`,
href: getSettingsPath(SettingsPath.WorkspaceEmail),
},
{ children: topicName },
]}
actionButton={
<Button
Icon={IconTrash}
title={t`Delete`}
variant="secondary"
accent="danger"
size="small"
disabled={deleting}
onClick={() => openModal(DELETE_UNSUBSCRIBE_TOPIC_MODAL_ID)}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Name`}
description={t`The name recipients see for this topic.`}
/>
<SettingsTextInput
instanceId="unsubscribe-topic-name"
label={t`Name`}
value={name}
onChange={setName}
onBlur={handleNameBlur}
fullWidth
/>
</Section>
<Section>
<H2Title
title={t`Description`}
description={t`Optional context shown to recipients on the preferences page.`}
/>
<SettingsTextInput
instanceId="unsubscribe-topic-description"
label={t`Description`}
value={description}
onChange={setDescription}
onBlur={handleDescriptionBlur}
fullWidth
/>
</Section>
<Section>
<H2Title
title={t`Visibility`}
description={t`Control whether recipients can find and manage this topic.`}
/>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconEye}
title={t`Listed on the unsubscribe page`}
description={t`Public topics appear on the recipient preferences page.`}
checked={isPublic}
onChange={handleVisibilityChange}
/>
</Card>
</Section>
</SettingsPageContainer>
<ConfirmationModal
modalInstanceId={DELETE_UNSUBSCRIBE_TOPIC_MODAL_ID}
title={t`Delete unsubscribe topic`}
subtitle={t`Are you sure you want to delete ${topicName}? Recipients will no longer be able to opt out of this category.`}
onConfirmClick={handleDelete}
confirmButtonText={t`Delete`}
confirmButtonAccent="danger"
loading={deleting}
/>
</SettingsPageLayout>
);
};
@@ -1,122 +0,0 @@
import { useMutation, useQuery } from '@apollo/client/react';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { Trans, useLingui } from '@lingui/react/macro';
import { useParams } from 'react-router-dom';
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsEmailingDomainVerificationRecords } from '@/settings/emailing-domains/components/SettingsEmailingDomainVerificationRecords';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { IconTrash } from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
import {
DeleteEmailingDomainDocument,
type GetEmailingDomainsQuery,
GetEmailingDomainsDocument,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const DELETE_EMAILING_DOMAIN_MODAL_ID = 'delete-emailing-domain-modal';
export const SettingsEmailingDomainDetail = () => {
const { t } = useLingui();
const navigateSettings = useNavigateSettings();
const { domainId } = useParams<{ domainId: string }>();
const { data, loading, error } = useQuery<GetEmailingDomainsQuery>(
GetEmailingDomainsDocument,
{
skip: !domainId,
},
);
const { openModal } = useModal();
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const [deleteEmailingDomain, { loading: deleting }] = useMutation(
DeleteEmailingDomainDocument,
{ refetchQueries: [GetEmailingDomainsDocument] },
);
const emailingDomain = data?.getEmailingDomains?.find(
(domain) => domain.id === domainId,
);
if (loading) {
return <SettingsEmptyPlaceholder>{t`Loading...`}</SettingsEmptyPlaceholder>;
}
if (isDefined(error) || !isDefined(emailingDomain)) {
return (
<SettingsEmptyPlaceholder>
<Trans>Domain not found</Trans>
</SettingsEmptyPlaceholder>
);
}
const handleDelete = async () => {
try {
await deleteEmailingDomain({ variables: { id: emailingDomain.id } });
enqueueSuccessSnackBar({
message: t`Emailing domain deleted successfully`,
});
navigateSettings(SettingsPath.WorkspaceEmail);
} catch (deleteError) {
enqueueErrorSnackBar({
...(CombinedGraphQLErrors.is(deleteError)
? { apolloError: deleteError }
: {}),
});
}
};
return (
<SettingsPageLayout
title={emailingDomain.domain}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.General),
},
{
children: <Trans>Email</Trans>,
href: getSettingsPath(SettingsPath.WorkspaceEmail),
},
{ children: emailingDomain.domain },
]}
actionButton={
<Button
Icon={IconTrash}
title={t`Delete`}
variant="secondary"
accent="danger"
size="small"
disabled={deleting}
onClick={() => openModal(DELETE_EMAILING_DOMAIN_MODAL_ID)}
/>
}
>
<SettingsPageContainer>
{emailingDomain.verificationRecords &&
emailingDomain.verificationRecords.length > 0 && (
<SettingsEmailingDomainVerificationRecords
domain={emailingDomain}
/>
)}
</SettingsPageContainer>
<ConfirmationModal
modalInstanceId={DELETE_EMAILING_DOMAIN_MODAL_ID}
title={t`Delete emailing domain`}
subtitle={t`Are you sure you want to delete ${emailingDomain.domain}? Outbound mail through this domain will stop working.`}
onConfirmClick={handleDelete}
confirmButtonText={t`Delete`}
confirmButtonAccent="danger"
loading={deleting}
/>
</SettingsPageLayout>
);
};
@@ -1,149 +0,0 @@
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui-deprecated/display';
import { Section } from 'twenty-ui-deprecated/layout';
import { useMutation } from '@apollo/client/react';
import { CreateEmailingDomainDocument } from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import {
settingsEmailingDomainFormSchema,
type SettingsEmailingDomainFormValues,
} from '~/pages/settings/emailing-domains/validation-schemas/settingsEmailingDomainFormSchema';
type FieldErrors = Partial<
Record<keyof SettingsEmailingDomainFormValues, string>
>;
export const SettingsNewEmailingDomain = () => {
const navigate = useNavigateSettings();
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [formValues, setFormValues] =
useState<SettingsEmailingDomainFormValues>({
domain: '',
});
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [createEmailingDomain] = useMutation(CreateEmailingDomainDocument);
const validateForm = (): boolean => {
const result = settingsEmailingDomainFormSchema.safeParse(formValues);
if (!result.success) {
setFieldErrors(result.error?.flatten().fieldErrors as FieldErrors);
return false;
}
setFieldErrors({});
return true;
};
const handleFieldChange = (
field: keyof SettingsEmailingDomainFormValues,
value: string,
) => {
setFormValues((prev) => ({
...prev,
[field]: value,
}));
if (isDefined(fieldErrors[field])) {
setFieldErrors((prev) => ({
...prev,
[field]: undefined,
}));
}
};
const canSave = !isSubmitting;
const handleSave = async () => {
if (!validateForm()) {
return;
}
setIsSubmitting(true);
try {
await createEmailingDomain({
variables: {
domain: formValues.domain,
},
onCompleted: (data) => {
enqueueSuccessSnackBar({
message: t`Emailing domain created successfully. Please verify the domain to start using it.`,
});
if (!data.createEmailingDomain?.id) return;
navigate(SettingsPath.EmailingDomainDetail, {
domainId: data.createEmailingDomain.id,
});
},
onError: (error) => {
enqueueErrorSnackBar({
apolloError: CombinedGraphQLErrors.is(error) ? error : undefined,
});
},
});
} catch (error) {
enqueueErrorSnackBar({
apolloError: CombinedGraphQLErrors.is(error) ? error : undefined,
});
} finally {
setIsSubmitting(false);
}
};
return (
<SettingsPageLayout
title={t`New Emailing Domain`}
actionButton={
<SaveAndCancelButtons
onCancel={() => navigate(SettingsPath.WorkspaceEmail)}
onSave={handleSave}
isSaveDisabled={!canSave}
/>
}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.General),
},
{
children: <Trans>Email</Trans>,
href: getSettingsPath(SettingsPath.WorkspaceEmail),
},
{ children: <Trans>New Emailing Domain</Trans> },
]}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Domain`}
description={t`The domain name you want to use for emailing`}
/>
<SettingsTextInput
instanceId="emailing-domain"
autoFocus
autoComplete="off"
value={formValues.domain}
onChange={(value) => handleFieldChange('domain', value)}
fullWidth
placeholder="yourdomain.com"
error={fieldErrors.domain}
/>
</Section>
</SettingsPageContainer>
</SettingsPageLayout>
);
};
@@ -53,9 +53,8 @@ export const mockedClientConfig: ClientConfig = {
isAttachmentPreviewEnabled: true,
isConfigVariablesInDbEnabled: false,
isImapSmtpCaldavEnabled: false,
isEmailGroupEnabled: false,
isTwoFactorAuthenticationEnabled: false,
isEmailingDomainsEnabled: false,
isEmailingDomainInDemoMode: false,
allowRequestsToTwentyIcons: true,
isCloudflareIntegrationEnabled: false,
isClickHouseConfigured: false,
@@ -15,6 +15,7 @@ import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/
import { BillingEntitlementEntity } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { MessageSuppressionEntity } from 'src/engine/core-modules/emailing-domain/message-suppression.entity';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity';
@@ -102,6 +103,7 @@ const WORKSPACE_RELATED_ENTITIES: EntityTarget<ObjectLiteral>[] = [
BillingSubscriptionEntity,
DataSourceEntity,
EmailingDomainEntity,
MessageSuppressionEntity,
FeatureFlagEntity,
FileEntity,
PublicDomainEntity,
@@ -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.13.0', 1780088214774)
export class AddEmailingDomainUnsubscribeHostFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "core"."emailingDomain" ADD COLUMN IF NOT EXISTS "unsubscribeHostname" character varying');
await queryRunner.query('ALTER TABLE "core"."emailingDomain" ADD COLUMN IF NOT EXISTS "unsubscribeHostnameId" character varying');
await queryRunner.query('DO $$ BEGIN CREATE TYPE "core"."emailingDomain_unsubscribehostnamestatus_enum" AS ENUM(\'PENDING\', \'ACTIVE\', \'FAILED\'); EXCEPTION WHEN duplicate_object THEN null; END $$');
await queryRunner.query('ALTER TABLE "core"."emailingDomain" ADD COLUMN IF NOT EXISTS "unsubscribeHostnameStatus" "core"."emailingDomain_unsubscribehostnamestatus_enum"');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "core"."emailingDomain" DROP COLUMN IF EXISTS "unsubscribeHostnameStatus"');
await queryRunner.query('DROP TYPE IF EXISTS "core"."emailingDomain_unsubscribehostnamestatus_enum"');
await queryRunner.query('ALTER TABLE "core"."emailingDomain" DROP COLUMN IF EXISTS "unsubscribeHostnameId"');
await queryRunner.query('ALTER TABLE "core"."emailingDomain" DROP COLUMN IF EXISTS "unsubscribeHostname"');
}
}
@@ -0,0 +1,55 @@
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.13.0', 1781250000000)
export class CreateMessageSuppressionCoreTableFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DO $$ BEGIN CREATE TYPE "core"."messageSuppression_reason_enum" AS ENUM ('BOUNCE', 'COMPLAINT', 'UNSUBSCRIBE'); EXCEPTION WHEN duplicate_object THEN null; END $$`,
);
await queryRunner.query(
`DO $$ BEGIN CREATE TYPE "core"."messageSuppression_source_enum" AS ENUM ('WEBHOOK', 'SYSTEM'); EXCEPTION WHEN duplicate_object THEN null; END $$`,
);
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "core"."messageSuppression" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"emailAddress" character varying NOT NULL,
"reason" "core"."messageSuppression_reason_enum" NOT NULL,
"source" "core"."messageSuppression_source_enum" NOT NULL,
"providerEventId" character varying,
"unsubscribeTopicId" uuid,
"workspaceId" uuid NOT NULL,
CONSTRAINT "PK_messageSuppression_id" PRIMARY KEY ("id"),
CONSTRAINT "FK_6eba121ed8e57afaa1f052cb685" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE
)`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_MESSAGE_SUPPRESSION_GLOBAL_UNIQUE"
ON "core"."messageSuppression" ("workspaceId", "emailAddress")
WHERE "unsubscribeTopicId" IS NULL`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_MESSAGE_SUPPRESSION_TOPIC_UNIQUE"
ON "core"."messageSuppression" ("workspaceId", "emailAddress", "unsubscribeTopicId")
WHERE "unsubscribeTopicId" IS NOT NULL`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_MESSAGE_SUPPRESSION_WORKSPACE_ID"
ON "core"."messageSuppression" ("workspaceId")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "core"."messageSuppression"`);
await queryRunner.query(
`DROP TYPE IF EXISTS "core"."messageSuppression_reason_enum"`,
);
await queryRunner.query(
`DROP TYPE IF EXISTS "core"."messageSuppression_source_enum"`,
);
}
}
@@ -0,0 +1,38 @@
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.13.0', 1781260000000)
export class CreateUnsubscribeTopicCoreTableFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DO $$ BEGIN CREATE TYPE "core"."unsubscribeTopic_visibility_enum" AS ENUM ('PUBLIC', 'PRIVATE'); EXCEPTION WHEN duplicate_object THEN null; END $$`,
);
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "core"."unsubscribeTopic" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"name" character varying,
"description" character varying,
"visibility" "core"."unsubscribeTopic_visibility_enum" NOT NULL DEFAULT 'PRIVATE',
"workspaceId" uuid NOT NULL,
CONSTRAINT "PK_unsubscribeTopic_id" PRIMARY KEY ("id"),
-- FK name must match TypeORM's generated hash for WorkspaceRelatedEntity.workspace (schema drift otherwise).
CONSTRAINT "FK_16d7bf6f90fac4745c89e1e8d56" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE
)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_UNSUBSCRIBE_TOPIC_WORKSPACE_ID"
ON "core"."unsubscribeTopic" ("workspaceId")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "core"."unsubscribeTopic"`);
await queryRunner.query(
`DROP TYPE IF EXISTS "core"."unsubscribeTopic_visibility_enum"`,
);
}
}
@@ -62,6 +62,9 @@ import { MigrateAiModelPreferencesSlowInstanceCommand } from 'src/database/comma
import { DropIsCustomFromObjectAndFieldMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-12/2-12-instance-command-fast-1780579070012-drop-is-custom-from-object-and-field-metadata';
import { AddArchivedAtToConnectedAccountFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-instance-command-fast-1781171103000-add-archived-at-to-connected-account';
import { DropEmailingDomainDriverColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-11/2-11-instance-command-fast-1780926908000-drop-emailing-domain-driver-column';
import { AddEmailingDomainUnsubscribeHostFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-instance-command-fast-1780088214774-add-emailing-domain-unsubscribe-host';
import { CreateMessageSuppressionCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-instance-command-fast-1781250000000-create-message-suppression-core-table';
import { CreateUnsubscribeTopicCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-instance-command-fast-1781260000000-create-unsubscribe-topic-core-table';
import { ViewOverridableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-12/2-12-instance-command-fast-1781114009075-view-overridable-entity';
import { RenameIsUiReadOnlyToIsUiEditableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-instance-command-fast-1781277453604-rename-is-ui-read-only-to-is-ui-editable';
import { BackfillNonUiCreatableStandardSystemObjectsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-instance-command-slow-1781277480000-backfill-non-ui-creatable-standard-system-objects';
@@ -129,7 +132,10 @@ export const INSTANCE_COMMANDS = [
EncryptNonSecretApplicationVariableSlowInstanceCommand,
DropIsCustomFromObjectAndFieldMetadataFastInstanceCommand,
DropEmailingDomainDriverColumnFastInstanceCommand,
AddEmailingDomainUnsubscribeHostFastInstanceCommand,
ViewOverridableEntityFastInstanceCommand,
CreateMessageSuppressionCoreTableFastInstanceCommand,
CreateUnsubscribeTopicCoreTableFastInstanceCommand,
AddArchivedAtToConnectedAccountFastInstanceCommand,
RenameIsUiReadOnlyToIsUiEditableFastInstanceCommand,
BackfillNonUiCreatableStandardSystemObjectsSlowInstanceCommand,
@@ -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;
@@ -47,7 +47,7 @@ import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/worksp
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
import { getDomainFromEmailOrThrow } from 'src/utils/get-domain-from-email-or-throw';
import { isWorkEmail } from 'src/utils/is-work-email';
@Injectable()
@@ -542,7 +542,7 @@ export class SignInUpService {
);
if (isWorkEmailFound) {
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`;
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainFromEmailOrThrow(email)}`;
const logoFile =
await this.fileCorePictureService.uploadWorkspaceLogoFromUrl({
imageUrl: logoUrl,
@@ -94,7 +94,7 @@ describe('ClientConfigController', () => {
isGoogleCalendarEnabled: false,
isConfigVariablesInDbEnabled: false,
isImapSmtpCaldavEnabled: false,
isEmailGroupEnabled: false,
isEmailingDomainInDemoMode: false,
calendarBookingPageId: undefined,
isTwoFactorAuthenticationEnabled: false,
allowRequestsToTwentyIcons: true,
@@ -310,7 +310,7 @@ export class ClientConfig {
isImapSmtpCaldavEnabled: boolean;
@Field(() => Boolean)
isEmailGroupEnabled: boolean;
isEmailingDomainInDemoMode: boolean;
@Field(() => Boolean)
allowRequestsToTwentyIcons: boolean;
@@ -171,7 +171,7 @@ describe('ClientConfigService', () => {
isGoogleCalendarEnabled: true,
isConfigVariablesInDbEnabled: false,
isImapSmtpCaldavEnabled: false,
isEmailGroupEnabled: false,
isEmailingDomainInDemoMode: false,
allowRequestsToTwentyIcons: false,
calendarBookingPageId: 'team/twenty/talk-to-us',
isCloudflareIntegrationEnabled: false,
@@ -3,7 +3,6 @@ import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces/file-storage.interface';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
@@ -13,6 +12,7 @@ import {
type ClientConfig,
} from 'src/engine/core-modules/client-config/client-config.entity';
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import {
@@ -46,6 +46,10 @@ export class ClientConfigService {
'CALENDAR_BOOKING_PAGE_ID',
);
const isEmailingDomainInDemoMode =
this.twentyConfigService.get('EMAILING_DOMAIN_DRIVER') ===
EmailingDomainDriver.LOG;
const availableModels =
this.aiModelRegistryService.getAdminFilteredModels();
const recommendedModelIds =
@@ -235,10 +239,7 @@ export class ClientConfigService {
isImapSmtpCaldavEnabled: this.twentyConfigService.get(
'IS_IMAP_SMTP_CALDAV_ENABLED',
),
isEmailGroupEnabled:
this.twentyConfigService.get('STORAGE_TYPE') ===
StorageDriverType.S_3 &&
isNonEmptyString(this.twentyConfigService.get('INBOUND_EMAIL_DOMAIN')),
isEmailingDomainInDemoMode,
allowRequestsToTwentyIcons: this.twentyConfigService.get(
'ALLOW_REQUESTS_TO_TWENTY_ICONS',
),
@@ -29,6 +29,7 @@ import { CodeInterpreterModule } from 'src/engine/core-modules/code-interpreter/
import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
import { EmailingModule } from 'src/modules/emailing/emailing.module';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
import { ExceptionHandlerModule } from 'src/engine/core-modules/exception-handler/exception-handler.module';
import { exceptionHandlerModuleFactory } from 'src/engine/core-modules/exception-handler/exception-handler.module-factory';
@@ -45,7 +46,7 @@ import { LogicFunctionModule } from 'src/engine/core-modules/logic-function/logi
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
import { messageQueueModuleFactory } from 'src/engine/core-modules/message-queue/message-queue.module-factory';
import { TimelineMessagingModule } from 'src/engine/core-modules/messaging/timeline-messaging.module';
import { MessagingWebhooksModule } from 'src/engine/core-modules/messaging-webhooks/messaging-webhooks.module';
import { MessagingWebhooksModule } from 'src/modules/messaging-webhooks/messaging-webhooks.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { OpenApiModule } from 'src/engine/core-modules/open-api/open-api.module';
@@ -108,6 +109,7 @@ import { FileModule } from './file/file.module';
WorkspaceSSOModule,
ApprovedAccessDomainModule,
EmailingDomainModule,
EmailingModule,
PublicDomainModule,
CloudflareModule,
DnsManagerModule,
@@ -1,12 +1,12 @@
import { isDefined } from 'twenty-shared/utils';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
import { getDomainFromEmailOrThrow } from 'src/utils/get-domain-from-email-or-throw';
import { isWorkEmail } from 'src/utils/is-work-email';
export const getSubdomainFromEmail = (email?: string) => {
if (!isDefined(email) || !isWorkEmail(email)) return;
const domain = getDomainNameByEmail(email);
const domain = getDomainFromEmailOrThrow(email);
return domain.split('.')[0].toLowerCase();
};
@@ -0,0 +1,24 @@
export const CAMPAIGN_MESSAGE_DELIVERY_STATUS = {
QUEUED: 'QUEUED',
SENT: 'SENT',
FAILED: 'FAILED',
BOUNCED: 'BOUNCED',
COMPLAINED: 'COMPLAINED',
SKIPPED: 'SKIPPED',
} as const;
export const CAMPAIGN_STATUS = {
DRAFT: 'DRAFT',
SCHEDULED: 'SCHEDULED',
SENDING: 'SENDING',
SENT: 'SENT',
SENT_WITH_ERRORS: 'SENT_WITH_ERRORS',
} as const;
export const MATERIALIZE_CAMPAIGN_JOB = 'MaterializeCampaignJob';
export const SEND_CAMPAIGN_EMAIL_JOB = 'SendCampaignEmailJob';
export const MAX_CAMPAIGN_RECIPIENTS = 10000;
export const CAMPAIGN_MESSAGE_ID_NAMESPACE =
'0c4b9e7a-3f2d-4b6c-9e1a-7d8f5a2c3b4e';
@@ -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,11 @@
import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type';
export const HARD_SUPPRESSION_REASONS = [
MessageSuppressionReason.BOUNCE,
MessageSuppressionReason.COMPLAINT,
];
export const GLOBAL_BLOCKING_SUPPRESSION_REASONS = [
...HARD_SUPPRESSION_REASONS,
MessageSuppressionReason.UNSUBSCRIBE,
];
@@ -0,0 +1 @@
export const UNSUBSCRIBE_HOSTNAME_PREFIX = 'unsubscribe';
@@ -0,0 +1 @@
export const UNSUBSCRIBE_MAILBOX_LOCAL_PART = 'unsubscribe';
@@ -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,
@@ -21,10 +20,8 @@ import {
type EmailingDomainResourceInput,
type EmailingDomainVerificationResult,
} from 'src/engine/core-modules/emailing-domain/drivers/interfaces/emailing-domain-driver.interface';
import {
type EmailingDomainSendEmailInput,
type EmailingDomainSendEmailResult,
} from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
import { type EmailingDomainSendEmailInput } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-input.type';
import { type EmailingDomainSendEmailResult } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-result.type';
import { AWS_SES_RESOURCE_NAME_PREFIX } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-resource-name-prefix.constant';
import { type AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
@@ -120,7 +117,6 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
{
tenantName,
configurationSetName: this.buildConfigurationSetName(workspaceId),
contactListName: this.buildContactListName(workspaceId),
},
this.config,
);
@@ -136,7 +132,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 +162,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 +185,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 +200,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({
@@ -3,12 +3,9 @@ import { Injectable, Logger } from '@nestjs/common';
import { SendEmailCommand } from '@aws-sdk/client-sesv2';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import {
type EmailingDomainSendEmailInput,
type EmailingDomainSendEmailResult,
} from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
import { type EmailingDomainSendEmailInput } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-input.type';
import { type EmailingDomainSendEmailResult } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-result.type';
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 +16,6 @@ import {
type SendEmailContext = {
tenantName: string;
configurationSetName: string;
contactListName: string;
};
@Injectable()
@@ -56,6 +52,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 +77,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 +95,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,7 +1,5 @@
import {
type EmailingDomainSendEmailInput,
type EmailingDomainSendEmailResult,
} from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
import { type EmailingDomainSendEmailInput } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-input.type';
import { type EmailingDomainSendEmailResult } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-result.type';
import { type EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import { type VerificationRecord } from 'src/engine/core-modules/emailing-domain/drivers/types/verifications-record';
@@ -8,10 +8,8 @@ import {
type EmailingDomainVerificationResult,
} from 'src/engine/core-modules/emailing-domain/drivers/interfaces/emailing-domain-driver.interface';
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import {
type EmailingDomainSendEmailInput,
type EmailingDomainSendEmailResult,
} from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
import { type EmailingDomainSendEmailInput } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-input.type';
import { type EmailingDomainSendEmailResult } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-result.type';
@Injectable()
export class LogEmailingDomainDriver implements EmailingDomainDriverInterface {
@@ -66,6 +64,13 @@ export class LogEmailingDomainDriver implements EmailingDomainDriverInterface {
`[log-driver] sendEmail from=${input.from} to=${input.to.join(',')} subject="${input.subject}" → fake messageId=${messageId}`,
);
return { messageId };
return {
messageId,
deliveredRecipients: {
to: input.to,
cc: input.cc ?? [],
bcc: input.bcc ?? [],
},
};
}
}
@@ -0,0 +1,5 @@
export type EmailingDomainAttachment = {
filename: string;
content: Buffer;
contentType: string;
};
@@ -0,0 +1,16 @@
import { type EmailingDomainAttachment } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-attachment.type';
import { type EmailingDomainHeader } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-header.type';
export type EmailingDomainEmailContent = {
from: string;
to: string[];
cc?: string[];
bcc?: string[];
subject: string;
text: string;
html?: string;
replyTo?: string[];
attachments?: EmailingDomainAttachment[];
headers?: EmailingDomainHeader[];
unsubscribeTopicId?: string;
};
@@ -0,0 +1,4 @@
export type EmailingDomainHeader = {
name: string;
value: string;
};
@@ -0,0 +1,6 @@
import { type EmailingDomainEmailContent } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-email-content.type';
export type EmailingDomainSendEmailInput = EmailingDomainEmailContent & {
workspaceId: string;
domain: string;
};
@@ -0,0 +1,4 @@
export type EmailingDomainSendEmailResult = {
messageId: string;
deliveredRecipients: { to: string[]; cc: string[]; bcc: string[] };
};
@@ -1,26 +0,0 @@
export type EmailingDomainAttachment = {
filename: string;
content: Buffer;
contentType: string;
};
export type EmailingDomainEmailContent = {
from: string;
to: string[];
cc?: string[];
bcc?: string[];
subject: string;
text: string;
html?: string;
replyTo?: string[];
attachments?: EmailingDomainAttachment[];
};
export type EmailingDomainSendEmailInput = EmailingDomainEmailContent & {
workspaceId: string;
domain: string;
};
export type EmailingDomainSendEmailResult = {
messageId: string;
};
@@ -0,0 +1,5 @@
export enum UnsubscribeHostnameStatus {
PENDING = 'PENDING',
ACTIVE = 'ACTIVE',
FAILED = 'FAILED',
}
@@ -0,0 +1,22 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class CampaignAudiencePreviewDTO {
@Field(() => Int)
totalMembers: number;
@Field(() => Int)
withoutEmail: number;
@Field(() => Int)
duplicateEmails: number;
@Field(() => Int)
globallyUnsubscribed: number;
@Field(() => Int)
topicUnsubscribed: number;
@Field(() => Int)
sendable: number;
}
@@ -0,0 +1,13 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class CampaignSkippedRecipientsDTO {
@Field(() => Int)
noEmail: number;
@Field(() => Int)
deduped: number;
@Field(() => Int)
overCap: number;
}
@@ -0,0 +1,16 @@
import { Field, InputType } from '@nestjs/graphql';
import { Matches, MaxLength } from 'class-validator';
const DOMAIN_REGEX =
/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i;
@InputType()
export class CreateEmailingDomainInput {
@Field(() => String)
@MaxLength(255)
@Matches(DOMAIN_REGEX, {
message: 'domain must be a valid domain name (e.g. mail.example.com)',
})
domain: string;
}
@@ -0,0 +1,24 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
import { UnsubscribeTopicVisibility } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-topic-visibility.type';
@InputType()
export class CreateUnsubscribeTopicInput {
@Field(() => String)
@IsString()
@MaxLength(256)
name: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@MaxLength(1024)
description?: string;
@Field(() => UnsubscribeTopicVisibility, { nullable: true })
@IsOptional()
@IsEnum(UnsubscribeTopicVisibility)
visibility?: UnsubscribeTopicVisibility;
}
@@ -0,0 +1,15 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsOptional, IsUUID } from 'class-validator';
@InputType()
export class PreviewMessageCampaignAudienceInput {
@Field(() => String)
@IsUUID('4')
listId: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsUUID('4')
unsubscribeTopicId?: string;
}

Some files were not shown because too many files have changed in this diff Show More