feat: recipient search

Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
2026-06-22 22:54:02 -04:00
parent 949bbfa538
commit b419af09eb
8 changed files with 294 additions and 26 deletions
+110
View File
@@ -0,0 +1,110 @@
/**
* Recipient service
*/
import { entityService } from '@PeopleManager/services'
import { GroupObject, IndividualObject, OrganizationObject, type EntityObject } from '@PeopleManager/models'
import type { MessageAddressInterface } from '@MailManager/types/message'
/** Minimum query length before a lookup is worthwhile. */
const MIN_QUERY_LENGTH = 3
/** Default cap on the number of matches returned. */
const DEFAULT_LIMIT = 10
/**
* Derive a human-readable display name from a contact's properties.
*/
function displayName(properties: IndividualObject | OrganizationObject | GroupObject): string {
if (properties.label) {
return properties.label
}
if (properties instanceof IndividualObject) {
const parts = [properties.names?.given, properties.names?.family].filter(Boolean)
if (parts.length > 0) {
return parts.join(' ')
}
} else if (properties instanceof OrganizationObject) {
if (properties.names?.full) {
return properties.names.full
}
}
return ''
}
/**
* Flatten a contact entity into one recipient per email address.
* Groups (no addressable email) are skipped.
*/
function toRecipients(entity: EntityObject): MessageAddressInterface[] {
const properties = entity.properties
if (properties instanceof GroupObject) {
return []
}
const emails = properties.emails
if (!emails) {
return []
}
const label = displayName(properties)
const recipients: MessageAddressInterface[] = []
for (const email of Object.values(emails)) {
const address = email?.address?.trim()
if (!address) {
continue
}
recipients.push({ address, label: label || address })
}
return recipients
}
export const recipientService = {
/**
* Search the user's contacts for recipients matching a query.
*
* @param query - free-text fragment matched against contact names
* @param limit - maximum number of recipients to return
*
* @returns De-duplicated recipients (one per email address)
*/
async search(query: string, limit: number = DEFAULT_LIMIT): Promise<MessageAddressInterface[]> {
const trimmed = query.trim()
if (trimmed.length < MIN_QUERY_LENGTH) {
return []
}
const recipients: MessageAddressInterface[] = []
const seen = new Set<string>()
try {
// The "*" attribute defaults to LIKE, so a scalar string is all that is
// needed; range caps the scan to the first `limit` matches.
await entityService.listStream(
{ filter: { '*': trimmed }, range: { type: 'tally', anchor: 'absolute', position: 0, tally: limit } },
(entity: EntityObject) => {
for (const recipient of toRecipients(entity)) {
const key = recipient.address.toLowerCase()
if (seen.has(key)) {
continue
}
seen.add(key)
recipients.push(recipient)
}
},
)
} catch (error: any) {
console.error('[Mail][Recipient Service] - Search failed:', error)
return []
}
return recipients.slice(0, limit)
},
}
export default recipientService