Skip to content
This repository was archived by the owner on Jun 29, 2026. It is now read-only.
Merged
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
1 change: 1 addition & 0 deletions frontend/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ declare module 'vue' {
MailListItemActions: typeof import('./src/components/MailListItemActions.vue')['default']
MailLogo: typeof import('./src/components/Icons/MailLogo.vue')['default']
MailThread: typeof import('./src/components/MailThread.vue')['default']
MailThreadSkeleton: typeof import('./src/components/MailThreadSkeleton.vue')['default']
NoMails: typeof import('./src/components/Icons/NoMails.vue')['default']
PDFIcon: typeof import('./src/components/Icons/PDFIcon.vue')['default']
ProfileSettings: typeof import('./src/components/Settings/ProfileSettings.vue')['default']
Expand Down
27 changes: 22 additions & 5 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
</FrappeUIProvider>
</template>
<script setup lang="ts">
import { computed, onMounted, watchEffect } from 'vue'
import { computed, onMounted, onUnmounted, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import { FrappeUIProvider } from 'frappe-ui'

import { shouldIgnoreKeypress } from '@/utils'
import { useScreenSize, useTheme } from '@/utils/composables'
import { showNotification } from '@/utils/push-notifications'
import DefaultLayout from '@/components/DefaultLayout.vue'
Expand All @@ -19,7 +20,7 @@ import LoginLayout from '@/components/LoginLayout.vue'

import type { NotificationPayload } from '@/types'

const { dataTheme } = useTheme()
const { dataTheme, cycleTheme } = useTheme()
const { isMobile } = useScreenSize()
const route = useRoute()

Expand All @@ -31,9 +32,25 @@ const Layout = computed(() => {

watchEffect(() => document.documentElement.setAttribute('data-theme', dataTheme.value))

onMounted(() =>
// App-wide Cmd/Ctrl+Shift+L to cycle the color scheme (works on every page, not just the mailbox).
const handleThemeShortcut = (e: KeyboardEvent) => {
if (
(e.metaKey || e.ctrlKey) &&
e.shiftKey &&
e.key.toLowerCase() === 'l' &&
!shouldIgnoreKeypress(e, true)
) {
e.preventDefault()
cycleTheme()
}
}

onMounted(() => {
window.frappePushNotification?.onMessage((payload: NotificationPayload) =>
showNotification(payload),
),
)
)
window.addEventListener('keydown', handleThemeShortcut)
})

onUnmounted(() => window.removeEventListener('keydown', handleThemeShortcut))
</script>
104 changes: 67 additions & 37 deletions frontend/src/components/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@
</template>
</Button>
</Dropdown>
<span class="text-ink-gray-4 text-sm group-hover:hidden">
<span
class="text-ink-gray-4 text-sm"
:class="{ 'group-hover:hidden': item.menuOptions }"
>
{{ item.suffix }}
</span>
</div>
Expand All @@ -75,7 +78,7 @@ import { Check, Keyboard, User } from 'lucide-vue-next'
import { Avatar, Button, Dropdown, Sidebar, SidebarItem, createResource } from 'frappe-ui'

import { FOLDER_ICON_COLOR_MAP } from '@/constants'
import { getIcon, toTitleCase } from '@/utils'
import { getIcon, getMailboxName, toTitleCase } from '@/utils'
import { useScreenSize, useSettings, useSidebar } from '@/utils/composables'
import { sessionStore } from '@/stores/session'
import { userStore } from '@/stores/user'
Expand Down Expand Up @@ -277,45 +280,67 @@ const mailboxItems = computed(
() =>
mailboxes.data
?.filter((mailbox: MailboxData) => mailbox.subscribed)
?.map((mailbox: MailboxData) => ({
label: mailbox._name,
icon: h(Icon, {
name: getIcon(mailbox),
class: FOLDER_ICON_COLOR_MAP[mailbox.color],
}),
to: {
name: 'Mailbox',
params: { accountId: store.accountId, mailbox: mailbox.id },
},
suffix: mailbox.unread_threads ? String(mailbox.unread_threads) : '',
activeFor: [mailbox.id],
menuOptions: [
{
label: __('Configure'),
icon: Settings,
onClick: () => {
selectedMailbox.value = mailbox
showFolderModal.value = true
},
},
{
label: __('Delete'),
theme: 'red',
icon: Trash2,
onClick: () => {
selectedMailbox.value = mailbox
showDeleteMailbox.value = true
},
},
],
})) || [],
?.map((mailbox: MailboxData) => {
// The Screening folder opens the dedicated Screener page, not the thread list.
const isScreener = mailbox.id === store.mailboxIds.screening
return {
mailboxId: mailbox.id,
label: getMailboxName(mailbox),
icon: h(Icon, {
name: getIcon(mailbox),
class: FOLDER_ICON_COLOR_MAP[mailbox.color],
}),
to: isScreener
? { name: 'Screener', params: { accountId: store.accountId } }
: {
name: 'Mailbox',
params: { accountId: store.accountId, mailbox: mailbox.id },
},
suffix: mailbox.unread_threads ? String(mailbox.unread_threads) : '',
activeFor: isScreener ? ['Screener'] : [mailbox.id],
menuOptions: isScreener
? undefined
: [
{
label: __('Configure'),
icon: Settings,
onClick: () => {
selectedMailbox.value = mailbox
showFolderModal.value = true
},
},
{
label: __('Delete'),
theme: 'red',
icon: Trash2,
onClick: () => {
selectedMailbox.value = mailbox
showDeleteMailbox.value = true
},
},
],
}
}) || [],
)

const screeningEnabled = computed(
() =>
!!store.userResource?.data?.accounts?.find((a) => a.id === store.accountId)
?.enable_screening,
)

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

// Screening is a roleless folder; it gets its own nameless group pinned to the top of the
// sidebar, separate from the default and custom mailboxes.
const isScreening = (item: { mailboxId?: string }) =>
!!store.mailboxIds.screening && item.mailboxId === store.mailboxIds.screening

const screenerItem = mailboxItems.value.find((item) => isScreening(item))

const defaultMailboxes = mailboxItems.value.filter(
(item) => mailboxes.data?.find((m) => m.id === item.activeFor[0])?.role,
(item) => mailboxes.data?.find((m) => m.id === item.mailboxId)?.role,
)
const starredItem = {
label: __('Starred'),
Expand All @@ -326,7 +351,8 @@ const sidebarItems = computed(() => {
const defaultItems = [...defaultMailboxes, starredItem]

const customMailboxes = mailboxItems.value.filter(
(item) => !mailboxes.data?.find((m) => m.id === item.activeFor[0])?.role,
(item) =>
!mailboxes.data?.find((m) => m.id === item.mailboxId)?.role && !isScreening(item),
)
const addMailboxItem = {
label: __('New Folder'),
Expand All @@ -353,11 +379,15 @@ const sidebarItems = computed(() => {
},
]

return [
const groups = [
{ label: __('Default'), items: defaultItems },
{ label: __('Custom'), items: customItems },
{ label: __('People'), items: contactsItems },
]
// Screener is its own nameless group, pinned first — only when screening is enabled.
if (screenerItem && screeningEnabled.value)
groups.unshift({ label: '', items: [screenerItem] })
return groups
})

// Shortcuts
Expand Down
116 changes: 83 additions & 33 deletions frontend/src/components/EmailContent.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
<template>
<div
v-if="showImagesBanner"
class="text-ink-gray-6 mb-3 flex items-center gap-3 rounded border p-2.5 px-4"
>
<ImageOff class="h-4.5 w-4.5 stroke-1.5" />
<span class="text-ink-gray-8 min-w-0 flex-1"> {{ blockedLabel }} </span>
<Button
v-if="canTrust"
variant="ghost"
:label="__('Mark Sender as Trusted')"
@click="handleTrust"
/>
<Button :label="__('Load Images')" class="w-28" @click="imagesLoaded = true" />
</div>
<div v-if="!isIframeReady" class="animate-pulse space-y-2 py-4">
<div
v-for="i in 5"
Expand All @@ -23,15 +37,50 @@ import iframeResizerChildScript from '@iframe-resizer/child/index.umd.js?raw'
// eslint-disable-next-line import/no-unresolved
import IframeResizer from '@iframe-resizer/vue/sfc'
import DOMPurify from 'dompurify'
import { ImageOff } from 'lucide-vue-next'
import { Button } from 'frappe-ui'

import { analyzeRemoteAssets, blockRemoteAssets } from '@/utils'
import { useTheme } from '@/utils/composables'

const { content } = defineProps<{ content: string }>()
const {
content,
blockImages = false,
canTrust = true,
} = defineProps<{ content: string; blockImages?: boolean; canTrust?: boolean }>()
const emit = defineEmits<{ trust: [] }>()

const { dataTheme } = useTheme()

const isIframeReady = ref(false)

// Remote images are withheld until the reader opts in (per message), so a sender can't use them to track
// when their mail was opened.
const imagesLoaded = ref(false)
// Trusting dismisses the banner instantly (and reveals images) without waiting for the sender's accept to
// round-trip — otherwise the bar lingers before it disappears.
const trusted = ref(false)
const effectiveBlock = computed(() => blockImages && !imagesLoaded.value && !trusted.value)
const remoteAssets = computed(() => analyzeRemoteAssets(content))
// The banner is dismissed once the reader loads the images (or trusts the sender) — there's nothing
// left to act on after that.
const showImagesBanner = computed(
() => blockImages && !imagesLoaded.value && !trusted.value && remoteAssets.value.hasRemote,
)
const blockedLabel = computed(() => {
const n = remoteAssets.value.images
if (n === 0) return __('Remote content hidden to protect your privacy.')
return n === 1
? __('1 remote image hidden to protect your privacy.')
: __('{0} remote images hidden to protect your privacy.', [String(n)])
})

// Trusting reveals images and dismisses the banner now; the parent accepts the sender for future mail.
const handleTrust = () => {
trusted.value = true
emit('trust')
}

// Listen for keyboard events from iframe
const handleMessage = (event: MessageEvent) => {
if (event.data?.type !== 'keyboard') return
Expand All @@ -51,39 +100,36 @@ const handleMessage = (event: MessageEvent) => {
onMounted(() => window.addEventListener('message', handleMessage))
onUnmounted(() => window.removeEventListener('message', handleMessage))

const srcdoc = computed(() => {
const collapseButton = `
<button
style="
background: ${colors.value.button};
color: ${colors.value.text};
padding: 0.5px 6px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
margin: 12px 0;
"
onmouseover="this.style.background='${colors.value.buttonHover}'"
onmouseout="this.style.background='${colors.value.button}'"
onclick="this.nextElementSibling.classList.toggle('quote-hidden');"
>
&middot;&middot;&middot;
</button>
`
const transformedContent = DOMPurify.sanitize(content, DOMPURIFY_CONFIG)
.replace(
/<div\s+([^>]*)\bclass="([^"]*)"\s*([^>]*)>([\s\S]*?)<\/div>/gi,
(match, beforeAttrs, classValue, afterAttrs, innerHtml) => {
// Check if this div has gmail_quote or frappe_mail_quote class
if (/\b(gmail_quote|frappe_mail_quote)\b/.test(classValue)) {
const allAttrs = `${beforeAttrs} ${afterAttrs}`.trim()
const attrString = allAttrs ? ` ${allAttrs}` : ''
return `${collapseButton}<div class="quote-hidden ${classValue}"${attrString}>${innerHtml}</div>`
}
return match
},
// Collapse each top-level quoted reply (gmail_quote / frappe_mail_quote) behind a "···" toggle. Done on
// the DOM, not regex: a quote with nested divs is wrapped as one unit, instead of the old regex stopping
// at the first </div> and collapsing the wrong region.
const collapseQuotes = (html: string) => {
const doc = new DOMParser().parseFromString(html, 'text/html')
doc.querySelectorAll('.gmail_quote, .frappe_mail_quote').forEach((quote) => {
// Only the outermost quote gets a toggle — hiding it hides any quotes nested inside.
if (quote.parentElement?.closest('.gmail_quote, .frappe_mail_quote')) return
quote.classList.add('quote-hidden')
const button = doc.createElement('button')
button.textContent = '···'
button.setAttribute(
'style',
`background:${colors.value.button};color:${colors.value.text};padding:0.5px 6px;border-radius:8px;cursor:pointer;transition:background .2s;margin:12px 0;`,
)
.replace(/<([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>/g, '<b>&lt;$1&gt;</b>')
button.setAttribute('onmouseover', `this.style.background='${colors.value.buttonHover}'`)
button.setAttribute('onmouseout', `this.style.background='${colors.value.button}'`)
button.setAttribute('onclick', "this.nextElementSibling.classList.toggle('quote-hidden');")
quote.parentNode?.insertBefore(button, quote)
})
return doc.documentElement.outerHTML
}

const srcdoc = computed(() => {
let sanitized = DOMPurify.sanitize(content, DOMPURIFY_CONFIG)
if (effectiveBlock.value) sanitized = blockRemoteAssets(sanitized)
const transformedContent = collapseQuotes(sanitized).replace(
/<([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>/g,
'<b>&lt;$1&gt;</b>',
)

/* eslint-disable no-useless-escape */
return `
Expand Down Expand Up @@ -131,6 +177,10 @@ const srcdoc = computed(() => {
overflow: hidden !important;
}

img[data-blocked-image] {
display: none !important;
}

pre, code {
font-family: 'Courier New', Courier, monospace;
white-space: pre;
Expand Down
Loading
Loading