async function handleMessage(settings, message, sendResponse) {
    // check that action is present
    if (typeof message !== "object" || !message.hasOwnProperty("action")) {
        sendResponse({ status: "error", message: "Action is missing" });
        return;
    }

    // fetch file & parse fields if a login entry is present
    try {
        // do not fetch file for new login entries
        if (typeof message.login !== "undefined" && message.action != "add") {
            await parseFields(settings, message.login);
        }
    } catch (e) {
        sendResponse({
            status: "error",
            message: "Unable to fetch and parse login fields: " + e.toString(),
        });
        return;
    }

    // route action
    switch (message.action) {
        case "getSettings":
            sendResponse({
                status: "ok",
                settings: settings,
            });
            break;
        case "saveSettings":
            try {
                await saveSettings(message.settings);
                sendResponse({ status: "ok" });
            } catch (e) {
                sendResponse({
                    status: "error",
                    message: e.message,
                });
            }
            break;
        case "listFiles":
            try {
                var response = await hostAction(settings, "list");
                if (response.status != "ok") {
                    throw new Error(JSON.stringify(response)); // TODO handle host error
                }
                let files = helpers.ignoreFiles(response.data.files, settings);
                sendResponse({ status: "ok", files });
            } catch (e) {
                sendResponse({
                    status: "error",
                    message: "Unable to enumerate password files. " + e.toString(),
                });
            }
            break;
        case "listDirs":
            try {
                var response = await hostAction(settings, "tree");
                if (response.status != "ok") {
                    throw new Error(JSON.stringify(response));
                }
                let dirs = response.data.directories;
                sendResponse({ status: "ok", dirs });
            } catch (e) {
                sendResponse({
                    status: "error",
                    message: "Unable to enumerate directory trees. " + e.toString(),
                });
            }
            break;
        case "add":
        case "save":
            try {
                var response = await hostAction(settings, "save", {
                    storeId: message.login.store.id,
                    file: `${message.login.login}.gpg`,
                    contents: message.params.rawContents,
                });

                if (response.status != "ok") {
                    alert(`Save failed: ${response.params.message}`);
                    throw new Error(JSON.stringify(response)); // TODO handle host error
                }
                sendResponse({ status: "ok" });
            } catch (e) {
                sendResponse({
                    status: "error",
                    message: "Unable to save password file" + e.toString(),
                });
            }
            break;
        case "delete":
            try {
                var response = await hostAction(settings, "delete", {
                    storeId: message.login.store.id,
                    file: `${message.login.login}.gpg`,
                });

                if (response.status != "ok") {
                    alert(`Delete failed: ${response.params.message}`);
                    throw new Error(JSON.stringify(response));
                }
                sendResponse({ status: "ok" });
            } catch (e) {
                sendResponse({
                    status: "error",
                    message: "Unable to delete password file" + e.toString(),
                });
            }
            break;
        case "copyPassword":
            try {
                await copyToClipboard(message.login.fields.secret);
                await saveRecent(settings, message.login);
                sendResponse({ status: "ok" });
            } catch (e) {
                sendResponse({
                    status: "error",
                    message: "Unable to copy password",
                });
            }
            break;
        case "copyUsername":
            try {
                await copyToClipboard(message.login.fields.login);
                await saveRecent(settings, message.login);
                sendResponse({ status: "ok" });
            } catch (e) {
                sendResponse({
                    status: "error",
                    message: "Unable to copy username",
                });
            }
            break;
        case "copyOTP":
            if (helpers.getSetting("enableOTP", message.login, settings)) {
                try {
                    if (!message.login.fields.otp) {
                        throw new Exception("No OTP seed available");
                    }
                    await copyToClipboard(helpers.makeTOTP(message.login.fields.otp.params));
                    sendResponse({ status: "ok" });
                } catch (e) {
                    sendResponse({
                        status: "error",
                        message: "Unable to copy OTP token",
                    });
                }
            } else {
                sendResponse({ status: "error", message: "OTP support is disabled" });
            }
            break;

        case "getDetails":
            sendResponse({ status: "ok", login: message.login });
            break;

        case "launch":
        case "launchInNewTab":
            try {
                var url = message.login.fields.url || message.login.host;
                if (!url) {
                    throw new Error("No URL is defined for this entry");
                }
                if (!url.match(/:\/\//)) {
                    url = "http://" + url;
                }

                const tab =
                    message.action === "launch"
                        ? await chrome.tabs.update(settings.tab.id, { url: url })
                        : await chrome.tabs.create({ url: url });

                sendResponse({ status: "ok" });
            } catch (e) {
                sendResponse({
                    status: "error",
                    message: "Unable to launch URL: " + e.toString(),
                });
            }
            break;
        case "fill":
            try {
                let fields = message.login.fields.openid ? ["openid"] : ["login", "secret"];

                if (settings.authRequested) {
                    resolveAuthRequest(
                        {
                            authCredentials: {
                                username: message.login.fields.login,
                                password: message.login.fields.secret,
                            },
                        },
                        settings.tab.url
                    );
                    await saveRecent(settings, message.login);
                    sendResponse({ status: "ok" });
                    break;
                } else {
                    // dispatch initial fill request
                    var filledFields = await fillFields(settings, message.login, fields);
                    await saveRecent(settings, message.login);
                }

                // no need to check filledFields, because fillFields() already throws an error if empty
                sendResponse({ status: "ok", filledFields: filledFields });

                // copy OTP token after fill
                if (
                    typeof message.login !== "undefined" &&
                    helpers.getSetting("enableOTP", message.login, settings) &&
                    message.login.fields.hasOwnProperty("otp")
                ) {
                    await copyToClipboard(helpers.makeTOTP(message.login.fields.otp.params));
                }
            } catch (e) {
                try {
                    sendResponse({
                        status: "error",
                        message: e.toString(),
                    });
                } catch (e) {
                    // TODO An error here is typically a closed message port, due to a popup taking focus
                    // away from the extension menu and the menu closing as a result. Need to investigate
                    // whether triggering the extension menu from the background script is possible.
                    console.log(e);
                }
            }
            break;
        case "clearUsageData":
            try {
                await clearUsageData();
                sendResponse({ status: "ok" });
            } catch (e) {
                sendResponse({
                    status: "error",
                    message: e.message,
                });
            }
            break;
        default:
            sendResponse({
                status: "error",
                message: "Unknown action: " + message.action,
            });
            break;
    }
}
Handle a message from elsewhere within the extension

@since 3.0.0

@param object          settings     Settings object
@param mixed           message      Incoming message
@param function(mixed) sendResponse Callback to send response
@return void