/** @file
 * @brief Extract text and metadata using gmime.
 */
/* Copyright (C) 2019 Bruno Baruffaldi
 * Copyright (C) 2022,2023 Olly Betts
 *
 * 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.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, see
 * <https://www.gnu.org/licenses/>.
 */
#include <config.h>
#include "handler.h"

#include "htmlparser.h"
#include "str.h"
#include "stringutils.h"
#include "utf8convert.h"

#include <glib.h>
#include <gmime/gmime.h>
#include <string.h>

#include "safefcntl.h"

using namespace std;

static string
get_attachment_dir()
{
    string result;
    const char* p = getenv("XAPIAN_OMEGA_ATTACHMENT_DIR");
    if (p && *p) {
        result = p;
        if (result.back() != '/') {
            result += '/';
        }
    }
    return result;
}

/** Directory to save attachments into.
 *
 *  If empty, attachments are not saved.
 */
static string attachment_dir = get_attachment_dir();

/** Counter used to ensure saved attachments have unique names.
 *
 *  Reset to 0 for each email processed.
 */
static unsigned attachment_counter;

/** To aid debugging attachment filenames from emails are used in many cases.
 *
 *  This constant limits how long an attachment leafname can be.
 */
static constexpr size_t MAX_ATTACHMENT_LEAF_LEN = 64;

/** To aid debugging attachment filenames from emails are used in many cases.
 *
 *  This constant limits how long a file extension can be.
 */
static constexpr size_t MAX_ATTACHMENT_EXT_LEN = 14;

/** To aid debugging attachment filenames from emails are used in many cases.
 *
 *  If there's no supplied filename or it isn't suitable then this basename
 *  is used instead (potentially with the extension if there's a supplied
 *  name).
 */
static constexpr const char* FALLBACK_ATTACHMENT_BASENAME = "attachment";

/** Buffer size used when reading and decoding MIME data. */
static constexpr unsigned SIZE = 4096;

static void
extract_html(const string& text, string& charset)
{
    HtmlParser parser;
    if (charset.empty())
        charset = "UTF-8";
    try {
        parser.ignore_metarobots();
        parser.parse(text, charset, false);
    } catch (const string& newcharset) {
        parser.reset();
        parser.ignore_metarobots();
        parser.parse(text, newcharset, true);
    }
    send_field(FIELD_BODY, parser.dump);
}

static std::string
decode(GMimeDataWrapper* content, GMimeContentEncoding content_encoding)
{
    string result;
    char buffer[SIZE];
    unsigned char data[SIZE];
    auto u_buff = reinterpret_cast<unsigned char*>(buffer);
    GMimeStream* sr = g_mime_data_wrapper_get_stream(content);
    int state = 0;
    guint32 save = 0;
    int len;
    switch (content_encoding) {
        case GMIME_CONTENT_ENCODING_BASE64:
            while ((len = g_mime_stream_read(sr, buffer, SIZE)) > 0) {
                len = g_mime_encoding_base64_decode_step(u_buff, len, data,
                                                         &state, &save);
                result.append(reinterpret_cast<char*>(data), len);
            }
            break;
        case GMIME_CONTENT_ENCODING_UUENCODE:
            while ((len = g_mime_stream_read(sr, buffer, SIZE)) > 0) {
                len = g_mime_encoding_uudecode_step(u_buff, len, data,
                                                    &state, &save);
                result.append(reinterpret_cast<char*>(data), len);
            }
            break;
        case GMIME_CONTENT_ENCODING_QUOTEDPRINTABLE:
            while ((len = g_mime_stream_read(sr, buffer, SIZE)) > 0) {
                len = g_mime_encoding_quoted_decode_step(u_buff, len, data,
                                                         &state, &save);
                result.append(reinterpret_cast<char*>(data), len);
            }
            break;
        default:
            while ((len = g_mime_stream_read(sr, buffer, SIZE)) > 0) {
                result.append(reinterpret_cast<char*>(buffer), len);
            }
            break;
    }
    return result;
}

static bool
parse_mime_part(GMimePart* part,
                GMimeContentType* ct,
                bool attachments)
{
    const char* type = g_mime_content_type_get_media_type(ct);
    const char* subtype = g_mime_content_type_get_media_subtype(ct);
    enum { OTHER = 0, TEXT_PLAIN, TEXT_HTML } t = OTHER;
    if (g_ascii_strcasecmp(type, "text") == 0) {
        if (g_ascii_strcasecmp(subtype, "plain") == 0) {
            t = TEXT_PLAIN;
        } else if (g_ascii_strcasecmp(subtype, "html") == 0) {
            t = TEXT_HTML;
        }
    }

    if (t == OTHER && !attachments) {
        // We're not interested in this MIME part.
        return false;
    }

#if GMIME_MAJOR_VERSION >= 3
    GMimeDataWrapper* content = g_mime_part_get_content(part);
#else
    GMimeDataWrapper* content = g_mime_part_get_content_object(part);
#endif
    if (!content) return false;

    string data = decode(content, g_mime_part_get_content_encoding(part));
    if (t == TEXT_PLAIN) {
        // Always convert text/plain parts to UTF-8 (doesn't matter whether
        // we're sending as a field or saving as an attachment).
        const char* charset = g_mime_content_type_get_parameter(ct, "charset");
        if (charset) {
            charset = g_mime_charset_canon_name(charset);
            convert_to_utf8(data, charset);
        }
    }

    bool is_attachment = g_mime_part_is_attachment(part);
    // If we're not saving attachments, treat all text/plain and text/html
    // parts as inline.
    if (t != OTHER && (!attachments || !is_attachment)) {
        if (t == TEXT_PLAIN) {
            // text/plain
            send_field(FIELD_BODY, data);
        } else {
            // text/html
            string charset;
            const char* p = g_mime_content_type_get_parameter(ct, "charset");
            if (p) {
                charset = g_mime_charset_canon_name(p);
            }
            extract_html(data, charset);
        }
        return true;
    }

    // Save attachment.
    string filename = attachment_dir;
    // Prefix with `a` for attachment, `i` for inline.
    filename += (is_attachment ? 'a' : 'i');
    filename += str(attachment_counter++);
    filename += '-';

    // It's much easier to debug if the extracted files can be easily
    // matched up with the attachments in the email so using any
    // supplied filename is good, but we need to keep security concerns
    // in mind.
    const char* leaf = g_mime_part_get_filename(part);
    if (!leaf)
        leaf = g_mime_content_type_get_parameter(ct, "filename");
    if (leaf) {
        // Remove any path.
        const char* slash = strrchr(leaf, '/');
        if (slash) leaf = slash + 1;
        slash = strrchr(leaf, '\\');
        if (slash) leaf = slash + 1;

        size_t len = strlen(leaf);
        if (len < 3 || len > MAX_ATTACHMENT_LEAF_LEN || leaf[0] == '.') {
            // Don't use the leafname as is, but use the extension if it's
            // sensible.
            leaf = strrchr(leaf + 1, '.');
            if (leaf && strlen(leaf + 1) <= MAX_ATTACHMENT_EXT_LEN) {
                // Use the extension.
            } else {
                // Don't use the leafname at all.
                leaf = nullptr;
            }
        }
    }

    if (!leaf || leaf[0] == '.') {
        filename += FALLBACK_ATTACHMENT_BASENAME;
    }
    if (leaf) {
        for (size_t i = 0; leaf[i]; ++i) {
            char ch = leaf[i];
            // Only allow clearly safe characters.
            if (C_isalnum(ch) || ch == '.' || ch == '-' || ch == '+')
                filename += ch;
            else
                filename += '_';
        }
    }

    // This ends up ignoring the supplied Content-Type.  If the extracted
    // attachments are fed back into the indexer then this will end up
    // determining the MIME Content-Type from the extension or file
    // contents instead.
    //
    // It seems odd to ignore the supplied Content-Type, but in practice
    // the supplied MIME type is likely actually determined by the sending
    // MUA from the extension and/or file contents or is just set to
    // `application/octet-stream`, so arguably it's better to determine the
    // type for ourselves in a consistent way.  Otherwise the exact same
    // attachment could be indexed or not depending who sent it.
    int fd = open(filename.c_str(),
                  O_CREAT | O_EXCL | O_WRONLY | O_BINARY,
                  0664);
    const char* p = data.data();
    size_t count = data.size();
    while (count) {
        ssize_t r = write(fd, p, count);
        if (rare(r < 0)) {
            if (errno == EINTR) continue;
            close(fd);
            unlink(filename.c_str());
            send_field(FIELD_ERROR, "saving attachment failed");
            return false;
        }
        p += r;
        count -= r;
    }
    close(fd);
    send_field(FIELD_ATTACHMENT, filename);
    return false;
}

static bool
parse_mime_object(GMimeObject* me, bool attachments)
{
    GMimeContentType* ct = g_mime_object_get_content_type(me);
    if (GMIME_IS_PART(me)) {
        return parse_mime_part(reinterpret_cast<GMimePart*>(me),
                               ct,
                               attachments);
    }

    if (!GMIME_IS_MULTIPART(me))
        return false;

    GMimeMultipart* mpart = reinterpret_cast<GMimeMultipart*>(me);
    const char* subtype = g_mime_content_type_get_media_subtype(ct);
    int count = g_mime_multipart_get_count(mpart);
    if (g_ascii_strcasecmp(subtype, "alternative") == 0) {
        // Use the last MIME part which we get text from.
        for (int i = count - 1; i >= 0; --i) {
            GMimeObject* part = g_mime_multipart_get_part(mpart, i);
            // Don't consider parts under an alternative as attachments.
            if (parse_mime_object(part, false))
                return true;
        }
        return false;
    }

    bool ret = false;
    for (int i = 0; i < count; ++i) {
        GMimeObject* part = g_mime_multipart_get_part(mpart, i);
        ret |= parse_mime_object(part, attachments);
    }
    return ret;
}

static void
send_glib_field(Field field, gchar* data)
{
    if (data) {
        send_field(field, data);
        g_free(data);
    }
}

static void
extract_addresses(Field field, InternetAddressList* address_list)
{
#if GMIME_MAJOR_VERSION >= 3
    auto value = internet_address_list_to_string(address_list, NULL, false);
#else
    auto value = internet_address_list_to_string(address_list, false);
#endif
    send_glib_field(field, value);
}

static void
extract_message(GMimeMessage* message)
{
    attachment_counter = 0;
    (void)parse_mime_object(g_mime_message_get_mime_part(message),
                            !attachment_dir.empty());
    send_field(FIELD_TITLE, g_mime_message_get_subject(message));

#if GMIME_MAJOR_VERSION >= 3
    extract_addresses(FIELD_AUTHOR, g_mime_message_get_from(message));
#else
    send_field(FIELD_AUTHOR, g_mime_message_get_sender(message));
#endif
#if GMIME_MAJOR_VERSION >= 3
    extract_addresses(FIELD_TO, g_mime_message_get_to(message));
#else
    extract_addresses(FIELD_TO,
                      g_mime_message_get_recipients(message,
                                                    GMIME_RECIPIENT_TYPE_TO));
#endif
#if GMIME_MAJOR_VERSION >= 3
    extract_addresses(FIELD_CC, g_mime_message_get_cc(message));
#else
    extract_addresses(FIELD_CC,
                      g_mime_message_get_recipients(message,
                                                    GMIME_RECIPIENT_TYPE_CC));
#endif
#if GMIME_MAJOR_VERSION >= 3
    extract_addresses(FIELD_BCC, g_mime_message_get_bcc(message));
#else
    extract_addresses(FIELD_BCC,
                      g_mime_message_get_recipients(message,
                                                    GMIME_RECIPIENT_TYPE_BCC));
#endif
#if GMIME_MAJOR_VERSION >= 3
    GDateTime* datetime = g_mime_message_get_date(message);
    if (datetime) {
        GDateTime* utc_datetime = g_date_time_to_utc(datetime);
        if (utc_datetime) {
            gint64 unix_time = g_date_time_to_unix(utc_datetime);
            // Check value doesn't overflow time_t.
            if (gint64(time_t(unix_time)) == unix_time) {
                send_field_created_date(time_t(unix_time));
            }
            g_date_time_unref(utc_datetime);
        }
    }
#else
    time_t datetime;
    int tz_offset;
    g_mime_message_get_date(message, &datetime, &tz_offset);
    if (datetime != time_t(-1)) {
        // The documentation doesn't clearly say, but from testing the
        // time_t value is in UTC which is what we want so we don't need
        // tz_offset.
        //
        // (If we did, tz_offset is not actually in hours as the docs say,
        // but actually hours*100+minutes, e.g. +1300 for UTC+13).
        send_field_created_date(datetime);
    }
#endif
    send_field(FIELD_MESSAGE_ID, g_mime_message_get_message_id(message));
    GMimeObject* object = GMIME_OBJECT(message);
    GMimeHeaderList* headers = g_mime_object_get_header_list(object);
#if GMIME_MAJOR_VERSION >= 3
    int count = g_mime_header_list_get_count(headers);
    for (int i = 0; i < count; ++i) {
        GMimeHeader* header = g_mime_header_list_get_header_at(headers, i);
        auto name = g_mime_header_get_name(header);
# define value g_mime_header_get_value(header)
#else
    g_mime_header_list_foreach(headers,
                               [](const char* name,
                                  const char* value,
                                  gpointer) {
#endif
        if (g_ascii_strcasecmp(name, "Comments") == 0 ||
            g_ascii_strcasecmp(name, "Keywords") == 0) {
            send_field(FIELD_KEYWORDS, value);
        }
#if GMIME_MAJOR_VERSION >= 3
    }
# undef value
#else
                               }, nullptr);
#endif
}

bool
initialise(string&)
{
#if GMIME_MAJOR_VERSION >= 3
    g_mime_init();
#else
    g_mime_init(GMIME_ENABLE_RFC2047_WORKAROUNDS);
#endif
    return true;
}

void
extract(const string& filename, const string&)
{
    FILE* fp = fopen(filename.c_str(), "r");

    if (fp == NULL) {
        send_field(FIELD_ERROR, "fopen() failed");
        return;
    }

    GMimeStream* stream = g_mime_stream_file_new(fp);
    GMimeParser* parser = g_mime_parser_new_with_stream(stream);
#if GMIME_MAJOR_VERSION >= 3
    GMimeMessage* message = g_mime_parser_construct_message(parser, NULL);
#else
    GMimeMessage* message = g_mime_parser_construct_message(parser);
#endif
    if (message) {
        extract_message(message);
        g_object_unref(message);
    }
    g_object_unref(parser);
    g_object_unref(stream);
}
