前阵子朋友跟我说了一个需求,思考了一下想说花的时间不多顺手就做了
主功能
自动将Gmail信件的附档分类并储存至Google Drive指定的资料夹
朋友说以往都是每个信件手动下载再逐一上传,他称之为归档的动作
功能演示
这是主要也是唯一的页面,首先要进行登入,就如同一般使用Google登入会询问权限相关的描述,反正就同意下一步就对了
接下来建置一个资料夹,準备归档用,我就建了一个Test
然后必须将资料夹权限分享给Service account,这个Service account后续技术部份会再说明,如果没分享的话帐号没有权限操作Google Drive是没办法正常运作的
最后再回到主画面,找到资料夹,确认自动对应上的ID是否正确,然后就开始归档啦
技术部分
前端Html, css, javascript
后端Nodejs
运作流程
前端Google Login,透过帐号oauth呼叫Gmail API取得信件列表
-> 透过信件列表的ID逐一取得每个信件的详细资料(附加档案为Base64格式)
-> 将附加档案透过内部API传送至Nodejs后端
-> 后端将档案暂存至Server
-> 呼叫Drive上传档案API
-> 删除暂存在Server的档案
前置作业
相关的权限、帐号需要申请
红色框的OAuth是给前端Google登入 access gmail使用的
蓝色框则Service account则是Drive需要的
这边需要自行新增金钥,这个金钥会是Nodejs后端需要用到的身份验证
实作
这边就是基本的初始化设定
"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 } })}//gsuiteHelperexports.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) } });}//gsuiteHelperexports.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 } } }) } }); } });}//gsuiteHelperexports.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