UBERF-13241: Support gmail space config (#9710)

This commit is contained in:
Artyom Savchenko
2025-08-22 17:37:11 +07:00
committed by GitHub
parent c897d9ea68
commit cdceb8c196
32 changed files with 405 additions and 84 deletions
+4
View File
@@ -30,3 +30,7 @@ export function isSameIntegrationEvent (event: IntegrationEventData, integration
event.integration?.workspaceUuid === integration.workspaceUuid
)
}
export function getIntegrationConfig (integration: Integration): Record<string, any> | undefined {
return integration?.data?.config
}
+6 -1
View File
@@ -40,6 +40,11 @@
"GooglePrivacy": "Použití a přenos informací získaných z Google API do jakékoli jiné aplikace bude v souladu s <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">zásadami Google API Services User Data Policy</a>, včetně požadavků na omezené použití.",
"EmailNotificationsDescription": "Dostávejte osobní oznámení na e-mail.",
"FailedToConnect": "Služba Gmail není k dispozici",
"TotalMessages": "Celkový počet zpráv:"
"TotalMessages": "Celkový počet zpráv:",
"Configure": "Konfigurovat integraci Gmail",
"GmailSpace": "Prostor pro Gmail zprávy",
"PersonSpaceInfo": "E-maily budou dostupné pouze vám",
"SharedSpaceInfo": "E-maily budou dostupné všem účastníkům prostoru",
"ConfigurationRequired": "Prosím, nakonfigurujte svou integraci Gmail, abyste ji mohli začít používat"
}
}
+6 -1
View File
@@ -40,6 +40,11 @@
"GooglePrivacy": "Die Nutzung und Übertragung von Informationen, die Huly von Google-APIs erhält, an andere Apps erfolgt gemäß der <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API Services User Data Policy</a>, einschließlich der Anforderungen zur eingeschränkten Nutzung.",
"EmailNotificationsDescription": "Erhalten Sie persönliche Benachrichtigungen per E-Mail.",
"FailedToConnect": "Gmail-Dienst ist nicht verfügbar",
"TotalMessages": "Gesamtanzahl der Nachrichten:"
"TotalMessages": "Gesamtanzahl der Nachrichten:",
"Configure": "Gmail-Integration konfigurieren",
"GmailSpace": "Gmail-Nachrichtenbereich",
"PersonSpaceInfo": "E-Mails werden nur für Sie verfügbar sein",
"SharedSpaceInfo": "E-Mails werden für alle Bereichsteilnehmer verfügbar sein",
"ConfigurationRequired": "Bitte konfigurieren Sie Ihre Gmail-Integration, um sie nutzen zu können"
}
}
+6 -1
View File
@@ -40,6 +40,11 @@
"GooglePrivacy": "Hulys use and transfer of information received from Google APIs to any other app will adhere to <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API Services User Data Policy</a>, including the Limited Use requirements.",
"EmailNotificationsDescription": "Receive personal notifications on email.",
"FailedToConnect": "Gmail service is not available",
"TotalMessages": "Total messages:"
"TotalMessages": "Total messages:",
"Configure": "Configure Gmail integration",
"GmailSpace": "Space for message channel",
"PersonSpaceInfo": "Mail will be available to you",
"SharedSpaceInfo": "Mail will be available to all space participants",
"ConfigurationRequired": "Please configure your Gmail integration to start using it"
}
}
+6 -1
View File
@@ -40,6 +40,11 @@
"GooglePrivacy": "Hulys use and transfer of information received from Google APIs to any other app will adhere to <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API Services User Data Policy</a>, including the Limited Use requirements.",
"EmailNotificationsDescription": "Reciba notificaciones personales por correo electrónico.",
"FailedToConnect": "El servicio de Gmail no está disponible",
"TotalMessages": "Total de mensajes:"
"TotalMessages": "Total de mensajes:",
"Configure": "Configurar integración de Gmail",
"GmailSpace": "Espacio para mensajes de Gmail",
"PersonSpaceInfo": "El correo estará disponible solo para usted",
"SharedSpaceInfo": "El correo estará disponible para todos los participantes del espacio",
"ConfigurationRequired": "Por favor, configure su integración de Gmail para comenzar a usarla"
}
}
+6 -1
View File
@@ -40,6 +40,11 @@
"GooglePrivacy": "L'utilisation et le transfert des informations reçues des API Google par Huly à toute autre application respecteront les <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">règles d'utilisation des données utilisateur des services API Google</a>, y compris les exigences d'utilisation limitée.",
"EmailNotificationsDescription": "Recevez des notifications personnelles par e-mail.",
"FailedToConnect": "Le service Gmail n'est pas disponible",
"TotalMessages": "Total des messages:"
"TotalMessages": "Total des messages:",
"Configure": "Configurer l'intégration Gmail",
"GmailSpace": "Espace pour les messages Gmail",
"PersonSpaceInfo": "Les e-mails seront disponibles uniquement pour vous",
"SharedSpaceInfo": "Les e-mails seront disponibles pour tous les participants de l'espace",
"ConfigurationRequired": "Veuillez configurer votre intégration Gmail pour commencer à l'utiliser"
}
}
+6 -1
View File
@@ -40,6 +40,11 @@
"GooglePrivacy": "L'uso e il trasferimento delle informazioni ricevute dalle API di Google a qualsiasi altra app di Huly adempirà alla <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Politica sui dati degli utenti dei servizi API di Google</a>, inclusi i requisiti di utilizzo limitato.",
"EmailNotificationsDescription": "Ricevi notifiche personali sulle email.",
"FailedToConnect": "Il servizio Gmail non è disponibile",
"TotalMessages": "Totale messaggi:"
"TotalMessages": "Totale messaggi:",
"Configure": "Configura integrazione Gmail",
"GmailSpace": "Spazio per messaggi Gmail",
"PersonSpaceInfo": "Le email saranno disponibili solo per te",
"SharedSpaceInfo": "Le email saranno disponibili per tutti i partecipanti dello spazio",
"ConfigurationRequired": "Per favore, configura la tua integrazione Gmail per iniziare a usarla"
}
}
+6 -1
View File
@@ -40,6 +40,11 @@
"GooglePrivacy": "Huly による Google API から受信した情報の他のアプリへの使用および転送は、<a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API Services User Data Policy</a>(限定的な使用の要件を含む)に準拠します。",
"EmailNotificationsDescription": "メールで個人通知を受け取る",
"FailedToConnect": "Gmail サービスは利用できません",
"TotalMessages": "メッセージの総数:"
"TotalMessages": "メッセージの総数:",
"Configure": "Gmail 統合を設定",
"GmailSpace": "Gmail メッセージスペース",
"PersonSpaceInfo": "メールはあなたのみに利用可能になります",
"SharedSpaceInfo": "メールはすべてのスペース参加者が利用可能になります",
"ConfigurationRequired": "Gmail 統合を設定する必要があります"
}
}
+6 -1
View File
@@ -40,6 +40,11 @@
"GooglePrivacy": "Hulys use and transfer of information received from Google APIs to any other app will adhere to <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API Services User Data Policy</a>, including the Limited Use requirements.",
"EmailNotificationsDescription": "Receba notificações pessoais por e-mail.",
"FailedToConnect": "O serviço Gmail não está disponível",
"TotalMessages": "Total de mensagens:"
"TotalMessages": "Total de mensagens:",
"Configure": "Configurar integração do Gmail",
"GmailSpace": "Espaço para mensagens do Gmail",
"PersonSpaceInfo": "O email estará disponível apenas para você",
"SharedSpaceInfo": "O email estará disponível para todos os participantes do espaço",
"ConfigurationRequired": "Por favor, configure sua integração de Gmail para começar a usá-la"
}
}
+6 -1
View File
@@ -40,6 +40,11 @@
"GooglePrivacy": "Использование и передача информации, полученной Huly от Google API, будет соответствовать <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Политике использования данных пользователей Google API</a>, включая требования ограниченного использования.",
"EmailNotificationsDescription": "Получайте персональные уведомления на электронную почту.",
"FailedToConnect": "Gmail сервис недоступен",
"TotalMessages": "Всего сообщений:"
"TotalMessages": "Всего сообщений:",
"Configure": "Настроить Gmail интеграцию",
"GmailSpace": "Пространство для канала сообщений",
"PersonSpaceInfo": "Почта будет доступна только вам",
"SharedSpaceInfo": "Почта будет доступна всем участникам пространства",
"ConfigurationRequired": "Пожалуйста, настройте интеграцию Gmail, чтобы начать ее использовать"
}
}
+6 -1
View File
@@ -40,6 +40,11 @@
"GooglePrivacy": "Huly 从 Google API 接收的信息的使用和传输将遵守 <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API 服务用户数据政策</a>,包括有限使用要求。",
"EmailNotificationsDescription": "透過電子郵件接收個人通知。",
"FailedToConnect": "Gmail 服务不可用",
"TotalMessages": "总消息数:"
"TotalMessages": "总消息数:",
"Configure": "配置 Gmail 集成",
"GmailSpace": "Gmail 消息空间",
"PersonSpaceInfo": "邮件将仅对您可用",
"SharedSpaceInfo": "邮件将对所有空间参与者可用",
"ConfigurationRequired": "请配置您的 Gmail 集成以开始使用"
}
}
+1
View File
@@ -47,6 +47,7 @@
"@hcengineering/text-html": "^0.6.0",
"@hcengineering/text-editor": "^0.6.0",
"@hcengineering/text-editor-resources": "^0.6.0",
"@hcengineering/card": "^0.6.0",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/setting": "^0.6.17",
"@hcengineering/chunter": "^0.6.20",
+4
View File
@@ -49,6 +49,10 @@ export async function getState (socialId: string): Promise<GmailSyncState | null
return await request('GET', `/state?socialId=${encodeURIComponent(socialId)}`)
}
export async function startSync (socialId: string): Promise<void> {
await request('POST', `/start-sync?socialId=${encodeURIComponent(socialId)}`)
}
export async function signout (): Promise<void> {
await request('GET', '/signout')
}
@@ -24,8 +24,10 @@
import { isWorkspaceIntegration } from '@hcengineering/integration-client'
import { Integration as AccountIntegration } from '@hcengineering/account-client'
import ConfigureV2 from './ConfigureV2.svelte'
import { getIntegrationClient } from '../api'
import gmail from '../plugin'
import { isNewGmailIntegration } from '../utils'
export let integration: AccountIntegration
let integrationSettings: Integration | undefined
@@ -74,36 +76,40 @@
const dispatch = createEventDispatcher()
</script>
<Card
label={gmail.string.Shared}
okAction={async () => {
await apply()
dispatch('close')
}}
canSave={true}
fullSize
okLabel={presentation.string.Ok}
on:close={() => dispatch('close')}
on:changeContent
>
<div style="width: 25rem;">
<Grid rowGap={1}>
<div>
<Label label={gmail.string.Shared} />
</div>
<Toggle bind:on={shared} on:change={disable} />
{#if shared}
{#if isNewGmailIntegration(integration)}
<ConfigureV2 {integration} on:close />
{:else}
<Card
label={gmail.string.Shared}
okAction={async () => {
await apply()
dispatch('close')
}}
canSave={true}
fullSize
okLabel={presentation.string.Ok}
on:close={() => dispatch('close')}
on:changeContent
>
<div style="width: 25rem;">
<Grid rowGap={1}>
<div>
<Label label={gmail.string.AvailableTo} />
<Label label={gmail.string.Shared} />
</div>
<AccountArrayEditor
kind={'regular'}
label={gmail.string.AvailableTo}
excludeItems={[currentEmployee]}
value={integrationSettings?.shared ?? []}
onChange={(res) => change(res)}
/>
{/if}
</Grid>
</div>
</Card>
<Toggle bind:on={shared} on:change={disable} />
{#if shared}
<div>
<Label label={gmail.string.AvailableTo} />
</div>
<AccountArrayEditor
kind={'regular'}
label={gmail.string.AvailableTo}
excludeItems={[currentEmployee]}
value={integrationSettings?.shared ?? []}
onChange={(res) => change(res)}
/>
{/if}
</Grid>
</div>
</Card>
{/if}
@@ -0,0 +1,130 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'
import { fade } from 'svelte/transition'
import presentation, { Card, getClient, getCurrentWorkspaceUuid, SpaceSelector } from '@hcengineering/presentation'
import { Icon, Label, Loading } from '@hcengineering/ui'
import type { Integration } from '@hcengineering/account-client'
import { isWorkspaceIntegration, getIntegrationConfig } from '@hcengineering/integration-client'
import card from '@hcengineering/card'
import contact from '@hcengineering/contact'
import { getIntegrationClient, startSync } from '../api'
import gmail from '../plugin'
import core, { getCurrentAccount, Ref, Space } from '@hcengineering/core'
import GmailColor from './icons/GmailColor.svelte'
export let integration: Integration
let isLoading = true
let selectedSpace: Ref<Space> | undefined = undefined
let personSpace: Ref<Space> | undefined = undefined
const client = getClient()
onMount(async () => {
try {
const personSpaceObj = await client.findOne(contact.class.PersonSpace, { members: getCurrentAccount().uuid })
const integrationSpace = getIntegrationConfig(integration)?.spaceId as Ref<Space>
personSpace = personSpaceObj?._id
selectedSpace = integrationSpace ?? personSpace
isLoading = false
} catch (err) {
isLoading = false
console.error('Failed to find person space:', err)
}
})
async function apply (): Promise<void> {
const integrationClient = await getIntegrationClient()
integration = isWorkspaceIntegration(integration)
? integration
: await integrationClient.integrate(integration, getCurrentWorkspaceUuid())
await integrationClient.updateConfig(integration, {
spaceId: selectedSpace
})
await startSync(integration.socialId)
}
const dispatch = createEventDispatcher()
</script>
<Card
label={gmail.string.Configure}
okAction={async () => {
await apply()
dispatch('close')
}}
canSave={true}
fullSize
okLabel={presentation.string.Ok}
on:close={() => dispatch('close')}
on:changeContent
>
<svelte:fragment slot="title">
<div class="flex-row-center gap-2">
<GmailColor size="medium" />
<span class="text-normal">
<Label label={gmail.string.Configure} />
</span>
</div>
</svelte:fragment>
<div class="flex-col min-w-100" transition:fade={{ duration: 300 }}>
{#if isLoading}
<div class="flex-center">
<div class="p-5">
<Loading />
</div>
</div>
{:else}
<div class="flex-between flex-gap-4">
<Label label={gmail.string.GmailSpace} />
<SpaceSelector
_class={core.class.Space}
query={{
archived: false,
members: getCurrentAccount().uuid,
_class: { $in: [card.class.CardSpace, contact.class.PersonSpace] }
}}
label={core.string.Space}
kind={'regular'}
size={'medium'}
justify={'left'}
autoSelect={false}
bind:space={selectedSpace}
width="9rem"
/>
</div>
{/if}
</div>
<svelte:fragment slot="footer">
<div class="flex-row-center max-w-80 pr-4">
{#if personSpace === selectedSpace}
<Icon size={'small'} icon={contact.icon.Person} />
<span class="text-sm ml-2">
<Label label={gmail.string.PersonSpaceInfo} />
</span>
{:else}
<Icon size={'small'} icon={contact.icon.Contacts} />
<span class="text-sm ml-2">
<Label label={gmail.string.SharedSpaceInfo} />
</span>
{/if}
</div>
</svelte:fragment>
</Card>
@@ -22,7 +22,7 @@
import gmail from '../plugin'
import { getState } from '../api'
import { IntlString, OK, ERROR, Status } from '@hcengineering/platform'
import platform, { IntlString, OK, ERROR, Status, Severity } from '@hcengineering/platform'
export let integration: Integration
@@ -39,7 +39,7 @@
state = await getState(integration.socialId)
isLoading = false
subscribe()
status = OK
status = state?.status === 'inactive' ? new Status(Severity.WARNING, platform.status.OK, {}) : OK
} catch (err) {
status = ERROR
error = err instanceof Error ? err.message : 'Failed to load gmail state'
@@ -83,8 +83,11 @@
<BaseIntegrationState {integration} {isLoading} {status} {errorLabel} value={email}>
<svelte:fragment slot="content">
{#if state?.totalMessages != null}
{#if state?.isConfigured === true && state?.totalMessages != null}
<IntegrationStateRow label={gmail.string.TotalMessages} value={state.totalMessages} />
{/if}
{#if state?.isConfigured !== true}
<IntegrationStateRow label={gmail.string.ConfigurationRequired} />
{/if}
</svelte:fragment>
</BaseIntegrationState>
@@ -14,10 +14,15 @@
// limitations under the License.
-->
<script lang="ts">
export let size: 'small' | 'medium' | 'large' | undefined = undefined
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="3 3 30 30"
class={size !== undefined ? `svg-${size}` : null}
style="enable-background:new 0 0 36 36;"
xml:space="preserve"
>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+6 -1
View File
@@ -45,6 +45,11 @@ export default mergeIds(gmailId, gmail, {
NewIncomingMessage: '' as IntlString,
GooglePrivacy: '' as IntlString,
FailedToConnect: '' as IntlString,
TotalMessages: '' as IntlString
TotalMessages: '' as IntlString,
Configure: '' as IntlString,
GmailSpace: '' as IntlString,
PersonSpaceInfo: '' as IntlString,
SharedSpaceInfo: '' as IntlString,
ConfigurationRequired: '' as IntlString
}
})
+6
View File
@@ -5,6 +5,8 @@ import { employeeBySocialKeyStore, getPersonByPersonId } from '@hcengineering/co
import { buildSocialIdString, type PersonId, SocialIdType, type Client, type Doc, type Ref } from '@hcengineering/core'
import { type Message, type SharedMessage } from '@hcengineering/gmail'
import { getClient } from '@hcengineering/presentation'
import type { Integration } from '@hcengineering/account-client'
import gmail from './plugin'
export function getTime (time: number): string {
@@ -122,3 +124,7 @@ export async function MessageTitleProvider (client: Client, ref: Ref<Message>, d
return object?.subject ?? ''
}
export function isNewGmailIntegration (integration: Integration): boolean {
return integration.data?.integrationVersion === 'v2'
}
+1
View File
@@ -90,6 +90,7 @@ export interface GmailSyncState {
totalMessages: number | null | undefined
lastSynchronization?: Date | null
error?: string
isConfigured?: boolean
}
/**
@@ -123,7 +123,8 @@ jest.mock('../utils', () => ({
setValue: jest.fn().mockImplementation(() => Promise.resolve(undefined)),
deleteKey: jest.fn().mockImplementation(() => Promise.resolve(undefined)),
listKeys: jest.fn()
}))
})),
getSpaceId: jest.fn().mockReturnValue('test-space-id')
}))
// Mock gmail module
@@ -376,7 +377,7 @@ describe('GmailClient', () => {
// Fix unbound method reference by using the spy
expect(jest.spyOn(SyncManager.prototype, 'sync')).toHaveBeenCalledWith(
mockSocialId._id,
{ noNotify: true },
{ noNotify: true, spaceId: 'test-space-id' },
'test@example.com'
)
})
+53 -8
View File
@@ -23,7 +23,7 @@ import core, {
TxOperations,
WorkspaceUuid
} from '@hcengineering/core'
import gmail, { type NewMessage } from '@hcengineering/gmail'
import gmail, { gmailIntegrationKind, type NewMessage } from '@hcengineering/gmail'
import { type StorageAdapter } from '@hcengineering/server-core'
import setting from '@hcengineering/setting'
import type { Credentials, OAuth2Client } from 'google-auth-library'
@@ -49,7 +49,7 @@ import config from './config'
import { GmailController } from './gmailController'
import { RateLimiter } from './rateLimiter'
import { type ProjectCredentials, type Token, type User, type SyncState, GmailMessageType } from './types'
import { addFooter, isToken, serviceToken, getKvsClient, createGmailSearchQuery } from './utils'
import { addFooter, isToken, serviceToken, getKvsClient, createGmailSearchQuery, getSpaceId } from './utils'
import type { WorkspaceClient } from './workspaceClient'
import { getOrCreateSocialId } from './accounts'
import { createIntegrationIfNotExists, disableIntegration, removeIntegration } from './integrations'
@@ -113,7 +113,7 @@ export class GmailClient {
private readonly messageManager: IMessageManager
private readonly syncManager: SyncManager
private readonly integrationToken: string
private integration: Integration | undefined = undefined
private integration: Integration | null | undefined = undefined
private syncStarted: boolean = false
private channel: Card | undefined = undefined
@@ -310,7 +310,19 @@ export class GmailClient {
try {
this.integration = await createIntegrationIfNotExists(this.socialId._id, this.user.workspace, this.email)
} catch (err: any) {
this.ctx.error('Failed to create integration', { socialdId: this.socialId, workspace: this.workspace })
this.ctx.error('Failed to create integration', { socialId: this.socialId, workspace: this.workspace })
}
}
async refreshIntegration (): Promise<void> {
try {
this.integration = await this.accountClient.getIntegration({
socialId: this.socialId._id,
workspaceUuid: this.user.workspace,
kind: gmailIntegrationKind
})
} catch (err: any) {
this.ctx.error('Failed to refresh integration', { socialId: this.socialId, workspace: this.workspace })
}
}
@@ -367,6 +379,11 @@ export class GmailClient {
if (personId !== this.socialId._id && !this.allSocialIds.has(personId)) {
return
}
if (!this.isConfigured()) {
return
}
if (message.date !== undefined) {
const messageDate = message.date instanceof Date ? message.date : new Date(message.date)
if (messageDate < config.OutgoingSyncStartDate) {
@@ -489,7 +506,7 @@ export class GmailClient {
}
async disableIntegration (byError: boolean = false): Promise<void> {
if (this.integration !== undefined) {
if (this.integration != null) {
if (byError) {
await disableIntegration(this.integration)
} else {
@@ -508,10 +525,25 @@ export class GmailClient {
return
}
// Check for spaceId requirement in v2 configurations
if (!this.isConfigured()) {
this.ctx.info('Cannot start sync: spaceId is required for v2 configuration', {
workspaceUuid: this.user.workspace,
userId: this.user.userId,
email: this.email,
integrationVersion: this.integration?.data?.integrationVersion
})
return
}
try {
this.syncStarted = true
this.ctx.info('Start sync', { workspaceUuid: this.user.workspace, userId: this.user.userId, email: this.email })
await this.syncManager.sync(this.socialId._id, { noNotify: true }, this.email)
await this.syncManager.sync(
this.socialId._id,
{ noNotify: true, spaceId: getSpaceId(this.integration) },
this.email
)
await this.watch()
// recall every 24 hours https://developers.google.com/gmail/api/guides/push
if (this.watchTimer !== undefined) clearInterval(this.watchTimer)
@@ -530,8 +562,15 @@ export class GmailClient {
}
async sync (options: SyncOptions): Promise<void> {
if (!this.isConfigured()) {
return
}
this.ctx.info('Sync', { workspaceUuid: this.user.workspace, userId: this.user.userId })
await this.syncManager.sync(this.socialId._id, options, this.email)
const syncOptions = {
...options,
spaceId: getSpaceId(this.integration)
}
await this.syncManager.sync(this.socialId._id, syncOptions, this.email)
}
async newChannel (value: string): Promise<void> {
@@ -689,6 +728,11 @@ export class GmailClient {
}
}
private isConfigured (): boolean {
if (this.integration?.data?.integrationVersion !== 'v2') return true
return getSpaceId(this.integration) !== undefined
}
async getStateSummary (): Promise<SyncState> {
let totalMessages: number | null | undefined
try {
@@ -708,7 +752,8 @@ export class GmailClient {
status: this.syncStarted ? 'active' : 'inactive',
email: this.email,
totalMessages,
syncInfo: syncStatus
syncInfo: syncStatus,
isConfigured: this.isConfigured()
}
}
@@ -393,4 +393,20 @@ export class GmailController {
}
return await mailClient.getStateSummary()
}
async startSyncForClient (workspace: WorkspaceUuid, socialId: PersonId): Promise<void> {
const workspaceClient = this.workspaces.get(workspace)
if (workspaceClient === undefined) {
this.ctx.info('Workspace client not found for startSync', { workspace, socialId })
return
}
const mailClient = workspaceClient.getGmailClient(socialId)
if (mailClient === undefined) {
this.ctx.info('Gmail client not found for startSync', { workspace, socialId })
return
}
this.ctx.info('Starting sync for specific client', { workspace, socialId })
await mailClient.refreshIntegration()
void mailClient.startSync()
}
}
+5 -2
View File
@@ -18,10 +18,11 @@ import { AccountUuid, MeasureContext, PersonId, TxOperations, WorkspaceUuid } fr
import gmail, { gmailIntegrationKind } from '@hcengineering/gmail'
import { getAccountClient } from '@hcengineering/server-client'
import setting from '@hcengineering/setting'
import { IntegrationClient, IntegrationClientImpl } from '@hcengineering/integration-client'
import { serviceToken } from './utils'
import { getAccountSocialIds } from './accounts'
import { IntegrationClient, IntegrationClientImpl } from '@hcengineering/integration-client'
import config from './config'
let integrationClient: IntegrationClient | undefined
export function getIntegrationClient (): IntegrationClient {
@@ -94,7 +95,9 @@ export async function createIntegrationIfNotExists (
const connection = await client.connect(socialId, {
email
})
return await client.integrate(connection, workspace)
return await client.integrate(connection, workspace, {
integrationVersion: config.Version
})
}
export async function disableIntegration (integration: Integration): Promise<void> {
+27
View File
@@ -199,6 +199,33 @@ export const main = async (): Promise<void> => {
res.status(500).send({ error: err.message })
}
}
},
{
endpoint: '/start-sync',
type: 'post',
handler: async (req, res) => {
try {
const token = extractToken(req.headers)
if (token === undefined) {
res.status(401).send()
return
}
const { workspace } = decodeToken(token)
const socialId = req.query.socialId as PersonId | undefined
ctx.info('Sync request received', { workspace, socialId })
if (socialId == null || socialId === '') {
res.status(400).send({ error: 'Missing socialId param' })
return
}
await gmailController.startSyncForClient(workspace, socialId)
res.send({ success: true })
} catch (err: any) {
ctx.error('Failed to start sync for client', { message: err.message })
res.status(500).send({ error: err.message })
}
}
}
]
+1
View File
@@ -85,6 +85,7 @@ export interface SyncState {
totalMessages: number | null | undefined
lastSynchronization?: Date | null
error?: string
isConfigured?: boolean
syncInfo?: {
type: 'full' | 'partial' | null
processedMessages: number
+14 -1
View File
@@ -14,12 +14,21 @@
// limitations under the License.
//
import { type Data, type Doc, type DocumentUpdate, systemAccountUuid, WorkspaceUuid } from '@hcengineering/core'
import {
type Data,
type Doc,
type DocumentUpdate,
Ref,
Space,
systemAccountUuid,
WorkspaceUuid
} from '@hcengineering/core'
import { generateToken } from '@hcengineering/server-token'
import { deepEqual } from 'fast-equals'
import { type KeyValueClient, getClient as getKeyValueClient } from '@hcengineering/kvs-client'
import { type Token, type User } from './types'
import config from './config'
import { Integration } from '@hcengineering/account-client'
export class DeferredPromise<T = any> {
public readonly promise: Promise<T>
@@ -107,3 +116,7 @@ export function createGmailSearchQuery (fromDate: Date, toDate: Date, fromEmail:
return `after:${afterDate} before:${beforeDate} from:${fromEmail}`
}
export function getSpaceId (integration: Integration | null | undefined): Ref<Space> | undefined {
return integration?.data?.config?.spaceId
}
+5 -6
View File
@@ -13,11 +13,10 @@
// limitations under the License.
//
import { MeasureContext, PersonId, Ref, TxOperations, WorkspaceUuid, generateId } from '@hcengineering/core'
import { MeasureContext, PersonId, Ref, Space, TxOperations, WorkspaceUuid, generateId } from '@hcengineering/core'
import { type Card } from '@hcengineering/card'
import chat from '@hcengineering/chat'
import mail from '@hcengineering/mail'
import { PersonSpace } from '@hcengineering/contact'
import { SyncMutex } from './mutex'
import { MessageTimeShift, normalizeEmail } from './utils'
@@ -40,7 +39,7 @@ export class ChannelCache {
* Gets or creates a mail channel with caching
*/
async getOrCreateChannel (
spaceId: Ref<PersonSpace>,
spaceId: Ref<Space>,
participants: PersonId[],
email: string,
owner: PersonId
@@ -61,7 +60,7 @@ export class ChannelCache {
return channel
}
clearCache (spaceId: Ref<PersonSpace>, email: string): void {
clearCache (spaceId: Ref<Space>, email: string): void {
const normalizedEmail = normalizeEmail(email)
this.cache.delete(`${spaceId}:${normalizedEmail}`)
}
@@ -75,7 +74,7 @@ export class ChannelCache {
}
private async fetchOrCreateChannel (
space: Ref<PersonSpace>,
space: Ref<Space>,
participants: PersonId[],
email: string,
personId: PersonId
@@ -109,7 +108,7 @@ export class ChannelCache {
}
private async createNewChannel (
space: Ref<PersonSpace>,
space: Ref<Space>,
participants: PersonId[],
email: string,
personId: PersonId
+10 -8
View File
@@ -18,7 +18,6 @@ import { WorkspaceLoginInfo } from '@hcengineering/account-client'
import { type Card } from '@hcengineering/card'
import { MessageID, MessageType } from '@hcengineering/communication-types'
import chat from '@hcengineering/chat'
import { PersonSpace } from '@hcengineering/contact'
import core, {
type Blob,
type MeasureContext,
@@ -28,7 +27,8 @@ import core, {
type TxOperations,
AccountUuid,
generateId,
RateLimiter
RateLimiter,
Space
} from '@hcengineering/core'
import { type KeyValueClient } from '@hcengineering/kvs-client'
@@ -152,7 +152,10 @@ export async function createMessages (
for (const person of messageRecipients) {
try {
const spaces = await personSpacesCache.getPersonSpaces(mailId, person.uuid, person.email)
let spaces = options?.spaceId != null ? [options.spaceId] : undefined
if (spaces === undefined) {
spaces = (await personSpacesCache.getPersonSpaces(mailId, person.uuid, person.email)).map((s) => s._id)
}
if (spaces.length > 0) {
await saveMessageToSpaces(
config,
@@ -189,7 +192,7 @@ async function saveMessageToSpaces (
threadLookup: ThreadLookupService,
wsInfo: WorkspaceLoginInfo,
mailId: string,
spaces: PersonSpace[],
spaces: Ref<Space>[],
participants: PersonId[],
modifiedBy: PersonId,
subject: string,
@@ -203,8 +206,7 @@ async function saveMessageToSpaces (
): Promise<void> {
const rateLimiter = new RateLimiter(10)
const createdDate = mailMessage.sendOn ?? Date.now()
for (const space of spaces) {
const spaceId = space._id
for (const spaceId of spaces) {
let isReply = false
await rateLimiter.add(async () => {
let threadId = await threadLookup.getThreadId(mailId, spaceId, recipient.email)
@@ -221,7 +223,7 @@ async function saveMessageToSpaces (
if (threadId === undefined) {
const newThreadId = await client.createDoc(
chat.masterTag.Thread,
space._id,
spaceId,
{
title: subject,
description: content,
@@ -264,7 +266,7 @@ async function saveMessageToSpaces (
const messageId = await createMailMessage(producer, config, messageData, threadId, options)
await createFiles(ctx, producer, config, attachments, messageData, threadId, messageId)
await threadLookup.setThreadId(mailId, space._id, threadId, recipient.email)
await threadLookup.setThreadId(mailId, spaceId, threadId, recipient.email)
})
}
await rateLimiter.waitProcessing()
@@ -13,7 +13,7 @@
// limitations under the License.
//
import { MeasureContext, PersonUuid, TxOperations, WorkspaceUuid } from '@hcengineering/core'
import { MeasureContext, PersonUuid, Space, TxOperations, WorkspaceUuid } from '@hcengineering/core'
import contact, { PersonSpace } from '@hcengineering/contact'
/**
@@ -28,7 +28,7 @@ export class PersonSpacesCache {
private readonly workspace: WorkspaceUuid
) {}
async getPersonSpaces (mailId: string, personUuid: PersonUuid, email: string): Promise<PersonSpace[]> {
async getPersonSpaces (mailId: string, personUuid: PersonUuid, email: string): Promise<Space[]> {
let spacesPromise = this.cache.get(personUuid)
if (spacesPromise === undefined) {
+6 -7
View File
@@ -13,10 +13,9 @@
// limitations under the License.
//
import { MeasureContext, Ref } from '@hcengineering/core'
import { MeasureContext, Ref, Space } from '@hcengineering/core'
import { type KeyValueClient } from '@hcengineering/kvs-client'
import { Card } from '@hcengineering/card'
import { PersonSpace } from '@hcengineering/contact'
export interface ThreadInfo {
threadId: Ref<Card>
@@ -54,7 +53,7 @@ export class ThreadLookupService {
ThreadLookupService.instances.delete(token)
}
async getThreadId (mailId: string, spaceId: Ref<PersonSpace>, email: string): Promise<Ref<Card> | undefined> {
async getThreadId (mailId: string, spaceId: Ref<Space>, email: string): Promise<Ref<Card> | undefined> {
try {
if (mailId == null || spaceId == null) {
this.ctx.warn('Invalid parameters for thread lookup', { mailId, spaceId })
@@ -80,7 +79,7 @@ export class ThreadLookupService {
}
}
async setThreadId (mailId: string, spaceId: Ref<PersonSpace>, threadId: Ref<Card>, email: string): Promise<void> {
async setThreadId (mailId: string, spaceId: Ref<Space>, threadId: Ref<Card>, email: string): Promise<void> {
try {
const key = this.getLookupKey(mailId, spaceId, email)
@@ -102,7 +101,7 @@ export class ThreadLookupService {
async getParentThreadId (
inReplyTo: string | undefined,
spaceId: Ref<PersonSpace>,
spaceId: Ref<Space>,
email: string
): Promise<Ref<Card> | undefined> {
if (inReplyTo === undefined) {
@@ -112,7 +111,7 @@ export class ThreadLookupService {
return await this.getThreadId(inReplyTo, spaceId, email)
}
async deleteMapping (mailId: string, spaceId: Ref<PersonSpace>, email: string): Promise<void> {
async deleteMapping (mailId: string, spaceId: Ref<Space>, email: string): Promise<void> {
try {
const key = this.getLookupKey(mailId, spaceId, email)
await this.keyValueClient.deleteKey(key)
@@ -122,7 +121,7 @@ export class ThreadLookupService {
}
}
private getLookupKey (mailId: string, spaceId: Ref<PersonSpace>, email: string): string {
private getLookupKey (mailId: string, spaceId: Ref<Space>, email: string): string {
return `mail-thread-lookup:${mailId}:${spaceId}:${email}`
}
}
+3 -3
View File
@@ -14,8 +14,7 @@
import { Card } from '@hcengineering/card'
import { MessageExtra } from '@hcengineering/communication-types'
import { PersonSpace } from '@hcengineering/contact'
import { OperationDomain, PersonId, PersonUuid, Ref, WorkspaceUuid } from '@hcengineering/core'
import { OperationDomain, PersonId, PersonUuid, Ref, Space, WorkspaceUuid } from '@hcengineering/core'
//
export interface Attachment {
@@ -71,7 +70,7 @@ export interface MessageData {
created: Date
modifiedBy: PersonId
mailId: string
spaceId: Ref<PersonSpace>
spaceId: Ref<Space>
workspace: WorkspaceUuid
threadId: Ref<Card>
recipient: MailRecipient
@@ -81,6 +80,7 @@ export interface MessageData {
export interface SyncOptions {
noNotify?: boolean
spaceId?: Ref<Space>
}
export interface MailRecipients {