feat: Send email from UI — inline reply composer & SendEmail mutation (#19363)

## Summary

- **Inline email reply**: Replace external email client redirects
(Gmail/Outlook deeplinks) with an in-app email composer. Users can reply
to email threads directly from the email thread widget or via the
command menu.
- **SendEmail GraphQL mutation**: New backend mutation that reuses
`EmailComposerService` for body sanitization, recipient validation, and
SMTP dispatch via the existing outbound messaging infrastructure.
- **Side panel compose page**: Command menu "Reply" action now opens a
side-panel compose email page with pre-filled To, Subject, and
In-Reply-To fields.

### Backend
- `SendEmailResolver` with `SendEmailInput` / `SendEmailOutputDTO`
- `SendEmailModule` wired into `CoreEngineModule`
- Reuses `EmailComposerService` + `MessagingMessageOutboundService`

### Frontend
- `EmailComposer` / `EmailComposerFields` components
- `useSendEmail`, `useReplyContext`, `useEmailComposerState` hooks
- `useOpenComposeEmailInSidePanel` + `SidePanelComposeEmailPage`
- `EmailThreadWidget` inline Reply bar with toggle composer
- `ReplyToEmailThreadCommand` now opens side-panel instead of external
links

### Seeds
- Added `handle` field to message participant seeds for realistic email
addresses
- Seed `connectedAccount` and `messageChannel` in correct batch order

## Test plan

- [ ] Open an email thread on a person/company record → verify
"Reply..." bar appears below the last message
- [ ] Click "Reply..." → composer opens inline with pre-filled To and
Subject
- [ ] Type a message and click Send → email is sent via SMTP, composer
closes
- [ ] Use command menu Reply action → side panel opens with compose
email page
- [ ] Verify Send/Cancel buttons work correctly in side panel
- [ ] Test with Cc/Bcc toggle in composer fields
- [ ] Verify error handling: invalid recipients, missing connected
account


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-07 08:43:48 +02:00
committed by GitHub
parent aec43da1e2
commit 83d30f8b76
68 changed files with 2466 additions and 579 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ jobs:
timeout-minutes: 30
runs-on: ubuntu-latest
env:
NODE_OPTIONS: '--max-old-space-size=4096'
NODE_OPTIONS: '--max-old-space-size=6144'
TASK_CACHE_KEY: front-task-${{ matrix.task }}
strategy:
matrix:
@@ -2639,6 +2639,7 @@ enum EngineComponentKey {
TRIGGER_WORKFLOW_VERSION
FRONT_COMPONENT_RENDERER
REPLY_TO_EMAIL_THREAD
COMPOSE_EMAIL
DELETE_SINGLE_RECORD
DELETE_MULTIPLE_RECORDS
RESTORE_SINGLE_RECORD
@@ -2762,6 +2763,25 @@ type DuplicatedDashboard {
updatedAt: String!
}
type ConnectedAccountDTO {
id: UUID!
handle: String!
provider: String!
lastCredentialsRefreshedAt: DateTime
authFailedAt: DateTime
handleAliases: [String!]
scopes: [String!]
lastSignedInAt: DateTime
userWorkspaceId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
}
type SendEmailOutput {
success: Boolean!
error: String
}
type EventLogRecord {
event: String!
timestamp: DateTime!
@@ -2930,20 +2950,6 @@ enum CalendarChannelContactAutoCreationPolicy {
NONE
}
type ConnectedAccountDTO {
id: UUID!
handle: String!
provider: String!
lastCredentialsRefreshedAt: DateTime
authFailedAt: DateTime
handleAliases: [String!]
scopes: [String!]
lastSignedInAt: DateTime
userWorkspaceId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
}
type MessageChannel {
id: UUID!
visibility: MessageChannelVisibility!
@@ -3552,6 +3558,7 @@ type Mutation {
deleteSSOIdentityProvider(input: DeleteSsoInput!): DeleteSso!
editSSOIdentityProvider(input: EditSsoInput!): EditSso!
impersonate(userId: UUID!, workspaceId: UUID!): Impersonate!
sendEmail(input: SendEmailInput!): SendEmailOutput!
startChannelSync(connectedAccountId: UUID!): ChannelSyncSuccess!
saveImapSmtpCaldavAccount(accountOwnerId: UUID!, handle: String!, connectionParameters: EmailAccountConnectionParameters!, id: UUID): ImapSmtpCaldavConnectionSuccess!
updateLabPublicFeatureFlag(input: UpdateLabPublicFeatureFlagInput!): FeatureFlag!
@@ -4522,6 +4529,16 @@ input EditSsoInput {
status: SSOIdentityProviderStatus!
}
input SendEmailInput {
connectedAccountId: String!
to: String!
cc: String
bcc: String
subject: String!
body: String!
inReplyTo: String
}
input EmailAccountConnectionParameters {
IMAP: ConnectionParameters
SMTP: ConnectionParameters
@@ -2295,7 +2295,7 @@ export interface CommandMenuItem {
__typename: 'CommandMenuItem'
}
export type EngineComponentKey = 'NAVIGATE_TO_NEXT_RECORD' | 'NAVIGATE_TO_PREVIOUS_RECORD' | 'CREATE_NEW_RECORD' | 'DELETE_RECORDS' | 'RESTORE_RECORDS' | 'DESTROY_RECORDS' | 'ADD_TO_FAVORITES' | 'REMOVE_FROM_FAVORITES' | 'EXPORT_NOTE_TO_PDF' | 'EXPORT_RECORDS' | 'UPDATE_MULTIPLE_RECORDS' | 'MERGE_MULTIPLE_RECORDS' | 'IMPORT_RECORDS' | 'EXPORT_VIEW' | 'SEE_DELETED_RECORDS' | 'CREATE_NEW_VIEW' | 'HIDE_DELETED_RECORDS' | 'GO_TO_PEOPLE' | 'GO_TO_COMPANIES' | 'GO_TO_DASHBOARDS' | 'GO_TO_OPPORTUNITIES' | 'GO_TO_SETTINGS' | 'GO_TO_TASKS' | 'GO_TO_NOTES' | 'EDIT_RECORD_PAGE_LAYOUT' | 'EDIT_DASHBOARD_LAYOUT' | 'SAVE_DASHBOARD_LAYOUT' | 'CANCEL_DASHBOARD_LAYOUT' | 'DUPLICATE_DASHBOARD' | 'GO_TO_WORKFLOWS' | 'ACTIVATE_WORKFLOW' | 'DEACTIVATE_WORKFLOW' | 'DISCARD_DRAFT_WORKFLOW' | 'TEST_WORKFLOW' | 'SEE_ACTIVE_VERSION_WORKFLOW' | 'SEE_RUNS_WORKFLOW' | 'SEE_VERSIONS_WORKFLOW' | 'ADD_NODE_WORKFLOW' | 'TIDY_UP_WORKFLOW' | 'DUPLICATE_WORKFLOW' | 'GO_TO_RUNS' | 'SEE_VERSION_WORKFLOW_RUN' | 'SEE_WORKFLOW_WORKFLOW_RUN' | 'STOP_WORKFLOW_RUN' | 'SEE_RUNS_WORKFLOW_VERSION' | 'SEE_WORKFLOW_WORKFLOW_VERSION' | 'USE_AS_DRAFT_WORKFLOW_VERSION' | 'SEE_VERSIONS_WORKFLOW_VERSION' | 'SEARCH_RECORDS' | 'SEARCH_RECORDS_FALLBACK' | 'ASK_AI' | 'VIEW_PREVIOUS_AI_CHATS' | 'TRIGGER_WORKFLOW_VERSION' | 'FRONT_COMPONENT_RENDERER' | 'REPLY_TO_EMAIL_THREAD' | 'DELETE_SINGLE_RECORD' | 'DELETE_MULTIPLE_RECORDS' | 'RESTORE_SINGLE_RECORD' | 'RESTORE_MULTIPLE_RECORDS' | 'DESTROY_SINGLE_RECORD' | 'DESTROY_MULTIPLE_RECORDS' | 'EXPORT_FROM_RECORD_INDEX' | 'EXPORT_FROM_RECORD_SHOW' | 'EXPORT_MULTIPLE_RECORDS'
export type EngineComponentKey = 'NAVIGATE_TO_NEXT_RECORD' | 'NAVIGATE_TO_PREVIOUS_RECORD' | 'CREATE_NEW_RECORD' | 'DELETE_RECORDS' | 'RESTORE_RECORDS' | 'DESTROY_RECORDS' | 'ADD_TO_FAVORITES' | 'REMOVE_FROM_FAVORITES' | 'EXPORT_NOTE_TO_PDF' | 'EXPORT_RECORDS' | 'UPDATE_MULTIPLE_RECORDS' | 'MERGE_MULTIPLE_RECORDS' | 'IMPORT_RECORDS' | 'EXPORT_VIEW' | 'SEE_DELETED_RECORDS' | 'CREATE_NEW_VIEW' | 'HIDE_DELETED_RECORDS' | 'GO_TO_PEOPLE' | 'GO_TO_COMPANIES' | 'GO_TO_DASHBOARDS' | 'GO_TO_OPPORTUNITIES' | 'GO_TO_SETTINGS' | 'GO_TO_TASKS' | 'GO_TO_NOTES' | 'EDIT_RECORD_PAGE_LAYOUT' | 'EDIT_DASHBOARD_LAYOUT' | 'SAVE_DASHBOARD_LAYOUT' | 'CANCEL_DASHBOARD_LAYOUT' | 'DUPLICATE_DASHBOARD' | 'GO_TO_WORKFLOWS' | 'ACTIVATE_WORKFLOW' | 'DEACTIVATE_WORKFLOW' | 'DISCARD_DRAFT_WORKFLOW' | 'TEST_WORKFLOW' | 'SEE_ACTIVE_VERSION_WORKFLOW' | 'SEE_RUNS_WORKFLOW' | 'SEE_VERSIONS_WORKFLOW' | 'ADD_NODE_WORKFLOW' | 'TIDY_UP_WORKFLOW' | 'DUPLICATE_WORKFLOW' | 'GO_TO_RUNS' | 'SEE_VERSION_WORKFLOW_RUN' | 'SEE_WORKFLOW_WORKFLOW_RUN' | 'STOP_WORKFLOW_RUN' | 'SEE_RUNS_WORKFLOW_VERSION' | 'SEE_WORKFLOW_WORKFLOW_VERSION' | 'USE_AS_DRAFT_WORKFLOW_VERSION' | 'SEE_VERSIONS_WORKFLOW_VERSION' | 'SEARCH_RECORDS' | 'SEARCH_RECORDS_FALLBACK' | 'ASK_AI' | 'VIEW_PREVIOUS_AI_CHATS' | 'TRIGGER_WORKFLOW_VERSION' | 'FRONT_COMPONENT_RENDERER' | 'REPLY_TO_EMAIL_THREAD' | 'COMPOSE_EMAIL' | 'DELETE_SINGLE_RECORD' | 'DELETE_MULTIPLE_RECORDS' | 'RESTORE_SINGLE_RECORD' | 'RESTORE_MULTIPLE_RECORDS' | 'DESTROY_SINGLE_RECORD' | 'DESTROY_MULTIPLE_RECORDS' | 'EXPORT_FROM_RECORD_INDEX' | 'EXPORT_FROM_RECORD_SHOW' | 'EXPORT_MULTIPLE_RECORDS'
export type CommandMenuItemAvailabilityType = 'GLOBAL' | 'RECORD_SELECTION' | 'FALLBACK'
@@ -2416,6 +2416,27 @@ export interface DuplicatedDashboard {
__typename: 'DuplicatedDashboard'
}
export interface ConnectedAccountDTO {
id: Scalars['UUID']
handle: Scalars['String']
provider: Scalars['String']
lastCredentialsRefreshedAt?: Scalars['DateTime']
authFailedAt?: Scalars['DateTime']
handleAliases?: Scalars['String'][]
scopes?: Scalars['String'][]
lastSignedInAt?: Scalars['DateTime']
userWorkspaceId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'ConnectedAccountDTO'
}
export interface SendEmailOutput {
success: Scalars['Boolean']
error?: Scalars['String']
__typename: 'SendEmailOutput'
}
export interface EventLogRecord {
event: Scalars['String']
timestamp: Scalars['DateTime']
@@ -2575,21 +2596,6 @@ export type CalendarChannelVisibility = 'METADATA' | 'SHARE_EVERYTHING'
export type CalendarChannelContactAutoCreationPolicy = 'AS_PARTICIPANT_AND_ORGANIZER' | 'AS_PARTICIPANT' | 'AS_ORGANIZER' | 'NONE'
export interface ConnectedAccountDTO {
id: Scalars['UUID']
handle: Scalars['String']
provider: Scalars['String']
lastCredentialsRefreshedAt?: Scalars['DateTime']
authFailedAt?: Scalars['DateTime']
handleAliases?: Scalars['String'][]
scopes?: Scalars['String'][]
lastSignedInAt?: Scalars['DateTime']
userWorkspaceId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'ConnectedAccountDTO'
}
export interface MessageChannel {
id: Scalars['UUID']
visibility: MessageChannelVisibility
@@ -2999,6 +3005,7 @@ export interface Mutation {
deleteSSOIdentityProvider: DeleteSso
editSSOIdentityProvider: EditSso
impersonate: Impersonate
sendEmail: SendEmailOutput
startChannelSync: ChannelSyncSuccess
saveImapSmtpCaldavAccount: ImapSmtpCaldavConnectionSuccess
updateLabPublicFeatureFlag: FeatureFlag
@@ -5615,6 +5622,29 @@ export interface DuplicatedDashboardGenqlSelection{
__scalar?: boolean | number
}
export interface ConnectedAccountDTOGenqlSelection{
id?: boolean | number
handle?: boolean | number
provider?: boolean | number
lastCredentialsRefreshedAt?: boolean | number
authFailedAt?: boolean | number
handleAliases?: boolean | number
scopes?: boolean | number
lastSignedInAt?: boolean | number
userWorkspaceId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface SendEmailOutputGenqlSelection{
success?: boolean | number
error?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EventLogRecordGenqlSelection{
event?: boolean | number
timestamp?: boolean | number
@@ -5782,22 +5812,6 @@ export interface CalendarChannelGenqlSelection{
__scalar?: boolean | number
}
export interface ConnectedAccountDTOGenqlSelection{
id?: boolean | number
handle?: boolean | number
provider?: boolean | number
lastCredentialsRefreshedAt?: boolean | number
authFailedAt?: boolean | number
handleAliases?: boolean | number
scopes?: boolean | number
lastSignedInAt?: boolean | number
userWorkspaceId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MessageChannelGenqlSelection{
id?: boolean | number
visibility?: boolean | number
@@ -6239,6 +6253,7 @@ export interface MutationGenqlSelection{
deleteSSOIdentityProvider?: (DeleteSsoGenqlSelection & { __args: {input: DeleteSsoInput} })
editSSOIdentityProvider?: (EditSsoGenqlSelection & { __args: {input: EditSsoInput} })
impersonate?: (ImpersonateGenqlSelection & { __args: {userId: Scalars['UUID'], workspaceId: Scalars['UUID']} })
sendEmail?: (SendEmailOutputGenqlSelection & { __args: {input: SendEmailInput} })
startChannelSync?: (ChannelSyncSuccessGenqlSelection & { __args: {connectedAccountId: Scalars['UUID']} })
saveImapSmtpCaldavAccount?: (ImapSmtpCaldavConnectionSuccessGenqlSelection & { __args: {accountOwnerId: Scalars['UUID'], handle: Scalars['String'], connectionParameters: EmailAccountConnectionParameters, id?: (Scalars['UUID'] | null)} })
updateLabPublicFeatureFlag?: (FeatureFlagGenqlSelection & { __args: {input: UpdateLabPublicFeatureFlagInput} })
@@ -6589,6 +6604,8 @@ export interface DeleteSsoInput {identityProviderId: Scalars['UUID']}
export interface EditSsoInput {id: Scalars['UUID'],status: SSOIdentityProviderStatus}
export interface SendEmailInput {connectedAccountId: Scalars['String'],to: Scalars['String'],cc?: (Scalars['String'] | null),bcc?: (Scalars['String'] | null),subject: Scalars['String'],body: Scalars['String'],inReplyTo?: (Scalars['String'] | null)}
export interface EmailAccountConnectionParameters {IMAP?: (ConnectionParameters | null),SMTP?: (ConnectionParameters | null),CALDAV?: (ConnectionParameters | null)}
export interface ConnectionParameters {host: Scalars['String'],port: Scalars['Float'],username?: (Scalars['String'] | null),password: Scalars['String'],secure?: (Scalars['Boolean'] | null)}
@@ -8496,6 +8513,22 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const ConnectedAccountDTO_possibleTypes: string[] = ['ConnectedAccountDTO']
export const isConnectedAccountDTO = (obj?: { __typename?: any } | null): obj is ConnectedAccountDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isConnectedAccountDTO"')
return ConnectedAccountDTO_possibleTypes.includes(obj.__typename)
}
const SendEmailOutput_possibleTypes: string[] = ['SendEmailOutput']
export const isSendEmailOutput = (obj?: { __typename?: any } | null): obj is SendEmailOutput => {
if (!obj?.__typename) throw new Error('__typename is missing in "isSendEmailOutput"')
return SendEmailOutput_possibleTypes.includes(obj.__typename)
}
const EventLogRecord_possibleTypes: string[] = ['EventLogRecord']
export const isEventLogRecord = (obj?: { __typename?: any } | null): obj is EventLogRecord => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEventLogRecord"')
@@ -8624,14 +8657,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const ConnectedAccountDTO_possibleTypes: string[] = ['ConnectedAccountDTO']
export const isConnectedAccountDTO = (obj?: { __typename?: any } | null): obj is ConnectedAccountDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isConnectedAccountDTO"')
return ConnectedAccountDTO_possibleTypes.includes(obj.__typename)
}
const MessageChannel_possibleTypes: string[] = ['MessageChannel']
export const isMessageChannel = (obj?: { __typename?: any } | null): obj is MessageChannel => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMessageChannel"')
@@ -9274,6 +9299,7 @@ export const enumEngineComponentKey = {
TRIGGER_WORKFLOW_VERSION: 'TRIGGER_WORKFLOW_VERSION' as const,
FRONT_COMPONENT_RENDERER: 'FRONT_COMPONENT_RENDERER' as const,
REPLY_TO_EMAIL_THREAD: 'REPLY_TO_EMAIL_THREAD' as const,
COMPOSE_EMAIL: 'COMPOSE_EMAIL' as const,
DELETE_SINGLE_RECORD: 'DELETE_SINGLE_RECORD' as const,
DELETE_MULTIPLE_RECORDS: 'DELETE_MULTIPLE_RECORDS' as const,
RESTORE_SINGLE_RECORD: 'RESTORE_SINGLE_RECORD' as const,
File diff suppressed because it is too large Load Diff
@@ -29,6 +29,7 @@ module.exports = {
'./src/modules/front-components/graphql/**/*.{ts,tsx}',
'./src/modules/page-layout/widgets/**/graphql/**/*.{ts,tsx}',
'./src/modules/activities/emails/graphql/mutations/**/*.{ts,tsx}',
'./src/modules/dashboards/graphql/**/*.{ts,tsx}',
'./src/modules/page-layout/graphql/**/*.{ts,tsx}',
+2 -1
View File
@@ -6,7 +6,8 @@ module.exports = {
'/graphql',
documents: [
'./src/modules/workflow/**/graphql/**/*.{ts,tsx}',
'./src/modules/activities/emails/graphql/**/*.{ts,tsx}',
'./src/modules/activities/emails/graphql/queries/**/*.{ts,tsx}',
'./src/modules/activities/emails/graphql/operation-signatures/**/*.{ts,tsx}',
'./src/modules/activities/calendar/graphql/**/*.{ts,tsx}',
'./src/modules/search/graphql/**/*.{ts,tsx}',
'./src/modules/command-menu/graphql/**/*.{ts,tsx}',
File diff suppressed because one or more lines are too long
@@ -0,0 +1,91 @@
import { useFirstConnectedAccount } from '@/activities/emails/hooks/useFirstConnectedAccount';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { CoreObjectNameSingular, SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const ComposeEmailButton = () => {
const targetRecord = useTargetRecord();
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
const navigateSettings = useNavigateSettings();
const { connectedAccountId, loading: accountLoading } =
useFirstConnectedAccount();
const isPerson =
targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Person;
const isCompany =
targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Company;
const isOpportunity =
targetRecord.targetObjectNameSingular ===
CoreObjectNameSingular.Opportunity;
const { record: personRecord } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.Person,
objectRecordId: targetRecord.id,
recordGqlFields: { id: true, emails: { primaryEmail: true } },
skip: !isPerson,
});
const { records: companyPeople } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.Person,
filter: { companyId: { eq: targetRecord.id } },
recordGqlFields: { id: true, emails: { primaryEmail: true } },
limit: 1,
skip: !isCompany,
});
const { record: opportunityRecord } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.Opportunity,
objectRecordId: targetRecord.id,
recordGqlFields: {
id: true,
pointOfContact: { id: true, emails: { primaryEmail: true } },
company: { id: true },
},
skip: !isOpportunity,
});
const resolveDefaultTo = (): string => {
if (isPerson) {
return personRecord?.emails?.primaryEmail ?? '';
}
if (isCompany) {
return companyPeople[0]?.emails?.primaryEmail ?? '';
}
if (isOpportunity) {
return opportunityRecord?.pointOfContact?.emails?.primaryEmail ?? '';
}
return '';
};
const handleClick = () => {
if (!isDefined(connectedAccountId)) {
navigateSettings(SettingsPath.NewAccount);
return;
}
openComposeEmailInSidePanel({
connectedAccountId,
defaultTo: resolveDefaultTo(),
});
};
if (accountLoading) {
return null;
}
return (
<LightIconButton
Icon={IconPlus}
accent="tertiary"
size="small"
onClick={handleClick}
/>
);
};
@@ -0,0 +1,81 @@
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/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
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>
<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>
);
};
@@ -0,0 +1,117 @@
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { type EmailComposerState } from '@/activities/emails/types/EmailComposerState';
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
import { FormMultiTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiTextFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import { Select } from '@/ui/input/components/Select';
import { t } from '@lingui/core/macro';
import { type SelectOption } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledFieldsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[2]};
`;
const StyledToRow = styled.div`
display: flex;
flex-direction: column;
position: relative;
`;
const StyledCcBccToggle = styled.button`
all: unset;
color: ${themeCssVariables.font.color.tertiary};
cursor: pointer;
font-size: ${themeCssVariables.font.size.xs};
position: absolute;
right: 0;
top: 0;
&:hover {
color: ${themeCssVariables.font.color.secondary};
}
`;
type EmailComposerFieldsProps = {
composerState: EmailComposerState;
};
export const EmailComposerFields = ({
composerState,
}: EmailComposerFieldsProps) => {
const { data: accountsData } = useQuery<{
myConnectedAccounts: { id: string; handle: string }[];
}>(GET_MY_CONNECTED_ACCOUNTS);
const accountOptions: SelectOption<string>[] =
accountsData?.myConnectedAccounts?.map((account) => ({
label: account.handle,
value: account.id,
})) ?? [];
const hasMultipleAccounts = accountOptions.length > 1;
return (
<StyledFieldsContainer>
{hasMultipleAccounts && (
<Select
dropdownId="email-composer-from-account"
label={t`From`}
fullWidth
value={composerState.connectedAccountId}
options={accountOptions}
onChange={(value) => composerState.setConnectedAccountId(value)}
/>
)}
<StyledToRow>
<FormMultiTextFieldInput
label={t`To`}
defaultValue={composerState.defaultTo}
onChange={composerState.setTo}
placeholder={t`Recipients`}
/>
{!composerState.showCcBcc && (
<StyledCcBccToggle onClick={() => composerState.setShowCcBcc(true)}>
{t`Cc/Bcc`}
</StyledCcBccToggle>
)}
</StyledToRow>
{composerState.showCcBcc && (
<>
<FormMultiTextFieldInput
label={t`Cc`}
defaultValue=""
onChange={composerState.setCc}
placeholder={t`Cc`}
/>
<FormMultiTextFieldInput
label={t`Bcc`}
defaultValue=""
onChange={composerState.setBcc}
placeholder={t`Bcc`}
/>
</>
)}
<FormTextFieldInput
label={t`Subject`}
defaultValue={composerState.defaultSubject}
onChange={composerState.setSubject}
placeholder={t`Subject`}
/>
<FormAdvancedTextFieldInput
defaultValue=""
onChange={composerState.setBody}
placeholder={t`Type something or press "/" to see commands`}
minHeight={120}
maxWidth={600}
contentType="json"
/>
</StyledFieldsContainer>
);
};
@@ -8,13 +8,16 @@ import { EmailThreadMessageSender } from '@/activities/emails/components/EmailTh
import { EmailThreadNotShared } from '@/activities/emails/components/EmailThreadNotShared';
import { type EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { MessageParticipantRole } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { MessageChannelVisibility } from '~/generated/graphql';
const StyledThreadMessage = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
const StyledThreadMessage = styled.div<{ hideBottomBorder?: boolean }>`
border-bottom: ${({ hideBottomBorder }) =>
hideBottomBorder
? 'none'
: `1px solid ${themeCssVariables.border.color.light}`};
display: flex;
flex-direction: column;
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[0]};
@@ -26,11 +29,11 @@ const StyledThreadMessageHeader = styled.div`
flex-direction: column;
justify-content: space-between;
margin-bottom: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[6]};
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
`;
const StyledThreadMessageBody = styled.div`
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[6]};
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
`;
type EmailThreadMessageProps = {
@@ -39,6 +42,7 @@ type EmailThreadMessageProps = {
sender: EmailThreadMessageParticipant;
participants: EmailThreadMessageParticipant[];
isExpanded?: boolean;
hideBottomBorder?: boolean;
};
export const EmailThreadMessage = ({
@@ -47,6 +51,7 @@ export const EmailThreadMessage = ({
sender,
participants,
isExpanded = false,
hideBottomBorder = false,
}: EmailThreadMessageProps) => {
const [isOpen, setIsOpen] = useState(isExpanded);
@@ -63,6 +68,7 @@ export const EmailThreadMessage = ({
return (
<StyledThreadMessage
hideBottomBorder={hideBottomBorder}
onClick={() => !isOpen && setIsOpen(true)}
style={{ cursor: isOpen || isRestricted ? 'auto' : 'pointer' }}
>
@@ -3,6 +3,7 @@ import { styled } from '@linaria/react';
import { ActivityList } from '@/activities/components/ActivityList';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { ComposeEmailButton } from '@/activities/emails/components/ComposeEmailButton';
import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview';
import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/Messaging';
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId';
@@ -38,6 +39,12 @@ const StyledContainer = styled.div`
${themeCssVariables.spacing[2]};
`;
const StyledHeaderRow = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
`;
const StyledH1TitleWrapper = styled.div`
> h2 {
display: flex;
@@ -49,6 +56,11 @@ const StyledEmailCount = styled.span`
color: ${themeCssVariables.font.color.light};
`;
const StyledComposeButtonRow = styled.div`
display: flex;
justify-content: flex-end;
`;
export const EmailsCard = () => {
const targetRecord = useTargetRecord();
@@ -89,37 +101,47 @@ export const EmailsCard = () => {
if (!firstQueryLoading && !timelineThreads?.length) {
return (
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="emptyInbox" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
<Trans>Empty Inbox</Trans>
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
<Trans>No email exchange has occurred with this record yet.</Trans>
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
</AnimatedPlaceholderEmptyContainer>
<StyledContainer>
<StyledComposeButtonRow>
<ComposeEmailButton />
</StyledComposeButtonRow>
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="emptyInbox" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
<Trans>Empty Inbox</Trans>
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
<Trans>
No email exchange has occurred with this record yet.
</Trans>
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
</AnimatedPlaceholderEmptyContainer>
</StyledContainer>
);
}
return (
<StyledContainer>
<Section>
<StyledH1TitleWrapper>
<H1Title
title={
<>
<Trans>Inbox</Trans>{' '}
<StyledEmailCount>{totalNumberOfThreads}</StyledEmailCount>
</>
}
fontColor={H1TitleFontColor.Primary}
/>
</StyledH1TitleWrapper>
<StyledHeaderRow>
<StyledH1TitleWrapper>
<H1Title
title={
<>
<Trans>Inbox</Trans>{' '}
<StyledEmailCount>{totalNumberOfThreads}</StyledEmailCount>
</>
}
fontColor={H1TitleFontColor.Primary}
/>
</StyledH1TitleWrapper>
<ComposeEmailButton />
</StyledHeaderRow>
{!firstQueryLoading && (
<ActivityList>
{timelineThreads?.map((thread: TimelineThread) => (
@@ -0,0 +1,10 @@
import gql from 'graphql-tag';
export const SEND_EMAIL = gql`
mutation SendEmail($input: SendEmailInput!) {
sendEmail(input: $input) {
success
error
}
}
`;
@@ -0,0 +1,90 @@
import { useCallback, useState } from 'react';
import { useSendEmail } from '@/activities/emails/hooks/useSendEmail';
type UseEmailComposerStateArgs = {
connectedAccountId: string;
defaultTo?: string;
defaultSubject?: string;
defaultInReplyTo?: string;
onSent?: () => void;
};
export const useEmailComposerState = ({
connectedAccountId: initialConnectedAccountId,
defaultTo = '',
defaultSubject = '',
defaultInReplyTo,
onSent,
}: UseEmailComposerStateArgs) => {
const [connectedAccountId, setConnectedAccountId] = useState(
initialConnectedAccountId,
);
const [to, setTo] = useState(defaultTo);
const [cc, setCc] = useState('');
const [bcc, setBcc] = useState('');
const [subject, setSubject] = useState(defaultSubject);
const [body, setBody] = useState('');
const [showCcBcc, setShowCcBcc] = useState(false);
const { sendEmail, loading } = useSendEmail();
const canSend =
to.trim().length > 0 && connectedAccountId.length > 0 && !loading;
const handleSend = useCallback(async () => {
if (!to.trim() || !connectedAccountId) {
return;
}
const trimmedTo = to.trim();
const trimmedCc = cc.trim();
const trimmedBcc = bcc.trim();
const success = await sendEmail({
connectedAccountId,
to: trimmedTo,
cc: trimmedCc || undefined,
bcc: trimmedBcc || undefined,
subject,
body,
inReplyTo: defaultInReplyTo,
});
if (success) {
onSent?.();
}
}, [
connectedAccountId,
to,
cc,
bcc,
subject,
body,
defaultInReplyTo,
sendEmail,
onSent,
]);
return {
connectedAccountId,
setConnectedAccountId,
to,
setTo,
cc,
setCc,
bcc,
setBcc,
subject,
setSubject,
body,
setBody,
showCcBcc,
setShowCcBcc,
handleSend,
loading,
canSend,
defaultTo,
defaultSubject,
};
};
@@ -1,6 +1,6 @@
import { useQuery } from '@apollo/client/react';
import { useCallback, useEffect, useState } from 'react';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { fetchAllThreadMessagesOperationSignatureFactory } from '@/activities/emails/graphql/operation-signatures/factories/fetchAllThreadMessagesOperationSignatureFactory';
import { type EmailThread } from '@/activities/emails/types/EmailThread';
import { type EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage';
@@ -10,7 +10,9 @@ import { type MessageChannelMessageAssociation } from '@/activities/emails/types
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import {
type ConnectedAccountProvider,
CoreObjectNameSingular,
MessageParticipantRole,
} from 'twenty-shared/types';
@@ -19,9 +21,6 @@ import { isDefined } from 'twenty-shared/utils';
export const useEmailThread = (threadId: string | null) => {
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const [lastMessageId, setLastMessageId] = useState<string | null>(null);
const [lastMessageChannelId, setLastMessageChannelId] = useState<
string | null
>(null);
const [isMessagesFetchComplete, setIsMessagesFetchComplete] = useState(false);
const { record: thread } = useFindOneRecord<EmailThread>({
@@ -66,6 +65,14 @@ export const useEmailThread = (threadId: string | null) => {
}
}, [fetchMoreRecords, messagesLoading, hasNextPage]);
// When all messages fit in the first page, fetchMoreMessages is never called,
// so we need to mark fetch as complete here to unblock downstream queries
useEffect(() => {
if (!messagesLoading && !hasNextPage) {
setIsMessagesFetchComplete(true);
}
}, [messagesLoading, hasNextPage]);
useEffect(() => {
if (messages.length > 0 && isMessagesFetchComplete) {
const lastMessage = messages[messages.length - 1];
@@ -116,34 +123,6 @@ export const useEmailThread = (threadId: string | null) => {
skip: !lastMessageId || !isMessagesFetchComplete,
});
useEffect(() => {
if (messageChannelMessageAssociationData.length > 0) {
setLastMessageChannelId(
messageChannelMessageAssociationData[0].messageChannelId,
);
}
}, [messageChannelMessageAssociationData]);
const { records: messageChannelData, loading: messageChannelLoading } =
useFindManyRecords<MessageChannel>({
filter: {
id: {
eq: lastMessageChannelId ?? '',
},
},
objectNameSingular: CoreObjectNameSingular.MessageChannel,
recordGqlFields: {
id: true,
handle: true,
connectedAccount: {
id: true,
provider: true,
connectionParameters: true,
},
},
skip: !lastMessageChannelId,
});
const messageThreadExternalId =
messageChannelMessageAssociationData.length > 0
? messageChannelMessageAssociationData[0].messageThreadExternalId
@@ -152,8 +131,6 @@ export const useEmailThread = (threadId: string | null) => {
messageChannelMessageAssociationData.length > 0
? messageChannelMessageAssociationData[0].messageExternalId
: null;
const connectedAccountHandle =
messageChannelData.length > 0 ? messageChannelData[0].handle : null;
const messagesWithSender: EmailThreadMessageWithSender[] = messages
.map((message) => {
@@ -172,21 +149,32 @@ export const useEmailThread = (threadId: string | null) => {
})
.filter(isDefined);
const connectedAccount =
messageChannelData.length > 0
? messageChannelData[0]?.connectedAccount
: null;
const connectedAccountProvider = connectedAccount?.provider ?? null;
const connectedAccountConnectionParameters =
connectedAccount?.connectionParameters;
// connectedAccount and messageChannel live in the core schema,
// so we resolve the account from the core myConnectedAccounts query
// rather than the workspace-level messageChannel records.
const { data: myConnectedAccountsData, loading: messageChannelLoading } =
useQuery<{
myConnectedAccounts: {
id: string;
handle: string;
provider: ConnectedAccountProvider;
}[];
}>(GET_MY_CONNECTED_ACCOUNTS);
const resolvedConnectedAccount =
myConnectedAccountsData?.myConnectedAccounts[0] ?? null;
const connectedAccountId = resolvedConnectedAccount?.id ?? null;
const connectedAccountHandle = resolvedConnectedAccount?.handle ?? null;
const connectedAccountProvider = resolvedConnectedAccount?.provider ?? null;
return {
thread,
messages: messagesWithSender,
messageThreadExternalId,
connectedAccountId,
connectedAccountHandle,
connectedAccountProvider,
connectedAccountConnectionParameters,
threadLoading: messagesLoading,
messageChannelLoading,
lastMessageExternalId,
@@ -0,0 +1,17 @@
import { useQuery } from '@apollo/client/react';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
export const useFirstConnectedAccount = () => {
const { data, loading } = useQuery<{
myConnectedAccounts: { id: string; handle: string }[];
}>(GET_MY_CONNECTED_ACCOUNTS);
const firstAccount = data?.myConnectedAccounts?.[0] ?? null;
return {
connectedAccountId: firstAccount?.id ?? null,
connectedAccountHandle: firstAccount?.handle ?? null,
loading,
};
};
@@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import {
type ReplyContext,
type ReplyContextReady,
} from '@/activities/emails/types/ReplyContext';
import { isDefined } from 'twenty-shared/utils';
export type { ReplyContext, ReplyContextReady };
export const useReplyContext = (
threadId: string | null,
): ReplyContext | null => {
const {
messages,
connectedAccountId,
connectedAccountProvider,
messageChannelLoading,
threadLoading,
} = useEmailThread(threadId);
return useMemo(() => {
if (
!isDefined(connectedAccountId) ||
!isDefined(connectedAccountProvider)
) {
if (messageChannelLoading || threadLoading) {
return { loading: true };
}
return null;
}
const lastMessage = messages[messages.length - 1];
if (!isDefined(lastMessage)) {
return null;
}
const senderHandle = lastMessage.sender?.handle ?? '';
const rawSubject = lastMessage.subject ?? '';
const subject = rawSubject.startsWith('Re: ')
? rawSubject
: `Re: ${rawSubject}`;
return {
loading: false,
to: senderHandle,
subject,
inReplyTo: lastMessage.headerMessageId ?? '',
connectedAccountId,
connectedAccountProvider,
};
}, [
messages,
connectedAccountId,
connectedAccountProvider,
messageChannelLoading,
threadLoading,
]);
};
@@ -0,0 +1,94 @@
import { useMutation } from '@apollo/client/react';
import { useCallback } from 'react';
import { SEND_EMAIL } from '@/activities/emails/graphql/mutations/sendEmail';
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId';
import { getTimelineThreadsFromOpportunityId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromOpportunityId';
import { getTimelineThreadsFromPersonId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromPersonId';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { t } from '@lingui/core/macro';
import {
type SendEmailMutation,
type SendEmailMutationVariables,
} from '~/generated-metadata/graphql';
type SendEmailParams = {
connectedAccountId: string;
to: string;
cc?: string;
bcc?: string;
subject: string;
body: string;
inReplyTo?: string;
};
export const useSendEmail = () => {
const apolloCoreClient = useApolloCoreClient();
const [sendEmailMutation, { loading }] = useMutation<
SendEmailMutation,
SendEmailMutationVariables
>(SEND_EMAIL);
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const sendEmail = useCallback(
async (params: SendEmailParams): Promise<boolean> => {
try {
const result = await sendEmailMutation({
variables: {
input: {
connectedAccountId: params.connectedAccountId,
to: params.to,
cc: params.cc,
bcc: params.bcc,
subject: params.subject,
body: params.body,
inReplyTo: params.inReplyTo,
},
},
});
if (result.data?.sendEmail.success) {
enqueueSuccessSnackBar({
message: t`Email sent successfully`,
});
await apolloCoreClient.refetchQueries({
include: [
getTimelineThreadsFromCompanyId,
getTimelineThreadsFromPersonId,
getTimelineThreadsFromOpportunityId,
'FindManyMessages',
'FindManyMessageParticipants',
'FindManyMessageChannelMessageAssociations',
],
});
return true;
}
enqueueErrorSnackBar({
message: result.data?.sendEmail.error ?? t`Failed to send email`,
});
return false;
} catch {
enqueueErrorSnackBar({
message: t`Failed to send email`,
});
return false;
}
},
[
sendEmailMutation,
enqueueSuccessSnackBar,
enqueueErrorSnackBar,
apolloCoreClient,
],
);
return { sendEmail, loading };
};
@@ -0,0 +1,3 @@
import { type useEmailComposerState } from '@/activities/emails/hooks/useEmailComposerState';
export type EmailComposerState = ReturnType<typeof useEmailComposerState>;
@@ -6,6 +6,7 @@ export type EmailThreadMessage = {
text: string;
receivedAt: string;
subject: string;
headerMessageId: string;
messageThreadId: string;
messageParticipants: EmailThreadMessageParticipant[];
messageThread: MessageThread;
@@ -0,0 +1,16 @@
import { type ConnectedAccountProvider } from 'twenty-shared/types';
type ReplyContextLoading = {
loading: true;
};
export type ReplyContextReady = {
loading: false;
to: string;
subject: string;
inReplyTo: string;
connectedAccountId: string;
connectedAccountProvider: ConnectedAccountProvider;
};
export type ReplyContext = ReplyContextLoading | ReplyContextReady;
@@ -4,8 +4,13 @@ import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuCont
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { getSidePanelCommandMenuDropdownIdFromCommandMenuId } from '@/command-menu-item/utils/getSidePanelCommandMenuDropdownIdFromCommandMenuId';
import { OptionsDropdownMenu } from '@/ui/layout/dropdown/components/OptionsDropdownMenu';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { sidePanelWidgetFooterActionsState } from '@/ui/layout/side-panel/states/sidePanelWidgetFooterActionsState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext } from 'react';
import { HorizontalSeparator } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
export const RecordPageSidePanelCommandMenuDropdown = () => {
const { commandMenuItems } = useContext(CommandMenuContext);
@@ -17,13 +22,24 @@ export const RecordPageSidePanelCommandMenuDropdown = () => {
const dropdownId =
getSidePanelCommandMenuDropdownIdFromCommandMenuId(commandMenuId);
const { closeDropdown } = useCloseDropdown();
const sidePanelWidgetFooterActions = useAtomStateValue(
sidePanelWidgetFooterActionsState,
);
const dropdownWidgetActions = sidePanelWidgetFooterActions.filter(
(action) => action.isPinned === false,
);
const recordSelectionActions = commandMenuItems.filter(
(action) => action.scope === CommandMenuItemScope.RecordSelection,
);
const selectableItemIdArray = recordSelectionActions.map(
(action) => action.key,
);
const selectableItemIdArray = [
...dropdownWidgetActions.map((action) => action.key),
...recordSelectionActions.map((action) => action.key),
];
return (
<OptionsDropdownMenu
@@ -31,6 +47,19 @@ export const RecordPageSidePanelCommandMenuDropdown = () => {
selectableListId={commandMenuId}
selectableItemIdArray={selectableItemIdArray}
>
{dropdownWidgetActions.map((action) => (
<MenuItem
key={action.key}
text={action.label}
LeftIcon={action.Icon}
onClick={() => {
closeDropdown(dropdownId);
action.onClick();
}}
/>
))}
{dropdownWidgetActions.length > 0 &&
recordSelectionActions.length > 0 && <HorizontalSeparator noMargin />}
{recordSelectionActions.map((action) => (
<CommandMenuItemComponent action={action} key={action.key} />
))}
@@ -40,6 +40,7 @@ import { TestWorkflowSingleRecordCommand } from '@/command-menu-item/engine-comm
import { TidyUpWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand';
import { HeadlessFrontComponentRendererEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessFrontComponentRendererEngineCommand';
import { TriggerWorkflowVersionEngineCommand } from '@/command-menu-item/engine-command/record/components/TriggerWorkflowVersionEngineCommand';
import { ComposeEmailCommand } from '@/command-menu-item/engine-command/global/components/ComposeEmailCommand';
import { ReplyToEmailThreadCommand } from '@/command-menu-item/engine-command/record/single-record/message-thread/components/ReplyToEmailThreadCommand';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { msg } from '@lingui/core/macro';
@@ -242,6 +243,7 @@ export const ENGINE_COMPONENT_KEY_COMPONENT_MAP: Record<
<HeadlessFrontComponentRendererEngineCommand />
),
[EngineComponentKey.REPLY_TO_EMAIL_THREAD]: <ReplyToEmailThreadCommand />,
[EngineComponentKey.COMPOSE_EMAIL]: <ComposeEmailCommand />,
// Deprecated keys kept for backward compatibility until migration runs
[EngineComponentKey.DELETE_SINGLE_RECORD]: <DeleteRecordsCommand />,
@@ -0,0 +1,31 @@
import { useFirstConnectedAccount } from '@/activities/emails/hooks/useFirstConnectedAccount';
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const ComposeEmailCommand = () => {
const { connectedAccountId, loading } = useFirstConnectedAccount();
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
const navigateSettings = useNavigateSettings();
const handleExecute = () => {
if (!isDefined(connectedAccountId)) {
navigateSettings(SettingsPath.NewAccount);
return;
}
openComposeEmailInSidePanel({
connectedAccountId,
});
};
return (
<HeadlessEngineCommandWrapperEffect
execute={handleExecute}
ready={!loading}
/>
);
};
@@ -15,10 +15,13 @@ export const NavigateToNextRecordSingleRecordCommand = () => {
);
}
const { navigateToNextRecord } = useRecordShowPagePagination(
objectMetadataItem.nameSingular,
recordId,
);
const { navigateToNextRecord, isLoadingPagination } =
useRecordShowPagePagination(objectMetadataItem.nameSingular, recordId);
return <HeadlessEngineCommandWrapperEffect execute={navigateToNextRecord} />;
return (
<HeadlessEngineCommandWrapperEffect
execute={navigateToNextRecord}
ready={!isLoadingPagination}
/>
);
};
@@ -15,12 +15,13 @@ export const NavigateToPreviousRecordSingleRecordCommand = () => {
);
}
const { navigateToPreviousRecord } = useRecordShowPagePagination(
objectMetadataItem.nameSingular,
recordId,
);
const { navigateToPreviousRecord, isLoadingPagination } =
useRecordShowPagePagination(objectMetadataItem.nameSingular, recordId);
return (
<HeadlessEngineCommandWrapperEffect execute={navigateToPreviousRecord} />
<HeadlessEngineCommandWrapperEffect
execute={navigateToPreviousRecord}
ready={!isLoadingPagination}
/>
);
};
@@ -1,73 +1,31 @@
import { useMemo } from 'react';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import { useReplyContext } from '@/activities/emails/hooks/useReplyContext';
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
import { isDefined } from 'twenty-shared/utils';
const ALLOWED_REPLY_PROVIDERS = [
ConnectedAccountProvider.GOOGLE,
ConnectedAccountProvider.MICROSOFT,
ConnectedAccountProvider.IMAP_SMTP_CALDAV,
];
export const ReplyToEmailThreadCommand = () => {
const { selectedRecords } = useHeadlessCommandContextApi();
const threadId = selectedRecords[0]?.id ?? null;
const {
messageThreadExternalId,
connectedAccountHandle,
connectedAccountProvider,
lastMessageExternalId,
connectedAccountConnectionParameters,
messageChannelLoading,
} = useEmailThread(threadId);
const canReply = useMemo(() => {
return (
isDefined(connectedAccountHandle) &&
isDefined(connectedAccountProvider) &&
ALLOWED_REPLY_PROVIDERS.includes(connectedAccountProvider) &&
(connectedAccountProvider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV ||
isDefined(connectedAccountConnectionParameters?.SMTP)) &&
isDefined(messageThreadExternalId)
);
}, [
connectedAccountConnectionParameters,
connectedAccountHandle,
connectedAccountProvider,
messageThreadExternalId,
]);
const replyContext = useReplyContext(threadId);
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
const handleExecute = () => {
if (!canReply) {
if (!isDefined(replyContext) || replyContext.loading) {
return;
}
switch (connectedAccountProvider) {
case ConnectedAccountProvider.MICROSOFT: {
const url = `https://outlook.office.com/mail/deeplink?ItemID=${lastMessageExternalId}`;
window.open(url, '_blank');
break;
}
case ConnectedAccountProvider.GOOGLE: {
const url = `https://mail.google.com/mail/?authuser=${connectedAccountHandle}#all/${messageThreadExternalId}`;
window.open(url, '_blank');
break;
}
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case null:
return;
default:
return;
}
openComposeEmailInSidePanel({
threadId: threadId ?? undefined,
connectedAccountId: replyContext.connectedAccountId,
defaultTo: replyContext.to,
defaultSubject: replyContext.subject,
defaultInReplyTo: replyContext.inReplyTo,
});
};
const isReady = !messageChannelLoading && canReply;
const isReady = isDefined(replyContext) && !replyContext.loading;
return (
<HeadlessEngineCommandWrapperEffect
@@ -20,7 +20,14 @@ export const CommandMenuContextProviderServerItems = ({
containerType,
children,
}: CommandMenuContextProviderServerItemsProps) => {
const commandMenuContextApi = useCommandMenuContextApi();
const commandMenuContextApiFromHook = useCommandMenuContextApi();
// SidePanelRecordPage shadows the outer ContextStore provider with a
// per-page instance ID, so useCommandMenuContextApi derives isInSidePanel
// as false. The explicit prop from the caller is the source of truth.
const commandMenuContextApi = isInSidePanel
? { ...commandMenuContextApiFromHook, isInSidePanel: true as const }
: commandMenuContextApiFromHook;
const currentObjectNameSingular =
commandMenuContextApi.objectMetadataItem.nameSingular;
@@ -10,9 +10,12 @@ import { usePageLayoutIdForRecord } from '@/page-layout/hooks/usePageLayoutIdFor
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { type TargetRecordIdentifier } from '@/ui/layout/contexts/TargetRecordIdentifier';
import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter';
import { sidePanelWidgetFooterActionsState } from '@/ui/layout/side-panel/states/sidePanelWidgetFooterActionsState';
import { styled } from '@linaria/react';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { PageLayoutType } from '~/generated-metadata/graphql';
@@ -55,6 +58,16 @@ export const PageLayoutRecordPageRenderer = ({
targetObjectNameSingular: targetRecordIdentifier.targetObjectNameSingular,
});
const sidePanelWidgetFooterActions = useAtomStateValue(
sidePanelWidgetFooterActionsState,
);
const pinnedWidgetActions = sidePanelWidgetFooterActions.filter(
(action) => action.isPinned !== false,
);
const hasPinnedWidgetActions = pinnedWidgetActions.length > 0;
return (
<>
<RecordShowEffect
@@ -101,13 +114,30 @@ export const PageLayoutRecordPageRenderer = ({
{isInSidePanel && (
<SidePanelFooter
actions={[
<RecordPageSidePanelCommandMenu />,
<RecordShowSidePanelOpenRecordButton
objectNameSingular={
targetRecordIdentifier.targetObjectNameSingular
}
recordId={targetRecordIdentifier.id}
/>,
<RecordPageSidePanelCommandMenu key="options" />,
...(hasPinnedWidgetActions
? pinnedWidgetActions.map((action) => (
<Button
key={action.key}
size="small"
variant={action.isPrimaryCTA ? 'primary' : 'secondary'}
accent={action.isPrimaryCTA ? 'blue' : 'default'}
title={action.label}
Icon={action.Icon}
hotkeys={action.hotkeys}
onClick={action.onClick}
disabled={action.disabled}
/>
))
: [
<RecordShowSidePanelOpenRecordButton
key="open"
objectNameSingular={
targetRecordIdentifier.targetObjectNameSingular
}
recordId={targetRecordIdentifier.id}
/>,
]),
]}
/>
)}
@@ -0,0 +1,137 @@
import { styled } from '@linaria/react';
import { useCallback, useEffect, useMemo } from 'react';
import { EmailComposerFields } from '@/activities/emails/components/EmailComposerFields';
import { useEmailComposerState } from '@/activities/emails/hooks/useEmailComposerState';
import { type ReplyContextReady } from '@/activities/emails/hooks/useReplyContext';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { sidePanelWidgetFooterActionsState } from '@/ui/layout/side-panel/states/sidePanelWidgetFooterActionsState';
import { type SidePanelFooterAction } from '@/ui/layout/side-panel/types/SidePanelFooterAction';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { t } from '@lingui/core/macro';
import { IconArrowBackUp, IconSend, IconX } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getOsControlSymbol } from 'twenty-ui/utilities';
const StyledReplyBar = styled.button`
align-items: center;
all: unset;
color: ${themeCssVariables.font.color.tertiary};
cursor: pointer;
display: flex;
font-size: ${themeCssVariables.font.size.md};
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[4]};
&:hover {
background: ${themeCssVariables.background.transparent.light};
color: ${themeCssVariables.font.color.secondary};
}
`;
type EmailThreadComposerProps = {
replyContext: ReplyContextReady;
isInSidePanel: boolean;
isComposerOpen: boolean;
setIsComposerOpen: (open: boolean) => void;
};
export const EmailThreadComposer = ({
replyContext,
isInSidePanel,
isComposerOpen,
setIsComposerOpen,
}: EmailThreadComposerProps) => {
const handleReplySent = useCallback(() => {
setIsComposerOpen(false);
}, [setIsComposerOpen]);
const composerState = useEmailComposerState({
connectedAccountId: replyContext.connectedAccountId,
defaultTo: replyContext.to,
defaultSubject: replyContext.subject,
defaultInReplyTo: replyContext.inReplyTo,
onSent: handleReplySent,
});
const setSidePanelWidgetFooterActions = useSetAtomState(
sidePanelWidgetFooterActionsState,
);
const footerActions = useMemo((): SidePanelFooterAction[] => {
if (!isComposerOpen) {
return [
{
key: 'reply',
label: t`Reply`,
Icon: IconArrowBackUp,
isPrimaryCTA: true,
onClick: () => setIsComposerOpen(true),
},
];
}
return [
{
key: 'cancel-reply',
label: t`Cancel reply`,
Icon: IconX,
isPinned: false,
onClick: () => setIsComposerOpen(false),
},
{
key: 'send',
label: t`Send`,
Icon: IconSend,
isPrimaryCTA: true,
hotkeys: [getOsControlSymbol(), '⏎'],
onClick: composerState.handleSend,
disabled: !composerState.canSend,
},
];
}, [
isComposerOpen,
composerState.handleSend,
composerState.canSend,
setIsComposerOpen,
]);
useEffect(() => {
if (!isInSidePanel) {
return;
}
setSidePanelWidgetFooterActions(footerActions);
return () => setSidePanelWidgetFooterActions([]);
}, [isInSidePanel, footerActions, setSidePanelWidgetFooterActions]);
const handleSendHotkey = useCallback(() => {
if (isComposerOpen && composerState.canSend) {
composerState.handleSend();
}
}, [isComposerOpen, composerState.canSend, composerState.handleSend]);
useHotkeysOnFocusedElement({
keys: ['ctrl+Enter,meta+Enter'],
callback: handleSendHotkey,
focusId: SIDE_PANEL_FOCUS_ID,
dependencies: [handleSendHotkey],
});
if (!isComposerOpen) {
if (isInSidePanel) {
return null;
}
return (
<StyledReplyBar onClick={() => setIsComposerOpen(true)}>
<IconArrowBackUp size={16} />
{t`Reply...`}
</StyledReplyBar>
);
}
return <EmailComposerFields composerState={composerState} />;
};
@@ -1,13 +1,18 @@
import { styled } from '@linaria/react';
import { useState } from 'react';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { EmailLoader } from '@/activities/emails/components/EmailLoader';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import { useReplyContext } from '@/activities/emails/hooks/useReplyContext';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { EmailThreadComposer } from '@/page-layout/widgets/email-thread/components/EmailThreadComposer';
import { EmailThreadIntermediaryMessages } from '@/page-layout/widgets/email-thread/components/EmailThreadIntermediaryMessages';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
const StyledWrapper = styled.div`
display: flex;
@@ -30,11 +35,18 @@ export const EmailThreadWidget = ({
widget: _widget,
}: EmailThreadWidgetProps) => {
const targetRecord = useTargetRecord();
const { isInSidePanel } = useLayoutRenderingContext();
const { thread, messages, fetchMoreMessages, threadLoading } = useEmailThread(
targetRecord.id,
);
const replyContext = useReplyContext(targetRecord.id);
const [isComposerOpen, setIsComposerOpen] = useState(false);
const canReply = isDefined(replyContext) && !replyContext.loading;
const messagesCount = messages.length;
const is5OrMoreMessages = messagesCount >= 5;
const firstMessages = messages.slice(
@@ -59,33 +71,38 @@ export const EmailThreadWidget = ({
return (
<StyledWrapper>
<StyledContainer>
{
<>
{firstMessages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
/>
))}
<EmailThreadIntermediaryMessages messages={intermediaryMessages} />
<EmailThreadMessage
key={lastMessage.id}
sender={lastMessage.sender}
participants={lastMessage.messageParticipants}
body={lastMessage.text}
sentAt={lastMessage.receivedAt}
isExpanded
/>
<CustomResolverFetchMoreLoader
loading={threadLoading}
onLastRowVisible={fetchMoreMessages}
/>
</>
}
{firstMessages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
/>
))}
<EmailThreadIntermediaryMessages messages={intermediaryMessages} />
<EmailThreadMessage
key={lastMessage.id}
sender={lastMessage.sender}
participants={lastMessage.messageParticipants}
body={lastMessage.text}
sentAt={lastMessage.receivedAt}
isExpanded
hideBottomBorder={!isComposerOpen}
/>
<CustomResolverFetchMoreLoader
loading={threadLoading}
onLastRowVisible={fetchMoreMessages}
/>
</StyledContainer>
{canReply && (
<EmailThreadComposer
replyContext={replyContext}
isInSidePanel={isInSidePanel}
isComposerOpen={isComposerOpen}
setIsComposerOpen={setIsComposerOpen}
/>
)}
</StyledWrapper>
);
};
@@ -14,14 +14,28 @@ import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { Trans } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { styled } from '@linaria/react';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/display';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { SidePanelPageInfoLayout } from './SidePanelPageInfoLayout';
const StyledClickableTitle = styled.div`
cursor: pointer;
a {
color: inherit;
text-decoration: none;
}
`;
export const SidePanelRecordInfo = ({
sidePanelPageInstanceId,
}: {
@@ -85,6 +99,11 @@ export const SidePanelRecordInfo = ({
objectNameSingular,
});
const recordShowPagePath = getAppPath(AppPath.RecordShowPage, {
objectNameSingular,
objectRecordId,
});
const fieldDefinition = {
type: labelIdentifierFieldMetadataItem?.type ?? FieldMetadataType.TEXT,
iconName: '',
@@ -97,6 +116,25 @@ export const SidePanelRecordInfo = ({
defaultValue: labelIdentifierFieldMetadataItem?.defaultValue,
};
const titleContent = (
<FieldContext.Provider
value={{
recordId: objectRecordId,
isLabelIdentifier: false,
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
isCentered: false,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isTitleReadOnly,
}}
>
<RecordTitleCell
sizeVariant="sm"
containerType={RecordTitleCellContainerType.PageHeader}
/>
</FieldContext.Provider>
);
return (
<SidePanelPageInfoLayout
icon={
@@ -111,22 +149,15 @@ export const SidePanelRecordInfo = ({
) : undefined
}
title={
<FieldContext.Provider
value={{
recordId: objectRecordId,
isLabelIdentifier: false,
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
isCentered: false,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isTitleReadOnly,
}}
>
<RecordTitleCell
sizeVariant="sm"
containerType={RecordTitleCellContainerType.PageHeader}
/>
</FieldContext.Provider>
isTitleReadOnly ? (
<StyledClickableTitle>
<UndecoratedLink to={recordShowPagePath}>
{titleContent}
</UndecoratedLink>
</StyledClickableTitle>
) : (
titleContent
)
}
label={
beautifiedCreatedAt ? (
@@ -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 { SidePanelComposeEmailPage } from '@/side-panel/pages/compose-email/components/SidePanelComposeEmailPage';
import { SidePanelFrontComponentPage } from '@/side-panel/pages/front-component/components/SidePanelFrontComponentPage';
import { SidePanelPageLayoutChartSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutChartSettings';
import { SidePanelPageLayoutFieldSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldSettings';
@@ -17,7 +18,6 @@ import { SidePanelMergeRecordPage } from '@/side-panel/pages/record-page/compone
import { SidePanelRecordPage } from '@/side-panel/pages/record-page/components/SidePanelRecordPage';
import { SidePanelUpdateMultipleRecords } from '@/side-panel/pages/record-page/components/SidePanelUpdateMultipleRecords';
import { SidePanelEditRichTextPage } from '@/side-panel/pages/rich-text-page/components/SidePanelEditRichTextPage';
import { SidePanelRootPage } from '@/side-panel/pages/root/components/SidePanelRootPage';
import { SidePanelSearchRecordsPage } from '@/side-panel/pages/search/components/SidePanelSearchRecordsPage';
import { SidePanelWorkflowCreateStep } from '@/side-panel/pages/workflow/step/create/components/SidePanelWorkflowCreateStep';
import { SidePanelWorkflowEditStep } from '@/side-panel/pages/workflow/step/edit/components/SidePanelWorkflowEditStep';
@@ -82,5 +82,6 @@ export const SIDE_PANEL_PAGES_CONFIG = new Map<SidePanelPages, React.ReactNode>(
],
[SidePanelPages.NavigationMenuAddItem, <SidePanelNewSidebarItemPage />],
[SidePanelPages.CommandMenuEdit, <SidePanelCommandMenuItemEditPage />],
[SidePanelPages.ComposeEmail, <SidePanelComposeEmailPage />],
],
);
@@ -0,0 +1,78 @@
import { useCallback } from 'react';
import { useStore } from 'jotai';
import { SidePanelPages } from 'twenty-shared/types';
import {
type IconComponent,
IconArrowBackUp,
IconMail,
} from 'twenty-ui/display';
import { v4 } from 'uuid';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { composeEmailConnectedAccountIdComponentState } from '@/side-panel/pages/compose-email/states/composeEmailConnectedAccountIdComponentState';
import { composeEmailDefaultInReplyToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultInReplyToComponentState';
import { composeEmailDefaultSubjectComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultSubjectComponentState';
import { composeEmailDefaultToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultToComponentState';
import { t } from '@lingui/core/macro';
type OpenComposeEmailParams = {
threadId?: string;
connectedAccountId: string;
defaultTo?: string;
defaultSubject?: string;
defaultInReplyTo?: string;
pageTitle?: string;
pageIcon?: IconComponent;
};
export const useOpenComposeEmailInSidePanel = () => {
const store = useStore();
const { navigateSidePanelMenu } = useSidePanelMenu();
const openComposeEmailInSidePanel = useCallback(
(params: OpenComposeEmailParams) => {
const pageId = v4();
const isReply = !!params.defaultInReplyTo;
store.set(
composeEmailConnectedAccountIdComponentState.atomFamily({
instanceId: pageId,
}),
params.connectedAccountId,
);
store.set(
composeEmailDefaultToComponentState.atomFamily({
instanceId: pageId,
}),
params.defaultTo ?? '',
);
store.set(
composeEmailDefaultSubjectComponentState.atomFamily({
instanceId: pageId,
}),
params.defaultSubject ?? '',
);
store.set(
composeEmailDefaultInReplyToComponentState.atomFamily({
instanceId: pageId,
}),
params.defaultInReplyTo ?? '',
);
navigateSidePanelMenu({
page: SidePanelPages.ComposeEmail,
pageTitle: params.pageTitle ?? (isReply ? t`Reply` : t`New Email`),
pageIcon: params.pageIcon ?? (isReply ? IconArrowBackUp : IconMail),
pageId,
});
},
[navigateSidePanelMenu, store],
);
return { openComposeEmailInSidePanel };
};
@@ -0,0 +1,103 @@
import { useCallback } from 'react';
import { EmailComposerFields } from '@/activities/emails/components/EmailComposerFields';
import { useEmailComposerState } from '@/activities/emails/hooks/useEmailComposerState';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
import { composeEmailConnectedAccountIdComponentState } from '@/side-panel/pages/compose-email/states/composeEmailConnectedAccountIdComponentState';
import { composeEmailDefaultInReplyToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultInReplyToComponentState';
import { composeEmailDefaultSubjectComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultSubjectComponentState';
import { composeEmailDefaultToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultToComponentState';
import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { IconSend } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { getOsControlSymbol } from 'twenty-ui/utilities';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledContent = styled.div`
display: flex;
flex: 1;
flex-direction: column;
overflow-y: auto;
`;
export const SidePanelComposeEmailPage = () => {
const composeEmailConnectedAccountId = useAtomComponentStateValue(
composeEmailConnectedAccountIdComponentState,
);
const composeEmailDefaultTo = useAtomComponentStateValue(
composeEmailDefaultToComponentState,
);
const composeEmailDefaultSubject = useAtomComponentStateValue(
composeEmailDefaultSubjectComponentState,
);
const composeEmailDefaultInReplyTo = useAtomComponentStateValue(
composeEmailDefaultInReplyToComponentState,
);
const { goBackFromSidePanel } = useSidePanelHistory();
const composerState = useEmailComposerState({
connectedAccountId: composeEmailConnectedAccountId ?? '',
defaultTo: composeEmailDefaultTo ?? '',
defaultSubject: composeEmailDefaultSubject ?? '',
defaultInReplyTo: composeEmailDefaultInReplyTo ?? undefined,
onSent: goBackFromSidePanel,
});
const handleSendHotkey = useCallback(() => {
if (composerState.canSend) {
composerState.handleSend();
}
}, [composerState.canSend, composerState.handleSend]);
useHotkeysOnFocusedElement({
keys: ['ctrl+Enter,meta+Enter'],
callback: handleSendHotkey,
focusId: SIDE_PANEL_FOCUS_ID,
dependencies: [handleSendHotkey],
});
if (!composeEmailConnectedAccountId) {
return null;
}
return (
<StyledContainer>
<StyledContent>
<EmailComposerFields composerState={composerState} />
</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`}
Icon={IconSend}
hotkeys={[getOsControlSymbol(), '⏎']}
onClick={composerState.handleSend}
disabled={!composerState.canSend}
/>,
]}
/>
</StyledContainer>
);
};
@@ -0,0 +1,9 @@
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const composeEmailConnectedAccountIdComponentState =
createAtomComponentState<string>({
key: 'side-panel/compose-email-connected-account-id',
defaultValue: '',
componentInstanceContext: SidePanelPageComponentInstanceContext,
});
@@ -0,0 +1,9 @@
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const composeEmailDefaultInReplyToComponentState =
createAtomComponentState<string>({
key: 'side-panel/compose-email-default-in-reply-to',
defaultValue: '',
componentInstanceContext: SidePanelPageComponentInstanceContext,
});
@@ -0,0 +1,9 @@
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const composeEmailDefaultSubjectComponentState =
createAtomComponentState<string>({
key: 'side-panel/compose-email-default-subject',
defaultValue: '',
componentInstanceContext: SidePanelPageComponentInstanceContext,
});
@@ -0,0 +1,9 @@
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const composeEmailDefaultToComponentState =
createAtomComponentState<string>({
key: 'side-panel/compose-email-default-to',
defaultValue: '',
componentInstanceContext: SidePanelPageComponentInstanceContext,
});
@@ -0,0 +1,9 @@
import { type SidePanelFooterAction } from '@/ui/layout/side-panel/types/SidePanelFooterAction';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const sidePanelWidgetFooterActionsState = createAtomState<
SidePanelFooterAction[]
>({
key: 'side-panel/widgetFooterActionsState',
defaultValue: [],
});
@@ -0,0 +1,12 @@
import { type IconComponent } from 'twenty-ui/display';
export type SidePanelFooterAction = {
key: string;
label: string;
Icon?: IconComponent;
isPrimaryCTA?: boolean;
isPinned?: boolean;
onClick: () => void;
disabled?: boolean;
hotkeys?: string[];
};
@@ -0,0 +1,123 @@
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const COMPOSE_EMAIL_UNIVERSAL_IDENTIFIER =
STANDARD_COMMAND_MENU_ITEMS.composeEmail.universalIdentifier;
@Command({
name: 'upgrade:1-21:add-compose-email-command-menu-item',
description: 'Add the Compose Email command menu item to existing workspaces',
})
export class AddComposeEmailCommandMenuItemCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Checking compose email command for workspace ${workspaceId}`,
);
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatCommandMenuItemMaps',
]);
const alreadyExists = isDefined(
existingFlatCommandMenuItemMaps.byUniversalIdentifier[
COMPOSE_EMAIL_UNIVERSAL_IDENTIFIER
],
);
if (alreadyExists) {
this.logger.log(
`Compose email command already exists for workspace ${workspaceId}, skipping`,
);
return;
}
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
shouldIncludeRecordPageLayouts: true,
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const itemToCreate =
standardAllFlatEntityMaps.flatCommandMenuItemMaps.byUniversalIdentifier[
COMPOSE_EMAIL_UNIVERSAL_IDENTIFIER
];
if (!isDefined(itemToCreate)) {
this.logger.warn(
`Compose email command not found in standard application for workspace ${workspaceId}`,
);
return;
}
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would create compose email command for workspace ${workspaceId}`,
);
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: [itemToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to add compose email command:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to add compose email command for workspace ${workspaceId}`,
);
}
this.logger.log(
`Successfully added compose email command for workspace ${workspaceId}`,
);
}
}
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { AddComposeEmailCommandMenuItemCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-compose-email-command-menu-item.command';
import { BackfillMessageThreadSubjectCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-message-thread-subject.command';
import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-global-key-value-pair-unique-index.command';
import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command';
@@ -38,6 +39,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceSchemaManagerModule,
],
providers: [
AddComposeEmailCommandMenuItemCommand,
AddGlobalKeyValuePairUniqueIndexCommand,
BackfillDatasourceToWorkspaceCommand,
BackfillMessageThreadSubjectCommand,
@@ -50,6 +52,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
MigrateMessageFolderParentIdToExternalIdCommand,
],
exports: [
AddComposeEmailCommandMenuItemCommand,
AddGlobalKeyValuePairUniqueIndexCommand,
BackfillDatasourceToWorkspaceCommand,
BackfillMessageThreadSubjectCommand,
@@ -26,6 +26,7 @@ import { MigrateMessagingInfrastructureToMetadataCommand } from 'src/database/co
import { MigrateRichTextToTextCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-rich-text-to-text.command';
import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command';
import { UpdateStandardIndexViewNamesCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-update-standard-index-view-names.command';
import { AddComposeEmailCommandMenuItemCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-compose-email-command-menu-item.command';
import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-global-key-value-pair-unique-index.command';
import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command';
import { BackfillMessageThreadSubjectCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-message-thread-subject.command';
@@ -74,6 +75,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
private readonly makeWorkflowSearchableCommand: MakeWorkflowSearchableCommand,
// 1.21 Commands
private readonly addComposeEmailCommandMenuItemCommand: AddComposeEmailCommandMenuItemCommand,
private readonly addGlobalKeyValuePairUniqueIndexCommand: AddGlobalKeyValuePairUniqueIndexCommand,
private readonly backfillDatasourceToWorkspaceCommand: BackfillDatasourceToWorkspaceCommand,
private readonly backfillMessageThreadSubjectCommand: BackfillMessageThreadSubjectCommand,
@@ -116,6 +118,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
];
const commands_1210: VersionCommands = [
this.addComposeEmailCommandMenuItemCommand,
this.addGlobalKeyValuePairUniqueIndexCommand,
this.backfillDatasourceToWorkspaceCommand,
this.backfillMessageThreadSubjectCommand,
@@ -70,6 +70,7 @@ import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.modul
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
import { ChannelSyncModule } from 'src/modules/connected-account/channel-sync/channel-sync.module';
import { DashboardModule } from 'src/modules/dashboard/dashboard.module';
import { SendEmailModule } from 'src/modules/messaging/message-outbound-manager/send-email.module';
import { AuditModule } from './audit/audit.module';
import { ClientConfigModule } from './client-config/client-config.module';
import { EventLogsModule } from './event-logs/event-logs.module';
@@ -123,6 +124,7 @@ import { FileModule } from './file/file.module';
SubscriptionsModule,
ImapSmtpCaldavModule,
ChannelSyncModule,
SendEmailModule,
FileStorageModule.forRoot(),
LoggerModule.forRootAsync({
useFactory: loggerModuleFactory,
@@ -24,6 +24,8 @@ import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-ac
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message';
import { parseEmailBody } from 'src/utils/parse-email-body';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@@ -209,6 +211,50 @@ export class EmailComposerService {
return attachments;
}
// Look up the provider-specific thread ID (e.g. Gmail threadId) from the
// parent message so replies can be explicitly threaded in the provider API.
private async getThreadExternalId(
workspaceId: string,
inReplyTo: string,
messageChannelId: string,
): Promise<string | undefined> {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const messageRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageWorkspaceEntity>(
workspaceId,
'message',
);
const parentMessage = await messageRepository.findOne({
where: { headerMessageId: inReplyTo },
});
if (!parentMessage) {
return undefined;
}
const associationRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
workspaceId,
'messageChannelMessageAssociation',
);
const association = await associationRepository.findOne({
where: {
messageId: parentMessage.id,
messageChannelId,
},
});
return association?.messageThreadExternalId ?? undefined;
},
authContext,
);
}
async composeEmail(
parameters: EmailToolInput,
context: ToolExecutionContext,
@@ -301,6 +347,16 @@ export class EmailComposerService {
const sanitizedHtmlBody = purify.sanitize(htmlBody || '');
const sanitizedSubject = purify.sanitize(subject || '');
let threadExternalId: string | undefined;
if (inReplyTo) {
threadExternalId = await this.getThreadExternalId(
workspaceId,
inReplyTo,
messageChannel.id,
);
}
return {
success: true,
data: {
@@ -311,7 +367,9 @@ export class EmailComposerService {
sanitizedHtmlBody,
attachments,
connectedAccount: connectedAccountWithFreshTokens,
messageChannelId: messageChannel.id,
inReplyTo,
threadExternalId,
},
};
}
@@ -4,12 +4,11 @@ import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-t
import { EmailToolInputZodSchema } from 'src/engine/core-modules/tool/tools/email-tool/email-tool.schema';
import { EmailToolException } from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
import { isInsufficientPermissionsError } from 'src/engine/core-modules/tool/tools/email-tool/utils/is-insufficient-permissions-error.util';
import { type ComposedEmail } from 'src/engine/core-modules/tool/tools/email-tool/types/composed-email.type';
import { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
import { SendEmailService } from 'src/modules/messaging/message-outbound-manager/services/send-email.service';
@Injectable()
export class SendEmailTool implements Tool {
@@ -21,7 +20,7 @@ export class SendEmailTool implements Tool {
constructor(
private readonly emailComposerService: EmailComposerService,
private readonly messageOutboundService: MessagingMessageOutboundService,
private readonly sendEmailService: SendEmailService,
) {}
async execute(
@@ -40,7 +39,13 @@ export class SendEmailTool implements Tool {
const { data } = result;
await this.sendEmail(data);
const sendResult = await this.sendEmailService.sendComposedEmail(data);
await this.sendEmailService.persistSentMessage(
sendResult,
data,
context.workspaceId,
);
this.logger.log(
`Email sent successfully to ${data.toRecipientsDisplay}${data.attachments.length > 0 ? ` with ${data.attachments.length} attachments` : ''}`,
@@ -86,20 +91,4 @@ export class SendEmailTool implements Tool {
};
}
}
private async sendEmail(data: ComposedEmail): Promise<void> {
await this.messageOutboundService.sendMessage(
{
to: data.recipients.to,
cc: data.recipients.cc.length > 0 ? data.recipients.cc : undefined,
bcc: data.recipients.bcc.length > 0 ? data.recipients.bcc : undefined,
subject: data.sanitizedSubject,
body: data.plainTextBody,
html: data.sanitizedHtmlBody,
attachments: data.attachments,
inReplyTo: data.inReplyTo,
},
data.connectedAccount,
);
}
}
@@ -9,5 +9,7 @@ export type ComposedEmail = {
sanitizedHtmlBody: string;
attachments: MessageAttachment[];
connectedAccount: ConnectedAccountEntity;
messageChannelId: string;
inReplyTo?: string;
threadExternalId?: string;
};
@@ -56,6 +56,7 @@ export enum EngineComponentKey {
TRIGGER_WORKFLOW_VERSION = 'TRIGGER_WORKFLOW_VERSION',
FRONT_COMPONENT_RENDERER = 'FRONT_COMPONENT_RENDERER',
REPLY_TO_EMAIL_THREAD = 'REPLY_TO_EMAIL_THREAD',
COMPOSE_EMAIL = 'COMPOSE_EMAIL',
// Deprecated keys kept for backward compatibility until migration runs
DELETE_SINGLE_RECORD = 'DELETE_SINGLE_RECORD',
@@ -72,6 +72,7 @@ type ParticipantData = {
workspaceMemberId: string;
personId: string;
displayName: string;
handle: string;
};
const GET_RANDOM_FAKE_PARTICIPANT = () => {
@@ -120,6 +121,7 @@ const CREATE_PERSON_PARTICIPANT = (
workspaceMemberId: defaultWorkspaceMemberId,
personId: PERSON_ID,
displayName: `Person ${PERSON_INDEX}`,
handle: `person${PERSON_INDEX}@example.com`,
};
};
@@ -141,26 +143,30 @@ const CREATE_WORKSPACE_MEMBER_PARTICIPANT = (
case WORKSPACE_MEMBER_DATA_SEED_IDS.TIM:
return {
workspaceMemberId: WORKSPACE_MEMBER_ID,
personId: personIds[0] || personIds[0],
personId: personIds[0],
displayName: 'Tim Apple',
handle: 'tim@apple.dev',
};
case WORKSPACE_MEMBER_DATA_SEED_IDS.JONY:
return {
workspaceMemberId: WORKSPACE_MEMBER_ID,
personId: personIds[1] || personIds[0],
displayName: 'Jony Ive',
handle: 'jony@apple.dev',
};
case WORKSPACE_MEMBER_DATA_SEED_IDS.PHIL:
return {
workspaceMemberId: WORKSPACE_MEMBER_ID,
personId: personIds[2] || personIds[0],
displayName: 'Phil Schiller',
handle: 'phil@apple.dev',
};
default:
return {
workspaceMemberId: WORKSPACE_MEMBER_ID,
personId: personIds[0] || personIds[0],
personId: personIds[0],
displayName: 'Workspace Member',
handle: 'member@apple.dev',
};
}
};
@@ -176,6 +182,7 @@ const CREATE_FAKE_PARTICIPANT = (
personId:
personIds[Math.floor(Math.random() * Math.min(10, personIds.length))],
displayName: FAKE.name,
handle: FAKE.email,
};
};
@@ -231,7 +238,6 @@ const CREATE_MESSAGE_PARTICIPANTS = (
const ROLE = IS_SENDER
? MessageParticipantRole.FROM
: MessageParticipantRole.TO;
const HANDLE = IS_SENDER ? 'outgoing' : 'incoming';
// Random date within the last 3 months
const NOW = new Date();
@@ -255,7 +261,7 @@ const CREATE_MESSAGE_PARTICIPANTS = (
workspaceMemberId: PARTICIPANT_DATA.workspaceMemberId,
personId: PARTICIPANT_DATA.personId,
displayName: PARTICIPANT_DATA.displayName,
handle: HANDLE,
handle: PARTICIPANT_DATA.handle,
role: ROLE,
messageId,
});
@@ -44,6 +44,14 @@ import {
EMPLOYMENT_HISTORY_DATA_SEED_COLUMNS,
EMPLOYMENT_HISTORY_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/employment-history-data-seeds.constant';
import {
CONNECTED_ACCOUNT_DATA_SEED_COLUMNS,
CONNECTED_ACCOUNT_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/connected-account-data-seeds.constant';
import {
MESSAGE_CHANNEL_DATA_SEED_COLUMNS,
MESSAGE_CHANNEL_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/message-channel-data-seeds.constant';
import {
MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_DATA_SEED_COLUMNS,
MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_DATA_SEEDS,
@@ -157,9 +165,14 @@ const getRecordSeedsBatches = (
pgColumns: DASHBOARD_DATA_SEED_COLUMNS,
recordSeeds: getDashboardDataSeeds(workspaceId),
},
{
tableName: 'connectedAccount',
pgColumns: CONNECTED_ACCOUNT_DATA_SEED_COLUMNS,
recordSeeds: CONNECTED_ACCOUNT_DATA_SEEDS,
},
];
// Batch 3: Depends on company and connectedAccount
// Batch 3: Depends on company, connectedAccount
const batch3: RecordSeedConfig[] = [
{
tableName: 'person',
@@ -171,6 +184,11 @@ const getRecordSeedsBatches = (
pgColumns: PET_DATA_SEED_COLUMNS,
recordSeeds: PET_DATA_SEEDS,
},
{
tableName: 'messageChannel',
pgColumns: MESSAGE_CHANNEL_DATA_SEED_COLUMNS,
recordSeeds: MESSAGE_CHANNEL_DATA_SEEDS,
},
];
// Batch 4: Depends on person/company/messageChannel or independent
@@ -802,4 +802,18 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
engineComponentKey: EngineComponentKey.REPLY_TO_EMAIL_THREAD,
hotKeys: null,
},
composeEmail: {
universalIdentifier: '96457c5a-b028-4d48-94e3-27f4c41296b8',
label: 'Compose Email',
icon: 'IconMail',
isPinned: false,
position: 71,
shortLabel: 'Compose',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: null,
availabilityObjectMetadataUniversalIdentifier: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.COMPOSE_EMAIL,
hotKeys: null,
},
} as const;
@@ -10,6 +10,8 @@ import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
import { extractMessageIdFromBuffer } from 'src/modules/messaging/message-outbound-manager/utils/extract-message-id-from-buffer.util';
import { toMailComposerOptions } from 'src/modules/messaging/message-outbound-manager/utils/to-mail-composer-options.util';
@Injectable()
@@ -21,18 +23,25 @@ export class GmailMessageOutboundService implements MessageOutboundDriver {
async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<void> {
const { gmailClient, encodedMessage } = await this.composeGmailMessage(
connectedAccount,
sendMessageInput,
);
): Promise<SendMessageResult> {
const { gmailClient, encodedMessage, messageBuffer } =
await this.composeGmailMessage(connectedAccount, sendMessageInput);
await gmailClient.users.messages.send({
const { data } = await gmailClient.users.messages.send({
userId: 'me',
requestBody: {
raw: encodedMessage,
...(sendMessageInput.threadExternalId
? { threadId: sendMessageInput.threadExternalId }
: {}),
},
});
return {
headerMessageId: extractMessageIdFromBuffer(messageBuffer),
messageExternalId: data.id ?? undefined,
threadExternalId: data.threadId ?? undefined,
};
}
async createDraft(
@@ -60,6 +69,7 @@ export class GmailMessageOutboundService implements MessageOutboundDriver {
): Promise<{
gmailClient: gmail_v1.Gmail;
encodedMessage: string;
messageBuffer: Buffer;
}> {
const oAuth2Client =
await this.oAuth2ClientManagerService.getGoogleOAuth2Client(
@@ -104,6 +114,6 @@ export class GmailMessageOutboundService implements MessageOutboundDriver {
const messageBuffer = await compiledMessage.build();
const encodedMessage = Buffer.from(messageBuffer).toString('base64url');
return { gmailClient, encodedMessage };
return { gmailClient, encodedMessage, messageBuffer };
}
}
@@ -14,6 +14,8 @@ import { ImapClientProvider } from 'src/modules/messaging/message-import-manager
import { ImapFindDraftsFolderService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-drafts-folder.service';
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
import { extractMessageIdFromBuffer } from 'src/modules/messaging/message-outbound-manager/utils/extract-message-id-from-buffer.util';
import { toMailComposerOptions } from 'src/modules/messaging/message-outbound-manager/utils/to-mail-composer-options.util';
@Injectable()
@@ -31,7 +33,7 @@ export class ImapSmtpMessageOutboundService implements MessageOutboundDriver {
async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<void> {
): Promise<SendMessageResult> {
const { handle, connectionParameters } = connectedAccount;
const smtpClient =
@@ -80,6 +82,10 @@ export class ImapSmtpMessageOutboundService implements MessageOutboundDriver {
await this.imapClientProvider.closeClient(imapClient);
}
return {
headerMessageId: extractMessageIdFromBuffer(messageBuffer),
};
}
async createDraft(
@@ -6,6 +6,7 @@ import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { toMicrosoftRecipients } from 'src/modules/messaging/message-import-manager/utils/to-microsoft-recipients.util';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
import { type Client as MicrosoftGraphClient } from '@microsoft/microsoft-graph-client';
import { isDefined } from 'twenty-shared/utils';
@@ -18,18 +19,25 @@ export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<void> {
): Promise<SendMessageResult> {
const microsoftClient =
await this.oAuth2ClientManagerService.getMicrosoftOAuth2Client(
connectedAccount,
);
const messageId = await this.createDraftMessage(
microsoftClient,
sendMessageInput,
);
const {
id: messageId,
internetMessageId,
conversationId,
} = await this.createDraftMessage(microsoftClient, sendMessageInput);
await microsoftClient.api(`/me/messages/${messageId}/send`).post({});
return {
headerMessageId: internetMessageId ?? '',
messageExternalId: messageId,
threadExternalId: conversationId ?? undefined,
};
}
async createDraft(
@@ -47,7 +55,11 @@ export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
private async createDraftMessage(
microsoftClient: MicrosoftGraphClient,
sendMessageInput: SendMessageInput,
): Promise<string> {
): Promise<{
id: string;
internetMessageId?: string;
conversationId?: string;
}> {
const parentMessageGraphId = sendMessageInput.inReplyTo
? await this.findMessageByInternetMessageId(
microsoftClient,
@@ -62,14 +74,25 @@ export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
.api(`/me/messages/${parentMessageGraphId}/createReply`)
.post({});
await microsoftClient.api(`/me/messages/${reply.id}`).patch(message);
const patched = await microsoftClient
.api(`/me/messages/${reply.id}`)
.patch(message);
return reply.id;
return {
id: reply.id,
internetMessageId:
patched?.internetMessageId ?? reply.internetMessageId,
conversationId: patched?.conversationId ?? reply.conversationId,
};
}
const response = await microsoftClient.api('/me/messages').post(message);
return response.id;
return {
id: response.id,
internetMessageId: response.internetMessageId,
conversationId: response.conversationId,
};
}
private async findMessageByInternetMessageId(
@@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('SendEmailOutput')
export class SendEmailOutputDTO {
@Field(() => Boolean)
success: boolean;
@Field(() => String, { nullable: true })
error?: string;
}
@@ -0,0 +1,25 @@
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class SendEmailInput {
@Field(() => String)
connectedAccountId: string;
@Field(() => String)
to: string;
@Field(() => String, { nullable: true })
cc?: string;
@Field(() => String, { nullable: true })
bcc?: string;
@Field(() => String)
subject: string;
@Field(() => String)
body: string;
@Field(() => String, { nullable: true })
inReplyTo?: string;
}
@@ -1,11 +1,12 @@
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
export type MessageOutboundDriver = {
sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<void>;
): Promise<SendMessageResult>;
createDraft(
sendMessageInput: SendMessageInput,
@@ -10,6 +10,8 @@ import { GmailMessageOutboundService } from 'src/modules/messaging/message-outbo
import { ImapSmtpMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/imap/services/imap-smtp-message-outbound.service';
import { MicrosoftMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
import { SendEmailService } from 'src/modules/messaging/message-outbound-manager/services/send-email.service';
import { SentMessagePersistenceService } from 'src/modules/messaging/message-outbound-manager/services/sent-message-persistence.service';
@Module({
imports: [
@@ -23,7 +25,13 @@ import { MessagingMessageOutboundService } from 'src/modules/messaging/message-o
MicrosoftMessageOutboundService,
ImapSmtpMessageOutboundService,
MessagingMessageOutboundService,
SendEmailService,
SentMessagePersistenceService,
],
exports: [
MessagingMessageOutboundService,
SendEmailService,
SentMessagePersistenceService,
],
exports: [MessagingMessageOutboundService],
})
export class MessagingSendManagerModule {}
@@ -0,0 +1,97 @@
import {
ForbiddenException,
Logger,
UseFilters,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { SendEmailOutputDTO } from 'src/modules/messaging/message-outbound-manager/dtos/send-email-output.dto';
import { SendEmailInput } from 'src/modules/messaging/message-outbound-manager/dtos/send-email.input';
import { SendEmailService } from 'src/modules/messaging/message-outbound-manager/services/send-email.service';
@MetadataResolver()
@UsePipes(ResolverValidationPipe)
@UseFilters(AuthGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
export class SendEmailResolver {
private readonly logger = new Logger(SendEmailResolver.name);
constructor(
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
private readonly emailComposerService: EmailComposerService,
private readonly sendEmailService: SendEmailService,
) {}
@Mutation(() => SendEmailOutputDTO)
async sendEmail(
@Args('input') input: SendEmailInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<SendEmailOutputDTO> {
try {
await this.connectedAccountMetadataService.verifyOwnership({
id: input.connectedAccountId,
userWorkspaceId,
workspaceId: workspace.id,
});
const result = await this.emailComposerService.composeEmail(
{
recipients: {
to: input.to,
cc: input.cc ?? '',
bcc: input.bcc ?? '',
},
subject: input.subject,
body: input.body,
connectedAccountId: input.connectedAccountId,
files: [],
inReplyTo: input.inReplyTo,
},
{ workspaceId: workspace.id },
);
if (!result.success) {
return {
success: false,
error: result.output.error ?? result.output.message,
};
}
const { data } = result;
const sendResult = await this.sendEmailService.sendComposedEmail(data);
await this.sendEmailService.persistSentMessage(
sendResult,
data,
workspace.id,
);
return { success: true };
} catch (error) {
if (error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Failed to send email: ${error}`);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to send email',
};
}
}
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
import { SendEmailResolver } from 'src/modules/messaging/message-outbound-manager/resolvers/send-email.resolver';
import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbound-manager/messaging-send-manager.module';
@Module({
imports: [
ToolModule,
MessagingSendManagerModule,
ConnectedAccountMetadataModule,
],
providers: [SendEmailResolver],
})
export class SendEmailModule {}
@@ -8,6 +8,7 @@ import { GmailMessageOutboundService } from 'src/modules/messaging/message-outbo
import { ImapSmtpMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/imap/services/imap-smtp-message-outbound.service';
import { MicrosoftMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service';
import { SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
@Injectable()
export class MessagingMessageOutboundService {
@@ -20,7 +21,7 @@ export class MessagingMessageOutboundService {
public async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<void> {
): Promise<SendMessageResult> {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return this.gmailMessageOutboundService.sendMessage(
@@ -0,0 +1,56 @@
import { Injectable, Logger } from '@nestjs/common';
import { type ComposedEmail } from 'src/engine/core-modules/tool/tools/email-tool/types/composed-email.type';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
import { SentMessagePersistenceService } from 'src/modules/messaging/message-outbound-manager/services/sent-message-persistence.service';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
@Injectable()
export class SendEmailService {
private readonly logger = new Logger(SendEmailService.name);
constructor(
private readonly messageOutboundService: MessagingMessageOutboundService,
private readonly sentMessagePersistenceService: SentMessagePersistenceService,
) {}
async sendComposedEmail(data: ComposedEmail): Promise<SendMessageResult> {
return this.messageOutboundService.sendMessage(
{
to: data.recipients.to,
cc: data.recipients.cc.length > 0 ? data.recipients.cc : undefined,
bcc: data.recipients.bcc.length > 0 ? data.recipients.bcc : undefined,
subject: data.sanitizedSubject,
body: data.plainTextBody,
html: data.sanitizedHtmlBody,
attachments: data.attachments,
inReplyTo: data.inReplyTo,
threadExternalId: data.threadExternalId,
},
data.connectedAccount,
);
}
async persistSentMessage(
sendResult: SendMessageResult,
data: ComposedEmail,
workspaceId: string,
): Promise<void> {
try {
await this.sentMessagePersistenceService.persistSentMessage({
sendResult,
subject: data.sanitizedSubject,
body: data.plainTextBody,
recipients: data.recipients,
connectedAccount: data.connectedAccount,
messageChannelId: data.messageChannelId,
inReplyTo: data.inReplyTo,
workspaceId,
});
} catch (persistenceError) {
this.logger.warn(
`Failed to persist sent message (sync will recover): ${persistenceError}`,
);
}
}
}
@@ -0,0 +1,185 @@
import { Injectable, Logger } from '@nestjs/common';
import { MessageParticipantRole } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
import { type MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
type PersistSentMessageInput = {
sendResult: SendMessageResult;
subject: string;
body: string;
recipients: { to: string[]; cc: string[]; bcc: string[] };
connectedAccount: ConnectedAccountEntity;
messageChannelId: string;
inReplyTo?: string;
workspaceId: string;
};
@Injectable()
export class SentMessagePersistenceService {
private readonly logger = new Logger(SentMessagePersistenceService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
async persistSentMessage(input: PersistSentMessageInput): Promise<void> {
const authContext = buildSystemAuthContext(input.workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const messageRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageWorkspaceEntity>(
input.workspaceId,
'message',
);
const messageThreadRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageThreadWorkspaceEntity>(
input.workspaceId,
'messageThread',
);
const messageParticipantRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageParticipantWorkspaceEntity>(
input.workspaceId,
'messageParticipant',
);
const associationRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
input.workspaceId,
'messageChannelMessageAssociation',
);
const messageThreadId = await this.findOrCreateThread({
messageRepository,
messageThreadRepository,
inReplyTo: input.inReplyTo,
subject: input.subject,
});
const messageId = v4();
await messageRepository.insert({
id: messageId,
headerMessageId: input.sendResult.headerMessageId,
subject: input.subject,
text: input.body,
receivedAt: new Date(),
messageThreadId,
});
const participants = this.buildParticipants(
messageId,
input.connectedAccount.handle ?? '',
input.recipients,
);
if (participants.length > 0) {
await messageParticipantRepository.insert(participants);
}
await associationRepository.insert({
messageChannelId: input.messageChannelId,
messageId,
messageExternalId: input.sendResult.messageExternalId ?? null,
messageThreadExternalId: input.sendResult.threadExternalId ?? null,
direction: MessageDirection.OUTGOING,
});
}, authContext);
}
private async findOrCreateThread({
messageRepository,
messageThreadRepository,
inReplyTo,
subject,
}: {
messageRepository: Awaited<
ReturnType<GlobalWorkspaceOrmManager['getRepository']>
>;
messageThreadRepository: Awaited<
ReturnType<GlobalWorkspaceOrmManager['getRepository']>
>;
inReplyTo?: string;
subject: string;
}): Promise<string> {
if (inReplyTo) {
const parentMessage = await messageRepository.findOne({
where: { headerMessageId: inReplyTo },
});
if (parentMessage?.messageThreadId) {
return parentMessage.messageThreadId;
}
}
const threadId = v4();
await messageThreadRepository.insert({
id: threadId,
subject,
});
return threadId;
}
private buildParticipants(
messageId: string,
senderHandle: string,
recipients: { to: string[]; cc: string[]; bcc: string[] },
): Pick<
MessageParticipantWorkspaceEntity,
'messageId' | 'handle' | 'displayName' | 'role'
>[] {
const participants: Pick<
MessageParticipantWorkspaceEntity,
'messageId' | 'handle' | 'displayName' | 'role'
>[] = [];
participants.push({
messageId,
handle: senderHandle,
displayName: senderHandle,
role: MessageParticipantRole.FROM,
});
for (const email of recipients.to) {
participants.push({
messageId,
handle: email,
displayName: email,
role: MessageParticipantRole.TO,
});
}
for (const email of recipients.cc) {
participants.push({
messageId,
handle: email,
displayName: email,
role: MessageParticipantRole.CC,
});
}
for (const email of recipients.bcc) {
participants.push({
messageId,
handle: email,
displayName: email,
role: MessageParticipantRole.BCC,
});
}
return participants;
}
}
@@ -13,4 +13,5 @@ export type SendMessageInput = {
contentType: string;
}[];
inReplyTo?: string;
threadExternalId?: string;
};
@@ -0,0 +1,5 @@
export type SendMessageResult = {
headerMessageId: string;
messageExternalId?: string;
threadExternalId?: string;
};
@@ -0,0 +1,17 @@
// Extracts the RFC 2822 Message-ID header from a raw email buffer.
// Handles folded headers (continuation lines starting with whitespace).
// MailComposer always generates this header; if missing, falls back to empty string.
export const extractMessageIdFromBuffer = (messageBuffer: Buffer): string => {
const headerSection = messageBuffer.toString('utf-8').split('\r\n\r\n')[0];
if (!headerSection) {
return '';
}
// Unfold continuation lines (RFC 2822: lines starting with whitespace are continuations)
const unfolded = headerSection.replace(/\r\n([ \t])/g, '$1');
const match = unfolded.match(/^Message-ID:\s*(.+)$/im);
return match?.[1]?.trim() ?? '';
};
@@ -26,4 +26,5 @@ export enum SidePanelPages {
NavigationMenuItemEdit = 'navigation-menu-item-edit',
NavigationMenuAddItem = 'navigation-menu-add-item',
CommandMenuEdit = 'command-menu-edit',
ComposeEmail = 'compose-email',
}