Serenity Operating System
at master 513 lines 22 kB view raw
1/* 2 * Copyright (c) 2021, Luke Wilde <lukew@serenityos.org> 3 * Copyright (c) 2021, Undefine <cqundefine@gmail.com> 4 * Copyright (c) 2022, the SerenityOS developers. 5 * 6 * SPDX-License-Identifier: BSD-2-Clause 7 */ 8 9#include "MailWidget.h" 10#include <AK/Base64.h> 11#include <AK/GenericLexer.h> 12#include <Applications/Mail/MailWindowGML.h> 13#include <LibConfig/Client.h> 14#include <LibDesktop/Launcher.h> 15#include <LibGUI/Action.h> 16#include <LibGUI/Clipboard.h> 17#include <LibGUI/Menu.h> 18#include <LibGUI/MessageBox.h> 19#include <LibGUI/PasswordInputDialog.h> 20#include <LibGUI/Statusbar.h> 21#include <LibGUI/TableView.h> 22#include <LibGUI/TreeView.h> 23#include <LibIMAP/QuotedPrintable.h> 24 25MailWidget::MailWidget() 26{ 27 load_from_gml(mail_window_gml).release_value_but_fixme_should_propagate_errors(); 28 29 m_mailbox_list = *find_descendant_of_type_named<GUI::TreeView>("mailbox_list"); 30 m_individual_mailbox_view = *find_descendant_of_type_named<GUI::TableView>("individual_mailbox_view"); 31 m_web_view = *find_descendant_of_type_named<WebView::OutOfProcessWebView>("web_view"); 32 m_statusbar = *find_descendant_of_type_named<GUI::Statusbar>("statusbar"); 33 34 m_mailbox_list->on_selection_change = [this] { 35 selected_mailbox(); 36 }; 37 38 m_individual_mailbox_view->on_selection_change = [this] { 39 selected_email_to_load(); 40 }; 41 42 m_web_view->on_link_click = [this](auto& url, auto&, unsigned) { 43 if (!Desktop::Launcher::open(url)) { 44 GUI::MessageBox::show( 45 window(), 46 DeprecatedString::formatted("The link to '{}' could not be opened.", url), 47 "Failed to open link"sv, 48 GUI::MessageBox::Type::Error); 49 } 50 }; 51 52 m_web_view->on_link_middle_click = [this](auto& url, auto& target, unsigned modifiers) { 53 m_web_view->on_link_click(url, target, modifiers); 54 }; 55 56 m_web_view->on_link_hover = [this](auto& url) { 57 if (url.is_valid()) 58 m_statusbar->set_text(url.to_deprecated_string()); 59 else 60 m_statusbar->set_text(""); 61 }; 62 63 m_link_context_menu = GUI::Menu::construct(); 64 auto link_default_action = GUI::Action::create("&Open in Browser", [this](auto&) { 65 m_web_view->on_link_click(m_link_context_menu_url, "", 0); 66 }); 67 m_link_context_menu->add_action(link_default_action); 68 m_link_context_menu_default_action = link_default_action; 69 m_link_context_menu->add_separator(); 70 m_link_context_menu->add_action(GUI::Action::create("&Copy URL", [this](auto&) { 71 GUI::Clipboard::the().set_plain_text(m_link_context_menu_url.to_deprecated_string()); 72 })); 73 74 m_web_view->on_link_context_menu_request = [this](auto& url, auto screen_position) { 75 m_link_context_menu_url = url; 76 m_link_context_menu->popup(screen_position, m_link_context_menu_default_action); 77 }; 78 79 m_image_context_menu = GUI::Menu::construct(); 80 m_image_context_menu->add_action(GUI::Action::create("&Copy Image", [this](auto&) { 81 if (m_image_context_menu_bitmap.is_valid()) 82 GUI::Clipboard::the().set_bitmap(*m_image_context_menu_bitmap.bitmap()); 83 })); 84 m_image_context_menu->add_action(GUI::Action::create("Copy Image &URL", [this](auto&) { 85 GUI::Clipboard::the().set_plain_text(m_image_context_menu_url.to_deprecated_string()); 86 })); 87 m_image_context_menu->add_separator(); 88 m_image_context_menu->add_action(GUI::Action::create("&Open Image in Browser", [this](auto&) { 89 m_web_view->on_link_click(m_image_context_menu_url, "", 0); 90 })); 91 92 m_web_view->on_image_context_menu_request = [this](auto& image_url, auto screen_position, Gfx::ShareableBitmap const& shareable_bitmap) { 93 m_image_context_menu_url = image_url; 94 m_image_context_menu_bitmap = shareable_bitmap; 95 m_image_context_menu->popup(screen_position); 96 }; 97} 98 99bool MailWidget::connect_and_login() 100{ 101 auto server = Config::read_string("Mail"sv, "Connection"sv, "Server"sv, {}); 102 103 if (server.is_empty()) { 104 auto result = GUI::MessageBox::show(window(), "Mail has no servers configured. Do you want configure them now?"sv, "Error"sv, GUI::MessageBox::Type::Error, GUI::MessageBox::InputType::YesNo); 105 if (result == GUI::MessageBox::ExecResult::Yes) 106 Desktop::Launcher::open(URL::create_with_file_scheme("/bin/MailSettings")); 107 return false; 108 } 109 110 // Assume TLS by default, which is on port 993. 111 auto port = Config::read_i32("Mail"sv, "Connection"sv, "Port"sv, 993); 112 auto tls = Config::read_bool("Mail"sv, "Connection"sv, "TLS"sv, true); 113 114 auto username = Config::read_string("Mail"sv, "User"sv, "Username"sv, {}); 115 if (username.is_empty()) { 116 GUI::MessageBox::show_error(window(), "Mail has no username configured. Refer to the Mail(1) man page for more information."sv); 117 return false; 118 } 119 120 auto password = Config::read_string("Mail"sv, "User"sv, "Password"sv, {}); 121 while (password.is_empty()) { 122 if (GUI::PasswordInputDialog::show(window(), password, "Login"sv, server, username) != GUI::Dialog::ExecResult::OK) 123 return false; 124 } 125 126 auto maybe_imap_client = tls ? IMAP::Client::connect_tls(server, port) : IMAP::Client::connect_plaintext(server, port); 127 if (maybe_imap_client.is_error()) { 128 GUI::MessageBox::show_error(window(), DeprecatedString::formatted("Failed to connect to '{}:{}' over {}: {}", server, port, tls ? "TLS" : "Plaintext", maybe_imap_client.error())); 129 return false; 130 } 131 m_imap_client = maybe_imap_client.release_value(); 132 133 auto connection_promise = m_imap_client->connection_promise(); 134 VERIFY(!connection_promise.is_null()); 135 MUST(connection_promise->await()); 136 137 auto response = MUST(m_imap_client->login(username, password)->await()).release_value(); 138 139 if (response.status() != IMAP::ResponseStatus::OK) { 140 dbgln("Failed to login. The server says: '{}'", response.response_text()); 141 GUI::MessageBox::show_error(window(), DeprecatedString::formatted("Failed to login. The server says: '{}'", response.response_text())); 142 return false; 143 } 144 145 response = MUST(m_imap_client->list(""sv, "*"sv)->await()).release_value(); 146 147 if (response.status() != IMAP::ResponseStatus::OK) { 148 dbgln("Failed to retrieve mailboxes. The server says: '{}'", response.response_text()); 149 GUI::MessageBox::show_error(window(), DeprecatedString::formatted("Failed to retrieve mailboxes. The server says: '{}'", response.response_text())); 150 return false; 151 } 152 153 auto& list_items = response.data().list_items(); 154 155 m_account_holder = AccountHolder::create(); 156 m_account_holder->add_account_with_name_and_mailboxes(username, move(list_items)); 157 158 m_mailbox_list->set_model(m_account_holder->mailbox_tree_model()); 159 m_mailbox_list->expand_tree(); 160 161 return true; 162} 163 164void MailWidget::on_window_close() 165{ 166 auto response = move(MUST(m_imap_client->send_simple_command(IMAP::CommandType::Logout)->await()).release_value().get<IMAP::SolidResponse>()); 167 VERIFY(response.status() == IMAP::ResponseStatus::OK); 168 169 m_imap_client->close(); 170} 171 172IMAP::MultiPartBodyStructureData const* MailWidget::look_for_alternative_body_structure(IMAP::MultiPartBodyStructureData const& current_body_structure, Vector<u32>& position_stack) const 173{ 174 if (current_body_structure.media_type.equals_ignoring_ascii_case("ALTERNATIVE"sv)) 175 return &current_body_structure; 176 177 u32 structure_index = 1; 178 179 for (auto& structure : current_body_structure.bodies) { 180 if (structure->data().has<IMAP::BodyStructureData>()) { 181 ++structure_index; 182 continue; 183 } 184 185 position_stack.append(structure_index); 186 auto* potential_alternative_structure = look_for_alternative_body_structure(structure->data().get<IMAP::MultiPartBodyStructureData>(), position_stack); 187 188 if (potential_alternative_structure) 189 return potential_alternative_structure; 190 191 position_stack.take_last(); 192 ++structure_index; 193 } 194 195 return nullptr; 196} 197 198Vector<MailWidget::Alternative> MailWidget::get_alternatives(IMAP::MultiPartBodyStructureData const& multi_part_body_structure_data) const 199{ 200 Vector<u32> position_stack; 201 202 auto* alternative_body_structure = look_for_alternative_body_structure(multi_part_body_structure_data, position_stack); 203 if (!alternative_body_structure) 204 return {}; 205 206 Vector<MailWidget::Alternative> alternatives; 207 alternatives.ensure_capacity(alternative_body_structure->bodies.size()); 208 209 int alternative_index = 1; 210 for (auto& alternative_body : alternative_body_structure->bodies) { 211 VERIFY(alternative_body->data().has<IMAP::BodyStructureData>()); 212 213 position_stack.append(alternative_index); 214 215 MailWidget::Alternative alternative = { 216 .body_structure = alternative_body->data().get<IMAP::BodyStructureData>(), 217 .position = position_stack, 218 }; 219 alternatives.append(alternative); 220 221 position_stack.take_last(); 222 ++alternative_index; 223 } 224 225 return alternatives; 226} 227 228bool MailWidget::is_supported_alternative(Alternative const& alternative) const 229{ 230 return alternative.body_structure.type.equals_ignoring_ascii_case("text"sv) && (alternative.body_structure.subtype.equals_ignoring_ascii_case("plain"sv) || alternative.body_structure.subtype.equals_ignoring_ascii_case("html"sv)); 231} 232 233void MailWidget::selected_mailbox() 234{ 235 m_individual_mailbox_view->set_model(InboxModel::create({})); 236 237 auto const& index = m_mailbox_list->selection().first(); 238 239 if (!index.is_valid()) 240 return; 241 242 auto& base_node = *static_cast<BaseNode*>(index.internal_data()); 243 244 if (is<AccountNode>(base_node)) { 245 // FIXME: Do something when clicking on an account node. 246 return; 247 } 248 249 auto& mailbox_node = verify_cast<MailboxNode>(base_node); 250 auto& mailbox = mailbox_node.mailbox(); 251 252 // FIXME: It would be better if we didn't allow the user to click on this mailbox node at all. 253 if (mailbox.flags & (unsigned)IMAP::MailboxFlag::NoSelect) 254 return; 255 256 auto response = MUST(m_imap_client->select(mailbox.name)->await()).release_value(); 257 258 if (response.status() != IMAP::ResponseStatus::OK) { 259 dbgln("Failed to select mailbox. The server says: '{}'", response.response_text()); 260 GUI::MessageBox::show_error(window(), DeprecatedString::formatted("Failed to select mailbox. The server says: '{}'", response.response_text())); 261 return; 262 } 263 264 if (response.data().exists() == 0) { 265 // No mail in this mailbox, return. 266 return; 267 } 268 269 auto fetch_command = IMAP::FetchCommand { 270 // 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". 271 .sequence_set = { { 1, (int)response.data().exists() } }, 272 .data_items = { 273 IMAP::FetchCommand::DataItem { 274 .type = IMAP::FetchCommand::DataItemType::BodySection, 275 .section = IMAP::FetchCommand::DataItem::Section { 276 .type = IMAP::FetchCommand::DataItem::SectionType::HeaderFields, 277 .headers = { { "Subject", "From" } }, 278 }, 279 }, 280 }, 281 }; 282 283 auto fetch_response = MUST(m_imap_client->fetch(fetch_command, false)->await()).release_value(); 284 285 if (response.status() != IMAP::ResponseStatus::OK) { 286 dbgln("Failed to retrieve subject/from for e-mails. The server says: '{}'", response.response_text()); 287 GUI::MessageBox::show_error(window(), DeprecatedString::formatted("Failed to retrieve e-mails. The server says: '{}'", response.response_text())); 288 return; 289 } 290 291 Vector<InboxEntry> active_inbox_entries; 292 293 for (auto& fetch_data : fetch_response.data().fetch_data()) { 294 auto& response_data = fetch_data.get<IMAP::FetchResponseData>(); 295 auto& body_data = response_data.body_data(); 296 297 auto data_item_has_header = [](IMAP::FetchCommand::DataItem const& data_item, DeprecatedString const& search_header) { 298 if (!data_item.section.has_value()) 299 return false; 300 if (data_item.section->type != IMAP::FetchCommand::DataItem::SectionType::HeaderFields) 301 return false; 302 if (!data_item.section->headers.has_value()) 303 return false; 304 auto header_iterator = data_item.section->headers->find_if([&search_header](auto& header) { 305 return header.equals_ignoring_ascii_case(search_header); 306 }); 307 return header_iterator != data_item.section->headers->end(); 308 }; 309 310 auto subject_iterator = body_data.find_if([&data_item_has_header](Tuple<IMAP::FetchCommand::DataItem, Optional<DeprecatedString>>& data) { 311 auto const data_item = data.get<0>(); 312 return data_item_has_header(data_item, "Subject"); 313 }); 314 315 VERIFY(subject_iterator != body_data.end()); 316 317 auto from_iterator = body_data.find_if([&data_item_has_header](Tuple<IMAP::FetchCommand::DataItem, Optional<DeprecatedString>>& data) { 318 auto const data_item = data.get<0>(); 319 return data_item_has_header(data_item, "From"); 320 }); 321 322 VERIFY(from_iterator != body_data.end()); 323 324 // FIXME: All of the following doesn't really follow RFC 2822: https://datatracker.ietf.org/doc/html/rfc2822 325 326 auto parse_and_unfold = [](DeprecatedString const& value) { 327 GenericLexer lexer(value); 328 StringBuilder builder; 329 330 // There will be a space at the start of the value, which should be ignored. 331 VERIFY(lexer.consume_specific(' ')); 332 333 while (!lexer.is_eof()) { 334 auto current_line = lexer.consume_while([](char c) { 335 return c != '\r'; 336 }); 337 338 builder.append(current_line); 339 340 bool consumed_end_of_line = lexer.consume_specific("\r\n"); 341 VERIFY(consumed_end_of_line); 342 343 // 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. 344 // If it's not followed by WSP, then it is the end of the header. 345 // https://datatracker.ietf.org/doc/html/rfc2822#section-2.2.3 346 if (lexer.is_eof() || (lexer.peek() != ' ' && lexer.peek() != '\t')) 347 break; 348 } 349 350 return builder.to_deprecated_string(); 351 }; 352 353 auto& subject_iterator_value = subject_iterator->get<1>().value(); 354 auto subject_index = subject_iterator_value.find("Subject:"sv); 355 DeprecatedString subject; 356 if (subject_index.has_value()) { 357 auto potential_subject = subject_iterator_value.substring(subject_index.value()); 358 auto subject_parts = potential_subject.split_limit(':', 2); 359 subject = parse_and_unfold(subject_parts.last()); 360 } 361 362 if (subject.is_empty()) 363 subject = "(no subject)"; 364 365 auto& from_iterator_value = from_iterator->get<1>().value(); 366 auto from_index = from_iterator_value.find("From:"sv); 367 VERIFY(from_index.has_value()); 368 auto potential_from = from_iterator_value.substring(from_index.value()); 369 auto from_parts = potential_from.split_limit(':', 2); 370 auto from = parse_and_unfold(from_parts.last()); 371 372 InboxEntry inbox_entry { from, subject }; 373 374 active_inbox_entries.append(inbox_entry); 375 } 376 377 m_individual_mailbox_view->set_model(InboxModel::create(move(active_inbox_entries))); 378} 379 380void MailWidget::selected_email_to_load() 381{ 382 auto const& index = m_individual_mailbox_view->selection().first(); 383 384 if (!index.is_valid()) 385 return; 386 387 // IMAP is 1-based. 388 int id_of_email_to_load = index.row() + 1; 389 390 auto fetch_command = IMAP::FetchCommand { 391 .sequence_set = { { id_of_email_to_load, id_of_email_to_load } }, 392 .data_items = { 393 IMAP::FetchCommand::DataItem { 394 .type = IMAP::FetchCommand::DataItemType::BodyStructure, 395 }, 396 }, 397 }; 398 399 auto fetch_response = MUST(m_imap_client->fetch(fetch_command, false)->await()).release_value(); 400 401 if (fetch_response.status() != IMAP::ResponseStatus::OK) { 402 dbgln("Failed to retrieve the body structure of the selected e-mail. The server says: '{}'", fetch_response.response_text()); 403 GUI::MessageBox::show_error(window(), DeprecatedString::formatted("Failed to retrieve the selected e-mail. The server says: '{}'", fetch_response.response_text())); 404 return; 405 } 406 407 Vector<u32> selected_alternative_position; 408 DeprecatedString selected_alternative_encoding; 409 410 auto& response_data = fetch_response.data().fetch_data().last().get<IMAP::FetchResponseData>(); 411 412 response_data.body_structure().data().visit( 413 [&](IMAP::BodyStructureData const& data) { 414 // The message will be in the first position. 415 selected_alternative_position.append(1); 416 selected_alternative_encoding = data.encoding; 417 }, 418 [&](IMAP::MultiPartBodyStructureData const& data) { 419 auto alternatives = get_alternatives(data); 420 if (alternatives.is_empty()) { 421 dbgln("No alternatives. The server said: '{}'", fetch_response.response_text()); 422 GUI::MessageBox::show_error(window(), "The server sent no message to display."sv); 423 return; 424 } 425 426 // We can choose whichever alternative we want. In general, we should choose the last alternative that know we can display. 427 // RFC 2046 Section 5.1.4 https://datatracker.ietf.org/doc/html/rfc2046#section-5.1.4 428 auto chosen_alternative = alternatives.last_matching([this](auto& alternative) { 429 return is_supported_alternative(alternative); 430 }); 431 432 if (!chosen_alternative.has_value()) { 433 GUI::MessageBox::show(window(), "Displaying this type of e-mail is currently unsupported."sv, "Unsupported"sv, GUI::MessageBox::Type::Information); 434 return; 435 } 436 437 selected_alternative_position = chosen_alternative->position; 438 selected_alternative_encoding = chosen_alternative->body_structure.encoding; 439 }); 440 441 if (selected_alternative_position.is_empty()) { 442 // An error occurred above, return. 443 return; 444 } 445 446 fetch_command = IMAP::FetchCommand { 447 .sequence_set { { id_of_email_to_load, id_of_email_to_load } }, 448 .data_items = { 449 IMAP::FetchCommand::DataItem { 450 .type = IMAP::FetchCommand::DataItemType::BodySection, 451 .section = IMAP::FetchCommand::DataItem::Section { 452 .type = IMAP::FetchCommand::DataItem::SectionType::Parts, 453 .parts = selected_alternative_position, 454 }, 455 .partial_fetch = false, 456 }, 457 }, 458 }; 459 460 fetch_response = MUST(m_imap_client->fetch(fetch_command, false)->await()).release_value(); 461 462 if (fetch_response.status() != IMAP::ResponseStatus::OK) { 463 dbgln("Failed to retrieve the body of the selected e-mail. The server says: '{}'", fetch_response.response_text()); 464 GUI::MessageBox::show_error(window(), DeprecatedString::formatted("Failed to retrieve the selected e-mail. The server says: '{}'", fetch_response.response_text())); 465 return; 466 } 467 468 auto& fetch_data = fetch_response.data().fetch_data(); 469 470 if (fetch_data.is_empty()) { 471 dbgln("The server sent no fetch data."); 472 GUI::MessageBox::show_error(window(), "The server sent no data."sv); 473 return; 474 } 475 476 auto& fetch_response_data = fetch_data.last().get<IMAP::FetchResponseData>(); 477 478 if (!fetch_response_data.contains_response_type(IMAP::FetchResponseType::Body)) { 479 GUI::MessageBox::show_error(window(), "The server sent no body."sv); 480 return; 481 } 482 483 auto& body_data = fetch_response_data.body_data(); 484 auto body_text_part_iterator = body_data.find_if([](Tuple<IMAP::FetchCommand::DataItem, Optional<DeprecatedString>>& data) { 485 const auto data_item = data.get<0>(); 486 return data_item.section.has_value() && data_item.section->type == IMAP::FetchCommand::DataItem::SectionType::Parts; 487 }); 488 VERIFY(body_text_part_iterator != body_data.end()); 489 490 auto& encoded_data = body_text_part_iterator->get<1>().value(); 491 492 DeprecatedString decoded_data; 493 494 // FIXME: String uses char internally, so 8bit shouldn't be stored in it. 495 // However, it works for now. 496 if (selected_alternative_encoding.equals_ignoring_ascii_case("7bit"sv) || selected_alternative_encoding.equals_ignoring_ascii_case("8bit"sv)) { 497 decoded_data = encoded_data; 498 } else if (selected_alternative_encoding.equals_ignoring_ascii_case("base64"sv)) { 499 auto decoded_base64 = decode_base64(encoded_data); 500 if (!decoded_base64.is_error()) 501 decoded_data = decoded_base64.release_value(); 502 } else if (selected_alternative_encoding.equals_ignoring_ascii_case("quoted-printable"sv)) { 503 decoded_data = IMAP::decode_quoted_printable(encoded_data).release_value_but_fixme_should_propagate_errors(); 504 } else { 505 dbgln("Mail: Unimplemented decoder for encoding: {}", selected_alternative_encoding); 506 GUI::MessageBox::show(window(), DeprecatedString::formatted("The e-mail encoding '{}' is currently unsupported.", selected_alternative_encoding), "Unsupported"sv, GUI::MessageBox::Type::Information); 507 return; 508 } 509 510 // FIXME: I'm not sure what the URL should be. Just use the default URL "about:blank". 511 // FIXME: It would be nice if we could pass over the charset. 512 m_web_view->load_html(decoded_data, "about:blank"sv); 513}