dolibarr 25.0.0-alpha
emailsignature.lib.php
Go to the documentation of this file.
1<?php
2/* Copyright (C) 2026 Frédéric France <frederic.france@free.fr>
3 *
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation; either version 3 of the License, or
7 * (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 */
17
40{
41 $html = (string) $html;
42
43 // Tunable thresholds
44 $maximg = getDolGlobalInt('SIGNATURE_QUALITY_MAX_IMG', 3);
45 $maximgwidth = getDolGlobalInt('SIGNATURE_QUALITY_MAX_IMG_WIDTH', 600);
46 $maxtextlen = getDolGlobalInt('SIGNATURE_QUALITY_MAX_TEXT_LEN', 600);
47 $maxlinks = getDolGlobalInt('SIGNATURE_QUALITY_MAX_LINKS', 5);
48 $mincharsperimg = getDolGlobalInt('SIGNATURE_QUALITY_MIN_CHARS_PER_IMG', 60);
49 $mintextlen = getDolGlobalInt('SIGNATURE_QUALITY_MIN_TEXT_LEN', 20);
50
51 // Fixed penalty weights (kept internal so the 0..100 scale stays meaningful)
52 $w = array(
53 'script_tag' => 60,
54 'style_block' => 10,
55 'non_absolute_url' => 25,
56 'text_image_ratio_error' => 55,
57 'text_image_ratio_warning' => 12,
58 'image_count_error' => 20,
59 'image_count_warning' => 10,
60 'image_alt_per_image' => 5,
61 'image_alt_cap' => 15,
62 'image_weight' => 10,
63 'link_count' => 10,
64 'text_length' => 10,
65 'contact_info' => 20,
66 'fixed_width' => 5,
67 );
68
69 // A signature with almost no real text (and no image) can never be more than this
70 $insufficientcontentcap = 35;
71
72 $codes = array(
73 'content', 'script_tag', 'style_block', 'non_absolute_url', 'text_image_ratio',
74 'image_count', 'image_alt', 'image_weight', 'link_count', 'text_length',
75 'contact_info', 'fixed_width',
76 );
77 $checks = array();
78 foreach ($codes as $code) {
79 $checks[$code] = array('status' => 'ok', 'weight' => 0);
80 }
81 $score = 100;
82
83 $set = function (string $code, string $status, int $weight) use (&$checks, &$score) {
84 $checks[$code] = array('status' => $status, 'weight' => $weight);
85 $score -= $weight;
86 };
87
88 // Plain text length, ignoring the content of <script>/<style> blocks
89 $htmlnoscript = preg_replace('/<(script|style)\b[^>]*>.*?<\/\1>/is', ' ', $html);
90 $text = dol_string_nohtmltag((string) $htmlnoscript, 1, 'UTF-8', 0, 1);
91 $text = trim((string) preg_replace('/\s+/u', ' ', (string) $text));
92 $textlen = function_exists('mb_strlen') ? mb_strlen($text, 'UTF-8') : strlen($text);
93 $wordcount = preg_match_all('/\p{L}{2,}/u', $text);
94
95 // Collect <img> tags
96 $matches = array();
97 preg_match_all('/<img\b[^>]*>/i', $html, $matches);
98 $imgtags = $matches[0];
99 $nbimg = count($imgtags);
100
101 // Nothing usable at all: this is not a signature
102 if ($textlen == 0 && $nbimg == 0) {
103 $checks['content']['status'] = 'error';
104 $checks['text_image_ratio']['status'] = 'error';
105 $checks['text_length']['status'] = 'error';
106 $checks['contact_info']['status'] = 'error';
107 return array('score' => 0, 'grade' => 'poor', 'checks' => $checks);
108 }
109
110 // Real content: a text-only signature must carry at least a couple of words (a name)
111 $insufficientcontent = ($nbimg == 0 && ($textlen < $mintextlen || $wordcount < 2));
112 if ($insufficientcontent) {
113 $set('content', 'error', 0);
114 }
115
116 // Executable / non portable markup
117 if (preg_match('/<script\b/i', $html)) {
118 $set('script_tag', 'error', $w['script_tag']);
119 }
120 if (preg_match('/<style\b/i', $html) || preg_match('/<link\b[^>]*stylesheet/i', $html)) {
121 $set('style_block', 'warning', $w['style_block']);
122 }
123
124 // Per image inspection
125 $nbimgnoalt = 0;
126 $hasnonabsolute = false;
127 $hasheavyimg = false;
128 foreach ($imgtags as $tag) {
129 $src = '';
130 if (preg_match('/\ssrc\s*=\s*("|\')(.*?)\1/is', $tag, $ms)) {
131 $src = trim(html_entity_decode($ms[2], ENT_QUOTES, 'UTF-8'));
132 }
133 // Accepted: https://... , protocol relative //... , inline data:
134 if ($src === '' || (!preg_match('#^(https:)?//#i', $src) && strpos($src, 'data:') !== 0)) {
135 $hasnonabsolute = true;
136 }
137 if (strpos($src, 'data:') === 0) {
138 $hasheavyimg = true; // inline base64 is reloaded in every single mail
139 }
140 if (!preg_match('/\salt\s*=\s*("|\')(.*?)\1/is', $tag) && !preg_match('/\salt\s*=\s*[^"\'\s>]+/i', $tag)) {
141 $nbimgnoalt++;
142 }
143 if (preg_match('/\swidth\s*=\s*("|\')?\s*(\d+)/i', $tag, $mw) && (int) $mw[2] > $maximgwidth) {
144 $hasheavyimg = true;
145 }
146 if (preg_match('/width\s*:\s*(\d+)\s*px/i', $tag, $mw2) && (int) $mw2[1] > $maximgwidth) {
147 $hasheavyimg = true;
148 }
149 }
150
151 if ($hasnonabsolute) {
152 $set('non_absolute_url', 'error', $w['non_absolute_url']);
153 }
154
155 // Balance between real text and images
156 if ($nbimg > 0) {
157 $ratio = $textlen / $nbimg;
158 if ($textlen < 10 || $ratio < 20) {
159 $set('text_image_ratio', 'error', $w['text_image_ratio_error']);
160 } elseif ($ratio < $mincharsperimg) {
161 $set('text_image_ratio', 'warning', $w['text_image_ratio_warning']);
162 }
163 }
164
165 // Too many images
166 if ($nbimg > 2 * $maximg) {
167 $set('image_count', 'error', $w['image_count_error']);
168 } elseif ($nbimg > $maximg) {
169 $set('image_count', 'warning', $w['image_count_warning']);
170 }
171
172 // Images without alt text
173 if ($nbimgnoalt > 0) {
174 $set('image_alt', 'warning', min($w['image_alt_cap'], $nbimgnoalt * $w['image_alt_per_image']));
175 }
176
177 // Heavy / oversized images
178 if ($hasheavyimg) {
179 $set('image_weight', 'warning', $w['image_weight']);
180 }
181
182 // Too many links
183 $nblinks = preg_match_all('/<a\b[^>]*\shref\s*=/i', $html);
184 if ($nblinks > $maxlinks) {
185 $set('link_count', 'warning', $w['link_count']);
186 }
187
188 // Overly long text
189 if ($textlen > $maxtextlen) {
190 $set('text_length', 'warning', $w['text_length']);
191 }
192
193 // No reachable contact detail
194 $hascontact = (preg_match('/mailto:/i', $html) || preg_match('/tel:/i', $html)
195 || preg_match('/[\w.+-]+@[\w-]+\.[\w.-]+/', $text) || preg_match('/[\w.+-]+@[\w-]+\.[\w.-]+/', $html));
196 if (!$hascontact) {
197 $set('contact_info', 'warning', $w['contact_info']);
198 }
199
200 // Oversized fixed layout width (breaks on mobile)
201 $fixedwidth = false;
202 if (preg_match_all('/<(?:table|td|div)\b[^>]*\swidth\s*=\s*("|\')?\s*(\d+)/i', $html, $mfw)) {
203 foreach ($mfw[2] as $wv) {
204 if ((int) $wv > $maximgwidth) {
205 $fixedwidth = true;
206 break;
207 }
208 }
209 }
210 if ($fixedwidth) {
211 $set('fixed_width', 'warning', $w['fixed_width']);
212 }
213
214 if ($insufficientcontent) {
215 $score = min($score, $insufficientcontentcap);
216 }
217
218 $score = max(0, min(100, $score));
219
220 if ($score >= 85) {
221 $grade = 'excellent';
222 } elseif ($score >= 65) {
223 $grade = 'good';
224 } elseif ($score >= 40) {
225 $grade = 'fair';
226 } else {
227 $grade = 'poor';
228 }
229
230 return array('score' => (int) $score, 'grade' => $grade, 'checks' => $checks);
231}
232
233
242function dolGetSignatureQualityBadge($html, $outputlangs = null)
243{
244 global $langs;
245
246 if (!is_object($outputlangs)) {
247 $outputlangs = $langs;
248 }
249 $outputlangs->load('users');
250
251 if (trim((string) $html) === '') {
252 return '';
253 }
254
255 $res = dolCheckSignatureQuality($html);
256
257 $gradecolors = array(
258 'excellent' => '#1a9850',
259 'good' => '#66bd63',
260 'fair' => '#f46d43',
261 'poor' => '#d73027',
262 );
263 $color = isset($gradecolors[$res['grade']]) ? $gradecolors[$res['grade']] : '#999999';
264 $gradelabel = $outputlangs->trans('SignatureQuality'.ucfirst($res['grade']));
265
266 $out = '<div class="signaturequalityscore paddingtop">';
267 $out .= '<span class="badge" style="background-color: '.$color.';" title="'.dol_escape_htmltag($outputlangs->trans('SignatureQualityTooltip')).'">';
268 $out .= dol_escape_htmltag($outputlangs->trans('SignatureQuality')).': '.dol_escape_htmltag($gradelabel).' &mdash; '.((int) $res['score']).'/100';
269 $out .= '</span>';
270
271 $issues = array();
272 foreach ($res['checks'] as $code => $check) {
273 if ($check['status'] === 'ok') {
274 continue;
275 }
276 $key = 'SignatureCheck'.str_replace(' ', '', ucwords(str_replace('_', ' ', $code)));
277 $advkey = 'SignatureAdvice'.str_replace(' ', '', ucwords(str_replace('_', ' ', $code)));
278 $icon = ($check['status'] === 'error' ? img_picto('', 'error') : img_picto('', 'warning'));
279 $issues[] = '<li>'.$icon.' <strong>'.dol_escape_htmltag($outputlangs->trans($key)).'</strong> &mdash; '.dol_escape_htmltag($outputlangs->trans($advkey)).'</li>';
280 }
281
282 if (count($issues)) {
283 $out .= ' <details class="signaturequalitydetails inline-block">';
284 $out .= '<summary class="cursorpointer">'.dol_escape_htmltag($outputlangs->trans('SignatureQualityDetails')).'</summary>';
285 $out .= '<ul class="nomargin">'.implode('', $issues).'</ul>';
286 $out .= '</details>';
287 } else {
288 $out .= ' <span class="opacitymedium">'.dol_escape_htmltag($outputlangs->trans('SignatureQualityNoIssue')).'</span>';
289 }
290
291 $out .= '</div>';
292
293 return $out;
294}
dolCheckSignatureQuality($html)
Analyze an HTML email signature and return a quality score (0..100) against common emailing best prac...
dolGetSignatureQualityBadge($html, $outputlangs=null)
Build a ready to print HTML badge summarising the quality of an email signature, with a collapsible l...
dol_string_nohtmltag($stringtoclean, $removelinefeed=1, $pagecodeto='UTF-8', $strip_tags=0, $removedoublespaces=1)
Clean a string from all HTML tags and entities.
getDolGlobalInt($key, $default=0)
Return a Dolibarr global constant int value.
dol_escape_htmltag($stringtoescape, $keepb=0, $keepn=0, $noescapetags='', $escapeonlyhtmltags=0, $cleanalsojavascript=0)
Definition html.lib.php:181