#!/bin/sh
#
# $OpenIMAPD$
#
# Copyright (c) 2026 David Williams <dhw@openimapd.dev>
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#
# imapduser -- add or delete an imapd mailbox account.
#
# Usage:
#   imapduser -a [-c credentials-file] [-s spool-root] [-u uid] [-g gid] username
#   imapduser -d [-c credentials-file] username
#
# -a adds a new mailbox account and prints its smtpd(8) userbase line.
#
# -d removes a mailbox account's credentials-file line ONLY.
#
# Exactly one of -a or -d is required.
#
# Must be run as root (or via doas/su): -a chown(8)s a maildir to an
# arbitrary uid/gid and both modes write to a file that should stay
# root-owned.

set -e

CRED_FILE=/etc/imapd/credentials
SPOOL_ROOT=/var/mail/imapd
BASE_ID=2000
NEWUID=
NEWGID=
MODE=

usage() {
	echo "usage: ${0##*/} -a [-c credentials-file] [-s spool-root] [-u uid] [-g gid] username" 1>&2
	echo "       ${0##*/} -d [-c credentials-file] username" 1>&2
	exit 1
}

while getopts "ac:dg:s:u:" opt; do
	case "$opt" in
	a)	[ -z "$MODE" ] || usage; MODE=add ;;
	d)	[ -z "$MODE" ] || usage; MODE=del ;;
	c)	CRED_FILE=$OPTARG ;;
	s)	SPOOL_ROOT=$OPTARG ;;
	u)	NEWUID=$OPTARG ;;
	g)	NEWGID=$OPTARG ;;
	*)	usage ;;
	esac
done
shift $((OPTIND - 1))

[ -n "$MODE" ] || usage
[ $# -eq 1 ] || usage
USERNAME=$1

# Field 0 of a credentials-file line, so it can't itself contain ":" or
# a newline; kept to a conservative safe-for-a-bare-maildir-directory-
# name charset besides, since $USERNAME doubles as the maildir name
# in -a mode.
case "$USERNAME" in
*[!A-Za-z0-9_.-]*|"")
	echo "${0##*/}: invalid username: $USERNAME (letters, digits, '.', '_', '-' only)" 1>&2
	exit 1
	;;
esac

if [ "$(id -u)" -ne 0 ]; then
	echo "${0##*/}: must be run as root" 1>&2
	exit 1
fi

set_cred_perms() {
	chown root:wheel "$CRED_FILE" 2>/dev/null || true
	if ! chgrp _imapauth "$CRED_FILE" 2>/dev/null; then
		echo "${0##*/}: warning: group '_imapauth' doesn't exist yet --" \
		    "left $CRED_FILE group-owned by wheel. auth.c drops" \
		    "privileges to the _imapauth account before reading this" \
		    "file, so it must be chgrp'd to _imapauth (rerun this" \
		    "script, or 'chgrp _imapauth $CRED_FILE' by hand) once that" \
		    "account exists, or every AUTHENTICATE will fail with" \
		    "\"fopen credentials: Permission denied\" in the auth log." 1>&2
	fi
	chmod 640 "$CRED_FILE"
}

id_in_use() {
	_id=$1
	awk -F: -v id="$_id" '$3 == id { f=1 } END { exit !f }' /etc/passwd && return 0
	awk -F: -v id="$_id" '$3 == id { f=1 } END { exit !f }' /etc/group && return 0
	awk -F: -v id="$_id" '$3 == id || $4 == id { f=1 } END { exit !f }' \
	    "$CRED_FILE" && return 0
	return 1
}

next_free_id() {
	_id=$BASE_ID
	while id_in_use "$_id"; do
		_id=$((_id + 1))
	done
	echo "$_id"
}

do_add() {
	if [ ! -e "$CRED_FILE" ]; then

		CRED_DIR=${CRED_FILE%/*}
		if [ "$CRED_DIR" != "$CRED_FILE" ] && [ ! -d "$CRED_DIR" ]; then
			mkdir -p "$CRED_DIR"
			chmod 755 "$CRED_DIR"
		fi
		: > "$CRED_FILE"
		set_cred_perms
	fi

	if awk -F: -v u="$USERNAME" '$1 == u { found=1 } END { exit !found }' "$CRED_FILE"; then
		echo "${0##*/}: $USERNAME already has a line in $CRED_FILE" 1>&2
		exit 1
	fi

	if [ -z "$NEWUID" ] && [ -z "$NEWGID" ]; then
		NEWUID=$(next_free_id)
		NEWGID=$NEWUID
	elif [ -z "$NEWUID" ]; then
		NEWUID=$NEWGID
	elif [ -z "$NEWGID" ]; then
		NEWGID=$NEWUID
	fi

	case "$NEWUID" in
	*[!0-9]*|"") echo "${0##*/}: invalid uid: $NEWUID" 1>&2; exit 1 ;;
	esac
	case "$NEWGID" in
	*[!0-9]*|"") echo "${0##*/}: invalid gid: $NEWGID" 1>&2; exit 1 ;;
	esac

	MAILDIR=$USERNAME
	MAILDIR_PATH=$SPOOL_ROOT/$MAILDIR

	if [ -e "$MAILDIR_PATH" ]; then
		echo "${0##*/}: $MAILDIR_PATH already exists -- not touching it" 1>&2
		exit 1
	fi

	mkdir -p "$MAILDIR_PATH"
	chown "$NEWUID:$NEWGID" "$MAILDIR_PATH"
	chmod 700 "$MAILDIR_PATH"

	echo "Password for $USERNAME:" 1>&2
	HASH=$(encrypt -b a -p)
	if [ -z "$HASH" ]; then
		echo "${0##*/}: encrypt(1) produced no output -- aborting" 1>&2
		rmdir "$MAILDIR_PATH" 2>/dev/null || true
		exit 1
	fi

	echo "${USERNAME}:${HASH}:${NEWUID}:${NEWGID}:${MAILDIR}" >> "$CRED_FILE"

	echo "${0##*/}: added $USERNAME (uid $NEWUID, gid $NEWGID, maildir $MAILDIR_PATH) to $CRED_FILE" 1>&2
	echo "${0##*/}: its smtpd(8) userbase line follows; see imapd.conf(5)" 1>&2
	printf '%s\t%s:%s:%s\n' "$USERNAME" "$NEWUID" "$NEWGID" "$MAILDIR_PATH"
}

do_delete() {
	if [ ! -e "$CRED_FILE" ]; then
		echo "${0##*/}: no credentials file at $CRED_FILE" 1>&2
		exit 1
	fi

	if ! awk -F: -v u="$USERNAME" '$1 == u { found=1 } END { exit !found }' "$CRED_FILE"; then
		echo "${0##*/}: $USERNAME has no line in $CRED_FILE" 1>&2
		exit 1
	fi

	# Captured only to tell the operator where the mail data is.
	MAILDIR_FIELD=$(awk -F: -v u="$USERNAME" '$1 == u { print $5; exit }' "$CRED_FILE")

	TMP_CRED_FILE=$(mktemp "${CRED_FILE}.XXXXXXXX") || {
		echo "${0##*/}: mktemp failed" 1>&2
		exit 1
	}
	awk -F: -v u="$USERNAME" '$1 != u' "$CRED_FILE" > "$TMP_CRED_FILE"
	mv -f "$TMP_CRED_FILE" "$CRED_FILE"
	set_cred_perms

	echo "${0##*/}: removed $USERNAME from $CRED_FILE ($SPOOL_ROOT/$MAILDIR_FIELD" \
	    "was left untouched -- remove it by hand if you also want the" \
	    "mail data gone)" 1>&2
	echo "${0##*/}: remove $USERNAME's line from smtpd(8)'s userbase" \
	    "table too, or mail for it is still delivered" 1>&2
}

case "$MODE" in
add)	do_add ;;
del)	do_delete ;;
esac
