feat: recipient search
Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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 { 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}`,
|
||||||
|
|||||||
@@ -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 { 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
|
||||||
|
|||||||
@@ -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
@@ -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/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user