summaryrefslogtreecommitdiffstats
path: root/src/htmlbuilder.cpp
blob: 18303837186d2204b7afd6ba0daa8976903ee5e2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
#include "htmlbuilder.h"

#include <QRegularExpression>

namespace {

const char *kStyle = R"CSS(
body { font-family: sans-serif; font-size: 10pt; margin: 12px; }
pre.plain { white-space: pre-wrap; word-wrap: break-word;
            font-family: monospace; margin: 0; }
span.quote { color: #4a6f8a; }
.message { border-top: 1px solid #bbb; padding: 10px 0; }
.message:first-child { border-top: none; }
.msg-header { font-size: 9pt; color: #555; margin-bottom: 8px; }
.msg-header .who { font-weight: bold; color: #000; }
.stub { font-size: 9pt; color: #666; padding: 4px 0;
        border-top: 1px solid #ddd; }
)CSS";

} // namespace

QString HtmlBuilder::renderPlain(const QString &text)
{
    QString out;
    out += QStringLiteral("<pre class=\"plain\">");

    const QStringList lines = text.split(QLatin1Char('\n'));
    for (int i = 0; i < lines.size(); ++i) {
        const QString &line = lines.at(i);
        const bool quoted = line.startsWith(QLatin1Char('>'));

        if (quoted)
            out += QStringLiteral("<span class=\"quote\">");
        out += line.toHtmlEscaped();
        if (quoted)
            out += QStringLiteral("</span>");

        if (i + 1 < lines.size())
            out += QLatin1Char('\n');
    }

    out += QStringLiteral("</pre>");
    return out;
}

QString HtmlBuilder::document(const QString &bodyHtml)
{
    return QStringLiteral(
        "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
        "<style>%1</style></head><body>%2</body></html>")
        .arg(QString::fromUtf8(kStyle), bodyHtml);
}

QString HtmlBuilder::namespaceCids(const QString &html, const QString &prefix)
{
    if (prefix.isEmpty())
        return html;

    // This runs on the sender's raw, unescaped HTML markup (not on text that
    // has been through toHtmlEscaped()), so no double-escaping happens here;
    // it is purely a URL rewrite over the existing markup.
    //
    // Two independent patterns are needed:
    //
    // 1. Attribute values: src=, href=, background=, poster= (the common
    //    real-world attributes that can carry a cid: reference in HTML
    //    email), quoted with " or ', quoted the other way, or entirely
    //    unquoted (<img src=cid:x> is valid HTML and unquoted references are
    //    seen in the wild). Attribute name is matched case-insensitively and
    //    whitespace/newlines are tolerated around '='.
    //
    // 2. CSS url(cid:...): appears both inside a style="" attribute value
    //    and inside a <style> block, quoted or unquoted. This is handled as
    //    a separate pass since it has different quoting/terminator rules
    //    (a bare url(...) form terminates on ')', not on a matching quote).
    //
    // srcset= is not handled: it is a list of URL/descriptor pairs with a
    // different quoting grammar, and cid: URLs in srcset are not something
    // real-world mail has been observed to use; treating it is out of scope
    // for this task.
    QString out = html;

    {
        // (?:"([^"]*)"|'([^']*)'|([^\s"'<>]+)) picks the correctly-bounded
        // value for whichever quoting style is used, so a quoted value can
        // never be captured past its own closing quote (fixing the greedy
        // [^"']+ that the naive version used, which is provably safe here
        // since each alternative's character class already excludes its own
        // terminator). capturedStart(N) (rather than testing the captured
        // text for emptiness) is what disambiguates which alternative
        // matched, so an empty-but-present cid ("cid:\"\"") is handled
        // correctly too.
        static const QRegularExpression re(
            QStringLiteral(
                "\\b(src|href|background|poster)"
                "\\s*=\\s*"
                "(?:\"cid:([^\"]*)\"|'cid:([^']*)'|cid:([^\\s\"'<>]+))"),
            QRegularExpression::CaseInsensitiveOption);

        QString rewritten;
        qsizetype last = 0;
        auto it = re.globalMatch(out);
        while (it.hasNext()) {
            const QRegularExpressionMatch m = it.next();
            QString id;
            QChar quote;
            if (m.capturedStart(2) != -1) {
                id = m.captured(2);
                quote = QLatin1Char('"');
            } else if (m.capturedStart(3) != -1) {
                id = m.captured(3);
                quote = QLatin1Char('\'');
            } else {
                id = m.captured(4);
            }

            rewritten += out.mid(last, m.capturedStart() - last);
            rewritten += m.captured(1);
            rewritten += QStringLiteral("=");
            if (!quote.isNull())
                rewritten += quote;
            rewritten += QStringLiteral("cid:%1!%2").arg(prefix, id);
            if (!quote.isNull())
                rewritten += quote;
            last = m.capturedEnd();
        }
        rewritten += out.mid(last);
        out = rewritten;
    }

    {
        // CSS url(cid:...), quoted (' or ") or bare, inside a style="" value
        // or a <style> block. Bare form terminates at ')'.
        static const QRegularExpression re(
            QStringLiteral(
                "url\\(\\s*"
                "(?:\"cid:([^\"]*)\"|'cid:([^']*)'|cid:([^)\\s]+))"
                "\\s*\\)"),
            QRegularExpression::CaseInsensitiveOption);

        QString rewritten;
        qsizetype last = 0;
        auto it = re.globalMatch(out);
        while (it.hasNext()) {
            const QRegularExpressionMatch m = it.next();
            QString id;
            QChar quote;
            if (m.capturedStart(1) != -1) {
                id = m.captured(1);
                quote = QLatin1Char('"');
            } else if (m.capturedStart(2) != -1) {
                id = m.captured(2);
                quote = QLatin1Char('\'');
            } else {
                id = m.captured(3);
            }

            rewritten += out.mid(last, m.capturedStart() - last);
            rewritten += QStringLiteral("url(");
            if (!quote.isNull())
                rewritten += quote;
            rewritten += QStringLiteral("cid:%1!%2").arg(prefix, id);
            if (!quote.isNull())
                rewritten += quote;
            rewritten += QStringLiteral(")");
            last = m.capturedEnd();
        }
        rewritten += out.mid(last);
        out = rewritten;
    }

    return out;
}

QString HtmlBuilder::renderBody(const ThreadRenderItem &item, Mode mode)
{
    if (mode == PreferHtml && item.message.hasHtml())
        return namespaceCids(item.message.htmlBody, item.cidPrefix);
    return renderPlain(item.message.plainBody);
}

QString HtmlBuilder::renderStub(const ParsedMessage &message)
{
    return QStringLiteral("<div class=\"stub\">%1 &mdash; %2</div>")
        .arg(message.from.toHtmlEscaped(), message.subject.toHtmlEscaped());
}

QString HtmlBuilder::build(const ParsedMessage &message, Mode mode)
{
    ThreadRenderItem item;
    item.message = message;
    item.expanded = true;
    return document(renderBody(item, mode));
}

QString HtmlBuilder::buildThread(const QList<ThreadRenderItem> &items, Mode mode)
{
    QString body;

    for (int i = 0; i < items.size(); ++i) {
        const ThreadRenderItem &item = items.at(i);

        if (!item.expanded) {
            body += renderStub(item.message);
            continue;
        }

        body += QStringLiteral(
            "<div class=\"message\" id=\"msg-%1\">"
            "<div class=\"msg-header\"><span class=\"who\">%2</span><br>%3</div>"
            "%4</div>")
            .arg(QString::number(i),
                 item.message.from.toHtmlEscaped(),
                 item.message.date.toHtmlEscaped(),
                 renderBody(item, mode));
    }

    return document(body);
}