Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 124 additions & 32 deletions frontend/src/components/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,37 +30,50 @@
/>
</template>
<template #sidebar-item="{ item }">
<SidebarItem
:label="item.label"
:icon="item.icon"
:to="item.to"
:is-active="
item.activeFor?.includes(
['Mailbox', 'Mail'].includes(route.name as string)
? route.params.mailbox
: route.name,
)
"
:on-click="item.onClick"
class="group"
>
<template #suffix>
<div class="flex items-center">
<Dropdown v-if="item.menuOptions" :options="item.menuOptions">
<Button variant="ghost" class="!bg-transparent" @click.stop>
<template #icon>
<Ellipsis
class="text-ink-gray-6 invisible h-4 w-4 group-hover:visible"
/>
</template>
</Button>
</Dropdown>
<span class="text-ink-gray-4 text-sm group-hover:hidden">
{{ item.suffix }}
</span>
</div>
</template>
</SidebarItem>
<div class="flex flex-col">
<span
class="mx-2 -mt-0.5 h-px"
:class="{ 'bg-surface-gray-5': item.isDropTarget }"
/>
<SidebarItem
:label="item.label"
:icon="item.icon"
:to="item.to"
:is-active="
item.activeFor?.includes(
['Mailbox', 'Mail'].includes(route.name as string)
? route.params.mailbox
: route.name,
)
"
:on-click="item.onClick"
class="group"
:class="{ 'opacity-50': item.isDragging }"
:draggable="item.draggable"
@dragstart="(e: DragEvent) => item.onDragStart?.(e)"
@dragend="(e: DragEvent) => item.onDragEnd?.(e)"
@dragover="(e: DragEvent) => item.onDragOver?.(e)"
@dragleave="(e: DragEvent) => item.onDragLeave?.(e)"
@drop="(e: DragEvent) => item.onDrop?.(e)"
>
<template #suffix>
<div class="flex items-center">
<Dropdown v-if="item.menuOptions" :options="item.menuOptions">
<Button variant="ghost" class="!bg-transparent" @click.stop>
<template #icon>
<Ellipsis
class="text-ink-gray-6 invisible h-4 w-4 group-hover:visible"
/>
</template>
</Button>
</Dropdown>
<span class="text-ink-gray-4 text-sm group-hover:hidden">
{{ item.suffix }}
</span>
</div>
</template>
</SidebarItem>
</div>
</template>
</Sidebar>
</Transition>
Expand Down Expand Up @@ -213,6 +226,73 @@ const MAILBOX_ICONS = {
important: Bookmark,
}

// Drag and drop
const draggedItem = ref<string | null>(null)
const dropTargetId = ref<string | null>(null)

const handleDragStart = (mailboxId: string) => (e: DragEvent) => {
draggedItem.value = mailboxId
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', mailboxId)
}
}

const handleDragEnd = () => {
draggedItem.value = null
dropTargetId.value = null
}

const handleDragOver = (mailboxId: string) => (e: DragEvent) => {
if (draggedItem.value && draggedItem.value !== mailboxId) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'

dropTargetId.value = mailboxId
}
}

const handleDragLeave = () => (dropTargetId.value = null)

const handleDrop = (targetMailboxId: string) => (e: DragEvent) => {
e.preventDefault()
if (draggedItem.value && draggedItem.value !== targetMailboxId) {
const draggedIndex = mailboxes.data.findIndex((m) => m.id === draggedItem.value)
const targetIndex =
targetMailboxId === 'starred'
? mailboxes.data.length - 1
: mailboxes.data.findIndex((m) => m.id === targetMailboxId)
if (draggedIndex + 1 === targetIndex) {
dropTargetId.value = null
draggedItem.value = null
return
}
updateMailboxPosition.submit({
target_mailbox_id: draggedItem.value,
prior_mailbox_id:
targetIndex === 0
? null
: targetMailboxId === 'starred'
? mailboxes.data.at(-1).id
: mailboxes.data[targetIndex - 1].id,
})
}
dropTargetId.value = null
draggedItem.value = null
}

const updateMailboxPosition = createResource({
url: 'mail.client.doctype.mailbox.mailbox.update_mailbox_position',
makeParams: ({
target_mailbox_id,
prior_mailbox_id,
}: {
target_mailbox_id: string
prior_mailbox_id: string | null
}) => ({ user: user.data.name, target_mailbox_id, prior_mailbox_id }),
onSuccess: () => mailboxes.reload(),
})

const sidebarItems = computed(() => {
if (route.meta.isDashboard) return dashboardItems

Expand All @@ -227,6 +307,14 @@ const sidebarItems = computed(() => {
to: { name: 'Mailbox', params: { mailbox: mailbox.id } },
suffix: mailbox.unread_threads ? String(mailbox.unread_threads) : '',
activeFor: [mailbox.id],
draggable: true,
isDragging: draggedItem.value === mailbox.id,
isDropTarget: dropTargetId.value === mailbox.id,
onDragStart: handleDragStart(mailbox.id),
onDragEnd: handleDragEnd,
onDragOver: handleDragOver(mailbox.id),
onDragLeave: handleDragLeave,
onDrop: handleDrop(mailbox.id),
menuOptions: [
{
label: __('Edit Folder'),
Expand All @@ -251,6 +339,10 @@ const sidebarItems = computed(() => {
icon: Star,
to: { name: 'Mailbox', params: { mailbox: 'starred' } },
activeFor: ['starred'],
isDropTarget: dropTargetId.value === 'starred',
onDragOver: handleDragOver('starred'),
onDragLeave: handleDragLeave,
onDrop: handleDrop('starred'),
}

const addMailboxItem = {
Expand All @@ -260,7 +352,7 @@ const sidebarItems = computed(() => {
}

return mailboxes.data?.length
? [{ items: [mailboxItems[0], starredItem, ...mailboxItems.slice(1), addMailboxItem] }]
? [{ items: [...mailboxItems, starredItem, addMailboxItem] }]
: []
})

Expand Down
10 changes: 5 additions & 5 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ const registerServiceWorker = async () => {
}

router.isReady().then(async () => {
if (import.meta.env.DEV)
await frappeRequest({ url: '/api/method/mail.www.mail.get_context_for_dev' }).then(
(values) => Object.keys(values).forEach((key) => (window[key] = values[key])),
)
// if (import.meta.env.DEV)
// await frappeRequest({ url: '/api/method/mail.www.mail.get_context_for_dev' }).then(
// (values) => Object.keys(values).forEach((key) => (window[key] = values[key])),
// )

registerServiceWorker()
// registerServiceWorker()
app.mount('#app')
})
7 changes: 2 additions & 5 deletions frontend/src/types/doctypes.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

interface DocType {
name: string
creation: string
modified: string
owner: string
modified_by: string
docstatus: 0 | 1 | 2
}

interface ChildDocType extends DocType {
Expand Down Expand Up @@ -388,7 +385,7 @@ export interface MailMessageMailbox extends ChildDocType {
mailbox_name: string
}

// Last updated: 2025-12-09 13:11:05.006924
// Last updated: 2025-12-17 10:54:02.407375
export interface Identity extends DocType {
/** May Delete: Check */
may_delete: 0 | 1
Expand Down Expand Up @@ -420,7 +417,7 @@ export interface MailSignature extends DocType {
html_body?: string
}

// Last updated: 2025-12-09 12:55:32.269456
// Last updated: 2025-12-15 11:47:17.806197
export interface VacationResponse extends DocType {
/** User: Link (User) */
user: string
Expand Down
14 changes: 10 additions & 4 deletions mail/api/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
set_spam_status,
)
from mail.client.doctype.mail_queue.mail_queue import MailQueue
from mail.client.doctype.mailbox.mailbox import update_mailbox
from mail.jmap import get_mailbox_id_by_role
from mail.utils import convert_html_to_text
from mail.utils.user import has_role
Expand All @@ -33,11 +34,9 @@ def get_mailboxes() -> list[dict]:
if not has_role(user, "Mail User") or user == "Administrator":
return []

fields = ["id", "_name", "role", "total_threads", "unread_threads"]
fields = ["id", "_name", "role", "total_threads", "unread_threads", "sort_order"]
mailboxes = get_user_mailboxes(user)
return [
{field: mailbox[field] for field in fields} for mailbox in mailboxes if mailbox["subscribed"] == 1
]
return [{field: mailbox[field] for field in fields} for mailbox in mailboxes]


def get_user_mailboxes(user) -> list[dict]:
Expand Down Expand Up @@ -539,3 +538,10 @@ def normalize_search_filter(filter: dict) -> dict:
def parse_date_to_utc_iso(date_str: str) -> str:
"""Parse date string and convert to ISO format with UTC timezone."""
return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC).isoformat()


@frappe.whitelist()
def update_mailbox_sort_order(mailboxes: dict) -> None:
"""Updates mailbox sort order of the given mailboxes."""

print(mailboxes)