diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 82b11d4..d6e1e6d 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -56,6 +56,8 @@ class MessageEnvelope(BaseModel): has_remote_images: bool size_bytes: int snippet: str + # Other accounts this same delivery also landed in (collapsed duplicates). + also_in_account_ids: list[uuid.UUID] = [] class ThreadDetail(BaseModel): diff --git a/backend/app/api/threads.py b/backend/app/api/threads.py index 88a158d..78a6333 100644 --- a/backend/app/api/threads.py +++ b/backend/app/api/threads.py @@ -6,7 +6,7 @@ import uuid from typing import Annotated from fastapi import APIRouter, Depends, Query -from sqlalchemy import func, literal, or_, select, tuple_ +from sqlalchemy import Text, func, literal, or_, select, tuple_ from sqlalchemy.ext.asyncio import AsyncSession from app.api.cursors import decode_cursor, encode_cursor @@ -131,8 +131,11 @@ async def list_threads( # keeps the list count in step with a single deleted message. counts: dict[uuid.UUID, int] = {} if threads: + # count DISTINCT deliveries so collapsed cross-account copies count once, + # matching what get_thread renders. + delivery = func.coalesce(Message.delivery_id, func.cast(Message.id, Text)) count_stmt = ( - select(Message.thread_id, func.count()) + select(Message.thread_id, func.count(func.distinct(delivery))) .join(Mailbox, Mailbox.id == Message.mailbox_id) .where(Message.user_id == user.id, Message.thread_id.in_([t.id for t in threads])) .group_by(Message.thread_id) @@ -196,11 +199,34 @@ async def get_thread( or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_REMOVAL_FOLDERS)) ) messages = list(await db.scalars(stmt)) + + # Collapse duplicate deliveries: the same email (same delivery_id) that landed + # in more than one of the user's accounts shows once, noting the other accounts. + # Messages without a real Message-ID (delivery_id is NULL) never collapse. + reps: dict[str, Message] = {} + also: dict[str, list[uuid.UUID]] = {} + ordered: list[Message] = [] + for m in messages: + key = m.delivery_id or f"id:{m.id}" + rep = reps.get(key) + if rep is None: + reps[key] = m + also[key] = [] + ordered.append(m) + elif m.account_id != rep.account_id and m.account_id not in also[key]: + also[key].append(m.account_id) + + envelopes = [] + for m in ordered: + env = _envelope(m) + env.also_in_account_ids = also[m.delivery_id or f"id:{m.id}"] + envelopes.append(env) + order = "desc" if sort == "desc" else "asc" return ThreadDetail( id=thread.id, subject=thread.subject_norm or "(no subject)", order=order, pinned=thread.pinned, - messages=[_envelope(m) for m in messages], + messages=envelopes, ) diff --git a/backend/app/indexer/indexer.py b/backend/app/indexer/indexer.py index 6bc1e3f..6515a2e 100644 --- a/backend/app/indexer/indexer.py +++ b/backend/app/indexer/indexer.py @@ -161,6 +161,9 @@ async def persist_message( mailbox_id=mailbox_id, uid=remote.uid, message_id_hdr=message_id_hdr, + # Keep the real delivered Message-ID (may differ from the stored, possibly + # synthesized, message_id_hdr) so cross-account copies collapse in threads. + delivery_id=parsed.message_id, in_reply_to=parsed.in_reply_to, references_hdr=parsed.references, subject=parsed.subject, diff --git a/backend/app/models.py b/backend/app/models.py index 83b5b88..a442272 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -157,6 +157,10 @@ class Message(Base): UUID(as_uuid=True), ForeignKey("threads.id") ) message_id_hdr: Mapped[str | None] = mapped_column(Text) + # The real header Message-ID as delivered (kept even when message_id_hdr is + # synthesized for a cross-account duplicate, §7.1). Identifies one delivery, so + # the same email fanned out to several accounts collapses to one row in a thread. + delivery_id: Mapped[str | None] = mapped_column(Text) in_reply_to: Mapped[str | None] = mapped_column(Text) references_hdr: Mapped[list[str]] = mapped_column( ARRAY(Text), nullable=False, server_default=text("'{}'") diff --git a/backend/migrations/versions/0011_message_delivery_id.py b/backend/migrations/versions/0011_message_delivery_id.py new file mode 100644 index 0000000..c21bde2 --- /dev/null +++ b/backend/migrations/versions/0011_message_delivery_id.py @@ -0,0 +1,38 @@ +"""add messages.delivery_id + +The real header Message-ID as delivered, kept even when message_id_hdr is +synthesized for a cross-account duplicate (SPEC §7.1). It identifies a single +delivery, so the same email delivered to several of the user's accounts can be +collapsed to one row in the thread view. Backfilled from message_id_hdr for +non-synthetic ids; synthetic rows are recovered from the raw blob by a script. + +Revision ID: 0011 +Revises: 0010 +Create Date: 2026-07-22 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0011" +down_revision: str | None = "0010" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("ALTER TABLE messages ADD COLUMN delivery_id text") + # Non-synthetic ids are the real Message-ID already; use them directly. + op.execute( + "UPDATE messages SET delivery_id = message_id_hdr " + "WHERE message_id_hdr IS NOT NULL AND message_id_hdr NOT LIKE '%@synthetic.local'" + ) + op.execute("CREATE INDEX ix_messages_delivery ON messages (user_id, delivery_id)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_messages_delivery") + op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS delivery_id") diff --git a/backend/scripts/backfill_delivery_id.py b/backend/scripts/backfill_delivery_id.py new file mode 100644 index 0000000..f5ad05a --- /dev/null +++ b/backend/scripts/backfill_delivery_id.py @@ -0,0 +1,35 @@ +"""One-off: recover delivery_id for messages whose stored Message-ID was +synthesized (§7.1 cross-account duplicate). The real Message-ID is still in the +raw blob, so re-parse it. Non-synthetic rows were already backfilled by migration +0011; here we only touch rows left with delivery_id IS NULL that have a real +Message-ID in their raw bytes.""" + +import asyncio + +from sqlalchemy import select + +from app.db import session_scope +from app.indexer import blobstore +from app.indexer.mime import parse_message +from app.models import Message + + +async def main() -> None: + async with session_scope() as session: + rows = list( + await session.scalars(select(Message).where(Message.delivery_id.is_(None))) + ) + fixed = 0 + for m in rows: + try: + raw = blobstore.load(m.user_id, m.blob_sha256) + except FileNotFoundError: + continue + parsed = parse_message(raw) + if parsed.message_id: + m.delivery_id = parsed.message_id + fixed += 1 + print(f"scanned {len(rows)} null rows, set delivery_id on {fixed}") + + +asyncio.run(main()) diff --git a/backend/tests/api/test_api.py b/backend/tests/api/test_api.py index ae06487..98e7570 100644 --- a/backend/tests/api/test_api.py +++ b/backend/tests/api/test_api.py @@ -314,6 +314,44 @@ async def test_folder_counts(api: ApiCtx) -> None: assert body["inbox_unread"] == 2 +async def test_thread_collapses_cross_account_duplicate(api: ApiCtx) -> None: + assert api.session is not None + # The kickoff thread's first message; give it a real delivery_id. + kickoff = next(t for t in (await api.client.get("/api/threads")).json()["threads"] + if t["message_count"] == 2) + detail = (await api.client.get(f"/api/threads/{kickoff['id']}")).json() + first_id = uuid.UUID(detail["messages"][0]["id"]) + first = await api.session.get(Message, first_id) + assert first is not None + first.delivery_id = "" + + # A second account receives the SAME email (same delivery_id) into the same thread. + acct2 = Account( + user_id=api.user_id, email="me2@example.com", imap_config={}, smtp_config={}, + secret_ct=b"", discovery_src="manual", + ) + api.session.add(acct2) + await api.session.flush() + inbox2 = Mailbox(account_id=acct2.id, user_id=api.user_id, path="INBOX", special_use="inbox") + api.session.add(inbox2) + await api.session.flush() + api.session.add(Message( + user_id=api.user_id, account_id=acct2.id, mailbox_id=inbox2.id, uid=1, + thread_id=first.thread_id, message_id_hdr="", + delivery_id="", subject=first.subject, + internal_date=datetime.now(UTC), blob_sha256="z" * 64, # later -> first stays the rep + )) + await api.session.commit() + + body = (await api.client.get(f"/api/threads/{kickoff['id']}")).json() + ids = [m["id"] for m in body["messages"]] + # Still two rows (kickoff + reply) — the duplicate collapsed into the first, not + # a third row — and the first now notes the other account. + assert len(ids) == 2 + rep = next(m for m in body["messages"] if m["id"] == str(first_id)) + assert rep["also_in_account_ids"] == [str(acct2.id)] + + async def test_message_html_is_sanitized(api: ApiCtx) -> None: # the lunch message has HTML with a script resp = await api.client.get("/api/threads") diff --git a/frontend/fonts/Lato-Black.ttf b/frontend/fonts/Lato-Black.ttf new file mode 100644 index 0000000..4340502 Binary files /dev/null and b/frontend/fonts/Lato-Black.ttf differ diff --git a/frontend/fonts/Lato-BlackItalic.ttf b/frontend/fonts/Lato-BlackItalic.ttf new file mode 100644 index 0000000..4df1555 Binary files /dev/null and b/frontend/fonts/Lato-BlackItalic.ttf differ diff --git a/frontend/fonts/Lato-Bold.ttf b/frontend/fonts/Lato-Bold.ttf new file mode 100644 index 0000000..016068b Binary files /dev/null and b/frontend/fonts/Lato-Bold.ttf differ diff --git a/frontend/fonts/Lato-BoldItalic.ttf b/frontend/fonts/Lato-BoldItalic.ttf new file mode 100644 index 0000000..a05d503 Binary files /dev/null and b/frontend/fonts/Lato-BoldItalic.ttf differ diff --git a/frontend/fonts/Lato-Italic.ttf b/frontend/fonts/Lato-Italic.ttf new file mode 100644 index 0000000..0d0f69e Binary files /dev/null and b/frontend/fonts/Lato-Italic.ttf differ diff --git a/frontend/fonts/Lato-Light.ttf b/frontend/fonts/Lato-Light.ttf new file mode 100644 index 0000000..dfa72ce Binary files /dev/null and b/frontend/fonts/Lato-Light.ttf differ diff --git a/frontend/fonts/Lato-LightItalic.ttf b/frontend/fonts/Lato-LightItalic.ttf new file mode 100644 index 0000000..12f2b6c Binary files /dev/null and b/frontend/fonts/Lato-LightItalic.ttf differ diff --git a/frontend/fonts/Lato-Regular.ttf b/frontend/fonts/Lato-Regular.ttf new file mode 100644 index 0000000..bb2e887 Binary files /dev/null and b/frontend/fonts/Lato-Regular.ttf differ diff --git a/frontend/fonts/Lato-Thin.ttf b/frontend/fonts/Lato-Thin.ttf new file mode 100644 index 0000000..ba58da1 Binary files /dev/null and b/frontend/fonts/Lato-Thin.ttf differ diff --git a/frontend/fonts/Lato-ThinItalic.ttf b/frontend/fonts/Lato-ThinItalic.ttf new file mode 100644 index 0000000..4d82766 Binary files /dev/null and b/frontend/fonts/Lato-ThinItalic.ttf differ diff --git a/frontend/fonts/OFL.txt b/frontend/fonts/OFL.txt new file mode 100644 index 0000000..1d7bf3b --- /dev/null +++ b/frontend/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/icons/email-illustration.svg b/frontend/icons/email-illustration.svg index b6582ce..fd54b9b 100644 --- a/frontend/icons/email-illustration.svg +++ b/frontend/icons/email-illustration.svg @@ -1,1247 +1,217 @@ - - - + id="defs49" /> + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + fill="#fbbd59" + d="m 1409.7571,962.20358 35.6,157.45002 c 6.36,28.35 12.36,58.12 19.14,86.25 3.9,25.32 -4.71,44.31 -29.88,53.35 3.88,26.68 -4.12,49.1 -33.03,55.57 -52.18,11.68 -104.45,23.13 -156.57,35.06 l -419.28997,95.86 -200.274,45.82 -64.235,14.85 c -19.141,4.42 -44.311,13.58 -62.345,3.29 -19.595,-11.19 -20.836,-34.73 -25.079,-55.11 l -15.468,-75.97 -54.829,-252.52 c -5.259,-17.14 -10.251,-48.45 -14.781,-67.41 -5.901,-27.4 -12.269,-54.67 -17.273,-82.23997 -3.766,-20.75005 10.659,-45.39005 28.88,-54.67005 -1.663,-18.16 2.94,-26.58 14.126,-39.99 l 2.792,-2.57 c 6.698,-1.51 12.808,-4.35 19.606,-6.09 l 21.911,7.3 c 15.815,6.37 35.127,12.71 51.336,18.43 17.965,6.33 37.853,13.71 55.896,19.46 l 8.164,2.87 c 28.055,10.12 56.172,20.07 84.35,29.84 l 44.4,15.48005 c 5.756,2.01 17.942,6.68 23.466,7.97 32.671,12.26 67.247,23.68 100.307,35.13997 16.52,5.72 44.39,16.39 60.28,20.37 l -0.03,0.39 6.08,1.64 c 2.55,-1.38 2.92,-2.58 4.78,-5.02 10.46,-13.73 13.14,-15.66 29.51,-19.24 l 2.47,-0.42 c 16.29,0.28 16.6,7.11 29.55,11.27 l 5.48,-5.72 0.91,-0.29 c 8.37,-9.71 29.45,-29.22997 39.37997,-38.95997 27.19,-26.27005 414.51,-23.94005 404.67,-11.44005 z" + id="path2" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/list/DraftsList.tsx b/frontend/src/components/list/DraftsList.tsx index 6218878..07f9819 100644 --- a/frontend/src/components/list/DraftsList.tsx +++ b/frontend/src/components/list/DraftsList.tsx @@ -32,13 +32,13 @@ function DraftRow({
{recipients} {badge && ( - + {badge} )} - {time} + {time}
-
{subject}
+
{subject}
{preview &&
{preview}
} ); diff --git a/frontend/src/components/list/MessageList.tsx b/frontend/src/components/list/MessageList.tsx index 118927b..17d69f6 100644 --- a/frontend/src/components/list/MessageList.tsx +++ b/frontend/src/components/list/MessageList.tsx @@ -60,6 +60,8 @@ export function MessageList({ onTrash, onDeletePermanent, onTogglePin, + checkedIds, + onToggleCheck, }: { threads: ThreadSummary[]; selectedId?: string; @@ -70,12 +72,18 @@ export function MessageList({ onTrash: (id: string) => void; onDeletePermanent: (id: string) => void; onTogglePin: (id: string, pinned: boolean) => void; + checkedIds: Set; + onToggleCheck: (id: string) => void; }) { const t = useT(); const parentRef = useRef(null); // In Trash/Spam/Archive the middle action restores; elsewhere it archives. const isRemovalFolder = folder === "trash" || folder === "junk" || folder === "archive"; + // Row selection: hovering an avatar reveals a checkbox; once anything is checked, + // every row shows a checkbox (selection mode) until all are cleared. + const checkMode = checkedIds.size > 0; + const virtualizer = useVirtualizer({ count: threads.length, getScrollElement: () => parentRef.current, @@ -84,7 +92,7 @@ export function MessageList({ }); if (threads.length === 0) { - return
{t("mail.noConversations")}
; + return
{t("mail.noConversations")}
; } return ( @@ -94,6 +102,7 @@ export function MessageList({ const thread = threads[item.index]; const unread = thread.unread_count > 0; const selected = selectedId === thread.id; + const checked = checkedIds.has(thread.id); const primary = thread.participants[0]; const names = thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)"; @@ -124,35 +133,67 @@ export function MessageList({ >
- {/* Avatar + extra-participants badge */} -
- - {initials(primary)} - - {extra > 0 && ( - - +{extra} - + {/* Avatar, replaced by a selection checkbox on avatar-hover or once + selection mode is active. An unread dot sits in the left margin. */} +
+ {unread && ( + )} +
+ + {initials(primary)} + + {extra > 0 && ( + + +{extra} + + )} +
+
{/* Sender column */} -
+
{names} {thread.message_count > 1 && ( - + {thread.message_count} )} @@ -161,8 +202,8 @@ export function MessageList({ {/* Subject + snippet (extra left margin -> 3x the gap to the sender). */}
{thread.subject} @@ -175,7 +216,7 @@ export function MessageList({ space while hidden), and the date is an overlay on the right that is simply hidden on hover to reveal archive + trash — so nothing ever shifts between the normal and hover states. */} -
+
{/* Attachment: fixed slot (reserved even when empty) so the rail width — and thus the excerpt width — is the same in every row. */} @@ -221,13 +262,17 @@ export function MessageList({ onClick={stop(() => folder === "trash" ? onDeletePermanent(thread.id) : onTrash(thread.id), )} - className="invisible text-app-muted hover:text-app-fg group-hover:visible" + className="invisible mr-[7px] text-app-muted hover:text-app-fg group-hover:visible" > {/* Date overlay, aligned to the right edge (over the archive/trash slots); hidden on hover so the actions show through. */} - + {timeLabel(thread.last_message_at)}
diff --git a/frontend/src/components/thread/ThreadView.tsx b/frontend/src/components/thread/ThreadView.tsx index e90e60c..003fb82 100644 --- a/frontend/src/components/thread/ThreadView.tsx +++ b/frontend/src/components/thread/ThreadView.tsx @@ -188,7 +188,7 @@ function ActionsMenu({ onClick={() => setOpen((v) => !v)} > {t("mail.actions")} - ▾ + ▾ {open && (
@@ -215,6 +215,7 @@ function MessageItem({ zIndex, showReplyBar, canReply, + alsoIn, onToggle, onReply, onToggleRead, @@ -231,6 +232,7 @@ function MessageItem({ zIndex: number; showReplyBar: boolean; canReply: boolean; + alsoIn: string[]; onToggle: () => void; onReply: (kind: string) => void; onToggleRead: () => void; @@ -286,9 +288,9 @@ function MessageItem({ onClick={onToggle} > {avatar()} - + {senderName} - {message.snippet} + {message.snippet} {fmtRelative(message.date)} @@ -302,7 +304,7 @@ function MessageItem({ className="flex w-full items-baseline justify-between gap-2 text-left" onClick={onToggle} > - + {senderName} {message.from_addr[0]?.email && ( <{message.from_addr[0].email}> @@ -320,7 +322,7 @@ function MessageItem({ {t("mail.to")} {recipientSummary(message.to_addr)} - {showDetails ? "▲" : "▼"} + {showDetails ? "▲" : "▼"}
{location && {location}} @@ -355,6 +357,11 @@ function MessageItem({ />
+ {alsoIn.length > 0 && ( +
+ {t("mail.alsoIn")} {alsoIn.join(", ")} +
+ )} {showDetails && (
{t("mail.from")}
@@ -514,8 +521,8 @@ export function ThreadView({ return (
-
-

{subject || "(no subject)"}

+
+

{subject || "(no subject)"}

-