Roberto Santini 1 viikko sitten
vanhempi
commit
d47f0c2845

+ 3
- 0
.env.example Näytä tiedosto

@@ -75,6 +75,9 @@ PAYPAL_CLIENT_ID=
75 75
 PAYPAL_SECRET=
76 76
 PAYPAL_MODE=sandbox
77 77
 
78
+# API macchina-macchina (Segresta e altri client)
79
+MYAZIENDA_API_TOKEN=
80
+
78 81
 # Redmine 6.x — import issue / tempi / costi
79 82
 REDMINE_URL=
80 83
 REDMINE_API_KEY=

+ 158
- 0
app/Http/Controllers/Api/SegnalazioneApiController.php Näytä tiedosto

@@ -0,0 +1,158 @@
1
+<?php
2
+
3
+namespace App\Http\Controllers\Api;
4
+
5
+use App\Http\Controllers\Controller;
6
+use App\Models\Annotazione;
7
+use App\Models\Segnalazione;
8
+use App\Services\SegnalazioneApiService;
9
+use Illuminate\Http\JsonResponse;
10
+use Illuminate\Http\Request;
11
+use Illuminate\Validation\Rule;
12
+
13
+class SegnalazioneApiController extends Controller
14
+{
15
+    public function __construct(protected SegnalazioneApiService $service)
16
+    {
17
+    }
18
+
19
+    public function store(Request $request): JsonResponse
20
+    {
21
+        $validated = $request->validate(array_merge([
22
+            'titolo' => 'required|string|max:255',
23
+            'descrizione' => 'required|string',
24
+            'email' => 'required|email|max:255',
25
+            'referente' => 'nullable|string|max:255',
26
+            'telefono' => 'nullable|string|max:50',
27
+            'azienda_id' => ['required', 'integer', Rule::exists('users', 'id')->where('is_azienda', 1)],
28
+            'source_url' => 'nullable|string|max:2048',
29
+            'priorita' => 'nullable|string|max:50',
30
+            'tipo' => 'nullable|string|max:50',
31
+            'notifica_aperta_da' => 'nullable|boolean',
32
+        ], $this->allegatoRules($request, false)));
33
+
34
+        $segnalazione = $this->service->create($validated, $this->allegatiFromRequest($request));
35
+
36
+        return response()->json([
37
+            'data' => $this->service->toArray($segnalazione),
38
+        ], 201);
39
+    }
40
+
41
+    public function show(int $id): JsonResponse
42
+    {
43
+        $segnalazione = Segnalazione::find($id);
44
+        if (! $segnalazione) {
45
+            return response()->json(['message' => 'Segnalazione non trovata'], 404);
46
+        }
47
+
48
+        return response()->json([
49
+            'data' => $this->service->toArray($segnalazione),
50
+        ]);
51
+    }
52
+
53
+    public function storeAnnotazione(Request $request, int $id): JsonResponse
54
+    {
55
+        $segnalazione = Segnalazione::find($id);
56
+        if (! $segnalazione) {
57
+            return response()->json(['message' => 'Segnalazione non trovata'], 404);
58
+        }
59
+
60
+        $validated = $request->validate(array_merge([
61
+            'text' => 'required|string',
62
+            'email' => 'nullable|email|max:255',
63
+            'riservata' => 'nullable|boolean',
64
+        ], $this->allegatoRules($request, false)));
65
+
66
+        $annotazione = $this->service->addAnnotazione(
67
+            $segnalazione,
68
+            $validated['text'],
69
+            $validated['email'] ?? null,
70
+            $this->allegatiFromRequest($request),
71
+            (bool) ($validated['riservata'] ?? false),
72
+        );
73
+
74
+        return response()->json([
75
+            'data' => [
76
+                'id' => $annotazione->id,
77
+                'segnalazione_id' => $segnalazione->id,
78
+                'riservata' => (bool) $annotazione->riservata,
79
+            ],
80
+        ], 201);
81
+    }
82
+
83
+    public function storeAllegati(Request $request, int $id): JsonResponse
84
+    {
85
+        $segnalazione = Segnalazione::find($id);
86
+        if (! $segnalazione) {
87
+            return response()->json(['message' => 'Segnalazione non trovata'], 404);
88
+        }
89
+
90
+        $request->validate($this->allegatoRules($request, true));
91
+
92
+        $files = $this->allegatiFromRequest($request);
93
+        if ($files === []) {
94
+            return response()->json(['message' => 'Nessun allegato valido'], 422);
95
+        }
96
+
97
+        $annotazione = $segnalazione->annotazioni()->orderBy('id')->first();
98
+        if (! $annotazione) {
99
+            $annotazione = new Annotazione();
100
+            $annotazione->text = 'Allegati';
101
+            $annotazione->segnalazione_id = $segnalazione->id;
102
+            $annotazione->riservata = false;
103
+            $annotazione->save();
104
+        }
105
+
106
+        $this->service->storeAllegati($segnalazione, $annotazione, $files);
107
+
108
+        return response()->json([
109
+            'data' => [
110
+                'segnalazione_id' => $segnalazione->id,
111
+                'annotazione_id' => $annotazione->id,
112
+                'allegati' => count($files),
113
+            ],
114
+        ], 201);
115
+    }
116
+
117
+    /**
118
+     * @return array<string, mixed>
119
+     */
120
+    protected function allegatoRules(Request $request, bool $required): array
121
+    {
122
+        $single = $request->file('allegato') instanceof \Illuminate\Http\UploadedFile;
123
+        $base = $required ? 'required' : 'nullable';
124
+
125
+        if ($single) {
126
+            return ['allegato' => $base.'|file|max:10240'];
127
+        }
128
+
129
+        return [
130
+            'allegato' => $base.'|array',
131
+            'allegato.*' => 'file|max:10240',
132
+        ];
133
+    }
134
+
135
+    /**
136
+     * @return list<\Illuminate\Http\UploadedFile>
137
+     */
138
+    protected function allegatiFromRequest(Request $request): array
139
+    {
140
+        if (! $request->hasFile('allegato')) {
141
+            return [];
142
+        }
143
+
144
+        $file = $request->file('allegato');
145
+        if ($file instanceof \Illuminate\Http\UploadedFile) {
146
+            return $file->isValid() ? [$file] : [];
147
+        }
148
+
149
+        $out = [];
150
+        foreach ((array) $file as $item) {
151
+            if ($item instanceof \Illuminate\Http\UploadedFile && $item->isValid()) {
152
+                $out[] = $item;
153
+            }
154
+        }
155
+
156
+        return $out;
157
+    }
158
+}

+ 29
- 0
app/Http/Middleware/VerifyApiToken.php Näytä tiedosto

@@ -0,0 +1,29 @@
1
+<?php
2
+
3
+namespace App\Http\Middleware;
4
+
5
+use Closure;
6
+use Illuminate\Http\Request;
7
+use Symfony\Component\HttpFoundation\Response;
8
+
9
+class VerifyApiToken
10
+{
11
+    public function handle(Request $request, Closure $next): Response
12
+    {
13
+        $expected = (string) config('api.token', '');
14
+        if ($expected === '') {
15
+            return response()->json([
16
+                'message' => 'MYAZIENDA_API_TOKEN non configurato',
17
+            ], 503);
18
+        }
19
+
20
+        $provided = (string) ($request->bearerToken() ?: $request->header('X-Api-Token', ''));
21
+        if ($provided === '' || ! hash_equals($expected, $provided)) {
22
+            return response()->json([
23
+                'message' => 'Token API non valido',
24
+            ], 401);
25
+        }
26
+
27
+        return $next($request);
28
+    }
29
+}

+ 3
- 0
app/Models/ContrattoServizioTodo.php Näytä tiedosto

@@ -74,6 +74,9 @@ class ContrattoServizioTodo extends \App\Models\AbstractModels\AbstractContratto
74 74
 
75 75
             return $next->startOfDay();
76 76
         }
77
+        if ($cron === 'bimestrale') {
78
+            return $from->copy()->addMonthsNoOverflow(2);
79
+        }
77 80
         if ($cron === 'trimestrale') {
78 81
             return $from->copy()->addMonthsNoOverflow(3);
79 82
         }

+ 2
- 0
app/Models/ContrattoTemplateTodo.php Näytä tiedosto

@@ -28,6 +28,7 @@ class ContrattoTemplateTodo extends \App\Models\AbstractModels\AbstractContratto
28 28
 
29 29
     public const RICORRENZE = [
30 30
         'mensile' => 'Mensile',
31
+        'bimestrale' => 'Bimestrale',
31 32
         'trimestrale' => 'Trimestrale',
32 33
         'semestrale' => 'Semestrale',
33 34
         'annuale' => 'Annuale',
@@ -133,6 +134,7 @@ class ContrattoTemplateTodo extends \App\Models\AbstractModels\AbstractContratto
133 134
         }
134 135
 
135 136
         $stepMonths = match ($cron) {
137
+            'bimestrale' => 2,
136 138
             'trimestrale' => 3,
137 139
             'semestrale' => 6,
138 140
             default => 12,

+ 252
- 0
app/Services/SegnalazioneApiService.php Näytä tiedosto

@@ -0,0 +1,252 @@
1
+<?php
2
+
3
+namespace App\Services;
4
+
5
+use App\Models\AllegatoAnnotazione;
6
+use App\Models\Annotazione;
7
+use App\Models\Config;
8
+use App\Models\ContrattoServizio;
9
+use App\Models\Segnalazione;
10
+use App\Models\User;
11
+use Illuminate\Http\UploadedFile;
12
+use Illuminate\Support\Facades\File;
13
+use Illuminate\Support\Facades\Hash;
14
+use Illuminate\Support\Str;
15
+
16
+class SegnalazioneApiService
17
+{
18
+    /**
19
+     * @param  array{
20
+     *     titolo:string,
21
+     *     descrizione:string,
22
+     *     email:string,
23
+     *     referente:?string,
24
+     *     azienda_id:int,
25
+     *     source_url:?string,
26
+     *     telefono:?string,
27
+     *     priorita:?string,
28
+     *     tipo:?string,
29
+     *     notifica_aperta_da?:bool
30
+     * }  $data
31
+     * @param  list<UploadedFile>|UploadedFile|null  $allegati
32
+     */
33
+    public function create(array $data, mixed $allegati = null): Segnalazione
34
+    {
35
+        $aziendaId = (int) $data['azienda_id'];
36
+        $email = strtolower(trim((string) $data['email']));
37
+        $referente = trim((string) ($data['referente'] ?? ''));
38
+        $user = $this->resolveContact($email, $referente, $aziendaId);
39
+
40
+        $segnalazione = new Segnalazione();
41
+        $segnalazione->titolo = Str::limit((string) $data['titolo'], 255);
42
+        $segnalazione->user_id = $user?->id;
43
+        $segnalazione->azienda_id = $aziendaId;
44
+        $segnalazione->contratto_servizio_id = ContrattoServizio::defaultImportEmailForAzienda($aziendaId)?->id;
45
+        $segnalazione->assegnata_a_id = null;
46
+        $segnalazione->stato = Config::getStatoSegnalazioneDefault();
47
+        $segnalazione->priorita = $data['priorita'] ?? Config::getPrioritaSegnalazioneDefault();
48
+        $segnalazione->tipo = $data['tipo'] ?? 'ordinaria';
49
+        $segnalazione->origine = 'api';
50
+        $segnalazione->notificaApertaDa = (bool) ($data['notifica_aperta_da'] ?? true);
51
+        $segnalazione->meta = [
52
+            'api' => [
53
+                'source_url' => $data['source_url'] ?? null,
54
+                'referente' => $referente !== '' ? $referente : null,
55
+                'email' => $email,
56
+            ],
57
+        ];
58
+        $segnalazione->save();
59
+
60
+        $annotazione = new Annotazione();
61
+        $annotazione->text = $this->buildDescrizioneHtml($data);
62
+        $annotazione->segnalazione_id = $segnalazione->id;
63
+        $annotazione->user_id = $user?->id;
64
+        $annotazione->riservata = false;
65
+        $annotazione->email = $email;
66
+        $annotazione->telefono = $data['telefono'] ?? null;
67
+        $annotazione->save();
68
+
69
+        $this->storeAllegati($segnalazione, $annotazione, $allegati);
70
+
71
+        return $segnalazione->fresh(['user', 'azienda', 'annotazioni.allegatiAnnotazione']);
72
+    }
73
+
74
+    public function addAnnotazione(
75
+        Segnalazione $segnalazione,
76
+        string $text,
77
+        ?string $email = null,
78
+        mixed $allegati = null,
79
+        bool $riservata = false,
80
+    ): Annotazione {
81
+        $annotazione = new Annotazione();
82
+        $annotazione->text = nl2br(e($text), false);
83
+        $annotazione->segnalazione_id = $segnalazione->id;
84
+        $annotazione->riservata = $riservata;
85
+        $annotazione->email = $email;
86
+        $annotazione->save();
87
+
88
+        $this->storeAllegati($segnalazione, $annotazione, $allegati);
89
+
90
+        if (! $riservata && $segnalazione->stato !== 'in_attesa_operatore') {
91
+            $segnalazione->stato = 'in_attesa_operatore';
92
+            $segnalazione->save();
93
+        }
94
+
95
+        return $annotazione->fresh(['allegatiAnnotazione']);
96
+    }
97
+
98
+    /**
99
+     * @param  list<UploadedFile>|UploadedFile|null  $allegati
100
+     */
101
+    public function storeAllegati(Segnalazione $segnalazione, Annotazione $annotazione, mixed $allegati): void
102
+    {
103
+        $files = $this->normalizeFiles($allegati);
104
+        if ($files === []) {
105
+            return;
106
+        }
107
+
108
+        $directory = storage_path('app/public/allegatiAnnotazioni/'.$segnalazione->id);
109
+        if (! File::exists($directory)) {
110
+            File::makeDirectory($directory, 0755, true);
111
+        }
112
+
113
+        foreach ($files as $file) {
114
+            $filename = time().'_'.$file->getClientOriginalName();
115
+            $path = $file->storeAs((string) $segnalazione->id, $filename, 'allegatiAnnotazioni');
116
+
117
+            $allegato = new AllegatoAnnotazione();
118
+            $allegato->annotazione()->associate($annotazione);
119
+            $allegato->nome = $file->getClientOriginalName();
120
+            $allegato->path_file = $path;
121
+            $allegato->save();
122
+        }
123
+    }
124
+
125
+    /**
126
+     * @return array<string, mixed>
127
+     */
128
+    public function toArray(Segnalazione $segnalazione): array
129
+    {
130
+        $segnalazione->loadMissing(['user', 'azienda', 'annotazioni.allegatiAnnotazione']);
131
+
132
+        return [
133
+            'id' => $segnalazione->id,
134
+            'numero' => $segnalazione->numero,
135
+            'titolo' => $segnalazione->titolo,
136
+            'stato' => $segnalazione->stato,
137
+            'priorita' => $segnalazione->priorita,
138
+            'tipo' => $segnalazione->tipo,
139
+            'token' => $segnalazione->token,
140
+            'url' => $segnalazione->urlCliente(),
141
+            'azienda_id' => $segnalazione->azienda_id,
142
+            'user_id' => $segnalazione->user_id,
143
+            'created_at' => optional($segnalazione->created_at)?->toIso8601String(),
144
+        ];
145
+    }
146
+
147
+    /**
148
+     * @param  array<string, mixed>  $data
149
+     */
150
+    protected function buildDescrizioneHtml(array $data): string
151
+    {
152
+        $lines = [];
153
+        $sourceUrl = trim((string) ($data['source_url'] ?? ''));
154
+        if ($sourceUrl !== '') {
155
+            $lines[] = 'URL applicazione: '.$sourceUrl;
156
+        }
157
+        $referente = trim((string) ($data['referente'] ?? ''));
158
+        if ($referente !== '') {
159
+            $lines[] = 'Referente: '.$referente;
160
+        }
161
+        $email = trim((string) ($data['email'] ?? ''));
162
+        if ($email !== '') {
163
+            $lines[] = 'Email: '.$email;
164
+        }
165
+        $telefono = trim((string) ($data['telefono'] ?? ''));
166
+        if ($telefono !== '') {
167
+            $lines[] = 'Telefono: '.$telefono;
168
+        }
169
+        $lines[] = '';
170
+        $lines[] = (string) $data['descrizione'];
171
+
172
+        return nl2br(e(implode("\n", $lines)), false);
173
+    }
174
+
175
+    protected function resolveContact(string $email, string $referente, int $aziendaId): ?User
176
+    {
177
+        if ($email === '') {
178
+            return null;
179
+        }
180
+
181
+        $existing = User::query()
182
+            ->whereRaw('LOWER(email) = ?', [$email])
183
+            ->orderBy('id')
184
+            ->first();
185
+
186
+        if ($existing) {
187
+            if ($existing->is_azienda || $existing->is_gruppo) {
188
+                return null;
189
+            }
190
+            if (! $existing->azienda_id) {
191
+                $existing->azienda_id = $aziendaId;
192
+                $existing->save();
193
+            }
194
+
195
+            return $existing;
196
+        }
197
+
198
+        [$nome, $cognome] = $this->splitNome($referente);
199
+
200
+        $user = new User();
201
+        $user->email = $email;
202
+        $user->nome = $nome;
203
+        $user->cognome = $cognome;
204
+        $user->azienda_id = $aziendaId;
205
+        $user->password = Hash::make(Str::password(32));
206
+        $user->save();
207
+
208
+        return $user;
209
+    }
210
+
211
+    /**
212
+     * @return array{0:string,1:string}
213
+     */
214
+    protected function splitNome(string $referente): array
215
+    {
216
+        $referente = trim($referente);
217
+        if ($referente === '') {
218
+            return ['', ''];
219
+        }
220
+
221
+        $parts = preg_split('/\s+/', $referente, 2) ?: [];
222
+
223
+        return [
224
+            $parts[0] ?? '',
225
+            $parts[1] ?? '',
226
+        ];
227
+    }
228
+
229
+    /**
230
+     * @param  list<UploadedFile>|UploadedFile|null  $allegati
231
+     * @return list<UploadedFile>
232
+     */
233
+    protected function normalizeFiles(mixed $allegati): array
234
+    {
235
+        if ($allegati instanceof UploadedFile) {
236
+            return $allegati->isValid() ? [$allegati] : [];
237
+        }
238
+
239
+        if (! is_array($allegati)) {
240
+            return [];
241
+        }
242
+
243
+        $files = [];
244
+        foreach ($allegati as $file) {
245
+            if ($file instanceof UploadedFile && $file->isValid()) {
246
+                $files[] = $file;
247
+            }
248
+        }
249
+
250
+        return $files;
251
+    }
252
+}

+ 3
- 168
app/Services/SegnalazioneEmailInboxService.php Näytä tiedosto

@@ -11,6 +11,7 @@ use App\Models\Segnalazione;
11 11
 use App\Models\User;
12 12
 use App\Notifications\RispostaAutoSegnalazioneEmail;
13 13
 use App\Notifications\RispostaEmailAssegnatario;
14
+use App\Support\EmailQuotedReplyStripper;
14 15
 use Illuminate\Support\Facades\Log;
15 16
 use Illuminate\Support\Facades\Notification;
16 17
 use Illuminate\Support\Facades\Storage;
@@ -406,108 +407,7 @@ class SegnalazioneEmailInboxService
406 407
      */
407 408
     protected function stripQuotedHtml(string $html): string
408 409
     {
409
-        $html = trim($html);
410
-        if ($html === '') {
411
-            return '';
412
-        }
413
-
414
-        $previous = libxml_use_internal_errors(true);
415
-        $dom = new \DOMDocument('1.0', 'UTF-8');
416
-        $loaded = $dom->loadHTML(
417
-            '<?xml encoding="UTF-8">'.$html,
418
-            LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET
419
-        );
420
-        libxml_clear_errors();
421
-        libxml_use_internal_errors($previous);
422
-
423
-        if (! $loaded) {
424
-            return $this->stripQuotedHtmlRegexFallback($html);
425
-        }
426
-
427
-        $xpath = new \DOMXPath($dom);
428
-
429
-        // Contenitori tipici delle citazioni
430
-        $quoteQueries = [
431
-            '//blockquote',
432
-            '//div[contains(concat(" ", normalize-space(@class), " "), " gmail_quote ")]',
433
-            '//div[contains(concat(" ", normalize-space(@class), " "), " gmail_extra ")]',
434
-            '//div[contains(concat(" ", normalize-space(@class), " "), " yahoo_quoted ")]',
435
-            '//div[contains(concat(" ", normalize-space(@class), " "), " moz-cite-prefix ")]',
436
-            '//div[contains(@id, "divRplyFwdMsg")]',
437
-            '//div[@id="appendonsend"]',
438
-            '//hr[@id="stopSpelling"]',
439
-        ];
440
-
441
-        foreach ($quoteQueries as $query) {
442
-            $nodes = $xpath->query($query);
443
-            if (! $nodes) {
444
-                continue;
445
-            }
446
-            for ($i = $nodes->length - 1; $i >= 0; $i--) {
447
-                $node = $nodes->item($i);
448
-                if (! $node || ! $node->parentNode) {
449
-                    continue;
450
-                }
451
-                // Outlook: rimuovi anche i sibling successivi all'header reply
452
-                if ($node instanceof \DOMElement && (
453
-                    str_contains((string) $node->getAttribute('id'), 'divRplyFwdMsg')
454
-                    || $node->getAttribute('id') === 'appendonsend'
455
-                    || $node->getAttribute('id') === 'stopSpelling'
456
-                )) {
457
-                    while ($node->nextSibling) {
458
-                        $node->parentNode->removeChild($node->nextSibling);
459
-                    }
460
-                }
461
-                $node->parentNode->removeChild($node);
462
-            }
463
-        }
464
-
465
-        // Prefissi tipo "Il ... ha scritto:" / "On ... wrote:"
466
-        $prefixNodes = $xpath->query('//p|//div|//span');
467
-        if ($prefixNodes) {
468
-            for ($i = $prefixNodes->length - 1; $i >= 0; $i--) {
469
-                $node = $prefixNodes->item($i);
470
-                if (! $node instanceof \DOMElement) {
471
-                    continue;
472
-                }
473
-                $text = trim(preg_replace('/\s+/u', ' ', $node->textContent ?? '') ?? '');
474
-                if ($text === '' || mb_strlen($text) >= 220 || ! $this->looksLikeQuoteHeader($text)) {
475
-                    continue;
476
-                }
477
-                $nestedQuotes = $xpath->query('.//blockquote|.//div[contains(@class,"gmail_quote")]', $node);
478
-                if ($nestedQuotes && $nestedQuotes->length > 0) {
479
-                    continue;
480
-                }
481
-                $node->parentNode?->removeChild($node);
482
-            }
483
-        }
484
-
485
-        $body = $dom->getElementsByTagName('body')->item(0);
486
-        $root = $body ?: $dom->documentElement;
487
-        if (! $root) {
488
-            return $this->stripQuotedHtmlRegexFallback($html);
489
-        }
490
-
491
-        $out = '';
492
-        foreach ($root->childNodes as $child) {
493
-            $out .= $dom->saveHTML($child);
494
-        }
495
-
496
-        $out = trim($out);
497
-        if (trim(strip_tags($out)) === '') {
498
-            return $this->stripQuotedHtmlRegexFallback($html);
499
-        }
500
-
501
-        return $out;
502
-    }
503
-
504
-    protected function stripQuotedHtmlRegexFallback(string $html): string
505
-    {
506
-        $html = preg_replace('/<blockquote\b[^>]*>.*?<\/blockquote>/is', '', $html) ?? $html;
507
-        $html = preg_replace('/<div[^>]*class="[^"]*gmail_quote[^"]*"[^>]*>.*?<\/div>/is', '', $html) ?? $html;
508
-        $html = preg_replace('/<div[^>]*class="[^"]*gmail_extra[^"]*"[^>]*>.*?<\/div>/is', '', $html) ?? $html;
509
-
510
-        return trim($html);
410
+        return EmailQuotedReplyStripper::html($html);
511 411
     }
512 412
 
513 413
     /**
@@ -515,72 +415,7 @@ class SegnalazioneEmailInboxService
515 415
      */
516 416
     protected function stripQuotedPlainText(string $text): string
517 417
     {
518
-        $text = str_replace(["\r\n", "\r"], "\n", $text);
519
-        $lines = explode("\n", $text);
520
-        $kept = [];
521
-
522
-        $lineCount = count($lines);
523
-        for ($idx = 0; $idx < $lineCount; $idx++) {
524
-            $line = $lines[$idx];
525
-            $trimmed = rtrim($line);
526
-
527
-            // Separatore messaggio originale
528
-            if (preg_match('/^-{2,}\s*(Original Message|Messaggio originale)\s*-{2,}/i', $trimmed)) {
529
-                break;
530
-            }
531
-
532
-            // Outlook: riga di underscore seguita da header From/Sent
533
-            if (preg_match('/^_{5,}\s*$/', $trimmed)) {
534
-                $next = '';
535
-                for ($j = $idx + 1; $j < $lineCount; $j++) {
536
-                    if (trim($lines[$j]) !== '') {
537
-                        $next = trim($lines[$j]);
538
-                        break;
539
-                    }
540
-                }
541
-                if ($next !== '' && preg_match('/^(From|Da|Sent|Inviato|Date|Data|To|A|Subject|Oggetto)\s*:/i', $next)) {
542
-                    break;
543
-                }
544
-            }
545
-
546
-            // Header citazione su una riga
547
-            if ($this->looksLikeQuoteHeader($trimmed)) {
548
-                break;
549
-            }
550
-
551
-            // Linee quotate stile mail >
552
-            if (preg_match('/^>+\s?/', $trimmed)) {
553
-                if (count(array_filter($kept, fn ($l) => trim($l) !== '')) > 0) {
554
-                    break;
555
-                }
556
-                continue;
557
-            }
558
-
559
-            $kept[] = $line;
560
-        }
561
-
562
-        return trim(implode("\n", $kept));
563
-    }
564
-
565
-    protected function looksLikeQuoteHeader(string $text): bool
566
-    {
567
-        $text = trim($text);
568
-        if ($text === '') {
569
-            return false;
570
-        }
571
-
572
-        // EN / IT / FR comuni
573
-        if (preg_match('/^(On|Il|Le|Am|El)\s+.+\s+(wrote|ha scritto|a écrit|schrieb)\s*:?\s*$/iu', $text)) {
574
-            return true;
575
-        }
576
-        if (preg_match('/^From:\s.+/i', $text) && preg_match('/(Sent|Date|To|Subject):/i', $text)) {
577
-            return true;
578
-        }
579
-        if (preg_match('/^-{2,}\s*(Original Message|Messaggio originale)/i', $text)) {
580
-            return true;
581
-        }
582
-
583
-        return false;
418
+        return EmailQuotedReplyStripper::plain($text);
584 419
     }
585 420
 
586 421
     protected function storeAttachments(Segnalazione $segnalazione, Annotazione $annotazione, Message $message): void

+ 373
- 0
app/Support/EmailQuotedReplyStripper.php Näytä tiedosto

@@ -0,0 +1,373 @@
1
+<?php
2
+
3
+namespace App\Support;
4
+
5
+/**
6
+ * Estrae solo il messaggio nuovo da una risposta email (Gmail/Outlook/Thunderbird).
7
+ * Outlook in italiano avvolge il thread dopo un blocco Da:/Inviato:/Oggetto: senza blockquote.
8
+ */
9
+final class EmailQuotedReplyStripper
10
+{
11
+    public static function html(string $html): string
12
+    {
13
+        $html = trim($html);
14
+        if ($html === '') {
15
+            return '';
16
+        }
17
+
18
+        $previous = libxml_use_internal_errors(true);
19
+        $dom = new \DOMDocument('1.0', 'UTF-8');
20
+        $loaded = $dom->loadHTML(
21
+            '<?xml encoding="UTF-8"><!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head><body>'.$html.'</body></html>',
22
+            LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET | LIBXML_HTML_NODEFDTD | LIBXML_PARSEHUGE
23
+        );
24
+        libxml_clear_errors();
25
+        libxml_use_internal_errors($previous);
26
+
27
+        if (! $loaded) {
28
+            return self::htmlRegexFallback($html);
29
+        }
30
+
31
+        $xpath = new \DOMXPath($dom);
32
+
33
+        $outlookStart = self::findOutlookQuoteStart($xpath);
34
+        if ($outlookStart) {
35
+            self::cutFromQuotedHeader($outlookStart);
36
+        }
37
+
38
+        $quoteQueries = [
39
+            '//blockquote',
40
+            '//div[contains(concat(" ", normalize-space(@class), " "), " gmail_quote ")]',
41
+            '//div[contains(concat(" ", normalize-space(@class), " "), " gmail_extra ")]',
42
+            '//div[contains(concat(" ", normalize-space(@class), " "), " yahoo_quoted ")]',
43
+            '//div[contains(concat(" ", normalize-space(@class), " "), " moz-cite-prefix ")]',
44
+            '//div[contains(@id, "divRplyFwdMsg")]',
45
+            '//div[@id="appendonsend"]',
46
+            '//hr[@id="stopSpelling"]',
47
+        ];
48
+
49
+        foreach ($quoteQueries as $query) {
50
+            $nodes = $xpath->query($query);
51
+            if (! $nodes) {
52
+                continue;
53
+            }
54
+            for ($i = $nodes->length - 1; $i >= 0; $i--) {
55
+                $node = $nodes->item($i);
56
+                if (! $node || ! $node->parentNode) {
57
+                    continue;
58
+                }
59
+                if ($node instanceof \DOMElement && (
60
+                    str_contains((string) $node->getAttribute('id'), 'divRplyFwdMsg')
61
+                    || $node->getAttribute('id') === 'appendonsend'
62
+                    || $node->getAttribute('id') === 'stopSpelling'
63
+                )) {
64
+                    self::removeFollowingSiblings($node);
65
+                }
66
+                $node->parentNode->removeChild($node);
67
+            }
68
+        }
69
+
70
+        $prefixNodes = $xpath->query('//p|//div|//span');
71
+        if ($prefixNodes) {
72
+            for ($i = $prefixNodes->length - 1; $i >= 0; $i--) {
73
+                $node = $prefixNodes->item($i);
74
+                if (! $node instanceof \DOMElement) {
75
+                    continue;
76
+                }
77
+                $text = self::normalizedText($node);
78
+                if ($text === '' || mb_strlen($text) >= 220 || ! self::looksLikeQuoteHeader($text)) {
79
+                    continue;
80
+                }
81
+                $nestedQuotes = $xpath->query('.//blockquote|.//div[contains(@class,"gmail_quote")]', $node);
82
+                if ($nestedQuotes && $nestedQuotes->length > 0) {
83
+                    continue;
84
+                }
85
+                $node->parentNode?->removeChild($node);
86
+            }
87
+        }
88
+
89
+        $body = $dom->getElementsByTagName('body')->item(0);
90
+        $root = $body ?: $dom->documentElement;
91
+        if (! $root) {
92
+            return self::htmlRegexFallback($html);
93
+        }
94
+
95
+        $out = '';
96
+        foreach ($root->childNodes as $child) {
97
+            $out .= $dom->saveHTML($child);
98
+        }
99
+
100
+        $out = trim($out);
101
+        if (trim(strip_tags($out)) === '') {
102
+            return self::htmlRegexFallback($html);
103
+        }
104
+
105
+        return $out;
106
+    }
107
+
108
+    public static function plain(string $text): string
109
+    {
110
+        $text = str_replace(["\r\n", "\r"], "\n", $text);
111
+        $lines = explode("\n", $text);
112
+        $kept = [];
113
+
114
+        $lineCount = count($lines);
115
+        for ($idx = 0; $idx < $lineCount; $idx++) {
116
+            $line = $lines[$idx];
117
+            $trimmed = rtrim($line);
118
+
119
+            if (preg_match('/^-{2,}\s*(Original Message|Messaggio originale)\s*-{2,}/i', $trimmed)) {
120
+                break;
121
+            }
122
+
123
+            if (preg_match('/^_{5,}\s*$/', $trimmed)) {
124
+                $next = '';
125
+                for ($j = $idx + 1; $j < $lineCount; $j++) {
126
+                    if (trim($lines[$j]) !== '') {
127
+                        $next = trim($lines[$j]);
128
+                        break;
129
+                    }
130
+                }
131
+                if ($next !== '' && preg_match('/^(From|Da|Sent|Inviato|Date|Data|To|A|Subject|Oggetto)\s*:/i', $next)) {
132
+                    break;
133
+                }
134
+            }
135
+
136
+            if (self::plainLineStartsOutlookThread($lines, $idx)) {
137
+                break;
138
+            }
139
+
140
+            if (self::looksLikeQuoteHeader($trimmed)) {
141
+                break;
142
+            }
143
+
144
+            if (preg_match('/^>+\s?/', $trimmed)) {
145
+                if (count(array_filter($kept, fn ($l) => trim($l) !== '')) > 0) {
146
+                    break;
147
+                }
148
+                continue;
149
+            }
150
+
151
+            $kept[] = $line;
152
+        }
153
+
154
+        return trim(implode("\n", $kept));
155
+    }
156
+
157
+    public static function looksLikeQuoteHeader(string $text): bool
158
+    {
159
+        $text = trim($text);
160
+        if ($text === '') {
161
+            return false;
162
+        }
163
+
164
+        if (preg_match('/^(On|Il|Le|Am|El)\s+.+\s+(wrote|ha scritto|a écrit|schrieb)\s*:?\s*$/iu', $text)) {
165
+            return true;
166
+        }
167
+        if (self::isOutlookReplyHeaderBlock($text)) {
168
+            return true;
169
+        }
170
+        if (preg_match('/^-{2,}\s*(Original Message|Messaggio originale)/i', $text)) {
171
+            return true;
172
+        }
173
+
174
+        return false;
175
+    }
176
+
177
+    private static function findOutlookQuoteStart(\DOMXPath $xpath): ?\DOMElement
178
+    {
179
+        $nodes = $xpath->query('//div|//p|//table|//hr');
180
+        if (! $nodes) {
181
+            return null;
182
+        }
183
+
184
+        foreach ($nodes as $node) {
185
+            if (! $node instanceof \DOMElement) {
186
+                continue;
187
+            }
188
+
189
+            $text = self::normalizedText($node);
190
+            if ($text === '') {
191
+                continue;
192
+            }
193
+
194
+            $len = mb_strlen($text);
195
+            if ($len <= 2000 && self::isOutlookReplyHeaderBlock($text)) {
196
+                return $node;
197
+            }
198
+
199
+            if ($len <= 500 && self::startsWithFromHeader($text) && self::followingSiblingsLookLikeHeaders($node)) {
200
+                return $node;
201
+            }
202
+        }
203
+
204
+        return null;
205
+    }
206
+
207
+    private static function cutFromQuotedHeader(\DOMElement $headerNode): void
208
+    {
209
+        $node = $headerNode;
210
+        while ($node->parentNode instanceof \DOMElement) {
211
+            $parent = $node->parentNode;
212
+            $parentName = strtolower($parent->nodeName);
213
+            $parentClass = $parent->getAttribute('class');
214
+
215
+            $isTop = in_array($parentName, ['body', 'html'], true)
216
+                || (bool) preg_match('/WordSection/i', $parentClass);
217
+
218
+            if ($isTop) {
219
+                self::removeNodeAndFollowingSiblings($node);
220
+
221
+                return;
222
+            }
223
+
224
+            $node = $parent;
225
+        }
226
+
227
+        self::removeNodeAndFollowingSiblings($headerNode);
228
+    }
229
+
230
+    private static function removeNodeAndFollowingSiblings(\DOMNode $node): void
231
+    {
232
+        $parent = $node->parentNode;
233
+        if (! $parent) {
234
+            return;
235
+        }
236
+
237
+        self::removeFollowingSiblings($node);
238
+        $parent->removeChild($node);
239
+    }
240
+
241
+    private static function removeFollowingSiblings(\DOMNode $node): void
242
+    {
243
+        $parent = $node->parentNode;
244
+        if (! $parent) {
245
+            return;
246
+        }
247
+
248
+        while ($node->nextSibling) {
249
+            $parent->removeChild($node->nextSibling);
250
+        }
251
+    }
252
+
253
+    private static function isOutlookReplyHeaderBlock(string $text): bool
254
+    {
255
+        if (! preg_match('/^(Da|From)\s*:/iu', $text)) {
256
+            return false;
257
+        }
258
+
259
+        $hasSent = (bool) preg_match('/\b(?:Inviato|Inviata|Sent)\s*:/iu', $text);
260
+        $hasSubject = (bool) preg_match('/\b(?:Oggetto|Subject)\s*:/iu', $text);
261
+
262
+        return $hasSent && $hasSubject;
263
+    }
264
+
265
+    private static function startsWithFromHeader(string $text): bool
266
+    {
267
+        return (bool) preg_match('/^(Da|From)\s*:/iu', $text);
268
+    }
269
+
270
+    private static function followingSiblingsLookLikeHeaders(\DOMElement $node): bool
271
+    {
272
+        $seenSent = false;
273
+        $seenSubject = false;
274
+        $checked = 0;
275
+
276
+        for ($sib = $node->nextSibling; $sib && $checked < 12; $sib = $sib->nextSibling) {
277
+            if (! in_array($sib->nodeType, [XML_ELEMENT_NODE, XML_TEXT_NODE], true)) {
278
+                continue;
279
+            }
280
+
281
+            $t = self::normalizedText($sib);
282
+            if ($t === '') {
283
+                continue;
284
+            }
285
+
286
+            $checked++;
287
+            if (preg_match('/\b(?:Inviato|Inviata|Sent|Date|Data)\s*:/iu', $t)) {
288
+                $seenSent = true;
289
+            }
290
+            if (preg_match('/\b(?:Oggetto|Subject)\s*:/iu', $t)) {
291
+                $seenSubject = true;
292
+            }
293
+            if ($seenSent && $seenSubject) {
294
+                return true;
295
+            }
296
+
297
+            if (mb_strlen($t) > 400 && ! preg_match('/\b(?:Da|From|Inviato|Sent|Oggetto|Subject)\s*:/iu', $t)) {
298
+                return false;
299
+            }
300
+        }
301
+
302
+        return $seenSent && $seenSubject;
303
+    }
304
+
305
+    private static function plainLineStartsOutlookThread(array $lines, int $idx): bool
306
+    {
307
+        $trimmed = trim($lines[$idx]);
308
+        if (! preg_match('/^(Da|From)\s*:/iu', $trimmed)) {
309
+            return false;
310
+        }
311
+
312
+        $seenSent = (bool) preg_match('/\b(?:Inviato|Inviata|Sent)\s*:/iu', $trimmed);
313
+        $seenSubject = (bool) preg_match('/\b(?:Oggetto|Subject)\s*:/iu', $trimmed);
314
+        if ($seenSent && $seenSubject) {
315
+            return true;
316
+        }
317
+
318
+        $checked = 0;
319
+        $count = count($lines);
320
+        for ($j = $idx + 1; $j < $count && $checked < 8; $j++) {
321
+            $next = trim($lines[$j]);
322
+            if ($next === '') {
323
+                continue;
324
+            }
325
+            $checked++;
326
+            if (preg_match('/^(Inviato|Inviata|Sent|Date|Data|To|A|Cc|Ccn|Bcc|Oggetto|Subject)\s*:/iu', $next)) {
327
+                if (preg_match('/^(Inviato|Inviata|Sent|Date|Data)\s*:/iu', $next)) {
328
+                    $seenSent = true;
329
+                }
330
+                if (preg_match('/^(Oggetto|Subject)\s*:/iu', $next)) {
331
+                    $seenSubject = true;
332
+                }
333
+                if ($seenSent && $seenSubject) {
334
+                    return true;
335
+                }
336
+
337
+                continue;
338
+            }
339
+
340
+            break;
341
+        }
342
+
343
+        return false;
344
+    }
345
+
346
+    private static function normalizedText(\DOMNode $node): string
347
+    {
348
+        $text = str_replace("\u{00A0}", ' ', $node->textContent ?? '');
349
+
350
+        return trim(preg_replace('/\s+/u', ' ', $text) ?? '');
351
+    }
352
+
353
+    private static function htmlRegexFallback(string $html): string
354
+    {
355
+        $html = preg_replace('/<blockquote\b[^>]*>.*?<\/blockquote>/is', '', $html) ?? $html;
356
+        $html = preg_replace('/<div[^>]*class="[^"]*gmail_quote[^"]*"[^>]*>.*?<\/div>/is', '', $html) ?? $html;
357
+        $html = preg_replace('/<div[^>]*class="[^"]*gmail_extra[^"]*"[^>]*>.*?<\/div>/is', '', $html) ?? $html;
358
+
359
+        if (preg_match(
360
+            '/<(?:div|p)[^>]*>[\s\S]{0,80}?\b(?:Da|From)\s*:[\s\S]{0,800}?\b(?:Inviato|Sent)\s*:[\s\S]{0,800}?\b(?:Oggetto|Subject)\s*:/iu',
361
+            $html,
362
+            $m,
363
+            PREG_OFFSET_CAPTURE
364
+        )) {
365
+            $cut = substr($html, 0, $m[0][1]);
366
+            if (trim(strip_tags($cut)) !== '') {
367
+                $html = $cut;
368
+            }
369
+        }
370
+
371
+        return trim($html);
372
+    }
373
+}

+ 2
- 0
bootstrap/app.php Näytä tiedosto

@@ -8,6 +8,7 @@ use App\Http\Middleware\LocaleMiddleware;
8 8
 return Application::configure(basePath: dirname(__DIR__))
9 9
     ->withRouting(
10 10
         web: __DIR__ . '/../routes/web.php',
11
+        api: __DIR__ . '/../routes/api.php',
11 12
         commands: __DIR__ . '/../routes/console.php',
12 13
         health: '/up',
13 14
     )
@@ -23,6 +24,7 @@ return Application::configure(basePath: dirname(__DIR__))
23 24
             'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
24 25
             'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
25 26
             'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
27
+            'api.token' => \App\Http\Middleware\VerifyApiToken::class,
26 28
         ]);
27 29
     })
28 30
     ->withExceptions(function (Exceptions $exceptions) {

+ 9
- 0
config/api.php Näytä tiedosto

@@ -0,0 +1,9 @@
1
+<?php
2
+
3
+return [
4
+    /*
5
+    | Token condiviso per le API macchina-macchina (Segresta e altri client).
6
+    | Header: Authorization: Bearer {token} oppure X-Api-Token.
7
+    */
8
+    'token' => env('MYAZIENDA_API_TOKEN', ''),
9
+];

+ 6
- 3
routes/api.php Näytä tiedosto

@@ -1,8 +1,11 @@
1 1
 <?php
2 2
 
3
-use Illuminate\Http\Request;
3
+use App\Http\Controllers\Api\SegnalazioneApiController;
4 4
 use Illuminate\Support\Facades\Route;
5 5
 
6
-
7
-Route::middleware([/*'auth:sanctum'*/])->group(function () {
6
+Route::middleware('api.token')->group(function () {
7
+    Route::post('segnalazioni', [SegnalazioneApiController::class, 'store']);
8
+    Route::get('segnalazioni/{id}', [SegnalazioneApiController::class, 'show'])->whereNumber('id');
9
+    Route::post('segnalazioni/{id}/annotazioni', [SegnalazioneApiController::class, 'storeAnnotazione'])->whereNumber('id');
10
+    Route::post('segnalazioni/{id}/allegati', [SegnalazioneApiController::class, 'storeAllegati'])->whereNumber('id');
8 11
 });

+ 114
- 0
tests/Unit/EmailQuotedReplyStripperTest.php Näytä tiedosto

@@ -0,0 +1,114 @@
1
+<?php
2
+
3
+namespace Tests\Unit;
4
+
5
+use App\Support\EmailQuotedReplyStripper;
6
+use PHPUnit\Framework\TestCase;
7
+
8
+class EmailQuotedReplyStripperTest extends TestCase
9
+{
10
+    public function test_strips_outlook_italian_thread_after_da_inviato_oggetto(): void
11
+    {
12
+        $html = <<<'HTML'
13
+<div class="WordSection1">
14
+<p class="MsoNormal">Ciao Roberto,</p>
15
+<p class="MsoNormal">le modifiche che hai fatto vanno benissimo. Confermo che serve il pdf.</p>
16
+<p class="MsoNormal">Grazie</p>
17
+<div>
18
+<p class="MsoNormal"><b>Alessandro RATTI</b></p>
19
+<p class="MsoNormal">Direzione | AR di Adelio Ratti &amp; C. Srl</p>
20
+</div>
21
+<div>
22
+<div style="border:none;border-top:solid #E1E1E1 1.0pt;padding:3.0pt 0cm 0cm 0cm">
23
+<p class="MsoNormal"><b>Da:</b> Assistenza Elephantech &lt;assistenza@elephantech.it&gt;<br>
24
+<b>Inviato:</b> mercoledì 9 settembre 2026 00:05<br>
25
+<b>A:</b> gestione.ordini@arratti.it<br>
26
+<b>Cc:</b> alessandro.ratti@arratti.it<br>
27
+<b>Oggetto:</b> Re: [#19503] ordini gestione pz a magazzino</p>
28
+</div>
29
+</div>
30
+<table class="MsoNormalTable"><tr><td>
31
+<p>Azienda: A.R. di Adelio Ratti</p>
32
+<p>Ciao, ho aggiunto questa colonna/campo nella tabella Ordine:</p>
33
+<a href="https://my.elephantech.it/segnalazione/show/abc">Apri segnalazione</a>
34
+<img src="https://my.elephantech.it/email/t/8TRFD1jUhTlbPBtvkPDaEjdletXmSfFH">
35
+</td></tr></table>
36
+</div>
37
+HTML;
38
+
39
+        $out = EmailQuotedReplyStripper::html($html);
40
+        $plain = trim(html_entity_decode(strip_tags($out), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
41
+
42
+        $this->assertStringContainsString('le modifiche che hai fatto vanno benissimo', $plain);
43
+        $this->assertStringContainsString('Alessandro RATTI', $plain);
44
+        $this->assertStringNotContainsString('Apri segnalazione', $plain);
45
+        $this->assertStringNotContainsString('ordini gestione pz a magazzino', $plain);
46
+        $this->assertStringNotContainsString('ho aggiunto questa colonna', $plain);
47
+        $this->assertStringNotContainsString('/email/t/', $out);
48
+        $this->assertStringNotContainsString('Assistenza Elephantech', $plain);
49
+    }
50
+
51
+    public function test_strips_outlook_headers_split_across_paragraphs(): void
52
+    {
53
+        $html = <<<'HTML'
54
+<div class="WordSection1">
55
+<p>Confermo, va bene così.</p>
56
+<p><b>Da:</b> Assistenza Elephantech</p>
57
+<p><b>Inviato:</b> mercoledì 9 settembre 2026 00:05</p>
58
+<p><b>A:</b> gestione.ordini@arratti.it</p>
59
+<p><b>Oggetto:</b> Re: [#19503] prova</p>
60
+<table><tr><td>Messaggio precedente con Apri segnalazione</td></tr></table>
61
+</div>
62
+HTML;
63
+
64
+        $out = EmailQuotedReplyStripper::html($html);
65
+        $plain = trim(strip_tags($out));
66
+
67
+        $this->assertStringContainsString('Confermo, va bene così.', $plain);
68
+        $this->assertStringNotContainsString('Messaggio precedente', $plain);
69
+        $this->assertStringNotContainsString('Apri segnalazione', $plain);
70
+    }
71
+
72
+    public function test_keeps_gmail_quote_stripped(): void
73
+    {
74
+        $html = '<div>Ok, grazie<div class="gmail_quote">Il giorno lunedì ha scritto:<br>vecchio testo</div></div>';
75
+
76
+        $out = EmailQuotedReplyStripper::html($html);
77
+
78
+        $this->assertStringContainsString('Ok, grazie', $out);
79
+        $this->assertStringNotContainsString('vecchio testo', $out);
80
+    }
81
+
82
+    public function test_strips_italian_outlook_plain_text_thread(): void
83
+    {
84
+        $text = <<<'TEXT'
85
+Ciao Roberto,
86
+
87
+le modifiche che hai fatto vanno benissimo.
88
+
89
+Grazie
90
+
91
+Da: Assistenza Elephantech <assistenza@elephantech.it>
92
+Inviato: mercoledì 9 settembre 2026 00:05
93
+A: gestione.ordini@arratti.it
94
+Oggetto: Re: [#19503] ordini gestione pz a magazzino
95
+
96
+Ciao, ho aggiunto questa colonna
97
+TEXT;
98
+
99
+        $out = EmailQuotedReplyStripper::plain($text);
100
+
101
+        $this->assertStringContainsString('le modifiche che hai fatto vanno benissimo', $out);
102
+        $this->assertStringNotContainsString('ho aggiunto questa colonna', $out);
103
+        $this->assertStringNotContainsString('Oggetto:', $out);
104
+    }
105
+
106
+    public function test_does_not_cut_body_that_mentions_da_senza_header_thread(): void
107
+    {
108
+        $html = '<p>Da parte mia va tutto bene, grazie.</p>';
109
+
110
+        $out = EmailQuotedReplyStripper::html($html);
111
+
112
+        $this->assertStringContainsString('Da parte mia va tutto bene', $out);
113
+    }
114
+}

Loading…
Peruuta
Tallenna