xref: /dokuwiki/inc/MailUtils.php (revision b22130d2b9e76b1247849cd618b2bcf664ee020f)
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