|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+<?php
|
|
|
2
|
+
|
|
|
3
|
+namespace App\Support;
|
|
|
4
|
+
|
|
|
5
|
+use App\Models\Annotazione;
|
|
|
6
|
+use Illuminate\Notifications\Messages\MailMessage;
|
|
|
7
|
+use Illuminate\Support\Facades\Storage;
|
|
|
8
|
+use Symfony\Component\Mime\Email;
|
|
|
9
|
+use Symfony\Component\Mime\Part\DataPart;
|
|
|
10
|
+
|
|
|
11
|
+/**
|
|
|
12
|
+ * Prepara l'HTML delle annotazioni per le email:
|
|
|
13
|
+ * toglie html/body nidificati (che troncherebbero l'anteprima nel browser)
|
|
|
14
|
+ * e converte le immagini (data URI, cid, file locali) in allegati inline CID
|
|
|
15
|
+ * perché Gmail ignora le data:image nel corpo HTML.
|
|
|
16
|
+ */
|
|
|
17
|
+final class MailAnnotazioneHtml
|
|
|
18
|
+{
|
|
|
19
|
+ private const CID_DOMAIN = 'inline.myazienda';
|
|
|
20
|
+
|
|
|
21
|
+ private const MAX_IMAGE_BYTES = 10485760;
|
|
|
22
|
+
|
|
|
23
|
+ /**
|
|
|
24
|
+ * @param list<array{cid: string, name: string, mime: string, data: string}> $parts
|
|
|
25
|
+ */
|
|
|
26
|
+ public function __construct(
|
|
|
27
|
+ public readonly string $html,
|
|
|
28
|
+ public readonly array $parts = [],
|
|
|
29
|
+ ) {
|
|
|
30
|
+ }
|
|
|
31
|
+
|
|
|
32
|
+ public static function fromAnnotazione(?Annotazione $annotazione): self
|
|
|
33
|
+ {
|
|
|
34
|
+ $annotazione?->loadMissing('allegatiAnnotazione');
|
|
|
35
|
+
|
|
|
36
|
+ return self::fromHtml((string) ($annotazione?->text ?? ''), $annotazione);
|
|
|
37
|
+ }
|
|
|
38
|
+
|
|
|
39
|
+ public static function fromHtml(string $html, ?Annotazione $annotazione = null): self
|
|
|
40
|
+ {
|
|
|
41
|
+ $html = self::unwrap($html);
|
|
|
42
|
+ $parts = [];
|
|
|
43
|
+ $cache = [];
|
|
|
44
|
+
|
|
|
45
|
+ if ($html === '' || ! preg_match('/<img\b/i', $html)) {
|
|
|
46
|
+ return new self($html, $parts);
|
|
|
47
|
+ }
|
|
|
48
|
+
|
|
|
49
|
+ $rewritten = preg_replace_callback(
|
|
|
50
|
+ '/<img\b[^>]*>/i',
|
|
|
51
|
+ function (array $imgMatch) use ($annotazione, &$parts, &$cache) {
|
|
|
52
|
+ $tag = $imgMatch[0];
|
|
|
53
|
+ $src = self::extractSrc($tag);
|
|
|
54
|
+ if ($src === null || $src === '') {
|
|
|
55
|
+ return $tag;
|
|
|
56
|
+ }
|
|
|
57
|
+
|
|
|
58
|
+ if (isset($cache[$src])) {
|
|
|
59
|
+ return self::replaceSrc($tag, 'cid:'.$cache[$src]);
|
|
|
60
|
+ }
|
|
|
61
|
+
|
|
|
62
|
+ $resolved = self::resolveImage($src, $annotazione);
|
|
|
63
|
+ if ($resolved === null) {
|
|
|
64
|
+ return $tag;
|
|
|
65
|
+ }
|
|
|
66
|
+
|
|
|
67
|
+ $cid = $resolved['sha1'].'@'.self::CID_DOMAIN;
|
|
|
68
|
+ $cache[$src] = $cid;
|
|
|
69
|
+ $parts[] = [
|
|
|
70
|
+ 'cid' => $cid,
|
|
|
71
|
+ 'name' => $resolved['name'],
|
|
|
72
|
+ 'mime' => $resolved['mime'],
|
|
|
73
|
+ 'data' => $resolved['data'],
|
|
|
74
|
+ ];
|
|
|
75
|
+ self::storePreviewSidecar($resolved['sha1'], $resolved['data'], $resolved['mime']);
|
|
|
76
|
+
|
|
|
77
|
+ return self::replaceSrc($tag, 'cid:'.$cid);
|
|
|
78
|
+ },
|
|
|
79
|
+ $html
|
|
|
80
|
+ );
|
|
|
81
|
+
|
|
|
82
|
+ return new self(is_string($rewritten) ? $rewritten : $html, $parts);
|
|
|
83
|
+ }
|
|
|
84
|
+
|
|
|
85
|
+ public function bind(MailMessage $mail): MailMessage
|
|
|
86
|
+ {
|
|
|
87
|
+ if ($this->parts === []) {
|
|
|
88
|
+ return $mail;
|
|
|
89
|
+ }
|
|
|
90
|
+
|
|
|
91
|
+ return $mail->withSymfonyMessage(function (Email $email) {
|
|
|
92
|
+ $this->attachTo($email);
|
|
|
93
|
+ });
|
|
|
94
|
+ }
|
|
|
95
|
+
|
|
|
96
|
+ public function attachTo(Email $email): void
|
|
|
97
|
+ {
|
|
|
98
|
+ foreach ($this->parts as $part) {
|
|
|
99
|
+ $dataPart = (new DataPart($part['data'], $part['name'], $part['mime']))->asInline();
|
|
|
100
|
+ $dataPart->setContentId($part['cid']);
|
|
|
101
|
+ $email->addPart($dataPart);
|
|
|
102
|
+ }
|
|
|
103
|
+ }
|
|
|
104
|
+
|
|
|
105
|
+ public static function hasVisibleContent(string $html): bool
|
|
|
106
|
+ {
|
|
|
107
|
+ if (trim(strip_tags($html)) !== '') {
|
|
|
108
|
+ return true;
|
|
|
109
|
+ }
|
|
|
110
|
+
|
|
|
111
|
+ return (bool) preg_match('/<img\b/i', $html);
|
|
|
112
|
+ }
|
|
|
113
|
+
|
|
|
114
|
+ /**
|
|
|
115
|
+ * Sostituisce cid: con data URI per l'anteprima Mail Tracker (il browser non ha le parti MIME).
|
|
|
116
|
+ */
|
|
|
117
|
+ public static function forPreview(?string $html): string
|
|
|
118
|
+ {
|
|
|
119
|
+ $html = (string) $html;
|
|
|
120
|
+ if ($html === '' || ! str_contains($html, 'cid:')) {
|
|
|
121
|
+ return $html;
|
|
|
122
|
+ }
|
|
|
123
|
+
|
|
|
124
|
+ return preg_replace_callback(
|
|
|
125
|
+ '/(<img\b[^>]*?\bsrc\s*=\s*)(["\'])cid:([^"\']+)\2/i',
|
|
|
126
|
+ function (array $m) {
|
|
|
127
|
+ $cid = html_entity_decode($m[3], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
|
128
|
+ $dataUri = self::previewDataUri($cid);
|
|
|
129
|
+ if ($dataUri === null) {
|
|
|
130
|
+ return $m[0];
|
|
|
131
|
+ }
|
|
|
132
|
+
|
|
|
133
|
+ return $m[1].$m[2].$dataUri.$m[2];
|
|
|
134
|
+ },
|
|
|
135
|
+ $html
|
|
|
136
|
+ ) ?? $html;
|
|
|
137
|
+ }
|
|
|
138
|
+
|
|
|
139
|
+ public static function unwrap(string $html): string
|
|
|
140
|
+ {
|
|
|
141
|
+ $html = trim($html);
|
|
|
142
|
+ if ($html === '') {
|
|
|
143
|
+ return '';
|
|
|
144
|
+ }
|
|
|
145
|
+
|
|
|
146
|
+ if (preg_match('/<body\b[^>]*>(.*)<\/body>/is', $html, $m)) {
|
|
|
147
|
+ $html = $m[1];
|
|
|
148
|
+ } else {
|
|
|
149
|
+ $html = preg_replace('/<!DOCTYPE[^>]*>/i', '', $html) ?? $html;
|
|
|
150
|
+ $html = preg_replace('/<head\b[^>]*>.*?<\/head>/is', '', $html) ?? $html;
|
|
|
151
|
+ $html = preg_replace('/<\/?html\b[^>]*>/i', '', $html) ?? $html;
|
|
|
152
|
+ }
|
|
|
153
|
+
|
|
|
154
|
+ $html = preg_replace('/<\/(?:body|html)\s*>/i', '', $html) ?? $html;
|
|
|
155
|
+
|
|
|
156
|
+ return trim($html);
|
|
|
157
|
+ }
|
|
|
158
|
+
|
|
|
159
|
+ private static function extractSrc(string $tag): ?string
|
|
|
160
|
+ {
|
|
|
161
|
+ if (preg_match('/\bsrc\s*=\s*"([^"]*)"/i', $tag, $m) || preg_match("/\bsrc\s*=\s*'([^']*)'/i", $tag, $m)) {
|
|
|
162
|
+ return trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
|
|
163
|
+ }
|
|
|
164
|
+
|
|
|
165
|
+ return null;
|
|
|
166
|
+ }
|
|
|
167
|
+
|
|
|
168
|
+ private static function replaceSrc(string $tag, string $src): string
|
|
|
169
|
+ {
|
|
|
170
|
+ $quoted = htmlspecialchars($src, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
|
171
|
+ $replaced = preg_replace('/\bsrc\s*=\s*"[^"]*"/i', 'src="'.$quoted.'"', $tag, 1);
|
|
|
172
|
+ if (is_string($replaced) && $replaced !== $tag) {
|
|
|
173
|
+ return $replaced;
|
|
|
174
|
+ }
|
|
|
175
|
+
|
|
|
176
|
+ $replaced = preg_replace("/\bsrc\s*=\s*'[^']*'/i", 'src="'.$quoted.'"', $tag, 1);
|
|
|
177
|
+
|
|
|
178
|
+ return is_string($replaced) ? $replaced : $tag;
|
|
|
179
|
+ }
|
|
|
180
|
+
|
|
|
181
|
+ /**
|
|
|
182
|
+ * @return array{data: string, mime: string, name: string, sha1: string}|null
|
|
|
183
|
+ */
|
|
|
184
|
+ private static function resolveImage(string $src, ?Annotazione $annotazione): ?array
|
|
|
185
|
+ {
|
|
|
186
|
+ if (str_starts_with(strtolower($src), 'data:image/')) {
|
|
|
187
|
+ return self::fromDataUri($src);
|
|
|
188
|
+ }
|
|
|
189
|
+
|
|
|
190
|
+ if (str_starts_with(strtolower($src), 'cid:')) {
|
|
|
191
|
+ return self::fromCid(substr($src, 4), $annotazione);
|
|
|
192
|
+ }
|
|
|
193
|
+
|
|
|
194
|
+ $path = self::localPathFromUrl($src);
|
|
|
195
|
+ if ($path !== null) {
|
|
|
196
|
+ return self::fromFile($path, basename($path));
|
|
|
197
|
+ }
|
|
|
198
|
+
|
|
|
199
|
+ return null;
|
|
|
200
|
+ }
|
|
|
201
|
+
|
|
|
202
|
+ /**
|
|
|
203
|
+ * @return array{data: string, mime: string, name: string, sha1: string}|null
|
|
|
204
|
+ */
|
|
|
205
|
+ private static function fromDataUri(string $src): ?array
|
|
|
206
|
+ {
|
|
|
207
|
+ if (! preg_match('#^data:image/(png|jpe?g|gif|webp|bmp|x-icon)(;charset=[^;]+)?;base64,(.+)$#is', $src, $m)) {
|
|
|
208
|
+ return null;
|
|
|
209
|
+ }
|
|
|
210
|
+
|
|
|
211
|
+ $binary = base64_decode(preg_replace('/\s+/', '', $m[3]) ?? '', true);
|
|
|
212
|
+ if (! is_string($binary) || $binary === '') {
|
|
|
213
|
+ return null;
|
|
|
214
|
+ }
|
|
|
215
|
+
|
|
|
216
|
+ $ext = strtolower($m[1]) === 'jpeg' ? 'jpg' : strtolower($m[1]);
|
|
|
217
|
+ if ($ext === 'x-icon') {
|
|
|
218
|
+ $ext = 'ico';
|
|
|
219
|
+ }
|
|
|
220
|
+
|
|
|
221
|
+ return self::pack($binary, 'image/'.($ext === 'jpg' ? 'jpeg' : $ext), 'inline.'.$ext);
|
|
|
222
|
+ }
|
|
|
223
|
+
|
|
|
224
|
+ /**
|
|
|
225
|
+ * @return array{data: string, mime: string, name: string, sha1: string}|null
|
|
|
226
|
+ */
|
|
|
227
|
+ private static function fromCid(string $cid, ?Annotazione $annotazione): ?array
|
|
|
228
|
+ {
|
|
|
229
|
+ $cid = trim($cid, " \t\n\r\0\x0B<>\"'");
|
|
|
230
|
+ if ($cid === '' || ! $annotazione) {
|
|
|
231
|
+ return null;
|
|
|
232
|
+ }
|
|
|
233
|
+
|
|
|
234
|
+ $allegati = $annotazione->allegatiAnnotazione ?? collect();
|
|
|
235
|
+ $filename = strstr($cid, '@', true) ?: $cid;
|
|
|
236
|
+ $filename = strtolower($filename);
|
|
|
237
|
+
|
|
|
238
|
+ foreach ($allegati as $allegato) {
|
|
|
239
|
+ $nome = strtolower((string) $allegato->nome);
|
|
|
240
|
+ $base = strtolower(basename((string) $allegato->path_file));
|
|
|
241
|
+ if ($nome === '' && $base === '') {
|
|
|
242
|
+ continue;
|
|
|
243
|
+ }
|
|
|
244
|
+ if ($nome !== $filename && $base !== $filename && ! str_starts_with($nome, $filename)) {
|
|
|
245
|
+ continue;
|
|
|
246
|
+ }
|
|
|
247
|
+ if (! $allegato->path_file || ! Storage::disk('allegatiAnnotazioni')->exists($allegato->path_file)) {
|
|
|
248
|
+ continue;
|
|
|
249
|
+ }
|
|
|
250
|
+
|
|
|
251
|
+ return self::fromFile(
|
|
|
252
|
+ Storage::disk('allegatiAnnotazioni')->path($allegato->path_file),
|
|
|
253
|
+ $allegato->nome ?: basename((string) $allegato->path_file)
|
|
|
254
|
+ );
|
|
|
255
|
+ }
|
|
|
256
|
+
|
|
|
257
|
+ return null;
|
|
|
258
|
+ }
|
|
|
259
|
+
|
|
|
260
|
+ /**
|
|
|
261
|
+ * @return array{data: string, mime: string, name: string, sha1: string}|null
|
|
|
262
|
+ */
|
|
|
263
|
+ private static function fromFile(string $path, string $name): ?array
|
|
|
264
|
+ {
|
|
|
265
|
+ if (! is_file($path) || ! is_readable($path)) {
|
|
|
266
|
+ return null;
|
|
|
267
|
+ }
|
|
|
268
|
+
|
|
|
269
|
+ $mime = (string) (mime_content_type($path) ?: '');
|
|
|
270
|
+ if (! str_starts_with($mime, 'image/')) {
|
|
|
271
|
+ return null;
|
|
|
272
|
+ }
|
|
|
273
|
+
|
|
|
274
|
+ $binary = file_get_contents($path);
|
|
|
275
|
+ if (! is_string($binary) || $binary === '') {
|
|
|
276
|
+ return null;
|
|
|
277
|
+ }
|
|
|
278
|
+
|
|
|
279
|
+ $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION) ?: 'png');
|
|
|
280
|
+
|
|
|
281
|
+ return self::pack($binary, $mime, 'inline.'.$ext);
|
|
|
282
|
+ }
|
|
|
283
|
+
|
|
|
284
|
+ /**
|
|
|
285
|
+ * @return array{data: string, mime: string, name: string, sha1: string}|null
|
|
|
286
|
+ */
|
|
|
287
|
+ private static function pack(string $binary, string $mime, string $name): ?array
|
|
|
288
|
+ {
|
|
|
289
|
+ if (strlen($binary) > self::MAX_IMAGE_BYTES) {
|
|
|
290
|
+ return null;
|
|
|
291
|
+ }
|
|
|
292
|
+
|
|
|
293
|
+ return [
|
|
|
294
|
+ 'data' => $binary,
|
|
|
295
|
+ 'mime' => $mime,
|
|
|
296
|
+ 'name' => $name,
|
|
|
297
|
+ 'sha1' => sha1($binary),
|
|
|
298
|
+ ];
|
|
|
299
|
+ }
|
|
|
300
|
+
|
|
|
301
|
+ private static function localPathFromUrl(string $src): ?string
|
|
|
302
|
+ {
|
|
|
303
|
+ $src = trim($src);
|
|
|
304
|
+ if ($src === '') {
|
|
|
305
|
+ return null;
|
|
|
306
|
+ }
|
|
|
307
|
+
|
|
|
308
|
+ $path = $src;
|
|
|
309
|
+ if (preg_match('#^https?://#i', $src)) {
|
|
|
310
|
+ $path = (string) (parse_url($src, PHP_URL_PATH) ?: '');
|
|
|
311
|
+ }
|
|
|
312
|
+
|
|
|
313
|
+ $path = urldecode($path);
|
|
|
314
|
+
|
|
|
315
|
+ if (preg_match('#/storage/allegatiAnnotazioni/(.+)$#', $path, $m)) {
|
|
|
316
|
+ $relative = $m[1];
|
|
|
317
|
+ if (Storage::disk('allegatiAnnotazioni')->exists($relative)) {
|
|
|
318
|
+ return Storage::disk('allegatiAnnotazioni')->path($relative);
|
|
|
319
|
+ }
|
|
|
320
|
+ }
|
|
|
321
|
+
|
|
|
322
|
+ if (preg_match('#/storage/(.+)$#', $path, $m)) {
|
|
|
323
|
+ $relative = $m[1];
|
|
|
324
|
+ if (Storage::disk('public')->exists($relative)) {
|
|
|
325
|
+ return Storage::disk('public')->path($relative);
|
|
|
326
|
+ }
|
|
|
327
|
+ }
|
|
|
328
|
+
|
|
|
329
|
+ return null;
|
|
|
330
|
+ }
|
|
|
331
|
+
|
|
|
332
|
+ private static function storePreviewSidecar(string $sha1, string $data, string $mime): void
|
|
|
333
|
+ {
|
|
|
334
|
+ try {
|
|
|
335
|
+ $disk = Storage::disk('local');
|
|
|
336
|
+ $binPath = 'mail-inline/'.$sha1.'.bin';
|
|
|
337
|
+ if (! $disk->exists($binPath)) {
|
|
|
338
|
+ $disk->put($binPath, $data);
|
|
|
339
|
+ $disk->put('mail-inline/'.$sha1.'.mime', $mime);
|
|
|
340
|
+ }
|
|
|
341
|
+ } catch (\Throwable $e) {
|
|
|
342
|
+ report($e);
|
|
|
343
|
+ }
|
|
|
344
|
+ }
|
|
|
345
|
+
|
|
|
346
|
+ private static function previewDataUri(string $cid): ?string
|
|
|
347
|
+ {
|
|
|
348
|
+ $cid = trim($cid, " \t\n\r\0\x0B<>");
|
|
|
349
|
+ $sha1 = strstr($cid, '@', true) ?: $cid;
|
|
|
350
|
+ if (! preg_match('/^[a-f0-9]{40}$/', $sha1)) {
|
|
|
351
|
+ return null;
|
|
|
352
|
+ }
|
|
|
353
|
+
|
|
|
354
|
+ try {
|
|
|
355
|
+ $disk = Storage::disk('local');
|
|
|
356
|
+ $binPath = 'mail-inline/'.$sha1.'.bin';
|
|
|
357
|
+ if (! $disk->exists($binPath)) {
|
|
|
358
|
+ return null;
|
|
|
359
|
+ }
|
|
|
360
|
+ $mime = $disk->exists('mail-inline/'.$sha1.'.mime')
|
|
|
361
|
+ ? trim((string) $disk->get('mail-inline/'.$sha1.'.mime'))
|
|
|
362
|
+ : 'image/png';
|
|
|
363
|
+ $binary = $disk->get($binPath);
|
|
|
364
|
+ if (! is_string($binary) || $binary === '') {
|
|
|
365
|
+ return null;
|
|
|
366
|
+ }
|
|
|
367
|
+
|
|
|
368
|
+ return 'data:'.$mime.';base64,'.base64_encode($binary);
|
|
|
369
|
+ } catch (\Throwable $e) {
|
|
|
370
|
+ return null;
|
|
|
371
|
+ }
|
|
|
372
|
+ }
|
|
|
373
|
+}
|