diff options
| author | saturneric <[email protected]> | 2026-09-12 21:14:17 +0200 |
|---|---|---|
| committer | saturneric <[email protected]> | 2026-09-12 21:14:17 +0200 |
| commit | 38c8f6010ab4e8da4e0ee2272aca84efc9722c85 (patch) | |
| tree | a96ce8076fc886add64a1ded01bdc90df50140f0 /src | |
| parent | feat(email): enhance header view controls (diff) | |
| download | modules-38c8f6010ab4e8da4e0ee2272aca84efc9722c85.tar.gz modules-38c8f6010ab4e8da4e0ee2272aca84efc9722c85.zip | |
feat(email): enhance message interaction and security UX
- add tab icons, keyboard shortcuts, focus handling, and theme updates
- support attachment context menus, opening, key import, and safer removal
- improve attachment sizing, save feedback, and batch error reporting
- expose verification retry and distinguish unverified outcomes
- show encryption key capability and HTML source rendering notices
- add clearer failure dialogs for reply and forward operations
- centralize view colors and improve layout spacing and accessibility
Diffstat (limited to 'src')
| -rw-r--r-- | src/m_email/EMailPageView.cpp | 804 | ||||
| -rw-r--r-- | src/m_email/EMailPageView.h | 60 |
2 files changed, 743 insertions, 121 deletions
diff --git a/src/m_email/EMailPageView.cpp b/src/m_email/EMailPageView.cpp index f680213..e9b7705 100644 --- a/src/m_email/EMailPageView.cpp +++ b/src/m_email/EMailPageView.cpp @@ -32,7 +32,9 @@ #include <QAbstractItemView> #include <QApplication> +#include <QClipboard> #include <QCompleter> +#include <QDesktopServices> #include <QDir> #include <QDragEnterEvent> #include <QDropEvent> @@ -54,11 +56,15 @@ #include <QPushButton> #include <QSaveFile> #include <QSet> +#include <QShortcut> #include <QStackedWidget> #include <QStyle> #include <QTabWidget> +#include <QTemporaryDir> +#include <QTimer> #include <QToolButton> #include <QTreeWidget> +#include <QUrl> #include <QVBoxLayout> #include <functional> @@ -81,6 +87,19 @@ namespace { constexpr double kLockedTint = 0.10; constexpr double kLockedFieldTint = 0.05; +/// How long a one-off note stays in the attachment heading before it goes back +/// to describing the list. Long enough to read a short sentence without having +/// gone looking for it, short enough not to become the label. +constexpr int kStatusNoteMs = 4000; + +/// How many tabs get an Alt+<n> key. Five is what this view has; a sixth would +/// need one too, and Alt+6 is still free. +constexpr int kMaxTabShortcuts = 5; + +/// The attachment list never gets less than this, however short the window: +/// below it the list is smaller than its own header plus a row. +constexpr int kMinAttachmentHeight = 120; + /// Names the completer so it can be found again on the line edit it belongs /// to. Needed because a multi-value completer is not reachable through /// QLineEdit::completer(): it is driven by hand and only parented to the edit. @@ -288,22 +307,58 @@ void EMailPageView::build_ui() { tabs_ = new QTabWidget(this); tabs_->setDocumentMode(true); + // Smaller than the default 16: these sit beside text at the normal size, and + // full-size coloured glyphs shout over the words they are labelling. + tabs_->setIconSize(QSize(14, 14)); outer->addWidget(tabs_, 1); - tabs_->addTab(build_message_tab(), tr("Message")); + tabs_->addTab(build_message_tab(), QIcon(":/icons/email.png"), tr("Message")); structure_view_ = new EMailStructureView(this); - tabs_->addTab(structure_view_, tr("Structure")); + tabs_->addTab(structure_view_, QIcon(":/icons/stairs.png"), tr("Structure")); security_view_ = new EMailSecurityView(this); - tabs_->addTab(security_view_, tr("Security")); + // Deliberately not lock.png. That glyph is state-bearing on the security + // button beside the body, where it means THIS message is encrypted; sitting + // permanently on a tab it would say the same thing about every message. + tabs_->addTab(security_view_, QIcon(":/icons/decr-verify.png"), + tr("Security")); + + // The Security tab can now ask for the two things it used to only describe. + connect(security_view_, &EMailSecurityView::SignalVerifyAgainRequested, this, + [this]() { + run_verification(); + refresh_security(); + refresh_attachments(); + }); + connect(security_view_, &EMailSecurityView::SignalImportMessageKeysRequested, + this, &EMailPageView::import_message_keys); header_view_ = new EMailHeaderView(this); - tabs_->addTab(header_view_, tr("Headers")); + tabs_->addTab(header_view_, QIcon(":/icons/detail.png"), tr("Headers")); + + // Alt+1..5 rather than mnemonics in the tab labels: a mnemonic has to be a + // letter of the translated word, and the five translations will not always + // have five distinct free letters between them. + for (int i = 0; i < kMaxTabShortcuts; ++i) { + auto* shortcut = new QShortcut( + QKeySequence(Qt::ALT | static_cast<Qt::Key>(Qt::Key_1 + i)), this); + // Only within this tab: several of these views can be open at once, and a + // key pressed in one must not switch tabs in another. + shortcut->setContext(Qt::WidgetWithChildrenShortcut); + connect(shortcut, &QShortcut::activated, this, [this, i]() { + if (i < tabs_->count()) tabs_->setCurrentIndex(i); + }); + } connect(tabs_, &QTabWidget::currentChanged, this, [this](int index) { auto* page = tabs_->widget(index); + // Focus follows the tab. Without this it stays on whatever was clicked, so + // the filter box and the trees on the tab just opened are only reachable + // by tabbing across the whole page first. + if (page != nullptr) page->setFocus(Qt::OtherFocusReason); + // The raw document is the host's, and it may be behind this view's edits. // Asking for it to be brought up to date is the whole reason the host // wants to hear about this switch. @@ -325,6 +380,55 @@ void EMailPageView::build_ui() { ensure_regions_verified(); refresh_security(); }); + + apply_colors(); +} + +void EMailPageView::apply_colors() { + // The one place any colour in this view is decided. Called at the end of + // build_ui() and again on every theme change, which is what keeps a single + // definition from having to exist twice -- and what makes the tab follow a + // live light/dark switch at all. Nothing here may touch a font: the font + // scaling done at build time is not idempotent. + for (auto* caption : captions_) { + if (caption != nullptr) EMailMakeMuted(caption); + } + + if (envelope_rule_ != nullptr) EMailPaintRule(envelope_rule_); + if (action_separator_ != nullptr) EMailPaintRule(action_separator_); + + if (locked_banner_ != nullptr) { + EMailTintBanner(locked_banner_, MutedColor(this), kLockedTint); + } + if (locked_notice_ != nullptr) { + EMailSetLabelColor(locked_notice_, MutedColor(this)); + } + if (locked_recipients_ != nullptr) { + EMailSetLabelColor(locked_recipients_, MutedColor(this)); + } + if (attachment_heading_ != nullptr) { + EMailSetLabelColor(attachment_heading_, MutedColor(this)); + } + if (unsigned_notice_ != nullptr) { + EMailSetLabelColor(unsigned_notice_, ThemeColor(this, &GFUIWarningColor)); + } + + if (attachment_list_ != nullptr) { + EMailPaintTreeHeader(attachment_list_); + EMailRepaintTree(attachment_list_); + } + + // These decide a colour from state rather than holding a fixed one, so they + // are asked to decide again rather than being recoloured from here. + if (security_button_ != nullptr) paint_security_button(); + if (from_edit_ != nullptr) { + style_as_locked(content_lock() != EMailLockReason::kNONE); + } +} + +void EMailPageView::changeEvent(QEvent* event) { + QWidget::changeEvent(event); + if (EMailIsRestyle(event)) apply_colors(); } auto EMailPageView::build_message_tab() -> QWidget* { @@ -335,8 +439,11 @@ auto EMailPageView::build_message_tab() -> QWidget* { auto* form = new QFormLayout(); form->setLabelAlignment(Qt::AlignRight); - form->setHorizontalSpacing(8); - form->setVerticalSpacing(3); + form->setFormAlignment(Qt::AlignLeft | Qt::AlignTop); + // Three points of leading ran the rows together into one block of boxes; a + // wider gutter stops the captions touching the fields they name. + form->setHorizontalSpacing(10); + form->setVerticalSpacing(4); form->setContentsMargins(0, 0, 0, 0); from_edit_ = new QLineEdit(this); @@ -378,9 +485,12 @@ auto EMailPageView::build_message_tab() -> QWidget* { // addresses rather than on the word "From". const auto caption = [this](const QString& text) { auto* label = new QLabel(text, this); - auto palette = label->palette(); - palette.setColor(QPalette::WindowText, MutedColor(this)); - label->setPalette(palette); + // Quieter AND smaller than the value beside it, which is the convention + // everywhere else in this module. The colour is not set here: + // apply_colors() owns every colour in this view, so a theme change repaints + // these too. + EMailMakeSecondary(label); + captions_.append(label); return label; }; @@ -400,7 +510,10 @@ auto EMailPageView::build_message_tab() -> QWidget* { cc_bcc_toggle_->setText(tr("Cc/Bcc")); cc_bcc_toggle_->setCheckable(true); cc_bcc_toggle_->setAutoRaise(true); - cc_bcc_toggle_->setToolTip(tr("Show carbon copy and blind carbon copy")); + cc_bcc_toggle_->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_C)); + cc_bcc_toggle_->setToolTip( + tr("Show carbon copy and blind carbon copy (%1)") + .arg(cc_bcc_toggle_->shortcut().toString(QKeySequence::NativeText))); to_row_layout->addWidget(cc_bcc_toggle_); form->addRow(caption(tr("To:")), to_row); @@ -421,16 +534,12 @@ auto EMailPageView::build_message_tab() -> QWidget* { layout->addLayout(form); // A hairline between the envelope and the message, so the two stop running - // together into one undifferentiated column of boxes. - auto* rule = new QFrame(this); - rule->setFrameShape(QFrame::HLine); - rule->setFrameShadow(QFrame::Plain); - auto rule_palette = rule->palette(); - rule_palette.setColor(QPalette::WindowText, - ThemeColor(this, &GFUIBorderColor)); - rule->setPalette(rule_palette); - rule->setFixedHeight(1); - layout->addWidget(rule); + // together into one undifferentiated column of boxes. Given room on both + // sides: a rule pressed against what it divides reads as an underline. + envelope_rule_ = EMailRule(this); + layout->addSpacing(2); + layout->addWidget(envelope_rule_); + layout->addSpacing(2); auto* body_row = new QHBoxLayout(); body_row->setContentsMargins(0, 0, 0, 0); @@ -445,29 +554,42 @@ auto EMailPageView::build_message_tab() -> QWidget* { // platforms that have no icon theme at all. const auto make_action = [this, body_row](const QString& theme_icon, const QString& fallback_icon, - const QString& text, const QString& tip) { + const QString& text, const QString& tip, + const QKeySequence& shortcut) { auto* button = new QToolButton(this); button->setIcon(QIcon::fromTheme(theme_icon, QIcon(fallback_icon))); button->setText(text); button->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); button->setAutoRaise(true); - button->setToolTip(tip); button->setVisible(false); + + // The key and the sentence advertising it are set together, so they + // cannot drift apart. A shortcut nothing mentions is a shortcut only + // the person who wrote it knows about. + button->setShortcut(shortcut); + button->setToolTip( + shortcut.isEmpty() + ? tip + : tr("%1 (%2)").arg( + tip, shortcut.toString(QKeySequence::NativeText))); + body_row->addWidget(button); return button; }; - reply_button_ = - make_action(QStringLiteral("mail-reply-sender"), ":/icons/reply.png", - tr("Reply"), tr("Write a reply to the sender.")); + reply_button_ = make_action( + QStringLiteral("mail-reply-sender"), ":/icons/reply.png", tr("Reply"), + tr("Write a reply to the sender."), QKeySequence(Qt::CTRL | Qt::Key_R)); reply_all_button_ = make_action( QStringLiteral("mail-reply-all"), ":/icons/reply-all.png", tr("Reply All"), tr("Write a reply to the sender and everyone else who was addressed. " - "Blind recipients are not included.")); + "Blind recipients are not included."), + QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_R)); forward_button_ = make_action( QStringLiteral("mail-forward"), ":/icons/redo.png", tr("Forward"), - tr("Pass this message on, with its attachments.")); + tr("Pass this message on, with its attachments."), + QKeySequence(Qt::CTRL | Qt::Key_L)); connect(reply_button_, &QToolButton::clicked, this, [this]() { slot_derive_message(static_cast<int>(EMailReplyMode::kREPLY)); @@ -482,22 +604,14 @@ auto EMailPageView::build_message_tab() -> QWidget* { send_button_ = make_action(QStringLiteral("mail-send"), ":/icons/export-email.png", tr("Send..."), tr("Send this message through a configured " - "mail account.")); + "mail account."), + QKeySequence(Qt::CTRL | Qt::Key_Return)); connect(send_button_, &QToolButton::clicked, this, &EMailPageView::slot_send_message); // Locking the document is a different kind of act from deriving a new // message out of it, so it is set apart rather than lined up with them. - auto* action_separator = new QFrame(this); - action_separator->setFrameShape(QFrame::VLine); - action_separator->setFrameShadow(QFrame::Plain); - action_separator->setFixedWidth(1); - { - auto separator_palette = action_separator->palette(); - separator_palette.setColor(QPalette::WindowText, - ThemeColor(this, &GFUIBorderColor)); - action_separator->setPalette(separator_palette); - } + auto* action_separator = EMailRule(this, Qt::Vertical); action_separator->setVisible(false); body_row->addSpacing(4); body_row->addWidget(action_separator); @@ -507,7 +621,8 @@ auto EMailPageView::build_message_tab() -> QWidget* { forensic_toggle_ = make_action( QStringLiteral("object-locked"), ":/icons/read-only.png", tr("Read-only"), tr("Lock this message so it cannot be edited or rewritten. Reply and " - "Forward still work and produce new messages.")); + "Forward still work and produce new messages."), + QKeySequence()); forensic_toggle_->setCheckable(true); connect(forensic_toggle_, &QToolButton::toggled, this, [this](bool on) { SetForensicMode(on); }); @@ -547,21 +662,19 @@ auto EMailPageView::build_message_tab() -> QWidget* { body_view_ = new EMailBodyView(this); + // The body view decides for itself when it has given up on rendering and is + // showing source instead, so it is the thing that gets asked. Answering the + // signal rather than re-deriving the same condition here keeps the sentence + // the user reads tied to what was actually done to the body. + connect(body_view_, &EMailBodyView::SignalHtmlShownAsSource, this, + [this]() { body_is_html_ = true; }); + // The reason the message is locked is not a footnote: it is the first thing // to read when the fields will not take input. A tinted strip with the state // icon beside it says so at a glance, where small grey text under the body // did not. - locked_banner_ = new QFrame(this); + locked_banner_ = EMailTintedBanner(this, MutedColor(this), kLockedTint); locked_banner_->setVisible(false); - locked_banner_->setAutoFillBackground(true); - locked_banner_->setFrameShape(QFrame::NoFrame); - { - auto palette = locked_banner_->palette(); - const auto base = palette.color(QPalette::Window); - palette.setColor(QPalette::Window, - Blend(base, MutedColor(this), kLockedTint)); - locked_banner_->setPalette(palette); - } auto* banner_row = new QHBoxLayout(locked_banner_); banner_row->setContentsMargins(8, 6, 8, 6); @@ -578,6 +691,7 @@ auto EMailPageView::build_message_tab() -> QWidget* { font.setPointSizeF(font.pointSizeF() * 0.92); locked_notice_->setFont(font); } + // Colour belongs to apply_colors(), like every other colour in this view. banner_row->addWidget(locked_notice_, 1); layout->addWidget(locked_banner_); @@ -592,14 +706,14 @@ auto EMailPageView::build_message_tab() -> QWidget* { attachment_heading_ = new QLabel(this); { - auto palette = attachment_heading_->palette(); - palette.setColor(QPalette::WindowText, MutedColor(this)); - attachment_heading_->setPalette(palette); auto font = attachment_heading_->font(); font.setPointSizeF(font.pointSizeF() * 0.92); attachment_heading_->setFont(font); } attachment_heading_->setVisible(false); + // Not welded to the body above it: this is a new group, not a caption on the + // editor. + layout->addSpacing(2); layout->addWidget(attachment_heading_); attachment_list_ = new QTreeWidget(this); @@ -612,7 +726,7 @@ auto EMailPageView::build_message_tab() -> QWidget* { attachment_list_->header()->setStretchLastSection(false); attachment_list_->header()->setSectionResizeMode(kColName, QHeaderView::Stretch); - attachment_list_->setMaximumHeight(160); + EMailPolishTree(attachment_list_); attachment_list_->setVisible(false); layout->addWidget(attachment_list_); @@ -620,9 +734,6 @@ auto EMailPageView::build_message_tab() -> QWidget* { unsigned_notice_->setWordWrap(true); unsigned_notice_->setVisible(false); { - auto palette = unsigned_notice_->palette(); - palette.setColor(QPalette::WindowText, ThemeColor(this, &GFUIWarningColor)); - unsigned_notice_->setPalette(palette); auto font = unsigned_notice_->font(); font.setPointSizeF(font.pointSizeF() * 0.92); unsigned_notice_->setFont(font); @@ -638,13 +749,20 @@ auto EMailPageView::build_message_tab() -> QWidget* { const auto make_attachment_action = [this](const QString& theme_icon, const QString& fallback_icon, - const QString& text, const QString& tip) { + const QString& text, const QString& tip, + const QKeySequence& shortcut = {}) { auto* button = new QToolButton(this); button->setIcon(QIcon::fromTheme(theme_icon, QIcon(fallback_icon))); button->setText(text); button->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); button->setAutoRaise(true); - button->setToolTip(tip); + + button->setShortcut(shortcut); + button->setToolTip( + shortcut.isEmpty() + ? tip + : tr("%1 (%2)").arg( + tip, shortcut.toString(QKeySequence::NativeText))); return button; }; @@ -652,7 +770,8 @@ auto EMailPageView::build_message_tab() -> QWidget* { QStringLiteral("mail-attachment"), ":/icons/attachment.png", tr("Attach Fileā¦"), tr("Add one or more files to this message. Files can also be dropped " - "onto the message.")); + "onto the message."), + QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_A)); // Removing takes a part out of the message being composed; it deletes // nothing on disk, and the wording and icon both stay away from suggesting // it does. @@ -684,6 +803,28 @@ auto EMailPageView::build_message_tab() -> QWidget* { connect(attachment_list_, &QTreeWidget::itemSelectionChanged, this, &EMailPageView::slot_selection_changed); + // Everything the four buttons below do, on the row the user is pointing at. + // The buttons stay: this is the second way to reach them, for the people who + // look for it on the thing itself. + attachment_list_->setContextMenuPolicy(Qt::CustomContextMenu); + connect(attachment_list_, &QTreeWidget::customContextMenuRequested, this, + &EMailPageView::slot_attachment_menu); + + // What a double-click on a file has meant everywhere else for thirty years. + connect(attachment_list_, &QTreeWidget::itemDoubleClicked, this, + [this](QTreeWidgetItem* item, int) { open_attachment(item); }); + + // The keys a list of files is expected to answer to. Delete goes through + // slot_remove_attachment(), so it asks the same question the button does + // rather than being a quieter way to destroy the same bytes. + auto* del = new QShortcut(QKeySequence::Delete, attachment_list_); + del->setContext(Qt::WidgetShortcut); + connect(del, &QShortcut::activated, this, + &EMailPageView::slot_remove_attachment); + + connect(attachment_list_, &QTreeWidget::itemActivated, this, + [this](QTreeWidgetItem* item, int) { open_attachment(item); }); + for (auto* edit : {from_edit_, to_edit_, cc_edit_, bcc_edit_, subject_edit_}) { connect(edit, &QLineEdit::textEdited, this, [this]() { mark_dirty(); }); @@ -801,12 +942,37 @@ void EMailPageView::apply_content_lock() { } setToolTip(notice); + + // An HTML body is shown as its source, deliberately, and the user is owed + // that sentence -- without it the body looks like a rendering that failed. + // It shares the banner rather than getting one of its own: two tinted strips + // stacked above the message would cost more room than either is worth. The + // lock wins, because it is the reason the message will not take input, and + // the HTML note falls back to the body's tooltip. + const auto html_notice = + body_is_html_ + ? tr("This message was written in HTML. It is shown as its source " + "rather than rendered: nothing here loads images or follows " + "links.") + : QString(); + + if (!locked && body_is_html_) { + icon = ":/icons/code.png"; + notice = html_notice; + } + + // Said on the body itself in the case the banner could not take: a locked + // HTML message is both things at once, and only one of them fits up there. + if (body_view_ != nullptr) { + body_view_->setToolTip(locked ? html_notice : QString()); + } + if (locked_notice_ != nullptr) locked_notice_->setText(notice); if (locked_banner_icon_ != nullptr && !icon.isEmpty()) { locked_banner_icon_->setPixmap(QIcon(icon).pixmap(16, 16)); } if (locked_banner_ != nullptr) { - locked_banner_->setVisible(locked && !notice.isEmpty()); + locked_banner_->setVisible(!notice.isEmpty()); } style_as_locked(locked); @@ -947,6 +1113,25 @@ void EMailPageView::LoadFromSource(const QByteArray& source) { // host hand the original bytes to a verify untouched. dirty_ = false; inspection_stale_ = false; + + focus_first_field(); +} + +void EMailPageView::focus_first_field() { + // Nothing set focus at all before this, so it landed on the first widget in + // the form -- From -- which on a received message is a box that will not + // take input. Where it goes now follows what the tab is FOR: a draft is + // going to be addressed, and a message that arrived is going to be read. + if (document_is_received_) { + if (body_stack_ != nullptr && body_stack_->currentWidget() != nullptr) { + body_stack_->currentWidget()->setFocus(Qt::OtherFocusReason); + } + return; + } + + // Not From: it is normally already filled in from the account, and the + // first thing anyone actually types is the recipient. + if (to_edit_ != nullptr) to_edit_->setFocus(Qt::OtherFocusReason); } void EMailPageView::refresh_structure() { @@ -958,7 +1143,7 @@ void EMailPageView::refresh_structure() { // would be worse than showing none. signature_results_.clear(); recipient_rows_.clear(); - regions_verified_ = false; + verify_state_ = EMailVerifyState::kNOT_ATTEMPTED; vmime::shared_ptr<vmime::message> parsed; if (!CheckIfEMLMessage(last_source_, parsed)) { @@ -1133,7 +1318,7 @@ void EMailPageView::NotifyKeyringChanged() { if (regions_.isEmpty()) return; // The previous answers were about a keyring that no longer exists. - regions_verified_ = false; + verify_state_ = EMailVerifyState::kNOT_ATTEMPTED; signature_results_.clear(); // Only redo the work now if it is being looked at; otherwise the next visit @@ -1155,14 +1340,25 @@ void EMailPageView::sync_inspection() { collect_fields(); QString eml; + // Re-encodes every attachment, on this thread. Long enough on a message with + // a few large parts that the window would otherwise sit there looking dead. + const EMailBusyCursor busy; + if (BuildMimeEML(message_, message_.body, message_.attachments, eml) != 0) { MLogWarn("failed to serialize the edited message for inspection: " + eml); // Show nothing rather than the previous document's structure: a stale tree // presented as the current one is worse than an empty tab. The flag stays // set, so the next visit tries again. - structure_view_->Clear(); - header_view_->Clear(); - security_view_->Clear(); + // + // Said rather than left blank: three empty tabs after a tab switch read as + // a message with nothing in it, which is a different and much worse claim + // than "this could not be read". + const auto why = + tr("This message could not be assembled for inspection, so there is " + "nothing to show here. The message itself is unchanged."); + structure_view_->ShowNotice(why); + header_view_->ShowNotice(why); + security_view_->ShowNotice(why); return; } @@ -1180,19 +1376,56 @@ void EMailPageView::sync_inspection() { } void EMailPageView::ensure_regions_verified() { - if (regions_verified_ || regions_.isEmpty() || last_source_.isEmpty()) return; + if (verify_state_ != EMailVerifyState::kNOT_ATTEMPTED || regions_.isEmpty() || + last_source_.isEmpty()) { + return; + } + + // Once per load, unless asked again. A verification that produced nothing is + // still an answer, and repeating it on every focus would re-run the engine + // for no new information -- but it is an answer the user is now able to tell + // apart from "not tried yet", and so is able to ask to have taken again. + run_verification(); +} - // Once per load. A failed or partial verification is still an answer, and - // retrying it every time the tab is focused would re-run the engine for no - // new information. - regions_verified_ = true; +void EMailPageView::run_verification() { + // Can wait on the agent, on this thread. + const EMailBusyCursor busy; signature_results_.clear(); VerifyEMLRegions(GFGpgCurrentGpgContextChannel(), last_source_, tree_root_, regions_, signature_results_); + + // The distinction the Security tab needs: a signed message with no results + // after a verification has been attempted is a different thing from one + // nothing has looked at yet, and saying "nothing has verified it yet" for + // both left the user with no way to know which they were reading. + verify_state_ = signature_results_.isEmpty() + ? EMailVerifyState::kATTEMPTED_EMPTY + : EMailVerifyState::kVERIFIED; } void EMailPageView::slot_derive_message(int mode) { + // What the user asked for, for the failure notices below. A refusal that + // does not name the action the user just clicked leaves them guessing which + // of the three buttons it came from. + const auto what = [mode]() -> QString { + switch (static_cast<EMailReplyMode>(mode)) { + case EMailReplyMode::kREPLY: + return tr("Cannot Reply"); + case EMailReplyMode::kREPLY_ALL: + return tr("Cannot Reply to All"); + case EMailReplyMode::kFORWARD: + break; + } + return tr("Cannot Forward"); + }(); + + // Nothing below this point changes THIS message, so every failure can say + // so. It is the one reassurance worth giving: the user has just watched an + // action on a message they care about not happen. + const auto intact = tr("\n\nThis message itself has not been changed."); + // Walk up to the tab widget that owns this page. The module cannot link the // UI library, so the host is reached by invokable name rather than by type. QObject* edit = parent(); @@ -1204,6 +1437,11 @@ void EMailPageView::slot_derive_message(int mode) { if (edit == nullptr) { MLogWarn("cannot derive a message: no tab host above this view"); + QMessageBox::warning( + this, what, + tr("A new message cannot be opened from here, because this view is " + "not inside a window that holds tabs.") + + intact); return; } @@ -1226,6 +1464,12 @@ void EMailPageView::slot_derive_message(int mode) { QString eml; if (BuildMimeEML(derived, quoted, derived.attachments, eml) != 0) { MLogWarn("failed to build the derived message"); + QMessageBox::warning( + this, what, + tr("The new message could not be assembled. This usually means a part " + "of the original could not be re-encoded -- an attachment, most " + "often.") + + intact); return; } @@ -1238,6 +1482,9 @@ void EMailPageView::slot_derive_message(int mode) { if (page == nullptr) { MLogWarn("the host did not create a tab for the derived message"); + QMessageBox::warning( + this, what, + tr("A tab for the new message could not be opened.") + intact); return; } @@ -1246,6 +1493,11 @@ void EMailPageView::slot_derive_message(int mode) { auto* view = page->findChild<EMailPageView*>(); if (view == nullptr) { MLogWarn("the new tab has no message view to fill"); + QMessageBox::warning( + this, what, + tr("The new tab opened, but it is not showing a message view, so " + "there is nowhere to put the reply. The tab can be closed.") + + intact); return; } @@ -1279,13 +1531,17 @@ auto EMailPageView::build_locked_panel() -> QWidget* { locked_recipients_->setAlignment(Qt::AlignCenter); locked_recipients_->setWordWrap(true); locked_recipients_->setTextInteractionFlags(Qt::TextSelectableByMouse); - { - auto palette = locked_recipients_->palette(); - palette.setColor(QPalette::WindowText, MutedColor(this)); - locked_recipients_->setPalette(palette); - } layout->addWidget(locked_recipients_); + // Whether this user can open it at all, which is the question the panel was + // silent on. Pressing Decrypt with no matching private key produces an + // error from the host and a panel that comes back looking exactly as it did, + // so the only way to learn it was never going to work was to try it. + locked_capability_ = new QLabel(panel); + locked_capability_->setAlignment(Qt::AlignCenter); + locked_capability_->setWordWrap(true); + layout->addWidget(locked_capability_); + auto* row = new QHBoxLayout(); row->addStretch(); locked_decrypt_button_ = @@ -1336,9 +1592,70 @@ void EMailPageView::refresh_locked_panel() { named.isEmpty() ? tr("The recipients are not named in the headers.") : tr("Addressed to %1").arg(named.join(", "))); + refresh_locked_capability(named); + locked_decrypt_button_->setEnabled(true); } +void EMailPageView::refresh_locked_capability(const QStringList& named) { + // The addresses this user holds the secret half for. An address being here + // is not a promise the decryption will work -- the message may be encrypted + // to a key whose address the headers never mention, which is exactly why + // Decrypt stays enabled either way -- but its ABSENCE across every named + // recipient is worth saying before the user presses a button that cannot + // succeed. + char** addresses = nullptr; + int count = 0; + if (GFGpgListKeyAddresses(GFGpgCurrentGpgContextChannel(), 1, &addresses, + &count) != 0) { + locked_capability_->setVisible(false); + return; + } + + QStringList mine; + for (int i = 0; i < count; ++i) { + const auto address = AddressOfUid(QString::fromUtf8(addresses[i])); + if (!address.isEmpty()) mine.append(address.toLower()); + } + GFGpgFreeStringArray(addresses, count); + + // Nothing knowable either way: no recipient is named, or the keyring holds + // no private key at all. Saying something in that case would be inventing + // an answer to a question that was not asked. + if (named.isEmpty() || mine.isEmpty()) { + locked_capability_->setVisible(!mine.isEmpty()); + if (!mine.isEmpty()) { + EMailSetLabelColor(locked_capability_, MutedColor(this)); + locked_capability_->setText( + tr("The recipients are not named, so whether you can open this " + "cannot be told until you try.")); + } + return; + } + + QStringList matched; + for (const auto& entry : named) { + const auto address = AddressOfUid(entry).toLower(); + if (!address.isEmpty() && mine.contains(address)) matched.append(address); + } + + locked_capability_->setVisible(true); + if (matched.isEmpty()) { + EMailSetLabelColor(locked_capability_, ThemeColor(this, &GFUIWarningColor)); + locked_capability_->setText( + tr("You do not hold a private key for any of these addresses. Unless " + "the message was also encrypted to a key that is not named here, " + "it cannot be opened on this computer.")); + return; + } + + EMailSetLabelColor(locked_capability_, AccentColor(this, true)); + locked_capability_->setText( + matched.size() == 1 + ? tr("You hold the private key for %1.").arg(matched.front()) + : tr("You hold private keys for %1.").arg(matched.join(", "))); +} + auto EMailPageView::AttachPublicKey(const QByteArray& key_data, const QString& suggested_name) -> int { if (key_data.isEmpty()) return kEMAIL_ADD_NOT_HANDLED; @@ -1412,6 +1729,12 @@ auto EMailPageView::refuse_when_locked(const QString& what) -> bool { } void EMailPageView::refresh_body_view() { + // Cleared before the body view is told anything, so the flag can only be set + // again by the view itself saying it fell back to source. + // apply_content_lock() runs after this function in LoadFromSource() and is + // what puts the sentence on screen. + body_is_html_ = false; + // The plain-text part is the body whenever the message has one, and the // choice is the message's own rather than a mode the user picks. Only a // message that offers nothing but HTML falls through to its source, which @@ -1511,7 +1834,8 @@ void EMailPageView::refresh_security() { security_view_->SetMessage(security_state_, regions_, signature_results_, recipient_rows_, addresses, - GFGpgCurrentGpgContextChannel(), findings); + GFGpgCurrentGpgContextChannel(), findings, + verify_state_, !message_key_parts().isEmpty()); } /** @@ -1662,31 +1986,49 @@ void EMailPageView::install_address_hints() { } } +void EMailPageView::paint_security_button() { + // Colour only. Split from refresh_security_button() because that one also + // rebuilds the menu and announces that the available operations changed, + // and a theme change must not claim either of those happened. + QColor colour = MutedColor(this); + switch (security_state_) { + case EMailSecurityState::kENCRYPTED: + case EMailSecurityState::kSIGNED: + case EMailSecurityState::kSIGNED_ENCRYPTED: + colour = AccentColor(this, true); + break; + case EMailSecurityState::kMALFORMED_PGP: + colour = EMailWarningColor(this); + break; + case EMailSecurityState::kPLAIN: + break; + } + + auto palette = security_button_->palette(); + palette.setColor(QPalette::ButtonText, colour); + security_button_->setPalette(palette); +} + void EMailPageView::refresh_security_button() { QString text; QString icon; - QColor colour = MutedColor(this); switch (security_state_) { case EMailSecurityState::kENCRYPTED: text = tr("Encrypted"); icon = ":/icons/lock.png"; - colour = AccentColor(this, true); break; case EMailSecurityState::kSIGNED: text = tr("Signed"); icon = ":/icons/signature.png"; - colour = AccentColor(this, true); break; case EMailSecurityState::kSIGNED_ENCRYPTED: text = tr("Encrypted and signed"); icon = ":/icons/lock.png"; - colour = AccentColor(this, true); break; case EMailSecurityState::kMALFORMED_PGP: text = tr("Malformed OpenPGP structure"); icon = ":/icons/warning.png"; - colour = EMailWarningColor(this); break; case EMailSecurityState::kPLAIN: // Stated plainly and quietly. An unprotected message is the ordinary @@ -1705,10 +2047,7 @@ void EMailPageView::refresh_security_button() { security_button_->setVisible(true); security_button_->setIcon(QIcon(icon)); - - auto palette = security_button_->palette(); - palette.setColor(QPalette::ButtonText, colour); - security_button_->setPalette(palette); + paint_security_button(); // The button keeps the whole wording and draws as much of it as it was given // room for, so narrowing the window never loses what the message is. @@ -1772,10 +2111,13 @@ auto EMailPageView::AvailableCryptoOperations() -> QStringList { void EMailPageView::rebuild_security_menu() { security_menu_->clear(); - const auto show_details = [this]() { - if (tabs_ != nullptr && security_view_ != nullptr) { - tabs_->setCurrentWidget(security_view_); - } + // Each of these items is a question -- what signed this, who is it + // encrypted to -- and answering it by opening a tab and leaving the user to + // find the row themselves answers a different, easier question. + const auto show_details = [this](const QString& section) { + if (tabs_ == nullptr || security_view_ == nullptr) return; + tabs_->setCurrentWidget(security_view_); + security_view_->RevealSection(section); }; const auto request = [this](const QString& op) { emit SignalCryptoOperationRequested(op); @@ -1812,7 +2154,8 @@ void EMailPageView::rebuild_security_menu() { case EMailSecurityState::kSIGNED: { add_read(tr("Verify Signature"), "verify"); auto* details = security_menu_->addAction(tr("Signature Details...")); - connect(details, &QAction::triggered, this, show_details); + connect(details, &QAction::triggered, this, + [show_details]() { show_details(kSectionSignatures); }); security_menu_->addSeparator(); add_op(tr("Sign Again With Another Key..."), "sign"); auto* remove = security_menu_->addAction(tr("Remove Signature...")); @@ -1827,7 +2170,8 @@ void EMailPageView::rebuild_security_menu() { add_read(tr("Decrypt and Verify"), "decrypt_verify"); security_menu_->addSeparator(); auto* details = security_menu_->addAction(tr("Encryption Details...")); - connect(details, &QAction::triggered, this, show_details); + connect(details, &QAction::triggered, this, + [show_details]() { show_details(kSectionRecipients); }); break; } @@ -1836,7 +2180,8 @@ void EMailPageView::rebuild_security_menu() { add_read(tr("Decrypt"), "decrypt"); security_menu_->addSeparator(); auto* details = security_menu_->addAction(tr("Details...")); - connect(details, &QAction::triggered, this, show_details); + connect(details, &QAction::triggered, this, + [show_details]() { show_details(kSectionSignatures); }); auto* remove = security_menu_->addAction(tr("Remove Signature...")); // Only a signature this view can actually see can be lifted off. When @@ -1858,7 +2203,8 @@ void EMailPageView::rebuild_security_menu() { case EMailSecurityState::kMALFORMED_PGP: { auto* details = security_menu_->addAction(tr("What Is Wrong With This Message?")); - connect(details, &QAction::triggered, this, show_details); + connect(details, &QAction::triggered, this, + [show_details]() { show_details(kSectionFindings); }); security_menu_->addSeparator(); add_read(tr("Try to Verify Anyway"), "verify"); add_read(tr("Try to Decrypt Anyway"), "decrypt"); @@ -1983,8 +2329,6 @@ void EMailPageView::refresh_attachments() { bool any_unsigned = false; bool any_signed_info = false; - const auto muted = MutedColor(this); - for (const auto& att : message_.attachments) { auto* item = new QTreeWidgetItem(attachment_list_); item->setText(kColName, @@ -1997,21 +2341,21 @@ void EMailPageView::refresh_attachments() { // Type and size describe the name; they are not values in their own right. item->setText(kColType, att.mime_type); - item->setForeground(kColType, muted); + EMailSetCellTone(item, kColType, EMailTone::kMUTED, this); item->setText(kColSize, HumanSize(att.data.size())); - item->setForeground(kColSize, muted); + EMailSetCellTone(item, kColSize, EMailTone::kMUTED, this); item->setTextAlignment(kColSize, Qt::AlignRight | Qt::AlignVCenter); if (att.inside_signed_part) { item->setText(kColSigned, tr("signed")); - item->setForeground(kColSigned, AccentColor(this, true)); + EMailSetCellTone(item, kColSigned, EMailTone::kGOOD, this); any_signed_info = true; } else { // Not painted red: nothing here is broken and nothing is irreversible. // The part simply arrived without the signature vouching for it, which // is a caveat rather than an alarm. item->setText(kColSigned, tr("unsigned")); - item->setForeground(kColSigned, ThemeColor(this, &GFUIWarningColor)); + EMailSetCellTone(item, kColSigned, EMailTone::kWARN, this); any_unsigned = true; } @@ -2059,7 +2403,12 @@ void EMailPageView::refresh_attachments() { const auto wanted = header_height + row_height * static_cast<int>(message_.attachments.size()) + 8; - attachment_list_->setFixedHeight(qMin(wanted, 160)); + // Capped against the page rather than against a constant. A fixed 160px + // meant a message with a dozen parts scrolled a box four rows tall no + // matter how much room the window had; a third of the page is the same + // restraint expressed as a share of what is actually available. + const auto ceiling = qMax(kMinAttachmentHeight, height() / 3); + attachment_list_->setFixedHeight(qMin(wanted, ceiling)); } attachment_list_->resizeColumnToContents(kColType); @@ -2068,6 +2417,19 @@ void EMailPageView::refresh_attachments() { slot_selection_changed(); } +void EMailPageView::report_attachment_status(const QString& note) { + // Said where the attachments are, rather than in a dialog the user has to + // dismiss: this is the outcome of something they just asked for and were + // watching, not news that has to interrupt them. + attachment_heading_->setVisible(true); + attachment_heading_->setText(note); + + // Back to describing the list afterwards. The heading is the list's label + // first and a status line only in passing, so it must not keep saying + // "Saved 3 files." over a list the user has since changed. + QTimer::singleShot(kStatusNoteMs, this, [this]() { refresh_attachments(); }); +} + auto EMailPageView::SaveToSource() -> QByteArray { // Refused outright rather than merely never reached: the host probes this by // name, and no future call site may be able to rewrite a forensic document. @@ -2188,7 +2550,7 @@ void EMailPageView::WipeContent() { regions_.clear(); signature_results_.clear(); recipient_rows_.clear(); - regions_verified_ = false; + verify_state_ = EMailVerifyState::kNOT_ATTEMPTED; inspection_stale_ = false; security_state_ = EMailSecurityState::kPLAIN; view_state_ = EMailViewState{}; @@ -2258,21 +2620,22 @@ void EMailPageView::attach_paths(const QStringList& paths) { // behind the disabled button and the refused drag. if (content_lock() != EMailLockReason::kNONE) return; + // Collected rather than reported as they happen: a folder dropped on the + // message can easily be twenty unreadable paths, and twenty modal dialogs in + // a row is a worse way to learn that than one list. + QStringList refused; + for (const auto& path : paths) { // Directories arrive from a drop as readily as files do, and reading one // yields nothing useful. if (QFileInfo(path).isDir()) { - QMessageBox::warning( - this, tr("Attach File"), - tr("%1 is a folder and was not attached.").arg(path)); + refused.append(tr("%1 -- a folder").arg(path)); continue; } QFile file(path); if (!file.open(QIODevice::ReadOnly)) { - QMessageBox::warning( - this, tr("Attach File"), - tr("Cannot read %1:\n%2.").arg(path, file.errorString())); + refused.append(tr("%1 -- %2").arg(path, file.errorString())); continue; } @@ -2289,6 +2652,14 @@ void EMailPageView::attach_paths(const QStringList& paths) { refresh_attachments(); mark_dirty(); + + if (!refused.isEmpty()) { + QMessageBox::warning( + this, tr("Attach Files"), + refused.size() == paths.size() + ? tr("Nothing was attached:\n\n%1").arg(refused.join("\n")) + : tr("These were not attached:\n\n%1").arg(refused.join("\n"))); + } } void EMailPageView::dragEnterEvent(QDragEnterEvent* event) { @@ -2337,6 +2708,152 @@ void EMailPageView::dropEvent(QDropEvent* event) { attach_paths(paths); } +void EMailPageView::slot_attachment_menu(const QPoint& pos) { + auto* item = attachment_list_->itemAt(pos); + if (item == nullptr) return; + + const auto row = attachment_list_->indexOfTopLevelItem(item); + if (row < 0 || row >= message_.attachments.size()) return; + const auto& att = message_.attachments.at(row); + + // The row under the cursor is what the menu is about, so it is what the menu + // acts on. Clicking a row already selects it, which keeps this in step with + // the buttons, which act on the selection. + if (!item->isSelected()) { + attachment_list_->setCurrentItem(item); + } + + QMenu menu(this); + auto* open = menu.addAction(tr("Open")); + auto* save = menu.addAction(tr("Save...")); + + QAction* import = nullptr; + if (att.is_openpgp_key) { + menu.addSeparator(); + // Offered only where it means something. A message carrying a public key + // is how most first contacts arrive, and until now the only way to act on + // one was to save it and import it from the key manager. + import = menu.addAction(tr("Import Key")); + } + + menu.addSeparator(); + auto* copy_name = menu.addAction(tr("Copy Name")); + auto* remove = menu.addAction(tr("Remove")); + remove->setEnabled(content_lock() == EMailLockReason::kNONE); + + auto* chosen = menu.exec(attachment_list_->viewport()->mapToGlobal(pos)); + if (chosen == nullptr) return; + + if (chosen == open) { + open_attachment(item); + } else if (chosen == save) { + slot_save_attachment(); + } else if (import != nullptr && chosen == import) { + import_attachment_key(att); + } else if (chosen == copy_name) { + QApplication::clipboard()->setText(att.filename); + } else if (chosen == remove) { + slot_remove_attachment(); + } +} + +auto EMailPageView::message_key_parts() const -> QList<const EMailPart*> { + QList<const EMailPart*> keys; + for (const auto* part : FlattenMimeTree(tree_root_)) { + if (part != nullptr && part->is_openpgp_key && !part->data.isEmpty()) { + keys.append(part); + } + } + return keys; +} + +void EMailPageView::import_message_keys() { + const auto keys = message_key_parts(); + if (keys.isEmpty()) return; + + // Every key part in the message, in one go. A message that carries two of + // them carries them for the same reason, and asking twice about one act the + // user already asked for would be a dialog per part. + for (const auto* part : keys) { + GFGpgImportKeys(GFGpgCurrentGpgContextChannel(), this, + part->data.constData(), + static_cast<int>(part->data.size())); + } + + // The keyring is what the Security tab was reporting against, so it has to + // be asked again now rather than on the next visit -- the user is looking + // straight at the row they just acted on. + NotifyKeyringChanged(); + refresh_security(); +} + +void EMailPageView::import_attachment_key(const EMailAttachment& att) { + // The host owns the import, including whatever it wants to say about what + // came of it: this module has no business inventing a second report of the + // same operation. + GFGpgImportKeys(GFGpgCurrentGpgContextChannel(), this, att.data.constData(), + static_cast<int>(att.data.size())); +} + +void EMailPageView::open_attachment(QTreeWidgetItem* item) { + if (item == nullptr) return; + + const auto row = attachment_list_->indexOfTopLevelItem(item); + if (row < 0 || row >= message_.attachments.size()) return; + const auto& att = message_.attachments.at(row); + + const auto name = SanitizeAttachmentFileName(att.filename, att.mime_type); + + // Handing a file from a message straight to whatever the desktop has + // registered for it is how mail clients have historically run attachments + // for people. What can be opened here is limited to the types where that is + // a viewing, not an execution -- everything else is offered as a save, which + // puts the decision somewhere the user can see it. + if (!IsSafeToOpenAttachment(att)) { + QMessageBox::information( + this, tr("Open Attachment"), + tr("%1 is not a kind of file this program will open for you, because " + "opening it would mean running it or handing it to something that " + "might.\n\nSave it instead, and open it yourself if you are sure " + "of where it came from.") + .arg(name)); + slot_save_attachment(); + return; + } + + // Written where the user cannot mistake it for a file of their own, and + // named so the application that opens it sees the extension it expects. + auto* temp = new QTemporaryDir(); + if (!temp->isValid()) { + delete temp; + QMessageBox::warning(this, tr("Open Attachment"), + tr("A temporary folder to open %1 from could not be " + "created.") + .arg(name)); + return; + } + + // Kept alive for as long as this view is: the viewer is a separate process + // and may still be reading the file long after this function has returned. + // Removed when the tab closes, which is the last moment it is still ours to + // remove. + temp_dirs_.emplace_back(temp); + + if (!write_attachment(att, temp->path(), name)) { + QMessageBox::warning( + this, tr("Open Attachment"), + tr("%1 could not be written out to be opened.").arg(name)); + return; + } + + if (!QDesktopServices::openUrl( + QUrl::fromLocalFile(QDir(temp->path()).filePath(name)))) { + QMessageBox::warning( + this, tr("Open Attachment"), + tr("Nothing on this system is registered to open %1.").arg(name)); + } +} + void EMailPageView::slot_remove_attachment() { // Removing a part changes the message, so it follows the same lock as // adding one. The button is already disabled; this is the backstop. @@ -2352,6 +2869,33 @@ void EMailPageView::slot_remove_attachment() { } std::sort(rows.begin(), rows.end(), std::greater<>()); + // On a message that arrived from somewhere else, these bytes may be the only + // copy in existence -- an attachment out of a decrypted message has never + // been on disk -- and the removal below wipes the buffer before dropping it. + // Asked only here: warning every time a file is taken back out of one's own + // draft would be noise, and there the original is still where it came from. + if (document_is_received_) { + QStringList names; + for (const auto row : rows) { + if (row >= 0 && row < message_.attachments.size()) { + names.prepend( + SanitizeAttachmentFileName(message_.attachments[row].filename, + message_.attachments[row].mime_type)); + } + } + + if (QMessageBox::question( + this, tr("Remove Attachment"), + tr("Take out of this message:\n\n%1\n\nThis message came from " + "somewhere else, so unless these have been saved already this " + "is the only copy. Removing them cannot be undone.") + .arg(names.join("\n")), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel) != QMessageBox::Yes) { + return; + } + } + for (const auto row : rows) { if (row >= 0 && row < message_.attachments.size()) { auto& data = message_.attachments[row].data; @@ -2399,6 +2943,18 @@ void EMailPageView::slot_save_attachment() { chosen.append(message_.attachments[row]); } } + save_attachments(chosen); +} + +void EMailPageView::slot_save_all_attachments() { + // Every attachment, passed explicitly. This used to call selectAll() and + // reuse the selection path, which left everything selected afterwards -- so + // a Save All followed by a Remove took out the whole message's attachments + // rather than the one row the user thought was still selected. + save_attachments(message_.attachments); +} + +void EMailPageView::save_attachments(const QList<EMailAttachment>& chosen) { if (chosen.isEmpty()) return; auto default_dir = UnStrDup(GFUIDefaultUserFilePath()); @@ -2448,19 +3004,27 @@ void EMailPageView::slot_save_attachment() { return; } - // The names are reported back because sanitizing or de-duplicating may have - // changed them, and a silent rename is worse than a noisy one. - const auto saved_count = written.size(); - const auto saved_line = saved_count == 1 - ? tr("Saved 1 file:") - : tr("Saved %1 files:").arg(saved_count); - QMessageBox::information(this, tr("Save Attachments"), - saved_line + "\n" + written.join("\n")); -} + // A rename is still reported, because sanitizing or de-duplicating may have + // changed a name and a silent rename is worse than a noisy one. A save that + // wrote what it was asked to write is not: a modal confirming that a routine + // operation did what it said is a click the user has to spend on nothing. + QStringList renamed; + for (int i = 0; i < chosen.size(); ++i) { + const auto asked = + SanitizeAttachmentFileName(chosen[i].filename, chosen[i].mime_type); + if (names[i] != asked) renamed.append(tr("%1 -> %2").arg(asked, names[i])); + } -void EMailPageView::slot_save_all_attachments() { - if (message_.attachments.isEmpty()) return; + if (!renamed.isEmpty()) { + QMessageBox::information( + this, tr("Save Attachments"), + tr("Saved. Some names were already taken in that folder, so these " + "were written under a different name:\n\n%1") + .arg(renamed.join("\n"))); + return; + } - attachment_list_->selectAll(); - slot_save_attachment(); + report_attachment_status(written.size() == 1 + ? tr("Saved 1 file.") + : tr("Saved %1 files.").arg(written.size())); } diff --git a/src/m_email/EMailPageView.h b/src/m_email/EMailPageView.h index faa606d..c0d4f84 100644 --- a/src/m_email/EMailPageView.h +++ b/src/m_email/EMailPageView.h @@ -30,7 +30,10 @@ #include <QHash> #include <QStringList> +#include <QTemporaryDir> #include <QWidget> +#include <memory> +#include <vector> #include "EMailModel.h" #include "EMailOutgoing.h" @@ -51,6 +54,8 @@ class EMailBodyView; class QStackedWidget; class QMenu; class QPushButton; +class QTreeWidgetItem; +class QEvent; /** * @brief The message view of an e-mail tab. @@ -279,6 +284,7 @@ class EMailPageView : public QWidget { protected: /// Accepts a drag only when it carries files and the document may change. + void changeEvent(QEvent* event) override; void dragEnterEvent(QDragEnterEvent* event) override; void dragMoveEvent(QDragMoveEvent* event) override; /// Attaches dropped files. @@ -294,6 +300,8 @@ class EMailPageView : public QWidget { void slot_remove_attachment(); void slot_save_attachment(); void slot_save_all_attachments(); + /// The attachment list's own menu, for the row at @p pos. + void slot_attachment_menu(const QPoint& pos); void slot_selection_changed(); /// Opens a new tab holding a message derived from this one. The current /// document is only read: deriving never modifies what it derives from. @@ -317,7 +325,36 @@ class EMailPageView : public QWidget { /// Saves one attachment into `dir` under `name`, atomically. auto write_attachment(const EMailAttachment& att, const QString& dir, const QString& name) -> bool; + /// Asks where to put @p chosen and writes them there. + /// + /// Takes the attachments explicitly rather than reading the selection, so + /// "save all" does not have to select everything to reuse this -- which left + /// the whole list selected for whatever the user clicked next. + void save_attachments(const QList<EMailAttachment>& chosen); + /// Says @p note where the attachment count normally is, briefly. + void report_attachment_status(const QString& note); + /// Writes @p item's attachment somewhere temporary and hands it to the + /// desktop. Refuses anything IsSafeToOpenAttachment() does not vouch for, + /// and offers a save instead. + void open_attachment(QTreeWidgetItem* item); + /// Hands @p att to the host's key import. + void import_attachment_key(const EMailAttachment& att); + /// Says whether this user holds a private key for any of @p named, so the + /// encrypted panel can answer "can I open this" before the user tries. + void refresh_locked_capability(const QStringList& named); + /// Every OpenPGP key part carried by the parsed message, in tree order. + [[nodiscard]] auto message_key_parts() const -> QList<const EMailPart*>; + /// Imports all of them, then asks the Security tab to describe the keyring + /// as it now is. + void import_message_keys(); void build_ui(); + /// The single place every colour in this view is decided. Run at the end of + /// build_ui() and again on a theme change, so nothing is coloured twice and + /// nothing keeps the old theme's greys. Must not touch fonts. + void apply_colors(); + /// Puts focus where this tab is about to be used: the recipient of a draft, + /// the body of a message that arrived. + void focus_first_field(); /// Why the document may not be modified, if it may not. [[nodiscard]] auto content_lock() const -> EMailLockReason; /// Applies content_lock() to every widget that can modify the document. @@ -360,6 +397,9 @@ class EMailPageView : public QWidget { /// menu of what can be done about it. The button keeps itself fitted to /// whatever room the action row leaves it. void refresh_security_button(); + /// Recolours the security button from the current state, and nothing else. + /// The half of refresh_security_button() that a repaint may run. + void paint_security_button(); /// Refreshes the Security tab from what parsing and any completed operation /// have established. Read-only: it never writes the document, so opening the /// tab cannot change the bytes. @@ -369,6 +409,9 @@ class EMailPageView : public QWidget { /// may carry several signatures and most tabs are never opened; verifying /// reads the message and writes nothing, so it is safe to run here. void ensure_regions_verified(); + /// Verifies every signed region now, whatever has been tried before, and + /// records which of EMailVerifyState the outcome was. Runs on this thread. + void run_verification(); /// Chooses between the formatted and plain renderings of the body, and /// shows the toggle only when the message actually offers both. void refresh_body_view(); @@ -396,7 +439,7 @@ class EMailPageView : public QWidget { /// produced it. Empty until an operation has actually run. QList<EMailSignatureResult> signature_results_; QList<EMailRecipientRow> recipient_rows_; - bool regions_verified_{false}; + EMailVerifyState verify_state_{EMailVerifyState::kNOT_ATTEMPTED}; /// Whether edits have left the inspection tabs describing an older document /// than the one the editor now holds. bool inspection_stale_{false}; @@ -415,8 +458,17 @@ class EMailPageView : public QWidget { /// SaveToSource() can tell "taken out of the original" from "built from the /// fields" -- only the latter may go through BuildMimeEML. QByteArray pending_replacement_; + /// Directories holding attachments written out to be opened. Kept for the + /// life of the tab rather than the call: the viewer is another process and + /// may still be reading the file long after openUrl() returned. Removed when + /// the tab closes, the last moment they are still ours to remove. + std::vector<std::unique_ptr<QTemporaryDir>> temp_dirs_; bool dirty_{false}; bool loading_{false}; + /// Whether the body on screen is HTML being shown as its own source. Set by + /// EMailBodyView rather than derived here, so the notice cannot claim + /// something different from what the view actually did. + bool body_is_html_{false}; /// When set, this document cannot be modified or reserialized at all. bool forensic_{false}; /// Whether this tab was opened on a message someone else produced, as @@ -462,10 +514,16 @@ class EMailPageView : public QWidget { /// Divider between the actions that derive a new message and the toggle /// that locks this one. Hidden with them when there is no message. QFrame* action_separator_{}; + /// The hairline between the envelope and the body. + QFrame* envelope_rule_{}; + /// The "From:", "To:" and so on. Kept so apply_colors() can find them again. + QList<QLabel*> captions_; QLabel* attachment_heading_{}; QLabel* unsigned_notice_{}; QToolButton* security_button_{}; QMenu* security_menu_{}; + /// Whether this user can open the encrypted message in front of them. + QLabel* locked_capability_{}; /// Says why the message cannot be edited, when it cannot. QLabel* locked_notice_{}; /// Carries locked_notice_ and its icon as one tinted strip, so the reason |
