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