You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Build-your-own-mail-server, or just the parts that you need
Direct mail delivery (no paid/free-tier proxies)
Supports common extensions (pipelining, chunking, utf8, ...)
SpamAssasin client built-in
Built to be lightweight and performant
Built in the big '25 with modern language features and practices
Sending emails
import{Mail,SMTPClient}from"paperplane-mailer"// DKIM key can be generated with// npx paperplane-mailer gen [file?]constclient=newSMTPClient('mydomain.com','./dkim.key',/*selector*/'mail')constmail=newMail({From: 'Me <[email protected]>','Content-Type': 'text/html'},`<html><h1>Hello, world!</h1></html>`)// .send(from, [to...], mail)client.send('[email protected]',['[email protected]'],mail).then(failed=>{if(failed.length){for(constfoffailed)console.error('× Failed to send to %s',f)return}console.info('Mail sent to all recipients successfully!')})
Receiving emails
import{Mail,SMTPServer,SpamAssassin,uniqueId}from"paperplane-mailer"// Create an SMTP server with a basic filter [ 'mydomain.com' ]// All incoming mail not meant for something@mydomain.com is rejected for us// Leave arguments empty to disable filterconstsmtpServer=newSMTPServer('mydomain.com')consttlsOptions={key: fs.readFileSync('mydomain.key'),cert: fs.readFileSync('mydomain.cert')}awaitsmtpServer.listen(tlsOptions)console.info('SMTP servers listening on :25, :465, :587')// We configure spam-detection using spamassasin and spamhausconstspamc=newSpamAssassin('127.0.0.1')smtpServer.onIncoming=async(_,from,toArr,mail,rawMail,ip)=>{// The from parameter can be used to identify the sender but is not always the same as the `From` header that users see. Keep that in mind, and use the from header if in doubtconsole.log('\x1b[35mIncoming from %s to %s\nIP: %s, headers: %d, body: %d bytes',mail.get('from')??from,toArr,ip,mail.headerCount,mail.body.length)// Mail is checked by spamassasin. IP (if specified) is checked by zen.spamhaus.orgconstspam=awaitspamc.check(rawMail,ip)// SpamAssasin by default doesn't strongly penalize invalid DKIM// Despite it being an industry standard and very important in verifying email authenticity// Here we automatically spam any email without a valid DKIM signatureif(!spam.symbols.includes('DKIM_VALID')||spam.spam){console.warn('Message flagged as spam with score %d and symbols:\n %s',spam.score,spam.symbols.join(' ')+(spam.blocked ? ' SPAMHAUS_IP_BLOCKED':''))return}else{console.info('Message passed spam test with score %d and symbols:\n %s',spam.score,spam.symbols.join(' '))}// toArr is guaranteed to all match our filter ['mydomain.com']// Normalize the email (make sure we have a correct `Date` header, `Message-ID`, ...)mail.normalize()letcount=0for(lettooftoArr){// Convert user@mydomain.com to userto=Mail.getLocal(to)||to// This example uses in-memory inboxes, see further belowconstinbox=inboxes.get(to)if(!inbox)continueinbox.add(mail)count++}console.log('Stored to %d inboxes',count)}constinboxes=newMap()classInboxextendsMap{constructor({ password =''}){super()this.password=password}add(mail){constid=uniqueId()// Unique identifier in the format: paperplane-<unix_timestamp>-r4nDomBaSe64...this.set(id,mail)returnid}}inboxes.set('john',newInbox({password: 'password123'}))
Downloading emails to a client
import{Mail,POPServer}from"paperplane-mailer"/* Variables from previous example omitted for brevity */constpopServer=newPOPServer('mydomain.com')awaitpopServer.listen(tlsOptions)console.log('\x1b[32mPOP servers listening on :110, :995\x1b[m')popServer.onAuthenticate=(user,pass)=>{// TODO: password hashing, timing safe equal, etc...constinbox=inboxes.get(user=Mail.getLocal(user)||user)if(!inbox||inbox.password!==pass)returnnullreturn{ inbox,username: user}}popServer.onGetMessages=(auth)=>{const{ inbox, username }=auth// Return array of message IDs// Conceptually they could be any string as they are just passed to onFetchMessagereturn[...inbox.keys()]}popServer.onFetchMessage=(auth,id)=>{const{ inbox, username }=auth// Return the Mail object for this message ID, or null// This callback is only invoked with message IDs returned by `onGetMessages` with the same auth object so mail being null is a rare edge-casereturninbox.get(id)}
Sending emails from a client
import{Mail,SMTPServer}from'paperplane-mailer'/* Variables from previous examples omitted for brevity *///const smtpServer = ...// Similar to popServer.onAuthenticatesmtpServer.onAuthenticate=(user,pass)=>{constinbox=inboxes.get(user=Mail.getLocal(user)||user)if(!inbox||inbox.password!==pass)returnnullreturn{ inbox,username: user}}smtpServer.onOutgoing=(auth,from,toArr,mail)=>{const{ inbox, username }=auth// from is guaranteed to match our filter ['mydomain.com']// Unlike onIncoming, `from` here actually means the sender// Mail.getLocal('[email protected]') returns 'abc'// We can return a string to indicate to the sender that delivery failed for that reasonif(Mail.getLocal(from)!=auth.user)return'Not allowed to send from that email'// Normalize the email, also setting the `From` header based on the value we just checkedmail.normalize(from)cli.send(from,toArr,mail).then(failed=>{// IDEA: "Undelivered mail returned to sender"?})}