From 1f5889dc39ac4bf7ec18f1dd0009c6bddb474984 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Sun, 5 Jan 2025 19:55:54 -0500 Subject: [PATCH] Implement support for replies --- bun.lockb | Bin 30971 -> 31321 bytes package.json | 1 + src/config.ts | 1 + src/mail/read.ts | 11 +++++++---- src/threads/id.ts | 17 +++++++++++++++-- src/threads/refresh.ts | 36 +++++++++++++++++++++++++++++++++--- src/types.ts | 7 +++++++ 7 files changed, 64 insertions(+), 9 deletions(-) diff --git a/bun.lockb b/bun.lockb index a0d36874afc9a060d2c693d036822ae50d23d30b..1248ee3bb58348f11b0a0f8d7516d3446964680b 100755 GIT binary patch delta 2227 zcmdUwT}+!*7{||f`4|PdvI(>WHbfmzRtjZwEydYEHi31Fua!>2Mj_J;h96NkL7mjh z#BnhrIbNt2nq@aE&SbMOacUN(L|xn*nHNT#aVUwpM2(4wF-xld|J#G<1;&fT3!mio z{@>@E=bY!f@AR|a^@(v-4c9iZ0h^5vB68g$$S9ha^d zD8pYg`|%^cX?|T0NVy_i*P=RaT|S$_hY^a+ejDgC9r*3My0V);^Wr8q+>C6YSOga>~s21NO5|$5GkO zsP?I>Kd>};V{q8;L0$0>bjkAZLTKsA_?ur{dh*hj8Aq13yv&ihnH`p{9KcPlE}dWe z^YSwDivMbLb^hz}LVkYf|8jK|o0B)DKjH7ym1ruz>e$|LaP`2k`N-vKcbsp0tR(uz zjBonAPiLy%AH4k4_ftRl*S+&*^ZwUMrqv^#z5LXdl}Dfae1ki``r?h}zYD+h?d&(n z!&oO$ByCA(hq45s>g|A4JrD@XusREGf0I?MX$s3uHPRGSj-XXtg4d;-!KnHe-r-@1KpaO88S^@t&4+EZgRiFoSg9x|>^n;yX7vMKV0go$wS{Q5x zEr7eD5%_=`)B>KH+LW^PP-y+^};)sx?x{lTGc59C0k&(M#8@A@_Ja4IXdehscaY zE~@E4r{OA6rv@8RPU&g=X7wpm?^)t5+2Nj@4r8}MC}<13TnI^_IB hqGsq+VUZqhU2UCytHp5DGkYsk= zeiE^;5;mfV+*LPS);kCx5)unLB3>K8@crJojgg4Iz%TjC_x#T5o_pq;?|tz_fBUG< z`pQ2&IeFfZb+zO5E$7F3kH>xtr5=uM_C=n4XevK)>d8=ZZ$eoSITtN9PIlf*v>2f} z)XY$7aM-2PROn~uG-xI2sROf=nh7mMo&)tj9nf{NmCA;mF|)JU4Kp@zYo2^}uhb*r z_cRzkT{7UQ*NrmbX~;f}DVouBf`lfmFrLnmfVW<|q~F`1yJQ5v7v-zB!FWDf0{MXu z7QuyBk*Nl1oIr!=R%=?Z4mHotsakI2yOXRavuiVzqZa_qRsonV1dL2&`(mp$t?cIm z)FpQP-<0F9brn{}D!XH*W}&VcaH1Nv%a0;=ISVTQhx-9r1GWaC3`^y5fjh}w^?=$4 z*gFImRQxY$9bo%;!z&F1ww|@q6u@%JzM>_Ch&sznvd|jt7?xxL1E38xHt?T>c*yrG6imR8ETL zN-6Vt@m-HRs155@@%p3U@jK;+Kdi%Y2ia|8ivwZ3LHYwxNd%no3R%092cxne=#_#K=oamN?!_#ptY1v*cpb9_s**Skf=gb;7r}*O%J!vY_ptNW t>%pPg>iaq|J=GCU_uOHBksT`!)z+6(O036Y_H94zl34#_GuSsL>lds_>?i;L 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 = {