import nodemailer from 'nodemailer'
import get from 'lodash-es/get.js'
import genPm from 'wsemi/src/genPm.mjs'
import isstr from 'wsemi/src/isstr.mjs'
import isarr from 'wsemi/src/isarr.mjs'
import isobj from 'wsemi/src/isobj.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import cint from 'wsemi/src/cint.mjs'
import isEmail from 'wsemi/src/isEmail.mjs'
//getOpt, 取opt欄位值, undefined與null皆視為未給定而回傳預設值
function getOpt(opt, key, def) {
let v = get(opt, key)
if (v === undefined || v === null) {
return def
}
return v
}
//toText, 文字欄位: 字串原樣回傳, 有限數字轉為字串, 其餘回傳null代表不合規
function toText(v) {
if (isstr(v)) {
return v
}
if (typeof v === 'number' && Number.isFinite(v)) {
return String(v)
}
return null
}
//isSkip, 陣列元素為falsy或空白字串者視為未給定而略過
function isSkip(v) {
return !v || (isstr(v) && v.trim() === '')
}
//hasNonAscii, 字串是否含非ASCII字元
function hasNonAscii(s) {
return [...s].some((c) => {
return c.charCodeAt(0) > 0x7f
})
}
//hasUrlUnsafe, 字串是否含URL剖析器會截斷或改寫網域之字元(控制字元、空白、DEL與/\?#%:@[])
function hasUrlUnsafe(s) {
return [...s].some((c) => {
let code = c.charCodeAt(0)
return code <= 0x20 || code === 0x7f || '/\\?#%:@[]'.includes(c)
})
}
//normEmail, email正規化: 去除前後空白, 網域含非ASCII字元時先轉為punycode, 再以isEmail檢核, 回傳正規化後之email, 不合規回傳null
function normEmail(v) {
if (!isstr(v)) {
return null
}
let email = v.trim()
let i = email.lastIndexOf('@')
if (i < 0) {
return null
}
let local = email.slice(0, i)
let domain = email.slice(i + 1)
if (hasNonAscii(domain)) {
//國際化網域, 先排除URL剖析器會截斷或改寫之字元, 使檢核值與寄送值一致
if (hasUrlUnsafe(domain)) {
return null
}
try {
domain = new URL(`http://${domain}`).hostname
}
catch (err) {
return null
}
}
email = `${local}@${domain}`
if (!isEmail(email)) {
return null
}
return email
}
//normEmails, 收件人欄位正規化: 可為email字串或其陣列, falsy視為無收件人, 陣列元素為falsy或空白字串者略過
function normEmails(opt, key) {
let v = get(opt, key)
if (!v) {
return { emails: [] }
}
let isArr = isarr(v)
if (isstr(v)) {
v = [v]
}
if (!isarr(v)) {
return { err: `${key} is not string or array` }
}
let emails = []
for (let i = 0; i < v.length; i++) {
if (isSkip(v[i])) {
continue
}
let email = normEmail(v[i])
if (email === null) {
return { err: isArr ? `${key}[${i}] is not email` : `${key} is not email` }
}
emails.push(email)
}
return { emails }
}
//isAttachment, 附件物件須具nodemailer之內容來源raw、path、href或content其一, 且filename若給定須為字串或false
function isAttachment(a) {
if (!isobj(a)) {
return false
}
let fn = a.filename
if (fn !== undefined && fn !== null && fn !== false && !isstr(fn)) {
return false
}
if (a.raw) {
return true
}
if (isstr(a.path) && a.path !== '') {
return true
}
if (isstr(a.href) && a.href !== '') {
return true
}
let c = a.content
return isstr(c) || Buffer.isBuffer(c) || (!!c && typeof c.pipe === 'function')
}
//normAttachments, 附件正規化: 可為附件物件或其陣列, falsy視為無附件, 陣列元素為falsy或空白字串者略過
function normAttachments(opt) {
let v = get(opt, 'emAttachments')
if (!v) {
return { atts: [] }
}
let isArr = isarr(v)
if (isobj(v)) {
v = [v]
}
if (!isarr(v)) {
return { err: 'emAttachments is not object or array' }
}
let atts = []
for (let i = 0; i < v.length; i++) {
if (isSkip(v[i])) {
continue
}
if (!isAttachment(v[i])) {
return { err: isArr ? `emAttachments[${i}] is not valid attachment` : 'emAttachments is not valid attachment' }
}
atts.push(v[i])
}
return { atts }
}
/**
* 寄送email
*
* opt各欄位給予undefined或null時皆視為未給定而使用預設值;欄位型別或值不合規時,不連線即reject錯誤訊息字串(見下方「錯誤訊息」)。
*
* **email檢核規則**(srcEmail,以及toEmails、toEmailsCC、toEmailsBCC之每一筆皆適用):
* 1. 須為字串,先去除前後空白。
* 2. 網域(@之後)含非ASCII字元者視為國際化網域,先自動轉為punycode再檢核,並以轉換後之值寄送,例如`user@例子.測試`寄至`user@xn--fsqu00a.xn--g6w251d`;國際化網域內不得含空白、控制字元與`/`、`\`、`?`、`#`、`%`、`:`、`@`、`[`、`]`。
* 3. 再以wsemi之isEmail檢核,須同時符合:
* - 全長至多254字元,帳號部分(@之前)至多64字元。
* - 帳號部分僅限ASCII之英數字與特殊字元 ! # $ % & ' * + / = ? ^ _ { | } ~ - 及反引號,點只能作為分隔,不得位於開頭、結尾或連續出現。
* - 網域為一段以上之主機名稱(英數字與中間之連字號,每段至多63字元,不得以連字號開頭或結尾)加頂級網域,頂級網域須為2字元以上之英文字母,或xn--開頭之punycode。
* 4. 因此以下格式一律拒收:帶顯示名稱或角括號(`Alice <alice@example.com>`、`<alice@example.com>`)、帶註解(`bob@example.com (Bob)`)、一個字串內含多筆(以逗號、分號或空白分隔)、引號形式之帳號(`"john..doe"@example.com`)、非ASCII之帳號(`張三@example.com`)、無頂級網域(`user@localhost`)、IP位址(`user@[127.0.0.1]`)、網域含底線。
*
* **收件人規則**(toEmails、toEmailsCC、toEmailsBCC):
* - 可為一個email字串(僅一位收件人)或email字串陣列,多位收件人須以陣列給予。
* - 給予null、undefined、false、0或空字串時,視為該欄無收件人;陣列內為falsy或空白字串之元素略過。
* - 三欄合計至少須有一位收件人。密件副本(toEmailsBCC)不出現於信件標頭。
*
* **附件規則**(emAttachments):
* - 可為附件物件或附件物件陣列;給予null、undefined、false、0或空字串時視為無附件,陣列內為falsy或空白字串之元素略過。
* - 每個附件物件須具下列內容來源之一:`raw`(非空)、`path`(非空字串,本機路徑或網址)、`href`(非空字串網址)、`content`(字串、Buffer或可讀串流);`filename`若給予須為字串,或給予false表示不帶檔名。
* - path與href會讀取本機檔案或連線下載,不可直接採用不可信任之輸入。
*
* **錯誤訊息**(輸入檢核失敗時reject之字串,皆於連線前發生):
* - `srcEmail is not email`
* - `srcName is not string`、`srcPW is not string`、`emTitle is not string`、`emContent is not string`
* - `srcHost is not effective string`
* - `srcPort is not valid port`
* - `toEmails is not string or array`、`toEmails is not email`(以字串給予時)、`toEmails[i] is not email`(以陣列給予時,i為元素索引);toEmailsCC與toEmailsBCC同,僅欄位名不同
* - `no recipients`
* - `emAttachments is not object or array`、`emAttachments is not valid attachment`(以物件給予時)、`emAttachments[i] is not valid attachment`(以陣列給予時)
*
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {String} [opt.srcName='system by w-email'] 輸入寄件人顯示名稱字串,可為數字,換行字元轉為空白並去除前後空白,給予空字串則不顯示名稱,預設'system by w-email'
* @param {String} opt.srcEmail 輸入寄件人email信箱字串,亦作為登入郵件伺服器之帳號,須符合email檢核規則
* @param {String} [opt.srcPW=''] 輸入寄件人email密碼字串,可為數字,郵件伺服器未要求登入時可為空字串,預設''
* @param {String} [opt.srcHost='smtp.gmail.com'] 輸入郵件伺服器位址字串,去除前後空白後不可為空,預設'smtp.gmail.com'
* @param {Number|String} [opt.srcPort=587] 輸入郵件伺服器連接埠,可為正整數或其字串,範圍1至65535,465使用SSL/TLS連線,其他連接埠於伺服器支援時使用STARTTLS(伺服器未宣告STARTTLS時以明文連線與登入),預設587
* @param {String} [opt.emTitle=''] 輸入郵件名稱字串,可為數字,預設''
* @param {String|Buffer} [opt.emContent=''] 輸入郵件訊息字串,以html寄送,可為數字或Buffer,預設''
* @param {String|Array} [opt.toEmails=[]] 輸入收件人email字串或陣列,須符合email檢核規則與收件人規則,預設[]
* @param {String|Array} [opt.toEmailsCC=[]] 輸入副本收件人email字串或陣列,規則同toEmails,預設[]
* @param {String|Array} [opt.toEmailsBCC=[]] 輸入密件副本收件人email字串或陣列,規則同toEmails,預設[]
* @param {Object|Array} [opt.emAttachments=[]] 輸入附件物件或陣列,須符合附件規則,預設[]
* @returns {Promise} 回傳Promise,resolve回傳郵件伺服器回應字串,部分收件人被伺服器拒收時仍為resolve且不回傳被拒名單;reject回傳錯誤,輸入檢核失敗時為錯誤訊息字串,其餘為Error物件(通常帶code,伺服器回應之錯誤另帶responseCode);toEmails、toEmailsCC、toEmailsBCC合計至少須有一位收件人;郵件伺服器無回應時依nodemailer預設逾時(連線2分鐘、招呼30秒、閒置10分鐘)後reject
* @example
* import WEmail from 'w-email'
*
* //opt
* let opt = {
* srcName: 'test name',
* srcEmail: 'your email',
* srcPW: 'your password',
* emTitle: 'test title',
* emContent: '<h4>test head</h4>'+'<div>test content</div>',
* toEmails: 'to email',
* //toEmailsCC: 'to email',
* //toEmailsBCC: 'to email',
* emAttachments: {
* filename: 'your filename',
* path: 'your filepath'
* }
* }
*
* //WEmail
* new WEmail(opt)
* .then((msg)=>{
* console.log('then',msg)
* })
* .catch((err)=>{
* console.log('catch',err)
* })
*/
function WEmail(opt = {}) {
let pm = genPm()
//fail, 輸入檢核失敗, 不連線即reject錯誤訊息字串
let fail = (msg) => {
pm.reject(msg)
return pm
}
try {
//寄件人email信箱
let srcEmail = normEmail(get(opt, 'srcEmail'))
if (srcEmail === null) {
return fail('srcEmail is not email')
}
//寄件人顯示名稱, 換行字元轉為空白並去除前後空白
let srcName = toText(getOpt(opt, 'srcName', 'system by w-email'))
if (srcName === null) {
return fail('srcName is not string')
}
srcName = srcName.replace(/[\r\n]+/g, ' ').trim()
//寄件人email密碼, 郵件伺服器未要求登入時可為空字串
let srcPW = toText(getOpt(opt, 'srcPW', ''))
if (srcPW === null) {
return fail('srcPW is not string')
}
//郵件伺服器位址
let srcHost = getOpt(opt, 'srcHost', 'smtp.gmail.com')
if (!isstr(srcHost) || srcHost.trim() === '') {
return fail('srcHost is not effective string')
}
srcHost = srcHost.trim()
//gmail外寄郵件 (SMTP) 伺服器
//郵件伺服器連接埠, 可為正整數或其字串
let srcPort = getOpt(opt, 'srcPort', 587)
if (!ispint(srcPort) || cint(srcPort) > 65535) {
return fail('srcPort is not valid port')
}
srcPort = cint(srcPort)
//安全資料傳輸層 (SSL) 通訊埠:465
//傳輸層安全性 (TLS)/STARTTLS 通訊埠:587
//郵件名稱
let emTitle = toText(getOpt(opt, 'emTitle', ''))
if (emTitle === null) {
return fail('emTitle is not string')
}
//郵件訊息, 可為字串、數字或Buffer
let emContent = getOpt(opt, 'emContent', '')
if (!Buffer.isBuffer(emContent)) {
emContent = toText(emContent)
if (emContent === null) {
return fail('emContent is not string')
}
}
//附件
let ra = normAttachments(opt)
if (ra.err) {
return fail(ra.err)
}
//收件人email
let rt = normEmails(opt, 'toEmails')
if (rt.err) {
return fail(rt.err)
}
//副本收件人email
let rc = normEmails(opt, 'toEmailsCC')
if (rc.err) {
return fail(rc.err)
}
//密件副本收件人email
let rb = normEmails(opt, 'toEmailsBCC')
if (rb.err) {
return fail(rb.err)
}
//須至少一位收件人
if (rt.emails.length + rc.emails.length + rb.emails.length === 0) {
return fail('no recipients')
}
//transporter
let transporter = nodemailer.createTransport({
host: srcHost,
port: srcPort,
auth: {
user: srcEmail,
pass: srcPW
},
//secureConnecton: true,
secure: srcPort === 465, // true for 465, false for other ports
// ssl: false,
// ignoreTLS: false,
// requireTLS: true,
// tls: {
// secureProtocol: 'TLSv1_2_method',
// rejectUnauthorized: false
// }
})
//optt
let optt = {
from: {
name: srcName,
address: srcEmail,
}, //以位址物件給予, 避免srcName被當成位址字串重新解析
to: rt.emails,
cc: rc.emails,
bcc: rb.emails,
subject: emTitle,
html: emContent,
attachments: ra.atts
}
//sendMail
transporter.sendMail(optt, function(err, info) {
if (err) {
pm.reject(err)
}
else {
pm.resolve(info.response)
}
})
}
catch (err) {
//非預期之例外(例如opt之getter拋錯), reject原例外, 確保不同步拋出
pm.reject(err)
}
return pm
}
export default WEmail