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