electron-vue郵件客戶端總結

evanoooo發表於2018-05-11

關於專案

這是我的畢業設計(2018),郵件客戶端

包含收發郵件、通訊錄、多賬戶登入、本地資料儲存等功能

github:github.com/ooooevan/Vm…

使用的相關模組

  • 用vue-cli構建electron-vue專案
  • 用node-imap模組接收郵件
  • 用nodemailer傳送郵件
  • 用element-ui做樣式框架
  • 用lowdb做本地資料儲存
  • 用iconv-lite、quoted-printable、utf8等處理編碼
  • 用vue-quill-editor做富文字編輯器

除錯執行

npm run dev    # 除錯執行,localhost:9080

npm run build  # 打包,安裝包在build目錄
複製程式碼

頁面截圖

收件箱

郵箱詳情

寫郵件

通訊錄

專案目錄

最外層結構是由electron-vue建立,主要看src的結構

─ src
 ├── main
 │  ├── index.js                         #主程式,建立渲染程式
 ├── models                              #定義模型,用於封裝物件
 ├── renrender                           #渲染程式,裡面就是一個vue專案目錄
 │  ├── common                           #一些重要的js函式與公共樣式
 │      ├── javascript
 │          ├── cache.js                 #硬碟存取相關函式
 │          ├── config.js                #存放配置及正規表示式
 │          ├── getEmail.js              #獲取email的函式
 │          ├── parseEmail.js            #解析email的函式
 │          ├── sendEmail.js             #傳送email的函式
 │      ├── style
 │  ├── components                       #存放元件
 │  ├── pages                            #存放頁面
 │  ├── router                           #路由
 │  ├── store                            #vuex的store相關檔案
 │  ├── app.vue                          #vue頁面最外層結構
 │  ├── main.js                          #vue專案入口
 ├── index.ejs                           #electron頁面入口
複製程式碼

開發過程

關於electron和vue

electron將chromium和nodejs合併到同一執行時環境中,可以用html、css、javascript來構建跨平臺的桌面應用。說白了就是我們寫網頁的同時還可以呼叫nodejs的api(如呼叫fs模組儲存資料到電腦),然後electron幫我們打包成一個跨平臺的桌面應用。 vue是當前主流mvvm框架之一,這裡就不多介紹了,用到了vuex、router等,不懂的話需要先去了解一下才能看懂專案

本專案用vue-vli初始化electron-vue,開發方便

vue init simulatedgreg/electron-vue Vmail
複製程式碼

初始化選項

打包選的是electron-builder,這個工具可以直接打包安裝包,而electron-packager打包成可執行檔案

專案思路分解

專案主體是郵件,比較重要的有四步:獲取解析郵件、儲存郵件、顯示郵件和傳送郵件 獲取和傳送郵件要根據郵件協議來分析

獲取與解析郵件

讀取郵件的協議有pop3(Post Office Protocol)、imap(Internet Message Access Protocol)。pop3簡單但互動性較弱。imap較複雜,可互動性強,是一個聯機協議,如可以獲取郵件後將郵件置為已讀,而pop3協議是隻讀的。 如果要自己實現獲取協議會比較麻煩,去github逛了一圈,發現node-imap這個庫挺不錯的,就用了它

關於密碼要先去郵件伺服器開通獲取,如qq:郵箱->設定->賬號->imap服務,開啟(需要自己手動儲存密碼)

專案中獲取郵件有兩個方法:一個是獲取一個完整的郵件(getEmailDetail),一個獲取一組郵件頭(getEmailList)。如在登陸成功後,會自動獲取郵件列表顯示出來,此時是呼叫getEmailList。當點選某一個郵件時,會自動獲取一個完整的郵件,呼叫getEmailDetail。node-imap這個庫包含著兩個功能,還支援很多不同引數,可自行去github熟悉。

下面重點講解析一封郵件

郵件有郵件頭和郵件體兩部分。郵件頭的格式基本都是一樣的,而郵件體格式就多種多樣了,因為有很多型別如:純文字,html頁面,包含附件等等 第一步是看Content-Type

  1. text,主要有text/html和text/plain,內容需要用Content-Transfer-Encoding解碼,常見傳輸編碼為base64和quoted-printable
  2. multipart,又分為mixed、alternative和related。multipart有boundary分割符,將郵件體分割成不同段
    • mixed是有附件的型別
    • alternative是純文字和超文字同時存在的型別
    • related是資源內嵌型別,如內容為html,但html裡有圖片,把圖片提取出來以附件形式傳送
  3. image、application,一般是出現在附件中的格式

第二步看boundary

只有multipart型別才有boundary,因為這種型別比較複雜,需要用boundary分段解析

Content-Type: multipart/mixed;
  boundary="----=_NextPart_5A640E3E_0AF97620_651579F6"
複製程式碼

這裡的boundary是一串字元,但是分割不是直接用boundary,而是用父段和子段來分割

父段: '--' + boundary + '--'

子段: '--' + boundary

據我觀察,父段只出現0次或1次並且在最後的位置(可能我遇到的郵件型別有限),所以內容就是分割後的陣列的第一個元素:emailText = emailText.split(fatherBoundary)[0].trim() 子段將內容分為不同的段,每個子段需要單獨重新解析,因為裡面也有自己的Content-typeboundary,所以一封郵件可能出現兩個不同的boundary

第三步解析 若分割後的段是html或附件等,直接根據charset和encoding等解析;若仍是multipart型別,則用同樣的思路再次解析(關於編碼解析下面有說)

下面舉個例子(已刪除部分不必要的):

From: "=?gb18030?B?amlhbmJvKw==?=" <490549111@qq.com>
To: "=?gb18030?B?amlhbmJvKw==?=" <635638508@qq.com>
Subject: 123
Content-Type: multipart/mixed;
  boundary="----=_NextPart_5A5F05FC_0A84FD10_42CF1A15"
Content-Transfer-Encoding: 8Bit
Date: Wed, 17 Jan 2018 16:14:52 +0800

This is a multi-part message in MIME format.

------=_NextPart_5A5F05FC_0A84FD10_42CF1A15
Content-Type: multipart/alternative;
  boundary="----=_NextPart_5A5F05FC_0A84FD10_3247BB56";

------=_NextPart_5A5F05FC_0A84FD10_3247BB56
Content-Type: text/plain;
  charset="gb18030"
Content-Transfer-Encoding: base64

MTINCjM=

------=_NextPart_5A5F05FC_0A84FD10_3247BB56
Content-Type: text/html;
  charset="gb18030"
Content-Transfer-Encoding: base64

PGRpdj4xMjwvZGl2PjxkaXY+MzwvZGl2Pg==

------=_NextPart_5A5F05FC_0A84FD10_3247BB56--

------=_NextPart_5A5F05FC_0A84FD10_42CF1A15
Content-Type: application/octet-stream;
  charset="gb18030";
  name="=?gb18030?B?08q8/s/qx+kucG5n?="
Content-Disposition: attachment; filename="=?gb18030?B?08q8/s/qx+kucG5n?="
Content-Transfer-Encoding: base64

iVBORw0KGgoAAAANSUhEUgAAA2QAAAHRCAIAAACKEu1wAAAAAXNSR0IArs4c6QAAA...(很長,省略)

------=_NextPart_5A5F05FC_0A84FD10_42CF1A15--

複製程式碼

分析:

  1. Content-type是multipart/mixed,說明是包含附件型別
  2. 父段出現在最後,分割後的陣列取第一項即可
  3. 根據子段分割,段內有各自的boundary、Content-type和charset等。
    1. 第一段是一個alternative的小郵件,包含純文字和超文字。再根據boundary分割即可
      1. 解析後可的到純文字內容為12\r\n3
      2. 解析後可的到超文字內容為<div>12</div><div>3</div>
    2. 第二段是application型別附件,根據charset和encoding解析得到檔名是郵件詳情.png

編寫時要注意的問題

  1. 遇到過Content-type為multipart/related;type="multipart/alternative";boundary="----=_NextPart_5A6951CD_6F185580_3879981A,這樣要算related,不能算alternative,按複雜的那個算
  2. 觀察發現related型別和mixed型別的解析規則一樣
  3. 有些郵件一些值不全(如沒有charset),需要設定預設值
  4. base64值解析錯誤,是因為base64有換行符,需要去掉

儲存郵件

分析了郵件的型別,那儲存郵件就不難了。下面是剛解析完的郵件物件格式

── attr   #有郵件uid等簡單資訊
── body
   ├──attachment   #附件
   ├──bodyHtml      #超文字
   ├──bodyText      #純文字
── emailText        #完整的郵件文字
── header           #頭部資訊
複製程式碼

我們需要根據郵件Content-type進行轉換再儲存,不然的話就要在顯示時在判斷不同型別不同處理。顯然儲存前處理更好

  1. 若是html型別,則將bodyHtml單獨存入一個檔案,bodyHtml的值為檔案路徑。這裡需要考慮有的html並不是完整頁面而是一個片段
  2. 若是mixed型別,將attachment存入單獨檔案,同樣存為檔案路徑
  3. 對於alternative和related,到這裡已經不用單獨考慮。因為alternative是純文字和超文字共存,也就是重複的,超文字是html片段,包含格式,而純文字只有文字,直保留超文字即可;related是和mixed解析規則一樣,並需要將資源拼合成完整html,將html單獨儲存即可。
if (contentType.match(htmlTypeReg)) {...}  //單獨儲存html
else if (contentType.match(mixedMultipart)) {...} // 單獨儲存附件
if (!contentType.match(htmlTypeReg)) {...}  //非htmlTypeReg也進行單獨儲存html
複製程式碼

顯示郵件

進行了很規則的儲存,所以顯示時邏輯就很清晰了

  1. bodyHtml以.html結尾,則是html路徑,用webview的src引入
  2. bodyHtml不是路徑,則將html片段插入
  3. 沒有bodyHtml,則將bodyText插入
  4. 有attachment則顯示,沒有就不顯示
if (bodyHtml.indexOf(HTML) && bodyHtml.indexOf(HTML) + HTML.length === bodyHtml.length) {...} //html是路徑
else if (bodyHtml) {...} //html不是路徑
else {...} //沒有html,只能取bodyText
if (detail.body.attachment && detail.body.attachment.length) {...} //顯示附件
複製程式碼

傳送郵件

傳送郵件有stmp(Simple Mail Transfer Protocol),github有現成較成熟的nodemailer,無論傳送html還是附件,都非常簡單。

開發遇到的問題

編碼

郵件最開始獲取的是流,需要一個編碼轉為最初的字串。我用的是gb18030解碼 關於gb系列編碼可以自行了解,簡單提一下:最初只有ascii,中國想顯示中文,就有了gb2312、gbk等,從簡體中文慢慢加入繁體字等,最後更新的版本是gb18030,所以是gb系列直接用gb18030即可,因為它向下相容。

解析最初的流好像用gb18030或utf-8都可以,因為各個部分都已經用base64或其他編碼轉為ascii碼了。

From: "=?gb18030?B?amlhbmJvKw==?=" <490549111@qq.com>
複製程式碼

上面的字串反應了兩個事: 1、字符集為gb18030,即本郵件由gb18030編碼 2、B代表base64,後面的字元用base64編碼

解析的思路是先用base64轉為buffer,在用gb18030字符集轉為字串 解析的方法是iconv.decode(iconv.encode('amlhbmJvKw==?=','base64'),'gb18030')

From: =?UTF-8?B?6Zi/6YeM5LqR?= <system@notice.aliyun.com>
複製程式碼

同樣的道理,這是utf-8字符集的base64編碼 解析的方法是iconv.decode(iconv.encode('B?6Zi/6YeM5LqR?=','base64'),'utf-8')

iconv.decode(iconv.encode('阿里郎阿里雲','gb18030'),'utf-8')
iconv.decode(iconv.encode('6Zi/6YeM6YOO6Zi/6YeM5LqR','base64'),'gb18030')
複製程式碼

如果gb18030和utf-8混用了,那就出現亂碼了,因為他們字符集不一樣,同一個編碼代表的文字不一樣。

上面的第一行輸出�����ɰ�����。第二行輸出闃塊噷閮庨樋閲屼簯

對於boundary段內的內容如:

------=_NextPart_5A640E3E_0AF97620_02509F49
Content-Type: text/plain;
  charset="gb18030"
Content-Transfer-Encoding: base64

cXdlenhj
複製程式碼

裡面清楚寫了字符集和傳輸編碼,按它規則解析即可得到純文字qwezxc

使用imap的node-imap相關

使用node-imap模組,一方面是較靈活,另一方面是可以同步狀態。最大的問題是發現同步狀態失敗,根據文件下面這樣就可以標記郵件已讀,但是怎麼都失敗。。可能是支援性不夠

imap.openBox('INBOX', false, cb);       //openBox不能是readOnly,置false
let f = imap.fetch(results, { bodies: '', markSeen: true }); //markSeen為true
複製程式碼

文件api比較多,引數也多,但是很多得到的結果不一致,要多觀察多測試,查出哪個是要用的api。

electron最小化,全屏按鈕和無邊框

預設的視窗是有系統邊框的,我不喜歡,只要再建立渲染程式是配置去掉即可

  mainWindow = new BrowserWindow({
    height: 563,
    width: 1000,
    useContentSize: true,
    autoHideMenuBar: true,
    title: 'Vmail',
    disableAutoHideCursor: true,
    frame: false // 沒有邊框
  })
複製程式碼

沒有了邊框,就要手動新增介面拖動。

<header style="-webkit-app-region: drag">
複製程式碼

這樣,header就可以拖動了。但要注意,只有寫行內樣式才起效。同時會導致裡面標籤的hover不觸發,要想觸發,就要將這個標籤設定不可拖動

<div class="refresh fl" style="-webkit-app-region: no-drag">
複製程式碼

下面是最小化和全屏的部分程式碼

const { remote } = require('electron')
...
data () {
  return {
    isFullScreen: false  //當前是否全屏狀態
  }
},
mounted () {
  window.addEventListener('resize', () => {  //當resize時檢測是否全屏
    this.isFullScreen = remote.getCurrentWindow().isMaximized()
  })
},
methods: {
  close () {
    remote.getCurrentWindow().close()  //點選關閉,停止渲染程式
  },
  minimize () {
    remote.getCurrentWindow().minimize()  //視窗最小化
  },
  full () {
    const browserWindow = remote.getCurrentWindow()  //全屏toggle
    if (browserWindow.isMaximized()) {
      browserWindow.unmaximize()
      this.isFullScreen = false
    } else {
      browserWindow.maximize()
      this.isFullScreen = true
    }
  },
複製程式碼

判斷全屏還有browserWindow.isFullScreen(),發現雙擊拖動欄全屏不能正確返回,browserWindow.isMaximized()可以正確判斷。

關於事件監聽,要用addEventListener,不能用onresize,因為多個地方用到了resize,用onresize會相互覆蓋

打包

專案是用electron-builder打包,第一次build要下載依賴。因為資源在牆外,要翻牆,否則報錯。也可以嘗試手動下載,根據命令列的提示下載對應的檔案

npm應該設定映象,配置檔案為~/.npmrc

registry=https://registry.npm.taobao.org
sass_binary_site=https://npm.taobao.org/mirrors/node-sass/
phantomjs_cdnurl=http://npm.taobao.org/mirrors/phantomjs
ELECTRON_MIRROR=http://npm.taobao.org/mirrors/electron/
複製程式碼

www.cnblogs.com/chenweixuan… blog.csdn.net/bailong1/ar…

其他

下圖是硬碟儲存結構

其中config.js儲存所有使用者和當前使用者

每個郵箱目錄都有一個index檔案,存著各種郵件列表,具體html或附件都單獨提取出來了

硬碟儲存結構
郵箱使用者目錄結構

開始是用qq郵箱測試,之後用其他郵箱測試,基本是沒問題的,因為大多數都是根據標準來收發郵件。測試了qq、163、aliyun等都基本沒問題。(163需要多一個授權步驟)

專案缺點

專案做的比較粗糙,有些功能時不完善的。比如草稿箱,寫郵件時的右側快捷選擇收件人,webview不能自適應高度等。功能也不多,如沒有快捷回覆郵件功能等。

總結

此次專案,業務不難,主要是郵件解析部分比較繞。我沒有查閱權威的文件,所以可能有缺陷。

匹配字串用到了大量正規表示式,我寫的正則也不是很好

對於vue專案結構,vuex等知識不是重點,所以我一筆帶過

以上就是對專案的總結,如果有錯,望指正

參考: MIME---multipart型別

郵件編碼Content-Transfer-Encoding的各種形式

相關文章