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:
@@ -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}',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+117
@@ -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>
|
||||
);
|
||||
};
|
||||
+11
-5
@@ -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;
|
||||
+32
-3
@@ -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} />
|
||||
))}
|
||||
|
||||
+2
@@ -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 />,
|
||||
|
||||
+31
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+8
-5
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-5
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+13
-55
@@ -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
|
||||
|
||||
+8
-1
@@ -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;
|
||||
|
||||
+37
-7
@@ -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}
|
||||
/>,
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
+137
@@ -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} />;
|
||||
};
|
||||
+43
-26
@@ -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 };
|
||||
};
|
||||
+103
@@ -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>
|
||||
);
|
||||
};
|
||||
+9
@@ -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,
|
||||
});
|
||||
+9
@@ -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,
|
||||
});
|
||||
+9
@@ -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,
|
||||
});
|
||||
+9
@@ -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,
|
||||
});
|
||||
+9
@@ -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[];
|
||||
};
|
||||
+123
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
@@ -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,
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+58
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+9
-20
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -9,5 +9,7 @@ export type ComposedEmail = {
|
||||
sanitizedHtmlBody: string;
|
||||
attachments: MessageAttachment[];
|
||||
connectedAccount: ConnectedAccountEntity;
|
||||
messageChannelId: string;
|
||||
inReplyTo?: string;
|
||||
threadExternalId?: string;
|
||||
};
|
||||
|
||||
+1
@@ -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',
|
||||
|
||||
+10
-4
@@ -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,
|
||||
});
|
||||
|
||||
+19
-1
@@ -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
|
||||
|
||||
+14
@@ -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;
|
||||
|
||||
+17
-7
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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(
|
||||
|
||||
+32
-9
@@ -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(
|
||||
|
||||
+10
@@ -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;
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+9
-1
@@ -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 {}
|
||||
|
||||
+97
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -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 {}
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+56
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+185
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
@@ -13,4 +13,5 @@ export type SendMessageInput = {
|
||||
contentType: string;
|
||||
}[];
|
||||
inReplyTo?: string;
|
||||
threadExternalId?: string;
|
||||
};
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type SendMessageResult = {
|
||||
headerMessageId: string;
|
||||
messageExternalId?: string;
|
||||
threadExternalId?: string;
|
||||
};
|
||||
+17
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user