Kaynağa Gözat

mail tracker

Roberto Santini 1 hafta önce
ebeveyn
işleme
6b915788e7

+ 7
- 0
app/Providers/AppServiceProvider.php Dosyayı Görüntüle

@@ -2,6 +2,9 @@
2 2
 
3 3
 namespace App\Providers;
4 4
 
5
+use App\Support\EnsureMailTrackerHtml;
6
+use Illuminate\Mail\Events\MessageSending;
7
+use Illuminate\Support\Facades\Event;
5 8
 use Illuminate\Support\ServiceProvider;
6 9
 use Illuminate\Support\Facades\Vite;
7 10
 use jdavidbakr\MailTracker\MailTracker;
@@ -35,6 +38,10 @@ class AppServiceProvider extends ServiceProvider
35 38
     \App\Models\Segnalazione::observe(\App\Observers\SegnalazioneObserver::class);
36 39
     MailTracker::useSentEmailModel(\App\Models\SentEmail::class);
37 40
 
41
+    // Dopo il listener del package: il package salva l'HTML *prima* dei tracker
42
+    // e con MIME nested / HTML enormi può non iniettare pixel e link.
43
+    Event::listen(MessageSending::class, EnsureMailTrackerHtml::class);
44
+
38 45
     // InviaNotificaSegnalazione è registrato da Laravel tramite event discovery (app/Listeners).
39 46
     // Non usare qui Event::listen(...): verrebbe eseguito due handle() per ogni NuovaSegnalazione.
40 47
   }

+ 82
- 0
app/Support/EnsureMailTrackerHtml.php Dosyayı Görüntüle

@@ -0,0 +1,82 @@
1
+<?php
2
+
3
+namespace App\Support;
4
+
5
+use Illuminate\Mail\Events\MessageSending;
6
+use Illuminate\Support\Facades\Log;
7
+use Illuminate\Support\Facades\URL;
8
+use jdavidbakr\MailTracker\MailTracker;
9
+use Symfony\Component\Mime\Email;
10
+
11
+/**
12
+ * Dopo Mail Tracker: assicura pixel/link nel MIME inviato e salva
13
+ * quell'HTML nel log (il package memorizza il body *prima* dei tracker).
14
+ */
15
+final class EnsureMailTrackerHtml
16
+{
17
+    public function handle(MessageSending $event): void
18
+    {
19
+        $message = $event->message;
20
+        if (! $message instanceof Email) {
21
+            return;
22
+        }
23
+
24
+        $hash = $message->getHeaders()->get('X-Mailer-Hash')?->getBody();
25
+        if (! is_string($hash) || $hash === '') {
26
+            return;
27
+        }
28
+
29
+        try {
30
+            $this->process($message, $hash);
31
+        } catch (\Throwable $e) {
32
+            Log::warning('EnsureMailTrackerHtml: '.$e->getMessage(), [
33
+                'exception' => $e,
34
+            ]);
35
+        }
36
+    }
37
+
38
+    private function process(Email $message, string $hash): void
39
+    {
40
+        $body = $message->getBody();
41
+        $html = MailTrackerHtml::extractHtml($body);
42
+        if (! is_string($html) || $html === '') {
43
+            return;
44
+        }
45
+
46
+        $updated = $html;
47
+
48
+        if (config('mail-tracker.inject-pixel') && ! MailTrackerHtml::hasUsablePixel($updated)) {
49
+            $updated = MailTrackerHtml::injectPixel($updated, $this->pixelTag($hash));
50
+        }
51
+
52
+        if (config('mail-tracker.track-links')) {
53
+            $updated = MailTrackerHtml::rewriteLinks($updated, function (string $url) use ($hash) {
54
+                return URL::signedRoute('mailTracker_n', [
55
+                    'l' => $url,
56
+                    'h' => $hash,
57
+                ]);
58
+            });
59
+        }
60
+
61
+        if ($updated !== $html && $body !== null) {
62
+            $replaced = MailTrackerHtml::replaceHtml($body, $updated, $message->getHtmlCharset());
63
+            if ($replaced !== null) {
64
+                $message->setBody($replaced);
65
+            }
66
+        }
67
+
68
+        $toStore = MailTrackerHtml::extractHtml($message->getBody()) ?? $updated;
69
+        $sent = MailTracker::sentEmailModel()->newQuery()->where('hash', $hash)->first();
70
+        if ($sent) {
71
+            $sent->fillContent($toStore, $hash);
72
+            $sent->save();
73
+        }
74
+    }
75
+
76
+    private function pixelTag(string $hash): string
77
+    {
78
+        $src = e(route('mailTracker_t', [$hash]));
79
+
80
+        return '<img src="'.$src.'" width="1" height="1" alt="" style="display:none;border:0;width:1px;height:1px;" />';
81
+    }
82
+}

+ 168
- 0
app/Support/MailTrackerHtml.php Dosyayı Görüntüle

@@ -0,0 +1,168 @@
1
+<?php
2
+
3
+namespace App\Support;
4
+
5
+use Symfony\Component\Mime\Part\AbstractPart;
6
+use Symfony\Component\Mime\Part\Multipart\AlternativePart;
7
+use Symfony\Component\Mime\Part\Multipart\MixedPart;
8
+use Symfony\Component\Mime\Part\Multipart\RelatedPart;
9
+use Symfony\Component\Mime\Part\TextPart;
10
+
11
+/**
12
+ * Iniezione tracker senza la regex greedy del package (che con HTML enormi
13
+ * può fallire o appendere il pixel dopo </html>, invisibile ai client).
14
+ */
15
+final class MailTrackerHtml
16
+{
17
+    public static function htmlBeforeClosingTag(string $html): string
18
+    {
19
+        $end = stripos($html, '</html>');
20
+
21
+        return $end === false ? $html : substr($html, 0, $end);
22
+    }
23
+
24
+    public static function hasUsablePixel(string $html): bool
25
+    {
26
+        return str_contains(self::htmlBeforeClosingTag($html), '/email/t/');
27
+    }
28
+
29
+    public static function injectPixel(string $html, string $pixelTag): string
30
+    {
31
+        if (self::hasUsablePixel($html)) {
32
+            return $html;
33
+        }
34
+
35
+        $pos = strripos($html, '</body>');
36
+        if ($pos !== false) {
37
+            return substr($html, 0, $pos).$pixelTag.substr($html, $pos);
38
+        }
39
+
40
+        $end = strripos($html, '</html>');
41
+        if ($end !== false) {
42
+            return substr($html, 0, $end).$pixelTag.substr($html, $end);
43
+        }
44
+
45
+        return $html.$pixelTag;
46
+    }
47
+
48
+    /**
49
+     * @param  callable(string $url): string  $rewriteUrl
50
+     */
51
+    public static function rewriteLinks(string $html, callable $rewriteUrl): string
52
+    {
53
+        $rewritten = preg_replace_callback(
54
+            '/(<a\b[^>]*\bhref\s*=\s*")([^"]*)(")/i',
55
+            function (array $m) use ($rewriteUrl) {
56
+                $url = html_entity_decode(str_replace('&amp;', '&', $m[2]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
57
+                if (self::shouldSkipLink($url)) {
58
+                    return $m[0];
59
+                }
60
+
61
+                return $m[1].$rewriteUrl($url).$m[3];
62
+            },
63
+            $html
64
+        );
65
+
66
+        return is_string($rewritten) ? $rewritten : $html;
67
+    }
68
+
69
+    public static function shouldSkipLink(string $url): bool
70
+    {
71
+        if ($url === '' || str_starts_with($url, '#')) {
72
+            return true;
73
+        }
74
+
75
+        $lower = strtolower($url);
76
+        if (
77
+            str_starts_with($lower, 'mailto:')
78
+            || str_starts_with($lower, 'tel:')
79
+            || str_starts_with($lower, 'javascript:')
80
+        ) {
81
+            return true;
82
+        }
83
+
84
+        return str_contains($url, '/email/n');
85
+    }
86
+
87
+    public static function extractHtml(?AbstractPart $part): ?string
88
+    {
89
+        if ($part === null) {
90
+            return null;
91
+        }
92
+
93
+        if ($part instanceof TextPart && $part->getMediaSubtype() === 'html') {
94
+            return $part->getBody();
95
+        }
96
+
97
+        if ($part instanceof AlternativePart || $part instanceof MixedPart || $part instanceof RelatedPart) {
98
+            foreach ($part->getParts() as $child) {
99
+                $html = self::extractHtml($child);
100
+                if ($html !== null) {
101
+                    return $html;
102
+                }
103
+            }
104
+        }
105
+
106
+        return null;
107
+    }
108
+
109
+    public static function replaceHtml(?AbstractPart $part, string $html, ?string $charset): ?AbstractPart
110
+    {
111
+        if ($part === null) {
112
+            return null;
113
+        }
114
+
115
+        if ($part instanceof TextPart && $part->getMediaSubtype() === 'html') {
116
+            return new TextPart($html, $charset, 'html', null);
117
+        }
118
+
119
+        if ($part instanceof AlternativePart || $part instanceof MixedPart || $part instanceof RelatedPart) {
120
+            $replaced = false;
121
+            $newParts = [];
122
+            foreach ($part->getParts() as $child) {
123
+                if (! $replaced) {
124
+                    $mapped = self::replaceHtml($child, $html, $charset);
125
+                    if ($mapped !== $child) {
126
+                        $replaced = true;
127
+                    }
128
+                    $newParts[] = $mapped ?? $child;
129
+                } else {
130
+                    $newParts[] = $child;
131
+                }
132
+            }
133
+
134
+            return $replaced ? self::rebuildMultipart($part, $newParts) : $part;
135
+        }
136
+
137
+        return $part;
138
+    }
139
+
140
+    /**
141
+     * @param  list<AbstractPart>  $newParts
142
+     */
143
+    private static function rebuildMultipart(AbstractPart $part, array $newParts): AbstractPart
144
+    {
145
+        if ($newParts === []) {
146
+            return $part;
147
+        }
148
+
149
+        if ($part instanceof RelatedPart) {
150
+            $main = array_shift($newParts);
151
+            if ($newParts === []) {
152
+                return $main;
153
+            }
154
+
155
+            return new RelatedPart($main, $newParts[0], ...array_slice($newParts, 1));
156
+        }
157
+
158
+        if ($part instanceof AlternativePart) {
159
+            return new AlternativePart(...$newParts);
160
+        }
161
+
162
+        if ($part instanceof MixedPart) {
163
+            return new MixedPart(...$newParts);
164
+        }
165
+
166
+        return $part;
167
+    }
168
+}

+ 9
- 1
composer.json Dosyayı Görüntüle

@@ -11,6 +11,7 @@
11 11
   "require": {
12 12
     "php": "^8.2",
13 13
     "barryvdh/laravel-dompdf": "^3.1",
14
+    "cweagans/composer-patches": "^1.7",
14 15
     "elibyy/tcpdf-laravel": "^11.5",
15 16
     "imtigger/laravel-job-status": "^1.2",
16 17
     "jdavidbakr/mail-tracker": "^8.3",
@@ -78,13 +79,20 @@
78 79
   "extra": {
79 80
     "laravel": {
80 81
       "dont-discover": []
81
-    }
82
+    },
83
+    "patches": {
84
+      "jdavidbakr/mail-tracker": {
85
+        "Fix Response import in MailTrackerController (Laravel facade)": "patches/jdavidbakr-mail-tracker-response-facade.patch"
86
+      }
87
+    },
88
+    "composer-exit-on-patch-failure": true
82 89
   },
83 90
   "config": {
84 91
     "optimize-autoloader": true,
85 92
     "preferred-install": "dist",
86 93
     "sort-packages": true,
87 94
     "allow-plugins": {
95
+      "cweagans/composer-patches": true,
88 96
       "pestphp/pest-plugin": true,
89 97
       "php-http/discovery": true
90 98
     }

+ 49
- 1
composer.lock Dosyayı Görüntüle

@@ -4,7 +4,7 @@
4 4
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
5 5
         "This file is @generated automatically"
6 6
     ],
7
-    "content-hash": "36ecee00ae097944ec3042aa231e6116",
7
+    "content-hash": "d4c6459f7ac996d75eb45ee96032efb8",
8 8
     "packages": [
9 9
         {
10 10
             "name": "aws/aws-crt-php",
@@ -556,6 +556,54 @@
556 556
             ],
557 557
             "time": "2024-11-12T16:29:46+00:00"
558 558
         },
559
+        {
560
+            "name": "cweagans/composer-patches",
561
+            "version": "1.7.3",
562
+            "source": {
563
+                "type": "git",
564
+                "url": "https://github.com/cweagans/composer-patches.git",
565
+                "reference": "e190d4466fe2b103a55467dfa83fc2fecfcaf2db"
566
+            },
567
+            "dist": {
568
+                "type": "zip",
569
+                "url": "https://api.github.com/repos/cweagans/composer-patches/zipball/e190d4466fe2b103a55467dfa83fc2fecfcaf2db",
570
+                "reference": "e190d4466fe2b103a55467dfa83fc2fecfcaf2db",
571
+                "shasum": ""
572
+            },
573
+            "require": {
574
+                "composer-plugin-api": "^1.0 || ^2.0",
575
+                "php": ">=5.3.0"
576
+            },
577
+            "require-dev": {
578
+                "composer/composer": "~1.0 || ~2.0",
579
+                "phpunit/phpunit": "~4.6"
580
+            },
581
+            "type": "composer-plugin",
582
+            "extra": {
583
+                "class": "cweagans\\Composer\\Patches"
584
+            },
585
+            "autoload": {
586
+                "psr-4": {
587
+                    "cweagans\\Composer\\": "src"
588
+                }
589
+            },
590
+            "notification-url": "https://packagist.org/downloads/",
591
+            "license": [
592
+                "BSD-3-Clause"
593
+            ],
594
+            "authors": [
595
+                {
596
+                    "name": "Cameron Eagans",
597
+                    "email": "me@cweagans.net"
598
+                }
599
+            ],
600
+            "description": "Provides a way to patch Composer packages.",
601
+            "support": {
602
+                "issues": "https://github.com/cweagans/composer-patches/issues",
603
+                "source": "https://github.com/cweagans/composer-patches/tree/1.7.3"
604
+            },
605
+            "time": "2022-12-20T22:53:13+00:00"
606
+        },
559 607
         {
560 608
             "name": "dasprid/enum",
561 609
             "version": "1.0.7",

+ 13
- 0
patches/jdavidbakr-mail-tracker-response-facade.patch Dosyayı Görüntüle

@@ -0,0 +1,13 @@
1
+diff --git a/src/MailTrackerController.php b/src/MailTrackerController.php
2
+--- a/src/MailTrackerController.php
3
++++ b/src/MailTrackerController.php
4
+@@ -5,8 +5,8 @@
5
+ use Illuminate\Http\Request;
6
+ use Illuminate\Routing\Controller;
7
+ use Illuminate\Support\Facades\Event;
8
++use Illuminate\Support\Facades\Response;
9
+ use jdavidbakr\MailTracker\Events\ValidActionEvent;
10
+-use Response;
11
+ 
12
+ class MailTrackerController extends Controller
13
+ {

+ 68
- 0
tests/Unit/MailTrackerHtmlTest.php Dosyayı Görüntüle

@@ -0,0 +1,68 @@
1
+<?php
2
+
3
+namespace Tests\Unit;
4
+
5
+use App\Support\MailTrackerHtml;
6
+use PHPUnit\Framework\TestCase;
7
+use Symfony\Component\Mime\Part\Multipart\AlternativePart;
8
+use Symfony\Component\Mime\Part\Multipart\MixedPart;
9
+use Symfony\Component\Mime\Part\Multipart\RelatedPart;
10
+use Symfony\Component\Mime\Part\TextPart;
11
+
12
+class MailTrackerHtmlTest extends TestCase
13
+{
14
+    public function test_injects_pixel_before_body_close_on_huge_html(): void
15
+    {
16
+        $huge = str_repeat('A', 2_000_000);
17
+        $html = '<html><body><p>ciao</p><img src="data:image/png;base64,'.$huge.'"></body></html>';
18
+        $pixel = '<img src="https://example.test/email/t/abc" />';
19
+
20
+        $out = MailTrackerHtml::injectPixel($html, $pixel);
21
+
22
+        $pixelPos = strpos($out, '/email/t/abc');
23
+        $this->assertNotFalse($pixelPos);
24
+        $this->assertLessThan(stripos($out, '</body>'), $pixelPos);
25
+        $this->assertTrue(MailTrackerHtml::hasUsablePixel($out));
26
+    }
27
+
28
+    public function test_pixel_after_html_is_not_usable(): void
29
+    {
30
+        $html = '<html><body>ciao</body></html><img src="https://x.test/email/t/abc" />';
31
+
32
+        $this->assertFalse(MailTrackerHtml::hasUsablePixel($html));
33
+    }
34
+
35
+    public function test_rewrites_http_links_and_skips_mailto(): void
36
+    {
37
+        $html = '<a href="https://my.elephantech.it/segnalazione/show/abc">Apri</a>'
38
+            .'<a href="mailto:alessandro.ratti@arratti.it">mail</a>';
39
+
40
+        $out = MailTrackerHtml::rewriteLinks($html, fn (string $url) => 'TRACKED:'.$url);
41
+
42
+        $this->assertStringContainsString('TRACKED:https://my.elephantech.it/segnalazione/show/abc', $out);
43
+        $this->assertStringContainsString('mailto:alessandro.ratti@arratti.it', $out);
44
+        $this->assertStringNotContainsString('TRACKED:mailto', $out);
45
+    }
46
+
47
+    public function test_replaces_html_inside_mixed_related_alternative(): void
48
+    {
49
+        $html = new TextPart('<html><body>orig</body></html>', 'utf-8', 'html');
50
+        $text = new TextPart('plain', 'utf-8', 'plain');
51
+        $alt = new AlternativePart($text, $html);
52
+        $related = new RelatedPart($alt, new TextPart('img', null, 'plain'));
53
+        $mixed = new MixedPart($related, new TextPart('pdf', 'utf-8', 'plain'));
54
+
55
+        $this->assertSame('<html><body>orig</body></html>', MailTrackerHtml::extractHtml($mixed));
56
+
57
+        $updated = MailTrackerHtml::replaceHtml(
58
+            $mixed,
59
+            '<html><body>orig<img src="https://x.test/email/t/h" /></body></html>',
60
+            'utf-8'
61
+        );
62
+
63
+        $this->assertNotNull($updated);
64
+        $extracted = MailTrackerHtml::extractHtml($updated);
65
+        $this->assertNotNull($extracted);
66
+        $this->assertStringContainsString('/email/t/h', $extracted);
67
+    }
68
+}

Loading…
İptal
Kaydet