mirror of
				https://github.com/Crazyco-xyz/48hr.email.git
				synced 2025-11-04 07:16:32 +01:00 
			
		
		
		
	Add Files
This commit is contained in:
		
							parent
							
								
									84a216a2e6
								
							
						
					
					
						commit
						758b72f4c4
					
				
					 26 changed files with 1897 additions and 0 deletions
				
			
		
							
								
								
									
										4
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
node_modules
 | 
			
		||||
.idea
 | 
			
		||||
.DS_Store
 | 
			
		||||
.vscode
 | 
			
		||||
							
								
								
									
										74
									
								
								app.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								app.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,74 @@
 | 
			
		|||
#!/usr/bin/env node
 | 
			
		||||
/* eslint unicorn/no-process-exit: 0 */
 | 
			
		||||
 | 
			
		||||
const config = require('./application/config')
 | 
			
		||||
 | 
			
		||||
// Until node 11 adds flatmap, we use this:
 | 
			
		||||
require('array.prototype.flatmap').shim()
 | 
			
		||||
 | 
			
		||||
const {app, io, server} = 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 imapService = new ImapService(config)
 | 
			
		||||
const mailProcessingService = new MailProcessingService(
 | 
			
		||||
	new MailRepository(),
 | 
			
		||||
	imapService,
 | 
			
		||||
	clientNotification,
 | 
			
		||||
	config
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// 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)
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
mailProcessingService.on('error', err => {
 | 
			
		||||
	console.error('error from mailProcessingService, stopping.', err)
 | 
			
		||||
	process.exit(1)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
imapService.on(ImapService.EVENT_ERROR, error => {
 | 
			
		||||
	console.error('fatal error from imap service', error)
 | 
			
		||||
	process.exit(1)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
app.set('mailProcessingService', mailProcessingService)
 | 
			
		||||
 | 
			
		||||
imapService.connectAndLoadMessages().catch(error => {
 | 
			
		||||
	console.error('fatal error from imap service', error)
 | 
			
		||||
	process.exit(1)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
server.on('error', error => {
 | 
			
		||||
	if (error.syscall !== 'listen') {
 | 
			
		||||
		console.error('fatal web server error', error)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Handle specific listen errors with friendly messages
 | 
			
		||||
	switch (error.code) {
 | 
			
		||||
		case 'EACCES':
 | 
			
		||||
			console.error(
 | 
			
		||||
				'Port ' + config.http.port + ' requires elevated privileges'
 | 
			
		||||
			)
 | 
			
		||||
			process.exit(1)
 | 
			
		||||
		case 'EADDRINUSE':
 | 
			
		||||
			console.error('Port ' + config.http.port + ' is already in use')
 | 
			
		||||
			process.exit(1)
 | 
			
		||||
		default:
 | 
			
		||||
			console.error('fatal web server error', error)
 | 
			
		||||
			process.exit(1)
 | 
			
		||||
	}
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										30
									
								
								app.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								app.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,30 @@
 | 
			
		|||
{
 | 
			
		||||
  "name": "48hr.email | disposable email",
 | 
			
		||||
  "description": "a simple and fast disposable mail service that works directly with your imap server. No database required.",
 | 
			
		||||
  "repository": "https://github.com/Crazyco-xyz/48hr.email",
 | 
			
		||||
  "logo": "https://github.com/Crazyco-xyz/48hr.email/blobl/main/infrastructure/web/public/images/logo.gif",
 | 
			
		||||
  "keywords": [
 | 
			
		||||
    "node",
 | 
			
		||||
    "disposable-mail"
 | 
			
		||||
  ],
 | 
			
		||||
  "env": {
 | 
			
		||||
    "DOMAINS": {
 | 
			
		||||
      "description": "Email domains."
 | 
			
		||||
    },
 | 
			
		||||
    "IMAP_SERVER": {
 | 
			
		||||
      "description": "Hostname of the server (usually imap.example.com)"
 | 
			
		||||
    },
 | 
			
		||||
    "IMAP_USER": {
 | 
			
		||||
      "description": "Username to login to the imap server"
 | 
			
		||||
    },
 | 
			
		||||
    "IMAP_PASSWORD": {
 | 
			
		||||
      "description": "Password to login to the imap server"
 | 
			
		||||
    },
 | 
			
		||||
    "IMAP_REFRESH_INTERVAL_SECONDS": {
 | 
			
		||||
      "description": "How often to refresh the imap messages manually"
 | 
			
		||||
    },
 | 
			
		||||
    "DELETE_MAILS_OLDER_THAN_DAYS": {
 | 
			
		||||
      "description": "How many days to to wait before deleting messages. (default: `30`)"
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										48
									
								
								application/config.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								application/config.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,48 @@
 | 
			
		|||
// Note: Also update app.json and README.md!
 | 
			
		||||
 | 
			
		||||
const config = {
 | 
			
		||||
	email: {
 | 
			
		||||
		domains: process.env.DOMAINS,
 | 
			
		||||
		deleteMailsOlderThanDays: process.env.DELETE_MAILS_OLDER_THAN_DAYS
 | 
			
		||||
	},
 | 
			
		||||
	imap: {
 | 
			
		||||
		user: process.env.IMAP_USER,
 | 
			
		||||
		password: process.env.IMAP_PASSWORD,
 | 
			
		||||
		host: process.env.IMAP_SERVER,
 | 
			
		||||
		port: 993,
 | 
			
		||||
		tls: true,
 | 
			
		||||
		authTimeout: 3000,
 | 
			
		||||
		refreshIntervalSeconds: process.env.IMAP_REFRESH_INTERVAL_SECONDS
 | 
			
		||||
	},
 | 
			
		||||
	http: {port: normalizePort(process.env.PORT || '3000')}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
if (!config.imap.user || !config.imap.password || !config.imap.host) {
 | 
			
		||||
	throw new Error('IMAP is not configured. Use IMAP_* ENV vars.')
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
if (!config.email.domains) {
 | 
			
		||||
	throw new Error('DOMAINS is not configured. Use ENV vars.')
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Normalize a port into a number, string, or false.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
function normalizePort(val) {
 | 
			
		||||
	const port = parseInt(val, 10)
 | 
			
		||||
 | 
			
		||||
	if (isNaN(port)) {
 | 
			
		||||
		// Named pipe
 | 
			
		||||
		return val
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (port >= 0) {
 | 
			
		||||
		// Port number
 | 
			
		||||
		return port
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = config
 | 
			
		||||
							
								
								
									
										319
									
								
								application/imap-service.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										319
									
								
								application/imap-service.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,319 @@
 | 
			
		|||
const EventEmitter = require('events')
 | 
			
		||||
const imaps = require('imap-simple')
 | 
			
		||||
const {simpleParser} = require('mailparser')
 | 
			
		||||
const addressparser = require('nodemailer/lib/addressparser')
 | 
			
		||||
const pSeries = require('p-series')
 | 
			
		||||
const retry = require('async-retry')
 | 
			
		||||
const debug = require('debug')('48hr-email:imap')
 | 
			
		||||
const _ = require('lodash')
 | 
			
		||||
const Mail = require('../domain/mail')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// Just adding some missing functions to imap-simple... :-)
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Deletes the specified message(s).
 | 
			
		||||
 *
 | 
			
		||||
 * @param {string|Array} uid The uid or array of uids indicating the messages to be deleted
 | 
			
		||||
 * @param {function} [callback] Optional callback, receiving signature (err)
 | 
			
		||||
 * @returns {undefined|Promise} Returns a promise when no callback is specified, resolving when the action succeeds.
 | 
			
		||||
 * @memberof ImapSimple
 | 
			
		||||
 */
 | 
			
		||||
imaps.ImapSimple.prototype.deleteMessage = function (uid, callback) {
 | 
			
		||||
    var self = this;
 | 
			
		||||
 | 
			
		||||
    if (callback) {
 | 
			
		||||
        return nodeify(self.deleteMessage(uid), callback);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return new Promise(function (resolve, reject) {
 | 
			
		||||
        self.imap.addFlags(uid, '\\Deleted', function (err) {
 | 
			
		||||
            if (err) {
 | 
			
		||||
                reject(err);
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            self.imap.expunge( function (err) {
 | 
			
		||||
                if (err) {
 | 
			
		||||
                    reject(err);
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
                resolve();
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Close a mailbox
 | 
			
		||||
 *
 | 
			
		||||
 * @param {boolean} [autoExpunge=true] If autoExpunge is true, any messages marked as Deleted in the currently open mailbox will be remove
 | 
			
		||||
 * @param {function} [callback] Optional callback, receiving signature (err)
 | 
			
		||||
 * @returns {undefined|Promise} Returns a promise when no callback is specified, resolving to `boxName`
 | 
			
		||||
 * @memberof ImapSimple
 | 
			
		||||
 */
 | 
			
		||||
imaps.ImapSimple.prototype.closeBox = function (autoExpunge=true, callback) {
 | 
			
		||||
    var self = this;
 | 
			
		||||
 | 
			
		||||
    if (typeof(autoExpunge) == 'function'){
 | 
			
		||||
        callback = autoExpunge;
 | 
			
		||||
        autoExpunge = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (callback) {
 | 
			
		||||
        return nodeify(this.closeBox(autoExpunge), callback);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return new Promise(function (resolve, reject) {
 | 
			
		||||
 | 
			
		||||
        self.imap.closeBox(autoExpunge, function (err, result) {
 | 
			
		||||
 | 
			
		||||
            if (err) {
 | 
			
		||||
                reject(err);
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            resolve(result);
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 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.
 | 
			
		||||
 *
 | 
			
		||||
 * With this abstraction it would be easy to replace this with any inbound mail service like mailgun.com.
 | 
			
		||||
 */
 | 
			
		||||
class ImapService extends EventEmitter {
 | 
			
		||||
	constructor(config) {
 | 
			
		||||
		super()
 | 
			
		||||
		this.config = config
 | 
			
		||||
 | 
			
		||||
		/**
 | 
			
		||||
		 * Set of emitted UIDs. Listeners should get each email only once.
 | 
			
		||||
		 * @type {Set<any>}
 | 
			
		||||
		 */
 | 
			
		||||
		this.loadedUids = new Set()
 | 
			
		||||
 | 
			
		||||
		this.connection = null
 | 
			
		||||
		this.initialLoadDone = false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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).
 | 
			
		||||
			onmail: () => this._doOnNewMail()
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		this.once(ImapService.EVENT_INITIAL_LOAD_DONE, () =>
 | 
			
		||||
			this._doAfterInitialLoad()
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		await this._connectWithRetry(configWithListener)
 | 
			
		||||
 | 
			
		||||
		// Load all messages in the background. (ASYNC)
 | 
			
		||||
		this._loadMailSummariesAndEmitAsEvents()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async _connectWithRetry(configWithListener) {
 | 
			
		||||
		try {
 | 
			
		||||
			await retry(
 | 
			
		||||
				async _bail => {
 | 
			
		||||
					// If anything throws, we retry
 | 
			
		||||
					this.connection = await imaps.connect(configWithListener)
 | 
			
		||||
 | 
			
		||||
					this.connection.on('error', err => {
 | 
			
		||||
						// We assume that the app will be restarted after a crash.
 | 
			
		||||
						console.error(
 | 
			
		||||
							'got fatal error during imap operation, stop app.',
 | 
			
		||||
							err
 | 
			
		||||
						)
 | 
			
		||||
						this.emit('error', err)
 | 
			
		||||
					})
 | 
			
		||||
 | 
			
		||||
					await this.connection.openBox('INBOX')
 | 
			
		||||
					debug('connected to imap')
 | 
			
		||||
				},
 | 
			
		||||
				{
 | 
			
		||||
					retries: 5
 | 
			
		||||
				}
 | 
			
		||||
			)
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error('can not connect, even with retry, stop app', error)
 | 
			
		||||
			throw error
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_doOnNewMail() {
 | 
			
		||||
		// Only react to new mails after the initial load, otherwise it might load the same mails twice.
 | 
			
		||||
		if (this.initialLoadDone) {
 | 
			
		||||
			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._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._loadMailSummariesAndEmitAsEvents(),
 | 
			
		||||
				this.config.imap.refreshIntervalSeconds * 1000
 | 
			
		||||
			)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async _loadMailSummariesAndEmitAsEvents() {
 | 
			
		||||
		// UID: Unique id of a message.
 | 
			
		||||
 | 
			
		||||
		const uids = await this._getAllUids()
 | 
			
		||||
		const newUids = uids.filter(uid => !this.loadedUids.has(uid))
 | 
			
		||||
 | 
			
		||||
		// Optimize by fetching several messages (but not all) with one 'search' call.
 | 
			
		||||
		// fetching all at once might be more efficient, but then it takes long until we see any messages
 | 
			
		||||
		// in the frontend. With a small chunk size we ensure that we see the newest emails after a few seconds after
 | 
			
		||||
		// restart.
 | 
			
		||||
		const uidChunks = _.chunk(newUids, 20)
 | 
			
		||||
 | 
			
		||||
		// Creates an array of functions. We do not start the search now, we just create the function.
 | 
			
		||||
		const fetchFunctions = uidChunks.map(uidChunk => () =>
 | 
			
		||||
			this._getMailHeadersAndEmitAsEvents(uidChunk)
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		await pSeries(fetchFunctions)
 | 
			
		||||
 | 
			
		||||
		if (!this.initialLoadDone) {
 | 
			
		||||
			this.initialLoadDone = true
 | 
			
		||||
			this.emit(ImapService.EVENT_INITIAL_LOAD_DONE)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 *
 | 
			
		||||
	 * @param {Date} deleteMailsBefore delete mails before this date instance
 | 
			
		||||
	 */
 | 
			
		||||
	async deleteOldMails(deleteMailsBefore) {
 | 
			
		||||
		debug(`deleting mails before ${deleteMailsBefore}`)
 | 
			
		||||
		const uids = await this._searchWithoutFetch([
 | 
			
		||||
			['!DELETED'],
 | 
			
		||||
			['BEFORE', deleteMailsBefore]
 | 
			
		||||
		])
 | 
			
		||||
		if (uids.length === 0) {
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		debug(`deleting mails ${uids}`)
 | 
			
		||||
		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
 | 
			
		||||
	 */
 | 
			
		||||
	async _searchWithoutFetch(searchCriteria) {
 | 
			
		||||
		const imapUnderlying = this.connection.imap
 | 
			
		||||
 | 
			
		||||
		return new Promise((resolve, reject) => {
 | 
			
		||||
			imapUnderlying.search(searchCriteria, (err, uids) => {
 | 
			
		||||
				if (err) {
 | 
			
		||||
					reject(err)
 | 
			
		||||
				} else {
 | 
			
		||||
					resolve(uids || [])
 | 
			
		||||
				}
 | 
			
		||||
			})
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_createMailSummary(message) {
 | 
			
		||||
		const headerPart = message.parts[0].body
 | 
			
		||||
		const to = headerPart.to
 | 
			
		||||
			.flatMap(to => addressparser(to))
 | 
			
		||||
			// The address also contains the name, just keep the email
 | 
			
		||||
			.map(addressObj => addressObj.address)
 | 
			
		||||
 | 
			
		||||
		const from = headerPart.from.flatMap(from => addressparser(from))
 | 
			
		||||
 | 
			
		||||
		const subject = headerPart.subject[0]
 | 
			
		||||
		const date = headerPart.date[0]
 | 
			
		||||
		const {uid} = message.attributes
 | 
			
		||||
 | 
			
		||||
		return Mail.create(to, from, date, subject, uid)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async fetchOneFullMail(to, uid) {
 | 
			
		||||
		if (!this.connection) {
 | 
			
		||||
			// Here we 'fail fast' instead of waiting for the connection.
 | 
			
		||||
			throw new Error('imap connection not ready')
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		debug(`fetching full message ${uid}`)
 | 
			
		||||
 | 
			
		||||
		// For security we also filter TO, so it is harder to just enumerate all messages.
 | 
			
		||||
		const searchCriteria = [['UID', uid], ['TO', to]]
 | 
			
		||||
		const fetchOptions = {
 | 
			
		||||
			bodies: ['HEADER', ''], // Empty string means full body
 | 
			
		||||
			markSeen: false
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const messages = await this.connection.search(searchCriteria, fetchOptions)
 | 
			
		||||
		if (messages.length === 0) {
 | 
			
		||||
			throw new Error('email not found')
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const fullBody = _.find(messages[0].parts, {which: ''})
 | 
			
		||||
		return simpleParser(fullBody.body)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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. Sort with newest first (DESC).
 | 
			
		||||
		return [...uids].sort().reverse()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async _getMailHeadersAndEmitAsEvents(uids) {
 | 
			
		||||
		try {
 | 
			
		||||
			const mails = await this._getMailHeaders(uids)
 | 
			
		||||
			mails.forEach(mail => {
 | 
			
		||||
				this.loadedUids.add(mail.attributes.uid)
 | 
			
		||||
				// Some broadcast messages have no TO field. We have to ignore those messages.
 | 
			
		||||
				if (mail.parts[0].body.to) {
 | 
			
		||||
					this.emit(ImapService.EVENT_NEW_MAIL, this._createMailSummary(mail))
 | 
			
		||||
				}
 | 
			
		||||
			})
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			debug('can not fetch', error)
 | 
			
		||||
			throw error
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async _getMailHeaders(uids) {
 | 
			
		||||
		const fetchOptions = {
 | 
			
		||||
			bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)'],
 | 
			
		||||
			struct: false
 | 
			
		||||
		}
 | 
			
		||||
		const searchCriteria = [['UID', ...uids]]
 | 
			
		||||
		return this.connection.search(searchCriteria, fetchOptions)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Consumers should use these constants:
 | 
			
		||||
ImapService.EVENT_NEW_MAIL = 'mail'
 | 
			
		||||
ImapService.EVENT_DELETED_MAIL = 'mailDeleted'
 | 
			
		||||
ImapService.EVENT_INITIAL_LOAD_DONE = 'initial load done'
 | 
			
		||||
ImapService.EVENT_ERROR = 'error'
 | 
			
		||||
 | 
			
		||||
module.exports = ImapService
 | 
			
		||||
							
								
								
									
										89
									
								
								application/mail-processing-service.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								application/mail-processing-service.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,89 @@
 | 
			
		|||
const EventEmitter = require('events')
 | 
			
		||||
const debug = require('debug')('48hr-email:imap-manager')
 | 
			
		||||
const mem = require('mem')
 | 
			
		||||
const moment = require('moment')
 | 
			
		||||
const ImapService = require('./imap-service')
 | 
			
		||||
 | 
			
		||||
class MailProcessingService extends EventEmitter {
 | 
			
		||||
	constructor(mailRepository, imapService, clientNotification, config) {
 | 
			
		||||
		super()
 | 
			
		||||
		this.mailRepository = mailRepository
 | 
			
		||||
		this.clientNotification = clientNotification
 | 
			
		||||
		this.imapService = imapService
 | 
			
		||||
		this.config = config
 | 
			
		||||
 | 
			
		||||
		// Cached methods:
 | 
			
		||||
		this.cachedFetchFullMail = mem(
 | 
			
		||||
			this.imapService.fetchOneFullMail.bind(this.imapService),
 | 
			
		||||
			{maxAge: 10 * 60 * 1000}
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		this.initialLoadDone = false
 | 
			
		||||
 | 
			
		||||
		// Delete old messages now and every few hours
 | 
			
		||||
		this.imapService.once(ImapService.EVENT_INITIAL_LOAD_DONE, () =>
 | 
			
		||||
			this._deleteOldMails()
 | 
			
		||||
		)
 | 
			
		||||
		setInterval(() => this._deleteOldMails(), 1000 * 3600 * 6)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	getMailSummaries(address) {
 | 
			
		||||
		return this.mailRepository.getForRecipient(address)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	getOneFullMail(address, uid) {
 | 
			
		||||
		return this.cachedFetchFullMail(address, uid)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	getAllMailSummaries() {
 | 
			
		||||
		return this.mailRepository.getAll()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	onInitialLoadDone() {
 | 
			
		||||
		this.initialLoadDone = true
 | 
			
		||||
		console.log(
 | 
			
		||||
			`initial load done, got ${this.mailRepository.mailCount()} mails`
 | 
			
		||||
		)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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.mailRepository.add(to, mail)
 | 
			
		||||
			return this.clientNotification.emit(to)
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	onMailDeleted(uid) {
 | 
			
		||||
		debug('mail deleted with uid', uid)
 | 
			
		||||
		this.mailRepository.removeUid(uid)
 | 
			
		||||
		// No client notification required, as nobody can cold a connection for 30+ days.
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async _deleteOldMails() {
 | 
			
		||||
		try {
 | 
			
		||||
			await this.imapService.deleteOldMails(
 | 
			
		||||
				moment()
 | 
			
		||||
					.subtract(this.config.email.deleteMailsOlderThanDays, 'days')
 | 
			
		||||
					.toDate()
 | 
			
		||||
			)
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.log('can not delete old messages', error)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_saveToFile(mails, filename) {
 | 
			
		||||
		const fs = require('fs')
 | 
			
		||||
		fs.writeFile(filename, JSON.stringify(mails), err => {
 | 
			
		||||
			if (err) {
 | 
			
		||||
				console.error('can not save mails to file', err)
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = MailProcessingService
 | 
			
		||||
							
								
								
									
										42
									
								
								domain/mail-repository.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								domain/mail-repository.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,42 @@
 | 
			
		|||
const debug = require('debug')('48hr-email:mail-summary-store')
 | 
			
		||||
const MultiMap = require('mnemonist/multi-map')
 | 
			
		||||
const _ = require('lodash')
 | 
			
		||||
 | 
			
		||||
class MailRepository {
 | 
			
		||||
	constructor() {
 | 
			
		||||
		// MultiMap docs: https://yomguithereal.github.io/mnemonist/multi-map
 | 
			
		||||
		this.mailSummaries = new MultiMap()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	getForRecipient(address) {
 | 
			
		||||
		const mails = this.mailSummaries.get(address) || []
 | 
			
		||||
		return _.orderBy(mails, mail => Date.parse(mail.date), ['desc'])
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	getAll() {
 | 
			
		||||
		const mails = [...this.mailSummaries.values()]
 | 
			
		||||
		return _.orderBy(mails, mail => Date.parse(mail.date), ['desc'])
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	add(to, mailSummary) {
 | 
			
		||||
		this.mailSummaries.set(to.toLowerCase(), mailSummary)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	removeUid(uid) {
 | 
			
		||||
		// TODO: make this more efficient, looping through each email is not cool.
 | 
			
		||||
		this.mailSummaries.forEachAssociation((mails, to) => {
 | 
			
		||||
			mails
 | 
			
		||||
				.filter(mail => mail.uid === uid)
 | 
			
		||||
				.forEach(mail => {
 | 
			
		||||
					this.mailSummaries.remove(to, mail)
 | 
			
		||||
					debug('removed ', mail.date, to, mail.subject)
 | 
			
		||||
				})
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	mailCount() {
 | 
			
		||||
		return this.mailSummaries.size
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = MailRepository
 | 
			
		||||
							
								
								
									
										15
									
								
								domain/mail.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								domain/mail.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,15 @@
 | 
			
		|||
class Mail {
 | 
			
		||||
	constructor(to, from, date, subject, uid) {
 | 
			
		||||
		this.to = to
 | 
			
		||||
		this.from = from
 | 
			
		||||
		this.date = date
 | 
			
		||||
		this.subject = subject
 | 
			
		||||
		this.uid = uid
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	static create(to, from, date, subject, uid) {
 | 
			
		||||
		return new Mail(to, from, date, subject, uid)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = Mail
 | 
			
		||||
							
								
								
									
										31
									
								
								infrastructure/web/client-notification.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								infrastructure/web/client-notification.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,31 @@
 | 
			
		|||
const EventEmitter = require('events')
 | 
			
		||||
const debug = require('debug')('48hr-email:notification')
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Receives sign-ins from users and notifies them when new mails are available.
 | 
			
		||||
 */
 | 
			
		||||
class ClientNotification extends EventEmitter {
 | 
			
		||||
	use(io) {
 | 
			
		||||
		io.on('connection', socket => {
 | 
			
		||||
			socket.on('sign in', address => this._signIn(socket, address))
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_signIn(socket, address) {
 | 
			
		||||
		debug(`socketio signed in: ${address}`)
 | 
			
		||||
 | 
			
		||||
		const newMailListener = () => {
 | 
			
		||||
			debug(`${address} has new messages, sending notification`)
 | 
			
		||||
			socket.emit('new emails')
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		this.on(address, newMailListener)
 | 
			
		||||
 | 
			
		||||
		socket.on('disconnect', reason => {
 | 
			
		||||
			debug(`client disconnect: ${address} (${reason})`)
 | 
			
		||||
			this.removeListener(address, newMailListener)
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = ClientNotification
 | 
			
		||||
							
								
								
									
										602
									
								
								infrastructure/web/public/dependencies/milligram.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										602
									
								
								infrastructure/web/public/dependencies/milligram.css
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,602 @@
 | 
			
		|||
/*!
 | 
			
		||||
 * Milligram v1.3.0
 | 
			
		||||
 * https://milligram.github.io
 | 
			
		||||
 *
 | 
			
		||||
 * Copyright (c) 2017 CJ Patoilo
 | 
			
		||||
 * Licensed under the MIT license
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
*,
 | 
			
		||||
*:after,
 | 
			
		||||
*:before {
 | 
			
		||||
  box-sizing: inherit;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
html {
 | 
			
		||||
  box-sizing: border-box;
 | 
			
		||||
  font-size: 62.5%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
  color: #606c76;
 | 
			
		||||
  font-family: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
 | 
			
		||||
  font-size: 1.6em;
 | 
			
		||||
  font-weight: 300;
 | 
			
		||||
  letter-spacing: .01em;
 | 
			
		||||
  line-height: 1.6;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
blockquote {
 | 
			
		||||
  border-left: 0.3rem solid #d1d1d1;
 | 
			
		||||
  margin-left: 0;
 | 
			
		||||
  margin-right: 0;
 | 
			
		||||
  padding: 1rem 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
blockquote *:last-child {
 | 
			
		||||
  margin-bottom: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.button,
 | 
			
		||||
button,
 | 
			
		||||
input[type='button'],
 | 
			
		||||
input[type='reset'],
 | 
			
		||||
input[type='submit'] {
 | 
			
		||||
  background-color: #9b4dca;
 | 
			
		||||
  border: 0.1rem solid #9b4dca;
 | 
			
		||||
  border-radius: .4rem;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  font-size: 1.1rem;
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
  height: 3.8rem;
 | 
			
		||||
  letter-spacing: .1rem;
 | 
			
		||||
  line-height: 3.8rem;
 | 
			
		||||
  padding: 0 3.0rem;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
  text-transform: uppercase;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.button:focus, .button:hover,
 | 
			
		||||
button:focus,
 | 
			
		||||
button:hover,
 | 
			
		||||
input[type='button']:focus,
 | 
			
		||||
input[type='button']:hover,
 | 
			
		||||
input[type='reset']:focus,
 | 
			
		||||
input[type='reset']:hover,
 | 
			
		||||
input[type='submit']:focus,
 | 
			
		||||
input[type='submit']:hover {
 | 
			
		||||
  background-color: #606c76;
 | 
			
		||||
  border-color: #606c76;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  outline: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.button[disabled],
 | 
			
		||||
button[disabled],
 | 
			
		||||
input[type='button'][disabled],
 | 
			
		||||
input[type='reset'][disabled],
 | 
			
		||||
input[type='submit'][disabled] {
 | 
			
		||||
  cursor: default;
 | 
			
		||||
  opacity: .5;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.button[disabled]:focus, .button[disabled]:hover,
 | 
			
		||||
button[disabled]:focus,
 | 
			
		||||
button[disabled]:hover,
 | 
			
		||||
input[type='button'][disabled]:focus,
 | 
			
		||||
input[type='button'][disabled]:hover,
 | 
			
		||||
input[type='reset'][disabled]:focus,
 | 
			
		||||
input[type='reset'][disabled]:hover,
 | 
			
		||||
input[type='submit'][disabled]:focus,
 | 
			
		||||
input[type='submit'][disabled]:hover {
 | 
			
		||||
  background-color: #9b4dca;
 | 
			
		||||
  border-color: #9b4dca;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.button.button-outline,
 | 
			
		||||
button.button-outline,
 | 
			
		||||
input[type='button'].button-outline,
 | 
			
		||||
input[type='reset'].button-outline,
 | 
			
		||||
input[type='submit'].button-outline {
 | 
			
		||||
  background-color: transparent;
 | 
			
		||||
  color: #9b4dca;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.button.button-outline:focus, .button.button-outline:hover,
 | 
			
		||||
button.button-outline:focus,
 | 
			
		||||
button.button-outline:hover,
 | 
			
		||||
input[type='button'].button-outline:focus,
 | 
			
		||||
input[type='button'].button-outline:hover,
 | 
			
		||||
input[type='reset'].button-outline:focus,
 | 
			
		||||
input[type='reset'].button-outline:hover,
 | 
			
		||||
input[type='submit'].button-outline:focus,
 | 
			
		||||
input[type='submit'].button-outline:hover {
 | 
			
		||||
  background-color: transparent;
 | 
			
		||||
  border-color: #606c76;
 | 
			
		||||
  color: #606c76;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.button.button-outline[disabled]:focus, .button.button-outline[disabled]:hover,
 | 
			
		||||
button.button-outline[disabled]:focus,
 | 
			
		||||
button.button-outline[disabled]:hover,
 | 
			
		||||
input[type='button'].button-outline[disabled]:focus,
 | 
			
		||||
input[type='button'].button-outline[disabled]:hover,
 | 
			
		||||
input[type='reset'].button-outline[disabled]:focus,
 | 
			
		||||
input[type='reset'].button-outline[disabled]:hover,
 | 
			
		||||
input[type='submit'].button-outline[disabled]:focus,
 | 
			
		||||
input[type='submit'].button-outline[disabled]:hover {
 | 
			
		||||
  border-color: inherit;
 | 
			
		||||
  color: #9b4dca;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.button.button-clear,
 | 
			
		||||
button.button-clear,
 | 
			
		||||
input[type='button'].button-clear,
 | 
			
		||||
input[type='reset'].button-clear,
 | 
			
		||||
input[type='submit'].button-clear {
 | 
			
		||||
  background-color: transparent;
 | 
			
		||||
  border-color: transparent;
 | 
			
		||||
  color: #9b4dca;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.button.button-clear:focus, .button.button-clear:hover,
 | 
			
		||||
button.button-clear:focus,
 | 
			
		||||
button.button-clear:hover,
 | 
			
		||||
input[type='button'].button-clear:focus,
 | 
			
		||||
input[type='button'].button-clear:hover,
 | 
			
		||||
input[type='reset'].button-clear:focus,
 | 
			
		||||
input[type='reset'].button-clear:hover,
 | 
			
		||||
input[type='submit'].button-clear:focus,
 | 
			
		||||
input[type='submit'].button-clear:hover {
 | 
			
		||||
  background-color: transparent;
 | 
			
		||||
  border-color: transparent;
 | 
			
		||||
  color: #606c76;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.button.button-clear[disabled]:focus, .button.button-clear[disabled]:hover,
 | 
			
		||||
button.button-clear[disabled]:focus,
 | 
			
		||||
button.button-clear[disabled]:hover,
 | 
			
		||||
input[type='button'].button-clear[disabled]:focus,
 | 
			
		||||
input[type='button'].button-clear[disabled]:hover,
 | 
			
		||||
input[type='reset'].button-clear[disabled]:focus,
 | 
			
		||||
input[type='reset'].button-clear[disabled]:hover,
 | 
			
		||||
input[type='submit'].button-clear[disabled]:focus,
 | 
			
		||||
input[type='submit'].button-clear[disabled]:hover {
 | 
			
		||||
  color: #9b4dca;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
code {
 | 
			
		||||
  background: #f4f5f6;
 | 
			
		||||
  border-radius: .4rem;
 | 
			
		||||
  font-size: 86%;
 | 
			
		||||
  margin: 0 .2rem;
 | 
			
		||||
  padding: .2rem .5rem;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pre {
 | 
			
		||||
  background: #f4f5f6;
 | 
			
		||||
  border-left: 0.3rem solid #9b4dca;
 | 
			
		||||
  overflow-y: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pre > code {
 | 
			
		||||
  border-radius: 0;
 | 
			
		||||
  display: block;
 | 
			
		||||
  padding: 1rem 1.5rem;
 | 
			
		||||
  white-space: pre;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
hr {
 | 
			
		||||
  border: 0;
 | 
			
		||||
  border-top: 0.1rem solid #f4f5f6;
 | 
			
		||||
  margin: 3.0rem 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
input[type='email'],
 | 
			
		||||
input[type='number'],
 | 
			
		||||
input[type='password'],
 | 
			
		||||
input[type='search'],
 | 
			
		||||
input[type='tel'],
 | 
			
		||||
input[type='text'],
 | 
			
		||||
input[type='url'],
 | 
			
		||||
textarea,
 | 
			
		||||
select {
 | 
			
		||||
  -webkit-appearance: none;
 | 
			
		||||
     -moz-appearance: none;
 | 
			
		||||
          appearance: none;
 | 
			
		||||
  background-color: transparent;
 | 
			
		||||
  border: 0.1rem solid #d1d1d1;
 | 
			
		||||
  border-radius: .4rem;
 | 
			
		||||
  box-shadow: none;
 | 
			
		||||
  box-sizing: inherit;
 | 
			
		||||
  height: 3.8rem;
 | 
			
		||||
  padding: .6rem 1.0rem;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
input[type='email']:focus,
 | 
			
		||||
input[type='number']:focus,
 | 
			
		||||
input[type='password']:focus,
 | 
			
		||||
input[type='search']:focus,
 | 
			
		||||
input[type='tel']:focus,
 | 
			
		||||
input[type='text']:focus,
 | 
			
		||||
input[type='url']:focus,
 | 
			
		||||
textarea:focus,
 | 
			
		||||
select:focus {
 | 
			
		||||
  border-color: #9b4dca;
 | 
			
		||||
  outline: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
select {
 | 
			
		||||
  background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#d1d1d1" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>') center right no-repeat;
 | 
			
		||||
  padding-right: 3.0rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
select:focus {
 | 
			
		||||
  background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#9b4dca" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
textarea {
 | 
			
		||||
  min-height: 6.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
label,
 | 
			
		||||
legend {
 | 
			
		||||
  display: block;
 | 
			
		||||
  font-size: 1.6rem;
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
  margin-bottom: .5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fieldset {
 | 
			
		||||
  border-width: 0;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
input[type='checkbox'],
 | 
			
		||||
input[type='radio'] {
 | 
			
		||||
  display: inline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.label-inline {
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  font-weight: normal;
 | 
			
		||||
  margin-left: .5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.container {
 | 
			
		||||
  margin: 0 auto;
 | 
			
		||||
  max-width: 112.0rem;
 | 
			
		||||
  padding: 0 2.0rem;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row.row-no-padding {
 | 
			
		||||
  padding: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row.row-no-padding > .column {
 | 
			
		||||
  padding: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row.row-wrap {
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row.row-top {
 | 
			
		||||
  align-items: flex-start;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row.row-bottom {
 | 
			
		||||
  align-items: flex-end;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row.row-center {
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row.row-stretch {
 | 
			
		||||
  align-items: stretch;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row.row-baseline {
 | 
			
		||||
  align-items: baseline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column {
 | 
			
		||||
  display: block;
 | 
			
		||||
  flex: 1 1 auto;
 | 
			
		||||
  margin-left: 0;
 | 
			
		||||
  max-width: 100%;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-offset-10 {
 | 
			
		||||
  margin-left: 10%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-offset-20 {
 | 
			
		||||
  margin-left: 20%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-offset-25 {
 | 
			
		||||
  margin-left: 25%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-offset-33, .row .column.column-offset-34 {
 | 
			
		||||
  margin-left: 33.3333%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-offset-50 {
 | 
			
		||||
  margin-left: 50%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-offset-66, .row .column.column-offset-67 {
 | 
			
		||||
  margin-left: 66.6666%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-offset-75 {
 | 
			
		||||
  margin-left: 75%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-offset-80 {
 | 
			
		||||
  margin-left: 80%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-offset-90 {
 | 
			
		||||
  margin-left: 90%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-10 {
 | 
			
		||||
  flex: 0 0 10%;
 | 
			
		||||
  max-width: 10%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-20 {
 | 
			
		||||
  flex: 0 0 20%;
 | 
			
		||||
  max-width: 20%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-25 {
 | 
			
		||||
  flex: 0 0 25%;
 | 
			
		||||
  max-width: 25%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-33, .row .column.column-34 {
 | 
			
		||||
  flex: 0 0 33.3333%;
 | 
			
		||||
  max-width: 33.3333%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-40 {
 | 
			
		||||
  flex: 0 0 40%;
 | 
			
		||||
  max-width: 40%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-50 {
 | 
			
		||||
  flex: 0 0 50%;
 | 
			
		||||
  max-width: 50%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-60 {
 | 
			
		||||
  flex: 0 0 60%;
 | 
			
		||||
  max-width: 60%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-66, .row .column.column-67 {
 | 
			
		||||
  flex: 0 0 66.6666%;
 | 
			
		||||
  max-width: 66.6666%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-75 {
 | 
			
		||||
  flex: 0 0 75%;
 | 
			
		||||
  max-width: 75%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-80 {
 | 
			
		||||
  flex: 0 0 80%;
 | 
			
		||||
  max-width: 80%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column.column-90 {
 | 
			
		||||
  flex: 0 0 90%;
 | 
			
		||||
  max-width: 90%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column .column-top {
 | 
			
		||||
  align-self: flex-start;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column .column-bottom {
 | 
			
		||||
  align-self: flex-end;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.row .column .column-center {
 | 
			
		||||
  -ms-grid-row-align: center;
 | 
			
		||||
      align-self: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (min-width: 40rem) {
 | 
			
		||||
  .row {
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
    margin-left: -1.0rem;
 | 
			
		||||
    width: calc(100% + 2.0rem);
 | 
			
		||||
  }
 | 
			
		||||
  .row .column {
 | 
			
		||||
    margin-bottom: inherit;
 | 
			
		||||
    padding: 0 1.0rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
a {
 | 
			
		||||
  color: #9b4dca;
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
a:focus, a:hover {
 | 
			
		||||
  color: #606c76;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
dl,
 | 
			
		||||
ol,
 | 
			
		||||
ul {
 | 
			
		||||
  list-style: none;
 | 
			
		||||
  margin-top: 0;
 | 
			
		||||
  padding-left: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
dl dl,
 | 
			
		||||
dl ol,
 | 
			
		||||
dl ul,
 | 
			
		||||
ol dl,
 | 
			
		||||
ol ol,
 | 
			
		||||
ol ul,
 | 
			
		||||
ul dl,
 | 
			
		||||
ul ol,
 | 
			
		||||
ul ul {
 | 
			
		||||
  font-size: 90%;
 | 
			
		||||
  margin: 1.5rem 0 1.5rem 3.0rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ol {
 | 
			
		||||
  list-style: decimal inside;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ul {
 | 
			
		||||
  list-style: circle inside;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.button,
 | 
			
		||||
button,
 | 
			
		||||
dd,
 | 
			
		||||
dt,
 | 
			
		||||
li {
 | 
			
		||||
  margin-bottom: 1.0rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fieldset,
 | 
			
		||||
input,
 | 
			
		||||
select,
 | 
			
		||||
textarea {
 | 
			
		||||
  margin-bottom: 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
blockquote,
 | 
			
		||||
dl,
 | 
			
		||||
figure,
 | 
			
		||||
form,
 | 
			
		||||
ol,
 | 
			
		||||
p,
 | 
			
		||||
pre,
 | 
			
		||||
table,
 | 
			
		||||
ul {
 | 
			
		||||
  margin-bottom: 2.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
table {
 | 
			
		||||
  border-spacing: 0;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
td,
 | 
			
		||||
th {
 | 
			
		||||
  border-bottom: 0.1rem solid #e1e1e1;
 | 
			
		||||
  padding: 1.2rem 1.5rem;
 | 
			
		||||
  text-align: left;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
td:first-child,
 | 
			
		||||
th:first-child {
 | 
			
		||||
  padding-left: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
td:last-child,
 | 
			
		||||
th:last-child {
 | 
			
		||||
  padding-right: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
b,
 | 
			
		||||
strong {
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
p {
 | 
			
		||||
  margin-top: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h1,
 | 
			
		||||
h2,
 | 
			
		||||
h3,
 | 
			
		||||
h4,
 | 
			
		||||
h5,
 | 
			
		||||
h6 {
 | 
			
		||||
  font-weight: 300;
 | 
			
		||||
  letter-spacing: -.1rem;
 | 
			
		||||
  margin-bottom: 2.0rem;
 | 
			
		||||
  margin-top: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h1 {
 | 
			
		||||
  font-size: 4.6rem;
 | 
			
		||||
  line-height: 1.2;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h2 {
 | 
			
		||||
  font-size: 3.6rem;
 | 
			
		||||
  line-height: 1.25;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h3 {
 | 
			
		||||
  font-size: 2.8rem;
 | 
			
		||||
  line-height: 1.3;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h4 {
 | 
			
		||||
  font-size: 2.2rem;
 | 
			
		||||
  letter-spacing: -.08rem;
 | 
			
		||||
  line-height: 1.35;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h5 {
 | 
			
		||||
  font-size: 1.8rem;
 | 
			
		||||
  letter-spacing: -.05rem;
 | 
			
		||||
  line-height: 1.5;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h6 {
 | 
			
		||||
  font-size: 1.6rem;
 | 
			
		||||
  letter-spacing: 0;
 | 
			
		||||
  line-height: 1.4;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
img {
 | 
			
		||||
  max-width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.clearfix:after {
 | 
			
		||||
  clear: both;
 | 
			
		||||
  content: ' ';
 | 
			
		||||
  display: table;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.float-left {
 | 
			
		||||
  float: left;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.float-right {
 | 
			
		||||
  float: right;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*# sourceMappingURL=milligram.css.map */
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								infrastructure/web/public/images/icon.ico
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								infrastructure/web/public/images/icon.ico
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 46 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								infrastructure/web/public/images/logo.gif
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								infrastructure/web/public/images/logo.gif
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 73 KiB  | 
							
								
								
									
										57
									
								
								infrastructure/web/public/javascripts/notifications.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								infrastructure/web/public/javascripts/notifications.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,57 @@
 | 
			
		|||
/* eslint no-unused-vars: 0 */
 | 
			
		||||
/* eslint no-undef: 0 */
 | 
			
		||||
 | 
			
		||||
function showNewMailsNotification(address, reloadPage) {
 | 
			
		||||
	// We want the page to be reloaded. But then when clicking the notification, it can not find the tab and will open a new one.
 | 
			
		||||
 | 
			
		||||
	const notification = new Notification(address, {
 | 
			
		||||
		body: 'You have new messages',
 | 
			
		||||
		icon: '/images/logo.gif',
 | 
			
		||||
		tag: 'voidmail-replace-notification',
 | 
			
		||||
		renotify: true
 | 
			
		||||
	})
 | 
			
		||||
	notification.addEventListener('click', event => {
 | 
			
		||||
		// TODO: does not work after reloading the page, see #1
 | 
			
		||||
		event.preventDefault()
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	if (reloadPage) {
 | 
			
		||||
		location.reload()
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function enableNewMessageNotifications(address, reloadPage) {
 | 
			
		||||
	enableNotifications()
 | 
			
		||||
	const socket = io()
 | 
			
		||||
	socket.emit('sign in', address)
 | 
			
		||||
 | 
			
		||||
	socket.on('reconnect', () => {
 | 
			
		||||
		socket.emit('sign in', address)
 | 
			
		||||
	})
 | 
			
		||||
	socket.on('new emails', () => {
 | 
			
		||||
		showNewMailsNotification(address, reloadPage)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function enableNotifications() {
 | 
			
		||||
	// Let's check if the browser supports notifications
 | 
			
		||||
	if (!('Notification' in window)) {
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Let's check whether notification permissions have already been granted
 | 
			
		||||
	if (Notification.permission === 'granted') {
 | 
			
		||||
		return true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Otherwise, we need to ask the user for permission
 | 
			
		||||
	if (Notification.permission !== 'denied') {
 | 
			
		||||
		Notification.requestPermission(permission => {
 | 
			
		||||
			// If the user accepts, let's create a notification
 | 
			
		||||
			return permission === 'granted'
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Finally, if the user has denied notifications and you
 | 
			
		||||
	// want to be respectful there is no need to bother them any more.
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										134
									
								
								infrastructure/web/public/stylesheets/custom.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								infrastructure/web/public/stylesheets/custom.css
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,134 @@
 | 
			
		|||
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    padding: 10px 10px 10px;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    min-height: 100vh;
 | 
			
		||||
    background-color: #131516;
 | 
			
		||||
    color: #cccccc;
 | 
			
		||||
}
 | 
			
		||||
main {
 | 
			
		||||
    flex: 1; /* keep footer at the bottom */
 | 
			
		||||
}
 | 
			
		||||
a {
 | 
			
		||||
    color: #cccccc;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h1 {
 | 
			
		||||
	font-size: 3rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h3 {
 | 
			
		||||
	font-size: 2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
a.no-link-color {
 | 
			
		||||
    color: #cccccc;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.email:hover {
 | 
			
		||||
    color: black;
 | 
			
		||||
    background-color: #9b4cda;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
iframe {
 | 
			
		||||
    width: 80%;
 | 
			
		||||
    height: 60vh;
 | 
			
		||||
    border: 1px dotted black;
 | 
			
		||||
    margin-left: 10%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
blockquote {
 | 
			
		||||
    border-left: .3rem solid #9b4dca;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
blockquote.warning {
 | 
			
		||||
    border-left: .3rem solid #ca5414;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
text-muted {
 | 
			
		||||
    color: gray;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.footer {
 | 
			
		||||
    text-align: center
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Reset apple form styles */
 | 
			
		||||
input, textarea, select, select:active, select:focus, select:hover {
 | 
			
		||||
    -webkit-appearance: none;
 | 
			
		||||
    -moz-appearance: none;
 | 
			
		||||
    appearance: none; border-radius: 0;
 | 
			
		||||
    background-image: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#login {
 | 
			
		||||
	padding-top: 15vh;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    max-width: 600px;
 | 
			
		||||
    margin: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#login h1, #login h4 {
 | 
			
		||||
    text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#login fieldset {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#login fieldset label {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    top: 12px;
 | 
			
		||||
    left: 12px;
 | 
			
		||||
    width: fit-content;
 | 
			
		||||
    line-height: 1;
 | 
			
		||||
    padding: 0 6px;
 | 
			
		||||
    font-size: 1.4rem;
 | 
			
		||||
    background-color: #131516;
 | 
			
		||||
    z-index: 999;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#login input[type="text"], #login select {
 | 
			
		||||
    border-radius: 0.4rem;
 | 
			
		||||
    color: #cccccc;
 | 
			
		||||
    font-size: 1.6rem;
 | 
			
		||||
    height: 4.2rem;
 | 
			
		||||
    padding: 0 1.4rem;
 | 
			
		||||
    margin-bottom: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#login select option {
 | 
			
		||||
    background-color: #131516;
 | 
			
		||||
    color: #cccccc;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#login .dropdown {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    border-radius: 0.4rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#login .dropdown::before {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    content: "\2193";
 | 
			
		||||
    top: 15%;
 | 
			
		||||
    right: 12px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#login .buttons {
 | 
			
		||||
    margin-top: 1.5rem;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    gap: 20px;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#login .buttons > * {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    flex: 1;
 | 
			
		||||
    font-size: 1.3rem;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										51
									
								
								infrastructure/web/routes/inbox.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								infrastructure/web/routes/inbox.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,51 @@
 | 
			
		|||
const express = require('express')
 | 
			
		||||
 | 
			
		||||
const router = new express.Router()
 | 
			
		||||
const {sanitizeParam} = require('express-validator/filter')
 | 
			
		||||
 | 
			
		||||
const sanitizeAddress = sanitizeParam('address').customSanitizer(
 | 
			
		||||
	(value, {req}) => {
 | 
			
		||||
		return req.params.address
 | 
			
		||||
			.replace(/[^A-Za-z0-9_.+@-]/g, '') // Remove special characters
 | 
			
		||||
			.toLowerCase()
 | 
			
		||||
	}
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, (req, res, _next) => {
 | 
			
		||||
	const mailProcessingService = req.app.get('mailProcessingService')
 | 
			
		||||
	res.render('inbox', {
 | 
			
		||||
		title: "48hr.email | " + req.params.address,
 | 
			
		||||
		address: req.params.address,
 | 
			
		||||
		mailSummaries: mailProcessingService.getMailSummaries(req.params.address)
 | 
			
		||||
	})
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
router.get(
 | 
			
		||||
	'^/:address/:uid([0-9]+$)',
 | 
			
		||||
	sanitizeAddress,
 | 
			
		||||
	async (req, res, next) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const mailProcessingService = req.app.get('mailProcessingService')
 | 
			
		||||
			const mail = await mailProcessingService.getOneFullMail(
 | 
			
		||||
				req.params.address,
 | 
			
		||||
				req.params.uid
 | 
			
		||||
			)
 | 
			
		||||
			if (mail) {
 | 
			
		||||
				// Emails are immutable, cache if found
 | 
			
		||||
				res.set('Cache-Control', 'private, max-age=600')
 | 
			
		||||
				res.render('mail', {
 | 
			
		||||
					title: req.params.address,
 | 
			
		||||
					address: req.params.address,
 | 
			
		||||
					mail
 | 
			
		||||
				})
 | 
			
		||||
			} else {
 | 
			
		||||
				next({message: 'email not found', status: 404})
 | 
			
		||||
			}
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error('error while fetching one email', error)
 | 
			
		||||
			next(error)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
module.exports = router
 | 
			
		||||
							
								
								
									
										41
									
								
								infrastructure/web/routes/login.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								infrastructure/web/routes/login.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,41 @@
 | 
			
		|||
const express = require('express')
 | 
			
		||||
 | 
			
		||||
const router = new express.Router()
 | 
			
		||||
const randomWord = require('random-word')
 | 
			
		||||
const {check, validationResult} = require('express-validator/check')
 | 
			
		||||
const config = require('../../../application/config')
 | 
			
		||||
 | 
			
		||||
router.get('/', (req, res, _next) => {
 | 
			
		||||
	res.render('login', {
 | 
			
		||||
		title: '48hr.email | Your temporary Inbox',
 | 
			
		||||
		username: randomWord(),
 | 
			
		||||
		domains: config.email.domains
 | 
			
		||||
	})
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
router.get('/random', (req, res, _next) => {
 | 
			
		||||
	res.redirect(`/${randomWord()}@${config.email.domains[Math.floor(Math.random() * config.email.domains.length)]}`)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
router.post(
 | 
			
		||||
	'/',
 | 
			
		||||
	[
 | 
			
		||||
		check('username').isLength({min: 1}),
 | 
			
		||||
		check('domain').isIn(config.email.domains)
 | 
			
		||||
	],
 | 
			
		||||
	(req, res) => {
 | 
			
		||||
		const errors = validationResult(req)
 | 
			
		||||
		if (!errors.isEmpty()) {
 | 
			
		||||
			return res.render('login', {
 | 
			
		||||
				title: 'Login',
 | 
			
		||||
				username: req.body.username,
 | 
			
		||||
				domain: req.body.domain,
 | 
			
		||||
				userInputError: true
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		res.redirect(`/${req.body.username}@${req.body.domain}`)
 | 
			
		||||
	}
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
module.exports = router
 | 
			
		||||
							
								
								
									
										9
									
								
								infrastructure/web/views/error.twig
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								infrastructure/web/views/error.twig
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
{% extends 'layout.twig' %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
  <div style="float: right"><a href="/login"> Logout</a></div>
 | 
			
		||||
 | 
			
		||||
  <h1>{{message}}</h1>
 | 
			
		||||
  <h2>{{error.status}}</h2>
 | 
			
		||||
  <pre>{{error.stack}}</pre>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										35
									
								
								infrastructure/web/views/inbox.twig
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								infrastructure/web/views/inbox.twig
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,35 @@
 | 
			
		|||
{% extends 'layout.twig' %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
    <script>
 | 
			
		||||
        document.addEventListener("DOMContentLoaded", function () {
 | 
			
		||||
            enableNewMessageNotifications('{{ address }}', true)
 | 
			
		||||
        });
 | 
			
		||||
    </script>
 | 
			
		||||
 | 
			
		||||
    <div style="float: right"><a href="/login"> Logout</a></div>
 | 
			
		||||
    <h1>{{ address }}</h1>
 | 
			
		||||
    {% for mail in mailSummaries %}
 | 
			
		||||
        <a href="{{ mail.to[0] }}/{{ mail.uid }}" class="no-link-color">
 | 
			
		||||
            <blockquote class="email">
 | 
			
		||||
                <h6 class="list-group-item-heading">
 | 
			
		||||
                    {{ mail.from[0].name }}
 | 
			
		||||
                    <span class="text-muted">{{ mail.from[0].address }}</span>
 | 
			
		||||
                    <small class="float-right">{{ mail.date |date }}</small>
 | 
			
		||||
                </h6>
 | 
			
		||||
                <p class="list-group-item-text text-truncate" style="width: 75%">
 | 
			
		||||
                    {{ mail.subject }} </p>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
            </blockquote>
 | 
			
		||||
        </a>
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
 | 
			
		||||
    {% if not mailSummaries %}
 | 
			
		||||
        <blockquote>
 | 
			
		||||
            There are no mails yet.
 | 
			
		||||
        </blockquote>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										37
									
								
								infrastructure/web/views/layout.twig
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								infrastructure/web/views/layout.twig
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,37 @@
 | 
			
		|||
<!DOCTYPE html>
 | 
			
		||||
<html>
 | 
			
		||||
  <head>
 | 
			
		||||
    <title>{{ title }}</title>
 | 
			
		||||
    <meta charset="UTF-8">
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
 | 
			
		||||
    <meta name="darkreader" content="stfu">
 | 
			
		||||
 | 
			
		||||
    <meta name="description" content="Dont give shady companies your real email. Use 48hr.email to protect your privacy!">
 | 
			
		||||
    <meta property="og:image" content="images/logo.gif">
 | 
			
		||||
 | 
			
		||||
    <link rel="shortcut icon" href="/images/icon.ico">
 | 
			
		||||
    <link rel='stylesheet' href='/dependencies/milligram.css' />
 | 
			
		||||
    <link rel='stylesheet' href='/stylesheets/custom.css' />
 | 
			
		||||
 | 
			
		||||
    <script src="/socket.io/socket.io.js" defer="true"></script>
 | 
			
		||||
    <script src="/javascripts/notifications.js" defer="true"></script>
 | 
			
		||||
 | 
			
		||||
  </head>
 | 
			
		||||
  <body>
 | 
			
		||||
  <main>
 | 
			
		||||
    <a href="https://48hr.email/">
 | 
			
		||||
      <img src="/images/logo.gif" class="logo" style="max-width: 75px">
 | 
			
		||||
    </a>
 | 
			
		||||
    {% block body %}{% endblock %}
 | 
			
		||||
  </main>
 | 
			
		||||
 | 
			
		||||
    {% block footer %}
 | 
			
		||||
    <section class="container footer">
 | 
			
		||||
      <hr>
 | 
			
		||||
      <h4>48hr.email offered by <a href="https://crazyco.xyz/" style="text-decoration:underline">CrazyCo</a> | All Emails will be deleted after 48hrs</h4>
 | 
			
		||||
    </section>
 | 
			
		||||
    {% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										1
									
								
								infrastructure/web/views/log.txt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								infrastructure/web/views/log.txt
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
2023/11/01 09:58:03 Micro started
 | 
			
		||||
							
								
								
									
										36
									
								
								infrastructure/web/views/login.twig
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								infrastructure/web/views/login.twig
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,36 @@
 | 
			
		|||
{% extends 'layout.twig' %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
 | 
			
		||||
<div id="login">
 | 
			
		||||
    <h1>Welcome!</h1>
 | 
			
		||||
    <h4>Here you can either create a new Inbox, or access your old one</h4>
 | 
			
		||||
    <br>
 | 
			
		||||
    <br>
 | 
			
		||||
 | 
			
		||||
    {% if userInputError %}
 | 
			
		||||
        <blockquote class="warning">
 | 
			
		||||
            Your input was invalid. Please try other values.
 | 
			
		||||
        </blockquote>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <form method="POST" action="/login">
 | 
			
		||||
        <fieldset>
 | 
			
		||||
            <label for="nameField">Name</label>
 | 
			
		||||
            <input type="text" id="nameField" name="username" value="{{ username }}">
 | 
			
		||||
            <label for="commentField">Domain</label>
 | 
			
		||||
            <div class="dropdown">
 | 
			
		||||
                <select id="commentField" name="domain">
 | 
			
		||||
                    {% for domain in domains %}
 | 
			
		||||
                        <option value="{{ domain }}">{{ domain }}</option>
 | 
			
		||||
                    {% endfor %}
 | 
			
		||||
                </select>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="buttons">
 | 
			
		||||
                <input class="button" type="submit" value="Access This Inbox">
 | 
			
		||||
                <a class="button" href="/login/random">Create Random Inbox</a>
 | 
			
		||||
            </div>
 | 
			
		||||
        </fieldset>
 | 
			
		||||
    </form>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										35
									
								
								infrastructure/web/views/mail.twig
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								infrastructure/web/views/mail.twig
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,35 @@
 | 
			
		|||
 | 
			
		||||
 | 
			
		||||
{% extends 'layout.twig' %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
    <script>
 | 
			
		||||
        enableNewMessageNotifications('{{ address }}', false)
 | 
			
		||||
    </script>
 | 
			
		||||
 | 
			
		||||
    <div style="float: right; text-align: end;">
 | 
			
		||||
	    <a href="/{{ address }}">
 | 
			
		||||
	        ← Return to inbox</a>
 | 
			
		||||
	    <br>
 | 
			
		||||
        <a href="/login">
 | 
			
		||||
            Logout</a>
 | 
			
		||||
    </div>
 | 
			
		||||
    <hr>
 | 
			
		||||
	<div style="text-align: center;">
 | 
			
		||||
    <h1>{{ mail.subject }}</h1>
 | 
			
		||||
    </div>
 | 
			
		||||
    {% if mail.html %}
 | 
			
		||||
    <div>
 | 
			
		||||
		<iframe srcdoc='{{ mail.html }}'></iframe>
 | 
			
		||||
	</div>
 | 
			
		||||
    {% elseif mail.textAsHtml %}
 | 
			
		||||
        <div class="mail_body">
 | 
			
		||||
            {{ mail.textAsHtml|raw }}
 | 
			
		||||
	</div>
 | 
			
		||||
    {% else %}
 | 
			
		||||
        <div class="mail_body"></div>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <h3 style="text-align: center;display: block;">{{ mail.from.text }} | {{ mail.date| date }}</h3>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										34
									
								
								infrastructure/web/views/mail.twig.save
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								infrastructure/web/views/mail.twig.save
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,34 @@
 | 
			
		|||
 | 
			
		||||
{% extends 'layout.twig' %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
    <script>
 | 
			
		||||
        enableNewMessageNotifications('{{ address }}', false)
 | 
			
		||||
    </script>
 | 
			
		||||
 | 
			
		||||
    <a href="/{{ address }}">
 | 
			
		||||
        ← Return to inbox</a>
 | 
			
		||||
    <div style="float: right">
 | 
			
		||||
        <a href="/login">
 | 
			
		||||
            Logout</a>
 | 
			
		||||
    </div>
 | 
			
		||||
    <h1>{{ mail.subject }}</h1>
 | 
			
		||||
    <h3>{{ mail.from.text }}</h3>
 | 
			
		||||
    <h3>{{ mail.date| date }}</h3>
 | 
			
		||||
    <hr>
 | 
			
		||||
    {% if mail.html %}
 | 
			
		||||
	<iframe>
 | 
			
		||||
        <div class="mail_body" style="overflow: auto;">
 | 
			
		||||
            {{ mail.html|raw }}
 | 
			
		||||
	</div>
 | 
			
		||||
	</iframe>
 | 
			
		||||
    {% elseif mail.textAsHtml %}
 | 
			
		||||
        <div class="mail_body">
 | 
			
		||||
            {{ mail.textAsHtml|raw }}
 | 
			
		||||
	</div>
 | 
			
		||||
    {% else %}
 | 
			
		||||
        <div class="mail_body"></div>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										27
									
								
								infrastructure/web/views/twig-filters.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								infrastructure/web/views/twig-filters.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,27 @@
 | 
			
		|||
const sanitizeHtml = require('sanitize-html')
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Transformes <a> tags to always use "noreferrer noopener" and open in a new window.
 | 
			
		||||
 * @param {Object} value  the dom before transformation
 | 
			
		||||
 * @returns  {*} dom after transformation
 | 
			
		||||
 */
 | 
			
		||||
exports.sanitizeHtmlTwigFilter = function(value) {
 | 
			
		||||
	return sanitizeHtml(value, {
 | 
			
		||||
		allowedAttributes: {
 | 
			
		||||
			a: ['href', 'target', 'rel']
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		transformTags: {
 | 
			
		||||
			a(tagName, attribs) {
 | 
			
		||||
				return {
 | 
			
		||||
					tagName,
 | 
			
		||||
					attribs: {
 | 
			
		||||
						rel: 'noreferrer noopener',
 | 
			
		||||
						href: attribs.href,
 | 
			
		||||
						target: '_blank'
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										83
									
								
								infrastructure/web/web.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								infrastructure/web/web.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,83 @@
 | 
			
		|||
const path = require('path')
 | 
			
		||||
const http = require('http')
 | 
			
		||||
const debug = require('debug')('voidmail:server')
 | 
			
		||||
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')
 | 
			
		||||
const inboxRouter = require('./routes/inbox')
 | 
			
		||||
const loginRouter = require('./routes/login')
 | 
			
		||||
const {sanitizeHtmlTwigFilter} = require('./views/twig-filters')
 | 
			
		||||
 | 
			
		||||
// 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)
 | 
			
		||||
 | 
			
		||||
app.get('/', (req, res, _next) => {
 | 
			
		||||
	res.redirect('/login')
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
app.use('/login', loginRouter)
 | 
			
		||||
app.use('/', inboxRouter)
 | 
			
		||||
 | 
			
		||||
// Catch 404 and forward to error handler
 | 
			
		||||
app.use((req, res, next) => {
 | 
			
		||||
	next({message: 'page not found', status: 404})
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
// 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')
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get port from environment and store in Express.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
app.set('port', config.http.port)
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Listen on provided port, on all network interfaces.
 | 
			
		||||
 */
 | 
			
		||||
server.listen(config.http.port)
 | 
			
		||||
server.on('listening', () => {
 | 
			
		||||
	const addr = server.address()
 | 
			
		||||
	const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port
 | 
			
		||||
	debug('Listening on ' + bind)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
module.exports = {app, io, server}
 | 
			
		||||
							
								
								
									
										63
									
								
								package.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								package.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,63 @@
 | 
			
		|||
{
 | 
			
		||||
  "name": "48hr.email",
 | 
			
		||||
  "version": "1.0",
 | 
			
		||||
  "private": false,
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "start": "node ./app.js",
 | 
			
		||||
    "test": "xo",
 | 
			
		||||
    "debug": "node --nolazy --inspect-brk=9229 ./app.js"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "array.prototype.flatmap": "^1.2.1",
 | 
			
		||||
    "async-retry": "^1.2.3",
 | 
			
		||||
    "compression": "^1.7.3",
 | 
			
		||||
    "debug": "^2.6.9",
 | 
			
		||||
    "express": "~4.16.0",
 | 
			
		||||
    "express-validator": "^5.3.1",
 | 
			
		||||
    "helmet": "^3.16.0",
 | 
			
		||||
    "http-errors": "~1.6.2",
 | 
			
		||||
    "imap-simple": "^4.3.0",
 | 
			
		||||
    "lodash": "^4.17.13",
 | 
			
		||||
    "mailparser": "^2.4.3",
 | 
			
		||||
    "mem": "^4.2.0",
 | 
			
		||||
    "mnemonist": "^0.27.2",
 | 
			
		||||
    "moment": "^2.24.0",
 | 
			
		||||
    "morgan": "~1.9.0",
 | 
			
		||||
    "nodemailer": "^5.1.1",
 | 
			
		||||
    "p-series": "^2.0.0",
 | 
			
		||||
    "random-word": "^2.0.0",
 | 
			
		||||
    "sanitize-html": "^1.20.0",
 | 
			
		||||
    "socket.io": "^2.2.0",
 | 
			
		||||
    "twig": "~0.10.3"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "xo": "^0.24.0"
 | 
			
		||||
  },
 | 
			
		||||
  "xo": {
 | 
			
		||||
    "semicolon": false,
 | 
			
		||||
    "prettier": true,
 | 
			
		||||
    "rules": {
 | 
			
		||||
      "no-unused-vars": [
 | 
			
		||||
        "error",
 | 
			
		||||
        {
 | 
			
		||||
          "argsIgnorePattern": "^_"
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    },
 | 
			
		||||
    "overrides": [
 | 
			
		||||
      {
 | 
			
		||||
        "files": "public/javascripts/*.js",
 | 
			
		||||
        "esnext": false,
 | 
			
		||||
        "env": [
 | 
			
		||||
          "browser"
 | 
			
		||||
        ],
 | 
			
		||||
        "globals": [
 | 
			
		||||
          "io"
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  "engines": {
 | 
			
		||||
    "node": "10.x"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	Add table
		
		Reference in a new issue