Files
Keep/keep-notes/lib/agent-email-template.ts

168 lines
7.2 KiB
TypeScript

import { promises as fs } from 'fs'
import path from 'path'
import { randomUUID } from 'crypto'
export interface EmailAttachment {
filename: string
content: Buffer
cid: string
}
interface AgentEmailParams {
agentName: string
content: string
appUrl: string
userName?: string
}
/**
* Read a local image file from the public directory.
*/
async function readLocalImage(relativePath: string): Promise<Buffer | null> {
try {
const filePath = path.join(process.cwd(), 'public', relativePath)
return await fs.readFile(filePath)
} catch {
return null
}
}
/**
* Convert markdown to simple HTML suitable for email clients.
* Replaces local image references with cid: placeholders for inline attachments.
* Returns the HTML and a list of attachments to include.
*/
export async function markdownToEmailHtml(md: string, appUrl: string): Promise<{ html: string; attachments: EmailAttachment[] }> {
let html = md
const attachments: EmailAttachment[] = []
const baseUrl = appUrl.replace(/\/$/, '')
// Remove the execution footer (agent trace)
html = html.replace(/\n---\n\n_\$Agent execution:[\s\S]*$/, '')
html = html.replace(/\n---\n\n_Agent execution:[\s\S]*$/, '')
// Horizontal rules
html = html.replace(/^---+$/gm, '<hr style="border:none;border-top:1px solid #e5e7eb;margin:20px 0;">')
// Headings
html = html.replace(/^### (.+)$/gm, '<h3 style="margin:16px 0 8px;font-size:15px;font-weight:600;color:#1f2937;">$1</h3>')
html = html.replace(/^## (.+)$/gm, '<h2 style="margin:20px 0 10px;font-size:16px;font-weight:700;color:#111827;">$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1 style="margin:0 0 16px;font-size:18px;font-weight:700;color:#111827;">$1</h1>')
// Bold and italic
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.+?)\*/g, '<em style="color:#6b7280;">$1</em>')
// Unordered list items
html = html.replace(/^(\s*)[-*] (.+)$/gm, '$1<li style="margin:4px 0;padding-left:4px;">$2</li>')
// Wrap consecutive <li> in <ul>
html = html.replace(/((?:<li[^>]*>.*?<\/li>\s*)+)/g, (match) => {
return '<ul style="margin:8px 0;padding-left:20px;list-style-type:disc;">' + match + '</ul>'
})
// Images ![alt](url) — local images become CID attachments, external stay as-is
const imageMatches = [...html.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/g)]
for (const match of imageMatches) {
const [fullMatch, alt, url] = match
let imgTag: string
if (url.startsWith('/uploads/')) {
// Local image: read file and attach as CID
const buffer = await readLocalImage(url)
if (buffer) {
const cid = `img-${randomUUID()}`
const ext = path.extname(url).toLowerCase() || '.jpg'
attachments.push({ filename: `image${ext}`, content: buffer, cid })
imgTag = `<img src="cid:${cid}" alt="${alt}" style="max-width:100%;border-radius:8px;margin:12px 0;" />`
} else {
// Fallback to absolute URL if file not found
imgTag = `<img src="${baseUrl}${url}" alt="${alt}" style="max-width:100%;border-radius:8px;margin:12px 0;" />`
}
} else {
imgTag = `<img src="${url}" alt="${alt}" style="max-width:100%;border-radius:8px;margin:12px 0;" />`
}
html = html.replace(fullMatch, imgTag)
}
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => {
const absoluteUrl = url.startsWith('/') ? `${baseUrl}${url}` : url
return `<a href="${absoluteUrl}" style="color:#3b82f6;text-decoration:none;">${text}</a>`
})
// Paragraphs
html = html.replace(/\n\n+/g, '</p><p style="margin:0 0 12px;">')
html = html.replace(/\n/g, '<br>')
html = '<p style="margin:0 0 12px;">' + html + '</p>'
html = html.replace(/<p[^>]*>\s*<\/p>/g, '')
return { html, attachments }
}
export async function getAgentEmailTemplate({ agentName, content, appUrl, userName }: AgentEmailParams): Promise<{ html: string; attachments: EmailAttachment[] }> {
const greeting = userName ? `Bonjour ${userName},` : 'Bonjour,'
const { html: htmlContent, attachments } = await markdownToEmailHtml(content, appUrl)
// Extract a preview (first ~150 chars of plain text for subtitle)
const plainText = content
.replace(/^#{1,3}\s+/gm, '')
.replace(/\*\*/g, '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/[-*]\s+/g, '')
.replace(/\n+/g, ' ')
.trim()
const html = `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${agentName}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #374151; background: #f3f4f6; margin: 0; padding: 0; }
.wrapper { width: 100%; background: #f3f4f6; padding: 32px 16px; }
.container { max-width: 620px; margin: 0 auto; }
.card { background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.04); }
.card-header { background: linear-gradient(135deg, #1e293b 0%, #334155 100%); padding: 28px 32px; }
.card-header h1 { margin: 0; font-size: 20px; font-weight: 700; color: #ffffff; }
.card-header .subtitle { margin: 6px 0 0; font-size: 13px; color: #94a3b8; }
.card-header .badge { display: inline-block; background: rgba(59,130,246,0.2); color: #93c5fd; font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 9999px; margin-top: 10px; letter-spacing: 0.5px; text-transform: uppercase; }
.card-body { padding: 28px 32px; font-size: 14px; color: #374151; }
.card-footer { padding: 20px 32px; border-top: 1px solid #f1f5f9; text-align: center; background: #fafbfc; }
.button { display: inline-block; padding: 12px 28px; background-color: #1e293b; color: #ffffff; text-decoration: none; border-radius: 10px; font-weight: 600; font-size: 14px; letter-spacing: 0.3px; }
.button:hover { background-color: #334155; }
.footer-text { margin-top: 20px; font-size: 12px; color: #9ca3af; text-align: center; }
.footer-text a { color: #64748b; text-decoration: none; }
.footer-text a:hover { text-decoration: underline; }
.date { font-size: 12px; color: #9ca3af; margin-top: 4px; }
</style>
</head>
<body>
<div class="wrapper">
<div class="container">
<div class="card">
<div class="card-header">
<h1>${agentName}</h1>
<div class="subtitle">${plainText.substring(0, 120)}${plainText.length > 120 ? '...' : ''}</div>
<span class="badge">Synthèse automatique</span>
</div>
<div class="card-body">
<p style="margin:0 0 8px;color:#6b7280;font-size:13px;">${greeting}</p>
<p style="margin:0 0 20px;color:#6b7280;font-size:13px;">Votre agent <strong style="color:#1f2937;">${agentName}</strong> a terminé son exécution. Voici les résultats :</p>
<hr style="border:none;border-top:1px solid #f1f5f9;margin:0 0 20px;">
${htmlContent}
</div>
<div class="card-footer">
<a href="${appUrl}" class="button">Ouvrir dans Memento</a>
</div>
</div>
<p class="footer-text">Cet email a été envoyé par votre agent Memento &middot; <a href="${appUrl}/agents">Gérer mes agents</a></p>
</div>
</div>
</body>
</html>`
return { html, attachments }
}