Branch data Line data Source code
1 : : #include "ComposeWindow.h"
2 : :
3 : : #include "ui/ThemeManager.h"
4 : :
5 : : #include <QAction>
6 : : #include <QCloseEvent>
7 : : #include <QDragEnterEvent>
8 : : #include <QDir>
9 : : #include <QFile>
10 : : #include <QFileDialog>
11 : : #include <QFileIconProvider>
12 : : #include <QFileInfo>
13 : : #include <QFormLayout>
14 : : #include <QIcon>
15 : : #include <QLabel>
16 : : #include <QLineEdit>
17 : : #include <QMessageBox>
18 : : #include <QMimeData>
19 : : #include <QPushButton>
20 : : #include <QRegularExpression>
21 : : #include <QTemporaryDir>
22 : : #include <QToolBar>
23 : : #include <QTimer>
24 : : #include <QVBoxLayout>
25 : :
26 : : #include "data/AccountConfig.h"
27 : : #include "service/SmtpService.h"
28 : : #include "service/Rfc2822Builder.h"
29 : : #include "ui/ContactCompleter.h"
30 : : #include "data/ContactStore.h"
31 : : #include "ui/MentionTextEdit.h"
32 : : #include "ui/PlainTextMessageBox.h"
33 : : #include <QEvent>
34 : :
35 : :
36 : : // 67.B3: all colors come from ThemeManager tokens (docs/DESIGN.md §2);
37 : : // stylesheets are built at construction time with the active palette.
38 : 162 : static QString tok(const char *token) {
39 [ + - + - ]: 162 : return ThemeManager::instance().color(QLatin1String(token));
40 : : }
41 : :
42 [ + - ]: 81 : ComposeWindow::ComposeWindow(QWidget *parent) : QDialog(parent) {
43 [ + - ]: 81 : setupUi();
44 : :
45 [ + - + - : 81 : m_smtp = new SmtpService(this);
- + - - ]
46 [ + - ]: 81 : connect(m_smtp, &SmtpService::sendSuccess, this, [this]() {
47 [ + - + - ]: 1 : m_statusLabel->setText(tr("Mail sent!"));
48 : 1 : m_sendAction->setEnabled(true);
49 : 1 : m_wasSentSuccessfully = true; // T-177: prevent draft-save prompt
50 : : // T-155: Emit recipients for contact tracking
51 [ + - + - : 1 : emit recipientsSent(parseRecipients(m_toEdit->text()),
+ - ]
52 [ + - + - ]: 2 : parseRecipients(m_ccEdit->text()),
53 [ + - + - ]: 2 : parseRecipients(m_bccEdit->text()));
54 : 1 : emit messageSent();
55 [ + - ]: 1 : QTimer::singleShot(1500, this, &QDialog::accept);
56 : 1 : });
57 [ + - ]: 81 : connect(m_smtp, &SmtpService::sendFailed, this, [this](const QString &err) {
58 [ + - + - : 2 : m_statusLabel->setText(tr("Error: %1").arg(err));
+ - ]
59 : 1 : m_sendAction->setEnabled(true);
60 : 1 : });
61 : 81 : connect(m_smtp, &SmtpService::statusMessage, this,
62 [ + - ]: 82 : [this](const QString &msg) { m_statusLabel->setText(msg); });
63 : 81 : }
64 : :
65 : 81 : void ComposeWindow::setupUi() {
66 [ + - + - ]: 81 : setWindowTitle(tr("New Message"));
67 : 81 : setMinimumSize(550, 400);
68 : 81 : resize(750, 600);
69 : :
70 [ + - - + : 81 : auto *mainLayout = new QVBoxLayout(this);
- - ]
71 : 81 : mainLayout->setSpacing(0);
72 : 81 : mainLayout->setContentsMargins(0, 0, 0, 0);
73 : :
74 : : // ═══ Toolbar ═══
75 [ + - - + : 81 : m_toolbar = new QToolBar(this);
- - ]
76 [ + - ]: 162 : m_toolbar->setObjectName(QStringLiteral("composeToolbar"));
77 : 81 : m_toolbar->setMovable(false);
78 [ + - ]: 81 : m_toolbar->setIconSize(QSize(18, 18));
79 : 81 : m_toolbar->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
80 : :
81 [ + - ]: 81 : m_discardAction = m_toolbar->addAction(
82 [ + - ]: 162 : QIcon::fromTheme(QStringLiteral("edit-delete"),
83 [ + - ]: 162 : QIcon::fromTheme(QStringLiteral("user-trash"))),
84 [ + - ]: 162 : tr("Discard"));
85 : 81 : connect(m_discardAction, &QAction::triggered, this,
86 [ + - ]: 81 : &ComposeWindow::onDiscardClicked);
87 : :
88 [ + - ]: 81 : m_attachAction = m_toolbar->addAction(
89 [ + - ]: 162 : QIcon::fromTheme(QStringLiteral("mail-attachment"),
90 [ + - ]: 162 : QIcon::fromTheme(QStringLiteral("attachment"))),
91 [ + - ]: 162 : tr("Attach"));
92 : 81 : connect(m_attachAction, &QAction::triggered, this,
93 [ + - ]: 81 : &ComposeWindow::onAttachClicked);
94 : :
95 : 81 : m_toolbar->addSeparator();
96 [ + - - + : 81 : auto *spacer = new QWidget(this);
- - ]
97 : 81 : spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
98 : 81 : m_toolbar->addWidget(spacer);
99 : :
100 [ + - ]: 81 : m_sendAction = m_toolbar->addAction(
101 [ + - ]: 162 : QIcon::fromTheme(QStringLiteral("mail-send"),
102 [ + - ]: 162 : QIcon::fromTheme(QStringLiteral("document-send"))),
103 [ + - ]: 162 : tr("Send"));
104 : 81 : connect(m_sendAction, &QAction::triggered, this,
105 [ + - ]: 81 : &ComposeWindow::onSendClicked);
106 : :
107 : : // Style the send button blue (object name → main.qss rule)
108 : 81 : auto *sendWidget = m_toolbar->widgetForAction(m_sendAction);
109 [ + - ]: 81 : if (sendWidget) {
110 [ + - ]: 162 : sendWidget->setObjectName(QStringLiteral("composeSendButton"));
111 : : }
112 : :
113 [ + - ]: 81 : mainLayout->addWidget(m_toolbar);
114 : :
115 : : // ═══ Header Fields ═══
116 [ + - - + : 81 : auto *headerWidget = new QWidget(this);
- - ]
117 [ + - - + : 81 : auto *headerLayout = new QFormLayout(headerWidget);
- - ]
118 : 81 : headerLayout->setContentsMargins(12, 8, 12, 8);
119 : 81 : headerLayout->setSpacing(8);
120 : 81 : headerLayout->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter);
121 : : // Sprint 69: header field styling comes from global QLineEdit/QLabel rules
122 : : // in main.qss — no inline QSS (was 3px radius, non-token).
123 : :
124 [ + - - + : 81 : m_fromEdit = new QLineEdit(this);
- - ]
125 [ + - ]: 162 : m_fromEdit->setObjectName(QStringLiteral("composeFromEdit"));
126 : 81 : m_fromEdit->setReadOnly(true);
127 [ + - + - ]: 81 : headerLayout->addRow(tr("From:"), m_fromEdit);
128 : :
129 [ + - - + : 81 : m_toEdit = new QLineEdit(this);
- - ]
130 [ + - + - ]: 81 : m_toEdit->setPlaceholderText(tr("recipient@example.com"));
131 [ + - + - ]: 81 : headerLayout->addRow(tr("To:"), m_toEdit);
132 : :
133 : : // CC field
134 [ + - - + : 81 : m_ccEdit = new QLineEdit(this);
- - ]
135 [ + - + - ]: 81 : m_ccEdit->setPlaceholderText(tr("Optional"));
136 [ + - + - ]: 81 : headerLayout->addRow(tr("CC:"), m_ccEdit);
137 : :
138 : : // BCC field (hidden by default)
139 [ + - - + : 81 : m_bccEdit = new QLineEdit(this);
- - ]
140 [ + - + - ]: 81 : m_bccEdit->setPlaceholderText(tr("Optional"));
141 : 81 : m_bccEdit->hide();
142 : :
143 : : // CC/BCC toggle label
144 [ - - ]: 0 : m_ccBccLabel = new QLabel(
145 : 162 : QStringLiteral("<a href='#' style='color: %1; font-size: 11px;'>"
146 : : "BCC anzeigen</a>")
147 [ + - + - ]: 162 : .arg(tok("@link")),
148 [ + - - + ]: 243 : this);
149 [ + - + - ]: 81 : m_ccBccLabel->setCursor(Qt::PointingHandCursor);
150 [ + - ]: 81 : connect(m_ccBccLabel, &QLabel::linkActivated, this, [this]() {
151 [ # # ]: 0 : if (m_bccEdit->isVisible()) {
152 : 0 : m_bccEdit->hide();
153 [ # # ]: 0 : m_ccBccLabel->setText(QStringLiteral(
154 : : "<a href='#' style='color: %1; font-size: 11px;'>"
155 [ # # # # ]: 0 : "BCC anzeigen</a>").arg(tok("@link")));
156 : : } else {
157 : 0 : m_bccEdit->show();
158 [ # # ]: 0 : m_ccBccLabel->setText(QStringLiteral(
159 : : "<a href='#' style='color: %1; font-size: 11px;'>"
160 [ # # # # ]: 0 : "BCC ausblenden</a>").arg(tok("@link")));
161 : : }
162 : 0 : });
163 [ + - ]: 81 : headerLayout->addRow(QString(), m_ccBccLabel);
164 [ + - + - ]: 81 : headerLayout->addRow(tr("BCC:"), m_bccEdit);
165 : :
166 [ + - - + : 81 : m_subjectEdit = new QLineEdit(this);
- - ]
167 [ + - + - ]: 81 : headerLayout->addRow(tr("Subject:"), m_subjectEdit);
168 : :
169 [ + - ]: 81 : mainLayout->addWidget(headerWidget);
170 : :
171 : : // ═══ Separator ═══
172 [ + - - + : 81 : auto *separator = new QFrame(this);
- - ]
173 [ + - ]: 162 : separator->setObjectName(QStringLiteral("composeSeparator"));
174 : 81 : separator->setFixedHeight(1);
175 [ + - ]: 81 : mainLayout->addWidget(separator);
176 : :
177 : : // ═══ Body Editor ═══
178 [ + - - + : 81 : m_bodyEdit = new MentionTextEdit(this);
- - ]
179 [ + - ]: 162 : m_bodyEdit->setObjectName(QStringLiteral("composeBodyEdit"));
180 : 81 : m_bodyEdit->setAcceptRichText(false);
181 : : // Sprint 69: styling lives in main.qss (QTextEdit#composeBodyEdit);
182 : : // UI typography (Inter) via the global QWidget font-family rule.
183 [ + - ]: 81 : mainLayout->addWidget(m_bodyEdit, 1);
184 : : // T-161: Wire @-mention to auto-add to CC
185 : 81 : connect(m_bodyEdit, &MentionTextEdit::mentionSelected, this,
186 [ + - ]: 81 : [this](const QString &email, const QString &displayName) {
187 [ + - + - ]: 4 : QString cc = m_ccEdit->text().trimmed();
188 : : // Check if email already in CC
189 [ + - + + ]: 4 : if (cc.contains(email, Qt::CaseInsensitive))
190 : 1 : return;
191 : 3 : QString entry = displayName.isEmpty()
192 : 3 : ? email
193 [ + + + - : 5 : : QStringLiteral("%1 <%2>").arg(displayName, email);
+ + + + -
- - - ]
194 [ + + ]: 3 : if (!cc.isEmpty())
195 [ + - ]: 1 : cc += QStringLiteral(", ");
196 [ + - ]: 3 : cc += entry;
197 [ + - ]: 3 : m_ccEdit->setText(cc);
198 [ + - ]: 3 : m_statusLabel->setText(
199 [ + - + + : 9 : tr("%1 added to CC").arg(
+ - ]
200 : 3 : displayName.isEmpty() ? email : displayName));
201 [ + + ]: 4 : });
202 : :
203 : : // ═══ Attachment Chip Bar (T-106) ═══
204 [ + - - + : 81 : m_attachmentBar = new QWidget(this);
- - ]
205 [ + - ]: 162 : m_attachmentBar->setObjectName(QStringLiteral("composeAttachmentBar"));
206 [ + - - + : 81 : auto *attachLayout = new QVBoxLayout(m_attachmentBar);
- - ]
207 : 81 : attachLayout->setContentsMargins(12, 8, 12, 8);
208 : 81 : attachLayout->setSpacing(4);
209 : 81 : m_attachmentBar->hide(); // Only show when attachments exist
210 : :
211 [ + - - + : 81 : m_attachmentSizeLabel = new QLabel(m_attachmentBar);
- - ]
212 [ + - ]: 162 : m_attachmentSizeLabel->setObjectName(
213 : 162 : QStringLiteral("composeAttachmentSizeLabel"));
214 : 81 : m_attachmentSizeLabel->setTextFormat(Qt::PlainText);
215 [ + - ]: 81 : attachLayout->addWidget(m_attachmentSizeLabel);
216 : :
217 [ + - ]: 81 : mainLayout->addWidget(m_attachmentBar);
218 : :
219 : : // ═══ Status Bar ═══
220 [ + - - + : 81 : m_statusLabel = new QLabel(this);
- - ]
221 [ + - ]: 162 : m_statusLabel->setObjectName(QStringLiteral("composeStatusBar"));
222 : 81 : m_statusLabel->setTextFormat(Qt::PlainText);
223 [ + - ]: 81 : mainLayout->addWidget(m_statusLabel);
224 : :
225 : : // ═══ Drop Overlay (T-107) ═══
226 : 81 : setAcceptDrops(true);
227 [ + - - + : 81 : m_dropOverlay = new QWidget(this);
- - ]
228 [ + - ]: 162 : m_dropOverlay->setObjectName(QStringLiteral("composeDropOverlay"));
229 : : {
230 : : // Sprint 69: only the dynamic translucent RGBA fill stays inline;
231 : : // the dashed border + radius live in main.qss.
232 [ + - ]: 81 : QColor overlayBg(tok("@accent"));
233 [ + - ]: 81 : overlayBg.setAlphaF(0.12f);
234 [ + - ]: 162 : m_dropOverlay->setStyleSheet(
235 : 162 : QStringLiteral("background: rgba(%1, %2, %3, 0.12);")
236 [ + - ]: 162 : .arg(overlayBg.red())
237 [ + - ]: 162 : .arg(overlayBg.green())
238 [ + - ]: 162 : .arg(overlayBg.blue()));
239 : : }
240 [ + - - + : 81 : auto *overlayLayout = new QVBoxLayout(m_dropOverlay);
- - ]
241 [ + - + - : 81 : auto *dropLabel = new QLabel(tr("↓ Drop files here"), m_dropOverlay);
- + - - ]
242 [ + - ]: 162 : dropLabel->setObjectName(QStringLiteral("composeDropLabel"));
243 [ + - ]: 81 : dropLabel->setAlignment(Qt::AlignCenter);
244 [ + - ]: 81 : overlayLayout->addWidget(dropLabel);
245 : 81 : m_dropOverlay->hide();
246 : :
247 : 81 : m_toEdit->setFocus();
248 : :
249 : : // T-177: Ctrl+S = Save Draft
250 [ + - + - : 81 : auto *saveDraftAction = new QAction(tr("Save Draft"), this);
- + - - ]
251 [ + - + - ]: 81 : saveDraftAction->setShortcut(QKeySequence::Save); // Ctrl+S
252 [ + - ]: 81 : connect(saveDraftAction, &QAction::triggered, this, &ComposeWindow::saveDraft);
253 : 81 : addAction(saveDraftAction);
254 : :
255 : : // T-177: Auto-save timer (60 seconds)
256 [ + - - + : 81 : m_autoSaveTimer = new QTimer(this);
- - ]
257 : 81 : m_autoSaveTimer->setInterval(60 * 1000);
258 [ + - ]: 81 : connect(m_autoSaveTimer, &QTimer::timeout, this, [this]() {
259 [ # # # # : 0 : if (hasUnsavedChanges() && !m_draftsFolder.isEmpty()) {
# # ]
260 : 0 : saveDraft();
261 : : }
262 : 0 : });
263 : : // Timer starts only after draftsFolder is set via setDraftsFolder()
264 : 81 : }
265 : :
266 : 47 : void ComposeWindow::setTo(const QString &to) { m_toEdit->setText(to); }
267 : 6 : void ComposeWindow::setCc(const QString &cc) { m_ccEdit->setText(cc); }
268 : 6 : void ComposeWindow::setBcc(const QString &bcc) {
269 : 6 : m_bccEdit->setText(bcc);
270 [ + - + - ]: 6 : if (!bcc.trimmed().isEmpty()) m_bccEdit->show(); // T-177: show BCC if pre-filled
271 : 6 : }
272 : 49 : void ComposeWindow::setSubject(const QString &subject) {
273 : 49 : m_subjectEdit->setText(subject);
274 : 49 : }
275 : 35 : void ComposeWindow::setBody(const QString &body) {
276 : 35 : m_bodyEdit->setPlainText(body);
277 : 35 : }
278 : 2 : QString ComposeWindow::subject() const { return m_subjectEdit->text(); }
279 : 1 : QString ComposeWindow::body() const { return m_bodyEdit->toPlainText(); }
280 : 53 : void ComposeWindow::setFrom(const QString &from) {
281 : 53 : m_fromEdit->setText(from);
282 : 53 : }
283 : :
284 : 20 : void ComposeWindow::setSmtpConfig(const SmtpConfig &config) {
285 : : // T-507: Use unique_ptr to prevent memory leak
286 [ + - ]: 20 : m_smtpConfig = std::make_unique<SmtpConfig>(config);
287 : 20 : }
288 : :
289 : : // ═══════════════════════════════════════════════════════
290 : : // T-177: Draft management
291 : : // ═══════════════════════════════════════════════════════
292 : :
293 : 5 : void ComposeWindow::saveDraft() {
294 [ - + ]: 5 : if (m_draftsFolder.isEmpty()) {
295 [ # # # # ]: 0 : m_statusLabel->setText(tr("No Drafts folder found"));
296 : 1 : return;
297 : : }
298 : :
299 : 5 : QString buildError;
300 [ + - ]: 5 : QByteArray msg = buildMessage(/*includeBcc=*/true, &buildError);
301 [ + + ]: 5 : if (msg.isEmpty()) {
302 [ + - ]: 2 : m_statusLabel->setText(buildError.isEmpty()
303 [ - + - - ]: 2 : ? tr("Could not build draft")
304 : : : buildError);
305 : 1 : return;
306 : : }
307 : :
308 [ + - + - ]: 4 : m_statusLabel->setText(tr("Saving draft…"));
309 : :
310 [ + - ]: 4 : emit draftSaveRequested(msg);
311 [ + + + + ]: 6 : }
312 : :
313 : 10 : void ComposeWindow::markDraftSaved() {
314 [ + - ]: 10 : m_lastSavedContent = currentContentSnapshot();
315 [ + - + - ]: 10 : m_statusLabel->setText(tr("Draft saved"));
316 [ - + ]: 10 : if (m_closeAfterDraftSave) {
317 : 0 : m_closeAfterDraftSave = false;
318 : 0 : close();
319 : : }
320 : 10 : }
321 : :
322 : 3 : void ComposeWindow::markDraftSaveFailed(const QString &error) {
323 : 3 : m_closeAfterDraftSave = false;
324 [ + - ]: 6 : m_statusLabel->setText(error.isEmpty()
325 [ + + + - : 8 : ? tr("Could not save draft")
+ - ]
326 [ + - + + : 5 : : tr("Could not save draft: %1")
- - ]
327 : : .arg(error));
328 [ + - + + : 3 : if (m_autoSaveTimer && !m_draftsFolder.isEmpty())
+ + ]
329 : 1 : m_autoSaveTimer->start();
330 : 3 : }
331 : :
332 : 13 : bool ComposeWindow::hasUnsavedChanges() const {
333 [ + - ]: 13 : return currentContentSnapshot() != m_lastSavedContent;
334 : : }
335 : :
336 : 23 : QString ComposeWindow::currentContentSnapshot() const {
337 : 23 : QStringList attachmentState;
338 [ + - ]: 23 : attachmentState.reserve(m_attachmentPaths.size());
339 [ + + ]: 29 : for (const auto &path : m_attachmentPaths) {
340 [ + - ]: 6 : const QFileInfo info(path);
341 [ + - ]: 6 : attachmentState.append(
342 : 12 : QStringLiteral("%1|%2|%3|%4")
343 [ + - ]: 12 : .arg(path)
344 [ + - + + : 12 : .arg(info.exists() ? 1 : 0)
+ - ]
345 [ + - + + : 12 : .arg(info.exists() ? info.size() : -1)
+ - + - ]
346 [ + - + + : 12 : .arg(info.exists() ? info.lastModified().toMSecsSinceEpoch()
+ - + - +
- + + -
- ]
347 : : : -1));
348 : 6 : }
349 : :
350 [ + - + - : 46 : return m_toEdit->text() + m_ccEdit->text() + m_bccEdit->text() +
+ - + - +
- ]
351 [ + - + - : 92 : m_subjectEdit->text() + m_bodyEdit->toPlainText() +
+ - + - ]
352 [ + - + - : 115 : QStringLiteral("\n--attachments--\n") + attachmentState.join('\n');
+ - ]
353 : 23 : }
354 : :
355 : 29 : void ComposeWindow::setContactStore(ContactStore *store) {
356 : 29 : m_contactStore = store;
357 [ - + ]: 29 : if (!store)
358 : 0 : return;
359 : : // Install completers on address fields
360 [ + - - + : 29 : m_toCompleter = new ContactCompleter(store, m_toEdit, this);
- - ]
361 [ + - - + : 29 : m_ccCompleter = new ContactCompleter(store, m_ccEdit, this);
- - ]
362 [ + - - + : 29 : m_bccCompleter = new ContactCompleter(store, m_bccEdit, this);
- - ]
363 : : // Fix A: Wire @-mention in body editor
364 : 29 : m_bodyEdit->setContactStore(store);
365 : : }
366 : :
367 : 6 : bool ComposeWindow::addAttachmentData(const QString &filename,
368 : : const QByteArray &data,
369 : : const QByteArray &mimeType) {
370 : : Q_UNUSED(mimeType);
371 : :
372 [ + + ]: 6 : if (!m_restoredAttachmentDir) {
373 [ + - ]: 5 : m_restoredAttachmentDir = std::make_unique<QTemporaryDir>();
374 : : }
375 [ + - - + ]: 6 : if (!m_restoredAttachmentDir->isValid()) {
376 : 0 : return false;
377 : : }
378 : :
379 [ + - + - ]: 6 : QString safeFilename = QFileInfo(filename).fileName();
380 [ - + ]: 6 : if (safeFilename.isEmpty()) {
381 : 0 : safeFilename = QStringLiteral("attachment.bin");
382 : : }
383 : :
384 [ + - ]: 6 : const QFileInfo safeInfo(safeFilename);
385 [ + - ]: 6 : const QString baseName = safeInfo.completeBaseName().isEmpty()
386 [ - + - - ]: 6 : ? QStringLiteral("attachment")
387 [ - + + - ]: 6 : : safeInfo.completeBaseName();
388 [ + - ]: 6 : const QString suffix = safeInfo.suffix();
389 : :
390 : 6 : QString candidate = safeFilename;
391 : 6 : for (int counter = 2;
392 [ + - + - : 6 : QFileInfo::exists(m_restoredAttachmentDir->filePath(candidate));
- + ]
393 : : ++counter) {
394 : 0 : candidate = suffix.isEmpty()
395 [ # # # # : 0 : ? QStringLiteral("%1-%2").arg(baseName).arg(counter)
# # # # #
# # # # #
# # # # ]
396 [ # # # # : 0 : : QStringLiteral("%1-%2.%3")
# # # # ]
397 [ # # # # : 0 : .arg(baseName)
# # ]
398 [ # # # # : 0 : .arg(counter)
# # # # ]
399 : 0 : .arg(suffix);
400 : : }
401 : :
402 [ + - ]: 6 : const QString path = m_restoredAttachmentDir->filePath(candidate);
403 [ + - ]: 6 : QFile file(path);
404 [ + - - + ]: 6 : if (!file.open(QIODevice::WriteOnly)) {
405 : 0 : return false;
406 : : }
407 [ + - + - : 6 : if (!data.isEmpty() && file.write(data) != data.size()) {
- + - + ]
408 [ # # ]: 0 : file.close();
409 [ # # ]: 0 : file.remove();
410 : 0 : return false;
411 : : }
412 [ + - ]: 6 : file.close();
413 : :
414 [ + - ]: 6 : addAttachment(path);
415 : 6 : return true;
416 : 6 : }
417 : :
418 : : // ═══════════════════════════════════════════════════════
419 : : // Actions
420 : : // ═══════════════════════════════════════════════════════
421 : :
422 : 3 : void ComposeWindow::onSendClicked() {
423 [ + - + - : 3 : if (m_toEdit->text().trimmed().isEmpty()) {
+ + ]
424 [ + - + - ]: 1 : QMessageBox::warning(this, tr("Missing recipients"),
425 [ + - ]: 2 : tr("Please enter at least one recipient."));
426 [ + - ]: 1 : m_toEdit->setFocus();
427 : 3 : return;
428 : : }
429 [ + + ]: 2 : if (!m_smtpConfig) {
430 [ + - ]: 1 : QMessageBox::warning(
431 [ + - ]: 2 : this, tr("SMTP not configured"),
432 [ + - ]: 2 : tr("Please configure SMTP in the settings."));
433 : 1 : return;
434 : : }
435 : :
436 [ + - + - ]: 1 : QStringList recipients = parseRecipients(m_toEdit->text());
437 [ + - + - : 1 : recipients.append(parseRecipients(m_ccEdit->text()));
+ - ]
438 [ + - + - : 1 : recipients.append(parseRecipients(m_bccEdit->text()));
+ - ]
439 : :
440 : : // T-502: Build message WITHOUT Bcc header for SMTP transport
441 : 1 : QString buildError;
442 [ + - ]: 1 : QByteArray message = buildMessage(/*includeBcc=*/false, &buildError);
443 [ + - ]: 1 : if (message.isEmpty()) {
444 : 1 : const QString errorText = buildError.isEmpty()
445 [ - + ]: 1 : ? tr("Could not build message")
446 [ - - ]: 1 : : buildError;
447 [ + - ]: 1 : m_statusLabel->setText(errorText);
448 [ + - + - ]: 1 : PlainTextMessageBox::warning(this, tr("Attachment unreadable"),
449 : : errorText);
450 : 1 : return;
451 : 1 : }
452 : :
453 [ # # ]: 0 : m_sendAction->setEnabled(false);
454 [ # # # # ]: 0 : m_statusLabel->setText(tr("Sending..."));
455 [ # # # # ]: 0 : m_smtp->sendMail(*m_smtpConfig, m_fromEdit->text(), recipients, message);
456 [ - + - + : 3 : }
- + ]
457 : :
458 : 2 : void ComposeWindow::onAttachClicked() {
459 : 2 : QStringList files = QFileDialog::getOpenFileNames(
460 [ + - + - ]: 2 : this, tr("Attach files"), QString());
461 [ + - + - : 3 : for (const auto &file : files) {
+ + ]
462 [ + - ]: 1 : addAttachment(file);
463 : : }
464 : 2 : }
465 : :
466 : 1 : void ComposeWindow::onDiscardClicked() {
467 : : // If there is content, show confirmation
468 [ + - + - : 2 : bool hasContent = !m_toEdit->text().trimmed().isEmpty() ||
- - - - ]
469 [ + - + - : 2 : !m_subjectEdit->text().trimmed().isEmpty() ||
+ - + - +
- - - -
- ]
470 [ + - + - : 5 : !m_bodyEdit->toPlainText().trimmed().isEmpty() ||
+ - + - +
- + - + -
- - - - ]
471 [ - + + - ]: 2 : !m_attachmentPaths.isEmpty();
472 : :
473 [ - + ]: 1 : if (hasContent) {
474 [ # # ]: 0 : auto result = QMessageBox::question(
475 [ # # ]: 0 : this, tr("Discard draft?"),
476 [ # # ]: 0 : tr("Do you really want to discard this message?"),
477 : : QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
478 [ # # ]: 0 : if (result != QMessageBox::Yes)
479 : 0 : return;
480 : : }
481 : :
482 : 1 : reject();
483 : : }
484 : :
485 : 4 : void ComposeWindow::closeEvent(QCloseEvent *event) {
486 : : // T-177: If mail was sent → close immediately
487 [ - + ]: 4 : if (m_wasSentSuccessfully) {
488 : 0 : event->accept();
489 : 0 : return;
490 : : }
491 : :
492 : : // If no unsaved changes and a draft already exists → close
493 [ + + + - : 4 : if (!hasUnsavedChanges() && m_draftUid > 0) {
+ + ]
494 : 1 : event->accept();
495 : 1 : return;
496 : : }
497 : :
498 [ + - + - : 6 : bool hasContent = !m_toEdit->text().trimmed().isEmpty() ||
- - - - ]
499 [ + - + - : 6 : !m_subjectEdit->text().trimmed().isEmpty() ||
+ - + - +
- - - -
- ]
500 [ + - + - : 14 : !m_bodyEdit->toPlainText().trimmed().isEmpty() ||
+ - + - +
+ + - + -
- - - - ]
501 [ - + + - ]: 5 : !m_attachmentPaths.isEmpty();
502 : :
503 [ + + ]: 3 : if (!hasContent) {
504 : 2 : event->accept();
505 : 2 : return;
506 : : }
507 : :
508 : : // T-177: Stop auto-save while dialog is open
509 [ + - ]: 1 : if (m_autoSaveTimer) m_autoSaveTimer->stop();
510 : :
511 : : // T-177: 3-way dialog: Save / Discard / Cancel
512 [ + - ]: 1 : if (!m_draftsFolder.isEmpty()) {
513 [ + - ]: 1 : QMessageBox msgBox(this);
514 [ + - + - ]: 1 : msgBox.setWindowTitle(tr("Draft"));
515 [ + - + - ]: 1 : msgBox.setText(tr("What do you want to do with the draft?"));
516 [ + - ]: 1 : msgBox.setTextFormat(Qt::PlainText);
517 [ + - ]: 1 : msgBox.setIcon(QMessageBox::Question);
518 : :
519 [ + - + - ]: 1 : auto *saveBtn = msgBox.addButton(tr("Save Draft"),
520 : : QMessageBox::AcceptRole);
521 [ + - + - ]: 1 : auto *discardBtn = msgBox.addButton(tr("Discard"),
522 : : QMessageBox::DestructiveRole);
523 [ + - ]: 1 : msgBox.addButton(QMessageBox::Cancel);
524 : :
525 [ + - ]: 1 : msgBox.exec();
526 : :
527 [ + - - + ]: 1 : if (msgBox.clickedButton() == saveBtn) {
528 : 0 : m_closeAfterDraftSave = true;
529 [ # # ]: 0 : saveDraft();
530 : 0 : event->ignore();
531 [ + - + - ]: 1 : } else if (msgBox.clickedButton() == discardBtn) {
532 [ - + ]: 1 : if (m_draftUid > 0) {
533 [ # # ]: 0 : emit draftDiscarded(m_draftUid);
534 : : }
535 : 1 : event->accept();
536 : : } else {
537 [ # # # # ]: 0 : if (m_autoSaveTimer) m_autoSaveTimer->start();
538 : 0 : event->ignore();
539 : : }
540 : 1 : } else {
541 : : // Fallback: no Drafts folder configured → simple discard dialog
542 [ # # ]: 0 : auto result = QMessageBox::question(
543 [ # # ]: 0 : this, tr("Discard draft?"),
544 [ # # ]: 0 : tr("Do you really want to discard this message?"),
545 : : QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
546 [ # # ]: 0 : if (result == QMessageBox::Yes) {
547 : 0 : event->accept();
548 : : } else {
549 [ # # ]: 0 : if (m_autoSaveTimer) m_autoSaveTimer->start();
550 : 0 : event->ignore();
551 : : }
552 : : }
553 : : }
554 : :
555 : : // ═══════════════════════════════════════════════════════
556 : : // Message Building
557 : : // ═══════════════════════════════════════════════════════
558 : :
559 : 36 : QByteArray ComposeWindow::buildMessage(bool includeBcc, QString *errorMessage) {
560 [ + + ]: 36 : if (errorMessage)
561 : 7 : errorMessage->clear();
562 : :
563 : : // T-184: Delegate to Rfc2822Builder for proper RFC-2822 compliance
564 : 36 : Rfc2822Builder builder;
565 : :
566 : : // From: parse "Display Name <email>" or plain email
567 [ + - + - ]: 36 : QString fromText = m_fromEdit->text().trimmed();
568 : 36 : int ltIdx = fromText.indexOf('<');
569 : 36 : int gtIdx = fromText.indexOf('>');
570 [ - + - - ]: 36 : if (ltIdx > 0 && gtIdx > ltIdx) {
571 [ # # # # : 0 : builder.setFrom(fromText.left(ltIdx).trimmed(),
# # ]
572 [ # # # # ]: 0 : fromText.mid(ltIdx + 1, gtIdx - ltIdx - 1).trimmed());
573 : : } else {
574 [ + - ]: 36 : builder.setFrom({}, fromText);
575 : : }
576 : :
577 [ + - + - : 36 : builder.setTo(parseRecipientList(m_toEdit->text()));
+ - ]
578 [ + - + - : 36 : if (!m_ccEdit->text().trimmed().isEmpty())
+ + ]
579 [ + - + - : 6 : builder.setCc(parseRecipientList(m_ccEdit->text()));
+ - ]
580 [ + - + - : 36 : if (!m_bccEdit->text().trimmed().isEmpty())
+ + ]
581 [ + - + - : 6 : builder.setBcc(parseRecipientList(m_bccEdit->text()));
+ - ]
582 : :
583 [ + - + - ]: 36 : builder.setSubject(m_subjectEdit->text());
584 : :
585 : : // Threading: In-Reply-To + References (set by reply/forward)
586 [ + + ]: 36 : if (!m_inReplyTo.isEmpty())
587 [ + - ]: 4 : builder.setInReplyTo(m_inReplyTo);
588 [ + + ]: 36 : if (!m_references.isEmpty())
589 [ + - ]: 4 : builder.setReferences(m_references);
590 : :
591 [ + - + - ]: 36 : builder.setBodyText(m_bodyEdit->toPlainText());
592 : :
593 : : // Attachments
594 [ + - + - : 41 : for (const auto &path : m_attachmentPaths) {
+ + ]
595 [ + - + + ]: 8 : if (!builder.addAttachmentFromFile(path)) {
596 : : const QString errorText =
597 [ + - ]: 3 : tr("Attachment cannot be read: %1")
598 [ + - + - ]: 3 : .arg(QDir::toNativeSeparators(path));
599 [ + - ]: 3 : if (errorMessage)
600 : 3 : *errorMessage = errorText;
601 [ + - ]: 3 : m_lastBuiltMessage.clear();
602 : 3 : m_lastMessageId.clear();
603 : 3 : return {};
604 : 3 : }
605 : : }
606 : :
607 : : // T-502: Control BCC header inclusion
608 : 33 : builder.setIncludeBcc(includeBcc);
609 : :
610 [ + - ]: 33 : m_lastBuiltMessage = builder.build();
611 : 33 : m_lastMessageId = builder.messageId();
612 : 33 : return m_lastBuiltMessage;
613 : 36 : }
614 : :
615 : 54 : QStringList ComposeWindow::parseRecipientList(const QString &field) const {
616 : : // Split on comma/semicolon but preserve "Name <email>" groups
617 : 54 : QStringList result;
618 [ + - + - : 116 : for (auto &part : field.split(QRegularExpression(QStringLiteral("[,;]")))) {
+ - + - +
+ ]
619 [ + - ]: 62 : QString trimmed = part.trimmed();
620 [ + + ]: 62 : if (!trimmed.isEmpty())
621 [ + - ]: 56 : result.append(trimmed);
622 : 116 : }
623 : 54 : return result;
624 : 0 : }
625 : :
626 : 11 : QStringList ComposeWindow::parseRecipients(const QString &field) const {
627 : 11 : QStringList result;
628 [ + - + - : 25 : for (auto &addr : field.split(QRegularExpression("[,;]"))) {
+ - + - +
- + + ]
629 [ + - ]: 14 : QString trimmed = addr.trimmed();
630 : 14 : int ltIdx = trimmed.indexOf('<');
631 : 14 : int gtIdx = trimmed.indexOf('>');
632 [ + + + - ]: 14 : if (ltIdx >= 0 && gtIdx > ltIdx) {
633 [ + - + - ]: 5 : trimmed = trimmed.mid(ltIdx + 1, gtIdx - ltIdx - 1).trimmed();
634 : : }
635 [ + + ]: 14 : if (!trimmed.isEmpty()) {
636 [ + - ]: 8 : result.append(trimmed);
637 : : }
638 : 25 : }
639 : 11 : return result;
640 : 0 : }
641 : :
642 : : // ═══════════════════════════════════════════════════════
643 : : // Attachment Chips (T-106)
644 : : // ═══════════════════════════════════════════════════════
645 : :
646 : 34 : static QString formatFileSize(qint64 bytes) {
647 [ + + ]: 34 : if (bytes < 1024)
648 [ + - + - ]: 29 : return QString::number(bytes) + QStringLiteral(" B");
649 [ + + ]: 5 : if (bytes < 1024 * 1024)
650 [ + - + - ]: 3 : return QString::number(bytes / 1024.0, 'f', 1) + QStringLiteral(" KB");
651 [ + - ]: 4 : return QString::number(bytes / (1024.0 * 1024.0), 'f', 1) +
652 [ + - ]: 6 : QStringLiteral(" MB");
653 : : }
654 : :
655 : 15 : void ComposeWindow::addAttachment(const QString &path) {
656 [ - + ]: 15 : if (m_attachmentPaths.contains(path))
657 : 0 : return;
658 : 15 : m_attachmentPaths.append(path);
659 : 15 : refreshAttachmentBar();
660 : : }
661 : :
662 : 3 : void ComposeWindow::removeAttachment(int index) {
663 [ + - + - : 3 : if (index >= 0 && index < m_attachmentPaths.size()) {
+ - ]
664 : 3 : m_attachmentPaths.removeAt(index);
665 : 3 : refreshAttachmentBar();
666 : : }
667 : 3 : }
668 : :
669 : 18 : void ComposeWindow::refreshAttachmentBar() {
670 : : // Clear existing chip widgets
671 : 18 : auto *layout = m_attachmentBar->layout();
672 [ + + ]: 40 : while (layout->count() > 0) {
673 : 22 : auto *item = layout->takeAt(0);
674 [ + - ]: 22 : if (auto *widget = item->widget()) {
675 [ + + ]: 22 : if (widget != m_attachmentSizeLabel)
676 : 5 : widget->deleteLater();
677 : : }
678 [ + - ]: 22 : delete item;
679 : : }
680 : :
681 [ + + ]: 18 : if (m_attachmentPaths.isEmpty()) {
682 : 2 : m_attachmentBar->hide();
683 [ + - ]: 2 : m_statusLabel->setText(QString());
684 : 2 : return;
685 : : }
686 : :
687 : : // Chip row
688 [ + - - + : 16 : auto *chipRow = new QWidget(m_attachmentBar);
- - ]
689 [ + - - + : 16 : auto *chipLayout = new QHBoxLayout(chipRow);
- - ]
690 : 16 : chipLayout->setContentsMargins(0, 0, 0, 0);
691 : 16 : chipLayout->setSpacing(6);
692 : :
693 : 16 : qint64 totalSize = 0;
694 : :
695 [ + + ]: 34 : for (int i = 0; i < m_attachmentPaths.size(); ++i) {
696 [ + - + - ]: 18 : QFileInfo info(m_attachmentPaths[i]);
697 [ + - ]: 18 : totalSize += info.size();
698 : :
699 : : // Chip widget
700 [ + - + - : 18 : auto *chip = new QWidget(chipRow);
- + - - ]
701 [ + - ]: 36 : chip->setObjectName(QStringLiteral("attachmentChip"));
702 [ + - + - : 18 : auto *chipInner = new QHBoxLayout(chip);
- + - - ]
703 [ + - ]: 18 : chipInner->setContentsMargins(8, 2, 4, 2);
704 [ + - ]: 18 : chipInner->setSpacing(4);
705 : :
706 : : // Filename + size
707 : : auto *nameLabel = new QLabel(
708 : 36 : QStringLiteral("%1 (%2)")
709 [ + - + - : 36 : .arg(info.fileName(), formatFileSize(info.size())),
+ - + - ]
710 [ + - + - : 54 : chip);
- + - - ]
711 [ + - ]: 36 : nameLabel->setObjectName(QStringLiteral("attachmentChipName"));
712 [ + - ]: 18 : nameLabel->setTextFormat(Qt::PlainText);
713 [ + - ]: 18 : chipInner->addWidget(nameLabel);
714 : :
715 : : // Remove button
716 [ + - + - : 36 : auto *removeBtn = new QPushButton(QStringLiteral("×"), chip);
- + - - ]
717 [ + - ]: 36 : removeBtn->setObjectName(QStringLiteral("attachmentChipRemove"));
718 [ + - ]: 18 : removeBtn->setFixedSize(16, 16);
719 : : // T-507/H7: Capture path instead of index — prevents stale index
720 : : // after earlier items are removed
721 [ + - ]: 18 : connect(removeBtn, &QPushButton::clicked, this,
722 [ + - ]: 36 : [this, path = m_attachmentPaths[i]]() {
723 : 0 : int idx = m_attachmentPaths.indexOf(path);
724 [ # # ]: 0 : if (idx >= 0) removeAttachment(idx);
725 : 0 : });
726 [ + - ]: 18 : chipInner->addWidget(removeBtn);
727 : :
728 [ + - ]: 18 : chipLayout->addWidget(chip);
729 : 18 : }
730 : :
731 : 16 : chipLayout->addStretch();
732 : 16 : layout->addWidget(chipRow);
733 : :
734 : : // Total size label
735 [ + - ]: 16 : m_attachmentSizeLabel->setText(
736 [ + - + - : 48 : tr("Gesamt: %1").arg(formatFileSize(totalSize)));
+ - ]
737 : 16 : layout->addWidget(m_attachmentSizeLabel);
738 : :
739 : : // Size warning — toggle a property the QSS reads; the base rule
740 : : // (QLabel#composeAttachmentSizeLabel) lives in main.qss.
741 [ - + ]: 16 : if (totalSize > 25 * 1024 * 1024) {
742 [ # # ]: 0 : m_attachmentSizeLabel->setStyleSheet(
743 : 0 : QStringLiteral("color: %1; font-weight: bold;")
744 [ # # # # ]: 0 : .arg(tok("@danger")));
745 [ # # ]: 0 : m_attachmentSizeLabel->setText(
746 [ # # # # : 0 : tr("⚠ Gesamt: %1 (> 25 MB!)").arg(formatFileSize(totalSize)));
# # ]
747 : : } else {
748 [ + - ]: 16 : m_attachmentSizeLabel->setStyleSheet(QString());
749 : : }
750 : :
751 : 16 : m_attachmentBar->show();
752 [ + - ]: 16 : m_statusLabel->setText(
753 [ + - + - ]: 48 : tr("%1 attachment(s)").arg(m_attachmentPaths.size()));
754 : : }
755 : :
756 : : // ═══════════════════════════════════════════════════════
757 : : // Drag & Drop (T-107)
758 : : // ═══════════════════════════════════════════════════════
759 : :
760 : 3 : void ComposeWindow::showDropOverlay(bool show) {
761 [ + + ]: 3 : if (show) {
762 [ + - ]: 1 : m_dropOverlay->setGeometry(rect().adjusted(8, 8, -8, -8));
763 : 1 : m_dropOverlay->raise();
764 : 1 : m_dropOverlay->show();
765 : : } else {
766 : 2 : m_dropOverlay->hide();
767 : : }
768 : 3 : }
769 : :
770 : 1 : void ComposeWindow::dragEnterEvent(QDragEnterEvent *event) {
771 [ + - ]: 1 : if (event->mimeData()->hasUrls()) {
772 : : // Only accept file URLs
773 : 1 : bool hasFiles = false;
774 [ + - + - : 1 : for (const auto &url : event->mimeData()->urls()) {
+ - + - ]
775 [ + - + - : 1 : if (url.isLocalFile() && QFileInfo(url.toLocalFile()).isFile()) {
+ - + - +
- + - + -
+ - + - -
- - - ]
776 : 1 : hasFiles = true;
777 : 1 : break;
778 : : }
779 : 1 : }
780 [ + - ]: 1 : if (hasFiles) {
781 : 1 : event->acceptProposedAction();
782 : 1 : showDropOverlay(true);
783 : 1 : return;
784 : : }
785 : : }
786 : 0 : event->ignore();
787 : : }
788 : :
789 : 1 : void ComposeWindow::dragLeaveEvent(QDragLeaveEvent *event) {
790 : 1 : showDropOverlay(false);
791 : 1 : QDialog::dragLeaveEvent(event);
792 : 1 : }
793 : :
794 : 1 : void ComposeWindow::dropEvent(QDropEvent *event) {
795 : 1 : showDropOverlay(false);
796 : :
797 [ - + ]: 1 : if (!event->mimeData()->hasUrls()) {
798 : 0 : event->ignore();
799 : 0 : return;
800 : : }
801 : :
802 : 1 : int added = 0;
803 [ + - + - : 2 : for (const auto &url : event->mimeData()->urls()) {
+ - + + ]
804 [ + - + - ]: 1 : if (url.isLocalFile()) {
805 [ + - + - ]: 1 : QFileInfo info(url.toLocalFile());
806 [ + - + - ]: 1 : if (info.isFile()) {
807 [ + - + - ]: 1 : addAttachment(info.absoluteFilePath());
808 : 1 : ++added;
809 : : }
810 : 1 : }
811 : 1 : }
812 : :
813 [ + - ]: 1 : if (added > 0) {
814 : 1 : event->acceptProposedAction();
815 : : } else {
816 : 0 : event->ignore();
817 : : }
818 : : }
819 : :
820 : : // T-304: Runtime language switching
821 : 209 : void ComposeWindow::changeEvent(QEvent *event) {
822 [ + + ]: 209 : if (event->type() == QEvent::LanguageChange)
823 : 1 : retranslateUi();
824 : 209 : QDialog::changeEvent(event);
825 : 209 : }
826 : :
827 : 1 : void ComposeWindow::retranslateUi() {
828 [ + - + - ]: 1 : setWindowTitle(tr("New Message"));
829 [ + - + - : 1 : if (m_sendAction) m_sendAction->setText(tr("Send"));
+ - ]
830 [ + - + - : 1 : if (m_discardAction) m_discardAction->setText(tr("Discard"));
+ - ]
831 [ + - + - : 1 : if (m_attachAction) m_attachAction->setText(tr("Attach"));
+ - ]
832 : 1 : }
|