feat: implement Gmail metadata synchronization service

- Added a new file `metadataSync.ts` to handle the synchronization of Gmail profile and email summaries to the backend via the `integration:metadata-sync` socket event.
- Updated import paths in `SkillProvider` to reflect the new location of the Gmail metadata synchronization function.
- Enhanced type definitions for Gmail profile and email summaries to ensure proper data structure during synchronization.
This commit is contained in:
M3gA-Mind
2026-02-23 14:35:47 +05:30
parent b17d721130
commit 497d3414df
5 changed files with 68 additions and 20 deletions
@@ -3,7 +3,7 @@
* `integration:metadata-sync` socket event so the server can merge them
* into the user's Google OAuth integration metadata.
*/
import { emitViaRustSocket } from '../../utils/tauriSocket';
import { emitViaRustSocket } from '../../../utils/tauriSocket';
const INTEGRATION_METADATA_SYNC_EVENT = 'integration:metadata-sync';
const PROVIDER_GOOGLE = 'gmail';
+1 -1
View File
@@ -22,7 +22,7 @@ import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSli
import {
syncGmailMetadataToBackend,
type GmailStateForSync,
} from '../lib/skills/gmailMetadataSync';
} from '../lib/gmail/services/metadataSync';
import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config';
// ---------------------------------------------------------------------------
+1 -4
View File
@@ -22,9 +22,6 @@ export const userApi = {
* POST /settings/onboarding-complete
*/
onboardingComplete: async (): Promise<void> => {
await apiClient.post<{ success: boolean; data: unknown }>(
'/settings/onboarding-complete',
{}
);
await apiClient.post<{ success: boolean; data: unknown }>('/settings/onboarding-complete', {});
},
};
+29 -3
View File
@@ -90,7 +90,7 @@ const handleOAuthDeepLink = async (parsed: URL) => {
}
const encryptionKeyHex = state.auth.encryptionKeyByUser[userId];
if (!encryptionKeyHex) {
if (!encryptionKeyHex || typeof encryptionKeyHex !== 'string') {
console.warn(
'[DeepLink] Cannot fetch integration tokens: no encryption key found for user',
userId
@@ -98,8 +98,34 @@ const handleOAuthDeepLink = async (parsed: URL) => {
return;
}
const keyForBackend = hexToBase64(encryptionKeyHex);
const response = await fetchIntegrationTokens(integrationId, keyForBackend || encryptionKeyHex);
const trimmedHex = encryptionKeyHex.trim().replace(/^0x/i, '');
if (!trimmedHex || trimmedHex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(trimmedHex)) {
console.error(
'[DeepLink] Cannot fetch integration tokens: encryption key must be non-empty hex (even length, [0-9a-fA-F])',
{ userId, encryptionKeyHex }
);
return;
}
let keyForBackend: string;
try {
keyForBackend = hexToBase64(encryptionKeyHex);
} catch (e) {
console.error(
'[DeepLink] Cannot fetch integration tokens: encryption key conversion failed',
{ userId, encryptionKeyHex, error: e }
);
return;
}
if (!keyForBackend) {
console.error(
'[DeepLink] Cannot fetch integration tokens: encryption key produced empty base64',
{ userId, encryptionKeyHex }
);
return;
}
const response = await fetchIntegrationTokens(integrationId, keyForBackend);
if (!response.success || !response.data?.encrypted) {
console.warn(
'[DeepLink] Integration tokens response missing encrypted payload for integration',
+36 -11
View File
@@ -12,13 +12,23 @@ export type IntegrationTokensPayload = {
};
/** Stored value: encrypted blob only; decrypt with key when needed */
export type StoredIntegrationTokens = {
encrypted: string;
};
export type StoredIntegrationTokens = { encrypted: string };
const HEX_REGEX = /^[0-9a-fA-F]*$/;
export function hexToBytes(hex: string): Uint8Array {
const cleanHex = hex.trim().replace(/^0x/i, '');
if (!cleanHex) return new Uint8Array();
if (cleanHex.length % 2 !== 0) {
throw new TypeError(
`hexToBytes: hex string must have even length (got ${cleanHex.length})`
);
}
if (!HEX_REGEX.test(cleanHex)) {
throw new TypeError(
'hexToBytes: hex string must contain only [0-9a-fA-F] characters'
);
}
const bytes = new Uint8Array(cleanHex.length / 2);
for (let i = 0; i < cleanHex.length; i += 2) {
bytes[i / 2] = parseInt(cleanHex.slice(i, i + 2), 16);
@@ -63,8 +73,8 @@ export async function decryptIntegrationTokens(
}
const keyBytes = hexToBytes(keyHex);
if (keyBytes.length === 0) {
throw new Error('Invalid encryption key');
if (keyBytes.length !== 32) {
throw new Error('Invalid encryption key: expected 32-byte AES-GCM key');
}
const combined = base64ToBytes(encryptedPayload);
@@ -101,21 +111,36 @@ export async function decryptIntegrationTokens(
* Matches backend: deterministic IV = first 16 bytes of SHA-256(message).
* Returns base64(IV + AuthTag + EncryptedData).
*/
export async function encryptIntegrationTokens(
plaintext: string,
keyHex: string
): Promise<string> {
export async function encryptIntegrationTokens(plaintext: string, keyHex: string): Promise<string> {
if (typeof crypto === 'undefined' || !crypto.subtle) {
throw new Error('Web Crypto API is not available for encryption');
}
const keyBytes = hexToBytes(keyHex);
if (keyBytes.length === 0) {
throw new Error('Invalid encryption key');
if (keyBytes.length !== 32) {
throw new Error('Invalid encryption key: expected 32-byte AES-GCM key');
}
try {
const payload = JSON.parse(plaintext) as Record<string, unknown>;
if (typeof payload.expiresAt !== 'string' || !payload.expiresAt.trim()) {
throw new Error(
'Payload must include a non-empty expiresAt field when using deterministic IV'
);
}
} catch (e) {
if (e instanceof SyntaxError) {
throw new Error(
'Plaintext must be JSON with a non-empty expiresAt field when using deterministic IV'
);
}
throw e;
}
const messageBytes = new TextEncoder().encode(plaintext);
// Deterministic IV for backend compatibility. TODO: Prefer a random 12-byte IV
// (crypto.getRandomValues) prepended to ciphertext; update decrypt to handle both formats.
const hashBuffer = await crypto.subtle.digest('SHA-256', messageBytes);
const iv = new Uint8Array(hashBuffer).slice(0, 16);