MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - service - Rfc2822Builder.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 92.9 % 226 210
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: 53.0 % 470 249

             Branch data     Line data    Source code
       1                 :             : #include "Rfc2822Builder.h"
       2                 :             : 
       3                 :             : #include <QFile>
       4                 :             : #include <QFileInfo>
       5                 :             : #include <QHostInfo>
       6                 :             : #include <QLocale>
       7                 :             : #include <QUuid>
       8                 :             : 
       9                 :             : // ═══════════════════════════════════════════════════════
      10                 :             : // Header setters
      11                 :             : // ═══════════════════════════════════════════════════════
      12                 :             : 
      13                 :          69 : void Rfc2822Builder::setFrom(const QString &name, const QString &email) {
      14                 :          69 :   m_fromName = name;
      15                 :          69 :   m_fromEmail = email;
      16                 :          69 : }
      17                 :             : 
      18                 :          69 : void Rfc2822Builder::setTo(const QStringList &recipients) {
      19                 :          69 :   m_to = recipients;
      20                 :          69 : }
      21                 :             : 
      22                 :           9 : void Rfc2822Builder::setCc(const QStringList &cc) { m_cc = cc; }
      23                 :             : 
      24                 :          12 : void Rfc2822Builder::setBcc(const QStringList &bcc) { m_bcc = bcc; }
      25                 :             : 
      26                 :          69 : void Rfc2822Builder::setSubject(const QString &subject) {
      27                 :          69 :   m_subject = subject;
      28                 :          69 : }
      29                 :             : 
      30                 :           5 : void Rfc2822Builder::setDate(const QDateTime &date) { m_date = date; }
      31                 :             : 
      32                 :           2 : void Rfc2822Builder::setMessageId(const QString &messageId) {
      33                 :           2 :   m_messageId = messageId;
      34                 :           2 : }
      35                 :             : 
      36                 :           9 : void Rfc2822Builder::setInReplyTo(const QString &messageId) {
      37                 :           9 :   m_inReplyTo = messageId;
      38                 :           9 : }
      39                 :             : 
      40                 :           8 : void Rfc2822Builder::setReferences(const QStringList &messageIds) {
      41                 :           8 :   m_references = messageIds;
      42                 :           8 : }
      43                 :             : 
      44                 :          69 : void Rfc2822Builder::setBodyText(const QString &plainText) {
      45                 :          69 :   m_bodyText = plainText;
      46                 :          69 : }
      47                 :             : 
      48                 :           8 : void Rfc2822Builder::addAttachment(const Attachment &attachment) {
      49                 :           8 :   m_attachments.append(attachment);
      50                 :           8 : }
      51                 :             : 
      52                 :          10 : bool Rfc2822Builder::addAttachmentFromFile(const QString &filePath) {
      53         [ +  - ]:          10 :   QFile file(filePath);
      54                 :             :   // T-622/FUNC-07: Return false if file cannot be opened
      55   [ +  -  +  + ]:          10 :   if (!file.open(QIODevice::ReadOnly))
      56                 :           4 :     return false;
      57                 :             : 
      58                 :           6 :   Attachment att;
      59   [ +  -  +  - ]:           6 :   att.filename = QFileInfo(filePath).fileName();
      60         [ +  - ]:           6 :   att.data = file.readAll();
      61         [ +  - ]:           6 :   att.mimeType = "application/octet-stream";
      62         [ +  - ]:           6 :   m_attachments.append(att);
      63                 :           6 :   return true;
      64                 :          10 : }
      65                 :             : 
      66                 :             : // ═══════════════════════════════════════════════════════
      67                 :             : // RFC 2047: Header encoding for non-ASCII
      68                 :             : // ═══════════════════════════════════════════════════════
      69                 :             : 
      70                 :         138 : QByteArray Rfc2822Builder::encodeHeader(const QString &value) {
      71         [ +  - ]:         138 :   QByteArray utf8 = value.toUtf8();
      72                 :             : 
      73                 :             :   // T-400/Bug 4: Always strip CR/LF to prevent header injection
      74         [ +  - ]:         138 :   utf8.replace('\r', "");
      75         [ +  - ]:         138 :   utf8.replace('\n', "");
      76                 :             : 
      77                 :             :   // Check if encoding is needed (any non-ASCII?)
      78                 :         138 :   bool needsEncoding = false;
      79   [ +  -  +  -  :        1314 :   for (char c : utf8) {
                   +  + ]
      80         [ +  + ]:        1184 :     if (static_cast<unsigned char>(c) > 127) {
      81                 :           8 :       needsEncoding = true;
      82                 :           8 :       break;
      83                 :             :     }
      84                 :             :   }
      85                 :             : 
      86         [ +  + ]:         138 :   if (!needsEncoding)
      87                 :         130 :     return utf8;
      88                 :             : 
      89                 :             :   // RFC 2047 Base64 encoded-word: =?UTF-8?B?<base64>?=
      90                 :             :   // Split into chunks if needed (max 75 chars per encoded-word)
      91                 :           8 :   QByteArray encoded;
      92         [ +  - ]:           8 :   const QByteArray prefix = "=?UTF-8?B?";
      93         [ +  - ]:           8 :   const QByteArray suffix = "?=";
      94                 :             :   // Max base64 payload to fit in 75 chars: 75 - prefix.size() - suffix.size()
      95                 :           8 :   const int maxPayloadBytes = ((75 - prefix.size() - suffix.size()) / 4) * 3;
      96                 :             : 
      97                 :           8 :   int pos = 0;
      98         [ +  + ]:          16 :   while (pos < utf8.size()) {
      99                 :             :     // Find a chunk boundary that doesn't split a UTF-8 multibyte sequence
     100                 :           8 :     int chunkEnd = qMin(pos + maxPayloadBytes, utf8.size());
     101                 :             :     // Don't split in the middle of a UTF-8 multibyte char
     102   [ -  +  -  -  :           8 :     while (chunkEnd < utf8.size() && chunkEnd > pos &&
                   -  + ]
     103   [ #  #  #  # ]:           0 :            (static_cast<unsigned char>(utf8[chunkEnd]) & 0xC0) == 0x80) {
     104                 :           0 :       --chunkEnd;
     105                 :             :     }
     106         [ -  + ]:           8 :     if (chunkEnd <= pos)
     107                 :           0 :       chunkEnd = qMin(pos + maxPayloadBytes, utf8.size());
     108                 :             : 
     109         [ +  - ]:           8 :     QByteArray chunk = utf8.mid(pos, chunkEnd - pos);
     110                 :             : 
     111         [ -  + ]:           8 :     if (!encoded.isEmpty())
     112         [ #  # ]:           0 :       encoded += "\r\n "; // Folding whitespace between encoded-words
     113                 :             : 
     114   [ +  -  +  -  :           8 :     encoded += prefix + chunk.toBase64() + suffix;
             +  -  +  - ]
     115                 :           8 :     pos = chunkEnd;
     116                 :           8 :   }
     117                 :             : 
     118                 :           8 :   return encoded;
     119                 :         138 : }
     120                 :             : 
     121                 :          76 : QByteArray Rfc2822Builder::encodeAddress(const QString &name,
     122                 :             :                                           const QString &email) {
     123                 :             :   // T-400/Bug 4: Sanitize CRLF from email to prevent header injection
     124                 :          76 :   QString safeEmail = email;
     125         [ +  - ]:          76 :   safeEmail.remove('\r');
     126         [ +  - ]:          76 :   safeEmail.remove('\n');
     127                 :             : 
     128         [ +  + ]:          76 :   if (name.isEmpty())
     129         [ +  - ]:          41 :     return safeEmail.toUtf8();
     130                 :             : 
     131   [ +  -  +  -  :          35 :   return encodeHeader(name) + " <" + safeEmail.toUtf8() + ">";
          +  -  +  -  +  
                      - ]
     132                 :          76 : }
     133                 :             : 
     134                 :          87 : QByteArray Rfc2822Builder::encodeAddressList(const QStringList &addresses) {
     135                 :             :   // Each address can be "name <email>" or just "email"
     136                 :             :   // Parse and re-encode each
     137                 :          87 :   QByteArray result;
     138         [ +  + ]:         180 :   for (const QString &addr : addresses) {
     139         [ +  + ]:          93 :     if (!result.isEmpty())
     140         [ +  - ]:           7 :       result += ",\r\n ";
     141                 :             : 
     142         [ +  - ]:          93 :     QString trimmed = addr.trimmed();
     143                 :          93 :     int ltIdx = trimmed.indexOf('<');
     144                 :          93 :     int gtIdx = trimmed.indexOf('>');
     145                 :             : 
     146   [ +  +  +  - ]:          93 :     if (ltIdx > 0 && gtIdx > ltIdx) {
     147                 :             :       // "Display Name <email>"
     148   [ +  -  +  - ]:           6 :       QString name = trimmed.left(ltIdx).trimmed();
     149                 :             :       // Remove surrounding quotes if present
     150   [ +  -  -  +  :           6 :       if (name.startsWith('"') && name.endsWith('"'))
          -  -  -  -  -  
                      + ]
     151         [ #  # ]:           0 :         name = name.mid(1, name.size() - 2);
     152   [ +  -  +  - ]:           6 :       QString email = trimmed.mid(ltIdx + 1, gtIdx - ltIdx - 1).trimmed();
     153   [ +  -  +  - ]:           6 :       result += encodeAddress(name, email);
     154                 :           6 :     } else {
     155                 :             :       // Plain email address — T-509: sanitize CRLF to prevent header injection
     156                 :          87 :       QString safeAddr = trimmed;
     157         [ +  - ]:          87 :       safeAddr.remove('\r');
     158         [ +  - ]:          87 :       safeAddr.remove('\n');
     159   [ +  -  +  - ]:          87 :       result += safeAddr.toUtf8();
     160                 :          87 :     }
     161                 :          93 :   }
     162                 :          87 :   return result;
     163                 :           0 : }
     164                 :             : 
     165                 :             : // ═══════════════════════════════════════════════════════
     166                 :             : // Quoted-Printable encoding (RFC 2045)
     167                 :             : // ═══════════════════════════════════════════════════════
     168                 :             : 
     169                 :          74 : QByteArray Rfc2822Builder::encodeQuotedPrintable(const QByteArray &data) {
     170                 :          74 :   QByteArray result;
     171                 :          74 :   int lineLen = 0;
     172                 :             : 
     173         [ +  + ]:        1277 :   for (int i = 0; i < data.size(); ++i) {
     174                 :        1203 :     unsigned char c = static_cast<unsigned char>(data[i]);
     175                 :             : 
     176                 :             :     // Handle CRLF pass-through
     177   [ +  +  +  -  :        1203 :     if (c == '\r' && i + 1 < data.size() && data[i + 1] == '\n') {
             +  -  +  + ]
     178         [ +  - ]:           1 :       result += "\r\n";
     179                 :           1 :       lineLen = 0;
     180                 :           1 :       ++i; // Skip the \n
     181                 :          33 :       continue;
     182                 :             :     }
     183                 :             : 
     184                 :             :     // Handle bare LF → CRLF
     185         [ +  + ]:        1202 :     if (c == '\n') {
     186         [ +  - ]:          32 :       result += "\r\n";
     187                 :          32 :       lineLen = 0;
     188                 :          32 :       continue;
     189                 :             :     }
     190                 :             : 
     191                 :        1170 :     QByteArray encoded;
     192                 :             :     // Printable ASCII (33-126) except '=' can be literal
     193   [ +  +  +  +  :        1170 :     if ((c >= 33 && c <= 126 && c != '=') || c == '\t' || c == ' ') {
          +  +  +  -  +  
                      + ]
     194                 :             :       // Space/tab at end of line must be encoded
     195                 :             :       bool atEol =
     196         [ +  - ]:        2244 :           (i + 1 >= data.size()) ||
     197   [ +  +  +  +  :        4424 :           (i + 1 < data.size() && data[i + 1] == '\r') ||
                   +  - ]
     198         [ +  + ]:        2180 :           (i + 1 < data.size() && data[i + 1] == '\n');
     199   [ +  +  -  +  :        1153 :       if ((c == ' ' || c == '\t') && atEol) {
                   -  + ]
     200   [ #  #  #  #  :           0 :         encoded = "=" + QByteArray::number(c, 16).toUpper().rightJustified(2, '0');
             #  #  #  # ]
     201                 :             :       } else {
     202         [ +  - ]:        1153 :         encoded += static_cast<char>(c);
     203                 :             :       }
     204                 :        1153 :     } else {
     205   [ +  -  +  -  :          17 :       encoded = "=" + QByteArray::number(c, 16).toUpper().rightJustified(2, '0');
             +  -  +  - ]
     206                 :             :     }
     207                 :             : 
     208                 :             :     // Soft line break if line would exceed 76 chars
     209         [ -  + ]:        1170 :     if (lineLen + encoded.size() > 75) {
     210         [ #  # ]:           0 :       result += "=\r\n";
     211                 :           0 :       lineLen = 0;
     212                 :             :     }
     213                 :             : 
     214         [ +  - ]:        1170 :     result += encoded;
     215                 :        1170 :     lineLen += encoded.size();
     216                 :        1170 :   }
     217                 :             : 
     218                 :          74 :   return result;
     219                 :           0 : }
     220                 :             : 
     221                 :             : // ═══════════════════════════════════════════════════════
     222                 :             : // Message-ID and boundary generation
     223                 :             : // ═══════════════════════════════════════════════════════
     224                 :             : 
     225                 :          64 : QByteArray Rfc2822Builder::generateMessageId() {
     226                 :             :   QString uuid =
     227   [ +  -  +  -  :          64 :       QUuid::createUuid().toString(QUuid::WithoutBraces).remove('-');
                   +  - ]
     228         [ +  - ]:          64 :   QString host = QHostInfo::localHostName();
     229         [ -  + ]:          64 :   if (host.isEmpty())
     230                 :           0 :     host = QStringLiteral("localhost");
     231   [ +  -  +  -  :         128 :   return ('<' + uuid + '@' + host + '>').toUtf8();
          +  -  +  -  +  
                      - ]
     232                 :          64 : }
     233                 :             : 
     234                 :          12 : QByteArray Rfc2822Builder::generateBoundary() const {
     235                 :             :   return "----=_Part_" +
     236         [ +  - ]:          12 :          QUuid::createUuid()
     237         [ +  - ]:          12 :              .toString(QUuid::WithoutBraces)
     238         [ +  - ]:          24 :              .remove('-')
     239   [ +  -  +  - ]:          24 :              .toLatin1();
     240                 :             : }
     241                 :             : 
     242                 :             : // ═══════════════════════════════════════════════════════
     243                 :             : // Build the complete RFC-2822 message
     244                 :             : // ═══════════════════════════════════════════════════════
     245                 :             : 
     246                 :          67 : QByteArray Rfc2822Builder::build() const {
     247                 :          67 :   QByteArray msg;
     248                 :             : 
     249                 :             :   // Date header — required
     250   [ +  -  +  +  :          67 :   QDateTime date = m_date.isValid() ? m_date : QDateTime::currentDateTimeUtc();
                   +  - ]
     251                 :             :   // T-400/Bug 15: Use C locale for RFC 5322 compliant English date names
     252                 :          67 :   msg += "Date: " +
     253   [ +  -  +  -  :         201 :          QLocale(QLocale::C).toString(date, QStringLiteral("ddd, dd MMM yyyy HH:mm:ss")).toUtf8();
          +  -  +  -  +  
                      - ]
     254         [ +  - ]:          67 :   int utcOffset = date.offsetFromUtc();
     255         [ +  - ]:          67 :   if (utcOffset == 0) {
     256         [ +  - ]:          67 :     msg += " +0000";
     257                 :             :   } else {
     258                 :           0 :     int hours = qAbs(utcOffset) / 3600;
     259                 :           0 :     int mins = (qAbs(utcOffset) % 3600) / 60;
     260   [ #  #  #  # ]:           0 :     msg += (utcOffset > 0 ? " +" : " -");
     261   [ #  #  #  #  :           0 :     msg += QByteArray::number(hours).rightJustified(2, '0');
                   #  # ]
     262   [ #  #  #  #  :           0 :     msg += QByteArray::number(mins).rightJustified(2, '0');
                   #  # ]
     263                 :             :   }
     264         [ +  - ]:          67 :   msg += "\r\n";
     265                 :             : 
     266                 :             :   // From
     267         [ +  + ]:          67 :   if (!m_fromEmail.isEmpty()) {
     268   [ +  -  +  -  :          66 :     msg += "From: " + encodeAddress(m_fromName, m_fromEmail) + "\r\n";
             +  -  +  - ]
     269                 :             :   }
     270                 :             : 
     271                 :             :   // To
     272         [ +  + ]:          67 :   if (!m_to.isEmpty()) {
     273   [ +  -  +  -  :          65 :     msg += "To: " + encodeAddressList(m_to) + "\r\n";
             +  -  +  - ]
     274                 :             :   }
     275                 :             : 
     276                 :             :   // Cc
     277         [ +  + ]:          67 :   if (!m_cc.isEmpty()) {
     278   [ +  -  +  -  :           9 :     msg += "Cc: " + encodeAddressList(m_cc) + "\r\n";
             +  -  +  - ]
     279                 :             :   }
     280                 :             : 
     281                 :             :   // Bcc — T-502: only include if m_includeBcc is set (IMAP APPEND yes, SMTP no)
     282   [ +  +  +  +  :          67 :   if (!m_bcc.isEmpty() && m_includeBcc) {
                   +  + ]
     283   [ +  -  +  -  :           8 :     msg += "Bcc: " + encodeAddressList(m_bcc) + "\r\n";
             +  -  +  - ]
     284                 :             :   }
     285                 :             : 
     286                 :             :   // Subject — RFC 2047 encoded if non-ASCII
     287   [ +  -  +  -  :          67 :   msg += "Subject: " + encodeHeader(m_subject) + "\r\n";
             +  -  +  - ]
     288                 :             : 
     289                 :             :   // Message-ID — T-509: sanitize CRLF to prevent header injection
     290         [ +  + ]:          67 :   if (m_messageId.isEmpty())
     291   [ +  -  +  - ]:          64 :     m_messageId = QString::fromUtf8(generateMessageId());
     292                 :          67 :   QString safeMessageId = m_messageId;
     293         [ +  - ]:          67 :   safeMessageId.remove('\r');
     294         [ +  - ]:          67 :   safeMessageId.remove('\n');
     295   [ +  -  +  -  :          67 :   msg += "Message-ID: " + safeMessageId.toUtf8() + "\r\n";
             +  -  +  - ]
     296                 :             : 
     297                 :             :   // In-Reply-To
     298         [ +  + ]:          67 :   if (!m_inReplyTo.isEmpty()) {
     299                 :             :     // T-400/Bug 4: Sanitize CRLF from In-Reply-To
     300                 :           9 :     QString safeIrt = m_inReplyTo;
     301         [ +  - ]:           9 :     safeIrt.remove('\r');
     302         [ +  - ]:           9 :     safeIrt.remove('\n');
     303   [ +  -  +  -  :           9 :     msg += "In-Reply-To: " + safeIrt.toUtf8() + "\r\n";
             +  -  +  - ]
     304                 :           9 :   }
     305                 :             : 
     306                 :             :   // References
     307         [ +  + ]:          67 :   if (!m_references.isEmpty()) {
     308                 :             :     // T-400/Bug 4: Sanitize CRLF from References
     309                 :           8 :     QStringList safeRefs;
     310         [ +  + ]:          21 :     for (const QString &ref : m_references) {
     311                 :          13 :       QString safe = ref;
     312         [ +  - ]:          13 :       safe.remove('\r');
     313         [ +  - ]:          13 :       safe.remove('\n');
     314         [ +  - ]:          13 :       safeRefs.append(safe);
     315                 :          13 :     }
     316   [ +  -  +  -  :           8 :     msg += "References: " + safeRefs.join(' ').toUtf8() + "\r\n";
          +  -  +  -  +  
                      - ]
     317                 :           8 :   }
     318                 :             : 
     319                 :             :   // MIME-Version
     320         [ +  - ]:          67 :   msg += "MIME-Version: 1.0\r\n";
     321                 :             : 
     322                 :             :   // Body + optional attachments
     323         [ +  + ]:          67 :   if (m_attachments.isEmpty()) {
     324                 :             :     // Simple text message
     325         [ +  - ]:          55 :     msg += "Content-Type: text/plain; charset=UTF-8\r\n";
     326         [ +  - ]:          55 :     msg += "Content-Transfer-Encoding: quoted-printable\r\n";
     327         [ +  - ]:          55 :     msg += "\r\n";
     328   [ +  -  +  -  :          55 :     msg += encodeQuotedPrintable(m_bodyText.toUtf8());
                   +  - ]
     329                 :             :   } else {
     330                 :             :     // Multipart/mixed
     331         [ +  - ]:          12 :     QByteArray boundary = generateBoundary();
     332         [ +  - ]:          24 :     msg += "Content-Type: multipart/mixed;\r\n boundary=\"" + boundary +
     333   [ +  -  +  - ]:          12 :            "\"\r\n";
     334         [ +  - ]:          12 :     msg += "\r\n";
     335                 :             : 
     336                 :             :     // Text body part
     337   [ +  -  +  -  :          12 :     msg += "--" + boundary + "\r\n";
                   +  - ]
     338         [ +  - ]:          12 :     msg += "Content-Type: text/plain; charset=UTF-8\r\n";
     339         [ +  - ]:          12 :     msg += "Content-Transfer-Encoding: quoted-printable\r\n";
     340         [ +  - ]:          12 :     msg += "\r\n";
     341   [ +  -  +  -  :          12 :     msg += encodeQuotedPrintable(m_bodyText.toUtf8()) + "\r\n";
             +  -  +  - ]
     342                 :             : 
     343                 :             :     // Attachment parts
     344         [ +  + ]:          26 :     for (const auto &att : m_attachments) {
     345   [ +  -  +  -  :          14 :       msg += "--" + boundary + "\r\n";
                   +  - ]
     346                 :             :       // Bug 32: Sanitize filename — quotes corrupt Content-Type/Disposition
     347                 :          14 :       QString safeFilename = att.filename;
     348         [ +  - ]:          14 :       safeFilename.replace('"', '\'');
     349                 :             :       // T-614/SEC-13: Sanitize mimeType to prevent CRLF header injection
     350                 :             :       QByteArray mime =
     351   [ -  +  -  - ]:          14 :           att.mimeType.isEmpty() ? "application/octet-stream" : att.mimeType;
     352         [ +  - ]:          14 :       mime.replace('\r', "");
     353         [ +  - ]:          14 :       mime.replace('\n', "");
     354   [ +  -  +  - ]:          28 :       msg += "Content-Type: " + mime + ";\r\n name=\"" +
     355   [ +  -  +  -  :          42 :              encodeHeader(safeFilename) + "\"\r\n";
             +  -  +  - ]
     356         [ +  - ]:          14 :       msg += "Content-Transfer-Encoding: base64\r\n";
     357                 :          14 :       msg += "Content-Disposition: attachment;\r\n filename=\"" +
     358   [ +  -  +  -  :          28 :              encodeHeader(safeFilename) + "\"\r\n";
             +  -  +  - ]
     359         [ +  - ]:          14 :       msg += "\r\n";
     360                 :             : 
     361                 :             :       // Base64 with 76-char line wrapping
     362         [ +  - ]:          14 :       QByteArray b64 = att.data.toBase64();
     363         [ +  + ]:          31 :       for (int i = 0; i < b64.size(); i += 76) {
     364   [ +  -  +  -  :          17 :         msg += b64.mid(i, 76) + "\r\n";
                   +  - ]
     365                 :             :       }
     366                 :          14 :     }
     367                 :             : 
     368   [ +  -  +  -  :          12 :     msg += "--" + boundary + "--\r\n";
                   +  - ]
     369                 :          12 :   }
     370                 :             : 
     371                 :          67 :   return msg;
     372                 :          67 : }
        

Generated by: LCOV version 2.0-1