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:
@@ -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>;
|
||||
|
||||
+5
-5
@@ -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}
|
||||
|
||||
+60
-56
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+57
@@ -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
-1
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ export const fetchAllThreadMessagesOperationSignatureFactory: RecordGqlOperation
|
||||
subject: true,
|
||||
text: true,
|
||||
receivedAt: true,
|
||||
isDraft: true,
|
||||
messageThread: {
|
||||
id: true,
|
||||
},
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ export const timelineThreadFragment = gql`
|
||||
subject
|
||||
numberOfMessagesInThread
|
||||
participantCount
|
||||
lastMessageIsDraft
|
||||
}
|
||||
${participantFragment}
|
||||
`;
|
||||
|
||||
+27
-11
@@ -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';
|
||||
};
|
||||
|
||||
+22
@@ -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,
|
||||
};
|
||||
};
|
||||
+22
-3
@@ -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,
|
||||
|
||||
+4
-4
@@ -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}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
+55
-12
@@ -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
-3
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
+6
-1
@@ -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 {}
|
||||
|
||||
+144
@@ -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;
|
||||
}
|
||||
|
||||
+1
@@ -106,6 +106,7 @@ export class TimelineMessagingService {
|
||||
lastMessageBody: lastMessage.text ?? '',
|
||||
lastMessageReceivedAt: lastMessage.receivedAt ?? new Date(),
|
||||
numberOfMessagesInThread: messageThread.messages.length,
|
||||
lastMessageIsDraft: lastMessage.isDraft ?? false,
|
||||
};
|
||||
}),
|
||||
totalNumberOfThreads,
|
||||
|
||||
+940
-937
File diff suppressed because it is too large
Load Diff
+20
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
+1
@@ -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',
|
||||
|
||||
+1
@@ -19,4 +19,5 @@ export class MessageWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
messageCampaign: EntityRelation<MessageCampaignWorkspaceEntity> | null;
|
||||
messageCampaignId: string | null;
|
||||
deliveryStatus: string | null;
|
||||
isDraft: boolean;
|
||||
}
|
||||
|
||||
-1
@@ -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,
|
||||
];
|
||||
|
||||
+1
-1
@@ -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,6 +1 @@
|
||||
export const MESSAGING_GMAIL_EXCLUDED_SYSTEM_LABELS = [
|
||||
'TRASH',
|
||||
'SPAM',
|
||||
'DRAFT',
|
||||
'CHAT',
|
||||
];
|
||||
export const MESSAGING_GMAIL_EXCLUDED_SYSTEM_LABELS = ['TRASH', 'SPAM', 'CHAT'];
|
||||
|
||||
+1
-1
@@ -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', () => {
|
||||
|
||||
+28
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+15
-3
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+3
@@ -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
-5
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ export class InboundEmailParserService {
|
||||
direction: MessageDirection.INCOMING,
|
||||
attachments: [],
|
||||
participants: extractParticipantsFromParsedEmail(parsedEmail),
|
||||
isDraft: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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]
|
||||
|
||||
+1
@@ -163,6 +163,7 @@ export class MicrosoftGetMessagesService {
|
||||
messageFolderExternalIds: response.parentFolderId
|
||||
? [response.parentFolderId]
|
||||
: [],
|
||||
isDraft: response.isDraft ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+38
@@ -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: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+3
@@ -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 &&
|
||||
|
||||
+3
@@ -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[];
|
||||
|
||||
+10
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+7
@@ -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> {
|
||||
|
||||
+75
-1
@@ -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,
|
||||
|
||||
+54
-1
@@ -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,
|
||||
|
||||
+29
-1
@@ -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,
|
||||
|
||||
+3
@@ -7,4 +7,7 @@ export class SendEmailOutputDTO {
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
error?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
messageThreadId?: string;
|
||||
}
|
||||
|
||||
+3
@@ -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[];
|
||||
}
|
||||
|
||||
+6
@@ -12,4 +12,10 @@ export type MessageOutboundDriver = {
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountEntity,
|
||||
): Promise<void>;
|
||||
|
||||
sendDraft(
|
||||
draftExternalId: string,
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountEntity,
|
||||
): Promise<SendMessageResult>;
|
||||
};
|
||||
|
||||
+5
@@ -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,
|
||||
],
|
||||
|
||||
+52
-16
@@ -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;
|
||||
|
||||
+171
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+39
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
-12
@@ -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,
|
||||
|
||||
+1
@@ -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',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user