mirror of
https://github.com/RGBCube/serenity
synced 2025-05-14 12:05:00 +00:00

This is primarily to be able to remove the GenericLexer include out of Format.h as well. A subsequent commit will add AK::Result to GenericLexer, which will cause naming conflicts with other structures named Result. This can be avoided (for now) by preventing nearly every file in the system from implicitly including GenericLexer. Other changes in this commit are to add the GenericLexer include to files where it is missing.
510 lines
20 KiB
C++
510 lines
20 KiB
C++
/*
|
|
* Copyright (c) 2021, Luke Wilde <lukew@serenityos.org>
|
|
*
|
|
* SPDX-License-Identifier: BSD-2-Clause
|
|
*/
|
|
|
|
#include "MailWidget.h"
|
|
#include <AK/Base64.h>
|
|
#include <AK/GenericLexer.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/PasswordInputDialog.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", {});
|
|
while (password.is_empty()) {
|
|
if (GUI::PasswordInputDialog::show(window(), password, "Login", server, username) != GUI::Dialog::ExecOK)
|
|
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;
|
|
auto header_iterator = data_item.section->headers->find_if([&search_header](auto& header) {
|
|
return header.equals_ignoring_case(search_header);
|
|
});
|
|
return header_iterator != data_item.section->headers->end();
|
|
};
|
|
|
|
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");
|
|
}
|