diff --git a/bun.lockb b/bun.lockb index a0d3687..1248ee3 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index c1b5df2..19ed73a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@types/imapflow": "^1.0.19", "@types/jsdom": "^21.1.7", "@types/mailparser": "^3.4.5", + "blakejs": "^1.2.1", "imapflow": "^1.0.171", "isomorphic-dompurify": "^2.19.0", "jsdom": "^25.0.1", diff --git a/src/config.ts b/src/config.ts index c5c95de..a340483 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,7 @@ const maybeConfig: any = { type: 'alias', template: process.env.CHORUS_THREAD_TEMPLATE, idPrefix: 'c.', + replyPrefix: '.r.', }, }, dirs: { diff --git a/src/mail/read.ts b/src/mail/read.ts index 3f4faf7..361b81c 100644 --- a/src/mail/read.ts +++ b/src/mail/read.ts @@ -10,6 +10,7 @@ import {AsyncCollection} from "../bones/collection/AsyncCollection.ts"; import {withClient} from "./client.ts"; import {buildThreadAddressMatcher} from "../threads/id.ts"; import {htmlReplyPipeline} from "./sanitize.ts"; +import {blake2bHex, blake2sHex} from "blakejs"; export async function getMailboxesToSearch(thread?: string, client?: ImapFlow): Promise> { // There are 2 possibilities for where mail might end up. @@ -102,12 +103,13 @@ export class MailboxIterable extends Iterable { .map(x => x.address || '') .filter(Boolean) - const thread = recipients.map(addr => this.addressMatcher.exec(addr)) - .map(result => result?.[1]) + const addressMatch = recipients.map(addr => this.addressMatcher.exec(addr)) .filter(Boolean)[0] + const id = `${this.mailbox}.${message.uid}` return { - id: `${this.mailbox}.${message.uid}`, + id, + idHash: blake2sHex(id, undefined, 8), date: message.envelope.date, from: message.envelope.from[0], subject: message.envelope.subject, @@ -116,7 +118,8 @@ export class MailboxIterable extends Iterable { html: htmlReplyPipeline.apply(source), recipients, content, - thread, + thread: addressMatch?.[1], + replyToHash: addressMatch?.[2]?.substring(config.mail.threads.replyPrefix.length), } } diff --git a/src/threads/id.ts b/src/threads/id.ts index 844fdc1..cae8c02 100644 --- a/src/threads/id.ts +++ b/src/threads/id.ts @@ -15,8 +15,21 @@ export function buildThreadAddressMatcher(): RegExp { // In the config, the thread ID is represented with the placeholder: %ID% const idPrefix = escapeRexString(config.mail.threads.idPrefix) - const idCapture = `(${idPrefix}[^@]+)` // first rule of email: don't be as strict as the RFC + const idCapture = `(${idPrefix}[^@.]+)` // first rule of email: don't be as strict as the RFC (special case: IDs after the prefix cannot contain a period) + + const replyPrefix = escapeRexString(config.mail.threads.replyPrefix) + const replyCapture = `(${replyPrefix}[^@.]+)?` + + const template = escapeRexString(config.mail.threads.template) + .replace('%ID%', idCapture) + .replace('%REPLY%', replyCapture) - const template = escapeRexString(config.mail.threads.template).replace('%ID%', idCapture) return new RegExp(`^${template}$`) } + +export function formatThreadAddress(thread: string, replyToHash?: string): string { + let reply = replyToHash ? `${config.mail.threads.replyPrefix}${replyToHash}` : '' + return config.mail.threads.template + .replace('%ID%', thread) + .replace('%REPLY%', reply) +} diff --git a/src/threads/refresh.ts b/src/threads/refresh.ts index 6012ece..f5c23a5 100644 --- a/src/threads/refresh.ts +++ b/src/threads/refresh.ts @@ -1,11 +1,12 @@ import {getMailboxesToSearch, MailboxIterable} from "../mail/read.ts"; import {withClient} from "../mail/client.ts"; -import type {Message, RootData, ThreadData} from "../types.ts"; +import type {Message, RootData, ThreadComment, ThreadData} from "../types.ts"; import {AsyncCollection} from "../bones/collection/AsyncCollection.ts"; import {sha256} from "../bones/crypto.ts"; import {config} from "../config.ts"; import { marked } from "marked"; import {sanitizeHtml} from "../mail/sanitize.ts"; +import {formatThreadAddress} from "./id.ts"; export async function refreshThreadsEntirely(): Promise { await withClient(async client => { @@ -41,6 +42,9 @@ export async function refreshThreadsEntirely(): Promise { comments: [], } + // 3 passes required to make this work (works because objects are by-reference) + // Pass 1: create all the ThreadComment instances and make a map by their ID + const commentsByHash: {[hash: string]: ThreadComment} = {} const messages = messagesByThread[threadId] for ( const message of messages ) { if ( @@ -50,7 +54,10 @@ export async function refreshThreadsEntirely(): Promise { threadData.refresh.markers[message.mailbox] = message.modseq } - threadData.comments.push({ + commentsByHash[message.idHash] = { + idHash: message.idHash, + replyToHash: message.replyToHash, + replyAddress: formatThreadAddress(threadId, message.idHash), user: { name: message.from.name || '(anonymous)', mailId: sha256(message.from.address!.toLowerCase()), @@ -60,7 +67,30 @@ export async function refreshThreadsEntirely(): Promise { subject: message.subject, text: message.content, rendered: message.html || sanitizeHtml(await marked(message.content)), - }) + } + } + + // Pass 2: Push replies into the replies array of their immediate parent + for ( const message of messages ) { + if ( !message.replyToHash ) { + continue + } + + if ( !commentsByHash[message.replyToHash].replies ) { + commentsByHash[message.replyToHash].replies = [] + } + + commentsByHash[message.replyToHash].replies!.push(commentsByHash[message.idHash]) + } + + // Pass 3: push all top-level comments into the ThreadData + for ( const idHash of Object.keys(commentsByHash) ) { + const comment = commentsByHash[idHash] + if ( comment.replyToHash ) { + continue + } + + threadData.comments.push(comment) } const json = JSON.stringify( diff --git a/src/types.ts b/src/types.ts index 5599797..ef3f782 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,7 @@ const commentsConfigSchema = z.object({ type: z.string(), // fixme : in validation template: z.string(), idPrefix: z.string(), + replyPrefix: z.string(), }), }), dirs: z.object({ @@ -30,6 +31,7 @@ export const castCommentsConfig = (what: unknown): CommentsConfig => { export type Message = { id: string, + idHash: string, date: Date, recipients: string[], from: { @@ -42,6 +44,7 @@ export type Message = { mailbox: string, modseq: BigInt, thread?: string, + replyToHash?: string, } export type ThreadUser = { @@ -51,11 +54,15 @@ export type ThreadUser = { } export type ThreadComment = { + idHash: string, + replyToHash?: string, + replyAddress: string, user: ThreadUser, date: Date, subject: string, text: string, rendered: string, + replies?: ThreadComment[], } export type ThreadData = {