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/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",

View File

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

View File

@ -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<Collection<string>> {
// There are 2 possibilities for where mail might end up.
@ -102,12 +103,13 @@ export class MailboxIterable extends Iterable<Message> {
.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<Message> {
html: htmlReplyPipeline.apply(source),
recipients,
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%
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)
}

View File

@ -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<void> {
await withClient(async client => {
@ -41,6 +42,9 @@ export async function refreshThreadsEntirely(): Promise<void> {
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<void> {
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<void> {
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(

View File

@ -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 = {