morristech
6/29/2019 - 12:59 AM

bash functions to dump and inspect a message in MIME format

bash functions to dump and inspect a message in MIME format

#!/usr/bin/env bash

##############################################################################
#### MIME interface
##############################################################################

# Parse message in MIME format and create a temporary cache directory
mime_parse()
{
	MIME_CACHE=${MIME_CACHE:-`mktemp -d ${BIN}.XXXXXXXXXX`}

	local D=$MIME_CACHE
	local HEADER=1
	local LAST=
	local BOUNDARY=

	while read
	do
		REPLY=${REPLY%$CR}

		[ "$REPLY" == '.' ] && break

		# in mime header
		if [ "$HEADER" ]
		then
			# header closed
			[ "$REPLY" ] || {
				HEADER=

				[ -r "$D/content-type" ] && {
					local VALUE
					value "`< "$D/content-type"`" \
						'[Bb][Oo][Uu][Nn][Dd][Aa][Rr][Yy]='
					[ "$VALUE" ] && {
						BOUNDARY=$VALUE
						echo "$BOUNDARY" > "$D/boundary"
					}
				}

				[ -r "$D/content-disposition" ] && {
					local VALUE
					value "`< $D/content-disposition`" \
						'[Ff][Ii][Ll][Ee][Nn][Aa][Mm][Ee]='
					[ "$VALUE" ] && {
						echo "$VALUE" >> "$MIME_CACHE/attachments"
						echo "$D" >> "$MIME_CACHE/attachments-paths"
					}
				}

				continue
			}

			local F
			if [[ "$REPLY" == [' '$'\t']* ]]
			then
				[ "$LAST" ] || continue
				F=$LAST
			else
				F=`lower "${REPLY%%:*}"`
				LAST=$F
			fi

			echo ${REPLY#*:} >> "$D/$F"
			continue
		elif [ "$BOUNDARY" ] && [ "${REPLY:0:2}" == '--' ]
		then
			[[ "$REPLY" == --$BOUNDARY* ]] && {
				[ "$D" == "$MIME_CACHE" ] || D=${D%/*}

				if [ "$REPLY" == "--$BOUNDARY--" ]
				then
					if [ -r "$D/boundary" ]
					then
						BOUNDARY=`< "$D/boundary"`
					else
						BOUNDARY=
					fi

					HEADER=
				else
					local PART=1

					[ -r "$D/parts" ] && {
						PART=`< "$D/parts"`
						(( ++PART ))
					}

					echo $PART > "$D/parts"
					D="$D/part-$PART"

					mkdir "$D" || return 1

					HEADER=1
				fi
			}

			continue
		fi

		echo "$REPLY"$CR >> $D/body
	done
}

# Free MIME data structure
mime_free()
{
	rm -rf $MIME_CACHE
	MIME_CACHE=
}

# Decode possibly encoded message text
#
# @param 1 - message directory
mime_decode_message()
{
	local F="$1/body"

	[ -r "$F" ] && {
		local T=`< "$1/content-type"` CS='cat'

		case "$T" in
			[Tt][Ee][Xx][Tt]/*)
				local VALUE
				value "$T" '[Cc][Hh][Aa][Rr][Ss][Ee][Tt]='
				[ "$VALUE" ] &&
					CS="iconv -f $VALUE -t utf-8"
				;;
		esac

		case "`< "$1/content-transfer-encoding"`" in
			*[Qq][Uu][Oo][Tt][Ee][Dd]-[Pp][Rr][Ii][Nn][Tt][Aa][Bb][Ll][Ee]*)
				decode_quoted_printable
				;;
			*[Bb][Aa][Ss][Ee]64*)
				base64 -d -i
				;;
			*)
				cat
				;;
		esac < "$F" | $CS
	} 2>/dev/null
}

# Display message with header information
#
# @param 1 - message directory
mime_display_message()
{
	# echo headers
	{
		local H HEADERS=${HEADERS:-from to subject date attachments}
		local M=0

		# get length of longest header label
		{
			local L
			for H in $HEADERS
			do
				L=${#H}
				(( L > M )) &&
					M=$L
			done
		}

		local W=$(( ${WIDTH:-80}-(M+2) ))
		for H in $HEADERS
		do
			local F="$1/$H"
			while ! [ -r "$F" ]
			do
				[ "$F" == "$MIME_CACHE/$H" ] && break
				F="$MIME_CACHE/$H"
			done
			[ -r "$F" ] || continue

			local S
			if [ "$H" == 'attachments' ]
			then
				S=`< "$F"`
				S=${S//$'\n'/ }
			else
				S=`decode_encoded_word < "$F"`
			fi

			local N L=${#S} LABEL=$H
			for (( N = 0; N < L; N += W ))
			do
				printf "%-${M}s %-${W}s\n" "$LABEL" "${S:$N:$W}"
				LABEL=
			done
		done

		[ "$H" ] && echo
	}

	mime_decode_message "$1"
}

# Returns true if content type is text
#
# @param 1 - file with content type
mime_content_is_text()
{
	case "`< "$1"`" in
		*[Tt][Ee][Xx][Tt]/[Pp][Ll][Aa][Ii][Nn]*|\
		*[Tt][Ee][Xx][Tt]/[Hh][Tt][Mm][Ll]*)
			return 0
			;;
	esac 2>/dev/null

	return 1
}

# Traverse message tree to find message text
#
# @param 1 - directory in MIME tree
# @param 2 - callback function
mime_find_message()
{
	(( $# < 2 )) && return 1

	local TYPE=0

	case "`< "$1/content-type"`" in
		*[Mm][Uu][Ll][Tt][Ii][Pp][Aa][Rr][Tt]/[Aa][Ll][Tt][Ee][Rr][Nn][Aa][Tt][Ii][Vv][Ee]*)
			TYPE=1
			;;
		*[Mm][Uu][Ll][Tt][Ii][Pp][Aa][Rr][Tt]/[Dd][Ii][Gg][Ee][Ss][Tt]*)
			TYPE=2
			;;
	esac 2>/dev/null

	local N PARTS=`< "$1/parts"`

	for (( N=1; N < PARTS; ++N ))
	do
		local P="$1/part-$N"

		[ -r "$P/body" ] &&
			mime_content_is_text "$P/content-type" && {
				$2 "$P"
				(( TYPE == 2 )) || return 0
			}

		(( TYPE == 2 )) && return 0

		[ -r "$P/parts" ] && mime_find_message "$P" "$2" && return 0
	done

	return 1
}

# Echo message from MIME data structure
#
# @param 1 - callback function (optional)
mime_message()
{
	local C=${1:-mime_display_message}

	[ -r "$MIME_CACHE/parts" ] &&
		mime_find_message "$MIME_CACHE" $C &&
		return

	[ -r "$MIME_CACHE/content-type" ] && {
		mime_content_is_text "$MIME_CACHE/content-type" ||
		return
	}

	$C "$MIME_CACHE"
}

##############################################################################
#### Encoding/Decoding
##############################################################################

# Decode quoted-printable-encoded stream
decode_quoted_printable()
{
	local C=0 EOF=0

	while (( ! EOF ))
	do
		read -d '=' || EOF=1

		(( C )) &&
			if [[ $REPLY == [$'\r'$'\n']* ]]
			then
				REPLY=${REPLY:1}
			else
				printf \\x"${REPLY:0:2}"
				REPLY=${REPLY:2}
			fi

		echo -n "$REPLY"
		C=1
	done
}

# Decode MIME encoded-word syntax
decode_encoded_word()
{
	while read
	do
		while [[ $REPLY == *'=?'* ]]
		do
			echo -n ${REPLY%%'=?'*}
			local A=${REPLY#*'?='} V=${REPLY#*'=?'}
			V=${V%%'?='*}
			local P=( ${V//\?/ } )
			if (( ${#P[@]} == 3 ))
			then
				case "${P[1]}" in
					[Qq])
						echo -n "${P[2]}" | decode_quoted_printable
						;;
					[Bb])
						echo -n "${P[2]}" | base64 -d -i
						;;
				esac | iconv -f "${P[0]}" -t utf-8
			else
				echo -n $V
			fi

			REPLY=$A
		done

		echo -n $REPLY
	done
}

which iconv &>/dev/null || iconv() {
	cat
}

which base64 &>/dev/null || {
	echo 'error: base64 not found!' >&2
	echo 'Either install it or get this fallback implementation:' >&2
	echo 'https://gist.github.com/2648733' >&2
	exit 1
}

##############################################################################
#### String auxiliaries
##############################################################################

# Make string lower case
#
# @param 1 - some string
if [ $BASH_VERSINFO ] && (( ${BASH_VERSINFO[0]} > 3 ))
then
lower()
{
	echo "${1,,}"
}
else
lower()
{
	echo "$1" | tr '[:upper:]' '[:lower:]'
}
fi

# Find a key/value pair in the given string and set VALUE accordingly
#
# @param 1 - string
# @param 2 - pattern of key
value()
{
	[[ "$1" == *$2* ]] || {
		VALUE=
		return
	}

	VALUE=${1#*$2}

	local QUOTE="${VALUE:0:1}"
	case "$QUOTE" in
		'"'|"'")
			;;
		*)
			QUOTE=
			;;
	esac

	if [ "$QUOTE" ]
	then
		VALUE=${VALUE:1}
		VALUE=${VALUE%%$QUOTE*}
	else
		VALUE=${VALUE%% *}
	fi
}

##############################################################################
#### Features
##############################################################################

# Manually check data structure
#
# @param 1 - message file
inspect()
{
	[ -r "$1" ] || {
		echo "error: file $1 not found" >&2
		return 1
	}

	echo "(unpacking \"$1\")"

	mime_parse < "$1" &&
		cd "$MIME_CACHE" && \
		ls && \
		PS1='inspect> ' bash && \
		cd ..

	mime_free
}

# Dump message text
#
# @param 1 - message file
dump()
{
	mime_parse < "$1" &&
		mime_message

	mime_free
}

##############################################################################
#### Command processing
##############################################################################

# Process arguments
#
# @param ... - arguments
mime()
{
	(( $# < 1 )) && {
		cat <<EOF
usage: ${BIN} [-di] FILE...
    d   dump message (default)
    i   inspect message tree

EOF
		return
	}

	local F ACTION=dump

	for F in "$@"
	do
		case "$F" in
			-i)
				ACTION=inspect
				continue
				;;
			-d)
				ACTION=dump
				continue
				;;
			-*)
				echo "error: unkown flag '$F'" >&2
				return
				;;
		esac

		$ACTION "$F"
	done
}

readonly BIN=${0##*/}
readonly CR=$'\r'

mime "$@"