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
4 changes: 2 additions & 2 deletions frontend/src/components/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ const mailboxItems = computed(
?.filter((mailbox: MailboxData) => mailbox.subscribed)
?.map((mailbox: MailboxData) => {
// The Screening folder opens the dedicated Screener page, not the thread list.
const isScreener = mailbox.id === store.mailboxIds.screening
const isScreener = mailbox.id === store.mailboxIds.screener
return {
mailboxId: mailbox.id,
label: getMailboxName(mailbox),
Expand Down Expand Up @@ -328,7 +328,7 @@ const sidebarItems = computed(() => {
// 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
!!store.mailboxIds.screener && item.mailboxId === store.mailboxIds.screener

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

Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/MailActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -203,20 +203,20 @@ const moreActions = (mail: Mail): GroupedAction[] => [
label: __('Accept Sender'),
onClick: () => handleScreenSender('Accepted'),
icon: CircleCheck,
condition: () => mailbox === mailboxIds.screening,
condition: () => mailbox === mailboxIds.screener,
},
{
label: __('Reject Sender'),
onClick: () => handleScreenSender('Reject'),
icon: Ban,
condition: () => mailbox === mailboxIds.screening,
condition: () => mailbox === mailboxIds.screener,
},
{
label: __('Block Sender'),
onClick: () => handleBlockAddress(true),
icon: Ban,
condition: () =>
mailbox !== mailboxIds.screening &&
mailbox !== mailboxIds.screener &&
!identities.data.some((i: Identity) => i.email === mail.from_email) &&
!isSenderBlocked(mail.from_email),
},
Expand All @@ -225,7 +225,7 @@ const moreActions = (mail: Mail): GroupedAction[] => [
onClick: () => handleBlockAddress(false),
icon: LockOpen,
condition: () =>
mailbox !== mailboxIds.screening && isSenderBlocked(mail.from_email),
mailbox !== mailboxIds.screener && isSenderBlocked(mail.from_email),
},
],
},
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Settings/AccountSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ const save = async () => {
askMoveToInbox =
screeningChanged &&
!accountSettings.doc.enable_screening &&
(mailboxes.data?.find((m: MailboxData) => m.id === mailboxIds.screening)
(mailboxes.data?.find((m: MailboxData) => m.id === mailboxIds.screener)
?.total_threads ?? 0) > 0
await accountSettings.save.submit()
// Sync the shared user data so compose picks up the new default and the junk flow picks up
Expand Down
14 changes: 10 additions & 4 deletions frontend/src/components/Settings/FolderSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
<h1>{{ __('Folders') }}</h1>
<Button icon-left="plus" :label="__('New')" @click="editMailbox()" />
</div>
<div v-if="mailboxes?.data?.length">
<div v-if="managedMailboxes.length">
<div
v-for="mailbox in mailboxes?.data"
v-for="mailbox in managedMailboxes"
:key="mailbox.name"
class="hover:bg-surface-gray-1 -mx-2 flex cursor-pointer items-center justify-between rounded px-3 py-1"
@click="editMailbox(mailbox)"
Expand Down Expand Up @@ -44,12 +44,12 @@
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { Icon } from 'frappe-ui/icons'
import { Ellipsis, Eye, EyeOff, Settings, Trash2 } from 'lucide-vue-next'
import { Button, Dropdown, createResource } from 'frappe-ui'

import { FOLDER_ICON_COLOR_MAP } from '@/constants'
import { FOLDER_ICON_COLOR_MAP, SCREENER_MAILBOX_NAME } from '@/constants'
import { getIcon, raiseToast } from '@/utils'
import { userStore } from '@/stores/user'
import DeleteFolderModal from '@/components/Modals/DeleteFolderModal.vue'
Expand All @@ -59,6 +59,12 @@ import type { MailboxData } from '@/types'

const { mailboxes } = userStore()

// The Screener is a system folder driven by the screening flow, not a user-configurable folder — keep
// it out of the management list so it can't be renamed, deleted, or given a folder icon/color here.
const managedMailboxes = computed(
() => mailboxes?.data?.filter((m: MailboxData) => m._name !== SCREENER_MAILBOX_NAME) ?? [],
)

const showFolderModal = ref(false)
const selectedMailbox = ref<MailboxData>()
const showDeleteFolderModal = ref(false)
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/ThreadHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ const addToOptions = computed(() =>
?.filter(
(m) =>
(!m.role || ['inbox', 'archive'].includes(m.role)) &&
m.id !== mailboxIds.screener &&
!threadMailboxes.value.includes(m.id),
)
.map((m) => getMailboxOption(m, 'addThreadToMailbox')),
Expand All @@ -259,6 +260,7 @@ const moveToOptions = computed(() => {
const excludedMailboxes = new Set([
mailboxIds.sent,
mailboxIds.drafts,
mailboxIds.screener,
...threadMailboxes.value,
])
return mailboxes.data
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// The Screening mailbox is a plain named folder (no JMAP role), created server-side as "Screening";
// The Screening mailbox is a plain named folder (no JMAP role), created server-side as "Screener";
// it's surfaced to users as the "Screener".
export const SCREENING_MAILBOX_NAME = 'Screener'
export const SCREENER_MAILBOX_NAME = 'Screener'

export const getAttachmentOptions = () => [
{ label: __('All'), value: ' ' },
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/MailboxView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,7 @@ const activeAccount = computed(() => user.data?.accounts?.find((a) => a.id === a
const screeningEnabled = computed(() => !!activeAccount.value?.enable_screening)
const screenerCount = computed(
() =>
mailboxes.data?.find((m: MailboxData) => m.id === mailboxIds.screening)?.unread_threads ??
mailboxes.data?.find((m: MailboxData) => m.id === mailboxIds.screener)?.unread_threads ??
0,
)
const showScreenerBanner = computed(
Expand Down
80 changes: 68 additions & 12 deletions frontend/src/pages/ScreenerView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ const handleKeydown = (e: KeyboardEvent) => {
// Poll the Screening folder's count and only refetch the (heavier) sender list when it changes — the
// same cheap-count-then-reload approach the mailbox uses, so a quiet screener isn't reloaded every tick.
const screeningCount = () =>
store.mailboxes.data?.find((m: MailboxData) => m.id === store.mailboxIds.screening)
store.mailboxes.data?.find((m: MailboxData) => m.id === store.mailboxIds.screener)
?.total_threads

const pollForChanges = async () => {
Expand All @@ -324,6 +324,11 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
clearInterval(pollInterval)
// Don't strand a queued batch on navigation — the acted rows were already removed optimistically.
if (flushTimer) {
clearTimeout(flushTimer)
flushScreening()
}
})

usePageMeta(() => {
Expand Down Expand Up @@ -352,7 +357,65 @@ const screenOutResource = createResource({
}),
})

const runAction = async (resource: typeof allowResource, fromEmails: string[]) => {
// Block/Allow clicks are coalesced and flushed as one batched request per action. Triaging senders in
// quick succession otherwise fires a request per click, and each rebuilds the shared automation sieve —
// the concurrent rebuilds race on that single script and throw CannotChangeConstantError. The backend
// already accepts a list, so we just accumulate the burst and submit it once. A sender's latest action
// wins if both buttons are hit before the flush.
const SCREEN_FLUSH_DELAY = 500
const pending = { allow: new Set<string>(), screenOut: new Set<string>() }
let flushTimer: ReturnType<typeof setTimeout> | null = null
let flushChain: Promise<void> = Promise.resolve()

const flushScreening = () => {
flushTimer = null
const allowEmails = [...pending.allow]
const screenOutEmails = [...pending.screenOut]
pending.allow.clear()
pending.screenOut.clear()
if (!allowEmails.length && !screenOutEmails.length) return

// Chain onto the previous flush so requests never overlap (overlapping rebuilds are the bug).
flushChain = flushChain.then(async () => {
// Submit each action independently so one failing doesn't skip the other — a burst can mix
// allow and screen-out across different senders, and all were already optimistically removed.
let submitted = false
let firstError: unknown
if (allowEmails.length) {
try {
await allowResource.submit({ from_emails: allowEmails })
submitted = true
} catch (error) {
firstError ??= error
}
}
if (screenOutEmails.length) {
try {
await screenOutResource.submit({ from_emails: screenOutEmails })
submitted = true
} catch (error) {
firstError ??= error
}
}
// Allowing/screening senders changes inbox/junk counts too.
if (submitted) store.mailboxes.reload()
if (firstError) {
senders.reload()
raiseToast((firstError as Error).message || __('Action failed.'), 'error')
}
})
}

const queueScreening = (action: 'allow' | 'screenOut', fromEmails: string[]) => {
const other = action === 'allow' ? pending.screenOut : pending.allow
for (const email of fromEmails) {
other.delete(email)
pending[action].add(email)
}
if (!flushTimer) flushTimer = setTimeout(flushScreening, SCREEN_FLUSH_DELAY)
}

const runAction = (action: 'allow' | 'screenOut', fromEmails: string[]) => {
if (!fromEmails.length) return

// When acting on the sender open in the detail view, line up the next one down so you can triage
Expand Down Expand Up @@ -380,18 +443,11 @@ const runAction = async (resource: typeof allowResource, fromEmails: string[]) =
else closeSender()
}

try {
await resource.submit({ from_emails: fromEmails })
// Allowing/screening senders changes inbox/junk counts too.
store.mailboxes.reload()
} catch (error) {
senders.reload()
raiseToast((error as Error).message || __('Action failed.'), 'error')
}
queueScreening(action, fromEmails)
}

const allow = (fromEmails: string[]) => runAction(allowResource, fromEmails)
const screenOut = (fromEmails: string[]) => runAction(screenOutResource, fromEmails)
const allow = (fromEmails: string[]) => runAction('allow', fromEmails)
const screenOut = (fromEmails: string[]) => runAction('screenOut', fromEmails)

// Clear All empties the queue without judging anyone: it moves all screened mail to the inbox but
// creates no Block/Allow rule, so a mixed queue can't accidentally whitelist spam or block a real sender.
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/stores/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { createResource } from 'frappe-ui'

import { SCREENING_MAILBOX_NAME } from '@/constants'
import { SCREENER_MAILBOX_NAME } from '@/constants'

import type { UserAccount, UserResource } from '@/types'

Expand Down Expand Up @@ -66,19 +66,19 @@ export const userStore = defineStore('mail-user', () => {
})

const mailboxIds = computed(() => {
const ids: Record<MailboxRole | 'screening', string> = {
const ids: Record<MailboxRole | 'screener', string> = {
inbox: '',
sent: '',
drafts: '',
trash: '',
junk: '',
archive: '',
important: '',
screening: '',
screener: '',
}
mailboxes.data?.forEach((m: { role?: MailboxRole; _name?: string; id: string }) => {
if (m.role) ids[m.role] = m.id
else if (m._name === SCREENING_MAILBOX_NAME) ids.screening = m.id
else if (m._name === SCREENER_MAILBOX_NAME) ids.screener = m.id
Comment thread
krantheman marked this conversation as resolved.
})
return ids
})
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as cheerio from 'cheerio'
import { File, Paperclip } from 'lucide-vue-next'
import { toast } from 'frappe-ui'

import { FOLDER_ICON_MAP, SCREENING_MAILBOX_NAME } from '@/constants'
import { FOLDER_ICON_MAP, SCREENER_MAILBOX_NAME } from '@/constants'
import dayjs from '@/utils/dayjs'
import AudioIcon from '@/components/Icons/AudioIcon.vue'
import ImageIcon from '@/components/Icons/ImageIcon.vue'
Expand Down Expand Up @@ -382,15 +382,17 @@ export const hasHtmlContent = (content: string | null | undefined): boolean => {
}

export const getIcon = (mailbox: MailboxData) => {
// The Screener is a system folder: its 'eye' icon is authoritative and can't be overridden by a
// stray Mailbox Settings icon (it must never render as a generic folder).
if (mailbox._name === SCREENER_MAILBOX_NAME) return 'eye'
if (mailbox.icon) return mailbox.icon
if (mailbox.role && mailbox.role in FOLDER_ICON_MAP) return FOLDER_ICON_MAP[mailbox.role]
if (mailbox._name === SCREENING_MAILBOX_NAME) return 'eye'
return 'folder'
}

// The Screening folder is surfaced to users as the "Screener".
export const getMailboxName = (mailbox: MailboxData) =>
mailbox._name === SCREENING_MAILBOX_NAME ? __('Screener') : mailbox._name
mailbox._name === SCREENER_MAILBOX_NAME ? __('Screener') : mailbox._name

export const downloadUrlAsFile = (url: string, filename: string) => {
const link = document.createElement('a')
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/utils/useThreadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,15 @@ export function useThreadActions(deps: {

const moveToOptions = computed(() =>
mailboxes.data
?.filter((m) => ![mailbox.value, mailboxIds.sent, mailboxIds.drafts].includes(m.id))
?.filter(
(m) =>
![
mailbox.value,
mailboxIds.sent,
mailboxIds.drafts,
mailboxIds.screener,
].includes(m.id),
)
.map((m) => ({
label: getMailboxName(m),
icon: h(Icon, { name: getIcon(m), class: FOLDER_ICON_COLOR_MAP[m.color] }),
Expand Down Expand Up @@ -176,7 +184,11 @@ export function useThreadActions(deps: {

const addToOptions = computed(() =>
mailboxes.data
?.filter((m) => !m.role || ['inbox', 'archive'].includes(m.role))
?.filter(
(m) =>
(!m.role || ['inbox', 'archive'].includes(m.role)) &&
m.id !== mailboxIds.screener,
)
.filter((m) => {
const selected = threadsResource.value.data?.filter((t: Thread) =>
selections.value.includes(t.thread_id),
Expand Down
10 changes: 5 additions & 5 deletions mail/api/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from mail.api.contacts import create_contacts_if_not_exists
from mail.api.sieve import (
SCREENING_MAILBOX_NAME,
SCREENER_MAILBOX_NAME,
build_automation_sieve,
pause_automation_sieve_build,
)
Expand Down Expand Up @@ -1054,9 +1054,9 @@ def unscreen_email_addresses(account_id: str, emails: list[str]) -> None:
def _screening_message_ids(account: str, from_email: str | None = None) -> list[str]:
"""Return ids of Screening-folder messages, optionally only those from a given sender."""

screening_id = get_mailbox_id_by_name(*parse_account(account), SCREENING_MAILBOX_NAME)
screening_id = get_mailbox_id_by_name(*parse_account(account), SCREENER_MAILBOX_NAME)
if not screening_id:
add_mailbox(account, SCREENING_MAILBOX_NAME)
add_mailbox(account, SCREENER_MAILBOX_NAME)
return []

conditions = [{"inMailbox": screening_id}]
Expand All @@ -1081,9 +1081,9 @@ def get_screening_senders(account_id: str) -> list[dict]:

has_permission_for_user(parse_account(account)[0])

screening_id = get_mailbox_id_by_name(*parse_account(account), SCREENING_MAILBOX_NAME)
screening_id = get_mailbox_id_by_name(*parse_account(account), SCREENER_MAILBOX_NAME)
if not screening_id:
add_mailbox(account, SCREENING_MAILBOX_NAME)
add_mailbox(account, SCREENER_MAILBOX_NAME)
return []

messages, _total = search_messages(
Expand Down
Loading
Loading