10 Commits

Author SHA1 Message Date
Sebastian 5c9e13f87b refactor(module): register browser integration through context
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-31 22:55:11 -04:00
Sebastian 9508106d4f feat(preview): disable attachment previews when unavailable
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-31 22:55:11 -04:00
Sebastian da1e8e52b0 feat: use new variant type
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-29 19:13:11 -04:00
Sebastian 74d3654515 feat: implement attachment preview
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-29 16:21:18 -04:00
Sebastian 704e01fece feat: implement format toggle
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-28 22:29:22 -04:00
Sebastian 45cb0bd817 feat: wire mailbox search to server-side filtering
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-28 21:36:55 -04:00
Sebastian 33c44d62db feat(core): add CLI management for tenant system stores
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-28 21:23:50 -04:00
Sebastian a5eb104bd7 fix: reuse synced remote draft when sending a composition
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-25 22:39:33 -04:00
Sebastian 03862ed0cf fix: purge local composition after successful send
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-25 22:14:57 -04:00
Sebastian 0c7f103353 fix: increment composition revision on attachment add and remove
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-25 22:10:19 -04:00
18 changed files with 590 additions and 106 deletions
+14 -1
View File
@@ -440,7 +440,14 @@ class CompositionManager {
]; ];
} }
$source = null; // Reuse the synced remote draft (if any) so the provider can natively clean it up
$remoteEntity = $this->remoteEntityIdentifier($composed['remote']['entity'] ?? null);
$source = ($remoteEntity !== null
&& $remoteEntity->provider() === $service->provider()
&& (string)$remoteEntity->service() === (string)$service->identifier())
? $remoteEntity
: null;
$properties = $this->buildMessageProperties( $properties = $this->buildMessageProperties(
service: $service, service: $service,
sender: $senderObject, sender: $senderObject,
@@ -465,6 +472,8 @@ class CompositionManager {
]; ];
} }
$this->compositionStore->compositionDiscard($tenantId, $userId, $identifier);
return [ return [
'identifier' => $identifier, 'identifier' => $identifier,
'disposition' => 'sent' 'disposition' => 'sent'
@@ -582,6 +591,7 @@ class CompositionManager {
$deviceAttachmentsChanged = $uploads !== []; $deviceAttachmentsChanged = $uploads !== [];
$documentAttachmentsChanged = count($failed) < count($documents); $documentAttachmentsChanged = count($failed) < count($documents);
if ($deviceAttachmentsChanged || $documentAttachmentsChanged) { if ($deviceAttachmentsChanged || $documentAttachmentsChanged) {
$composed['revision'] = (int)($composed['revision'] ?? 0) + 1;
$composed = $this->markRemoteDirty($composed); $composed = $this->markRemoteDirty($composed);
$compositionChanged = true; $compositionChanged = true;
} }
@@ -616,6 +626,7 @@ class CompositionManager {
]; ];
} }
if ($compositionChanged) { if ($compositionChanged) {
$result['revision'] = (int)($composed['revision'] ?? 0);
$event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0)); $event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0));
$this->events->dispatch($event); $this->events->dispatch($event);
} }
@@ -774,8 +785,10 @@ class CompositionManager {
} }
unset($composed['attachments'][$identifier]); unset($composed['attachments'][$identifier]);
$composed['revision'] = (int)($composed['revision'] ?? 0) + 1;
$composed = $this->markRemoteDirty($composed); $composed = $this->markRemoteDirty($composed);
$result['disposition'] = 'removed'; $result['disposition'] = 'removed';
$result['revision'] = $composed['revision'];
return $composed; return $composed;
}; };
+4 -4
View File
@@ -80,8 +80,8 @@ final class CompositionController extends ControllerAbstract {
if (!isset($data['action']) || !is_string($data['action'])) { if (!isset($data['action']) || !is_string($data['action'])) {
throw new InvalidArgumentException('Invalid parameter: action must be a string'); throw new InvalidArgumentException('Invalid parameter: action must be a string');
} }
if (!in_array($data['action'], ['fresh', 'reply', 'forward'], true)) { if (!in_array($data['action'], ['fresh', 'reply', 'replyAll', 'forward'], true)) {
throw new InvalidArgumentException('Invalid parameter: action must be one of fresh, reply, forward'); throw new InvalidArgumentException('Invalid parameter: action must be one of fresh, reply, replyAll, forward');
} }
if (!isset($data['sender']) || !is_array($data['sender'])) { if (!isset($data['sender']) || !is_array($data['sender'])) {
throw new InvalidArgumentException('Invalid parameter: sender must be an array'); throw new InvalidArgumentException('Invalid parameter: sender must be an array');
@@ -89,8 +89,8 @@ final class CompositionController extends ControllerAbstract {
if (!isset($data['message']) || !is_array($data['message'])) { if (!isset($data['message']) || !is_array($data['message'])) {
throw new InvalidArgumentException('Invalid parameter: message must be an array'); throw new InvalidArgumentException('Invalid parameter: message must be an array');
} }
if (($data['action'] === 'reply' || $data['action'] === 'forward') && (!isset($data['source']) || !is_string($data['source']) || $data['source'] === '')) { if (($data['action'] === 'reply' || $data['action'] === 'replyAll' || $data['action'] === 'forward') && (!isset($data['source']) || !is_string($data['source']) || $data['source'] === '')) {
throw new InvalidArgumentException('Invalid parameter: source must be a non-empty string for reply or forward action'); throw new InvalidArgumentException('Invalid parameter: source must be a non-empty string for reply, replyAll, or forward action');
} }
return $this->compositionManager->stage($tenantId, $userId, $data['identifier'], $data['action'], $data['sender'], $data['message'], $data['source'] ?? null); return $this->compositionManager->stage($tenantId, $userId, $data['identifier'], $data['action'], $data['sender'], $data['message'], $data['source'] ?? null);
+7 -12
View File
@@ -4,13 +4,14 @@ namespace KTXM\Mail;
use KTXF\Event\DeliveryMode; use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistrarInterface; use KTXF\Event\EventListenerRegistrarInterface;
use KTXF\Module\ModuleBrowserInterface; use KTXF\Module\Configuration\BrowserModuleContextInterface;
use KTXF\Module\Configuration\ModuleContextInterface;
use KTXF\Module\ModuleInstanceAbstract; use KTXF\Module\ModuleInstanceAbstract;
/** /**
* Mail Module - Email Client * Mail Module - Email Client
*/ */
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface class Module extends ModuleInstanceAbstract
{ {
public function __construct( public function __construct(
@@ -79,16 +80,10 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
]; ];
} }
public function registerBI(): array public function configure(ModuleContextInterface $context): void
{ {
return [ if ($context instanceof BrowserModuleContextInterface) {
'handle' => $this->handle(), $context->registerModule($this, 'Mail', 'static/module.mjs');
'namespace' => 'Mail', }
'version' => $this->version(),
'label' => $this->label(),
'author' => $this->author(),
'description' => $this->description(),
'boot' => 'static/module.mjs',
];
} }
} }
+53
View File
@@ -12,6 +12,7 @@ import type { CollectionObject, MessageAddressObject } from '@MailManager/models
import { useMailOperationsStore } from '@/stores/mailOperationsStore' import { useMailOperationsStore } from '@/stores/mailOperationsStore'
import { useMailCompositionStore } from '@/stores/mailCompositionStore' import { useMailCompositionStore } from '@/stores/mailCompositionStore'
import { ComposerMode } from '@/types/composer' import { ComposerMode } from '@/types/composer'
import type { ComposerFormat } from '@/types/composer'
import { documentsSelectorComponent } from '@/services/documentsSelector' import { documentsSelectorComponent } from '@/services/documentsSelector'
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue' import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
import ComposerSender from '@/components/composer/ComposerSender.vue' import ComposerSender from '@/components/composer/ComposerSender.vue'
@@ -48,6 +49,8 @@ const {
// State // State
const showCc = ref(false) const showCc = ref(false)
const showBcc = ref(false) const showBcc = ref(false)
const format = ref<ComposerFormat>('rich')
const textEdited = ref(false)
const applyingDraftToEditor = ref(false) const applyingDraftToEditor = ref(false)
const showDocumentSelector = ref(false) const showDocumentSelector = ref(false)
const showCloseDialog = ref(false) const showCloseDialog = ref(false)
@@ -85,6 +88,14 @@ const subject = computed({
const attachments = computed(() => activeDraft.value?.attachments ?? {}) const attachments = computed(() => activeDraft.value?.attachments ?? {})
const text = computed({
get: () => activeDraft.value?.message.body.text ?? '',
set: value => {
textEdited.value = true
compositionStore.updateBody({ text: value, html: textToHtml(value) })
},
})
// Initialize Tiptap editor // Initialize Tiptap editor
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
@@ -118,6 +129,8 @@ watch(
compositionStore.openDraft(props.mode, props.source) compositionStore.openDraft(props.mode, props.source)
showCc.value = (activeDraft.value?.message.cc.length ?? 0) > 0 showCc.value = (activeDraft.value?.message.cc.length ?? 0) > 0
showBcc.value = (activeDraft.value?.message.bcc.length ?? 0) > 0 showBcc.value = (activeDraft.value?.message.bcc.length ?? 0) > 0
format.value = 'rich'
textEdited.value = false
applyingDraftToEditor.value = true applyingDraftToEditor.value = true
currentEditor.commands.setContent(activeDraft.value?.message.body.html || '') currentEditor.commands.setContent(activeDraft.value?.message.body.html || '')
@@ -249,6 +262,42 @@ const toggleLink = () => {
const isActive = (name: string, attrs?: any) => { const isActive = (name: string, attrs?: any) => {
return editor.value?.isActive(name, attrs) || false return editor.value?.isActive(name, attrs) || false
} }
const toggleFormat = () => {
if (format.value === 'rich') {
if (editor.value) {
compositionStore.updateBody({
html: activeDraft.value?.message.body.html ?? '',
text: editor.value.getText(),
})
}
textEdited.value = false
format.value = 'plain'
return
}
format.value = 'rich'
if (textEdited.value && editor.value) {
applyingDraftToEditor.value = true
editor.value.commands.setContent(activeDraft.value?.message.body.html || '')
applyingDraftToEditor.value = false
}
}
function textToHtml(text: string): string {
if (!text) {
return ''
}
return text
.split(/\r?\n/)
.map(line => `<p>${escapeHtml(line) || '<br>'}</p>`)
.join('')
}
function escapeHtml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
</script> </script>
<template> <template>
@@ -297,6 +346,8 @@ const isActive = (name: string, attrs?: any) => {
<ComposerEditor <ComposerEditor
:editor="editor" :editor="editor"
:format="format"
:text="text"
:is-bold-active="isActive('bold')" :is-bold-active="isActive('bold')"
:is-italic-active="isActive('italic')" :is-italic-active="isActive('italic')"
:is-underline-active="isActive('underline')" :is-underline-active="isActive('underline')"
@@ -309,6 +360,8 @@ const isActive = (name: string, attrs?: any) => {
@bullet-list="toggleBulletList" @bullet-list="toggleBulletList"
@ordered-list="toggleOrderedList" @ordered-list="toggleOrderedList"
@link="toggleLink" @link="toggleLink"
@toggle-format="toggleFormat"
@update:text="text = $event"
@attach-from-device="handleAttachFromDevice" @attach-from-device="handleAttachFromDevice"
@attach-from-documents="handleAttachFromDocuments" @attach-from-documents="handleAttachFromDocuments"
/> />
+4 -2
View File
@@ -16,12 +16,14 @@ interface Props {
selectionMode?: boolean selectionMode?: boolean
selectedCollection?: CollectionObject | null selectedCollection?: CollectionObject | null
loading?: boolean loading?: boolean
searchActive?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
loading: false, loading: false,
selectionList: () => [], selectionList: () => [],
selectionMode: false, selectionMode: false,
searchActive: false,
}) })
// Emits // Emits
@@ -372,10 +374,10 @@ onBeforeUnmount(() => {
<!-- Empty state --> <!-- Empty state -->
<div v-else-if="messages.length === 0" class="pa-8 text-center"> <div v-else-if="messages.length === 0" class="pa-8 text-center">
<v-icon size="64" color="grey-lighten-1">mdi-email-outline</v-icon> <v-icon size="64" color="grey-lighten-1">{{ searchActive ? 'mdi-email-search-outline' : 'mdi-email-outline' }}</v-icon>
<div class="text-h6 mt-4 text-medium-emphasis">No messages</div> <div class="text-h6 mt-4 text-medium-emphasis">No messages</div>
<div class="text-body-2 text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis">
This folder is empty {{ searchActive ? 'No messages match your search' : 'This folder is empty' }}
</div> </div>
</div> </div>
+12 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useUser } from '@KTXC' import { usePreviewStore, useUser } from '@KTXC'
import { useMailUiStore } from '@/stores/mailUiStore' import { useMailUiStore } from '@/stores/mailUiStore'
import type { EntityObject, MessageObject } from '@MailManager/models' import type { EntityObject, MessageObject } from '@MailManager/models'
import { SecurityLevel } from '@/utile/emailSanitizer' import { SecurityLevel } from '@/utile/emailSanitizer'
@@ -19,6 +19,7 @@ const props = defineProps<{
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
reply: [entity: EntityObject] reply: [entity: EntityObject]
replyAll: [entity: EntityObject]
forward: [entity: EntityObject] forward: [entity: EntityObject]
move: [entity: EntityObject] move: [entity: EntityObject]
delete: [entity: EntityObject] delete: [entity: EntityObject]
@@ -28,6 +29,7 @@ const emit = defineEmits<{
// User settings // User settings
const { getSetting } = useUser() const { getSetting } = useUser()
const previewStore = usePreviewStore()
const mailUiStore = useMailUiStore() const mailUiStore = useMailUiStore()
// Security overrides // Security overrides
@@ -64,6 +66,7 @@ const effectiveSecurityLevel = computed(() => {
// Preview state // Preview state
const previewDialog = computed(() => mediaViewerComponent('dialog')) const previewDialog = computed(() => mediaViewerComponent('dialog'))
const previewItems = computed<MediaViewerItem[]>(() => { const previewItems = computed<MediaViewerItem[]>(() => {
if (!previewStore.availability.storage) return []
const attachments = message.value?.attachments ?? [] const attachments = message.value?.attachments ?? []
const items: MediaViewerItem[] = [] const items: MediaViewerItem[] = []
attachments.forEach((attachment, index) => { attachments.forEach((attachment, index) => {
@@ -107,6 +110,12 @@ const handleReply = () => {
} }
} }
const handleReplyAll = () => {
if (props.entity) {
emit('replyAll', props.entity)
}
}
const handleForward = () => { const handleForward = () => {
if (props.entity) { if (props.entity) {
emit('forward', props.entity) emit('forward', props.entity)
@@ -152,7 +161,7 @@ const handlePreview = (index: number) => {
const handleViewerRetrieve = (item: MediaViewerItem): Promise<Blob> => { const handleViewerRetrieve = (item: MediaViewerItem): Promise<Blob> => {
if (!props.entity) return Promise.reject(new Error('No message selected')) if (!props.entity) return Promise.reject(new Error('No message selected'))
return mailUiStore.attachmentBlob(props.entity, item.meta.attachment) return mailUiStore.attachmentPreviewBlob(props.entity, item.meta.attachment, 'fullscreen')
} }
const handleViewerDownload = (item: MediaViewerItem) => { const handleViewerDownload = (item: MediaViewerItem) => {
@@ -176,6 +185,7 @@ const handleViewerDownload = (item: MediaViewerItem) => {
:security-level="effectiveSecurityLevel" :security-level="effectiveSecurityLevel"
:is-security-overridden="overrideSecurityLevel !== null" :is-security-overridden="overrideSecurityLevel !== null"
@reply="handleReply" @reply="handleReply"
@reply-all="handleReplyAll"
@forward="handleForward" @forward="handleForward"
@move="handleMove" @move="handleMove"
@delete="handleDelete" @delete="handleDelete"
+99 -58
View File
@@ -9,6 +9,14 @@ defineProps({
type: Object as PropType<Editor | null>, type: Object as PropType<Editor | null>,
default: null, default: null,
}, },
format: {
type: String as PropType<'rich' | 'plain'>,
required: true,
},
text: {
type: String,
default: '',
},
isBoldActive: { isBoldActive: {
type: Boolean, type: Boolean,
required: true, required: true,
@@ -42,6 +50,8 @@ const emit = defineEmits<{
bulletList: [] bulletList: []
orderedList: [] orderedList: []
link: [] link: []
toggleFormat: []
'update:text': [value: string]
attachFromDevice: [files: File[]] attachFromDevice: [files: File[]]
attachFromDocuments: [] attachFromDocuments: []
}>() }>()
@@ -66,68 +76,84 @@ function handleFileChange(event: Event) {
<template> <template>
<v-toolbar density="compact" elevation="0" class="editor-toolbar"> <v-toolbar density="compact" elevation="0" class="editor-toolbar">
<v-btn <template v-if="format === 'rich'">
icon <v-btn
size="small" icon
:class="{ 'v-btn--active': isBoldActive }" size="small"
@click="$emit('bold')" :class="{ 'v-btn--active': isBoldActive }"
> @click="$emit('bold')"
<v-icon>mdi-format-bold</v-icon> >
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip> <v-icon>mdi-format-bold</v-icon>
</v-btn> <v-tooltip activator="parent" location="bottom">Bold</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isItalicActive }"
@click="$emit('italic')"
>
<v-icon>mdi-format-italic</v-icon>
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isUnderlineActive }"
@click="$emit('underline')"
>
<v-icon>mdi-format-underline</v-icon>
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isBulletListActive }"
@click="$emit('bulletList')"
>
<v-icon>mdi-format-list-bulleted</v-icon>
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isOrderedListActive }"
@click="$emit('orderedList')"
>
<v-icon>mdi-format-list-numbered</v-icon>
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isLinkActive }"
@click="$emit('link')"
>
<v-icon>mdi-link</v-icon>
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
</template>
<v-btn <v-btn
icon icon
size="small" size="small"
:class="{ 'v-btn--active': isItalicActive }" :class="{ 'v-btn--active': format === 'plain' }"
@click="$emit('italic')" @click="$emit('toggleFormat')"
> >
<v-icon>mdi-format-italic</v-icon> <v-icon>{{ format === 'plain' ? 'mdi-format-text' : 'mdi-code-tags' }}</v-icon>
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip> <v-tooltip activator="parent" location="bottom">
</v-btn> {{ format === 'plain' ? 'Rich Text' : 'Plain Text' }}
</v-tooltip>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isUnderlineActive }"
@click="$emit('underline')"
>
<v-icon>mdi-format-underline</v-icon>
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isBulletListActive }"
@click="$emit('bulletList')"
>
<v-icon>mdi-format-list-bulleted</v-icon>
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isOrderedListActive }"
@click="$emit('orderedList')"
>
<v-icon>mdi-format-list-numbered</v-icon>
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isLinkActive }"
@click="$emit('link')"
>
<v-icon>mdi-link</v-icon>
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
</v-btn> </v-btn>
<v-spacer /> <v-spacer />
@@ -154,7 +180,17 @@ function handleFileChange(event: Event) {
<v-divider /> <v-divider />
<div class="editor-container"> <div class="editor-container">
<EditorContent :editor="editor ?? undefined" /> <EditorContent v-if="format === 'rich'" :editor="editor ?? undefined" />
<v-textarea
v-else
:model-value="text"
variant="plain"
auto-grow
hide-details
placeholder="Write your message..."
class="text-editor pa-4"
@update:model-value="$emit('update:text', $event)"
/>
</div> </div>
</template> </template>
@@ -179,6 +215,11 @@ function handleFileChange(event: Event) {
color: rgb(var(--v-theme-primary)); color: rgb(var(--v-theme-primary));
} }
.text-editor :deep(textarea) {
min-height: 300px;
font-family: inherit;
}
:deep(.tiptap-editor) { :deep(.tiptap-editor) {
outline: none; outline: none;
min-height: 300px; min-height: 300px;
+1 -1
View File
@@ -30,7 +30,7 @@ defineEmits<{
</v-btn> </v-btn>
<v-toolbar-title> <v-toolbar-title>
{{ mode === ComposerMode.Reply ? 'Reply' : mode === ComposerMode.Forward ? 'Forward' : 'New Message' }} {{ mode === ComposerMode.Reply ? 'Reply' : mode === ComposerMode.ReplyAll ? 'Reply All' : mode === ComposerMode.Forward ? 'Forward' : 'New Message' }}
</v-toolbar-title> </v-toolbar-title>
<v-spacer /> <v-spacer />
+6 -3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { usePreviewStore } from '@KTXC'
import RecipientDetails from '@/components/common/RecipientDetails.vue' import RecipientDetails from '@/components/common/RecipientDetails.vue'
import { formatFileSize } from '@/utile/format' import { formatFileSize } from '@/utile/format'
import { mediaViewerCanDisplay, mediaViewerComponent } from '@/services/mediaViewer' import { mediaViewerCanDisplay, mediaViewerComponent } from '@/services/mediaViewer'
@@ -27,7 +28,9 @@ const emit = defineEmits<{
}>() }>()
const mailUiStore = useMailUiStore() const mailUiStore = useMailUiStore()
const previewPopover = computed(() => mediaViewerComponent('popover')) const previewStore = usePreviewStore()
const previewsAvailable = computed(() => previewStore.availability.storage)
const previewPopover = computed(() => previewsAvailable.value ? mediaViewerComponent('popover') : null)
const message = computed(() => { const message = computed(() => {
return props.entity?.properties ?? null return props.entity?.properties ?? null
@@ -48,7 +51,7 @@ const attachmentItems = computed<AttachmentItem[]>(() =>
) )
const isPreviewable = (attachment: MessagePartInterface): boolean => { const isPreviewable = (attachment: MessagePartInterface): boolean => {
return mediaViewerCanDisplay(attachment.type) return previewsAvailable.value && mediaViewerCanDisplay(attachment.type)
} }
const handleAttachmentClick = (attachment: MessagePartInterface, index: number): void => { const handleAttachmentClick = (attachment: MessagePartInterface, index: number): void => {
@@ -61,7 +64,7 @@ const handleAttachmentClick = (attachment: MessagePartInterface, index: number):
const handleViewerRetrieve = (item: AttachmentItem): Promise<Blob> => { const handleViewerRetrieve = (item: AttachmentItem): Promise<Blob> => {
if (!props.entity) return Promise.reject(new Error('No message selected')) if (!props.entity) return Promise.reject(new Error('No message selected'))
return mailUiStore.attachmentBlob(props.entity, item.meta.attachment) return mailUiStore.attachmentPreviewBlob(props.entity, item.meta.attachment, 'inline')
} }
// Format date for display // Format date for display
+2 -1
View File
@@ -13,6 +13,7 @@ const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
reply: [] reply: []
replyAll: []
forward: [] forward: []
move: [] move: []
delete: [] delete: []
@@ -47,7 +48,7 @@ const currentSecurityLevel = computed(() => {
<v-btn <v-btn
icon="mdi-reply-all" icon="mdi-reply-all"
variant="text" variant="text"
@click="emit('reply')" @click="emit('replyAll')"
> >
<v-icon>mdi-reply-all</v-icon> <v-icon>mdi-reply-all</v-icon>
<v-tooltip activator="parent" location="bottom">Reply All</v-tooltip> <v-tooltip activator="parent" location="bottom">Reply All</v-tooltip>
+16 -11
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, unref } from 'vue' import { computed, onMounted, ref, unref, watch } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useModuleStore } from '@KTXC' import { useModuleStore } from '@KTXC'
@@ -41,6 +41,8 @@ const {
loading, loading,
selectedMessage, selectedMessage,
currentMessages, currentMessages,
searchResults,
searching,
} = storeToRefs(mailOperationsStore) } = storeToRefs(mailOperationsStore)
const { const {
@@ -74,21 +76,19 @@ const {
// Complex store/composable objects accessed directly (not simple refs) // Complex store/composable objects accessed directly (not simple refs)
const { mailSync, entitiesStore } = mailOperationsStore const { mailSync, entitiesStore } = mailOperationsStore
// Message search (filters the current message list by subject/sender) // Message search — debounced server-side search of the selected folder
const searchQuery = ref('') const searchQuery = ref('')
const filteredMessages = computed(() => { watch(searchQuery, query => mailOperationsStore.searchMessages(query))
const query = (searchQuery.value ?? '').trim().toLowerCase()
if (!query) return currentMessages.value
return currentMessages.value.filter((message: EntityObject) => { // Reset the search box when the folder changes so it doesn't show a stale
const subject = (message.properties.subject || '').toLowerCase() // query for a folder whose results are no longer being filtered.
const fromLabel = (message.properties.from?.label || '').toLowerCase() watch(selectedFolder, () => {
const fromAddress = (message.properties.from?.address || '').toLowerCase() searchQuery.value = ''
return subject.includes(query) || fromLabel.includes(query) || fromAddress.includes(query)
})
}) })
const filteredMessages = computed(() => searchResults.value ?? currentMessages.value)
const lastSyncLabel = computed(() => { const lastSyncLabel = computed(() => {
const lastSync = unref(unref(mailSync.lastSync)) const lastSync = unref(unref(mailSync.lastSync))
@@ -167,6 +167,8 @@ const handleMessageComposeFresh = () => mailUiStore.openComposer()
const handleMessageComposeReply = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Reply) const handleMessageComposeReply = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Reply)
const handleMessageComposeReplyAll = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.ReplyAll)
const handleMessageComposeForward = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Forward) const handleMessageComposeForward = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Forward)
const handleMessageComposeClose = () => mailUiStore.closeComposer() const handleMessageComposeClose = () => mailUiStore.closeComposer()
@@ -250,6 +252,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
variant="outlined" variant="outlined"
placeholder="Search messages..." placeholder="Search messages..."
prepend-inner-icon="mdi-magnify" prepend-inner-icon="mdi-magnify"
:loading="searching"
hide-details hide-details
single-line single-line
clearable clearable
@@ -308,6 +311,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
:selection-list="selectionList" :selection-list="selectionList"
:selection-mode="selectionMode" :selection-mode="selectionMode"
:loading="loading" :loading="loading"
:search-active="searchResults !== null"
@open="handleMessageOpen" @open="handleMessageOpen"
@reply="handleMessageComposeReply" @reply="handleMessageComposeReply"
@forward="handleMessageComposeForward" @forward="handleMessageComposeForward"
@@ -339,6 +343,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
:entity="selectedMessage" :entity="selectedMessage"
@compose="handleMessageComposeFresh" @compose="handleMessageComposeFresh"
@reply="handleMessageComposeReply" @reply="handleMessageComposeReply"
@reply-all="handleMessageComposeReplyAll"
@forward="handleMessageComposeForward" @forward="handleMessageComposeForward"
@move="handleMessageMove" @move="handleMessageMove"
@delete="handleMessageDelete" @delete="handleMessageDelete"
+37 -9
View File
@@ -118,7 +118,8 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
return return
} }
activeDraft.value = buildDraft(mode, sender, source) const ownAddresses = senderIdentities.value.map(identity => identity.address)
activeDraft.value = buildDraft(mode, sender, source, ownAddresses)
activeDraft.value.action = mode activeDraft.value.action = mode
stageDraft() stageDraft()
@@ -380,6 +381,10 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
source: attachment.source, source: attachment.source,
} }
}) })
if (response.revision !== undefined) {
activeDraft.value.revision = response.revision
}
} }
async function addAttachmentsFromDocuments(items: { identifier: string; name: string; mimeType?: string; size?: number }[]) { async function addAttachmentsFromDocuments(items: { identifier: string; name: string; mimeType?: string; size?: number }[]) {
@@ -432,6 +437,10 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
} }
}) })
if (response.revision !== undefined) {
activeDraft.value.revision = response.revision
}
if (response.failed?.length) { if (response.failed?.length) {
mailUiStore.notify( mailUiStore.notify(
response.failed.length === 1 response.failed.length === 1
@@ -462,8 +471,11 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
if (response.disposition === 'error') { if (response.disposition === 'error') {
console.error('[Mail][Composer] Failed to remove attachment:', response.error) console.error('[Mail][Composer] Failed to remove attachment:', response.error)
} }
delete activeDraft.value.attachments[attachment.identifier] delete activeDraft.value.attachments[attachment.identifier]
if (response.revision !== undefined) {
activeDraft.value.revision = response.revision
}
} }
function queueSave(immediate: boolean) { function queueSave(immediate: boolean) {
@@ -547,9 +559,9 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
} }
}) })
function buildDraft(mode: ComposerMode, sender: ComposerSenderIdentity, source?: EntityObject | MessageAddressObject | null): ComposerDraft { function buildDraft(mode: ComposerMode, sender: ComposerSenderIdentity, source: EntityObject | MessageAddressObject | null | undefined, ownAddresses: string[]): ComposerDraft {
const composition = createIdentifier() const composition = createIdentifier()
const freshMessage = buildMessage(mode, source) const freshMessage = buildMessage(mode, source, ownAddresses)
const freshAttachments = buildAttachments(composition, mode, source) const freshAttachments = buildAttachments(composition, mode, source)
const sourceIdentifier = source instanceof EntityObject ? source.identifier : null const sourceIdentifier = source instanceof EntityObject ? source.identifier : null
return { return {
@@ -565,7 +577,7 @@ function buildDraft(mode: ComposerMode, sender: ComposerSenderIdentity, source?:
} }
} }
function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressObject | null | undefined): ComposerDraftMessage { function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressObject | null | undefined, ownAddresses: string[] = []): ComposerDraftMessage {
if (!source) { if (!source) {
return emptyMessage() return emptyMessage()
@@ -592,12 +604,28 @@ function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressO
const sentAt = sourceMessage.sent || source.created || '' const sentAt = sourceMessage.sent || source.created || ''
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 || mode === ComposerMode.ReplyAll) {
const replyToAddr = sourceMessage.replyTo?.[0] ?? sourceMessage.from ?? null const replyToAddr = sourceMessage.replyTo?.[0] ?? sourceMessage.from ?? null
const replyToAddress = replyToAddr?.address || '' const replyToAddress = replyToAddr?.address || ''
const to: MessageAddressInterface[] = replyToAddress ? [{ address: replyToAddress, label: replyToAddr?.label }] : []
let cc: MessageAddressInterface[] = []
if (mode === ComposerMode.ReplyAll) {
const excluded = new Set([replyToAddress.toLowerCase(), ...ownAddresses.map(addr => addr.toLowerCase())])
const seen = new Set<string>()
cc = [...(sourceMessage.to || []), ...(sourceMessage.cc || [])].filter(recipient => {
const address = recipient.address?.toLowerCase() || ''
if (!address || excluded.has(address) || seen.has(address)) {
return false
}
seen.add(address)
return true
})
}
return { return {
to: replyToAddress ? [{ address: replyToAddress, label: replyToAddr?.label }] : [], to,
cc: [], cc,
bcc: [], bcc: [],
subject: /^Re:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`, subject: /^Re:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`,
body: { body: {
@@ -620,7 +648,7 @@ function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressO
} }
function buildAttachments(composition: string, mode: ComposerMode, source?: EntityObject | MessageAddressObject | null): Record<string, ComposerDraftAttachment> { function buildAttachments(composition: string, mode: ComposerMode, source?: EntityObject | MessageAddressObject | null): Record<string, ComposerDraftAttachment> {
if (mode === ComposerMode.Fresh || mode === ComposerMode.Reply || !(source instanceof EntityObject)) { if (mode === ComposerMode.Fresh || mode === ComposerMode.Reply || mode === ComposerMode.ReplyAll || !(source instanceof EntityObject)) {
return {} return {}
} }
+89 -1
View File
@@ -4,7 +4,8 @@ import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useEntitiesStore } from '@MailManager/stores/entitiesStore' import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
import { useServicesStore } from '@MailManager/stores/servicesStore' import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailSync } from '@MailManager/composables/useMailSync' import { useMailSync } from '@MailManager/composables/useMailSync'
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common' import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier, ListFilter } from '@MailManager/types/common'
import { ListFilterComparisonOperator, ListFilterConjunctionOperator } from '@MailManager/types/common'
import type { EntityTransmitRequest, EntityBlobSelector } from '@MailManager/types/entity' import type { EntityTransmitRequest, EntityBlobSelector } from '@MailManager/types/entity'
import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message' import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models' import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
@@ -50,6 +51,8 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
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 searchResults = shallowRef<EntityObject[] | null>(null)
const searching = ref(false)
const composerSaving = ref(false) const composerSaving = ref(false)
const composerSending = ref(false) const composerSending = ref(false)
const composerLastSaved = ref<Date | null>(null) const composerLastSaved = ref<Date | null>(null)
@@ -293,6 +296,7 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
} }
async function selectFolder(folder: CollectionObject | null) { async function selectFolder(folder: CollectionObject | null) {
clearSearch()
selectedFolder.value = folder selectedFolder.value = folder
selectedMessage.value = null selectedMessage.value = null
@@ -307,6 +311,67 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
_updateSyncSources() _updateSyncSources()
} }
const SEARCH_DEBOUNCE_MS = 300
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
// Monotonic token so out-of-order/stale search responses are discarded.
let searchToken = 0
/**
* Debounced server-side search of the selected folder. Clearing the query
* (or the folder) reverts the message list back to `currentMessages`.
*/
function searchMessages(query: string) {
const trimmed = (query ?? '').trim()
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
if (!trimmed || !selectedFolder.value) {
searchToken++
searchResults.value = null
searching.value = false
return
}
const folder = selectedFolder.value
// 'from'/'to'/'subject'/'body' are the attributes both IMAP and JMAP
// backends whitelist for entity search (see their entityListFilter
// capability); OR them together for a single-box search.
const filter: ListFilter = {
from: [trimmed, ListFilterComparisonOperator.LIKE],
to: [trimmed, ListFilterComparisonOperator.LIKE, ListFilterConjunctionOperator.OR],
subject: [trimmed, ListFilterComparisonOperator.LIKE, ListFilterConjunctionOperator.OR],
body: [trimmed, ListFilterComparisonOperator.LIKE, ListFilterConjunctionOperator.OR],
}
searching.value = true
const token = ++searchToken
searchDebounceTimer = setTimeout(async () => {
try {
const results = await entitiesStore.list([folder.identifier], filter)
if (token !== searchToken) return
searchResults.value = Object.values(results)
} catch (error) {
if (token === searchToken) searchResults.value = []
console.error('[Mail][Operations] Failed to search messages:', error)
} finally {
if (token === searchToken) searching.value = false
}
}, SEARCH_DEBOUNCE_MS)
}
function clearSearch() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
searchToken++
searchResults.value = null
searching.value = false
}
function selectMessage(entity: EntityObject | null) { function selectMessage(entity: EntityObject | null) {
selectedMessage.value = entity selectedMessage.value = entity
} }
@@ -575,6 +640,24 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
return blob return blob
} }
async function attachmentPreviewBlob(
entity: EntityObject,
part: MessagePartInterface,
variant: 'inline' | 'fullscreen' = 'inline',
): Promise<Blob> {
return entitiesStore.preview({
target: entity.identifier,
part: {
blobId: part.blobId ?? undefined,
partId: part.partId ?? undefined,
cid: part.cid ?? undefined,
type: part.type || 'application/octet-stream',
size: part.size ?? null,
},
variant,
})
}
// ── Exports ─────────────────────────────────────────────────────────────── // ── Exports ───────────────────────────────────────────────────────────────
return { return {
@@ -587,6 +670,8 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
// State // State
loading, loading,
selectedMessage, selectedMessage,
searchResults,
searching,
composerSaving, composerSaving,
composerSending, composerSending,
composerLastSaved, composerLastSaved,
@@ -602,6 +687,8 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
retrieveService, retrieveService,
selectFolder, selectFolder,
selectMessage, selectMessage,
searchMessages,
clearSearch,
createFolder, createFolder,
saveComposerDraft, saveComposerDraft,
sendComposerMessage, sendComposerMessage,
@@ -612,6 +699,7 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
moveMessages, moveMessages,
downloadMessage, downloadMessage,
attachmentBlob, attachmentBlob,
attachmentPreviewBlob,
moveFolder, moveFolder,
renameFolder, renameFolder,
isServiceFolderLoading, isServiceFolderLoading,
+10 -1
View File
@@ -714,6 +714,14 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
return mailOperationsStore.attachmentBlob(entity, part) return mailOperationsStore.attachmentBlob(entity, part)
} }
function attachmentPreviewBlob(
entity: EntityObject,
part: Parameters<typeof mailOperationsStore.attachmentPreviewBlob>[1],
variant: 'inline' | 'fullscreen' = 'inline',
) {
return mailOperationsStore.attachmentPreviewBlob(entity, part, variant)
}
return { return {
sidebarVisible, sidebarVisible,
settingsDialogVisible, settingsDialogVisible,
@@ -763,6 +771,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
moveMessages, moveMessages,
downloadMessage, downloadMessage,
attachmentBlob, attachmentBlob,
attachmentPreviewBlob,
messageSelectionModeActivate, messageSelectionModeActivate,
messageSelectionModeDeactivate, messageSelectionModeDeactivate,
messageSelectionToggleOne, messageSelectionToggleOne,
@@ -788,4 +797,4 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
confirmDeleteFolder, confirmDeleteFolder,
flagSelectedMessages, flagSelectedMessages,
} }
}) })
+4
View File
@@ -5,9 +5,13 @@ import type { MessageAddressInterface } from "@MailManager/types/message"
export enum ComposerMode { export enum ComposerMode {
Fresh = 'fresh', Fresh = 'fresh',
Reply = 'reply', Reply = 'reply',
ReplyAll = 'replyAll',
Forward = 'forward', Forward = 'forward',
} }
/** Body editing format: rich (Tiptap/HTML) or plain text. */
export type ComposerFormat = 'rich' | 'plain'
export interface ComposerDraftAttachment { export interface ComposerDraftAttachment {
identifier: string identifier: string
composition: string composition: string
+2
View File
@@ -111,6 +111,7 @@ export interface CompositionAttachmentAddResponse {
message: string message: string
} }
composition: string composition: string
revision?: number
attachments: Record<string, CompositionAttachmentInterface> attachments: Record<string, CompositionAttachmentInterface>
failed?: { failed?: {
identifier: string identifier: string
@@ -131,4 +132,5 @@ export interface CompositionAttachmentRemoveResponse {
} }
composition: string composition: string
identifier: string identifier: string
revision?: number
} }
+87
View File
@@ -187,6 +187,7 @@ final class CompositionManagerTest extends TestCase {
$this->assertSame('added', $result['disposition']); $this->assertSame('added', $result['disposition']);
$this->assertCount(1, $result['attachments']); $this->assertCount(1, $result['attachments']);
$this->assertSame([], $result['failed']); $this->assertSame([], $result['failed']);
$this->assertSame(1, $result['revision']);
$attachment = $result['attachments']['attach-1']; $attachment = $result['attachments']['attach-1'];
$this->assertSame('documents', $attachment['origin']); $this->assertSame('documents', $attachment['origin']);
@@ -196,11 +197,58 @@ final class CompositionManagerTest extends TestCase {
$this->assertSame(strlen('hello world'), $attachment['size']); $this->assertSame(strlen('hello world'), $attachment['size']);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId); $snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
$this->assertSame(1, $snapshot['revision']);
$this->assertSame('dirty', $snapshot['remote']['status']); $this->assertSame('dirty', $snapshot['remote']['status']);
$this->assertNull($snapshot['remote']['entity']); $this->assertNull($snapshot['remote']['entity']);
$this->assertNull($snapshot['remote']['error']); $this->assertNull($snapshot['remote']['error']);
} }
public function testAttachmentAddIncrementsRevisionOnSubsequentAdd(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$documentsManager->method('entityFetchBulk')->willReturn([
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
$this->makeEntity('default:personal:root:doc2', 'world.txt', 'text/plain'),
]);
$documentsManager->method('entityReadStream')->willReturnCallback(
fn(): mixed => $this->makeStream('hello world'),
);
$manager = $this->makeManager($documentsManager);
$manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-1',
'origin' => 'documents',
'source' => 'default:personal:root:doc1',
]);
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-2',
'origin' => 'documents',
'source' => 'default:personal:root:doc2',
]);
$this->assertSame(2, $result['revision']);
}
public function testAttachmentAddDoesNotChangeRevisionWhenNothingIsAdded(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
// documents_manager unavailable means the sourced attachment fails
// entirely, so no attachment is actually added to the composition.
$manager = $this->makeManager($documentsManager, documentsModuleEnabled: false);
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-1',
'origin' => 'documents',
'source' => 'default:personal:root:doc1',
]);
$this->assertSame('error', $result['disposition']);
$this->assertArrayNotHasKey('revision', $result);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
$this->assertArrayNotHasKey('revision', $snapshot);
}
public function testAttachmentAddFromDocumentsPartialFailure(): void { public function testAttachmentAddFromDocumentsPartialFailure(): void {
$compositionId = $this->stageEmptyComposition(); $compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
@@ -285,6 +333,45 @@ final class CompositionManagerTest extends TestCase {
$this->assertStringContainsString('not installed or enabled', $result['failed'][0]['message']); $this->assertStringContainsString('not installed or enabled', $result['failed'][0]['message']);
} }
public function testAttachmentRemoveIncrementsRevision(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$documentsManager->method('entityFetchBulk')->willReturn([
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
]);
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
$manager = $this->makeManager($documentsManager);
$added = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-1',
'origin' => 'documents',
'source' => 'default:personal:root:doc1',
]);
$this->assertSame(1, $added['revision']);
$result = $manager->attachmentRemove(self::TENANT_ID, self::USER_ID, $compositionId, 'attach-1');
$this->assertSame('removed', $result['disposition']);
$this->assertSame(2, $result['revision']);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
$this->assertSame(2, $snapshot['revision']);
$this->assertArrayNotHasKey('attach-1', $snapshot['attachments']);
$this->assertSame('dirty', $snapshot['remote']['status']);
}
public function testAttachmentRemoveNotFoundDoesNotChangeRevision(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$manager = $this->makeManager($documentsManager);
$result = $manager->attachmentRemove(self::TENANT_ID, self::USER_ID, $compositionId, 'missing-attachment');
$this->assertSame('error', $result['disposition']);
$this->assertSame('attachment_not_found', $result['error']['type']);
$this->assertArrayNotHasKey('revision', $result);
}
private function makeManager( private function makeManager(
Stub $documentsManager, Stub $documentsManager,
bool $documentsModuleEnabled = true, bool $documentsModuleEnabled = true,
@@ -12,6 +12,8 @@ use KTXF\Mail\Entity\EntityMutableInterface;
use KTXF\Mail\Object\MessagePropertiesMutableInterface; use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Mail\Service\ServiceBaseInterface; use KTXF\Mail\Service\ServiceBaseInterface;
use KTXF\Mail\Service\ServiceEntityMutableInterface; use KTXF\Mail\Service\ServiceEntityMutableInterface;
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
use KTXF\Mail\Submission\EntitySubmitResult;
use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Identifier\CollectionIdentifier; use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier; use KTXF\Resource\Identifier\EntityIdentifier;
@@ -257,6 +259,133 @@ final class CompositionSynchronizationTest extends TestCase {
)); ));
} }
public function testSendPurgesLocalCompositionOnSuccess(): void {
$this->stageComposition();
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->submitService($properties);
$service->method('entitySubmit')->willReturn(new EntitySubmitResult(EntitySubmitResult::DISPOSITION_SENT));
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$result = $this->compositionManager($mailManager)->send(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
['address' => 'sender@example.com'],
['subject' => 'Sent draft'],
[],
);
$this->assertSame('sent', $result['disposition']);
$this->assertNull($this->compositionStore->compositionFetch(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
));
}
public function testSendReusesSyncedRemoteDraftAsSubmissionSource(): void {
$this->stageComposition('provider:service:drafts:remote-1');
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->createMockForIntersectionOfInterfaces([
ServiceBaseInterface::class,
ServiceEntitySubmitInterface::class,
]);
$entity = $this->createStub(EntityMutableInterface::class);
$entity->method('getProperties')->willReturn($properties);
$service->method('provider')->willReturn('provider');
$service->method('identifier')->willReturn('service');
$service->method('getEnabled')->willReturn(true);
$service->method('entityFresh')->willReturn($entity);
$service->expects($this->once())
->method('entitySubmit')
->with(
$this->anything(),
$this->callback(static fn(?EntityIdentifier $source): bool => $source !== null
&& (string)$source === 'provider:service:drafts:remote-1'),
$properties,
)
->willReturn(new EntitySubmitResult(EntitySubmitResult::DISPOSITION_SENT));
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$result = $this->compositionManager($mailManager)->send(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
['address' => 'sender@example.com'],
['subject' => 'Sent draft'],
[],
);
$this->assertSame('sent', $result['disposition']);
}
public function testSendIgnoresRemoteDraftBelongingToAnotherService(): void {
$this->stageComposition('provider:other-service:drafts:remote-1');
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->createMockForIntersectionOfInterfaces([
ServiceBaseInterface::class,
ServiceEntitySubmitInterface::class,
]);
$entity = $this->createStub(EntityMutableInterface::class);
$entity->method('getProperties')->willReturn($properties);
$service->method('provider')->willReturn('provider');
$service->method('identifier')->willReturn('service');
$service->method('getEnabled')->willReturn(true);
$service->method('entityFresh')->willReturn($entity);
$service->expects($this->once())
->method('entitySubmit')
->with($this->anything(), $this->isNull(), $properties)
->willReturn(new EntitySubmitResult(EntitySubmitResult::DISPOSITION_SENT));
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$result = $this->compositionManager($mailManager)->send(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
['address' => 'sender@example.com'],
['subject' => 'Sent draft'],
[],
);
$this->assertSame('sent', $result['disposition']);
}
public function testSendFailureKeepsLocalComposition(): void {
$this->stageComposition();
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->submitService($properties);
$service->method('entitySubmit')->willReturn(new EntitySubmitResult(
EntitySubmitResult::DISPOSITION_ERROR,
errorMessage: 'Transport unavailable',
));
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$result = $this->compositionManager($mailManager)->send(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
['address' => 'sender@example.com'],
['subject' => 'Keep this draft'],
[],
);
$this->assertSame('error', $result['disposition']);
$this->assertSame('Transport unavailable', $result['error']['message']);
$this->assertNotNull($this->compositionStore->compositionFetch(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
));
}
private function stageComposition(?string $remoteEntity = null): void { private function stageComposition(?string $remoteEntity = null): void {
$saveComposition = static fn(): array => [ $saveComposition = static fn(): array => [
'identifier' => self::COMPOSITION_ID, 'identifier' => self::COMPOSITION_ID,
@@ -292,6 +421,20 @@ final class CompositionSynchronizationTest extends TestCase {
return $service; return $service;
} }
private function submitService(MessagePropertiesMutableInterface $properties): ServiceBaseInterface&ServiceEntitySubmitInterface&Stub {
$service = $this->createStubForIntersectionOfInterfaces([
ServiceBaseInterface::class,
ServiceEntitySubmitInterface::class,
]);
$entity = $this->createStub(EntityMutableInterface::class);
$entity->method('getProperties')->willReturn($properties);
$service->method('provider')->willReturn('provider');
$service->method('identifier')->willReturn('service');
$service->method('getEnabled')->willReturn(true);
$service->method('entityFresh')->willReturn($entity);
return $service;
}
private function compositionManager(\KTXM\MailManager\Manager $mailManager): CompositionManager { private function compositionManager(\KTXM\MailManager\Manager $mailManager): CompositionManager {
return new CompositionManager( return new CompositionManager(
container: $this->createStub(ContainerInterface::class), container: $this->createStub(ContainerInterface::class),