From 091b3a1a96adf2fe93f59ce86223a04699a36f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=84min=20Baumeler?= Date: Wed, 11 Mar 2026 22:17:07 +0100 Subject: [PATCH] initial commit --- .gitignore | 1 + README.md | 19 ++++ scripts/build.sh | 21 +++++ src/awk/lastmessageinthread.awk | 49 ++++++++++ src/awk/threadoverview.awk | 33 +++++++ src/main.sh | 158 ++++++++++++++++++++++++++++++++ src/sh/awk.sh | 17 ++++ src/sh/info.sh | 11 +++ src/sh/theme.sh | 13 +++ src/sh/tools.sh | 28 ++++++ 10 files changed, 350 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 scripts/build.sh create mode 100644 src/awk/lastmessageinthread.awk create mode 100644 src/awk/threadoverview.awk create mode 100755 src/main.sh create mode 100644 src/sh/awk.sh create mode 100644 src/sh/info.sh create mode 100644 src/sh/theme.sh create mode 100644 src/sh/tools.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3dde46 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +nmf diff --git a/README.md b/README.md new file mode 100644 index 0000000..6cec1e7 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +There's [not much](https://notmuchmail.org/) [fuzz](https://junegunn.github.io/fzf/) about emails. + +[Yousef Akbar](https://yousef.sh/) developed a lovely [notmuch plugin](https://github.com/yousefakbar/notmuch.nvim) for [neovim.](https://neovim.io/) +But since lately I find myself preferring [vim](https://www.vim.org/) over neovim, i was left with two options: +adapt the plugin to vim, or write a shell script instead. +This application is built around the second route. +This is a POSIX-compliant script with internal `awk` elements for handling your notmuch-organized emails. +You are free to choose whatever editor you prefer for writing emails. + + +Requirements and Installation +------------------------------ +### Requirements +This is a POSIX-compliant shell script with inline `awk` elements that makes calls to: +- the command-line fuzzy finder [fzf](https://junegunn.github.io/fzf/), +- the command-line email system [notmuch](https://notmuchmail.org/) + +### Installation +Run `./scripts/build.sh`, then copy `nmf` to your preferred location, e.g., `~/.local/bin`, and make it executable. diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..94583fd --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +BOLD="\033[1m" +GREEN="\033[0;32m" +OFF="\033[m" +NAME="nmf" +SRC="./src/main.sh" + +tmpdir=$(mktemp -d) +echo "🥚 ${GREEN}Internalize sourced files${OFF}" +sed -E 's|\. "([^$].+)"$|cat src/\1|e' "$SRC" >"$tmpdir/1.sh" +echo "🐔 ${GREEN}Internalize awk scripts${OFF}" +sed -E 's|@@include (.+)$|cat src/\1|e' "$tmpdir/1.sh" >"$tmpdir/2.sh" +echo "🥚 ${GREEN}Internalize awk libraries${OFF}" +sed -E 's|@include "(.+)"$|cat src/\1|e' "$tmpdir/2.sh" >"$tmpdir/3.sh" +echo "🐔 ${GREEN}Strip comments${OFF}" +grep -v "^ *# " "$tmpdir/3.sh" | grep -v "^ *#$" >"$NAME" +echo "🥚 ${GREEN}Make executable and cleanup${OFF}" +chmod +x "$NAME" +rm -rf "$tmpdir" +echo "🍳 ${GREEN}Done:${OFF} Sucessfully built ${BOLD}${GREEN}$NAME${OFF}" diff --git a/src/awk/lastmessageinthread.awk b/src/awk/lastmessageinthread.awk new file mode 100644 index 0000000..b2a3780 --- /dev/null +++ b/src/awk/lastmessageinthread.awk @@ -0,0 +1,49 @@ +BEGIN { + RS = "\x0c" + FS = "\n" +} + +/^message}/ { + mheader = header + mpart = part + mparts = parts + level = 0 + multipart = "" + header = "" + part = "" + parts = "" +} +/^header{/ { + header = $0 +} +/^part}/ { + level-- +} +/^part{/ { + parts = sprintf("%s\n%"(2*level)"s%s", parts, "", substr($1, 7)) + level++ +} +# /^part{ ID: [[:digit:]]+, Content-type: multipart\/alternative/ { +# multipart = "alternative" +# } +# /^part{ ID: [[:digit:]]+, Content-type: multipart\/mixed/ { +# multipart = "mixed" +# } +/^part{ ID: [[:digit:]]+, Content-type: text\/plain/ { + part = $0 +} + +END { + split(mheader, a, "\n") + delete a[1] + # delete a[2] + for (line in a) { + print a[line] + } + split(mpart, a, "\n") + delete a[1] + for (line in a) { + print a[line] + } + print "Parts:"mparts +} diff --git a/src/awk/threadoverview.awk b/src/awk/threadoverview.awk new file mode 100644 index 0000000..a1aec45 --- /dev/null +++ b/src/awk/threadoverview.awk @@ -0,0 +1,33 @@ +BEGIN { + RS = "\x0c" + FS = "\n" +} + +# message{ id:eb93d845-6fc7-4181-9848-7411ed999b3c@indyfac.ch depth:0 match:1 excluded:0 filename:/home/amin/.email/indyfac/INBOX/cur/1773225033.821818_4.fredkin,U=4172:2,S + +# header{ +# lobo loco (Today 10:12) (inbox) + +/^message{/ { + id = "" + depth = -1 + split($1, a, " ") + for (i in a) { + if (id && depth >= 0) + continue + if (substr(a[i], 1, 3) == "id:") + id = substr(a[i], 4) + else if (substr(a[i], 1, 6) == "depth:") + depth = substr(a[i], 7) + 0 + } +} +/^header{/ { + subj = "" + for (i=3; i<=NF; i++) { + if (substr($i, 1, 9) == "Subject: ") { + subj = substr($i, 10) + break + } + } + printf "%s\t%s\t%"(2*depth)"s%s\n", id, subj, "", $2 +} diff --git a/src/main.sh b/src/main.sh new file mode 100755 index 0000000..2e40163 --- /dev/null +++ b/src/main.sh @@ -0,0 +1,158 @@ +#!/bin/sh + +set -eu + +# Application info +. "sh/info.sh" + +# Theme +. "sh/theme.sh" + +# awk scripts +. "sh/awk.sh" + +# tools +. "sh/tools.sh" + +__notmuch_search() { + $NOTMUCH search "$*" \ + | sed "s/^thread:\([[:xdigit:]]\+\)\s\+\([^[]\+\)\s\+\[\([^]]\+\)\]\([^;]*\); \(.*\) (\([^(]*\))$/${COLDATE}\2${RESET}\t${COLCNTS}\3${RESET}\t${COLFROM}\4${RESET}\t${COLSUBJ}\5${RESET}\t${COLTAGS}\6${RESET}\t\1/" \ + | column -s "$(printf '\t')" -t -l 3 -R 1,2 +} + +__notmuch_thread() { + $NOTMUCH show thread:"$1" \ + | awk "$AWK_THREADOVERVIEW" \ + | sed "s/^\(\S*\)\t\([^\t]*\)\t\(.*\) (\([^()]*\)) (\([^()]*\))$/${COLFROM}\3${RESET}\t${COLSUBJ}\2${RESET}\t${COLDATE}\4${RESET}\t${COLTAGS}\5${RESET}\t\1/" +} + +__notmuch_list_tags() { + $NOTMUCH search --output=tags tag:/./ +} + +__notmuch_list_address() { + $NOTMUCH address '*' +} + +if [ "${1:-}" = "--preview-thread" ]; then + shift + thread="$1" + $NOTMUCH show thread:$thread \ + | awk "$AWK_LASTMESSAGEINTHREAD" \ + | tail -n +2 \ + | $CAT + exit +fi + +if [ "${1:-}" = "--preview-message" ]; then + shift + messageid="$1" + $NOTMUCH show id:$messageid \ + | awk "$AWK_LASTMESSAGEINTHREAD" \ + | tail -n +2 \ + | $CAT + exit +fi + +# FZF default configs +FZF_DEFAULT_PREVIEW_WINDOW="right,80,border-line,wrap-word" +FZF_DEFAULT_THREAD_LINE="{1} {3} ({4})" + +if [ "${1:-}" = "--show-thread" ]; then + shift + thread="$1" + res=$(__notmuch_thread "$thread" | fzf \ + --raw \ + --ansi \ + --reverse \ + --multi \ + --delimiter="$(printf '\t')" \ + --with-nth="$FZF_DEFAULT_THREAD_LINE" \ + --bind="alt-s:change-with-nth({1} {2} {3} ({4})|$FZF_DEFAULT_THREAD_LINE)" \ + --accept-nth=5 \ + --bind='alt-j:preview-down,alt-k:preview-up' \ + --bind='page-up:preview-half-page-up,page-down:preview-half-page-down' \ + --bind="enter:" \ + --bind="ctrl-h,backward-eof:abort" \ + --bind="alt-/:change-preview-window(right,90%,border-line,wrap-word|$FZF_DEFAULT_PREVIEW_WINDOW)" \ + --preview="$0 --preview-message {5}" \ + --preview-window="$FZF_DEFAULT_PREVIEW_WINDOW" || true) + exit +fi + +# Modes +APPEND="append" +NWSRCH="search" +CMD_SEARCH_TAG="tag-" +ADDRESS_TO="to-" +ADDRESS_FROM="from-" +CMD_SEARCH_ADDRESS="address-" + +while true; do + nmquery="${nmquery:-tag:inbox}" + cmd=$(__notmuch_search "$nmquery" | fzf \ + --header="$nmquery" --footer="FOOTER" \ + --ansi \ + --tiebreak=index \ + --reverse \ + --multi \ + --delimiter="$(printf '\t')" \ + --with-nth="{1} {2} ({3})" \ + --bind="alt-t:print($CMD_SEARCH_TAG$NWSRCH)+accept" \ + --bind="alt-T:print($CMD_SEARCH_TAG$APPEND)+accept" \ + --bind="alt-s:print($CMD_SEARCH_ADDRESS$ADDRESS_FROM$NWSRCH)+accept" \ + --bind="alt-S:print($CMD_SEARCH_ADDRESS$ADDRESS_FROM$APPEND)+accept" \ + --bind="alt-r:print($CMD_SEARCH_ADDRESS$ADDRESS_TO$NWSRCH)+accept" \ + --bind="alt-R:print($CMD_SEARCH_ADDRESS$ADDRESS_TO$APPEND)+accept" \ + --bind='alt-j:preview-down,alt-k:preview-up' \ + --bind='page-up:preview-half-page-up,page-down:preview-half-page-down' \ + --bind="enter,ctrl-l:execute:$0 --show-thread {4}" \ + --bind="alt-/:change-preview-window(right,90%,border-line,wrap-word|$FZF_DEFAULT_PREVIEW_WINDOW)" \ + --preview="$0 --preview-thread {4}" \ + --preview-window="$FZF_DEFAULT_PREVIEW_WINDOW" | head -1 || true) + [ -n "$cmd" ] || exit 0 + case "$cmd" in + $CMD_SEARCH_TAG*) + tag=$(__notmuch_list_tags | fzf \ + --ansi \ + --cycle \ + --margin='20%' \ + --bind='enter:accept-or-print-query' \ + --border=double \ + --border-label='Select tag' || true) + [ -n "$tag" ] || continue + [ "$cmd" = "$CMD_SEARCH_TAG$APPEND" ] && nmquery="$nmquery and tag:$tag" || nmquery="tag:$tag" + ;; + $CMD_SEARCH_ADDRESS*) + case "$cmd" in + *$ADDRESS_FROM*) + label_text='Select sender' + field='from' + ;; + *) + label_text='Select recipient' + field='to' + ;; + esac + address=$(__notmuch_list_address | fzf \ + --ansi \ + --tiebreak=index \ + --cycle \ + --margin='20%' \ + --bind='enter:accept-or-print-query' \ + --border=double \ + --border-label="$label_text" || true) + [ -n "$address" ] || continue + case "$cmd" in + *$APPEND) + nmquery="$nmquery and $field:$address" + ;; + *) + nmquery="$field:$address" + ;; + esac + ;; + *) + ;; + esac +done diff --git a/src/sh/awk.sh b/src/sh/awk.sh new file mode 100644 index 0000000..2d3b43f --- /dev/null +++ b/src/sh/awk.sh @@ -0,0 +1,17 @@ +if [ ! "${AWK_LOADED:-}" ]; then + AWK_LASTMESSAGEINTHREAD=$( + cat <<'EOF' +@@include awk/lastmessageinthread.awk +EOF + ) + export AWK_LASTMESSAGEINTHREAD + + AWK_THREADOVERVIEW=$( + cat <<'EOF' +@@include awk/threadoverview.awk +EOF + ) + export AWK_THREADOVERVIEW + + export AWK_LOADED=1 +fi diff --git a/src/sh/info.sh b/src/sh/info.sh new file mode 100644 index 0000000..2b556c4 --- /dev/null +++ b/src/sh/info.sh @@ -0,0 +1,11 @@ +# Application information +if [ ! "${INFO_LOADED:-}" ]; then + APP_NAME="nmf" + APP_VERSION="0.1" + APP_WEBSITE="https://git.indyfac.ch/amin/not-much-fuzz/" + WINDOW_TITLE="📨 $APP_NAME | not much fuzz" + export APP_NAME APP_VERSION APP_WEBSITE WINDOW_TITLE + + export INFO_LOADED=1 +fi + diff --git a/src/sh/theme.sh b/src/sh/theme.sh new file mode 100644 index 0000000..93ae6fb --- /dev/null +++ b/src/sh/theme.sh @@ -0,0 +1,13 @@ +# Default theme +if [ ! "${THEME_LOADED:-}" ]; then + COLDATE="$(printf '\033[38;5;195m')" + COLCNTS="$(printf '\033[38;5;244m')" + COLFROM="$(printf '\033[38;5;208m')" + COLSUBJ="$(printf '\033[38;5;179m\033[3m')" + COLTAGS="$(printf '\033[38;5;106m\033[2m')" + RESET="$(printf '\033[0m')" + + export COLDATE COLCNTS COLFROM COLSUBJ COLTAGS + + export THEME_LOADED=1 +fi diff --git a/src/sh/tools.sh b/src/sh/tools.sh new file mode 100644 index 0000000..118877b --- /dev/null +++ b/src/sh/tools.sh @@ -0,0 +1,28 @@ +if [ ! "${TOOLS_LOADED:-}" ]; then + if command -v "fzf" >/dev/null; then + FZF="fzf --black --ansi --cycle --tiebreak=chunk,index" + else + err "Did not find the command-line fuzzy finder fzf." + exit 1 + fi + export FZF + + if command -v "notmuch" >/dev/null; then + NOTMUCH="notmuch" + else + err "Did not find the command-line email system notmuch." + exit 1 + fi + export NOTMUCH + + if command -v "bat" >/dev/null; then + CAT="bat" + elif command -v "batcat" >/dev/null; then + CAT="batcat" + fi + CAT=${CAT:+$CAT -l email --style=plain --color=always --wrap} + CAT=${CAT:-cat} + export CAT + + export TOOLS_LOADED=1 +fi