Serenity Operating System
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 ¤t_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}