/* * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs * Copyright (C) 2026 Danilo M. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2 as * published by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ #pragma once #include #include #include "config.h" #include "formattoolbar.h" // MarkdownFormat::Edit is used by value below, and // a type nested in a namespace cannot be // forward-declared from outside it. #include "types.h" class QAction; class QCheckBox; class QComboBox; class QLabel; class QLineEdit; class QListWidget; class QPlainTextEdit; class QTimer; class QToolBar; class QWidget; class MessageSender; /// One draft. A separate top-level window, several open at once. /// /// A QMainWindow rather than a dialog: a modal dialog cannot consult another /// message while writing, which is most of what replying is, and taking over /// the message pane fights the pane that exists to show what is being replied /// to. /// /// NO GEOMETRY RESTORE and no geometry save. CLAUDE.md records what /// saveGeometry does under a tiling compositor: it stores normalGeometry, the /// compositor owns the tile, and the restore is correct while looking broken. /// A whole session went into that once. The composer opens at a sensible /// default size and the compositor places it. /// /// It contains no MIME and no process logic: a composer bug and a MIME bug are /// found in different files. Everything it does with a message goes through /// MessageBuilder, DraftStore, MessageSender, MarkdownFormat and SendDialog. class ComposeWindow : public QMainWindow { Q_OBJECT public: /// \p mailRoot is the Maildir root, passed in rather than derived. /// /// There is NO Config::maildirPath(). The root comes from /// notmuch_config_get(NOTMUCH_CONFIG_MAIL_ROOT), wrapped by mailRootOf() /// which is file-static inside notmuchworker.cpp and needs the database /// handle. Item 124 records why this matters: notmuch can split the index /// from the mail, and under that layout database.path is the INDEX /// directory. Composing a destination from the wrong root would write /// drafts and sent copies into the Xapian tree. MainWindow already /// receives the root from the worker; it passes it here. ComposeWindow(const ComposeContext &context, const Config &config, const QString &mailRoot, QWidget *parent = nullptr); /// True when the buffer has changed since the last successful autosave. /// The quit path asks every open composer this. bool hasUnsavedEdits() const { return m_dirty; } /// True when the LAST autosave attempt failed. Escalated to its own /// dialog on the way out, because saving is what is already not working /// and quitting therefore loses that text. bool lastSaveFailed() const { return m_saveFailed; } /// Writes the current buffer to the drafts folder now. Returns false and /// leaves the banner up on failure. /// /// Returns TRUE when the account configures no drafts folder: nothing was /// written and nothing failed, and reporting a failure would make the quit /// path offer a retry for a state no retry can change. The composer /// running without draft protection is warned about at startup instead. bool saveDraftNow(); /// What the composer would send or save right now. /// /// Public so a test can assert on the message the widgets produce without /// building MIME, and so the quit path can be reasoned about from values. OutgoingMessage currentMessage() const; /// The paths currently attached, in the order they were attached. QStringList attachments() const { return m_attachments; } /// Attaches \p path, asking first when it is larger than /// [compose] attachment_warn_bytes. /// /// A warning rather than a refusal: the limit belongs to the recipient's /// server, which this application cannot know, so the user decides. void attachFile(const QString &path); /// A byte count as a figure a person reads. /// /// Static and public so the formatting is testable without a modal. The /// integer MB division this replaces produced "0 MB" for any /// attachment_warn_bytes under a megabyte, in both halves of the same /// sentence. static QString humanSize(qint64 bytes); /// Whether \p size would raise the large-attachment question. /// /// Split out so the threshold is testable without a modal. A limit of zero /// or less disables the warning outright rather than warning about /// everything. bool attachmentNeedsWarning(qint64 size) const; signals: /// The composer finished with its message, one way or another, and the /// registry should forget it. /// /// Emitted from the close path, so a registry connected to it can drop its /// pointer before WA_DeleteOnClose destroys the window. void closed(ComposeWindow *window); protected: /// The one place the registry is told, whichever route closes the window. void closeEvent(QCloseEvent *event) override; private: void buildUi(); void buildFormatToolbar(); void seedFields(); void seedBody(); void refreshAttachmentList(); void setInputsEnabled(bool enabled); void showSendFailure(const QString &stderrText); void applyEdit(const MarkdownFormat::Edit &edit); void markDirty(); void autosave(); void send(); void applyFormat(const QString &token); Account currentAccount() const; ComposeContext m_context; Config m_config; QString m_mailRoot; QStringList m_attachments; QLineEdit *m_to = nullptr; QLineEdit *m_cc = nullptr; QLineEdit *m_bcc = nullptr; QLineEdit *m_subject = nullptr; QComboBox *m_from = nullptr; QPlainTextEdit *m_body = nullptr; QCheckBox *m_sendHtml = nullptr; QLabel *m_banner = nullptr; QListWidget *m_attachmentList = nullptr; QWidget *m_sendLogPane = nullptr; QPlainTextEdit *m_sendLog = nullptr; QToolBar *m_formatToolbar = nullptr; QAction *m_sendAction = nullptr; QAction *m_attachAction = nullptr; QAction *m_detachAction = nullptr; QTimer *m_autosaveTimer = nullptr; MessageSender *m_sender = nullptr; QString m_draftPath; ///< The revision on disk, unlinked on the next write. /// A fingerprint of the message the last successful save wrote, for the /// dirty CHECK. /// /// NOT the built bytes, and that is a correction of the plan's draft. /// MessageBuilder generates a fresh Date and Message-ID on every build /// (measured, messagebuilder.cpp around the g_mime_message_set_date call), /// so two builds of an unchanged message never compare equal and a check /// on the bytes can never fire. It would read as working while writing a /// file, and an mbsync upload, on every debounce. QString m_savedFingerprint; bool m_dirty = false; bool m_saveFailed = false; /// True from the moment Send is pressed until the operation ends, however /// it ends: the countdown, the command, the sent copy. /// /// ONE flag, covering the whole operation, and an earlier revision had two /// because a narrower "committed and running" flag reads as the honest /// thing to guard a live SMTP conversation with. It is not: every question /// this window has to answer while sending has the same answer through the /// countdown as after it. A close during the countdown destroys the /// parented SendDialog and committed() never fires, so the user watches a /// countdown for a message that is never sent, and a second Send during /// the countdown opens a second popup. Splitting the two left the narrower /// flag written in three places and read in none. bool m_sendInFlight = false; /// Set once the message has gone, so the close that follows a successful /// send is neither refused nor made to write a draft. /// /// The close-REFUSAL half is load-bearing: m_sendInFlight is cleared in /// the same handler, and without m_finished the composer's own close would /// depend on that clear having already happened, which is a race rather /// than a guarantee. /// /// The last-moment-SAVE half is deliberately redundant, and it is worth /// saying so rather than letting the next reader mistake it for load /// bearing: the send handler already clears m_dirty, so either condition /// alone stops the save. Measured, each survives the other's removal and /// only dropping both puts the draft of an already-sent message back on /// disk. Kept because the two say different things, "nothing to write" and /// "this window is done", and a future path that finishes without clearing /// m_dirty would otherwise resurrect a sent message's draft silently. bool m_finished = false; };