more UX on email
This commit is contained in:
parent
c1e6775376
commit
3baf8a5b81
7 changed files with 215 additions and 2 deletions
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
export PATH="/etc/profiles/per-user/$USER/bin:/run/current-system/sw/bin:$PATH"
|
export PATH="/etc/profiles/per-user/$USER/bin:/run/current-system/sw/bin:$PATH"
|
||||||
export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt"
|
export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt"
|
||||||
|
export NOTMUCH_CONFIG="$HOME/.config/notmuch/config"
|
||||||
|
|
||||||
if [[ $# -eq 1 ]]; then
|
if [[ $# -eq 1 ]]; then
|
||||||
# Single argument - run it through bash to handle complex commands
|
# Single argument - run it through bash to handle complex commands
|
||||||
|
|
|
||||||
191
bin/view-email-html
Executable file
191
bin/view-email-html
Executable file
|
|
@ -0,0 +1,191 @@
|
||||||
|
#!/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: #ffffff; --text: #000000; --header-bg: #f5f5f5; --link: #0066cc; }"
|
||||||
|
echo ".dark { --bg: #1a1a1a; --text: #e0e0e0; --header-bg: #2d2d2d; --link: #6db3f2; }"
|
||||||
|
echo "body { font-family: -apple-system, sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; background: var(--bg); color: var(--text); transition: background 0.2s, color 0.2s; }"
|
||||||
|
echo "pre { white-space: pre-wrap; word-wrap: break-word; }"
|
||||||
|
echo ".headers { background: var(--header-bg); padding: 15px; margin-bottom: 20px; border-radius: 5px; transition: background 0.2s; }"
|
||||||
|
echo ".headers p { margin: 5px 0; }"
|
||||||
|
echo ".headers ul { margin: 5px 0 10px 20px; padding: 0; }"
|
||||||
|
echo ".headers li { margin: 2px 0; }"
|
||||||
|
echo ".body { line-height: 1.6; white-space: pre-wrap; word-wrap: break-word; }"
|
||||||
|
echo "code { background: var(--header-bg); padding: 2px 5px; border-radius: 3px; font-family: monospace; }"
|
||||||
|
echo ".codeblock { background: var(--header-bg); padding: 15px; border-radius: 5px; overflow-x: auto; }"
|
||||||
|
echo ".codeblock code { background: none; padding: 0; }"
|
||||||
|
echo "img { max-width: 100%; height: auto; }"
|
||||||
|
echo "a { color: var(--link); }"
|
||||||
|
echo ".toggle { position: fixed; top: 10px; right: 10px; width: 40px; height: 40px; border: none; border-radius: 50%; cursor: pointer; background: var(--header-bg); color: var(--text); font-size: 20px; transition: background 0.2s; }"
|
||||||
|
echo ".toggle:hover { opacity: 0.8; }"
|
||||||
|
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 "</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) ~ /^(to|cc|bcc)$/) {
|
||||||
|
n = split_addresses(value, addrs)
|
||||||
|
if (n > 1) {
|
||||||
|
print "<p><strong>" header ":</strong></p><ul>"
|
||||||
|
for (i=1; i<=n; i++) print "<li>" addrs[i] "</li>"
|
||||||
|
print "</ul>"
|
||||||
|
} else {
|
||||||
|
print "<p><strong>" header ":</strong> " value "</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 and inline code
|
||||||
|
echo "$body_content" | awk '
|
||||||
|
BEGIN { incode=0 }
|
||||||
|
/^```/ {
|
||||||
|
if (incode) { print "</code></pre>"; incode=0 }
|
||||||
|
else { print "<pre class=\"codeblock\"><code>"; incode=1 }
|
||||||
|
next
|
||||||
|
}
|
||||||
|
{
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print
|
||||||
|
}
|
||||||
|
END { 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") &
|
||||||
13
bin/yazi-pick-file
Executable file
13
bin/yazi-pick-file
Executable file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Pick file using yazi for neomutt attachment
|
||||||
|
|
||||||
|
tmpfile=/tmp/neomutt-yazi-pick
|
||||||
|
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
cd ~
|
||||||
|
yazi --chooser-file="$tmpfile" &&
|
||||||
|
awk 'BEGIN {printf "%s", "push "} {printf "%s", "<attach-file>\"" $0 "\"<enter>"}' "$tmpfile" >"${tmpfile}.cmd" &&
|
||||||
|
mv "${tmpfile}.cmd" "$tmpfile"
|
||||||
|
elif [ "$1" == "clean" ]; then
|
||||||
|
rm -f "$tmpfile"
|
||||||
|
fi
|
||||||
|
|
@ -16,7 +16,8 @@ set from = 'rayandrew@uchicago.edu'
|
||||||
set realname = 'Ray Andrew'
|
set realname = 'Ray Andrew'
|
||||||
set spoolfile = '+Inbox'
|
set spoolfile = '+Inbox'
|
||||||
set postponed = '+Drafts'
|
set postponed = '+Drafts'
|
||||||
set record = '+Sent'
|
# set record = '+Sent' # Server saves sent mail automatically
|
||||||
|
unset record
|
||||||
set trash = '+Trash'
|
set trash = '+Trash'
|
||||||
|
|
||||||
# PGP settings
|
# PGP settings
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ bind editor ^T complete
|
||||||
|
|
||||||
# Pager
|
# Pager
|
||||||
bind index,pager V edit-raw-message
|
bind index,pager V edit-raw-message
|
||||||
|
macro index,pager H "<pipe-message>~/dotfiles/bin/view-email-html<enter>" "View email in browser"
|
||||||
bind pager c imap-fetch-mail
|
bind pager c imap-fetch-mail
|
||||||
bind pager j next-line
|
bind pager j next-line
|
||||||
bind pager k previous-line
|
bind pager k previous-line
|
||||||
|
|
@ -77,6 +78,8 @@ bind index,pager E recall-message
|
||||||
|
|
||||||
# Compose menu - PGP shortcuts
|
# Compose menu - PGP shortcuts
|
||||||
bind compose S pgp-menu
|
bind compose S pgp-menu
|
||||||
|
macro compose a "<shell-escape>~/dotfiles/bin/yazi-pick-file<enter><enter-command>source /tmp/neomutt-yazi-pick<enter><shell-escape>~/dotfiles/bin/yazi-pick-file clean<enter>" "Attach file with yazi"
|
||||||
|
bind compose d detach-file
|
||||||
|
|
||||||
# Mark messages
|
# Mark messages
|
||||||
bind index,pager m noop
|
bind index,pager m noop
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ set shell = "/bin/bash -l"
|
||||||
# Editor
|
# Editor
|
||||||
set editor = "nvim"
|
set editor = "nvim"
|
||||||
set edit_headers = yes
|
set edit_headers = yes
|
||||||
|
set query_command = "notmuch address '%s'"
|
||||||
|
set attach_save_dir = "~/"
|
||||||
|
|
||||||
# General settings
|
# General settings
|
||||||
set color_directcolor = yes
|
set color_directcolor = yes
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
services.mbsync = {
|
services.mbsync = {
|
||||||
enable = config.custom.email.mbsync;
|
enable = config.custom.email.mbsync;
|
||||||
configFile = "${dots}/config/mbsync/mbsyncrc";
|
configFile = "${dots}/config/mbsync/mbsyncrc";
|
||||||
frequency = "*:0/5";
|
frequency = "*:0/3";
|
||||||
extraPackages = with pkgs; [ sops ] ++ lib.optionals config.custom.email.notmuch [ notmuch ];
|
extraPackages = with pkgs; [ sops ] ++ lib.optionals config.custom.email.notmuch [ notmuch ];
|
||||||
postExec = lib.mkIf config.custom.email.notmuch "${pkgs.notmuch}/bin/notmuch new";
|
postExec = lib.mkIf config.custom.email.notmuch "${pkgs.notmuch}/bin/notmuch new";
|
||||||
environment = {
|
environment = {
|
||||||
|
|
@ -63,6 +63,8 @@
|
||||||
zathura # PDF viewer
|
zathura # PDF viewer
|
||||||
chafa # terminal image viewer
|
chafa # terminal image viewer
|
||||||
bat # text viewer with syntax highlighting
|
bat # text viewer with syntax highlighting
|
||||||
|
mblaze # mshow for email viewing
|
||||||
|
ripmime # extract MIME parts from emails
|
||||||
];
|
];
|
||||||
|
|
||||||
# Symlink config files
|
# Symlink config files
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue