diff options
-rw-r--r-- | Base/res/apps/Mail.af | 4 | ||||
-rw-r--r-- | Base/res/icons/16x16/app-mail.png | bin | 0 -> 155 bytes | |||
-rw-r--r-- | Base/res/icons/32x32/app-mail.png | bin | 0 -> 237 bytes | |||
-rw-r--r-- | Userland/Applications/CMakeLists.txt | 1 | ||||
-rw-r--r-- | Userland/Applications/Mail/AccountHolder.cpp | 79 | ||||
-rw-r--r-- | Userland/Applications/Mail/AccountHolder.h | 108 | ||||
-rw-r--r-- | Userland/Applications/Mail/CMakeLists.txt | 19 | ||||
-rw-r--r-- | Userland/Applications/Mail/InboxModel.cpp | 50 | ||||
-rw-r--r-- | Userland/Applications/Mail/InboxModel.h | 42 | ||||
-rw-r--r-- | Userland/Applications/Mail/MailWidget.cpp | 505 | ||||
-rw-r--r-- | Userland/Applications/Mail/MailWidget.h | 57 | ||||
-rw-r--r-- | Userland/Applications/Mail/MailWindow.gml | 28 | ||||
-rw-r--r-- | Userland/Applications/Mail/MailboxTreeModel.cpp | 120 | ||||
-rw-r--r-- | Userland/Applications/Mail/MailboxTreeModel.h | 38 | ||||
-rw-r--r-- | Userland/Applications/Mail/main.cpp | 62 |
15 files changed, 1113 insertions, 0 deletions
diff --git a/Base/res/apps/Mail.af b/Base/res/apps/Mail.af new file mode 100644 index 0000000000..545d91fdd1 --- /dev/null +++ b/Base/res/apps/Mail.af @@ -0,0 +1,4 @@ +[App] +Name=Mail +Executable=/bin/Mail +Category=Internet diff --git a/Base/res/icons/16x16/app-mail.png b/Base/res/icons/16x16/app-mail.png Binary files differnew file mode 100644 index 0000000000..197a0c4f46 --- /dev/null +++ b/Base/res/icons/16x16/app-mail.png diff --git a/Base/res/icons/32x32/app-mail.png b/Base/res/icons/32x32/app-mail.png Binary files differnew file mode 100644 index 0000000000..b9eca40226 --- /dev/null +++ b/Base/res/icons/32x32/app-mail.png diff --git a/Userland/Applications/CMakeLists.txt b/Userland/Applications/CMakeLists.txt index 1e3ab8ded4..63fc2fc093 100644 --- a/Userland/Applications/CMakeLists.txt +++ b/Userland/Applications/CMakeLists.txt @@ -17,6 +17,7 @@ add_subdirectory(ImageViewer) add_subdirectory(KeyboardMapper) add_subdirectory(KeyboardSettings) add_subdirectory(Magnifier) +add_subdirectory(Mail) add_subdirectory(MouseSettings) add_subdirectory(PDFViewer) add_subdirectory(Piano) diff --git a/Userland/Applications/Mail/AccountHolder.cpp b/Userland/Applications/Mail/AccountHolder.cpp new file mode 100644 index 0000000000..8226046427 --- /dev/null +++ b/Userland/Applications/Mail/AccountHolder.cpp @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2021, Luke Wilde <lukew@serenityos.org> + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "AccountHolder.h" + +AccountHolder::AccountHolder() +{ + m_mailbox_tree_model = MailboxTreeModel::create(*this); +} + +AccountHolder::~AccountHolder() +{ +} + +void AccountHolder::add_account_with_name_and_mailboxes(String name, Vector<IMAP::ListItem> const& mailboxes) +{ + auto account = AccountNode::create(move(name)); + + // This holds all of the ancestors of the current leaf folder. + NonnullRefPtrVector<MailboxNode> folder_stack; + + for (auto& mailbox : mailboxes) { + // mailbox.name is converted to StringView to get access to split by string. + auto subfolders = StringView(mailbox.name).split_view(mailbox.reference); + + // Use the last part of the path as the display name. + // For example: "[Mail]/Subfolder" will be displayed as "Subfolder" + auto mailbox_node = MailboxNode::create(account, mailbox, subfolders.last()); + + if (subfolders.size() > 1) { + VERIFY(!folder_stack.is_empty()); + + // This gets the parent folder of the leaf folder that we just created above. + // For example, with "[Mail]/Subfolder/Leaf", "subfolders" will have three items: + // - "[Mail]" at index 0. + // - "Subfolder" at index 1. This is the parent folder of the leaf folder. + // - "Leaf" at index 2. This is the leaf folder. + // Notice that the parent folder is always two below the size of "subfolders". + // This assumes that there was two listings before this, in this exact order: + // 1. "[Mail]" + // 2. "[Mail]/Subfolder" + auto& parent_folder = folder_stack.at(subfolders.size() - 2); + + // Only keep the ancestors of the current leaf folder. + folder_stack.shrink(subfolders.size() - 1); + + parent_folder.add_child(mailbox_node); + VERIFY(!mailbox_node->has_parent()); + mailbox_node->set_parent(parent_folder); + + // FIXME: This assumes that the server has the "CHILDREN" capability. + if (mailbox.flags & (unsigned)IMAP::MailboxFlag::HasChildren) + folder_stack.append(mailbox_node); + } else { + // FIXME: This assumes that the server has the "CHILDREN" capability. + if (mailbox.flags & (unsigned)IMAP::MailboxFlag::HasChildren) { + if (!folder_stack.is_empty() && folder_stack.first().select_name() != mailbox.name) { + // This is a new root folder, clear the stack as there are no ancestors of the current leaf folder at this point. + folder_stack.clear(); + } + + folder_stack.append(mailbox_node); + } + + account->add_mailbox(move(mailbox_node)); + } + } + + m_accounts.append(move(account)); + rebuild_tree(); +} + +void AccountHolder::rebuild_tree() +{ + m_mailbox_tree_model->update(); +} diff --git a/Userland/Applications/Mail/AccountHolder.h b/Userland/Applications/Mail/AccountHolder.h new file mode 100644 index 0000000000..56a134a15d --- /dev/null +++ b/Userland/Applications/Mail/AccountHolder.h @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2021, Luke Wilde <lukew@serenityos.org> + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "MailboxTreeModel.h" +#include <AK/NonnullRefPtrVector.h> +#include <AK/RefCounted.h> +#include <AK/String.h> +#include <LibIMAP/Objects.h> + +class BaseNode : public RefCounted<BaseNode> { +public: + virtual ~BaseNode() = default; +}; + +class MailboxNode; + +class AccountNode final : public BaseNode { +public: + static NonnullRefPtr<AccountNode> create(String name) + { + return adopt_ref(*new AccountNode(move(name))); + } + + virtual ~AccountNode() override = default; + + void add_mailbox(NonnullRefPtr<MailboxNode> mailbox) + { + m_mailboxes.append(move(mailbox)); + } + + NonnullRefPtrVector<MailboxNode> const& mailboxes() const { return m_mailboxes; } + String const& name() const { return m_name; } + +private: + explicit AccountNode(String name) + : m_name(move(name)) + { + } + + String m_name; + NonnullRefPtrVector<MailboxNode> m_mailboxes; +}; + +class MailboxNode final : public BaseNode { +public: + static NonnullRefPtr<MailboxNode> create(AccountNode const& associated_account, IMAP::ListItem const& mailbox, String display_name) + { + return adopt_ref(*new MailboxNode(associated_account, mailbox, move(display_name))); + } + + virtual ~MailboxNode() override = default; + + AccountNode const& associated_account() const { return m_associated_account; } + String const& select_name() const { return m_mailbox.name; } + String const& display_name() const { return m_display_name; } + IMAP::ListItem const& mailbox() const { return m_mailbox; } + + bool has_parent() const { return m_parent; } + RefPtr<MailboxNode> parent() const { return m_parent; } + void set_parent(NonnullRefPtr<MailboxNode> parent) { m_parent = parent; } + + bool has_children() const { return !m_children.is_empty(); } + NonnullRefPtrVector<MailboxNode> const& children() const { return m_children; } + void add_child(NonnullRefPtr<MailboxNode> child) { m_children.append(child); } + +private: + MailboxNode(AccountNode const& associated_account, IMAP::ListItem const& mailbox, String display_name) + : m_associated_account(associated_account) + , m_mailbox(mailbox) + , m_display_name(move(display_name)) + { + } + + AccountNode const& m_associated_account; + IMAP::ListItem m_mailbox; + String m_display_name; + + NonnullRefPtrVector<MailboxNode> m_children; + RefPtr<MailboxNode> m_parent; +}; + +class AccountHolder { +public: + ~AccountHolder(); + + static NonnullOwnPtr<AccountHolder> create() + { + return adopt_own(*new AccountHolder()); + } + + void add_account_with_name_and_mailboxes(String, Vector<IMAP::ListItem> const&); + + NonnullRefPtrVector<AccountNode> const& accounts() const { return m_accounts; } + MailboxTreeModel& mailbox_tree_model() { return *m_mailbox_tree_model; } + +private: + AccountHolder(); + + void rebuild_tree(); + + NonnullRefPtrVector<AccountNode> m_accounts; + RefPtr<MailboxTreeModel> m_mailbox_tree_model; +}; diff --git a/Userland/Applications/Mail/CMakeLists.txt b/Userland/Applications/Mail/CMakeLists.txt new file mode 100644 index 0000000000..6479aa3447 --- /dev/null +++ b/Userland/Applications/Mail/CMakeLists.txt @@ -0,0 +1,19 @@ +serenity_component( + Mail + RECOMMENDED + TARGETS Mail +) + +compile_gml(MailWindow.gml MailWindowGML.h mail_window_gml) + +set(SOURCES + AccountHolder.cpp + InboxModel.cpp + MailboxTreeModel.cpp + MailWidget.cpp + MailWindowGML.h + main.cpp +) + +serenity_app(Mail ICON app-mail) +target_link_libraries(Mail LibCore LibDesktop LibGfx LibGUI LibIMAP LibWeb) diff --git a/Userland/Applications/Mail/InboxModel.cpp b/Userland/Applications/Mail/InboxModel.cpp new file mode 100644 index 0000000000..bfac9a94e3 --- /dev/null +++ b/Userland/Applications/Mail/InboxModel.cpp @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2021, Luke Wilde <lukew@serenityos.org> + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "InboxModel.h" + +InboxModel::InboxModel(Vector<InboxEntry> entries) + : m_entries(move(entries)) +{ +} + +InboxModel::~InboxModel() +{ +} + +int InboxModel::row_count(GUI::ModelIndex const&) const +{ + return m_entries.size(); +} + +String InboxModel::column_name(int column_index) const +{ + switch (column_index) { + case Column::From: + return "From"; + case Subject: + return "Subject"; + default: + VERIFY_NOT_REACHED(); + } +} + +GUI::Variant InboxModel::data(GUI::ModelIndex const& index, GUI::ModelRole role) const +{ + auto& value = m_entries[index.row()]; + if (role == GUI::ModelRole::Display) { + if (index.column() == Column::From) + return value.from; + if (index.column() == Column::Subject) + return value.subject; + } + return {}; +} + +void InboxModel::update() +{ + did_update(); +} diff --git a/Userland/Applications/Mail/InboxModel.h b/Userland/Applications/Mail/InboxModel.h new file mode 100644 index 0000000000..a2b6724de8 --- /dev/null +++ b/Userland/Applications/Mail/InboxModel.h @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2021, Luke Wilde <lukew@serenityos.org> + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include <LibGUI/Model.h> +#include <LibIMAP/Objects.h> + +struct InboxEntry { + String from; + String subject; +}; + +class InboxModel final : public GUI::Model { +public: + enum Column { + From, + Subject, + __Count + }; + + static NonnullRefPtr<InboxModel> create(Vector<InboxEntry> inbox_entries) + { + return adopt_ref(*new InboxModel(move(inbox_entries))); + } + + virtual ~InboxModel() override; + + virtual int row_count(const GUI::ModelIndex& = GUI::ModelIndex()) const override; + virtual int column_count(const GUI::ModelIndex& = GUI::ModelIndex()) const override { return Column::__Count; } + virtual String column_name(int) const override; + virtual GUI::Variant data(const GUI::ModelIndex&, GUI::ModelRole) const override; + virtual void update() override; + +private: + InboxModel(Vector<InboxEntry>); + + Vector<InboxEntry> m_entries; +}; diff --git a/Userland/Applications/Mail/MailWidget.cpp b/Userland/Applications/Mail/MailWidget.cpp new file mode 100644 index 0000000000..41e35a641d --- /dev/null +++ b/Userland/Applications/Mail/MailWidget.cpp @@ -0,0 +1,505 @@ +/* + * Copyright (c) 2021, Luke Wilde <lukew@serenityos.org> + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "MailWidget.h" +#include <AK/Base64.h> +#include <Applications/Mail/MailWindowGML.h> +#include <LibCore/ConfigFile.h> +#include <LibDesktop/Launcher.h> +#include <LibGUI/Action.h> +#include <LibGUI/Clipboard.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/Statusbar.h> +#include <LibGUI/TableView.h> +#include <LibGUI/TreeView.h> +#include <LibIMAP/QuotedPrintable.h> + +MailWidget::MailWidget() +{ + load_from_gml(mail_window_gml); + + m_mailbox_list = *find_descendant_of_type_named<GUI::TreeView>("mailbox_list"); + m_individual_mailbox_view = *find_descendant_of_type_named<GUI::TableView>("individual_mailbox_view"); + m_web_view = *find_descendant_of_type_named<Web::OutOfProcessWebView>("web_view"); + m_statusbar = *find_descendant_of_type_named<GUI::Statusbar>("statusbar"); + + m_mailbox_list->on_selection_change = [this] { + selected_mailbox(); + }; + + m_individual_mailbox_view->on_selection_change = [this] { + selected_email_to_load(); + }; + + m_web_view->on_link_click = [this](auto& url, auto&, unsigned) { + if (!Desktop::Launcher::open(url)) { + GUI::MessageBox::show( + window(), + String::formatted("The link to '{}' could not be opened.", url), + "Failed to open link", + GUI::MessageBox::Type::Error); + } + }; + + m_web_view->on_link_middle_click = [this](auto& url, auto& target, unsigned modifiers) { + m_web_view->on_link_click(url, target, modifiers); + }; + + m_web_view->on_link_hover = [this](auto& url) { + if (url.is_valid()) + m_statusbar->set_text(url.to_string()); + else + m_statusbar->set_text(""); + }; + + m_link_context_menu = GUI::Menu::construct(); + auto link_default_action = GUI::Action::create("&Open in Browser", [this](auto&) { + m_web_view->on_link_click(m_link_context_menu_url, "", 0); + }); + m_link_context_menu->add_action(link_default_action); + m_link_context_menu_default_action = link_default_action; + m_link_context_menu->add_separator(); + m_link_context_menu->add_action(GUI::Action::create("&Copy URL", [this](auto&) { + GUI::Clipboard::the().set_plain_text(m_link_context_menu_url.to_string()); + })); + + m_web_view->on_link_context_menu_request = [this](auto& url, auto& screen_position) { + m_link_context_menu_url = url; + m_link_context_menu->popup(screen_position, m_link_context_menu_default_action); + }; + + m_image_context_menu = GUI::Menu::construct(); + m_image_context_menu->add_action(GUI::Action::create("&Copy Image", [this](auto&) { + if (m_image_context_menu_bitmap.is_valid()) + GUI::Clipboard::the().set_bitmap(*m_image_context_menu_bitmap.bitmap()); + })); + m_image_context_menu->add_action(GUI::Action::create("Copy Image &URL", [this](auto&) { + GUI::Clipboard::the().set_plain_text(m_image_context_menu_url.to_string()); + })); + m_image_context_menu->add_separator(); + m_image_context_menu->add_action(GUI::Action::create("&Open Image in Browser", [this](auto&) { + m_web_view->on_link_click(m_image_context_menu_url, "", 0); + })); + + m_web_view->on_image_context_menu_request = [this](auto& image_url, auto& screen_position, Gfx::ShareableBitmap const& shareable_bitmap) { + m_image_context_menu_url = image_url; + m_image_context_menu_bitmap = shareable_bitmap; + m_image_context_menu->popup(screen_position); + }; +} + +MailWidget::~MailWidget() +{ +} + +bool MailWidget::connect_and_login() +{ + auto config = Core::ConfigFile::get_for_app("Mail"); + + auto server = config->read_entry("Connection", "Server", {}); + + if (server.is_empty()) { + GUI::MessageBox::show_error(window(), "Mail has no servers configured. Refer to the Mail(1) man page for more information."); + return false; + } + + // Assume TLS by default, which is on port 993. + auto port = config->read_num_entry("Connection", "Port", 993); + auto tls = config->read_bool_entry("Connection", "TLS", true); + + auto username = config->read_entry("User", "Username", {}); + if (username.is_empty()) { + GUI::MessageBox::show_error(window(), "Mail has no username configured. Refer to the Mail(1) man page for more information."); + return false; + } + + auto password = config->read_entry("User", "Password", {}); + if (password.is_empty()) { + GUI::MessageBox::show_error(window(), "Mail has no password configured. Refer to the Mail(1) man page for more information."); + return false; + } + + m_imap_client = make<IMAP::Client>(server, port, tls); + auto connection_promise = m_imap_client->connect(); + if (!connection_promise.has_value()) { + GUI::MessageBox::show_error(window(), String::formatted("Failed to connect to '{}:{}' over {}.", server, port, tls ? "TLS" : "Plaintext")); + return false; + } + connection_promise.value()->await(); + + auto response = m_imap_client->login(username, password)->await().release_value(); + + if (response.status() != IMAP::ResponseStatus::OK) { + dbgln("Failed to login. The server says: '{}'", response.response_text()); + GUI::MessageBox::show_error(window(), String::formatted("Failed to login. The server says: '{}'", response.response_text())); + return false; + } + + response = m_imap_client->list("", "*")->await().release_value(); + + if (response.status() != IMAP::ResponseStatus::OK) { + dbgln("Failed to retrieve mailboxes. The server says: '{}'", response.response_text()); + GUI::MessageBox::show_error(window(), String::formatted("Failed to retrieve mailboxes. The server says: '{}'", response.response_text())); + return false; + } + + auto& list_items = response.data().list_items(); + + m_account_holder = AccountHolder::create(); + m_account_holder->add_account_with_name_and_mailboxes(username, move(list_items)); + + m_mailbox_list->set_model(m_account_holder->mailbox_tree_model()); + m_mailbox_list->expand_tree(); + + return true; +} + +void MailWidget::on_window_close() +{ + auto response = move(m_imap_client->send_simple_command(IMAP::CommandType::Logout)->await().release_value().get<IMAP::SolidResponse>()); + VERIFY(response.status() == IMAP::ResponseStatus::OK); + + m_imap_client->close(); +} + +IMAP::MultiPartBodyStructureData const* MailWidget::look_for_alternative_body_structure(IMAP::MultiPartBodyStructureData const& current_body_structure, Vector<u32>& position_stack) const +{ + if (current_body_structure.media_type.equals_ignoring_case("ALTERNATIVE")) + return ¤t_body_structure; + + u32 structure_index = 1; + + for (auto& structure : current_body_structure.bodies) { + if (structure->data().has<IMAP::BodyStructureData>()) { + ++structure_index; + continue; + } + + position_stack.append(structure_index); + auto* potential_alternative_structure = look_for_alternative_body_structure(structure->data().get<IMAP::MultiPartBodyStructureData>(), position_stack); + + if (potential_alternative_structure) + return potential_alternative_structure; + + position_stack.take_last(); + ++structure_index; + } + + return nullptr; +} + +Vector<MailWidget::Alternative> MailWidget::get_alternatives(IMAP::MultiPartBodyStructureData const& multi_part_body_structure_data) const +{ + Vector<u32> position_stack; + + auto* alternative_body_structure = look_for_alternative_body_structure(multi_part_body_structure_data, position_stack); + if (!alternative_body_structure) + return {}; + + Vector<MailWidget::Alternative> alternatives; + alternatives.ensure_capacity(alternative_body_structure->bodies.size()); + + int alternative_index = 1; + for (auto& alternative_body : alternative_body_structure->bodies) { + VERIFY(alternative_body->data().has<IMAP::BodyStructureData>()); + + position_stack.append(alternative_index); + + MailWidget::Alternative alternative = { + .body_structure = alternative_body->data().get<IMAP::BodyStructureData>(), + .position = position_stack, + }; + alternatives.append(alternative); + + position_stack.take_last(); + ++alternative_index; + } + + return alternatives; +} + +bool MailWidget::is_supported_alternative(Alternative const& alternative) const +{ + return alternative.body_structure.type.equals_ignoring_case("text") && (alternative.body_structure.subtype.equals_ignoring_case("plain") || alternative.body_structure.subtype.equals_ignoring_case("html")); +} + +void MailWidget::selected_mailbox() +{ + m_individual_mailbox_view->set_model(InboxModel::create({})); + + auto const& index = m_mailbox_list->selection().first(); + + if (!index.is_valid()) + return; + + auto& base_node = *static_cast<BaseNode*>(index.internal_data()); + + if (is<AccountNode>(base_node)) { + // FIXME: Do something when clicking on an account node. + return; + } + + auto& mailbox_node = verify_cast<MailboxNode>(base_node); + auto& mailbox = mailbox_node.mailbox(); + + // FIXME: It would be better if we didn't allow the user to click on this mailbox node at all. + if (mailbox.flags & (unsigned)IMAP::MailboxFlag::NoSelect) + return; + + auto response = m_imap_client->select(mailbox.name)->await().release_value(); + + if (response.status() != IMAP::ResponseStatus::OK) { + dbgln("Failed to select mailbox. The server says: '{}'", response.response_text()); + GUI::MessageBox::show_error(window(), String::formatted("Failed to select mailbox. The server says: '{}'", response.response_text())); + return; + } + + if (response.data().exists() == 0) { + // No mail in this mailbox, return. + return; + } + + auto fetch_command = IMAP::FetchCommand { + // Mail will always be numbered from 1 up to the number of mail items that exist, which is specified in the select response with "EXISTS". + .sequence_set = { { 1, (int)response.data().exists() } }, + .data_items = { + IMAP::FetchCommand::DataItem { + .type = IMAP::FetchCommand::DataItemType::BodySection, + .section = IMAP::FetchCommand::DataItem::Section { + .type = IMAP::FetchCommand::DataItem::SectionType::HeaderFields, + .headers = { { "Subject", "From" } }, + }, + }, + }, + }; + + auto fetch_response = m_imap_client->fetch(fetch_command, false)->await().release_value(); + + if (response.status() != IMAP::ResponseStatus::OK) { + dbgln("Failed to retrieve subject/from for e-mails. The server says: '{}'", response.response_text()); + GUI::MessageBox::show_error(window(), String::formatted("Failed to retrieve e-mails. The server says: '{}'", response.response_text())); + return; + } + + Vector<InboxEntry> active_inbox_entries; + + for (auto& fetch_data : fetch_response.data().fetch_data()) { + auto& response_data = fetch_data.get<IMAP::FetchResponseData>(); + auto& body_data = response_data.body_data(); + + auto data_item_has_header = [](IMAP::FetchCommand::DataItem const& data_item, String const& search_header) { + if (!data_item.section.has_value()) + return false; + if (data_item.section->type != IMAP::FetchCommand::DataItem::SectionType::HeaderFields) + return false; + if (!data_item.section->headers.has_value()) + return false; + return data_item.section->headers->contains_slow(search_header); + }; + + auto subject_iterator = body_data.find_if([&data_item_has_header](Tuple<IMAP::FetchCommand::DataItem, Optional<String>>& data) { + auto const data_item = data.get<0>(); + return data_item_has_header(data_item, "Subject"); + }); + + VERIFY(subject_iterator != body_data.end()); + + auto from_iterator = body_data.find_if([&data_item_has_header](Tuple<IMAP::FetchCommand::DataItem, Optional<String>>& data) { + auto const data_item = data.get<0>(); + return data_item_has_header(data_item, "From"); + }); + + VERIFY(from_iterator != body_data.end()); + + // FIXME: All of the following doesn't really follow RFC 2822: https://datatracker.ietf.org/doc/html/rfc2822 + + auto parse_and_unfold = [](String const& value) { + GenericLexer lexer(value); + StringBuilder builder; + + // There will be a space at the start of the value, which should be ignored. + VERIFY(lexer.consume_specific(' ')); + + while (!lexer.is_eof()) { + auto current_line = lexer.consume_while([](char c) { + return c != '\r'; + }); + + builder.append(current_line); + + bool consumed_end_of_line = lexer.consume_specific("\r\n"); + VERIFY(consumed_end_of_line); + + // If CRLF are immediately followed by WSP (which is either ' ' or '\t'), then it is not the end of the header and is instead just a wrap. + // If it's not followed by WSP, then it is the end of the header. + // https://datatracker.ietf.org/doc/html/rfc2822#section-2.2.3 + if (lexer.is_eof() || (lexer.peek() != ' ' && lexer.peek() != '\t')) + break; + } + + return builder.to_string(); + }; + + auto& subject_iterator_value = subject_iterator->get<1>().value(); + auto subject_index = subject_iterator_value.find("Subject:"); + String subject; + if (subject_index.has_value()) { + auto potential_subject = subject_iterator_value.substring(subject_index.value()); + auto subject_parts = potential_subject.split_limit(':', 2); + subject = parse_and_unfold(subject_parts.last()); + } + + if (subject.is_empty()) + subject = "(no subject)"; + + auto& from_iterator_value = from_iterator->get<1>().value(); + auto from_index = from_iterator_value.find("From:"); + VERIFY(from_index.has_value()); + auto potential_from = from_iterator_value.substring(from_index.value()); + auto from_parts = potential_from.split_limit(':', 2); + auto from = parse_and_unfold(from_parts.last()); + + InboxEntry inbox_entry { from, subject }; + + active_inbox_entries.append(inbox_entry); + } + + m_individual_mailbox_view->set_model(InboxModel::create(move(active_inbox_entries))); +} + +void MailWidget::selected_email_to_load() +{ + auto const& index = m_individual_mailbox_view->selection().first(); + + if (!index.is_valid()) + return; + + // IMAP is 1-based. + int id_of_email_to_load = index.row() + 1; + + auto fetch_command = IMAP::FetchCommand { + .sequence_set = { { id_of_email_to_load, id_of_email_to_load } }, + .data_items = { + IMAP::FetchCommand::DataItem { + .type = IMAP::FetchCommand::DataItemType::BodyStructure, + }, + }, + }; + + auto fetch_response = m_imap_client->fetch(fetch_command, false)->await().release_value(); + + if (fetch_response.status() != IMAP::ResponseStatus::OK) { + dbgln("Failed to retrieve the body structure of the selected e-mail. The server says: '{}'", fetch_response.response_text()); + GUI::MessageBox::show_error(window(), String::formatted("Failed to retrieve the selected e-mail. The server says: '{}'", fetch_response.response_text())); + return; + } + + Vector<u32> selected_alternative_position; + String selected_alternative_encoding; + + auto& response_data = fetch_response.data().fetch_data().last().get<IMAP::FetchResponseData>(); + + response_data.body_structure().data().visit( + [&](IMAP::BodyStructureData const& data) { + // The message will be in the first position. + selected_alternative_position.append(1); + selected_alternative_encoding = data.encoding; + }, + [&](IMAP::MultiPartBodyStructureData const& data) { + auto alternatives = get_alternatives(data); + if (alternatives.is_empty()) { + dbgln("No alternatives. The server said: '{}'", fetch_response.response_text()); + GUI::MessageBox::show_error(window(), "The server sent no message to display."); + return; + } + + // We can choose whichever alternative we want. In general, we should choose the last alternative that know we can display. + // RFC 2046 Section 5.1.4 https://datatracker.ietf.org/doc/html/rfc2046#section-5.1.4 + auto chosen_alternative = alternatives.last_matching([this](auto& alternative) { + return is_supported_alternative(alternative); + }); + + if (!chosen_alternative.has_value()) { + GUI::MessageBox::show(window(), "Displaying this type of e-mail is currently unsupported.", "Unsupported", GUI::MessageBox::Type::Information); + return; + } + + selected_alternative_position = chosen_alternative->position; + selected_alternative_encoding = chosen_alternative->body_structure.encoding; + }); + + if (selected_alternative_position.is_empty()) { + // An error occurred above, return. + return; + } + + fetch_command = IMAP::FetchCommand { + .sequence_set { { id_of_email_to_load, id_of_email_to_load } }, + .data_items = { + IMAP::FetchCommand::DataItem { + .type = IMAP::FetchCommand::DataItemType::BodySection, + .section = IMAP::FetchCommand::DataItem::Section { + .type = IMAP::FetchCommand::DataItem::SectionType::Parts, + .parts = selected_alternative_position, + }, + .partial_fetch = false, + }, + }, + }; + + fetch_response = m_imap_client->fetch(fetch_command, false)->await().release_value(); + + if (fetch_response.status() != IMAP::ResponseStatus::OK) { + dbgln("Failed to retrieve the body of the selected e-mail. The server says: '{}'", fetch_response.response_text()); + GUI::MessageBox::show_error(window(), String::formatted("Failed to retrieve the selected e-mail. The server says: '{}'", fetch_response.response_text())); + return; + } + + auto& fetch_data = fetch_response.data().fetch_data(); + + if (fetch_data.is_empty()) { + dbgln("The server sent no fetch data."); + GUI::MessageBox::show_error(window(), "The server sent no data."); + return; + } + + auto& fetch_response_data = fetch_data.last().get<IMAP::FetchResponseData>(); + + if (!fetch_response_data.contains_response_type(IMAP::FetchResponseType::Body)) { + GUI::MessageBox::show_error(window(), "The server sent no body."); + return; + } + + auto& body_data = fetch_response_data.body_data(); + auto body_text_part_iterator = body_data.find_if([](Tuple<IMAP::FetchCommand::DataItem, Optional<String>>& data) { + const auto data_item = data.get<0>(); + return data_item.section.has_value() && data_item.section->type == IMAP::FetchCommand::DataItem::SectionType::Parts; + }); + VERIFY(body_text_part_iterator != body_data.end()); + + auto& encoded_data = body_text_part_iterator->get<1>().value(); + + String decoded_data; + + // FIXME: String uses char internally, so 8bit shouldn't be stored in it. + // However, it works for now. + if (selected_alternative_encoding.equals_ignoring_case("7bit") || selected_alternative_encoding.equals_ignoring_case("8bit")) { + decoded_data = encoded_data; + } else if (selected_alternative_encoding.equals_ignoring_case("base64")) { + decoded_data = decode_base64(encoded_data); + } else if (selected_alternative_encoding.equals_ignoring_case("quoted-printable")) { + decoded_data = IMAP::decode_quoted_printable(encoded_data); + } else { + dbgln("Mail: Unimplemented decoder for encoding: {}", selected_alternative_encoding); + GUI::MessageBox::show(window(), String::formatted("The e-mail encoding '{}' is currently unsupported.", selected_alternative_encoding), "Unsupported", GUI::MessageBox::Type::Information); + return; + } + + // FIXME: I'm not sure what the URL should be. Just use the default URL "about:blank". + // FIXME: It would be nice if we could pass over the charset. + m_web_view->load_html(decoded_data, "about:blank"); +} diff --git a/Userland/Applications/Mail/MailWidget.h b/Userland/Applications/Mail/MailWidget.h new file mode 100644 index 0000000000..fe135f6035 --- /dev/null +++ b/Userland/Applications/Mail/MailWidget.h @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2021, Luke Wilde <lukew@serenityos.org> + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "AccountHolder.h" +#include "InboxModel.h" +#include <AK/NonnullOwnPtrVector.h> +#include <LibGUI/Widget.h> +#include <LibGfx/ShareableBitmap.h> +#include <LibIMAP/Client.h> +#include <LibWeb/OutOfProcessWebView.h> + +class MailWidget final : public GUI::Widget { + C_OBJECT(MailWidget) +public: + virtual ~MailWidget() override; + + bool connect_and_login(); + + void on_window_close(); + +private: + MailWidget(); + + void selected_mailbox(); + void selected_email_to_load(); + + struct Alternative { + IMAP::BodyStructureData const& body_structure; + Vector<u32> position; + }; + + IMAP::MultiPartBodyStructureData const* look_for_alternative_body_structure(IMAP::MultiPartBodyStructureData const& current_body_structure, Vector<u32>& position_stack) const; + Vector<Alternative> get_alternatives(IMAP::MultiPartBodyStructureData const&) const; + bool is_supported_alternative(Alternative const&) const; + + OwnPtr<IMAP::Client> m_imap_client; + + RefPtr<GUI::TreeView> m_mailbox_list; + RefPtr<GUI::TableView> m_individual_mailbox_view; + RefPtr<Web::OutOfProcessWebView> m_web_view; + RefPtr<GUI::Statusbar> m_statusbar; + + RefPtr<GUI::Menu> m_link_context_menu; + RefPtr<GUI::Action> m_link_context_menu_default_action; + URL m_link_context_menu_url; + + RefPtr<GUI::Menu> m_image_context_menu; + Gfx::ShareableBitmap m_image_context_menu_bitmap; + URL m_image_context_menu_url; + + OwnPtr<AccountHolder> m_account_holder; +}; diff --git a/Userland/Applications/Mail/MailWindow.gml b/Userland/Applications/Mail/MailWindow.gml new file mode 100644 index 0000000000..071bb2eaf6 --- /dev/null +++ b/Userland/Applications/Mail/MailWindow.gml @@ -0,0 +1,28 @@ +@GUI::Widget { + fill_with_background_color: true + + layout: @GUI::VerticalBoxLayout { + margins: [2, 2, 2, 2] + } + + @GUI::HorizontalSplitter { + @GUI::TreeView { + name: "mailbox_list" + fixed_width: 250 + } + + @GUI::VerticalSplitter { + @GUI::TableView { + name: "individual_mailbox_view" + } + + @Web::OutOfProcessWebView { + name: "web_view" + } + } + } + + @GUI::Statusbar { + name: "statusbar" + } +} diff --git a/Userland/Applications/Mail/MailboxTreeModel.cpp b/Userland/Applications/Mail/MailboxTreeModel.cpp new file mode 100644 index 0000000000..4183e7fc5a --- /dev/null +++ b/Userland/Applications/Mail/MailboxTreeModel.cpp @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2021, Luke Wilde <lukew@serenityos.org> + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "MailboxTreeModel.h" +#include "AccountHolder.h" + +MailboxTreeModel::MailboxTreeModel(AccountHolder const& account_holder) + : m_account_holder(account_holder) +{ + m_mail_icon.set_bitmap_for_size(16, Gfx::Bitmap::try_load_from_file("/res/icons/16x16/app-mail.png")); + m_folder_icon.set_bitmap_for_size(16, Gfx::Bitmap::try_load_from_file("/res/icons/16x16/filetype-folder.png")); + m_account_icon.set_bitmap_for_size(16, Gfx::Bitmap::try_load_from_file("/res/icons/16x16/home-directory.png")); +} + +MailboxTreeModel::~MailboxTreeModel() +{ +} + +GUI::ModelIndex MailboxTreeModel::index(int row, int column, GUI::ModelIndex const& parent) const +{ + if (!parent.is_valid()) { + if (m_account_holder.accounts().is_empty()) + return {}; + return create_index(row, column, &m_account_holder.accounts().at(row)); + } + auto& base_node = *static_cast<BaseNode*>(parent.internal_data()); + + if (is<MailboxNode>(base_node)) { + auto& remote_mailbox = verify_cast<MailboxNode>(base_node); + return create_index(row, column, &remote_mailbox.children().at(row)); + } + + auto& remote_parent = verify_cast<AccountNode>(base_node); + return create_index(row, column, &remote_parent.mailboxes().at(row)); +} + +GUI::ModelIndex MailboxTreeModel::parent_index(GUI::ModelIndex const& index) const +{ + if (!index.is_valid()) + return {}; + + auto& base_node = *static_cast<BaseNode*>(index.internal_data()); + + if (is<AccountNode>(base_node)) + return {}; + + auto& mailbox_node = verify_cast<MailboxNode>(base_node); + + if (!mailbox_node.has_parent()) { + for (size_t row = 0; row < mailbox_node.associated_account().mailboxes().size(); ++row) { + if (&mailbox_node.associated_account().mailboxes()[row] == &mailbox_node) { + return create_index(row, index.column(), &mailbox_node.associated_account()); + } + } + } else { + VERIFY(mailbox_node.parent()->has_children()); + for (size_t row = 0; row < mailbox_node.parent()->children().size(); ++row) { + if (&mailbox_node.parent()->children()[row] == &mailbox_node) { + return create_index(row, index.column(), mailbox_node.parent()); + } + } + } + + VERIFY_NOT_REACHED(); + return {}; +} + +int MailboxTreeModel::row_count(GUI::ModelIndex const& index) const +{ + if (!index.is_valid()) + return m_account_holder.accounts().size(); + + auto& base_node = *static_cast<BaseNode*>(index.internal_data()); + + if (is<MailboxNode>(base_node)) + return verify_cast<MailboxNode>(base_node).children().size(); + + auto& node = verify_cast<AccountNode>(base_node); + return node.mailboxes().size(); +} + +int MailboxTreeModel::column_count(GUI::ModelIndex const&) const +{ + return 1; +} + +GUI::Variant MailboxTreeModel::data(GUI::ModelIndex const& index, GUI::ModelRole role) const +{ + auto& base_node = *static_cast<BaseNode*>(index.internal_data()); + + if (role == GUI::ModelRole::Display) { + if (is<AccountNode>(base_node)) { + auto& account_node = verify_cast<AccountNode>(base_node); + return account_node.name(); + } + + auto& mailbox_node = verify_cast<MailboxNode>(base_node); + return mailbox_node.display_name(); + } + + if (role == GUI::ModelRole::Icon) { + if (is<AccountNode>(base_node)) + return m_account_icon; + + auto& mailbox_node = verify_cast<MailboxNode>(base_node); + if (!mailbox_node.children().is_empty()) + return m_folder_icon; + return m_mail_icon; + } + + return {}; +} + +void MailboxTreeModel::update() +{ + did_update(); +} diff --git a/Userland/Applications/Mail/MailboxTreeModel.h b/Userland/Applications/Mail/MailboxTreeModel.h new file mode 100644 index 0000000000..d24a766ce3 --- /dev/null +++ b/Userland/Applications/Mail/MailboxTreeModel.h @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2021, Luke Wilde <lukew@serenityos.org> + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include <LibGUI/Model.h> +#include <LibIMAP/Objects.h> + +class AccountHolder; + +class MailboxTreeModel final : public GUI::Model { +public: + static NonnullRefPtr<MailboxTreeModel> create(AccountHolder const& account_holder) + { + return adopt_ref(*new MailboxTreeModel(account_holder)); + } + + virtual ~MailboxTreeModel() override; + + virtual int row_count(GUI::ModelIndex const& = GUI::ModelIndex()) const override; + virtual int column_count(GUI::ModelIndex const& = GUI::ModelIndex()) const override; + virtual GUI::Variant data(GUI::ModelIndex const&, GUI::ModelRole) const override; + virtual GUI::ModelIndex index(int row, int column, GUI::ModelIndex const& parent = GUI::ModelIndex()) const override; + virtual GUI::ModelIndex parent_index(GUI::ModelIndex const&) const override; + virtual void update() override; + +private: + explicit MailboxTreeModel(AccountHolder const&); + + AccountHolder const& m_account_holder; + + GUI::Icon m_mail_icon; + GUI::Icon m_folder_icon; + GUI::Icon m_account_icon; +}; diff --git a/Userland/Applications/Mail/main.cpp b/Userland/Applications/Mail/main.cpp new file mode 100644 index 0000000000..0a34248a07 --- /dev/null +++ b/Userland/Applications/Mail/main.cpp @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2021, Luke Wilde <lukew@serenityos.org> + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "MailWidget.h" +#include <LibGUI/Application.h> +#include <LibGUI/Icon.h> +#include <LibGUI/Menu.h> +#include <LibGUI/Menubar.h> +#include <LibGUI/Window.h> +#include <LibGfx/Bitmap.h> +#include <stdio.h> +#include <unistd.h> + +int main(int argc, char** argv) +{ + if (pledge("stdio recvfd sendfd rpath unix cpath wpath thread inet", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + auto window = GUI::Window::construct(); + + auto app_icon = GUI::Icon::default_icon("app-mail"); + window->set_icon(app_icon.bitmap_for_size(16)); + + auto& mail_widget = window->set_main_widget<MailWidget>(); + + window->set_title("Mail"); + window->resize(640, 400); + + auto menubar = GUI::Menubar::construct(); + + auto& file_menu = menubar->add_menu("&File"); + + file_menu.add_action(GUI::CommonActions::make_quit_action([&](auto&) { + mail_widget.on_window_close(); + app->quit(); + })); + + auto& help_menu = menubar->add_menu("&Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("Mail", app_icon, window)); + + window->on_close_request = [&] { + mail_widget.on_window_close(); + return GUI::Window::CloseRequestDecision::Close; + }; + + window->set_menubar(menubar); + + window->show(); + + bool should_continue = mail_widget.connect_and_login(); + if (!should_continue) + return 1; + + return app->exec(); +} |