feat(messaging): sync draft emails and edit them in the thread composer (#22178)

Stop excluding drafts from sync across all three providers (Gmail DRAFT
label, Microsoft/IMAP Drafts folder) and add an isDraft boolean field on
Message so drafts are queryable by the API and AI agents.

Drafts render in the thread with a Draft tag; clicking one opens the
existing reply composer pre-filled with the draft's recipients, subject
and body, and Send reuses the existing send-email flow.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22178?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
neo773
2026-06-30 16:37:53 +05:30
committed by GitHub
parent c4a6446757
commit 9f3ebaaf22
60 changed files with 2057 additions and 1100 deletions
@@ -2789,6 +2789,7 @@ type DuplicatedDashboard {
type SendEmailOutput {
success: Boolean!
error: String
messageThreadId: String
}
type Analytics {
@@ -4609,6 +4610,7 @@ input SendEmailInput {
subject: String!
body: String!
inReplyTo: String
draftMessageId: String
files: [SendEmailAttachmentInput!]
}
@@ -2453,6 +2453,7 @@ export interface DuplicatedDashboard {
export interface SendEmailOutput {
success: Scalars['Boolean']
error?: Scalars['String']
messageThreadId?: Scalars['String']
__typename: 'SendEmailOutput'
}
@@ -5611,6 +5612,7 @@ export interface DuplicatedDashboardGenqlSelection{
export interface SendEmailOutputGenqlSelection{
success?: boolean | number
error?: boolean | number
messageThreadId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -6590,7 +6592,7 @@ export interface EditSsoInput {id: Scalars['UUID'],status: SSOIdentityProviderSt
export interface CreateCalendarEventInput {connectedAccountId: Scalars['String'],title: Scalars['String'],description?: (Scalars['String'] | null),location?: (Scalars['String'] | null),startsAt: Scalars['String'],endsAt: Scalars['String'],isFullDay?: (Scalars['Boolean'] | null),timeZone?: (Scalars['String'] | null),attendees?: (Scalars['String'] | null),sendInvitations?: (Scalars['Boolean'] | null),addConferencing?: (Scalars['Boolean'] | null)}
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),files?: (SendEmailAttachmentInput[] | null)}
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),draftMessageId?: (Scalars['String'] | null),files?: (SendEmailAttachmentInput[] | null)}
export interface SendEmailAttachmentInput {id: Scalars['String'],name: Scalars['String']}
@@ -5549,6 +5549,9 @@ export default {
"error": [
1
],
"messageThreadId": [
1
],
"__typename": [
1
]
@@ -11702,6 +11705,9 @@ export default {
"inReplyTo": [
1
],
"draftMessageId": [
1
],
"files": [
491
],
File diff suppressed because one or more lines are too long
@@ -545,6 +545,7 @@ export type TimelineThread = {
firstParticipant: TimelineThreadParticipant;
id: Scalars['UUID']['output'];
lastMessageBody: Scalars['String']['output'];
lastMessageIsDraft: Scalars['Boolean']['output'];
lastMessageReceivedAt: Scalars['DateTime']['output'];
lastTwoParticipants: Array<TimelineThreadParticipant>;
numberOfMessagesInThread: Scalars['Float']['output'];
@@ -708,9 +709,9 @@ export type GetTimelineCalendarEventsFromObjectRecordQuery = { __typename?: 'Que
export type ParticipantFragmentFragment = { __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string };
export type TimelineThreadFragmentFragment = { __typename?: 'TimelineThread', id: any, read: boolean, visibility: MessageChannelVisibility, lastMessageReceivedAt: string, lastMessageBody: string, subject: string, numberOfMessagesInThread: number, participantCount: number, firstParticipant: { __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }, lastTwoParticipants: Array<{ __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> };
export type TimelineThreadFragmentFragment = { __typename?: 'TimelineThread', id: any, read: boolean, visibility: MessageChannelVisibility, lastMessageReceivedAt: string, lastMessageBody: string, subject: string, numberOfMessagesInThread: number, participantCount: number, lastMessageIsDraft: boolean, firstParticipant: { __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }, lastTwoParticipants: Array<{ __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> };
export type TimelineThreadsWithTotalFragmentFragment = { __typename?: 'TimelineThreadsWithTotal', totalNumberOfThreads: number, relatedPersonIds: Array<any>, timelineThreads: Array<{ __typename?: 'TimelineThread', id: any, read: boolean, visibility: MessageChannelVisibility, lastMessageReceivedAt: string, lastMessageBody: string, subject: string, numberOfMessagesInThread: number, participantCount: number, firstParticipant: { __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }, lastTwoParticipants: Array<{ __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> }> };
export type TimelineThreadsWithTotalFragmentFragment = { __typename?: 'TimelineThreadsWithTotal', totalNumberOfThreads: number, relatedPersonIds: Array<any>, timelineThreads: Array<{ __typename?: 'TimelineThread', id: any, read: boolean, visibility: MessageChannelVisibility, lastMessageReceivedAt: string, lastMessageBody: string, subject: string, numberOfMessagesInThread: number, participantCount: number, lastMessageIsDraft: boolean, firstParticipant: { __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }, lastTwoParticipants: Array<{ __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> }> };
export type GetTimelineThreadsFromObjectRecordQueryVariables = Exact<{
objectNameSingular: Scalars['String']['input'];
@@ -720,7 +721,7 @@ export type GetTimelineThreadsFromObjectRecordQueryVariables = Exact<{
}>;
export type GetTimelineThreadsFromObjectRecordQuery = { __typename?: 'Query', getTimelineThreadsFromObjectRecord: { __typename?: 'TimelineThreadsWithTotal', totalNumberOfThreads: number, relatedPersonIds: Array<any>, timelineThreads: Array<{ __typename?: 'TimelineThread', id: any, read: boolean, visibility: MessageChannelVisibility, lastMessageReceivedAt: string, lastMessageBody: string, subject: string, numberOfMessagesInThread: number, participantCount: number, firstParticipant: { __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }, lastTwoParticipants: Array<{ __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> }> } };
export type GetTimelineThreadsFromObjectRecordQuery = { __typename?: 'Query', getTimelineThreadsFromObjectRecord: { __typename?: 'TimelineThreadsWithTotal', totalNumberOfThreads: number, relatedPersonIds: Array<any>, timelineThreads: Array<{ __typename?: 'TimelineThread', id: any, read: boolean, visibility: MessageChannelVisibility, lastMessageReceivedAt: string, lastMessageBody: string, subject: string, numberOfMessagesInThread: number, participantCount: number, lastMessageIsDraft: boolean, firstParticipant: { __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }, lastTwoParticipants: Array<{ __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> }> } };
export type SearchQueryVariables = Exact<{
searchInput: Scalars['String']['input'];
@@ -873,11 +874,11 @@ export const TimelineCalendarEventParticipantFragmentFragmentDoc = {"kind":"Docu
export const TimelineCalendarEventFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineCalendarEventFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineCalendarEvent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"location"}},{"kind":"Field","name":{"kind":"Name","value":"startsAt"}},{"kind":"Field","name":{"kind":"Name","value":"endsAt"}},{"kind":"Field","name":{"kind":"Name","value":"isFullDay"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"participants"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TimelineCalendarEventParticipantFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineCalendarEventParticipantFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineCalendarEventParticipant"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"personId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberId"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}}]}}]} as unknown as DocumentNode<TimelineCalendarEventFragmentFragment, unknown>;
export const TimelineCalendarEventsWithTotalFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineCalendarEventsWithTotalFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineCalendarEventsWithTotal"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalNumberOfCalendarEvents"}},{"kind":"Field","name":{"kind":"Name","value":"relatedPersonIds"}},{"kind":"Field","name":{"kind":"Name","value":"timelineCalendarEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TimelineCalendarEventFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineCalendarEventParticipantFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineCalendarEventParticipant"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"personId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberId"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineCalendarEventFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineCalendarEvent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"location"}},{"kind":"Field","name":{"kind":"Name","value":"startsAt"}},{"kind":"Field","name":{"kind":"Name","value":"endsAt"}},{"kind":"Field","name":{"kind":"Name","value":"isFullDay"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"participants"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TimelineCalendarEventParticipantFragment"}}]}}]}}]} as unknown as DocumentNode<TimelineCalendarEventsWithTotalFragmentFragment, unknown>;
export const ParticipantFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ParticipantFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThreadParticipant"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"personId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberId"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}}]}}]} as unknown as DocumentNode<ParticipantFragmentFragment, unknown>;
export const TimelineThreadFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineThreadFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThread"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"read"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"firstParticipant"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ParticipantFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lastTwoParticipants"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ParticipantFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageReceivedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageBody"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"numberOfMessagesInThread"}},{"kind":"Field","name":{"kind":"Name","value":"participantCount"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ParticipantFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThreadParticipant"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"personId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberId"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}}]}}]} as unknown as DocumentNode<TimelineThreadFragmentFragment, unknown>;
export const TimelineThreadsWithTotalFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineThreadsWithTotalFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThreadsWithTotal"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalNumberOfThreads"}},{"kind":"Field","name":{"kind":"Name","value":"relatedPersonIds"}},{"kind":"Field","name":{"kind":"Name","value":"timelineThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TimelineThreadFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ParticipantFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThreadParticipant"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"personId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberId"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineThreadFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThread"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"read"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"firstParticipant"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ParticipantFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lastTwoParticipants"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ParticipantFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageReceivedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageBody"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"numberOfMessagesInThread"}},{"kind":"Field","name":{"kind":"Name","value":"participantCount"}}]}}]} as unknown as DocumentNode<TimelineThreadsWithTotalFragmentFragment, unknown>;
export const TimelineThreadFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineThreadFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThread"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"read"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"firstParticipant"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ParticipantFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lastTwoParticipants"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ParticipantFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageReceivedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageBody"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"numberOfMessagesInThread"}},{"kind":"Field","name":{"kind":"Name","value":"participantCount"}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageIsDraft"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ParticipantFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThreadParticipant"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"personId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberId"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}}]}}]} as unknown as DocumentNode<TimelineThreadFragmentFragment, unknown>;
export const TimelineThreadsWithTotalFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineThreadsWithTotalFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThreadsWithTotal"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalNumberOfThreads"}},{"kind":"Field","name":{"kind":"Name","value":"relatedPersonIds"}},{"kind":"Field","name":{"kind":"Name","value":"timelineThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TimelineThreadFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ParticipantFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThreadParticipant"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"personId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberId"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineThreadFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThread"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"read"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"firstParticipant"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ParticipantFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lastTwoParticipants"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ParticipantFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageReceivedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageBody"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"numberOfMessagesInThread"}},{"kind":"Field","name":{"kind":"Name","value":"participantCount"}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageIsDraft"}}]}}]} as unknown as DocumentNode<TimelineThreadsWithTotalFragmentFragment, unknown>;
export const WorkflowDiffFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkflowDiffFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkflowVersionStepChanges"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"triggerDiff"}},{"kind":"Field","name":{"kind":"Name","value":"stepsDiff"}}]}}]} as unknown as DocumentNode<WorkflowDiffFragmentFragment, unknown>;
export const GetTimelineCalendarEventsFromObjectRecordDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetTimelineCalendarEventsFromObjectRecord"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectNameSingular"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recordId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"page"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pageSize"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getTimelineCalendarEventsFromObjectRecord"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"objectNameSingular"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectNameSingular"}}},{"kind":"Argument","name":{"kind":"Name","value":"recordId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recordId"}}},{"kind":"Argument","name":{"kind":"Name","value":"page"},"value":{"kind":"Variable","name":{"kind":"Name","value":"page"}}},{"kind":"Argument","name":{"kind":"Name","value":"pageSize"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pageSize"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TimelineCalendarEventsWithTotalFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineCalendarEventParticipantFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineCalendarEventParticipant"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"personId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberId"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineCalendarEventFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineCalendarEvent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"location"}},{"kind":"Field","name":{"kind":"Name","value":"startsAt"}},{"kind":"Field","name":{"kind":"Name","value":"endsAt"}},{"kind":"Field","name":{"kind":"Name","value":"isFullDay"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"participants"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TimelineCalendarEventParticipantFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineCalendarEventsWithTotalFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineCalendarEventsWithTotal"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalNumberOfCalendarEvents"}},{"kind":"Field","name":{"kind":"Name","value":"relatedPersonIds"}},{"kind":"Field","name":{"kind":"Name","value":"timelineCalendarEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TimelineCalendarEventFragment"}}]}}]}}]} as unknown as DocumentNode<GetTimelineCalendarEventsFromObjectRecordQuery, GetTimelineCalendarEventsFromObjectRecordQueryVariables>;
export const GetTimelineThreadsFromObjectRecordDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetTimelineThreadsFromObjectRecord"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectNameSingular"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recordId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"page"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pageSize"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getTimelineThreadsFromObjectRecord"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"objectNameSingular"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectNameSingular"}}},{"kind":"Argument","name":{"kind":"Name","value":"recordId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recordId"}}},{"kind":"Argument","name":{"kind":"Name","value":"page"},"value":{"kind":"Variable","name":{"kind":"Name","value":"page"}}},{"kind":"Argument","name":{"kind":"Name","value":"pageSize"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pageSize"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TimelineThreadsWithTotalFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ParticipantFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThreadParticipant"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"personId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberId"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineThreadFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThread"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"read"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"firstParticipant"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ParticipantFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lastTwoParticipants"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ParticipantFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageReceivedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageBody"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"numberOfMessagesInThread"}},{"kind":"Field","name":{"kind":"Name","value":"participantCount"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineThreadsWithTotalFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThreadsWithTotal"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalNumberOfThreads"}},{"kind":"Field","name":{"kind":"Name","value":"relatedPersonIds"}},{"kind":"Field","name":{"kind":"Name","value":"timelineThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TimelineThreadFragment"}}]}}]}}]} as unknown as DocumentNode<GetTimelineThreadsFromObjectRecordQuery, GetTimelineThreadsFromObjectRecordQueryVariables>;
export const GetTimelineThreadsFromObjectRecordDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetTimelineThreadsFromObjectRecord"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectNameSingular"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recordId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"page"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pageSize"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getTimelineThreadsFromObjectRecord"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"objectNameSingular"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectNameSingular"}}},{"kind":"Argument","name":{"kind":"Name","value":"recordId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recordId"}}},{"kind":"Argument","name":{"kind":"Name","value":"page"},"value":{"kind":"Variable","name":{"kind":"Name","value":"page"}}},{"kind":"Argument","name":{"kind":"Name","value":"pageSize"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pageSize"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TimelineThreadsWithTotalFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ParticipantFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThreadParticipant"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"personId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberId"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineThreadFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThread"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"read"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"firstParticipant"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ParticipantFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lastTwoParticipants"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ParticipantFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageReceivedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageBody"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"numberOfMessagesInThread"}},{"kind":"Field","name":{"kind":"Name","value":"participantCount"}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageIsDraft"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineThreadsWithTotalFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineThreadsWithTotal"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalNumberOfThreads"}},{"kind":"Field","name":{"kind":"Name","value":"relatedPersonIds"}},{"kind":"Field","name":{"kind":"Name","value":"timelineThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TimelineThreadFragment"}}]}}]}}]} as unknown as DocumentNode<GetTimelineThreadsFromObjectRecordQuery, GetTimelineThreadsFromObjectRecordQueryVariables>;
export const SearchDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Search"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchInput"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"excludedObjectNameSingulars"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"includedObjectNameSingulars"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ObjectRecordFilterInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"search"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"searchInput"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchInput"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"excludedObjectNameSingulars"},"value":{"kind":"Variable","name":{"kind":"Name","value":"excludedObjectNameSingulars"}}},{"kind":"Argument","name":{"kind":"Name","value":"includedObjectNameSingulars"},"value":{"kind":"Variable","name":{"kind":"Name","value":"includedObjectNameSingulars"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recordId"}},{"kind":"Field","name":{"kind":"Name","value":"objectNameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"objectLabelSingular"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"tsRankCD"}},{"kind":"Field","name":{"kind":"Name","value":"tsRank"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode<SearchQuery, SearchQueryVariables>;
export const ActivateWorkflowVersionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ActivateWorkflowVersion"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workflowVersionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activateWorkflowVersion"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workflowVersionId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workflowVersionId"}}}]}]}}]} as unknown as DocumentNode<ActivateWorkflowVersionMutation, ActivateWorkflowVersionMutationVariables>;
export const ComputeStepOutputSchemaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ComputeStepOutputSchema"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ComputeStepOutputSchemaInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"computeStepOutputSchema"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<ComputeStepOutputSchemaMutation, ComputeStepOutputSchemaMutationVariables>;
@@ -73,7 +73,7 @@ export const EmailComposerFields = ({
<StyledToRow>
<FormMultiTextFieldInput
label={t`To`}
defaultValue={composerState.defaultTo}
defaultValue={composerState.initialTo}
onChange={composerState.setTo}
placeholder={t`Recipients`}
/>
@@ -87,13 +87,13 @@ export const EmailComposerFields = ({
<>
<FormMultiTextFieldInput
label={t`Cc`}
defaultValue=""
defaultValue={composerState.initialCc}
onChange={composerState.setCc}
placeholder={t`Cc`}
/>
<FormMultiTextFieldInput
label={t`Bcc`}
defaultValue=""
defaultValue={composerState.initialBcc}
onChange={composerState.setBcc}
placeholder={t`Bcc`}
/>
@@ -101,12 +101,12 @@ export const EmailComposerFields = ({
)}
<FormTextFieldInput
label={t`Subject`}
defaultValue={composerState.defaultSubject}
defaultValue={composerState.initialSubject}
onChange={composerState.setSubject}
placeholder={t`Subject`}
/>
<FormAdvancedTextFieldInput
defaultValue=""
defaultValue={composerState.initialBody}
onChange={composerState.setBody}
placeholder={t`Type something or press "/" to see commands`}
minHeight={120}
@@ -1,92 +1,96 @@
import { styled } from '@linaria/react';
import { useState } from 'react';
import { EmailThreadMessageBody } from '@/activities/emails/components/EmailThreadMessageBody';
import { EmailThreadMessageBodyPreview } from '@/activities/emails/components/EmailThreadMessageBodyPreview';
import { EmailThreadMessageLayout } from '@/activities/emails/components/EmailThreadMessageLayout';
import { EmailThreadMessageReceivers } from '@/activities/emails/components/EmailThreadMessageReceivers';
import { EmailThreadMessageSender } from '@/activities/emails/components/EmailThreadMessageSender';
import { EmailThreadNotShared } from '@/activities/emails/components/EmailThreadNotShared';
import { type EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
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<{ 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]};
`;
const StyledThreadMessageHeader = styled.div`
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: space-between;
margin-bottom: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
`;
const StyledThreadMessageBody = styled.div`
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
`;
type EmailThreadMessageProps = {
body: string;
sentAt: string;
sender: EmailThreadMessageParticipant;
participants: EmailThreadMessageParticipant[];
message: EmailThreadMessageWithSender;
isExpanded?: boolean;
hideBottomBorder?: boolean;
onDraftClick: (message: EmailThreadMessageWithSender) => void;
};
export const EmailThreadMessage = ({
body,
sentAt,
sender,
participants,
message,
isExpanded = false,
hideBottomBorder = false,
onDraftClick,
}: EmailThreadMessageProps) => {
const [isOpen, setIsOpen] = useState(isExpanded);
const receivers = participants.filter(
const receivers = message.messageParticipants.filter(
(participant) => participant.role !== MessageParticipantRole.FROM,
);
if (!isDefined(sender) || receivers.length === 0) {
if (
!isDefined(message.sender) ||
(!message.isDraft && receivers.length === 0)
) {
return null;
}
const { isDraft } = message;
const isRestricted =
body === FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED;
message.text === FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED;
const handleRowClick = () => {
if (isRestricted) {
return;
}
if (isDraft) {
onDraftClick(message);
return;
}
if (!isOpen) {
setIsOpen(true);
}
};
const handleHeaderClick = () => {
if (!isDraft && isOpen) {
setIsOpen(false);
}
};
return (
<StyledThreadMessage
<EmailThreadMessageLayout
hideBottomBorder={hideBottomBorder}
onClick={() => !isOpen && setIsOpen(true)}
style={{ cursor: isOpen || isRestricted ? 'auto' : 'pointer' }}
>
<StyledThreadMessageHeader onClick={() => isOpen && setIsOpen(false)}>
<EmailThreadMessageSender sender={sender} sentAt={sentAt} />
{isOpen && <EmailThreadMessageReceivers receivers={receivers} />}
</StyledThreadMessageHeader>
<StyledThreadMessageBody>
{isRestricted ? (
<EmailThreadNotShared
visibility={MessageChannelVisibility.METADATA}
isRowClickable={!isRestricted && (isDraft || !isOpen)}
isHeaderClickable={!isDraft && isOpen}
onRowClick={handleRowClick}
onHeaderClick={handleHeaderClick}
header={
<>
<EmailThreadMessageSender
sender={message.sender}
sentAt={message.receivedAt}
/>
) : isOpen ? (
<EmailThreadMessageBody body={body} isDisplayed />
) : (
<EmailThreadMessageBodyPreview body={body} />
)}
</StyledThreadMessageBody>
</StyledThreadMessage>
{!isDraft && isOpen && (
<EmailThreadMessageReceivers receivers={receivers} />
)}
</>
}
>
{isRestricted ? (
<EmailThreadNotShared visibility={MessageChannelVisibility.METADATA} />
) : isDraft || !isOpen ? (
<EmailThreadMessageBodyPreview body={message.text} />
) : (
<EmailThreadMessageBody body={message.text} isDisplayed />
)}
</EmailThreadMessageLayout>
);
};
@@ -0,0 +1,57 @@
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
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]};
`;
const StyledHeader = styled.div<{ isClickable?: boolean }>`
cursor: ${({ isClickable }) => (isClickable ? 'pointer' : 'auto')};
display: flex;
flex-direction: column;
justify-content: space-between;
margin-bottom: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
`;
const StyledBody = styled.div`
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
`;
type EmailThreadMessageLayoutProps = {
header: ReactNode;
children: ReactNode;
hideBottomBorder?: boolean;
isRowClickable?: boolean;
isHeaderClickable?: boolean;
onRowClick?: () => void;
onHeaderClick?: () => void;
};
export const EmailThreadMessageLayout = ({
header,
children,
hideBottomBorder = false,
isRowClickable = false,
isHeaderClickable = false,
onRowClick,
onHeaderClick,
}: EmailThreadMessageLayoutProps) => (
<StyledThreadMessage
hideBottomBorder={hideBottomBorder}
onClick={onRowClick}
style={{ cursor: isRowClickable ? 'pointer' : 'auto' }}
>
<StyledHeader isClickable={isHeaderClickable} onClick={onHeaderClick}>
{header}
</StyledHeader>
<StyledBody>{children}</StyledBody>
</StyledThreadMessage>
);
@@ -5,9 +5,10 @@ import { EmailThreadNotShared } from '@/activities/emails/components/EmailThread
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { useContext } from 'react';
import { t } from '@lingui/core/macro';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/data-display';
import { Avatar, Tag } from 'twenty-ui/data-display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import {
MessageChannelVisibility,
@@ -173,6 +174,9 @@ export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => {
)}
{visibility === MessageChannelVisibility.SHARE_EVERYTHING && (
<>
{thread.lastMessageIsDraft && (
<Tag color="orange" text={t`Draft`} />
)}
<StyledSubject>{thread.subject}</StyledSubject>
<StyledBody>{thread.lastMessageBody}</StyledBody>
</>
@@ -5,6 +5,7 @@ export const SEND_EMAIL = gql`
sendEmail(input: $input) {
success
error
messageThreadId
}
}
`;
@@ -31,6 +31,7 @@ export const fetchAllThreadMessagesOperationSignatureFactory: RecordGqlOperation
subject: true,
text: true,
receivedAt: true,
isDraft: true,
messageThread: {
id: true,
},
@@ -18,6 +18,7 @@ export const timelineThreadFragment = gql`
subject
numberOfMessagesInThread
participantCount
lastMessageIsDraft
}
${participantFragment}
`;
@@ -3,13 +3,15 @@ import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
import { type EmailAttachment } from 'twenty-shared/types';
import { useSendEmail } from '@/activities/emails/hooks/useSendEmail';
import { type EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill';
type UseEmailComposerStateArgs = {
connectedAccountId: string;
draftPrefill?: EmailDraftPrefill | null;
defaultTo?: string;
defaultSubject?: string;
defaultInReplyTo?: string;
onSent?: () => void;
onSent?: (messageThreadId: string | null) => void;
};
const countRecipients = (csv: string): number =>
@@ -20,20 +22,29 @@ const countRecipients = (csv: string): number =>
export const useEmailComposerState = ({
connectedAccountId: initialConnectedAccountId,
draftPrefill,
defaultTo = '',
defaultSubject = '',
defaultInReplyTo,
onSent,
}: UseEmailComposerStateArgs) => {
const initialTo = draftPrefill?.to ?? defaultTo;
const initialCc = draftPrefill?.cc ?? '';
const initialBcc = draftPrefill?.bcc ?? '';
const initialSubject = draftPrefill?.subject ?? defaultSubject;
const initialBody = draftPrefill?.body ?? '';
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 [to, setTo] = useState(initialTo);
const [cc, setCc] = useState(initialCc);
const [bcc, setBcc] = useState(initialBcc);
const [subject, setSubject] = useState(initialSubject);
const [body, setBody] = useState(initialBody);
const [showCcBcc, setShowCcBcc] = useState(
initialCc.length > 0 || initialBcc.length > 0,
);
const [files, setFiles] = useState<EmailAttachment[]>([]);
const { sendEmail, loading } = useSendEmail();
@@ -60,7 +71,7 @@ export const useEmailComposerState = ({
const trimmedCc = cc.trim();
const trimmedBcc = bcc.trim();
const success = await sendEmail({
const { success, messageThreadId } = await sendEmail({
connectedAccountId,
to: trimmedTo,
cc: trimmedCc || undefined,
@@ -68,11 +79,12 @@ export const useEmailComposerState = ({
subject,
body,
inReplyTo: defaultInReplyTo,
draftMessageId: draftPrefill?.messageId,
files: files.length > 0 ? files : undefined,
});
if (success) {
onSent?.();
onSent?.(messageThreadId);
}
}, [
connectedAccountId,
@@ -82,6 +94,7 @@ export const useEmailComposerState = ({
subject,
body,
defaultInReplyTo,
draftPrefill?.messageId,
files,
sendEmail,
onSent,
@@ -108,8 +121,11 @@ export const useEmailComposerState = ({
handleSend,
loading,
canSend,
defaultTo,
defaultSubject,
initialTo,
initialCc,
initialBcc,
initialSubject,
initialBody,
recipientCount,
exceedsRecipientLimit,
maxRecipients: MAX_EMAIL_RECIPIENTS,
@@ -32,15 +32,27 @@ export const useReplyContext = (
return null;
}
const lastMessage = messages[messages.length - 1];
const sentMessages = messages.filter((message) => !message.isDraft);
const lastSentMessage = sentMessages[sentMessages.length - 1];
if (!isDefined(lastMessage)) {
return null;
if (!isDefined(lastSentMessage)) {
if (messages.length === 0) {
return null;
}
return {
loading: false,
to: '',
subject: '',
inReplyTo: '',
connectedAccountId,
connectedAccountProvider,
};
}
const senderHandle = lastMessage.sender?.handle ?? '';
const senderHandle = lastSentMessage.sender?.handle ?? '';
const rawSubject = lastMessage.subject ?? '';
const rawSubject = lastSentMessage.subject ?? '';
const subject = rawSubject.startsWith('Re: ')
? rawSubject
: `Re: ${rawSubject}`;
@@ -49,7 +61,7 @@ export const useReplyContext = (
loading: false,
to: senderHandle,
subject,
inReplyTo: lastMessage.headerMessageId ?? '',
inReplyTo: lastSentMessage.headerMessageId ?? '',
connectedAccountId,
connectedAccountProvider,
};
@@ -12,6 +12,11 @@ import {
type SendEmailMutationVariables,
} from '~/generated-metadata/graphql';
type SendEmailResult = {
success: boolean;
messageThreadId: string | null;
};
type SendEmailParams = {
connectedAccountId: string;
to: string;
@@ -20,6 +25,7 @@ type SendEmailParams = {
subject: string;
body: string;
inReplyTo?: string;
draftMessageId?: string;
files?: EmailAttachment[];
};
@@ -34,7 +40,7 @@ export const useSendEmail = () => {
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const sendEmail = useCallback(
async (params: SendEmailParams): Promise<boolean> => {
async (params: SendEmailParams): Promise<SendEmailResult> => {
try {
const result = await sendEmailMutation({
variables: {
@@ -46,6 +52,7 @@ export const useSendEmail = () => {
subject: params.subject,
body: params.body,
inReplyTo: params.inReplyTo,
draftMessageId: params.draftMessageId,
files: params.files,
},
},
@@ -65,20 +72,23 @@ export const useSendEmail = () => {
],
});
return true;
return {
success: true,
messageThreadId: result.data.sendEmail.messageThreadId ?? null,
};
}
enqueueErrorSnackBar({
message: result.data?.sendEmail.error ?? t`Failed to send email`,
});
return false;
return { success: false, messageThreadId: null };
} catch {
enqueueErrorSnackBar({
message: t`Failed to send email`,
});
return false;
return { success: false, messageThreadId: null };
}
},
[
@@ -0,0 +1,7 @@
import { type EmailRecipients } from 'twenty-shared/workflow';
export type EmailDraftPrefill = EmailRecipients & {
messageId: string;
subject: string;
body: string;
};
@@ -10,5 +10,6 @@ export type EmailThreadMessage = {
messageThreadId: string;
messageParticipants: EmailThreadMessageParticipant[];
messageThread: MessageThread;
isDraft: boolean;
__typename: 'EmailThreadMessage';
};
@@ -0,0 +1,22 @@
import { type EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill';
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
import { MessageParticipantRole } from 'twenty-shared/types';
export const getEmailDraftPrefillFromMessage = (
message: EmailThreadMessageWithSender,
): EmailDraftPrefill => {
const joinHandlesByRole = (role: MessageParticipantRole) =>
message.messageParticipants
.filter((participant) => participant.role === role)
.map((participant) => participant.handle)
.join(', ');
return {
messageId: message.id,
to: joinHandlesByRole(MessageParticipantRole.TO),
cc: joinHandlesByRole(MessageParticipantRole.CC),
bcc: joinHandlesByRole(MessageParticipantRole.BCC),
subject: message.subject,
body: message.text,
};
};
@@ -4,11 +4,15 @@ import { useCallback, 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 { type EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill';
import { EmailThreadComposerFooterEffect } from '@/page-layout/widgets/email-thread/components/EmailThreadComposerFooterEffect';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { type SidePanelFooterCommandMenuItem } from '@/ui/layout/side-panel/types/SidePanelFooterCommandMenuItem';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { t } from '@lingui/core/macro';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconArrowBackUp, IconSend, IconX } from 'twenty-ui/icon';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getOsControlSymbol } from 'twenty-ui/utilities';
@@ -34,6 +38,7 @@ type EmailThreadComposerProps = {
isInSidePanel: boolean;
isComposerOpen: boolean;
setIsComposerOpen: (open: boolean) => void;
draftPrefill?: EmailDraftPrefill | null;
};
export const EmailThreadComposer = ({
@@ -41,13 +46,27 @@ export const EmailThreadComposer = ({
isInSidePanel,
isComposerOpen,
setIsComposerOpen,
draftPrefill,
}: EmailThreadComposerProps) => {
const handleReplySent = useCallback(() => {
setIsComposerOpen(false);
}, [setIsComposerOpen]);
const { openRecordInSidePanel } = useOpenRecordInSidePanel();
const handleReplySent = useCallback(
(messageThreadId: string | null) => {
setIsComposerOpen(false);
if (isDefined(messageThreadId)) {
openRecordInSidePanel({
recordId: messageThreadId,
objectNameSingular: CoreObjectNameSingular.MessageThread,
});
}
},
[setIsComposerOpen, openRecordInSidePanel],
);
const composerState = useEmailComposerState({
connectedAccountId: replyContext.connectedAccountId,
draftPrefill,
defaultTo: replyContext.to,
defaultSubject: replyContext.subject,
defaultInReplyTo: replyContext.inReplyTo,
@@ -15,8 +15,10 @@ const StyledButtonContainer = styled.div`
export const EmailThreadIntermediaryMessages = ({
messages,
onDraftClick,
}: {
messages: EmailThreadMessageWithSender[];
onDraftClick: (message: EmailThreadMessageWithSender) => void;
}) => {
const [areMessagesOpen, setAreMessagesOpen] = useState(false);
const messagesLength = messages.length;
@@ -29,10 +31,8 @@ export const EmailThreadIntermediaryMessages = ({
messages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
message={message}
onDraftClick={onDraftClick}
/>
))
) : (
@@ -1,11 +1,14 @@
import { styled } from '@linaria/react';
import { useState } from 'react';
import { useCallback, 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 EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill';
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
import { getEmailDraftPrefillFromMessage } from '@/activities/emails/utils/getEmailDraftPrefillFromMessage';
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';
@@ -43,7 +46,36 @@ export const EmailThreadWidget = ({
const replyContext = useReplyContext(targetRecord.id);
const [isComposerOpen, setIsComposerOpen] = useState(false);
const [composerIntent, setComposerIntent] = useState<
'opened' | 'closed' | null
>(null);
const [clickedDraftPrefill, setClickedDraftPrefill] =
useState<EmailDraftPrefill | null>(null);
const [previousTargetRecordId, setPreviousTargetRecordId] = useState(
targetRecord.id,
);
if (previousTargetRecordId !== targetRecord.id) {
setPreviousTargetRecordId(targetRecord.id);
setComposerIntent(null);
setClickedDraftPrefill(null);
}
const handleComposerOpenChange = useCallback((open: boolean) => {
setComposerIntent(open ? 'opened' : 'closed');
if (!open) {
setClickedDraftPrefill(null);
}
}, []);
const handleDraftClick = useCallback(
(message: EmailThreadMessageWithSender) => {
setClickedDraftPrefill(getEmailDraftPrefillFromMessage(message));
setComposerIntent('opened');
},
[],
);
const canReply = isDefined(replyContext) && !replyContext.loading;
@@ -58,6 +90,16 @@ export const EmailThreadWidget = ({
: [];
const lastMessage = messages[messagesCount - 1];
const trailingDraft = lastMessage?.isDraft ? lastMessage : undefined;
const draftPrefill =
clickedDraftPrefill ??
(isDefined(trailingDraft)
? getEmailDraftPrefillFromMessage(trailingDraft)
: null);
const isComposerOpen =
composerIntent === 'opened' ||
(composerIntent === null && isDefined(trailingDraft));
if (threadLoading || !thread || !messages.length) {
return (
<StyledWrapper>
@@ -74,21 +116,20 @@ export const EmailThreadWidget = ({
{firstMessages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
message={message}
onDraftClick={handleDraftClick}
/>
))}
<EmailThreadIntermediaryMessages messages={intermediaryMessages} />
<EmailThreadIntermediaryMessages
messages={intermediaryMessages}
onDraftClick={handleDraftClick}
/>
<EmailThreadMessage
key={lastMessage.id}
sender={lastMessage.sender}
participants={lastMessage.messageParticipants}
body={lastMessage.text}
sentAt={lastMessage.receivedAt}
message={lastMessage}
isExpanded
hideBottomBorder={!isComposerOpen}
onDraftClick={handleDraftClick}
/>
<CustomResolverFetchMoreLoader
loading={threadLoading}
@@ -97,10 +138,12 @@ export const EmailThreadWidget = ({
</StyledContainer>
{canReply && (
<EmailThreadComposer
key={draftPrefill?.messageId ?? 'reply'}
replyContext={replyContext}
isInSidePanel={isInSidePanel}
isComposerOpen={isComposerOpen}
setIsComposerOpen={setIsComposerOpen}
setIsComposerOpen={handleComposerOpenChange}
draftPrefill={draftPrefill}
/>
)}
</StyledWrapper>
@@ -1,6 +1,5 @@
import { getInitialEditorContent } from '@/workflow/workflow-variables/utils/getInitialEditorContent';
import type { JSONContent } from '@tiptap/react';
import { logError } from '~/utils/logError';
// Previous format of the email body was plain text,
// but from now on we will save it as JSON.
@@ -33,8 +32,7 @@ export const getInitialAdvancedTextEditorContent = (
}
return json;
} catch (error) {
logError(error);
} catch {
return getInitialEditorContent(rawContent);
}
};
@@ -1,16 +1,21 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { AddMessageIsDraftFieldCommand } from 'src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1810000005000-add-message-is-draft-field.command';
import { NormalizeLegacyIndexNamesCommand } from 'src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1799200000000-normalize-legacy-index-names.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
ApplicationModule,
WorkspaceCacheModule,
WorkspaceIteratorModule,
WorkspaceMigrationModule,
WorkspaceSchemaManagerModule,
],
providers: [NormalizeLegacyIndexNamesCommand],
providers: [AddMessageIsDraftFieldCommand, NormalizeLegacyIndexNamesCommand],
})
export class V2_18_UpgradeVersionCommandModule {}
@@ -0,0 +1,144 @@
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 { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const MESSAGE_OBJECT_UNIVERSAL_IDENTIFIER =
'20202020-3f6b-4425-80ab-e468899ab4b2';
const MESSAGE_IS_DRAFT_FIELD_UNIVERSAL_IDENTIFIER =
'20202020-4d3a-4b6e-9c1f-2a5e7b9d0c34';
@RegisteredWorkspaceCommand('2.18.0', 1810000005000)
@Command({
name: 'upgrade:2-18:add-message-is-draft-field',
description:
'Add the Message isDraft field metadata and column to existing workspaces',
})
export class AddMessageIsDraftFieldCommand 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;
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
'flatObjectMetadataMaps',
]);
const messageObjectMetadata =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier: MESSAGE_OBJECT_UNIVERSAL_IDENTIFIER,
});
if (!isDefined(messageObjectMetadata)) {
this.logger.log(
`Message object does not exist for workspace ${workspaceId}, skipping`,
);
return;
}
const existingIsDraftFieldMetadata =
findFlatEntityByUniversalIdentifier<FlatFieldMetadata>({
flatEntityMaps: flatFieldMetadataMaps,
universalIdentifier: MESSAGE_IS_DRAFT_FIELD_UNIVERSAL_IDENTIFIER,
});
if (isDefined(existingIsDraftFieldMetadata)) {
this.logger.log(
`Message isDraft field already present for workspace ${workspaceId}`,
);
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const isDraftFlatFieldMetadata =
findFlatEntityByUniversalIdentifier<FlatFieldMetadata>({
flatEntityMaps: standardAllFlatEntityMaps.flatFieldMetadataMaps,
universalIdentifier: MESSAGE_IS_DRAFT_FIELD_UNIVERSAL_IDENTIFIER,
});
if (!isDefined(isDraftFlatFieldMetadata)) {
throw new Error(
'Standard application is missing the Message isDraft field metadata',
);
}
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would create Message isDraft field for workspace ${workspaceId}`,
);
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
isSystemBuild: true,
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: [isDraftFlatFieldMetadata],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to create Message isDraft field:\n${JSON.stringify(
validateAndBuildResult,
null,
2,
)}`,
);
throw new Error(
`Failed to create Message isDraft field for workspace ${workspaceId}`,
);
}
this.logger.log(
`Created Message isDraft field for workspace ${workspaceId}`,
);
}
}
@@ -35,4 +35,7 @@ export class TimelineThreadDTO {
@Field()
participantCount: number;
@Field()
lastMessageIsDraft: boolean;
}
@@ -106,6 +106,7 @@ export class TimelineMessagingService {
lastMessageBody: lastMessage.text ?? '',
lastMessageReceivedAt: lastMessage.receivedAt ?? new Date(),
numberOfMessagesInThread: messageThread.messages.length,
lastMessageIsDraft: lastMessage.isDraft ?? false,
};
}),
totalNumberOfThreads,
@@ -415,4 +415,24 @@ export const buildMessageStandardFlatFieldMetadatas = ({
twentyStandardApplicationId,
now,
}),
isDraft: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'isDraft',
type: FieldMetadataType.BOOLEAN,
label: i18nLabel(msg`Is draft`),
description: i18nLabel(
msg`Whether this message is an unsent draft synced from the provider`,
),
icon: 'IconPencil',
isNullable: false,
isUIEditable: false,
defaultValue: false,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
});
@@ -29,6 +29,7 @@ const createMockMessage = (
messageCampaign: null,
messageCampaignId: null,
deliveryStatus: null,
isDraft: false,
deletedAt: null,
createdAt: '2024-03-20T09:00:00Z',
updatedAt: '2024-03-20T09:00:00Z',
@@ -19,4 +19,5 @@ export class MessageWorkspaceEntity extends BaseWorkspaceEntity {
messageCampaign: EntityRelation<MessageCampaignWorkspaceEntity> | null;
messageCampaignId: string | null;
deliveryStatus: string | null;
isDraft: boolean;
}
@@ -1,7 +1,6 @@
import { StandardFolder } from 'src/modules/messaging/message-import-manager/drivers/types/standard-folder';
export const MESSAGING_FOLDER_MANAGER_ALWAYS_EXCLUDED_FOLDERS = [
StandardFolder.DRAFTS,
StandardFolder.TRASH,
StandardFolder.JUNK,
];
@@ -5,6 +5,7 @@ describe('shouldCreateFolderByDefault', () => {
it('should allow creating user folders', () => {
expect(shouldCreateFolderByDefault(StandardFolder.INBOX)).toBe(true);
expect(shouldCreateFolderByDefault(StandardFolder.SENT)).toBe(true);
expect(shouldCreateFolderByDefault(StandardFolder.DRAFTS)).toBe(true);
});
it('should allow creating custom folders', () => {
@@ -13,7 +14,6 @@ describe('shouldCreateFolderByDefault', () => {
});
it('should prevent creating system-excluded folders', () => {
expect(shouldCreateFolderByDefault(StandardFolder.DRAFTS)).toBe(false);
expect(shouldCreateFolderByDefault(StandardFolder.TRASH)).toBe(false);
expect(shouldCreateFolderByDefault(StandardFolder.JUNK)).toBe(false);
});
@@ -1,6 +1 @@
export const MESSAGING_GMAIL_EXCLUDED_SYSTEM_LABELS = [
'TRASH',
'SPAM',
'DRAFT',
'CHAT',
];
export const MESSAGING_GMAIL_EXCLUDED_SYSTEM_LABELS = ['TRASH', 'SPAM', 'CHAT'];
@@ -190,8 +190,8 @@ describe('computeGmailExcludeSearchFilter', () => {
expect(result).toContain('-label:trash');
expect(result).toContain('-label:spam');
expect(result).toContain('-label:draft');
expect(result).toContain('-label:chat');
expect(result).not.toContain('-label:draft');
});
it('uses -category: syntax for category exclusions in ALL_FOLDERS mode', () => {
@@ -152,4 +152,32 @@ describe('parseAndFormatGmailMessage', () => {
expect(result).toBeNull();
});
it('should keep a draft missing a Message-ID header by synthesizing a fallback id', () => {
const result = parseAndFormatGmailMessage(
buildMessage(
[
{ name: 'From', value: 'me@example.com' },
{ name: 'To', value: 'alice@example.com' },
],
{ labelIds: ['DRAFT'] },
),
connectedAccount,
);
expect(result?.isDraft).toBe(true);
expect(result?.headerMessageId).toBe('draft-msg-1');
});
it('should still drop a non-draft message missing a Message-ID header', () => {
const result = parseAndFormatGmailMessage(
buildMessage([
{ name: 'From', value: 'sender@example.com' },
{ name: 'To', value: 'alice@example.com' },
]),
connectedAccount,
);
expect(result).toBeNull();
});
});
@@ -33,7 +33,18 @@ export const parseAndFormatGmailMessage = (
labelIds,
} = parseGmailMessage(message);
if (!isDefined(from) || !isDefined(headerMessageId) || !isDefined(threadId)) {
const isDraft = (labelIds ?? []).includes('DRAFT');
// Gmail may omit the Message-ID header on drafts; synthesize a stable id from
// the message id so drafts aren't dropped.
const resolvedHeaderMessageId =
headerMessageId ?? (isDraft ? `draft-${id}` : undefined);
if (
!isDefined(from) ||
!isDefined(resolvedHeaderMessageId) ||
!isDefined(threadId)
) {
return null;
}
@@ -58,13 +69,13 @@ export const parseAndFormatGmailMessage = (
(participant) => participant.role !== MessageParticipantRole.FROM,
);
if (!hasRecipientParticipant) {
if (!hasRecipientParticipant && !isDraft) {
return null;
}
return {
externalId: id,
headerMessageId,
headerMessageId: resolvedHeaderMessageId,
subject: subject || '',
messageThreadExternalId: threadId,
receivedAt: new Date(parseInt(internalDate)),
@@ -74,5 +85,6 @@ export const parseAndFormatGmailMessage = (
attachments,
messageFolderExternalIds: labelIds,
labelIds,
isDraft,
};
};
@@ -145,6 +145,7 @@ export class ImapGetMessagesService {
folderPath,
folderExternalId,
connectedAccount,
result.flags,
),
);
}
@@ -162,6 +163,7 @@ export class ImapGetMessagesService {
folderPath: string,
folderExternalId: string,
connectedAccount: Pick<ConnectedAccountEntity, 'handle' | 'handleAliases'>,
flags?: Set<string>,
): MessageWithParticipants {
const fromAddresses = extractAddressesFromParsedEmail(parsed.from);
const senderAddress = fromAddresses[0]?.address ?? '';
@@ -184,6 +186,7 @@ export class ImapGetMessagesService {
})),
participants: extractParticipantsFromParsedEmail(parsed),
messageFolderExternalIds: [folderExternalId],
isDraft: flags?.has('\\Draft') ?? false,
};
}
}
@@ -6,6 +6,7 @@ import PostalMime, { type Email as ParsedEmail } from 'postal-mime';
export type MessageParseResult = {
uid: number;
parsed: ParsedEmail | null;
flags?: Set<string>;
error?: Error;
};
@@ -40,7 +41,7 @@ export class ImapMessageParserService {
const messages = await client.fetchAll(
uidSet,
{ uid: true, source: true },
{ uid: true, source: true, flags: true },
{ uid: true },
);
@@ -80,22 +81,22 @@ export class ImapMessageParserService {
private async parseMessage(
message: FetchMessageObject,
): Promise<MessageParseResult> {
const { uid, source } = message;
const { uid, source, flags } = message;
if (!source) {
this.logger.debug(`No source content for message UID ${uid}`);
return { uid, parsed: null };
return { uid, parsed: null, flags };
}
try {
const parsed = await PostalMime.parse(source);
return { uid, parsed };
return { uid, parsed, flags };
} catch (error) {
this.logger.error(`Failed to parse message UID ${uid}: ${error.message}`);
return { uid, parsed: null, error: error as Error };
return { uid, parsed: null, flags, error: error as Error };
}
}
@@ -35,6 +35,7 @@ export class InboundEmailParserService {
direction: MessageDirection.INCOMING,
attachments: [],
participants: extractParticipantsFromParsedEmail(parsedEmail),
isDraft: false,
};
}
}
@@ -99,6 +99,7 @@ describe('Microsoft get messages service', () => {
role: MessageParticipantRole.TO,
},
],
isDraft: false,
attachments: [],
messageFolderExternalIds: responseExample1.body.parentFolderId
? [responseExample1.body.parentFolderId]
@@ -145,6 +146,7 @@ describe('Microsoft get messages service', () => {
role: MessageParticipantRole.CC,
},
],
isDraft: false,
attachments: [],
messageFolderExternalIds: responseExample2.body.parentFolderId
? [responseExample2.body.parentFolderId]
@@ -188,6 +190,7 @@ describe('Microsoft get messages service', () => {
role: MessageParticipantRole.FROM,
},
],
isDraft: false,
attachments: [],
messageFolderExternalIds: responseExample.body.parentFolderId
? [responseExample.body.parentFolderId]
@@ -163,6 +163,7 @@ export class MicrosoftGetMessagesService {
messageFolderExternalIds: response.parentFolderId
? [response.parentFolderId]
: [],
isDraft: response.isDraft ?? false,
};
});
@@ -24,6 +24,7 @@ type MessageAccumulator = {
| 'receivedAt'
| 'text'
| 'messageThreadId'
| 'isDraft'
>;
threadToCreate?: Pick<MessageThreadWorkspaceEntity, 'id' | 'subject'>;
messageChannelMessageAssociationToCreate?: Pick<
@@ -168,6 +169,7 @@ export class MessagingMessageService {
receivedAt: message.receivedAt,
text: message.text,
messageThreadId,
isDraft: message.isDraft,
};
messageAccumulator.messageToCreate = messageToCreate;
@@ -55,6 +55,7 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
text: 'Test content 1',
receivedAt: new Date(),
attachments: [],
isDraft: false,
messageThreadExternalId: 'thread-1',
direction: MessageDirection.OUTGOING,
participants: [
@@ -77,6 +78,7 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
text: 'Test content 2',
receivedAt: new Date(),
attachments: [],
isDraft: false,
messageThreadExternalId: 'thread-1',
direction: MessageDirection.INCOMING,
participants: [
@@ -329,4 +331,40 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
},
);
});
it('should not create contacts for unsent drafts', async () => {
await service.saveMessagesAndEnqueueContactCreation(
[
{
...mockMessages[0],
isDraft: true,
participants: [
{
role: MessageParticipantRole.FROM,
handle: 'test@example.com',
displayName: 'Test User',
},
{
role: MessageParticipantRole.TO,
handle: 'prospect@company.com',
displayName: 'Prospect',
},
],
},
],
mockMessageChannel,
mockConnectedAccount,
workspaceId,
);
expect(messageQueueService.add).toHaveBeenCalledWith(
CreateCompanyAndContactJob.name,
{
workspaceId,
connectedAccount: mockConnectedAccount,
source: FieldActorSource.EMAIL,
contactsToCreate: [],
},
);
});
});
@@ -96,7 +96,10 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
messageChannel.excludeNonProfessionalEmails &&
!isWorkEmail(participant.handle);
// Drafts are outgoing, so don't turn recipients of an
// unsent email into CRM contacts.
const shouldCreateContact =
!message.isDraft &&
!!participant.handle &&
!isParticipantConnectedAccount &&
!isExcludedByNonProfessionalEmails &&
@@ -25,6 +25,7 @@ export const messagingGetMessagesServiceGetMessages = [
},
],
attachments: [],
isDraft: false,
},
{
externalId: 'AA-work-emails-external',
@@ -47,6 +48,7 @@ export const messagingGetMessagesServiceGetMessages = [
},
],
attachments: [],
isDraft: false,
},
{
externalId: 'AA-personal-emails',
@@ -69,5 +71,6 @@ export const messagingGetMessagesServiceGetMessages = [
},
],
attachments: [],
isDraft: false,
},
] satisfies MessageWithParticipants[];
@@ -93,6 +93,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
{
externalId: 'support-message',
@@ -110,6 +111,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
{
externalId: 'regular-message',
@@ -127,6 +129,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
];
@@ -155,6 +158,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
];
@@ -176,6 +180,7 @@ describe('filterEmails', () => {
direction: MessageDirection.INCOMING,
participants: undefined as any,
attachments: [],
isDraft: false,
},
];
@@ -203,6 +208,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
];
@@ -236,6 +242,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
{
externalId: 'alias-sent-message',
@@ -258,6 +265,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
{
externalId: 'reply-from-john',
@@ -280,6 +288,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
{
externalId: 'incoming-from-noreply',
@@ -302,6 +311,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
];
@@ -71,6 +71,13 @@ export class EmailGroupMessageOutboundService implements MessageOutboundDriver {
);
}
async sendDraft(): Promise<SendMessageResult> {
throw new MessageChannelException(
'Email handle channels do not support drafts.',
MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT,
);
}
private async resolveEmailingDomain(
connectedAccount: ConnectedAccountEntity,
): Promise<EmailingDomainEntity> {
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { type gmail_v1, google } from 'googleapis';
@@ -17,6 +17,8 @@ import { toMailComposerOptions } from 'src/modules/messaging/message-outbound-ma
@Injectable()
export class GmailMessageOutboundService implements MessageOutboundDriver {
private readonly logger = new Logger(GmailMessageOutboundService.name);
constructor(
private readonly googleOAuth2ClientProvider: GoogleOAuth2ClientProvider,
) {}
@@ -67,6 +69,78 @@ export class GmailMessageOutboundService implements MessageOutboundDriver {
});
}
async sendDraft(
draftExternalId: string,
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<SendMessageResult> {
const sendResult = await this.sendMessage(
sendMessageInput,
connectedAccount,
);
try {
await this.deleteDraftByMessageId(connectedAccount, draftExternalId);
} catch (error) {
this.logger.warn(
`Failed to delete Gmail draft for message ${draftExternalId} after send: ${error}`,
);
}
return sendResult;
}
private async deleteDraftByMessageId(
connectedAccount: ConnectedAccountEntity,
messageId: string,
): Promise<void> {
const oAuth2Client = await this.googleOAuth2ClientProvider.getClient(
connectedAccount.id,
);
const gmailClient = google.gmail({ version: 'v1', auth: oAuth2Client });
const draftId = await this.findDraftIdByMessageId(gmailClient, messageId);
if (isDefined(draftId)) {
await gmailClient.users.drafts.delete({ userId: 'me', id: draftId });
return;
}
this.logger.warn(
`No Gmail draft found for message ${messageId}; skipping delete`,
);
}
private async findDraftIdByMessageId(
gmailClient: gmail_v1.Gmail,
messageId: string,
): Promise<string | undefined> {
let pageToken: string | undefined = undefined;
do {
const { data }: { data: gmail_v1.Schema$ListDraftsResponse } =
await gmailClient.users.drafts.list({
userId: 'me',
maxResults: 500,
pageToken,
});
const draft = (data.drafts ?? []).find(
(currentDraft) => currentDraft.message?.id === messageId,
);
if (isDefined(draft?.id)) {
return draft.id;
}
pageToken = data.nextPageToken ?? undefined;
} while (isDefined(pageToken));
return undefined;
}
private async composeGmailMessage(
connectedAccount: ConnectedAccountEntity,
sendMessageInput: SendMessageInput,
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import MailComposer from 'nodemailer/lib/mail-composer';
@@ -13,6 +13,7 @@ import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connect
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
import { ImapFindDraftsFolderService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-drafts-folder.service';
import { getImapFolderPath } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/get-imap-folder-path.util';
import { parseMessageId } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-message-id.util';
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';
@@ -21,6 +22,8 @@ import { toMailComposerOptions } from 'src/modules/messaging/message-outbound-ma
@Injectable()
export class ImapSmtpMessageOutboundService implements MessageOutboundDriver {
private readonly logger = new Logger(ImapSmtpMessageOutboundService.name);
constructor(
private readonly smtpClientProvider: SmtpClientProvider,
private readonly imapClientProvider: ImapClientProvider,
@@ -131,6 +134,56 @@ export class ImapSmtpMessageOutboundService implements MessageOutboundDriver {
}
}
async sendDraft(
draftExternalId: string,
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<SendMessageResult> {
const sendResult = await this.sendMessage(
sendMessageInput,
connectedAccount,
);
try {
await this.deleteDraft(draftExternalId, connectedAccount);
} catch (error) {
this.logger.warn(
`Failed to delete IMAP draft ${draftExternalId} after send: ${error}`,
);
}
return sendResult;
}
async deleteDraft(
externalId: string,
connectedAccount: ConnectedAccountEntity,
): Promise<void> {
const parsedMessageId = parseMessageId(externalId);
if (!isDefined(parsedMessageId)) {
throw new Error(
`Could not resolve IMAP drafts folder and uid from external id ${externalId}`,
);
}
const imapClient = await this.imapClientProvider.getClient(
connectedAccount.id,
);
try {
const lock = await imapClient.getMailboxLock(parsedMessageId.folder);
try {
await imapClient.messageDelete(`${parsedMessageId.uid}`, { uid: true });
} finally {
lock.release();
}
} finally {
await this.imapClientProvider.closeClient(imapClient);
}
}
private async compileRawMessage(
from: string,
sendMessageInput: SendMessageInput,
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { type MessageOutboundDriver } from 'src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface';
@@ -12,6 +12,8 @@ import { isDefined } from 'twenty-shared/utils';
@Injectable()
export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
private readonly logger = new Logger(MicrosoftMessageOutboundService.name);
constructor(
private readonly microsoftOAuth2ClientProvider: MicrosoftOAuth2ClientProvider,
) {}
@@ -50,6 +52,32 @@ export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
await this.createDraftMessage(microsoftClient, sendMessageInput);
}
async sendDraft(
draftExternalId: string,
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<SendMessageResult> {
const sendResult = await this.sendMessage(
sendMessageInput,
connectedAccount,
);
const microsoftClient = await this.microsoftOAuth2ClientProvider.getClient(
connectedAccount.id,
);
await microsoftClient
.api(`/me/messages/${draftExternalId}`)
.delete()
.catch((error) =>
this.logger.warn(
`Failed to delete Microsoft draft ${draftExternalId} after send: ${error}`,
),
);
return sendResult;
}
private async createDraftMessage(
microsoftClient: MicrosoftGraphClient,
sendMessageInput: SendMessageInput,
@@ -7,4 +7,7 @@ export class SendEmailOutputDTO {
@Field(() => String, { nullable: true })
error?: string;
@Field(() => String, { nullable: true })
messageThreadId?: string;
}
@@ -32,6 +32,9 @@ export class SendEmailInput {
@Field(() => String, { nullable: true })
inReplyTo?: string;
@Field(() => String, { nullable: true })
draftMessageId?: string;
@Field(() => [SendEmailAttachmentInput], { nullable: true })
files?: SendEmailAttachmentInput[];
}
@@ -12,4 +12,10 @@ export type MessageOutboundDriver = {
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<void>;
sendDraft(
draftExternalId: string,
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<SendMessageResult>;
};
@@ -10,10 +10,12 @@ import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-
import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module';
import { MessagingSmtpDriverModule } from 'src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module';
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
import { MessagingMessageCleanerModule } from 'src/modules/messaging/message-cleaner/messaging-message-cleaner.module';
import { EmailGroupMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/email-group/services/email-group-message-outbound.service';
import { GmailMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service';
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 { MessagingDraftSendService } from 'src/modules/messaging/message-outbound-manager/services/messaging-draft-send.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';
@@ -24,6 +26,7 @@ import { SentMessagePersistenceService } from 'src/modules/messaging/message-out
MessagingIMAPDriverModule,
MessagingSmtpDriverModule,
MessagingImportManagerModule,
MessagingMessageCleanerModule,
EmailingModule,
TypeOrmModule.forFeature([
MessageChannelEntity,
@@ -37,12 +40,14 @@ import { SentMessagePersistenceService } from 'src/modules/messaging/message-out
ImapSmtpMessageOutboundService,
EmailGroupMessageOutboundService,
MessagingMessageOutboundService,
MessagingDraftSendService,
SendEmailService,
SentMessagePersistenceService,
provideWorkspaceScopedRepository(EmailingDomainEntity),
],
exports: [
MessagingMessageOutboundService,
MessagingDraftSendService,
SendEmailService,
SentMessagePersistenceService,
],
@@ -23,6 +23,8 @@ import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/con
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';
import { isDefined } from 'twenty-shared/utils';
import { isNonEmptyString } from '@sniptt/guards';
@MetadataResolver()
@UsePipes(ResolverValidationPipe)
@@ -79,26 +81,60 @@ export class SendEmailResolver {
const { data } = result;
const sendResult = await this.sendEmailService.sendComposedEmail(data);
const sendResult = isDefined(input.draftMessageId)
? await this.sendEmailService.sendComposedDraft(
data,
input.draftMessageId,
workspace.id,
)
: await this.sendEmailService.sendComposedEmail(data);
if (data.shouldPersistMessage) {
await this.sendEmailService.persistSentMessage(
sendResult,
data,
workspace.id,
let messageThreadId: string | undefined;
try {
if (data.shouldPersistMessage) {
await this.sendEmailService.persistSentMessage(
sendResult,
data,
workspace.id,
);
}
if (isDefined(input.draftMessageId)) {
await this.sendEmailService.deleteSentDraft(
input.draftMessageId,
input.connectedAccountId,
workspace.id,
);
}
const sentMessageExternalId =
sendResult.messageExternalId ?? sendResult.headerMessageId;
messageThreadId =
isDefined(input.draftMessageId) &&
isNonEmptyString(sentMessageExternalId)
? await this.sendEmailService.getSentMessageThreadId(
sentMessageExternalId,
workspace.id,
)
: undefined;
const attachmentFileIds = (input.files ?? []).map((file) => file.id);
if (attachmentFileIds.length > 0) {
await this.fileEmailAttachmentService.deleteFiles({
fileIds: attachmentFileIds,
workspaceId: workspace.id,
});
}
} catch (postSendError) {
this.logger.warn(
`Email sent but post-send cleanup failed (sync will recover): ${postSendError}`,
);
}
const attachmentFileIds = (input.files ?? []).map((file) => file.id);
if (attachmentFileIds.length > 0) {
await this.fileEmailAttachmentService.deleteFiles({
fileIds: attachmentFileIds,
workspaceId: workspace.id,
});
}
return { success: true };
return { success: true, messageThreadId };
} catch (error) {
if (error instanceof ForbiddenException) {
throw error;
@@ -0,0 +1,171 @@
import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { In } from 'typeorm';
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 { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { MessagingMessageCleanerService } from 'src/modules/messaging/message-cleaner/services/messaging-message-cleaner.service';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
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';
@Injectable()
export class MessagingDraftSendService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly messageOutboundService: MessagingMessageOutboundService,
private readonly messageCleanerService: MessagingMessageCleanerService,
) {}
async sendDraftMessage({
draftMessageId,
sendMessageInput,
connectedAccount,
workspaceId,
}: {
draftMessageId: string;
sendMessageInput: SendMessageInput;
connectedAccount: ConnectedAccountEntity;
workspaceId: string;
}): Promise<SendMessageResult> {
const draftAssociation = await this.resolveDraftAssociation(
draftMessageId,
connectedAccount.id,
workspaceId,
);
if (!isDefined(draftAssociation)) {
throw new Error(
`Could not find a synced draft to send for message ${draftMessageId}`,
);
}
return this.messageOutboundService.sendDraft(
draftAssociation.messageExternalId,
sendMessageInput,
connectedAccount,
);
}
async getSentMessageThreadId({
messageExternalId,
workspaceId,
}: {
messageExternalId: string;
workspaceId: string;
}): Promise<string | undefined> {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const messageChannelMessageAssociationRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
workspaceId,
'messageChannelMessageAssociation',
);
const association =
await messageChannelMessageAssociationRepository.findOne({
where: { messageExternalId },
relations: ['message'],
});
return association?.message?.messageThreadId ?? undefined;
},
authContext,
{ lite: true },
);
}
async deleteSentDraft({
draftMessageId,
connectedAccountId,
workspaceId,
}: {
draftMessageId: string;
connectedAccountId: string;
workspaceId: string;
}): Promise<void> {
const draftAssociation = await this.resolveDraftAssociation(
draftMessageId,
connectedAccountId,
workspaceId,
);
if (!isDefined(draftAssociation)) {
return;
}
await this.messageCleanerService.deleteMessagesChannelMessageAssociationsAndRelatedOrphans(
{
workspaceId,
messageExternalIds: [draftAssociation.messageExternalId],
messageChannelId: draftAssociation.messageChannelId,
},
);
}
// Scoped to the caller's own channels so a member cannot act on another
// member's draft by passing its message id.
private async resolveDraftAssociation(
draftMessageId: string,
connectedAccountId: string,
workspaceId: string,
): Promise<{ messageExternalId: string; messageChannelId: string } | null> {
const authContext = buildSystemAuthContext(workspaceId);
const associations =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const channels = await messageChannelRepository.find({
where: { connectedAccountId },
});
const channelIds = channels.map((channel) => channel.id);
if (channelIds.length === 0) {
return [];
}
const messageChannelMessageAssociationRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
workspaceId,
'messageChannelMessageAssociation',
);
return messageChannelMessageAssociationRepository.find({
where: {
messageId: draftMessageId,
messageChannelId: In(channelIds),
},
});
},
authContext,
{ lite: true },
);
const association = associations.find((currentAssociation) =>
isNonEmptyString(currentAssociation.messageExternalId),
);
if (!association || !isNonEmptyString(association.messageExternalId)) {
return null;
}
return {
messageExternalId: association.messageExternalId,
messageChannelId: association.messageChannelId,
};
}
}
@@ -93,4 +93,43 @@ export class MessagingMessageOutboundService {
);
}
}
public async sendDraft(
draftExternalId: string,
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<SendMessageResult> {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return this.gmailMessageOutboundService.sendDraft(
draftExternalId,
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.MICROSOFT:
return this.microsoftMessageOutboundService.sendDraft(
draftExternalId,
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return this.imapSmtpMessageOutboundService.sendDraft(
draftExternalId,
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.EMAIL_GROUP:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.APP:
throw new Error(
`Provider ${connectedAccount.provider} does not support sending drafts`,
);
default:
assertUnreachable(
connectedAccount.provider,
`Provider ${connectedAccount.provider} not supported for sending drafts`,
);
}
}
}
@@ -1,8 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { type ComposedEmail } from 'src/engine/core-modules/tool/tools/email-tool/types/composed-email.type';
import { MessagingDraftSendService } from 'src/modules/messaging/message-outbound-manager/services/messaging-draft-send.service';
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 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()
@@ -11,27 +13,67 @@ export class SendEmailService {
constructor(
private readonly messageOutboundService: MessagingMessageOutboundService,
private readonly messagingDraftSendService: MessagingDraftSendService,
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,
references: data.references,
},
this.toSendMessageInput(data),
data.connectedAccount,
);
}
async sendComposedDraft(
data: ComposedEmail,
draftMessageId: string,
workspaceId: string,
): Promise<SendMessageResult> {
return this.messagingDraftSendService.sendDraftMessage({
draftMessageId,
sendMessageInput: this.toSendMessageInput(data),
connectedAccount: data.connectedAccount,
workspaceId,
});
}
async deleteSentDraft(
draftMessageId: string,
connectedAccountId: string,
workspaceId: string,
): Promise<void> {
await this.messagingDraftSendService.deleteSentDraft({
draftMessageId,
connectedAccountId,
workspaceId,
});
}
async getSentMessageThreadId(
messageExternalId: string,
workspaceId: string,
): Promise<string | undefined> {
return this.messagingDraftSendService.getSentMessageThreadId({
messageExternalId,
workspaceId,
});
}
private toSendMessageInput(data: ComposedEmail): SendMessageInput {
return {
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,
references: data.references,
};
}
async persistSentMessage(
sendResult: SendMessageResult,
data: ComposedEmail,
@@ -55,5 +55,6 @@ export const formatSentMessage = (
direction: MessageDirection.OUTGOING,
attachments: [],
participants,
isDraft: false,
};
};
@@ -1480,6 +1480,9 @@ export const STANDARD_OBJECTS = {
deliveryStatus: {
universalIdentifier: '209254fa-2b89-429d-a72a-c401c4bd5a78',
},
isDraft: {
universalIdentifier: '20202020-4d3a-4b6e-9c1f-2a5e7b9d0c34',
},
createdBy: {
universalIdentifier: '6e52bde4-ed41-4462-aa70-121e496270b4',
},