1<?php 2 3namespace dokuwiki; 4 5use dokuwiki\Utf8\Conversion; 6 7/** 8 * Stateless email-address utilities: obfuscation, validation, and quoted-printable body encoding. 9 */ 10class MailUtils 11{ 12 /** 13 * RFC 2822 atext characters (paras 3.4.1 & 3.2.4). 14 * 15 * NOTE: the unquoted '/' must remain unquoted to be usable as part of a 16 * Lexer pattern; pick the surrounding pattern delimiters with care. 17 */ 18 public const RFC2822_ATEXT = "0-9a-zA-Z!#$%&'*+/=?^_`{|}~-"; 19 20 /** 21 * Pattern for use in email detection and validation. 22 * 23 * Uses non-capturing groups since the parser does not allow captures. 24 * 25 * The dot-separated groups are possessive: an atext run stops at the 26 * next dot or the @, and a domain label always ends in a dot, so neither 27 * group can over-consume and neither ever needs to backtrack. A plain 28 * quantifier here is a ReDoS vector: a long `a.a.a.a…` local part or 29 * `a.a.a.a…` domain makes the non-JIT PCRE engine retain one 30 * backtracking frame per segment before the match ultimately fails. 31 */ 32 public const PREG_PATTERN_VALID_EMAIL = 33 '[' . self::RFC2822_ATEXT . ']+(?:\.[' . self::RFC2822_ATEXT . ']+)*+' 34 . '@(?i:[0-9a-z][0-9a-z-]*\.)++(?i:[a-z]{2,63})'; 35 36 // region email-address obfuscation 37 38 /** 39 * Return an obfuscated email address suitable for HTML text content 40 * (link labels, titles). 41 * 42 * The caller MUST pass a raw, unescaped string; the result is 43 * HTML-text-safe. Any query string after the first '?' is preserved 44 * verbatim and is never run through the [at]/[dot]/[dash] substitution, 45 * so dots and dashes inside body/subject values stay intact. 46 * 47 * @param string $email raw email address, optionally followed by ?query 48 * @return string HTML-text-safe representation 49 */ 50 public static function obfuscate(string $email): string 51 { 52 global $conf; 53 54 [$addr, $query] = sexplode('?', $email, 2); 55 $out = self::obfuscateAddress($addr); 56 // 'hex' output is already pure ASCII numeric entities → HTML-safe. 57 // For 'none'/'visible' the address half still needs HTML escaping. 58 if ($conf['mailguard'] !== 'hex') { 59 $out = htmlspecialchars($out, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); 60 } 61 if ($query !== null) { 62 $out .= '?' . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); 63 } 64 return $out; 65 } 66 67 /** 68 * Return an obfuscated email address suitable for use as a mailto: href 69 * value (HTML attribute context). 70 * 71 * Like obfuscate() but for HTML attribute context. The caller MUST pass a 72 * raw, unescaped string. The address half is obfuscated per the mailguard 73 * setting; in 'visible' mode the address (with its [at]/[dot] spaces) is 74 * percent-encoded so the URL is well-formed. The query string is 75 * preserved verbatim with only HTML-attribute escaping applied, so mail 76 * clients receive correct subject/body separators. 77 * 78 * @param string $email raw email address, optionally followed by ?query 79 * @return string HTML-attribute-safe URL fragment (without 'mailto:' prefix) 80 */ 81 public static function obfuscateUrl(string $email): string 82 { 83 global $conf; 84 85 [$addr, $query] = sexplode('?', $email, 2); 86 $addr = self::obfuscateAddress($addr); 87 if ($conf['mailguard'] === 'visible') { 88 $addr = rawurlencode($addr); 89 } 90 if ($conf['mailguard'] !== 'hex') { 91 $addr = htmlspecialchars($addr, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); 92 } 93 if ($query !== null) { 94 $addr .= '?' . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); 95 } 96 return $addr; 97 } 98 99 /** 100 * Apply the configured mailguard mode to the address half of a mailto 101 * target. Returns hex-mode output as numeric entities (HTML-safe); 102 * visible/none modes return raw text that still needs HTML escaping. 103 * 104 * @param string $addr raw local@domain 105 * @return string 106 */ 107 protected static function obfuscateAddress(string $addr): string 108 { 109 global $conf; 110 111 return match ($conf['mailguard']) { 112 'visible' => strtr($addr, ['@' => ' [at] ', '.' => ' [dot] ', '-' => ' [dash] ']), 113 'hex' => Conversion::toHtml($addr, true), 114 default => $addr, 115 }; 116 } 117 118 // endregion 119 // region outgoing-mail helpers 120 121 /** 122 * Check if a given mail address is valid. 123 * 124 * @param string $email the address to check 125 * @return bool true if address is valid 126 */ 127 public static function isValid(string $email): bool 128 { 129 return \EmailAddressValidator::checkEmailAddress($email, true); 130 } 131 132 /** 133 * RFC 2045 quoted-printable encoding. 134 * 135 * @param string $sText 136 * @param int $maxlen 137 * @param bool $bEmulate_imap_8bit 138 * @return string 139 * @author umu <umuAThrz.tu-chemnitz.de> 140 * @link http://php.net/manual/en/function.imap-8bit.php#61216 141 * 142 */ 143 public static function quotedPrintableEncode( 144 string $sText, 145 int $maxlen = 74, 146 bool $bEmulate_imap_8bit = true 147 ): string { 148 // split text into lines 149 $aLines = preg_split("/(?:\r\n|\r|\n)/", $sText); 150 $cnt = count($aLines); 151 152 for ($i = 0; $i < $cnt; $i++) { 153 $sLine =& $aLines[$i]; 154 if ($sLine === '') continue; // do nothing, if empty 155 156 $sRegExp = '/[^\x09\x20\x21-\x3C\x3E-\x7E]/e'; 157 158 // imap_8bit encodes x09 everywhere, not only at lineends, 159 // for EBCDIC safeness encode !"#$@[\]^`{|}~, 160 // for complete safeness encode every character :) 161 if ($bEmulate_imap_8bit) 162 $sRegExp = '/[^\x20\x21-\x3C\x3E-\x7E]/'; 163 164 $sLine = preg_replace_callback( 165 $sRegExp, 166 static fn(array $matches): string => sprintf("=%02X", ord($matches[0])), 167 $sLine 168 ); 169 170 // encode x09,x20 at lineends 171 $iLength = strlen($sLine); 172 $iLastChar = ord($sLine[$iLength - 1]); 173 174 // imap_8_bit does not encode x20 at the very end of a text, 175 // here is, where I don't agree with imap_8_bit, 176 // please correct me, if I'm wrong, 177 // or comment next line for RFC2045 conformance, if you like 178 if (!($bEmulate_imap_8bit && ($i == count($aLines) - 1))) { 179 if (($iLastChar == 0x09) || ($iLastChar == 0x20)) { 180 $sLine[$iLength - 1] = '='; 181 $sLine .= ($iLastChar == 0x09) ? '09' : '20'; 182 } 183 } 184 185 // imap_8bit encodes x20 before chr(13), too 186 // although IMHO not requested by RFC2045, why not do it safer :) 187 // and why not encode any x20 around chr(10) or chr(13) 188 if ($bEmulate_imap_8bit) { 189 $sLine = str_replace(' =0D', '=20=0D', $sLine); 190 //$sLine=str_replace(' =0A','=20=0A',$sLine); 191 //$sLine=str_replace('=0D ','=0D=20',$sLine); 192 //$sLine=str_replace('=0A ','=0A=20',$sLine); 193 } 194 195 // finally split into softlines no longer than $maxlen chars, 196 // for even more safeness one could encode x09,x20 197 // at the very first character of the line 198 // and after soft linebreaks, as well, 199 // but this wouldn't be caught by such an easy RegExp 200 if ($maxlen) { 201 preg_match_all('/.{1,' . ($maxlen - 2) . '}([^=]{0,2})?/', $sLine, $aMatch); 202 $sLine = implode('=' . MAILHEADER_EOL, $aMatch[0]); // add soft crlf's 203 } 204 } 205 206 // join lines into text 207 return implode(MAILHEADER_EOL, $aLines); 208 } 209 210 // endregion 211} 212