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
+73 -12
View File
@@ -1,8 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRecipientLookupStore } from '@/stores/mailRecipientsStore'
import type { MessageAddressInterface } from '@MailManager/types/message'
interface Props { interface Props {
to: string[] to: MessageAddressInterface[]
cc: string[] cc: MessageAddressInterface[]
bcc: string[] bcc: MessageAddressInterface[]
subject: string subject: string
showCc: boolean showCc: boolean
showBcc: boolean showBcc: boolean
@@ -10,14 +13,34 @@ interface Props {
defineProps<Props>() defineProps<Props>()
defineEmits<{ const emit = defineEmits<{
'update:to': [value: string[]] 'update:to': [value: MessageAddressInterface[]]
'update:cc': [value: string[]] 'update:cc': [value: MessageAddressInterface[]]
'update:bcc': [value: string[]] 'update:bcc': [value: MessageAddressInterface[]]
'update:subject': [value: string] 'update:subject': [value: string]
'toggle:cc': [] 'toggle:cc': []
'toggle:bcc': [] 'toggle:bcc': []
}>() }>()
const recipientLookup = useRecipientLookupStore()
/**
* Normalize combobox entries: typed strings become bare address objects;
* picked suggestions are already MessageAddressInterface and pass through.
*/
function normalize(entries: (MessageAddressInterface | string)[]): MessageAddressInterface[] {
return entries.map(entry =>
typeof entry === 'string' ? { address: entry } : entry,
)
}
function chipLabel(item: MessageAddressInterface): string {
return item.label ? `${item.label} <${item.address}>` : item.address
}
function itemTitle(item: MessageAddressInterface): string {
return item.label || item.address
}
</script> </script>
<template> <template>
@@ -28,11 +51,23 @@ defineEmits<{
chips chips
multiple multiple
closable-chips closable-chips
return-object
no-filter
variant="outlined" variant="outlined"
density="compact" density="compact"
class="mb-2" class="mb-2"
@update:model-value="$emit('update:to', $event)" :items="recipientLookup.suggestions"
:loading="recipientLookup.searching"
:item-title="itemTitle"
@update:search="recipientLookup.search($event)"
@update:model-value="emit('update:to', normalize($event))"
> >
<template #chip="{ item, props: chipProps }">
<v-chip v-bind="chipProps">{{ chipLabel(item) }}</v-chip>
</template>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps" :subtitle="item.label ? item.address : undefined" />
</template>
<template #append-inner> <template #append-inner>
<v-btn <v-btn
size="x-small" size="x-small"
@@ -59,11 +94,24 @@ defineEmits<{
chips chips
multiple multiple
closable-chips closable-chips
return-object
no-filter
variant="outlined" variant="outlined"
density="compact" density="compact"
class="mb-2" class="mb-2"
@update:model-value="$emit('update:cc', $event)" :items="recipientLookup.suggestions"
/> :loading="recipientLookup.searching"
:item-title="itemTitle"
@update:search="recipientLookup.search($event)"
@update:model-value="emit('update:cc', normalize($event))"
>
<template #chip="{ item, props: chipProps }">
<v-chip v-bind="chipProps">{{ chipLabel(item) }}</v-chip>
</template>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps" :subtitle="item.label ? item.address : undefined" />
</template>
</v-combobox>
<v-combobox <v-combobox
v-if="showBcc" v-if="showBcc"
@@ -72,11 +120,24 @@ defineEmits<{
chips chips
multiple multiple
closable-chips closable-chips
return-object
no-filter
variant="outlined" variant="outlined"
density="compact" density="compact"
class="mb-2" class="mb-2"
@update:model-value="$emit('update:bcc', $event)" :items="recipientLookup.suggestions"
/> :loading="recipientLookup.searching"
:item-title="itemTitle"
@update:search="recipientLookup.search($event)"
@update:model-value="emit('update:bcc', normalize($event))"
>
<template #chip="{ item, props: chipProps }">
<v-chip v-bind="chipProps">{{ chipLabel(item) }}</v-chip>
</template>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps" :subtitle="item.label ? item.address : undefined" />
</template>
</v-combobox>
<v-text-field <v-text-field
:model-value="subject" :model-value="subject"
+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
+6 -4
View File
@@ -14,6 +14,7 @@ import type {
import { ComposerMode } from '@/types/composer' import { ComposerMode } from '@/types/composer'
import type { ComposerDraft, ComposerDraftAttachment, ComposerDraftMessage, ComposerSenderIdentity } from '@/types/composer' import type { ComposerDraft, ComposerDraftAttachment, ComposerDraftMessage, ComposerSenderIdentity } from '@/types/composer'
import { EntityObject, MessageAddressObject, ServiceObject } from '@MailManager/models' import { EntityObject, MessageAddressObject, ServiceObject } from '@MailManager/models'
import type { MessageAddressInterface } from '@MailManager/types/message'
export const useMailCompositionStore = defineStore('mailCompositionStore', () => { export const useMailCompositionStore = defineStore('mailCompositionStore', () => {
const servicesStore = useServicesStore() const servicesStore = useServicesStore()
@@ -234,7 +235,7 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
} }
} }
function updateRecipients(field: 'to' | 'cc' | 'bcc', values: string[]) { function updateRecipients(field: 'to' | 'cc' | 'bcc', values: MessageAddressInterface[]) {
if (!activeDraft.value) { if (!activeDraft.value) {
return return
} }
@@ -464,7 +465,7 @@ function buildMessage(
const freshMessage = emptyMessage() const freshMessage = emptyMessage()
if (source && source instanceof MessageAddressObject) { if (source && source instanceof MessageAddressObject) {
freshMessage.to = [source.address] freshMessage.to = [{ address: source.address, label: source.label }]
} }
return freshMessage return freshMessage
@@ -482,9 +483,10 @@ function buildMessage(
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time' const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
if (mode === ComposerMode.Reply) { if (mode === ComposerMode.Reply) {
const fromEmail = sourceMessage.replyTo?.[0]?.address || sourceMessage.from?.address || '' const replyToAddr = sourceMessage.replyTo?.[0] ?? sourceMessage.from ?? null
const replyToAddress = replyToAddr?.address || ''
return { return {
to: fromEmail ? [fromEmail] : [], to: replyToAddress ? [{ address: replyToAddress, label: replyToAddr?.label }] : [],
cc: [], cc: [],
bcc: [], bcc: [],
subject: /^Re:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`, subject: /^Re:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`,
+85
View File
@@ -0,0 +1,85 @@
/**
* Recipients Store
*/
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { recipientService } from '../services/recipientService'
import type { MessageAddressInterface } from '@MailManager/types/message'
/** Minimum characters before a lookup fires. */
const MIN_QUERY_LENGTH = 3
/** Debounce window for keystroke-driven searches. */
const DEBOUNCE_MS = 250
export const useRecipientLookupStore = defineStore('mailRecipientsStore', () => {
const suggestions = ref<MessageAddressInterface[]>([])
const searching = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
// Monotonic token so out-of-order/stale responses are discarded.
let requestToken = 0
function cancelPending() {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}
/**
* Clear suggestions and cancel any in-flight lookup.
*/
function clear() {
cancelPending()
requestToken++
suggestions.value = []
searching.value = false
}
/**
* Debounced contact search. Queries shorter than MIN_QUERY_LENGTH clear the
* suggestion list without hitting the backend.
*/
function search(query: string) {
const trimmed = (query ?? '').trim()
cancelPending()
if (trimmed.length < MIN_QUERY_LENGTH) {
requestToken++
suggestions.value = []
searching.value = false
return
}
searching.value = true
const token = ++requestToken
debounceTimer = setTimeout(async () => {
try {
const matches = await recipientService.search(trimmed)
if (token !== requestToken) {
return
}
suggestions.value = matches
} catch (error: any) {
if (token === requestToken) {
suggestions.value = []
}
console.error('[Mail][Recipient Lookup] - Search failed:', error)
} finally {
if (token === requestToken) {
searching.value = false
}
}
}, DEBOUNCE_MS)
}
return {
suggestions,
searching,
search,
clear,
}
})
+5 -4
View File
@@ -1,5 +1,6 @@
import type { ServiceObject } from "@MailManager/models/service" import type { ServiceObject } from "@MailManager/models/service"
import type { EntityIdentifier, ServiceIdentifier } from "@MailManager/types/common" import type { EntityIdentifier } from "@MailManager/types/common"
import type { MessageAddressInterface } from "@MailManager/types/message"
export enum ComposerMode { export enum ComposerMode {
Fresh = 'fresh', Fresh = 'fresh',
@@ -18,9 +19,9 @@ export interface ComposerDraftAttachment {
} }
export interface ComposerDraftMessage { export interface ComposerDraftMessage {
to: string[] to: MessageAddressInterface[]
cc: string[] cc: MessageAddressInterface[]
bcc: string[] bcc: MessageAddressInterface[]
subject: string subject: string
body: { body: {
html: string html: string
+5 -4
View File
@@ -1,4 +1,5 @@
import type { EntityIdentifier, ServiceIdentifier } from '@MailManager/types/common' import type { EntityIdentifier } from '@MailManager/types/common'
import type { MessageAddressInterface } from '@MailManager/types/message'
export interface CompositionSenderInterface { export interface CompositionSenderInterface {
provider: string provider: string
@@ -8,9 +9,9 @@ export interface CompositionSenderInterface {
} }
export interface CompositionMessageInterface { export interface CompositionMessageInterface {
to: string[] to: MessageAddressInterface[]
cc: string[] cc: MessageAddressInterface[]
bcc: string[] bcc: MessageAddressInterface[]
subject: string subject: string
body: { body: {
text: string text: string
+5 -2
View File
@@ -6,7 +6,9 @@
"src/utile/**/*.ts", "src/utile/**/*.ts",
"../../core/src/**/*.ts", "../../core/src/**/*.ts",
"../mail_manager/src/**/*.ts", "../mail_manager/src/**/*.ts",
"../mail_manager/src/**/*.vue" "../mail_manager/src/**/*.vue",
"../people_manager/src/**/*.ts",
"../people_manager/src/**/*.vue"
], ],
"exclude": ["src/**/__tests__/*"], "exclude": ["src/**/__tests__/*"],
"compilerOptions": { "compilerOptions": {
@@ -15,7 +17,8 @@
"@/*": ["./src/*"], "@/*": ["./src/*"],
"@KTXC": ["../../core/src/shared/index.ts"], "@KTXC": ["../../core/src/shared/index.ts"],
"@KTXC/*": ["../../core/src/*"], "@KTXC/*": ["../../core/src/*"],
"@MailManager/*": ["../mail_manager/src/*"] "@MailManager/*": ["../mail_manager/src/*"],
"@PeopleManager/*": ["../people_manager/src/*"]
} }
} }
} }
+5
View File
@@ -31,6 +31,7 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
'@KTXC': path.resolve(__dirname, '../../core/src'), '@KTXC': path.resolve(__dirname, '../../core/src'),
'@MailManager': path.resolve(__dirname, '../mail_manager/src'), '@MailManager': path.resolve(__dirname, '../mail_manager/src'),
'@PeopleManager': path.resolve(__dirname, '../people_manager/src'),
}, },
}, },
build: { build: {
@@ -49,6 +50,7 @@ export default defineConfig({
'pinia', 'pinia',
'@KTXC', '@KTXC',
/^@MailManager\//, /^@MailManager\//,
/^@PeopleManager\//,
], ],
output: { output: {
paths: (id) => { paths: (id) => {
@@ -56,6 +58,9 @@ export default defineConfig({
if (id.startsWith('@MailManager/')) { if (id.startsWith('@MailManager/')) {
return '/modules/mail_manager/static/module.mjs' return '/modules/mail_manager/static/module.mjs'
} }
if (id.startsWith('@PeopleManager/')) {
return '/modules/people_manager/static/module.mjs'
}
return id return id
}, },
assetFileNames: (assetInfo) => { assetFileNames: (assetInfo) => {