()
+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
@@ -51,39 +100,36 @@ const handleMessage = (event: MessageEvent) => {
onMounted(() => window.addEventListener('message', handleMessage))
onUnmounted(() => window.removeEventListener('message', handleMessage))
-const srcdoc = computed(() => {
- const collapseButton = `
-
- ···
-
- `
- const transformedContent = DOMPurify.sanitize(content, DOMPURIFY_CONFIG)
- .replace(
- /
]*)\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}
${innerHtml}
`
- }
- 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
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, '
<$1> ')
+ 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,
+ '
<$1> ',
+ )
/* eslint-disable no-useless-escape */
return `
@@ -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;
diff --git a/frontend/src/components/MailActions.vue b/frontend/src/components/MailActions.vue
index 35fd859c0..c818d9624 100644
--- a/frontend/src/components/MailActions.vue
+++ b/frontend/src/components/MailActions.vue
@@ -46,7 +46,7 @@ import { downloadUrlAsFile, raisePromiseToast, raiseToast } from '@/utils'
import { useBlockSender, useScreenSize, useUndo } from '@/utils/composables'
import { userStore } from '@/stores/user'
-import type { ComposeMailData, Identity, Mail } from '@/types'
+import type { ComposeMailData, Identity, Mail, ScreenedAddress } from '@/types'
const {
mailbox,
@@ -79,11 +79,17 @@ const emit = defineEmits(['setFlagged', 'syncUnseen'])
const { isMobile } = useScreenSize()
const route = useRoute()
const router = useRouter()
-const { accountId, mailboxes, mailboxIds, identities, blockedAddresses } = userStore()
+const { accountId, mailboxes, mailboxIds, identities, screenedAddresses } = userStore()
const { setUndoAction, undo } = useUndo()
const { promptBlockSenders, willJunkSenders } = useBlockSender()
const user = inject('$user')
+// A sender is "blocked" when screened with the Reject action (their mail is discarded).
+const isSenderBlocked = (email: string) =>
+ screenedAddresses.data?.some(
+ (a: ScreenedAddress) => a.email === email && a.action === 'Reject',
+ )
+
const primaryActions = (mail: Mail): MailAction[] => [
{
label: __('Unstar'),
@@ -193,19 +199,33 @@ const moreActions = (mail: Mail): GroupedAction[] => [
icon: MailOpen,
condition: () => !mail.draft,
},
+ {
+ label: __('Accept Sender'),
+ onClick: () => handleScreenSender('Accepted'),
+ icon: CircleCheck,
+ condition: () => mailbox === mailboxIds.screening,
+ },
+ {
+ label: __('Reject Sender'),
+ onClick: () => handleScreenSender('Reject'),
+ icon: Ban,
+ condition: () => mailbox === mailboxIds.screening,
+ },
{
label: __('Block Sender'),
onClick: () => handleBlockAddress(true),
icon: Ban,
condition: () =>
+ mailbox !== mailboxIds.screening &&
!identities.data.some((i: Identity) => i.email === mail.from_email) &&
- !blockedAddresses.data?.includes(mail.from_email),
+ !isSenderBlocked(mail.from_email),
},
{
label: __('Unblock Sender'),
onClick: () => handleBlockAddress(false),
icon: LockOpen,
- condition: () => blockedAddresses.data?.includes(mail.from_email),
+ condition: () =>
+ mailbox !== mailboxIds.screening && isSenderBlocked(mail.from_email),
},
],
},
@@ -347,13 +367,40 @@ const handleMarkUnreadFromHere = () => {
if (ids.length) setMailsSeen.submit({ ids })
}
+// Screening-folder decisions: accept the sender (let future mail in, move this one to Inbox) or
+// reject them (discard future mail, move this one to Trash). The sieve regenerates from the list.
+const screenSender = createResource({
+ url: 'mail.api.mail.screen_email_address',
+ makeParams: ({ action }: { action: string }) => ({
+ account_id: accountId,
+ email: mail.from_email,
+ action,
+ }),
+})
+
+const handleScreenSender = (action: 'Accepted' | 'Reject') => {
+ const accepted = action === 'Accepted'
+ const target = accepted ? mailboxIds.inbox : mailboxIds.trash
+ const run = async () => {
+ await screenSender.submit({ action })
+ screenedAddresses.reload()
+ await moveMail.submit(target)
+ reloadMails()
+ }
+ raisePromiseToast(
+ run,
+ accepted ? __('Accepting sender...') : __('Rejecting sender...'),
+ accepted ? __('Sender accepted.') : __('Sender rejected.'),
+ )
+}
+
const blockEmailAddress = createResource({
- url: 'mail.api.mail.block_email_address',
- makeParams: () => ({ account_id: accountId, email: mail.from_email }),
+ url: 'mail.api.mail.screen_email_address',
+ makeParams: () => ({ account_id: accountId, email: mail.from_email, action: 'Reject' }),
})
const unblockEmailAddress = createResource({
- url: 'mail.api.mail.unblock_email_addresses',
+ url: 'mail.api.mail.unscreen_email_addresses',
makeParams: () => ({ account_id: accountId, emails: [mail.from_email] }),
})
@@ -361,7 +408,7 @@ const handleBlockAddress = (block: boolean, isUndo = false) => {
const action = () =>
(block ? blockEmailAddress : unblockEmailAddress)
.submit()
- .then(() => blockedAddresses.reload())
+ .then(() => screenedAddresses.reload())
const successMessage = block ? __('Sender blocked.') : __('Sender unblocked.')
if (isUndo) return raisePromiseToast(action, __('Undoing...'), successMessage)
diff --git a/frontend/src/components/MailDetailsPopover.vue b/frontend/src/components/MailDetailsPopover.vue
index 32c0caea0..2a77ed0a4 100644
--- a/frontend/src/components/MailDetailsPopover.vue
+++ b/frontend/src/components/MailDetailsPopover.vue
@@ -2,7 +2,7 @@
diff --git a/frontend/src/components/MailThread.vue b/frontend/src/components/MailThread.vue
index 57aaf2cfe..b30a4173b 100644
--- a/frontend/src/components/MailThread.vue
+++ b/frontend/src/components/MailThread.vue
@@ -1,6 +1,7 @@
-
-
- {{ thread[0].subject || __('[No subject]') }}
-
-
+
+
+
+
+ {{ thread[0].subject || __('[No subject]') }}
+
+
-
-
-
-
+
+
-
-
+
-
-
-
popOutDraft(mailDetails)
- "
+ v-if="shouldShowUnseenMarker(mail.id)"
+ class="!text-ink-blue-2 [&_.border-t]:border-[var(--outline-blue-1)] [&_span:not(.border-t)]:border-[var(--outline-blue-1)]"
+ :message="unseenMessage"
/>
-
-
-
-
+
+
+
+
popOutDraft(mailDetails)
+ "
+ />
+
+
+
+
+
+
+
+
+ emit('setFlagged', [id], flagged)
+ "
+ @sync-unseen="handleSyncUnseen"
/>
-
-
- emit('setFlagged', [id], flagged)
- "
- @sync-unseen="handleSyncUnseen"
- />
-
-
-
-
-
-
-
- {{ mail.from_name || mail.from_email }}
-
-
- {{ `<${mail.from_email}>` }}
-
-
-
-
-
+
+
+
+
+
+
+ {{ mail.from_name || mail.from_email }}
+
+
+ {{ mail.from_email }}
+
+
+
+
+
+
+
+ {{ getFormattedRecipients(mail.recipients) }}
+
-
- {{ getFormattedRecipients(mail.recipients) }}
+
+
+
+ emit('setFlagged', [id], flagged)
+ "
+ @sync-unseen="handleSyncUnseen"
+ />
-
-
-
- emit('setFlagged', [id], flagged)
- "
- @sync-unseen="handleSyncUnseen"
- />
-
-
-
+
-
- {{ mail.preview }}
-
+
+ {{ mail.preview }}
+
-
-
-
-
- {{
- __('{0} is currently on your', [
- mail.from_name || mail.from_email,
- ])
- }}
+
+
+
+
+ {{
+ __('{0} is currently on your', [
+ mail.from_name || mail.from_email,
+ ])
+ }}
+
+ {{ __('block list') }} {{
+ __(
+ ". You won't receive new messages from this source until you unblock them.",
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ __('{0} attachments', [
+ String(
+ filteredAttachments(mail).length,
+ ),
+ ])
+ }}
+
+
·
- {{ __('block list') }} {{
- __(
- ". You won't receive new messages from this source until you unblock them.",
- )
- }}
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
- {{
- __('{0} attachments', [
- String(filteredAttachments(mail).length),
- ])
- }}
-
-
-
-
-
-
-
+
+
+
-
-
-
+
()
const emit = defineEmits([
@@ -433,9 +463,30 @@ const { openSettings } = useSettings()
const dayjs = inject('$dayjs')
const user = inject('$user')
const store = userStore()
-const { mailboxes, mailboxIds, identities, blockedAddresses } = store
+const { mailboxes, mailboxIds, identities, screenedAddresses } = store
const { dataTheme } = useTheme()
+// A sender is "blocked" when screened with the Reject action (their mail is discarded).
+const isSenderBlocked = (email: string) =>
+ screenedAddresses.data?.some(
+ (a: ScreenedAddress) => a.email === email && a.action === 'Reject',
+ )
+
+// Trusted senders — you, or anyone you've accepted — load images normally. For everyone else, the
+// account's "Block Remote Images" setting withholds remote images (read-tracking pixels) until you opt in.
+const blockRemoteImagesEnabled = computed(
+ () =>
+ store.userResource?.data?.accounts?.find((a) => a.id === store.accountId)
+ ?.block_remote_images ?? true,
+)
+const isScreenedIn = (email: string) =>
+ !!identities.data?.some((i: Identity) => i.email === email) ||
+ !!screenedAddresses.data?.some(
+ (a: ScreenedAddress) => a.email === email && a.action === 'Accepted',
+ )
+const shouldBlockImages = (mail: { from_email: string }) =>
+ blockRemoteImagesEnabled.value && !isScreenedIn(mail.from_email)
+
const route = useRoute()
const router = useRouter()
@@ -497,6 +548,10 @@ const goToMailbox = () => router.push({ name: 'Mailbox', params: { mailbox }, qu
// directly via `get_thread`.
const thread = ref([])
+// A thread is selected but its messages aren't ready yet (still resolving from the list prop or being
+// fetched directly) — show the skeleton instead of a blank pane.
+const isLoading = computed(() => !!threadID && !thread.value.length)
+
const threadFallback = createResource({
url: 'mail.api.mail.get_thread',
makeParams: () => ({ account_id: store.accountId, thread_id: threadID }),
@@ -514,7 +569,8 @@ const threadFallback = createResource({
const transformThreadMails = (mails: Mail[]) =>
mails
- .filter((mail) => filterRelevantMails(mail))
+ // Read-only views (the Screener) pass an explicit message list that isn't scoped to a mailbox.
+ .filter((mail) => readonly || filterRelevantMails(mail))
.map((mail) => ({
...mail,
groupedRecipients: getGroupedRecipients(mail.recipients, false),
@@ -559,8 +615,8 @@ const loadThread = () => {
})
// Opening a thread marks every message in the whole conversation read — including copies in other
- // mailboxes (e.g. Sent) that aren't shown in this view.
- if (source.some((mail) => !mail.seen)) setThreadSeen(true)
+ // mailboxes (e.g. Sent) that aren't shown in this view. Read-only views (the Screener) never do this.
+ if (!readonly && source.some((mail) => !mail.seen)) setThreadSeen(true)
}
// Mark the whole conversation seen/unseen — every message across all mailboxes, not just the ones
@@ -686,11 +742,25 @@ watch(
onMounted(() => loadThread())
const unblockEmailAddress = createResource({
- url: 'mail.api.mail.unblock_email_addresses',
+ url: 'mail.api.mail.unscreen_email_addresses',
makeParams: (email) => ({ account_id: store.accountId, emails: [email] }),
onSuccess: () => {
raiseToast(__('Sender unblocked.'))
- blockedAddresses.reload()
+ screenedAddresses.reload()
+ },
+})
+
+// Trusting a sender accepts them (screened in), so their remote images load now and going forward.
+const trustSender = createResource({
+ url: 'mail.api.mail.screen_email_addresses',
+ makeParams: (email: string) => ({
+ account_id: store.accountId,
+ emails: [email],
+ action: 'Accepted',
+ }),
+ onSuccess: () => {
+ raiseToast(__('Sender marked as trusted.'))
+ screenedAddresses.reload()
},
})
@@ -837,7 +907,8 @@ const discardLocalDraft = (mail: string) => {
// Shortcuts
const handleKeydown = (e: KeyboardEvent) => {
- if (shouldIgnoreKeypress(e)) return
+ // Read-only views (the Screener) expose no reply/forward, so their shortcuts are inert too.
+ if (readonly || shouldIgnoreKeypress(e)) return
const key = e.key.toLowerCase()
const lastMail = thread.value?.at(-1)
diff --git a/frontend/src/components/MailThreadSkeleton.vue b/frontend/src/components/MailThreadSkeleton.vue
new file mode 100644
index 000000000..c1eaea2f6
--- /dev/null
+++ b/frontend/src/components/MailThreadSkeleton.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
diff --git a/frontend/src/components/Modals/FolderModal.vue b/frontend/src/components/Modals/FolderModal.vue
index b4c4f1ee9..6d20abc86 100644
--- a/frontend/src/components/Modals/FolderModal.vue
+++ b/frontend/src/components/Modals/FolderModal.vue
@@ -286,9 +286,17 @@ watch(show, (val) => {
folder.disable_push_notification = original.disable_push_notification =
isNotificationsDisabled.value ? true : !!mailbox.disable_push_notification
- if (parsedAutomationRules.value) {
- Object.assign(automationRules, parsedAutomationRules.value)
- Object.assign(originalAutomationRules, parsedAutomationRules.value)
+ // Rules come from the Mailbox Settings backup (mailbox.automation_rules), so they survive even if
+ // the sieve script was deleted by a third-party client.
+ if (mailbox.automation_rules) {
+ Object.assign(automationRules, {
+ ...DEFAULT_AUTOMATION_RULES,
+ ...mailbox.automation_rules,
+ })
+ Object.assign(originalAutomationRules, {
+ ...DEFAULT_AUTOMATION_RULES,
+ ...mailbox.automation_rules,
+ })
}
})
@@ -312,53 +320,6 @@ const DEFAULT_AUTOMATION_RULES = {
const automationRules = reactive({ ...DEFAULT_AUTOMATION_RULES })
const originalAutomationRules = reactive({ ...DEFAULT_AUTOMATION_RULES })
-const parsedAutomationRules = computed(() => {
- if (isNew.value || !automationScript.value || !original.name) return null
-
- const content = automationScript.value.content
-
- const commentPattern = `# Mailbox: ${original.name}\n`
- const commentIndex = content.indexOf(commentPattern)
-
- if (commentIndex === -1) return null
-
- // Extract the block (from comment to closing brace)
- const blockStart = commentIndex
- const blockEnd = content.indexOf('\n}', blockStart)
-
- if (blockEnd === -1) return null
-
- const block = content.substring(blockStart, blockEnd + 2)
-
- const rules = { ...DEFAULT_AUTOMATION_RULES }
-
- // Parse emails_from
- const emailsMatch = block.match(/address :matches "from" \[(.*?)\]/)
- if (emailsMatch)
- rules.emails_from = emailsMatch[1]
- .split(',')
- .map((email: string) => email.trim().replace(/"/g, ''))
- .join(', ')
-
- // Parse subject_contains
- const subjectMatch = block.match(/header :contains "subject" \[(.*?)\]/)
- if (subjectMatch)
- rules.subject_contains = subjectMatch[1]
- .split(',')
- .map((keyword: string) => keyword.trim().replace(/"/g, ''))
- .join(', ')
-
- // Parse match_if (anyof vs allof)
- if (block.includes('allof')) rules.match_if = 'all'
- else if (block.includes('anyof')) rules.match_if = 'any'
-
- // Parse flags
- rules.mark_as_read = block.includes('addflag "\\\\Seen"')
- rules.add_star = block.includes('addflag "\\\\Flagged"')
-
- return rules
-})
-
const isDefaultAutomation = computed(
() => JSON.stringify(automationRules) === JSON.stringify(DEFAULT_AUTOMATION_RULES),
)
diff --git a/frontend/src/components/Modals/SettingsModal.vue b/frontend/src/components/Modals/SettingsModal.vue
index 439de4715..96b8be394 100644
--- a/frontend/src/components/Modals/SettingsModal.vue
+++ b/frontend/src/components/Modals/SettingsModal.vue
@@ -123,7 +123,7 @@ const tabs = computed(() => {
condition: user.data.is_jmap_configure,
},
{
- label: __('Block List'),
+ label: __('Screened Senders'),
icon: Ban,
component: markRaw(BlockListSettings),
condition: user.data.is_jmap_configured,
diff --git a/frontend/src/components/SendMail.vue b/frontend/src/components/SendMail.vue
index 1afafb48e..d868497fd 100644
--- a/frontend/src/components/SendMail.vue
+++ b/frontend/src/components/SendMail.vue
@@ -2,7 +2,7 @@
emit('reloadMails')"
@send-mail="editor?.sendMail()"
@discard-mail="editor?.discardMail()"
diff --git a/frontend/src/components/Settings/AccountSettings.vue b/frontend/src/components/Settings/AccountSettings.vue
index 45d9ad616..6b1c05b32 100644
--- a/frontend/src/components/Settings/AccountSettings.vue
+++ b/frontend/src/components/Settings/AccountSettings.vue
@@ -33,6 +33,22 @@
/>
{{ __('Incoming') }}
+
+
+
+
diff --git a/frontend/src/components/Settings/AutomationSettings.vue b/frontend/src/components/Settings/AutomationSettings.vue
index 8bc689647..d0d927c7a 100644
--- a/frontend/src/components/Settings/AutomationSettings.vue
+++ b/frontend/src/components/Settings/AutomationSettings.vue
@@ -1,7 +1,18 @@
{{ __('Sieve Scripts') }}
-
+
+
+
+
@@ -59,8 +70,9 @@
diff --git a/frontend/src/components/ThreadHeader.vue b/frontend/src/components/ThreadHeader.vue
index 4401477b9..31a981cf3 100644
--- a/frontend/src/components/ThreadHeader.vue
+++ b/frontend/src/components/ThreadHeader.vue
@@ -1,12 +1,12 @@
-
+
-
+
@@ -109,7 +109,7 @@ import {
import { Button, Dropdown, Tooltip } from 'frappe-ui'
import { FOLDER_ICON_COLOR_MAP } from '@/constants'
-import { getIcon } from '@/utils'
+import { getIcon, getMailboxName } from '@/utils'
import { useScreenSize } from '@/utils/composables'
import { userStore } from '@/stores/user'
@@ -270,7 +270,7 @@ const getMailboxOption = (
mailbox: MailboxData,
emitName: 'moveThread' | 'addThreadToMailbox' | 'removeThreadFromMailbox',
) => ({
- label: mailbox._name,
+ label: getMailboxName(mailbox),
icon: h(Icon, { name: getIcon(mailbox), class: FOLDER_ICON_COLOR_MAP[mailbox.color!] }),
onClick: () => emit(emitName, mailbox.id),
})
diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts
index aa1a1aa4f..7b0ca2512 100644
--- a/frontend/src/constants.ts
+++ b/frontend/src/constants.ts
@@ -1,3 +1,7 @@
+// The Screening mailbox is a plain named folder (no JMAP role), created server-side as "Screening";
+// it's surfaced to users as the "Screener".
+export const SCREENING_MAILBOX_NAME = 'Screener'
+
export const getAttachmentOptions = () => [
{ label: __('All'), value: ' ' },
{ label: __('With Attachments'), value: 'true' },
diff --git a/frontend/src/pages/MailboxView.vue b/frontend/src/pages/MailboxView.vue
index f291e1ad6..c80810efa 100644
--- a/frontend/src/pages/MailboxView.vue
+++ b/frontend/src/pages/MailboxView.vue
@@ -14,6 +14,14 @@
+
+
+
+ {{ screenerBannerLabel }}
+
+
+
{
isShiftPressed.value = e.shiftKey
const key = e.key.toLowerCase()
- // Handle Ctrl/Cmd+Shift+L (Cycle Theme)
- if ((e.metaKey || e.ctrlKey) && e.shiftKey && key === 'l' && !shouldIgnoreKeypress(e, true)) {
- e.preventDefault()
- isGPressed.value = false
- return cycleTheme()
- }
-
// Handle Ctrl/Cmd+A (Select All)
if ((e.metaKey || e.ctrlKey) && key === 'a' && !shouldIgnoreKeypress(e, true)) {
e.preventDefault()
@@ -628,28 +629,6 @@ const handleShowShortcuts = (e: KeyboardEvent) => {
showShortcuts.value = true
}
-const COLOR_SCHEME_CYCLE = ['System Default', 'Light Mode', 'Dark Mode'] as const
-
-const cycleTheme = () => {
- const current = user.data.color_scheme
- const idx = COLOR_SCHEME_CYCLE.indexOf(current as COLOR_SCHEME)
- const next = COLOR_SCHEME_CYCLE[(idx + 1) % COLOR_SCHEME_CYCLE.length]
- updateColorScheme.submit(next)
-}
-
-const updateColorScheme = createResource({
- url: 'frappe.client.set_value',
- makeParams: (color_scheme: COLOR_SCHEME) => ({
- doctype: 'User Settings',
- name: user.data.user_settings,
- fieldname: { color_scheme },
- }),
- onSuccess: (data) => {
- raiseToast(__('Color scheme updated to {0}.', [data.color_scheme]))
- user.reload()
- },
-})
-
const handleEnter = (e: KeyboardEvent) => {
e.preventDefault()
if (threadInFocus.value) goToThread(threadInFocus.value)
@@ -842,6 +821,31 @@ const hasMore = ref(false) // lookahead: an extra row was returned, so a next pa
// detect count changes and by the tab title's unread badge.
const mailboxObj = computed(() => mailboxes.data?.find((m) => m.id === mailbox))
+// ── Screener banner ─────────────────────────────────────────────────────────────────────────────
+// An info bar mirroring the trash/junk one, shown on the inbox while Hey-style screening is on and
+// unscreened threads are waiting. The count is the Screening folder's unread count, kept fresh by the
+// periodic mailbox poll below.
+const activeAccount = computed(() => user.data?.accounts?.find((a) => a.id === accountId))
+const screeningEnabled = computed(() => !!activeAccount.value?.enable_screening)
+const screenerCount = computed(
+ () =>
+ mailboxes.data?.find((m: MailboxData) => m.id === mailboxIds.screening)?.unread_threads ??
+ 0,
+)
+const showScreenerBanner = computed(
+ () =>
+ mailbox === mailboxIds.inbox &&
+ screeningEnabled.value &&
+ screenerCount.value > 0 &&
+ (showReadingPane.value || !threadID),
+)
+const screenerBannerLabel = computed(() =>
+ screenerCount.value === 1
+ ? __('1 new thread is waiting to be screened.')
+ : __('{0} new threads are waiting to be screened.', [String(screenerCount.value)]),
+)
+const goToScreener = () => router.push({ name: 'Screener', params: { accountId } })
+
// Called once a page's data has loaded: reveal it (range + list update together) and scroll to top.
// No-op for same-page reloads (e.g. the periodic refresh) so those don't yank the scroll position.
const syncDisplayedPage = () => {
@@ -1072,6 +1076,8 @@ onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
if (reloadInterval.value) clearInterval(reloadInterval.value)
+ // Leaving the mailbox drops any pending undo so a lingering toast can't undo into another view.
+ setUndoAction(undefined)
})
const goToMailbox = () =>
diff --git a/frontend/src/pages/ScreenerView.vue b/frontend/src/pages/ScreenerView.vue
new file mode 100644
index 000000000..bd21bbdd3
--- /dev/null
+++ b/frontend/src/pages/ScreenerView.vue
@@ -0,0 +1,426 @@
+
+
+
+
+
+
+
+
+
+ {{ __('Loading...') }}
+
+
+
+
+
+
+
{{ __('You have no new senders to screen.') }}
+
+
+
+
+
+
+
+
+ {{ waitingLabel }}
+
+
+
+
+
+
+
+ {{ sender.from_name || sender.from_email }}
+
+
+ {{ sender.from_email }}
+
+
+
+ {{ sender.subject || __('[No subject]') }}
+
+
+ {{ sender.preview }}
+
+ {{ sender.preview ? ' · ' : ''
+ }}{{ __('{0} messages', [String(sender.count)]) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ openSender.subject || __('[No subject]') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __('Select a sender to view their emails.') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/router.ts b/frontend/src/router.ts
index 18a34151e..66d7b14b3 100644
--- a/frontend/src/router.ts
+++ b/frontend/src/router.ts
@@ -52,6 +52,12 @@ const routes = [
component: () => import('@/pages/MailboxView.vue'),
props: true,
},
+ {
+ path: '/account/:accountId/screener',
+ name: 'Screener',
+ component: () => import('@/pages/ScreenerView.vue'),
+ props: true,
+ },
{
path: '/account/:accountId/address-books/',
name: 'AddressBooks',
diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts
index dae7eed9c..3a8fa6361 100644
--- a/frontend/src/stores/user.ts
+++ b/frontend/src/stores/user.ts
@@ -2,6 +2,8 @@ import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { createResource } from 'frappe-ui'
+import { SCREENING_MAILBOX_NAME } from '@/constants'
+
import type { UserAccount, UserResource } from '@/types'
export type MailboxRole = 'inbox' | 'sent' | 'drafts' | 'trash' | 'junk' | 'archive' | 'important'
@@ -39,7 +41,7 @@ export const userStore = defineStore('mail-user', () => {
mailboxes.fetch()
addressBooks.fetch()
identities.fetch()
- blockedAddresses.fetch()
+ screenedAddresses.fetch()
sieveScripts.fetch()
}
@@ -64,7 +66,7 @@ export const userStore = defineStore('mail-user', () => {
})
const mailboxIds = computed(() => {
- const ids: Record
= {
+ const ids: Record = {
inbox: '',
sent: '',
drafts: '',
@@ -72,9 +74,11 @@ export const userStore = defineStore('mail-user', () => {
junk: '',
archive: '',
important: '',
+ screening: '',
}
- mailboxes.data?.forEach((m: { role?: MailboxRole; id: string }) => {
+ 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
})
return ids
})
@@ -91,10 +95,12 @@ export const userStore = defineStore('mail-user', () => {
cache: ['identities', accountId.value],
})
- const blockedAddresses = createResource({
- url: 'mail.api.mail.get_blocked_addresses',
+ // Screened senders for the account: each is `{ email, action }` where action is 'Reject'
+ // (discard incoming mail), 'Spam' (file it into the Spam folder), or 'Accepted'.
+ const screenedAddresses = createResource({
+ url: 'mail.api.mail.get_screened_addresses',
makeParams: () => ({ account_id: accountId.value }),
- cache: ['blockedAddresses', accountId.value],
+ cache: ['screenedAddresses', accountId.value],
})
const sieveScripts = createResource({
@@ -115,7 +121,7 @@ export const userStore = defineStore('mail-user', () => {
mailboxes.reset()
addressBooks.reset()
identities.reset()
- blockedAddresses.reset()
+ screenedAddresses.reset()
sieveScripts.reset()
domains.reset()
}
@@ -131,7 +137,7 @@ export const userStore = defineStore('mail-user', () => {
identities,
domains,
sieveScripts,
- blockedAddresses,
+ screenedAddresses,
reset,
}
})
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index c96923227..3deae587c 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -7,6 +7,26 @@ export type COLOR_SCHEME = 'System Default' | 'Light Mode' | 'Dark Mode'
// What happens to a sender when one of their messages is marked as Junk (Account Settings).
export type OnMarkAsJunk = "Junk Sender's Mail" | 'Ask to Block Sender'
+// A screened sender: how their future mail is handled. 'Reject' discards it silently; 'Spam' files
+// it into the Spam (Junk) folder; 'Accepted' lets it reach the inbox. (Doctype: Screened Email Address.)
+export type ScreeningAction = 'Reject' | 'Spam' | 'Accepted'
+
+export interface ScreenedAddress {
+ email: string
+ action: ScreeningAction
+}
+
+// A row in the Screener: one unique sender in the Screening folder, summarised by their latest mail.
+export interface ScreeningSender {
+ from_email: string
+ from_name: string
+ subject: string
+ preview: string
+ received_at: number
+ count: number
+ unread: number
+}
+
export interface User {
name: string
email: string
@@ -33,6 +53,8 @@ export interface User {
default_outgoing_email?: string
account_settings?: string
on_mark_as_junk?: OnMarkAsJunk
+ enable_screening?: boolean
+ block_remote_images?: boolean
})[]
}
@@ -150,6 +172,15 @@ export interface MailboxData {
icon?: string
color?: 'Blue' | 'Green' | 'Amber' | 'Red' | 'Purple'
disable_push_notification?: 0 | 1
+ automation_rules?: AutomationRules | null
+}
+
+export interface AutomationRules {
+ emails_from: string
+ subject_contains: string
+ match_if: 'any' | 'all'
+ mark_as_read: boolean
+ add_star: boolean
}
export interface NotificationPayload {
diff --git a/frontend/src/utils/composables.ts b/frontend/src/utils/composables.ts
index 820b00191..ee2a2e548 100644
--- a/frontend/src/utils/composables.ts
+++ b/frontend/src/utils/composables.ts
@@ -1,10 +1,10 @@
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { createResource } from 'frappe-ui'
-import { raisePromiseToast } from '@/utils'
+import { raisePromiseToast, raiseToast } from '@/utils'
import { userStore } from '@/stores/user'
-import type { Identity } from '@/types'
+import type { COLOR_SCHEME, Identity, ScreenedAddress } from '@/types'
export const useScreenSize = () => {
const size = reactive({ width: window.innerWidth, height: window.innerHeight })
@@ -116,8 +116,8 @@ const sendersToBlock = ref([])
export const useBlockSender = () => {
const store = userStore()
- const { userResource, identities, blockedAddresses } = store
- const { setUndoAction, undo, prependUndoAction } = useUndo()
+ const { userResource, identities, screenedAddresses } = store
+ const { setUndoAction, undo } = useUndo()
// Read account/accountId off the store at call time — destructuring would snapshot the unwrapped
// values and miss account switches while a component stays mounted.
@@ -125,11 +125,15 @@ export const useBlockSender = () => {
userResource.data?.accounts?.find((a) => a.id === store.accountId),
)
- // Senders worth offering to block: drop the user's own identities and addresses already blocked,
- // and de-duplicate by email (keeping the first occurrence's display name).
+ // Senders worth offering to block: drop the user's own identities and addresses already blocked
+ // (screened as Reject), and de-duplicate by email (keeping the first occurrence's display name).
const blockableSenders = (senders: { name?: string; email?: string }[]) => {
const own = new Set((identities.data ?? []).map((i: Identity) => i.email))
- const blocked = new Set(blockedAddresses.data ?? [])
+ const blocked = new Set(
+ (screenedAddresses.data ?? [])
+ .filter((a: ScreenedAddress) => a.action === 'Reject')
+ .map((a: ScreenedAddress) => a.email),
+ )
const seen = new Set()
const result: BlockableSender[] = []
for (const { name, email } of senders) {
@@ -141,37 +145,22 @@ export const useBlockSender = () => {
}
const blockResource = createResource({
- url: 'mail.api.mail.block_email_addresses',
+ url: 'mail.api.mail.screen_email_addresses',
makeParams: ({ emails }: { emails: string[] }) => ({
account_id: store.accountId,
emails,
+ action: 'Reject',
}),
- onSuccess: () => blockedAddresses.reload(),
+ onSuccess: () => screenedAddresses.reload(),
})
- const junkResource = createResource({
- url: 'mail.api.mail.junk_senders',
+ const unscreenResource = createResource({
+ url: 'mail.api.mail.unscreen_email_addresses',
makeParams: ({ emails }: { emails: string[] }) => ({
account_id: store.accountId,
emails,
}),
- })
-
- const unjunkResource = createResource({
- url: 'mail.api.mail.unjunk_senders',
- makeParams: ({ emails }: { emails: string[] }) => ({
- account_id: store.accountId,
- emails,
- }),
- })
-
- const unblockResource = createResource({
- url: 'mail.api.mail.unblock_email_addresses',
- makeParams: ({ emails }: { emails: string[] }) => ({
- account_id: store.accountId,
- emails,
- }),
- onSuccess: () => blockedAddresses.reload(),
+ onSuccess: () => screenedAddresses.reload(),
})
// Block the senders chosen in the prompt ('Ask to Block Sender' confirm). Blocking becomes the new
@@ -181,7 +170,7 @@ export const useBlockSender = () => {
if (!emails.length) return
setUndoAction(() => {
- const undoAction = () => unblockResource.submit({ emails })
+ const undoAction = () => unscreenResource.submit({ emails })
const restored =
emails.length === 1 ? __('Sender unblocked.') : __('Senders unblocked.')
raisePromiseToast(undoAction, __('Undoing...'), restored)
@@ -192,35 +181,22 @@ export const useBlockSender = () => {
raisePromiseToast(action, __('Blocking...'), success, undo)
}
- // File the senders' future mail into Junk (the default 'Junk Sender's Mail'). Side effect only — the
- // caller renders the toast (see willJunkSenders) so the junk action shows a single toast, not two.
- // Undoing the junk move also drops them from the junk list (composed onto that move's undo).
- const junkSenders = (senders: BlockableSender[]) => {
- const emails = senders.map((sender) => sender.email)
- if (!emails.length) return
-
- junkResource.submit({ emails })
- prependUndoAction(() => unjunkResource.submit({ emails }))
- }
-
// Whether marking these senders as junk will auto-file their future mail into Junk (vs prompting
// to block, or doing nothing when there's nothing blockable). Lets the caller show one accurate toast.
const willJunkSenders = (senders: { name?: string; email?: string }[]) =>
blockableSenders(senders).length > 0 &&
activeAccount.value?.on_mark_as_junk !== 'Ask to Block Sender'
- // Apply the account's 'on mark as junk' behaviour to the senders of a just-junked message:
- // 'Ask to Block Sender' opens the prompt; otherwise silently junk their future mail.
+ // Open the block prompt for the senders of a just-junked message, in 'Ask to Block Sender' mode. The
+ // default 'Junk Sender's Mail' screening now rides on the junk API call, so it's not handled here.
const promptBlockSenders = (senders: { name?: string; email?: string }[]) => {
+ if (activeAccount.value?.on_mark_as_junk !== 'Ask to Block Sender') return
+
const list = blockableSenders(senders)
if (!list.length) return
- if (activeAccount.value?.on_mark_as_junk === 'Ask to Block Sender') {
- sendersToBlock.value = list
- showBlockSender.value = true
- return
- }
- junkSenders(list)
+ sendersToBlock.value = list
+ showBlockSender.value = true
}
return { showBlockSender, sendersToBlock, willJunkSenders, promptBlockSenders, blockSenders }
@@ -245,6 +221,8 @@ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const systemIsDark = ref(mediaQuery.matches)
mediaQuery.addEventListener('change', () => (systemIsDark.value = mediaQuery.matches))
+const COLOR_SCHEME_CYCLE = ['System Default', 'Light Mode', 'Dark Mode'] as const
+
export const useTheme = () => {
const { userResource } = userStore()
@@ -254,5 +232,26 @@ export const useTheme = () => {
return colorScheme === 'Dark Mode' ? 'dark' : 'light'
})
- return { dataTheme }
+ const updateColorScheme = createResource({
+ url: 'frappe.client.set_value',
+ makeParams: (color_scheme: COLOR_SCHEME) => ({
+ doctype: 'User Settings',
+ name: userResource.data?.user_settings,
+ fieldname: { color_scheme },
+ }),
+ onSuccess: (data: { color_scheme: COLOR_SCHEME }) => {
+ raiseToast(__('Color scheme updated to {0}.', [data.color_scheme]))
+ userResource.reload()
+ },
+ })
+
+ // Cycle System Default → Light → Dark. Bound to Cmd/Ctrl+Shift+L app-wide (see App.vue).
+ const cycleTheme = () => {
+ const current = userResource.data?.color_scheme
+ const idx = COLOR_SCHEME_CYCLE.indexOf(current as COLOR_SCHEME)
+ const next = COLOR_SCHEME_CYCLE[(idx + 1) % COLOR_SCHEME_CYCLE.length]
+ updateColorScheme.submit(next)
+ }
+
+ return { dataTheme, cycleTheme }
}
diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts
index 4294472ca..09f580acc 100644
--- a/frontend/src/utils/index.ts
+++ b/frontend/src/utils/index.ts
@@ -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 } from '@/constants'
+import { FOLDER_ICON_MAP, SCREENING_MAILBOX_NAME } from '@/constants'
import dayjs from '@/utils/dayjs'
import AudioIcon from '@/components/Icons/AudioIcon.vue'
import ImageIcon from '@/components/Icons/ImageIcon.vue'
@@ -243,6 +243,69 @@ export const convertHtmlToText = (html: string) => {
export const isEmail = (s: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)
+// An externally-hosted reference (http/https or protocol-relative //). cid: (inline attachments) and
+// data: URIs are part of the message, so they're never remote.
+const REMOTE_URL = /^\s*(?:https?:)?\/\//i
+const REMOTE_CSS_URL = /url\(\s*['"]?(?:https?:)?\/\//i
+
+// Inspect the HTML for externally-hosted assets via the DOM (one parse): counts remote , and flags
+// whether any remote asset is present at all — a remote , or a remote url(...) in an inline style or
+//