282 lines
15 KiB
Bash
Executable file
282 lines
15 KiB
Bash
Executable file
#!/bin/bash
|
|
# Convert email to HTML and open in browser with images
|
|
|
|
emlfile="/tmp/neomutt-email-$$.eml"
|
|
tmpdir="/tmp/neomutt-email-$$"
|
|
tmpfile="$tmpdir/email-$$.html"
|
|
|
|
# Save email from stdin
|
|
mkdir -p "$tmpdir"
|
|
cat >"$emlfile"
|
|
|
|
# Extract MIME parts using ripmime if available
|
|
if command -v ripmime &>/dev/null; then
|
|
ripmime -i "$emlfile" -d "$tmpdir" --no-nameless 2>/dev/null
|
|
elif command -v munpack &>/dev/null; then
|
|
cd "$tmpdir" && munpack -q "$emlfile" 2>/dev/null
|
|
fi
|
|
|
|
# Check if there's an HTML part extracted
|
|
html_part=$(find "$tmpdir" -maxdepth 1 -name "*.html" -o -name "*.htm" 2>/dev/null | head -1)
|
|
|
|
if [[ -n $html_part && -s $html_part ]]; then
|
|
# Use extracted HTML directly
|
|
cp "$html_part" "$tmpfile"
|
|
else
|
|
# Generate HTML from text
|
|
{
|
|
echo "<html><head>"
|
|
echo '<meta charset="utf-8">'
|
|
echo "<script>if(window.matchMedia('(prefers-color-scheme:dark)').matches)document.documentElement.classList.add('dark');</script>"
|
|
echo "<style>"
|
|
echo ":root { --bg: #fafafa; --text: #1a1a1a; --header-bg: #ffffff; --muted: #666666; --link: #0066cc; --border: #e0e0e0; --shadow: rgba(0,0,0,0.08); }"
|
|
echo ".dark { --bg: #121212; --text: #e0e0e0; --header-bg: #1e1e1e; --muted: #999999; --link: #6db3f2; --border: #333333; --shadow: rgba(0,0,0,0.3); }"
|
|
echo "body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 30px 20px; max-width: 800px; margin: 0 auto; background: var(--bg); color: var(--text); transition: all 0.2s ease; line-height: 1.5; }"
|
|
echo "pre { white-space: pre-wrap; word-wrap: break-word; }"
|
|
echo ".headers { background: var(--header-bg); padding: 20px 25px; margin-bottom: 25px; border-radius: 12px; border: 1px solid var(--border); box-shadow: 0 2px 8px var(--shadow); }"
|
|
echo ".headers p { margin: 8px 0; display: grid; grid-template-columns: 70px 1fr; gap: 8px; align-items: baseline; }"
|
|
echo ".headers strong { color: var(--muted); font-weight: 500; }"
|
|
echo ".headers .addr-list { display: flex; flex-wrap: wrap; gap: 6px; }"
|
|
echo ".headers .addr { padding: 4px 10px; background: var(--bg); border-radius: 6px; font-size: 0.95em; }"
|
|
echo ".subject { font-weight: 600; color: var(--text); }"
|
|
echo ".body { line-height: 1.7; white-space: pre-wrap; word-wrap: break-word; padding: 5px 0; }"
|
|
echo ".quoted { margin: 10px 0; }"
|
|
echo ".quoted-toggle { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 0.85em; color: var(--muted); display: inline-block; }"
|
|
echo ".quoted-toggle:hover { background: var(--border); }"
|
|
echo ".quoted-content { display: none; margin-top: 10px; padding: 10px 15px; border-left: 3px solid var(--border); color: var(--muted); font-size: 0.95em; white-space: pre-wrap; }"
|
|
echo ".quoted-content.show { display: block; }"
|
|
echo ".quote-line { color: var(--muted); }"
|
|
echo "code { background: var(--header-bg); padding: 3px 7px; border-radius: 4px; font-family: 'SF Mono', Consolas, monospace; font-size: 0.9em; border: 1px solid var(--border); }"
|
|
echo ".codeblock { background: var(--header-bg); padding: 18px; border-radius: 10px; overflow-x: auto; border: 1px solid var(--border); margin: 15px 0; }"
|
|
echo ".codeblock code { background: none; padding: 0; border: none; }"
|
|
echo "img { max-width: 100%; height: auto; border-radius: 8px; margin: 10px 0; box-shadow: 0 2px 12px var(--shadow); }"
|
|
echo "a { color: var(--link); text-decoration: none; } a:hover { text-decoration: underline; }"
|
|
echo ".toggle { position: fixed; top: 20px; right: 20px; width: 44px; height: 44px; border: 1px solid var(--border); border-radius: 50%; cursor: pointer; background: var(--header-bg); color: var(--text); font-size: 20px; transition: all 0.2s; box-shadow: 0 2px 8px var(--shadow); }"
|
|
echo ".toggle:hover { transform: scale(1.05); box-shadow: 0 4px 12px var(--shadow); }"
|
|
echo ".sun { display: none; } .dark .sun { display: inline; } .dark .moon { display: none; }"
|
|
echo "</style></head><body>"
|
|
echo '<button class="toggle" onclick="toggleTheme()" title="Toggle theme"><span class="moon">☾</span><span class="sun">☀</span></button>'
|
|
echo "<script>"
|
|
echo "function toggleTheme() { document.documentElement.classList.toggle('dark'); }"
|
|
echo "function toggleQuote(el) { var c = el.nextElementSibling; c.classList.toggle('show'); el.textContent = c.classList.contains('show') ? '▼ Hide quoted text' : '▶ Show quoted text'; }"
|
|
echo "</script>"
|
|
|
|
# Extract and display headers (handles multi-line headers)
|
|
echo '<div class="headers">'
|
|
awk 'BEGIN{IGNORECASE=1; show=0; header=""; value=""}
|
|
function decode_qp(str, result, i, hex, char) {
|
|
result = str
|
|
# Decode =XX hex sequences
|
|
while (match(result, /=[0-9A-Fa-f][0-9A-Fa-f]/)) {
|
|
hex = substr(result, RSTART+1, 2)
|
|
cmd = "printf \"\\x" hex "\""
|
|
cmd | getline char
|
|
close(cmd)
|
|
result = substr(result, 1, RSTART-1) char substr(result, RSTART+3)
|
|
}
|
|
gsub(/_/, " ", result)
|
|
return result
|
|
}
|
|
function decode_mime(str, result, before, after, encoded, charset, encoding, text) {
|
|
result = str
|
|
# Decode =?charset?Q?text?= or =?charset?B?text?=
|
|
while (match(result, /=\?[^?]+\?[QqBb]\?[^?]+\?=/)) {
|
|
before = substr(result, 1, RSTART-1)
|
|
encoded = substr(result, RSTART, RLENGTH)
|
|
after = substr(result, RSTART+RLENGTH)
|
|
# Remove =?...?Q? and ?=
|
|
gsub(/=\?[^?]+\?[Qq]\?/, "", encoded)
|
|
gsub(/\?=$/, "", encoded)
|
|
text = decode_qp(encoded)
|
|
result = before text after
|
|
}
|
|
return result
|
|
}
|
|
function split_addresses(str, arr, n, i, c, inquote, current) {
|
|
n = 0; inquote = 0; current = ""
|
|
for (i = 1; i <= length(str); i++) {
|
|
c = substr(str, i, 1)
|
|
if (c == "\"") inquote = !inquote
|
|
if (c == "," && !inquote) {
|
|
gsub(/^[ \t]+|[ \t]+$/, "", current)
|
|
if (current != "") arr[++n] = current
|
|
current = ""
|
|
} else {
|
|
current = current c
|
|
}
|
|
}
|
|
gsub(/^[ \t]+|[ \t]+$/, "", current)
|
|
if (current != "") arr[++n] = current
|
|
return n
|
|
}
|
|
function output() {
|
|
if (header != "" && value != "") {
|
|
value = decode_mime(value)
|
|
gsub(/</, "\\<", value)
|
|
gsub(/>/, "\\>", value)
|
|
# Only split To, CC, Bcc addresses (not Date, Subject, From)
|
|
if (tolower(header) ~ /^(from|to|cc|bcc)$/) {
|
|
n = split_addresses(value, addrs)
|
|
printf "<p><strong>" header ":</strong> <span class=\"addr-list\">"
|
|
if (n > 1) {
|
|
for (i=1; i<=n; i++) printf "<span class=\"addr\">" addrs[i] "</span>"
|
|
} else {
|
|
printf "<span class=\"addr\">" value "</span>"
|
|
}
|
|
print "</span></p>"
|
|
} else if (tolower(header) == "subject") {
|
|
print "<p><strong>" header ":</strong> <span class=\"subject\">" value "</span></p>"
|
|
} else {
|
|
print "<p><strong>" header ":</strong> " value "</p>"
|
|
}
|
|
}
|
|
header = ""; value = ""
|
|
}
|
|
/^$/{output(); exit}
|
|
/^(From|To|CC|Bcc|Reply-To|Subject|Date):/{
|
|
output()
|
|
show=1
|
|
match($0, /^[^:]+/)
|
|
header = substr($0, 1, RLENGTH)
|
|
value = substr($0, RLENGTH+2)
|
|
gsub(/^[ \t]+|[ \t]+$/, "", value)
|
|
next
|
|
}
|
|
/^[A-Za-z0-9-]+:/ && !/^(From|To|CC|Bcc|Reply-To|Subject|Date):/{output(); show=0}
|
|
show && /^[ \t]/{
|
|
gsub(/^[ \t]+/, "", $0)
|
|
value = value " " $0
|
|
}
|
|
END{output()}' "$emlfile"
|
|
echo "</div>"
|
|
|
|
# Display body - extract text part and format code blocks
|
|
echo '<div class="body">'
|
|
# Try to find extracted text file first
|
|
text_part=$(find "$tmpdir" -maxdepth 1 -name "textfile*" -o -name "*.txt" 2>/dev/null | head -1)
|
|
if [[ -n $text_part && -s $text_part ]]; then
|
|
body_content=$(cat "$text_part")
|
|
elif command -v mshow &>/dev/null; then
|
|
body_content=$(mshow -N "$emlfile" 2>&1 | grep -v "no filter or default handler" | grep -viE "^(From|To|Cc|Bcc|Reply-To|Subject|Date):")
|
|
else
|
|
body_content=$(sed '1,/^$/d' "$emlfile")
|
|
fi
|
|
# Process body: escape HTML, handle code blocks, inline code, and quoted text
|
|
# First pass: detect quote blocks (lines starting with > followed by more > lines)
|
|
echo "$body_content" | awk '
|
|
BEGIN { incode=0; inquote=0; quotebuf=""; quotecount=0 }
|
|
function flush_quote() {
|
|
if (quotebuf != "") {
|
|
# Only make collapsible if we have 3+ quoted lines
|
|
n = split(quotebuf, lines, "\n")
|
|
if (n >= 3) {
|
|
print "<div class=\"quoted\"><span class=\"quoted-toggle\" onclick=\"toggleQuote(this)\">▶ Show quoted text (" n " lines)</span><pre class=\"quoted-content\">"
|
|
print quotebuf
|
|
print "</pre></div>"
|
|
} else {
|
|
print "<span class=\"quote-line\">" quotebuf "</span>"
|
|
}
|
|
quotebuf = ""
|
|
}
|
|
}
|
|
/^```/ {
|
|
flush_quote()
|
|
if (incode) { print "</code></pre>"; incode=0 }
|
|
else { print "<pre class=\"codeblock\"><code>"; incode=1 }
|
|
next
|
|
}
|
|
/^>/ {
|
|
if (incode) { gsub(/</, "\\<"); gsub(/>/, "\\>"); print; next }
|
|
gsub(/</, "\\<")
|
|
gsub(/>/, "\\>")
|
|
if (quotebuf != "") quotebuf = quotebuf "\n"
|
|
quotebuf = quotebuf $0
|
|
next
|
|
}
|
|
function linkify(line, result, pos, url, email, before, after, linktext) {
|
|
result = line
|
|
# First: Handle email<mailto:email> pattern - keep only one email as link
|
|
while (match(result, /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}<mailto:[^&]*>/)) {
|
|
before = substr(result, 1, RSTART-1)
|
|
# Extract just the email part (before <mailto:)
|
|
pos = index(substr(result, RSTART), "<mailto:")
|
|
email = substr(result, RSTART, pos-1)
|
|
after = substr(result, RSTART+RLENGTH)
|
|
result = before "<a href=\"mailto:" email "\">" email "</a>" after
|
|
}
|
|
# Second: Handle standalone <mailto:email>
|
|
while (match(result, /<mailto:[^&]+>/)) {
|
|
before = substr(result, 1, RSTART-1)
|
|
email = substr(result, RSTART+11, RLENGTH-15)
|
|
after = substr(result, RSTART+RLENGTH)
|
|
result = before "<a href=\"mailto:" email "\">" email "</a>" after
|
|
}
|
|
# Third: Handle text<https://url> - text becomes link text
|
|
while (match(result, /[A-Za-z0-9_-]+<https?:\/\/[^&]+>/)) {
|
|
before = substr(result, 1, RSTART-1)
|
|
full = substr(result, RSTART, RLENGTH)
|
|
# Find where < starts
|
|
pos = index(full, "<")
|
|
linktext = substr(full, 1, pos-1)
|
|
# Extract URL: after < (4 chars) until > (4 chars at end)
|
|
url = substr(full, pos+4, length(full)-pos-7)
|
|
after = substr(result, RSTART+RLENGTH)
|
|
result = before "<a href=\"" url "\" target=\"_blank\">" linktext "</a>" after
|
|
}
|
|
# Fourth: Handle standalone <https://url>
|
|
while (match(result, /<https?:\/\/[^&]+>/)) {
|
|
before = substr(result, 1, RSTART-1)
|
|
url = substr(result, RSTART+4, RLENGTH-8)
|
|
after = substr(result, RSTART+RLENGTH)
|
|
result = before "<a href=\"" url "\" target=\"_blank\">" url "</a>" after
|
|
}
|
|
# Fifth: Handle plain URLs (not already in href)
|
|
while (match(result, /https?:\/\/[^ &<>"\n\t]+/)) {
|
|
before = substr(result, 1, RSTART-1)
|
|
if (before ~ /href="$/) break
|
|
url = substr(result, RSTART, RLENGTH)
|
|
after = substr(result, RSTART+RLENGTH)
|
|
# Clean trailing punctuation
|
|
sub(/[.,;:!?)]+$/, "", url)
|
|
result = before "<a href=\"" url "\" target=\"_blank\">" url "</a>" after
|
|
}
|
|
return result
|
|
}
|
|
{
|
|
flush_quote()
|
|
# HTML escape first
|
|
gsub(/</, "\\<")
|
|
gsub(/>/, "\\>")
|
|
if (!incode) {
|
|
# Handle inline code `code`
|
|
while (match($0, /`[^`]+`/)) {
|
|
before = substr($0, 1, RSTART-1)
|
|
code = substr($0, RSTART+1, RLENGTH-2)
|
|
after = substr($0, RSTART+RLENGTH)
|
|
$0 = before "<code>" code "</code>" after
|
|
}
|
|
# Linkify URLs and emails
|
|
$0 = linkify($0)
|
|
}
|
|
print
|
|
}
|
|
END {
|
|
flush_quote()
|
|
if (incode) print "</code></pre>"
|
|
}
|
|
'
|
|
echo "</div>"
|
|
|
|
# Show extracted images
|
|
find "$tmpdir" -maxdepth 1 -type f \( -iname "*.png" -o -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.gif" \) 2>/dev/null | while read -r img; do
|
|
echo "<p><img src=\"$(basename "$img")\"></p>"
|
|
done
|
|
|
|
echo "</body></html>"
|
|
} >"$tmpfile"
|
|
fi
|
|
|
|
open "$tmpfile"
|
|
rm -f "$emlfile"
|
|
# Clean up after 60 seconds in background
|
|
(sleep 60 && rm -rf "$tmpdir") &
|