+
+
+ {{ messageCount }}
+
·
@@ -148,6 +156,7 @@ import { Badge, Popover, Tooltip } from 'frappe-ui'
import { getAttachmentUrl } from '@/apps/mail/resources'
import { downloadUrlAsFile, getFileIcon, getFormattedRecipients, getSenderInitial } from '@/apps/mail/utils'
+import { formatThreadParticipants, primaryParticipant } from '@/apps/mail/utils/participants'
import { userStore } from '@/apps/mail/stores/user'
import AttachmentCapsule from '@/apps/mail/components/AttachmentCapsule.vue'
import AttachmentViewer from '@/apps/mail/components/AttachmentViewer.vue'
@@ -204,21 +213,46 @@ const to = computed(() => ({
query: route.query,
}))
-const mailboxes = computed(() => mail.mailboxes.map((m) => m.mailbox_id))
-
const mailboxesToShow = computed(() => mail.mailboxes.filter((m) => m.mailbox_id !== mailbox))
const attachments = computed(
() => mail.attachments.filter((m) => m.filename && m.disposition === 'attachment') || [],
)
+// Sent and Drafts are about who the mail is going to, so those rows name the recipients. Everywhere
+// else the row names the thread's participants.
+//
+// In a mailbox it is the VIEW that decides this, not whether the thread happens to hold a sent
+// message: a thread you have answered carries a sent copy wherever it sits, so testing the row's
+// mailboxes named recipients all over Inbox and Archive. Worse, those mailboxes come from the
+// thread's message in the current mailbox while `recipients` comes from its latest message anywhere
+// — on an archived thread whose newest mail is an incoming reply, the row took "outgoing" from its
+// own sent copy and then named that reply's recipient: you.
+//
+// Search results are the exception: they are single messages with no thread behind them and no
+// participants to name, so there the message's own mailboxes still answer it — otherwise a mail you
+// sent, found in search, would go by your own name rather than by who you sent it to.
+const isOutgoing = computed(() => {
+ if (mailbox === 'search')
+ return mail.mailboxes.some(
+ (m) => m.mailbox_id === mailboxIds.sent || m.mailbox_id === mailboxIds.drafts,
+ )
+ return mailbox === mailboxIds.sent || mailbox === mailboxIds.drafts
+})
+
const header = computed(() => {
- const isOutgoing =
- mailboxes.value.includes(mailboxIds.sent) || mailboxes.value.includes(mailboxIds.drafts)
+ if (isOutgoing.value) return getFormattedRecipients(mail.recipients) || __('To:')
+ return formatThreadParticipants(mail.participants ?? []) || mail.from_name || mail.from_email
+})
+
+// Gmail-style count of how many messages the conversation holds, shown once there's more than one.
+const messageCount = computed(() => mail.messages?.length ?? 0)
- return isOutgoing
- ? getFormattedRecipients(mail.recipients) || __('To:')
- : mail.from_name || mail.from_email
+// The letter behind the avatar, for a contact with no picture: whoever the picture itself would be of.
+const avatarLabel = computed(() => {
+ const primary = primaryParticipant(mail.participants ?? [])
+ if (!primary) return getSenderInitial(mail)
+ return getSenderInitial({ from_name: primary.name, from_email: primary.email })
})
// In search results, highlight the matched query term. Escape the text first (so any markup in the
diff --git a/frontend/src/apps/mail/components/StackListItem.vue b/frontend/src/apps/mail/components/StackListItem.vue
index dc12dc3d2..7c82350b0 100644
--- a/frontend/src/apps/mail/components/StackListItem.vue
+++ b/frontend/src/apps/mail/components/StackListItem.vue
@@ -2,7 +2,7 @@
emit('setSelected', selected)"
>
- {{ latest.from_name || latest.from_email }}
+ {{ sender }}
+
-
+
+
+
+
+ {{ threads.length }}
+
+
{{ latest.subject || __('[No subject]') }}
@@ -40,10 +49,11 @@
diff --git a/frontend/src/apps/mail/types/index.ts b/frontend/src/apps/mail/types/index.ts
index d0da2e0ae..cb2a201a4 100644
--- a/frontend/src/apps/mail/types/index.ts
+++ b/frontend/src/apps/mail/types/index.ts
@@ -88,6 +88,15 @@ export interface Recipient {
display_name: string | null
}
+// Everyone who has written in a thread, in the order they first wrote (see serialize_participants).
+// `is_self` is resolved server-side against the addresses of the account the thread belongs to —
+// not always the active one, since All Inboxes merges rows from every account the user owns.
+export interface ThreadParticipant {
+ name: string
+ email: string
+ is_self: boolean
+}
+
export interface Mailbox {
mailbox: string
mailbox_id: string
@@ -169,6 +178,7 @@ export interface Thread {
thread_id: string
from_name: string
from_email: string
+ participants?: ThreadParticipant[]
subject: string | null
preview: string | null
has_attachment: 0 | 1
diff --git a/frontend/src/apps/mail/utils/participants.test.ts b/frontend/src/apps/mail/utils/participants.test.ts
new file mode 100644
index 000000000..e36cbaca9
--- /dev/null
+++ b/frontend/src/apps/mail/utils/participants.test.ts
@@ -0,0 +1,99 @@
+import { beforeAll, describe, expect, it } from 'vitest'
+
+import { formatThreadParticipants } from './participants'
+
+import type { ThreadParticipant } from '@/apps/mail/types'
+
+// `__` is installed on window by the translation plugin at app boot; the util calls it at format time,
+// so standing it up before the first test is enough.
+beforeAll(() => {
+ window.__ = (message: string) => message
+})
+
+const participant = (name: string, email: string, is_self = false): ThreadParticipant => ({
+ name,
+ email,
+ is_self,
+})
+
+describe('formatThreadParticipants', () => {
+ it('names a lone sender in full', () => {
+ expect(formatThreadParticipants([participant('Sarfaraz Shaikh', 'sarfaraz@frappe.io')])).toBe(
+ 'Sarfaraz Shaikh',
+ )
+ })
+
+ it('keeps the original sender ahead of the user who replied', () => {
+ const thread = [
+ participant('Sarfaraz Shaikh', 'sarfaraz@frappe.io'),
+ participant('Vibhav Katre', 'vibhav@frappe.io', true),
+ ]
+ expect(formatThreadParticipants(thread)).toBe('Sarfaraz, me')
+ })
+
+ it('capitalizes "me" only where it heads the row', () => {
+ const thread = [
+ participant('Vibhav Katre', 'vibhav@frappe.io', true),
+ participant('Sarfaraz Shaikh', 'sarfaraz@frappe.io'),
+ ]
+ expect(formatThreadParticipants(thread)).toBe('Me, Sarfaraz')
+ expect(formatThreadParticipants([participant('Vibhav Katre', 'vibhav@frappe.io', true)])).toBe(
+ 'Me',
+ )
+ })
+
+ it('says "me" once however many of the user\'s addresses wrote', () => {
+ const thread = [
+ participant('Sarfaraz Shaikh', 'sarfaraz@frappe.io'),
+ participant('Vibhav Katre', 'vibhav@frappe.io', true),
+ participant('Vibhav', 'vibhav@example.com', true),
+ ]
+ expect(formatThreadParticipants(thread)).toBe('Sarfaraz, me')
+ })
+
+ it('lists every name up to the limit', () => {
+ const thread = [
+ participant('Brittany Court', 'brittany@frappe.io'),
+ participant('Milind Jain', 'milind@frappe.io'),
+ participant('Vibhav Katre', 'vibhav@frappe.io', true),
+ ]
+ expect(formatThreadParticipants(thread)).toBe('Brittany, Milind, me')
+ })
+
+ it('elides as soon as the thread runs one name past the limit', () => {
+ const thread = [
+ participant('Brittany Court', 'brittany@frappe.io'),
+ participant('Milind Jain', 'milind@frappe.io'),
+ participant('Neha Sankhe', 'neha@frappe.io'),
+ participant('Vibhav Katre', 'vibhav@frappe.io', true),
+ ]
+ expect(formatThreadParticipants(thread)).toBe('Brittany … Neha, me')
+ })
+
+ it('elides the middle of a long thread, keeping its ends', () => {
+ const thread = [
+ participant('Brittany Court', 'brittany@frappe.io'),
+ participant('Milind Jain', 'milind@frappe.io'),
+ participant('Neha Sankhe', 'neha@frappe.io'),
+ participant('Courtney Diaz', 'courtney@frappe.io'),
+ participant('Vibhav Katre', 'vibhav@frappe.io', true),
+ ]
+ expect(formatThreadParticipants(thread)).toBe('Brittany … Courtney, me')
+ })
+
+ it('falls back to the address of a sender who goes by no name', () => {
+ expect(formatThreadParticipants([participant('', 'noreply@frappe.io')])).toBe(
+ 'noreply@frappe.io',
+ )
+ expect(
+ formatThreadParticipants([
+ participant(' ', 'noreply@frappe.io'),
+ participant('Vibhav Katre', 'vibhav@frappe.io', true),
+ ]),
+ ).toBe('noreply@frappe.io, me')
+ })
+
+ it('has nothing to say about a thread with no senders', () => {
+ expect(formatThreadParticipants([])).toBe('')
+ })
+})
diff --git a/frontend/src/apps/mail/utils/participants.ts b/frontend/src/apps/mail/utils/participants.ts
new file mode 100644
index 000000000..3f5f17c10
--- /dev/null
+++ b/frontend/src/apps/mail/utils/participants.ts
@@ -0,0 +1,57 @@
+import type { ThreadParticipant } from '@/apps/mail/types'
+
+// Past this many names the middle of the list is dropped for an ellipsis, keeping the
+// thread's first sender and its two most recent — the ends identify a conversation.
+const MAX_PARTICIPANTS_SHOWN = 3
+
+const capitalizeFirst = (word: string) => word.charAt(0).toUpperCase() + word.slice(1)
+
+/**
+ * The participant a row's avatar stands for: the first person in the thread who isn't the user,
+ * falling back to the user themselves for a thread only they have written in.
+ *
+ * The avatar IMAGE is chosen server-side by the same rule (add_user_images_to_emails skips the user's
+ * own addresses), so deriving the fallback letter from anywhere else — the latest sender, say, which is
+ * the user on any thread they have answered — puts one person's initial on another's photo.
+ */
+export const primaryParticipant = (participants: ThreadParticipant[]): ThreadParticipant | undefined =>
+ participants.find((p) => !p.is_self) ?? participants[0]
+
+/**
+ * The name(s) a thread row goes by: everyone who has written in it, in the order they
+ * first wrote, with the user's own addresses collapsed to "me" — "Alice, me",
+ * "Alice … Carol, me", "Me, Alice".
+ *
+ * A thread is named after its participants rather than after its latest sender because
+ * replying to one made the row read as though the user had sent it: an incoming mail
+ * answered once was filed under the answerer's own name, with no trace of who had
+ * written in. Everyone keeps their place in the conversation, so an inbox thread still
+ * leads with whoever started it.
+ *
+ * Several names are shortened to first names to fit the line; a lone participant keeps
+ * their full one.
+ */
+export const formatThreadParticipants = (participants: ThreadParticipant[]) => {
+ // The user may have written from more than one of their addresses; they're still one name.
+ const firstSelf = participants.findIndex((p) => p.is_self)
+ const distinct = participants.filter((p, i) => !p.is_self || i === firstSelf)
+ if (!distinct.length) return ''
+
+ const nameOf = ({ name, email, is_self }: ThreadParticipant, firstNameOnly: boolean) => {
+ if (is_self) return __('me')
+ const fullName = name.trim()
+ if (!fullName) return email
+ return firstNameOnly ? fullName.split(/\s+/)[0] : fullName
+ }
+
+ // "me" is a word standing in for a name, so it reads lowercase inside the line ("Figma, me")
+ // and is capitalized only where it heads the row. Real names arrive capitalized already, and an
+ // address standing in for a missing name ("noreply@frappe.io") must be left exactly as it is.
+ const names = distinct.map((participant, i) => {
+ const name = nameOf(participant, distinct.length > 1)
+ return i === 0 && participant.is_self ? capitalizeFirst(name) : name
+ })
+
+ if (names.length <= MAX_PARTICIPANTS_SHOWN) return names.join(', ')
+ return `${names[0]} … ${names.slice(-2).join(', ')}`
+}
diff --git a/frontend/src/apps/mail/utils/threadStacks.test.ts b/frontend/src/apps/mail/utils/threadStacks.test.ts
index 930d87cf5..5992b4b34 100644
--- a/frontend/src/apps/mail/utils/threadStacks.test.ts
+++ b/frontend/src/apps/mail/utils/threadStacks.test.ts
@@ -4,6 +4,8 @@ import { buildListRows, stackKeyOf } from './threadStacks'
import type { Thread } from '@/apps/mail/types'
+const participant = (email: string, is_self = false) => ({ name: '', email, is_self })
+
const thread = (overrides: Partial = {}): Thread =>
({
name: 'm1',
@@ -11,12 +13,22 @@ const thread = (overrides: Partial = {}): Thread =>
thread_id: 't1',
from_email: 'alerts@uptimerobot.com',
from_name: 'UptimeRobot',
+ participants: [participant('alerts@uptimerobot.com')],
subject: 'Monitor is UP: preview.frappe.cloud/',
received_at: '2026-07-16 10:00:00',
seen: 0,
...overrides,
}) as Thread
+// A thread the user has answered: the bot still wrote first, but the latest sender is now the user.
+const answered = (overrides: Partial = {}) =>
+ thread({
+ from_email: 'vibhav@frappe.io',
+ from_name: 'Vibhav Katre',
+ participants: [participant('alerts@uptimerobot.com'), participant('vibhav@frappe.io', true)],
+ ...overrides,
+ })
+
// A run of adjacent threads that differ the way a real notification burst does: distinct ids, distinct
// times within the same day.
const run = (count: number, overrides: Partial = {}) =>
@@ -45,19 +57,34 @@ describe('stackKeyOf', () => {
})
it('separates different senders', () => {
- expect(stackKeyOf(thread({ from_email: 'a@x.com' }))).not.toBe(
- stackKeyOf(thread({ from_email: 'b@x.com' })),
+ expect(stackKeyOf(thread({ participants: [participant('a@x.com')] }))).not.toBe(
+ stackKeyOf(thread({ participants: [participant('b@x.com')] })),
)
})
+ it('returns null once a second person has written, so the thread never stacks', () => {
+ // A thread with an answer in it is correspondence, not a flood — whoever wrote the answer.
+ expect(stackKeyOf(answered())).toBeNull()
+ const two = [participant('alerts@uptimerobot.com'), participant('neha@frappe.io')]
+ expect(stackKeyOf(thread({ participants: two }))).toBeNull()
+ })
+
it('is case- and whitespace-insensitive about the sender', () => {
- expect(stackKeyOf(thread({ from_email: ' Alerts@UptimeRobot.com ' }))).toBe(
- stackKeyOf(thread({ from_email: 'alerts@uptimerobot.com' })),
+ expect(stackKeyOf(thread({ participants: [participant(' Alerts@UptimeRobot.com ')] }))).toBe(
+ stackKeyOf(thread({ participants: [participant('alerts@uptimerobot.com')] })),
+ )
+ })
+
+ it('falls back to the sender for rows with no thread behind them', () => {
+ // Search results are single messages: no participant list, so the sender is all there is to key on.
+ expect(stackKeyOf(thread({ participants: undefined, from_email: 'a@x.com' }))).not.toBe(
+ stackKeyOf(thread({ participants: undefined, from_email: 'b@x.com' })),
)
+ expect(stackKeyOf(thread({ participants: undefined }))).toBe(stackKeyOf(thread()))
})
it('returns null without a sender, so such threads never stack', () => {
- expect(stackKeyOf(thread({ from_email: '' }))).toBeNull()
+ expect(stackKeyOf(thread({ participants: [], from_email: '' }))).toBeNull()
})
it('separates the same sender across accounts', () => {
@@ -96,12 +123,30 @@ describe('buildListRows', () => {
it('breaks a run at a different sender and preserves order', () => {
const [a, b, c, d] = run(4)
- const other = thread({ name: 'other', from_email: 'hey@posthog.com' })
+ const other = thread({ name: 'other', participants: [participant('hey@posthog.com')] })
const rows = buildListRows([a, b, other, c, d], rowOptions)
expect(rows.map((r) => r.type)).toEqual(['thread', 'thread', 'thread', 'thread', 'thread'])
expect(rows.map((r) => r.key)).toEqual(['m0', 'm1', 'other', 'm2', 'm3'])
})
+ it('never stacks threads more than one person has written in, however alike they are', () => {
+ // Same two people, same day, three in a row — still three conversations, never a pile.
+ const conversations = run(3, {
+ participants: [participant('neha@frappe.io'), participant('vibhav@frappe.io', true)],
+ })
+ expect(buildListRows(conversations, rowOptions).map((r) => r.type)).toEqual([
+ 'thread',
+ 'thread',
+ 'thread',
+ ])
+ })
+
+ it('breaks a run where the user has replied to one of its threads', () => {
+ const [a, b, c] = run(3)
+ const rows = buildListRows([a, b, answered({ name: 'replied' }), c], rowOptions)
+ expect(rows.map((r) => r.type)).toEqual(['thread', 'thread', 'thread', 'thread'])
+ })
+
it('splits a run that crosses midnight into per-day stacks', () => {
const day1 = run(3).map((t) => ({ ...t, received_at: '2026-07-16 10:00:00' }))
const day2 = run(3).map((t, i) => ({ ...t, name: `n${i}`, received_at: '2026-07-17 10:00:00' }))
@@ -112,7 +157,7 @@ describe('buildListRows', () => {
})
it('never merges senderless threads with each other', () => {
- const rows = buildListRows(run(3, { from_email: '' }), rowOptions)
+ const rows = buildListRows(run(3, { participants: [], from_email: '' }), rowOptions)
expect(rows.map((r) => r.type)).toEqual(['thread', 'thread', 'thread'])
})
@@ -136,7 +181,7 @@ describe('buildListRows', () => {
})
it('detects runs that sit at either end of the list', () => {
- const other = thread({ name: 'other', from_email: 'hey@posthog.com' })
+ const other = thread({ name: 'other', participants: [participant('hey@posthog.com')] })
expect(buildListRows([...run(3), other], rowOptions).map((r) => r.type)).toEqual([
'stack',
'thread',
diff --git a/frontend/src/apps/mail/utils/threadStacks.ts b/frontend/src/apps/mail/utils/threadStacks.ts
index f59155344..b18e6fedf 100644
--- a/frontend/src/apps/mail/utils/threadStacks.ts
+++ b/frontend/src/apps/mail/utils/threadStacks.ts
@@ -3,19 +3,38 @@ import dayjs from '@/apps/mail/utils/dayjs'
import type { Thread } from '@/apps/mail/types'
// Chatty automated senders (uptime alerts, CI, ticket systems) post runs of threads that bury
-// everything around them. A run of this many adjacent threads from one sender collapses into a single
-// stack row. Two in a row is normal correspondence, not a flood — hence three.
+// everything around them. A run of this many adjacent threads from one lone sender collapses into a
+// single stack row. Two in a row is normal correspondence, not a flood — hence three.
const MIN_STACK_SIZE = 3
+// The thread's lone sender, or null when more than one person has written in it. Search results are
+// single messages with no thread behind them and carry no participant list, so their sender is all
+// there is to go on.
+const loneSenderOf = (thread: Thread): string | null => {
+ const emails = (thread.participants ?? [])
+ .map((p) => (p.email ?? '').trim().toLowerCase())
+ .filter(Boolean)
+
+ if (emails.length > 1) return null
+
+ return emails[0] || (thread.from_email ?? '').trim().toLowerCase() || null
+}
+
/**
* The stack identity of a thread, or null when it must never stack.
*
- * Stacking keys on the SENDER, not the subject. Matching subjects too was tried and measured against a
- * real 300-thread inbox, and the two classes turned out not to be separable: "Outbound IP Change — KSA
- * Region" vs "… Johannesburg Region" (one template, must stack) scored 0.71 similarity, while "Your CRM
- * trial just expired" vs "Your Learning trial just expired" (distinct notices, must not stack) scored
- * 0.74 — higher. Any threshold loose enough to catch the real floods admits everything from a sender
- * anyway, which is this rule with extra machinery. So: one sender, one day, three in a row.
+ * Only threads that ONE person has written in can stack, and they stack by that person — never by the
+ * subject. Matching subjects too was tried and measured against a real 300-thread inbox, and the two
+ * classes turned out not to be separable: "Outbound IP Change — KSA Region" vs "… Johannesburg Region"
+ * (one template, must stack) scored 0.71 similarity, while "Your CRM trial just expired" vs "Your
+ * Learning trial just expired" (distinct notices, must not stack) scored 0.74 — higher. Any threshold
+ * loose enough to catch the real floods admits everything from a sender anyway, which is this rule with
+ * extra machinery. So: one sender, nobody else in the thread, one day, three in a row.
+ *
+ * The moment a second person writes — you replying included — the thread is correspondence rather than
+ * a flood, and correspondence is never worth burying: it has an answer in it, and the row names a cast
+ * a stack headed by one sender cannot stand for. Keying on the latest sender instead used to pile such
+ * threads together under your own name, since the latest sender of anything you have answered is you.
*
* `account` is part of the key because the merged all-accounts search view can place two accounts' rows
* adjacent: the same sender writing to two of my accounts is two piles, not one.
@@ -26,11 +45,11 @@ const MIN_STACK_SIZE = 3
* adjacent threads from different days simply get different keys and the run flushes at midnight.
*/
export const stackKeyOf = (thread: Thread): string | null => {
- const from = (thread.from_email ?? '').trim().toLowerCase()
- if (!from) return null
+ const sender = loneSenderOf(thread)
+ if (!sender) return null
const day = dayjs(thread.received_at).format('YYYY-MM-DD')
- return `${thread.account ?? ''}|${day}|${from}`
+ return `${thread.account ?? ''}|${day}|${sender}`
}
interface ThreadRow {
diff --git a/suite/mail/api/mail.py b/suite/mail/api/mail.py
index 120d763f1..8ab00bb6d 100644
--- a/suite/mail/api/mail.py
+++ b/suite/mail/api/mail.py
@@ -225,23 +225,37 @@ def get_threads(account: str, mailbox: str, limit: int, start: int = 0, filter_b
conversations = fetch_threads(account, filter, start, limit)
- sent_mailbox = get_mailbox_id_by_role(account, "sent")
+ # Four roles are needed below, so they come off one cached mailbox list rather than a lookup each:
+ # every `get_mailbox_id_by_role` resolves the account's user and connection again on the way in.
+ ids_by_role = {
+ (m.get("role") or "").lower(): m["id"] for m in get_mailbox_service(account).mailboxes
+ }
+ trash_mailbox = ids_by_role.get("trash")
+ junk_mailbox = ids_by_role.get("junk")
+ # Sent and Drafts are about the message you wrote, so their rows follow the latest message in the
+ # folder itself; every other view follows the conversation's most recent activity.
+ outgoing_mailboxes = {ids_by_role[role] for role in ("sent", "drafts") if role in ids_by_role}
+ user_emails = {e.lower() for e in get_account_emails(account)}
threads = []
for conversation in conversations.values():
if not conversation:
continue
+ visible = visible_in_mailbox(conversation, mailbox, trash_mailbox, junk_mailbox)
+
# The summary row is derived from the thread's messages in the current mailbox (falling back
# to the whole conversation for cross-mailbox views like "starred").
in_mailbox = [
- m for m in conversation if any(mb["mailbox_id"] == mailbox for mb in m["mailboxes"])
- ] or conversation
+ m for m in visible if any(mb["mailbox_id"] == mailbox for mb in m["mailboxes"])
+ ] or visible
- # The preview/date reflect the latest message in the whole conversation (the most recent
- # activity) everywhere except Sent, where the latest sent message is shown.
- latest = in_mailbox[-1] if mailbox == sent_mailbox else conversation[-1]
- threads.append(serialize_thread(in_mailbox, conversation, latest))
+ # The preview/date reflect the latest message in the conversation (the most recent activity)
+ # everywhere except Sent and Drafts, which show the latest message in the folder itself: a
+ # draft reply must keep its own recipients and its "Draft" badge when the thread it answers
+ # receives a newer mail.
+ latest = in_mailbox[-1] if mailbox in outgoing_mailboxes else visible[-1]
+ threads.append(serialize_thread(in_mailbox, visible, latest, user_emails, first=conversation[0]))
# Avatars for the list-view summary rows, and for each message in the nested threads.
add_user_images_to_emails(account, threads, is_thread=False)
@@ -250,6 +264,30 @@ def get_threads(account: str, mailbox: str, limit: int, start: int = 0, filter_b
return threads, mailbox
+def visible_in_mailbox(messages: list[dict], mailbox: str, trash: str | None, junk: str | None) -> list[dict]:
+ """The thread's messages a mailbox view is allowed to show, oldest to newest.
+
+ Mirrors the thread pane's `filterRelevantMails`: junked and trashed messages appear only in their
+ own folders. The summary row is derived from this rather than from the whole conversation so that
+ it describes the thread the way opening it would — a spam reply was adding a stranger to a row's
+ participants, raising its message count, and lending it its preview and date, all for a message
+ the pane then refused to render.
+
+ Falls back to the whole conversation rather than to nothing, so a thread is never a blank row.
+ """
+
+ def is_trashed(message: dict) -> bool:
+ return any(mb["mailbox_id"] == trash for mb in message["mailboxes"])
+
+ if trash and mailbox == trash:
+ return [m for m in messages if is_trashed(m)] or messages
+
+ if junk and mailbox == junk:
+ return [m for m in messages if m.get("junk")] or messages
+
+ return [m for m in messages if not is_trashed(m) and not m.get("junk")] or messages
+
+
def get_user_jmap_accounts() -> list[dict]:
"""Return the current user's JMAP accounts (id + display name), personal first.
@@ -361,17 +399,28 @@ def get_attachment(account: str, blob_id: str, filename: str | None = None) -> N
frappe.local.response.type = "download"
-def serialize_thread(messages: list[dict], thread_messages: list[dict], latest: dict | None = None) -> dict:
+def serialize_thread(
+ messages: list[dict],
+ thread_messages: list[dict],
+ latest: dict | None = None,
+ user_emails: set[str] | None = None,
+ first: dict | None = None,
+) -> dict:
"""Serializes a thread for response.
- Both `messages` (the thread's messages within the current mailbox) and `thread_messages` (the full
- conversation across all mailboxes) are expected ordered oldest to newest. The list-view summary
- fields are derived from `latest` (defaulting to the latest of `messages`), except `subject` which
- comes from the conversation's first message (the thread's original subject); the full conversation
- is serialized under `messages` so the whole thread can be rendered without a separate fetch.
+ Both `messages` (the thread's messages within the current mailbox) and `thread_messages` (the
+ conversation this view can show — see `visible_in_mailbox`) are expected ordered oldest to newest.
+ The list-view summary fields are derived from `latest` (defaulting to the latest of `messages`),
+ except `subject` which comes from `first`, the conversation's opening message (the thread's
+ original subject, without the "Re:" its replies carry — it defaults to the earliest message given,
+ which is only the true first when nothing has been filtered out). The conversation is serialized
+ under `messages` so the whole thread can be rendered without a separate fetch.
+
+ `user_emails` is the set of the account's own addresses (lowercased), used to flag which of the
+ thread's senders is the user themselves.
"""
- first = thread_messages[0]
+ first = first or thread_messages[0]
latest = latest or messages[-1]
# The row's identity + state come from the thread's representative message in the CURRENT mailbox
# (`messages` is scoped to it), so its folder tags and junk/flag/seen reflect THIS view — not a
@@ -396,11 +445,40 @@ def serialize_thread(messages: list[dict], thread_messages: list[dict], latest:
**{field: current[field] for field in current_fields},
**{field: latest[field] for field in activity_fields},
"subject": first["subject"],
+ "participants": serialize_participants(thread_messages, user_emails or set()),
"attachments": serialize_attachments(latest.get("attachments", [])),
"messages": [serialize_mail(message) for message in thread_messages],
}
+def serialize_participants(thread_messages: list[dict], user_emails: set[str]) -> list[dict]:
+ """The thread's senders, in the order they first wrote, de-duplicated by address.
+
+ This is what the list row names, in place of the latest message's sender alone: a thread you have
+ replied to led with your own name, which read as though you had started it. Each entry carries
+ `is_self` when the sender is one of the account's own addresses, so a row merged in from another
+ account (All Inboxes, cross-account search) is still resolved against the account that owns it.
+ """
+
+ participants: list[dict] = []
+ seen: set[str] = set()
+ for message in thread_messages:
+ email = message.get("from_email") or ""
+ if not email or email.lower() in seen:
+ continue
+
+ seen.add(email.lower())
+ participants.append(
+ {
+ "name": message.get("from_name") or "",
+ "email": email,
+ "is_self": email.lower() in user_emails,
+ }
+ )
+
+ return participants
+
+
def serialize_mail(mail: dict) -> dict:
"""Serializes mail for response."""