diff --git a/src/components/composer/ComposerRecipients.vue b/src/components/composer/ComposerRecipients.vue
index 0e7110f..f2ef810 100644
--- a/src/components/composer/ComposerRecipients.vue
+++ b/src/components/composer/ComposerRecipients.vue
@@ -1,8 +1,11 @@
@@ -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))"
>
+
+ {{ chipLabel(item) }}
+
+
+
+
+ :items="recipientLookup.suggestions"
+ :loading="recipientLookup.searching"
+ :item-title="itemTitle"
+ @update:search="recipientLookup.search($event)"
+ @update:model-value="emit('update:cc', normalize($event))"
+ >
+
+ {{ chipLabel(item) }}
+
+
+
+
+
+ :items="recipientLookup.suggestions"
+ :loading="recipientLookup.searching"
+ :item-title="itemTitle"
+ @update:search="recipientLookup.search($event)"
+ @update:model-value="emit('update:bcc', normalize($event))"
+ >
+
+ {{ chipLabel(item) }}
+
+
+
+
+
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 {
+ const trimmed = query.trim()
+ if (trimmed.length < MIN_QUERY_LENGTH) {
+ return []
+ }
+
+ const recipients: MessageAddressInterface[] = []
+ const seen = new Set()
+
+ 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
diff --git a/src/stores/mailCompositionStore.ts b/src/stores/mailCompositionStore.ts
index a38c641..43b7724 100644
--- a/src/stores/mailCompositionStore.ts
+++ b/src/stores/mailCompositionStore.ts
@@ -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}`,
diff --git a/src/stores/mailRecipientsStore.ts b/src/stores/mailRecipientsStore.ts
new file mode 100644
index 0000000..4fb323e
--- /dev/null
+++ b/src/stores/mailRecipientsStore.ts
@@ -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([])
+ const searching = ref(false)
+
+ let debounceTimer: ReturnType | 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,
+ }
+})
diff --git a/src/types/composer.ts b/src/types/composer.ts
index f1495f8..75810ae 100644
--- a/src/types/composer.ts
+++ b/src/types/composer.ts
@@ -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
diff --git a/src/types/composition.ts b/src/types/composition.ts
index b3e38e5..31ea426 100644
--- a/src/types/composition.ts
+++ b/src/types/composition.ts
@@ -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
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 1e834fa..02c04d8 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -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/*"]
}
}
}
diff --git a/vite.config.ts b/vite.config.ts
index 6c0cda3..925ba02 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -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) => {