Skip to content
This repository has been archived by the owner on Feb 27, 2023. It is now read-only.

Commit

Permalink
Feature/ddd structure (#10)
Browse files Browse the repository at this point in the history
* implementing ddd structure
* add .nvmrc
  • Loading branch information
synox authored Jun 27, 2019
1 parent db42b9d commit e32c5c4
Show file tree
Hide file tree
Showing 24 changed files with 209 additions and 201 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules
.idea
.DS_Store
.vscode
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
lts/*
101 changes: 31 additions & 70 deletions app.js
Original file line number Diff line number Diff line change
@@ -1,89 +1,50 @@
#!/usr/bin/env node
/* eslint unicorn/no-process-exit: 0 */

const path = require('path')
const http = require('http')
const express = require('express')
const logger = require('morgan')
const Twig = require('twig')
const compression = require('compression')
const helmet = require('helmet')
const socketio = require('socket.io')
const config = require('./application/config')

// Until node 11 adds flatmap, we use this:
require('array.prototype.flatmap').shim()

const {sanitizeHtmlTwigFilter} = require('./views/twig-filters')
const EmailManager = require('./mailbox/email-manager')
const inboxRouter = require('./routes/inbox')
const loginRouter = require('./routes/login')
const ClientNotification = require('./helper/client-notification')
const config = require('./helper/config')

// Init express middleware
const app = express()
app.use(helmet())
app.use(compression())
app.set('config', config)
const server = http.createServer(app)
const io = socketio(server)

app.set('socketio', io)
app.use(logger('dev'))
app.use(express.json())
app.use(express.urlencoded({extended: false}))
// View engine setup
app.set('views', path.join(__dirname, 'views'))
app.set('view engine', 'twig')
app.set('twig options', {
autoescape: true
})

// Application code:
app.use(
express.static(path.join(__dirname, 'public'), {
immutable: true,
maxAge: '1h'
})
)
Twig.extendFilter('sanitizeHtml', sanitizeHtmlTwigFilter)
const {app, io} = require('./infrastructure/web/web')
const ClientNotification = require('./infrastructure/web/client-notification')
const ImapService = require('./application/imap-service')
const MailProcessingService = require('./application/mail-processing-service')
const MailRepository = require('./domain/mail-repository')

const clientNotification = new ClientNotification()
clientNotification.use(io)

const emailManager = new EmailManager(config, clientNotification)
app.set('emailManager', emailManager)

app.get('/', (req, res, _next) => {
res.redirect('/login')
})
const imapService = new ImapService(config)
const mailProcessingService = new MailProcessingService(
new MailRepository(),
imapService,
clientNotification,
config
)

app.use('/login', loginRouter)
app.use('/', inboxRouter)
// Put everything together:
imapService.on(ImapService.EVENT_NEW_MAIL, mail => mailProcessingService.onNewMail(mail))
imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, () =>
mailProcessingService.onInitialLoadDone()
)
imapService.on(ImapService.EVENT_DELETED_MAIL, mail =>
mailProcessingService.onMailDeleted(mail)
)

// Catch 404 and forward to error handler
app.use((req, res, next) => {
next({message: 'page not found', status: 404})
mailProcessingService.on('error', err => {
console.error('error from mailProcessingService, stopping.', err)
process.exit(1)
})

// Error handler
app.use((err, req, res, _next) => {
// Set locals, only providing error in development
res.locals.message = err.message
res.locals.error = req.app.get('env') === 'development' ? err : {}

// Render the error page
res.status(err.status || 500)
res.render('error')
imapService.on(ImapService.EVENT_ERROR, error => {
console.error('fatal error from imap service', error)
process.exit(1)
})

emailManager.connectImapAndAutorefresh().catch(error => {
console.error('fatal error from email manager', error)
return process.exit(1)
})
app.set('mailProcessingService', mailProcessingService)

emailManager.on('error', err => {
console.error('error from emailManager, stopping.', err)
imapService.connectAndLoadMessages().catch(error => {
console.error('fatal error from imap service', error)
process.exit(1)
})

module.exports = {app, server}
File renamed without changes.
46 changes: 24 additions & 22 deletions mailbox/imap-service.js → application/imap-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ const addressparser = require('nodemailer/lib/addressparser')
const pSeries = require('p-series')
const retry = require('async-retry')
const debug = require('debug')('void-mail:imap')

const _ = require('lodash')
const Mail = require('../domain/mail')

/**
* Fetches emails from the imap server. It is a facade against the more complicated imap-simple api. It keeps the connection
* as a member field.
* as a member field.
*
* With this abstraction it would be easy to replace this with any inbound mail service like mailgun.com.
*/
class ImapService extends EventEmitter {
constructor(config) {
Expand All @@ -27,7 +29,7 @@ class ImapService extends EventEmitter {
this.initialLoadDone = false
}

async connectAndLoad() {
async connectAndLoadMessages() {
const configWithListener = {
...this.config,
// 'onmail' adds a callback when new mails arrive. With this we can keep the imap refresh interval very low (or even disable it).
Expand All @@ -40,8 +42,8 @@ class ImapService extends EventEmitter {

await this._connectWithRetry(configWithListener)

// Load all messages. ASYNC, return control flow after connecting.
this._loadMailSummariesAndPublish()
// Load all messages in the background. (ASYNC)
this._loadMailSummariesAndEmitAsEvents()
}

async _connectWithRetry(configWithListener) {
Expand Down Expand Up @@ -76,27 +78,29 @@ class ImapService extends EventEmitter {
_doOnNewMail() {
// Only react to new mails after the initial load, otherwise it might load the same mails twice.
if (this.initialLoadDone) {
this._loadMailSummariesAndPublish()
this._loadMailSummariesAndEmitAsEvents()
}
}

_doAfterInitialLoad() {
// During initial load we ignored new incoming emails. In order to catch up with those, we have to refresh
// the mails once after the initial load. (async)
this._loadMailSummariesAndPublish()
this._loadMailSummariesAndEmitAsEvents()

// If the above trigger on new mails does not work reliable, we have to regularly check
// for new mails on the server. This is done only after all the mails have been loaded for the
// first time. (Note: set the refresh higher than the time it takes to download the mails).
if (this.config.imap.refreshIntervalSeconds) {
setInterval(
() => this._loadMailSummariesAndPublish(),
() => this._loadMailSummariesAndEmitAsEvents(),
this.config.imap.refreshIntervalSeconds * 1000
)
}
}

async _loadMailSummariesAndPublish() {
async _loadMailSummariesAndEmitAsEvents() {
// UID: Unique id of a message.

const uids = await this._getAllUids()
const newUids = uids.filter(uid => !this.loadedUids.has(uid))

Expand All @@ -106,9 +110,9 @@ class ImapService extends EventEmitter {
// restart.
const uidChunks = _.chunk(newUids, 20)

// Returning a function. We do not start the search now, we just create the function.
// creates an array of functions. We do not start the search now, we just create the function.
const fetchFunctions = uidChunks.map(uidChunk => () =>
this._getMailHeadersAndPublish(uidChunk)
this._getMailHeadersAndEmitAsEvents(uidChunk)
)

await pSeries(fetchFunctions)
Expand Down Expand Up @@ -137,11 +141,14 @@ class ImapService extends EventEmitter {
await this.connection.deleteMessage(uids)
console.log(`deleted ${uids.length} old messages.`)



uids.forEach(uid => this.emit(ImapService.EVENT_DELETED_MAIL, uid))
}

/**
*
* Helper method because ImapSimple#search also fetches each message. We just need the uids here.
*
* @param {Object} searchCriteria (see ImapSimple#search)
* @returns {Promise<Array<Int>>} Array of UIDs
* @private
Expand Down Expand Up @@ -173,14 +180,7 @@ class ImapService extends EventEmitter {
const date = headerPart.date[0]
const {uid} = message.attributes

return {
raw: message,
to,
from,
date,
subject,
uid
}
return Mail.create(to, from, date, subject, uid)
}

async fetchOneFullMail(to, uid) {
Expand Down Expand Up @@ -208,12 +208,13 @@ class ImapService extends EventEmitter {
}

async _getAllUids() {
// We ignore mails that are flagged as DELETED, but have not been removed (expunged) yet.
const uids = await this._searchWithoutFetch([['!DELETED']])
// Create copy to not mutate the original array
// Create copy to not mutate the original array. Sort with newest first (DESC).
return [...uids].sort().reverse()
}

async _getMailHeadersAndPublish(uids) {
async _getMailHeadersAndEmitAsEvents(uids) {
try {
const mails = await this._getMailHeaders(uids)
mails.forEach(mail => {
Expand All @@ -239,6 +240,7 @@ class ImapService extends EventEmitter {
}
}

// Consumers should use these constants:
ImapService.EVENT_NEW_MAIL = 'mail'
ImapService.EVENT_DELETED_MAIL = 'mailDeleted'
ImapService.EVENT_INITIAL_LOAD_DONE = 'initial load done'
Expand Down
56 changes: 18 additions & 38 deletions mailbox/email-manager.js → application/mail-processing-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,84 +3,64 @@ const debug = require('debug')('void-mail:imap-manager')
const mem = require('mem')
const moment = require('moment')
const ImapService = require('./imap-service')
const EmailSummaryStore = require('./email-summary-store')

/**
* Fetches mails from imap, caches them and provides methods to access them. Also notifies the users via websockets about
* new messages.
*/
class EmailManager extends EventEmitter {
constructor(config, clientNotification) {
class MailProcessingService extends EventEmitter {
constructor(mailRepository, imapService, clientNotification, config) {
super()
this.config = config
this.imapService = new ImapService(config)
this.summaryStore = new EmailSummaryStore()
this.mailRepository = mailRepository
this.clientNotification = clientNotification
this.initialLoadDone = false
this.imapService = imapService
this.config = config

// Cached methods:
this.cachedFetchFullMail = mem(
this.imapService.fetchOneFullMail.bind(this.imapService),
{maxAge: 10 * 60 * 1000}
)

this.imapService.on(ImapService.EVENT_ERROR, err => this.emit('error', err))
this.initialLoadDone = false

// Delete old messages now and every few hours
this.imapService.once(ImapService.EVENT_INITIAL_LOAD_DONE, () =>
this._deleteOldMails()
)

// Delete old messages now and then every few hours
setInterval(() => this._deleteOldMails(), 1000 * 3600 * 6)
}

async connectImapAndAutorefresh() {
// First add the listener, so we don't miss any messages:
this.imapService.on(ImapService.EVENT_NEW_MAIL, mail =>
this._onNewMail(mail)
)
this.imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, () =>
this._onInitialLoadDone()
)
this.imapService.on(ImapService.EVENT_DELETED_MAIL, mail =>
this._onMailDeleted(mail)
)

await this.imapService.connectAndLoad()
}

getMailSummaries(address) {
return this.summaryStore.getForRecipient(address)
return this.mailRepository.getForRecipient(address)
}

getOneFullMail(address, uid) {
return this.cachedFetchFullMail(address, uid)
}

getAllMailSummaries() {
return this.summaryStore.getAll()
return this.mailRepository.getAll()
}

_onInitialLoadDone() {
onInitialLoadDone() {
this.initialLoadDone = true
console.log(`initial load done, got ${this.summaryStore.mailCount()} mails`)
console.log(
`initial load done, got ${this.mailRepository.mailCount()} mails`
)
}

_onNewMail(mail) {
onNewMail(mail) {
if (this.initialLoadDone) {
// For now, only log messages if they arrive after the initial load
debug('new mail for', mail.to[0])
}

mail.to.forEach(to => {
this.summaryStore.add(to, mail)
this.mailRepository.add(to, mail)
return this.clientNotification.emit(to)
})
}

_onMailDeleted(uid) {
onMailDeleted(uid) {
debug('mail deleted with uid', uid)
this.summaryStore.removeUid(uid)
this.mailRepository.removeUid(uid)
// No client notification required, as nobody can cold a connection for 30+ days.
}

Expand All @@ -106,4 +86,4 @@ class EmailManager extends EventEmitter {
}
}

module.exports = EmailManager
module.exports = MailProcessingService
Loading

0 comments on commit e32c5c4

Please sign in to comment.