Skip to content

Commit 505f20a

Browse files
author
CloudLobster
committed
feat: markdown email support — auto-render + download .md
Worker (send.ts): - Auto-detect markdown in email body (code blocks, headers, bold, links, lists) - Convert to styled HTML with dark theme (code blocks, inline code, etc.) - Only when no explicit HTML is provided Frontend (Dashboard.tsx): - Extract text/html from MIME multipart and render it - Styled code blocks, links, headers, lists in email view - 📄 Download .md button (exports email as markdown file) - 📋 Copy text button - AI agents can now send beautifully formatted emails with code samples
1 parent 903c972 commit 505f20a

File tree

2 files changed

+120
-3
lines changed

2 files changed

+120
-3
lines changed

web/src/pages/Dashboard.tsx

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,29 @@ function extractTextFromMime(raw: string): string {
205205
return isQP ? decodeQuotedPrintable(body) : body;
206206
}
207207

208+
function extractHtmlFromMime(raw: string): string | null {
209+
if (!raw) return null;
210+
211+
const boundaryMatch = raw.match(/boundary="?([^"\r\n;]+)"?/);
212+
if (!boundaryMatch) return null;
213+
214+
const boundary = boundaryMatch[1];
215+
const parts = raw.split('--' + boundary);
216+
for (const part of parts) {
217+
if (part.toLowerCase().includes('content-type: text/html')) {
218+
const isQP = part.toLowerCase().includes('quoted-printable');
219+
const sep = part.includes('\r\n\r\n') ? '\r\n\r\n' : '\n\n';
220+
const bodyStart = part.indexOf(sep);
221+
if (bodyStart !== -1) {
222+
let body = part.slice(bodyStart + sep.length).trim();
223+
body = body.replace(/--$/, '').trim();
224+
return isQP ? decodeQuotedPrintable(body) : body;
225+
}
226+
}
227+
}
228+
return null;
229+
}
230+
208231
// Clean snippet for inbox list (strip MIME artifacts + decode QP)
209232
function cleanSnippet(snippet: string | null): string {
210233
if (!snippet) return '';
@@ -1391,6 +1414,7 @@ function EmailDetail({ auth }: { auth: AuthState }) {
13911414
}
13921415

13931416
const bodyText = extractTextFromMime(email.body || '');
1417+
const bodyHtml = extractHtmlFromMime(email.body || '');
13941418

13951419
return (
13961420
<div>
@@ -1470,8 +1494,51 @@ function EmailDetail({ auth }: { auth: AuthState }) {
14701494
{new Date(email.created_at * 1000).toLocaleString()}
14711495
</div>
14721496
</div>
1473-
<div className="whitespace-pre-wrap text-gray-300 font-mono text-sm leading-relaxed">
1474-
{bodyText}
1497+
{/* Render HTML if available, otherwise plain text */}
1498+
{bodyHtml ? (
1499+
<div
1500+
className="text-gray-300 text-sm leading-relaxed max-w-none
1501+
[&_pre]:bg-[#1a1a2e] [&_pre]:border [&_pre]:border-gray-700 [&_pre]:rounded-lg [&_pre]:p-4 [&_pre]:overflow-x-auto [&_pre]:text-[13px] [&_pre]:leading-relaxed
1502+
[&_code]:bg-[#1a1a2e] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-[0.9em] [&_code]:font-mono [&_code]:text-gray-200
1503+
[&_a]:text-base-blue [&_a]:underline
1504+
[&_h1]:text-white [&_h2]:text-white [&_h3]:text-white
1505+
[&_strong]:text-white
1506+
[&_hr]:border-gray-700
1507+
[&_ul]:ml-6 [&_ul]:list-disc [&_li]:mb-1
1508+
[&_p]:mb-3 [&_p]:leading-relaxed"
1509+
dangerouslySetInnerHTML={{ __html: bodyHtml }}
1510+
/>
1511+
) : (
1512+
<div className="whitespace-pre-wrap text-gray-300 font-mono text-sm leading-relaxed">
1513+
{bodyText}
1514+
</div>
1515+
)}
1516+
1517+
{/* Download .md */}
1518+
<div className="mt-4 pt-4 border-t border-gray-800 flex gap-2">
1519+
<button
1520+
onClick={() => {
1521+
const blob = new Blob([`# ${email.subject || 'Email'}\n\n**From:** ${email.from_addr}\n**To:** ${email.to_addr}\n**Date:** ${new Date(email.created_at * 1000).toISOString()}\n\n---\n\n${bodyText}`], { type: 'text/markdown' });
1522+
const url = URL.createObjectURL(blob);
1523+
const a = document.createElement('a');
1524+
a.href = url;
1525+
a.download = `${(email.subject || 'email').replace(/[^a-zA-Z0-9]/g, '-').slice(0, 50)}.md`;
1526+
a.click();
1527+
URL.revokeObjectURL(url);
1528+
}}
1529+
className="text-gray-500 hover:text-gray-300 text-xs flex items-center gap-1 transition"
1530+
>
1531+
📄 Download .md
1532+
</button>
1533+
<button
1534+
onClick={() => {
1535+
navigator.clipboard.writeText(bodyText);
1536+
alert('Copied to clipboard');
1537+
}}
1538+
className="text-gray-500 hover:text-gray-300 text-xs flex items-center gap-1 transition"
1539+
>
1540+
📋 Copy text
1541+
</button>
14751542
</div>
14761543
</div>
14771544
</div>

worker/src/routes/send.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,53 @@ const USDC_NETWORKS: Record<string, { chain: Chain; rpc: string; usdc: string; l
2525
};
2626
const USDC_TRANSFER_ABI = parseAbi(['event Transfer(address indexed from, address indexed to, uint256 value)']);
2727

28+
// ── Lightweight Markdown → HTML (zero deps) ──
29+
function hasMarkdown(text: string): boolean {
30+
return /```[\s\S]*?```|^#{1,3} |\*\*.*?\*\*|\[.*?\]\(.*?\)|^- |^\d+\. /m.test(text);
31+
}
32+
33+
function esc(s: string): string {
34+
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
35+
}
36+
37+
function md2html(md: string): string {
38+
let html = md
39+
// Code blocks (fenced)
40+
.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) =>
41+
`<pre style="background:#1a1a2e;border:1px solid #333;border-radius:8px;padding:16px;overflow-x:auto;font-size:13px;line-height:1.5"><code style="color:#e0e0e0;font-family:monospace">${esc(code.trim())}</code></pre>`)
42+
// Inline code
43+
.replace(/`([^`]+)`/g, '<code style="background:#1a1a2e;padding:2px 6px;border-radius:4px;font-size:0.9em;color:#e0e0e0;font-family:monospace">$1</code>')
44+
// Headers
45+
.replace(/^### (.+)$/gm, '<h3 style="color:#fff;margin:24px 0 8px">$1</h3>')
46+
.replace(/^## (.+)$/gm, '<h2 style="color:#fff;margin:32px 0 12px">$1</h2>')
47+
.replace(/^# (.+)$/gm, '<h1 style="color:#fff;margin:32px 0 16px">$1</h1>')
48+
// Bold + italic
49+
.replace(/\*\*(.+?)\*\*/g, '<strong style="color:#fff">$1</strong>')
50+
.replace(/\*(.+?)\*/g, '<em>$1</em>')
51+
// Links
52+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color:#0052FF" target="_blank">$1</a>')
53+
// Horizontal rules
54+
.replace(/^---+$/gm, '<hr style="border:none;border-top:1px solid #333;margin:24px 0" />')
55+
// Unordered lists
56+
.replace(/^- (.+)$/gm, '<li style="margin-bottom:4px">$1</li>')
57+
.replace(/(<li[^>]*>.*<\/li>\n?)+/g, m => `<ul style="margin:12px 0;padding-left:24px;color:#ccc">${m}</ul>`)
58+
// Ordered lists
59+
.replace(/^\d+\. (.+)$/gm, '<li style="margin-bottom:4px">$1</li>');
60+
61+
// Paragraphs
62+
html = html
63+
.split('\n\n')
64+
.map(block => {
65+
block = block.trim();
66+
if (!block) return '';
67+
if (/^<(h[1-6]|ul|ol|pre|hr|div|table)/.test(block)) return block;
68+
return `<p style="margin:12px 0;color:#ccc;line-height:1.6">${block.replace(/\n/g, '<br/>')}</p>`;
69+
})
70+
.join('\n');
71+
72+
return `<div style="background:#0a0a0a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;max-width:640px">${html}</div>`;
73+
}
74+
2875
export const sendRoutes = new Hono<AppBindings>();
2976

3077
sendRoutes.use('/*', authMiddleware());
@@ -252,7 +299,10 @@ sendRoutes.post('/', async (c) => {
252299

253300
// Append signature for free-tier users
254301
const finalBody = isPro ? enrichedBody : enrichedBody + TEXT_SIGNATURE;
255-
const finalHtml = html ? (isPro ? html : html + HTML_SIGNATURE) : undefined;
302+
// Auto-generate HTML from markdown if no HTML provided and body contains markdown syntax
303+
const autoHtml = (!html && hasMarkdown(enrichedBody)) ? md2html(enrichedBody) : undefined;
304+
const rawHtml = html || autoHtml;
305+
const finalHtml = rawHtml ? (isPro ? rawHtml : rawHtml + HTML_SIGNATURE) : undefined;
256306

257307
// Build MIME message
258308
const msg = createMimeMessage();

0 commit comments

Comments
 (0)