nodemailer极简源码解析与实现原理

1,288 阅读15分钟

前言

① 本文只解析基于SMTP协议发送邮件的情况
② 本文的解析基于删减学习版—simple-nodemailer
③ 关于处理email.content的部分省略

一、使用

这段跟 官网example 一样:

//位置:index.js
const nodemailer=require('./nodemailer')
const config=require('./config')

async function sendEMail(option){
  //根据用户名、密码、qq邮箱smtp地址、端口,新建 mailer 实例
  //比较简单就是初始化属性
  const transporter =nodemailer.createTransport({
    host"smtp.exmail.qq.com",
    port465,
    securetrue,
    auth: {
      user: config.user,
      pass: config.pass,
    },
  });

  const {email,title,date,content}=option

  await transporter.sendMail({
    from: email, // sender address
    to: email, // list of receivers
    subject: title, // 标题
    html: `<div><div>日期:${date}</div><div>内容:${content}</div></div>`// html body
  });
}

sendEMail({
  email:config.user,
  title:"看下nodemailer原理",
  date:new Date(),
  content:'本作男主角,与三笠·阿克曼、爱尔敏·阿诺德是儿时玩伴,拥有强韧的精神力与非凡的行动力,对墙壁外的世界有者比人们都要高的憧憬,从小立志加入调查兵团。在目睹母亲遭巨人吞食后,立誓要驱逐所有巨人。他和儿时玩伴一起受训并认识不少人,以第五名毕业。\n'
})
效果图:

二、nodemailer基于SMTP协议的流程

流程

1、创建基于smtp协议的connection
① 使用DNS协议解析域名,获得ip
② 建立tls连接
③ 发送greeting request
④ 发送login request

2、发送邮件
① 以rfc2822标准创建stream对象—message
② 发送MAIL FROM request
③ 发送RCPT TO request
④ 发送DATA request,也就是邮件内容,此时就能收到邮件了

流程图

三、transporter.sendMail

nodemailer.createTransport源码部分是初始化一些value,略过。

源码
  //位置:nodemailer.js
  sendMail(data, callback) {
    let promise;
    //初始化 promiseCallback
    if (!callback) {
      promise = new Promise((resolve, reject) => {
        callback = shared.callbackPromise(resolve, reject);
      });
    }
    //this 即 Mailer实例
    //根据 发送邮件选项 新建 mailMessage 实例
    //data:{ from:'xxx',to:'xxx',subject:'xxx',content:'xxx',headers:{}, },
    //message:mimeNode 实例,
    let mail = new MailMessage(this, data);
    //过程处理
    //1.compile
    //2.stream
    // this._processPlugins('compile', mail, err => {
    //新建 mimeNode 实例
      mail.message = new MailComposer(mail.data).compile();

      // this._processPlugins('stream', mail, err => {
    //SMPT 传输实例的send方法
        this.transporter.send(mail);
      // });
    // });

    return promise;
  }
解析

1、mail是一个object,由两部分构成:

{
  data:{ from:'xxx',to:'xxx',subject:'xxx',content:'xxx',headers:{}, },
  message:mimeNode 实例,
}

2、sendMail()核心是SMPT instancesend方法,流程图中的创建smtp的连接就是从此方法开始

四、transporter. send

发送邮件的核心函数,按照流程图讲

1、DNS解析域名,获取ip
核心源码
//位置:shared.js
const dns = require('dns');

const resolver = (family, hostname, callback) => {
  //使用DNS协议为hostname解析IPv4地址
  //dns[resolve4]('smtp.exmail.qq.com',()=>{})
  dns['resolve' + family](hostname, (err, addresses) => {
    //smtp.exmail.qq.com通过DNS解析有三个ip地址
    //addrresses=['113.96.208.92','113.96.232.106','113.96.200.115']
    return callback(null, Array.isArray(addresses) ? addresses : [].concat(addresses || []));
  });
};

直接调用dns-api即可

2、建立tls连接
核心源码
//位置:smtp-connection.js
const tls = require('tls');

//tls.connect与https.connect的区别:默认情况下不启用SNI(服务器名称指示)扩展名,这可能导致某些服务器返回不正确的证书或完全拒绝连接
//http://nodejs.cn/api/tls.html#tls_tls_connect_options_callback
//建立tls连接
// opts={host:'113.96.232.106', port:465,servername:'smtp.exmail.qq.com'}
this._socket = tls.connect(opts, () => {
    this._socket.setKeepAlive(true)
    this._onConnect()
})

调用tls-api后,执行的_onConnect()核心源码:

  //位置:smtp-connection.js
  //当建立与服务器的连接时,运行监听器listener
  _onConnect() {
    //打开socket的 data listener
    this._socket.on('data'this._onSocketData);
  }

这个方法很重要,它的作用是用来监听server发送过来的数据,也就是说,后面server发送的response,都能在该方法中获取到

_onSocketData内部调用了_onData,看下_onData源码:

  //位置:smtp-connection.js
  _onData(chunk) {
    if (this._destroyed || !chunk || !chunk.length) {
      return;
    }
    //接收到server的response的情况
    //1.建立tls连接成功时 220 smtp.qq.com Esmtp QQ Mail Server
    //2.发送gretting问候请求时 250-smtp.qq.com 250-PIPELINING 250-SIZE 73400320 250-AUTH LOGIN PLAIN 250-AUTH=LOGIN 250-MAILCOMPRESS 250 8BITMIME
    //3.发送auth登录验证时 235 Authentication successful
    //4.发送邮件时 250 Ok: queued as
    let data = (chunk || '').toString('binary'); 

    //xxx
  }

之后向server发送请求时,会反复提到这段源码,我们下文均称它为data监听器

tls连接完成

当建立tls连接成功时,data监听器会收到如下greeting response

220 smtp.qq.com Esmtp QQ Mail Server
3、发送问候请求

tls连接成功,并且收到servergreeting response后,client也会发送greeting request,类似于三次握手的最后一次🤝

核心源码
  //位置:smtp-connection.js
  _actionGreeting(str) {
    clearTimeout(this._greetingTimeout);
    this._responseActions.push(this._actionEHLO);
    this._sendCommand('EHLO ' + this.name);
  }
socket发送request
  _sendCommand(str) { 
    //str:EHLO 获取到的电脑信息
    this._socket.write(Buffer.from(str + '\r\n''utf-8')); 
  }
server response

data监听器收到如下回复,也就是smtp.exmail.qq.com支持的验证方式:

250-smtp.qq.com 
250-PIPELINING 
250-SIZE 73400320 
250-AUTH LOGIN PLAIN 
250-AUTH=LOGIN 
250-MAILCOMPRESS 
250 8BITMIME

nodemailer判断邮箱服务器支持哪些登录方式的函数为:

  //位置:smtp-connection.js
  //当socket.write发送了问候请求后
  //判断server回复的内容里对登录方式的支持
  _actionEHLO(str) {
    // Detect if the server supports PLAIN auth
    if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)PLAIN/i.test(str)) {
      this._supportedAuth.push('PLAIN');
    }
    //xxx省略好多
    //发送connect事件,也就是发送用户名、密码
    this.emit('connect');
  }
4、发送登录请求
核心源码
  //位置:smtp-connection.js
  //验证用户
  login(authData, callback) {
    this._auth = authData || {};
    // Select SASL authentication method
    //_responseActions的执行时机是等到server连接成功,并发送data后,再执行的
    this._responseActions.push(str => {
      this._actionAUTHComplete(str, callback);
    });
    //将用户名、密码转为base64并拼接
    this._sendCommand(
      'AUTH PLAIN ' +
      Buffer.from(
        //this._auth.user+'\u0000'+
        //\u0000 表示空格也就是 空格+用户名+空格+密码
        '\u0000' + // skip authorization identity as it causes problems with some servers
        this._auth.credentials.user +
        '\u0000' +
        this._auth.credentials.pass,
        'utf-8'
      ).toString('base64')
    );
  }
socket发送request
  _sendCommand(str) { 
    //str:AUTH PLAIN base64编成的用户名密码
    this._socket.write(Buffer.from(str + '\r\n''utf-8')); 
  }
server response

data监听器收到如下回复:

235 Authentication successful

登录成功后,就进入发送邮件阶段

5、以rfc2822标准创建stream对象

rfc2822用来定义邮件信息的格式,具体的解释请参考 这里

源码
  //位置:mime-node.js
  //以 rfc2822 标准创建stream对象
  //http://blog.chinaunix.net/uid-8532343-id-2029221.html
  createReadStream(options) {
    options = options || {};

    let stream = new PassThrough(options);
    let outputStream = stream;
    let transform;

    this.stream(stream, options, err => {
      if (err) {
        outputStream.emit('error', err);
        return;
      }
      stream.end();
    });

    for (let i = 0, len = this._transforms.length; i < len; i++) {
      transform = typeof this._transforms[i] === 'function' ? this._transforms[i]() : this._transforms[i];
      outputStream.once('error', err => {
        transform.emit('error', err);
      });
      outputStream = outputStream.pipe(transform);
    }

    // ensure terminating newline after possible user transforms
    transform = new LastNewline();
    outputStream.once('error', err => {
      transform.emit('error', err);
    });
    outputStream = outputStream.pipe(transform);

    // dkim and stuff
    for (let i = 0, len = this._processFuncs.length; i < len; i++) {
      transform = this._processFuncs[i];
      outputStream = transform(outputStream);
    }

    return outputStream;
  }
6、发送MAIL FROM请求

判断邮件的发起者是否可以正常发送

核心源码
//位置:smtp-transport.js
//mail.message即处理过的邮件内容
connection.send(envelope, mail.message.createReadStream(), callback)
  //位置:smtp-connection.js
  send(envelope, message, done) {
    let startTime = Date.now();
    this._setEnvelope(envelope, (err, info) => {
      //这个callback是发送RCPT TO请求后,发送DATA请求时,执行的callback
      if (err) {
        return callback(err);
      }
      let envelopeTime = Date.now();
      //创建发送流
      let stream = this._createSendStream(callback);
      //将发送流导入 可读流ReadStream中
      message.pipe(stream);
    });
  }
socket发送MAIL FROM
  //位置:smtp-connection.js
  //创建新的message,从 MAIL FROM开始
  _setEnvelope(envelope, callback) {
    let args = [];
    let useSmtpUtf8 = false;
    this._envelope = envelope
    // clone the recipients array for latter manipulation
    // this._envelope.rcptQueue = JSON.parse(JSON.stringify(this._envelope.to || []));
    this._envelope.rcptQueue = JSON.parse(JSON.stringify(this._envelope.to));
    this._envelope.rejected = [];
    this._envelope.rejectedErrors = [];
    this._envelope.accepted = [];
    this._responseActions.push(str => {
      this._actionMAIL(str, callback);
    });
    this._sendCommand('MAIL FROM:<' + this._envelope.from + '>' + (args.length ? ' ' + args.join(' ') : ''));
  }
  //位置:smtp-connection.js
  _sendCommand(str) {
    //str:MAIL FROM:<发件人的邮箱地址>
    this._socket.write(Buffer.from(str + '\r\n''utf-8'));
  }
server response

data监听器收到如下回复:

250 Ok
7、发送RCPT TO请求

判断邮件的发起者是否可以正常发送

核心源码
  //位置:smtp-connection.js
  //发送MAIL FROM请求,判断邮件的发起者是否正常
  _actionMAIL(str, callback) { //250 Ok
    let message, curRecipient;
    this._recipientQueue = [];
    //有BFC那味儿了
    while (this._envelope.rcptQueue.length) {
      curRecipient = this._envelope.rcptQueue.shift();
      this._recipientQueue.push(curRecipient);
      this._responseActions.push(str => {
        this._actionRCPT(str, callback);
      });
      this._sendCommand('RCPT TO:<' + curRecipient + '>' + this._getDsnRcptToArgs()); //'RCPT TO:<邮件接收者>'
    }
  }
socket发送RCPT TO
  //位置:smtp-connection.js
  _sendCommand(str) {
    //str:RCPT TO:<接收方的邮箱>
    this._socket.write(Buffer.from(str + '\r\n''utf-8'));
  }
server response

data监听器收到如下回复:

250 Ok

发送方和接收方都没问题,接下来就发送邮件内容

8、发送DATA请求

发送RCPT TO请求成功后,触发callback,接着发送邮件content

核心源码
  //位置:smtp-connection.js
  //发送RCPT TO请求成功后,发起DATA请求
  _actionRCPT(str, callback) {
    let curRecipient = this._recipientQueue.shift(); //邮箱
    this._envelope.accepted.push(curRecipient);
    this._responseActions.push(str => {
      this._actionDATA(str, callback);
    });
    this._sendCommand('DATA');
  }
  //位置:smtp-connection.js
  _sendCommand(str) {
    //str:DATA
    this._socket.write(Buffer.from(str + '\r\n''utf-8'));
  }
server response

data监听器收到如下回复:

354 End data with <CR><LF>.<CR><LF>

client告诉server,接下来我发送的是邮件内容,server回复发送的邮件内容以<CR><LF>.<CR><LF>结尾
这也表明server是能收到数据的,接下来就正式发送邮件内容了

创建发送流
  //位置:smtp-connection.js
  //创建发送流
  let stream = this._createSendStream(callback)

  _createSendStream(callback) {
    let dataStream = new DataStream();
    let logStream;

    this._responseActions.push(str => {
      this._actionSMTPStream(str, callback);
    });
    //将TLSSocket写入流,以便边读边写
    dataStream.pipe(this._socket, {
      endfalse
    });
    return dataStream;
  }
将发送流导入ReadStream
//位置:smtp-connection.js
//将发送流导入 可读流ReadStream中
message.pipe(stream);

message.pipe(stream)就是将邮件内容发送给server端了,再具体一点的话是这样的

//位置:mime-node.js
stream(outputStream, options, done) {
    let transferEncoding = this.getTransferEncoding();
    let contentStream;
    let localStream;
    // protect actual callback against multiple triggering
    let returned = false;
    let callback = err => {
      if (returned) {
        return;
      }
      returned = true;
      done(err);
    };
    // for multipart nodes, push child nodes
    // for content nodes end the stream
    let finalize = () => {
      let childId = 0;
      let processChildNode = () => {
        if (childId >= this.childNodes.length) {
          outputStream.write('\r\n--' + this.boundary + '--\r\n');
          return callback();
        }
        let child = this.childNodes[childId++];
        outputStream.write((childId > 1 ? '\r\n' : '') + '--' + this.boundary + '\r\n');
        child.stream(outputStream, options, err => {
          if (err) {
            return callback(err);
          }
          setImmediate(processChildNode);
        });
      };
      if (this.multipart) {
        setImmediate(processChildNode);
      } else {
        return callback();
      }
    };
    // pushes node content
    let sendContent = () => {
      let createStream = () => {
        contentStream = new (transferEncoding === 'base64' ? base64 : qp).Encoder(options);
        contentStream.pipe(outputStream, {
          endfalse
        });
        contentStream.once('end', finalize);
        contentStream.once('error', err => callback(err));
        localStream = this._getStream(this.content);
        localStream.pipe(contentStream);
        localStream.once('error', err => callback(err));
      };
      setImmediate(createStream);
    };
    outputStream.write(this.buildHeaders() + '\r\n\r\n');
    setImmediate(sendContent);
  }

这边即5、以rfc2822标准创建stream对象里的stream方法,在建立数据流管道后,并发送DATA字符串给server,通知server接下来发送邮件内容,然后通过message.pipe(stream),将邮件内容发送过去,邮件内容的处理这边就不讲了

至此,流程结束,你会收到邮件。

几点感受

① 有的函数的callback要往上翻好几层才能找到
if条件判断巨多,在删减代码上花了很多时间
③ 发送-监听处理机制有点像BFS,也就是将要处理responseaction pusharray中,待监听到后,再array.unshift取出处理
nodemailer库现在仍然处于活跃阶段,源码里无论是注释还是编码习惯都非常好

GitHub

nodemailergithub.com/nodemailer/…

simple-nodemailergithub.com/AttackXiaoJ…

要找完整源码的小伙伴,复制代码段,全局搜索即可


(完)