Add Files
parent
84a216a2e6
commit
758b72f4c4
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
.idea
|
||||
.DS_Store
|
||||
.vscode
|
|
@ -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)
|
||||
}
|
||||
})
|
|
@ -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`)"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 */
|
Binary file not shown.
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
After Width: | Height: | Size: 73 KiB |
|
@ -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.
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
|||
2023/11/01 09:58:03 Micro started
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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}
|
|
@ -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…
Reference in New Issue