[WIP] Feat/marketing emails (#21173)

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

## Model

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

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

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

## Sending

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

## Unsubscribe

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

## Architecture

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

## Frontend

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

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
neo773
2026-06-13 22:07:39 +05:30
committed by GitHub
parent 2c5da39dc5
commit 5d892bdfd0
238 changed files with 10433 additions and 3517 deletions
@@ -0,0 +1,152 @@
import { styled } from '@linaria/react';
import { useCampaignAudiencePreview } from '@/activities/emails/hooks/useCampaignAudiencePreview';
import { type useCampaignComposerState } from '@/activities/emails/hooks/useCampaignComposerState';
import { useUnsubscribeTopics } from '@/activities/emails/hooks/useUnsubscribeTopics';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
import { Select } from '@/ui/input/components/Select';
import { t } from '@lingui/core/macro';
import { MessageChannelType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type SelectOption } from 'twenty-ui-deprecated/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledFieldsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[2]};
`;
const StyledHint = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.xs};
padding: ${themeCssVariables.spacing[1]} 0;
`;
type CampaignAudiencePreview = NonNullable<
ReturnType<typeof useCampaignAudiencePreview>
>;
const buildAudienceHint = (preview: CampaignAudiencePreview): string => {
const parts: string[] = [];
if (preview.withoutEmail > 0) {
parts.push(t`${preview.withoutEmail} without email`);
}
if (preview.duplicateEmails > 0) {
parts.push(t`${preview.duplicateEmails} duplicate`);
}
if (preview.globallyUnsubscribed > 0) {
parts.push(t`${preview.globallyUnsubscribed} unsubscribed from everything`);
}
if (preview.topicUnsubscribed > 0) {
parts.push(t`${preview.topicUnsubscribed} opted out of this topic`);
}
const breakdown = parts.length > 0 ? ` (${parts.join(', ')})` : '';
return (
t`${preview.totalMembers} in this list — ${preview.sendable} sendable` +
breakdown
);
};
type CampaignComposerFieldsProps = {
campaignState: ReturnType<typeof useCampaignComposerState>;
};
export const CampaignComposerFields = ({
campaignState,
}: CampaignComposerFieldsProps) => {
const { channels } = useMyMessageChannels();
const { unsubscribeTopics } = useUnsubscribeTopics();
const { createOneRecord: createMessageList } = useCreateOneRecord({
objectNameSingular: 'messageList',
});
const handleCreateList = async (searchInput?: string) => {
const listName = searchInput?.trim() ?? '';
const createdList = await createMessageList({
name: listName.length > 0 ? listName : t`Untitled list`,
});
if (isDefined(createdList)) {
campaignState.setListId(createdList.id);
}
};
const audiencePreview = useCampaignAudiencePreview({
listId: campaignState.listId,
unsubscribeTopicId: campaignState.unsubscribeTopicId,
});
const senderOptions: SelectOption<string>[] = channels
.filter((channel) => channel.type === MessageChannelType.EMAIL_GROUP)
.map((channel) => channel.connectedAccount?.handle)
.filter(isDefined)
.map((handle) => ({ label: handle, value: handle }));
const topicOptions: SelectOption<string>[] = unsubscribeTopics.map(
(topic) => ({
label: topic.name ?? t`Untitled topic`,
value: topic.id,
}),
);
return (
<StyledFieldsContainer>
<Select
dropdownId="campaign-composer-from-account"
label={t`From`}
fullWidth
value={campaignState.fromAddress}
options={senderOptions}
emptyOption={{ label: t`Select a sender`, value: '' }}
onChange={campaignState.setFromAddress}
/>
<FormSingleRecordPicker
label={t`To`}
objectNameSingulars={['messageList']}
defaultValue={campaignState.listId}
onChange={campaignState.setListId}
onCreate={handleCreateList}
/>
{isDefined(audiencePreview) && (
<StyledHint>{buildAudienceHint(audiencePreview)}</StyledHint>
)}
<Select
dropdownId="campaign-composer-unsubscribe-topic"
label={t`Unsubscribe topic`}
fullWidth
value={campaignState.unsubscribeTopicId ?? ''}
options={topicOptions}
emptyOption={{ label: t`No topic`, value: '' }}
onChange={(value) =>
campaignState.setUnsubscribeTopicId(value === '' ? null : value)
}
/>
<StyledHint>
{t`The unsubscribe topic this email belongs to. Recipients who opted out of it are skipped, and the unsubscribe link is scoped to it.`}
</StyledHint>
<FormTextFieldInput
label={t`Subject`}
defaultValue={campaignState.subject}
onChange={campaignState.setSubject}
placeholder={t`Subject`}
/>
<FormAdvancedTextFieldInput
defaultValue=""
onChange={campaignState.setBody}
placeholder={t`Type something or press "/" to see commands`}
minHeight={120}
maxWidth={600}
contentType="html"
/>
</StyledFieldsContainer>
);
};
@@ -1,92 +0,0 @@
import { styled } from '@linaria/react';
import { EmailComposerFields } from '@/activities/emails/components/EmailComposerFields';
import { useEmailComposerState } from '@/activities/emails/hooks/useEmailComposerState';
import { t } from '@lingui/core/macro';
import { IconArrowBackUp } from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
import { themeCssVariables } from 'twenty-ui-deprecated/theme-constants';
const StyledFooterWarning = styled.span`
color: ${themeCssVariables.color.red};
flex: 1;
font-size: ${themeCssVariables.font.size.xs};
`;
const StyledComposerContainer = styled.div`
background: ${themeCssVariables.background.primary};
display: flex;
flex-direction: column;
`;
const StyledFooter = styled.div`
align-items: center;
border-top: 1px solid ${themeCssVariables.border.color.light};
display: flex;
justify-content: flex-end;
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[4]};
`;
const StyledFooterActions = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
type EmailComposerProps = {
connectedAccountId: string;
defaultTo?: string;
defaultSubject?: string;
defaultInReplyTo?: string;
onClose?: () => void;
onSent?: () => void;
};
export const EmailComposer = ({
connectedAccountId,
defaultTo = '',
defaultSubject = '',
defaultInReplyTo,
onClose,
onSent,
}: EmailComposerProps) => {
const composerState = useEmailComposerState({
connectedAccountId,
defaultTo,
defaultSubject,
defaultInReplyTo,
onSent,
});
return (
<StyledComposerContainer>
<EmailComposerFields composerState={composerState} />
<StyledFooter>
{composerState.exceedsRecipientLimit && (
<StyledFooterWarning>
{t`Too many recipients (${composerState.recipientCount}/${composerState.maxRecipients}).`}
</StyledFooterWarning>
)}
<StyledFooterActions>
{onClose && (
<Button
size="small"
variant="secondary"
title={t`Cancel`}
onClick={onClose}
/>
)}
<Button
size="small"
variant="primary"
accent="blue"
title={t`Send`}
Icon={IconArrowBackUp}
onClick={composerState.handleSend}
disabled={!composerState.canSend}
/>
</StyledFooterActions>
</StyledFooter>
</StyledComposerContainer>
);
};
@@ -1,40 +0,0 @@
import { MessageThreadSubscriberDropdownAddSubscriberMenuItem } from '@/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriberMenuItem';
import { type MessageThreadSubscriber } from '@/activities/emails/types/MessageThreadSubscriber';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
export const MessageThreadSubscriberDropdownAddSubscriber = ({
existingSubscribers,
}: {
existingSubscribers: MessageThreadSubscriber[];
}) => {
const { records: workspaceMembersLeftToAdd } =
useFindManyRecords<WorkspaceMember>({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
filter: {
not: {
id: {
in: existingSubscribers.map(
({ workspaceMember }) => workspaceMember.id,
),
},
},
},
});
return (
<DropdownMenuItemsContainer>
<DropdownMenuSearchInput />
<DropdownMenuSeparator />
{workspaceMembersLeftToAdd.map((workspaceMember) => (
<MessageThreadSubscriberDropdownAddSubscriberMenuItem
workspaceMember={workspaceMember}
/>
))}
</DropdownMenuItemsContainer>
);
};
@@ -1,43 +0,0 @@
import { type MessageThreadSubscriber } from '@/activities/emails/types/MessageThreadSubscriber';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { IconPlus } from 'twenty-ui-deprecated/display';
import { MenuItemAvatar } from 'twenty-ui-deprecated/navigation';
export const MessageThreadSubscriberDropdownAddSubscriberMenuItem = ({
workspaceMember,
}: {
workspaceMember: WorkspaceMember;
}) => {
const text = `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`;
const { createOneRecord } = useCreateOneRecord<MessageThreadSubscriber>({
objectNameSingular: CoreObjectNameSingular.MessageThreadSubscriber,
});
const handleAddButtonClick = () => {
createOneRecord({
workspaceMember,
});
};
return (
<MenuItemAvatar
avatar={{
placeholder: workspaceMember.name.firstName,
avatarUrl: workspaceMember.avatarUrl,
placeholderColorSeed: workspaceMember.id,
size: 'md',
type: 'rounded',
}}
text={text}
iconButtons={[
{
Icon: IconPlus,
onClick: handleAddButtonClick,
},
]}
/>
);
};
@@ -0,0 +1,16 @@
import gql from 'graphql-tag';
export const PREVIEW_MESSAGE_CAMPAIGN_AUDIENCE = gql`
query PreviewMessageCampaignAudience(
$input: PreviewMessageCampaignAudienceInput!
) {
previewMessageCampaignAudience(input: $input) {
totalMembers
withoutEmail
duplicateEmails
globallyUnsubscribed
topicUnsubscribed
sendable
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const UNSUBSCRIBE_TOPICS = gql`
query UnsubscribeTopics {
unsubscribeTopics {
id
name
description
visibility
}
}
`;
@@ -0,0 +1,15 @@
import gql from 'graphql-tag';
export const SEND_MESSAGE_CAMPAIGN = gql`
mutation SendMessageCampaign($input: SendMessageCampaignInput!) {
sendMessageCampaign(input: $input) {
campaignId
queuedCount
skipped {
noEmail
deduped
overCap
}
}
}
`;
@@ -0,0 +1,129 @@
import { act, renderHook } from '@testing-library/react';
import { useCampaignComposerState } from '@/activities/emails/hooks/useCampaignComposerState';
import { useSendMessageCampaign } from '@/activities/emails/hooks/useSendMessageCampaign';
jest.mock('@/activities/emails/hooks/useSendMessageCampaign');
const sendMessageCampaignMock = jest.fn(
(): Promise<boolean> => Promise.resolve(true),
);
const mockedUseSendMessageCampaign = jest.mocked(useSendMessageCampaign);
const fillSendableFields = (result: {
current: ReturnType<typeof useCampaignComposerState>;
}) => {
act(() => {
result.current.setListId('list-1');
result.current.setFromAddress(' sender@example.com ');
result.current.setSubject('Hello');
});
};
describe('useCampaignComposerState', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedUseSendMessageCampaign.mockReturnValue({
sendMessageCampaign: sendMessageCampaignMock,
loading: false,
});
});
it('should start empty and not be sendable', () => {
const { result } = renderHook(() => useCampaignComposerState({}));
expect(result.current.listId).toBeNull();
expect(result.current.unsubscribeTopicId).toBeNull();
expect(result.current.canSend).toBe(false);
});
it('should become sendable once list, from address and subject are set', () => {
const { result } = renderHook(() => useCampaignComposerState({}));
fillSendableFields(result);
expect(result.current.canSend).toBe(true);
});
it('should not be sendable while a send is in flight', () => {
mockedUseSendMessageCampaign.mockReturnValue({
sendMessageCampaign: sendMessageCampaignMock,
loading: true,
});
const { result } = renderHook(() => useCampaignComposerState({}));
fillSendableFields(result);
expect(result.current.canSend).toBe(false);
});
it('should send trimmed values with the selected topic and call onSent on success', async () => {
sendMessageCampaignMock.mockResolvedValue(true);
const onSent = jest.fn();
const { result } = renderHook(() => useCampaignComposerState({ onSent }));
act(() => {
result.current.setUnsubscribeTopicId('topic-1');
result.current.setBody('Body');
});
fillSendableFields(result);
await act(async () => {
await result.current.handleSend();
});
expect(sendMessageCampaignMock).toHaveBeenCalledWith({
listId: 'list-1',
unsubscribeTopicId: 'topic-1',
subject: 'Hello',
body: 'Body',
fromAddress: 'sender@example.com',
});
expect(onSent).toHaveBeenCalledTimes(1);
});
it('should pass an undefined topic when none is selected', async () => {
sendMessageCampaignMock.mockResolvedValue(true);
const { result } = renderHook(() => useCampaignComposerState({}));
fillSendableFields(result);
await act(async () => {
await result.current.handleSend();
});
expect(sendMessageCampaignMock).toHaveBeenCalledWith(
expect.objectContaining({ unsubscribeTopicId: undefined }),
);
});
it('should not send when required fields are missing', async () => {
const { result } = renderHook(() => useCampaignComposerState({}));
await act(async () => {
await result.current.handleSend();
});
expect(sendMessageCampaignMock).not.toHaveBeenCalled();
});
it('should not call onSent when the send fails', async () => {
sendMessageCampaignMock.mockResolvedValue(false);
const onSent = jest.fn();
const { result } = renderHook(() => useCampaignComposerState({ onSent }));
fillSendableFields(result);
await act(async () => {
await result.current.handleSend();
});
expect(sendMessageCampaignMock).toHaveBeenCalled();
expect(onSent).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,35 @@
import { useQuery } from '@apollo/client/react';
import { isNonEmptyString } from '@sniptt/guards';
import { PREVIEW_MESSAGE_CAMPAIGN_AUDIENCE } from '@/activities/emails/graphql/metadata-queries/previewMessageCampaignAudience';
import {
type PreviewMessageCampaignAudienceQuery,
type PreviewMessageCampaignAudienceQueryVariables,
} from '~/generated-metadata/graphql';
type UseCampaignAudiencePreviewArgs = {
listId: string | null;
unsubscribeTopicId: string | null;
};
export const useCampaignAudiencePreview = ({
listId,
unsubscribeTopicId,
}: UseCampaignAudiencePreviewArgs) => {
const { data } = useQuery<
PreviewMessageCampaignAudienceQuery,
PreviewMessageCampaignAudienceQueryVariables
>(PREVIEW_MESSAGE_CAMPAIGN_AUDIENCE, {
skip: !isNonEmptyString(listId),
variables: {
input: {
listId: listId ?? '',
unsubscribeTopicId: unsubscribeTopicId ?? undefined,
},
},
fetchPolicy: 'cache-and-network',
});
return data?.previewMessageCampaignAudience ?? null;
};
@@ -0,0 +1,61 @@
import { useState } from 'react';
import { useSendMessageCampaign } from '@/activities/emails/hooks/useSendMessageCampaign';
type UseCampaignComposerStateArgs = {
onSent?: () => void;
};
export const useCampaignComposerState = ({
onSent,
}: UseCampaignComposerStateArgs) => {
const [unsubscribeTopicId, setUnsubscribeTopicId] = useState<string | null>(
null,
);
const [listId, setListId] = useState<string | null>(null);
const [fromAddress, setFromAddress] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const { sendMessageCampaign, loading } = useSendMessageCampaign();
const canSend =
listId !== null &&
fromAddress.trim().length > 0 &&
subject.trim().length > 0 &&
!loading;
const handleSend = async () => {
if (listId === null || !canSend) {
return;
}
const success = await sendMessageCampaign({
listId,
unsubscribeTopicId: unsubscribeTopicId ?? undefined,
subject,
body,
fromAddress: fromAddress.trim(),
});
if (success) {
onSent?.();
}
};
return {
unsubscribeTopicId,
setUnsubscribeTopicId,
listId,
setListId,
fromAddress,
setFromAddress,
subject,
setSubject,
body,
setBody,
handleSend,
canSend,
loading,
};
};
@@ -0,0 +1,76 @@
import { useMutation } from '@apollo/client/react';
import { useCallback } from 'react';
import { SEND_MESSAGE_CAMPAIGN } from '@/activities/emails/graphql/mutations/sendMessageCampaign';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { t } from '@lingui/core/macro';
import {
type SendMessageCampaignMutation,
type SendMessageCampaignMutationVariables,
} from '~/generated-metadata/graphql';
type SendMessageCampaignParams = {
listId: string;
unsubscribeTopicId?: string;
subject: string;
body: string;
fromAddress: string;
};
export const useSendMessageCampaign = () => {
const [sendMessageCampaignMutation, { loading }] = useMutation<
SendMessageCampaignMutation,
SendMessageCampaignMutationVariables
>(SEND_MESSAGE_CAMPAIGN);
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const sendMessageCampaign = useCallback(
async (params: SendMessageCampaignParams): Promise<boolean> => {
try {
const result = await sendMessageCampaignMutation({
variables: { input: params },
});
const queued = result.data?.sendMessageCampaign;
if (!queued) {
enqueueErrorSnackBar({ message: t`Failed to send campaign` });
return false;
}
const { queuedCount, skipped } = queued;
const skippedCount =
skipped.noEmail + skipped.deduped + skipped.overCap;
if (queuedCount === 0) {
enqueueErrorSnackBar({
message: t`No recipients to send to (${skippedCount} skipped)`,
});
return false;
}
enqueueSuccessSnackBar({
message:
skippedCount > 0
? t`Campaign queued to ${queuedCount} recipient(s), ${skippedCount} skipped`
: t`Campaign queued to ${queuedCount} recipient(s)`,
});
return true;
} catch (error) {
enqueueErrorSnackBar({
message:
error instanceof Error ? error.message : t`Failed to send campaign`,
});
return false;
}
},
[sendMessageCampaignMutation, enqueueSuccessSnackBar, enqueueErrorSnackBar],
);
return { sendMessageCampaign, loading };
};
@@ -0,0 +1,9 @@
import { useQuery } from '@apollo/client/react';
import { UnsubscribeTopicsDocument } from '~/generated-metadata/graphql';
export const useUnsubscribeTopics = () => {
const { data, loading } = useQuery(UnsubscribeTopicsDocument);
return { unsubscribeTopics: data?.unsubscribeTopics ?? [], loading };
};
@@ -1,7 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const emailThreadIdWhenEmailThreadWasClosedState = createAtomState<
string | null
>({
key: 'emailThreadIdWhenEmailThreadWasClosedState',
defaultValue: null,
});
@@ -128,6 +128,22 @@ const SettingsWorkspaceEmailGroupChannelDetail = lazy(() =>
),
);
const SettingsWorkspaceNewUnsubscribeTopic = lazy(() =>
import('~/pages/settings/email/SettingsWorkspaceNewUnsubscribeTopic').then(
(module) => ({
default: module.SettingsWorkspaceNewUnsubscribeTopic,
}),
),
);
const SettingsWorkspaceUnsubscribeTopicDetail = lazy(() =>
import('~/pages/settings/email/SettingsWorkspaceUnsubscribeTopicDetail').then(
(module) => ({
default: module.SettingsWorkspaceUnsubscribeTopicDetail,
}),
),
);
const SettingsSubdomainPage = lazy(() =>
import('~/pages/settings/domains/SettingsSubdomainPage').then((module) => ({
default: module.SettingsSubdomainPage,
@@ -435,22 +451,6 @@ const SettingsSecurityApprovedAccessDomain = lazy(() =>
),
);
const SettingsNewEmailingDomain = lazy(() =>
import('~/pages/settings/emailing-domains/SettingsNewEmailingDomain').then(
(module) => ({
default: module.SettingsNewEmailingDomain,
}),
),
);
const SettingsEmailingDomainDetail = lazy(() =>
import('~/pages/settings/emailing-domains/SettingsEmailingDomainDetail').then(
(module) => ({
default: module.SettingsEmailingDomainDetail,
}),
),
);
const SettingsAdmin = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdmin').then((module) => ({
default: module.SettingsAdmin,
@@ -656,6 +656,14 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.EmailGroupChannelDetail}
element={<SettingsWorkspaceEmailGroupChannelDetail />}
/>
<Route
path={SettingsPath.NewUnsubscribeTopic}
element={<SettingsWorkspaceNewUnsubscribeTopic />}
/>
<Route
path={SettingsPath.UnsubscribeTopicDetail}
element={<SettingsWorkspaceUnsubscribeTopicDetail />}
/>
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
<Route path={SettingsPath.Usage} element={<SettingsUsage />} />
<Route
@@ -670,14 +678,6 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.CustomDomain}
element={<SettingsCustomDomainPage />}
/>
<Route
path={SettingsPath.NewEmailingDomain}
element={<SettingsNewEmailingDomain />}
/>
<Route
path={SettingsPath.EmailingDomainDetail}
element={<SettingsEmailingDomainDetail />}
/>
<Route
path={SettingsPath.PublicDomain}
element={<SettingPublicDomain />}
@@ -13,8 +13,7 @@ import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/i
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
import { isCloudflareIntegrationEnabledState } from '@/client-config/states/isCloudflareIntegrationEnabledState';
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { isEmailGroupEnabledState } from '@/client-config/states/isEmailGroupEnabledState';
import { isEmailingDomainsEnabledState } from '@/client-config/states/isEmailingDomainsEnabledState';
import { isEmailingDomainInDemoModeState } from '@/client-config/states/isEmailingDomainInDemoModeState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState';
@@ -101,10 +100,8 @@ export const useClientConfig = (): UseClientConfigResult => {
const setCalendarBookingPageId = useSetAtomState(calendarBookingPageIdState);
const setIsEmailGroupEnabled = useSetAtomState(isEmailGroupEnabledState);
const setIsEmailingDomainsEnabled = useSetAtomState(
isEmailingDomainsEnabledState,
const setIsEmailingDomainInDemoMode = useSetAtomState(
isEmailingDomainInDemoModeState,
);
const setIsImapSmtpCaldavEnabled = useSetAtomState(
@@ -199,8 +196,9 @@ export const useClientConfig = (): UseClientConfigResult => {
setCalendarBookingPageId(clientConfig?.calendarBookingPageId ?? null);
setIsImapSmtpCaldavEnabled(clientConfig?.isImapSmtpCaldavEnabled);
setIsEmailGroupEnabled(clientConfig?.isEmailGroupEnabled ?? false);
setIsEmailingDomainsEnabled(clientConfig?.isEmailingDomainsEnabled);
setIsEmailingDomainInDemoMode(
clientConfig?.isEmailingDomainInDemoMode ?? false,
);
setAllowRequestsToTwentyIcons(clientConfig?.allowRequestsToTwentyIcons);
setIsCloudflareIntegrationEnabled(
clientConfig?.isCloudflareIntegrationEnabled,
@@ -238,9 +236,8 @@ export const useClientConfig = (): UseClientConfigResult => {
setIsDeveloperDefaultSignInPrefilled,
setIsEmailVerificationRequired,
setIsImapSmtpCaldavEnabled,
setIsEmailGroupEnabled,
setIsMultiWorkspaceEnabled,
setIsEmailingDomainsEnabled,
setIsEmailingDomainInDemoMode,
setIsClickHouseConfigured,
setIsCloudflareIntegrationEnabled,
setIsDDLLocked,
@@ -1,6 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isEmailGroupEnabledState = createAtomState<boolean>({
key: 'isEmailGroupEnabled',
export const isEmailingDomainInDemoModeState = createAtomState<boolean>({
key: 'isEmailingDomainInDemoMode',
defaultValue: false,
});
@@ -1,6 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isEmailingDomainsEnabledState = createAtomState<boolean>({
key: 'isEmailingDomainsEnabled',
defaultValue: false,
});
@@ -31,8 +31,7 @@ export type ClientConfig = {
isMicrosoftMessagingEnabled: boolean;
isMultiWorkspaceEnabled: boolean;
isImapSmtpCaldavEnabled: boolean;
isEmailGroupEnabled: boolean;
isEmailingDomainsEnabled: boolean;
isEmailingDomainInDemoMode: boolean;
isCloudflareIntegrationEnabled: boolean;
isClickHouseConfigured: boolean;
isWorkspaceSchemaDDLLocked: boolean;
@@ -2,6 +2,7 @@ import { HeadlessFrontComponentRendererEngineCommand } from '@/command-menu-item
import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand';
import { HeadlessOpenSidePanelPageEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessOpenSidePanelPageEngineCommand';
import { NavigationEngineCommand } from '@/command-menu-item/engine-command/components/NavigationEngineCommand';
import { ComposeCampaignCommand } from '@/command-menu-item/engine-command/global/components/ComposeCampaignCommand';
import { ComposeEmailCommand } from '@/command-menu-item/engine-command/global/components/ComposeEmailCommand';
import { DeleteRecordsCommand } from '@/command-menu-item/engine-command/record/components/DeleteRecordsCommand';
import { DestroyRecordsCommand } from '@/command-menu-item/engine-command/record/components/DestroyRecordsCommand';
@@ -256,6 +257,7 @@ export const ENGINE_COMPONENT_KEY_COMPONENT_MAP: Record<
),
[EngineComponentKey.REPLY_TO_EMAIL_THREAD]: <ReplyToEmailThreadCommand />,
[EngineComponentKey.COMPOSE_EMAIL]: <ComposeEmailCommand />,
[EngineComponentKey.COMPOSE_CAMPAIGN]: <ComposeCampaignCommand />,
// Deprecated keys kept for backward compatibility until migration runs
[EngineComponentKey.DELETE_SINGLE_RECORD]: <DeleteRecordsCommand />,
@@ -0,0 +1,13 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useOpenCampaignComposerInSidePanel } from '@/side-panel/hooks/useOpenCampaignComposerInSidePanel';
export const ComposeCampaignCommand = () => {
const { openCampaignComposerInSidePanel } =
useOpenCampaignComposerInSidePanel();
const handleExecute = () => {
openCampaignComposerInSidePanel();
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} ready />;
};
@@ -58,6 +58,7 @@ export type FormSingleRecordPickerProps = {
defaultValue?: RecordId | Variable | null;
onChange: (value: RecordId | Variable | null) => void;
onClear?: () => void;
onCreate?: (searchInput?: string) => void | Promise<void>;
objectNameSingulars: string[];
disabled?: boolean;
testId?: string;
@@ -70,6 +71,7 @@ export const FormSingleRecordPicker = ({
objectNameSingulars,
onChange,
onClear,
onCreate,
disabled,
testId,
VariablePicker,
@@ -137,6 +139,11 @@ export const FormSingleRecordPicker = ({
closeDropdown(dropdownId);
};
const handleCreateRecord = async (searchInput?: string) => {
await onCreate?.(searchInput);
closeDropdown(dropdownId);
};
const handleVariableTagInsert = (variable: string) => {
onChange?.(variable);
};
@@ -225,6 +232,7 @@ export const FormSingleRecordPicker = ({
EmptyIcon={IconForbid}
emptyLabel={t`No record`}
onCancel={() => closeDropdown(dropdownId)}
onCreate={isDefined(onCreate) ? handleCreateRecord : undefined}
onMorphItemSelected={handleMorphItemSelected}
objectNameSingulars={objectNameSingulars}
recordPickerInstanceId={dropdownId}
@@ -89,6 +89,8 @@ export const SettingsAccountsMessageChannelDetails = ({
const supportsFolderImportPolicy =
messageChannel.type === MessageChannelType.EMAIL;
const isGroupMailbox = messageChannel.type === MessageChannelType.EMAIL_GROUP;
return (
<StyledDetailsContainer>
{supportsFolderImportPolicy && (
@@ -103,21 +105,23 @@ export const SettingsAccountsMessageChannelDetails = ({
/>
</Section>
)}
<Section>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconUsers}
title={t`Exclude group emails`}
description={t`Don't sync emails from team@ support@ noreply@...`}
checked={messageChannel.excludeGroupEmails}
onChange={() =>
handleIsGroupEmailExcludedToggle(
!messageChannel.excludeGroupEmails,
)
}
/>
</Card>
</Section>
{!isGroupMailbox && (
<Section>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconUsers}
title={t`Exclude group emails`}
description={t`Don't sync emails from team@ support@ noreply@...`}
checked={messageChannel.excludeGroupEmails}
onChange={() =>
handleIsGroupEmailExcludedToggle(
!messageChannel.excludeGroupEmails,
)
}
/>
</Card>
</Section>
)}
<Section>
<H2Title
title={t`Visibility`}
@@ -39,14 +39,14 @@ export const SettingsAccountsNewEmailGroupChannel = () => {
}
} catch {
enqueueErrorSnackBar({
message: t`Failed to create email handle. Email handles may not be configured on this server.`,
message: t`Failed to create email channel. Email channels may not be configured on this server.`,
});
}
}, [createEmailGroupChannel, handle, navigate, enqueueErrorSnackBar, t]);
return (
<SettingsPageLayout
title={t`New Email Handle`}
title={t`New Email Channel`}
links={[
{
children: t`Workspace`,
@@ -56,7 +56,7 @@ export const SettingsAccountsNewEmailGroupChannel = () => {
children: t`Email`,
href: getSettingsPath(SettingsPath.WorkspaceEmail),
},
{ children: t`New Email Handle` },
{ children: t`New Email Channel` },
]}
actionButton={
<SaveAndCancelButtons
@@ -9,6 +9,7 @@ import {
import { CREATE_EMAIL_GROUP_CHANNEL } from '@/settings/accounts/graphql/mutations/createEmailGroupChannel';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import { GET_MY_MESSAGE_CHANNELS } from '@/settings/accounts/graphql/queries/getMyMessageChannels';
import { GET_ALL_EMAILING_DOMAINS } from '@/settings/emailing-domains/graphql/queries/getAllEmailingDomains';
type CreateEmailGroupChannelResult = {
createEmailGroupChannel: {
@@ -39,6 +40,7 @@ export const useCreateEmailGroupChannel = () => {
refetchQueries: [
{ query: GET_MY_CONNECTED_ACCOUNTS },
{ query: GET_MY_MESSAGE_CHANNELS },
{ query: GET_ALL_EMAILING_DOMAINS },
],
});
@@ -3,6 +3,7 @@ import { useMutation } from '@apollo/client/react';
import { DELETE_EMAIL_GROUP_CHANNEL } from '@/settings/accounts/graphql/mutations/deleteEmailGroupChannel';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import { GET_MY_MESSAGE_CHANNELS } from '@/settings/accounts/graphql/queries/getMyMessageChannels';
import { GET_ALL_EMAILING_DOMAINS } from '@/settings/emailing-domains/graphql/queries/getAllEmailingDomains';
type DeleteEmailGroupChannelResult = {
deleteEmailGroupChannel: {
@@ -22,6 +23,7 @@ export const useDeleteEmailGroupChannel = () => {
refetchQueries: [
{ query: GET_MY_CONNECTED_ACCOUNTS },
{ query: GET_MY_MESSAGE_CHANNELS },
{ query: GET_ALL_EMAILING_DOMAINS },
],
});
@@ -0,0 +1,22 @@
import { getEmailChannelDomain } from '@/settings/accounts/utils/getEmailChannelDomain';
describe('getEmailChannelDomain', () => {
it('should return the lowercased domain after the last @', () => {
expect(getEmailChannelDomain('Jane@Example.COM')).toBe('example.com');
});
it('should use the domain after the last @ when several are present', () => {
expect(getEmailChannelDomain('weird@local@Sub.Example.com')).toBe(
'sub.example.com',
);
});
it('should return undefined when there is no @', () => {
expect(getEmailChannelDomain('not-an-email')).toBeUndefined();
});
it('should return undefined for null or undefined input', () => {
expect(getEmailChannelDomain(null)).toBeUndefined();
expect(getEmailChannelDomain(undefined)).toBeUndefined();
});
});
@@ -0,0 +1,15 @@
export const getEmailChannelDomain = (
handle: string | null | undefined,
): string | undefined => {
if (handle === null || handle === undefined) {
return undefined;
}
const lastAtIndex = handle.lastIndexOf('@');
if (lastAtIndex === -1) {
return undefined;
}
return handle.slice(lastAtIndex + 1).toLowerCase();
};
@@ -1,4 +1,4 @@
import { type ComponentType } from 'react';
import { type ComponentType, type ReactNode } from 'react';
import { styled } from '@linaria/react';
@@ -48,6 +48,7 @@ export type SettingsTableListSectionColumn<Item> = {
type SettingsTableListSectionProps<Item extends { id: string }> = {
title: string;
description: string;
headerAdornment?: ReactNode;
items: Item[];
columns: SettingsTableListSectionColumn<Item>[];
gridAutoColumns: string;
@@ -61,6 +62,7 @@ export const SettingsTableListSection = <
>({
title,
description,
headerAdornment,
items,
columns,
gridAutoColumns,
@@ -69,7 +71,11 @@ export const SettingsTableListSection = <
onFooterButtonClick,
}: SettingsTableListSectionProps<Item>) => (
<Section>
<H2Title title={title} description={description} />
<H2Title
title={title}
description={description}
adornment={headerAdornment}
/>
{items.length > 0 && (
<Table>
<TableRow gridAutoColumns={gridAutoColumns}>
@@ -1,30 +0,0 @@
import { styled } from '@linaria/react';
import { type GetEmailingDomainsQuery } from '~/generated-metadata/graphql';
import {
IconMail,
OverflowingTextWithTooltip,
} from 'twenty-ui-deprecated/display';
import { themeCssVariables } from 'twenty-ui-deprecated/theme-constants';
const StyledNameCell = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.primary};
display: flex;
font-weight: ${themeCssVariables.font.weight.medium};
gap: ${themeCssVariables.spacing[2]};
min-width: 0;
`;
type SettingsEmailingDomainNameCellProps = {
item: GetEmailingDomainsQuery['getEmailingDomains'][0];
};
export const SettingsEmailingDomainNameCell = ({
item,
}: SettingsEmailingDomainNameCellProps) => (
<StyledNameCell>
<IconMail size={16} />
<OverflowingTextWithTooltip text={item.domain} />
</StyledNameCell>
);
@@ -1,17 +0,0 @@
import { type GetEmailingDomainsQuery } from '~/generated-metadata/graphql';
import { getColorByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusColor';
import { getTextByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusText';
import { Status } from 'twenty-ui-deprecated/display';
type SettingsEmailingDomainStatusCellProps = {
item: GetEmailingDomainsQuery['getEmailingDomains'][0];
};
export const SettingsEmailingDomainStatusCell = ({
item,
}: SettingsEmailingDomainStatusCellProps) => (
<Status
color={getColorByEmailingDomainStatus(item.status)}
text={getTextByEmailingDomainStatus(item.status)}
/>
);
@@ -1,70 +0,0 @@
import { SettingsDnsRecordsTable } from '@/settings/components/SettingsDnsRecordsTable';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { t } from '@lingui/core/macro';
import { H2Title, IconRefresh } from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
import { Section } from 'twenty-ui-deprecated/layout';
import { useMutation } from '@apollo/client/react';
import {
type EmailingDomain,
VerifyEmailingDomainDocument,
} from '~/generated-metadata/graphql';
type SettingsEmailingDomainVerificationRecordsProps = {
domain: EmailingDomain;
};
export const SettingsEmailingDomainVerificationRecords = ({
domain,
}: SettingsEmailingDomainVerificationRecordsProps) => {
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [verifyEmailingDomainMutation, { loading: isVerifying }] = useMutation(
VerifyEmailingDomainDocument,
);
if (!domain.verificationRecords || domain.verificationRecords.length === 0) {
return null;
}
const handleVerifyEmailingDomain = async () => {
try {
await verifyEmailingDomainMutation({
variables: {
id: domain.id,
},
});
enqueueSuccessSnackBar({
message: t`Started verification process`,
});
} catch (error) {
enqueueErrorSnackBar({
...(CombinedGraphQLErrors.is(error) ? { apolloError: error } : {}),
});
}
};
return (
<Section>
<H2Title
title={t`DNS Records`}
description={t`Add these records to verify your domain.`}
adornment={
<Button
onClick={handleVerifyEmailingDomain}
isLoading={isVerifying}
variant="secondary"
Icon={IconRefresh}
size="small"
title={t`Check verification`}
disabled={isVerifying}
/>
}
/>
<SettingsDnsRecordsTable records={domain.verificationRecords} />
</Section>
);
};
@@ -0,0 +1,45 @@
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useMutation } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { VerifyEmailingDomainDocument } from '~/generated-metadata/graphql';
import { IconRefresh } from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
type SettingsEmailingDomainVerifyButtonProps = {
emailingDomainId: string;
};
export const SettingsEmailingDomainVerifyButton = ({
emailingDomainId,
}: SettingsEmailingDomainVerifyButtonProps) => {
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [verifyEmailingDomain, { loading }] = useMutation(
VerifyEmailingDomainDocument,
);
const handleVerify = async () => {
try {
await verifyEmailingDomain({ variables: { id: emailingDomainId } });
enqueueSuccessSnackBar({ message: t`Started verification process` });
} catch (error) {
enqueueErrorSnackBar({
...(CombinedGraphQLErrors.is(error) ? { apolloError: error } : {}),
});
}
};
return (
<Button
onClick={handleVerify}
isLoading={loading}
variant="secondary"
Icon={IconRefresh}
size="small"
title={t`Check verification`}
disabled={loading}
/>
);
};
@@ -1,20 +0,0 @@
import { gql } from '@apollo/client';
export const CREATE_EMAILING_DOMAIN = gql`
mutation CreateEmailingDomain($domain: String!) {
createEmailingDomain(domain: $domain) {
id
domain
status
verifiedAt
verificationRecords {
type
key
value
priority
}
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,86 @@
import { useLazyQuery } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
import { useUnsubscribeTopics } from '@/activities/emails/hooks/useUnsubscribeTopics';
import { SettingsTableListSection } from '@/settings/components/SettingsTableListSection';
import { GET_UNSUBSCRIBE_PAGE_PREVIEW_URL } from '@/settings/unsubscribe-topics/graphql/queries/getUnsubscribePagePreviewUrl';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type UnsubscribeTopicsQuery,
UnsubscribeTopicVisibility,
} from '~/generated-metadata/graphql';
import { IconExternalLink, Status } from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
type UnsubscribeTopic = UnsubscribeTopicsQuery['unsubscribeTopics'][number];
export const SettingsWorkspaceUnsubscribeTopicSection = () => {
const { t } = useLingui();
const navigateSettings = useNavigateSettings();
const { unsubscribeTopics } = useUnsubscribeTopics();
const [getPreviewUrl] = useLazyQuery<{
unsubscribePagePreviewUrl: string;
}>(GET_UNSUBSCRIBE_PAGE_PREVIEW_URL);
// Open the tab synchronously on click (so it isn't popup-blocked), then point
// it at the freshly minted preview URL once the query resolves.
const handlePreview = () => {
const previewWindow = window.open('', '_blank');
void getPreviewUrl()
.then(({ data }) => {
const url = data?.unsubscribePagePreviewUrl;
if (isDefined(previewWindow) && isDefined(url)) {
previewWindow.location.href = url;
} else {
previewWindow?.close();
}
})
.catch(() => previewWindow?.close());
};
return (
<SettingsTableListSection<UnsubscribeTopic>
title={t`Unsubscribe Topics`}
description={t`Email categories recipients can opt out of.`}
headerAdornment={
<Button
title={t`Preview`}
variant="secondary"
size="small"
Icon={IconExternalLink}
onClick={handlePreview}
/>
}
items={unsubscribeTopics}
columns={[
{
label: t`Name`,
Cell: ({ item }) => <>{item.name ?? t`Untitled topic`}</>,
},
{
label: t`Visibility`,
Cell: ({ item }) =>
item.visibility === UnsubscribeTopicVisibility.PUBLIC ? (
<Status color="blue" text={t`Public`} />
) : (
<Status color="gray" text={t`Private`} />
),
},
]}
gridAutoColumns="1fr 1fr"
onRowClick={(topic) =>
navigateSettings(SettingsPath.UnsubscribeTopicDetail, {
unsubscribeTopicId: topic.id,
})
}
footerButtonLabel={t`Add unsubscribe topic`}
onFooterButtonClick={() =>
navigateSettings(SettingsPath.NewUnsubscribeTopic)
}
/>
);
};
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const CREATE_UNSUBSCRIBE_TOPIC = gql`
mutation CreateUnsubscribeTopic($input: CreateUnsubscribeTopicInput!) {
createUnsubscribeTopic(input: $input) {
id
name
description
visibility
}
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DELETE_UNSUBSCRIBE_TOPIC = gql`
mutation DeleteUnsubscribeTopic($id: String!) {
deleteUnsubscribeTopic(id: $id)
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const UPDATE_UNSUBSCRIBE_TOPIC = gql`
mutation UpdateUnsubscribeTopic($input: UpdateUnsubscribeTopicInput!) {
updateUnsubscribeTopic(input: $input) {
id
name
description
visibility
}
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const GET_UNSUBSCRIBE_PAGE_PREVIEW_URL = gql`
query UnsubscribePagePreviewUrl {
unsubscribePagePreviewUrl
}
`;
@@ -0,0 +1,23 @@
import { useMutation } from '@apollo/client/react';
import { UNSUBSCRIBE_TOPICS } from '@/activities/emails/graphql/metadata-queries/unsubscribeTopics';
import { CREATE_UNSUBSCRIBE_TOPIC } from '@/settings/unsubscribe-topics/graphql/mutations/createUnsubscribeTopic';
import {
type CreateUnsubscribeTopicInput,
type CreateUnsubscribeTopicMutation,
type CreateUnsubscribeTopicMutationVariables,
} from '~/generated-metadata/graphql';
export const useCreateUnsubscribeTopic = () => {
const [mutate, { loading, error }] = useMutation<
CreateUnsubscribeTopicMutation,
CreateUnsubscribeTopicMutationVariables
>(CREATE_UNSUBSCRIBE_TOPIC, {
refetchQueries: [{ query: UNSUBSCRIBE_TOPICS }],
});
const createUnsubscribeTopic = (input: CreateUnsubscribeTopicInput) =>
mutate({ variables: { input } });
return { createUnsubscribeTopic, loading, error };
};
@@ -0,0 +1,21 @@
import { useMutation } from '@apollo/client/react';
import { UNSUBSCRIBE_TOPICS } from '@/activities/emails/graphql/metadata-queries/unsubscribeTopics';
import { DELETE_UNSUBSCRIBE_TOPIC } from '@/settings/unsubscribe-topics/graphql/mutations/deleteUnsubscribeTopic';
import {
type DeleteUnsubscribeTopicMutation,
type DeleteUnsubscribeTopicMutationVariables,
} from '~/generated-metadata/graphql';
export const useDeleteUnsubscribeTopic = () => {
const [mutate, { loading, error }] = useMutation<
DeleteUnsubscribeTopicMutation,
DeleteUnsubscribeTopicMutationVariables
>(DELETE_UNSUBSCRIBE_TOPIC, {
refetchQueries: [{ query: UNSUBSCRIBE_TOPICS }],
});
const deleteUnsubscribeTopic = (id: string) => mutate({ variables: { id } });
return { deleteUnsubscribeTopic, loading, error };
};
@@ -0,0 +1,23 @@
import { useMutation } from '@apollo/client/react';
import { UNSUBSCRIBE_TOPICS } from '@/activities/emails/graphql/metadata-queries/unsubscribeTopics';
import { UPDATE_UNSUBSCRIBE_TOPIC } from '@/settings/unsubscribe-topics/graphql/mutations/updateUnsubscribeTopic';
import {
type UpdateUnsubscribeTopicInput,
type UpdateUnsubscribeTopicMutation,
type UpdateUnsubscribeTopicMutationVariables,
} from '~/generated-metadata/graphql';
export const useUpdateUnsubscribeTopic = () => {
const [mutate, { loading, error }] = useMutation<
UpdateUnsubscribeTopicMutation,
UpdateUnsubscribeTopicMutationVariables
>(UPDATE_UNSUBSCRIBE_TOPIC, {
refetchQueries: [{ query: UNSUBSCRIBE_TOPICS }],
});
const updateUnsubscribeTopic = (input: UpdateUnsubscribeTopicInput) =>
mutate({ variables: { input } });
return { updateUnsubscribeTopic, loading, error };
};
@@ -0,0 +1,35 @@
import { useQuery } from '@apollo/client/react';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { getEmailChannelDomain } from '@/settings/accounts/utils/getEmailChannelDomain';
import { GetEmailingDomainsDocument } from '~/generated-metadata/graphql';
import { getColorByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusColor';
import { getTextByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusText';
import { isDefined } from 'twenty-shared/utils';
import { Status } from 'twenty-ui-deprecated/display';
type SettingsWorkspaceEmailChannelDomainStatusCellProps = {
item: MessageChannel;
};
export const SettingsWorkspaceEmailChannelDomainStatusCell = ({
item,
}: SettingsWorkspaceEmailChannelDomainStatusCellProps) => {
const { data } = useQuery(GetEmailingDomainsDocument);
const channelDomain = getEmailChannelDomain(item.connectedAccount?.handle);
const emailingDomain = data?.getEmailingDomains?.find(
(domain) => domain.domain.toLowerCase() === channelDomain,
);
if (!isDefined(emailingDomain)) {
return null;
}
return (
<Status
color={getColorByEmailingDomainStatus(emailingDomain.status)}
text={getTextByEmailingDomainStatus(emailingDomain.status)}
/>
);
};
@@ -3,6 +3,7 @@ import { useLingui } from '@lingui/react/macro';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
import { SettingsTableListSection } from '@/settings/components/SettingsTableListSection';
import { SettingsWorkspaceEmailChannelDomainStatusCell } from '@/settings/workspace/components/SettingsWorkspaceEmailChannelDomainStatusCell';
import { SettingsWorkspaceEmailGroupForwardingCell } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupForwardingCell';
import { SettingsWorkspaceEmailGroupSourceCell } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupSourceCell';
import { MessageChannelType, SettingsPath } from 'twenty-shared/types';
@@ -19,7 +20,7 @@ export const SettingsWorkspaceEmailGroupSection = () => {
return (
<SettingsTableListSection<MessageChannel>
title={t`Email Handles`}
title={t`Email Channels`}
description={t`Shared addresses your workspace uses to send and receive email.`}
items={emailGroupChannels}
columns={[
@@ -28,14 +29,19 @@ export const SettingsWorkspaceEmailGroupSection = () => {
label: t`Forwarding address`,
Cell: SettingsWorkspaceEmailGroupForwardingCell,
},
{
label: t`Domain`,
align: 'right',
Cell: SettingsWorkspaceEmailChannelDomainStatusCell,
},
]}
gridAutoColumns="1fr 1fr"
gridAutoColumns="1fr 1fr 1fr"
onRowClick={(channel) =>
navigateSettings(SettingsPath.EmailGroupChannelDetail, {
messageChannelId: channel.id,
})
}
footerButtonLabel={t`Add email handle`}
footerButtonLabel={t`Add email channel`}
onFooterButtonClick={() =>
navigateSettings(SettingsPath.NewEmailGroupChannel)
}
@@ -1,48 +0,0 @@
import { useLingui } from '@lingui/react/macro';
import { useQuery } from '@apollo/client/react';
import { SettingsTableListSection } from '@/settings/components/SettingsTableListSection';
import { SettingsEmailingDomainNameCell } from '@/settings/emailing-domains/components/SettingsEmailingDomainNameCell';
import { SettingsEmailingDomainStatusCell } from '@/settings/emailing-domains/components/SettingsEmailingDomainStatusCell';
import { SettingsPath } from 'twenty-shared/types';
import {
type GetEmailingDomainsQuery,
GetEmailingDomainsDocument,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
type EmailingDomain = GetEmailingDomainsQuery['getEmailingDomains'][0];
export const SettingsWorkspaceEmailingDomainsSection = () => {
const { t } = useLingui();
const navigateSettings = useNavigateSettings();
const { data } = useQuery(GetEmailingDomainsDocument);
const emailingDomains = data?.getEmailingDomains ?? [];
return (
<SettingsTableListSection<EmailingDomain>
title={t`Emailing Domains`}
description={t`Verify domains so the workspace can send outbound email through them.`}
items={emailingDomains}
columns={[
{ label: t`Domain`, Cell: SettingsEmailingDomainNameCell },
{
label: t`Status`,
align: 'right',
Cell: SettingsEmailingDomainStatusCell,
},
]}
gridAutoColumns="1fr 1fr"
onRowClick={(emailingDomain) =>
navigateSettings(SettingsPath.EmailingDomainDetail, {
domainId: emailingDomain.id,
})
}
footerButtonLabel={t`Add emailing domain`}
onFooterButtonClick={() =>
navigateSettings(SettingsPath.NewEmailingDomain)
}
/>
);
};
@@ -5,6 +5,7 @@ import { SidePanelNewSidebarItemPage } from '@/navigation-menu-item/edit/side-pa
import { SidePanelAiChatThreadsPage } from '@/side-panel/pages/ai-chat-threads/components/SidePanelAiChatThreadsPage';
import { SidePanelAskAiPage } from '@/side-panel/pages/ask-ai/components/SidePanelAskAiPage';
import { SidePanelCalendarEventPage } from '@/side-panel/pages/calendar-event/components/SidePanelCalendarEventPage';
import { SidePanelCampaignComposerPage } from '@/side-panel/pages/compose-campaign/components/SidePanelCampaignComposerPage';
import { SidePanelComposeEmailPage } from '@/side-panel/pages/compose-email/components/SidePanelComposeEmailPage';
import { SidePanelFrontComponentPage } from '@/side-panel/pages/front-component/components/SidePanelFrontComponentPage';
import { SidePanelDashboardChartSettings } from '@/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardChartSettings';
@@ -88,5 +89,6 @@ export const SIDE_PANEL_PAGES_CONFIG = new Map<SidePanelPages, React.ReactNode>(
[SidePanelPages.NavigationMenuAddItem, <SidePanelNewSidebarItemPage />],
[SidePanelPages.CommandMenuEdit, <SidePanelCommandMenuItemEditPage />],
[SidePanelPages.ComposeEmail, <SidePanelComposeEmailPage />],
[SidePanelPages.ComposeCampaign, <SidePanelCampaignComposerPage />],
],
);
@@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { IconSend } from 'twenty-ui-deprecated/display';
import { v4 } from 'uuid';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { t } from '@lingui/core/macro';
export const useOpenCampaignComposerInSidePanel = () => {
const { navigateSidePanelMenu } = useSidePanelMenu();
const openCampaignComposerInSidePanel = useCallback(() => {
navigateSidePanelMenu({
page: SidePanelPages.ComposeCampaign,
pageTitle: t`New Campaign`,
pageIcon: IconSend,
pageId: v4(),
});
}, [navigateSidePanelMenu]);
return { openCampaignComposerInSidePanel };
};
@@ -0,0 +1,69 @@
import { CampaignComposerFields } from '@/activities/emails/components/CampaignComposerFields';
import { useCampaignComposerState } from '@/activities/emails/hooks/useCampaignComposerState';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { IconSend } from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
import { getOsControlSymbol } from 'twenty-ui-deprecated/utilities';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledContent = styled.div`
display: flex;
flex: 1;
flex-direction: column;
overflow-y: auto;
`;
export const SidePanelCampaignComposerPage = () => {
const { goBackFromSidePanel } = useSidePanelHistory();
const campaignState = useCampaignComposerState({
onSent: goBackFromSidePanel,
});
useHotkeysOnFocusedElement({
keys: ['ctrl+Enter,meta+Enter'],
callback: campaignState.handleSend,
focusId: SIDE_PANEL_FOCUS_ID,
dependencies: [campaignState.handleSend],
});
return (
<StyledContainer>
<StyledContent>
<CampaignComposerFields campaignState={campaignState} />
</StyledContent>
<SidePanelFooter
actions={[
<Button
key="cancel"
size="small"
variant="secondary"
title={t`Cancel`}
onClick={goBackFromSidePanel}
/>,
<Button
key="send"
size="small"
variant="primary"
accent="blue"
title={t`Send campaign`}
Icon={IconSend}
hotkeys={[getOsControlSymbol(), '⏎']}
onClick={campaignState.handleSend}
disabled={!campaignState.canSend}
/>,
]}
/>
</StyledContainer>
);
};