fix: composition send responses and notifications

Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
2026-06-16 23:41:02 -04:00
parent 27cba6ecff
commit 0af38cf50c
8 changed files with 225 additions and 136 deletions
+25 -6
View File
@@ -10,6 +10,7 @@ use KTXF\Mail\Object\AddressInterface;
use KTXF\Mail\Object\MessagePart;
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
use KTXF\Mail\Submission\EntitySubmitResult;
use KTXF\Resource\Identifier\EntityIdentifierInterface;
use KTXF\Resource\Identifier\ResourceIdentifier;
use KTXF\Utile\UUID;
@@ -158,7 +159,7 @@ class CompositionManager {
if ($sender['address'] === '' || !filter_var($sender['address'], FILTER_VALIDATE_EMAIL)) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'invalid_sender',
'type' => 'composition_invalid_sender',
'message' => 'Invalid sender address',
];
return $result;
@@ -171,15 +172,19 @@ class CompositionManager {
if ($service === null || $service->getEnabled() === false) {
return [
'disposition' => 'error',
'errorCode' => 'service_not_found',
'errorMessage' => "Service not found for sender '$senderAddress' or service is disabled",
'error' => [
'type' => 'service_not_found',
'message' => "Service not found for sender '$senderAddress' or service is disabled",
],
];
}
if ($service instanceof ServiceEntitySubmitInterface === false) {
return [
'disposition' => 'error',
'errorCode' => 'service_not_supported',
'errorMessage' => "Service '{$service->identifier()}' does not support entity submission",
'error' => [
'type' => 'service_not_supported',
'message' => "Service '{$service->identifier()}' does not support entity submission",
],
];
}
@@ -197,7 +202,21 @@ class CompositionManager {
$sendResult = $service->entitySubmit($senderObject, $source, $properties);
return $sendResult->jsonSerialize();
if ($sendResult->disposition === EntitySubmitResult::DISPOSITION_ERROR) {
return [
'identifier' => $identifier,
'disposition' => 'error',
'error' => [
'type' => 'service_submission_error',
'message' => $sendResult->errorMessage ?? 'An unknown error occurred during submission',
],
];
}
return [
'identifier' => $identifier,
'disposition' => 'sent'
];
}
private function buildMessageProperties(
+3 -3
View File
@@ -3,7 +3,7 @@ import { computed, ref, watch } from 'vue'
import { useUser } from '@KTXC'
import type { EntityObject, MessageObject } from '@MailManager/models'
import { SecurityLevel } from '@/utile/emailSanitizer'
import { useMailStore } from '@/stores/mailStore'
import { useMailUiStore } from '@/stores/mailUiStore'
import ReaderEmpty from './reader/ReaderEmpty.vue'
import ReaderToolbar from './reader/ReaderToolbar.vue'
import ReaderHeader from './reader/ReaderHeader.vue'
@@ -26,7 +26,7 @@ const emit = defineEmits<{
// User settings
const { getSetting } = useUser()
const mailStore = useMailStore()
const mailUiStore = useMailUiStore()
// Per-message overrides
const allowImages = ref(false)
@@ -101,7 +101,7 @@ const handleDownload = async (partIndex?: number) => {
return
}
await mailStore.downloadMessage(props.entity, partIndex)
await mailUiStore.downloadMessage(props.entity, partIndex)
}
const handleFlag = () => {
+1 -3
View File
@@ -1,6 +1,5 @@
<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'
@@ -17,7 +16,6 @@ const emit = defineEmits<{
}>()
const mailUiStore = useMailUiStore()
const { showSnackbar } = useSnackbar()
const recipientLabel = computed(() => props.address?.label?.trim() || '')
const recipientAddress = computed(() => props.address?.address?.trim() || '')
@@ -32,7 +30,7 @@ const formattedAddress = computed(() => {
const hasEmail = computed(() => recipientAddress.value.length > 0)
const showCopyResult = (message: string, color: 'success' | 'error') => {
showSnackbar({ message, color })
mailUiStore.notify(message, color)
}
const copy = async (value: string, label: string) => {
+4 -4
View File
@@ -156,12 +156,12 @@ const handleMessageComposeForward = (message: EntityObject) => mailUiStore.openC
const handleMessageComposeClose = () => mailUiStore.closeComposer()
const handleMessageFlag = (message: EntityObject, flag: string, value: boolean) => {
mailStore.flagMessages([message.identifier], { [flag]: value })
const handleMessageFlag = async (message: EntityObject, flag: string, value: boolean) => {
await mailUiStore.flagMessage(message, flag, value)
}
const handleMessageDelete = (message: EntityObject) => {
mailStore.deleteMessages([message.identifier])
const handleMessageDelete = async (message: EntityObject) => {
await mailUiStore.deleteMessages([message.identifier])
}
const handleMessageMove = (message: EntityObject) => mailUiStore.openMoveMessagesDialog(message)
+11 -2
View File
@@ -14,11 +14,11 @@ 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 { ServiceIdentifier } from '@MailManager/services'
export const useMailCompositionStore = defineStore('mailCompositionStore', () => {
const servicesStore = useServicesStore()
const mailUiStore = useMailUiStore()
const activeDraft = ref<ComposerDraft | null>(null)
const saving = ref(false)
@@ -206,7 +206,7 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
}
try {
await compositionService.send({
const response = await compositionService.send({
identifier: activeDraft.value.identifier,
revision: activeDraft.value.revision,
sender: {
@@ -218,7 +218,16 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
message: activeDraft.value.message,
attachments: activeDraft.value.attachments,
})
if (response.disposition === 'error') {
console.error('[Mail][Composer] Failed to send message:', response.error)
mailUiStore.notify(`Failed to send message: ${response.error?.message || 'Unknown error'}`, 'error')
return
}
activeDraft.value = null
mailUiStore.notify('Message sent successfully', 'success')
mailUiStore.closeComposer()
} catch (error) {
console.error('[Mail][Composer] Failed to send draft:', error)
throw error
+8 -98
View File
@@ -4,7 +4,6 @@ import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailSync } from '@MailManager/composables/useMailSync'
import { useSnackbar } from '@KTXC'
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
import type { EntityTransmitRequest } from '@MailManager/types/entity'
import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
@@ -26,7 +25,6 @@ export const useMailStore = defineStore('mailStore', () => {
const servicesStore = useServicesStore()
const collectionsStore = useCollectionsStore()
const entitiesStore = useEntitiesStore()
const { showSnackbar } = useSnackbar()
// Background mail sync
const mailSyncController = useMailSync({
@@ -398,13 +396,10 @@ export const useMailStore = defineStore('mailStore', () => {
}
}
notify('Message sent', 'success')
resetComposerState()
return response
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to send message'
console.error('[Mail][Operations] Failed to send message:', error)
notify(messageText, 'error')
throw error
} finally {
composerSending.value = false
@@ -428,11 +423,6 @@ export const useMailStore = defineStore('mailStore', () => {
parentFolder?.identifier,
)
notify(
`Folder "${newFolder.properties.label || properties.label}" created`,
'success',
)
return newFolder
}
@@ -448,11 +438,6 @@ export const useMailStore = defineStore('mailStore', () => {
selectedFolder.value = updatedFolder
}
notify(
`Folder "${folder.properties.label || String(folder.identifier)}" renamed to "${updatedFolder.properties.label || properties.label}"`,
'success',
)
return updatedFolder
}
@@ -463,11 +448,6 @@ export const useMailStore = defineStore('mailStore', () => {
selectedFolder.value = movedFolder
}
notify(
`Folder "${source.properties.label || String(source.identifier)}" moved to "${target.properties.label || String(target.identifier)}"`,
'success',
)
return movedFolder
}
@@ -478,81 +458,38 @@ export const useMailStore = defineStore('mailStore', () => {
await selectFolder(null)
}
notify(
`Folder "${folder.properties.label || String(folder.identifier)}" deleted`,
'success',
)
return deletedFolder
}
async function deleteMessages(entityIdentifiers: EntityIdentifier[]) {
if (entityIdentifiers.length === 0) {
return
return null
}
loading.value = true
try {
const { successes, failures } = await entitiesStore.delete(entityIdentifiers)
if (failures.length === 0) {
notify(
successes.length === 1 ? 'Message deleted' : `${successes.length} messages deleted`,
'success',
)
}
if (failures.length > 0) {
notify(
successes.length === 0
? `Delete failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
: `Deleted ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`,
successes.length === 0 ? 'error' : 'warning',
)
}
return await entitiesStore.delete(entityIdentifiers)
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to delete messages'
console.error('[Mail][Operations] Failed to delete messages:', error)
notify(messageText, 'error')
throw error
} finally {
loading.value = false
}
}
async function flagMessages(entityIdentifiers: EntityIdentifier[], flags: Partial<MessageInterface['flags']>, options: { notify?: boolean } = {}) {
async function flagMessages(entityIdentifiers: EntityIdentifier[], flags: Partial<NonNullable<MessageInterface['flags']>>) {
if (entityIdentifiers.length === 0) {
return
return null
}
const shouldNotify = options.notify ?? true
try {
const patch = entitiesStore.fresh().properties
patch.flags = flags
const { successes, failures } = await entitiesStore.patch(patch, entityIdentifiers)
if (shouldNotify && successes.length > 0) {
notify(
successes.length === 1 ? 'Message updated' : `${successes.length} messages updated`,
'success',
)
}
if (shouldNotify && failures.length > 0) {
notify(
successes.length === 0
? `Update failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
: `Updated ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`,
successes.length === 0 ? 'error' : 'warning',
)
}
return await entitiesStore.patch(patch, entityIdentifiers)
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to update messages'
console.error('[Mail][Operations] Failed to update messages:', error)
notify(messageText, 'error')
throw error
}
}
@@ -592,7 +529,7 @@ export const useMailStore = defineStore('mailStore', () => {
)
if (movableIdentifiers.length === 0) {
return
return null
}
loading.value = true
@@ -600,29 +537,13 @@ export const useMailStore = defineStore('mailStore', () => {
try {
const { successes, failures } = await entitiesStore.move(target.identifier, movableIdentifiers)
if (failures.length === 0) {
notify(
successes.length === 1 ? 'Message moved' : `${successes.length} messages moved`,
'success',
)
}
if (failures.length > 0) {
notify(
successes.length === 0
? `Move failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
: `Moved ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`,
successes.length === 0 ? 'error' : 'warning',
)
}
// update source collections to reflect moved messages
sourceCollections.push(target.identifier)
await collectionsStore.fetch(sourceCollections)
return { successes, failures }
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to move messages'
console.error('[Mail][Operations] Failed to move messages:', error)
notify(messageText, 'error')
throw error
} finally {
loading.value = false
@@ -640,26 +561,16 @@ export const useMailStore = defineStore('mailStore', () => {
try {
await entitiesStore.download(target, part)
} catch (error) {
const messageText = error instanceof Error
? error.message
: index === undefined
? 'Failed to download message'
: 'Failed to download attachment'
console.error(
index === undefined
? '[Mail][Operations] Failed to download message:'
: '[Mail][Operations] Failed to download attachment:',
error,
)
notify(messageText, 'error')
throw error
}
}
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
showSnackbar({ message, color })
}
// ── Exports ───────────────────────────────────────────────────────────────
return {
@@ -698,7 +609,6 @@ export const useMailStore = defineStore('mailStore', () => {
downloadMessage,
moveFolder,
renameFolder,
notify,
isServiceFolderLoading,
hasServiceFoldersLoaded,
getServiceFolderError,
+163 -18
View File
@@ -7,12 +7,15 @@ import { ComposerMode } from '@/types/composer'
import type { ServiceIdentifier, EntityIdentifier } from '@MailManager/types/common'
import { EntityObject, type ServiceObject } from '@MailManager/models'
import type { CollectionObject } from '@MailManager/models/collection'
import type { MessageInterface } from '@MailManager/types/message'
import type { MessageAddressInterface } from '@MailManager/types/message'
import { useSnackbar } from '@KTXC'
export const useMailUiStore = defineStore('mailUiStore', () => {
const collectionsStore = useCollectionsStore()
const mailStore = useMailStore()
const mailSettingsStore = useMailSettingsStore()
const { showSnackbar } = useSnackbar()
const sidebarVisible = ref(true)
const settingsDialogVisible = ref(false)
@@ -112,6 +115,18 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
},
)
async function initialize() {
await mailStore.initialize()
if (!selectedFolder.value) {
const inbox = mailStore.findFoldersByRole('inbox')[0] ?? null
if (inbox) {
await selectFolder(inbox)
}
}
}
function sidebarToggle() {
sidebarVisible.value = !sidebarVisible.value
}
@@ -128,6 +143,52 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
settingsDialogVisible.value = false
}
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
showSnackbar({ message, color })
}
function notifyDeleteMessagesResult(successes: number, failures: number) {
if (failures === 0) {
notify(successes === 1 ? 'Message deleted' : `${successes} messages deleted`, 'success')
return
}
notify(
successes === 0
? `Delete failed for ${failures === 1 ? '1 message' : `${failures} messages`}`
: `Deleted ${successes} ${successes === 1 ? 'message' : 'messages'}. ${failures} failed.`,
successes === 0 ? 'error' : 'warning',
)
}
function notifyFlagMessagesResult(successes: number, failures: number) {
if (failures === 0) {
notify(successes === 1 ? 'Message updated' : `${successes} messages updated`, 'success')
return
}
notify(
successes === 0
? `Update failed for ${failures === 1 ? '1 message' : `${failures} messages`}`
: `Updated ${successes} ${successes === 1 ? 'message' : 'messages'}. ${failures} failed.`,
successes === 0 ? 'error' : 'warning',
)
}
function notifyMoveMessagesResult(successes: number, failures: number) {
if (failures === 0) {
notify(successes === 1 ? 'Message moved' : `${successes} messages moved`, 'success')
return
}
notify(
successes === 0
? `Move failed for ${failures === 1 ? '1 message' : `${failures} messages`}`
: `Moved ${successes} ${successes === 1 ? 'message' : 'messages'}. ${failures} failed.`,
successes === 0 ? 'error' : 'warning',
)
}
function _sameCollection(left: CollectionObject | null | undefined, right: CollectionObject | null | undefined): boolean {
if (!left || !right) {
return false
@@ -138,18 +199,6 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
String(left.identifier) === String(right.identifier)
}
async function initialize() {
await mailStore.initialize()
if (!selectedFolder.value) {
const inbox = mailStore.findFoldersByRole('inbox')[0] ?? null
if (inbox) {
await selectFolder(inbox)
}
}
}
async function selectFolder(folder: CollectionObject | null) {
closeComposer()
messageSelectionModeDeactivate()
@@ -200,7 +249,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
async function completeMessageRead(identifier: EntityIdentifier) {
try {
if (selectedMessage.value && selectedMessage.value.identifier === identifier && selectedMessage.value.properties.isRead === false) {
await mailStore.flagMessages([selectedMessage.value.identifier], { read: true }, { notify: false })
await flagMessages([selectedMessage.value.identifier], { read: true }, { notify: false })
}
} catch (error) {
console.error('[Mail][UI] Failed to auto-mark message as read:', error)
@@ -237,6 +286,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
try {
const folder = await mailStore.createFolder(service, label, createFolderDialogParent.value)
notify(`Folder "${folder.properties.label || label.trim()}" created`, 'success')
closeCreateFolderDialog()
return folder
} catch (error) {
@@ -281,6 +331,11 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
selectedFolder.value = updatedFolder
}
notify(
`Folder "${folder.properties.label || String(folder.identifier)}" renamed to "${updatedFolder.properties.label || label.trim()}"`,
'success',
)
closeRenameFolderDialog()
return updatedFolder
} catch (error) {
@@ -317,6 +372,11 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
selectedFolder.value = movedFolder
}
notify(
`Folder "${source.properties.label || String(source.identifier)}" moved to "${target.properties.label || String(target.identifier)}"`,
'success',
)
closeMoveFolderDialog()
return movedFolder
}
@@ -353,9 +413,10 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
if (_sameCollection(selectedFolder.value, folder)) {
selectFolder(null)
}
notify(`Folder "${folder.properties.label || String(folder.identifier)}" deleted`, 'success')
closeDeleteFolderDialog()
return deleted
} catch (error) {
@@ -556,21 +617,99 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
}
async function confirmMoveMessages(targetIdentifier: Parameters<typeof mailStore.moveMessages>[0]) {
await mailStore.moveMessages(targetIdentifier, moveMessagesDialogCandidates.value ?? [])
await moveMessages(targetIdentifier, moveMessagesDialogCandidates.value ?? [])
messageSelectionModeDeactivate()
closeMoveMessagesDialog()
}
async function deleteSelectedMessages() {
await mailStore.deleteMessages([...selectionList.value])
await deleteMessages([...selectionList.value])
messageSelectionModeDeactivate()
}
async function flagSelectedMessages(flag: string, value: boolean) {
await mailStore.flagMessages([...selectionList.value], { [flag]: value })
await flagMessages([...selectionList.value], { [flag]: value })
messageSelectionModeDeactivate()
}
async function deleteMessages(entityIdentifiers: EntityIdentifier[]) {
try {
const result = await mailStore.deleteMessages(entityIdentifiers)
if (!result) {
return null
}
notifyDeleteMessagesResult(result.successes.length, result.failures.length)
return result
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to delete messages'
notify(messageText, 'error')
throw error
}
}
async function flagMessages(
entityIdentifiers: EntityIdentifier[],
flags: Partial<NonNullable<MessageInterface['flags']>>,
options: { notify?: boolean } = {},
) {
try {
const result = await mailStore.flagMessages(entityIdentifiers, flags)
if (!result) {
return null
}
if (options.notify ?? true) {
notifyFlagMessagesResult(result.successes.length, result.failures.length)
}
return result
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to update messages'
if (options.notify ?? true) {
notify(messageText, 'error')
}
throw error
}
}
async function flagMessage(entity: EntityObject, flag: string, value: boolean) {
return await flagMessages([entity.identifier], { [flag]: value })
}
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
try {
const result = await mailStore.moveMessages(target, entityIdentifiers)
if (!result) {
return null
}
notifyMoveMessagesResult(result.successes.length, result.failures.length)
return result
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to move messages'
notify(messageText, 'error')
throw error
}
}
async function downloadMessage(entity: EntityObject, index?: number) {
try {
await mailStore.downloadMessage(entity, index)
} catch (error) {
const messageText = error instanceof Error
? error.message
: index === undefined
? 'Failed to download message'
: 'Failed to download attachment'
notify(messageText, 'error')
throw error
}
}
return {
sidebarVisible,
settingsDialogVisible,
@@ -605,14 +744,20 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
deleteFolderDialogFolder,
deleteFolderDialogLoading,
deleteFolderDialogError,
initialize,
sidebarToggle,
sidebarHide,
settingsOpen,
settingsClose,
initialize,
notify,
selectFolder,
openComposer,
closeComposer,
deleteMessages,
flagMessages,
flagMessage,
moveMessages,
downloadMessage,
messageSelectionModeActivate,
messageSelectionModeDeactivate,
messageSelectionToggleOne,
+10 -2
View File
@@ -63,7 +63,11 @@ export interface CompositionDiscardRequest {
export interface CompositionDiscardResponse {
identifier: string
disposition: boolean
disposition: 'discarded' | 'error'
error?: {
type: string
message: string
}
}
export interface CompositionSendRequest {
@@ -76,7 +80,11 @@ export interface CompositionSendRequest {
export interface CompositionSendResponse {
identifier: string
disposition: boolean
disposition: 'sent' | 'error'
error?: {
type: string
message: string
}
}
export interface CompositionAttachmentAddRequest {