iT邦幫忙

0

Gmail資料自動儲存並分類至Drive

前陣子朋友跟我說了一個需求,思考了一下想說花的時間不多順手就做了

主功能

自動將Gmail信件的附檔分類並儲存至Google Drive指定的資料夾
朋友說以往都是每個信件手動下載再逐一上傳,他稱之為歸檔的動作

功能演示

這是主要也是唯一的頁面,首先要進行登入,就如同一般使用Google登入會詢問權限相關的描述,反正就同意下一步就對了
https://ithelp.ithome.com.tw/upload/images/20200914/20126774TchiJ1SHdj.png

接下來建置一個資料夾,準備歸檔用,我就建了一個Test
https://ithelp.ithome.com.tw/upload/images/20200914/20126774zPHE7aggXZ.png

然後必須將資料夾權限分享給Service account,這個Service account後續技術部份會再說明,如果沒分享的話帳號沒有權限操作Google Drive是沒辦法正常運作的
https://ithelp.ithome.com.tw/upload/images/20200914/20126774gzlHFMBxwk.png

最後再回到主畫面,找到資料夾,確認自動對應上的ID是否正確,然後就開始歸檔啦
https://ithelp.ithome.com.tw/upload/images/20200914/20126774vNnCNB4WC5.png


技術部分

前端Html, css, javascript
後端Nodejs

運作流程

前端Google Login,透過帳號oauth呼叫Gmail API取得信件列表
-> 透過信件列表的ID逐一取得每個信件的詳細資料(附加檔案為Base64格式)
-> 將附加檔案透過內部API傳送至Nodejs後端
-> 後端將檔案暫存至Server
-> 呼叫Drive上傳檔案API
-> 刪除暫存在Server的檔案

前置作業

相關的權限、帳號需要申請
https://ithelp.ithome.com.tw/upload/images/20200914/20126774UYgGkVAWbg.png

紅色框的OAuth是給前端Google登入 access gmail使用的
https://ithelp.ithome.com.tw/upload/images/20200914/20126774bRKz1gm53m.png

藍色框則Service account則是Drive需要的
這邊需要自行新增金鑰,這個金鑰會是Nodejs後端需要用到的身份驗證
https://ithelp.ithome.com.tw/upload/images/20200914/201267743V6MQwnxDb.png

實作

這邊就是基本的初始化設定
"google-signin-client_id" = 前置作業紅框的 OAuth用戶端編號

<meta name="google-signin-client_id" content="YOUR_CLIENT_ID.apps.googleusercontent.com">
<script src="https://apis.google.com/js/client.js?onload=loadAPIs" async defer></script>
<script src="https://apis.google.com/js/platform.js?onload=renderButton" async defer></script>

function loadAPIs() {
    gapi.client.load('gmail', 'v1');
}

function renderButton() {
    var gmailScope = 'https://www.googleapis.com/auth/gmail.readonly';
    gapi.signin2.render('signin', {
        'scope': ['profile', gmailScope].join(' '),
        'onsuccess': onSigninSuccess,
        'onfailure': onSigninFailure
    });
}

讀取Gmail信件,大致上是使用SDK呼叫,比較麻煩的是query部分的語法,雖然官網上都有各自的教學,但是在多條件合併使用的時候還是要注意一下,印象中連has:attachment擺前或後都有影響到搜尋結果(導致錯誤),可能我英文不太好所以沒有非常清晰的了解,各位看官可以親自去體驗一下

gapi.load('client:auth2', () => {
    gapi.client.load('gmail', 'v1', () => {
        var query = ''
        //after:2020-08-01 before:2020-08-10
        if($('#startDate').val() != '') {
            query = 'after:' + $('#startDate').val()
        }
        if($('#endDate').val() != '') {
            query += ' before:' + $('#endDate').val()
        }

        query += ' has:attachment'
        if($('#email').val() != '') {
            query += ' from:' + $('#email').val()
        }
        else {
            query += ' from:-' + profile.bu
        }

        // q=in:sent after:2014/01/01 before:2014/02/01
        var userId = profile.getId()
        var request = gapi.client.gmail.users.messages.list({
            userId: userId,
            q: query,
            maxResults: 20
        });

        request.execute(function(resp) {
            var messages = resp.messages;
            if(messages && messages.length > 0) {
                requestFullEmailMessage(userId, messages, 0, function() {
                })
            }
        });
    });
})

用遞迴的方式逐一讀取每一封Email的內容

function requestFullEmailMessage(userId, messages, index, callback) {
    var message = messages[index]
    var fullMessageRequest = gapi.client.gmail.users.messages.get({
        userId: userId,
        id: message.id
    });
    fullMessageRequest.execute(function(fullMessage) {
        var from = getFromEmail(fullMessage.payload.headers)
        var subject = getSubject(fullMessage.payload.headers)

        if(fullMessage.payload.parts && fullMessage.payload.parts.length > 0) {
            startCheckAndCreateFolder(fullMessage.id, fullMessage.payload.parts, 0, from, subject, function() {
                if(index + 1 < parseInt(messages.length)) {
                    requestFullEmailMessage(userId, messages, index + 1, callback)
                } else {
                    callback()
                }
            })
        }
    });
}

同樣是使用遞迴的方式,呼叫SDK的方法attachments.get另外取得附件資料
取得附件Base64資料後,呼叫本地的checkAndCreateFolder建置歸檔的資料夾
確認目的地存在後在呼叫saveAndUploadFile將Base64附件資料上傳至後端

function startCheckAndCreateFolder(messageId, payloadParts, index, folderName, subject, callback){
    var part = payloadParts[index]
    if(part.body.attachmentId && part.filename) {
        var attachmentId = part.body.attachmentId;
        var request = gapi.client.gmail.users.messages.attachments.get({
            'id': attachmentId,
            'messageId': messageId,
            'userId': 'me'
        });
        request.execute(function (attachment) {
            $.ajax({
                type: 'POST',
                url: '/api/gsuite/checkAndCreateFolder',
                data: {
                    "parentId": "Google Drive資料夾ID",
                    "folderName": folderName
                },
                success: function(result) {
                    if(result.status == true) {
                        var requestFileData = {
                            mime: part.mimeType,
                            filename: part.filename,
                            folderId: result.data.id,
                            data: attachment.data
                        }
                        $.ajax({
                            type: 'POST',
                            url: '/api/gsuite/saveAndUploadFile',
                            contentType: "application/json; charset=utf-8",
                            dataType: "json",
                            data: JSON.stringify(requestFileData),
                            success: function(uploadResult) {
                                if(index + 1 < payloadParts.length) {
                                    startCheckAndCreateFolder(messageId, payloadParts, index + 1, folderName, subject, callback)
                                } else {
                                    callback()
                                }
                            },
                            error:function(XMLHttpRequest, textStatus){
                                startCheckAndCreateFolder(messageId, payloadParts, index + 1, folderName, subject, callback)
                            }
                        });	
                    } else {
                        callback()
                    }
                },
                error:function(XMLHttpRequest, textStatus){
                    startCheckAndCreateFolder(messageId, payloadParts, index + 1, folderName, subject, callback)
                }
            });	
        });
    } else if(index + 1 < payloadParts.length){
        startCheckAndCreateFolder(messageId, payloadParts, index + 1, folderName, subject, callback)
    } else {
        callback()
    }
}

接下來來到後端的部分
首先第一個API是checkAndCreateFolder,功能是建置當下檔案要存放的資料夾
那這邊客戶的需求是只要按執行當下月份分類就可以,所以基本上只需要執行一次

這邊使用到的gsuiteHelper drive.files是googleapis sdk可以直接到npm這邊查看
https://www.npmjs.com/package/googleapis

其他參數我就不細講,這邊完全是根據客戶的需求去調整設定,如果方法執行沒有達到預想的效果,可以到文檔查詢一下有哪些設定值可以修改,調整一下或許就沒問題了
https://developers.google.com/drive/api/v3/enable-shareddrives

exports.checkAndCreateFolder = function(req, res) {
  var parentId = req.body.parentId
  var folderName = new Date().toISOString().substring(0, 7)
  folderName = folderName.split("-").join("");
  gsuiteHelper.listByParentId(parentId, function(err, folders) {
    var targetFolder = null
    folders.forEach(folder => {
      if(folder.name == folderName) {
        targetFolder = folder
      }
    });

    if(targetFolder == null) {
      gsuiteHelper.createFolder(parentId, folderName, function(err, folder) {
        //response
      })
    } else {
      // response
    }
  })
}

//gsuiteHelper
exports.listByParentId = function(parentId, callback) {
  drive.files.list({
      q: "trashed=false and '" + parentId + "' in parents",
      includeItemsFromAllDrives: true,
      supportsAllDrives: true
    }, (err, driveRes) => {
      if (err) {
          callback(err, null)
      }
      else {
          const files = driveRes.data.files;
          callback(null, files)
      }
  });
}

//gsuiteHelper
exports.createFolder = function(parentId, folderName, callback) {
  var fileMetadata = {
    'name': folderName,
    'mimeType': 'application/vnd.google-apps.folder',
    'parents': [parentId]
  };
  drive.files.create({
    resource: fileMetadata,
    fields: 'id',
    supportsAllDrives: true,
    supportsTeamDrives: true
  }, function (err, file) {
    if (err) {
      callback(err, null)
    } else {
      callback(null, file)
    }
  });
}

到了最後一步saveAndUploadFile API
這邊是用fs套件做本地的檔案讀寫操作
首先要確認資料夾是否可以讀寫(通常我都會先手動chmod),再將Base64資料轉為檔案存在Server指定的資料夾
存下後在呼叫drive.files.create API上傳檔案到Drive,確認完成後再刪除Server暫存檔,避免佔空間

exports.saveAndUploadFile = function(req, res) {
  var tmpFolder = './privateFile/uploads'
  var tmpFile = './privateFile/uploads/' + req.body.filename;
  fs.access(tmpFolder, fs.constants.F_OK | fs.constants.W_OK, (err) => {
    if (err) {
      //response err
    } else {
      fs.writeFile(tmpFile, req.body.data, { encoding: 'base64' }, function(err) {
        if(err) {
          //response err
        } else {
          gsuiteHelper.createFile({
            'name': req.body.filename,
            'parents': [req.body.folderId]
          }, {
            mimeType: req.body.mime,
            body: fs.createReadStream(tmpFile)
          }, function(err, result) {
            if(err) {
              //response err
            } else {
              if(result.status == 200) {
                fs.unlink(tmpFile, function() {
                  resultHelper.responseSuccess(res, result.data);
                });
              } else {
                //response err
              }
            }
          })
        }
      });
    }
  });
}

//gsuiteHelper
exports.createFile = function(fileMetadata, media, callback) {
  drive.files.create({
    resource: fileMetadata,
    media: media,
    fields: 'id',
    supportsAllDrives: true,
    supportsTeamDrives: true
  }, function (err, file) {
    if (err) {
      callback(err, null)
    } else {
      callback(null, file)
    }
  });
}

整個功能到這邊是算完成了,上面這些是局部的程式,有些地方我手動刪除了,不過重點都有在

檢討

其實這次開發滿多地方寫的滿髒的,有些方法也用得不好,例如遞迴、或重複檢查資料夾是否存在,都可以再進行優化。
不過使用的人很少,量很小,也沒收錢,做事也是要考量一下成本,就算啦


有任何開發外包工作,或者產品開發都歡迎找我討論
產品部分可以視情況接受事後分潤的方式共同創業

Email: softicecan@gmail.com


尚未有邦友留言

立即登入留言