Implement support for replies
This commit is contained in:
parent
834c23735d
commit
1f5889dc39
@ -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",
|
||||
|
@ -15,6 +15,7 @@ const maybeConfig: any = {
|
||||
type: 'alias',
|
||||
template: process.env.CHORUS_THREAD_TEMPLATE,
|
||||
idPrefix: 'c.',
|
||||
replyPrefix: '.r.',
|
||||
},
|
||||
},
|
||||
dirs: {
|
||||
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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 = {
|
||||
|
Loading…
Reference in New Issue
Block a user