#在本教學中,我將向您介紹一些實際範例,說明如何使用 PHP 和 IMAP 建立用於管理電子郵件的新功能,而大型電子郵件提供者尚未為我們建立這些功能。
#我對此產生興趣始於 2010 年,當時我寫了《(再次)徹底改變電子郵件的十二個 Gmail 創意》,但大多數我想要的創意仍然遙不可及。儘管電子郵件很重要,但電子郵件作為應用程式的創新卻相當緩慢。
我們正被電子郵件淹沒,管理收件匣仍然是一個沉重的負擔。郵件服務和客戶在這方面幾乎沒有採取任何措施來幫助我們。我們收到的大部分電子郵件都是由機器而不是人們發送的,但我們必須單獨處理所有這些電子郵件。
對我自己的電子郵件的分析顯示,我收到了來自 230 多個自動寄件者的電子郵件,而實際寄件者的數量要少得多。我厭倦了在 Gmail 中建立過濾器並填寫無數的取消訂閱表格。我希望更好地控制我的電子郵件管理並簡化我的生活。
最後,在過去的一年裡,我決定建立我需要的功能。結果是 Simplify Email (SE),這是一個您可以自己託管的小型網路應用程序,它提供了各種很酷的新電子郵件功能,您可以在專案網站上查看所有這些功能。
SE 最酷的一點是它是一個用於閱讀、分析、路由和管理電子郵件的平台 - 可能性比比皆是。簡化電子郵件本質上是一個「駭客」您自己的電子郵件的可編程遊樂場。
我將引導您完成 SE 中使用 PHP、IMAP 和 MySQL 處理電子郵件的三個範例的程式碼:
本教學肯定會讓您在使用 PHP 編寫 IMAP 程式碼方面取得先機。但您也可以直接使用 Simplify Email 程式碼庫。您可以以低至 10 美元的價格購買代碼,並且有一個較舊的開源版本(缺少我們在下面描述的一些功能)。提供了典型 Linux 配置的安裝指南。我還在 Digital Ocean 提供預裝圖像,價格為 25 美元,並提供手持代客安裝服務。 SE 是在 Yii 框架中用 PHP 寫的。
請注意,除非您為 PHP 編譯安全的 IMAP 函式庫,否則您將無法透過本機開發電腦存取大多數電子郵件伺服器。這也是我鼓勵人們在 Digital Ocean 中以 Droplet 方式運行 Simplify Email 的原因之一。還有一些技巧可以確保 Google 帳戶安全,讓您透過 IMAP 進入。
借助 SE,您可以繼續在網路和行動裝置上使用您選擇的電子郵件用戶端。您無需更改任何應用程式或個人習慣。 SE 透過 IMAP 在幕後存取您的電子郵件帳戶;作為智慧個人助理,SE 會預先處理您的電子郵件,根據您告訴它的所有內容將訊息移至適當的位置。
#當來自熟悉的寄件者的郵件到達時,SE 會將其移至您指定的資料夾。當未知寄件者第一次收到郵件時,會將其移至審閱資料夾。
每隔幾個小時(或按照您選擇的頻率),SE 就會向您發送摘要,說明其將郵件移至何處以及哪些郵件正在審核中。請注意,審核資料夾中包含培訓發送者的鏈接,使得隨著時間的推移培訓 SE 變得非常容易。
您可以隨時瀏覽您的審閱資料夾 - 無需等待摘要到達。但SE的優點是你不再需要瀏覽你的資料夾;您只需閱讀摘要即可查看已收到的電子郵件並培訓新寄件者。
SE 使用多個 cron 任務在伺服器背景執行。每個都是從 DaemonController.php
呼叫的。
第一個,processInbox
,被頻繁調用,需要快速操作——它的工作是篩選電子郵件並儘快將其從收件匣移出並放入分類資料夾,稱為過濾資料夾。
第二個,processFiltering
,處理更加密集,對電子郵件執行更深入的操作,最終將郵件移至最終目的地。
cron 任務定期呼叫 processInbox
:
public function actionInbox() { // moves inbox messages to @filtering // runs frequently $r = new Remote(); $r->processInbox(); }
對於每個帳戶,我們都會解密您的電子郵件憑證,然後使用 imap_open 建立指向您的收件匣資料夾的 IMAP 流:
public function open($account_id, $mailbox='',$options=NULL) { // opens folder in an IMAP account $account = Account::model()->findByPk($account_id); $this->hostname = $account->address; if (!stristr($this->hostname,'{')) $this->hostname = '{'.$this->hostname.'}'; $cred = Account::model()->getCredentials($account->cred); if ($account->provider == Account::PROVIDER_ICLOUD) { // icloud accepts only name part of mailbox e.g. stevejobs vs. stevejobs@icloud.com $temp = explode('@',$cred[0]); $cred[0]=$temp[0]; } $this->stream = imap_open($this->hostname.$mailbox,$cred[0],$cred[1],$options,1) or die('Cannot connect to mail server - account_id:'.$account_id .' '.print_r(imap_errors())); }
在 processInbox
中,我們使用 PHP 函式庫函數 imap_search 和 imap_fetch_overview 來檢索訊息數組:
// lookup folder_id of this account's INBOX $folder_id = Folder::model()->lookup($account_id,$this->path_inbox); $this->open($account_id,$this->path_inbox); $cnt=0; $message_limit= 50; // break after n messages to prevent timeout echo 'Sort since: '.date("j F Y",$tstamp); // imap_search date format 30 November 2013 $recent_messages = @imap_search($this->stream, 'SINCE "'.date("j F Y",$tstamp).'"',SE_UID); if ($recent_messages===false) continue; // to do - continue into next account $result = imap_fetch_overview($this->stream, implode(',',array_slice($recent_messages,0,$message_limit)),FT_UID);
然後我們處理收件匣中的消息陣列:
foreach ($result as $item) { if (!$this->checkExecutionTime($time_start)) break; // get msg header and stream uid $msg = $this->parseHeader($item);
這是公開可用的 IMAP 標頭解析程式碼的改編版本,它收集 SE 完成各種任務所需的附加資訊。基本上,它使用 imap_rfc822_parse_adrlist 來確定收件者資訊、郵件 ID、主題和時間戳記(或掃描已傳送資料夾時的寄件者資訊):
public function parseHeader($header) { // parses header object returned from imap_fetch_overview if (!isset($header->from)) { return false; } else { $from_arr = imap_rfc822_parse_adrlist($header->from,'gmail.com'); $fi = $from_arr[0]; $msg = array( "uid" => (isset($header->uid)) ? $header->uid : 0, "personal" => (isset($fi->personal)) ? @imap_utf8($fi->personal) : "", "email" => (isset($fi->mailbox) && isset($fi->host)) ? $fi->mailbox . "@" . $fi->host : "", "mailbox" => (isset($fi->mailbox)) ? $fi->mailbox : "", "host" => (isset($fi->host)) ? $fi->host : "", "subject" => (isset($header->subject)) ? @imap_utf8($header->subject) : "", "message_id" => (isset($header->message_id)) ? $header->message_id : "", "in_reply_to" => (isset($header->in_reply_to)) ? $header->in_reply_to : "", "udate" => (isset($header->udate)) ? $header->udate : 0, "date_str" => (isset($header->date)) ? $header->date : "" ); // handles fetch with uid and rfc header parsing if ($msg['udate']==0 && isset($header->date)) { $msg['udate']=strtotime($header->date); } $msg['rx_email']=''; $msg['rx_personal']=''; $msg['rx_mailbox']=''; $msg['rx_host']=''; if (isset($header->to)) { $to_arr = imap_rfc822_parse_adrlist($header->to,'gmail.com'); $to_info = $to_arr[0]; if (isset($to_info->mailbox) && isset($to_info->host)) { $msg['rx_email']=$to_info->mailbox.'@'.$to_info->host; } if (isset($to_info->personal)) $msg['rx_personal']=$to_info->personal; if (isset($to_info->mailbox)) $msg['rx_mailbox']=$to_info->mailbox; if (isset($to_info->host)) $msg['rx_host']=$to_info->host; } return $msg; } }
我們在資料庫中為寄件者和郵件信封建立記錄:
// skip any system messages if ($msg['email']==$system_email) continue; // if udate is too old, skip msg if (time()-$msg['udate']>$this->scan_seconds) continue; // skip msg // default action $action = self::ACTION_MOVE_FILTERED; $isNew = $s->isNew($account_id,$msg["email"]); // look up sender, if new, create them $sender_id = $s->add($user_id,$account_id,$msg["personal"], $msg["mailbox"], $msg["host"],0); $sender = Sender::model()->findByPk($sender_id); // create a message in db if needed $message_id = $m->add($user_id,$account_id,0,$sender_id,$msg['message_id'],$msg['subject'],$msg['udate'],$msg['in_reply_to']); $message = Message::model()->findByPk($message_id);
如果寄件者對我們來說是新的(未知),我們將發送一封白名單質詢電子郵件(我們將在下面的下一部分中詳細討論白名單質詢):
if ($isNew) { $this->challengeSender($user_id,$account_id,$sender,$message); }
接下來,我們確定使用者是否可能已將郵件從另一個資料夾拖回收件箱 - 打算透過拖放來訓練它。如果是這樣,我們會將此寄件者的訓練設定到收件匣。換句話說,下次我們只想將郵件從該寄件者路由到收件匣:
if ($message['status'] == Message::STATUS_FILTERED || $message['status'] == Message::STATUS_REVIEW || ($message['status'] == Message::STATUS_TRAINED && $message['folder_id'] <> $folder_id) || ($message['status'] == Message::STATUS_ROUTED && $message['folder_id'] <> $folder_id)) { // then it's a training $action = self::ACTION_TRAIN_INBOX; } else if (($message['status'] == Message::STATUS_TRAINED || $message['status'] == Message::STATUS_ROUTED) && $message['folder_id'] == $folder_id) { // if trained already or routed to inbox already, skip it $action = self::ACTION_SKIP; echo 'Trained previously, skip ';lb(); continue; }
如果沒有,我們將準備將郵件移至「過濾」資料夾以進行進一步處理。首先,如果通知的寄件者相符或關鍵字匹配(並且不是安靜時間),我們可能會向用戶的手機發送通知:
if ($action == self::ACTION_MOVE_FILTERED) { $cnt+=1; if ($sender->exclude_quiet_hours == Sender::EQH_YES or !$this->isQuietHours($user_id)) { // send smartphone notifications based on sender if ($sender->alert==Sender::ALERT_YES) { $this->notify($sender,$message,Monitor::NOTIFY_SENDER); } // send notifications based on keywords if (AlertKeyword::model()->scan($msg)) { $this->notify($sender,$message,Monitor::NOTIFY_KEYWORD); } } // move imap msg to +Filtering echo 'Moving to +Filtering';lb(); //$result = @imap_mail_move($this->stream,$msg['uid'],$this->path_filtering,CP_UID); $result = $this->messageMoveHandler($msg['uid'],$this->path_filtering,false); if ($result) { echo 'moved<br />'; $m->setStatus($message_id,Message::STATUS_FILTERED); } }
如果訊息被拖曳到收件箱,那麼我們將更新我們的訓練設定:
else if ($action == self::ACTION_TRAIN_INBOX) { // set sender folder_id to inbox echo 'Train to Inbox';lb(); $m->setStatus($message_id,Message::STATUS_TRAINED); // only train sender when message is newer than last setting if ($msg['udate']>=$sender['last_trained']) { $s->setFolder($sender_id,$folder_id); } }
二次處理方法稱為processFiltering
,也在DaemonController.php
。它完成了將郵件移至適當資料夾的更耗時的工作:
public function actionIndex() { // processes messages in @Filtering to appropriate folders $r = new Remote(); $r->processFiltering(); // Record timestamp of cronjob for monitoring $file = file_put_contents('./protected/runtime/cronstamp.txt',time(),FILE_USE_INCLUDE_PATH); }
此方法會開啟您的電子郵件帳戶來搜尋最近的郵件並收集有關它們的資料。它也使用 imap_search
、imap_fetch_overview
和 parseHeader
:
$tstamp = time()-(7*24*60*60); // 7 days ago $recent_messages = @imap_search($this->stream, 'SINCE "'.date("j F Y",$tstamp).'"',SE_UID); if ($recent_messages===false) continue; // to do - continue into next account $result = imap_fetch_overview($this->stream, implode(',',array_slice($recent_messages,0,$message_limit)),FT_UID); foreach ($result as $item) { $cnt+=1; if (!$this->checkExecutionTime($time_start)) break; // get msg header and stream uid $msg = $this->parseHeader($item);
過濾資料夾中每個訊息的主要處理循環非常詳細。首先我們查看收件者地址,因為 SE 允許人們透過收件者地址來訓練資料夾,例如發送至 happyvegetarian.com 網域的郵件將轉到 veggie 資料夾:
// Set the default action to move to the review folder $action = self::ACTION_MOVE_REVIEW; $destination_folder =0; // look up & create recipient $recipient_id = $r->add($user_id,$account_id,$msg['rx_email'],0); $routeByRx = $this->routeByRecipient($recipient_id); if ($routeByRx!==false) { $action = $routeByRx->action; $destination_folder = $routeByRx->destination_folder; }
然後我們尋找寄件者並在資料庫中建立新記錄(如有必要)。如果發送者存在訓練,我們可以設定目標資料夾:
// look up sender, if new, create them $sender_id = $s->add($user_id,$account_id,$msg["personal"], $msg["mailbox"], $msg["host"],0); $sender = Sender::model()->findByPk($sender_id); // if sender destination known, route to folder if ($destination_folder ==0 && $sender['folder_id'] > 0) { $action = self::ACTION_ROUTE_FOLDER; $destination_folder = $sender['folder_id']; }
如果未經訓練的(新)寄件者已透過白名單質詢(我們將在下面的下一節中討論)驗證自己,那麼我們會將此郵件路由到收件匣:
// whitelist verified senders go to inbox if ($sender->is_verified==1 && $sender['folder_id'] ==0 && UserSetting::model()->useWhitelisting($user_id)) { // place message in inbox $action = self::ACTION_ROUTE_FOLDER; $destination_folder = Folder::model()->lookup($account_id,$this->path_inbox); }
然後,我們在資料庫中建立一個訊息條目,其中包含有關此訊息的信封資訊:
// create a message in db $message = Message::model()->findByAttributes(array('message_id'=>$msg['message_id'])); if (!empty($message)) { // message exists already, $message_id = $message->id; } else { $message_id = $m->add($user_id,$account_id,0,$sender_id,$msg['message_id'],$msg['subject'],$msg['udate'],$msg['in_reply_to']); }
如果郵件來自未知、未經驗證的寄件者,我們可以將郵件移至審閱資料夾。審閱資料夾包含我們無法辨識的寄件者的所有郵件。
如果郵件來自已知寄件人,並且我們已確定目的地,只要不是安靜時間(且請勿打擾已關閉),我們就可以將其移動:
if ($recipient_id!==false) $m->setRecipient($message_id,$recipient_id); if ($action == self::ACTION_MOVE_REVIEW) { echo 'Moving to +Filtering/Review';lb(); //$result = @imap_mail_move($this->stream,$msg['uid'],$this->path_review,CP_UID); $result = $this->messageMoveHandler($msg['uid'],$this->path_review,false); if ($result) { echo 'moved<br />'; $m->setStatus($message_id,Message::STATUS_REVIEW); } } else if ($action == self::ACTION_ROUTE_FOLDER || $action == self::ACTION_ROUTE_FOLDER_BY_RX) { // lookup folder name by folder_id $folder = Folder::model()->findByPk($destination_folder); // if inbox & quiet hours, don't route right now if (strtolower($folder['name'])=='inbox' and $sender->exclude_quiet_hours == Sender::EQH_NO and $this->isQuietHours($user_id)) continue; echo 'Moving to '.$folder['name'];lb(); $mark_read = Folder::model()->isMarkRead($folder['mark_read']) || Sender::model()->isMarkRead($sender['mark_read']); //$result = @imap_mail_move($this->stream,$msg['uid'],$folder['name'],CP_UID); $result = $this->messageMoveHandler($msg['uid'],$folder['name'],$mark_read); if ($result) { echo 'moved<br />'; $m->setStatus($message_id,Message::STATUS_ROUTED); $m->setFolder($message_id,$destination_folder); } }
在安靜時間,郵件主要保存在過濾資料夾中。
每隔幾個小時,就會有一個不同的程序使用訊息表記錄建立訊息摘要,以確定最近收到和過濾的電子郵件以及它們的路由方式。
白名單挑戰的目標是保留來自未知寄件者的任何訊息,例如可能是您收件匣中的行銷機器人或垃圾郵件發送者。 SE 將來自未知寄件者的郵件放入審閱資料夾中。但是,如果您打開白名單,我們會發送一封質疑電子郵件,讓寄件者有機會驗證自己是否是人類。如果他們回复,我們會將郵件移至您的收件匣。如果結果顯示該電子郵件是不需要的,您可以從摘要中刪除該郵件或將其拖曳到您想要將其訓練到的任何資料夾中。
使用者可以在設定中開啟和關閉白名單:
為了實作白名單,每當新寄件者收到郵件時,我們都會發送電子郵件質詢:
if ($isNew) { $this->challengeSender($user_id,$account_id,$sender,$message); }
ChallengeSender
向使用者發送一個編碼連結供他們點擊。我們也採取了一些保護措施,以確保我們不會陷入帶有外出訊息的電子郵件循環:
public function challengeSender($user_id,$account_id,$sender,$message) { // whitelist email challenge $yg = new Yiigun(); $ac = Account::model()->findByPk($account_id); if (!empty($ac['challenge_name'])) $from = $ac['challenge_name'].' <no-reply@'.$yg->mg_domain.'>'; else $from = 'Filter <no-reply@'.$yg->mg_domain.'>'; $cred = Account::model()->getCredentials($ac->cred); $account_email = $cred[0]; unset($cred); // safety: checks no recent email if ($sender->last_emailed>(time()-(48*60*60))) return false; if ($sender->isBot($sender['email'])) { // to do - can also set this person to bulk by default return false; } $link=Yii::app()->getBaseUrl(true)."/sender/verify/s/".$sender->id."/m/".$message->id.'/u/'.$message->udate; $subject = 'Please verify the message you sent to '.$account_email; $body="<p>Hi,<br /><br /> I'm trying to reduce unsolicited email. Could you please verify your email address by clicking the link below:<br /><a href=\"".$link.'">'.$link.'</a><br /><br />Verifying your email address will help speed your message into my inbox. Thanks for your assistance!</p>'; $yg->send_html_message($from, $sender['email'], $subject,$body); // update last_emailed $sender->touchLastEmailed($sender->id); }
然後,如果收件者點擊編碼鏈接,我們會在資料庫中驗證它們。發送者控制器處理這些請求並檢查它們的有效性:
public function actionVerify($s = 0, $m=0,$u=0) { // verify that secure msg url from digest is valid, log in user, show msg $sender_id = $s; $message_id = $m; $udate = $u; $msg = Message::model()->findByPk($message_id); if (!empty($msg) && $msg->sender_id == $sender_id && $msg->udate == $udate) { $result = 'Thank you for your assistance. I\'ll respond to your email as soon as possible.'; $a = new Advanced(); $a->verifySender($msg->account_id,$sender_id); } else { $result = 'Sorry, we could not verify your email address.'; } $this->render('verify',array( 'result'=>$result, )); }
這告訴我們的處理循環將此訊息和將來的訊息從該寄件者移動到收件匣。
有时,查看您已发送但未收到回复的消息摘要会有所帮助。为了识别这些邮件,Simplify Email 会监视已发送但尚未收到回复的邮件。
我们收到的每条消息都包含一个唯一的 ID,称为 message_id(IMAP 规范的一部分)。它通常看起来像这样:
Message-Id: <CALe0OAaF3fb3d=gCq2Fs=Ex61Qp6FdbiA4Mvs6kTQ@mail.gmail.com>
此外,当发送消息以回复其他消息时,它们有一个 in_reply_to
字段,该字段链接回原始 message_id
。
因此,我们使用 SQL 查询来查找所有收到的消息,这些消息没有引用其 message_id
的相应回复消息。为此,我们在没有 in_reply_to
id 的情况下使用 LEFT OUTER JOIN:
public function getUnanswered($account_id,$mode=0, $range_days = 7) { if ($mode==0) $subject_compare = 'not'; else $subject_compare = ''; $query = Yii::app()->db->createCommand("SELECT fi_sent_message.id, fi_sent_message.recipient_id as sender_id,fi_sent_message.subject,fi_sent_message.udate,fi_message.in_reply_to,fi_sent_message.message_id FROM fi_sent_message LEFT OUTER JOIN fi_message ON fi_message.in_reply_to = fi_sent_message.message_id WHERE fi_sent_message.account_id = ".$account_id." AND fi_message.in_reply_to is null and fi_sent_message.udate > ".(time()-(3600*24*$range_days))." and fi_sent_message.subject ".$subject_compare." like 'Re: %' ORDER BY fi_sent_message.udate DESC")->queryAll(); return $query; }
我们使用 $subject_compare
模式来区分我们发送的尚未回复的消息和我们发送给尚未回复的线程的回复。以下是您帐户中的未回复消息报告:
SE 还将此信息作为可选摘要提供,称为未回复电子邮件摘要。您可以每天、每隔几天或每周收到它。
我们还使用类似的 SQL 表格和 Google Charts 来提供有关某些人向您发送电子邮件的频率的报告:
public function reportInbound($account_id,$range=30,$limit = 100) { $result= Yii::app()->db->createCommand('SELECT fi_sender.personal, fi_sender.email,count(sender_id) as cnt FROM fi_message LEFT JOIN fi_sender ON fi_sender.id =fi_message.sender_id WHERE fi_sender.account_id = :account_id AND fi_message.created_at > DATE_SUB( NOW() , INTERVAL :range DAY ) GROUP BY sender_id ORDER BY cnt desc LIMIT :limit ')->bindValue('range',$range)->bindValue('account_id',$account_id)->bindValue('limit',$limit)->queryAll(); return $result; }
我很快就会撰写更多有关 Tuts+ 的 Google Charts 的文章。
我希望您已经发现 Simplify Email 足够有趣,可以尝试 PHP IMAP 编程。您可以构建许多很酷的功能,而不需要大型电子邮件提供商做任何新的事情。
如果您有任何疑问或更正,请在评论中提出。如果您想继续关注我未来的 Tuts+ 教程和其他系列,请关注 @reifman 或访问我的作者页面。您也可以在这里联系我。
以下是一些您可能会觉得有用的附加链接:
以上是使用IMAP和PHP建立進階電子郵件功能的詳細內容。更多資訊請關注PHP中文網其他相關文章!