summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Base/res/apps/Mail.af4
-rw-r--r--Base/res/icons/16x16/app-mail.pngbin0 -> 155 bytes
-rw-r--r--Base/res/icons/32x32/app-mail.pngbin0 -> 237 bytes
-rw-r--r--Userland/Applications/CMakeLists.txt1
-rw-r--r--Userland/Applications/Mail/AccountHolder.cpp79
-rw-r--r--Userland/Applications/Mail/AccountHolder.h108
-rw-r--r--Userland/Applications/Mail/CMakeLists.txt19
-rw-r--r--Userland/Applications/Mail/InboxModel.cpp50
-rw-r--r--Userland/Applications/Mail/InboxModel.h42
-rw-r--r--Userland/Applications/Mail/MailWidget.cpp505
-rw-r--r--Userland/Applications/Mail/MailWidget.h57
-rw-r--r--Userland/Applications/Mail/MailWindow.gml28
-rw-r--r--Userland/Applications/Mail/MailboxTreeModel.cpp120
-rw-r--r--Userland/Applications/Mail/MailboxTreeModel.h38
-rw-r--r--Userland/Applications/Mail/main.cpp62
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
new file mode 100644
index 0000000000..197a0c4f46
--- /dev/null
+++ b/Base/res/icons/16x16/app-mail.png
Binary files differ
diff --git a/Base/res/icons/32x32/app-mail.png b/Base/res/icons/32x32/app-mail.png
new file mode 100644
index 0000000000..b9eca40226
--- /dev/null
+++ b/Base/res/icons/32x32/app-mail.png
Binary files differ
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 &current_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();
+}