|
#!/usr/bin/env bash |
|
|
|
# NAME |
|
# Better Mail Sync Wrapper for Dovecot |
|
# |
|
# SYNOPSIS |
|
# ./mailbox-migration-utils.sh download -u|--user user -r|--remote-host host [--batch-mode] |
|
# ./mailbox-migration-utils.sh download [-h|--help] |
|
# ./mailbox-migration-utils.sh sync -u|--user user -r|--remote-host host |
|
# ./mailbox-migration-utils.sh sync [-h|--help] |
|
# ./mailbox-migration-utils.sh --version |
|
# ./mailbox-migration-utils.sh --help |
|
# |
|
# DESCRIPTION |
|
# A wrapper script around the 'doveadm' utility intended for easy download and sync |
|
# of user emails from a remote IMAP mailbox to the local Dovecot mailbox and for |
|
# backing up local mail to a local destination. |
|
# |
|
# AUTHOR |
|
# Martin Cagas |
|
# |
|
# EXIT STATUS |
|
# 0 Synchronisation completed successfully. |
|
# 1 Error while parsing arguments. |
|
# 2 File system access error. |
|
# 10 User not found in the local Dovecot user database. |
|
# 11 The person running this script chose to interrupt the synchronisation. |
|
# |
|
# CHANGELOG |
|
# version 1.2.0 |
|
# - Added changelog :) |
|
# - Added the working sync command |
|
# - Improved and simplified the doveadm calls (now uses the imapc_host to find the remote) |
|
# - Removed the -c|--command flag again |
|
# - Additional bug fixes |
|
# version 1.1.3 |
|
# - Added the initial implementation of the not-yet-working 'sync' command |
|
# - Added the -c|--command flag |
|
# version 1.1.2 |
|
# - Added the version flag |
|
# version 1.1.1 |
|
# - Now with a lot more safety precautions and checks |
|
# |
|
# COPYRIGHT |
|
# Copyright (c) 2022 Martin Cagas; MIT License <https://choosealicense.com/licenses/mit/> |
|
# This is a free software: you are free to change and redistribute it. |
|
# There is NO WARRANTY, to the extent permitted by law. |
|
|
|
# We don't want to source this file as it would pollute alias definitions and shell options |
|
# But it's a script - if you want to source it regardless, just delete these commands: |
|
$(return &>/dev/null) |
|
if (($? == 0)); then |
|
printf "Please don't source this script.\n" |
|
return 1 |
|
fi |
|
|
|
# Declare the version |
|
declare -r version="1.2.0" |
|
|
|
# Some color text definitions |
|
declare -r red=$'\e[1;31m' |
|
declare -r green=$'\e[1;32m' |
|
declare -r nc=$'\e[0m' |
|
|
|
# Function to query yes/no |
|
# 'No' being the default (is matched on empty string) |
|
function prompt_confirmation() { |
|
while true; do |
|
read -r -p "${*} [y/N]: " yn |
|
case $yn in |
|
[Yy]*) return 0 ;; |
|
[Nn]* | '') return 1 ;; |
|
esac |
|
done |
|
} |
|
|
|
# Download a user's mailbox from our old IMAP server using 'doveadm backup -R', |
|
# overwriting the local user's mailbox in the process. |
|
function download() { |
|
# Print the download command's usage |
|
function print_usage() { |
|
printf "Usage:\n" |
|
printf './mailbox-migration-utils.sh \\\n' |
|
printf "\tdownload -u|--user user -r|--remote-host host [--batch-mode]\n" |
|
printf "\tdownload [-h|--help]\n" |
|
} |
|
|
|
# A function to safely delete the failure message file |
|
function delete_failure_txt() { |
|
# The :? here serves as a check for undefined variable |
|
# If the variable is undefined, fail |
|
local -r l_file="${log_directory:?}/failure.txt" |
|
# Checks if the file actually exists before attempting to delete it |
|
if [[ -f "${l_file}" ]]; then |
|
rm -f "${l_file}" |
|
return |
|
else |
|
printf "Attempted delete of %s, but the file does not exist!\n" "${l_file}" >&2 |
|
return 1 |
|
fi |
|
} |
|
|
|
# A function to safely delete the user mailbox file |
|
function delete_user_mailbox() { |
|
# The :? here serve as a check for undefined variables |
|
# If any variable is undefined, fail |
|
local -r l_user="${user:?}" |
|
local -r l_maildir="${mail_directory:?}" |
|
# Check if the user actually exists |
|
if doveadm user "${l_user}" &>/dev/null; then |
|
# Check if the mail directory actually exists |
|
if [[ -d "${l_maildir}" ]]; then |
|
rm -rf "${l_maildir}" |
|
return |
|
else |
|
printf "Attempted delete of %s, but the directory does not exist!\n" "${l_maildir}" >&2 |
|
return 1 |
|
fi |
|
else |
|
printf "Attempted delete of %s's mailbox, but the user does not exist!\n" "${l_user}" >&2 |
|
return 1 |
|
fi |
|
} |
|
|
|
function download_worker() { |
|
# Output additional info |
|
# Since the output of the entire download_worker() is later being redirected into a file, |
|
# it'll serve as annotations for the logs |
|
printf "Beginning download for user %s on " "${user}" |
|
date |
|
printf "\n" |
|
|
|
local -i exit_status=0 |
|
|
|
# Measure the time it takes to sync the mailbox |
|
local TIMEFORMAT="Time spent processing: %R seconds." |
|
time { |
|
doveadm -c /etc/dovecot/dovecot-migration.conf \ |
|
-o imapc_host="${remote_host:?}" \ |
|
-o imapc_user="${user:?}" \ |
|
-o imapc_password="${password:?}" \ |
|
backup -Ru "${user:?}" imapc: |
|
|
|
# Store the 'doveadm' exit status before 'time' overwrites it |
|
exit_status=${?} |
|
|
|
# Based on 'dsync' exit statuses |
|
# See 'man dsync' for more info |
|
if ((exit_status == 0)); then |
|
printf "Download was done perfectly.\n" |
|
elif ((exit_status == 2)); then |
|
printf "Download was done without errors, but some changes couldn't be done, so the mailboxes aren't perfectly synchronised.\n" |
|
printf "This occurs if one of the mailboxes changes during the syncing.\n" |
|
elif ((exit_status == 1 || exit_status > 2)); then |
|
printf "Download failed.\n" |
|
echo -n "${user:?}" >"${log_directory:?}/failure.txt" |
|
fi |
|
} |
|
|
|
# The return the original exit status |
|
return "${exit_status}" |
|
} |
|
|
|
local user |
|
local remote_host |
|
local batch_mode_flag="false" |
|
local help_flag="false" |
|
|
|
# Parse the arguments using getopt (enchanced) |
|
# See 'man 1 getopt' and '/usr/share/doc/util-linux/getopt-example.bash' |
|
local temp |
|
temp=$(getopt -o 'u:r:h' --long 'user:,remote-host:,batch-mode,help' -n 'Mailbox Migration Utils' -- "$@") |
|
|
|
if (($? != 0)); then |
|
printf "%s\n" "${red}Internal error while parsing arguments.${nc}" >&2 |
|
exit 1 |
|
fi |
|
|
|
eval set -- "$temp" |
|
unset temp |
|
|
|
while true; do |
|
case "${1}" in |
|
"-u" | "--user") |
|
user="${2}" |
|
shift 2 |
|
continue |
|
;; |
|
"-r" | "--remote-host") |
|
remote_host="${2}" |
|
shift 2 |
|
continue |
|
;; |
|
"--batch-mode") |
|
batch_mode_flag="true" |
|
shift |
|
continue |
|
;; |
|
"-h" | "--help") |
|
help_flag="true" |
|
shift |
|
continue |
|
;; |
|
"--") |
|
shift |
|
break |
|
;; |
|
*) |
|
printf "%s\n" "${red}Internal error while parsing arguments.${nc}" >&2 |
|
exit 1 |
|
;; |
|
esac |
|
done |
|
|
|
# Declare the parsed variables read-only |
|
# This ensures they're never accidentally changed or unset |
|
declare -r user |
|
declare -r remote_host |
|
|
|
# If help was set, print usage and exit cleanly |
|
if [[ "${help_flag}" == "true" ]]; then |
|
print_usage |
|
exit 0 |
|
fi |
|
|
|
# If either user or remote host are unspecified, print usage and exit |
|
if [[ -z "${user}" || -z "${remote_host}" ]]; then |
|
printf "%s\n" "${red}Missing arguments!${nc}" |
|
print_usage |
|
exit 1 |
|
fi |
|
|
|
# If batch mode is set, don't prompt user for confirmation |
|
if [[ "${batch_mode_flag}" == "false" ]]; then |
|
printf "%s\n" "${red}IMPORTANT: This command copies the remote mailbox to local, overwriting the ${user}'s local mail in the process!${nc}" |
|
printf "Did you mean 'sync' or 'backup' instead?\n" |
|
prompt_confirmation "Please confirm you want to continue with 'download'" || exit 11 |
|
fi |
|
|
|
# Check if the migration configuration file exists |
|
if [[ ! -f "/etc/dovecot/dovecot-migration.conf" ]]; then |
|
printf "%s\n" "${red}The migration configuration file does not exist!${nc}" >&2 |
|
printf "Aborting...\n" >&2 |
|
exit 2 |
|
fi |
|
|
|
# Check if doveadm can actually find the local user before going further |
|
if ! doveadm user "${user}" &>/dev/null; then |
|
printf "%s\n" "${red}User not found in the local Dovecot user database.${nc}" >&2 |
|
printf "Aborting...\n" >&2 |
|
exit 10 |
|
fi |
|
|
|
# Set to the path to the mailbox and the log directory |
|
# Make both values read-only |
|
local -r mail_directory="/var/vmail/${user:?}" |
|
local -r log_directory="/tmp/mailbox-migration" |
|
|
|
if [[ ! -d "${log_directory}" ]]; then |
|
printf "The log directory %s does not exist!\n" "${log_directory}" |
|
printf "Attempting to create it...\n" |
|
if mkdir "${log_directory}"; then |
|
printf "Directory created succesfully.\n" |
|
else |
|
printf "%s\n" "${red}Could not create the directory.${nc}" |
|
printf "Aborting...\n" |
|
exit 2 |
|
fi |
|
fi |
|
|
|
# Check if past sync failed |
|
# Warn the user about overwriting logs, if they wish to continue |
|
if [[ -f "${log_directory}/failure.txt" ]]; then |
|
printf "%s\n" "${red}The last download failed!${nc}" |
|
if prompt_confirmation "Are you sure you want to continue? This can permanently overwrite past logs!"; then |
|
delete_failure_txt |
|
else |
|
exit 11 |
|
fi |
|
fi |
|
|
|
while true; do |
|
local password |
|
|
|
# Read the password without printfing it back to the shell |
|
read -rs -p "Password for ${user}: " password |
|
printf "\n" # Extra newline is required here because read relies on the newline from enter - but echo is disabled |
|
|
|
# Spawn the download_worker function as a background job and immediately disown it |
|
# This will cause the job to not be terminated in case the current shell gets closed for any reason |
|
download_worker &>"${log_directory}/${user}.log" & |
|
disown |
|
|
|
# Wait for the disowned process to finish |
|
# Play an animation of changing dots to signal the script hasn't frozen |
|
local -i pid=${!} |
|
local -i step=1 |
|
local status_string="Copying the mailbox for ${user}" |
|
|
|
while kill -0 "${pid}" &>/dev/null; do |
|
# Move cursor at the start of the line and delete it |
|
tput hpa 0 && tput el |
|
|
|
# Print the base string |
|
printf "%s" "${status_string}" |
|
|
|
# Print the required number of dots |
|
# This is true, cursed bash black magic... please don't learn from this... |
|
printf ".%.0s" $(eval "echo {1..${step}}") |
|
|
|
# Increment the counter |
|
((step++)) |
|
|
|
# The max number of dots can be adjusted by the upper limit of the step variable |
|
if ((step > 3)); then |
|
step=1 |
|
fi |
|
|
|
# Sleep for half a second |
|
sleep 0.5 |
|
done |
|
|
|
# Change the prompt one last time before writing the final status |
|
tput hpa 0 && tput el |
|
|
|
# Check if the process failed and attempt it again, if so |
|
if [[ -f "${log_directory}/failure.txt" && $(cat ${log_directory}/failure.txt) == "${user}" ]]; then |
|
printf "%s\n" "${red}${status_string}... failed!${nc}" |
|
printf "Possibly an authentication error?\n" |
|
|
|
if prompt_confirmation "Retry?"; then |
|
# All deletes are done with a plethora of checks |
|
# Better safe than sorry |
|
delete_user_mailbox |
|
delete_failure_txt |
|
else |
|
exit 11 |
|
fi |
|
else |
|
printf "%s\n" "${green}${status_string}... done!${nc}" |
|
break |
|
fi |
|
done |
|
|
|
# Wrap up with a summary (and an advice how to check the copied mails |
|
printf "Finished copying the mailbox for %s. Total size: %s\n" "${user}" "$(du -c --si "${mail_directory}" | tail -1 | awk '{print $1}')" |
|
printf "Please verify the operation with %s.\n" "${green}'du -c --si /var/vmail/${user}'${nc}" |
|
|
|
exit 0 |
|
} |
|
|
|
## My little programmer's defense of what you'll see ahead: |
|
## Most of you will see the following code is identical and say I should have extracted it into a single code somehow. |
|
## While there are parts that should (and will) be a function - like the wait cycle, |
|
## the rest of the code isn't "logically connected" - i.e. I would say the fact the following code is so similar is due to a coincidence, |
|
## not a logical outcome - like two procedures using the same mathematical function. |
|
## Also it's 10 pm, I'm tired and it's got to be working tomorrow morning. |
|
|
|
# Download a user's mailbox from our old IMAP server using 'doveadm sync -R1', |
|
# merging the remote mailbox into the local mailbox |
|
function sync() { |
|
# Print the download command's usage |
|
function print_usage() { |
|
printf "Usage:\n" |
|
printf './mailbox-migration-utils.sh \\\n' |
|
printf "\tsync -u|--user user -r|--remote-host host\n" |
|
printf "\tsync [-h|--help]\n" |
|
} |
|
|
|
# A function to safely delete the failure message file |
|
function delete_failure_txt() { |
|
# The :? here serves as a check for undefined variable |
|
# If the variable is undefined, fail |
|
local -r l_file="${log_directory:?}/failure.txt" |
|
# Checks if the file actually exists before attempting to delete it |
|
if [[ -f "${l_file}" ]]; then |
|
rm -f "${l_file}" |
|
return |
|
else |
|
printf "Attempted delete of %s, but the file does not exist!\n" "${l_file}" >&2 |
|
return 1 |
|
fi |
|
} |
|
|
|
function sync_worker() { |
|
# Output additional info |
|
# Since the output of the entire sync_worker() is later being redirected into a file, |
|
# it'll serve as annotations for the logs |
|
printf "Beginning sync for user %s on " "${user}" |
|
date |
|
printf "\n" |
|
|
|
local -i exit_status=0 |
|
|
|
# Measure the time it takes to sync the mailbox |
|
local TIMEFORMAT="Time spent processing: %R seconds." |
|
time { |
|
doveadm -c /etc/dovecot/dovecot-migration.conf \ |
|
-o imapc_host="${remote_host:?}" \ |
|
-o imapc_user="${user:?}" \ |
|
-o imapc_password="${password:?}" \ |
|
sync -R1u "${user:?}" imapc: |
|
|
|
# Store the 'doveadm' exit status before 'time' overwrites it |
|
exit_status=${?} |
|
|
|
# Based on 'dsync' exit statuses |
|
# See 'man dsync' for more info |
|
if ((exit_status == 0)); then |
|
printf "Synchronisation was done perfectly.\n" |
|
elif ((exit_status == 2)); then |
|
printf "Synchronisation was done without errors, but some changes couldn't be done, so the mailboxes aren't perfectly synchronised.\n" |
|
printf "This occurs if one of the mailboxes changes during the syncing.\n" |
|
printf "To solve this, run the 'sync' command for the same user again.\n" |
|
elif ((exit_status == 1 || exit_status > 2)); then |
|
printf "Synchronisation failed.\n" |
|
echo -n "${user:?}" >"${log_directory:?}/failure.txt" |
|
fi |
|
} |
|
|
|
# The return the original exit status |
|
return "${exit_status}" |
|
} |
|
|
|
local user |
|
local remote_host |
|
local help_flag="false" |
|
|
|
# Parse the arguments using getopt (enchanced) |
|
# See 'man 1 getopt' and '/usr/share/doc/util-linux/getopt-example.bash' |
|
local temp |
|
temp=$(getopt -o 'u:r:h' --long 'user:,remote-host:,help' -n 'Mailbox Migration Utils' -- "$@") |
|
|
|
if (($? != 0)); then |
|
printf "%s\n" "${red}Internal error while parsing arguments.${nc}" >&2 |
|
exit 1 |
|
fi |
|
|
|
eval set -- "$temp" |
|
unset temp |
|
|
|
while true; do |
|
case "${1}" in |
|
"-u" | "--user") |
|
user="${2}" |
|
shift 2 |
|
continue |
|
;; |
|
"-r" | "--remote-host") |
|
remote_host="${2}" |
|
shift 2 |
|
continue |
|
;; |
|
"-h" | "--help") |
|
help_flag="true" |
|
shift |
|
continue |
|
;; |
|
"--") |
|
shift |
|
break |
|
;; |
|
*) |
|
printf "%s\n" "${red}Internal error while parsing arguments.${nc}" >&2 |
|
exit 1 |
|
;; |
|
esac |
|
done |
|
|
|
# Declare the parsed variables read-only |
|
# This ensures they're never accidentally changed or unset |
|
declare -r user |
|
declare -r remote_host |
|
|
|
# If help was set, print usage and exit cleanly |
|
if [[ "${help_flag}" == "true" ]]; then |
|
print_usage |
|
exit 0 |
|
fi |
|
|
|
# If either user or remote_host are unspecified, print usage and exit |
|
if [[ -z "${user}" || -z "${remote_host}" ]]; then |
|
printf "%s\n" "${red}Missing arguments!${nc}" |
|
print_usage |
|
exit 1 |
|
fi |
|
|
|
# Check if the migration configuration file exists |
|
if [[ ! -f "/etc/dovecot/dovecot-migration.conf" ]]; then |
|
printf "%s\n" "${red}The migration configuration file does not exist!${nc}" >&2 |
|
printf "Aborting...\n" >&2 |
|
exit 2 |
|
fi |
|
|
|
# Check if doveadm can actually find the local user before going further |
|
if ! doveadm user "${user}" &>/dev/null; then |
|
printf "%s\n" "${red}User not found in the local Dovecot user database.${nc}" >&2 |
|
printf "Aborting...\n" >&2 |
|
exit 10 |
|
fi |
|
|
|
# Set to the path to the mailbox and the log directory |
|
# Make both values read-only |
|
local -r mail_directory="/var/vmail/${user:?}" |
|
local -r log_directory="/tmp/mailbox-migration" |
|
|
|
if [[ ! -d "${log_directory}" ]]; then |
|
printf "The log directory %s does not exist!\n" "${log_directory}" |
|
printf "Attempting to create it...\n" |
|
if mkdir "${log_directory}"; then |
|
printf "Directory created succesfully.\n" |
|
else |
|
printf "%s\n" "${red}Could not create the directory.${nc}" |
|
printf "Aborting...\n" |
|
exit 2 |
|
fi |
|
fi |
|
|
|
# Check if past sync failed |
|
# Warn the user about overwriting logs, if they wish to continue |
|
if [[ -f "${log_directory}/failure.txt" ]]; then |
|
printf "%s\n" "${red}The last synchronisation failed!${nc}" |
|
if prompt_confirmation "Are you sure you want to continue? This can permanently overwrite past logs!"; then |
|
delete_failure_txt |
|
else |
|
exit 11 |
|
fi |
|
fi |
|
|
|
while true; do |
|
local password |
|
|
|
# Read the password without printfing it back to the shell |
|
read -rs -p "Password for ${user}: " password |
|
printf "\n" # Extra newline is required here because read relies on the newline from enter - but echo is disabled |
|
|
|
# Spawn the syncworker function as a background job and immediately disown it |
|
# This will cause the job to not be terminated in case the current shell gets closed for any reason |
|
sync_worker &>"${log_directory}/${user}.log" & |
|
disown |
|
|
|
# Wait for the disowned process to finish |
|
# Play an animation of changing dots to signal the script hasn't frozen |
|
local -i pid=${!} |
|
local -i step=1 |
|
local status_string="Syncing mailbox for ${user}" |
|
|
|
while kill -0 "${pid}" &>/dev/null; do |
|
# Move cursor at the start of the line and delete it |
|
tput hpa 0 && tput el |
|
|
|
# Print the base string |
|
printf "%s" "${status_string}" |
|
|
|
# Print the required number of dots |
|
# This is true, cursed bash black magic... please don't learn from this... |
|
printf ".%.0s" $(eval "echo {1..${step}}") |
|
|
|
# Increment the counter |
|
((step++)) |
|
|
|
# The max number of dots can be adjusted by the upper limit of the step variable |
|
if ((step > 3)); then |
|
step=1 |
|
fi |
|
|
|
# Sleep for half a second |
|
sleep 0.5 |
|
done |
|
|
|
# Change the prompt one last time before writing the final status |
|
tput hpa 0 && tput el |
|
|
|
# Check if the process failed and attempt it again, if so |
|
if [[ -f "${log_directory}/failure.txt" && $(cat ${log_directory}/failure.txt) == "${user}" ]]; then |
|
printf "%s\n" "${red}${status_string}... failed!${nc}" |
|
printf "Possibly an authentication error?\n" |
|
|
|
if prompt_confirmation "Retry?"; then |
|
# All deletes are done with a plethora of checks |
|
# Better safe than sorry |
|
delete_failure_txt |
|
else |
|
exit 11 |
|
fi |
|
else |
|
printf "%s\n" "${green}${status_string}... done!${nc}" |
|
break |
|
fi |
|
done |
|
|
|
# Wrap up with a summary (and an advice how to check the copied mails |
|
printf "Finished syncing the mailbox for %s. New size: %s\n" "${user}" "$(du -c --si "${mail_directory}" | tail -1 | awk '{print $1}')" |
|
|
|
exit 0 |
|
} |
|
|
|
# Backs up a local mailbox to another place |
|
function backup() { |
|
# Print the backup command's usage |
|
function print_usage() { |
|
printf "Usage:\n" |
|
printf './mailbox-migration-utils.sh \\\n' |
|
printf "\tbackup %s\n" "${red}Not yet implemented!${nc}" |
|
} |
|
|
|
exit 1 |
|
} |
|
|
|
# Prints the top script usage |
|
function print_usage() { |
|
printf "Available commands:\n" |
|
printf "\t./mailbox-migration-utils.sh download ... [-h|--help]\n" |
|
printf "\t./mailbox-migration-utils.sh sync ... [-h|--help]\n" |
|
printf "\t./mailbox-migration-utils.sh backup ... [-h|--help]\n" |
|
printf "\t./mailbox-migration-utils.sh --version\n" |
|
printf "\t./mailbox-migration-utils.sh --help\n" |
|
} |
|
|
|
# Prints the program's version |
|
function print_version() { |
|
printf "Better Mail Sync Wrapper for Dovecot, v%s\n" "${version}" |
|
} |
|
|
|
# Declare the command variable read-only |
|
# Populate it with the first argument... this is notoriously unsafe (should use something like getopt), |
|
# but until I figure out how getopt implements subparsers, this will have to do. |
|
declare -r command="${1}" |
|
# Shift away the first argument (so the rest could be passed to the local functions) |
|
shift |
|
# Call the appropriate command |
|
# The 'version' command is hard-coded for now as getopt is struggling with |
|
# subparsers for now (or maybe I'm just doing it wrong) |
|
case "${command}" in |
|
"download") |
|
download "${@}" |
|
;; |
|
"sync") |
|
sync "${@}" |
|
;; |
|
"backup") |
|
backup "${@}" |
|
;; |
|
"version" | "--version") |
|
print_version |
|
exit 0 |
|
;; |
|
"--help") |
|
print_usage |
|
exit 0 |
|
;; |
|
*) |
|
printf "%s\n" "${red}Unknown command!${nc}" |
|
print_usage |
|
exit 1 |
|
;; |
|
esac |