[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:
@@ -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
+152
@@ -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>
|
||||
);
|
||||
};
|
||||
-40
@@ -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>
|
||||
);
|
||||
};
|
||||
-43
@@ -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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+16
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UNSUBSCRIBE_TOPICS = gql`
|
||||
query UnsubscribeTopics {
|
||||
unsubscribeTopics {
|
||||
id
|
||||
name
|
||||
description
|
||||
visibility
|
||||
}
|
||||
}
|
||||
`;
|
||||
+15
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+129
@@ -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();
|
||||
});
|
||||
});
|
||||
+35
@@ -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 };
|
||||
};
|
||||
-7
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
});
|
||||
-6
@@ -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
@@ -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 />,
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
|
||||
import { useOpenCampaignComposerInSidePanel } from '@/side-panel/hooks/useOpenCampaignComposerInSidePanel';
|
||||
|
||||
export const ComposeCampaignCommand = () => {
|
||||
const { openCampaignComposerInSidePanel } =
|
||||
useOpenCampaignComposerInSidePanel();
|
||||
|
||||
const handleExecute = () => {
|
||||
openCampaignComposerInSidePanel();
|
||||
};
|
||||
|
||||
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} ready />;
|
||||
};
|
||||
+8
@@ -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}
|
||||
|
||||
+19
-15
@@ -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`}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+2
@@ -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 },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
+2
@@ -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 },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
+22
@@ -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}>
|
||||
|
||||
-30
@@ -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>
|
||||
);
|
||||
-17
@@ -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)}
|
||||
/>
|
||||
);
|
||||
-70
@@ -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>
|
||||
);
|
||||
};
|
||||
+45
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
-20
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+86
@@ -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)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+12
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const DELETE_UNSUBSCRIBE_TOPIC = gql`
|
||||
mutation DeleteUnsubscribeTopic($id: String!) {
|
||||
deleteUnsubscribeTopic(id: $id)
|
||||
}
|
||||
`;
|
||||
+12
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_UNSUBSCRIBE_PAGE_PREVIEW_URL = gql`
|
||||
query UnsubscribePagePreviewUrl {
|
||||
unsubscribePagePreviewUrl
|
||||
}
|
||||
`;
|
||||
+23
@@ -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 };
|
||||
};
|
||||
+21
@@ -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 };
|
||||
};
|
||||
+23
@@ -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 };
|
||||
};
|
||||
+35
@@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+9
-3
@@ -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)
|
||||
}
|
||||
|
||||
-48
@@ -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 />],
|
||||
],
|
||||
);
|
||||
|
||||
+23
@@ -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 };
|
||||
};
|
||||
+69
@@ -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>
|
||||
);
|
||||
|
||||
+45
-16
@@ -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`}
|
||||
|
||||
+144
@@ -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>
|
||||
);
|
||||
};
|
||||
+197
@@ -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>
|
||||
);
|
||||
};
|
||||
-122
@@ -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>
|
||||
);
|
||||
};
|
||||
-149
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { QueryRunner } from 'typeorm';
|
||||
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
|
||||
|
||||
@RegisteredInstanceCommand('2.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"');
|
||||
}
|
||||
}
|
||||
+55
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+38
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
@@ -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,
|
||||
|
||||
+2
-1
@@ -27,6 +27,7 @@ import { isAsymmetricJwtHeader } from 'src/engine/core-modules/jwt/utils/is-asym
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
|
||||
import { isWorkDomain } from 'src/utils/is-work-email';
|
||||
|
||||
const APPROVED_ACCESS_DOMAIN_TOKEN_EXPIRES_IN = '7d';
|
||||
@@ -65,7 +66,7 @@ export class ApprovedAccessDomainService {
|
||||
);
|
||||
}
|
||||
|
||||
if (to.split('@')[1] !== approvedAccessDomain.domain) {
|
||||
if (getDomainFromEmail(to) !== approvedAccessDomain.domain) {
|
||||
throw new ApprovedAccessDomainException(
|
||||
'Approved access domain does not match email domain',
|
||||
ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL,
|
||||
|
||||
@@ -71,6 +71,7 @@ import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/worksp
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
|
||||
// import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-migration/constant/default-feature-flags';
|
||||
|
||||
@Injectable()
|
||||
@@ -896,7 +897,8 @@ export class AuthService {
|
||||
if (
|
||||
workspace?.approvedAccessDomains.some(
|
||||
(trustDomain) =>
|
||||
trustDomain.isValidated && trustDomain.domain === email.split('@')[1],
|
||||
trustDomain.isValidated &&
|
||||
trustDomain.domain === getDomainFromEmail(email),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ describe('ClientConfigController', () => {
|
||||
isGoogleCalendarEnabled: false,
|
||||
isConfigVariablesInDbEnabled: false,
|
||||
isImapSmtpCaldavEnabled: false,
|
||||
isEmailGroupEnabled: false,
|
||||
isEmailingDomainInDemoMode: false,
|
||||
calendarBookingPageId: undefined,
|
||||
isTwoFactorAuthenticationEnabled: false,
|
||||
allowRequestsToTwentyIcons: true,
|
||||
|
||||
+1
-1
@@ -310,7 +310,7 @@ export class ClientConfig {
|
||||
isImapSmtpCaldavEnabled: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isEmailGroupEnabled: boolean;
|
||||
isEmailingDomainInDemoMode: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
allowRequestsToTwentyIcons: boolean;
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+6
-5
@@ -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,
|
||||
|
||||
+2
-2
@@ -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();
|
||||
};
|
||||
|
||||
+24
@@ -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';
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { type UnsubscribeContent } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-content.type';
|
||||
|
||||
export const EMPTY_UNSUBSCRIBE_CONTENT: UnsubscribeContent = {
|
||||
headers: [],
|
||||
textFooter: '',
|
||||
htmlFooter: '',
|
||||
};
|
||||
+11
@@ -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,
|
||||
];
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const UNSUBSCRIBE_HOSTNAME_PREFIX = 'unsubscribe';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const UNSUBSCRIBE_MAILBOX_LOCAL_PART = 'unsubscribe';
|
||||
-1
@@ -1 +0,0 @@
|
||||
export const AWS_SES_MARKETING_TOPIC_NAME = 'marketing';
|
||||
-4
@@ -2,7 +2,6 @@ import {
|
||||
AlreadyExistsException,
|
||||
CreateConfigurationSetCommand,
|
||||
CreateConfigurationSetEventDestinationCommand,
|
||||
CreateContactListCommand,
|
||||
CreateTenantResourceAssociationCommand,
|
||||
PutEmailIdentityMailFromAttributesCommand,
|
||||
} from '@aws-sdk/client-sesv2';
|
||||
@@ -22,7 +21,6 @@ describe('AwsSesRegisterDomainService', () => {
|
||||
const provisionInput = {
|
||||
tenantName: 'twenty-workspace-ws1',
|
||||
configurationSetName: 'twenty-workspace-ws1',
|
||||
contactListName: 'twenty-workspace-ws1',
|
||||
};
|
||||
|
||||
const buildAlreadyExists = () =>
|
||||
@@ -56,7 +54,6 @@ describe('AwsSesRegisterDomainService', () => {
|
||||
expect(commandTypes).toEqual([
|
||||
CreateConfigurationSetCommand.name,
|
||||
CreateConfigurationSetEventDestinationCommand.name,
|
||||
CreateContactListCommand.name,
|
||||
CreateTenantResourceAssociationCommand.name,
|
||||
]);
|
||||
});
|
||||
@@ -75,7 +72,6 @@ describe('AwsSesRegisterDomainService', () => {
|
||||
expect(commandTypes).toEqual([
|
||||
CreateConfigurationSetCommand.name,
|
||||
CreateConfigurationSetEventDestinationCommand.name,
|
||||
CreateContactListCommand.name,
|
||||
CreateTenantResourceAssociationCommand.name,
|
||||
]);
|
||||
});
|
||||
|
||||
+2
-6
@@ -21,7 +21,6 @@ describe('AwsSesSendEmailService', () => {
|
||||
const baseContext = {
|
||||
tenantName: 'twenty-workspace-ws1',
|
||||
configurationSetName: 'twenty-workspace-ws1',
|
||||
contactListName: 'twenty-workspace-ws1',
|
||||
};
|
||||
|
||||
const setUp = () => {
|
||||
@@ -42,7 +41,7 @@ describe('AwsSesSendEmailService', () => {
|
||||
return { service, send, handleErrorService };
|
||||
};
|
||||
|
||||
it('should call SendEmail with tenant, config set, and list management options', async () => {
|
||||
it('should call SendEmail with tenant and config set', async () => {
|
||||
const { service, send } = setUp();
|
||||
|
||||
send.mockResolvedValue({ MessageId: 'msg-1' });
|
||||
@@ -59,11 +58,8 @@ describe('AwsSesSendEmailService', () => {
|
||||
Destination: { ToAddresses: ['user@example.com'] },
|
||||
ConfigurationSetName: 'twenty-workspace-ws1',
|
||||
TenantName: 'twenty-workspace-ws1',
|
||||
ListManagementOptions: {
|
||||
ContactListName: 'twenty-workspace-ws1',
|
||||
TopicName: 'marketing',
|
||||
},
|
||||
});
|
||||
expect(command.input.ListManagementOptions).toBeUndefined();
|
||||
expect(command.input.EmailTags).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ Name: 'workspace', Value: 'ws1' },
|
||||
|
||||
+2
-18
@@ -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();
|
||||
|
||||
|
||||
+1
-24
@@ -4,7 +4,6 @@ import {
|
||||
AlreadyExistsException,
|
||||
CreateConfigurationSetCommand,
|
||||
CreateConfigurationSetEventDestinationCommand,
|
||||
CreateContactListCommand,
|
||||
CreateTenantResourceAssociationCommand,
|
||||
PutEmailIdentityMailFromAttributesCommand,
|
||||
} from '@aws-sdk/client-sesv2';
|
||||
@@ -12,13 +11,11 @@ import { type AwsSesDriverConfig } from 'src/engine/core-modules/emailing-domain
|
||||
|
||||
import { AWS_SES_EVENT_BUS_NAME } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-event-bus-name.constant';
|
||||
import { AWS_SES_MAIL_FROM_SUBDOMAIN } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-mail-from-subdomain.constant';
|
||||
import { AWS_SES_MARKETING_TOPIC_NAME } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-marketing-topic-name.constant';
|
||||
import { AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
|
||||
|
||||
type ProvisionWorkspaceInput = {
|
||||
tenantName: string;
|
||||
configurationSetName: string;
|
||||
contactListName: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -42,7 +39,7 @@ export class AwsSesRegisterDomainService {
|
||||
ConfigurationSetName: input.configurationSetName,
|
||||
ReputationOptions: { ReputationMetricsEnabled: true },
|
||||
SendingOptions: { SendingEnabled: true },
|
||||
SuppressionOptions: { SuppressedReasons: ['BOUNCE', 'COMPLAINT'] },
|
||||
SuppressionOptions: { SuppressedReasons: [] },
|
||||
Tags: [{ Key: 'managed-by', Value: 'twenty' }],
|
||||
}),
|
||||
)
|
||||
@@ -79,26 +76,6 @@ export class AwsSesRegisterDomainService {
|
||||
}
|
||||
});
|
||||
|
||||
await sesClient
|
||||
.send(
|
||||
new CreateContactListCommand({
|
||||
ContactListName: input.contactListName,
|
||||
Topics: [
|
||||
{
|
||||
TopicName: AWS_SES_MARKETING_TOPIC_NAME,
|
||||
DisplayName: 'Marketing',
|
||||
DefaultSubscriptionStatus: 'OPT_IN',
|
||||
},
|
||||
],
|
||||
Tags: [{ Key: 'managed-by', Value: 'twenty' }],
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
if (!(error instanceof AlreadyExistsException)) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
await sesClient
|
||||
.send(
|
||||
new CreateTenantResourceAssociationCommand({
|
||||
|
||||
+16
-11
@@ -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;
|
||||
|
||||
+6
@@ -11,6 +11,8 @@ export enum EmailingDomainDriverExceptionCode {
|
||||
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
|
||||
CONFIGURATION_ERROR = 'CONFIGURATION_ERROR',
|
||||
SENDING_SUSPENDED = 'SENDING_SUSPENDED',
|
||||
ALL_RECIPIENTS_SUPPRESSED = 'ALL_RECIPIENTS_SUPPRESSED',
|
||||
UNSUBSCRIBE_NOT_READY = 'UNSUBSCRIBE_NOT_READY',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
@@ -26,6 +28,10 @@ const getEmailingDomainDriverExceptionUserFriendlyMessage = (
|
||||
return msg`Email domain configuration error.`;
|
||||
case EmailingDomainDriverExceptionCode.SENDING_SUSPENDED:
|
||||
return msg`Sending is currently suspended for this email domain.`;
|
||||
case EmailingDomainDriverExceptionCode.ALL_RECIPIENTS_SUPPRESSED:
|
||||
return msg`All recipients are suppressed for this email domain.`;
|
||||
case EmailingDomainDriverExceptionCode.UNSUBSCRIBE_NOT_READY:
|
||||
return msg`Marketing sending is on hold until the unsubscribe domain is verified.`;
|
||||
case EmailingDomainDriverExceptionCode.TEMPORARY_ERROR:
|
||||
case EmailingDomainDriverExceptionCode.UNKNOWN:
|
||||
return STANDARD_ERROR_MESSAGE;
|
||||
|
||||
+2
-4
@@ -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';
|
||||
|
||||
|
||||
+10
-5
@@ -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 ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type EmailingDomainAttachment = {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
};
|
||||
+16
@@ -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;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type EmailingDomainHeader = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
+6
@@ -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;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type EmailingDomainSendEmailResult = {
|
||||
messageId: string;
|
||||
deliveredRecipients: { to: string[]; cc: string[]; bcc: string[] };
|
||||
};
|
||||
-26
@@ -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;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export enum UnsubscribeHostnameStatus {
|
||||
PENDING = 'PENDING',
|
||||
ACTIVE = 'ACTIVE',
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+15
@@ -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
Reference in New Issue
Block a user