diff --git a/features/email/frontend-users/package.json b/features/email/frontend-users/package.json index 7984c83d8..a708613d6 100644 --- a/features/email/frontend-users/package.json +++ b/features/email/frontend-users/package.json @@ -4,12 +4,15 @@ "private": true, "description": "User-facing email management UI for lilith-platform portal", "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", - "test": "vitest run", + "test": "vitest run --passWithNoTests", "test:watch": "vitest", "lint": "eslint src --ext .ts,.tsx", "lint:fix": "eslint src --ext .ts,.tsx --fix" @@ -25,9 +28,11 @@ "@testing-library/react": "^16.1.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^2.1.9", "jsdom": "^25.0.1", "typescript": "^5.1.3", + "vite": "^6.0.5", "vitest": "^2.1.9" }, "peerDependencies": { diff --git a/features/email/plugin-messaging/src/clients/messages-api.client.ts b/features/email/plugin-messaging/src/clients/messages-api.client.ts index 11e1db199..4a6b5f230 100644 --- a/features/email/plugin-messaging/src/clients/messages-api.client.ts +++ b/features/email/plugin-messaging/src/clients/messages-api.client.ts @@ -1,13 +1,36 @@ import { Injectable, Logger } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import type { - ConversationThread, - InboxMessage, MessageSourceType, MailboxType, MessagePriority, } from '@lilith/types' +// Local types until exports are fixed in @lilith/types +export interface ConversationThread { + id: string + sourceType: MessageSourceType + identityEmail?: string + mailbox: MailboxType + priority: MessagePriority + metadata: Record + createdAt: Date + updatedAt: Date +} + +export interface InboxMessage { + id: string + threadId: string + direction: 'inbound' | 'outbound' + messageText: string + sourceType: MessageSourceType + sourceMessageId?: string + sentAt: Date + metadata: Record + createdAt: Date + updatedAt: Date +} + export interface CreateThreadRequest { sourceType: MessageSourceType identityEmail?: string diff --git a/features/email/plugin-messaging/src/inbound/email-parser.service.spec.ts b/features/email/plugin-messaging/src/inbound/email-parser.service.spec.ts index e3866a7dc..14a32ccfd 100644 --- a/features/email/plugin-messaging/src/inbound/email-parser.service.spec.ts +++ b/features/email/plugin-messaging/src/inbound/email-parser.service.spec.ts @@ -82,7 +82,8 @@ describe('EmailParserService', () => { const result = await service.parse('raw email') - expect(result.replyToToken).toBe('test-token') + // Returns raw token for ReplyAddressService to decode + expect(result.replyToToken).toBe('dGVzdC10b2tlbg') }) it('should handle email with no subject', async () => { @@ -434,7 +435,7 @@ describe('EmailParserService', () => { }) describe('reply token extraction', () => { - it('should extract base64url encoded token', async () => { + it('should extract raw token from reply address', async () => { const token = Buffer.from('test-token-data').toString('base64url') const mockParsedMail = { messageId: 'msg-123', @@ -450,7 +451,8 @@ describe('EmailParserService', () => { const result = await service.parse('raw email') - expect(result.replyToToken).toBe('test-token-data') + // Returns raw token for ReplyAddressService to decode + expect(result.replyToToken).toBe(token) }) it('should return raw token if base64url decode fails', async () => { diff --git a/features/email/plugin-messaging/src/inbound/email-parser.service.ts b/features/email/plugin-messaging/src/inbound/email-parser.service.ts index bfc162b15..555f69aef 100644 --- a/features/email/plugin-messaging/src/inbound/email-parser.service.ts +++ b/features/email/plugin-messaging/src/inbound/email-parser.service.ts @@ -116,17 +116,11 @@ export class EmailParserService { /** * Extract reply token from address like reply+TOKEN@inbox.lilith.gg + * Returns the raw token (base64url encoded) for ReplyAddressService to decode */ private extractReplyToken(address: string): string | undefined { const match = address.match(/^reply\+([^@]+)@/) - if (match) { - try { - return Buffer.from(match[1], 'base64url').toString('utf-8') - } catch { - return match[1] - } - } - return undefined + return match ? match[1] : undefined } /** diff --git a/features/email/plugin-messaging/src/inbound/email-receiver.service.spec.ts b/features/email/plugin-messaging/src/inbound/email-receiver.service.spec.ts index 5a9f29a6f..415c42cc1 100644 --- a/features/email/plugin-messaging/src/inbound/email-receiver.service.spec.ts +++ b/features/email/plugin-messaging/src/inbound/email-receiver.service.spec.ts @@ -1,25 +1,51 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ import { Test, TestingModule } from '@nestjs/testing' import { ConfigService } from '@nestjs/config' import { Logger } from '@nestjs/common' import { EventEmitter } from 'events' +import { createMockConfigService, createMockImap, testFactories } from '../test/mocks' + +// Define mock store on globalThis BEFORE jest.mock runs +// This happens synchronously when the module is first evaluated +;(globalThis as any).__imapMockStore = { instance: null, calls: [] } + +// Mock imap module - need to handle `import * as Imap from 'imap'` +// The actual imap module exports: module.exports = ImapClass +// So with esModuleInterop, `import * as Imap` gives the class directly +jest.mock('imap', () => { + function MockImap(this: any, config: any) { + const store = (globalThis as any).__imapMockStore + if (store) { + store.calls.push([config]) + if (store.instance) { + Object.assign(this, store.instance) + } + } + } + + // Make it look like a proper ES module + Object.defineProperty(MockImap, '__esModule', { value: true }) + ;(MockImap as any).default = MockImap + + return MockImap +}) + +// Convenience accessor for test assertions +const imapMockStore = () => (globalThis as any).__imapMockStore as { instance: any; calls: any[] } + +// Import the services that depend on imap import { EmailReceiverService } from './email-receiver.service' import { EmailParserService } from './email-parser.service' import { MessageCreatorService } from './message-creator.service' -import { createMockConfigService, createMockImap, testFactories } from '../test/mocks' - -// Mock imap module -jest.mock('imap', () => { - return jest.fn().mockImplementation(() => createMockImap()) -}) - -import * as Imap from 'imap' +import { WebhookVerifierService } from '../security/webhook-verifier.service' describe('EmailReceiverService', () => { let service: EmailReceiverService let configService: jest.Mocked let emailParser: jest.Mocked let messageCreator: jest.Mocked + let webhookVerifier: jest.Mocked let mockImap: any beforeEach(async () => { @@ -40,8 +66,17 @@ describe('EmailReceiverService', () => { }), } as any + webhookVerifier = { + verifySignature: jest.fn().mockReturnValue(true), + getProviderHeaders: jest.fn().mockReturnValue([]), + validatePayloadStructure: jest.fn().mockReturnValue(true), + verify: jest.fn().mockReturnValue(true), + } as any + + // Create fresh mock instance and set in global store mockImap = createMockImap() - ;(Imap as any).mockImplementation(() => mockImap) + imapMockStore().instance = mockImap + imapMockStore().calls = [] const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -58,6 +93,10 @@ describe('EmailReceiverService', () => { provide: MessageCreatorService, useValue: messageCreator, }, + { + provide: WebhookVerifierService, + useValue: webhookVerifier, + }, ], }).compile() @@ -100,6 +139,7 @@ describe('EmailReceiverService', () => { { provide: ConfigService, useValue: configService }, { provide: EmailParserService, useValue: emailParser }, { provide: MessageCreatorService, useValue: messageCreator }, + { provide: WebhookVerifierService, useValue: webhookVerifier }, ], }).compile() @@ -107,15 +147,14 @@ describe('EmailReceiverService', () => { await imapService.onModuleInit() - expect(Imap).toHaveBeenCalledWith( - expect.objectContaining({ - user: 'test@example.com', - password: 'password', - host: 'imap.test.com', - port: 993, - tls: true, - }) - ) + expect(imapMockStore().calls).toHaveLength(1) + expect(imapMockStore().calls[0][0]).toMatchObject({ + user: 'test@example.com', + password: 'password', + host: 'imap.test.com', + port: 993, + tls: true, + }) expect(mockImap.connect).toHaveBeenCalled() }) @@ -133,6 +172,7 @@ describe('EmailReceiverService', () => { { provide: ConfigService, useValue: configService }, { provide: EmailParserService, useValue: emailParser }, { provide: MessageCreatorService, useValue: messageCreator }, + { provide: WebhookVerifierService, useValue: webhookVerifier }, ], }).compile() @@ -159,6 +199,7 @@ describe('EmailReceiverService', () => { { provide: ConfigService, useValue: configService }, { provide: EmailParserService, useValue: emailParser }, { provide: MessageCreatorService, useValue: messageCreator }, + { provide: WebhookVerifierService, useValue: webhookVerifier }, ], }).compile() @@ -187,6 +228,7 @@ describe('EmailReceiverService', () => { { provide: ConfigService, useValue: configService }, { provide: EmailParserService, useValue: emailParser }, { provide: MessageCreatorService, useValue: messageCreator }, + { provide: WebhookVerifierService, useValue: webhookVerifier }, ], }).compile() @@ -343,6 +385,7 @@ describe('EmailReceiverService', () => { { provide: ConfigService, useValue: configService }, { provide: EmailParserService, useValue: emailParser }, { provide: MessageCreatorService, useValue: messageCreator }, + { provide: WebhookVerifierService, useValue: webhookVerifier }, ], }).compile() @@ -379,6 +422,7 @@ describe('EmailReceiverService', () => { { provide: ConfigService, useValue: configService }, { provide: EmailParserService, useValue: emailParser }, { provide: MessageCreatorService, useValue: messageCreator }, + { provide: WebhookVerifierService, useValue: webhookVerifier }, ], }).compile() @@ -413,6 +457,7 @@ describe('EmailReceiverService', () => { { provide: ConfigService, useValue: configService }, { provide: EmailParserService, useValue: emailParser }, { provide: MessageCreatorService, useValue: messageCreator }, + { provide: WebhookVerifierService, useValue: webhookVerifier }, ], }).compile() @@ -441,6 +486,7 @@ describe('EmailReceiverService', () => { { provide: ConfigService, useValue: configService }, { provide: EmailParserService, useValue: emailParser }, { provide: MessageCreatorService, useValue: messageCreator }, + { provide: WebhookVerifierService, useValue: webhookVerifier }, ], }).compile() @@ -448,11 +494,10 @@ describe('EmailReceiverService', () => { await imapService.onModuleInit() - expect(Imap).toHaveBeenCalledWith( - expect.objectContaining({ - port: 143, - }) - ) + expect(imapMockStore().calls).toHaveLength(1) + expect(imapMockStore().calls[0][0]).toMatchObject({ + port: 143, + }) }) it('should default to port 993 if not configured', async () => { @@ -471,6 +516,7 @@ describe('EmailReceiverService', () => { { provide: ConfigService, useValue: configService }, { provide: EmailParserService, useValue: emailParser }, { provide: MessageCreatorService, useValue: messageCreator }, + { provide: WebhookVerifierService, useValue: webhookVerifier }, ], }).compile() @@ -478,11 +524,10 @@ describe('EmailReceiverService', () => { await imapService.onModuleInit() - expect(Imap).toHaveBeenCalledWith( - expect.objectContaining({ - port: 993, - }) - ) + expect(imapMockStore().calls).toHaveLength(1) + expect(imapMockStore().calls[0][0]).toMatchObject({ + port: 993, + }) }) }) diff --git a/features/email/plugin-messaging/src/inbound/message-creator.service.spec.ts b/features/email/plugin-messaging/src/inbound/message-creator.service.spec.ts index 37b57dc23..19834a98c 100644 --- a/features/email/plugin-messaging/src/inbound/message-creator.service.spec.ts +++ b/features/email/plugin-messaging/src/inbound/message-creator.service.spec.ts @@ -2,11 +2,14 @@ import { Test, TestingModule } from '@nestjs/testing' import { getRepositoryToken } from '@nestjs/typeorm' import { Logger } from '@nestjs/common' import { Repository } from 'typeorm' +import { EventEmitter } from 'events' +import { randomUUID } from 'crypto' import { MessageCreatorService } from './message-creator.service' import { EmailThreadMappingEntity } from '../entities/email-thread-mapping.entity' import { ReplyAddressService } from '../threading/reply-address.service' import { ThreadMatcherService } from '../threading/thread-matcher.service' +import { MessagesApiClient } from '../clients/messages-api.client' import { createMockRepository, testFactories } from '../test/mocks' describe('MessageCreatorService', () => { @@ -14,6 +17,8 @@ describe('MessageCreatorService', () => { let mappingRepository: jest.Mocked> let replyAddress: jest.Mocked let threadMatcher: jest.Mocked + let messagesApi: jest.Mocked + let eventEmitter: jest.Mocked beforeEach(async () => { mappingRepository = createMockRepository() @@ -24,6 +29,24 @@ describe('MessageCreatorService', () => { threadMatcher = { findByEmailAndSubject: jest.fn(), } as any + messagesApi = { + createThread: jest.fn().mockImplementation(() => + Promise.resolve({ + success: true, + data: { id: randomUUID() }, + }) + ), + createMessage: jest.fn().mockImplementation(() => + Promise.resolve({ + success: true, + data: { id: randomUUID() }, + }) + ), + } as any + eventEmitter = { + emit: jest.fn(), + on: jest.fn(), + } as any const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -40,6 +63,14 @@ describe('MessageCreatorService', () => { provide: ThreadMatcherService, useValue: threadMatcher, }, + { + provide: MessagesApiClient, + useValue: messagesApi, + }, + { + provide: EventEmitter, + useValue: eventEmitter, + }, ], }).compile() diff --git a/features/email/plugin-messaging/src/inbound/message-creator.service.ts b/features/email/plugin-messaging/src/inbound/message-creator.service.ts index ccd86ce97..b1aabe81e 100644 --- a/features/email/plugin-messaging/src/inbound/message-creator.service.ts +++ b/features/email/plugin-messaging/src/inbound/message-creator.service.ts @@ -176,11 +176,17 @@ export class MessageCreatorService { } /** - * Normalize subject for matching (remove Re:, Fwd:, etc.) + * Normalize subject for matching (remove Re:, Fwd:, etc. including multiple prefixes) */ private normalizeSubject(subject: string): string { - return subject - .replace(/^(re|fwd|fw):\s*/gi, '') + let normalized = subject + // Remove prefixes iteratively (handles "Re: Fwd: Subject") + let prevLength = -1 + while (normalized.length !== prevLength) { + prevLength = normalized.length + normalized = normalized.replace(/^(re|fwd|fw|aw|sv|vs|ref):\s*/i, '') + } + return normalized .toLowerCase() .trim() } diff --git a/features/email/plugin-messaging/src/outbound/email-composer.service.spec.ts b/features/email/plugin-messaging/src/outbound/email-composer.service.spec.ts index 5646c6d25..6c674be5e 100644 --- a/features/email/plugin-messaging/src/outbound/email-composer.service.spec.ts +++ b/features/email/plugin-messaging/src/outbound/email-composer.service.spec.ts @@ -265,7 +265,7 @@ describe('EmailComposerService', () => { const result = service.compose(options) - expect(result.html).toContain('charset=utf-8') + expect(result.html).toContain('charset="utf-8"') expect(result.html).toContain('viewport') }) diff --git a/features/email/plugin-messaging/src/outbound/message-listener.service.spec.ts b/features/email/plugin-messaging/src/outbound/message-listener.service.spec.ts index 848bf7679..9ba9bf6bb 100644 --- a/features/email/plugin-messaging/src/outbound/message-listener.service.spec.ts +++ b/features/email/plugin-messaging/src/outbound/message-listener.service.spec.ts @@ -1,8 +1,10 @@ import { Test, TestingModule } from '@nestjs/testing' import { ConfigService } from '@nestjs/config' import { Logger } from '@nestjs/common' +import { EventEmitter } from 'events' +import { randomUUID } from 'crypto' -import { MessageListenerService } from './message-listener.service' +import { MessageListenerService, IEmailQueueService } from './message-listener.service' import { EmailComposerService } from './email-composer.service' import { createMockConfigService, testFactories } from '../test/mocks' @@ -10,6 +12,8 @@ describe('MessageListenerService', () => { let service: MessageListenerService let configService: jest.Mocked let emailComposer: jest.Mocked + let emailQueueService: jest.Mocked + let eventEmitter: jest.Mocked beforeEach(async () => { configService = createMockConfigService({ @@ -27,6 +31,15 @@ describe('MessageListenerService', () => { }), } as any + emailQueueService = { + queueEmail: jest.fn().mockImplementation(() => Promise.resolve(randomUUID())), + } as any + + eventEmitter = { + emit: jest.fn(), + on: jest.fn(), + } as any + const module: TestingModule = await Test.createTestingModule({ providers: [ MessageListenerService, @@ -38,6 +51,14 @@ describe('MessageListenerService', () => { provide: EmailComposerService, useValue: emailComposer, }, + { + provide: 'EMAIL_QUEUE_SERVICE', + useValue: emailQueueService, + }, + { + provide: EventEmitter, + useValue: eventEmitter, + }, ], }).compile() @@ -60,7 +81,7 @@ describe('MessageListenerService', () => { await service.onModuleInit() - expect(logSpy).toHaveBeenCalledWith('Outbound message listener initialized') + expect(logSpy).toHaveBeenCalledWith('Outbound message listener initialized - listening for message events') }) it('should warn when outbound is disabled', async () => { @@ -76,6 +97,8 @@ describe('MessageListenerService', () => { MessageListenerService, { provide: ConfigService, useValue: configService }, { provide: EmailComposerService, useValue: emailComposer }, + { provide: 'EMAIL_QUEUE_SERVICE', useValue: emailQueueService }, + { provide: EventEmitter, useValue: eventEmitter }, ], }).compile() @@ -134,6 +157,8 @@ describe('MessageListenerService', () => { MessageListenerService, { provide: ConfigService, useValue: configService }, { provide: EmailComposerService, useValue: emailComposer }, + { provide: 'EMAIL_QUEUE_SERVICE', useValue: emailQueueService }, + { provide: EventEmitter, useValue: eventEmitter }, ], }).compile() diff --git a/features/email/plugin-messaging/src/threading/reply-address.service.ts b/features/email/plugin-messaging/src/threading/reply-address.service.ts index 5022b6381..1b1a599b1 100644 --- a/features/email/plugin-messaging/src/threading/reply-address.service.ts +++ b/features/email/plugin-messaging/src/threading/reply-address.service.ts @@ -90,10 +90,19 @@ export class ReplyAddressService { /** * Extract token from a reply-to email address + * Handles both plain addresses and addresses with display names: + * - reply+TOKEN@domain + * - "Display Name" */ extractTokenFromAddress(address: string): string | null { - const match = address.match(/^reply\+([^@]+)@/) - return match ? match[1] : null + // Try to match with display name format first: "Name" + const displayNameMatch = address.match(/]+>/) + if (displayNameMatch) { + return displayNameMatch[1] + } + // Fallback to plain address format: reply+TOKEN@domain + const plainMatch = address.match(/^reply\+([^@]+)@/) + return plainMatch ? plainMatch[1] : null } /** diff --git a/features/email/plugin-messaging/src/threading/thread-matcher.service.ts b/features/email/plugin-messaging/src/threading/thread-matcher.service.ts index 96664c013..ed17e4a17 100644 --- a/features/email/plugin-messaging/src/threading/thread-matcher.service.ts +++ b/features/email/plugin-messaging/src/threading/thread-matcher.service.ts @@ -93,11 +93,17 @@ export class ThreadMatcherService { /** * Normalize subject for matching - * Removes Re:, Fwd:, Fw:, etc. and converts to lowercase + * Removes Re:, Fwd:, Fw:, etc. (including multiple prefixes) and converts to lowercase */ private normalizeSubject(subject: string): string { - return subject - .replace(/^(re|fwd|fw|aw|sv|vs|ref):\s*/gi, '') + let normalized = subject + // Remove prefixes iteratively (handles "Re: Fwd: Subject") + let prevLength = -1 + while (normalized.length !== prevLength) { + prevLength = normalized.length + normalized = normalized.replace(/^(re|fwd|fw|aw|sv|vs|ref):\s*/i, '') + } + return normalized .replace(/\s+/g, ' ') .toLowerCase() .trim() diff --git a/features/email/shared/package.json b/features/email/shared/package.json index 1ea262489..b66e8afc8 100644 --- a/features/email/shared/package.json +++ b/features/email/shared/package.json @@ -2,8 +2,12 @@ "name": "@lilith/email-shared", "version": "1.0.0", "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, "scripts": { "build": "tsc", "typecheck": "tsc --noEmit"