From d396ef3bc70511960350e52da09f86f27fb3bf17 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:14:56 +0200 Subject: [PATCH 1/3] feat(db): add indexed DB logic --- package-lock.json | 38 ++- package.json | 3 + src/constants.ts | 5 + src/services/database/config.ts | 14 + .../database/database.service.test.ts | 258 ++++++++++++++++++ src/services/database/index.ts | 137 ++++++++++ src/services/database/types/index.ts | 62 +++++ src/services/search/types/index.ts | 22 ++ 8 files changed, 534 insertions(+), 5 deletions(-) create mode 100644 src/services/database/config.ts create mode 100644 src/services/database/database.service.test.ts create mode 100644 src/services/database/index.ts create mode 100644 src/services/database/types/index.ts create mode 100644 src/services/search/types/index.ts diff --git a/package-lock.json b/package-lock.json index 3013754..f95b759 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,8 @@ "dayjs": "^1.11.20", "dompurify": "^3.3.3", "i18next": "^25.8.13", + "idb": "^8.0.3", + "internxt-crypto": "^1.1.1", "prettysize": "^2.0.0", "react": "^19.2.0", "react-device-detect": "^2.2.3", @@ -59,6 +61,7 @@ "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", + "fake-indexeddb": "^6.2.5", "globals": "^16.5.0", "husky": "^9.1.7", "jsdom": "^28.1.0", @@ -1213,6 +1216,23 @@ "internxt-crypto": "1.0.2" } }, + "node_modules/@internxt/sdk/node_modules/internxt-crypto": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/internxt-crypto/-/internxt-crypto-1.0.2.tgz", + "integrity": "sha512-F9PuXci0eU1wlgDwqEbGR7hVDNS0MX8VNh/W+pdpR4ZsEsjRDBrOD2g1DvViR2woCxPiu1AW9Wwekpw2YVKfnA==", + "dependencies": { + "@noble/ciphers": "^2.1.1", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + "@noble/post-quantum": "^0.5.2", + "@scure/bip39": "^2.0.1", + "flexsearch": "^0.8.205", + "hash-wasm": "^4.12.0", + "husky": "^9.1.7", + "idb": "^8.0.3", + "uuid": "^13.0.0" + } + }, "node_modules/@internxt/ui": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/@internxt/ui/-/ui-0.1.11.tgz", @@ -6050,6 +6070,16 @@ "node": ">=12.0.0" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "license": "MIT" @@ -6652,19 +6682,17 @@ "license": "ISC" }, "node_modules/internxt-crypto": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/internxt-crypto/-/internxt-crypto-1.0.2.tgz", - "integrity": "sha512-F9PuXci0eU1wlgDwqEbGR7hVDNS0MX8VNh/W+pdpR4ZsEsjRDBrOD2g1DvViR2woCxPiu1AW9Wwekpw2YVKfnA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/internxt-crypto/-/internxt-crypto-1.1.1.tgz", + "integrity": "sha512-p//6dPvOLYUZL7nUfG1cABsZaJ7Y3qAd01oogYezrEPFdPRRjitEjrC9PovBw4WfJ/oqaZfnANbCUaGD0xqRuA==", "dependencies": { "@noble/ciphers": "^2.1.1", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@noble/post-quantum": "^0.5.2", "@scure/bip39": "^2.0.1", - "flexsearch": "^0.8.205", "hash-wasm": "^4.12.0", "husky": "^9.1.7", - "idb": "^8.0.3", "uuid": "^13.0.0" } }, diff --git a/package.json b/package.json index 130ff89..63f5f98 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "dayjs": "^1.11.20", "dompurify": "^3.3.3", "i18next": "^25.8.13", + "idb": "^8.0.3", + "internxt-crypto": "^1.1.1", "prettysize": "^2.0.0", "react": "^19.2.0", "react-device-detect": "^2.2.3", @@ -70,6 +72,7 @@ "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", + "fake-indexeddb": "^6.2.5", "globals": "^16.5.0", "husky": "^9.1.7", "jsdom": "^28.1.0", diff --git a/src/constants.ts b/src/constants.ts index e2deab6..1cd93b3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,3 +10,8 @@ export const DEFAULT_USER_NAME = 'My Internxt'; export const INTERNXT_EMAIL_DOMAINS = ['@inxt.me', '@inxt.eu', '@encrypt.eu'] as const; export const DEFAULT_FOLDER_LIMIT = 15; + +export const INDEXED_DB_VERSION = 1; +export const STORED_EMAILS_DB_LABEL = 'emails'; +export const STORED_EMAILS_CONTEXT_INDEX = + 'CRYPTO library 2025-07-30 17:20:00 key for protecting current emails indices'; diff --git a/src/services/database/config.ts b/src/services/database/config.ts new file mode 100644 index 0000000..b44d112 --- /dev/null +++ b/src/services/database/config.ts @@ -0,0 +1,14 @@ +import type { DatabaseConfig } from './types'; + +export const EMAIL_DB_CONFIG: DatabaseConfig = { + store: 'emails', + version: 1, + indexes: [ + { name: 'byTime', keyPath: 'params.receivedAt' }, + { name: 'byRead', keyPath: 'params.isRead' }, + { name: 'byFrom', keyPath: 'params.from' }, + { name: 'byTo', keyPath: 'params.to' }, + { name: 'byAttachmentType', keyPath: 'params.attachmentTypes', options: { multiEntry: true } }, + { name: 'byFolder', keyPath: 'params.folder' }, + ], +}; diff --git a/src/services/database/database.service.test.ts b/src/services/database/database.service.test.ts new file mode 100644 index 0000000..5ccdab5 --- /dev/null +++ b/src/services/database/database.service.test.ts @@ -0,0 +1,258 @@ +import 'fake-indexeddb/auto'; +import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import { DatabaseService } from './index'; +import type { StoredEmail, DatabaseConfig } from './types'; + +const TEST_USER_ID = 'user-123'; + +const TEST_CONFIG: DatabaseConfig = { + store: 'emails', + version: 1, + indexes: [ + { name: 'byTime', keyPath: 'params.receivedAt' }, + { name: 'byRead', keyPath: 'params.isRead' }, + { name: 'byFrom', keyPath: 'params.from' }, + ], +}; + +const createEmail = (overrides: Partial = {}): StoredEmail => ({ + id: crypto.randomUUID(), + mail: { subject: 'Test subject', body: 'Test body' }, + params: { + folder: 'inbox', + isRead: false, + receivedAt: Date.now().toString(), + from: [{ email: 'sender@test.com', name: 'Sender' }], + to: [{ email: 'recipient@test.com', name: 'Recipient' }], + hasAttachment: false, + attachmentTypes: [], + }, + ...overrides, +}); + +const createEmailWithDate = (daysAgo: number, overrides: Partial = {}): StoredEmail => { + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + return createEmail({ + ...overrides, + params: { + ...createEmail().params, + receivedAt: date.getTime().toString(), + ...overrides.params, + }, + }); +}; + +describe('DatabaseService', () => { + let db: DatabaseService; + + beforeEach(async () => { + db = new DatabaseService(TEST_USER_ID, TEST_CONFIG); + await db.open(); + }); + + afterEach(async () => { + try { + await db.destroy(); + } catch { + // NO OP - It is already closed + } + }); + + describe('Lifecycle', () => { + test('When opening the database, then it should be ready for operations', async () => { + const email = createEmail(); + await expect(db.put(email)).resolves.toBeDefined(); + }); + + test('When operating on a closed database, then it should reject with an error', async () => { + db.close(); + await expect(db.put(createEmail())).rejects.toThrow('Database not opened'); + }); + + test('When destroying the database, then all data should be removed', async () => { + await db.put(createEmail()); + await db.destroy(); + + db = new DatabaseService(TEST_USER_ID, TEST_CONFIG); + await db.open(); + + const count = await db.count(); + expect(count).toBe(0); + }); + }); + + describe('Put', () => { + test('When putting a record, then it should be retrievable by id', async () => { + const email = createEmail(); + await db.put(email); + + const result = await db.get(email.id); + expect(result).toStrictEqual(email); + }); + + test('When putting a record with an existing id, then it should overwrite the previous record', async () => { + const email = createEmail(); + await db.put(email); + + const updated = { ...email, mail: { subject: 'Updated', body: 'Updated body' } }; + await db.put(updated); + + const result = await db.get(email.id); + expect(result?.mail.subject).toBe('Updated'); + }); + + test('When putting many records, then all should be retrievable', async () => { + const emails = [createEmail(), createEmail(), createEmail()]; + await db.putMany(emails); + + const count = await db.count(); + expect(count).toBe(3); + }); + }); + + describe('Get', () => { + test('When getting a non-existent record, then it should return undefined', async () => { + const result = await db.get('non-existent-id'); + expect(result).toBeUndefined(); + }); + + test('When getting all records, then it should return every stored record', async () => { + const emails = [createEmail(), createEmail()]; + await db.putMany(emails); + + const results = await db.getAll(); + expect(results).toHaveLength(2); + }); + + test('When getting all records from an empty store, then it should return an empty array', async () => { + const results = await db.getAll(); + expect(results).toHaveLength(0); + }); + }); + + describe('Get by range', () => { + test('When getting by date range, then it should return records within the range', async () => { + const old = createEmailWithDate(30); + const recent = createEmailWithDate(1); + const veryOld = createEmailWithDate(90); + await db.putMany([old, recent, veryOld]); + + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + const now = Date.now(); + + const range = IDBKeyRange.bound(sevenDaysAgo.toString(), now.toString()); + const results = await db.getByRange('byTime', range); + + expect(results).toHaveLength(1); + expect(results[0].id).toBe(recent.id); + }); + }); + + describe('Remove', () => { + test('When removing a record, then it should no longer be retrievable', async () => { + const email = createEmail(); + await db.put(email); + await db.remove(email.id); + + const result = await db.get(email.id); + expect(result).toBeUndefined(); + }); + + test('When removing a record, then the count should decrease', async () => { + const emails = [createEmail(), createEmail()]; + await db.putMany(emails); + await db.remove(emails[0].id); + + const count = await db.count(); + expect(count).toBe(1); + }); + }); + + describe('Count', () => { + test('When counting an empty store, then it should return zero', async () => { + const count = await db.count(); + expect(count).toBe(0); + }); + + test('When counting after inserts, then it should return the correct number', async () => { + await db.putMany([createEmail(), createEmail(), createEmail()]); + + const count = await db.count(); + expect(count).toBe(3); + }); + }); + + describe('Get batch', () => { + test('When getting a batch, then it should return the requested number of items', async () => { + const emails = Array.from({ length: 10 }, (_, i) => createEmailWithDate(i)); + await db.putMany(emails); + + const { items } = await db.getBatch(5); + expect(items).toHaveLength(5); + }); + + test('When getting a batch, then items should be ordered newest first', async () => { + const emails = Array.from({ length: 5 }, (_, i) => createEmailWithDate(i)); + await db.putMany(emails); + + const { items } = await db.getBatch(5); + + for (let i = 0; i < items.length - 1; i++) { + const current = Number(items[i].params.receivedAt); + const next = Number(items[i + 1].params.receivedAt); + expect(current).toBeGreaterThanOrEqual(next); + } + }); + + test('When getting a batch with a cursor, then it should continue from that position', async () => { + const emails = Array.from({ length: 10 }, (_, i) => createEmailWithDate(i)); + await db.putMany(emails); + + const first = await db.getBatch(5); + expect(first.items).toHaveLength(5); + expect(first.nextCursor).toBeDefined(); + + const second = await db.getBatch(5, first.nextCursor); + expect(second.items).toHaveLength(5); + + const firstIds = new Set(first.items.map((e) => e.id)); + const hasDuplicates = second.items.some((e) => firstIds.has(e.id)); + expect(hasDuplicates).toBe(false); + }); + + test('When getting a batch larger than available records, then nextCursor should be undefined', async () => { + await db.putMany([createEmail(), createEmail()]); + + const { items, nextCursor } = await db.getBatch(10); + expect(items).toHaveLength(2); + expect(nextCursor).toBeUndefined(); + }); + }); + + describe('Delete oldest', () => { + test('When deleting oldest records, then the oldest should be removed', async () => { + const old = createEmailWithDate(30); + const recent = createEmailWithDate(1); + const newest = createEmailWithDate(0); + await db.putMany([old, recent, newest]); + + await db.deleteOldest(1); + + const count = await db.count(); + expect(count).toBe(2); + + const result = await db.get(old.id); + expect(result).toBeUndefined(); + }); + + test('When deleting more than available, then all records should be removed', async () => { + await db.putMany([createEmail(), createEmail()]); + + await db.deleteOldest(10); + + const count = await db.count(); + expect(count).toBe(0); + }); + }); +}); diff --git a/src/services/database/index.ts b/src/services/database/index.ts new file mode 100644 index 0000000..895f296 --- /dev/null +++ b/src/services/database/index.ts @@ -0,0 +1,137 @@ +import { openDB, deleteDB, type IDBPDatabase } from 'idb'; +import type { StoredEmail, DatabaseConfig } from './types'; + +export class DatabaseService { + private db: IDBPDatabase | null = null; + private readonly userId: string; + private readonly config: DatabaseConfig; + + constructor(userId: string, config: DatabaseConfig) { + this.userId = userId; + this.config = config; + } + + private getDbName(): string { + return `ES:${this.userId}:DB`; + } + + private getDb(): IDBPDatabase { + if (!this.db) throw new Error('Database not opened'); + return this.db; + } + + async open(): Promise { + const { store, version, indexes } = this.config; + + this.db = await openDB(this.getDbName(), version, { + upgrade(db) { + if (!db.objectStoreNames.contains(store)) { + const objectStore = db.createObjectStore(store, { keyPath: 'id' }); + + for (const index of indexes) { + objectStore.createIndex(index.name, index.keyPath, index.options); + } + } + }, + }); + } + + close(): void { + this.getDb().close(); + this.db = null; + } + + async destroy(): Promise { + this.close(); + await deleteDB(this.getDbName()); + } + + async put(record: StoredEmail): Promise { + return this.getDb().put(this.config.store, record); + } + + async putMany(records: StoredEmail[]): Promise { + const tx = this.getDb().transaction(this.config.store, 'readwrite'); + await Promise.all([...records.map((r) => tx.store.put(r)), tx.done]); + } + + async get(id: string): Promise { + return this.getDb().get(this.config.store, id); + } + + async getAll(): Promise { + return this.getDb().getAll(this.config.store); + } + + async getByIndex(indexName: string, value: IDBValidKey): Promise { + const tx = this.getDb().transaction(this.config.store, 'readonly'); + return tx.store.index(indexName).getAll(value) as Promise; + } + + async getByRange(indexName: string, range: IDBKeyRange): Promise { + const tx = this.getDb().transaction(this.config.store, 'readonly'); + return tx.store.index(indexName).getAll(range) as Promise; + } + + async getByFolder(folderId: string, limit?: number): Promise { + const tx = this.getDb().transaction(this.config.store, 'readonly'); + const results = await tx.store.index('byFolder').getAll(folderId); + + results.sort((a, b) => { + const dateA = Number((a as StoredEmail).params.receivedAt); + const dateB = Number((b as StoredEmail).params.receivedAt); + return dateB - dateA; + }); + + return limit ? (results.slice(0, limit) as StoredEmail[]) : (results as StoredEmail[]); + } + + async remove(id: string): Promise { + await this.getDb().delete(this.config.store, id); + } + + async count(): Promise { + return this.getDb().count(this.config.store); + } + + async getBatch( + batchSize: number, + startCursor?: IDBValidKey, + ): Promise<{ items: StoredEmail[]; nextCursor?: IDBValidKey }> { + const tx = this.getDb().transaction(this.config.store, 'readonly'); + const index = tx.store.index('byTime'); + + const items: StoredEmail[] = []; + let cursor = startCursor + ? await index.openCursor(IDBKeyRange.upperBound(startCursor, true), 'prev') + : await index.openCursor(null, 'prev'); + + let nextCursor: IDBValidKey | undefined; + + while (cursor && items.length < batchSize) { + items.push(cursor.value as StoredEmail); + nextCursor = cursor.key; + cursor = await cursor.continue(); + } + + return { + items, + nextCursor: items.length === batchSize ? nextCursor : undefined, + }; + } + + async deleteOldest(count: number): Promise { + const tx = this.getDb().transaction(this.config.store, 'readwrite'); + const index = tx.store.index('byTime'); + let cursor = await index.openCursor(); + let deleted = 0; + + while (cursor && deleted < count) { + await cursor.delete(); + deleted++; + cursor = await cursor.continue(); + } + + await tx.done; + } +} diff --git a/src/services/database/types/index.ts b/src/services/database/types/index.ts new file mode 100644 index 0000000..6c39f5a --- /dev/null +++ b/src/services/database/types/index.ts @@ -0,0 +1,62 @@ +import type { FolderType } from '@/types/mail'; + +export type AttachmentType = + | 'folder' + | 'pdf' + | 'image' + | 'video' + | 'audio' + | 'archive' + | 'document' + | 'powerpoint' + | 'excel'; + +export interface Email { + subject: string; + body: string; +} + +export interface User { + email?: string; + name?: string; + avatar?: string; +} + +export interface EmailParams { + folder?: FolderType; + isRead: boolean; + receivedAt: string; + from: User[]; + to: User[]; + hasAttachment: boolean; + attachmentTypes?: AttachmentType[]; +} + +export interface StoredEmail { + id: string; + mail: Email; + params: EmailParams; +} + +export interface EmailFilters { + from?: string; + to?: string; + isRead?: boolean; + dateRange?: { + after: number; + before: number; + }; + attachmentType?: AttachmentType; +} + +export interface IndexConfig { + name: string; + keyPath: string | string[]; + options?: IDBIndexParameters; +} + +export interface DatabaseConfig { + store: string; + version: number; + indexes: IndexConfig[]; +} diff --git a/src/services/search/types/index.ts b/src/services/search/types/index.ts new file mode 100644 index 0000000..cd44f04 --- /dev/null +++ b/src/services/search/types/index.ts @@ -0,0 +1,22 @@ +export interface SearchFilters { + dateRange?: { + after: number; + before: number; + }; + isRead?: boolean; + hasAttachment?: boolean; +} + +export type SearchField = 'subject' | 'body' | 'from' | 'to'; + +export interface SearchOptions { + fields?: SearchField[]; + limit?: number; + boost?: Partial>; + filters?: SearchFilters; +} + +export interface SearchResult { + item: T; + score: number; +} From 3af40eb1b61e9847dca7c6fc665e103aa15727c9 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:18:49 +0200 Subject: [PATCH 2/3] fix: db name --- src/services/database/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/database/index.ts b/src/services/database/index.ts index 895f296..2b7551c 100644 --- a/src/services/database/index.ts +++ b/src/services/database/index.ts @@ -12,7 +12,7 @@ export class DatabaseService { } private getDbName(): string { - return `ES:${this.userId}:DB`; + return `DB:${this.userId}`; } private getDb(): IDBPDatabase { From a8014d230d8ef8c4b74387f664f1eb8a117eae04 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:45:43 +0200 Subject: [PATCH 3/3] feat: add folder Id if provided --- src/services/database/database.service.test.ts | 4 ++-- src/services/database/types/index.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/services/database/database.service.test.ts b/src/services/database/database.service.test.ts index 5ccdab5..3073945 100644 --- a/src/services/database/database.service.test.ts +++ b/src/services/database/database.service.test.ts @@ -19,7 +19,7 @@ const createEmail = (overrides: Partial = {}): StoredEmail => ({ id: crypto.randomUUID(), mail: { subject: 'Test subject', body: 'Test body' }, params: { - folder: 'inbox', + folderId: 'd', isRead: false, receivedAt: Date.now().toString(), from: [{ email: 'sender@test.com', name: 'Sender' }], @@ -43,7 +43,7 @@ const createEmailWithDate = (daysAgo: number, overrides: Partial = }); }; -describe('DatabaseService', () => { +describe('Database Service', () => { let db: DatabaseService; beforeEach(async () => { diff --git a/src/services/database/types/index.ts b/src/services/database/types/index.ts index 6c39f5a..768e84a 100644 --- a/src/services/database/types/index.ts +++ b/src/services/database/types/index.ts @@ -1,5 +1,3 @@ -import type { FolderType } from '@/types/mail'; - export type AttachmentType = | 'folder' | 'pdf' @@ -23,7 +21,7 @@ export interface User { } export interface EmailParams { - folder?: FolderType; + folderId?: string; isRead: boolean; receivedAt: string; from: User[];