paperplane-mailer
v1.1.9
Published
4-way mail client/server
Maintainers
Readme
Paperplane mailer
- 4-way email handling in node.js
- 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?]
const client = new SMTPClient('mydomain.com', './dkim.key', /*selector*/'mail')
const mail = new Mail({
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(const f of failed)
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 [email protected] is rejected for us
// Leave arguments empty to disable filter
const smtpServer = new SMTPServer('mydomain.com')
const tlsOptions = {
key: fs.readFileSync('mydomain.key'),
cert: fs.readFileSync('mydomain.cert')
}
await smtpServer.listen(tlsOptions)
console.info('SMTP servers listening on :25, :465, :587')
// We configure spam-detection using spamassasin and spamhaus
const spamc = new SpamAssassin('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 doubt
console.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.org
const spam = await spamc.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 signature
if(!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()
let count = 0
for(let to of toArr){
// Convert [email protected] to user
to = Mail.getLocal(to) || to
// This example uses in-memory inboxes, see further below
const inbox = inboxes.get(to)
if(!inbox) continue
inbox.add(mail)
count++
}
console.log('Stored to %d inboxes', count)
}
const inboxes = new Map()
class Inbox extends Map{
constructor({ password = '' }){
super()
this.password = password
}
add(mail){
const id = uniqueId() // Unique identifier in the format: paperplane-<unix_timestamp>-r4nDomBaSe64...
this.set(id, mail)
return id
}
}
inboxes.set('john', new Inbox({ password: 'password123' }))
Downloading emails to a client
import { Mail, POPServer } from "paperplane-mailer"
/* Variables from previous example omitted for brevity */
const popServer = new POPServer('mydomain.com')
await popServer.listen(tlsOptions)
console.log('\x1b[32mPOP servers listening on :110, :995\x1b[m')
popServer.onAuthenticate = (user, pass) => {
// TODO: password hashing, timing safe equal, etc...
const inbox = inboxes.get(user = Mail.getLocal(user) || user)
if(!inbox || inbox.password !== pass) return null
return { 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 onFetchMessage
return [...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-case
return inbox.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.onAuthenticate
smtpServer.onAuthenticate = (user, pass) => {
const inbox = inboxes.get(user = Mail.getLocal(user) || user)
if(!inbox || inbox.password !== pass) return null
return { 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 reason
if(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 checked
mail.normalize(from)
cli.send(from, toArr, mail).then(failed => {
// IDEA: "Undelivered mail returned to sender"?
})
}