284 lines
9.1 KiB
TypeScript
284 lines
9.1 KiB
TypeScript
import type { Email, ConversationMessage } from "@/lib/email-data"
|
||
import type {
|
||
Contact,
|
||
ComposeOpenPreset,
|
||
ThreadComposeKind,
|
||
} from "@/lib/compose-context"
|
||
import { readCoarsePointerMatches } from "@/hooks/use-touch-nav"
|
||
import { readXsMatches } from "@/hooks/use-xs"
|
||
import { DEFAULT_IDENTITIES, SIGNATURES } from "@/lib/compose-context"
|
||
import { formatMailDetailDate } from "@/lib/mail-date"
|
||
import { cleanSenderName } from "@/lib/sender-display"
|
||
|
||
function appendDefaultSignature(html: string): string {
|
||
const identity = DEFAULT_IDENTITIES[0]
|
||
const sigId = identity.defaultSignatureId
|
||
const sig = sigId ? SIGNATURES.find((s) => s.id === sigId) : null
|
||
if (!sig) return html
|
||
return `${html}<div id="ultimail-signature"><p>--</p>${sig.html}</div>`
|
||
}
|
||
|
||
/** Signature juste après la zone de saisie, avant le bloc « message transféré ». */
|
||
function insertSignatureBeforeForwardedBlock(html: string): string {
|
||
const identity = DEFAULT_IDENTITIES[0]
|
||
const sigId = identity.defaultSignatureId
|
||
const sig = sigId ? SIGNATURES.find((s) => s.id === sigId) : null
|
||
if (!sig) return html
|
||
const sigBlock = `<div id="ultimail-signature"><p>--</p>${sig.html}</div>`
|
||
const lead = "<p></p>"
|
||
if (html.startsWith(lead)) {
|
||
return `${lead}${sigBlock}${html.slice(lead.length)}`
|
||
}
|
||
return `${lead}${sigBlock}${html}`
|
||
}
|
||
|
||
function escapeHtml(text: string): string {
|
||
return text
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
}
|
||
|
||
function myEmailSet(): Set<string> {
|
||
return new Set(DEFAULT_IDENTITIES.map((i) => i.email.toLowerCase()))
|
||
}
|
||
|
||
function contactKey(email: string): string {
|
||
return email.trim().toLowerCase()
|
||
}
|
||
|
||
/** Tous les expéditeurs des messages du fil (hors « moi »), ordre chronologique conservé. */
|
||
export function collectThreadParticipants(email: Email): Contact[] {
|
||
const my = myEmailSet()
|
||
const seen = new Set<string>()
|
||
const out: Contact[] = []
|
||
|
||
const push = (name: string, addrRaw: string) => {
|
||
const addr = addrRaw.trim()
|
||
if (!addr) return
|
||
const k = contactKey(addr)
|
||
if (my.has(k) || seen.has(k)) return
|
||
seen.add(k)
|
||
out.push({
|
||
name: cleanSenderName(name) || addr,
|
||
email: addr,
|
||
})
|
||
}
|
||
|
||
for (const m of email.conversation ?? []) {
|
||
push(m.sender, m.senderEmail)
|
||
}
|
||
const mainAddr =
|
||
email.senderEmail ??
|
||
`${cleanSenderName(email.sender).toLowerCase().replace(/\s+/g, ".")}@example.com`
|
||
push(email.sender, mainAddr)
|
||
|
||
return out
|
||
}
|
||
|
||
function lastMessage(email: Email): ConversationMessage | null {
|
||
const conv = email.conversation ?? []
|
||
if (conv.length === 0) return null
|
||
return conv[conv.length - 1]!
|
||
}
|
||
|
||
function replySubject(subject: string): string {
|
||
const s = subject.trim()
|
||
if (/^re\s*:/i.test(s)) return s
|
||
return `Re: ${s}`
|
||
}
|
||
|
||
function forwardSubject(subject: string): string {
|
||
const s = subject.trim()
|
||
if (/^(fwd|fw|tr)\s*:/i.test(s)) return s
|
||
return `Fwd: ${s}`
|
||
}
|
||
|
||
function buildReferences(email: Email): string[] {
|
||
const ids: string[] = []
|
||
for (const m of email.conversation ?? []) {
|
||
ids.push(`<thread-msg-${m.id}@local>`)
|
||
}
|
||
ids.push(`<thread-msg-${email.id}@local>`)
|
||
return ids
|
||
}
|
||
|
||
function inReplyToFor(email: Email): string {
|
||
const last = lastMessage(email)
|
||
if (last) return `<thread-msg-${last.id}@local>`
|
||
return `<thread-msg-${email.id}@local>`
|
||
}
|
||
|
||
function formatQuoteDate(
|
||
dateIso: string,
|
||
senderEmail: string,
|
||
senderName: string
|
||
): string {
|
||
const who =
|
||
senderName && senderName !== senderEmail
|
||
? `${escapeHtml(senderName)} <${escapeHtml(senderEmail)}>`
|
||
: escapeHtml(senderEmail)
|
||
return `Le ${escapeHtml(formatMailDetailDate(dateIso))}, ${who} a écrit :`
|
||
}
|
||
|
||
function quotedBlock(html: string): string {
|
||
return `<blockquote type="cite" style="margin:0 0 0 0.8ex;border-left:1px solid #ccc;padding-left:1ex;color:#500">${html}</blockquote>`
|
||
}
|
||
|
||
function replyQuotedHtml(email: Email): string {
|
||
const last = lastMessage(email)
|
||
if (last) {
|
||
const line = formatQuoteDate(last.date, last.senderEmail, cleanSenderName(last.sender))
|
||
return `<p><br></p><div style="color:#666;font-size:13px">${line}</div>${quotedBlock(last.body)}`
|
||
}
|
||
const addr =
|
||
email.senderEmail ??
|
||
`${cleanSenderName(email.sender).toLowerCase().replace(/\s+/g, ".")}@example.com`
|
||
const line = formatQuoteDate(email.date, addr, cleanSenderName(email.sender))
|
||
const body = email.body ?? `<p style="color:#5f6368">${escapeHtml(email.preview)}</p>`
|
||
return `<p><br></p><div style="color:#666;font-size:13px">${line}</div>${quotedBlock(body)}`
|
||
}
|
||
|
||
function forwardParticipantsLine(email: Email): string {
|
||
const parts = collectThreadParticipants(email)
|
||
if (parts.length === 0) return "To: (inconnu)"
|
||
return `To: ${parts.map((p) => `${p.name} <${p.email}>`).join(", ")}`
|
||
}
|
||
|
||
/** Corps HTML du fil (messages précédents + message principal), sans en-tête « Forwarded ». */
|
||
function forwardConversationHtml(email: Email): string {
|
||
const blocks: string[] = []
|
||
for (const m of email.conversation ?? []) {
|
||
blocks.push(
|
||
`<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e0e0e0">` +
|
||
`<div style="color:#666;font-size:12px;margin-bottom:8px"><strong>De :</strong> ${escapeHtml(cleanSenderName(m.sender))} <${escapeHtml(m.senderEmail)}><br/>` +
|
||
`<strong>Date :</strong> ${escapeHtml(formatMailDetailDate(m.date))}</div>${m.body}</div>`
|
||
)
|
||
}
|
||
const mainAddr =
|
||
email.senderEmail ??
|
||
`${cleanSenderName(email.sender).toLowerCase().replace(/\s+/g, ".")}@example.com`
|
||
blocks.push(
|
||
`<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e0e0e0">` +
|
||
`<div style="color:#666;font-size:12px;margin-bottom:8px"><strong>De :</strong> ${escapeHtml(cleanSenderName(email.sender))} <${escapeHtml(mainAddr)}><br/>` +
|
||
`<strong>Date :</strong> ${escapeHtml(formatMailDetailDate(email.date))}</div>` +
|
||
`${email.body ?? `<p style="color:#5f6368">${escapeHtml(email.preview)}</p>`}</div>`
|
||
)
|
||
return blocks.join("")
|
||
}
|
||
|
||
function forwardBodyHtml(email: Email): string {
|
||
const mainAddr =
|
||
email.senderEmail ??
|
||
`${cleanSenderName(email.sender).toLowerCase().replace(/\s+/g, ".")}@example.com`
|
||
const header =
|
||
`<p>---------- Forwarded message ---------</p>` +
|
||
`<p style="color:#222;font-size:13px;line-height:1.5">` +
|
||
`<strong>De :</strong> ${escapeHtml(cleanSenderName(email.sender))} <${escapeHtml(mainAddr)}><br/>` +
|
||
`<strong>Date :</strong> ${escapeHtml(formatMailDetailDate(email.date))}<br/>` +
|
||
`<strong>Objet :</strong> ${escapeHtml(email.subject)}<br/>` +
|
||
`<strong>${escapeHtml(forwardParticipantsLine(email))}</strong></p>`
|
||
return `<p></p>${header}${forwardConversationHtml(email)}`
|
||
}
|
||
|
||
/** Tablette / tactile : composition plein écran (dock maximisé) au lieu d’inline sous le fil. */
|
||
export function withTouchFullscreenComposePreset(
|
||
preset: ComposeOpenPreset
|
||
): ComposeOpenPreset {
|
||
if (typeof window === "undefined" || !readCoarsePointerMatches()) {
|
||
return preset
|
||
}
|
||
return {
|
||
...preset,
|
||
placement: "dock",
|
||
maximized: true,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Preset pour une fenêtre de composition inline (réponse / transfert) rattachée au fil `email`.
|
||
*/
|
||
export function buildThreadComposePreset(
|
||
email: Email,
|
||
kind: ThreadComposeKind
|
||
): ComposeOpenPreset {
|
||
const participants = collectThreadParticipants(email)
|
||
const last = lastMessage(email)
|
||
const lastContact: Contact | null = last
|
||
? { name: cleanSenderName(last.sender), email: last.senderEmail }
|
||
: participants.length > 0
|
||
? participants[participants.length - 1]!
|
||
: null
|
||
|
||
const threading = {
|
||
inReplyTo: inReplyToFor(email),
|
||
references: buildReferences(email),
|
||
}
|
||
|
||
if (kind === "forward") {
|
||
const baseBody = insertSignatureBeforeForwardedBlock(forwardBodyHtml(email))
|
||
return {
|
||
to: [],
|
||
cc: [],
|
||
bcc: [],
|
||
subject: forwardSubject(email.subject),
|
||
bodyHtml: baseBody,
|
||
placement: "inline",
|
||
threadEmailId: email.id,
|
||
threadKind: "forward",
|
||
focusToOnMount: true,
|
||
focusBodyOnMount: false,
|
||
threading,
|
||
}
|
||
}
|
||
|
||
if (kind === "reply") {
|
||
const to = lastContact ? [lastContact] : []
|
||
return {
|
||
to,
|
||
cc: [],
|
||
bcc: [],
|
||
subject: replySubject(email.subject),
|
||
bodyHtml: appendDefaultSignature(replyQuotedHtml(email)),
|
||
placement: "inline",
|
||
threadEmailId: email.id,
|
||
threadKind: "reply",
|
||
focusToOnMount: false,
|
||
focusBodyOnMount: true,
|
||
threading,
|
||
}
|
||
}
|
||
|
||
/* replyAll */
|
||
const to: Contact[] = []
|
||
const cc: Contact[] = []
|
||
if (lastContact) {
|
||
to.push(lastContact)
|
||
for (const p of participants) {
|
||
if (p.email === lastContact.email) continue
|
||
cc.push(p)
|
||
}
|
||
} else if (participants.length > 0) {
|
||
to.push(participants[0]!)
|
||
for (let i = 1; i < participants.length; i++) {
|
||
cc.push(participants[i]!)
|
||
}
|
||
}
|
||
|
||
return {
|
||
to,
|
||
cc,
|
||
bcc: [],
|
||
subject: replySubject(email.subject),
|
||
bodyHtml: appendDefaultSignature(replyQuotedHtml(email)),
|
||
placement: "inline",
|
||
threadEmailId: email.id,
|
||
threadKind: "replyAll",
|
||
focusToOnMount: false,
|
||
focusBodyOnMount: true,
|
||
threading,
|
||
showCc: cc.length > 0,
|
||
}
|
||
}
|