mirror of
https://github.com/hcengineering/platform.git
synced 2025-06-09 09:20:54 +00:00
UBERF-11411: Add communication threads for emails (#9156)
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / uitest-workspaces (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / uitest-workspaces (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions
* UBERF-11411: Add communication threads for emails Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11411: Clean up Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11411: Update test Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11411: Fix formatting Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11411: Do not create thread for reply Signed-off-by: Artem Savchenko <armisav@gmail.com> --------- Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
parent
94e9d0cbf1
commit
c20116c631
@ -141,9 +141,10 @@ describe('ChannelCache', () => {
|
|||||||
const error = new Error('Database error')
|
const error = new Error('Database error')
|
||||||
mockClient.findOne.mockRejectedValue(error)
|
mockClient.findOne.mockRejectedValue(error)
|
||||||
|
|
||||||
const result = await channelCache.getOrCreateChannel(spaceId, participants, emailAccount, personId)
|
await expect(channelCache.getOrCreateChannel(spaceId, participants, emailAccount, personId)).rejects.toThrow(
|
||||||
|
'Failed to create channel for test@example.com in space test-space-id: Database error'
|
||||||
|
)
|
||||||
|
|
||||||
expect(result).toBeUndefined()
|
|
||||||
expect(mockCtx.error).toHaveBeenCalledWith('Failed to create channel', {
|
expect(mockCtx.error).toHaveBeenCalledWith('Failed to create channel', {
|
||||||
me: emailAccount,
|
me: emailAccount,
|
||||||
space: spaceId,
|
space: spaceId,
|
||||||
|
@ -13,7 +13,8 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
import { MeasureContext, PersonId, Ref, TxOperations, Doc, WorkspaceUuid, generateId } from '@hcengineering/core'
|
import { MeasureContext, PersonId, Ref, TxOperations, WorkspaceUuid, generateId } from '@hcengineering/core'
|
||||||
|
import { type Card } from '@hcengineering/card'
|
||||||
import chat from '@hcengineering/chat'
|
import chat from '@hcengineering/chat'
|
||||||
import mail from '@hcengineering/mail'
|
import mail from '@hcengineering/mail'
|
||||||
import { PersonSpace } from '@hcengineering/contact'
|
import { PersonSpace } from '@hcengineering/contact'
|
||||||
@ -27,7 +28,7 @@ const createMutex = new SyncMutex()
|
|||||||
*/
|
*/
|
||||||
export class ChannelCache {
|
export class ChannelCache {
|
||||||
// Key is `${spaceId}:${normalizedEmail}`
|
// Key is `${spaceId}:${normalizedEmail}`
|
||||||
private readonly cache = new Map<string, Ref<Doc>>()
|
private readonly cache = new Map<string, Ref<Card>>()
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
private readonly ctx: MeasureContext,
|
private readonly ctx: MeasureContext,
|
||||||
@ -43,7 +44,7 @@ export class ChannelCache {
|
|||||||
participants: PersonId[],
|
participants: PersonId[],
|
||||||
email: string,
|
email: string,
|
||||||
owner: PersonId
|
owner: PersonId
|
||||||
): Promise<Ref<Doc> | undefined> {
|
): Promise<Ref<Card>> {
|
||||||
const normalizedEmail = normalizeEmail(email)
|
const normalizedEmail = normalizeEmail(email)
|
||||||
const cacheKey = `${spaceId}:${normalizedEmail}`
|
const cacheKey = `${spaceId}:${normalizedEmail}`
|
||||||
|
|
||||||
@ -78,7 +79,7 @@ export class ChannelCache {
|
|||||||
participants: PersonId[],
|
participants: PersonId[],
|
||||||
email: string,
|
email: string,
|
||||||
personId: PersonId
|
personId: PersonId
|
||||||
): Promise<Ref<Doc> | undefined> {
|
): Promise<Ref<Card>> {
|
||||||
const normalizedEmail = normalizeEmail(email)
|
const normalizedEmail = normalizeEmail(email)
|
||||||
try {
|
try {
|
||||||
// First try to find existing channel
|
// First try to find existing channel
|
||||||
@ -86,7 +87,7 @@ export class ChannelCache {
|
|||||||
|
|
||||||
if (channel != null) {
|
if (channel != null) {
|
||||||
this.ctx.info('Using existing channel', { me: normalizedEmail, space, channel: channel._id })
|
this.ctx.info('Using existing channel', { me: normalizedEmail, space, channel: channel._id })
|
||||||
return channel._id
|
return channel._id as Ref<Card>
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.createNewChannel(space, participants, normalizedEmail, personId)
|
return await this.createNewChannel(space, participants, normalizedEmail, personId)
|
||||||
@ -101,7 +102,9 @@ export class ChannelCache {
|
|||||||
// Remove failed lookup from cache
|
// Remove failed lookup from cache
|
||||||
this.cache.delete(`${space}:${normalizedEmail}`)
|
this.cache.delete(`${space}:${normalizedEmail}`)
|
||||||
|
|
||||||
return undefined
|
throw new Error(
|
||||||
|
`Failed to create channel for ${normalizedEmail} in space ${space}: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,7 +113,7 @@ export class ChannelCache {
|
|||||||
participants: PersonId[],
|
participants: PersonId[],
|
||||||
email: string,
|
email: string,
|
||||||
personId: PersonId
|
personId: PersonId
|
||||||
): Promise<Ref<Doc> | undefined> {
|
): Promise<Ref<Card>> {
|
||||||
const normalizedEmail = normalizeEmail(email)
|
const normalizedEmail = normalizeEmail(email)
|
||||||
const mutexKey = `channel:${this.workspace}:${space}:${normalizedEmail}`
|
const mutexKey = `channel:${this.workspace}:${space}:${normalizedEmail}`
|
||||||
const releaseLock = await createMutex.lock(mutexKey)
|
const releaseLock = await createMutex.lock(mutexKey)
|
||||||
@ -124,7 +127,7 @@ export class ChannelCache {
|
|||||||
space,
|
space,
|
||||||
channel: existingChannel._id
|
channel: existingChannel._id
|
||||||
})
|
})
|
||||||
return existingChannel._id
|
return existingChannel._id as Ref<Card>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new channel if it doesn't exist
|
// Create new channel if it doesn't exist
|
||||||
@ -156,7 +159,7 @@ export class ChannelCache {
|
|||||||
personId
|
personId
|
||||||
)
|
)
|
||||||
|
|
||||||
return channelId
|
return channelId as Ref<Card>
|
||||||
} finally {
|
} finally {
|
||||||
releaseLock()
|
releaseLock()
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,7 @@ import { Producer } from 'kafkajs'
|
|||||||
|
|
||||||
import { WorkspaceLoginInfo } from '@hcengineering/account-client'
|
import { WorkspaceLoginInfo } from '@hcengineering/account-client'
|
||||||
import { type Card } from '@hcengineering/card'
|
import { type Card } from '@hcengineering/card'
|
||||||
import { MessageType } from '@hcengineering/communication-types'
|
import { MessageID, MessageType } from '@hcengineering/communication-types'
|
||||||
import chat from '@hcengineering/chat'
|
import chat from '@hcengineering/chat'
|
||||||
import { PersonSpace } from '@hcengineering/contact'
|
import { PersonSpace } from '@hcengineering/contact'
|
||||||
import {
|
import {
|
||||||
@ -25,20 +25,25 @@ import {
|
|||||||
type PersonId,
|
type PersonId,
|
||||||
type Ref,
|
type Ref,
|
||||||
type TxOperations,
|
type TxOperations,
|
||||||
Doc,
|
AccountUuid,
|
||||||
generateId,
|
generateId,
|
||||||
RateLimiter,
|
RateLimiter
|
||||||
Space
|
|
||||||
} from '@hcengineering/core'
|
} from '@hcengineering/core'
|
||||||
import mail from '@hcengineering/mail'
|
|
||||||
import { type KeyValueClient } from '@hcengineering/kvs-client'
|
import { type KeyValueClient } from '@hcengineering/kvs-client'
|
||||||
|
|
||||||
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
|
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
|
||||||
import { MessageRequestEventType } from '@hcengineering/communication-sdk-types'
|
import {
|
||||||
|
AddCollaboratorsEvent,
|
||||||
|
MessageRequestEventType,
|
||||||
|
CreateFileEvent,
|
||||||
|
CreateMessageEvent,
|
||||||
|
CreateThreadEvent,
|
||||||
|
NotificationRequestEventType
|
||||||
|
} from '@hcengineering/communication-sdk-types'
|
||||||
import { generateMessageId } from '@hcengineering/communication-shared'
|
import { generateMessageId } from '@hcengineering/communication-shared'
|
||||||
|
|
||||||
import { BaseConfig, type Attachment } from './types'
|
import { BaseConfig, type Attachment } from './types'
|
||||||
import { EmailMessage, MailRecipient } from './types'
|
import { EmailMessage, MailRecipient, MessageData } from './types'
|
||||||
import { getMdContent } from './utils'
|
import { getMdContent } from './utils'
|
||||||
import { PersonCacheFactory } from './person'
|
import { PersonCacheFactory } from './person'
|
||||||
import { PersonSpacesCacheFactory } from './personSpaces'
|
import { PersonSpacesCacheFactory } from './personSpaces'
|
||||||
@ -161,8 +166,7 @@ export async function createMessages (
|
|||||||
subject,
|
subject,
|
||||||
content,
|
content,
|
||||||
attachedBlobs,
|
attachedBlobs,
|
||||||
person.email,
|
person,
|
||||||
person.socialId,
|
|
||||||
message.sendOn,
|
message.sendOn,
|
||||||
channelCache,
|
channelCache,
|
||||||
replyTo
|
replyTo
|
||||||
@ -188,8 +192,7 @@ async function saveMessageToSpaces (
|
|||||||
subject: string,
|
subject: string,
|
||||||
content: string,
|
content: string,
|
||||||
attachments: Attachment[],
|
attachments: Attachment[],
|
||||||
me: string,
|
recipient: MailRecipient,
|
||||||
owner: PersonId,
|
|
||||||
createdDate: number,
|
createdDate: number,
|
||||||
channelCache: ChannelCache,
|
channelCache: ChannelCache,
|
||||||
inReplyTo?: string
|
inReplyTo?: string
|
||||||
@ -197,9 +200,8 @@ async function saveMessageToSpaces (
|
|||||||
const rateLimiter = new RateLimiter(10)
|
const rateLimiter = new RateLimiter(10)
|
||||||
for (const space of spaces) {
|
for (const space of spaces) {
|
||||||
const spaceId = space._id
|
const spaceId = space._id
|
||||||
|
let isReply = false
|
||||||
await rateLimiter.add(async () => {
|
await rateLimiter.add(async () => {
|
||||||
ctx.info('Saving message to space', { mailId, space: spaceId })
|
|
||||||
|
|
||||||
let threadId = await threadLookup.getThreadId(mailId, spaceId)
|
let threadId = await threadLookup.getThreadId(mailId, spaceId)
|
||||||
if (threadId !== undefined) {
|
if (threadId !== undefined) {
|
||||||
ctx.info('Message is already in the thread, skip', { mailId, threadId, spaceId })
|
ctx.info('Message is already in the thread, skip', { mailId, threadId, spaceId })
|
||||||
@ -208,13 +210,10 @@ async function saveMessageToSpaces (
|
|||||||
|
|
||||||
if (inReplyTo !== undefined) {
|
if (inReplyTo !== undefined) {
|
||||||
threadId = await threadLookup.getParentThreadId(inReplyTo, spaceId)
|
threadId = await threadLookup.getParentThreadId(inReplyTo, spaceId)
|
||||||
if (threadId !== undefined) {
|
isReply = threadId !== undefined
|
||||||
ctx.info('Found existing thread', { mailId, threadId, spaceId })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let channel: Ref<Doc<Space>> | undefined
|
const channel = await channelCache.getOrCreateChannel(spaceId, participants, recipient.email, recipient.socialId)
|
||||||
if (threadId === undefined) {
|
if (threadId === undefined) {
|
||||||
channel = await channelCache.getOrCreateChannel(spaceId, participants, me, owner)
|
|
||||||
const newThreadId = await client.createDoc(
|
const newThreadId = await client.createDoc(
|
||||||
chat.masterTag.Thread,
|
chat.masterTag.Thread,
|
||||||
space._id,
|
space._id,
|
||||||
@ -233,78 +232,151 @@ async function saveMessageToSpaces (
|
|||||||
createdDate,
|
createdDate,
|
||||||
modifiedBy
|
modifiedBy
|
||||||
)
|
)
|
||||||
await client.createMixin(
|
|
||||||
newThreadId,
|
|
||||||
chat.masterTag.Thread,
|
|
||||||
space._id,
|
|
||||||
mail.tag.MailThread,
|
|
||||||
{},
|
|
||||||
createdDate,
|
|
||||||
owner
|
|
||||||
)
|
|
||||||
threadId = newThreadId as Ref<Card>
|
threadId = newThreadId as Ref<Card>
|
||||||
ctx.info('Created new thread', { mailId, threadId, spaceId })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageId = generateMessageId()
|
|
||||||
const created = new Date(createdDate)
|
const created = new Date(createdDate)
|
||||||
|
|
||||||
const messageData = Buffer.from(
|
const messageData: MessageData = {
|
||||||
JSON.stringify({
|
subject,
|
||||||
type: MessageRequestEventType.CreateMessage,
|
content,
|
||||||
messageType: MessageType.Message,
|
channel,
|
||||||
card: threadId,
|
created,
|
||||||
cardType: chat.masterTag.Thread,
|
modifiedBy,
|
||||||
content,
|
mailId,
|
||||||
creator: modifiedBy,
|
spaceId,
|
||||||
created,
|
threadId,
|
||||||
id: messageId
|
workspace: wsInfo.workspace,
|
||||||
})
|
recipient,
|
||||||
)
|
isReply
|
||||||
await producer.send({
|
}
|
||||||
topic: config.CommunicationTopic,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
key: Buffer.from(channel ?? spaceId),
|
|
||||||
value: messageData,
|
|
||||||
headers: {
|
|
||||||
WorkspaceUuid: wsInfo.workspace
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
ctx.info('Send message event', { mailId, messageId, threadId })
|
|
||||||
|
|
||||||
const fileData: Buffer[] = attachments.map((a) =>
|
const messageId = await createMailMessage(producer, config, messageData, threadId)
|
||||||
Buffer.from(
|
if (!isReply) {
|
||||||
JSON.stringify({
|
await addCollaborators(producer, config, messageData, threadId)
|
||||||
type: MessageRequestEventType.CreateFile,
|
await createMailThread(producer, config, messageData, messageId)
|
||||||
card: threadId,
|
}
|
||||||
message: messageId,
|
await createFiles(producer, config, attachments, messageData, threadId, messageId)
|
||||||
messageCreated: created,
|
|
||||||
blobId: a.id as Ref<Blob>,
|
|
||||||
fileType: a.contentType,
|
|
||||||
filename: a.name,
|
|
||||||
size: a.data.length,
|
|
||||||
creator: modifiedBy
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const fileEvents = fileData.map((data) => ({
|
|
||||||
key: Buffer.from(channel ?? spaceId),
|
|
||||||
value: data,
|
|
||||||
headers: {
|
|
||||||
WorkspaceUuid: wsInfo.workspace
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
await producer.send({
|
|
||||||
topic: config.CommunicationTopic,
|
|
||||||
messages: fileEvents
|
|
||||||
})
|
|
||||||
ctx.info('Send file events', { mailId, messageId, threadId, count: fileEvents.length })
|
|
||||||
|
|
||||||
await threadLookup.setThreadId(mailId, space._id, threadId)
|
await threadLookup.setThreadId(mailId, space._id, threadId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
await rateLimiter.waitProcessing()
|
await rateLimiter.waitProcessing()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createMailThread (
|
||||||
|
producer: Producer,
|
||||||
|
config: BaseConfig,
|
||||||
|
data: MessageData,
|
||||||
|
messageId: MessageID
|
||||||
|
): Promise<void> {
|
||||||
|
const threadEvent: CreateThreadEvent = {
|
||||||
|
type: MessageRequestEventType.CreateThread,
|
||||||
|
card: data.channel,
|
||||||
|
message: messageId,
|
||||||
|
messageCreated: data.created,
|
||||||
|
thread: data.threadId,
|
||||||
|
threadType: chat.masterTag.Thread
|
||||||
|
}
|
||||||
|
const thread = Buffer.from(JSON.stringify(threadEvent))
|
||||||
|
await sendToCommunicationTopic(producer, config, data, thread)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMailMessage (
|
||||||
|
producer: Producer,
|
||||||
|
config: BaseConfig,
|
||||||
|
data: MessageData,
|
||||||
|
threadId: Ref<Card>
|
||||||
|
): Promise<MessageID> {
|
||||||
|
const messageId = generateMessageId()
|
||||||
|
const createMessageEvent: CreateMessageEvent = {
|
||||||
|
type: MessageRequestEventType.CreateMessage,
|
||||||
|
messageType: MessageType.Message,
|
||||||
|
card: data.isReply ? threadId : data.channel,
|
||||||
|
cardType: chat.masterTag.Thread,
|
||||||
|
content: data.content,
|
||||||
|
creator: data.modifiedBy,
|
||||||
|
created: data.created,
|
||||||
|
id: messageId
|
||||||
|
}
|
||||||
|
const createMessageData = Buffer.from(JSON.stringify(createMessageEvent))
|
||||||
|
await sendToCommunicationTopic(producer, config, data, createMessageData)
|
||||||
|
return messageId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFiles (
|
||||||
|
producer: Producer,
|
||||||
|
config: BaseConfig,
|
||||||
|
attachments: Attachment[],
|
||||||
|
messageData: MessageData,
|
||||||
|
threadId: Ref<Card>,
|
||||||
|
messageId: MessageID
|
||||||
|
): Promise<void> {
|
||||||
|
const fileData: Buffer[] = attachments.map((a) => {
|
||||||
|
const creeateFileEvent: CreateFileEvent = {
|
||||||
|
type: MessageRequestEventType.CreateFile,
|
||||||
|
card: threadId,
|
||||||
|
message: messageId,
|
||||||
|
messageCreated: messageData.created,
|
||||||
|
creator: messageData.modifiedBy,
|
||||||
|
data: {
|
||||||
|
blobId: a.id as Ref<Blob>,
|
||||||
|
type: a.contentType,
|
||||||
|
filename: a.name,
|
||||||
|
size: a.data.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Buffer.from(JSON.stringify(creeateFileEvent))
|
||||||
|
})
|
||||||
|
const fileEvents = fileData.map((data) => ({
|
||||||
|
key: Buffer.from(messageData.channel ?? messageData.spaceId),
|
||||||
|
value: data,
|
||||||
|
headers: {
|
||||||
|
WorkspaceUuid: messageData.workspace
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
await producer.send({
|
||||||
|
topic: config.CommunicationTopic,
|
||||||
|
messages: fileEvents
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCollaborators (
|
||||||
|
producer: Producer,
|
||||||
|
config: BaseConfig,
|
||||||
|
data: MessageData,
|
||||||
|
threadId: Ref<Card>
|
||||||
|
): Promise<void> {
|
||||||
|
if (data.recipient.socialId === data.modifiedBy) {
|
||||||
|
return // Message author should be automatically added as a collaborator
|
||||||
|
}
|
||||||
|
const addCollaboratorsEvent: AddCollaboratorsEvent = {
|
||||||
|
type: NotificationRequestEventType.AddCollaborators,
|
||||||
|
card: threadId,
|
||||||
|
cardType: chat.masterTag.Thread,
|
||||||
|
collaborators: [data.recipient.uuid as AccountUuid],
|
||||||
|
creator: data.modifiedBy
|
||||||
|
}
|
||||||
|
const createMessageData = Buffer.from(JSON.stringify(addCollaboratorsEvent))
|
||||||
|
await sendToCommunicationTopic(producer, config, data, createMessageData)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendToCommunicationTopic (
|
||||||
|
producer: Producer,
|
||||||
|
config: BaseConfig,
|
||||||
|
messageData: MessageData,
|
||||||
|
content: Buffer
|
||||||
|
): Promise<void> {
|
||||||
|
await producer.send({
|
||||||
|
topic: config.CommunicationTopic,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
key: Buffer.from(messageData.channel ?? messageData.spaceId),
|
||||||
|
value: content,
|
||||||
|
headers: {
|
||||||
|
WorkspaceUuid: messageData.workspace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -12,7 +12,9 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { PersonId, PersonUuid } from '@hcengineering/core'
|
import { Card } from '@hcengineering/card'
|
||||||
|
import { PersonSpace } from '@hcengineering/contact'
|
||||||
|
import { PersonId, PersonUuid, Ref, WorkspaceUuid } from '@hcengineering/core'
|
||||||
|
|
||||||
//
|
//
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
@ -58,3 +60,17 @@ export interface BaseConfig {
|
|||||||
QueueRegion: string
|
QueueRegion: string
|
||||||
CommunicationTopic: string
|
CommunicationTopic: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessageData {
|
||||||
|
subject: string
|
||||||
|
content: string
|
||||||
|
channel: Ref<Card>
|
||||||
|
created: Date
|
||||||
|
modifiedBy: PersonId
|
||||||
|
mailId: string
|
||||||
|
spaceId: Ref<PersonSpace>
|
||||||
|
workspace: WorkspaceUuid
|
||||||
|
threadId: Ref<Card>
|
||||||
|
recipient: MailRecipient
|
||||||
|
isReply: boolean
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user