CWIS Developer Documentation
Email.php
Go to the documentation of this file.
1 <?PHP
2 
3 #
4 # FILE: Email.php
5 #
6 # Part of the ScoutLib application support library
7 # Copyright 2012 Edward Almasy and Internet Scout
8 # http://scout.wisc.edu
9 #
10 
15 class Email {
16 
17  # ---- PUBLIC INTERFACE --------------------------------------------------
18 
22  function __construct()
23  {
24  }
25 
26  /*@(*/
28 
34  function Send()
35  {
36  switch (self::$DeliveryMethod)
37  {
38  case self::METHOD_PHPMAIL:
39  # use PHPMailer to send multipart alternative messages because
40  # they can be tricky to construct properly
41  if ($this->HasAlternateBody())
42  {
43  $Result = $this->SendViaPhpMailer();
44  }
45 
46  # otherwise, just use the built-in mail() function
47  else
48  {
49  $Result = $this->SendViaPhpMail();
50  }
51  break;
52 
53  case self::METHOD_SMTP:
54  $Result = $this->SendViaSmtp();
55  break;
56  }
57  return $Result;
58  }
59 
60  /*@(*/
62 
68  function Body($NewValue = NULL)
69  {
70  if ($NewValue !== NULL) { $this->Body = $NewValue; }
71  return $this->Body;
72  }
73 
79  public function AlternateBody($NewValue = NULL)
80  {
81  # set the plain-text alternative if a parameter is given
82  if (func_num_args() > 0)
83  {
84  $this->AlternateBody = $NewValue;
85  }
86 
87  return $this->AlternateBody;
88  }
89 
95  function Subject($NewValue = NULL)
96  {
97  if ($NewValue !== NULL) { $this->Subject = $NewValue; }
98  return $this->Subject;
99  }
100 
109  function From($NewAddress = NULL, $NewName = NULL)
110  {
111  if ($NewAddress !== NULL)
112  {
113  $NewAddress = trim($NewAddress);
114  if ($NewName !== NULL)
115  {
116  $NewName = trim($NewName);
117  $this->From = $NewName." <".$NewAddress.">";
118  }
119  else
120  {
121  $this->From = $NewAddress;
122  }
123  }
124  return $this->From;
125  }
126 
135  function ReplyTo($NewAddress = NULL, $NewName = NULL)
136  {
137  if ($NewAddress !== NULL)
138  {
139  $NewAddress = trim($NewAddress);
140  if ($NewName !== NULL)
141  {
142  $NewName = trim($NewName);
143  $this->ReplyTo = $NewName." <".$NewAddress.">";
144  }
145  else
146  {
147  $this->ReplyTo = $NewAddress;
148  }
149  }
150  return $this->ReplyTo;
151  }
152 
160  function To($NewValue = NULL)
161  {
162  if ($NewValue !== NULL)
163  {
164  if (!is_array($NewValue))
165  {
166  $this->To = array($NewValue);
167  }
168  else
169  {
170  $this->To = $NewValue;
171  }
172  }
173  return $this->To;
174  }
175 
183  function CC($NewValue = NULL)
184  {
185  if ($NewValue !== NULL)
186  {
187  if (!is_array($NewValue))
188  {
189  $this->CC = array($NewValue);
190  }
191  else
192  {
193  $this->CC = $NewValue;
194  }
195  }
196  return $this->CC;
197  }
198 
206  function BCC($NewValue = NULL)
207  {
208  if ($NewValue !== NULL)
209  {
210  if (!is_array($NewValue))
211  {
212  $this->BCC = array($NewValue);
213  }
214  else
215  {
216  $this->BCC = $NewValue;
217  }
218  }
219  return $this->BCC;
220  }
221 
226  function AddHeaders($NewHeaders)
227  {
228  # add new headers to list
229  $this->Headers = array_merge($this->Headers, $NewHeaders);
230  }
231 
238  public function CharSet($NewValue = NULL)
239  {
240  # set the plain-text alternative if a parameter is given
241  if (func_num_args() > 0)
242  {
243  $this->CharSet = $NewValue;
244  }
245 
246  return $this->CharSet;
247  }
248 
254  public static function LineEnding($NewValue = NULL)
255  {
256  if (!is_null($NewValue))
257  {
258  self::$LineEnding = $NewValue;
259  }
260 
261  return self::$LineEnding;
262  }
263 
276  public static function WrapHtmlAsNecessary($Html, $MaxLineLength=998, $LineEnding="\r\n")
277  {
278  # the regular expression used to find long lines
279  $LongLineRegExp = '/[^\r\n]{'.($MaxLineLength+1).',}/';
280 
281  # find all lines that are too long
282  preg_match_all($LongLineRegExp, $Html, $Matches, PREG_PATTERN_ORDER|PREG_OFFSET_CAPTURE);
283 
284  # no changes are necessary
285  if (!count($Matches))
286  {
287  return $Html;
288  }
289 
290  # go backwards so that the HTML can be edited in place without messing
291  # with the offsets
292  for ($i = count($Matches[0]) - 1; $i >= 0; $i--)
293  {
294  # extract the line text and its offset within the string
295  list($Line, $Offset) = $Matches[0][$i];
296 
297  # first try to get the line under the limit without being too
298  # aggressive
299  $BetterLine = self::ConvertHtmlWhiteSpace($Line, FALSE, $LineEnding);
300  $WasAggressive = "No";
301 
302  # if the line is still too long, be more aggressive with replacing
303  # horizontal whitespace
304  if (preg_match($LongLineRegExp, $BetterLine))
305  {
306  $BetterLine = self::ConvertHtmlWhiteSpace($Line, TRUE, $LineEnding);
307  $WasAggressive = "Yes";
308  }
309 
310  # tack on an HTML comment stating that the line was wrapped and give
311  # some additional info
312  $BetterLine = $LineEnding."<!-- Line was wrapped. Aggressive: "
313  .$WasAggressive.", Max: ".$MaxLineLength.", Actual: "
314  .strlen($Line)." -->".$LineEnding.$BetterLine;
315 
316  # replace the line within the HTML
317  $Html = substr_replace($Html, $BetterLine, $Offset, strlen($Line));
318  }
319 
320  return $Html;
321  }
322 
330  public static function TestLineEndings($Value, $LineEnding)
331  {
332  # the number of \r in the string
333  $NumCR = substr_count($Value, "\r");
334 
335  # LF
336  if ($LineEnding == "\n")
337  {
338  return $NumCR === 0;
339  }
340 
341  # the number of \n in the string
342  $NumLF = substr_count($Value, "\n");
343 
344  # CR
345  if ($LineEnding == "\r")
346  {
347  return $NumLF === 0;
348  }
349 
350  # the number of \r\n in the string
351  $NumCRLF = substr_count($Value, "\r\n");
352 
353  # CRLF. also check CRLF to make sure CR and LF appear together and in
354  # the correct order
355  return $NumCR === $NumLF && $NumLF === $NumCRLF;
356  }
357  /*@(*/
359 
366  static function DeliveryMethod($NewValue = NULL)
367  {
368  if ($NewValue !== NULL)
369  {
370  self::$DeliveryMethod = $NewValue;
371  }
372  return self::$DeliveryMethod;
373  }
374  const METHOD_PHPMAIL = 1;
375  const METHOD_SMTP = 2;
376 
382  static function Server($NewValue = NULL)
383  {
384  if ($NewValue !== NULL) { self::$Server = $NewValue; }
385  return self::$Server;
386  }
387 
393  static function Port($NewValue = NULL)
394  {
395  if ($NewValue !== NULL) { self::$Port = $NewValue; }
396  return self::$Port;
397  }
398 
404  static function UserName($NewValue = NULL)
405  {
406  if ($NewValue !== NULL) { self::$UserName = $NewValue; }
407  return self::$UserName;
408  }
409 
415  static function Password($NewValue = NULL)
416  {
417  if ($NewValue !== NULL) { self::$Password = $NewValue; }
418  return self::$Password;
419  }
420 
426  static function UseAuthentication($NewValue = NULL)
427  {
428  if ($NewValue !== NULL) { self::$UseAuthentication = $NewValue; }
429  return self::$UseAuthentication;
430  }
431 
439  static function DeliverySettings($NewSettings = NULL)
440  {
441  if ($NewSettings !== NULL)
442  {
443  $Settings = unserialize($NewSettings);
444  self::$DeliveryMethod = $Settings["DeliveryMethod"];
445  self::$Server = $Settings["Server"];
446  self::$Port = $Settings["Port"];
447  self::$UserName = $Settings["UserName"];
448  self::$Password = $Settings["Password"];
449  self::$UseAuthentication = $Settings["UseAuthentication"];
450  }
451  else
452  {
453  $Settings["DeliveryMethod"] = self::$DeliveryMethod;
454  $Settings["Server"] = self::$Server;
455  $Settings["Port"] = self::$Port;
456  $Settings["UserName"] = self::$UserName;
457  $Settings["Password"] = self::$Password;
458  $Settings["UseAuthentication"] = self::$UseAuthentication;
459  }
460  return serialize($Settings);
461  }
462 
471  static function DeliverySettingsOkay()
472  {
473  # start out with error list clear
474  self::$DeliverySettingErrorList = array();
475 
476  # test based on delivery method
477  switch (self::$DeliveryMethod)
478  {
479  case self::METHOD_PHPMAIL:
480  # always report success
481  $SettingsOkay = TRUE;
482  break;
483 
484  case self::METHOD_SMTP:
485  # set up PHPMailer for test
486  $PMail = new PHPMailer(TRUE);
487  $PMail->IsSMTP();
488  $PMail->SMTPAuth = self::$UseAuthentication;
489  $PMail->Host = self::$Server;
490  $PMail->Port = self::$Port;
491  $PMail->Username = self::$UserName;
492  $PMail->Password = self::$Password;
493 
494  # test settings
495  try
496  {
497  $SettingsOkay = $PMail->SmtpConnect();
498  }
499  # if test failed
500  catch (phpmailerException $Except)
501  {
502  # translate PHPMailer error message to possibly bad settings
503  switch ($Except->getMessage())
504  {
505  case 'SMTP Error: Could not authenticate.':
506  self::$DeliverySettingErrorList = array(
507  "UseAuthentication",
508  "UserName",
509  "Password",
510  );
511  break;
512 
513  case 'SMTP Error: Could not connect to SMTP host.':
514  self::$DeliverySettingErrorList = array(
515  "Server",
516  "Port",
517  );
518  break;
519 
520  case 'Language string failed to load: tls':
521  self::$DeliverySettingErrorList = array("TLS");
522  break;
523 
524  default:
525  self::$DeliverySettingErrorList = array("UNKNOWN");
526  break;
527  }
528 
529  # make sure failure is reported
530  $SettingsOkay = FALSE;
531  }
532  break;
533  }
534 
535  # report result to caller
536  return $SettingsOkay;
537  }
538 
543  static function DeliverySettingErrors()
544  {
545  return self::$DeliverySettingErrorList;
546  }
547 
548 
549  # ---- PRIVATE INTERFACE -------------------------------------------------
550 
551  private $From = "";
552  private $ReplyTo = "";
553  private $To = array();
554  private $CC = array();
555  private $BCC = array();
556  private $Body = "";
557  private $AlternateBody = "";
558  private $Subject = "";
559  private $Headers = array();
560  private $CharSet;
561  private static $LineEnding = "\r\n";
562 
563  private static $DeliveryMethod = self::METHOD_PHPMAIL;
564  private static $DeliverySettingErrorList = array();
565  private static $Server;
566  private static $Port = 25;
567  private static $UserName = "";
568  private static $Password = "";
569  private static $UseAuthentication = FALSE;
570 
571  private function SendViaPhpMail()
572  {
573  # make lines using the line ending variable a bit shorter
574  $LE = self::$LineEnding;
575 
576  # build basic headers list
577  $Headers = "From: ".self::CleanHeaderValue($this->From).$LE;
578  $Headers .= $this->BuildAddresseeLine("Cc", $this->CC);
579  $Headers .= $this->BuildAddresseeLine("Bcc", $this->BCC);
580  $Headers .= "Reply-To: ".self::CleanHeaderValue(
581  strlen($this->ReplyTo) ? $this->ReplyTo : $this->From).$LE;
582 
583  # add additional headers
584  foreach ($this->Headers as $ExtraHeader)
585  {
586  $Headers .= $ExtraHeader.$LE;
587  }
588 
589  # build recipient list
590  $To = "";
591  $Separator = "";
592  foreach ($this->To as $Recipient)
593  {
594  $To .= $Separator.$Recipient;
595  $Separator = ", ";
596  }
597 
598  # normalize message body line endings
599  $Body = $this->NormalizeLineEndings($this->Body, $LE);
600 
601  # send message
602  $Result = mail($To, $this->Subject, $Body, $Headers);
603 
604  # report to caller whether attempt to send succeeded
605  return $Result;
606  }
607 
613  private function SendViaPhpMailer()
614  {
615  # create and initialize PHPMailer
616  $PMail = new PHPMailer();
617  $PMail->LE = self::$LineEnding;
618  $PMail->Subject = $this->Subject;
619  $PMail->Body = $this->Body;
620  $PMail->IsHTML(FALSE);
621 
622  # default values for the sender's name and address
623  $Name = "";
624  $Address = $this->From;
625 
626  # if the address contains a name and address, they need to extracted
627  # because PHPMailer requires that they are set as two different
628  # parameters
629  if (preg_match("/ </", $this->From))
630  {
631  $Pieces = explode(" ", $this->From);
632  $Address = array_pop($Pieces);
633  $Address = preg_replace("/[<>]+/", "", $Address);
634  $Name = trim(implode($Pieces, " "));
635  }
636 
637  # add the sender
638  $PMail->SetFrom($Address, $Name);
639 
640  # add each recipient
641  foreach ($this->To as $Recipient)
642  {
643  $PMail->AddAddress($Recipient);
644  }
645 
646  # add any extra header lines
647  foreach ($this->Headers as $ExtraHeader)
648  {
649  $PMail->AddCustomHeader($ExtraHeader);
650  }
651 
652  # add the charset if it's set
653  if (isset($this->CharSet))
654  {
655  $PMail->CharSet = strtolower($this->CharSet);
656  }
657 
658  # add the alternate plain-text body if it's set
659  if ($this->HasAlternateBody())
660  {
661  $PMail->AltBody = $this->AlternateBody;
662  }
663 
664  # set up SMTP if necessary
665  if (self::$DeliveryMethod == self::METHOD_SMTP)
666  {
667  $PMail->IsSMTP();
668  $PMail->SMTPAuth = self::$UseAuthentication;
669  $PMail->Host = self::$Server;
670  $PMail->Port = self::$Port;
671  $PMail->Username = self::$UserName;
672  $PMail->Password = self::$Password;
673  }
674 
675  # send message
676  $Result = $PMail->Send();
677 
678  # report to caller whether attempt to send succeeded
679  return $Result;
680  }
681 
686  private function SendViaSmtp()
687  {
688  # send via PHPMailer because it's capable of handling SMTP
689  return $this->SendViaPhpMailer();
690  }
691 
698  private function BuildAddresseeLine($Label, $Recipients)
699  {
700  $Line = "";
701  if (count($Recipients))
702  {
703  $Line .= $Label.": ";
704  $Separator = "";
705  foreach ($Recipients as $Recipient)
706  {
707  $Line .= $Separator.self::CleanHeaderValue($Recipient);
708  $Separator = ", ";
709  }
710  $Line .= self::$LineEnding;
711  }
712  return $Line;
713  }
714 
719  private function HasAlternateBody()
720  {
721  return isset($this->AlternateBody) && strlen(trim($this->AlternateBody)) > 0;
722  }
723 
729  private static function CleanHeaderValue($Value)
730  {
731  # (regular expression taken from sanitizeHeaders() function in
732  # Mail PEAR package)
733  return preg_replace('=((<CR>|<LF>|0x0A/%0A|0x0D/%0D|\\n|\\r)\S).*=i',
734  "", $Value);
735  }
736 
743  private static function NormalizeLineEndings($Value, $LineEnding)
744  {
745  return preg_replace('/\r\n|\r|\n/', $LineEnding, $Value);
746  }
747 
762  protected static function ConvertHtmlWhiteSpace($Html, $Aggressive=FALSE, $LineEnding="\r\n")
763  {
764  $HtmlLength = strlen($Html);
765 
766  # tags that should have their inner HTML left alone
767  $IgnoredTags = array('script', 'style', 'textarea', 'title');
768 
769  # values for determining context
770  $InTag = FALSE;
771  $InClosingTag = FALSE;
772  $InIgnoredTag = FALSE;
773  $InAttribute = FALSE;
774  $TagName = NULL;
775  $IgnoredTagName = NULL;
776  $AttributeDelimiter = NULL;
777 
778  # loop through each character of the string
779  for ($i = 0; $i < $HtmlLength; $i++)
780  {
781  $Char = $Html{$i};
782 
783  # beginning of a tag
784  if ($Char == "<" && !$InTag)
785  {
786  $InTag = TRUE;
787  $InAttribute = FALSE;
788  $AttributeDelimiter = NULL;
789 
790  # do some lookaheads to get the tag name and to see if the tag
791  # is a closing tag
792  list($InClosingTag, $TagName) = self::GetTagInfo($Html, $i);
793 
794  # moving into an ignored tag
795  if (!$InClosingTag && in_array($TagName, $IgnoredTags))
796  {
797  $InIgnoredTag = TRUE;
798  $IgnoredTagName = $TagName;
799  }
800 
801  continue;
802  }
803 
804  # end of a tag
805  if ($Char == ">" && $InTag && !$InAttribute)
806  {
807  # moving out of an ignored tag
808  if ($InClosingTag && $InIgnoredTag && $TagName == $IgnoredTagName)
809  {
810  $InIgnoredTag = FALSE;
811  $IgnoredTagName = NULL;
812  }
813 
814  $InTag = FALSE;
815  $InClosingTag = FALSE;
816  $InAttribute = FALSE;
817  $TagName = NULL;
818  $AttributeDelimiter = NULL;
819 
820  continue;
821  }
822 
823  # attribute delimiter characters
824  if ($Char == "'" || $Char == '"')
825  {
826  # beginning of an attribute
827  if (!$InAttribute)
828  {
829  $InAttribute = TRUE;
830  $AttributeDelimiter = $Char;
831  continue;
832  }
833 
834  # end of the attribute
835  if ($InAttribute && $Char == $AttributeDelimiter)
836  {
837  $InAttribute = FALSE;
838  $AttributeDelimiter = NULL;
839  continue;
840  }
841  }
842 
843  # whitespace inside of a tag but outside of an attribute can be
844  # safely converted to a newline
845  if ($InTag && !$InAttribute && preg_match('/\s/', $Char))
846  {
847  $Html{$i} = $LineEnding;
848  continue;
849  }
850 
851  # whitespace outside of a tag can be safely converted to a newline
852  # when not in one of the ignored tags, but only do so if horizontal
853  # space is at a premium because it can make the resulting HTML
854  # difficult to read
855  if ($Aggressive && !$InTag && !$InIgnoredTag && preg_match('/\s/', $Char))
856  {
857  $Html{$i} = $LineEnding;
858  continue;
859  }
860  }
861 
862  return $Html;
863  }
864 
873  protected static function GetTagInfo($Html, $TagBegin)
874  {
875  $HtmlLength = strlen($Html);
876 
877  # default return values
878  $InClosingTag = FALSE;
879  $TagName = NULL;
880 
881  # if at the end of the string and lookaheads aren't possible
882  if ($TagBegin + 1 >= $HtmlLength)
883  {
884  return array($InClosingTag, $TagName);
885  }
886 
887  # do a lookahead for whether it's a closing tag
888  if ($Html{$TagBegin+1} == "/")
889  {
890  $InClosingTag = TRUE;
891  }
892 
893  # determine whether to offset by one or two to get the tag name
894  $TagStart = $InClosingTag ? $TagBegin + 2 : $TagBegin + 1;
895 
896  # do a lookahead for the tag name
897  for ($i = $TagStart; $i < $HtmlLength; $i++)
898  {
899  $Char = $Html{$i};
900 
901  # stop getting the tag name if whitespace is found and something is
902  # available for the tag name
903  if (strlen($TagName) && preg_match('/[\r\n\s]/', $Char))
904  {
905  break;
906  }
907 
908  # stop getting the tag name if the character is >
909  if ($Char == ">")
910  {
911  break;
912  }
913 
914  $TagName .= $Char;
915  }
916 
917  # comment "tag"
918  if (substr($TagName, 0, 3) == "!--")
919  {
920  return array($InClosingTag, "!--");
921  }
922 
923  # remove characters that aren't part of a valid tag name
924  $TagName = preg_replace('/[^a-zA-Z0-9]/', '', $TagName);
925 
926  return array($InClosingTag, $TagName);
927  }
928 
929 }