1<?php
2// mailer.php -- HotCRP mail template manager
3// Copyright (c) 2006-2018 Eddie Kohler; see LICENSE.
4
5class MailPreparation {
6    public $conf;
7    public $subject = "";
8    public $body = "";
9    public $preparation_owner = "";
10    public $to = array();
11    public $sendable = false;
12    public $headers = array();
13    public $errors = array();
14    public $unique_preparation = false;
15
16    function __construct($conf) {
17        $this->conf = $conf;
18    }
19    function can_merge($p) {
20        return $this->subject == $p->subject
21            && $this->body == $p->body
22            && get($this->headers, "cc") == get($p->headers, "cc")
23            && get($this->headers, "reply-to") == get($p->headers, "reply-to")
24            && $this->preparation_owner == $p->preparation_owner
25            && !$this->unique_preparation
26            && !$p->unique_preparation;
27    }
28    function add_recipients($to) {
29        if (count($to) != 1
30            || count($this->to) == 0
31            || $this->to[count($this->to) - 1] != $to[0])
32            $this->to = array_merge($this->to, $to);
33    }
34    function send() {
35        if ($this->conf->call_hooks("send_mail", null, $this) === false)
36            return false;
37
38        $headers = $this->headers;
39        $eol = Mailer::eol();
40
41        // create valid To: header
42        $to = $this->to;
43        if (is_array($to))
44            $to = join(", ", $to);
45        $to = MimeText::encode_email_header("To: ", $to);
46        $headers["to"] = $to . $eol;
47
48        // set sendmail parameters
49        $extra = $this->conf->opt("sendmailParam");
50        if (($sender = $this->conf->opt("emailSender")) !== null) {
51            @ini_set("sendmail_from", $sender);
52            if ($extra === null)
53                $extra = "-f" . escapeshellarg($sender);
54        }
55
56        if ($this->sendable
57            && $this->conf->opt("internalMailer", strncasecmp(PHP_OS, "WIN", 3) != 0)
58            && ($sendmail = ini_get("sendmail_path"))) {
59            $htext = join("", $headers);
60            $f = popen($extra ? "$sendmail $extra" : $sendmail, "wb");
61            fwrite($f, $htext . $eol . $this->body);
62            $status = pclose($f);
63            if (pcntl_wifexitedsuccess($status))
64                return true;
65            else {
66                $this->conf->set_opt("internalMailer", false);
67                error_log("Mail " . $headers["to"] . " failed to send, falling back (status $status)");
68            }
69        }
70
71        if ($this->sendable) {
72            if (strpos($to, $eol) === false) {
73                unset($headers["to"]);
74                $to = substr($to, 4); // skip "To: "
75            } else
76                $to = "";
77            unset($headers["subject"]);
78            $htext = substr(join("", $headers), 0, -2);
79            return mail($to, $this->subject, $this->body, $htext, $extra);
80
81        } else if (!$this->conf->opt("sendEmail")
82                   && !preg_match('/\Aanonymous\d*\z/', $to)) {
83            unset($headers["mime-version"], $headers["content-type"]);
84            $text = join("", $headers) . $eol . $this->body;
85            if (PHP_SAPI != "cli")
86                $this->conf->infoMsg("<pre>" . htmlspecialchars($text) . "</pre>");
87            else if (!$this->conf->opt("disablePrintEmail"))
88                fwrite(STDERR, "========================================\n" . str_replace("\r\n", "\n", $text) .  "========================================\n");
89            return null;
90        }
91    }
92}
93
94class Mailer {
95    const EXPAND_BODY = 0;
96    const EXPAND_HEADER = 1;
97    const EXPAND_EMAIL = 2;
98
99    public static $email_fields = array("to" => "To", "cc" => "Cc", "bcc" => "Bcc",
100                                        "reply-to" => "Reply-To");
101
102    public $conf;
103    public $recipient = null;
104
105    protected $width = 75;
106    protected $sensitivity = null;
107    protected $reason = null;
108    protected $adminupdate = null;
109    protected $notes = null;
110    protected $preparation = null;
111    public $capability = null;
112
113    protected $expansionType = null;
114
115    protected $_unexpanded = array();
116
117    static private $eol = null;
118
119    function __construct(Conf $conf, $recipient = null, $settings = array()) {
120        $this->conf = $conf;
121        $this->reset($recipient, $settings);
122    }
123
124    function reset($recipient = null, $settings = array()) {
125        $this->recipient = $recipient;
126        foreach (array("width", "sensitivity", "reason", "adminupdate", "notes",
127                       "capability") as $k)
128            $this->$k = get($settings, $k);
129        if ($this->width === null)
130            $this->width = 75;
131        else if (!$this->width)
132            $this->width = 10000000;
133    }
134
135    static function eol() {
136        global $Conf;
137        if (self::$eol === null) {
138            if (($x = $Conf->opt("postfixMailer", null)) === null)
139                $x = $Conf->opt("postfixEOL");
140            if (!$x)
141                self::$eol = "\r\n";
142            else if ($x === true || !is_string($x))
143                self::$eol = PHP_EOL;
144            else
145                self::$eol = $x;
146        }
147        return self::$eol;
148    }
149
150
151    function expand_user($contact, $out) {
152        $r = Text::analyze_name($contact);
153        if (is_object($contact) && get_s($contact, "preferredEmail") != "")
154            $r->email = $contact->preferredEmail;
155
156        // maybe infer username
157        if ($r->firstName == ""
158            && $r->lastName == ""
159            && is_object($contact)
160            && (get_s($contact, "email") !== ""
161                || get_s($contact, "preferredEmail") !== ""))
162            $this->infer_user_name($r, $contact);
163
164        if ($out == "NAME" || $out == "CONTACT")
165            $t = $r->name;
166        else if ($out == "FIRST")
167            $t = $r->firstName;
168        else if ($out == "LAST")
169            $t = $r->lastName;
170        else
171            $t = "";
172        if ($t == "" && $out == "NAME" && $r->email
173            && $this->expansionType != self::EXPAND_EMAIL)
174            $t = $r->email;
175        if ($t != "" && $this->expansionType == self::EXPAND_EMAIL
176            && preg_match('#[\000-\037()[\]<>@,;:\\".]#', $t))
177            $t = "\"" . addcslashes($t, '"\\') . "\"";
178
179        $email = $r->email;
180        if ($email == "" && $this->expansionType == self::EXPAND_EMAIL)
181            $email = "<none>";
182        if ($out == "EMAIL")
183            $t = $email;
184        else if ($out == "CONTACT" && $this->expansionType == self::EXPAND_EMAIL) {
185            if ($t == "")
186                $t = $email;
187            else if ($email[0] == "<")
188                $t .= " $email";
189            else
190                $t .= " <$email>";
191        } else if ($out == "CONTACT" && $email != "")
192            $t = ($t == "" ? $email : "$t <$email>");
193
194        return $t;
195    }
196
197    function infer_user_name($r, $contact) {
198    }
199
200
201    static function kw_null() {
202        return false;
203    }
204
205    function kw_opt($args, $isbool) {
206        $hasinner = $this->expandvar("%$args%", true);
207        if ($hasinner && !$isbool)
208            return $this->expandvar("%$args%", false);
209        else
210            return $hasinner;
211    }
212
213    static function kw_urlenc($args, $isbool, $m) {
214        $hasinner = $m->expandvar("%$args%", true);
215        if ($hasinner && !$isbool)
216            return urlencode($m->expandvar("%$args%", false));
217        else
218            return $hasinner;
219    }
220
221    static function kw_ims_expand($args, $isbool, $mx) {
222        preg_match('/\A\s*(.*?)\s*(?:|,\s*(\d+)\s*)\z/', $args, $m);
223        $t = $mx->conf->_c("mail", $m[1]);
224        if ($m[2] && strlen($t) < $m[2])
225            $t = str_repeat(" ", $m[2] - strlen($t)) . $t;
226        return $t;
227    }
228
229    function kw_confnames($args, $isbool, $uf) {
230        if ($uf->name === "CONFNAME")
231            return $this->conf->full_name();
232        else if ($uf->name == "CONFSHORTNAME")
233            return $this->conf->short_name;
234        else
235            return $this->conf->long_name;
236    }
237
238    function kw_siteuser($args, $isbool, $uf) {
239        return $this->expand_user($this->conf->site_contact(), $uf->userx);
240    }
241
242    static function kw_signature($args, $isbool, $m) {
243        return $m->conf->opt("emailSignature") ? : "- " . $m->conf->short_name . " Submissions";
244    }
245
246    static function kw_url($args, $isbool, $m) {
247        if (!$args)
248            return $m->conf->opt("paperSite");
249        else {
250            $a = preg_split('/\s*,\s*/', $args);
251            foreach ($a as &$t) {
252                $t = preg_replace('/\&(?=\&|\z)/', "", $m->expand($t, "urlpart"));
253            }
254            return hoturl_absolute_nodefaults($a[0], isset($a[1]) ? $a[1] : "");
255        }
256    }
257
258    static function kw_loginnotice($args, $isbool, $m) {
259        if ($m->conf->opt("disableCapabilities"))
260            return $m->expand($m->conf->opt("mailtool_loginNotice", " To sign in, either click the link below or paste it into your web browser's location field.\n\n%LOGINURL%"), $isbool);
261        else
262            return "";
263    }
264
265    static function kw_adminupdate($args, $isbool, $m) {
266        if ($m->adminupdate)
267            return "An administrator performed this update. ";
268        else
269            return $m->recipient ? "" : null;
270    }
271
272    static function kw_notes($args, $isbool, $m, $uf) {
273        $which = strtolower($uf->name);
274        $value = $m->$which;
275        if ($value !== null || $m->recipient)
276            return (string) $value;
277        else
278            return null;
279    }
280
281    static function kw_recipient($args, $isbool, $m, $uf) {
282        if ($m->preparation)
283            $m->preparation->preparation_owner = $m->recipient->email;
284        return $m->expand_user($m->recipient, $uf->userx);
285    }
286
287    static function kw_capability($args, $isbool, $m, $uf) {
288        return $isbool || $m->capability ? $m->capability : "";
289    }
290
291    function kw_login($args, $isbool, $uf) {
292        $external_password = $this->conf->external_login();
293        if (!$this->recipient)
294            return $external_password ? false : null;
295
296        $password = false;
297        if (!$external_password) {
298            $pwd_plaintext = $this->recipient->plaintext_password();
299            if ($pwd_plaintext && !$this->sensitivity)
300                $password = $pwd_plaintext;
301            else if ($pwd_plaintext && $this->sensitivity === "display")
302                $password = "HIDDEN";
303        }
304
305        $loginparts = "";
306        if (!$this->conf->opt("httpAuthLogin")) {
307            $loginparts = "email=" . urlencode($this->recipient->email);
308            if ($password)
309                $loginparts .= "&password=" . urlencode($password);
310        }
311
312        if ($uf->name === "LOGINURL")
313            return $this->conf->opt("paperSite") . ($loginparts ? "/?" . $loginparts : "/");
314        else if ($uf->name === "LOGINURLPARTS")
315            return $loginparts;
316        else
317            return $password;
318    }
319
320    function expandvar($what, $isbool = false) {
321        if (str_ends_with($what, ")%") && ($paren = strpos($what, "("))) {
322            $name = substr($what, 1, $paren - 1);
323            $args = substr($what, $paren + 1, strlen($what) - $paren - 3);
324        } else {
325            $name = substr($what, 1, strlen($what) - 2);
326            $args = "";
327        }
328
329        $mks = $this->conf->mail_keywords($name);
330        foreach ($mks as $uf) {
331            $ok = $this->recipient || (isset($uf->global) && $uf->global);
332            if ($ok && isset($uf->expand_if)) {
333                if (is_string($uf->expand_if)) {
334                    if ($uf->expand_if[0] === "*")
335                        $ok = call_user_func([$this, substr($uf->expand_if, 1)], $uf);
336                    else
337                        $ok = call_user_func($uf->expand_if, $this, $uf);
338                } else
339                    $ok = $uf->expand_if;
340            }
341
342            if (!$ok)
343                $x = null;
344            else if ($uf->callback[0] === "*")
345                $x = call_user_func([$this, substr($uf->callback, 1)], $args, $isbool, $uf);
346            else
347                $x = call_user_func($uf->callback, $args, $isbool, $this, $uf);
348
349            if ($x !== null)
350                return $isbool ? $x : (string) $x;
351        }
352
353        if ($isbool)
354            return $mks ? null : false;
355        else {
356            $this->_unexpanded[$what] = true;
357            return $what;
358        }
359    }
360
361
362    private function _pushIf(&$ifstack, $text, $yes) {
363        if ($yes !== false && $yes !== true && $yes !== null)
364            $yes = (bool) $yes;
365        if ($yes === true || $yes === null)
366            array_push($ifstack, $yes);
367        else
368            array_push($ifstack, $text);
369    }
370
371    private function _popIf(&$ifstack, &$text) {
372        if (count($ifstack) == 0)
373            return null;
374        else if (($pop = array_pop($ifstack)) === true || $pop === null)
375            return $pop;
376        else {
377            $text = $pop;
378            return false;
379        }
380    }
381
382    private function _handleIf(&$ifstack, &$text, $cond, $haselse) {
383        assert($cond || $haselse);
384        if ($haselse) {
385            $yes = $this->_popIf($ifstack, $text);
386            if ($yes !== null)
387                $yes = !$yes;
388        } else
389            $yes = true;
390        if ($yes && $cond)
391            $yes = $this->expandvar("%" . substr($cond, 1, strlen($cond) - 2) . "%", true);
392        $this->_pushIf($ifstack, $text, $yes);
393        return $yes;
394    }
395
396    private function _expandConditionals($rest) {
397        $text = "";
398        $ifstack = array();
399
400        while (preg_match('/\A(.*?)%(IF|ELSE?IF|ELSE|ENDIF)((?:\(#?[-a-zA-Z0-9!@_:.\/]+(?:\([-a-zA-Z0-9!@_:.\/]*+\))*\))?)%(.*)\z/s', $rest, $m)) {
401            $text .= $m[1];
402            $rest = $m[4];
403
404            if ($m[2] == "IF" && $m[3] != "")
405                $yes = $this->_handleIf($ifstack, $text, $m[3], false);
406            else if (($m[2] == "ELSIF" || $m[2] == "ELSEIF") && $m[3] != "")
407                $yes = $this->_handleIf($ifstack, $text, $m[3], true);
408            else if ($m[2] == "ELSE" && $m[3] == "")
409                $yes = $this->_handleIf($ifstack, $text, false, true);
410            else if ($m[2] == "ENDIF" && $m[3] == "")
411                $yes = $this->_popIf($ifstack, $text);
412            else
413                $yes = null;
414
415            if ($yes === null)
416                $text .= "%" . $m[2] . $m[3] . "%";
417        }
418
419        return $text . $rest;
420    }
421
422    private function _lineexpand($line, $info, $indent, $width) {
423        $text = "";
424        while (preg_match('/^(.*?)(%#?[-a-zA-Z0-9!@_:.\/]+(?:|\([^\)]*\))%)(.*)$/s', $line, $m)) {
425            $text .= $m[1] . $this->expandvar($m[2], false);
426            $line = $m[3];
427        }
428        $text .= $line;
429        return prefix_word_wrap($info, $text, $indent, $width);
430    }
431
432    function expand($text, $field = null) {
433        if (is_array($text)) {
434            foreach ($text as $k => &$t)
435                $t = $this->expand($t, $k);
436            return $text;
437        }
438
439        // leave early on empty string
440        if ($text == "")
441            return "";
442
443        // width, expansion type based on field
444        $oldExpansionType = $this->expansionType;
445        $width = 100000;
446        if ($field == "to" || $field == "cc" || $field == "bcc"
447            || $field == "reply-to")
448            $this->expansionType = self::EXPAND_EMAIL;
449        else if ($field != "body" && $field != "")
450            $this->expansionType = self::EXPAND_HEADER;
451        else {
452            $this->expansionType = self::EXPAND_BODY;
453            $width = $this->width;
454        }
455
456        // expand out %IF% and %ELSE% and %ENDIF%.  Need to do this first,
457        // or we get confused with wordwrapping.
458        $text = $this->_expandConditionals(cleannl($text));
459
460        // separate text into lines
461        $lines = explode("\n", $text);
462        if (count($lines) && $lines[count($lines) - 1] === "")
463            array_pop($lines);
464
465        $text = "";
466        $textstart = 0;
467        for ($i = 0; $i < count($lines); ++$i) {
468            $line = rtrim($lines[$i]);
469            if ($line == "")
470                $text .= "\n";
471            else if (preg_match('/\A%(?:REVIEWS|COMMENTS)(?:[(].*[)])?%\z/s', $line)) {
472                if (($m = $this->expandvar($line, false)) != "")
473                    $text .= $m . "\n";
474            } else if (strpos($line, "%") === false)
475                $text .= prefix_word_wrap("", $line, 0, $width);
476            else {
477                if ($line[0] === " " || $line[0] === "\t") {
478                    if (preg_match('/\A([ \t]*)(%\w+(?:|\([^\)]*\))%)(:.*)\z/s', $line, $m)
479                        && $this->expandvar($m[2], true))
480                        $line = $m[1] . $this->expandvar($m[2]) . $m[3];
481                    if (preg_match('/\A([ \t]*.*?: )(%\w+(?:|\([^\)]*\))%|\S+)\s*\z/s', $line, $m)
482                        && ($tl = tabLength($m[1], true)) <= 20) {
483                        if (str_starts_with($m[2], "%OPT(")) {
484                            if (($yes = $this->expandvar($m[2], true)))
485                                $text .= prefix_word_wrap($m[1], $this->expandvar($m[2]), $tl, $width);
486                            else if ($yes === null)
487                                $text .= $line . "\n";
488                        } else
489                            $text .= $this->_lineexpand($m[2], $m[1], $tl, $width);
490                        continue;
491                    }
492                }
493                $text .= $this->_lineexpand($line, "", 0, $width);
494            }
495        }
496
497        // lose newlines on header expansion
498        if ($this->expansionType != self::EXPAND_BODY)
499            $text = rtrim(preg_replace('/[\r\n\f\x0B]+/', ' ', $text));
500
501        $this->expansionType = $oldExpansionType;
502        return $text;
503    }
504
505
506    static function get_template($templateName, $default = false) {
507        global $Conf, $mailTemplates;
508        $mail = $mailTemplates[$templateName];
509        if (!$default && $Conf) {
510            if (($t = $Conf->setting_data("mailsubj_" . $templateName, false)) !== false)
511                $mail["subject"] = $t;
512            if (($t = $Conf->setting_data("mailbody_" . $templateName, false)) !== false)
513                $mail["body"] = $t;
514        }
515        return $mail;
516    }
517
518    function expand_template($templateName, $default = false) {
519        return $this->expand(self::get_template($templateName, $default));
520    }
521
522    static function is_template($template) {
523        global $mailTemplates;
524        return (is_array($template)
525                && is_string(get($template, "subject"))
526                && is_string(get($template, "body")))
527            || (is_string($template)
528                && $template[0] === "@"
529                && isset($mailTemplates[substr($template, 1)]));
530    }
531
532
533    static function allow_send($email) {
534        global $Conf;
535        return $Conf->opt("sendEmail")
536            && ($at = strpos($email, "@")) !== false
537            && ((($ch = $email[$at + 1]) !== "_" && $ch !== "e" && $ch !== "E")
538                || !preg_match(';\A(?:_.*|example\.(?:com|net|org))\z;i', substr($email, $at + 1)));
539    }
540
541    function create_preparation() {
542        return new MailPreparation($this->conf);
543    }
544
545    function make_preparation($template, $rest = array()) {
546        // look up template
547        if (is_string($template) && $template[0] === "@")
548            $template = self::get_template(substr($template, 1));
549        // add rest fields to template for expansion
550        foreach (self::$email_fields as $lcfield => $field)
551            if (isset($rest[$lcfield]))
552                $template[$lcfield] = $rest[$lcfield];
553
554        // expand the template
555        $prep = $this->preparation = $this->create_preparation();
556        $mail = $this->expand($template);
557        $this->preparation = null;
558
559        $subject = MimeText::encode_header("Subject: ", $mail["subject"]);
560        $prep->subject = substr($subject, 9);
561
562        $prep->body = $mail["body"];
563
564        // look up recipient; use preferredEmail if set
565        $recipient = $this->recipient;
566        if (!$recipient || !$recipient->email)
567            return Conf::msg_error("no email in Mailer::send");
568        if (get($recipient, "preferredEmail")) {
569            $recipient = (object) array("email" => $recipient->preferredEmail);
570            foreach (array("firstName", "lastName", "name", "fullName") as $k)
571                if (get($this->recipient, $k))
572                    $recipient->$k = $this->recipient->$k;
573        }
574        $prep->to = array(Text::user_email_to($recipient));
575        $mail["to"] = $prep->to[0];
576        $prep->sendable = self::allow_send($recipient->email);
577
578        // parse headers
579        $fromHeader = $this->conf->opt("emailFromHeader");
580        if ($fromHeader === null) {
581            $fromHeader = MimeText::encode_email_header("From: ", $this->conf->opt("emailFrom"));
582            $this->conf->set_opt("emailFromHeader", $fromHeader);
583        }
584        $eol = self::eol();
585        $prep->headers = [];
586        if ($fromHeader)
587            $prep->headers["from"] = $fromHeader . $eol;
588        $prep->headers["subject"] = $subject . $eol;
589        $prep->headers["to"] = "";
590        foreach (self::$email_fields as $lcfield => $field)
591            if (($text = get_s($mail, $lcfield)) !== "" && $text !== "<none>") {
592                if (($hdr = MimeText::encode_email_header($field . ": ", $text)))
593                    $prep->headers[$lcfield] = $hdr . $eol;
594                else {
595                    $prep->errors[$lcfield] = $text;
596                    if (!get($rest, "no_error_quit"))
597                        Conf::msg_error("$field destination “<samp>" . htmlspecialchars($text) . "</samp>” isn't a valid email list.");
598                }
599            }
600        $prep->headers["mime-version"] = "MIME-Version: 1.0" . $eol;
601        $prep->headers["content-type"] = "Content-Type: text/plain; charset=utf-8" . $eol;
602
603        if ($prep->errors && !get($rest, "no_error_quit"))
604            return false;
605        else
606            return $prep;
607    }
608
609    static function send_combined_preparations($preps) {
610        $last_p = null;
611        foreach ($preps as $p) {
612            if ($last_p && $last_p->can_merge($p))
613                $last_p->add_recipients($p->to);
614            else {
615                $last_p && $last_p->send();
616                $last_p = $p;
617            }
618        }
619        $last_p && $last_p->send();
620    }
621
622
623    protected function unexpanded_warning() {
624        $a = array_keys($this->_unexpanded);
625        natcasesort($a);
626        for ($i = 0; $i < count($a); ++$i)
627            $a[$i] = "<code>" . htmlspecialchars($a[$i]) . "</code>";
628        if (count($a) == 1)
629            return "Keyword-like string " . commajoin($a) . " was not recognized.";
630        else
631            return "Keyword-like strings " . commajoin($a) . " were not recognized.";
632    }
633
634    function nwarnings() {
635        return count($this->_unexpanded);
636    }
637
638    function warnings() {
639        $e = array();
640        if (count($this->_unexpanded))
641            $e[] = $this->unexpanded_warning();
642        return $e;
643    }
644
645}
646
647class MimeText {
648    /// Quote potentially non-ASCII header text a la RFC2047 and/or RFC822.
649    static function append(&$result, &$linelen, $str, $utf8) {
650        if ($utf8) {
651            // replace all special characters used by the encoder
652            $str = str_replace(array('=',   '_',   '?',   ' '),
653                               array('=3D', '=5F', '=3F', '_'), $str);
654            // define nonsafe characters
655            if ($utf8 > 1)
656                $matcher = ',[^-0-9a-zA-Z!*+/=_],';
657            else
658                $matcher = ',[\x80-\xFF],';
659            preg_match_all($matcher, $str, $m, PREG_OFFSET_CAPTURE);
660            $xstr = "";
661            $last = 0;
662            foreach ($m[0] as $mx) {
663                $xstr .= substr($str, $last, $mx[1] - $last)
664                    . "=" . strtoupper(dechex(ord($mx[0])));
665                $last = $mx[1] + 1;
666            }
667            $xstr .= substr($str, $last);
668        } else
669            $xstr = $str;
670
671        // append words to the line
672        while ($xstr != "") {
673            $z = strlen($xstr);
674            assert($z > 0);
675
676            // add a line break
677            $maxlinelen = ($utf8 ? 76 - 12 : 78);
678            if (($linelen + $z > $maxlinelen && $linelen > 30)
679                || ($utf8 && substr($result, strlen($result) - 2) == "?=")) {
680                $result .= Mailer::eol() . " ";
681                $linelen = 1;
682                while (!$utf8 && $xstr !== "" && ctype_space($xstr[0])) {
683                    $xstr = substr($xstr, 1);
684                    --$z;
685                }
686            }
687
688            // if encoding, skip intact UTF-8 characters;
689            // otherwise, try to break at a space
690            if ($utf8 && $linelen + $z > $maxlinelen) {
691                $z = $maxlinelen - $linelen;
692                if ($xstr[$z - 1] == "=")
693                    $z -= 1;
694                else if ($xstr[$z - 2] == "=")
695                    $z -= 2;
696                while ($z > 3
697                       && $xstr[$z] == "="
698                       && ($chr = hexdec(substr($xstr, $z + 1, 2))) >= 128
699                       && $chr < 192)
700                    $z -= 3;
701            } else if ($linelen + $z > $maxlinelen) {
702                $y = strrpos(substr($xstr, 0, $maxlinelen - $linelen), " ");
703                if ($y > 0)
704                    $z = $y;
705            }
706
707            // append
708            if ($utf8)
709                $astr = "=?utf-8?q?" . substr($xstr, 0, $z) . "?=";
710            else
711                $astr = substr($xstr, 0, $z);
712
713            $result .= $astr;
714            $linelen += strlen($astr);
715
716            $xstr = substr($xstr, $z);
717        }
718    }
719
720    static function encode_email_header($header, $str) {
721        if (preg_match('/[\r\n]/', $str))
722            $str = simplify_whitespace($str);
723
724        $text = $header;
725        $linelen = strlen($text);
726
727        // separate $str into emails, quote each separately
728        while (true) {
729
730            // try three types of match in turn:
731            // 1. name <email> [RFC 822]
732            $match = preg_match("/\\A[,\\s]*((?:(?:\"(?:[^\"\\\\]|\\\\.)*\"|[^\\s\\000-\\037()[\\]<>@,;:\\\\\".]+)\\s*?)*)\\s*<\\s*(.*?)\\s*>\\s*(.*)\\z/s", $str, $m);
733            // 2. name including periods but no quotes <email> (canonicalize)
734            if (!$match) {
735                $match = preg_match("/\\A[,\\s]*((?:[^\\s\\000-\\037()[\\]<>@,;:\\\\\"]+\\s*?)*)\\s*<\\s*(.*?)\\s*>\\s*(.*)\\z/s", $str, $m);
736                if ($match)
737                    $m[1] = "\"$m[1]\"";
738            }
739            // 3. bare email
740            if (!$match)
741                $match = preg_match("/\\A[,\\s]*()<?\\s*([^\\s\\000-\\037()[\\]<>,;:\\\\\"]+)\\s*>?\\s*(.*)\\z/s", $str, $m);
742            // otherwise, fail
743            if (!$match)
744                break;
745
746            list($name, $email, $str) = array($m[1], $m[2], $m[3]);
747            if (strpos($email, "@") !== false && !validate_email($email))
748                return false;
749            if ($str != "" && $str[0] != ",")
750                return false;
751            if ($email == "none" || $email == "hidden")
752                continue;
753
754            if ($text !== $header) {
755                $text .= ", ";
756                $linelen += 2;
757            }
758
759            // unquote any existing UTF-8 encoding
760            if ($name != "" && $name[0] == "="
761                && strcasecmp(substr($name, 0, 10), "=?utf-8?q?") == 0)
762                $name = self::decode_header($name);
763
764            $utf8 = preg_match('/[\x80-\xFF]/', $name) ? 2 : 0;
765            if ($name != "" && $name[0] == "\""
766                && preg_match("/\\A\"([^\\\\\"]|\\\\.)*\"\\z/s", $name)) {
767                if ($utf8)
768                    self::append($text, $linelen, substr($name, 1, -1), $utf8);
769                else
770                    self::append($text, $linelen, $name, false);
771            } else if ($utf8)
772                self::append($text, $linelen, $name, $utf8);
773            else
774                self::append($text, $linelen, rfc2822_words_quote($name), false);
775
776            if ($name == "")
777                self::append($text, $linelen, $email, false);
778            else
779                self::append($text, $linelen, " <$email>", false);
780        }
781
782        if (!preg_match('/\A[\s,]*\z/', $str))
783            return false;
784        return $text;
785    }
786
787    static function encode_header($header, $str) {
788        if (preg_match('/[\r\n]/', $str))
789            $str = simplify_whitespace($str);
790
791        $text = $header;
792        $linelen = strlen($text);
793        if (preg_match('/[\x80-\xFF]/', $str))
794            self::append($text, $linelen, $str, true);
795        else
796            self::append($text, $linelen, $str, false);
797        return $text;
798    }
799
800    static function chr_hexdec_callback($m) {
801        return chr(hexdec($m[1]));
802    }
803
804    static function decode_header($text) {
805        if (strlen($text) > 2 && $text[0] == '=' && $text[1] == '?') {
806            $out = '';
807            while (preg_match('/\A=\?utf-8\?q\?(.*?)\?=(\r?\n )?/i', $text, $m)) {
808                $f = str_replace('_', ' ', $m[1]);
809                $out .= preg_replace_callback('/=([0-9A-F][0-9A-F])/',
810                                              "MimeText::chr_hexdec_callback",
811                                              $f);
812                $text = substr($text, strlen($m[0]));
813            }
814            return $out . $text;
815        } else
816            return $text;
817    }
818}
819