aboutsummaryrefslogtreecommitdiffstats
path: root/src/m_email/EMailAccountModel.cpp
diff options
context:
space:
mode:
authorsaturneric <[email protected]>2026-09-12 15:10:16 +0200
committersaturneric <[email protected]>2026-09-12 15:10:16 +0200
commit82290dbd828360a73d5be15493a7dd52a83c2289 (patch)
tree115e639bd8dce83438d03596e78b483fff1a6674 /src/m_email/EMailAccountModel.cpp
parentfix(email): correct save all attachment icon path (diff)
downloadmodules-82290dbd828360a73d5be15493a7dd52a83c2289.tar.gz
modules-82290dbd828360a73d5be15493a7dd52a83c2289.zip
feat(email): add secure mail account model
- define IMAP and SMTP transport settings with TLS modes and ports - enforce loopback-only cleartext connections - serialize account configuration without storing passwords - validate account usability and normalize persisted values - constrain message picker page sizes and retained message limits
Diffstat (limited to 'src/m_email/EMailAccountModel.cpp')
-rw-r--r--src/m_email/EMailAccountModel.cpp190
1 files changed, 190 insertions, 0 deletions
diff --git a/src/m_email/EMailAccountModel.cpp b/src/m_email/EMailAccountModel.cpp
new file mode 100644
index 0000000..0bb50fe
--- /dev/null
+++ b/src/m_email/EMailAccountModel.cpp
@@ -0,0 +1,190 @@
+/**
+ * Copyright (C) 2021-2024 Saturneric <[email protected]>
+ *
+ * This file is part of GpgFrontend.
+ *
+ * GpgFrontend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * GpgFrontend 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 GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The initial version of the source code is inherited from
+ * the gpg4usb project, which is under GPL-3.0-or-later.
+ *
+ * All the source code of GpgFrontend was modified and released by
+ * Saturneric <[email protected]> starting on May 12, 2021.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+#include "EMailAccountModel.h"
+
+#include <QHostAddress>
+#include <QJsonArray>
+
+namespace {
+
+constexpr auto kTlsImplicit = "implicit";
+constexpr auto kTlsStartTls = "starttls";
+constexpr auto kTlsNone = "none";
+
+} // namespace
+
+auto MailTlsModeToString(MailTlsMode mode) -> QString {
+ switch (mode) {
+ case MailTlsMode::kIMPLICIT:
+ return kTlsImplicit;
+ case MailTlsMode::kSTARTTLS:
+ return kTlsStartTls;
+ case MailTlsMode::kNONE:
+ return kTlsNone;
+ }
+ return kTlsImplicit;
+}
+
+auto MailTlsModeFromString(const QString& s) -> MailTlsMode {
+ const auto v = s.trimmed().toLower();
+ if (v == kTlsStartTls) return MailTlsMode::kSTARTTLS;
+ if (v == kTlsNone) return MailTlsMode::kNONE;
+
+ // Anything unrecognised reads as the safest mode rather than the nearest
+ // one. A typo, a truncated file or a value from a newer build must not be
+ // able to quietly downgrade a connection.
+ return MailTlsMode::kIMPLICIT;
+}
+
+auto MailHostAllowsCleartext(const QString& host) -> bool {
+ const auto h = host.trimmed().toLower();
+ if (h.isEmpty()) return false;
+ if (h == "localhost" || h == "localhost.localdomain") return true;
+
+ // Judged as an address rather than by string prefix: "127.0.0.1.example.com"
+ // is a remote host that merely looks local, and matching on text would hand
+ // it a cleartext session.
+ QHostAddress address(h);
+ if (address.isNull()) return false;
+ return address.isLoopback();
+}
+
+auto MailDefaultPort(bool imap, MailTlsMode mode) -> quint16 {
+ switch (mode) {
+ case MailTlsMode::kIMPLICIT:
+ return imap ? 993 : 465;
+ case MailTlsMode::kSTARTTLS:
+ return imap ? 143 : 587;
+ case MailTlsMode::kNONE:
+ return imap ? 143 : 25;
+ }
+ return 0;
+}
+
+auto MailAllowedPageSizes() -> QList<int> { return {25, 50, 100, 200}; }
+
+auto MailClampPageSize(int requested) -> int {
+ const auto allowed = MailAllowedPageSizes();
+ if (allowed.contains(requested)) return requested;
+
+ // Snap down rather than to the nearest: a value between two steps is more
+ // likely a guess than a demand, and the cheaper page is the safer answer.
+ int best = allowed.first();
+ for (const auto size : allowed) {
+ if (size <= requested) best = size;
+ }
+ return best;
+}
+
+auto MailTransportConfig::ToJson() const -> QJsonObject {
+ return QJsonObject{
+ {"enabled", enabled},
+ {"host", host},
+ {"port", static_cast<int>(port)},
+ {"tls", MailTlsModeToString(tls)},
+ {"username", username},
+ {"pinned_cert_sha256", pinned_cert_sha256},
+ };
+}
+
+auto MailTransportConfig::FromJson(const QJsonObject& json)
+ -> MailTransportConfig {
+ MailTransportConfig config;
+ config.enabled = json.value("enabled").toBool();
+ config.host = json.value("host").toString().trimmed();
+ config.tls = MailTlsModeFromString(json.value("tls").toString());
+ config.username = json.value("username").toString();
+ config.pinned_cert_sha256 =
+ json.value("pinned_cert_sha256").toString().toLower();
+
+ const auto port = json.value("port").toInt();
+ config.port = port > 0 && port <= 65535 ? static_cast<quint16>(port) : 0;
+
+ // A stored cleartext mode is re-checked against the host rather than
+ // trusted. The file may predate the policy, or have been edited by hand;
+ // either way a remote host must not come back cleartext.
+ if (config.tls == MailTlsMode::kNONE &&
+ !MailHostAllowsCleartext(config.host)) {
+ config.tls = MailTlsMode::kIMPLICIT;
+ }
+
+ return config;
+}
+
+auto MailAccountConfig::Label() const -> QString {
+ if (!display_name.trimmed().isEmpty() && !address.trimmed().isEmpty()) {
+ return QString("%1 <%2>").arg(display_name.trimmed(), address.trimmed());
+ }
+ if (!address.trimmed().isEmpty()) return address.trimmed();
+ if (!display_name.trimmed().isEmpty()) return display_name.trimmed();
+ return {};
+}
+
+auto MailAccountConfig::IsUsable() const -> bool {
+ if (address.trimmed().isEmpty()) return false;
+ if (!imap.enabled && !smtp.enabled) return false;
+ if (imap.enabled && imap.host.isEmpty()) return false;
+ if (smtp.enabled && smtp.host.isEmpty()) return false;
+ return true;
+}
+
+auto MailAccountConfig::ToJson() const -> QJsonObject {
+ // No password and no secret of any kind: the credential lives in the
+ // credential store under `id`, and this object is what gets written to
+ // ordinary settings, logged, and exported.
+ return QJsonObject{
+ {"id", id},
+ {"display_name", display_name},
+ {"address", address},
+ {"reply_to", reply_to},
+ {"default_key_fpr", default_key_fpr},
+ {"imap", imap.ToJson()},
+ {"smtp", smtp.ToJson()},
+ {"sent_folder_override", sent_folder_override},
+ {"remember_password", remember_password},
+ {"page_size", page_size},
+ };
+}
+
+auto MailAccountConfig::FromJson(const QJsonObject& json) -> MailAccountConfig {
+ MailAccountConfig config;
+ config.id = json.value("id").toString();
+ config.display_name = json.value("display_name").toString();
+ config.address = json.value("address").toString().trimmed();
+ config.reply_to = json.value("reply_to").toString().trimmed();
+ config.default_key_fpr = json.value("default_key_fpr").toString();
+ config.imap = MailTransportConfig::FromJson(json.value("imap").toObject());
+ config.smtp = MailTransportConfig::FromJson(json.value("smtp").toObject());
+ config.sent_folder_override =
+ json.value("sent_folder_override").toString().trimmed();
+ config.remember_password = json.value("remember_password").toBool();
+ config.page_size =
+ MailClampPageSize(json.value("page_size").toInt(kMailDefaultPageSize));
+ return config;
+}