Merge feat/conversations: conversations in cascade, answered marker, reply under the conversation, and Undo send that undoes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6b97807856
42 files changed
+2995
-633
No files matched your search
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"meta": {
|
"meta": {
|
||||||
"lastScanned": "2026-10-22T17:41:17.634Z",
|
"lastScanned": "2026-10-22T19:44:13.602Z",
|
||||||
"fileCount": 439,
|
"fileCount": 440,
|
||||||
"hits": 0,
|
"hits": 0,
|
||||||
"misses": 0,
|
"misses": 0,
|
||||||
"renderedHash": "6ed8dc6b806503f41452e51121e5f78379f1c88655d4934d082d2aa065428b8c",
|
"renderedHash": "4c74546f856ca02c0dd5eeb8a90c610c161275126dc91d9fc7f169a6424d61d6",
|
||||||
"storeUpdatedAt": "2026-10-22T17:41:17.650Z",
|
"storeUpdatedAt": "2026-10-22T19:44:13.627Z",
|
||||||
"rootHash": "098b934a6205dce4"
|
"rootHash": "d8862ac30fea5b61"
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
".github/workflows/ci.yml": {
|
".github/workflows/ci.yml": {
|
||||||
@@ -19624,6 +19624,66 @@
|
|||||||
"tokens": 36
|
"tokens": 36
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"services/webmail/src/components/ThreadReader.tsx": {
|
||||||
|
"description": "One message as the mail API returns it with its body.",
|
||||||
|
"tokens": 7350,
|
||||||
|
"hash": "54dad9430ffc2edb",
|
||||||
|
"size": 25722,
|
||||||
|
"mtimeMs": 1792698253418.4153,
|
||||||
|
"updatedAt": "2026-10-22T19:44:13.601Z",
|
||||||
|
"source": "hook",
|
||||||
|
"symbols": [
|
||||||
|
{
|
||||||
|
"name": "FullMessage",
|
||||||
|
"kind": "section",
|
||||||
|
"startLine": 39,
|
||||||
|
"endLine": 53,
|
||||||
|
"tokens": 74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MoveTarget",
|
||||||
|
"kind": "section",
|
||||||
|
"startLine": 54,
|
||||||
|
"endLine": 58,
|
||||||
|
"tokens": 17
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Trusted",
|
||||||
|
"kind": "section",
|
||||||
|
"startLine": 59,
|
||||||
|
"endLine": 64,
|
||||||
|
"tokens": 44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Props",
|
||||||
|
"kind": "section",
|
||||||
|
"startLine": 65,
|
||||||
|
"endLine": 84,
|
||||||
|
"tokens": 314
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "splitSender",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 85,
|
||||||
|
"endLine": 90,
|
||||||
|
"tokens": 74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "initialsOf",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 91,
|
||||||
|
"endLine": 96,
|
||||||
|
"tokens": 78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ThreadReader",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 97,
|
||||||
|
"endLine": 591,
|
||||||
|
"tokens": 6441
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"preamble": [
|
"preamble": [
|
||||||
|
|||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
# anatomy.md
|
# anatomy.md
|
||||||
|
|
||||||
> Auto-maintained by OpenWolf. Last scanned: 2026-10-22T17:41:17.634Z
|
> Auto-maintained by OpenWolf. Last scanned: 2026-10-22T19:44:13.602Z
|
||||||
> Files: 439 tracked | Anatomy hits: 0 | Misses: 0
|
> Files: 440 tracked | Anatomy hits: 0 | Misses: 0
|
||||||
|
|
||||||
> Project structure index. Auto-maintained by OpenWolf hooks and daemon.
|
> Project structure index. Auto-maintained by OpenWolf hooks and daemon.
|
||||||
> Run `openwolf scan` to generate, or wait for the first Claude Code session.
|
> Run `openwolf scan` to generate, or wait for the first Claude Code session.
|
||||||
@@ -610,6 +610,7 @@
|
|||||||
- `AvatarEditor.tsx` — The frame is square; the mask over it is what makes the photo a circle. (~2510 tok)
|
- `AvatarEditor.tsx` — The frame is square; the mask over it is what makes the photo a circle. (~2510 tok)
|
||||||
- `MessageReader.tsx` — The document the body frame renders, or null for a text-only message. (~4736 tok)
|
- `MessageReader.tsx` — The document the body frame renders, or null for a text-only message. (~4736 tok)
|
||||||
- `RecipientInput.tsx` — Entries as they will be sent: `Name <addr>` or a bare address. (~1486 tok)
|
- `RecipientInput.tsx` — Entries as they will be sent: `Name <addr>` or a bare address. (~1486 tok)
|
||||||
|
- `ThreadReader.tsx` — One message as the mail API returns it with its body. (~7350 tok)
|
||||||
- `TotpEnrolment.tsx` — The scannable half of TOTP enrolment, shared by the webmail settings page and (~1644 tok)
|
- `TotpEnrolment.tsx` — The scannable half of TOTP enrolment, shared by the webmail settings page and (~1644 tok)
|
||||||
|
|
||||||
## services/webmail/src/components/ui/
|
## services/webmail/src/components/ui/
|
||||||
|
|||||||
@@ -35,6 +35,17 @@
|
|||||||
"drafts"
|
"drafts"
|
||||||
],
|
],
|
||||||
"date": "2026-10-22"
|
"date": "2026-10-22"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"error_message": "Undo send did not undo: the message was sent anyway (and twice in development)",
|
||||||
|
"root_cause": "The 10-second countdown sent the message from inside a setUndoSeconds updater. Undo only set the number to null; the interval kept running, its next tick saw prev === null and sent. React may also run an updater twice",
|
||||||
|
"fix": "The countdown is counted in a plain closure with the timer kept in pendingSend; handleUndo clears it; leaving the screen flushes a pending send once (and does not navigate). Covered by compose-draft-integrity.test.ts",
|
||||||
|
"tags": [
|
||||||
|
"webmail",
|
||||||
|
"compose",
|
||||||
|
"undo-send"
|
||||||
|
],
|
||||||
|
"date": "2026-10-22"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -31,3 +31,4 @@ description: chronological action log per session, consolidated weekly
|
|||||||
| Time | Action | File(s) | Outcome | ~Tokens |
|
| Time | Action | File(s) | Outcome | ~Tokens |
|
||||||
|------|--------|---------|---------|--------|
|
|------|--------|---------|---------|--------|
|
||||||
| 14:41 | Created services/auth/src/accounts.ts | — | ~715 |
|
| 14:41 | Created services/auth/src/accounts.ts | — | ~715 |
|
||||||
|
| 16:44 | Created services/webmail/src/components/ThreadReader.tsx | — | ~7350 |
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_msg_mailbox_thread;
|
||||||
|
DROP FUNCTION IF EXISTS lm_has_reply_prefix(TEXT);
|
||||||
|
DROP FUNCTION IF EXISTS lm_subject_key(TEXT);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- Conversations.
|
||||||
|
--
|
||||||
|
-- email_messages.thread_id has existed since the first migration and nothing
|
||||||
|
-- filled it, and in_reply_to was only ever copied, never written. A reply and
|
||||||
|
-- the message it answered were therefore unrelated rows, which is why the
|
||||||
|
-- webmail could neither show a conversation nor say whether it had been
|
||||||
|
-- answered.
|
||||||
|
--
|
||||||
|
-- The subject key lives in the database so delivery, the backfill below and
|
||||||
|
-- anything later share one definition of "the same subject": the prefixes mail
|
||||||
|
-- clients add in several languages are not part of it.
|
||||||
|
CREATE FUNCTION lm_subject_key(subject TEXT) RETURNS TEXT AS $$
|
||||||
|
SELECT btrim(regexp_replace(
|
||||||
|
lower(coalesce(subject, '')),
|
||||||
|
'^(\s*(re|fw|fwd|enc|res|rv|aw|wg|tr|antw)(\[\d+\])?\s*:)+', ''))
|
||||||
|
$$ LANGUAGE SQL IMMUTABLE;
|
||||||
|
|
||||||
|
CREATE FUNCTION lm_has_reply_prefix(subject TEXT) RETURNS BOOLEAN AS $$
|
||||||
|
SELECT lower(coalesce(subject, '')) ~ '^\s*(re|fw|fwd|enc|res|rv|aw|wg|tr|antw)(\[\d+\])?\s*:'
|
||||||
|
$$ LANGUAGE SQL IMMUTABLE;
|
||||||
|
|
||||||
|
-- Mail that arrived before replies carried their headers cannot be linked by
|
||||||
|
-- Message-ID, so it is grouped by subject, and only where a reply is actually
|
||||||
|
-- in the mailbox: two unrelated messages that merely share a subject stay apart.
|
||||||
|
-- Everything else keeps thread_id NULL, which the webmail reads as "a
|
||||||
|
-- conversation of its own" (COALESCE(thread_id, id)).
|
||||||
|
WITH groups AS (
|
||||||
|
SELECT DISTINCT mailbox_id, lm_subject_key(subject) AS k
|
||||||
|
FROM email_messages
|
||||||
|
WHERE lm_has_reply_prefix(subject) AND lm_subject_key(subject) <> ''
|
||||||
|
)
|
||||||
|
UPDATE email_messages m
|
||||||
|
SET thread_id = md5(m.mailbox_id::text || '|' || g.k)::uuid
|
||||||
|
FROM groups g
|
||||||
|
WHERE m.mailbox_id = g.mailbox_id
|
||||||
|
AND lm_subject_key(m.subject) = g.k
|
||||||
|
AND m.thread_id IS NULL;
|
||||||
|
|
||||||
|
-- The listing groups by this and filters by mailbox and folder.
|
||||||
|
CREATE INDEX idx_msg_mailbox_thread ON email_messages (mailbox_id, (COALESCE(thread_id, id)))
|
||||||
|
WHERE expunged_at IS NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE mailboxes DROP COLUMN IF EXISTS conversation_view;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Whether the webmail lists mail as conversations. On by default: it is what
|
||||||
|
-- people coming from other mail programs expect, and it is one switch away.
|
||||||
|
ALTER TABLE mailboxes ADD COLUMN conversation_view BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -1195,21 +1195,32 @@ export async function deleteAlias(scope: AdminScope, aliasId: string): Promise<b
|
|||||||
|
|
||||||
// ------------------------------------------------ user preferences & sieve
|
// ------------------------------------------------ user preferences & sieve
|
||||||
|
|
||||||
export async function getUserPreferences(mailboxId: string): Promise<{ signature: string; auto_save_drafts: boolean }> {
|
export async function getUserPreferences(
|
||||||
const row = await queryOne<{ signature: string | null; auto_save_drafts: boolean | null }>(
|
mailboxId: string,
|
||||||
`SELECT signature, auto_save_drafts FROM mailboxes WHERE id = $1`,
|
): Promise<{ signature: string; auto_save_drafts: boolean; conversation_view: boolean }> {
|
||||||
|
const row = await queryOne<{ signature: string | null; auto_save_drafts: boolean | null; conversation_view: boolean | null }>(
|
||||||
|
`SELECT signature, auto_save_drafts, conversation_view FROM mailboxes WHERE id = $1`,
|
||||||
[mailboxId],
|
[mailboxId],
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
signature: row?.signature ?? "",
|
signature: row?.signature ?? "",
|
||||||
auto_save_drafts: row?.auto_save_drafts ?? true,
|
auto_save_drafts: row?.auto_save_drafts ?? true,
|
||||||
|
conversation_view: row?.conversation_view ?? true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUserPreferences(mailboxId: string, signature: string, autoSaveDrafts: boolean): Promise<boolean> {
|
/** conversationView is left as it is when the caller does not say. */
|
||||||
|
export async function updateUserPreferences(
|
||||||
|
mailboxId: string,
|
||||||
|
signature: string,
|
||||||
|
autoSaveDrafts: boolean,
|
||||||
|
conversationView: boolean | null = null,
|
||||||
|
): Promise<boolean> {
|
||||||
const rows = await query<{ id: string }>(
|
const rows = await query<{ id: string }>(
|
||||||
`UPDATE mailboxes SET signature = $2, auto_save_drafts = $3, updated_at = NOW() WHERE id = $1 RETURNING id`,
|
`UPDATE mailboxes SET signature = $2, auto_save_drafts = $3,
|
||||||
[mailboxId, signature, autoSaveDrafts],
|
conversation_view = COALESCE($4::boolean, conversation_view), updated_at = NOW()
|
||||||
|
WHERE id = $1 RETURNING id`,
|
||||||
|
[mailboxId, signature, autoSaveDrafts, conversationView],
|
||||||
);
|
);
|
||||||
return rows.length === 1;
|
return rows.length === 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1218,6 +1218,21 @@ describeDb("moving between the surfaces", () => {
|
|||||||
expect((await send("GET", "/api/v1/user/trusted-senders")).body.senders).toEqual([]);
|
expect((await send("GET", "/api/v1/user/trusted-senders")).body.senders).toEqual([]);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
|
it("lists conversations by default and remembers a switch back, without being reset by other saves", async () => {
|
||||||
|
const prefs = async () =>
|
||||||
|
(await send("GET", "/api/v1/user/preferences")).body as { conversation_view: boolean; signature: string };
|
||||||
|
expect((await prefs()).conversation_view).toBe(true);
|
||||||
|
|
||||||
|
await send("POST", "/api/v1/user/preferences", { signature: "x", auto_save_drafts: true, conversation_view: false });
|
||||||
|
expect((await prefs()).conversation_view).toBe(false);
|
||||||
|
|
||||||
|
// A save from an older screen that does not mention it must not flip it back.
|
||||||
|
await send("POST", "/api/v1/user/preferences", { signature: "y", auto_save_drafts: true });
|
||||||
|
const after = await prefs();
|
||||||
|
expect(after.conversation_view).toBe(false);
|
||||||
|
expect(after.signature).toBe("y");
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
it("refuses something that is not an address", async () => {
|
it("refuses something that is not an address", async () => {
|
||||||
expect((await send("PUT", "/api/v1/user/trusted-senders", { address: "not an address" })).status).toBe(400);
|
expect((await send("PUT", "/api/v1/user/trusted-senders", { address: "not an address" })).status).toBe(400);
|
||||||
expect((await send("PUT", "/api/v1/user/trusted-senders", {})).status).toBe(400);
|
expect((await send("PUT", "/api/v1/user/trusted-senders", {})).status).toBe(400);
|
||||||
|
|||||||
@@ -1224,7 +1224,8 @@ async function userUpdatePreferences(req: IncomingMessage, res: ServerResponse,
|
|||||||
const body = await parseJsonBody(req);
|
const body = await parseJsonBody(req);
|
||||||
const signature = typeof body?.signature === "string" ? body.signature : "";
|
const signature = typeof body?.signature === "string" ? body.signature : "";
|
||||||
const autoSaveDrafts = body?.auto_save_drafts !== false;
|
const autoSaveDrafts = body?.auto_save_drafts !== false;
|
||||||
await repo.updateUserPreferences(session.sub, signature, autoSaveDrafts);
|
const conversationView = typeof body?.conversation_view === "boolean" ? body.conversation_view : null;
|
||||||
|
await repo.updateUserPreferences(session.sub, signature, autoSaveDrafts, conversationView);
|
||||||
sendJson(res, 200, { ok: true });
|
sendJson(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ func run(cfg config) {
|
|||||||
if cfg.JwtSecret == "" {
|
if cfg.JwtSecret == "" {
|
||||||
log.Printf("JWT_SECRET is not set: the webmail mail API stays disabled")
|
log.Printf("JWT_SECRET is not set: the webmail mail API stays disabled")
|
||||||
}
|
}
|
||||||
|
webmailUC.SetConversations(postgres.NewConversations(pool))
|
||||||
router.SetMailAPI(webmailUC, cfg.JwtSecret)
|
router.SetMailAPI(webmailUC, cfg.JwtSecret)
|
||||||
// Folders the mailbox owner keeps for themselves, which the filing rules
|
// Folders the mailbox owner keeps for themselves, which the filing rules
|
||||||
// can then name as a destination.
|
// can then name as a destination.
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WebmailParticipant is someone who wrote in a conversation.
|
||||||
|
type WebmailParticipant struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
// Me marks the mailbox owner, so the list can say "me" instead of a name
|
||||||
|
// the reader knows perfectly well.
|
||||||
|
Me bool `json:"me"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebmailThread is one row of the conversation list: the newest message in the
|
||||||
|
// folder being looked at, with what the rest of the conversation adds.
|
||||||
|
type WebmailThread struct {
|
||||||
|
ThreadID string `json:"thread_id"`
|
||||||
|
UID uint32 `json:"uid"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
SenderAddress string `json:"sender_address"`
|
||||||
|
FromName string `json:"from_display_name"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
|
HasAttachments bool `json:"has_attachments"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
UnseenCount int `json:"unseen_count"`
|
||||||
|
Seen bool `json:"seen"`
|
||||||
|
Flagged bool `json:"flagged"`
|
||||||
|
Answered bool `json:"answered"`
|
||||||
|
Participants []WebmailParticipant `json:"participants"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebmailThreadMessage is one message inside a conversation.
|
||||||
|
type WebmailThreadMessage struct {
|
||||||
|
UID uint32 `json:"uid"`
|
||||||
|
Folder string `json:"folder"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
SenderAddress string `json:"sender_address"`
|
||||||
|
FromName string `json:"from_display_name"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
|
HasAttachments bool `json:"has_attachments"`
|
||||||
|
Seen bool `json:"seen"`
|
||||||
|
Flagged bool `json:"flagged"`
|
||||||
|
// Answered is true when the mailbox owner replied, whether the \Answered
|
||||||
|
// flag was set or the reply is simply there in the conversation - a reply
|
||||||
|
// written in another mail program leaves no flag, only the message.
|
||||||
|
Answered bool `json:"answered"`
|
||||||
|
// Own marks a message the mailbox owner wrote.
|
||||||
|
Own bool `json:"own"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationReader serves the conversation views.
|
||||||
|
type ConversationReader interface {
|
||||||
|
// ListThreads returns the conversations that have a message in the folder,
|
||||||
|
// newest first by that message.
|
||||||
|
ListThreads(ctx context.Context, mailboxID, folderName, search string, limit, offset int) ([]WebmailThread, error)
|
||||||
|
// ThreadMessages returns every message of one conversation, oldest first.
|
||||||
|
// folderName is the folder being viewed: a conversation's messages in Trash
|
||||||
|
// and Junk are left out unless that is the folder.
|
||||||
|
ThreadMessages(ctx context.Context, mailboxID, folderName, threadID string) ([]WebmailThreadMessage, error)
|
||||||
|
// MarkAnsweredByMessageID sets \Answered on the message a reply was written
|
||||||
|
// to, wherever in the mailbox it is filed.
|
||||||
|
MarkAnsweredByMessageID(ctx context.Context, mailboxID, messageID string) error
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@ type PersistInboundMessageInput struct {
|
|||||||
FromDisplayName string
|
FromDisplayName string
|
||||||
MessageIDHeader string
|
MessageIDHeader string
|
||||||
HasAttachments bool
|
HasAttachments bool
|
||||||
|
// InReplyTo and References place the message in its conversation. Both
|
||||||
|
// hold Message-IDs without angle brackets.
|
||||||
|
InReplyTo string
|
||||||
|
References []string
|
||||||
// AlreadySeen marks a copy the user themselves composed - their own Sent
|
// AlreadySeen marks a copy the user themselves composed - their own Sent
|
||||||
// copy, or a draft. It is not unread mail, and counting it as such is why
|
// copy, or a draft. It is not unread mail, and counting it as such is why
|
||||||
// the Sent folder carried an unread badge that nothing could clear: no
|
// the Sent folder carried an unread badge that nothing could clear: no
|
||||||
|
|||||||
@@ -19,10 +19,15 @@ type MessageHeaders struct {
|
|||||||
Subject string
|
Subject string
|
||||||
FromDisplayName string
|
FromDisplayName string
|
||||||
// FromAddress is the address in From, lowercased, or empty when it does not parse.
|
// FromAddress is the address in From, lowercased, or empty when it does not parse.
|
||||||
FromAddress string
|
FromAddress string
|
||||||
MessageID string
|
MessageID string
|
||||||
Snippet string
|
Snippet string
|
||||||
HasAttachments bool
|
HasAttachments bool
|
||||||
|
// InReplyTo and References are the Message-IDs this message answers, with
|
||||||
|
// the angle brackets removed so they compare equal to MessageID. They are
|
||||||
|
// what ties a reply to the conversation it belongs to.
|
||||||
|
InReplyTo string
|
||||||
|
References []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// wordDecoder tolerates the charsets that turn up in real mail. An unknown
|
// wordDecoder tolerates the charsets that turn up in real mail. An unknown
|
||||||
@@ -57,6 +62,10 @@ func ExtractMessageHeaders(payload []byte) MessageHeaders {
|
|||||||
|
|
||||||
out.Subject = truncateUTF8(decodeHeader(msg.Header.Get("Subject")), 998)
|
out.Subject = truncateUTF8(decodeHeader(msg.Header.Get("Subject")), 998)
|
||||||
out.MessageID = truncateUTF8(strings.Trim(msg.Header.Get("Message-ID"), "<>"), 998)
|
out.MessageID = truncateUTF8(strings.Trim(msg.Header.Get("Message-ID"), "<>"), 998)
|
||||||
|
if ids := messageIDsIn(msg.Header.Get("In-Reply-To")); len(ids) > 0 {
|
||||||
|
out.InReplyTo = ids[0]
|
||||||
|
}
|
||||||
|
out.References = messageIDsIn(msg.Header.Get("References"))
|
||||||
|
|
||||||
if from := msg.Header.Get("From"); from != "" {
|
if from := msg.Header.Get("From"); from != "" {
|
||||||
if addr, err := mail.ParseAddress(from); err == nil {
|
if addr, err := mail.ParseAddress(from); err == nil {
|
||||||
@@ -152,3 +161,23 @@ func truncateUTF8(s string, max int) string {
|
|||||||
}
|
}
|
||||||
return cut
|
return cut
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxReferences bounds how much of a long chain is kept. The ids nearest the
|
||||||
|
// end name the messages closest to this one, which is where its parent is.
|
||||||
|
const maxReferences = 20
|
||||||
|
|
||||||
|
// messageIDsIn lists the <id> tokens of an In-Reply-To or References header,
|
||||||
|
// without their brackets, in the order written.
|
||||||
|
func messageIDsIn(header string) []string {
|
||||||
|
var ids []string
|
||||||
|
for _, field := range strings.Fields(header) {
|
||||||
|
id := strings.Trim(field, "<>,;")
|
||||||
|
if id != "" && len(id) <= 998 && strings.Contains(field, "<") {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) > maxReferences {
|
||||||
|
ids = ids[len(ids)-maxReferences:]
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package usecase
|
package usecase
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestExtractMessageHeaders_ReadsSubjectSenderAndSnippet(t *testing.T) {
|
func TestExtractMessageHeaders_ReadsSubjectSenderAndSnippet(t *testing.T) {
|
||||||
msg := "From: Alice Example <alice@remote.test>\r\n" +
|
msg := "From: Alice Example <alice@remote.test>\r\n" +
|
||||||
@@ -142,3 +146,37 @@ func TestExtractMessageHeaders_AttachmentDetection(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHeadersCarryWhatAMessageAnswers(t *testing.T) {
|
||||||
|
raw := "Message-ID: <c@x.test>\r\nIn-Reply-To: <b@x.test>\r\nReferences: <a@x.test>\r\n <b@x.test>\r\nSubject: Re: Plan\r\n\r\nbody"
|
||||||
|
h := ExtractMessageHeaders([]byte(raw))
|
||||||
|
if h.InReplyTo != "b@x.test" {
|
||||||
|
t.Errorf("InReplyTo = %q, want b@x.test", h.InReplyTo)
|
||||||
|
}
|
||||||
|
if len(h.References) != 2 || h.References[0] != "a@x.test" || h.References[1] != "b@x.test" {
|
||||||
|
t.Errorf("References = %v, want [a@x.test b@x.test]", h.References)
|
||||||
|
}
|
||||||
|
if h.MessageID != "c@x.test" {
|
||||||
|
t.Errorf("MessageID = %q", h.MessageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMessageThatAnswersNothingHasNoReferences(t *testing.T) {
|
||||||
|
h := ExtractMessageHeaders([]byte("Subject: Hi\r\n\r\nbody"))
|
||||||
|
if h.InReplyTo != "" || len(h.References) != 0 {
|
||||||
|
t.Errorf("a first message claims to answer %q %v", h.InReplyTo, h.References)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A chain can run to hundreds of ids. The ones nearest the end name the parent.
|
||||||
|
func TestAVeryLongChainKeepsTheNewestIDs(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("References:")
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
fmt.Fprintf(&b, " <m%d@x.test>", i)
|
||||||
|
}
|
||||||
|
h := ExtractMessageHeaders([]byte(b.String() + "\r\nSubject: x\r\n\r\nbody"))
|
||||||
|
if len(h.References) != maxReferences || h.References[len(h.References)-1] != "m49@x.test" {
|
||||||
|
t.Errorf("kept %d ids ending %q", len(h.References), h.References[len(h.References)-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -347,6 +347,8 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
|
|||||||
FromDisplayName: headers.FromDisplayName,
|
FromDisplayName: headers.FromDisplayName,
|
||||||
MessageIDHeader: headers.MessageID,
|
MessageIDHeader: headers.MessageID,
|
||||||
HasAttachments: headers.HasAttachments,
|
HasAttachments: headers.HasAttachments,
|
||||||
|
InReplyTo: headers.InReplyTo,
|
||||||
|
References: headers.References,
|
||||||
AlreadySeen: alreadySeen,
|
AlreadySeen: alreadySeen,
|
||||||
SPFResult: authResult.SPF,
|
SPFResult: authResult.SPF,
|
||||||
DKIMResult: authResult.DKIM,
|
DKIMResult: authResult.DKIM,
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ func (uc *ProcessOutboundEmailUseCase) fileSentCopy(ctx context.Context, input P
|
|||||||
FromDisplayName: headers.FromDisplayName,
|
FromDisplayName: headers.FromDisplayName,
|
||||||
MessageIDHeader: headers.MessageID,
|
MessageIDHeader: headers.MessageID,
|
||||||
HasAttachments: headers.HasAttachments,
|
HasAttachments: headers.HasAttachments,
|
||||||
|
InReplyTo: headers.InReplyTo,
|
||||||
|
References: headers.References,
|
||||||
// The sender's own copy is not unread mail
|
// The sender's own copy is not unread mail
|
||||||
AlreadySeen: true,
|
AlreadySeen: true,
|
||||||
}})
|
}})
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ type WebmailUseCase struct {
|
|||||||
// the submission use case, not here.)
|
// the submission use case, not here.)
|
||||||
blobStore port.BlobStorage
|
blobStore port.BlobStorage
|
||||||
messages port.InboundMessageRepository
|
messages port.InboundMessageRepository
|
||||||
|
// conversations serves the conversation views and records which message a
|
||||||
|
// reply answered. Optional; see SetConversations.
|
||||||
|
conversations port.ConversationReader
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebmailUseCase(
|
func NewWebmailUseCase(
|
||||||
@@ -91,6 +94,42 @@ func (uc *WebmailUseCase) ListMessages(
|
|||||||
return uc.repo.ListMessages(ctx, id, folder, search, limit, offset)
|
return uc.repo.ListMessages(ctx, id, folder, search, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetConversations enables the conversation views. Optional: without it the
|
||||||
|
// thread routes answer "not configured" and the webmail falls back to the
|
||||||
|
// plain message list.
|
||||||
|
func (uc *WebmailUseCase) SetConversations(c port.ConversationReader) {
|
||||||
|
uc.conversations = c
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrConversationsDisabled is returned when no conversation reader was wired.
|
||||||
|
var ErrConversationsDisabled = errors.New("conversations are not enabled")
|
||||||
|
|
||||||
|
func (uc *WebmailUseCase) ListThreads(
|
||||||
|
ctx context.Context, address, folder, search string, limit, offset int,
|
||||||
|
) ([]port.WebmailThread, error) {
|
||||||
|
if uc.conversations == nil {
|
||||||
|
return nil, ErrConversationsDisabled
|
||||||
|
}
|
||||||
|
id, err := uc.mailboxID(ctx, address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return uc.conversations.ListThreads(ctx, id, folder, search, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *WebmailUseCase) ThreadMessages(
|
||||||
|
ctx context.Context, address, folder, threadID string,
|
||||||
|
) ([]port.WebmailThreadMessage, error) {
|
||||||
|
if uc.conversations == nil {
|
||||||
|
return nil, ErrConversationsDisabled
|
||||||
|
}
|
||||||
|
id, err := uc.mailboxID(ctx, address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return uc.conversations.ThreadMessages(ctx, id, folder, threadID)
|
||||||
|
}
|
||||||
|
|
||||||
// GetMessage returns the raw RFC 5322 bytes and marks the message read.
|
// GetMessage returns the raw RFC 5322 bytes and marks the message read.
|
||||||
func (uc *WebmailUseCase) GetMessage(ctx context.Context, address, folder string, uid uint32) ([]byte, error) {
|
func (uc *WebmailUseCase) GetMessage(ctx context.Context, address, folder string, uid uint32) ([]byte, error) {
|
||||||
id, err := uc.mailboxID(ctx, address)
|
id, err := uc.mailboxID(ctx, address)
|
||||||
@@ -207,6 +246,16 @@ func (uc *WebmailUseCase) Send(ctx context.Context, input ComposeInput) error {
|
|||||||
// thing. Swallowed like the Sent copy above and for the same reason: the
|
// thing. Swallowed like the Sent copy above and for the same reason: the
|
||||||
// mail is already queued, and failing the request now would only invite a
|
// mail is already queued, and failing the request now would only invite a
|
||||||
// second copy to be sent.
|
// second copy to be sent.
|
||||||
|
// The message this one answers is marked answered. Swallowed for the same
|
||||||
|
// reason: the reply is already on its way, and a failed flag only means the
|
||||||
|
// list shows the conversation as unanswered until the next message.
|
||||||
|
if uc.conversations != nil && cleanMessageID(input.InReplyTo) != "" {
|
||||||
|
if mailboxID, idErr := uc.mailboxID(ctx, account.EmailAddress); idErr == nil {
|
||||||
|
if err := uc.conversations.MarkAnsweredByMessageID(ctx, mailboxID, input.InReplyTo); err != nil {
|
||||||
|
log.Printf("webmail: could not mark %s answered: %v", input.InReplyTo, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if input.DraftUID != 0 {
|
if input.DraftUID != 0 {
|
||||||
if mailboxID, idErr := uc.mailboxID(ctx, account.EmailAddress); idErr == nil {
|
if mailboxID, idErr := uc.mailboxID(ctx, account.EmailAddress); idErr == nil {
|
||||||
if err := uc.repo.Expunge(ctx, mailboxID, "Drafts", input.DraftUID); err != nil {
|
if err := uc.repo.Expunge(ctx, mailboxID, "Drafts", input.DraftUID); err != nil {
|
||||||
@@ -341,6 +390,8 @@ func (uc *WebmailUseCase) SaveDraft(ctx context.Context, input ComposeInput, rep
|
|||||||
FromDisplayName: headers.FromDisplayName,
|
FromDisplayName: headers.FromDisplayName,
|
||||||
MessageIDHeader: headers.MessageID,
|
MessageIDHeader: headers.MessageID,
|
||||||
HasAttachments: headers.HasAttachments,
|
HasAttachments: headers.HasAttachments,
|
||||||
|
InReplyTo: headers.InReplyTo,
|
||||||
|
References: headers.References,
|
||||||
AlreadySeen: true,
|
AlreadySeen: true,
|
||||||
}})
|
}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolveThread decides which conversation a message being filed belongs to.
|
||||||
|
//
|
||||||
|
// In order of how much each is to be trusted:
|
||||||
|
//
|
||||||
|
// 1. A message already in this mailbox with the same Message-ID. The same
|
||||||
|
// message filed twice - a copy in Sent and one delivered back to oneself,
|
||||||
|
// or a copy moved to another folder - is one conversation, not two.
|
||||||
|
// 2. The message it answers, found through In-Reply-To and then References.
|
||||||
|
// This is what actually ties a reply to its original.
|
||||||
|
// 3. A subject that starts with Re:, Fwd: or a translation of them, when the
|
||||||
|
// headers found nothing. The same key the migration used for older mail,
|
||||||
|
// so a reply to a message from before this existed still lands with it.
|
||||||
|
// 4. Otherwise it starts a conversation of its own.
|
||||||
|
//
|
||||||
|
// A message with no stored thread_id is read as a conversation of its own
|
||||||
|
// (COALESCE(thread_id, id)), so a parent from before threading is adopted by
|
||||||
|
// its own id and needs no rewrite.
|
||||||
|
func resolveThread(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessageInput) (uuid.UUID, error) {
|
||||||
|
// The message's own id goes first so that it outranks the ones it answers.
|
||||||
|
var candidates []string
|
||||||
|
if input.MessageIDHeader != "" {
|
||||||
|
candidates = append(candidates, input.MessageIDHeader)
|
||||||
|
}
|
||||||
|
parents := make([]string, 0, len(input.References)+1)
|
||||||
|
if input.InReplyTo != "" {
|
||||||
|
parents = append(parents, input.InReplyTo)
|
||||||
|
}
|
||||||
|
// Nearest ancestor first: the last reference is the direct parent.
|
||||||
|
for i := len(input.References) - 1; i >= 0; i-- {
|
||||||
|
parents = append(parents, input.References[i])
|
||||||
|
}
|
||||||
|
candidates = append(candidates, parents...)
|
||||||
|
|
||||||
|
if len(candidates) > 0 {
|
||||||
|
var found uuid.UUID
|
||||||
|
err := tx.QueryRow(ctx, `
|
||||||
|
SELECT COALESCE(thread_id, id)
|
||||||
|
FROM email_messages
|
||||||
|
WHERE mailbox_id = $1
|
||||||
|
AND message_id_header = ANY($2)
|
||||||
|
AND expunged_at IS NULL
|
||||||
|
ORDER BY array_position($2::text[], message_id_header), received_at DESC
|
||||||
|
LIMIT 1`, input.MailboxID, candidates).Scan(&found)
|
||||||
|
if err == nil {
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return uuid.Nil, fmt.Errorf("find the conversation: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if key := subjectKey(input.Subject); hasReplyPrefix(input.Subject) && key != "" {
|
||||||
|
return derivedThread(input.MailboxID, key), nil
|
||||||
|
}
|
||||||
|
return uuid.New(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The subject helpers are written here as well as in the migration's SQL
|
||||||
|
// functions on purpose: delivery must keep working against a database whose
|
||||||
|
// migrations have not all run, so it cannot call functions a later migration
|
||||||
|
// creates. A test holds the two spellings to the same answers.
|
||||||
|
var replyPrefix = regexp.MustCompile(`^(\s*(re|fw|fwd|enc|res|rv|aw|wg|tr|antw)(\[\d+\])?\s*:)+`)
|
||||||
|
|
||||||
|
func hasReplyPrefix(subject string) bool {
|
||||||
|
return replyPrefix.MatchString(strings.ToLower(subject))
|
||||||
|
}
|
||||||
|
|
||||||
|
// subjectKey is the subject with the prefixes mail clients add taken off.
|
||||||
|
func subjectKey(subject string) string {
|
||||||
|
return strings.TrimSpace(replyPrefix.ReplaceAllString(strings.ToLower(subject), ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// derivedThread names the conversation a subject belongs to when nothing in
|
||||||
|
// the headers does. md5 over the same text as the migration's backfill, so
|
||||||
|
// mail filed before and after agree on it.
|
||||||
|
func derivedThread(mailboxID uuid.UUID, key string) uuid.UUID {
|
||||||
|
sum := md5.Sum([]byte(mailboxID.String() + "|" + key))
|
||||||
|
id, _ := uuid.FromBytes(sum[:])
|
||||||
|
return id
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
type threadFixture struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
repo *InboundMessageRepository
|
||||||
|
mailboxID uuid.UUID
|
||||||
|
domain string
|
||||||
|
blob port.BlobRef
|
||||||
|
}
|
||||||
|
|
||||||
|
func newThreadFixture(t *testing.T) *threadFixture {
|
||||||
|
t.Helper()
|
||||||
|
pool := testPool(t)
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
domainID := uuid.New()
|
||||||
|
name := "thread-test-" + domainID.String() + ".invalid"
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`, domainID, name, name); err != nil {
|
||||||
|
t.Fatalf("seed domain: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID) })
|
||||||
|
|
||||||
|
mailboxID := uuid.New()
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'me', $3, 'x')`,
|
||||||
|
mailboxID, domainID, "me@"+name); err != nil {
|
||||||
|
t.Fatalf("seed mailbox: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range [][2]string{{"INBOX", "inbox"}, {"Sent", "sent"}} {
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, $3, $4)`,
|
||||||
|
uuid.New(), mailboxID, f[0], f[1]); err != nil {
|
||||||
|
t.Fatalf("seed folder %s: %v", f[0], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blobID := uuid.New()
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 10)`,
|
||||||
|
blobID, testBlobDigest()); err != nil {
|
||||||
|
t.Fatalf("seed blob: %v", err)
|
||||||
|
}
|
||||||
|
return &threadFixture{pool: pool, repo: NewInboundMessageRepository(pool), mailboxID: mailboxID, domain: name,
|
||||||
|
blob: port.BlobRef{ID: blobID, SHA256: "deadbeef", SizeBytes: 10}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// file stores one message and returns the conversation it landed in.
|
||||||
|
func (f *threadFixture) file(t *testing.T, in port.PersistInboundMessageInput) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
in.MailboxID = f.mailboxID
|
||||||
|
in.Blob = f.blob
|
||||||
|
if in.SenderAddress == "" {
|
||||||
|
in.SenderAddress = "someone@example.test"
|
||||||
|
}
|
||||||
|
in.RecipientAddress = "me@" + f.domain
|
||||||
|
in.SPFResult, in.DKIMResult, in.DMARCResult = "none", "none", "none"
|
||||||
|
if _, err := f.repo.PersistAll(context.Background(), []port.PersistInboundMessageInput{in}); err != nil {
|
||||||
|
t.Fatalf("PersistAll: %v", err)
|
||||||
|
}
|
||||||
|
var thread uuid.UUID
|
||||||
|
err := f.pool.QueryRow(context.Background(), `
|
||||||
|
SELECT COALESCE(thread_id, id) FROM email_messages
|
||||||
|
WHERE mailbox_id = $1 AND subject = $2 ORDER BY received_at DESC, uid DESC LIMIT 1`,
|
||||||
|
f.mailboxID, in.Subject).Scan(&thread)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read the conversation back: %v", err)
|
||||||
|
}
|
||||||
|
return thread
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAReplyJoinsTheMessageItAnswers(t *testing.T) {
|
||||||
|
f := newThreadFixture(t)
|
||||||
|
root := f.file(t, port.PersistInboundMessageInput{Subject: "Lunch?", MessageIDHeader: "a@x.test"})
|
||||||
|
reply := f.file(t, port.PersistInboundMessageInput{Subject: "Re: Lunch?", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"})
|
||||||
|
if reply != root {
|
||||||
|
t.Fatalf("the reply started a conversation of its own")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reply carries no Re: and a different subject, so only the headers can
|
||||||
|
// tie it to the conversation.
|
||||||
|
func TestHeadersOutrankTheSubject(t *testing.T) {
|
||||||
|
f := newThreadFixture(t)
|
||||||
|
root := f.file(t, port.PersistInboundMessageInput{Subject: "Lunch?", MessageIDHeader: "a@x.test"})
|
||||||
|
reply := f.file(t, port.PersistInboundMessageInput{Subject: "Actually dinner", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"})
|
||||||
|
if reply != root {
|
||||||
|
t.Fatalf("a reply with a changed subject left the conversation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only References survives some clients, and a long chain names the parent last.
|
||||||
|
func TestReferencesAloneFindTheConversation(t *testing.T) {
|
||||||
|
f := newThreadFixture(t)
|
||||||
|
root := f.file(t, port.PersistInboundMessageInput{Subject: "Plan", MessageIDHeader: "a@x.test"})
|
||||||
|
f.file(t, port.PersistInboundMessageInput{Subject: "Re: Plan", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"})
|
||||||
|
third := f.file(t, port.PersistInboundMessageInput{Subject: "Re: Re: Plan", MessageIDHeader: "c@x.test",
|
||||||
|
References: []string{"a@x.test", "b@x.test"}})
|
||||||
|
if third != root {
|
||||||
|
t.Fatalf("a message known only by References left the conversation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What was sent and what came back are one conversation.
|
||||||
|
func TestTheSentCopyJoinsTheConversation(t *testing.T) {
|
||||||
|
f := newThreadFixture(t)
|
||||||
|
root := f.file(t, port.PersistInboundMessageInput{Subject: "Quote", MessageIDHeader: "a@x.test"})
|
||||||
|
sent := f.file(t, port.PersistInboundMessageInput{Subject: "Re: Quote", MessageIDHeader: "mine@x.test",
|
||||||
|
InReplyTo: "a@x.test", TargetFolderName: "Sent", AlreadySeen: true})
|
||||||
|
if sent != root {
|
||||||
|
t.Fatalf("my own reply is not in the conversation it answers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnrelatedMessagesStayApart(t *testing.T) {
|
||||||
|
f := newThreadFixture(t)
|
||||||
|
one := f.file(t, port.PersistInboundMessageInput{Subject: "Invoice", MessageIDHeader: "a@x.test"})
|
||||||
|
two := f.file(t, port.PersistInboundMessageInput{Subject: "Invoice", MessageIDHeader: "b@x.test"})
|
||||||
|
if one == two {
|
||||||
|
t.Fatalf("two unrelated messages that share a subject were merged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reply that names nothing is still a reply by its subject.
|
||||||
|
func TestAReplyWithNoHeadersFallsBackToItsSubject(t *testing.T) {
|
||||||
|
f := newThreadFixture(t)
|
||||||
|
a := f.file(t, port.PersistInboundMessageInput{Subject: "Re: Status"})
|
||||||
|
b := f.file(t, port.PersistInboundMessageInput{Subject: "RE: Re: STATUS"})
|
||||||
|
if a != b {
|
||||||
|
t.Fatalf("replies to the same subject were not grouped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same message filed twice, such as a copy sent to oneself, is one conversation.
|
||||||
|
func TestTheSameMessageFiledTwiceIsOneConversation(t *testing.T) {
|
||||||
|
f := newThreadFixture(t)
|
||||||
|
a := f.file(t, port.PersistInboundMessageInput{Subject: "Note", MessageIDHeader: "n@x.test", TargetFolderName: "Sent", AlreadySeen: true})
|
||||||
|
b := f.file(t, port.PersistInboundMessageInput{Subject: "Note", MessageIDHeader: "n@x.test"})
|
||||||
|
if a != b {
|
||||||
|
t.Fatalf("the sent copy and the delivered copy are two conversations")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mail from before threading has no thread_id and is its own conversation, so
|
||||||
|
// a reply must adopt it by id rather than orphan it.
|
||||||
|
func TestAReplyAdoptsAMessageFromBeforeThreading(t *testing.T) {
|
||||||
|
f := newThreadFixture(t)
|
||||||
|
f.file(t, port.PersistInboundMessageInput{Subject: "Old mail", MessageIDHeader: "old@x.test"})
|
||||||
|
if _, err := f.pool.Exec(context.Background(), `UPDATE email_messages SET thread_id = NULL WHERE mailbox_id = $1`, f.mailboxID); err != nil {
|
||||||
|
t.Fatalf("simulate legacy: %v", err)
|
||||||
|
}
|
||||||
|
var legacyID uuid.UUID
|
||||||
|
if err := f.pool.QueryRow(context.Background(), `SELECT id FROM email_messages WHERE mailbox_id = $1`, f.mailboxID).Scan(&legacyID); err != nil {
|
||||||
|
t.Fatalf("read legacy id: %v", err)
|
||||||
|
}
|
||||||
|
reply := f.file(t, port.PersistInboundMessageInput{Subject: "Re: Old mail", MessageIDHeader: "new@x.test", InReplyTo: "old@x.test"})
|
||||||
|
if reply != legacyID {
|
||||||
|
t.Fatalf("the reply did not adopt the older message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reading conversations --------------------------------------------------
|
||||||
|
|
||||||
|
func (f *threadFixture) own(t *testing.T, in port.PersistInboundMessageInput) {
|
||||||
|
t.Helper()
|
||||||
|
in.TargetFolderName = "Sent"
|
||||||
|
in.AlreadySeen = true
|
||||||
|
in.SenderAddress = "me@" + f.domain
|
||||||
|
f.file(t, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *threadFixture) inbox(t *testing.T, from string, in port.PersistInboundMessageInput) {
|
||||||
|
t.Helper()
|
||||||
|
in.SenderAddress = from
|
||||||
|
f.file(t, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheListHasOneRowPerConversationWithItsCount(t *testing.T) {
|
||||||
|
f := newThreadFixture(t)
|
||||||
|
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Plan", MessageIDHeader: "a@x.test"})
|
||||||
|
f.own(t, port.PersistInboundMessageInput{Subject: "Re: Plan", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"})
|
||||||
|
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Re: Plan", MessageIDHeader: "c@x.test", InReplyTo: "b@x.test"})
|
||||||
|
f.inbox(t, "bob@x.test", port.PersistInboundMessageInput{Subject: "Other", MessageIDHeader: "z@x.test"})
|
||||||
|
|
||||||
|
threads, err := NewConversations(f.pool).ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListThreads: %v", err)
|
||||||
|
}
|
||||||
|
if len(threads) != 2 {
|
||||||
|
t.Fatalf("got %d rows, want 2 conversations", len(threads))
|
||||||
|
}
|
||||||
|
plan := threads[0]
|
||||||
|
if !strings.Contains(plan.Subject, "Plan") {
|
||||||
|
plan = threads[1]
|
||||||
|
}
|
||||||
|
// The reply written from Sent counts towards the conversation shown in the inbox.
|
||||||
|
if plan.MessageCount != 3 {
|
||||||
|
t.Errorf("the conversation has %d messages, want 3 (two received, one sent)", plan.MessageCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a reader wants to know at a glance.
|
||||||
|
func TestAConversationIsAnsweredWhenIReplied(t *testing.T) {
|
||||||
|
f := newThreadFixture(t)
|
||||||
|
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Quote", MessageIDHeader: "a@x.test"})
|
||||||
|
conv := NewConversations(f.pool)
|
||||||
|
before, _ := conv.ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0)
|
||||||
|
if before[0].Answered {
|
||||||
|
t.Fatalf("an unanswered conversation was shown as answered")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reply written in another mail program leaves no \Answered flag, only the message.
|
||||||
|
f.own(t, port.PersistInboundMessageInput{Subject: "Re: Quote", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"})
|
||||||
|
after, _ := conv.ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0)
|
||||||
|
if !after[0].Answered {
|
||||||
|
t.Fatalf("the conversation I replied to is not shown as answered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANewMessageAfterMyReplyIsUnansweredAgain(t *testing.T) {
|
||||||
|
f := newThreadFixture(t)
|
||||||
|
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Quote", MessageIDHeader: "a@x.test"})
|
||||||
|
f.own(t, port.PersistInboundMessageInput{Subject: "Re: Quote", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"})
|
||||||
|
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Re: Quote", MessageIDHeader: "c@x.test", InReplyTo: "b@x.test"})
|
||||||
|
threads, _ := NewConversations(f.pool).ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0)
|
||||||
|
if threads[0].Answered {
|
||||||
|
t.Fatalf("a conversation waiting on my reply was shown as answered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkingAMessageAnsweredFlagsIt(t *testing.T) {
|
||||||
|
f := newThreadFixture(t)
|
||||||
|
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Ask", MessageIDHeader: "a@x.test"})
|
||||||
|
conv := NewConversations(f.pool)
|
||||||
|
if err := conv.MarkAnsweredByMessageID(context.Background(), f.mailboxID.String(), "<a@x.test>"); err != nil {
|
||||||
|
t.Fatalf("MarkAnsweredByMessageID: %v", err)
|
||||||
|
}
|
||||||
|
threads, _ := conv.ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0)
|
||||||
|
msgs, err := conv.ThreadMessages(context.Background(), f.mailboxID.String(), "INBOX", threads[0].ThreadID)
|
||||||
|
if err != nil || len(msgs) != 1 || !msgs[0].Answered {
|
||||||
|
t.Fatalf("the flag did not show: %v %+v", err, msgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAConversationOfAnotherMailboxCannotBeRead(t *testing.T) {
|
||||||
|
f := newThreadFixture(t)
|
||||||
|
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Private", MessageIDHeader: "a@x.test"})
|
||||||
|
conv := NewConversations(f.pool)
|
||||||
|
threads, _ := conv.ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0)
|
||||||
|
|
||||||
|
other := newThreadFixture(t)
|
||||||
|
if _, err := conv.ThreadMessages(context.Background(), other.mailboxID.String(), "INBOX", threads[0].ThreadID); err == nil {
|
||||||
|
t.Fatalf("another mailbox read this conversation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThreadMessagesAreOldestFirstAndLeaveOutTrash(t *testing.T) {
|
||||||
|
f := newThreadFixture(t)
|
||||||
|
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Topic", MessageIDHeader: "a@x.test"})
|
||||||
|
f.own(t, port.PersistInboundMessageInput{Subject: "Re: Topic", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"})
|
||||||
|
conv := NewConversations(f.pool)
|
||||||
|
threads, _ := conv.ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0)
|
||||||
|
msgs, err := conv.ThreadMessages(context.Background(), f.mailboxID.String(), "INBOX", threads[0].ThreadID)
|
||||||
|
if err != nil || len(msgs) != 2 {
|
||||||
|
t.Fatalf("got %d messages (%v), want 2", len(msgs), err)
|
||||||
|
}
|
||||||
|
if msgs[0].Own || !msgs[1].Own || !msgs[0].Answered {
|
||||||
|
t.Errorf("order or flags wrong: %+v", msgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery computes the subject key itself, and the migration backfilled older
|
||||||
|
// mail with SQL. If the two ever disagreed, a reply would miss the conversation
|
||||||
|
// its older messages were put in.
|
||||||
|
func TestGoAndSQLAgreeOnTheSubjectKey(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
defer pool.Close()
|
||||||
|
mailbox := uuid.New()
|
||||||
|
for _, subject := range []string{
|
||||||
|
"Plan", "Re: Plan", "RE: Re[2]: Fwd: Plan ", " re :plan", "AW: Plan", "ENC: Plan", "Reunion", "Re:", "",
|
||||||
|
"Fw:Fw: invoice 12", "Resumo: vendas", "Antw: Re: Hallo",
|
||||||
|
} {
|
||||||
|
var sqlKey string
|
||||||
|
var sqlPrefix bool
|
||||||
|
var sqlThread uuid.UUID
|
||||||
|
if err := pool.QueryRow(context.Background(),
|
||||||
|
`SELECT lm_subject_key($1), lm_has_reply_prefix($1), md5($2::text || '|' || lm_subject_key($1))::uuid`,
|
||||||
|
subject, mailbox).Scan(&sqlKey, &sqlPrefix, &sqlThread); err != nil {
|
||||||
|
t.Fatalf("%q: %v", subject, err)
|
||||||
|
}
|
||||||
|
if got := subjectKey(subject); got != sqlKey {
|
||||||
|
t.Errorf("%q: Go key %q, SQL key %q", subject, got, sqlKey)
|
||||||
|
}
|
||||||
|
if got := hasReplyPrefix(subject); got != sqlPrefix {
|
||||||
|
t.Errorf("%q: Go prefix %v, SQL prefix %v", subject, got, sqlPrefix)
|
||||||
|
}
|
||||||
|
if got := derivedThread(mailbox, sqlKey); got != sqlThread {
|
||||||
|
t.Errorf("%q: Go thread %v, SQL thread %v", subject, got, sqlThread)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Conversations reads the mailbox as conversations rather than as single
|
||||||
|
// messages. Writing is MailStore's job, as for every other change.
|
||||||
|
type Conversations struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
store *MailStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConversations(pool *pgxpool.Pool) *Conversations {
|
||||||
|
return &Conversations{pool: pool, store: NewMailStore(pool)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeRow is one message that counts towards a conversation.
|
||||||
|
type scopeRow struct {
|
||||||
|
threadID uuid.UUID
|
||||||
|
uid uint32
|
||||||
|
folder string
|
||||||
|
folderID string
|
||||||
|
subject string
|
||||||
|
sender string
|
||||||
|
name string
|
||||||
|
snippet string
|
||||||
|
receivedAt time.Time
|
||||||
|
hasAttach bool
|
||||||
|
seen bool
|
||||||
|
flagged bool
|
||||||
|
answeredFlag bool
|
||||||
|
own bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeSQL selects the messages of the given conversations that belong in a
|
||||||
|
// view of one folder: that folder's own messages, and the rest of the
|
||||||
|
// conversation wherever it is filed, except what has been thrown away. Trash,
|
||||||
|
// Junk and Drafts are left out of a conversation seen from elsewhere - a
|
||||||
|
// reply still being written, or one deleted, is not part of the thread a reader
|
||||||
|
// is looking at - and are the whole story when that is the folder viewed.
|
||||||
|
const scopeSQL = `
|
||||||
|
SELECT COALESCE(e.thread_id, e.id), e.uid, fo.name, fo.id::text,
|
||||||
|
COALESCE(e.subject, ''), e.sender_address::text,
|
||||||
|
COALESCE(e.from_display_name, ''), COALESCE(e.snippet, ''),
|
||||||
|
e.received_at, e.has_attachments,
|
||||||
|
EXISTS (SELECT 1 FROM message_flags f WHERE f.message_id = e.id AND f.received_at = e.received_at AND f.flag = '\Seen'),
|
||||||
|
EXISTS (SELECT 1 FROM message_flags f WHERE f.message_id = e.id AND f.received_at = e.received_at AND f.flag = '\Flagged'),
|
||||||
|
EXISTS (SELECT 1 FROM message_flags f WHERE f.message_id = e.id AND f.received_at = e.received_at AND f.flag = '\Answered'),
|
||||||
|
e.sender_address::text = mb.email_address::text
|
||||||
|
FROM email_messages e
|
||||||
|
JOIN folders fo ON fo.id = e.folder_id
|
||||||
|
JOIN mailboxes mb ON mb.id = e.mailbox_id
|
||||||
|
WHERE e.mailbox_id = $1
|
||||||
|
AND e.expunged_at IS NULL
|
||||||
|
AND COALESCE(e.thread_id, e.id) = ANY($3::uuid[])
|
||||||
|
AND (fo.id::text = $2
|
||||||
|
OR (COALESCE(fo.special_use, '') NOT IN ('trash', 'junk', 'drafts')
|
||||||
|
AND lower(fo.name) NOT IN ('trash', 'junk', 'drafts')))
|
||||||
|
ORDER BY e.received_at ASC, e.uid ASC`
|
||||||
|
|
||||||
|
func (c *Conversations) scope(ctx context.Context, mailboxID, folderID string, threads []uuid.UUID) (map[uuid.UUID][]scopeRow, error) {
|
||||||
|
rows, err := c.pool.Query(ctx, scopeSQL, mailboxID, folderID, threads)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read the conversations: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[uuid.UUID][]scopeRow, len(threads))
|
||||||
|
for rows.Next() {
|
||||||
|
var r scopeRow
|
||||||
|
var uid int64
|
||||||
|
if err := rows.Scan(&r.threadID, &uid, &r.folder, &r.folderID, &r.subject, &r.sender, &r.name,
|
||||||
|
&r.snippet, &r.receivedAt, &r.hasAttach, &r.seen, &r.flagged, &r.answeredFlag, &r.own); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.uid = uint32(uid)
|
||||||
|
out[r.threadID] = append(out[r.threadID], r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conversations) ListThreads(
|
||||||
|
ctx context.Context, mailboxID, folderName, search string, limit, offset int,
|
||||||
|
) ([]port.WebmailThread, error) {
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
folderID, err := c.store.ResolveFolder(ctx, mailboxID, folderName)
|
||||||
|
if errors.Is(err, port.ErrFolderMissing) {
|
||||||
|
return []port.WebmailThread{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page is chosen first, by the newest message each conversation has in
|
||||||
|
// this folder, and only then are those conversations read in full.
|
||||||
|
rows, err := c.pool.Query(ctx, `
|
||||||
|
SELECT COALESCE(thread_id, id)
|
||||||
|
FROM email_messages
|
||||||
|
WHERE folder_id = $1 AND expunged_at IS NULL
|
||||||
|
AND ($2 = '' OR subject ILIKE '%' || $2 || '%'
|
||||||
|
OR sender_address::text ILIKE '%' || $2 || '%'
|
||||||
|
OR snippet ILIKE '%' || $2 || '%')
|
||||||
|
GROUP BY COALESCE(thread_id, id)
|
||||||
|
ORDER BY max(received_at) DESC, max(uid) DESC
|
||||||
|
LIMIT $3 OFFSET $4`, folderID, search, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list conversations: %w", err)
|
||||||
|
}
|
||||||
|
var page []uuid.UUID
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
page = append(page, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(page) == 0 {
|
||||||
|
return []port.WebmailThread{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
byThread, err := c.scope(ctx, mailboxID, folderID, page)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]port.WebmailThread, 0, len(page))
|
||||||
|
for _, id := range page {
|
||||||
|
if t, ok := summarise(id, byThread[id], folderID); ok {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarise folds a conversation's messages into its list row. It reports
|
||||||
|
// false for a conversation with nothing in the folder viewed.
|
||||||
|
func summarise(id uuid.UUID, msgs []scopeRow, folderID string) (port.WebmailThread, bool) {
|
||||||
|
var latestHere *scopeRow
|
||||||
|
for i := range msgs {
|
||||||
|
if msgs[i].folderID == folderID {
|
||||||
|
latestHere = &msgs[i] // oldest first, so the last one wins
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if latestHere == nil {
|
||||||
|
return port.WebmailThread{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
t := port.WebmailThread{
|
||||||
|
ThreadID: id.String(), UID: latestHere.uid, Subject: latestHere.subject,
|
||||||
|
SenderAddress: latestHere.sender, FromName: latestHere.name, Snippet: latestHere.snippet,
|
||||||
|
ReceivedAt: latestHere.receivedAt, MessageCount: len(msgs),
|
||||||
|
Answered: answered(msgs),
|
||||||
|
Participants: participants(msgs),
|
||||||
|
}
|
||||||
|
for _, m := range msgs {
|
||||||
|
if !m.seen {
|
||||||
|
t.UnseenCount++
|
||||||
|
}
|
||||||
|
t.Flagged = t.Flagged || m.flagged
|
||||||
|
t.HasAttachments = t.HasAttachments || m.hasAttach
|
||||||
|
}
|
||||||
|
t.Seen = t.UnseenCount == 0
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// answered reports whether the last thing someone else wrote has been replied
|
||||||
|
// to - by the flag, or by a message of the owner's that came after it.
|
||||||
|
func answered(msgs []scopeRow) bool {
|
||||||
|
lastIncoming := -1
|
||||||
|
for i, m := range msgs {
|
||||||
|
if !m.own {
|
||||||
|
lastIncoming = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastIncoming < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if msgs[lastIncoming].answeredFlag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, m := range msgs[lastIncoming+1:] {
|
||||||
|
if m.own {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// participants lists who wrote, in the order they first did, at most four.
|
||||||
|
func participants(msgs []scopeRow) []port.WebmailParticipant {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []port.WebmailParticipant
|
||||||
|
for _, m := range msgs {
|
||||||
|
key := strings.ToLower(m.sender)
|
||||||
|
if seen[key] || len(out) == 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
name := m.name
|
||||||
|
if name == "" {
|
||||||
|
name = m.sender
|
||||||
|
}
|
||||||
|
out = append(out, port.WebmailParticipant{Name: name, Address: m.sender, Me: m.own})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conversations) ThreadMessages(
|
||||||
|
ctx context.Context, mailboxID, folderName, threadID string,
|
||||||
|
) ([]port.WebmailThreadMessage, error) {
|
||||||
|
id, err := uuid.Parse(threadID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrMessageNotFound
|
||||||
|
}
|
||||||
|
folderID, err := c.store.ResolveFolder(ctx, mailboxID, folderName)
|
||||||
|
if errors.Is(err, port.ErrFolderMissing) {
|
||||||
|
return nil, ErrMessageNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byThread, err := c.scope(ctx, mailboxID, folderID, []uuid.UUID{id})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msgs := byThread[id]
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
// Another mailbox's conversation and one that does not exist look alike.
|
||||||
|
return nil, ErrMessageNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]port.WebmailThreadMessage, 0, len(msgs))
|
||||||
|
for i, m := range msgs {
|
||||||
|
out = append(out, port.WebmailThreadMessage{
|
||||||
|
UID: m.uid, Folder: m.folder, Subject: m.subject, SenderAddress: m.sender, FromName: m.name,
|
||||||
|
Snippet: m.snippet, ReceivedAt: m.receivedAt, HasAttachments: m.hasAttach,
|
||||||
|
Seen: m.seen, Flagged: m.flagged, Own: m.own,
|
||||||
|
Answered: !m.own && (m.answeredFlag || ownAfter(msgs, i)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(a, b int) bool { return out[a].ReceivedAt.Before(out[b].ReceivedAt) })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownAfter(msgs []scopeRow, i int) bool {
|
||||||
|
for _, m := range msgs[i+1:] {
|
||||||
|
if m.own {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAnsweredByMessageID sets \Answered on the message a reply was written to.
|
||||||
|
func (c *Conversations) MarkAnsweredByMessageID(ctx context.Context, mailboxID, messageID string) error {
|
||||||
|
messageID = strings.Trim(strings.TrimSpace(messageID), "<>")
|
||||||
|
if messageID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows, err := c.pool.Query(ctx, `
|
||||||
|
SELECT fo.id::text, e.uid
|
||||||
|
FROM email_messages e JOIN folders fo ON fo.id = e.folder_id
|
||||||
|
WHERE e.mailbox_id = $1 AND e.message_id_header = $2 AND e.expunged_at IS NULL
|
||||||
|
AND COALESCE(fo.special_use, '') NOT IN ('sent', 'drafts', 'trash')`, mailboxID, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("find the message that was answered: %w", err)
|
||||||
|
}
|
||||||
|
type target struct {
|
||||||
|
folder string
|
||||||
|
uid int64
|
||||||
|
}
|
||||||
|
var targets []target
|
||||||
|
for rows.Next() {
|
||||||
|
var t target
|
||||||
|
if err := rows.Scan(&t.folder, &t.uid); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
targets = append(targets, t)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
for _, t := range targets {
|
||||||
|
if err := func() error {
|
||||||
|
_, e := c.store.SetFlags(ctx, t.folder, uint32(t.uid), port.FlagOpAdd, []string{`\Answered`}, 0)
|
||||||
|
return e
|
||||||
|
}(); err != nil {
|
||||||
|
return fmt.Errorf("flag the answered message: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -124,6 +124,11 @@ func persistOne(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessage
|
|||||||
// id and received_at come back because message_flags is keyed on both:
|
// id and received_at come back because message_flags is keyed on both:
|
||||||
// email_messages is partitioned by received_at, so the flag rows cannot be
|
// email_messages is partitioned by received_at, so the flag rows cannot be
|
||||||
// written without the same timestamp the row landed on.
|
// written without the same timestamp the row landed on.
|
||||||
|
threadID, err := resolveThread(ctx, tx, input)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
var messageID uuid.UUID
|
var messageID uuid.UUID
|
||||||
var receivedAt time.Time
|
var receivedAt time.Time
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
@@ -131,8 +136,8 @@ func persistOne(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessage
|
|||||||
mailbox_id, folder_id, uid, modseq, blob_id,
|
mailbox_id, folder_id, uid, modseq, blob_id,
|
||||||
sender_address, recipient_addresses, size_bytes,
|
sender_address, recipient_addresses, size_bytes,
|
||||||
subject, snippet, from_display_name, message_id_header, has_attachments,
|
subject, snippet, from_display_name, message_id_header, has_attachments,
|
||||||
spf_result, dkim_result, dmarc_result
|
spf_result, dkim_result, dmarc_result, in_reply_to, thread_id
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||||
RETURNING id, received_at
|
RETURNING id, received_at
|
||||||
`, input.MailboxID, folderID, uid, modseq, input.Blob.ID,
|
`, input.MailboxID, folderID, uid, modseq, input.Blob.ID,
|
||||||
input.SenderAddress, []string{input.RecipientAddress}, input.Blob.SizeBytes,
|
input.SenderAddress, []string{input.RecipientAddress}, input.Blob.SizeBytes,
|
||||||
@@ -147,7 +152,8 @@ func persistOne(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessage
|
|||||||
// Sent copy and drafts have no authentication results by definition -
|
// Sent copy and drafts have no authentication results by definition -
|
||||||
// and it failed the whole insert. Sent stayed empty because that write
|
// and it failed the whole insert. Sent stayed empty because that write
|
||||||
// is deliberately non-fatal, and saving a draft reported an error.
|
// is deliberately non-fatal, and saving a draft reported an error.
|
||||||
nullIfEmpty(input.SPFResult), nullIfEmpty(input.DKIMResult), nullIfEmpty(input.DMARCResult)).
|
nullIfEmpty(input.SPFResult), nullIfEmpty(input.DKIMResult), nullIfEmpty(input.DMARCResult),
|
||||||
|
nullIfEmpty(input.InReplyTo), threadID).
|
||||||
Scan(&messageID, &receivedAt)
|
Scan(&messageID, &receivedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("insert email_messages: %w", err)
|
return 0, fmt.Errorf("insert email_messages: %w", err)
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ func (s *MailStore) copyOne(ctx context.Context, sourceFolderID string, sourceUI
|
|||||||
SELECT $2::uuid, $3::uuid, $4::bigint, $5::bigint, blob_id,
|
SELECT $2::uuid, $3::uuid, $4::bigint, $5::bigint, blob_id,
|
||||||
sender_address, recipient_addresses, size_bytes,
|
sender_address, recipient_addresses, size_bytes,
|
||||||
spf_result, dkim_result, dmarc_result,
|
spf_result, dkim_result, dmarc_result,
|
||||||
from_display_name, subject, snippet, message_id_header, in_reply_to, thread_id, has_attachments,
|
from_display_name, subject, snippet, message_id_header, in_reply_to, COALESCE(thread_id, id), has_attachments,
|
||||||
arc_result, dane_status, tls_version, spam_score, spam_verdict, virus_name
|
arc_result, dane_status, tls_version, spam_score, spam_verdict, virus_name
|
||||||
FROM email_messages WHERE id = $1 AND received_at = $6
|
FROM email_messages WHERE id = $1 AND received_at = $6
|
||||||
RETURNING id, received_at`,
|
RETURNING id, received_at`,
|
||||||
|
|||||||
@@ -102,6 +102,47 @@ func (m *mailAPI) handleMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, messages)
|
writeJSON(w, http.StatusOK, messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleThreads lists the folder as conversations, one row each.
|
||||||
|
func (m *mailAPI) handleThreads(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, ok := m.authenticate(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := r.URL.Query()
|
||||||
|
folder := query.Get("folder")
|
||||||
|
if folder == "" {
|
||||||
|
folder = "inbox"
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(query.Get("limit"))
|
||||||
|
offset, _ := strconv.Atoi(query.Get("offset"))
|
||||||
|
|
||||||
|
threads, err := m.useCase.ListThreads(r.Context(), session.Email, folder, query.Get("q"), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
m.fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, threads)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleThread returns the messages of one conversation, oldest first.
|
||||||
|
func (m *mailAPI) handleThread(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, ok := m.authenticate(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
threadID := strings.TrimPrefix(r.URL.Path, "/api/v1/mail/thread/")
|
||||||
|
folder := r.URL.Query().Get("folder")
|
||||||
|
if folder == "" {
|
||||||
|
folder = "inbox"
|
||||||
|
}
|
||||||
|
messages, err := m.useCase.ThreadMessages(r.Context(), session.Email, folder, threadID)
|
||||||
|
if err != nil {
|
||||||
|
m.fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, messages)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mailAPI) handleMessage(w http.ResponseWriter, r *http.Request) {
|
func (m *mailAPI) handleMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
session, ok := m.authenticate(w, r)
|
session, ok := m.authenticate(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -482,6 +523,8 @@ func (m *mailAPI) decodeCompose(
|
|||||||
// fail maps domain errors onto status codes without leaking internals.
|
// fail maps domain errors onto status codes without leaking internals.
|
||||||
func (m *mailAPI) fail(w http.ResponseWriter, err error) {
|
func (m *mailAPI) fail(w http.ResponseWriter, err error) {
|
||||||
switch {
|
switch {
|
||||||
|
case errors.Is(err, usecase.ErrConversationsDisabled):
|
||||||
|
writeError(w, http.StatusNotImplemented, "CONVERSATIONS_DISABLED", "Conversations are not enabled")
|
||||||
case errors.Is(err, postgres.ErrMessageNotFound), errors.Is(err, usecase.ErrNoSuchMailbox):
|
case errors.Is(err, postgres.ErrMessageNotFound), errors.Is(err, usecase.ErrNoSuchMailbox):
|
||||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "Message not found")
|
writeError(w, http.StatusNotFound, "NOT_FOUND", "Message not found")
|
||||||
case errors.Is(err, usecase.ErrNoRecipients):
|
case errors.Is(err, usecase.ErrNoRecipients):
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ func (r *Router) registerRoutes() {
|
|||||||
r.mux.HandleFunc("/api/v1/mail/folders", r.handleMailFolders)
|
r.mux.HandleFunc("/api/v1/mail/folders", r.handleMailFolders)
|
||||||
r.mux.HandleFunc("/api/v1/mail/messages", r.handleMailMessages)
|
r.mux.HandleFunc("/api/v1/mail/messages", r.handleMailMessages)
|
||||||
r.mux.HandleFunc("/api/v1/mail/message/", r.handleMailMessage)
|
r.mux.HandleFunc("/api/v1/mail/message/", r.handleMailMessage)
|
||||||
|
r.mux.HandleFunc("/api/v1/mail/threads", r.handleMailThreads)
|
||||||
|
r.mux.HandleFunc("/api/v1/mail/thread/", r.handleMailThread)
|
||||||
r.mux.HandleFunc("/api/v1/mail/seen", r.handleMailSeen)
|
r.mux.HandleFunc("/api/v1/mail/seen", r.handleMailSeen)
|
||||||
r.mux.HandleFunc("/api/v1/mail/flagged", r.handleMailFlagged)
|
r.mux.HandleFunc("/api/v1/mail/flagged", r.handleMailFlagged)
|
||||||
r.mux.HandleFunc("/api/v1/mail/send", r.handleMailSend)
|
r.mux.HandleFunc("/api/v1/mail/send", r.handleMailSend)
|
||||||
@@ -287,6 +289,18 @@ func (r *Router) handleMailFolders(w http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Router) handleMailThreads(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if r.mailReady(w) {
|
||||||
|
r.mail.handleThreads(w, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) handleMailThread(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if r.mailReady(w) {
|
||||||
|
r.mail.handleThread(w, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Router) handleMailMessages(w http.ResponseWriter, req *http.Request) {
|
func (r *Router) handleMailMessages(w http.ResponseWriter, req *http.Request) {
|
||||||
if r.mailReady(w) {
|
if r.mailReady(w) {
|
||||||
r.mail.handleMessages(w, req)
|
r.mail.handleMessages(w, req)
|
||||||
|
|||||||
@@ -548,5 +548,21 @@
|
|||||||
"signOutAll": "Sign out of all accounts",
|
"signOutAll": "Sign out of all accounts",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
"settings": "Account settings"
|
"settings": "Account settings"
|
||||||
|
},
|
||||||
|
"conversations": {
|
||||||
|
"conversationView": "Group mail into conversations",
|
||||||
|
"conversationViewHint": "Show a message and its replies together, and whether you have answered.",
|
||||||
|
"messagesInConversation": "{count} messages",
|
||||||
|
"youReplied": "You replied",
|
||||||
|
"notReplied": "Not answered yet",
|
||||||
|
"expandAll": "Expand all",
|
||||||
|
"collapseOlder": "Collapse older",
|
||||||
|
"loadingMessage": "Loading message…",
|
||||||
|
"replyBelow": "Reply",
|
||||||
|
"conversationEmpty": "This conversation is empty.",
|
||||||
|
"me": "me",
|
||||||
|
"draftDiscarded": "Reply discarded",
|
||||||
|
"sentReply": "Reply sent",
|
||||||
|
"moreMessages": "Show {count} earlier messages"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -548,5 +548,21 @@
|
|||||||
"signOutAll": "Cerrar sesión en todas las cuentas",
|
"signOutAll": "Cerrar sesión en todas las cuentas",
|
||||||
"active": "Activa",
|
"active": "Activa",
|
||||||
"settings": "Ajustes de la cuenta"
|
"settings": "Ajustes de la cuenta"
|
||||||
|
},
|
||||||
|
"conversations": {
|
||||||
|
"conversationView": "Agrupar el correo en conversaciones",
|
||||||
|
"conversationViewHint": "Muestra un mensaje y sus respuestas juntos, y si ya respondiste.",
|
||||||
|
"messagesInConversation": "{count} mensajes",
|
||||||
|
"youReplied": "Respondiste",
|
||||||
|
"notReplied": "Aún sin responder",
|
||||||
|
"expandAll": "Expandir todo",
|
||||||
|
"collapseOlder": "Contraer anteriores",
|
||||||
|
"loadingMessage": "Cargando mensaje…",
|
||||||
|
"replyBelow": "Responder",
|
||||||
|
"conversationEmpty": "Esta conversación está vacía.",
|
||||||
|
"me": "yo",
|
||||||
|
"draftDiscarded": "Respuesta descartada",
|
||||||
|
"sentReply": "Respuesta enviada",
|
||||||
|
"moreMessages": "Mostrar {count} mensajes anteriores"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -548,5 +548,21 @@
|
|||||||
"signOutAll": "Sair de todas as contas",
|
"signOutAll": "Sair de todas as contas",
|
||||||
"active": "Ativa",
|
"active": "Ativa",
|
||||||
"settings": "Configurações da conta"
|
"settings": "Configurações da conta"
|
||||||
|
},
|
||||||
|
"conversations": {
|
||||||
|
"conversationView": "Agrupar e-mails em conversas",
|
||||||
|
"conversationViewHint": "Mostra uma mensagem e as respostas juntas, e se você já respondeu.",
|
||||||
|
"messagesInConversation": "{count} mensagens",
|
||||||
|
"youReplied": "Você respondeu",
|
||||||
|
"notReplied": "Ainda não respondida",
|
||||||
|
"expandAll": "Expandir tudo",
|
||||||
|
"collapseOlder": "Recolher anteriores",
|
||||||
|
"loadingMessage": "Carregando mensagem…",
|
||||||
|
"replyBelow": "Responder",
|
||||||
|
"conversationEmpty": "Esta conversa está vazia.",
|
||||||
|
"me": "eu",
|
||||||
|
"draftDiscarded": "Resposta descartada",
|
||||||
|
"sentReply": "Resposta enviada",
|
||||||
|
"moreMessages": "Mostrar {count} mensagens anteriores"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,583 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState, use } from "react";
|
import { use } from "react";
|
||||||
import Link from "next/link";
|
import { Composer, type ComposerInitial } from "../../../../components/Composer";
|
||||||
import {
|
|
||||||
Send,
|
|
||||||
Paperclip,
|
|
||||||
Bold,
|
|
||||||
Italic,
|
|
||||||
Underline,
|
|
||||||
Strikethrough,
|
|
||||||
List,
|
|
||||||
ListOrdered,
|
|
||||||
Link as LinkIcon,
|
|
||||||
Undo2,
|
|
||||||
X,
|
|
||||||
Sparkles,
|
|
||||||
CheckCircle2,
|
|
||||||
Flag,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
|
||||||
import { Button } from "../../../../components/ui/Button";
|
|
||||||
import { notifyMailStateChanged } from "../../../../lib/useFolders";
|
|
||||||
import { signatureToHtml } from "../../../../lib/signature";
|
|
||||||
import { RecipientInput } from "../../../../components/RecipientInput";
|
|
||||||
import { parseAddress, parseAddressList, prefixSubject } from "../../../../lib/recipients";
|
|
||||||
|
|
||||||
/** Entries from a query-string list, which may be several addresses. */
|
/**
|
||||||
function entriesFrom(value: string | undefined): string[] {
|
* The full compose screen. The composer itself lives in components/Composer so
|
||||||
return value ? parseAddressList(value).map((p) => p.entry) : [];
|
* the reply written under a conversation is the same code, not a copy of it.
|
||||||
}
|
*/
|
||||||
|
export default function ComposePage({ searchParams }: { searchParams?: Promise<ComposerInitial> }) {
|
||||||
interface AttachedFile {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
size: number;
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ComposePage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams?: Promise<{
|
|
||||||
replyTo?: string;
|
|
||||||
cc?: string;
|
|
||||||
subject?: string;
|
|
||||||
inReplyTo?: string;
|
|
||||||
mode?: string;
|
|
||||||
draft?: string;
|
|
||||||
}>;
|
|
||||||
}) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const params = searchParams ? use(searchParams) : {};
|
const params = searchParams ? use(searchParams) : {};
|
||||||
|
|
||||||
// Recipients are lists of entries, not one string to be split later: a
|
|
||||||
// reply-all arrives with several people already in To and Cc.
|
|
||||||
const [to, setTo] = useState<string[]>(() => entriesFrom(params?.replyTo));
|
|
||||||
const [cc, setCc] = useState<string[]>(() => entriesFrom(params?.cc));
|
|
||||||
const [bcc, setBcc] = useState<string[]>([]);
|
|
||||||
const [showCcBcc, setShowCcBcc] = useState(() => entriesFrom(params?.cc).length > 0);
|
|
||||||
const [subject, setSubject] = useState(
|
|
||||||
params?.subject ? prefixSubject(params.subject, params.mode === "forward" ? "forward" : "reply") : "",
|
|
||||||
);
|
|
||||||
// The Message-ID being answered, so the reply threads under it.
|
|
||||||
const [inReplyTo, setInReplyTo] = useState(params?.mode === "forward" ? "" : params?.inReplyTo || "");
|
|
||||||
const [important, setImportant] = useState(false);
|
|
||||||
const [attachments, setAttachments] = useState<AttachedFile[]>([]);
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [undoSeconds, setUndoSeconds] = useState<number | null>(null);
|
|
||||||
const [draftStatus, setDraftStatus] = useState<string>("");
|
|
||||||
const [discarding, setDiscarding] = useState(false);
|
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
|
||||||
// The UID of the draft already stored for this message. Sent back on each
|
|
||||||
// autosave so the server replaces it instead of leaving another copy.
|
|
||||||
const draftUidRef = useRef<number>(0);
|
|
||||||
// Set once the writer has discarded the message. An autosave already on its
|
|
||||||
// way when that happens would otherwise land afterwards and bring the draft
|
|
||||||
// back, so every save checks this before and after it talks to the server.
|
|
||||||
const discardedRef = useRef(false);
|
|
||||||
|
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
|
||||||
// The body, mirrored into React state.
|
|
||||||
//
|
|
||||||
// It used to live only inside the contenteditable, read through the ref at
|
|
||||||
// save time. Nothing observed it, so the autosave effect - which depends on
|
|
||||||
// what it reads - never re-ran when the message itself was typed. The draft
|
|
||||||
// was written once, moments after the address was entered, and never again:
|
|
||||||
// that is why a draft left behind by a sent message was empty.
|
|
||||||
const [body, setBody] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const savedSig = localStorage.getItem("lm_user_signature");
|
|
||||||
// signatureToHtml, not the raw value: the signature may be the plain text
|
|
||||||
// of the old field, whose newlines are whitespace once assigned as HTML -
|
|
||||||
// which is why every line break in a signature used to vanish here.
|
|
||||||
const signatureHtml = signatureToHtml(savedSig ?? "");
|
|
||||||
if (editorRef.current && signatureHtml) {
|
|
||||||
const initial = `<br/><br/>--<br/>${signatureHtml}`;
|
|
||||||
editorRef.current.innerHTML = initial;
|
|
||||||
setBody(initial);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reopening a draft loads what was written into the composer, and adopts its
|
|
||||||
// UID so carrying on writing replaces that draft instead of leaving the old
|
|
||||||
// copy behind and starting a second one.
|
|
||||||
//
|
|
||||||
// Opening a draft used to land in the reader, which offered to reply to the
|
|
||||||
// half-written message and gave no way to finish it.
|
|
||||||
const draftParam = params?.draft;
|
|
||||||
useEffect(() => {
|
|
||||||
const uid = Number(draftParam);
|
|
||||||
if (!Number.isFinite(uid) || uid <= 0) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/v1/mail/message/${uid}?folder=drafts`, { cache: "no-store" });
|
|
||||||
if (!res.ok || cancelled) return;
|
|
||||||
const message = await res.json();
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
draftUidRef.current = uid;
|
|
||||||
setTo(message.to ?? []);
|
|
||||||
setCc(message.cc ?? []);
|
|
||||||
setImportant(Boolean(message.important));
|
|
||||||
setInReplyTo(message.in_reply_to ?? "");
|
|
||||||
if ((message.cc ?? []).length > 0) setShowCcBcc(true);
|
|
||||||
setSubject(message.subject ?? "");
|
|
||||||
const html = message.html || message.text || "";
|
|
||||||
if (editorRef.current) editorRef.current.innerHTML = html;
|
|
||||||
setBody(html);
|
|
||||||
} catch {
|
|
||||||
// A draft that cannot be loaded leaves an empty composer rather than
|
|
||||||
// a broken screen; the draft itself is untouched on the server.
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [draftParam]);
|
|
||||||
|
|
||||||
// Removes a stored draft for good. discard_draft is what keeps it out of
|
|
||||||
// Trash: an ordinary delete moves it there, and a draft sitting in Trash is
|
|
||||||
// not discarded.
|
|
||||||
const removeDraft = async (uid: number): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/v1/mail/delete", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ folder: "drafts", uid, discard_draft: true }),
|
|
||||||
});
|
|
||||||
return res.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Saves the draft for real. This used to be a setTimeout that set the words
|
|
||||||
// "draft saved automatically" and nothing else - no request, no storage - so
|
|
||||||
// closing the tab lost the message while the screen had just said otherwise.
|
|
||||||
const saveDraft = useCallback(async () => {
|
|
||||||
if (discardedRef.current) return;
|
|
||||||
const html = editorRef.current?.innerHTML ?? body;
|
|
||||||
if (to.length === 0 && !subject && !body.trim() && !html.trim()) return;
|
|
||||||
setDraftStatus(t("mail.draftSaving"));
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/v1/mail/draft", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
to,
|
|
||||||
cc,
|
|
||||||
bcc,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
important,
|
|
||||||
in_reply_to: inReplyTo,
|
|
||||||
replace_uid: draftUidRef.current,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (typeof data.uid === "number") draftUidRef.current = data.uid;
|
|
||||||
// Discarded while this save was in flight: what it just stored is a
|
|
||||||
// draft nobody wants, so it goes too.
|
|
||||||
if (discardedRef.current && draftUidRef.current > 0) {
|
|
||||||
void removeDraft(draftUidRef.current);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setDraftStatus(res.ok ? t("mail.draftSaved") : t("mail.draftSaveFailed"));
|
|
||||||
} catch {
|
|
||||||
setDraftStatus(t("mail.draftSaveFailed"));
|
|
||||||
}
|
|
||||||
}, [to, cc, bcc, subject, body, important, inReplyTo, t]);
|
|
||||||
|
|
||||||
// Debounced so a draft is written after a pause in typing rather than on
|
|
||||||
// every keystroke. Skipped once the message is on its way out.
|
|
||||||
useEffect(() => {
|
|
||||||
if (sending) return;
|
|
||||||
// The body alone is enough: a message typed before its recipient is still
|
|
||||||
// worth keeping, and requiring an address here is half of why drafts were
|
|
||||||
// being stored with nothing in them.
|
|
||||||
if (to.length === 0 && !subject && !body.trim()) return;
|
|
||||||
const timer = setTimeout(() => void saveDraft(), 2500);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [to, subject, cc, bcc, body, important, sending, saveDraft]);
|
|
||||||
|
|
||||||
// Throws the message away: stops the autosave, removes the draft already
|
|
||||||
// stored for it, and only then leaves. Leaving first was the old behaviour -
|
|
||||||
// a link to the inbox - and left the draft in Drafts, as if nothing had
|
|
||||||
// been discarded.
|
|
||||||
const discardMessage = async () => {
|
|
||||||
discardedRef.current = true;
|
|
||||||
setDiscarding(true);
|
|
||||||
setSendError(null);
|
|
||||||
const uid = draftUidRef.current;
|
|
||||||
if (uid > 0 && !(await removeDraft(uid))) {
|
|
||||||
// Still there, so say so and let the autosave carry on rather than
|
|
||||||
// leaving the page with a draft the writer thinks is gone.
|
|
||||||
discardedRef.current = false;
|
|
||||||
setDiscarding(false);
|
|
||||||
setSendError(t("mail.discardFailed"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
draftUidRef.current = 0;
|
|
||||||
notifyMailStateChanged();
|
|
||||||
window.location.href = "/user/mail/inbox";
|
|
||||||
};
|
|
||||||
|
|
||||||
const executeFormat = (command: string, value: string | undefined = undefined) => {
|
|
||||||
document.execCommand(command, false, value);
|
|
||||||
editorRef.current?.focus();
|
|
||||||
// execCommand edits the DOM directly, so the mirrored body is re-read
|
|
||||||
// here rather than relying on an input event that may not be dispatched.
|
|
||||||
setBody(editorRef.current?.innerHTML ?? "");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (!e.target.files) return;
|
|
||||||
const newFiles: AttachedFile[] = Array.from(e.target.files).map((f) => ({
|
|
||||||
id: Math.random().toString(36).substring(2, 9),
|
|
||||||
name: f.name,
|
|
||||||
size: f.size,
|
|
||||||
type: f.type || "application/octet-stream",
|
|
||||||
}));
|
|
||||||
setAttachments((prev) => [...prev, ...newFiles]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeAttachment = (id: string) => {
|
|
||||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Anything in a recipient field that is not an address. These stay on screen
|
|
||||||
// as red chips; sending with one would queue mail to a destination that
|
|
||||||
// cannot exist and report success.
|
|
||||||
const invalidRecipients = [...to, ...cc, ...bcc].filter((entry) => !parseAddress(entry).valid);
|
|
||||||
const hasRecipient = to.length + cc.length + bcc.length > 0;
|
|
||||||
|
|
||||||
const handleSend = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!hasRecipient) {
|
|
||||||
setSendError(t("mail.noRecipients"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (invalidRecipients.length > 0) {
|
|
||||||
setSendError(t("mail.fixRecipients"));
|
|
||||||
setShowCcBcc(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSendError(null);
|
|
||||||
setSending(true);
|
|
||||||
setUndoSeconds(10);
|
|
||||||
|
|
||||||
const bodyHtml = editorRef.current?.innerHTML || body;
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setUndoSeconds((prev) => {
|
|
||||||
if (prev === null || prev <= 1) {
|
|
||||||
clearInterval(interval);
|
|
||||||
void sendActualMessage(bodyHtml);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return prev - 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendActualMessage = async (htmlBody: string) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/v1/mail/send", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
to,
|
|
||||||
cc,
|
|
||||||
bcc,
|
|
||||||
subject,
|
|
||||||
html: htmlBody,
|
|
||||||
important,
|
|
||||||
in_reply_to: inReplyTo,
|
|
||||||
// The draft this message grew out of, so the server can discard it.
|
|
||||||
// Without this the draft outlived the message it became and sat in
|
|
||||||
// Drafts as a duplicate of mail already sent.
|
|
||||||
draft_uid: draftUidRef.current,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
// Reporting success on a refused send is how a message silently
|
|
||||||
// disappears: the composer used to navigate away regardless.
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
setSendError(data.message ?? t("errors.serverError"));
|
|
||||||
setSending(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.href = "/user/mail/sent";
|
|
||||||
} catch {
|
|
||||||
setSendError(t("errors.serverError"));
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUndo = () => {
|
|
||||||
setUndoSeconds(null);
|
|
||||||
setSending(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** One row of the compose sheet: a fixed-width label beside the control. */
|
|
||||||
const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]";
|
|
||||||
const fieldLabel = "w-14 flex-none text-[12.5px] leading-snug text-slate-400";
|
|
||||||
const bareInput =
|
|
||||||
"min-h-[30px] min-w-[160px] flex-1 border-0 bg-transparent text-sm text-slate-100 placeholder-slate-500 focus:outline-none";
|
|
||||||
const toolButton =
|
|
||||||
"flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto bg-dark-bg px-5 pb-11 pt-7 sm:px-8">
|
<Composer
|
||||||
<form onSubmit={handleSend} className="mx-auto flex w-full max-w-[860px] flex-col gap-[18px]">
|
initial={params}
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
variant="page"
|
||||||
<div className="min-w-0">
|
onSent={() => {
|
||||||
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("mail.compose")}</h1>
|
window.location.href = "/user/mail/sent";
|
||||||
{draftStatus && (
|
}}
|
||||||
<div className="mt-1 flex items-center gap-1.5 text-[12.5px] text-slate-400">
|
onDiscarded={() => {
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 flex-none text-indigo-500" />
|
window.location.href = "/user/mail/inbox";
|
||||||
{draftStatus}
|
}}
|
||||||
</div>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setImportant((v) => !v)}
|
|
||||||
aria-pressed={important}
|
|
||||||
disabled={sending}
|
|
||||||
title={t("mail.highImportanceHint")}
|
|
||||||
className={`inline-flex h-10 items-center gap-2 rounded-xl px-3.5 text-sm shadow-edge transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
|
|
||||||
important ? "bg-rose-900/70 text-rose-100" : "bg-dark-card text-slate-200 hover:bg-white/[0.07]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Flag className="h-4 w-4" fill={important ? "currentColor" : "none"} />
|
|
||||||
<span>{t("mail.highImportance")}</span>
|
|
||||||
</button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="md"
|
|
||||||
disabled={sending || discarding}
|
|
||||||
onClick={() => void discardMessage()}
|
|
||||||
>
|
|
||||||
{t("mail.discard")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" variant="primary" size="md" disabled={sending}>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
<span>{sending ? t("mail.sending") : t("mail.sendMessage")}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sendError && (
|
|
||||||
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-xs leading-relaxed text-rose-200 shadow-edge">
|
|
||||||
{sendError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{undoSeconds !== null && (
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl bg-dark-card px-4 py-3 text-xs text-slate-200 shadow-edge">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Sparkles className="h-4 w-4 flex-none animate-spin text-indigo-500" />
|
|
||||||
{/* The countdown is a placeholder inside the message, not text
|
|
||||||
bolted on after it. It used to be both: the bundle's own
|
|
||||||
"{seconds}" was never substituted and a second, hardcoded
|
|
||||||
count was appended, so this read "Undo Send ({seconds}s
|
|
||||||
remaining) (9s remaining)". */}
|
|
||||||
<span>{t("mail.undoSend", { seconds: undoSeconds })}</span>
|
|
||||||
</div>
|
|
||||||
<Button variant="primary" size="sm" onClick={handleUndo}>
|
|
||||||
<Undo2 className="h-3.5 w-3.5" />
|
|
||||||
{t("mail.undoSendAction")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* One sheet, the fields stacked as rows inside it, rather than a stack
|
|
||||||
of separately outlined inputs: the message is one object. */}
|
|
||||||
<div className="overflow-hidden rounded-2xl bg-dark-panel shadow-edge">
|
|
||||||
<div className={fieldRow}>
|
|
||||||
<label htmlFor="compose-to" className={fieldLabel}>
|
|
||||||
{t("ui.toRecipients")}
|
|
||||||
</label>
|
|
||||||
<RecipientInput
|
|
||||||
id="compose-to"
|
|
||||||
value={to}
|
|
||||||
onChange={setTo}
|
|
||||||
placeholder="recipient@example.com"
|
|
||||||
disabled={sending}
|
|
||||||
invalid={to.some((e) => !parseAddress(e).valid)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCcBcc(!showCcBcc)}
|
|
||||||
className="flex-none rounded-lg px-2 py-1 text-[12.5px] text-indigo-500 transition-colors hover:bg-indigo-500/10"
|
|
||||||
>
|
|
||||||
{showCcBcc ? t("mail.hideCcBcc") : t("mail.showCcBcc")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCcBcc && (
|
|
||||||
<>
|
|
||||||
<div className={fieldRow}>
|
|
||||||
<label htmlFor="compose-cc" className={fieldLabel}>
|
|
||||||
{t("mail.ccLabel")}
|
|
||||||
</label>
|
|
||||||
<RecipientInput
|
|
||||||
id="compose-cc"
|
|
||||||
value={cc}
|
|
||||||
onChange={setCc}
|
|
||||||
placeholder="cc@domain.com"
|
|
||||||
disabled={sending}
|
|
||||||
invalid={cc.some((e) => !parseAddress(e).valid)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={fieldRow}>
|
|
||||||
<label htmlFor="compose-bcc" className={fieldLabel}>
|
|
||||||
{t("mail.bccLabel")}
|
|
||||||
</label>
|
|
||||||
<RecipientInput
|
|
||||||
id="compose-bcc"
|
|
||||||
value={bcc}
|
|
||||||
onChange={setBcc}
|
|
||||||
placeholder="bcc@domain.com"
|
|
||||||
disabled={sending}
|
|
||||||
invalid={bcc.some((e) => !parseAddress(e).valid)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={fieldRow}>
|
|
||||||
<label htmlFor="compose-subject" className={fieldLabel}>
|
|
||||||
{t("ui.subject")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="compose-subject"
|
|
||||||
type="text"
|
|
||||||
value={subject}
|
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
|
||||||
placeholder={t("ui.subject")}
|
|
||||||
required
|
|
||||||
disabled={sending}
|
|
||||||
className={bareInput}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Formatting Toolbar */}
|
|
||||||
<div className="flex flex-wrap items-center gap-0.5 px-3 py-2 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]">
|
|
||||||
<button type="button" onClick={() => executeFormat("bold")} className={toolButton} title={t("mail.bold")}>
|
|
||||||
<Bold className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => executeFormat("italic")} className={toolButton} title={t("mail.italic")}>
|
|
||||||
<Italic className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => executeFormat("underline")}
|
|
||||||
className={toolButton}
|
|
||||||
title={t("mail.underline")}
|
|
||||||
>
|
|
||||||
<Underline className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => executeFormat("strikeThrough")}
|
|
||||||
className={toolButton}
|
|
||||||
title={t("mail.strikethrough")}
|
|
||||||
>
|
|
||||||
<Strikethrough className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => executeFormat("insertUnorderedList")}
|
|
||||||
className={toolButton}
|
|
||||||
title={t("mail.bulletList")}
|
|
||||||
>
|
|
||||||
<List className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => executeFormat("insertOrderedList")}
|
|
||||||
className={toolButton}
|
|
||||||
title={t("mail.numberedList")}
|
|
||||||
>
|
|
||||||
<ListOrdered className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const url = prompt(t("mail.insertUrl"));
|
|
||||||
if (url) executeFormat("createLink", url);
|
|
||||||
}}
|
|
||||||
className={`${toolButton} text-indigo-500`}
|
|
||||||
title={t("mail.insertLink")}
|
|
||||||
>
|
|
||||||
<LinkIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={editorRef}
|
|
||||||
contentEditable
|
|
||||||
suppressContentEditableWarning
|
|
||||||
onInput={(e) => setBody((e.target as HTMLDivElement).innerHTML)}
|
|
||||||
onBlur={(e) => setBody((e.target as HTMLDivElement).innerHTML)}
|
|
||||||
aria-label={t("ui.messageBody")}
|
|
||||||
role="textbox"
|
|
||||||
aria-multiline="true"
|
|
||||||
className="min-h-[280px] px-[22px] py-5 font-sans text-[14.5px] leading-relaxed text-slate-200 focus:outline-none"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Attachment Selector & Display */}
|
|
||||||
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3.5 shadow-[inset_0_1px_0_0_rgba(233,233,237,0.07)]">
|
|
||||||
<label className="flex flex-none cursor-pointer items-center gap-2 rounded-[10px] border border-white/[0.14] px-3 py-2 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07]">
|
|
||||||
<Paperclip className="h-[15px] w-[15px]" />
|
|
||||||
<span>{t("mail.addFiles")}</span>
|
|
||||||
<input type="file" multiple onChange={handleFileUpload} className="hidden" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{attachments.map((file) => (
|
|
||||||
<div
|
|
||||||
key={file.id}
|
|
||||||
className="flex items-center gap-2 rounded-[10px] bg-dark-card px-3 py-2 text-[12.5px] text-slate-200 shadow-edge"
|
|
||||||
>
|
|
||||||
<Paperclip className="h-[15px] w-[15px] flex-none text-indigo-400" />
|
|
||||||
<span className="max-w-[160px] truncate">{file.name}</span>
|
|
||||||
<span className="text-[11px] text-slate-500">{(file.size / 1024).toFixed(1)} KB</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeAttachment(file.id)}
|
|
||||||
className="text-slate-400 transition-colors hover:text-rose-400"
|
|
||||||
aria-label={t("common.delete")}
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Says so plainly rather than letting someone attach a contract
|
|
||||||
and watch it silently not arrive: /mail/send is handed the file
|
|
||||||
names only, because there is no upload endpoint behind this
|
|
||||||
picker yet. */}
|
|
||||||
{attachments.length > 0 && (
|
|
||||||
<span className="ml-auto text-[11.5px] leading-relaxed text-slate-400">
|
|
||||||
{t("mail.attachmentsUnsupported")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
CornerUpLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useI18n } from "../../../../../i18n/provider";
|
import { useI18n } from "../../../../../i18n/provider";
|
||||||
import { useMailEvents } from "../../../../../lib/useMailEvents";
|
import { useMailEvents } from "../../../../../lib/useMailEvents";
|
||||||
@@ -27,10 +28,16 @@ import {
|
|||||||
moveTargets,
|
moveTargets,
|
||||||
type ListFilter,
|
type ListFilter,
|
||||||
} from "../../../../../lib/mailCounts";
|
} from "../../../../../lib/mailCounts";
|
||||||
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer";
|
|
||||||
import { plainSnippet } from "../../../../../lib/snippet";
|
import { plainSnippet } from "../../../../../lib/snippet";
|
||||||
import { useTrustedSenders } from "../../../../../lib/trustedSenders";
|
import { useTrustedSenders } from "../../../../../lib/trustedSenders";
|
||||||
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
|
import type { InlineImages } from "../../../../../lib/emailBody";
|
||||||
|
import { messageDocument, hasBlockedRemoteContent } from "../../../../../lib/renderMessage";
|
||||||
|
import {
|
||||||
|
participantsLine,
|
||||||
|
useConversationView,
|
||||||
|
type ThreadParticipant,
|
||||||
|
type ThreadSummary,
|
||||||
|
} from "../../../../../lib/conversations";
|
||||||
import { Button } from "../../../../../components/ui/Button";
|
import { Button } from "../../../../../components/ui/Button";
|
||||||
import {
|
import {
|
||||||
clampListWidth,
|
clampListWidth,
|
||||||
@@ -40,6 +47,7 @@ import {
|
|||||||
} from "../../../../../lib/paneWidth";
|
} from "../../../../../lib/paneWidth";
|
||||||
import { useAccount } from "../../../../../lib/useAccount";
|
import { useAccount } from "../../../../../lib/useAccount";
|
||||||
import { MessageReader } from "../../../../../components/MessageReader";
|
import { MessageReader } from "../../../../../components/MessageReader";
|
||||||
|
import { ThreadReader } from "../../../../../components/ThreadReader";
|
||||||
import { Avatar } from "../../../../../components/Avatar";
|
import { Avatar } from "../../../../../components/Avatar";
|
||||||
|
|
||||||
interface MessageSummary {
|
interface MessageSummary {
|
||||||
@@ -55,6 +63,34 @@ interface MessageSummary {
|
|||||||
flagged: boolean;
|
flagged: boolean;
|
||||||
spam_verdict: string;
|
spam_verdict: string;
|
||||||
dmarc_result: string;
|
dmarc_result: string;
|
||||||
|
// Present when the list is showing conversations: the row is then a whole
|
||||||
|
// conversation, and uid is its newest message in this folder.
|
||||||
|
thread_id?: string;
|
||||||
|
message_count?: number;
|
||||||
|
answered?: boolean;
|
||||||
|
participants?: ThreadParticipant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A conversation row in the shape the list already knows how to draw. */
|
||||||
|
function rowFromThread(thread: ThreadSummary): MessageSummary {
|
||||||
|
return {
|
||||||
|
uid: thread.uid,
|
||||||
|
subject: thread.subject,
|
||||||
|
sender_address: thread.sender_address,
|
||||||
|
from_display_name: thread.from_display_name,
|
||||||
|
snippet: thread.snippet,
|
||||||
|
size_bytes: 0,
|
||||||
|
received_at: thread.received_at,
|
||||||
|
has_attachments: thread.has_attachments,
|
||||||
|
seen: thread.seen,
|
||||||
|
flagged: thread.flagged,
|
||||||
|
spam_verdict: "",
|
||||||
|
dmarc_result: "",
|
||||||
|
thread_id: thread.thread_id,
|
||||||
|
message_count: thread.message_count,
|
||||||
|
answered: thread.answered,
|
||||||
|
participants: thread.participants,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RenderedMessage {
|
interface RenderedMessage {
|
||||||
@@ -121,6 +157,10 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
|
|
||||||
const [messages, setMessages] = useState<MessageSummary[]>([]);
|
const [messages, setMessages] = useState<MessageSummary[]>([]);
|
||||||
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
||||||
|
// The open conversation, when the list is showing conversations.
|
||||||
|
const [selectedThread, setSelectedThread] = useState<MessageSummary | null>(null);
|
||||||
|
const conversations = useConversationView();
|
||||||
|
const conversationMode = conversations.enabled;
|
||||||
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
|
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
|
||||||
// The senders this account trusts, kept on the server so the decision
|
// The senders this account trusts, kept on the server so the decision
|
||||||
// follows the account to every browser and also reaches the spam filter.
|
// follows the account to every browser and also reaches the spam filter.
|
||||||
@@ -163,25 +203,38 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
const actions = readerActions(folder);
|
const actions = readerActions(folder);
|
||||||
|
|
||||||
const loadMessages = useCallback(
|
const loadMessages = useCallback(
|
||||||
async (search: string) => {
|
async (search: string, { quiet = false }: { quiet?: boolean } = {}) => {
|
||||||
setLoading(true);
|
// A quiet reload refreshes what is shown without the spinner, for the
|
||||||
|
// changes the reader itself made.
|
||||||
|
if (!quiet) setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const query = new URLSearchParams({ folder, ...(search ? { q: search } : {}) });
|
const query = new URLSearchParams({ folder, ...(search ? { q: search } : {}) });
|
||||||
const res = await fetch(`/api/v1/mail/messages?${query}`, { cache: "no-store" });
|
const res = await fetch(`/api/v1/mail/${conversationMode ? "threads" : "messages"}?${query}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
window.location.href = "/user/login";
|
window.location.href = "/user/login";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// A server that has no conversations falls back to the plain list
|
||||||
|
// rather than showing an empty folder.
|
||||||
|
if (res.status === 501 && conversationMode) {
|
||||||
|
void conversations.setEnabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(t("errors.serverError"));
|
if (!res.ok) throw new Error(t("errors.serverError"));
|
||||||
setMessages(await res.json());
|
const data = await res.json();
|
||||||
|
setMessages(conversationMode ? (data as ThreadSummary[]).map(rowFromThread) : data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("errors.serverError"));
|
setError(err instanceof Error ? err.message : t("errors.serverError"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[folder, t]
|
// conversations.setEnabled is stable enough for this one call
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[folder, t, conversationMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
useMailEvents(
|
useMailEvents(
|
||||||
@@ -199,9 +252,18 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
// separate mount effect as well meant every folder change fired two requests
|
// separate mount effect as well meant every folder change fired two requests
|
||||||
// and the later one could be the stale answer.
|
// and the later one could be the stale answer.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Waits for the account's answer on how to list, or the folder would load
|
||||||
|
// one way and immediately reload the other.
|
||||||
|
if (!conversations.ready) return;
|
||||||
const handle = setTimeout(() => void loadMessages(searchQuery), searchQuery ? 250 : 0);
|
const handle = setTimeout(() => void loadMessages(searchQuery), searchQuery ? 250 : 0);
|
||||||
return () => clearTimeout(handle);
|
return () => clearTimeout(handle);
|
||||||
}, [searchQuery, loadMessages]);
|
}, [searchQuery, loadMessages, conversations.ready]);
|
||||||
|
|
||||||
|
// Changing between the two lists closes whatever was open in the other.
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(null);
|
||||||
|
setSelectedThread(null);
|
||||||
|
}, [conversationMode, folder]);
|
||||||
|
|
||||||
// The reader frame reports its own height; nothing outside a sandboxed frame
|
// The reader frame reports its own height; nothing outside a sandboxed frame
|
||||||
// can measure it.
|
// can measure it.
|
||||||
@@ -302,6 +364,39 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
[folder, t, loadMessages, searchQuery]
|
[folder, t, loadMessages, searchQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Deletes every message a conversation row stands for in this folder. */
|
||||||
|
const deleteConversationRow = useCallback(
|
||||||
|
async (row: MessageSummary) => {
|
||||||
|
setMessages((current) => current.filter((m) => m.thread_id !== row.thread_id));
|
||||||
|
setSelectedThread((current) => (current?.thread_id === row.thread_id ? null : current));
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/mail/thread/${encodeURIComponent(row.thread_id ?? "")}?folder=${encodeURIComponent(folder)}`,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(t("errors.serverError"));
|
||||||
|
const infos: { uid: number; folder: string }[] = await res.json();
|
||||||
|
await Promise.all(
|
||||||
|
infos
|
||||||
|
.filter((m) => m.folder.toLowerCase() === folder.toLowerCase())
|
||||||
|
.map((m) =>
|
||||||
|
fetch("/api/v1/mail/delete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ folder, uid: m.uid }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("errors.serverError"));
|
||||||
|
void loadMessages(searchQuery);
|
||||||
|
} finally {
|
||||||
|
notifyMailStateChanged();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[folder, t, loadMessages, searchQuery],
|
||||||
|
);
|
||||||
|
|
||||||
/** Files one message into another folder. */
|
/** Files one message into another folder. */
|
||||||
const moveMessage = useCallback(
|
const moveMessage = useCallback(
|
||||||
async (uid: number, target: string) => {
|
async (uid: number, target: string) => {
|
||||||
@@ -332,6 +427,14 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
router.push(`/user/compose?draft=${uid}`);
|
router.push(`/user/compose?draft=${uid}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (conversationMode) {
|
||||||
|
const row = messages.find((m) => m.uid === uid);
|
||||||
|
if (row?.thread_id) {
|
||||||
|
setSelected(null);
|
||||||
|
setSelectedThread(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setLoadRemoteImages(false);
|
setLoadRemoteImages(false);
|
||||||
setReaderHeight(420);
|
setReaderHeight(420);
|
||||||
try {
|
try {
|
||||||
@@ -357,6 +460,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
searching: searchQuery.trim().length > 0,
|
searching: searchQuery.trim().length > 0,
|
||||||
});
|
});
|
||||||
const selectedRow = messages.find((m) => m.uid === selected?.uid);
|
const selectedRow = messages.find((m) => m.uid === selected?.uid);
|
||||||
|
const reading = Boolean(selected || selectedThread);
|
||||||
const senderTrusted = selected ? trustedSenders.isTrusted(selected.from ?? "") : false;
|
const senderTrusted = selected ? trustedSenders.isTrusted(selected.from ?? "") : false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -368,27 +472,15 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
*/
|
*/
|
||||||
const readerDocument = useMemo(() => {
|
const readerDocument = useMemo(() => {
|
||||||
if (!selected) return null;
|
if (!selected) return null;
|
||||||
if (!selected.html) return null;
|
|
||||||
const sanitized = sanitizeEmailHtml(selected.html);
|
|
||||||
if (!sanitized) return null;
|
|
||||||
const withInline = resolveInlineImages(sanitized, selected.inline_images ?? {});
|
|
||||||
// A trusted sender's images load without asking; anyone else's wait for
|
// A trusted sender's images load without asking; anyone else's wait for
|
||||||
// the reader's choice, one message at a time.
|
// the reader's choice, one message at a time.
|
||||||
const showImages = loadRemoteImages || trustedSenders.isTrusted(selected.from ?? "");
|
const showImages = loadRemoteImages || trustedSenders.isTrusted(selected.from ?? "");
|
||||||
const body = showImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline);
|
return messageDocument(selected, showImages);
|
||||||
return buildReaderDocument(body);
|
|
||||||
}, [selected, loadRemoteImages, trustedSenders]);
|
}, [selected, loadRemoteImages, trustedSenders]);
|
||||||
|
|
||||||
// Whether anything was actually held back, so the privacy banner is not shown
|
// Whether anything was actually held back, so the privacy banner is not shown
|
||||||
// over a message that has no remote content at all.
|
// over a message that has no remote content at all.
|
||||||
const hasBlockedContent = useMemo(() => {
|
const hasBlockedContent = useMemo(() => (selected ? hasBlockedRemoteContent(selected) : false), [selected]);
|
||||||
if (!selected?.html) return false;
|
|
||||||
const sanitized = resolveInlineImages(
|
|
||||||
sanitizeEmailHtml(selected.html),
|
|
||||||
selected.inline_images ?? {}
|
|
||||||
);
|
|
||||||
return blockRemoteImages(sanitized) !== sanitized;
|
|
||||||
}, [selected]);
|
|
||||||
|
|
||||||
const chips: { key: ListFilter; label: string; count: number }[] = [
|
const chips: { key: ListFilter; label: string; count: number }[] = [
|
||||||
{ key: null, label: t("mail.allMessages"), count: header.count },
|
{ key: null, label: t("mail.allMessages"), count: header.count },
|
||||||
@@ -416,7 +508,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
{/* Messages List Column */}
|
{/* Messages List Column */}
|
||||||
<div
|
<div
|
||||||
style={{ "--list-w": `${listWidth}px` } as React.CSSProperties}
|
style={{ "--list-w": `${listWidth}px` } as React.CSSProperties}
|
||||||
className={`${selected ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg md:w-[var(--list-w)] md:flex-none`}
|
className={`${reading ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg md:w-[var(--list-w)] md:flex-none`}
|
||||||
>
|
>
|
||||||
{/* Folder heading, with the counts that belong to the folder rather
|
{/* Folder heading, with the counts that belong to the folder rather
|
||||||
than to the page of it that happens to be loaded. */}
|
than to the page of it that happens to be loaded. */}
|
||||||
@@ -494,7 +586,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredMessages.map((msg) => {
|
filteredMessages.map((msg) => {
|
||||||
const isSelected = selected?.uid === msg.uid;
|
const isSelected = msg.thread_id ? selectedThread?.thread_id === msg.thread_id : selected?.uid === msg.uid;
|
||||||
return (
|
return (
|
||||||
/* The row and its delete control are siblings inside a
|
/* The row and its delete control are siblings inside a
|
||||||
positioned wrapper rather than nested: a button inside a
|
positioned wrapper rather than nested: a button inside a
|
||||||
@@ -524,8 +616,24 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
msg.seen ? "text-slate-300" : "text-slate-100"
|
msg.seen ? "text-slate-300" : "text-slate-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{msg.from_display_name || msg.sender_address}
|
{msg.participants && msg.participants.length > 0
|
||||||
|
? participantsLine(msg.participants, t("conversations.me"))
|
||||||
|
: msg.from_display_name || msg.sender_address}
|
||||||
</span>
|
</span>
|
||||||
|
{msg.message_count && msg.message_count > 1 ? (
|
||||||
|
<span
|
||||||
|
className="flex-none text-[11.5px] tabular-nums text-slate-500"
|
||||||
|
title={t("conversations.messagesInConversation", { count: msg.message_count })}
|
||||||
|
>
|
||||||
|
{msg.message_count}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{msg.answered && (
|
||||||
|
<CornerUpLeft
|
||||||
|
className="h-3 w-3 flex-none text-emerald-300"
|
||||||
|
aria-label={t("conversations.youReplied")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className="ml-auto flex-none text-[11px] tabular-nums text-slate-500">
|
<span className="ml-auto flex-none text-[11px] tabular-nums text-slate-500">
|
||||||
{listTimestamp(msg.received_at, locale)}
|
{listTimestamp(msg.received_at, locale)}
|
||||||
</span>
|
</span>
|
||||||
@@ -578,7 +686,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
reading in it, and no other way to get rid of it. */}
|
reading in it, and no other way to get rid of it. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void deleteMessage(msg.uid)}
|
onClick={() => void (msg.thread_id ? deleteConversationRow(msg) : deleteMessage(msg.uid))}
|
||||||
title={t("common.delete")}
|
title={t("common.delete")}
|
||||||
aria-label={t("common.delete")}
|
aria-label={t("common.delete")}
|
||||||
className="absolute right-2 top-2 hidden h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/[0.09] hover:text-slate-100 focus:flex focus-visible:flex group-hover:flex"
|
className="absolute right-2 top-2 hidden h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/[0.09] hover:text-slate-100 focus:flex focus-visible:flex group-hover:flex"
|
||||||
@@ -633,9 +741,39 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message Reader Column */}
|
{/* Message Reader Column */}
|
||||||
<div className={`${selected ? "flex" : "hidden md:flex"} min-w-0 flex-1 flex-col overflow-hidden bg-dark-bg`}>
|
<div className={`${reading ? "flex" : "hidden md:flex"} min-w-0 flex-1 flex-col overflow-hidden bg-dark-bg`}>
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{selected ? (
|
{selectedThread ? (
|
||||||
|
<ThreadReader
|
||||||
|
key={selectedThread.thread_id}
|
||||||
|
thread={{
|
||||||
|
thread_id: selectedThread.thread_id ?? "",
|
||||||
|
uid: selectedThread.uid,
|
||||||
|
subject: selectedThread.subject,
|
||||||
|
sender_address: selectedThread.sender_address,
|
||||||
|
from_display_name: selectedThread.from_display_name,
|
||||||
|
snippet: selectedThread.snippet,
|
||||||
|
received_at: selectedThread.received_at,
|
||||||
|
has_attachments: selectedThread.has_attachments,
|
||||||
|
message_count: selectedThread.message_count ?? 1,
|
||||||
|
unseen_count: selectedThread.seen ? 0 : 1,
|
||||||
|
seen: selectedThread.seen,
|
||||||
|
flagged: selectedThread.flagged,
|
||||||
|
answered: Boolean(selectedThread.answered),
|
||||||
|
participants: selectedThread.participants ?? [],
|
||||||
|
}}
|
||||||
|
folder={folder}
|
||||||
|
ownAddress={account?.email ?? ""}
|
||||||
|
trusted={trustedSenders}
|
||||||
|
moveTargets={moveTargets(folders, folder).map((target) => ({
|
||||||
|
name: target.name,
|
||||||
|
label: FOLDER_LABELS[(target.special_use || target.name).toLowerCase()] ?? target.name,
|
||||||
|
}))}
|
||||||
|
deleteIsPermanent={actions.deleteIsPermanent}
|
||||||
|
onClose={() => setSelectedThread(null)}
|
||||||
|
onChanged={() => void loadMessages(searchQuery, { quiet: true })}
|
||||||
|
/>
|
||||||
|
) : selected ? (
|
||||||
<MessageReader
|
<MessageReader
|
||||||
key={selected.uid}
|
key={selected.uid}
|
||||||
message={selected}
|
message={selected}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useFolders } from "../../../../lib/useFolders";
|
|||||||
import { AvatarEditor } from "../../../../components/AvatarEditor";
|
import { AvatarEditor } from "../../../../components/AvatarEditor";
|
||||||
import { bumpAvatar } from "../../../../lib/avatarVersion";
|
import { bumpAvatar } from "../../../../lib/avatarVersion";
|
||||||
import { useAccount } from "../../../../lib/useAccount";
|
import { useAccount } from "../../../../lib/useAccount";
|
||||||
|
import { useConversationView } from "../../../../lib/conversations";
|
||||||
import { moveTargets } from "../../../../lib/mailCounts";
|
import { moveTargets } from "../../../../lib/mailCounts";
|
||||||
|
|
||||||
interface SieveRule {
|
interface SieveRule {
|
||||||
@@ -90,6 +91,7 @@ export default function UserSettingsPage() {
|
|||||||
const [vacationSaved, setVacationSaved] = useState(false);
|
const [vacationSaved, setVacationSaved] = useState(false);
|
||||||
|
|
||||||
const [signature, setSignature] = useState("");
|
const [signature, setSignature] = useState("");
|
||||||
|
const conversationView = useConversationView();
|
||||||
const signatureRef = useRef<HTMLDivElement>(null);
|
const signatureRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// execCommand edits the DOM directly, so the mirrored value is re-read here
|
// execCommand edits the DOM directly, so the mirrored value is re-read here
|
||||||
@@ -989,6 +991,24 @@ export default function UserSettingsPage() {
|
|||||||
<div className="max-w-[280px]">
|
<div className="max-w-[280px]">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* How the list is read, which belongs with how it is shown. */}
|
||||||
|
<label className="flex max-w-[560px] cursor-pointer items-start gap-3 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
checked={conversationView.enabled}
|
||||||
|
disabled={!conversationView.ready}
|
||||||
|
onChange={(e) => void conversationView.setEnabled(e.target.checked)}
|
||||||
|
className="mt-0.5 h-4 w-4 flex-none accent-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-[13.5px] text-slate-100">{t("conversations.conversationView")}</span>
|
||||||
|
<span className="mt-0.5 block text-[12.5px] leading-relaxed text-slate-400">
|
||||||
|
{t("conversations.conversationViewHint")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,641 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Send,
|
||||||
|
Paperclip,
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Underline,
|
||||||
|
Strikethrough,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Undo2,
|
||||||
|
X,
|
||||||
|
Sparkles,
|
||||||
|
CheckCircle2,
|
||||||
|
Flag,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "../i18n/provider";
|
||||||
|
import { Button } from "../components/ui/Button";
|
||||||
|
import { notifyMailStateChanged } from "../lib/useFolders";
|
||||||
|
import { signatureToHtml } from "../lib/signature";
|
||||||
|
import { RecipientInput } from "../components/RecipientInput";
|
||||||
|
import { parseAddress, parseAddressList, prefixSubject } from "../lib/recipients";
|
||||||
|
|
||||||
|
/** Entries from a query-string list, which may be several addresses. */
|
||||||
|
function entriesFrom(value: string | undefined): string[] {
|
||||||
|
return value ? parseAddressList(value).map((p) => p.entry) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttachedFile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComposerInitial {
|
||||||
|
replyTo?: string;
|
||||||
|
cc?: string;
|
||||||
|
subject?: string;
|
||||||
|
inReplyTo?: string;
|
||||||
|
mode?: string;
|
||||||
|
draft?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposerProps {
|
||||||
|
initial?: ComposerInitial;
|
||||||
|
/**
|
||||||
|
* "page" is the full compose screen. "inline" is the reply written under the
|
||||||
|
* conversation it answers: no title, no subject row (a reply keeps its
|
||||||
|
* subject) and a shorter editor, so the messages stay in view above it.
|
||||||
|
*/
|
||||||
|
variant?: "page" | "inline";
|
||||||
|
onSent: () => void;
|
||||||
|
onDiscarded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Composer({ initial, variant = "page", onSent, onDiscarded }: ComposerProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const inline = variant === "inline";
|
||||||
|
const params = initial ?? {};
|
||||||
|
|
||||||
|
// Recipients are lists of entries, not one string to be split later: a
|
||||||
|
// reply-all arrives with several people already in To and Cc.
|
||||||
|
const [to, setTo] = useState<string[]>(() => entriesFrom(params?.replyTo));
|
||||||
|
const [cc, setCc] = useState<string[]>(() => entriesFrom(params?.cc));
|
||||||
|
const [bcc, setBcc] = useState<string[]>([]);
|
||||||
|
const [showCcBcc, setShowCcBcc] = useState(() => entriesFrom(params?.cc).length > 0);
|
||||||
|
const [subject, setSubject] = useState(
|
||||||
|
params?.subject ? prefixSubject(params.subject, params.mode === "forward" ? "forward" : "reply") : "",
|
||||||
|
);
|
||||||
|
// The Message-ID being answered, so the reply threads under it.
|
||||||
|
const [inReplyTo, setInReplyTo] = useState(params?.mode === "forward" ? "" : params?.inReplyTo || "");
|
||||||
|
const [important, setImportant] = useState(false);
|
||||||
|
const [attachments, setAttachments] = useState<AttachedFile[]>([]);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [undoSeconds, setUndoSeconds] = useState<number | null>(null);
|
||||||
|
const [draftStatus, setDraftStatus] = useState<string>("");
|
||||||
|
const [discarding, setDiscarding] = useState(false);
|
||||||
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
|
// The UID of the draft already stored for this message. Sent back on each
|
||||||
|
// autosave so the server replaces it instead of leaving another copy.
|
||||||
|
const draftUidRef = useRef<number>(0);
|
||||||
|
// Set once the writer has discarded the message. An autosave already on its
|
||||||
|
// way when that happens would otherwise land afterwards and bring the draft
|
||||||
|
// back, so every save checks this before and after it talks to the server.
|
||||||
|
const discardedRef = useRef(false);
|
||||||
|
// The send waiting out its ten seconds, so Undo can stop it and leaving the
|
||||||
|
// screen cannot lose it.
|
||||||
|
const pendingSend = useRef<{ timer: ReturnType<typeof setInterval>; html: string } | null>(null);
|
||||||
|
const sendRef = useRef<(html: string) => Promise<void>>(async () => undefined);
|
||||||
|
const unmounted = useRef(false);
|
||||||
|
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
// The body, mirrored into React state.
|
||||||
|
//
|
||||||
|
// It used to live only inside the contenteditable, read through the ref at
|
||||||
|
// save time. Nothing observed it, so the autosave effect - which depends on
|
||||||
|
// what it reads - never re-ran when the message itself was typed. The draft
|
||||||
|
// was written once, moments after the address was entered, and never again:
|
||||||
|
// that is why a draft left behind by a sent message was empty.
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
|
||||||
|
// Leaving with a send still counting down sends it now. Dropping it would lose
|
||||||
|
// a message the writer was told was on its way; Undo is the way to cancel.
|
||||||
|
useEffect(() => {
|
||||||
|
unmounted.current = false;
|
||||||
|
return () => {
|
||||||
|
unmounted.current = true;
|
||||||
|
const pending = pendingSend.current;
|
||||||
|
if (pending) {
|
||||||
|
clearInterval(pending.timer);
|
||||||
|
pendingSend.current = null;
|
||||||
|
void sendRef.current(pending.html);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// An inline reply is opened to be written in, so the cursor starts in it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (inline) editorRef.current?.focus();
|
||||||
|
}, [inline]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedSig = localStorage.getItem("lm_user_signature");
|
||||||
|
// signatureToHtml, not the raw value: the signature may be the plain text
|
||||||
|
// of the old field, whose newlines are whitespace once assigned as HTML -
|
||||||
|
// which is why every line break in a signature used to vanish here.
|
||||||
|
const signatureHtml = signatureToHtml(savedSig ?? "");
|
||||||
|
if (editorRef.current && signatureHtml) {
|
||||||
|
const initial = `<br/><br/>--<br/>${signatureHtml}`;
|
||||||
|
editorRef.current.innerHTML = initial;
|
||||||
|
setBody(initial);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reopening a draft loads what was written into the composer, and adopts its
|
||||||
|
// UID so carrying on writing replaces that draft instead of leaving the old
|
||||||
|
// copy behind and starting a second one.
|
||||||
|
//
|
||||||
|
// Opening a draft used to land in the reader, which offered to reply to the
|
||||||
|
// half-written message and gave no way to finish it.
|
||||||
|
const draftParam = params?.draft;
|
||||||
|
useEffect(() => {
|
||||||
|
const uid = Number(draftParam);
|
||||||
|
if (!Number.isFinite(uid) || uid <= 0) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/mail/message/${uid}?folder=drafts`, { cache: "no-store" });
|
||||||
|
if (!res.ok || cancelled) return;
|
||||||
|
const message = await res.json();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
draftUidRef.current = uid;
|
||||||
|
setTo(message.to ?? []);
|
||||||
|
setCc(message.cc ?? []);
|
||||||
|
setImportant(Boolean(message.important));
|
||||||
|
setInReplyTo(message.in_reply_to ?? "");
|
||||||
|
if ((message.cc ?? []).length > 0) setShowCcBcc(true);
|
||||||
|
setSubject(message.subject ?? "");
|
||||||
|
const html = message.html || message.text || "";
|
||||||
|
if (editorRef.current) editorRef.current.innerHTML = html;
|
||||||
|
setBody(html);
|
||||||
|
} catch {
|
||||||
|
// A draft that cannot be loaded leaves an empty composer rather than
|
||||||
|
// a broken screen; the draft itself is untouched on the server.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [draftParam]);
|
||||||
|
|
||||||
|
// Removes a stored draft for good. discard_draft is what keeps it out of
|
||||||
|
// Trash: an ordinary delete moves it there, and a draft sitting in Trash is
|
||||||
|
// not discarded.
|
||||||
|
const removeDraft = async (uid: number): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/mail/delete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ folder: "drafts", uid, discard_draft: true }),
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Saves the draft for real. This used to be a setTimeout that set the words
|
||||||
|
// "draft saved automatically" and nothing else - no request, no storage - so
|
||||||
|
// closing the tab lost the message while the screen had just said otherwise.
|
||||||
|
const saveDraft = useCallback(async () => {
|
||||||
|
if (discardedRef.current) return;
|
||||||
|
const html = editorRef.current?.innerHTML ?? body;
|
||||||
|
if (to.length === 0 && !subject && !body.trim() && !html.trim()) return;
|
||||||
|
setDraftStatus(t("mail.draftSaving"));
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/mail/draft", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
bcc,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
important,
|
||||||
|
in_reply_to: inReplyTo,
|
||||||
|
replace_uid: draftUidRef.current,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (typeof data.uid === "number") draftUidRef.current = data.uid;
|
||||||
|
// Discarded while this save was in flight: what it just stored is a
|
||||||
|
// draft nobody wants, so it goes too.
|
||||||
|
if (discardedRef.current && draftUidRef.current > 0) {
|
||||||
|
void removeDraft(draftUidRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDraftStatus(res.ok ? t("mail.draftSaved") : t("mail.draftSaveFailed"));
|
||||||
|
} catch {
|
||||||
|
setDraftStatus(t("mail.draftSaveFailed"));
|
||||||
|
}
|
||||||
|
}, [to, cc, bcc, subject, body, important, inReplyTo, t]);
|
||||||
|
|
||||||
|
// Debounced so a draft is written after a pause in typing rather than on
|
||||||
|
// every keystroke. Skipped once the message is on its way out.
|
||||||
|
useEffect(() => {
|
||||||
|
if (sending) return;
|
||||||
|
// The body alone is enough: a message typed before its recipient is still
|
||||||
|
// worth keeping, and requiring an address here is half of why drafts were
|
||||||
|
// being stored with nothing in them.
|
||||||
|
if (to.length === 0 && !subject && !body.trim()) return;
|
||||||
|
const timer = setTimeout(() => void saveDraft(), 2500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [to, subject, cc, bcc, body, important, sending, saveDraft]);
|
||||||
|
|
||||||
|
// Throws the message away: stops the autosave, removes the draft already
|
||||||
|
// stored for it, and only then leaves. Leaving first was the old behaviour -
|
||||||
|
// a link to the inbox - and left the draft in Drafts, as if nothing had
|
||||||
|
// been discarded.
|
||||||
|
const discardMessage = async () => {
|
||||||
|
discardedRef.current = true;
|
||||||
|
setDiscarding(true);
|
||||||
|
setSendError(null);
|
||||||
|
const uid = draftUidRef.current;
|
||||||
|
if (uid > 0 && !(await removeDraft(uid))) {
|
||||||
|
// Still there, so say so and let the autosave carry on rather than
|
||||||
|
// leaving the page with a draft the writer thinks is gone.
|
||||||
|
discardedRef.current = false;
|
||||||
|
setDiscarding(false);
|
||||||
|
setSendError(t("mail.discardFailed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draftUidRef.current = 0;
|
||||||
|
notifyMailStateChanged();
|
||||||
|
onDiscarded();
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeFormat = (command: string, value: string | undefined = undefined) => {
|
||||||
|
document.execCommand(command, false, value);
|
||||||
|
editorRef.current?.focus();
|
||||||
|
// execCommand edits the DOM directly, so the mirrored body is re-read
|
||||||
|
// here rather than relying on an input event that may not be dispatched.
|
||||||
|
setBody(editorRef.current?.innerHTML ?? "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!e.target.files) return;
|
||||||
|
const newFiles: AttachedFile[] = Array.from(e.target.files).map((f) => ({
|
||||||
|
id: Math.random().toString(36).substring(2, 9),
|
||||||
|
name: f.name,
|
||||||
|
size: f.size,
|
||||||
|
type: f.type || "application/octet-stream",
|
||||||
|
}));
|
||||||
|
setAttachments((prev) => [...prev, ...newFiles]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAttachment = (id: string) => {
|
||||||
|
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Anything in a recipient field that is not an address. These stay on screen
|
||||||
|
// as red chips; sending with one would queue mail to a destination that
|
||||||
|
// cannot exist and report success.
|
||||||
|
const invalidRecipients = [...to, ...cc, ...bcc].filter((entry) => !parseAddress(entry).valid);
|
||||||
|
const hasRecipient = to.length + cc.length + bcc.length > 0;
|
||||||
|
|
||||||
|
const handleSend = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!hasRecipient) {
|
||||||
|
setSendError(t("mail.noRecipients"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (invalidRecipients.length > 0) {
|
||||||
|
setSendError(t("mail.fixRecipients"));
|
||||||
|
setShowCcBcc(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSendError(null);
|
||||||
|
setSending(true);
|
||||||
|
setUndoSeconds(10);
|
||||||
|
|
||||||
|
const bodyHtml = editorRef.current?.innerHTML || body;
|
||||||
|
|
||||||
|
// The countdown is counted here and not inside a state updater. The send
|
||||||
|
// used to be a side effect of that updater, which React may run twice (the
|
||||||
|
// message went out twice) and which Undo could not stop: Undo only cleared
|
||||||
|
// the number, and the next tick - finding it empty - sent the message
|
||||||
|
// anyway.
|
||||||
|
let remaining = 10;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining > 0) {
|
||||||
|
setUndoSeconds(remaining);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearInterval(timer);
|
||||||
|
pendingSend.current = null;
|
||||||
|
setUndoSeconds(null);
|
||||||
|
void sendRef.current(bodyHtml);
|
||||||
|
}, 1000);
|
||||||
|
pendingSend.current = { timer, html: bodyHtml };
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendActualMessage = async (htmlBody: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/mail/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
bcc,
|
||||||
|
subject,
|
||||||
|
html: htmlBody,
|
||||||
|
important,
|
||||||
|
in_reply_to: inReplyTo,
|
||||||
|
// The draft this message grew out of, so the server can discard it.
|
||||||
|
// Without this the draft outlived the message it became and sat in
|
||||||
|
// Drafts as a duplicate of mail already sent.
|
||||||
|
draft_uid: draftUidRef.current,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
// Reporting success on a refused send is how a message silently
|
||||||
|
// disappears: the composer used to navigate away regardless.
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setSendError(data.message ?? t("errors.serverError"));
|
||||||
|
setSending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A flush after leaving must not drag the writer back to Sent.
|
||||||
|
if (!unmounted.current) onSent();
|
||||||
|
} catch {
|
||||||
|
setSendError(t("errors.serverError"));
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sendRef.current = sendActualMessage;
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
// Stops the timer itself: clearing the number on screen is not enough.
|
||||||
|
if (pendingSend.current) {
|
||||||
|
clearInterval(pendingSend.current.timer);
|
||||||
|
pendingSend.current = null;
|
||||||
|
}
|
||||||
|
setUndoSeconds(null);
|
||||||
|
setSending(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One row of the compose sheet: a fixed-width label beside the control. */
|
||||||
|
const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]";
|
||||||
|
const fieldLabel = "w-14 flex-none text-[12.5px] leading-snug text-slate-400";
|
||||||
|
const bareInput =
|
||||||
|
"min-h-[30px] min-w-[160px] flex-1 border-0 bg-transparent text-sm text-slate-100 placeholder-slate-500 focus:outline-none";
|
||||||
|
const toolButton =
|
||||||
|
"flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={inline ? "" : "flex-1 overflow-y-auto bg-dark-bg px-5 pb-11 pt-7 sm:px-8"}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSend}
|
||||||
|
className={inline ? "flex w-full flex-col gap-3" : "mx-auto flex w-full max-w-[860px] flex-col gap-[18px]"}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
{!inline && <h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("mail.compose")}</h1>}
|
||||||
|
{draftStatus && (
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 text-[12.5px] text-slate-400">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 flex-none text-indigo-500" />
|
||||||
|
{draftStatus}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setImportant((v) => !v)}
|
||||||
|
aria-pressed={important}
|
||||||
|
disabled={sending}
|
||||||
|
title={t("mail.highImportanceHint")}
|
||||||
|
className={`inline-flex h-10 items-center gap-2 rounded-xl px-3.5 text-sm shadow-edge transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
|
||||||
|
important ? "bg-rose-900/70 text-rose-100" : "bg-dark-card text-slate-200 hover:bg-white/[0.07]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Flag className="h-4 w-4" fill={important ? "currentColor" : "none"} />
|
||||||
|
<span>{t("mail.highImportance")}</span>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
disabled={sending || discarding}
|
||||||
|
onClick={() => void discardMessage()}
|
||||||
|
>
|
||||||
|
{t("mail.discard")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" size="md" disabled={sending}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
<span>{sending ? t("mail.sending") : t("mail.sendMessage")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sendError && (
|
||||||
|
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-xs leading-relaxed text-rose-200 shadow-edge">
|
||||||
|
{sendError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{undoSeconds !== null && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl bg-dark-card px-4 py-3 text-xs text-slate-200 shadow-edge">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 flex-none animate-spin text-indigo-500" />
|
||||||
|
{/* The countdown is a placeholder inside the message, not text
|
||||||
|
bolted on after it. It used to be both: the bundle's own
|
||||||
|
"{seconds}" was never substituted and a second, hardcoded
|
||||||
|
count was appended, so this read "Undo Send ({seconds}s
|
||||||
|
remaining) (9s remaining)". */}
|
||||||
|
<span>{t("mail.undoSend", { seconds: undoSeconds })}</span>
|
||||||
|
</div>
|
||||||
|
{/* type="button": inside this form a button submits it, so Undo used to
|
||||||
|
stop the countdown and then send the message again. */}
|
||||||
|
<Button type="button" variant="primary" size="sm" onClick={handleUndo}>
|
||||||
|
<Undo2 className="h-3.5 w-3.5" />
|
||||||
|
{t("mail.undoSendAction")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* One sheet, the fields stacked as rows inside it, rather than a stack
|
||||||
|
of separately outlined inputs: the message is one object. */}
|
||||||
|
<div className="overflow-hidden rounded-2xl bg-dark-panel shadow-edge">
|
||||||
|
<div className={fieldRow}>
|
||||||
|
<label htmlFor="compose-to" className={fieldLabel}>
|
||||||
|
{t("ui.toRecipients")}
|
||||||
|
</label>
|
||||||
|
<RecipientInput
|
||||||
|
id="compose-to"
|
||||||
|
value={to}
|
||||||
|
onChange={setTo}
|
||||||
|
placeholder="recipient@example.com"
|
||||||
|
disabled={sending}
|
||||||
|
invalid={to.some((e) => !parseAddress(e).valid)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCcBcc(!showCcBcc)}
|
||||||
|
className="flex-none rounded-lg px-2 py-1 text-[12.5px] text-indigo-500 transition-colors hover:bg-indigo-500/10"
|
||||||
|
>
|
||||||
|
{showCcBcc ? t("mail.hideCcBcc") : t("mail.showCcBcc")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCcBcc && (
|
||||||
|
<>
|
||||||
|
<div className={fieldRow}>
|
||||||
|
<label htmlFor="compose-cc" className={fieldLabel}>
|
||||||
|
{t("mail.ccLabel")}
|
||||||
|
</label>
|
||||||
|
<RecipientInput
|
||||||
|
id="compose-cc"
|
||||||
|
value={cc}
|
||||||
|
onChange={setCc}
|
||||||
|
placeholder="cc@domain.com"
|
||||||
|
disabled={sending}
|
||||||
|
invalid={cc.some((e) => !parseAddress(e).valid)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={fieldRow}>
|
||||||
|
<label htmlFor="compose-bcc" className={fieldLabel}>
|
||||||
|
{t("mail.bccLabel")}
|
||||||
|
</label>
|
||||||
|
<RecipientInput
|
||||||
|
id="compose-bcc"
|
||||||
|
value={bcc}
|
||||||
|
onChange={setBcc}
|
||||||
|
placeholder="bcc@domain.com"
|
||||||
|
disabled={sending}
|
||||||
|
invalid={bcc.some((e) => !parseAddress(e).valid)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!inline && (
|
||||||
|
<div className={fieldRow}>
|
||||||
|
<label htmlFor="compose-subject" className={fieldLabel}>
|
||||||
|
{t("ui.subject")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="compose-subject"
|
||||||
|
type="text"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
placeholder={t("ui.subject")}
|
||||||
|
required
|
||||||
|
disabled={sending}
|
||||||
|
className={bareInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Formatting Toolbar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5 px-3 py-2 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]">
|
||||||
|
<button type="button" onClick={() => executeFormat("bold")} className={toolButton} title={t("mail.bold")}>
|
||||||
|
<Bold className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => executeFormat("italic")} className={toolButton} title={t("mail.italic")}>
|
||||||
|
<Italic className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => executeFormat("underline")}
|
||||||
|
className={toolButton}
|
||||||
|
title={t("mail.underline")}
|
||||||
|
>
|
||||||
|
<Underline className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => executeFormat("strikeThrough")}
|
||||||
|
className={toolButton}
|
||||||
|
title={t("mail.strikethrough")}
|
||||||
|
>
|
||||||
|
<Strikethrough className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => executeFormat("insertUnorderedList")}
|
||||||
|
className={toolButton}
|
||||||
|
title={t("mail.bulletList")}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => executeFormat("insertOrderedList")}
|
||||||
|
className={toolButton}
|
||||||
|
title={t("mail.numberedList")}
|
||||||
|
>
|
||||||
|
<ListOrdered className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const url = prompt(t("mail.insertUrl"));
|
||||||
|
if (url) executeFormat("createLink", url);
|
||||||
|
}}
|
||||||
|
className={`${toolButton} text-indigo-500`}
|
||||||
|
title={t("mail.insertLink")}
|
||||||
|
>
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
onInput={(e) => setBody((e.target as HTMLDivElement).innerHTML)}
|
||||||
|
onBlur={(e) => setBody((e.target as HTMLDivElement).innerHTML)}
|
||||||
|
aria-label={t("ui.messageBody")}
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
className={`${inline ? "min-h-[150px]" : "min-h-[280px]"} px-[22px] py-5 font-sans text-[14.5px] leading-relaxed text-slate-200 focus:outline-none`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Attachment Selector & Display */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3.5 shadow-[inset_0_1px_0_0_rgba(233,233,237,0.07)]">
|
||||||
|
<label className="flex flex-none cursor-pointer items-center gap-2 rounded-[10px] border border-white/[0.14] px-3 py-2 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07]">
|
||||||
|
<Paperclip className="h-[15px] w-[15px]" />
|
||||||
|
<span>{t("mail.addFiles")}</span>
|
||||||
|
<input type="file" multiple onChange={handleFileUpload} className="hidden" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{attachments.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="flex items-center gap-2 rounded-[10px] bg-dark-card px-3 py-2 text-[12.5px] text-slate-200 shadow-edge"
|
||||||
|
>
|
||||||
|
<Paperclip className="h-[15px] w-[15px] flex-none text-indigo-400" />
|
||||||
|
<span className="max-w-[160px] truncate">{file.name}</span>
|
||||||
|
<span className="text-[11px] text-slate-500">{(file.size / 1024).toFixed(1)} KB</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeAttachment(file.id)}
|
||||||
|
className="text-slate-400 transition-colors hover:text-rose-400"
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Says so plainly rather than letting someone attach a contract
|
||||||
|
and watch it silently not arrive: /mail/send is handed the file
|
||||||
|
names only, because there is no upload endpoint behind this
|
||||||
|
picker yet. */}
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<span className="ml-auto text-[11.5px] leading-relaxed text-slate-400">
|
||||||
|
{t("mail.attachmentsUnsupported")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslations } from "../i18n/provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One message body in its sandboxed frame, sized to its own content.
|
||||||
|
*
|
||||||
|
* Several of these sit on one page when a conversation is open, and every frame
|
||||||
|
* reports its height the same way, so a report is only taken from the frame
|
||||||
|
* that sent it. The sandbox has no allow-same-origin: the frame stays on an
|
||||||
|
* opaque origin and can read nothing of this page.
|
||||||
|
*/
|
||||||
|
export function MessageFrame({ document: html }: { document: string }) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const frame = useRef<HTMLIFrameElement>(null);
|
||||||
|
const [height, setHeight] = useState(160);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
if (event.source !== frame.current?.contentWindow) return;
|
||||||
|
const data = event.data as { type?: string; height?: number } | null;
|
||||||
|
if (data?.type !== "lm:reader-height" || typeof data.height !== "number") return;
|
||||||
|
// Clamped so a message that reports something absurd cannot produce a
|
||||||
|
// frame kilometres tall or one collapsed to nothing.
|
||||||
|
setHeight(Math.min(20000, Math.max(80, Math.ceil(data.height))));
|
||||||
|
};
|
||||||
|
window.addEventListener("message", onMessage);
|
||||||
|
return () => window.removeEventListener("message", onMessage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl bg-white shadow-edge">
|
||||||
|
<iframe
|
||||||
|
ref={frame}
|
||||||
|
title={t("ui.messageBody")}
|
||||||
|
srcDoc={html}
|
||||||
|
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
style={{ height: `${height}px` }}
|
||||||
|
className="block w-full border-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ChevronDown,
|
||||||
|
CornerUpLeft,
|
||||||
|
Download,
|
||||||
|
FolderInput,
|
||||||
|
Forward,
|
||||||
|
Image as ImageIcon,
|
||||||
|
MailOpen,
|
||||||
|
Reply,
|
||||||
|
ReplyAll,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useI18n } from "../i18n/provider";
|
||||||
|
import { Avatar } from "./Avatar";
|
||||||
|
import { Composer, type ComposerInitial } from "./Composer";
|
||||||
|
import { MessageFrame } from "./MessageFrame";
|
||||||
|
import {
|
||||||
|
initiallyOpen,
|
||||||
|
messageKey,
|
||||||
|
replyTarget,
|
||||||
|
type ThreadMessageInfo,
|
||||||
|
type ThreadSummary,
|
||||||
|
} from "../lib/conversations";
|
||||||
|
import { hasBlockedRemoteContent, messageDocument } from "../lib/renderMessage";
|
||||||
|
import { offersReplyAll, replyAllRecipients, replyRecipients } from "../lib/recipients";
|
||||||
|
import { plainSnippet } from "../lib/snippet";
|
||||||
|
import { notifyMailStateChanged } from "../lib/useFolders";
|
||||||
|
import type { InlineImages } from "../lib/emailBody";
|
||||||
|
|
||||||
|
/** One message as the mail API returns it with its body. */
|
||||||
|
interface FullMessage {
|
||||||
|
uid: number;
|
||||||
|
subject: string;
|
||||||
|
from: string;
|
||||||
|
to: string[];
|
||||||
|
cc: string[];
|
||||||
|
date: string;
|
||||||
|
text: string;
|
||||||
|
html: string;
|
||||||
|
attachments: string[];
|
||||||
|
message_id?: string;
|
||||||
|
important?: boolean;
|
||||||
|
inline_images: InlineImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MoveTarget {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Trusted {
|
||||||
|
isTrusted: (from: string) => boolean;
|
||||||
|
trust: (from: string) => Promise<boolean>;
|
||||||
|
revoke: (from: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
thread: ThreadSummary;
|
||||||
|
folder: string;
|
||||||
|
ownAddress: string;
|
||||||
|
trusted: Trusted;
|
||||||
|
moveTargets: MoveTarget[];
|
||||||
|
deleteIsPermanent: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Something changed that the list and the badges should show. */
|
||||||
|
onChanged: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandClass =
|
||||||
|
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
|
||||||
|
const infoLinkClass =
|
||||||
|
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
||||||
|
const replyButtonClass =
|
||||||
|
"inline-flex h-10 items-center gap-2 rounded-xl px-4 text-[13.5px] text-slate-200 shadow-[inset_0_0_0_1px_rgba(233,233,237,0.18)] transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
||||||
|
|
||||||
|
/** The name and address in a header like `Jenny <jenny@x.test>`. */
|
||||||
|
function splitSender(from: string): { name: string; address: string } {
|
||||||
|
const match = from.match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>\s*$/);
|
||||||
|
if (match) return { name: match[1].trim(), address: match[2].trim() };
|
||||||
|
return { name: "", address: from.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialsOf(name: string, address: string): string {
|
||||||
|
const source = (name || address || "?").trim();
|
||||||
|
const parts = source.replace(/[<>"]/g, "").split(/[\s.@_-]+/).filter(Boolean);
|
||||||
|
return (parts.slice(0, 2).map((p) => p[0]).join("") || "?").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThreadReader(props: Props) {
|
||||||
|
const { thread, folder, ownAddress, trusted } = props;
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
|
const [infos, setInfos] = useState<ThreadMessageInfo[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [open, setOpen] = useState<Set<string>>(new Set());
|
||||||
|
const [bodies, setBodies] = useState<Record<string, FullMessage | "error">>({});
|
||||||
|
// Images shown for this message only, by the reader's choice; a trusted
|
||||||
|
// sender's are shown without one.
|
||||||
|
const [imagesOnce, setImagesOnce] = useState<Set<string>>(new Set());
|
||||||
|
const [reply, setReply] = useState<{ nonce: number; initial: ComposerInitial } | null>(null);
|
||||||
|
const [moveOpen, setMoveOpen] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const fetching = useRef<Set<string>>(new Set());
|
||||||
|
const replyAnchor = useRef<HTMLDivElement>(null);
|
||||||
|
const loadedFor = useRef<string>("");
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
async (initial: boolean) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/mail/thread/${encodeURIComponent(thread.thread_id)}?folder=${encodeURIComponent(folder)}`,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
);
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = "/user/login";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(t("errors.serverError"));
|
||||||
|
const data: ThreadMessageInfo[] = await res.json();
|
||||||
|
setInfos(data);
|
||||||
|
setError(null);
|
||||||
|
setOpen((current) => {
|
||||||
|
if (initial) return initiallyOpen(data);
|
||||||
|
// After sending, the new message is the one to see.
|
||||||
|
const next = new Set(current);
|
||||||
|
if (data.length > 0) next.add(messageKey(data[data.length - 1]));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("errors.serverError"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[thread.thread_id, folder, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadedFor.current === thread.thread_id) return;
|
||||||
|
loadedFor.current = thread.thread_id;
|
||||||
|
setInfos(null);
|
||||||
|
setBodies({});
|
||||||
|
setImagesOnce(new Set());
|
||||||
|
setReply(null);
|
||||||
|
fetching.current = new Set();
|
||||||
|
void load(true);
|
||||||
|
}, [thread.thread_id, load]);
|
||||||
|
|
||||||
|
/** Fetches one message's body. Opening it is also what marks it read. */
|
||||||
|
const fetchBody = useCallback(
|
||||||
|
async (info: ThreadMessageInfo): Promise<FullMessage | null> => {
|
||||||
|
const key = messageKey(info);
|
||||||
|
if (fetching.current.has(key)) return null;
|
||||||
|
fetching.current.add(key);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/mail/message/${info.uid}?folder=${encodeURIComponent(info.folder)}`,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("fetch");
|
||||||
|
const full: FullMessage = await res.json();
|
||||||
|
setBodies((current) => ({ ...current, [key]: full }));
|
||||||
|
if (!info.seen) {
|
||||||
|
setInfos((current) =>
|
||||||
|
current ? current.map((m) => (messageKey(m) === key ? { ...m, seen: true } : m)) : current,
|
||||||
|
);
|
||||||
|
notifyMailStateChanged();
|
||||||
|
props.onChanged();
|
||||||
|
}
|
||||||
|
return full;
|
||||||
|
} catch {
|
||||||
|
setBodies((current) => ({ ...current, [key]: "error" }));
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
fetching.current.delete(key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// onChanged is a fresh closure each render; the fetch must not depend on it.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!infos) return;
|
||||||
|
for (const info of infos) {
|
||||||
|
const key = messageKey(info);
|
||||||
|
if (open.has(key) && !bodies[key]) void fetchBody(info);
|
||||||
|
}
|
||||||
|
}, [infos, open, bodies, fetchBody]);
|
||||||
|
|
||||||
|
const toggle = (info: ThreadMessageInfo) => {
|
||||||
|
const key = messageKey(info);
|
||||||
|
setOpen((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const inThisFolder = useMemo(
|
||||||
|
() => (infos ?? []).filter((m) => m.folder.toLowerCase() === folder.toLowerCase()),
|
||||||
|
[infos, folder],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Runs one request per message of the conversation that sits in this folder. */
|
||||||
|
const forEachHere = async (path: string, extra: (m: ThreadMessageInfo) => Record<string, unknown>) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
inThisFolder.map((m) =>
|
||||||
|
fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ folder, uid: m.uid, ...extra(m) }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
notifyMailStateChanged();
|
||||||
|
props.onChanged();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteThread = async () => {
|
||||||
|
await forEachHere("/api/v1/mail/delete", () => ({}));
|
||||||
|
props.onClose();
|
||||||
|
};
|
||||||
|
const moveThread = async (target: string) => {
|
||||||
|
setMoveOpen(false);
|
||||||
|
await forEachHere("/api/v1/mail/move", () => ({ target }));
|
||||||
|
props.onClose();
|
||||||
|
};
|
||||||
|
const markUnread = async () => {
|
||||||
|
const newest = inThisFolder[inThisFolder.length - 1];
|
||||||
|
if (!newest) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await fetch("/api/v1/mail/seen", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ folder, uid: newest.uid, seen: false }),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
notifyMailStateChanged();
|
||||||
|
props.onChanged();
|
||||||
|
}
|
||||||
|
props.onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Opens the reply under the conversation, addressed from the message it answers. */
|
||||||
|
const startReply = async (mode: "reply" | "replyAll") => {
|
||||||
|
if (!infos) return;
|
||||||
|
const target = replyTarget(infos);
|
||||||
|
if (!target) return;
|
||||||
|
const key = messageKey(target);
|
||||||
|
const full = (bodies[key] && bodies[key] !== "error" ? (bodies[key] as FullMessage) : null) ?? (await fetchBody(target));
|
||||||
|
if (!full) return;
|
||||||
|
const recipients =
|
||||||
|
mode === "replyAll" ? replyAllRecipients(full, ownAddress) : replyRecipients(full, ownAddress);
|
||||||
|
setReply({
|
||||||
|
nonce: Date.now(),
|
||||||
|
initial: {
|
||||||
|
mode,
|
||||||
|
subject: full.subject,
|
||||||
|
replyTo: recipients.to.join(", "),
|
||||||
|
cc: recipients.cc.join(", "),
|
||||||
|
inReplyTo: full.message_id ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setTimeout(() => replyAnchor.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastIncoming = infos ? replyTarget(infos) : null;
|
||||||
|
const canReplyAll = (() => {
|
||||||
|
if (!lastIncoming) return false;
|
||||||
|
const full = bodies[messageKey(lastIncoming)];
|
||||||
|
return full && full !== "error" ? offersReplyAll(full, ownAddress) : false;
|
||||||
|
})();
|
||||||
|
const waitingOnMe = infos && infos.length > 0 && !infos[infos.length - 1].own;
|
||||||
|
const iReplied = lastIncoming ? lastIncoming.answered : false;
|
||||||
|
|
||||||
|
const dateText = (iso: string, long: boolean) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
|
return long
|
||||||
|
? d.toLocaleString(locale, { weekday: "short", day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" })
|
||||||
|
: d.toLocaleDateString(locale, { day: "numeric", month: "short" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.article
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
aria-label={thread.subject}
|
||||||
|
>
|
||||||
|
{/* The commands that apply to the whole conversation. */}
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.09)] sm:px-4">
|
||||||
|
<button type="button" onClick={props.onClose} className={`${commandClass} md:hidden`} aria-label={t("mail.backToList")}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void startReply("reply")} disabled={!infos} className={commandClass}>
|
||||||
|
<Reply className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{t("mail.reply")}</span>
|
||||||
|
</button>
|
||||||
|
{canReplyAll && (
|
||||||
|
<button type="button" onClick={() => void startReply("replyAll")} className={commandClass}>
|
||||||
|
<ReplyAll className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{t("mail.replyAll")}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="mx-1 hidden h-5 w-px bg-white/10 sm:block" />
|
||||||
|
<button type="button" onClick={() => void deleteThread()} disabled={busy || inThisFolder.length === 0} className={commandClass}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
<span className="hidden lg:inline">{props.deleteIsPermanent ? t("mail.deleteForever") : t("common.delete")}</span>
|
||||||
|
</button>
|
||||||
|
{props.moveTargets.length > 0 && (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMoveOpen((v) => !v)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={moveOpen}
|
||||||
|
className={commandClass}
|
||||||
|
>
|
||||||
|
<FolderInput className="h-4 w-4" />
|
||||||
|
<span className="hidden lg:inline">{t("mail.moveTo")}</span>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
{moveOpen && (
|
||||||
|
<div role="menu" className="absolute left-0 top-full z-30 mt-1 min-w-[180px] rounded-xl bg-dark-panel p-1.5 shadow-edge ring-1 ring-white/10">
|
||||||
|
{props.moveTargets.map((target) => (
|
||||||
|
<button
|
||||||
|
key={target.name}
|
||||||
|
role="menuitem"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void moveThread(target.name)}
|
||||||
|
className="flex w-full items-center rounded-lg px-2.5 py-2 text-left text-[13px] text-slate-200 hover:bg-white/[0.07]"
|
||||||
|
>
|
||||||
|
{target.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={() => void markUnread()} disabled={busy} className={commandClass}>
|
||||||
|
<MailOpen className="h-4 w-4" />
|
||||||
|
<span className="hidden xl:inline">{t("mail.markUnread")}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={props.onClose} className={`${commandClass} ml-auto hidden md:inline-flex`} aria-label={t("mail.backToList")}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-3 px-4 pb-10 pt-5 sm:px-7 2xl:max-w-[1140px]">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-[19px] font-semibold leading-snug tracking-[-0.01em] text-slate-100 [overflow-wrap:anywhere] [text-wrap:pretty] sm:text-[22px]">
|
||||||
|
{thread.subject || t("mail.noSubject")}
|
||||||
|
</h1>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-[12.5px] text-slate-400">
|
||||||
|
<span>{t("conversations.messagesInConversation", { count: infos?.length ?? thread.message_count })}</span>
|
||||||
|
{infos && waitingOnMe && lastIncoming && (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium ${
|
||||||
|
iReplied ? "bg-emerald-500/15 text-emerald-200" : "bg-amber-500/15 text-amber-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CornerUpLeft className="h-3 w-3" aria-hidden />
|
||||||
|
{iReplied ? t("conversations.youReplied") : t("conversations.notReplied")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="rounded-lg bg-rose-500/10 px-3 py-2 text-[13px] text-rose-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!infos && !error && <div className="py-10 text-center text-sm text-slate-400">{t("common.loading")}</div>}
|
||||||
|
{infos && infos.length === 0 && (
|
||||||
|
<div className="py-10 text-center text-sm text-slate-400">{t("conversations.conversationEmpty")}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{infos?.map((info) => {
|
||||||
|
const key = messageKey(info);
|
||||||
|
const isOpen = open.has(key);
|
||||||
|
const full = bodies[key];
|
||||||
|
const loaded = full && full !== "error" ? full : null;
|
||||||
|
const sender = loaded ? splitSender(loaded.from) : { name: info.from_display_name, address: info.sender_address };
|
||||||
|
const displayName = info.own ? t("conversations.me") : sender.name || info.from_display_name || sender.address;
|
||||||
|
const trustedHere = loaded ? trusted.isTrusted(loaded.from) : false;
|
||||||
|
const showImages = trustedHere || imagesOnce.has(key);
|
||||||
|
const doc = loaded ? messageDocument(loaded, showImages) : null;
|
||||||
|
const blocked = loaded ? hasBlockedRemoteContent(loaded) : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={key}
|
||||||
|
className={`rounded-xl shadow-edge ${isOpen ? "bg-dark-panel" : "bg-dark-card/60 hover:bg-dark-card"}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(info)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl px-3.5 py-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
address={info.sender_address}
|
||||||
|
initials={initialsOf(displayName, info.sender_address)}
|
||||||
|
size={isOpen ? 40 : 30}
|
||||||
|
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
||||||
|
/>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="flex items-baseline gap-2">
|
||||||
|
<span className={`min-w-0 truncate text-[14px] ${info.seen ? "text-slate-200" : "font-semibold text-slate-100"}`}>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
{isOpen && !info.own && sender.address && (
|
||||||
|
<span className="min-w-0 truncate text-[12.5px] text-slate-400"><{sender.address}></span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{!isOpen && (
|
||||||
|
<span className="truncate text-[12.5px] text-slate-400">{plainSnippet(info.snippet)}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{info.answered && (
|
||||||
|
<CornerUpLeft
|
||||||
|
className="h-3.5 w-3.5 flex-none text-emerald-300"
|
||||||
|
aria-label={t("conversations.youReplied")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!info.seen && !info.own && <span className="h-[7px] w-[7px] flex-none rounded-full bg-indigo-500" />}
|
||||||
|
<span className="flex-none text-[12px] tabular-nums text-slate-400">{dateText(info.received_at, isOpen)}</span>
|
||||||
|
<ChevronDown className={`h-4 w-4 flex-none text-slate-500 transition-transform ${isOpen ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="flex flex-col gap-3 px-3.5 pb-4">
|
||||||
|
{full === "error" && (
|
||||||
|
<div role="alert" className="rounded-lg bg-rose-500/10 px-3 py-2 text-[13px] text-rose-200">
|
||||||
|
{t("errors.serverError")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loaded && full !== "error" && (
|
||||||
|
<div className="py-6 text-center text-[13px] text-slate-400">{t("conversations.loadingMessage")}</div>
|
||||||
|
)}
|
||||||
|
{loaded && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12.5px] text-slate-400 [overflow-wrap:anywhere]">
|
||||||
|
{loaded.to.length > 0 && (
|
||||||
|
<span>
|
||||||
|
<span className="text-slate-500">{t("ui.toRecipients")}: </span>
|
||||||
|
{loaded.to.slice(0, 3).join(", ")}
|
||||||
|
{loaded.to.length > 3 && ` +${loaded.to.length - 3}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!info.own && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void (trustedHere ? trusted.revoke(loaded.from) : trusted.trust(loaded.from))}
|
||||||
|
aria-pressed={trustedHere}
|
||||||
|
title={trustedHere ? t("mail.trustedSenderHint") : t("mail.trustSenderHint")}
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
|
||||||
|
trustedHere
|
||||||
|
? "bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30"
|
||||||
|
: "text-slate-400 shadow-[inset_0_0_0_1px_rgba(233,233,237,0.18)] hover:bg-white/[0.07] hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-3 w-3" aria-hidden />
|
||||||
|
{trustedHere ? t("mail.trustedSender") : t("mail.trustSender")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/user/compose?mode=forward&subject=${encodeURIComponent(loaded.subject)}`}
|
||||||
|
className="ml-auto inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-indigo-300 hover:bg-white/[0.07]"
|
||||||
|
>
|
||||||
|
<Forward className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{t("mail.forward")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{blocked && !trustedHere && (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg bg-dark-card px-3 py-2 shadow-edge">
|
||||||
|
<ImageIcon className="h-4 w-4 flex-none text-indigo-400" aria-hidden />
|
||||||
|
<span className="min-w-[180px] flex-1 text-[12.5px] leading-relaxed text-slate-300">
|
||||||
|
{imagesOnce.has(key) ? t("mail.imagesEnabled") : t("mail.remoteContentBlocked")}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setImagesOnce((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={infoLinkClass}
|
||||||
|
>
|
||||||
|
{imagesOnce.has(key) ? t("mail.hideImages") : t("mail.loadImages")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loaded.attachments.length > 0 && (
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{loaded.attachments.map((filename, idx) => (
|
||||||
|
<a
|
||||||
|
key={`${filename}-${idx}`}
|
||||||
|
href={`/api/v1/mail/message/${loaded.uid}/attachment/${encodeURIComponent(filename)}?folder=${encodeURIComponent(info.folder)}`}
|
||||||
|
download={filename}
|
||||||
|
className="group flex h-10 max-w-[260px] flex-none items-center gap-2 rounded-lg bg-dark-card px-2.5 text-slate-200 shadow-edge hover:shadow-edge-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate text-[12.5px]">{filename}</span>
|
||||||
|
<Download className="h-3.5 w-3.5 flex-none text-slate-500 group-hover:text-indigo-300" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{doc ? (
|
||||||
|
<MessageFrame document={doc} />
|
||||||
|
) : (
|
||||||
|
<pre className="whitespace-pre-wrap break-words rounded-xl bg-dark-bg px-4 py-4 font-sans text-[14.5px] leading-relaxed text-slate-200">
|
||||||
|
{loaded.text || t("mail.emptyBody")}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* The reply is written here, under the messages it answers, so they
|
||||||
|
stay in view while it is typed. */}
|
||||||
|
<div ref={replyAnchor} className="flex flex-col gap-3">
|
||||||
|
{reply ? (
|
||||||
|
<div className="rounded-xl bg-dark-panel p-3 shadow-edge">
|
||||||
|
<Composer
|
||||||
|
key={reply.nonce}
|
||||||
|
variant="inline"
|
||||||
|
initial={reply.initial}
|
||||||
|
onSent={() => {
|
||||||
|
setReply(null);
|
||||||
|
void load(false);
|
||||||
|
notifyMailStateChanged();
|
||||||
|
props.onChanged();
|
||||||
|
}}
|
||||||
|
onDiscarded={() => setReply(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
infos &&
|
||||||
|
infos.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button type="button" onClick={() => void startReply("reply")} className={replyButtonClass}>
|
||||||
|
<Reply className="h-4 w-4" />
|
||||||
|
{t("mail.reply")}
|
||||||
|
</button>
|
||||||
|
{canReplyAll && (
|
||||||
|
<button type="button" onClick={() => void startReply("replyAll")} className={replyButtonClass}>
|
||||||
|
<ReplyAll className="h-4 w-4" />
|
||||||
|
{t("mail.replyAll")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ const code = (path: string) =>
|
|||||||
.replace(/\{\/\*[\s\S]*?\*\/\}/g, "")
|
.replace(/\{\/\*[\s\S]*?\*\/\}/g, "")
|
||||||
.replace(/^\s*\/\/.*$/gm, "");
|
.replace(/^\s*\/\/.*$/gm, "");
|
||||||
|
|
||||||
const COMPOSE = "src/app/(user)/user/compose/page.tsx";
|
const COMPOSE = "src/components/Composer.tsx";
|
||||||
const MAIL_PAGE = "src/app/(user)/user/mail/[folder]/page.tsx";
|
const MAIL_PAGE = "src/app/(user)/user/mail/[folder]/page.tsx";
|
||||||
|
|
||||||
describe("the draft a sent message leaves behind", () => {
|
describe("the draft a sent message leaves behind", () => {
|
||||||
@@ -119,3 +119,32 @@ describe("discarding a draft", () => {
|
|||||||
expect(compose).toMatch(/discardedRef\.current && draftUidRef\.current > 0/);
|
expect(compose).toMatch(/discardedRef\.current && draftUidRef\.current > 0/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("undoing a send", () => {
|
||||||
|
// The countdown sent the message from inside a state updater. Undo cleared the
|
||||||
|
// number on screen but not the timer, whose next tick found it empty and sent
|
||||||
|
// the message anyway - so "Undo" undid nothing. React may also run an updater
|
||||||
|
// twice, which sent it twice.
|
||||||
|
it("does not send from inside a state updater", () => {
|
||||||
|
const compose = code(COMPOSE);
|
||||||
|
expect(compose).not.toMatch(/setUndoSeconds\(\(prev\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops the timer when the writer undoes", () => {
|
||||||
|
const compose = code(COMPOSE);
|
||||||
|
expect(compose).toMatch(/const handleUndo = \(\) => \{[\s\S]*?clearInterval\(pendingSend\.current\.timer\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A button inside a form submits it unless it says otherwise. Undo stopped the
|
||||||
|
// timer and then submitted the form, which started the send over.
|
||||||
|
it("is a button that does not submit the form", () => {
|
||||||
|
const compose = code(COMPOSE);
|
||||||
|
expect(compose).toMatch(/<Button type="button" variant="primary" size="sm" onClick=\{handleUndo\}>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a message still counting down when the screen is left", () => {
|
||||||
|
const compose = code(COMPOSE);
|
||||||
|
expect(compose).toMatch(/sendRef\.current\(pending\.html\)/);
|
||||||
|
expect(compose).toMatch(/if \(!unmounted\.current\) onSent\(\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
initiallyOpen,
|
||||||
|
messageKey,
|
||||||
|
participantsLine,
|
||||||
|
replyTarget,
|
||||||
|
type ThreadMessageInfo,
|
||||||
|
type ThreadParticipant,
|
||||||
|
} from "./lib/conversations";
|
||||||
|
|
||||||
|
const message = (uid: number, over: Partial<ThreadMessageInfo> = {}): ThreadMessageInfo => ({
|
||||||
|
uid,
|
||||||
|
folder: "INBOX",
|
||||||
|
subject: "s",
|
||||||
|
sender_address: "a@x.test",
|
||||||
|
from_display_name: "",
|
||||||
|
snippet: "",
|
||||||
|
received_at: "2026-10-22T10:00:00Z",
|
||||||
|
has_attachments: false,
|
||||||
|
seen: true,
|
||||||
|
flagged: false,
|
||||||
|
answered: false,
|
||||||
|
own: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("which messages of a conversation start open", () => {
|
||||||
|
it("opens the newest one and leaves the older read ones as a line", () => {
|
||||||
|
const open = initiallyOpen([message(1), message(2), message(3)]);
|
||||||
|
expect([...open]).toEqual(["inbox:3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also opens what somebody wrote and has not been read, wherever it is", () => {
|
||||||
|
const open = initiallyOpen([message(1, { seen: false }), message(2), message(3)]);
|
||||||
|
expect(open.has("inbox:1")).toBe(true);
|
||||||
|
expect(open.has("inbox:3")).toBe(true);
|
||||||
|
expect(open.has("inbox:2")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open my own message just because it carries no read flag", () => {
|
||||||
|
const open = initiallyOpen([message(1, { seen: false, own: true }), message(2)]);
|
||||||
|
expect(open.has("inbox:1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty for nothing", () => {
|
||||||
|
expect(initiallyOpen([]).size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("keys", () => {
|
||||||
|
// The same UID in two folders is two messages.
|
||||||
|
it("tells the same uid apart across folders", () => {
|
||||||
|
expect(messageKey({ folder: "INBOX", uid: 4 })).not.toBe(messageKey({ folder: "Sent", uid: 4 }));
|
||||||
|
});
|
||||||
|
it("ignores the case of the folder name", () => {
|
||||||
|
expect(messageKey({ folder: "Sent", uid: 4 })).toBe(messageKey({ folder: "sent", uid: 4 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("who a reply answers", () => {
|
||||||
|
it("is the newest message somebody else wrote", () => {
|
||||||
|
const target = replyTarget([message(1), message(2, { own: true }), message(3, { own: true })]);
|
||||||
|
expect(target?.uid).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the last message when everything is mine", () => {
|
||||||
|
expect(replyTarget([message(1, { own: true }), message(2, { own: true })])?.uid).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is nothing for an empty conversation", () => {
|
||||||
|
expect(replyTarget([])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the participants line", () => {
|
||||||
|
const p = (name: string, address: string, me = false): ThreadParticipant => ({ name, address, me });
|
||||||
|
|
||||||
|
it("names the owner as me", () => {
|
||||||
|
expect(participantsLine([p("Ana Souza", "ana@x.test"), p("Lucas", "me@x.test", true)], "me")).toBe("Ana, me");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the whole name when there is only one writer", () => {
|
||||||
|
expect(participantsLine([p("Ana Souza", "ana@x.test")], "me")).toBe("Ana Souza");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the address when there is no name, and does not cut it", () => {
|
||||||
|
expect(participantsLine([p("", "ana@x.test"), p("Bob Lee", "bob@x.test")], "me")).toBe("ana@x.test, Bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty for no one", () => {
|
||||||
|
expect(participantsLine([], "me")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mail read as conversations: one row per conversation in the list, and the
|
||||||
|
* messages of one stacked in the reader.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ThreadParticipant {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
me: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row of the conversation list. `uid` is the newest message in the folder. */
|
||||||
|
export interface ThreadSummary {
|
||||||
|
thread_id: string;
|
||||||
|
uid: number;
|
||||||
|
subject: string;
|
||||||
|
sender_address: string;
|
||||||
|
from_display_name: string;
|
||||||
|
snippet: string;
|
||||||
|
received_at: string;
|
||||||
|
has_attachments: boolean;
|
||||||
|
message_count: number;
|
||||||
|
unseen_count: number;
|
||||||
|
seen: boolean;
|
||||||
|
flagged: boolean;
|
||||||
|
answered: boolean;
|
||||||
|
participants: ThreadParticipant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadMessageInfo {
|
||||||
|
uid: number;
|
||||||
|
folder: string;
|
||||||
|
subject: string;
|
||||||
|
sender_address: string;
|
||||||
|
from_display_name: string;
|
||||||
|
snippet: string;
|
||||||
|
received_at: string;
|
||||||
|
has_attachments: boolean;
|
||||||
|
seen: boolean;
|
||||||
|
flagged: boolean;
|
||||||
|
answered: boolean;
|
||||||
|
own: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A UID is only unique inside its folder, and a conversation spans folders. */
|
||||||
|
export function messageKey(message: { folder: string; uid: number }): string {
|
||||||
|
return `${message.folder.toLowerCase()}:${message.uid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who wrote, for the list: names in the order they first wrote, the owner as
|
||||||
|
* "me", and a first name where there are several so the line fits.
|
||||||
|
*/
|
||||||
|
export function participantsLine(participants: ThreadParticipant[], meLabel: string): string {
|
||||||
|
if (participants.length === 0) return "";
|
||||||
|
const several = participants.length > 1;
|
||||||
|
return participants
|
||||||
|
.map((p) => {
|
||||||
|
if (p.me) return meLabel;
|
||||||
|
const name = (p.name || p.address).trim();
|
||||||
|
// "Ana Souza" becomes "Ana" beside other names, but an address stays whole.
|
||||||
|
return several && !name.includes("@") ? name.split(/\s+/)[0] : name;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which messages start open: the newest, and anything not yet read that
|
||||||
|
* someone else wrote. The rest are one line each, so a long conversation does
|
||||||
|
* not push the message that matters off the screen.
|
||||||
|
*/
|
||||||
|
export function initiallyOpen(messages: ThreadMessageInfo[]): Set<string> {
|
||||||
|
const open = new Set<string>();
|
||||||
|
if (messages.length === 0) return open;
|
||||||
|
open.add(messageKey(messages[messages.length - 1]));
|
||||||
|
for (const m of messages) {
|
||||||
|
if (!m.seen && !m.own) open.add(messageKey(m));
|
||||||
|
}
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The message a reply answers: the newest one somebody else wrote. */
|
||||||
|
export function replyTarget(messages: ThreadMessageInfo[]): ThreadMessageInfo | null {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (!messages[i].own) return messages[i];
|
||||||
|
}
|
||||||
|
return messages.length > 0 ? messages[messages.length - 1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Preferences {
|
||||||
|
signature: string;
|
||||||
|
auto_save_drafts: boolean;
|
||||||
|
conversation_view: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the list shows conversations. Kept on the account, so the choice
|
||||||
|
* follows it to every browser. On until the account says otherwise.
|
||||||
|
*
|
||||||
|
* `ready` is false until the account has answered: the list waits for it, or
|
||||||
|
* it would load one way and then reload the other.
|
||||||
|
*/
|
||||||
|
export function useConversationView() {
|
||||||
|
const [prefs, setPrefs] = useState<Preferences | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetch("/api/v1/user/preferences", { cache: "no-store" })
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled && data) setPrefs(data);
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setReady(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enabled = prefs?.conversation_view ?? true;
|
||||||
|
|
||||||
|
const setEnabled = useCallback(
|
||||||
|
async (next: boolean) => {
|
||||||
|
if (!prefs) return false;
|
||||||
|
setPrefs({ ...prefs, conversation_view: next });
|
||||||
|
try {
|
||||||
|
// The whole record goes back: the endpoint replaces the signature with
|
||||||
|
// whatever it is given, so saving only the switch would blank it.
|
||||||
|
const res = await fetch("/api/v1/user/preferences", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...prefs, conversation_view: next }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("rejected");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
setPrefs(prefs);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[prefs],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { enabled, ready, setEnabled };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "./sanitizer";
|
||||||
|
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "./emailBody";
|
||||||
|
|
||||||
|
/** What it takes to draw a message's body. */
|
||||||
|
export interface RenderableBody {
|
||||||
|
html: string;
|
||||||
|
inline_images?: InlineImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The document the reader frame renders, or null for a message with no HTML.
|
||||||
|
*
|
||||||
|
* Order matters: sanitise first so nothing downstream handles untrusted
|
||||||
|
* markup, then resolve the message's own inline parts, then block or restore
|
||||||
|
* remote sources - which is the only part the reader chooses.
|
||||||
|
*/
|
||||||
|
export function messageDocument(message: RenderableBody, showImages: boolean): string | null {
|
||||||
|
if (!message.html) return null;
|
||||||
|
const sanitized = sanitizeEmailHtml(message.html);
|
||||||
|
if (!sanitized) return null;
|
||||||
|
const withInline = resolveInlineImages(sanitized, message.inline_images ?? {});
|
||||||
|
const body = showImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline);
|
||||||
|
return buildReaderDocument(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether anything was actually held back, so the privacy bar is not shown over
|
||||||
|
* a message that has no remote content at all.
|
||||||
|
*/
|
||||||
|
export function hasBlockedRemoteContent(message: RenderableBody): boolean {
|
||||||
|
if (!message.html) return false;
|
||||||
|
const sanitized = resolveInlineImages(sanitizeEmailHtml(message.html), message.inline_images ?? {});
|
||||||
|
return blockRemoteImages(sanitized) !== sanitized;
|
||||||
|
}
|
||||||
@@ -31,7 +31,10 @@ export function plainSnippet(raw: string | null | undefined): string {
|
|||||||
const withoutTags = raw
|
const withoutTags = raw
|
||||||
// A block boundary is a space, or "Hello,</p><p>You" would read "Hello,You".
|
// A block boundary is a space, or "Hello,</p><p>You" would read "Hello,You".
|
||||||
.replace(/<\/(?:p|div|li|tr|h[1-6])>|<br\s*\/?>/gi, " ")
|
.replace(/<\/(?:p|div|li|tr|h[1-6])>|<br\s*\/?>/gi, " ")
|
||||||
.replace(/<(style|script|head)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
// A stylesheet or script is not text. The preview is cut at a fixed
|
||||||
|
// length, so the closing tag may be missing: then everything after the
|
||||||
|
// opening one is the rule set, and all of it goes.
|
||||||
|
.replace(/<(style|script|head)\b[^>]*>[\s\S]*?(?:<\/\1>|$)/gi, " ")
|
||||||
.replace(/<[^>]*>/g, "")
|
.replace(/<[^>]*>/g, "")
|
||||||
// The server cuts the preview at a fixed length, which can leave the start
|
// The server cuts the preview at a fixed length, which can leave the start
|
||||||
// of a tag with no end ("...open</p").
|
// of a tag with no end ("...open</p").
|
||||||
|
|||||||
@@ -106,13 +106,17 @@ describe("read and unread state", () => {
|
|||||||
|
|
||||||
describe("the reader pane", () => {
|
describe("the reader pane", () => {
|
||||||
it("resolves the message's own inline images", () => {
|
it("resolves the message's own inline images", () => {
|
||||||
const page = code(MAIL_PAGE);
|
// The pipeline moved to one place both readers - a single message and a
|
||||||
expect(page).toContain("resolveInlineImages");
|
// conversation - draw through, so it is checked there.
|
||||||
expect(page).toContain("inline_images");
|
const render = code("src/lib/renderMessage.ts");
|
||||||
|
expect(render).toContain("resolveInlineImages");
|
||||||
|
expect(render).toContain("inline_images");
|
||||||
|
expect(code(MAIL_PAGE)).toContain("messageDocument");
|
||||||
|
expect(code("src/components/ThreadReader.tsx")).toContain("messageDocument");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the body inside the tested document rather than a bare fragment", () => {
|
it("renders the body inside the tested document rather than a bare fragment", () => {
|
||||||
expect(code(MAIL_PAGE)).toContain("buildReaderDocument");
|
expect(code("src/lib/renderMessage.ts")).toContain("buildReaderDocument");
|
||||||
});
|
});
|
||||||
|
|
||||||
// A fixed 500px frame clipped every long message and left a slab of white
|
// A fixed 500px frame clipped every long message and left a slab of white
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ describe("plainSnippet", () => {
|
|||||||
it("drops a tag the preview was cut in the middle of", () => {
|
it("drops a tag the preview was cut in the middle of", () => {
|
||||||
expect(plainSnippet("<p>Open cyber model access</p")).toBe("Open cyber model access");
|
expect(plainSnippet("<p>Open cyber model access</p")).toBe("Open cyber model access");
|
||||||
});
|
});
|
||||||
|
it("drops a stylesheet the preview was cut inside of", () => {
|
||||||
|
expect(plainSnippet("<style>.card{border:2px solid #0a7;padding:16px")).toBe("");
|
||||||
|
expect(plainSnippet("<style>.a{color:red}</style><p>Hello")).toBe("Hello");
|
||||||
|
});
|
||||||
it("keeps an entity it does not know", () => {
|
it("keeps an entity it does not know", () => {
|
||||||
expect(plainSnippet("a &madeup; b")).toBe("a &madeup; b");
|
expect(plainSnippet("a &madeup; b")).toBe("a &madeup; b");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user