✨ 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:
parent
09bc4a5eec
commit
dfd9a4dab6
12 changed files with 207 additions and 57 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue