<?php
/*** COPYRIGHT NOTICE *********************************************************
 *
 * Copyright 2009-2017 ProjeQtOr - Pascal BERNARD - support@projeqtor.org
 * Contributors : -
 *
 * This file is part of ProjeQtOr.
 *
 * ProjeQtOr is free software: you can redistribute it and/or modify it under
 * the terms of the GNU Affero General Public License as published by the Free
 * Software Foundation, either version 3 of the License, or (at your option)
 * any later version.
 *
 * ProjeQtOr is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for
 * more details.
 *
 * You should have received a copy of the GNU Affero General Public License along with
 * ProjeQtOr. If not, see <http://www.gnu.org/licenses/>.
 *
 * You can get complete code of ProjeQtOr, other resource, help and information
 * about contributors at http://www.projeqtor.org
 *
 *** DO NOT REMOVE THIS NOTICE ************************************************/

/**
 *
 * @see https://github.com/barbushin/php-imap
 * @author Barbushin Sergey http://linkedin.com/in/barbushin
 *        
 */
require_once ('_securityCheck.php');

class ImapMailbox {

  protected $imapPath;
  protected $login;
  protected $password;
  protected $serverEncoding;
  protected $attachmentsDir;

  public function __construct($imapPath, $login, $password, $attachmentsDir=null, $serverEncoding='utf-8') {
    $this->imapPath=$imapPath;
    $this->login=$login;
    $this->password=$password;
    $this->serverEncoding=$serverEncoding;
    if ($attachmentsDir) {
      if (!is_dir($attachmentsDir)) {
        throw new Exception('Directory "'.$attachmentsDir.'" not found');
      }
      $this->attachmentsDir=rtrim(realpath($attachmentsDir), '\\/');
    }
  }

  public static function checkImapEnabled() {
    if (function_exists('imap_search')) {
      return true;
    } else {
      return false;
    }
  }

  /**
   * Get IMAP mailbox connection stream
   * 
   * @param bool $forceConnection
   *          Initialize connection if it's not initialized
   * @return null|resource
   */
  public function getImapStream($forceConnection=true) {
    static $imapStream;
    if ($forceConnection) {
      if ($imapStream&&(!is_resource($imapStream)||!imap_ping($imapStream))) {
        $this->disconnect();
        $imapStream=null;
      }
      if (!$imapStream) {
        $imapStream=$this->initImapStream();
      }
    }
    return $imapStream;
  }

  protected function initImapStream() {
    $imapStream=@imap_open($this->imapPath, $this->login, $this->password);
    if (!$imapStream) {
      throw new ImapMailboxException("Connection error to $this->imapPath for $this->login: ".imap_last_error());
    }
    return $imapStream;
  }

  protected function disconnect() {
    $imapStream=$this->getImapStream(false);
    if ($imapStream&&is_resource($imapStream)) {
      imap_close($imapStream, CL_EXPUNGE);
    }
  }

  /*
   * Get information about the current mailbox.
   *
   * Returns the information in an object with following properties:
   * Date - current system time formatted according to RFC2822
   * Driver - protocol used to access this mailbox: POP3, IMAP, NNTP
   * Mailbox - the mailbox name
   * Nmsgs - number of mails in the mailbox
   * Recent - number of recent mails in the mailbox
   *
   * @return stdClass
   */
  public function checkMailbox() {
    return imap_check($this->getImapStream());
  }

  /*
   * This function performs a search on the mailbox currently opened in the given IMAP stream.
   * For example, to match all unanswered mails sent by Mom, you'd use: "UNANSWERED FROM mom".
   * Searches appear to be case insensitive. This list of criteria is from a reading of the UW
   * c-client source code and may be incomplete or inaccurate (see also RFC2060, section 6.4.4).
   *
   * @param string $criteria String, delimited by spaces, in which the following keywords are allowed. Any multi-word arguments (e.g. FROM "joey smith") must be quoted. Results will match all criteria entries.
   * ALL - return all mails matching the rest of the criteria
   * ANSWERED - match mails with the \\ANSWERED flag set
   * BCC "string" - match mails with "string" in the Bcc: field
   * BEFORE "date" - match mails with Date: before "date"
   * BODY "string" - match mails with "string" in the body of the mail
   * CC "string" - match mails with "string" in the Cc: field
   * DELETED - match deleted mails
   * FLAGGED - match mails with the \\FLAGGED (sometimes referred to as Important or Urgent) flag set
   * FROM "string" - match mails with "string" in the From: field
   * KEYWORD "string" - match mails with "string" as a keyword
   * NEW - match new mails
   * OLD - match old mails
   * ON "date" - match mails with Date: matching "date"
   * RECENT - match mails with the \\RECENT flag set
   * SEEN - match mails that have been read (the \\SEEN flag is set)
   * SINCE "date" - match mails with Date: after "date"
   * SUBJECT "string" - match mails with "string" in the Subject:
   * TEXT "string" - match mails with text "string"
   * TO "string" - match mails with "string" in the To:
   * UNANSWERED - match mails that have not been answered
   * UNDELETED - match mails that are not deleted
   * UNFLAGGED - match mails that are not flagged
   * UNKEYWORD "string" - match mails that do not have the keyword "string"
   * UNSEEN - match mails which have not been read yet
   *
   * @return array Mails ids
   */
  public function searchMailbox($criteria='ALL') {
    //$mailsIds=imap_search($this->getImapStream(), $criteria, SE_UID, $this->serverEncoding);
    $mailsIds=imap_search($this->getImapStream(), $criteria, SE_UID);
    return $mailsIds?$mailsIds:array();
  }

  /*
   * Marks mails listed in mailId for deletion.
   * @return bool
   */
  public function deleteMail($mailId) {
    return imap_delete($this->getImapStream(), $mailId, FT_UID);
  }

  /*
   * Deletes all the mails marked for deletion by imap_delete(), imap_mail_move(), or imap_setflag_full().
   * @return bool
   */
  public function expungeDeletedMails() {
    return imap_expunge($this->getImapStream());
  }

  public function markMailAsRead($mailId) {
    if (!is_array($mailId)) {
      $mailId=array($mailId);
    }
    $this->setFlag($mailId, '\\Seen');
  }

  public function markMailAsUnread($mailId) {
    if (!is_array($mailId)) {
      $mailId=array($mailId);
    }
    $this->clearFlag($mailId, '\\Seen');
  }

  public function markMailAsImportant($mailId) {
    if (!is_array($mailId)) {
      $mailId=array($mailId);
    }
    $this->setFlag($mailId, '\\Flagged');
  }

  /*
   * Causes a store to add the specified flag to the flags set for the mails in the specified sequence.
   *
   * @param array $mailsIds
   * @param $flag Flags which you can set are \Seen, \Answered, \Flagged, \Deleted, and \Draft as defined by RFC2060.
   * @return bool
   */
  public function setFlag(array $mailsIds, $flag) {
    return imap_setflag_full($this->getImapStream(), implode(',', $mailsIds), $flag, ST_UID);
  }

  /*
   * Cause a store to delete the specified flag to the flags set for the mails in the specified sequence.
   *
   * @param array $mailsIds
   * @param $flag Flags which you can set are \Seen, \Answered, \Flagged, \Deleted, and \Draft as defined by RFC2060.
   * @return bool
   */
  public function clearFlag(array $mailsIds, $flag) {
    return imap_clearflag_full($this->getImapStream(), implode(',', $mailsIds), $flag, ST_UID);
  }

  /*
   * Fetch mail headers for listed mails ids
   *
   * Returns an array of objects describing one mail header each. The object will only define a property if it exists. The possible properties are:
   * subject - the mails subject
   * from - who sent it
   * to - recipient
   * date - when was it sent
   * message_id - Mail-ID
   * references - is a reference to this mail id
   * in_reply_to - is a reply to this mail id
   * size - size in bytes
   * uid - UID the mail has in the mailbox
   * msgno - mail sequence number in the mailbox
   * recent - this mail is flagged as recent
   * flagged - this mail is flagged
   * answered - this mail is flagged as answered
   * deleted - this mail is flagged for deletion
   * seen - this mail is flagged as already read
   * draft - this mail is flagged as being a draft
   *
   * @param array $mailsIds
   * @return array
   */
  public function getMailsInfo(array $mailsIds) {
    return imap_fetch_overview($this->getImapStream(), implode(',', $mailsIds), FT_UID);
  }

  /**
   * Gets mails ids sorted by some criteria
   *
   * Criteria can be one (and only one) of the following constants:
   * SORTDATE - mail Date
   * SORTARRIVAL - arrival date (default)
   * SORTFROM - mailbox in first From address
   * SORTSUBJECT - mail subject
   * SORTTO - mailbox in first To address
   * SORTCC - mailbox in first cc address
   * SORTSIZE - size of mail in octets
   *
   * @param int $criteria          
   * @param bool $reverse          
   * @return array Mails ids
   */
  public function sortMails($criteria=SORTARRIVAL, $reverse=true) {
    return imap_sort($this->getImapStream(), $criteria, $reverse, SE_UID);
  }

  /**
   * Get mails count in mail box
   * 
   * @return int
   */
  public function countMails() {
    return imap_num_msg($this->getImapStream());
  }

  /**
   * Get mail data
   *
   * @param
   *          $mailId
   * @return IncomingMail
   */
  public function getMail($mailId) {
    $head=imap_rfc822_parse_headers(imap_fetchheader($this->getImapStream(), $mailId, FT_UID));
    $mail=new IncomingMail();
    $mail->id=$mailId;
    if (!$head) return $mail;
    $mail->date=date('Y-m-d H:i:s', isset($head->date)?strtotime($head->date):time());
    $mail->subject=$this->decodeMimeStr($head->subject);
    $mail->fromName=isset($head->from[0]->personal)?$this->decodeMimeStr($head->from[0]->personal):null;
    $mail->fromAddress=strtolower($head->from[0]->mailbox.'@'.$head->from[0]->host);
    
    $toStrings=array();
    if (isset($head->to)) {
      foreach ($head->to as $to) {
        if (!empty($to->mailbox)&&!empty($to->host)) {
          $toEmail=strtolower($to->mailbox.'@'.$to->host);
          $toName=isset($to->personal)?$this->decodeMimeStr($to->personal):null;
          $toStrings[]=$toName?"$toName <$toEmail>":$toEmail;
          $mail->to[$toEmail]=$toName;
        }
      }
    }
    $mail->toString=implode(', ', $toStrings);
    
    if (isset($head->cc)) {
      foreach ($head->cc as $cc) {
        $mail->cc[strtolower($cc->mailbox.'@'.$cc->host)]=isset($cc->personal)?$this->decodeMimeStr($cc->personal):null;
      }
    }
    
    if (isset($head->reply_to)) {
      foreach ($head->reply_to as $replyTo) {
        $mail->replyTo[strtolower($replyTo->mailbox.'@'.$replyTo->host)]=isset($replyTo->personal)?$this->decodeMimeStr($replyTo->personal):null;
      }
    }
    
    $mailStructure=imap_fetchstructure($this->getImapStream(), $mailId, FT_UID);
    
    if (empty($mailStructure->parts)) {
      $this->initMailPart($mail, $mailStructure, 0);
    } else {
      foreach ($mailStructure->parts as $partNum=>$partStructure) {
        $this->initMailPart($mail, $partStructure, $partNum+1);
      }
    }
    
    return $mail;
  }

  protected function initMailPart(IncomingMail $mail, $partStructure, $partNum) {
    $data=$partNum?imap_fetchbody($this->getImapStream(), $mail->id, $partNum, FT_UID):imap_body($this->getImapStream(), $mail->id, FT_UID);
    
    if ($partStructure->encoding==1) {
      $data=imap_utf8($data);
    } elseif ($partStructure->encoding==2) {
      $data=imap_binary($data);
    } elseif ($partStructure->encoding==3) {
      $data=imap_base64($data);
    } elseif ($partStructure->encoding==4) {
      $data=imap_qprint($data);
    }
    
    $params=array();
    if (!empty($partStructure->parameters)) {
      foreach ($partStructure->parameters as $param) {
        $params[strtolower($param->attribute)]=$param->value;
      }
    }
    if (!empty($partStructure->dparameters)) {
      foreach ($partStructure->dparameters as $param) {
        $params[strtolower($param->attribute)]=$param->value;
      }
    }
    if (!empty($params['charset'])) {
      if (! $this->serverEncoding or ! trim($this->serverEncoding)) $this->serverEncoding='utf-8';
      if ($this->serverEncoding!='utf-8') traceLog("ImapMailbox conversion from '".$params['charset']."' to '".$this->serverEncoding."'");
      $data=iconv($params['charset'], $this->serverEncoding, $data);
    }
    
    // attachments
    $attachmentId=$partStructure->ifid?trim($partStructure->id, " <>"):(isset($params['filename'])||isset($params['name'])?mt_rand().mt_rand():null);
    if ($attachmentId) {
      if (empty($params['filename'])&&empty($params['name'])) {
        $fileName=$attachmentId.'.'.strtolower($partStructure->subtype);
      } else {
        $fileName=!empty($params['filename'])?$params['filename']:$params['name'];
        $fileName=$this->decodeMimeStr($fileName);
        $replace=array('/\s/'=>'_', '/[^0-9a-zA-Z_\.]/'=>'', '/_+/'=>'_', '/(^_)|(_$)/'=>'');
        $fileName=preg_replace(array_keys($replace), $replace, $fileName);
      }
      $attachment=new IncomingMailAttachment();
      $attachment->id=$attachmentId;
      $attachment->name=$fileName;
      if ($this->attachmentsDir) {
        $attachment->filePath=$this->attachmentsDir.DIRECTORY_SEPARATOR.preg_replace('~[\\\\/]~', '', $mail->id.'_'.$attachmentId.'_'.$fileName);
        file_put_contents($attachment->filePath, $data);
      }
      $mail->addAttachment($attachment);
    } elseif ($partStructure->type==0&&$data) {
      if (strtolower($partStructure->subtype)=='plain') {
        $mail->textPlain.=$data;
      } else {
        $mail->textHtml.=$data;
      }
    } elseif ($partStructure->type==2&&$data) {
      $mail->textPlain.=trim($data);
    }
    if (!empty($partStructure->parts)) {
      foreach ($partStructure->parts as $subPartNum=>$subPartStructure) {
        $this->initMailPart($mail, $subPartStructure, $partNum.'.'.($subPartNum+1));
      }
    }
  }

  protected function decodeMimeStr($string, $charset='UTF-8') {
    $newString='';
    $elements=imap_mime_header_decode($string);
    for ($i=0; $i<count($elements); $i++) {
      if ($elements[$i]->charset=='default') {
        $elements[$i]->charset='iso-8859-1';
      }
      $newString.=iconv($elements[$i]->charset, $charset, $elements[$i]->text);
    }
    return $newString;
  }

  public function __destruct() {
    $this->disconnect();
  }

}

class IncomingMail {

  public $id;
  public $date;
  public $subject;
  public $fromName;
  public $fromAddress;
  public $to=array();
  public $toString;
  public $cc=array();
  public $replyTo=array();
  public $textPlain;
  public $textHtml;

  /**
   * @var IncomingMailAttachment[]
   */
  protected $attachments=array();

  public function addAttachment(IncomingMailAttachment $attachment) {
    $this->attachments[$attachment->id]=$attachment;
  }

  /**
   *
   * @return IncomingMailAttachment[]
   */
  public function getAttachments() {
    return $this->attachments;
  }

  /**
   * Get array of internal HTML links placeholders
   * 
   * @return array attachmentId => link placeholder
   */
  public function getInternalLinksPlaceholders() {
    return preg_match_all('/=["\'](ci?d:(\w+))["\']/i', $this->textHtml, $matches)?array_combine($matches[2], $matches[1]):array();
  }

  public function replaceInternalLinks($baseUri) {
    $baseUri=rtrim($baseUri, '\\/').'/';
    $fetchedHtml=$this->textHtml;
    foreach ($this->getInternalLinksPlaceholders() as $attachmentId=>$placeholder) {
      $fetchedHtml=str_replace($placeholder, $baseUri.basename($this->attachments[$attachmentId]->filePath), $fetchedHtml);
    }
    return $fetchedHtml;
  }

}

class IncomingMailAttachment {

  public $id;
  public $name;
  public $filePath;

}

class ImapMailboxException extends Exception {

}