• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nette / mail / 30280377988

27 Jul 2026 03:28PM UTC coverage: 89.164% (+9.7%) from 79.487%
30280377988

push

github

dg
CssInliner: resolves declarations by the CSS cascade

Rules were applied in the order they appeared, so the last one to mention a
property won. A browser does not work that way: p.intro { color: red } followed
by p { color: blue } paints the intro red, while the inliner painted it blue.
The inlined mail therefore looked different from the page the CSS was written
for, and the more carefully the stylesheet was written, the more it diverged.

Declarations now compete the way they do in an author stylesheet: !important
first, then an existing inline style, then specificity, ties going to the later
rule. Each selector in a comma-separated list carries its own specificity, and
one part the DOM engine rejects (::marker) no longer discards the whole rule.
The argument of an ordinary functional pseudo-class is a keyword or an An+B
expression, not a selector, so the idents in :nth-child(odd) or :nth-child(-n+3)
do not count as type selectors; counting them would inflate specificity enough
to flip a winner.

Only the winner of each property is written out, so a property appears in the
style attribute once rather than several times with the earlier values trailing.
HTML attributes generated for Outlook (bgcolor, width) drop the !important
marker, which has no meaning in an attribute.

A '}' inside a style attribute would close the block the attribute is wrapped in
for parsing and turn the remainder into rules of its own, read back as the
element's inline declarations. Such an attribute is not a plain declaration list
and is kept verbatim.

683 of 766 relevant lines covered (89.16%)

0.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.09
/src/Mail/Message.php
1
<?php declare(strict_types=1);
1✔
2

3
/**
4
 * This file is part of the Nette Framework (https://nette.org)
5
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace Nette\Mail;
9

10
use Nette;
11
use Nette\Utils\Strings;
12
use Nette\Utils\Validators;
13
use function addcslashes, basename, date, finfo_buffer, finfo_open, is_array, is_numeric, is_string, ltrim, php_uname, preg_match, preg_replace, str_replace, strcasecmp, stripslashes, substr;
14

15

16
/**
17
 * Represents an email message with support for HTML body, attachments, and embedded files.
18
 *
19
 * @property-deprecated   string $subject
20
 * @property-deprecated   string $htmlBody
21
 */
22
class Message extends MimePart
23
{
24
        /** Priority */
25
        public const
26
                High = 1,
27
                Normal = 3,
28
                Low = 5;
29

30
        #[\Deprecated('use Message::High')]
31
        public const HIGH = self::High;
32

33
        #[\Deprecated('use Message::Normal')]
34
        public const NORMAL = self::Normal;
35

36
        #[\Deprecated('use Message::Low')]
37
        public const LOW = self::Low;
38

39
        /** @var array<string, string> */
40
        public static array $defaultHeaders = [
41
                'MIME-Version' => '1.0',
42
                'X-Mailer' => 'Nette Framework',
43
        ];
44

45
        /** @var list<MimePart> */
46
        private array $attachments = [];
47

48
        /** @var array<MimePart> */
49
        private array $inlines = [];
50
        private string $htmlBody = '';
51

52

53
        public function __construct()
54
        {
55
                foreach (static::$defaultHeaders as $name => $value) {
1✔
56
                        $this->setHeader($name, $value);
1✔
57
                }
58

59
                $this->setHeader('Date', date('r'));
1✔
60
        }
1✔
61

62

63
        /**
64
         * Sets the sender of the message. Email or format "John Doe" <doe@example.com>
65
         */
66
        public function setFrom(string $email, ?string $name = null): static
1✔
67
        {
68
                $this->setHeader('From', $this->formatEmail($email, $name));
1✔
69
                return $this;
1✔
70
        }
71

72

73
        /**
74
         * Returns the sender of the message.
75
         * @return ?array<string, ?string>
76
         */
77
        public function getFrom(): ?array
78
        {
79
                $value = $this->getHeader('From');
1✔
80
                return is_array($value) ? $value : null;
1✔
81
        }
82

83

84
        /**
85
         * Adds the reply-to address. Email or format "John Doe" <doe@example.com>
86
         */
87
        public function addReplyTo(string $email, ?string $name = null): static
1✔
88
        {
89
                $this->setHeader('Reply-To', $this->formatEmail($email, $name), append: true);
1✔
90
                return $this;
1✔
91
        }
92

93

94
        public function setSubject(string $subject): static
1✔
95
        {
96
                $this->setHeader('Subject', $subject);
1✔
97
                return $this;
1✔
98
        }
99

100

101
        public function getSubject(): ?string
102
        {
103
                $value = $this->getHeader('Subject');
1✔
104
                return is_string($value) ? $value : null;
1✔
105
        }
106

107

108
        /**
109
         * Adds email recipient. Email or format "John Doe" <doe@example.com>
110
         */
111
        public function addTo(string $email, ?string $name = null): static // addRecipient()
1✔
112
        {
113
                $this->setHeader('To', $this->formatEmail($email, $name), append: true);
1✔
114
                return $this;
1✔
115
        }
116

117

118
        /**
119
         * Adds carbon copy email recipient. Email or format "John Doe" <doe@example.com>
120
         */
121
        public function addCc(string $email, ?string $name = null): static
1✔
122
        {
123
                $this->setHeader('Cc', $this->formatEmail($email, $name), append: true);
1✔
124
                return $this;
1✔
125
        }
126

127

128
        /**
129
         * Adds blind carbon copy email recipient. Email or format "John Doe" <doe@example.com>
130
         */
131
        public function addBcc(string $email, ?string $name = null): static
1✔
132
        {
133
                $this->setHeader('Bcc', $this->formatEmail($email, $name), append: true);
1✔
134
                return $this;
1✔
135
        }
136

137

138
        /**
139
         * Formats recipient email.
140
         * @return array<string, ?string>
141
         */
142
        private function formatEmail(string $email, ?string $name = null): array
1✔
143
        {
144
                if (!$name && preg_match('#^(.+) +<(.*)>$#D', $email, $matches)) {
1✔
145
                        [, $name, $email] = $matches;
1✔
146
                        $name = stripslashes($name);
1✔
147
                        $tmp = substr($name, 1, -1);
1✔
148
                        if ($name === '"' . $tmp . '"') {
1✔
149
                                $name = $tmp;
1✔
150
                        }
151
                }
152

153
                return [$email => $name];
1✔
154
        }
155

156

157
        public function setReturnPath(string $email): static
1✔
158
        {
159
                // stored as a plain string, so it does not pass through the address normalization in setHeader()
160
                $this->setHeader('Return-Path', self::toAsciiEmail($email));
1✔
161
                return $this;
1✔
162
        }
163

164

165
        public function getReturnPath(): ?string
166
        {
167
                $value = $this->getHeader('Return-Path');
1✔
168
                return is_string($value) ? $value : null;
1✔
169
        }
170

171

172
        /**
173
         * Sets the one-click unsubscribe headers (RFC 2369 and RFC 8058) that Gmail and Yahoo require from
174
         * bulk senders. The URL must unsubscribe the recipient on a bare HTTP POST; the email address is
175
         * the fallback for clients that cannot POST.
176
         */
177
        public function setUnsubscribe(?string $url = null, ?string $email = null): static
1✔
178
        {
179
                $targets = [];
1✔
180
                if ($url !== null) {
1✔
181
                        Validators::assert($url, 'url', 'unsubscribe URL');
1✔
182
                        $targets[] = "<$url>";
1✔
183
                }
184

185
                if ($email !== null) {
1✔
186
                        Validators::assert($email, 'email', 'unsubscribe address');
1✔
187
                        $targets[] = '<mailto:' . self::toAsciiEmail($email) . '>';
1✔
188
                }
189

190
                if (!$targets) {
1✔
191
                        throw new Nette\InvalidArgumentException('Provide an unsubscribe URL, an email address, or both.');
1✔
192
                }
193

194
                $this->setHeader('List-Unsubscribe', implode(', ', $targets));
1✔
195
                // one-click requires an endpoint that takes a POST, so it is only announced together with a URL
196
                $this->setHeader('List-Unsubscribe-Post', $url === null ? null : 'List-Unsubscribe=One-Click');
1✔
197
                return $this;
1✔
198
        }
199

200

201
        public function setPriority(int $priority): static
1✔
202
        {
203
                $this->setHeader('X-Priority', (string) $priority);
1✔
204
                return $this;
1✔
205
        }
206

207

208
        public function getPriority(): ?int
209
        {
210
                $priority = $this->getHeader('X-Priority');
1✔
211
                return is_numeric($priority) ? (int) $priority : null;
1✔
212
        }
213

214

215
        /**
216
         * Sets HTML body. If $basePath is provided, local images referenced in the HTML
217
         * are automatically embedded as inline attachments with their src rewritten to cid: URIs.
218
         * Also sets the subject from the HTML <title> if not already set, and auto-generates a plain-text alternative.
219
         */
220
        public function setHtmlBody(string $html, ?string $basePath = null): static
1✔
221
        {
222
                $composer = new HtmlComposer($html);
1✔
223
                if ($basePath) {
1✔
224
                        $composer->embedImages($basePath);
1✔
225
                }
226
                $composer->applyTo($this);
1✔
227
                return $this;
1✔
228
        }
229

230

231
        public function getHtmlBody(): string
232
        {
233
                return $this->htmlBody;
1✔
234
        }
235

236

237
        /** @internal used by HtmlComposer */
238
        public function setRawHtmlBody(string $html): void
1✔
239
        {
240
                $this->htmlBody = ltrim(str_replace("\r", '', $html), "\n");
1✔
241
        }
1✔
242

243

244
        /**
245
         * Adds an embedded (inline) file. If $content is null, the file is read from disk.
246
         * In that case $file is the path; otherwise $file is used as the filename.
247
         */
248
        public function addEmbeddedFile(string $file, ?string $content = null, ?string $contentType = null): MimePart
1✔
249
        {
250
                return $this->inlines[$file] = $this->createAttachment($file, $content, $contentType, 'inline')
1✔
251
                        ->setHeader('Content-ID', $this->getRandomId());
1✔
252
        }
253

254

255
        /**
256
         * Adds a pre-built MIME part as an inline (embedded) attachment.
257
         */
258
        public function addInlinePart(MimePart $part): static
259
        {
260
                $this->inlines[] = $part;
×
261
                return $this;
×
262
        }
263

264

265
        /**
266
         * Adds an attachment. If $content is null, the file is read from disk.
267
         * In that case $file is the path; otherwise $file is used as the filename.
268
         */
269
        public function addAttachment(string $file, ?string $content = null, ?string $contentType = null): MimePart
1✔
270
        {
271
                return $this->attachments[] = $this->createAttachment($file, $content, $contentType, 'attachment');
1✔
272
        }
273

274

275
        /**
276
         * @return list<MimePart>
277
         */
278
        public function getAttachments(): array
279
        {
280
                return $this->attachments;
×
281
        }
282

283

284
        /**
285
         * Creates file MIME part.
286
         */
287
        private function createAttachment(
1✔
288
                string $file,
289
                ?string $content,
290
                ?string $contentType,
291
                string $disposition,
292
        ): MimePart
293
        {
294
                $part = new MimePart;
1✔
295
                if ($content === null) {
1✔
296
                        $content = Nette\Utils\FileSystem::read($file);
1✔
297
                        $file = Strings::fixEncoding(basename($file));
1✔
298
                }
299

300
                if (!$contentType) {
1✔
301
                        $finfo = finfo_open(FILEINFO_MIME_TYPE);
1✔
302
                        $contentType = $finfo ? finfo_buffer($finfo, $content) : false;
1✔
303
                        $contentType = $contentType ?: 'application/octet-stream';
1✔
304
                }
305

306
                if (!strcasecmp($contentType, 'message/rfc822')) { // not allowed for attached files
1✔
307
                        $contentType = 'application/octet-stream';
1✔
308
                } elseif (!strcasecmp($contentType, 'image/svg')) { // Troublesome for some mailers...
1✔
309
                        $contentType = 'image/svg+xml';
×
310
                }
311

312
                $part->setBody($content);
1✔
313
                $part->setContentType($contentType);
1✔
314
                $part->setEncoding(preg_match('#(multipart|message)/#A', $contentType) ? self::Encoding8Bit : self::EncodingBase64);
1✔
315
                $part->setHeader('Content-Disposition', $disposition . '; filename="' . addcslashes($file, '"\\') . '"');
1✔
316
                return $part;
1✔
317
        }
318

319

320
        /********************* building and sending ****************d*g**/
321

322

323
        /**
324
         * Returns encoded message.
325
         */
326
        public function generateMessage(): string
327
        {
328
                return $this->build()->getEncodedMessage();
1✔
329
        }
330

331

332
        /**
333
         * Builds email. Does not modify itself, but returns a new object.
334
         */
335
        public function build(): static
336
        {
337
                $mail = clone $this;
1✔
338
                $mail->setHeader('Message-ID', $mail->getHeader('Message-ID') ?? $this->getRandomId());
1✔
339

340
                $cursor = $mail;
1✔
341
                if ($mail->attachments) {
1✔
342
                        $tmp = $cursor->setContentType('multipart/mixed');
1✔
343
                        $cursor = $cursor->addPart();
1✔
344
                        foreach ($mail->attachments as $value) {
1✔
345
                                $tmp->addPart($value);
1✔
346
                        }
347
                }
348

349
                if ($mail->htmlBody !== '') {
1✔
350
                        $tmp = $cursor->setContentType('multipart/alternative');
1✔
351
                        $cursor = $cursor->addPart();
1✔
352
                        $alt = $tmp->addPart();
1✔
353
                        if ($mail->inlines) {
1✔
354
                                $tmp = $alt->setContentType('multipart/related');
1✔
355
                                $alt = $alt->addPart();
1✔
356
                                foreach ($mail->inlines as $value) {
1✔
357
                                        $tmp->addPart($value);
1✔
358
                                }
359
                        }
360

361
                        $alt->setContentType('text/html', 'UTF-8')
1✔
362
                                ->setEncoding(preg_match('#[^\n]{990}#', $mail->htmlBody)
1✔
363
                                        ? self::EncodingQuotedPrintable
×
364
                                        : (preg_match('#[\x80-\xFF]#', $mail->htmlBody) ? self::Encoding8Bit : self::Encoding7Bit))
1✔
365
                                ->setBody($mail->htmlBody);
1✔
366
                }
367

368
                $text = $mail->getBody();
1✔
369
                $mail->setBody('');
1✔
370
                $cursor->setContentType('text/plain', 'UTF-8')
1✔
371
                        ->setEncoding(preg_match('#[^\n]{990}#', $text)
1✔
372
                                ? self::EncodingQuotedPrintable
1✔
373
                                : (preg_match('#[\x80-\xFF]#', $text) ? self::Encoding8Bit : self::Encoding7Bit))
1✔
374
                        ->setBody($text);
1✔
375

376
                return $mail;
1✔
377
        }
378

379

380
        private function getRandomId(): string
381
        {
382
                return '<' . Nette\Utils\Random::generate() . '@'
1✔
383
                        . preg_replace('#[^\w.-]+#', '', $_SERVER['HTTP_HOST'] ?? php_uname('n'))
1✔
384
                        . '>';
1✔
385
        }
386
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc