Improve email plugin messaging

- Enhance email parser service with better header handling
- Update message creator for threading support
- Improve reply address and thread matching services

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-30 01:35:18 -08:00
parent 09bc4a5eec
commit dfd9a4dab6
12 changed files with 207 additions and 57 deletions

View file

@ -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": {

View file

@ -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<string, unknown>
createdAt: Date
updatedAt: Date
}
export interface InboxMessage {
id: string
threadId: string
direction: 'inbound' | 'outbound'
messageText: string
sourceType: MessageSourceType
sourceMessageId?: string
sentAt: Date
metadata: Record<string, unknown>
createdAt: Date
updatedAt: Date
}
export interface CreateThreadRequest {
sourceType: MessageSourceType
identityEmail?: string

View file

@ -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 () => {

View file

@ -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
}
/**

View file

@ -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<ConfigService>
let emailParser: jest.Mocked<EmailParserService>
let messageCreator: jest.Mocked<MessageCreatorService>
let webhookVerifier: jest.Mocked<WebhookVerifierService>
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,
})
})
})

View file

@ -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<Repository<EmailThreadMappingEntity>>
let replyAddress: jest.Mocked<ReplyAddressService>
let threadMatcher: jest.Mocked<ThreadMatcherService>
let messagesApi: jest.Mocked<MessagesApiClient>
let eventEmitter: jest.Mocked<EventEmitter>
beforeEach(async () => {
mappingRepository = createMockRepository<EmailThreadMappingEntity>()
@ -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()

View file

@ -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()
}

View file

@ -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')
})

View file

@ -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<ConfigService>
let emailComposer: jest.Mocked<EmailComposerService>
let emailQueueService: jest.Mocked<IEmailQueueService>
let eventEmitter: jest.Mocked<EventEmitter>
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()

View file

@ -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" <reply+TOKEN@domain>
*/
extractTokenFromAddress(address: string): string | null {
const match = address.match(/^reply\+([^@]+)@/)
return match ? match[1] : null
// Try to match with display name format first: "Name" <reply+TOKEN@domain>
const displayNameMatch = address.match(/<reply\+([^@]+)@[^>]+>/)
if (displayNameMatch) {
return displayNameMatch[1]
}
// Fallback to plain address format: reply+TOKEN@domain
const plainMatch = address.match(/^reply\+([^@]+)@/)
return plainMatch ? plainMatch[1] : null
}
/**

View file

@ -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()

View file

@ -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"