173dc0a89SAndreas Gohr<?php 273dc0a89SAndreas Gohr 373dc0a89SAndreas Gohrnamespace dokuwiki; 473dc0a89SAndreas Gohr 573dc0a89SAndreas Gohruse dokuwiki\Utf8\Conversion; 673dc0a89SAndreas Gohr 773dc0a89SAndreas Gohr/** 873dc0a89SAndreas Gohr * Stateless email-address utilities: obfuscation, validation, and quoted-printable body encoding. 973dc0a89SAndreas Gohr */ 1073dc0a89SAndreas Gohrclass MailUtils 1173dc0a89SAndreas Gohr{ 1273dc0a89SAndreas Gohr /** 1373dc0a89SAndreas Gohr * RFC 2822 atext characters (paras 3.4.1 & 3.2.4). 1473dc0a89SAndreas Gohr * 1573dc0a89SAndreas Gohr * NOTE: the unquoted '/' must remain unquoted to be usable as part of a 1673dc0a89SAndreas Gohr * Lexer pattern; pick the surrounding pattern delimiters with care. 1773dc0a89SAndreas Gohr */ 1873dc0a89SAndreas Gohr public const RFC2822_ATEXT = "0-9a-zA-Z!#$%&'*+/=?^_`{|}~-"; 1973dc0a89SAndreas Gohr 2073dc0a89SAndreas Gohr /** 2173dc0a89SAndreas Gohr * Pattern for use in email detection and validation. 2273dc0a89SAndreas Gohr * 2373dc0a89SAndreas Gohr * Uses non-capturing groups since the parser does not allow captures. 24*b22130d2SAndreas Gohr * 25*b22130d2SAndreas Gohr * The dot-separated groups are possessive: an atext run stops at the 26*b22130d2SAndreas Gohr * next dot or the @, and a domain label always ends in a dot, so neither 27*b22130d2SAndreas Gohr * group can over-consume and neither ever needs to backtrack. A plain 28*b22130d2SAndreas Gohr * quantifier here is a ReDoS vector: a long `a.a.a.a…` local part or 29*b22130d2SAndreas Gohr * `a.a.a.a…` domain makes the non-JIT PCRE engine retain one 30*b22130d2SAndreas Gohr * backtracking frame per segment before the match ultimately fails. 3173dc0a89SAndreas Gohr */ 3273dc0a89SAndreas Gohr public const PREG_PATTERN_VALID_EMAIL = 33*b22130d2SAndreas Gohr '[' . self::RFC2822_ATEXT . ']+(?:\.[' . self::RFC2822_ATEXT . ']+)*+' 34*b22130d2SAndreas Gohr . '@(?i:[0-9a-z][0-9a-z-]*\.)++(?i:[a-z]{2,63})'; 3573dc0a89SAndreas Gohr 3673dc0a89SAndreas Gohr // region email-address obfuscation 3773dc0a89SAndreas Gohr 3873dc0a89SAndreas Gohr /** 3973dc0a89SAndreas Gohr * Return an obfuscated email address suitable for HTML text content 4073dc0a89SAndreas Gohr * (link labels, titles). 4173dc0a89SAndreas Gohr * 4273dc0a89SAndreas Gohr * The caller MUST pass a raw, unescaped string; the result is 4373dc0a89SAndreas Gohr * HTML-text-safe. Any query string after the first '?' is preserved 4473dc0a89SAndreas Gohr * verbatim and is never run through the [at]/[dot]/[dash] substitution, 4573dc0a89SAndreas Gohr * so dots and dashes inside body/subject values stay intact. 4673dc0a89SAndreas Gohr * 4773dc0a89SAndreas Gohr * @param string $email raw email address, optionally followed by ?query 4873dc0a89SAndreas Gohr * @return string HTML-text-safe representation 4973dc0a89SAndreas Gohr */ 5073dc0a89SAndreas Gohr public static function obfuscate(string $email): string 5173dc0a89SAndreas Gohr { 5273dc0a89SAndreas Gohr global $conf; 5373dc0a89SAndreas Gohr 5473dc0a89SAndreas Gohr [$addr, $query] = sexplode('?', $email, 2); 5573dc0a89SAndreas Gohr $out = self::obfuscateAddress($addr); 5673dc0a89SAndreas Gohr // 'hex' output is already pure ASCII numeric entities → HTML-safe. 5773dc0a89SAndreas Gohr // For 'none'/'visible' the address half still needs HTML escaping. 5873dc0a89SAndreas Gohr if ($conf['mailguard'] !== 'hex') { 5973dc0a89SAndreas Gohr $out = htmlspecialchars($out, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); 6073dc0a89SAndreas Gohr } 6173dc0a89SAndreas Gohr if ($query !== null) { 6273dc0a89SAndreas Gohr $out .= '?' . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); 6373dc0a89SAndreas Gohr } 6473dc0a89SAndreas Gohr return $out; 6573dc0a89SAndreas Gohr } 6673dc0a89SAndreas Gohr 6773dc0a89SAndreas Gohr /** 6873dc0a89SAndreas Gohr * Return an obfuscated email address suitable for use as a mailto: href 6973dc0a89SAndreas Gohr * value (HTML attribute context). 7073dc0a89SAndreas Gohr * 7173dc0a89SAndreas Gohr * Like obfuscate() but for HTML attribute context. The caller MUST pass a 7273dc0a89SAndreas Gohr * raw, unescaped string. The address half is obfuscated per the mailguard 7373dc0a89SAndreas Gohr * setting; in 'visible' mode the address (with its [at]/[dot] spaces) is 7473dc0a89SAndreas Gohr * percent-encoded so the URL is well-formed. The query string is 7573dc0a89SAndreas Gohr * preserved verbatim with only HTML-attribute escaping applied, so mail 7673dc0a89SAndreas Gohr * clients receive correct subject/body separators. 7773dc0a89SAndreas Gohr * 7873dc0a89SAndreas Gohr * @param string $email raw email address, optionally followed by ?query 7973dc0a89SAndreas Gohr * @return string HTML-attribute-safe URL fragment (without 'mailto:' prefix) 8073dc0a89SAndreas Gohr */ 8173dc0a89SAndreas Gohr public static function obfuscateUrl(string $email): string 8273dc0a89SAndreas Gohr { 8373dc0a89SAndreas Gohr global $conf; 8473dc0a89SAndreas Gohr 8573dc0a89SAndreas Gohr [$addr, $query] = sexplode('?', $email, 2); 8673dc0a89SAndreas Gohr $addr = self::obfuscateAddress($addr); 8773dc0a89SAndreas Gohr if ($conf['mailguard'] === 'visible') { 8873dc0a89SAndreas Gohr $addr = rawurlencode($addr); 8973dc0a89SAndreas Gohr } 9073dc0a89SAndreas Gohr if ($conf['mailguard'] !== 'hex') { 9173dc0a89SAndreas Gohr $addr = htmlspecialchars($addr, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); 9273dc0a89SAndreas Gohr } 9373dc0a89SAndreas Gohr if ($query !== null) { 9473dc0a89SAndreas Gohr $addr .= '?' . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); 9573dc0a89SAndreas Gohr } 9673dc0a89SAndreas Gohr return $addr; 9773dc0a89SAndreas Gohr } 9873dc0a89SAndreas Gohr 9973dc0a89SAndreas Gohr /** 10073dc0a89SAndreas Gohr * Apply the configured mailguard mode to the address half of a mailto 10173dc0a89SAndreas Gohr * target. Returns hex-mode output as numeric entities (HTML-safe); 10273dc0a89SAndreas Gohr * visible/none modes return raw text that still needs HTML escaping. 10373dc0a89SAndreas Gohr * 10473dc0a89SAndreas Gohr * @param string $addr raw local@domain 10573dc0a89SAndreas Gohr * @return string 10673dc0a89SAndreas Gohr */ 10773dc0a89SAndreas Gohr protected static function obfuscateAddress(string $addr): string 10873dc0a89SAndreas Gohr { 10973dc0a89SAndreas Gohr global $conf; 11073dc0a89SAndreas Gohr 11173dc0a89SAndreas Gohr return match ($conf['mailguard']) { 11273dc0a89SAndreas Gohr 'visible' => strtr($addr, ['@' => ' [at] ', '.' => ' [dot] ', '-' => ' [dash] ']), 11373dc0a89SAndreas Gohr 'hex' => Conversion::toHtml($addr, true), 11473dc0a89SAndreas Gohr default => $addr, 11573dc0a89SAndreas Gohr }; 11673dc0a89SAndreas Gohr } 11773dc0a89SAndreas Gohr 11873dc0a89SAndreas Gohr // endregion 11973dc0a89SAndreas Gohr // region outgoing-mail helpers 12073dc0a89SAndreas Gohr 12173dc0a89SAndreas Gohr /** 12273dc0a89SAndreas Gohr * Check if a given mail address is valid. 12373dc0a89SAndreas Gohr * 12473dc0a89SAndreas Gohr * @param string $email the address to check 12573dc0a89SAndreas Gohr * @return bool true if address is valid 12673dc0a89SAndreas Gohr */ 12773dc0a89SAndreas Gohr public static function isValid(string $email): bool 12873dc0a89SAndreas Gohr { 12973dc0a89SAndreas Gohr return \EmailAddressValidator::checkEmailAddress($email, true); 13073dc0a89SAndreas Gohr } 13173dc0a89SAndreas Gohr 13273dc0a89SAndreas Gohr /** 13373dc0a89SAndreas Gohr * RFC 2045 quoted-printable encoding. 13473dc0a89SAndreas Gohr * 13573dc0a89SAndreas Gohr * @param string $sText 13673dc0a89SAndreas Gohr * @param int $maxlen 13773dc0a89SAndreas Gohr * @param bool $bEmulate_imap_8bit 13873dc0a89SAndreas Gohr * @return string 13973dc0a89SAndreas Gohr * @author umu <umuAThrz.tu-chemnitz.de> 14073dc0a89SAndreas Gohr * @link http://php.net/manual/en/function.imap-8bit.php#61216 14173dc0a89SAndreas Gohr * 14273dc0a89SAndreas Gohr */ 14373dc0a89SAndreas Gohr public static function quotedPrintableEncode( 14473dc0a89SAndreas Gohr string $sText, 14573dc0a89SAndreas Gohr int $maxlen = 74, 14673dc0a89SAndreas Gohr bool $bEmulate_imap_8bit = true 147c959e5abSsplitbrain ): string { 14873dc0a89SAndreas Gohr // split text into lines 14973dc0a89SAndreas Gohr $aLines = preg_split("/(?:\r\n|\r|\n)/", $sText); 15073dc0a89SAndreas Gohr $cnt = count($aLines); 15173dc0a89SAndreas Gohr 15273dc0a89SAndreas Gohr for ($i = 0; $i < $cnt; $i++) { 15373dc0a89SAndreas Gohr $sLine =& $aLines[$i]; 15473dc0a89SAndreas Gohr if ($sLine === '') continue; // do nothing, if empty 15573dc0a89SAndreas Gohr 15673dc0a89SAndreas Gohr $sRegExp = '/[^\x09\x20\x21-\x3C\x3E-\x7E]/e'; 15773dc0a89SAndreas Gohr 15873dc0a89SAndreas Gohr // imap_8bit encodes x09 everywhere, not only at lineends, 15973dc0a89SAndreas Gohr // for EBCDIC safeness encode !"#$@[\]^`{|}~, 16073dc0a89SAndreas Gohr // for complete safeness encode every character :) 16173dc0a89SAndreas Gohr if ($bEmulate_imap_8bit) 16273dc0a89SAndreas Gohr $sRegExp = '/[^\x20\x21-\x3C\x3E-\x7E]/'; 16373dc0a89SAndreas Gohr 16473dc0a89SAndreas Gohr $sLine = preg_replace_callback( 16573dc0a89SAndreas Gohr $sRegExp, 16673dc0a89SAndreas Gohr static fn(array $matches): string => sprintf("=%02X", ord($matches[0])), 16773dc0a89SAndreas Gohr $sLine 16873dc0a89SAndreas Gohr ); 16973dc0a89SAndreas Gohr 17073dc0a89SAndreas Gohr // encode x09,x20 at lineends 17173dc0a89SAndreas Gohr $iLength = strlen($sLine); 17273dc0a89SAndreas Gohr $iLastChar = ord($sLine[$iLength - 1]); 17373dc0a89SAndreas Gohr 17473dc0a89SAndreas Gohr // imap_8_bit does not encode x20 at the very end of a text, 17573dc0a89SAndreas Gohr // here is, where I don't agree with imap_8_bit, 17673dc0a89SAndreas Gohr // please correct me, if I'm wrong, 17773dc0a89SAndreas Gohr // or comment next line for RFC2045 conformance, if you like 17873dc0a89SAndreas Gohr if (!($bEmulate_imap_8bit && ($i == count($aLines) - 1))) { 17973dc0a89SAndreas Gohr if (($iLastChar == 0x09) || ($iLastChar == 0x20)) { 18073dc0a89SAndreas Gohr $sLine[$iLength - 1] = '='; 18173dc0a89SAndreas Gohr $sLine .= ($iLastChar == 0x09) ? '09' : '20'; 18273dc0a89SAndreas Gohr } 18373dc0a89SAndreas Gohr } 18473dc0a89SAndreas Gohr 18573dc0a89SAndreas Gohr // imap_8bit encodes x20 before chr(13), too 18673dc0a89SAndreas Gohr // although IMHO not requested by RFC2045, why not do it safer :) 18773dc0a89SAndreas Gohr // and why not encode any x20 around chr(10) or chr(13) 18873dc0a89SAndreas Gohr if ($bEmulate_imap_8bit) { 18973dc0a89SAndreas Gohr $sLine = str_replace(' =0D', '=20=0D', $sLine); 19073dc0a89SAndreas Gohr //$sLine=str_replace(' =0A','=20=0A',$sLine); 19173dc0a89SAndreas Gohr //$sLine=str_replace('=0D ','=0D=20',$sLine); 19273dc0a89SAndreas Gohr //$sLine=str_replace('=0A ','=0A=20',$sLine); 19373dc0a89SAndreas Gohr } 19473dc0a89SAndreas Gohr 19573dc0a89SAndreas Gohr // finally split into softlines no longer than $maxlen chars, 19673dc0a89SAndreas Gohr // for even more safeness one could encode x09,x20 19773dc0a89SAndreas Gohr // at the very first character of the line 19873dc0a89SAndreas Gohr // and after soft linebreaks, as well, 19973dc0a89SAndreas Gohr // but this wouldn't be caught by such an easy RegExp 20073dc0a89SAndreas Gohr if ($maxlen) { 20173dc0a89SAndreas Gohr preg_match_all('/.{1,' . ($maxlen - 2) . '}([^=]{0,2})?/', $sLine, $aMatch); 20273dc0a89SAndreas Gohr $sLine = implode('=' . MAILHEADER_EOL, $aMatch[0]); // add soft crlf's 20373dc0a89SAndreas Gohr } 20473dc0a89SAndreas Gohr } 20573dc0a89SAndreas Gohr 20673dc0a89SAndreas Gohr // join lines into text 20773dc0a89SAndreas Gohr return implode(MAILHEADER_EOL, $aLines); 20873dc0a89SAndreas Gohr } 20973dc0a89SAndreas Gohr 21073dc0a89SAndreas Gohr // endregion 21173dc0a89SAndreas Gohr} 212