Implement support for replies
This commit is contained in:
parent
834c23735d
commit
1f5889dc39
@ -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",
|
||||||
|
@ -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: {
|
||||||
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
@ -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(
|
||||||
|
@ -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 = {
|
||||||
|
Loading…
Reference in New Issue
Block a user