[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:
+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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user