diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de45fd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.idea +.DS_Store +.vscode diff --git a/app.js b/app.js new file mode 100644 index 0000000..31bb52e --- /dev/null +++ b/app.js @@ -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) + } +}) diff --git a/app.json b/app.json new file mode 100644 index 0000000..c92ea6d --- /dev/null +++ b/app.json @@ -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`)" + } + } +} diff --git a/application/config.js b/application/config.js new file mode 100644 index 0000000..c061895 --- /dev/null +++ b/application/config.js @@ -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 diff --git a/application/imap-service.js b/application/imap-service.js new file mode 100644 index 0000000..168d79f --- /dev/null +++ b/application/imap-service.js @@ -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} + */ + 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 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 diff --git a/application/mail-processing-service.js b/application/mail-processing-service.js new file mode 100644 index 0000000..67d9e53 --- /dev/null +++ b/application/mail-processing-service.js @@ -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 diff --git a/domain/mail-repository.js b/domain/mail-repository.js new file mode 100644 index 0000000..64802d7 --- /dev/null +++ b/domain/mail-repository.js @@ -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 diff --git a/domain/mail.js b/domain/mail.js new file mode 100644 index 0000000..d96da14 --- /dev/null +++ b/domain/mail.js @@ -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 diff --git a/infrastructure/web/client-notification.js b/infrastructure/web/client-notification.js new file mode 100644 index 0000000..45b02a9 --- /dev/null +++ b/infrastructure/web/client-notification.js @@ -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 diff --git a/infrastructure/web/public/dependencies/milligram.css b/infrastructure/web/public/dependencies/milligram.css new file mode 100644 index 0000000..d253355 --- /dev/null +++ b/infrastructure/web/public/dependencies/milligram.css @@ -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,') center right no-repeat; + padding-right: 3.0rem; +} + +select:focus { + background-image: url('data:image/svg+xml;utf8,'); +} + +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 */ \ No newline at end of file diff --git a/infrastructure/web/public/images/icon.ico b/infrastructure/web/public/images/icon.ico new file mode 100644 index 0000000..31217dd Binary files /dev/null and b/infrastructure/web/public/images/icon.ico differ diff --git a/infrastructure/web/public/images/logo.gif b/infrastructure/web/public/images/logo.gif new file mode 100644 index 0000000..13426b2 Binary files /dev/null and b/infrastructure/web/public/images/logo.gif differ diff --git a/infrastructure/web/public/javascripts/notifications.js b/infrastructure/web/public/javascripts/notifications.js new file mode 100644 index 0000000..444f374 --- /dev/null +++ b/infrastructure/web/public/javascripts/notifications.js @@ -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. +} diff --git a/infrastructure/web/public/stylesheets/custom.css b/infrastructure/web/public/stylesheets/custom.css new file mode 100644 index 0000000..3915f59 --- /dev/null +++ b/infrastructure/web/public/stylesheets/custom.css @@ -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; +} diff --git a/infrastructure/web/routes/inbox.js b/infrastructure/web/routes/inbox.js new file mode 100644 index 0000000..ebbab7e --- /dev/null +++ b/infrastructure/web/routes/inbox.js @@ -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 diff --git a/infrastructure/web/routes/login.js b/infrastructure/web/routes/login.js new file mode 100644 index 0000000..03464b8 --- /dev/null +++ b/infrastructure/web/routes/login.js @@ -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 diff --git a/infrastructure/web/views/error.twig b/infrastructure/web/views/error.twig new file mode 100644 index 0000000..095cef5 --- /dev/null +++ b/infrastructure/web/views/error.twig @@ -0,0 +1,9 @@ +{% extends 'layout.twig' %} + +{% block body %} +
Logout
+ +

{{message}}

+

{{error.status}}

+
{{error.stack}}
+{% endblock %} diff --git a/infrastructure/web/views/inbox.twig b/infrastructure/web/views/inbox.twig new file mode 100644 index 0000000..126a53f --- /dev/null +++ b/infrastructure/web/views/inbox.twig @@ -0,0 +1,35 @@ +{% extends 'layout.twig' %} + +{% block body %} + + +
Logout
+

{{ address }}

+ {% for mail in mailSummaries %} + + + + {% endfor %} + + {% if not mailSummaries %} +
+ There are no mails yet. +
+ {% endif %} + + +{% endblock %} diff --git a/infrastructure/web/views/layout.twig b/infrastructure/web/views/layout.twig new file mode 100644 index 0000000..7032fdb --- /dev/null +++ b/infrastructure/web/views/layout.twig @@ -0,0 +1,37 @@ + + + + {{ title }} + + + + + + + + + + + + + + + + +
+ + + + {% block body %}{% endblock %} +
+ + {% block footer %} + + {% endblock %} + + + + diff --git a/infrastructure/web/views/log.txt b/infrastructure/web/views/log.txt new file mode 100644 index 0000000..c63eb23 --- /dev/null +++ b/infrastructure/web/views/log.txt @@ -0,0 +1 @@ +2023/11/01 09:58:03 Micro started diff --git a/infrastructure/web/views/login.twig b/infrastructure/web/views/login.twig new file mode 100644 index 0000000..511ef78 --- /dev/null +++ b/infrastructure/web/views/login.twig @@ -0,0 +1,36 @@ +{% extends 'layout.twig' %} + +{% block body %} + +
+

Welcome!

+

Here you can either create a new Inbox, or access your old one

+
+
+ + {% if userInputError %} +
+ Your input was invalid. Please try other values. +
+ {% endif %} +
+
+ + + + + +
+
+
+ +{% endblock %} diff --git a/infrastructure/web/views/mail.twig b/infrastructure/web/views/mail.twig new file mode 100644 index 0000000..3722bfd --- /dev/null +++ b/infrastructure/web/views/mail.twig @@ -0,0 +1,35 @@ + + +{% extends 'layout.twig' %} + +{% block body %} + + +
+ + ← Return to inbox +
+ + Logout +
+
+
+

{{ mail.subject }}

+
+ {% if mail.html %} +
+ +
+ {% elseif mail.textAsHtml %} +
+ {{ mail.textAsHtml|raw }} +
+ {% else %} +
+ {% endif %} +

{{ mail.from.text }} | {{ mail.date| date }}

+ + +{% endblock %} diff --git a/infrastructure/web/views/mail.twig.save b/infrastructure/web/views/mail.twig.save new file mode 100644 index 0000000..c3025b2 --- /dev/null +++ b/infrastructure/web/views/mail.twig.save @@ -0,0 +1,34 @@ + +{% extends 'layout.twig' %} + +{% block body %} + + + + ← Return to inbox +
+ + Logout +
+

{{ mail.subject }}

+

{{ mail.from.text }}

+

{{ mail.date| date }}

+
+ {% if mail.html %} + + {% elseif mail.textAsHtml %} +
+ {{ mail.textAsHtml|raw }} +
+ {% else %} +
+ {% endif %} + + +{% endblock %} diff --git a/infrastructure/web/views/twig-filters.js b/infrastructure/web/views/twig-filters.js new file mode 100644 index 0000000..23bef84 --- /dev/null +++ b/infrastructure/web/views/twig-filters.js @@ -0,0 +1,27 @@ +const sanitizeHtml = require('sanitize-html') + +/** + * Transformes 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' + } + } + } + } + }) +} diff --git a/infrastructure/web/web.js b/infrastructure/web/web.js new file mode 100644 index 0000000..0ba32bf --- /dev/null +++ b/infrastructure/web/web.js @@ -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} diff --git a/package.json b/package.json new file mode 100644 index 0000000..83be7c7 --- /dev/null +++ b/package.json @@ -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" + } +}