From b716e902ba53f4128c64e558c07524689a07a8c2 Mon Sep 17 00:00:00 2001 From: Luke Date: Thu, 22 Jul 2021 18:44:59 +0100 Subject: [PATCH] Mail: Add an e-mail application called Mail This utilises LibIMAP and LibWeb to provide an e-mail client. The only way currently to connect to a server and login is with a config file. This config file should be stored in ~/.config/Mail.ini Here is an example config file: ``` [Connection] Server=email.example.com Port=993 TLS=true [User] Username=test@example.com Password=Example!1 ``` Since this is stored in plaintext and uses a less secure login method, I'd recommend not using this on your main accounts :^) This has been tested on Gmail and Outlook. For Gmail, you either have to generate an app password if you have 2FA enabled, or enable access from less secure apps in your account settings. --- Base/res/apps/Mail.af | 4 + Base/res/icons/16x16/app-mail.png | Bin 0 -> 155 bytes Base/res/icons/32x32/app-mail.png | Bin 0 -> 237 bytes Userland/Applications/CMakeLists.txt | 1 + Userland/Applications/Mail/AccountHolder.cpp | 79 +++ Userland/Applications/Mail/AccountHolder.h | 108 ++++ Userland/Applications/Mail/CMakeLists.txt | 19 + Userland/Applications/Mail/InboxModel.cpp | 50 ++ Userland/Applications/Mail/InboxModel.h | 42 ++ Userland/Applications/Mail/MailWidget.cpp | 505 ++++++++++++++++++ Userland/Applications/Mail/MailWidget.h | 57 ++ Userland/Applications/Mail/MailWindow.gml | 28 + .../Applications/Mail/MailboxTreeModel.cpp | 120 +++++ Userland/Applications/Mail/MailboxTreeModel.h | 38 ++ Userland/Applications/Mail/main.cpp | 62 +++ 15 files changed, 1113 insertions(+) create mode 100644 Base/res/apps/Mail.af create mode 100644 Base/res/icons/16x16/app-mail.png create mode 100644 Base/res/icons/32x32/app-mail.png create mode 100644 Userland/Applications/Mail/AccountHolder.cpp create mode 100644 Userland/Applications/Mail/AccountHolder.h create mode 100644 Userland/Applications/Mail/CMakeLists.txt create mode 100644 Userland/Applications/Mail/InboxModel.cpp create mode 100644 Userland/Applications/Mail/InboxModel.h create mode 100644 Userland/Applications/Mail/MailWidget.cpp create mode 100644 Userland/Applications/Mail/MailWidget.h create mode 100644 Userland/Applications/Mail/MailWindow.gml create mode 100644 Userland/Applications/Mail/MailboxTreeModel.cpp create mode 100644 Userland/Applications/Mail/MailboxTreeModel.h create mode 100644 Userland/Applications/Mail/main.cpp 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 new file mode 100644 index 0000000000000000000000000000000000000000..197a0c4f4692f99c2e874f916b0a0ec4eee46acd GIT binary patch literal 155 zcmeAS@N?(olHy`uVBq!ia0y~yU=RRd4mJh`2Kmqb6B!s7l001;Lo7}oo#e>Hpvc1< z{PBPMxp_>Xhl5@ooTFlKS&nOuA47t>!B>U%HiC={5>25YiM%qE&0Q*oyB8?keCYf^ zy>Nr*i{xoL&T)MbVO+PFX}gl=ZQa{1C)_G8kT!f9TsNP8_EuN^?Qxe485kHCJYD@< J);T3K0RUR#Hgf;~ literal 0 HcmV?d00001 diff --git a/Base/res/icons/32x32/app-mail.png b/Base/res/icons/32x32/app-mail.png new file mode 100644 index 0000000000000000000000000000000000000000..b9eca4022679b9e810371073dc2ec4ab3a0eac73 GIT binary patch literal 237 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}TRdGHLn02popzG1!GObM z`I7(tPk(J$$)&sFz`=7TH6HHO>txWb_h-Cd#HwY#MeKE;7bAnf;h(M|bHsiA^0|0? z`OxgpazwID@JEnSQ#Vt*M@B*D1K&=;8jTNaM|kW+E0hu&XS2NXhF2smq<@>5BbyKVP>XZCaC5#bM-SYFfe$!`njxgN@xNA+38|q literal 0 HcmV?d00001 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 + * + * 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 const& mailboxes) +{ + auto account = AccountNode::create(move(name)); + + // This holds all of the ancestors of the current leaf folder. + NonnullRefPtrVector 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 + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "MailboxTreeModel.h" +#include +#include +#include +#include + +class BaseNode : public RefCounted { +public: + virtual ~BaseNode() = default; +}; + +class MailboxNode; + +class AccountNode final : public BaseNode { +public: + static NonnullRefPtr create(String name) + { + return adopt_ref(*new AccountNode(move(name))); + } + + virtual ~AccountNode() override = default; + + void add_mailbox(NonnullRefPtr mailbox) + { + m_mailboxes.append(move(mailbox)); + } + + NonnullRefPtrVector 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 m_mailboxes; +}; + +class MailboxNode final : public BaseNode { +public: + static NonnullRefPtr 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 parent() const { return m_parent; } + void set_parent(NonnullRefPtr parent) { m_parent = parent; } + + bool has_children() const { return !m_children.is_empty(); } + NonnullRefPtrVector const& children() const { return m_children; } + void add_child(NonnullRefPtr 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 m_children; + RefPtr m_parent; +}; + +class AccountHolder { +public: + ~AccountHolder(); + + static NonnullOwnPtr create() + { + return adopt_own(*new AccountHolder()); + } + + void add_account_with_name_and_mailboxes(String, Vector const&); + + NonnullRefPtrVector const& accounts() const { return m_accounts; } + MailboxTreeModel& mailbox_tree_model() { return *m_mailbox_tree_model; } + +private: + AccountHolder(); + + void rebuild_tree(); + + NonnullRefPtrVector m_accounts; + RefPtr 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 + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "InboxModel.h" + +InboxModel::InboxModel(Vector 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 + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include + +struct InboxEntry { + String from; + String subject; +}; + +class InboxModel final : public GUI::Model { +public: + enum Column { + From, + Subject, + __Count + }; + + static NonnullRefPtr create(Vector 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); + + Vector 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 + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "MailWidget.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +MailWidget::MailWidget() +{ + load_from_gml(mail_window_gml); + + m_mailbox_list = *find_descendant_of_type_named("mailbox_list"); + m_individual_mailbox_view = *find_descendant_of_type_named("individual_mailbox_view"); + m_web_view = *find_descendant_of_type_named("web_view"); + m_statusbar = *find_descendant_of_type_named("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(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()); + 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& 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()) { + ++structure_index; + continue; + } + + position_stack.append(structure_index); + auto* potential_alternative_structure = look_for_alternative_body_structure(structure->data().get(), position_stack); + + if (potential_alternative_structure) + return potential_alternative_structure; + + position_stack.take_last(); + ++structure_index; + } + + return nullptr; +} + +Vector MailWidget::get_alternatives(IMAP::MultiPartBodyStructureData const& multi_part_body_structure_data) const +{ + Vector position_stack; + + auto* alternative_body_structure = look_for_alternative_body_structure(multi_part_body_structure_data, position_stack); + if (!alternative_body_structure) + return {}; + + Vector 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()); + + position_stack.append(alternative_index); + + MailWidget::Alternative alternative = { + .body_structure = alternative_body->data().get(), + .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(index.internal_data()); + + if (is(base_node)) { + // FIXME: Do something when clicking on an account node. + return; + } + + auto& mailbox_node = verify_cast(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 active_inbox_entries; + + for (auto& fetch_data : fetch_response.data().fetch_data()) { + auto& response_data = fetch_data.get(); + 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>& 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>& 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 selected_alternative_position; + String selected_alternative_encoding; + + auto& response_data = fetch_response.data().fetch_data().last().get(); + + 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(); + + 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>& 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 + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "AccountHolder.h" +#include "InboxModel.h" +#include +#include +#include +#include +#include + +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 position; + }; + + IMAP::MultiPartBodyStructureData const* look_for_alternative_body_structure(IMAP::MultiPartBodyStructureData const& current_body_structure, Vector& position_stack) const; + Vector get_alternatives(IMAP::MultiPartBodyStructureData const&) const; + bool is_supported_alternative(Alternative const&) const; + + OwnPtr m_imap_client; + + RefPtr m_mailbox_list; + RefPtr m_individual_mailbox_view; + RefPtr m_web_view; + RefPtr m_statusbar; + + RefPtr m_link_context_menu; + RefPtr m_link_context_menu_default_action; + URL m_link_context_menu_url; + + RefPtr m_image_context_menu; + Gfx::ShareableBitmap m_image_context_menu_bitmap; + URL m_image_context_menu_url; + + OwnPtr 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 + * + * 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(parent.internal_data()); + + if (is(base_node)) { + auto& remote_mailbox = verify_cast(base_node); + return create_index(row, column, &remote_mailbox.children().at(row)); + } + + auto& remote_parent = verify_cast(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(index.internal_data()); + + if (is(base_node)) + return {}; + + auto& mailbox_node = verify_cast(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(index.internal_data()); + + if (is(base_node)) + return verify_cast(base_node).children().size(); + + auto& node = verify_cast(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(index.internal_data()); + + if (role == GUI::ModelRole::Display) { + if (is(base_node)) { + auto& account_node = verify_cast(base_node); + return account_node.name(); + } + + auto& mailbox_node = verify_cast(base_node); + return mailbox_node.display_name(); + } + + if (role == GUI::ModelRole::Icon) { + if (is(base_node)) + return m_account_icon; + + auto& mailbox_node = verify_cast(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 + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include + +class AccountHolder; + +class MailboxTreeModel final : public GUI::Model { +public: + static NonnullRefPtr 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 + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "MailWidget.h" +#include +#include +#include +#include +#include +#include +#include +#include + +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(); + + 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(); +}