From c64fd84f6aa0a070a376bfb68f5e3e63862791be Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 25 Jun 2026 09:21:11 +0530 Subject: [PATCH 1/2] feat: Hey-style email screening; unify Blocked/Junk into Screened Email Address (backport #600 to v0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of PR #600 (frappe/mail, merged into develop) onto v0. Merges the legacy "Blocked Email Address" and "Junk Email Address" doctypes into a single account-scoped "Screened Email Address" doctype (Reject / Spam / Accepted actions), adds the Hey-style screening flow (Screener view + screening endpoints), and generates the frappe_mail_automation sieve through the unified build_automation_sieve layer (create-if-missing, build all four sections — Reject -> Mailbox -> Spam -> Screening — and optionally activate). A migration converts existing Blocked -> Reject and Junk -> Spam rules and drops the old doctypes. v0 adaptations during the backport: - mail/api/mail.py: kept v0's get_mail_config (develop renamed it to get_config) and v0's import layout while adding the screening imports. - mail/hooks.py: applied only #600's change (drop Blocked/Junk permission and ignore-links entries, add Screened Email Address); did not pull in the unrelated develop-only Calendar Exchange permission entry that v0 doesn't register. - frontend/src/types/doctypes.ts: kept v0's generated types; #600's only diff here was unrelated MailSettings log-field regeneration. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/components.d.ts | 1 + frontend/src/App.vue | 27 +- frontend/src/components/AppSidebar.vue | 104 ++- frontend/src/components/EmailContent.vue | 116 ++- frontend/src/components/MailActions.vue | 63 +- .../src/components/MailDetailsPopover.vue | 2 +- frontend/src/components/MailThread.vue | 663 ++++++++++-------- .../src/components/MailThreadSkeleton.vue | 39 ++ .../src/components/Modals/FolderModal.vue | 61 +- .../src/components/Modals/SettingsModal.vue | 2 +- frontend/src/components/SendMail.vue | 2 +- .../components/Settings/AccountSettings.vue | 87 ++- .../Settings/AutomationSettings.vue | 29 +- .../components/Settings/BlockListSettings.vue | 167 +++-- frontend/src/components/ThreadHeader.vue | 8 +- frontend/src/constants.ts | 4 + frontend/src/pages/MailboxView.vue | 70 +- frontend/src/pages/ScreenerView.vue | 426 +++++++++++ frontend/src/router.ts | 6 + frontend/src/stores/user.ts | 22 +- frontend/src/types/index.ts | 31 + frontend/src/utils/composables.ts | 97 ++- frontend/src/utils/index.ts | 70 +- frontend/src/utils/useThreadActions.ts | 62 +- mail/api/account.py | 10 +- mail/api/mail.py | 424 ++++++++--- mail/api/sieve.py | 372 +++++++--- .../account_settings/account_settings.json | 60 +- .../account_settings/account_settings.py | 14 + .../blocked_email_address.py | 84 --- .../blocked_email_address_list.js | 4 - .../doctype/junk_email_address/__init__.py | 0 .../junk_email_address.json | 80 --- .../junk_email_address/junk_email_address.py | 84 --- .../junk_email_address_list.js | 4 - .../test_junk_email_address.py | 20 - mail/client/doctype/mailbox/mailbox.py | 9 +- .../mailbox_settings/mailbox_settings.json | 55 +- .../mailbox_settings/mailbox_settings.py | 56 ++ .../__init__.py | 0 .../screened_email_address.js} | 2 +- .../screened_email_address.json} | 17 +- .../screened_email_address.py | 93 +++ .../screened_email_address_list.js} | 2 +- .../test_screened_email_address.py} | 4 +- mail/hooks.py | 8 +- mail/patches.txt | 2 + .../backfill_blocked_email_account_id.py | 9 +- .../backfill_mailbox_automation_rules.py | 15 + .../migrate_to_screened_email_address.py | 72 ++ 50 files changed, 2525 insertions(+), 1134 deletions(-) create mode 100644 frontend/src/components/MailThreadSkeleton.vue create mode 100644 frontend/src/pages/ScreenerView.vue delete mode 100644 mail/client/doctype/blocked_email_address/blocked_email_address.py delete mode 100644 mail/client/doctype/blocked_email_address/blocked_email_address_list.js delete mode 100644 mail/client/doctype/junk_email_address/__init__.py delete mode 100644 mail/client/doctype/junk_email_address/junk_email_address.json delete mode 100644 mail/client/doctype/junk_email_address/junk_email_address.py delete mode 100644 mail/client/doctype/junk_email_address/junk_email_address_list.js delete mode 100644 mail/client/doctype/junk_email_address/test_junk_email_address.py rename mail/client/doctype/{blocked_email_address => screened_email_address}/__init__.py (100%) rename mail/client/doctype/{blocked_email_address/blocked_email_address.js => screened_email_address/screened_email_address.js} (71%) rename mail/client/doctype/{blocked_email_address/blocked_email_address.json => screened_email_address/screened_email_address.json} (75%) create mode 100644 mail/client/doctype/screened_email_address/screened_email_address.py rename mail/client/doctype/{junk_email_address/junk_email_address.js => screened_email_address/screened_email_address_list.js} (68%) rename mail/client/doctype/{blocked_email_address/test_blocked_email_address.py => screened_email_address/test_screened_email_address.py} (82%) create mode 100644 mail/patches/backfill_mailbox_automation_rules.py create mode 100644 mail/patches/migrate_to_screened_email_address.py diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 48ea0bdd5..98d09e713 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -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'] diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ea2a944b3..c192275cc 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,10 +7,11 @@ diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index df2e61035..43e98c818 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -49,7 +49,10 @@ - + {{ item.suffix }} @@ -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' @@ -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'), @@ -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'), @@ -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 diff --git a/frontend/src/components/EmailContent.vue b/frontend/src/components/EmailContent.vue index 40aac5ad7..081d1e4fb 100644 --- a/frontend/src/components/EmailContent.vue +++ b/frontend/src/components/EmailContent.vue @@ -1,4 +1,18 @@