feat: recipient search
Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useRecipientLookupStore } from '@/stores/mailRecipientsStore'
|
||||
import type { MessageAddressInterface } from '@MailManager/types/message'
|
||||
|
||||
interface Props {
|
||||
to: string[]
|
||||
cc: string[]
|
||||
bcc: string[]
|
||||
to: MessageAddressInterface[]
|
||||
cc: MessageAddressInterface[]
|
||||
bcc: MessageAddressInterface[]
|
||||
subject: string
|
||||
showCc: boolean
|
||||
showBcc: boolean
|
||||
@@ -10,14 +13,34 @@ interface Props {
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
'update:to': [value: string[]]
|
||||
'update:cc': [value: string[]]
|
||||
'update:bcc': [value: string[]]
|
||||
const emit = defineEmits<{
|
||||
'update:to': [value: MessageAddressInterface[]]
|
||||
'update:cc': [value: MessageAddressInterface[]]
|
||||
'update:bcc': [value: MessageAddressInterface[]]
|
||||
'update:subject': [value: string]
|
||||
'toggle:cc': []
|
||||
'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>
|
||||
|
||||
<template>
|
||||
@@ -28,11 +51,23 @@ defineEmits<{
|
||||
chips
|
||||
multiple
|
||||
closable-chips
|
||||
return-object
|
||||
no-filter
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
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>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
@@ -59,11 +94,24 @@ defineEmits<{
|
||||
chips
|
||||
multiple
|
||||
closable-chips
|
||||
return-object
|
||||
no-filter
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
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-if="showBcc"
|
||||
@@ -72,11 +120,24 @@ defineEmits<{
|
||||
chips
|
||||
multiple
|
||||
closable-chips
|
||||
return-object
|
||||
no-filter
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
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
|
||||
:model-value="subject"
|
||||
|
||||
@@ -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
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import { ComposerMode } from '@/types/composer'
|
||||
import type { ComposerDraft, ComposerDraftAttachment, ComposerDraftMessage, ComposerSenderIdentity } from '@/types/composer'
|
||||
import { EntityObject, MessageAddressObject, ServiceObject } from '@MailManager/models'
|
||||
import type { MessageAddressInterface } from '@MailManager/types/message'
|
||||
|
||||
export const useMailCompositionStore = defineStore('mailCompositionStore', () => {
|
||||
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) {
|
||||
return
|
||||
}
|
||||
@@ -464,7 +465,7 @@ function buildMessage(
|
||||
const freshMessage = emptyMessage()
|
||||
|
||||
if (source && source instanceof MessageAddressObject) {
|
||||
freshMessage.to = [source.address]
|
||||
freshMessage.to = [{ address: source.address, label: source.label }]
|
||||
}
|
||||
|
||||
return freshMessage
|
||||
@@ -482,9 +483,10 @@ function buildMessage(
|
||||
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
|
||||
|
||||
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 {
|
||||
to: fromEmail ? [fromEmail] : [],
|
||||
to: replyToAddress ? [{ address: replyToAddress, label: replyToAddr?.label }] : [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: /^Re:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
Fresh = 'fresh',
|
||||
@@ -18,9 +19,9 @@ export interface ComposerDraftAttachment {
|
||||
}
|
||||
|
||||
export interface ComposerDraftMessage {
|
||||
to: string[]
|
||||
cc: string[]
|
||||
bcc: string[]
|
||||
to: MessageAddressInterface[]
|
||||
cc: MessageAddressInterface[]
|
||||
bcc: MessageAddressInterface[]
|
||||
subject: string
|
||||
body: {
|
||||
html: string
|
||||
|
||||
@@ -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 {
|
||||
provider: string
|
||||
@@ -8,9 +9,9 @@ export interface CompositionSenderInterface {
|
||||
}
|
||||
|
||||
export interface CompositionMessageInterface {
|
||||
to: string[]
|
||||
cc: string[]
|
||||
bcc: string[]
|
||||
to: MessageAddressInterface[]
|
||||
cc: MessageAddressInterface[]
|
||||
bcc: MessageAddressInterface[]
|
||||
subject: string
|
||||
body: {
|
||||
text: string
|
||||
|
||||
+5
-2
@@ -6,7 +6,9 @@
|
||||
"src/utile/**/*.ts",
|
||||
"../../core/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__/*"],
|
||||
"compilerOptions": {
|
||||
@@ -15,7 +17,8 @@
|
||||
"@/*": ["./src/*"],
|
||||
"@KTXC": ["../../core/src/shared/index.ts"],
|
||||
"@KTXC/*": ["../../core/src/*"],
|
||||
"@MailManager/*": ["../mail_manager/src/*"]
|
||||
"@MailManager/*": ["../mail_manager/src/*"],
|
||||
"@PeopleManager/*": ["../people_manager/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@KTXC': path.resolve(__dirname, '../../core/src'),
|
||||
'@MailManager': path.resolve(__dirname, '../mail_manager/src'),
|
||||
'@PeopleManager': path.resolve(__dirname, '../people_manager/src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
@@ -49,6 +50,7 @@ export default defineConfig({
|
||||
'pinia',
|
||||
'@KTXC',
|
||||
/^@MailManager\//,
|
||||
/^@PeopleManager\//,
|
||||
],
|
||||
output: {
|
||||
paths: (id) => {
|
||||
@@ -56,6 +58,9 @@ export default defineConfig({
|
||||
if (id.startsWith('@MailManager/')) {
|
||||
return '/modules/mail_manager/static/module.mjs'
|
||||
}
|
||||
if (id.startsWith('@PeopleManager/')) {
|
||||
return '/modules/people_manager/static/module.mjs'
|
||||
}
|
||||
return id
|
||||
},
|
||||
assetFileNames: (assetInfo) => {
|
||||
|
||||
Reference in New Issue
Block a user