MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - data - AccountConfig.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 93.6 % 298 279
Test Date: 2026-06-21 21:10:19 Functions: 100.0 % 19 19
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 58.3 % 926 540

             Branch data     Line data    Source code
       1                 :             : #include "AccountConfig.h"
       2                 :             : #include "CredentialStore.h"
       3                 :             : 
       4                 :             : #include <QDir>
       5                 :             : #include <QFile>
       6                 :             : #include <QJsonDocument>
       7                 :             : #include <QJsonObject>
       8                 :             : #include <QEventLoop>
       9                 :             : #include <QLoggingCategory>
      10                 :             : #include <QSaveFile>
      11                 :             : #include <QStandardPaths>
      12                 :             : #include <QTimer>
      13                 :             : 
      14   [ +  +  +  -  :         539 : Q_LOGGING_CATEGORY(lcAccountConfig, "mailjd.accountconfig")
             +  -  -  - ]
      15                 :             : 
      16                 :             : namespace {
      17                 :             : 
      18                 :          10 : bool deleteSecretBlocking(const QString &service, const QString &accountName,
      19                 :             :                           QString *error) {
      20         [ +  - ]:          10 :   CredentialStore store;
      21         [ +  - ]:          10 :   QEventLoop loop;
      22         [ +  - ]:          10 :   QTimer timeout;
      23         [ +  - ]:          10 :   timeout.setSingleShot(true);
      24                 :             : 
      25                 :          10 :   bool completed = false;
      26                 :          10 :   bool success = false;
      27                 :             : 
      28         [ +  - ]:          10 :   QObject::connect(&timeout, &QTimer::timeout, &loop, [&]() {
      29         [ #  # ]:           0 :     if (completed)
      30                 :           0 :       return;
      31                 :           0 :     completed = true;
      32         [ #  # ]:           0 :     if (error)
      33                 :           0 :       *error = QStringLiteral("Timed out deleting password from keyring");
      34                 :           0 :     loop.quit();
      35                 :             :   });
      36                 :             : 
      37   [ +  -  +  - ]:          10 :   store.deletePassword(service, accountName, [&](bool ok) {
      38         [ -  + ]:          10 :     if (completed)
      39                 :           0 :       return;
      40                 :          10 :     completed = true;
      41                 :          10 :     success = ok;
      42                 :          10 :     loop.quit();
      43                 :             :   });
      44                 :             : 
      45         [ +  - ]:          10 :   timeout.start(30000);
      46         [ +  - ]:          10 :   loop.exec();
      47                 :             : 
      48   [ +  +  +  -  :          10 :   if (!success && error && error->isEmpty())
             +  -  +  + ]
      49                 :           8 :     *error = QStringLiteral("Could not delete password from keyring");
      50                 :          10 :   return success;
      51                 :          10 : }
      52                 :             : 
      53                 :             : } // namespace
      54                 :             : 
      55                 :         174 : QString AccountConfigLoader::defaultConfigDir() {
      56         [ +  - ]:         348 :   return QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) +
      57         [ +  - ]:         348 :          "/mailjd/accounts";
      58                 :             : }
      59                 :             : 
      60                 :          82 : std::vector<AccountConfig> AccountConfigLoader::loadAll() {
      61   [ +  -  +  - ]:          82 :   return loadAll(defaultConfigDir());
      62                 :             : }
      63                 :             : 
      64                 :             : std::vector<AccountConfig>
      65                 :         185 : AccountConfigLoader::loadAll(const QString &configDir) {
      66                 :         185 :   std::vector<AccountConfig> accounts;
      67         [ +  - ]:         185 :   QDir dir(configDir);
      68                 :             : 
      69   [ +  -  +  + ]:         185 :   if (!dir.exists()) {
      70   [ +  -  +  -  :          94 :     qCWarning(lcAccountConfig)
                   +  + ]
      71   [ +  -  +  - ]:          47 :         << "Config directory does not exist:" << configDir;
      72                 :          47 :     return accounts;
      73                 :             :   }
      74                 :             : 
      75   [ +  -  +  +  :         414 :   const auto files = dir.entryList({"*.json"}, QDir::Files, QDir::Name);
                   -  - ]
      76         [ +  + ]:         273 :   for (const auto &fileName : files) {
      77         [ +  - ]:         135 :     const auto path = dir.absoluteFilePath(fileName);
      78         [ +  - ]:         135 :     auto account = loadFromFile(path);
      79         [ +  + ]:         135 :     if (account.has_value()) {
      80   [ +  -  +  - ]:         133 :       auto errors = validate(account.value());
      81         [ +  + ]:         133 :       if (errors.isEmpty()) {
      82   [ +  -  +  -  :         264 :         qCInfo(lcAccountConfig)
                   +  + ]
      83   [ +  -  +  -  :         132 :             << "Loaded account:" << account->name << "from" << fileName;
             +  -  +  - ]
      84   [ +  -  +  - ]:         132 :         accounts.push_back(std::move(account.value()));
      85                 :             :       } else {
      86   [ +  -  +  -  :           2 :         qCWarning(lcAccountConfig)
                   +  + ]
      87   [ +  -  +  -  :           1 :             << "Invalid account config" << fileName << ":" << errors.join(", ");
          +  -  +  -  +  
                -  +  - ]
      88                 :             :       }
      89                 :         133 :     }
      90                 :         135 :   }
      91                 :             : 
      92   [ +  -  +  -  :         276 :   qCInfo(lcAccountConfig) << "Loaded" << accounts.size() << "accounts from"
          +  -  +  -  +  
                -  +  + ]
      93         [ +  - ]:         138 :                           << configDir;
      94                 :         138 :   return accounts;
      95   [ +  -  +  -  :         323 : }
             -  -  -  - ]
      96                 :             : 
      97                 :             : std::optional<AccountConfig>
      98                 :         156 : AccountConfigLoader::loadFromFile(const QString &path) {
      99         [ +  - ]:         156 :   QFile file(path);
     100   [ +  -  +  + ]:         156 :   if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
     101   [ +  -  +  -  :           4 :     qCWarning(lcAccountConfig)
                   +  + ]
     102   [ +  -  +  -  :           2 :         << "Cannot open config file:" << path << file.errorString();
             +  -  +  - ]
     103                 :           2 :     return std::nullopt;
     104                 :             :   }
     105                 :             : 
     106                 :         154 :   QJsonParseError parseError;
     107   [ +  -  +  - ]:         154 :   auto doc = QJsonDocument::fromJson(file.readAll(), &parseError);
     108   [ +  -  +  + ]:         154 :   if (doc.isNull()) {
     109   [ +  -  +  -  :           8 :     qCWarning(lcAccountConfig)
                   +  + ]
     110   [ +  -  +  -  :           4 :         << "JSON parse error in" << path << ":" << parseError.errorString();
          +  -  +  -  +  
                      - ]
     111                 :           4 :     return std::nullopt;
     112                 :             :   }
     113                 :             : 
     114   [ +  -  +  + ]:         150 :   if (!doc.isObject()) {
     115   [ +  -  +  -  :           2 :     qCWarning(lcAccountConfig) << "JSON root is not an object in" << path;
          +  -  +  -  +  
                      + ]
     116                 :           1 :     return std::nullopt;
     117                 :             :   }
     118                 :             : 
     119         [ +  - ]:         149 :   const auto root = doc.object();
     120         [ +  - ]:         149 :   AccountConfig config;
     121                 :             : 
     122   [ +  -  +  -  :         149 :   config.name = root.value("name").toString();
                   +  - ]
     123   [ +  -  +  -  :         149 :   config.email = root.value("email").toString();
                   +  - ]
     124                 :             : 
     125                 :             :   // IMAP
     126   [ +  -  +  -  :         149 :   const auto imapObj = root.value("imap").toObject();
                   +  - ]
     127   [ +  -  +  -  :         149 :   config.imap.host = imapObj.value("host").toString();
                   +  - ]
     128   [ +  -  +  -  :         149 :   config.imap.port = static_cast<quint16>(imapObj.value("port").toInt(993));
                   +  - ]
     129   [ +  -  +  -  :         149 :   config.imap.security = imapObj.value("security").toString("ssl");
             +  -  +  - ]
     130   [ +  -  +  -  :         149 :   config.imap.username = imapObj.value("username").toString();
                   +  - ]
     131   [ +  -  +  -  :         149 :   config.imap.password = imapObj.value("password").toString().toUtf8();
             +  -  +  - ]
     132                 :             : 
     133                 :             :   // SMTP
     134   [ +  -  +  -  :         149 :   const auto smtpObj = root.value("smtp").toObject();
                   +  - ]
     135   [ +  -  +  -  :         149 :   config.smtp.host = smtpObj.value("host").toString();
                   +  - ]
     136   [ +  -  +  -  :         149 :   config.smtp.port = static_cast<quint16>(smtpObj.value("port").toInt(587));
                   +  - ]
     137   [ +  -  +  -  :         149 :   config.smtp.security = smtpObj.value("security").toString("starttls");
             +  -  +  - ]
     138   [ +  -  +  -  :         149 :   config.smtp.username = smtpObj.value("username").toString();
                   +  - ]
     139   [ +  -  +  -  :         149 :   config.smtp.password = smtpObj.value("password").toString().toUtf8();
             +  -  +  - ]
     140                 :             : 
     141   [ +  +  +  +  :         149 :   if (!config.imap.password.isEmpty() || !config.smtp.password.isEmpty()) {
                   +  + ]
     142   [ +  -  +  + ]:          38 :     if (saveToFile(config, path)) {
     143   [ +  -  +  -  :          30 :       qCInfo(lcAccountConfig)
                   +  + ]
     144   [ +  -  +  - ]:          15 :           << "Migrated plaintext account passwords to keyring:" << path;
     145                 :             :     } else {
     146   [ +  -  +  -  :          46 :       qCWarning(lcAccountConfig)
                   +  + ]
     147                 :             :           << "Could not migrate plaintext account passwords; retaining"
     148         [ +  - ]:          23 :              " the original config file:"
     149         [ +  - ]:          23 :           << path;
     150                 :             :     }
     151                 :             :   }
     152                 :             : 
     153                 :         149 :   return config;
     154                 :         156 : }
     155                 :             : 
     156                 :         173 : QStringList AccountConfigLoader::validate(const AccountConfig &config) {
     157                 :         173 :   QStringList errors;
     158                 :             : 
     159         [ +  + ]:         173 :   if (config.name.isEmpty())
     160   [ +  -  +  - ]:          13 :     errors << "Missing 'name'";
     161         [ +  + ]:         173 :   if (config.email.isEmpty())
     162   [ +  -  +  - ]:          16 :     errors << "Missing 'email'";
     163                 :             : 
     164                 :             :   // IMAP validation
     165         [ +  + ]:         173 :   if (config.imap.host.isEmpty())
     166   [ +  -  +  - ]:          14 :     errors << "Missing 'imap.host'";
     167         [ +  + ]:         173 :   if (config.imap.username.isEmpty())
     168   [ +  -  +  - ]:          13 :     errors << "Missing 'imap.username'";
     169         [ +  + ]:         173 :   if (config.imap.port == 0)
     170   [ +  -  +  - ]:           2 :     errors << "Invalid 'imap.port'";
     171   [ +  +  +  +  :         173 :   if (config.imap.security != "ssl" && config.imap.security != "starttls")
                   +  + ]
     172   [ +  -  +  - ]:           3 :     errors << "Invalid 'imap.security' (must be 'ssl' or 'starttls')";
     173                 :             : 
     174                 :             :   // SMTP validation
     175         [ +  + ]:         173 :   if (config.smtp.host.isEmpty())
     176   [ +  -  +  - ]:          13 :     errors << "Missing 'smtp.host'";
     177         [ +  + ]:         173 :   if (config.smtp.username.isEmpty())
     178   [ +  -  +  - ]:          13 :     errors << "Missing 'smtp.username'";
     179         [ +  + ]:         173 :   if (config.smtp.port == 0)
     180   [ +  -  +  - ]:           2 :     errors << "Invalid 'smtp.port'";
     181   [ +  +  +  +  :         173 :   if (config.smtp.security != "ssl" && config.smtp.security != "starttls")
                   +  + ]
     182   [ +  -  +  - ]:           2 :     errors << "Invalid 'smtp.security' (must be 'ssl' or 'starttls')";
     183                 :             : 
     184                 :         173 :   return errors;
     185                 :           0 : }
     186                 :             : 
     187                 :          13 : bool AccountConfigLoader::save(const AccountConfig &config,
     188                 :             :                                const QString &configDir) {
     189         [ +  - ]:          13 :   QDir dir(configDir);
     190   [ +  -  +  + ]:          13 :   if (!dir.exists()) {
     191   [ +  -  +  -  :           4 :     if (!dir.mkpath(".")) {
                   +  + ]
     192   [ +  -  +  -  :           2 :       qCWarning(lcAccountConfig)
                   +  + ]
     193   [ +  -  +  - ]:           1 :           << "Failed to create config directory:" << configDir;
     194                 :           1 :       return false;
     195                 :             :     }
     196                 :             :   }
     197                 :             : 
     198   [ +  -  +  - ]:          12 :   auto filename = slugify(config.name) + ".json";
     199         [ +  - ]:          12 :   auto path = dir.absoluteFilePath(filename);
     200         [ +  - ]:          12 :   return saveToFile(config, path);
     201                 :          13 : }
     202                 :             : 
     203                 :          56 : bool AccountConfigLoader::saveToFile(const AccountConfig &config,
     204                 :             :                                      const QString &path) {
     205                 :          88 :   auto writeSecret = [&config](const QString &service,
     206                 :             :                                const QByteArray &password,
     207                 :             :                                QString *error) {
     208         [ +  + ]:          88 :     if (password.isEmpty())
     209                 :          19 :       return true;
     210                 :             : 
     211         [ +  - ]:          69 :     CredentialStore store;
     212         [ +  - ]:          69 :     QEventLoop loop;
     213         [ +  - ]:          69 :     QTimer timeout;
     214         [ +  - ]:          69 :     timeout.setSingleShot(true);
     215                 :             : 
     216                 :          69 :     bool completed = false;
     217                 :          69 :     bool success = false;
     218                 :          69 :     QString writeError;
     219                 :             : 
     220         [ +  - ]:          69 :     QObject::connect(&timeout, &QTimer::timeout, &loop, [&]() {
     221         [ #  # ]:           0 :       if (!completed) {
     222                 :           0 :         completed = true;
     223                 :           0 :         writeError = QStringLiteral("Timed out writing password to keyring");
     224                 :           0 :         loop.quit();
     225                 :             :       }
     226                 :           0 :     });
     227                 :             : 
     228         [ +  - ]:          69 :     store.writePassword(service, config.name, password,
     229         [ +  - ]:          69 :                         [&](bool ok, const QString &err) {
     230         [ -  + ]:          69 :                           if (completed)
     231                 :           0 :                             return;
     232                 :          69 :                           completed = true;
     233                 :          69 :                           success = ok;
     234                 :          69 :                           writeError = err;
     235                 :          69 :                           loop.quit();
     236                 :             :                         });
     237         [ +  - ]:          69 :     timeout.start(30000);
     238         [ +  - ]:          69 :     loop.exec();
     239                 :             : 
     240         [ +  + ]:          69 :     if (!success) {
     241         [ +  - ]:          25 :       if (error)
     242                 :          25 :         *error = writeError.isEmpty()
     243   [ -  +  -  + ]:          50 :                      ? QStringLiteral("Could not write password to keyring")
     244                 :          25 :                      : writeError;
     245                 :          25 :       return false;
     246                 :             :     }
     247                 :          44 :     return true;
     248                 :          69 :   };
     249                 :             : 
     250                 :          56 :   QString keyringError;
     251   [ +  -  +  + ]:         112 :   if (!writeSecret(QStringLiteral("imap"), config.imap.password,
     252                 :             :                    &keyringError)) {
     253   [ +  -  +  -  :          48 :     qCWarning(lcAccountConfig)
                   +  + ]
     254         [ +  - ]:          24 :         << "Refusing to save account after IMAP keyring write failed:"
     255         [ +  - ]:          24 :         << keyringError;
     256                 :          24 :     return false;
     257                 :             :   }
     258   [ +  -  +  + ]:          64 :   if (!writeSecret(QStringLiteral("smtp"), config.smtp.password,
     259                 :             :                    &keyringError)) {
     260   [ +  -  +  -  :           2 :     qCWarning(lcAccountConfig)
                   +  + ]
     261         [ +  - ]:           1 :         << "Refusing to save account after SMTP keyring write failed:"
     262         [ +  - ]:           1 :         << keyringError;
     263                 :           1 :     return false;
     264                 :             :   }
     265                 :             : 
     266         [ +  - ]:          31 :   QJsonObject imapObj;
     267   [ +  -  +  -  :          31 :   imapObj["host"] = config.imap.host;
             +  -  +  - ]
     268   [ +  -  +  -  :          31 :   imapObj["port"] = config.imap.port;
             +  -  +  - ]
     269   [ +  -  +  -  :          31 :   imapObj["security"] = config.imap.security;
             +  -  +  - ]
     270   [ +  -  +  -  :          31 :   imapObj["username"] = config.imap.username;
             +  -  +  - ]
     271                 :             :   // Omit passwords from JSON (stored in keyring)
     272   [ +  -  +  -  :          31 :   imapObj["password"] = QString();
             +  -  +  - ]
     273                 :             : 
     274         [ +  - ]:          31 :   QJsonObject smtpObj;
     275   [ +  -  +  -  :          31 :   smtpObj["host"] = config.smtp.host;
             +  -  +  - ]
     276   [ +  -  +  -  :          31 :   smtpObj["port"] = config.smtp.port;
             +  -  +  - ]
     277   [ +  -  +  -  :          31 :   smtpObj["security"] = config.smtp.security;
             +  -  +  - ]
     278   [ +  -  +  -  :          31 :   smtpObj["username"] = config.smtp.username;
             +  -  +  - ]
     279   [ +  -  +  -  :          31 :   smtpObj["password"] = QString();
             +  -  +  - ]
     280                 :             : 
     281         [ +  - ]:          31 :   QJsonObject root;
     282   [ +  -  +  -  :          31 :   root["name"] = config.name;
             +  -  +  - ]
     283   [ +  -  +  -  :          31 :   root["email"] = config.email;
             +  -  +  - ]
     284   [ +  -  +  -  :          31 :   root["imap"] = imapObj;
             +  -  +  - ]
     285   [ +  -  +  -  :          31 :   root["smtp"] = smtpObj;
             +  -  +  - ]
     286                 :             : 
     287         [ +  - ]:          31 :   QJsonDocument doc(root);
     288                 :             : 
     289                 :             :   // T-501: Use QSaveFile for atomic write — prevents TOCTOU permission race
     290         [ +  - ]:          31 :   QSaveFile file(path);
     291   [ +  -  +  + ]:          31 :   if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
     292   [ +  -  +  -  :           2 :     qCWarning(lcAccountConfig)
                   +  + ]
     293   [ +  -  +  -  :           1 :         << "Cannot write config file:" << path << file.errorString();
             +  -  +  - ]
     294                 :           1 :     return false;
     295                 :             :   }
     296                 :             : 
     297                 :             :   // Set restrictive permissions BEFORE writing (prevents TOCTOU race)
     298         [ +  - ]:          30 :   file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner);
     299                 :             : 
     300   [ +  -  +  - ]:          30 :   file.write(doc.toJson(QJsonDocument::Indented));
     301                 :             : 
     302   [ +  -  -  + ]:          30 :   if (!file.commit()) {
     303   [ #  #  #  #  :           0 :     qCWarning(lcAccountConfig)
                   #  # ]
     304   [ #  #  #  # ]:           0 :         << "Failed to commit config file:" << path;
     305                 :           0 :     return false;
     306                 :             :   }
     307                 :             : 
     308   [ +  -  +  -  :          60 :   qCInfo(lcAccountConfig) << "Saved account:" << config.name << "to" << path;
          +  -  +  -  +  
             -  +  -  +  
                      + ]
     309                 :          30 :   return true;
     310                 :          56 : }
     311                 :             : 
     312                 :           8 : bool AccountConfigLoader::remove(const QString &accountName,
     313                 :             :                                  const QString &configDir) {
     314         [ +  - ]:           8 :   QDir dir(configDir);
     315   [ +  -  +  + ]:           8 :   if (!dir.exists()) {
     316   [ +  -  +  -  :           2 :     qCWarning(lcAccountConfig)
                   +  + ]
     317   [ +  -  +  - ]:           1 :         << "Config directory does not exist:" << configDir;
     318                 :           1 :     return false;
     319                 :             :   }
     320                 :             : 
     321                 :             :   // Strategy: find JSON file containing matching "name" field.
     322                 :             :   // Also check by slugified filename as fallback.
     323   [ +  -  +  +  :          21 :   const auto files = dir.entryList({"*.json"}, QDir::Files);
                   -  - ]
     324         [ +  + ]:          11 :   for (const auto &fileName : files) {
     325         [ +  - ]:           9 :     auto path = dir.absoluteFilePath(fileName);
     326         [ +  - ]:           9 :     auto account = loadFromFile(path);
     327   [ +  +  +  +  :           9 :     if (account.has_value() && account->name == accountName) {
                   +  + ]
     328   [ +  -  +  - ]:           5 :       if (QFile::remove(path)) {
     329                 :           5 :         QString keyringError;
     330   [ +  -  +  + ]:           5 :         if (!deleteSecretBlocking(QStringLiteral("imap"), accountName,
     331                 :             :                                   &keyringError)) {
     332   [ +  -  +  -  :           8 :           qCWarning(lcAccountConfig)
                   +  + ]
     333         [ +  - ]:           4 :               << "Failed to delete IMAP keyring secret for"
     334   [ +  -  +  -  :           4 :               << accountName << ":" << keyringError;
                   +  - ]
     335                 :             :         }
     336                 :           5 :         keyringError.clear();
     337   [ +  -  +  + ]:           5 :         if (!deleteSecretBlocking(QStringLiteral("smtp"), accountName,
     338                 :             :                                   &keyringError)) {
     339   [ +  -  +  -  :           8 :           qCWarning(lcAccountConfig)
                   +  + ]
     340         [ +  - ]:           4 :               << "Failed to delete SMTP keyring secret for"
     341   [ +  -  +  -  :           4 :               << accountName << ":" << keyringError;
                   +  - ]
     342                 :             :         }
     343   [ +  -  +  -  :          10 :         qCInfo(lcAccountConfig)
                   +  + ]
     344   [ +  -  +  -  :           5 :             << "Deleted account:" << accountName << "file:" << fileName;
             +  -  +  - ]
     345                 :           5 :         return true;
     346                 :           5 :       } else {
     347   [ #  #  #  #  :           0 :         qCWarning(lcAccountConfig) << "Failed to delete file:" << path;
          #  #  #  #  #  
                      # ]
     348                 :           0 :         return false;
     349                 :             :       }
     350                 :             :     }
     351   [ +  +  +  + ]:          14 :   }
     352                 :             : 
     353   [ +  -  +  -  :           4 :   qCWarning(lcAccountConfig)
                   +  + ]
     354   [ +  -  +  - ]:           2 :       << "No config file found for account:" << accountName;
     355                 :           2 :   return false;
     356   [ +  -  +  -  :          15 : }
             -  -  -  - ]
     357                 :             : 
     358                 :          40 : QString AccountConfigLoader::slugify(const QString &name) {
     359                 :          40 :   QString result;
     360         [ +  - ]:          40 :   result.reserve(name.size());
     361                 :             : 
     362                 :             :   // Normalize: lowercase, replace non-ASCII and special chars
     363   [ +  -  +  - ]:          40 :   auto normalized = name.toLower().normalized(QString::NormalizationForm_KD);
     364                 :             : 
     365   [ +  -  +  -  :         573 :   for (const auto &ch : normalized) {
                   +  + ]
     366   [ +  +  +  +  :         533 :     if (ch.isLetterOrNumber() && ch.unicode() < 128) {
                   +  + ]
     367         [ +  - ]:         497 :       result.append(ch);
     368   [ +  +  +  +  :          36 :     } else if (ch == ' ' || ch == '-' || ch == '_') {
             +  +  +  + ]
     369                 :             :       // Collapse consecutive separators
     370   [ +  +  +  -  :          29 :       if (!result.isEmpty() && result.back() != '_') {
             +  +  +  + ]
     371         [ +  - ]:          25 :         result.append('_');
     372                 :             :       }
     373                 :             :     }
     374                 :             :     // Skip all other characters (accents, special chars)
     375                 :             :   }
     376                 :             : 
     377                 :             :   // Remove trailing underscore
     378   [ +  -  +  + ]:          41 :   while (result.endsWith('_')) {
     379         [ +  - ]:           1 :     result.chop(1);
     380                 :             :   }
     381                 :             : 
     382                 :             :   // Limit length
     383         [ +  + ]:          40 :   if (result.size() > 50) {
     384         [ +  - ]:           3 :     result.truncate(50);
     385                 :             :     // Don't end on underscore after truncation
     386   [ +  -  +  + ]:           4 :     while (result.endsWith('_')) {
     387         [ +  - ]:           1 :       result.chop(1);
     388                 :             :     }
     389                 :             :   }
     390                 :             : 
     391                 :             :   // Fallback for empty result
     392         [ +  + ]:          40 :   if (result.isEmpty()) {
     393         [ +  - ]:           5 :     result = "account";
     394                 :             :   }
     395                 :             : 
     396                 :          40 :   return result;
     397                 :          40 : }
     398                 :             : 
     399                 :             : // SEC-01/02: Async keyring password resolution
     400                 :          67 : void AccountConfigLoader::resolvePasswords(
     401                 :             :     std::vector<AccountConfig> &accounts, CredentialStore *store,
     402                 :             :     std::function<void()> callback) {
     403   [ +  +  +  +  :          67 :   if (!store || accounts.empty()) {
                   +  + ]
     404   [ +  +  +  - ]:           3 :     if (callback) callback();
     405                 :          15 :     return;
     406                 :             :   }
     407                 :             : 
     408                 :             :   // Count how many passwords need resolving
     409                 :          64 :   int pending = 0;
     410         [ +  + ]:         129 :   for (const auto &acc : accounts) {
     411         [ +  + ]:          65 :     if (acc.imap.password.isEmpty()) ++pending;
     412         [ +  + ]:          65 :     if (acc.smtp.password.isEmpty()) ++pending;
     413                 :             :   }
     414                 :             : 
     415         [ +  + ]:          64 :   if (pending == 0) {
     416   [ +  +  +  - ]:          12 :     if (callback) callback();
     417                 :          12 :     return;
     418                 :             :   }
     419                 :             : 
     420                 :             :   // Shared counter for tracking completion
     421         [ +  - ]:          52 :   auto remaining = std::make_shared<int>(pending);
     422                 :             : 
     423         [ +  + ]:         105 :   for (size_t i = 0; i < accounts.size(); ++i) {
     424                 :          53 :     auto &acc = accounts[i];
     425                 :             : 
     426         [ +  + ]:          53 :     if (acc.imap.password.isEmpty()) {
     427         [ +  - ]:          52 :       store->readPassword(
     428                 :         156 :           QStringLiteral("imap"), acc.name,
     429   [ +  -  +  -  :         104 :           [&acc, remaining, callback](bool success, const QByteArray &pw) {
                   -  - ]
     430   [ +  -  +  +  :          52 :             if (success && !pw.isEmpty()) {
                   +  + ]
     431                 :          51 :               acc.imap.password = pw;
     432   [ +  -  +  -  :         102 :               qCInfo(lcAccountConfig)
                   +  + ]
     433   [ +  -  +  - ]:          51 :                   << "Resolved IMAP password from keyring for" << acc.name;
     434                 :             :             }
     435   [ -  +  -  -  :          52 :             if (--(*remaining) == 0 && callback) callback();
                   -  + ]
     436                 :          52 :           });
     437                 :             :     }
     438                 :             : 
     439         [ +  - ]:          53 :     if (acc.smtp.password.isEmpty()) {
     440         [ +  - ]:          53 :       store->readPassword(
     441                 :         159 :           QStringLiteral("smtp"), acc.name,
     442   [ +  -  +  -  :         106 :           [&acc, remaining, callback](bool success, const QByteArray &pw) {
                   -  - ]
     443   [ +  +  +  -  :          53 :             if (success && !pw.isEmpty()) {
                   +  + ]
     444                 :          52 :               acc.smtp.password = pw;
     445   [ +  -  +  -  :         104 :               qCInfo(lcAccountConfig)
                   +  + ]
     446   [ +  -  +  - ]:          52 :                   << "Resolved SMTP password from keyring for" << acc.name;
     447                 :             :             }
     448   [ +  +  +  +  :          53 :             if (--(*remaining) == 0 && callback) callback();
                   +  + ]
     449                 :          53 :           });
     450                 :             :     }
     451                 :             :   }
     452                 :          52 : }
        

Generated by: LCOV version 2.0-1