#! /bin/bash

# Copyright (C) 2026 Ján Máté <jan.mate@inf-it.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.

# NOTE: this script INTENTIONALLY exits with 0 in all success/error cases to not break the pipe mail processing!

set -u
set -o pipefail
umask 0007

CONF_FILE="$(dirname -- "${0}")/dovecot_spam.conf"

if [ ! -r "${CONF_FILE}" ]; then
	echo "Error: missing configuration file: '${CONF_FILE}'" > /dev/stderr
	exit 0
fi

# PosgreSQL configuration + schema information
source "${CONF_FILE}"

command -v "${CONF_CHECKSUM_PRG}" &>/dev/null || { echo >&2 "Error: '${CONF_CHECKSUM_PRG}' not found: check the CONF_CHECKSUM_PRG value. Aborting ..."; exit 0; }

{ stat --printf='%A' -- "${CONF_FILE}" | cut -c 8- | grep -qE -- '^---$' ; } 2> /dev/null
if [ $? -ne 0 ]; then
	echo "Error: invalid configuration file permissions" > /dev/stderr
	echo "     - you must remove (at least) all permissions for others: chmod o= -- '${CONF_FILE}'" > /dev/stderr
	echo "     - it is also recommended to check the group of the file, and consider 'go=' permission if possible" > /dev/stderr
	exit 0
fi

if [ "$#" -eq 2 ]; then
	MESSAGE_USER="${1}"
	if [ "${2}" == 'spam' -o "${2}" == 'ham' ]; then
		MESSAGE_TYPE="${2}"
	fi
fi

if [ "${MESSAGE_USER:-}" == '' -o "${MESSAGE_TYPE:-}" == '' ]; then
	echo "Usage: ${0} username { spam | ham }" > /dev/stderr
	exit 0
fi

# make sure the script runs under the expected user (auto-switch when started as root)
IFS=':' read -r EXEC_USER EXEC_GROUP <<< "${CONF_REPORT_USER_GROUP}"
if [ "$(id -un)" != "${EXEC_USER}" ]; then
	if [ "${EUID}" -eq 0 ]; then
		if [ -n "${EXEC_GROUP:-}" ]; then
			exec runuser -u "${EXEC_USER}" -g "${EXEC_GROUP}" -- "${0}" "$@"
		else
			exec runuser -u "${EXEC_USER}" -- "${0}" "$@"
		fi
	fi
	echo "Error: this script must run as '${CONF_REPORT_USER_GROUP}' (or as root)" > /dev/stderr
	exit 0
fi

TMP_FILE="$(mktemp -- "${CONF_CACHE_DIR}/msg.XXXXXXXXXXXXXXXX" 2>/dev/null)"
if [ $? -ne 0 ]; then
	logger -p mail.err -t "${0##*/}" --id="$$" "failed to create tmp file in '${CONF_CACHE_DIR}'"
	exit 0
fi
chmod g+r -- "${TMP_FILE}" 2>/dev/null
if [ $? -ne 0 ]; then
	logger -p mail.err -t "${0##*/}" --id="$$" "failed to change permission of tmp file '${TMP_FILE}'"
	rm --preserve-root --force -- "${TMP_FILE}" &> /dev/null
	exit 0
fi

# NOTE: tee opens the inherited fd 0 descriptor by default - do not use < /dev/stdin here, because it breaks the dovecot socket mode
TMP_FILE_HASH="$( { tee -- "${TMP_FILE}" | "${CONF_CHECKSUM_PRG}" | cut -d' ' -f1 ; } 2>/dev/null )" 
if [ $? -ne 0 ]; then
	logger -p mail.err -t "${0##*/}" --id="$$" "failed to write or create checksum of file '${TMP_FILE}'"
	rm --preserve-root --force -- "${TMP_FILE}" &> /dev/null
	exit 0
fi

DB_RESULT="$(PGPASSWORD="${CONF_PG_PASS}" psql \
	--no-psqlrc \
	--no-readline \
	--quiet --tuples-only --no-align \
	--set=ON_ERROR_STOP=1 \
	--variable=MESSAGE_USER="${MESSAGE_USER}" \
	--variable=MESSAGE_FILE="${TMP_FILE##*/}" \
	--variable=MESSAGE_HASH="${CONF_CHECKSUM_PRG##*/}:${TMP_FILE_HASH}" \
	--variable=MESSAGE_TYPE="${MESSAGE_TYPE}" \
	--host="${CONF_PG_HOST}" \
	--username="${CONF_PG_USER}" \
	--no-password \
	-- "${CONF_PG_NAME}" \
	2>/dev/null \
	<<-EOF
		-- always consider only the latest version of ("user", "message_hash")
		WITH "w_last" AS (
			SELECT
				"_id",
				"status",
				"type",
				(json_agg("type") FILTER (WHERE "status"='processed') OVER (ORDER BY "_ts_created" ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW))->>-1 AS "last_processed_type"
			FROM "public"."reporting"
			WHERE
				"user"=:'MESSAGE_USER' AND
				"message_hash"=:'MESSAGE_HASH' AND
				"status" IN ('waiting', 'processing', 'processed')
			ORDER BY
				("status"='waiting') DESC,	-- index-enforced latest (even in case of on NTP time change)
				"_ts_created" DESC
			LIMIT 1
		)
		MERGE INTO "public"."reporting" "r"
		USING (
			SELECT
				"v".*,
				"l"."last_processed_type",
				CASE
					-- the routing decision:
					-- address the latest row only if it is correctable (waiting) or agrees (dedup)
					-- otherwise match_id is NULL -> NOT MATCHED -> INSERT
					WHEN "l"."status"='waiting' OR "l"."type"="v"."type" THEN
						"l"."_id"
				END AS "match_id"
			FROM (VALUES (:'MESSAGE_USER', :'MESSAGE_FILE', :'MESSAGE_HASH', :'MESSAGE_TYPE')) AS "v" ("user", "message_file", "message_hash", "type")
			LEFT JOIN "w_last" "l" ON true
		) AS "_"
		ON "r"."_id"="_"."match_id"

		-- report status 'waiting'
		-- report type changed (spam -> ham or ham -> spam)
		WHEN MATCHED AND "r"."status"='waiting' AND "r"."type"<>"_"."type" THEN
			UPDATE SET "_ts_updated"=now(), "type"="_"."type", "status"=(CASE WHEN "_"."type"="_"."last_processed_type" THEN 'ignored' ELSE "r"."status" END)
		-- identical report already exists, just update the "_ts_updated"
		WHEN MATCHED AND "r"."status"='waiting' AND "r"."type"="_"."type" THEN
			UPDATE SET "_ts_updated"=now()

		-- message with the same report type is in processing (or was already processed)
		-- set the "_ts_matched" on the existing rule (just to have a record)
		WHEN MATCHED THEN
			UPDATE SET "_ts_matched"=now()

		-- a message requiring new processing (new, or it is too late for anything else)
		WHEN NOT MATCHED THEN
			INSERT ("user", "message_file", "message_hash", "type")
				VALUES ("_"."user", "_"."message_file", "_"."message_hash", "_"."type")

		RETURNING merge_action()
		;
	EOF
)"
DB_STATUS="$?"
if [ "${DB_STATUS}" -ne 0 ]; then
	logger -p mail.err -t "${0##*/}" --id="$$" "database connection or query error, exit code: '${DB_STATUS}' (MESSAGE_USER='${MESSAGE_USER}' MESSAGE_FILE='${TMP_FILE##*/}' MESSAGE_TYPE='${MESSAGE_TYPE}')"
	rm --preserve-root --force -- "${TMP_FILE}" &> /dev/null
elif [ "${DB_RESULT}" != 'INSERT' ]; then
	# if the MERGE operation was not INSERT remove the temp file (the file with the same hash already exists)
	rm --preserve-root --force -- "${TMP_FILE}" &> /dev/null
fi

exit 0
