feat: recipient details

Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
2026-05-22 11:56:29 -04:00
parent 897a03578e
commit f1d0511cbb
8 changed files with 297 additions and 44 deletions

View File

@@ -10,14 +10,16 @@ import Placeholder from '@tiptap/extension-placeholder'
import { EntityObject } from '@MailManager/models/entity' import { EntityObject } from '@MailManager/models/entity'
import type { CollectionObject } from '@MailManager/models' import type { CollectionObject } from '@MailManager/models'
import { useMailStore } from '@/stores/mailStore' import { useMailStore } from '@/stores/mailStore'
import type { MessageAddressInterface } from '@MailManager/types/message'
import { ComposerMode } from '@/types/composer'
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue' import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue' import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
import ComposerEditor from '@/components/composer/ComposerEditor.vue' import ComposerEditor from '@/components/composer/ComposerEditor.vue'
// Props // Props
interface Props { interface Props {
mode: 'new' | 'reply' | 'forward' mode: ComposerMode
source?: EntityObject | null source?: EntityObject | MessageAddressInterface | null
folder?: CollectionObject | null folder?: CollectionObject | null
} }
@@ -83,7 +85,15 @@ function initializeComposerFromProps() {
mailStore.resetComposerState() mailStore.resetComposerState()
resetComposerFields() resetComposerFields()
if (!props.source) { if (props.mode === ComposerMode.Fresh) {
if (props.source && 'address' in props.source) {
// If source is an email address, pre-fill the "To" field
to.value = [props.source.address]
}
return
}
if (props.source instanceof EntityObject == false) {
return return
} }
@@ -94,19 +104,19 @@ function initializeComposerFromProps() {
const sentAt = sourceMessage.sent || props.source.created || '' const sentAt = sourceMessage.sent || props.source.created || ''
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time' const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
if (props.mode === 'reply') { if (props.mode === ComposerMode.Reply) {
const fromEmail = sourceMessage.replyTo?.[0]?.address || sourceMessage.from?.address const fromEmail = sourceMessage.replyTo?.[0]?.address || sourceMessage.from?.address
to.value = fromEmail ? [fromEmail] : [] to.value = fromEmail ? [fromEmail] : []
subject.value = /^Re:/i.test(originalSubject) subject.value = /^Re:/i.test(originalSubject)
? originalSubject ? originalSubject
: `Re: ${originalSubject}` : `Re: ${originalSubject}`
editor.value?.commands.setContent( editor.value?.commands.setContent(
`<p><br></p><p>On ${sentLabel}, ${senderName} wrote:</p><blockquote>${originalBody}</blockquote>`, `<p><br></p><p>---------- Original message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
) )
return return
} }
if (props.mode === 'forward') { if (props.mode === ComposerMode.Forward) {
subject.value = /^Fwd:/i.test(originalSubject) subject.value = /^Fwd:/i.test(originalSubject)
? originalSubject ? originalSubject
: `Fwd: ${originalSubject}` : `Fwd: ${originalSubject}`

View File

@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, ref } from 'vue'
import type { EntityIdentifier } from '@MailManager/types/common' import type { EntityIdentifier } from '@MailManager/types/common'
import type { EntityObject } from '@MailManager/models' import type { EntityObject } from '@MailManager/models'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
import RecipientDetails from '@/components/common/RecipientDetails.vue'
// Props // Props
interface Props { interface Props {
@@ -363,7 +364,11 @@ onBeforeUnmount(() => {
<v-list-item-title class="d-flex align-center"> <v-list-item-title class="d-flex align-center">
<span class="flex-grow-1 text-truncate"> <span class="flex-grow-1 text-truncate">
{{ message.properties.from?.label || message.properties.from?.address || 'Unknown Sender' }} <RecipientDetails :address="message.properties.from">
<template #default="{ label }">
<span class="message-person-link text-truncate">{{ label }}</span>
</template>
</RecipientDetails>
</span> </span>
<span class="text-caption text-medium-emphasis ml-2"> <span class="text-caption text-medium-emphasis ml-2">
{{ formatDate(timeStamp(message)) }} {{ formatDate(timeStamp(message)) }}
@@ -519,6 +524,19 @@ onBeforeUnmount(() => {
background-color: rgba(var(--v-theme-primary), 0.14); background-color: rgba(var(--v-theme-primary), 0.14);
} }
.message-person-link {
display: inline-block;
max-width: 100%;
border-radius: 4px;
padding: 1px 4px;
margin: -1px -4px;
transition: background-color 0.2s ease;
}
.message-person-link:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
}
@media (max-width: 960px) { @media (max-width: 960px) {
.selection-summary { .selection-summary {
flex-direction: column; flex-direction: column;

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useSnackbar } from '@KTXC'
import type { MessageAddressInterface } from '@MailManager/types/message'
import { useMailUiStore } from '@/stores/mailUiStore'
import { ComposerMode } from '@/types/composer'
interface Props {
address?: MessageAddressInterface | null
}
const props = defineProps<Props>()
const mailUiStore = useMailUiStore()
const { showSnackbar } = useSnackbar()
const recipientLabel = computed(() => props.address?.label?.trim() || '')
const recipientAddress = computed(() => props.address?.address?.trim() || '')
const displayLabel = computed(() => recipientLabel.value || recipientAddress.value || 'Unknown Sender')
const formattedAddress = computed(() => {
if (recipientLabel.value && recipientAddress.value && recipientLabel.value !== recipientAddress.value) {
return `${recipientLabel.value} <${recipientAddress.value}>`
}
return recipientAddress.value || recipientLabel.value
})
const hasEmail = computed(() => recipientAddress.value.length > 0)
const showCopyResult = (message: string, color: 'success' | 'error') => {
showSnackbar({ message, color })
}
const copy = async (value: string, label: string) => {
if (!value) {
return
}
try {
await navigator.clipboard.writeText(value)
showCopyResult(`${label} copied`, 'success')
} catch (error) {
console.error('[Mail][RecipientDetails] Failed to copy text:', error)
showCopyResult(`Unable to copy ${label.toLowerCase()}`, 'error')
}
}
const handleCompose = () => {
if (!props.address) {
return
}
mailUiStore.openComposer(props.address, ComposerMode.Fresh)
}
</script>
<template>
<v-menu
:open-on-hover="true"
:open-on-click="false"
:open-delay="1000"
location="bottom start"
transition="slide-y-transition"
>
<template #activator="{ props: activatorProps }">
<span
v-bind="activatorProps"
class="address-activator"
@click.stop
>
<slot
:label="displayLabel"
:name="recipientLabel"
:email="recipientAddress"
>
{{ displayLabel }}
</slot>
</span>
</template>
<v-card class="address-card" elevation="8" rounded="lg">
<div class="address-card-header">
<v-avatar size="40" color="primary">
<span class="text-white text-subtitle-2">
{{ displayLabel[0]?.toUpperCase() || 'U' }}
</span>
</v-avatar>
<div class="address-card-meta">
<div class="text-subtitle-2 font-weight-medium">{{ displayLabel }}</div>
<div v-if="recipientAddress" class="text-body-2 text-medium-emphasis">{{ recipientAddress }}</div>
</div>
</div>
<v-divider class="my-3" />
<div class="address-card-actions">
<v-btn
class="address-action-button"
size="small"
variant="tonal"
prepend-icon="mdi-pencil"
:disabled="!hasEmail"
@click="handleCompose"
>
Send Email
</v-btn>
<v-btn
class="address-action-button"
size="small"
variant="text"
:disabled="!hasEmail"
@click="copy(recipientAddress, 'Email address')"
>
<v-icon>mdi-content-copy</v-icon>
<v-tooltip activator="parent" location="bottom">Copy Email</v-tooltip>
</v-btn>
<v-btn
class="address-action-button"
size="small"
variant="text"
:disabled="!formattedAddress"
@click="copy(formattedAddress, 'Address')"
>
<v-icon>mdi-card-account-details-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Copy Address</v-tooltip>
</v-btn>
<v-btn
v-if="recipientLabel"
class="address-action-button"
size="small"
variant="text"
@click="copy(recipientLabel, 'Name')"
>
<v-icon>mdi-account-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Copy Name</v-tooltip>
</v-btn>
</div>
</v-card>
</v-menu>
</template>
<style scoped lang="scss">
.address-activator {
display: inline-flex;
align-items: center;
max-width: 100%;
}
.address-card {
width: min(320px, calc(100vw - 32px));
padding: 16px;
}
.address-card-header {
display: flex;
align-items: center;
gap: 12px;
}
.address-card-meta {
min-width: 0;
}
.address-card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.address-card-actions :deep(.address-action-button) {
height: 36px;
min-height: 36px;
width: 36px;
min-width: 36px;
padding: 0;
}
.address-card-actions :deep(.address-action-button:first-child) {
width: auto;
min-width: 0;
padding-inline: 12px;
}
</style>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ComposerMode } from '@/types/composer'
interface Props { interface Props {
mode: 'new' | 'reply' | 'forward' mode: ComposerMode
saveStatus: string saveStatus: string
canSend: boolean canSend: boolean
sending: boolean sending: boolean
@@ -26,7 +28,7 @@ defineEmits<{
</v-btn> </v-btn>
<v-toolbar-title> <v-toolbar-title>
{{ mode === 'reply' ? 'Reply' : mode === 'forward' ? 'Forward' : 'New Message' }} {{ mode === ComposerMode.Reply ? 'Reply' : mode === ComposerMode.Forward ? 'Forward' : 'New Message' }}
</v-toolbar-title> </v-toolbar-title>
<v-spacer /> <v-spacer />

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { MessageObject } from '@MailManager/models/message' import { MessageObject } from '@MailManager/models/message'
import RecipientDetails from '@/components/common/RecipientDetails.vue'
interface Props { interface Props {
message: MessageObject message: MessageObject
@@ -7,6 +8,10 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const addressKey = (address: { address?: string | null; label?: string | null } | null | undefined, index: number): string => {
return `${address?.address || address?.label || 'address'}-${index}`
}
// Format date for display // Format date for display
const formatDate = (date: Date | string | null | undefined): string => { const formatDate = (date: Date | string | null | undefined): string => {
if (!date) return '' if (!date) return ''
@@ -46,10 +51,14 @@ const formatFileSize = (bytes: number | undefined): string => {
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="text-body-1 font-weight-medium"> <div class="text-body-1 font-weight-medium">
{{ message?.from?.label || message?.from?.address || 'Unknown Sender' }} <RecipientDetails :address="message?.from">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
</div> </div>
<div class="text-caption text-medium-emphasis"> <div class="text-caption text-medium-emphasis">
{{ formatDate(message?.date) }} {{ formatDate(message?.received || message?.sent) }}
</div> </div>
</div> </div>
</div> </div>
@@ -57,12 +66,26 @@ const formatFileSize = (bytes: number | undefined): string => {
<!-- Recipients --> <!-- Recipients -->
<div v-if="message?.to && message?.to.length > 0" class="text-body-2 mb-1"> <div v-if="message?.to && message?.to.length > 0" class="text-body-2 mb-1">
<span class="text-medium-emphasis">To:</span> <span class="text-medium-emphasis">To:</span>
{{ message?.to.map(t => t.label || t.address).join(', ') }} <template v-for="(recipient, index) in message.to" :key="addressKey(recipient, index)">
<RecipientDetails :address="recipient">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
<span v-if="index < message.to.length - 1">, </span>
</template>
</div> </div>
<div v-if="message?.cc && message?.cc.length > 0" class="text-body-2 mb-1"> <div v-if="message?.cc && message?.cc.length > 0" class="text-body-2 mb-1">
<span class="text-medium-emphasis">Cc:</span> <span class="text-medium-emphasis">Cc:</span>
{{ message?.cc.map(c => c.label || c.address).join(', ') }} <template v-for="(recipient, index) in message.cc" :key="addressKey(recipient, index)">
<RecipientDetails :address="recipient">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
<span v-if="index < message.cc.length - 1">, </span>
</template>
</div> </div>
<!-- Attachments --> <!-- Attachments -->
@@ -107,4 +130,16 @@ const formatFileSize = (bytes: number | undefined): string => {
white-space: nowrap; white-space: nowrap;
} }
} }
.contact-link {
display: inline-block;
border-radius: 4px;
padding: 1px 4px;
margin: -1px -4px;
transition: background-color 0.2s ease;
}
.contact-link:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
}
</style> </style>

View File

@@ -7,6 +7,7 @@ import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useMailStore } from '@/stores/mailStore' import { useMailStore } from '@/stores/mailStore'
import { useMailUiStore } from '@/stores/mailUiStore' import { useMailUiStore } from '@/stores/mailUiStore'
import type { CollectionObject, EntityObject } from '@MailManager/models' import type { CollectionObject, EntityObject } from '@MailManager/models'
import { ComposerMode } from '@/types/composer'
import MessageList from '@/components/MessageList.vue' import MessageList from '@/components/MessageList.vue'
import MessageReader from '@/components/MessageReader.vue' import MessageReader from '@/components/MessageReader.vue'
import MessageComposer from '@/components/MessageComposer.vue' import MessageComposer from '@/components/MessageComposer.vue'
@@ -43,16 +44,15 @@ const {
sidebarVisible, sidebarVisible,
settingsDialogVisible, settingsDialogVisible,
selectedFolder, selectedFolder,
composeMode, composerMode,
composeSource, composerSource,
composeVisible, composerVisible,
selectionList, selectionList,
selectionMode, selectionMode,
moveMessagesDialogVisible, moveMessagesDialogVisible,
moveMessagesDialogService, moveMessagesDialogService,
createFolderDialogVisible, createFolderDialogVisible,
createFolderDialogService, createFolderDialogService,
createFolderDialogParent,
createFolderDialogLoading, createFolderDialogLoading,
createFolderDialogError, createFolderDialogError,
renameFolderDialogVisible, renameFolderDialogVisible,
@@ -62,7 +62,6 @@ const {
renameFolderDialogError, renameFolderDialogError,
moveFolderDialogVisible, moveFolderDialogVisible,
moveFolderDialogService, moveFolderDialogService,
moveFolderDialogSource,
deleteFolderDialogVisible, deleteFolderDialogVisible,
deleteFolderDialogService, deleteFolderDialogService,
deleteFolderDialogFolder, deleteFolderDialogFolder,
@@ -149,13 +148,13 @@ const handleMessageOpen = (message: EntityObject) => {
} }
} }
const handleMessageComposeFresh = () => mailUiStore.openCompose() const handleMessageComposeFresh = () => mailUiStore.openComposer()
const handleMessageComposeReply = (message: EntityObject) => mailUiStore.openCompose(message, 'reply') const handleMessageComposeReply = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Reply)
const handleMessageComposeForward = (message: EntityObject) => mailUiStore.openCompose(message, 'forward') const handleMessageComposeForward = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Forward)
const handleMessageComposeClose = () => mailUiStore.closeCompose() const handleMessageComposeClose = () => mailUiStore.closeComposer()
const handleMessageFlag = (message: EntityObject, flag: string, value: boolean) => { const handleMessageFlag = (message: EntityObject, flag: string, value: boolean) => {
mailStore.flagMessages([message.identifier], { [flag]: value }) mailStore.flagMessages([message.identifier], { [flag]: value })
@@ -311,9 +310,9 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
<!-- Reader/Composer panel --> <!-- Reader/Composer panel -->
<div class="mail-reader-panel"> <div class="mail-reader-panel">
<MessageComposer <MessageComposer
v-if="composeVisible" v-if="composerVisible"
:mode="composeMode" :mode="composerMode"
:source="composeSource" :source="composerSource"
:folder="selectedFolder" :folder="selectedFolder"
@close="handleMessageComposeClose" @close="handleMessageComposeClose"
/> />

View File

@@ -3,9 +3,11 @@ import { defineStore } from 'pinia'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore' import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useMailStore } from '@/stores/mailStore' import { useMailStore } from '@/stores/mailStore'
import { useMailSettingsStore } from '@/stores/mailSettingsStore' import { useMailSettingsStore } from '@/stores/mailSettingsStore'
import { ComposerMode } from '@/types/composer'
import type { ServiceIdentifier, EntityIdentifier } from '@MailManager/types/common' import type { ServiceIdentifier, EntityIdentifier } from '@MailManager/types/common'
import { EntityObject, type ServiceObject } from '@MailManager/models' import { EntityObject, type ServiceObject } from '@MailManager/models'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
import type { MessageAddressInterface } from '@MailManager/types/message'
export const useMailUiStore = defineStore('mailUiStore', () => { export const useMailUiStore = defineStore('mailUiStore', () => {
const collectionsStore = useCollectionsStore() const collectionsStore = useCollectionsStore()
@@ -16,9 +18,9 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
const settingsDialogVisible = ref(false) const settingsDialogVisible = ref(false)
const selectedFolder = shallowRef<CollectionObject | null>(null) const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null) const selectedMessage = shallowRef<EntityObject | null>(null)
const composeMode = ref<'new' | 'reply' | 'forward'>('new') const composerMode = ref<ComposerMode>(ComposerMode.Fresh)
const composeSource = shallowRef<EntityObject | null>(null) const composerSource = shallowRef<EntityObject | MessageAddressInterface | null>(null)
const composeVisible = ref(false) const composerVisible = ref(false)
const selectionMode = ref(false) const selectionMode = ref(false)
const selectionList = ref<EntityIdentifier[]>([]) const selectionList = ref<EntityIdentifier[]>([])
const moveMessagesDialogVisible = ref(false) const moveMessagesDialogVisible = ref(false)
@@ -96,7 +98,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
() => mailStore.selectedMessage, () => mailStore.selectedMessage,
message => { message => {
if (message) { if (message) {
closeCompose() closeComposer()
} }
selectMessage(message) selectMessage(message)
@@ -149,7 +151,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
} }
async function selectFolder(folder: CollectionObject | null) { async function selectFolder(folder: CollectionObject | null) {
closeCompose() closeComposer()
messageSelectionModeDeactivate() messageSelectionModeDeactivate()
clearMessageReadTimer() clearMessageReadTimer()
selectedMessage.value = null selectedMessage.value = null
@@ -158,7 +160,6 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
} }
async function selectMessage(message: EntityObject | null) { async function selectMessage(message: EntityObject | null) {
closeCompose()
messageSelectionModeDeactivate() messageSelectionModeDeactivate()
createMessageReadTimer(message) createMessageReadTimer(message)
selectedMessage.value = message selectedMessage.value = message
@@ -450,17 +451,17 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
selectionList.value = Array.from(new Set(nextIds)) selectionList.value = Array.from(new Set(nextIds))
} }
function openCompose(source?: EntityObject, mode: 'reply' | 'forward' = 'reply') { function openComposer(source?: EntityObject | MessageAddressInterface, mode: ComposerMode = ComposerMode.Fresh) {
mailStore.selectMessage(null) mailStore.selectMessage(null)
composeSource.value = source ?? null composerSource.value = source ?? null
composeMode.value = mode composerMode.value = mode
composeVisible.value = true composerVisible.value = true
} }
function closeCompose() { function closeComposer() {
composeMode.value = 'new' composerMode.value = ComposerMode.Fresh
composeSource.value = null composerSource.value = null
composeVisible.value = false composerVisible.value = false
} }
function messageSelectionClear() { function messageSelectionClear() {
@@ -575,9 +576,9 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
settingsDialogVisible, settingsDialogVisible,
selectedFolder, selectedFolder,
selectedMessage, selectedMessage,
composeMode, composerMode,
composeSource, composerSource,
composeVisible, composerVisible,
selectionMode, selectionMode,
selectionList, selectionList,
moveMessagesDialogVisible, moveMessagesDialogVisible,
@@ -610,8 +611,8 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
settingsClose, settingsClose,
initialize, initialize,
selectFolder, selectFolder,
openCompose, openComposer,
closeCompose, closeComposer,
messageSelectionModeActivate, messageSelectionModeActivate,
messageSelectionModeDeactivate, messageSelectionModeDeactivate,
messageSelectionToggleOne, messageSelectionToggleOne,

5
src/types/composer.ts Normal file
View File

@@ -0,0 +1,5 @@
export enum ComposerMode {
Fresh,
Reply,
Forward,
}