Initial commit

This commit is contained in:
2026-02-10 19:39:08 -05:00
commit 2a251f9b3f
32 changed files with 6135 additions and 0 deletions

414
src/pages/MailPage.vue Normal file
View File

@@ -0,0 +1,414 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useDisplay } from 'vuetify'
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 type { CollectionObject } from '@MailManager/models/collection'
import type { EntityInterface } from '@MailManager/types/entity'
import type { MessageInterface } from '@MailManager/types/message'
import FolderTree from '@/components/FolderTree.vue'
import MessageList from '@/components/MessageList.vue'
import MessageReader from '@/components/MessageReader.vue'
import MessageComposer from '@/components/MessageComposer.vue'
import SettingsDialog from '@/components/settings/SettingsDialog.vue'
// Vuetify display for responsive behavior
const display = useDisplay()
// Snackbar state for notifications
const snackbarVisible = ref(false)
const snackbarMessage = ref('')
const snackbarColor = ref('success')
// Stores
const collectionsStore = useCollectionsStore()
const entitiesStore = useEntitiesStore()
const servicesStore = useServicesStore()
// Background mail sync
const mailSync = useMailSync({
interval: 30000, // Check every 30 seconds
autoStart: false, // We'll start it manually after initialization
fetchDetails: true, // Auto-fetch full message details for new/modified messages
})
// UI state
const sidebarVisible = ref(true)
const selectedFolder = ref<CollectionObject | null>(null)
const selectedMessage = ref<EntityInterface<MessageInterface> | null>(null)
const composeMode = ref(false)
const composeReplyTo = ref<EntityInterface<MessageInterface> | null>(null)
const settingsDialogVisible = ref(false)
// Loading state
const loading = ref(false)
// Computed
const isMobile = computed(() => display.mdAndDown.value)
// Initialize
onMounted(async () => {
loading.value = true
try {
// Load services (accounts)
await servicesStore.list()
// Load collections (folders)
await collectionsStore.loadCollections()
// Select inbox by default if available
const inbox = collectionsStore.collectionList.find(c => c.properties.role === 'inbox')
if (inbox) {
handleFolderSelect(inbox)
}
// Start background sync after initialization
mailSync.start()
} catch (error) {
console.error('[Mail] Failed to initialize:', error)
} finally {
loading.value = false
}
})
// Watch for folder and service changes to update sync sources
watch(
[selectedFolder, () => servicesStore.services],
() => {
mailSync.clearSources()
// Add currently selected folder to sync
if (selectedFolder.value) {
mailSync.addSource({
provider: selectedFolder.value.provider,
service: selectedFolder.value.service,
collections: [selectedFolder.value.identifier],
})
}
// Add inbox for each service to get notifications
servicesStore.services.forEach(service => {
// Find inbox collection for this service
const inboxes = collectionsStore.collectionList.filter(
c => c.service === service.identifier &&
(c.properties.role === 'inbox' ||
String(c.identifier).toLowerCase() === 'inbox')
)
if (inboxes.length > 0) {
mailSync.addSource({
provider: service.provider,
service: service.identifier,
collections: inboxes.map(inbox => inbox.identifier),
})
}
})
// Restart sync with updated sources
if (mailSync.sources.value.length > 0 && !mailSync.isRunning.value) {
mailSync.start()
}
},
{ deep: true }
)
// Handlers
const handleFolderSelect = async (folder: CollectionObject) => {
selectedFolder.value = folder
selectedMessage.value = null
composeMode.value = false
// Load messages for this folder
try {
await entitiesStore.loadMessages({
[folder.provider]: {
[folder.service]: {
[folder.identifier]: true
}
}
})
} catch (error) {
console.error('[Mail] Failed to load messages:', error)
}
}
const handleMessageSelect = (message: EntityInterface<MessageInterface>) => {
selectedMessage.value = message
composeMode.value = false
// Close sidebar on mobile after selection
if (isMobile.value) {
sidebarVisible.value = false
}
}
const handleCompose = (replyTo?: EntityInterface<MessageInterface>) => {
composeMode.value = true
composeReplyTo.value = replyTo || null
selectedMessage.value = null
}
const handleComposeClose = () => {
composeMode.value = false
composeReplyTo.value = null
}
const handleComposeSent = () => {
composeMode.value = false
composeReplyTo.value = null
// Reload current folder to show sent message in Sent folder
if (selectedFolder.value) {
handleFolderSelect(selectedFolder.value)
}
}
const handleReply = (message: EntityInterface<MessageInterface>) => {
handleCompose(message)
}
const handleDelete = async (message: EntityInterface<MessageInterface>) => {
// TODO: Implement delete functionality
console.log('[Mail] Delete message:', message.identifier)
}
const toggleSidebar = () => {
sidebarVisible.value = !sidebarVisible.value
}
const handleSettingsOpen = () => {
settingsDialogVisible.value = true
}
const handleFolderCreated = (folder: CollectionObject) => {
snackbarMessage.value = `Folder "${folder.properties.label}" created successfully`
snackbarColor.value = 'success'
snackbarVisible.value = true
// Reload collections to ensure UI is in sync
collectionsStore.loadCollections()
}
// Messages for current folder
const currentMessages = computed(() => {
if (!selectedFolder.value) return []
const provider = selectedFolder.value.provider
const service = String(selectedFolder.value.service)
const collection = String(selectedFolder.value.identifier)
const messages = entitiesStore.messages[provider]?.[service]?.[collection]
return messages ? Object.values(messages) : []
})
</script>
<template>
<div class="mail-container">
<!-- Top toolbar -->
<v-app-bar class="mail-toolbar" elevation="0" density="compact">
<v-app-bar-nav-icon
v-if="isMobile"
@click="toggleSidebar"
/>
<v-app-bar-title>Mail</v-app-bar-title>
<v-spacer />
<v-btn
icon="mdi-pencil"
@click="handleCompose()"
color="primary"
variant="text"
>
<v-icon>mdi-pencil</v-icon>
<v-tooltip activator="parent" location="bottom">Compose</v-tooltip>
</v-btn>
<v-btn
icon="mdi-refresh"
@click="mailSync.sync()"
:loading="mailSync.isRunning.value && entitiesStore.loading"
variant="text"
>
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="bottom">
Refresh {{ mailSync.lastSync.value ? `(Last: ${new Date(mailSync.lastSync.value).toLocaleTimeString()})` : '' }}
</v-tooltip>
</v-btn>
<v-icon
v-if="mailSync.isRunning.value"
color="success"
size="small"
class="ml-2"
>
mdi-sync
</v-icon>
</v-app-bar>
<!-- Main content area -->
<div class="mail-content">
<!-- Folder tree sidebar -->
<v-navigation-drawer
v-model="sidebarVisible"
:permanent="!isMobile"
:temporary="isMobile"
width="280"
class="mail-sidebar"
>
<FolderTree
:selected-folder="selectedFolder"
@select="handleFolderSelect"
@folder-created="handleFolderCreated"
/>
<template #append>
<div class="pa-2">
<v-btn
block
variant="text"
prepend-icon="mdi-cog"
@click="handleSettingsOpen"
>
Settings
</v-btn>
</div>
</template>
</v-navigation-drawer>
<!-- Main area with message list and reader -->
<div class="mail-main">
<div class="mail-wrapper">
<!-- Message list panel -->
<div class="mail-list-panel">
<MessageList
:messages="currentMessages"
:selected-message="selectedMessage"
:selected-collection="selectedFolder"
:loading="loading"
@select="handleMessageSelect"
/>
</div>
<!-- Reader/Composer panel -->
<div class="mail-reader-panel">
<MessageComposer
v-if="composeMode"
:reply-to="composeReplyTo"
:folder="selectedFolder"
@close="handleComposeClose"
@sent="handleComposeSent"
/>
<MessageReader
v-else
:message="selectedMessage"
@reply="handleReply"
@delete="handleDelete"
@compose="handleCompose()"
/>
</div>
</div>
</div>
</div>
<!-- Settings Dialog -->
<SettingsDialog v-model="settingsDialogVisible" />
<!-- Success Snackbar -->
<v-snackbar
v-model="snackbarVisible"
:color="snackbarColor"
:timeout="3000"
location="bottom right"
>
{{ snackbarMessage }}
<template v-slot:actions>
<v-btn
variant="text"
@click="snackbarVisible = false"
>
Close
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<style scoped lang="scss">
.mail-container {
display: flex;
flex-direction: column;
height: 100vh;
isolation: isolate;
}
.mail-toolbar {
flex-shrink: 0;
}
.mail-content {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
.mail-sidebar {
border-right: 1px solid rgb(var(--v-border-color));
overflow-y: auto;
}
.mail-main {
flex: 1;
display: flex;
overflow: hidden;
min-width: 0;
}
.mail-wrapper {
flex: 1;
display: flex;
overflow: hidden;
}
.mail-list-panel {
width: 320px;
min-width: 280px;
max-width: 450px;
border-right: 1px solid rgb(var(--v-border-color));
overflow-y: auto;
display: flex;
flex-direction: column;
}
.mail-reader-panel {
flex: 1;
overflow: hidden;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Responsive adjustments */
@media (max-width: 960px) {
.mail-wrapper {
flex-direction: column;
}
.mail-list-panel {
width: 100%;
max-width: 100%;
border-right: none;
border-bottom: 1px solid rgb(var(--v-border-color));
max-height: 50%;
}
.mail-reader-panel {
width: 100%;
}
}
</style>