168 lines
7.2 KiB
TypeScript
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  — 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 · <a href="${appUrl}/agents">Gérer mes agents</a></p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
|
|
return { html, attachments }
|
|
}
|