Implement support for replies

This commit is contained in:
Garrett Mills 2025-01-05 19:55:54 -05:00
parent 834c23735d
commit 1f5889dc39
7 changed files with 64 additions and 9 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -12,6 +12,7 @@
"@types/imapflow": "^1.0.19", "@types/imapflow": "^1.0.19",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/mailparser": "^3.4.5", "@types/mailparser": "^3.4.5",
"blakejs": "^1.2.1",
"imapflow": "^1.0.171", "imapflow": "^1.0.171",
"isomorphic-dompurify": "^2.19.0", "isomorphic-dompurify": "^2.19.0",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",

View File

@ -15,6 +15,7 @@ const maybeConfig: any = {
type: 'alias', type: 'alias',
template: process.env.CHORUS_THREAD_TEMPLATE, template: process.env.CHORUS_THREAD_TEMPLATE,
idPrefix: 'c.', idPrefix: 'c.',
replyPrefix: '.r.',
}, },
}, },
dirs: { dirs: {

View File

@ -10,6 +10,7 @@ import {AsyncCollection} from "../bones/collection/AsyncCollection.ts";
import {withClient} from "./client.ts"; import {withClient} from "./client.ts";
import {buildThreadAddressMatcher} from "../threads/id.ts"; import {buildThreadAddressMatcher} from "../threads/id.ts";
import {htmlReplyPipeline} from "./sanitize.ts"; import {htmlReplyPipeline} from "./sanitize.ts";
import {blake2bHex, blake2sHex} from "blakejs";
export async function getMailboxesToSearch(thread?: string, client?: ImapFlow): Promise<Collection<string>> { export async function getMailboxesToSearch(thread?: string, client?: ImapFlow): Promise<Collection<string>> {
// There are 2 possibilities for where mail might end up. // There are 2 possibilities for where mail might end up.
@ -102,12 +103,13 @@ export class MailboxIterable extends Iterable<Message> {
.map(x => x.address || '') .map(x => x.address || '')
.filter(Boolean) .filter(Boolean)
const thread = recipients.map(addr => this.addressMatcher.exec(addr)) const addressMatch = recipients.map(addr => this.addressMatcher.exec(addr))
.map(result => result?.[1])
.filter(Boolean)[0] .filter(Boolean)[0]
const id = `${this.mailbox}.${message.uid}`
return { return {
id: `${this.mailbox}.${message.uid}`, id,
idHash: blake2sHex(id, undefined, 8),
date: message.envelope.date, date: message.envelope.date,
from: message.envelope.from[0], from: message.envelope.from[0],
subject: message.envelope.subject, subject: message.envelope.subject,
@ -116,7 +118,8 @@ export class MailboxIterable extends Iterable<Message> {
html: htmlReplyPipeline.apply(source), html: htmlReplyPipeline.apply(source),
recipients, recipients,
content, content,
thread, thread: addressMatch?.[1],
replyToHash: addressMatch?.[2]?.substring(config.mail.threads.replyPrefix.length),
} }
} }

View File

@ -15,8 +15,21 @@ export function buildThreadAddressMatcher(): RegExp {
// In the config, the thread ID is represented with the placeholder: %ID% // In the config, the thread ID is represented with the placeholder: %ID%
const idPrefix = escapeRexString(config.mail.threads.idPrefix) 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}$`) 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)
}

View File

@ -1,11 +1,12 @@
import {getMailboxesToSearch, MailboxIterable} from "../mail/read.ts"; import {getMailboxesToSearch, MailboxIterable} from "../mail/read.ts";
import {withClient} from "../mail/client.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 {AsyncCollection} from "../bones/collection/AsyncCollection.ts";
import {sha256} from "../bones/crypto.ts"; import {sha256} from "../bones/crypto.ts";
import {config} from "../config.ts"; import {config} from "../config.ts";
import { marked } from "marked"; import { marked } from "marked";
import {sanitizeHtml} from "../mail/sanitize.ts"; import {sanitizeHtml} from "../mail/sanitize.ts";
import {formatThreadAddress} from "./id.ts";
export async function refreshThreadsEntirely(): Promise<void> { export async function refreshThreadsEntirely(): Promise<void> {
await withClient(async client => { await withClient(async client => {
@ -41,6 +42,9 @@ export async function refreshThreadsEntirely(): Promise<void> {
comments: [], 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] const messages = messagesByThread[threadId]
for ( const message of messages ) { for ( const message of messages ) {
if ( if (
@ -50,7 +54,10 @@ export async function refreshThreadsEntirely(): Promise<void> {
threadData.refresh.markers[message.mailbox] = message.modseq 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: { user: {
name: message.from.name || '(anonymous)', name: message.from.name || '(anonymous)',
mailId: sha256(message.from.address!.toLowerCase()), mailId: sha256(message.from.address!.toLowerCase()),
@ -60,7 +67,30 @@ export async function refreshThreadsEntirely(): Promise<void> {
subject: message.subject, subject: message.subject,
text: message.content, text: message.content,
rendered: message.html || sanitizeHtml(await marked(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( const json = JSON.stringify(

View File

@ -14,6 +14,7 @@ const commentsConfigSchema = z.object({
type: z.string(), // fixme : in validation type: z.string(), // fixme : in validation
template: z.string(), template: z.string(),
idPrefix: z.string(), idPrefix: z.string(),
replyPrefix: z.string(),
}), }),
}), }),
dirs: z.object({ dirs: z.object({
@ -30,6 +31,7 @@ export const castCommentsConfig = (what: unknown): CommentsConfig => {
export type Message = { export type Message = {
id: string, id: string,
idHash: string,
date: Date, date: Date,
recipients: string[], recipients: string[],
from: { from: {
@ -42,6 +44,7 @@ export type Message = {
mailbox: string, mailbox: string,
modseq: BigInt, modseq: BigInt,
thread?: string, thread?: string,
replyToHash?: string,
} }
export type ThreadUser = { export type ThreadUser = {
@ -51,11 +54,15 @@ export type ThreadUser = {
} }
export type ThreadComment = { export type ThreadComment = {
idHash: string,
replyToHash?: string,
replyAddress: string,
user: ThreadUser, user: ThreadUser,
date: Date, date: Date,
subject: string, subject: string,
text: string, text: string,
rendered: string, rendered: string,
replies?: ThreadComment[],
} }
export type ThreadData = { export type ThreadData = {