Add Files

pull/1/head
ClaraCrazy 2023-11-01 11:48:19 +01:00
parent 84a216a2e6
commit 758b72f4c4
26 changed files with 1897 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
.idea
.DS_Store
.vscode

74
app.js Normal file
View File

@ -0,0 +1,74 @@
#!/usr/bin/env node
/* eslint unicorn/no-process-exit: 0 */
const config = require('./application/config')
// Until node 11 adds flatmap, we use this:
require('array.prototype.flatmap').shim()
const {app, io, server} = require('./infrastructure/web/web')
const ClientNotification = require('./infrastructure/web/client-notification')
const ImapService = require('./application/imap-service')
const MailProcessingService = require('./application/mail-processing-service')
const MailRepository = require('./domain/mail-repository')
const clientNotification = new ClientNotification()
clientNotification.use(io)
const imapService = new ImapService(config)
const mailProcessingService = new MailProcessingService(
new MailRepository(),
imapService,
clientNotification,
config
)
// Put everything together:
imapService.on(ImapService.EVENT_NEW_MAIL, mail =>
mailProcessingService.onNewMail(mail)
)
imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, () =>
mailProcessingService.onInitialLoadDone()
)
imapService.on(ImapService.EVENT_DELETED_MAIL, mail =>
mailProcessingService.onMailDeleted(mail)
)
mailProcessingService.on('error', err => {
console.error('error from mailProcessingService, stopping.', err)
process.exit(1)
})
imapService.on(ImapService.EVENT_ERROR, error => {
console.error('fatal error from imap service', error)
process.exit(1)
})
app.set('mailProcessingService', mailProcessingService)
imapService.connectAndLoadMessages().catch(error => {
console.error('fatal error from imap service', error)
process.exit(1)
})
server.on('error', error => {
if (error.syscall !== 'listen') {
console.error('fatal web server error', error)
return
}
// Handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(
'Port ' + config.http.port + ' requires elevated privileges'
)
process.exit(1)
case 'EADDRINUSE':
console.error('Port ' + config.http.port + ' is already in use')
process.exit(1)
default:
console.error('fatal web server error', error)
process.exit(1)
}
})

30
app.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "48hr.email | disposable email",
"description": "a simple and fast disposable mail service that works directly with your imap server. No database required.",
"repository": "https://github.com/Crazyco-xyz/48hr.email",
"logo": "https://github.com/Crazyco-xyz/48hr.email/blobl/main/infrastructure/web/public/images/logo.gif",
"keywords": [
"node",
"disposable-mail"
],
"env": {
"DOMAINS": {
"description": "Email domains."
},
"IMAP_SERVER": {
"description": "Hostname of the server (usually imap.example.com)"
},
"IMAP_USER": {
"description": "Username to login to the imap server"
},
"IMAP_PASSWORD": {
"description": "Password to login to the imap server"
},
"IMAP_REFRESH_INTERVAL_SECONDS": {
"description": "How often to refresh the imap messages manually"
},
"DELETE_MAILS_OLDER_THAN_DAYS": {
"description": "How many days to to wait before deleting messages. (default: `30`)"
}
}
}

48
application/config.js Normal file
View File

@ -0,0 +1,48 @@
// Note: Also update app.json and README.md!
const config = {
email: {
domains: process.env.DOMAINS,
deleteMailsOlderThanDays: process.env.DELETE_MAILS_OLDER_THAN_DAYS
},
imap: {
user: process.env.IMAP_USER,
password: process.env.IMAP_PASSWORD,
host: process.env.IMAP_SERVER,
port: 993,
tls: true,
authTimeout: 3000,
refreshIntervalSeconds: process.env.IMAP_REFRESH_INTERVAL_SECONDS
},
http: {port: normalizePort(process.env.PORT || '3000')}
}
if (!config.imap.user || !config.imap.password || !config.imap.host) {
throw new Error('IMAP is not configured. Use IMAP_* ENV vars.')
}
if (!config.email.domains) {
throw new Error('DOMAINS is not configured. Use ENV vars.')
}
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
const port = parseInt(val, 10)
if (isNaN(port)) {
// Named pipe
return val
}
if (port >= 0) {
// Port number
return port
}
return false
}
module.exports = config

319
application/imap-service.js Normal file
View File

@ -0,0 +1,319 @@
const EventEmitter = require('events')
const imaps = require('imap-simple')
const {simpleParser} = require('mailparser')
const addressparser = require('nodemailer/lib/addressparser')
const pSeries = require('p-series')
const retry = require('async-retry')
const debug = require('debug')('48hr-email:imap')
const _ = require('lodash')
const Mail = require('../domain/mail')
// Just adding some missing functions to imap-simple... :-)
/**
* Deletes the specified message(s).
*
* @param {string|Array} uid The uid or array of uids indicating the messages to be deleted
* @param {function} [callback] Optional callback, receiving signature (err)
* @returns {undefined|Promise} Returns a promise when no callback is specified, resolving when the action succeeds.
* @memberof ImapSimple
*/
imaps.ImapSimple.prototype.deleteMessage = function (uid, callback) {
var self = this;
if (callback) {
return nodeify(self.deleteMessage(uid), callback);
}
return new Promise(function (resolve, reject) {
self.imap.addFlags(uid, '\\Deleted', function (err) {
if (err) {
reject(err);
return;
}
self.imap.expunge( function (err) {
if (err) {
reject(err);
return;
}
resolve();
});
});
});
};
/**
* Close a mailbox
*
* @param {boolean} [autoExpunge=true] If autoExpunge is true, any messages marked as Deleted in the currently open mailbox will be remove
* @param {function} [callback] Optional callback, receiving signature (err)
* @returns {undefined|Promise} Returns a promise when no callback is specified, resolving to `boxName`
* @memberof ImapSimple
*/
imaps.ImapSimple.prototype.closeBox = function (autoExpunge=true, callback) {
var self = this;
if (typeof(autoExpunge) == 'function'){
callback = autoExpunge;
autoExpunge = true;
}
if (callback) {
return nodeify(this.closeBox(autoExpunge), callback);
}
return new Promise(function (resolve, reject) {
self.imap.closeBox(autoExpunge, function (err, result) {
if (err) {
reject(err);
return;
}
resolve(result);
});
});
};
/**
* Fetches emails from the imap server. It is a facade against the more complicated imap-simple api. It keeps the connection
* as a member field.
*
* With this abstraction it would be easy to replace this with any inbound mail service like mailgun.com.
*/
class ImapService extends EventEmitter {
constructor(config) {
super()
this.config = config
/**
* Set of emitted UIDs. Listeners should get each email only once.
* @type {Set<any>}
*/
this.loadedUids = new Set()
this.connection = null
this.initialLoadDone = false
}
async connectAndLoadMessages() {
const configWithListener = {
...this.config,
// 'onmail' adds a callback when new mails arrive. With this we can keep the imap refresh interval very low (or even disable it).
onmail: () => this._doOnNewMail()
}
this.once(ImapService.EVENT_INITIAL_LOAD_DONE, () =>
this._doAfterInitialLoad()
)
await this._connectWithRetry(configWithListener)
// Load all messages in the background. (ASYNC)
this._loadMailSummariesAndEmitAsEvents()
}
async _connectWithRetry(configWithListener) {
try {
await retry(
async _bail => {
// If anything throws, we retry
this.connection = await imaps.connect(configWithListener)
this.connection.on('error', err => {
// We assume that the app will be restarted after a crash.
console.error(
'got fatal error during imap operation, stop app.',
err
)
this.emit('error', err)
})
await this.connection.openBox('INBOX')
debug('connected to imap')
},
{
retries: 5
}
)
} catch (error) {
console.error('can not connect, even with retry, stop app', error)
throw error
}
}
_doOnNewMail() {
// Only react to new mails after the initial load, otherwise it might load the same mails twice.
if (this.initialLoadDone) {
this._loadMailSummariesAndEmitAsEvents()
}
}
_doAfterInitialLoad() {
// During initial load we ignored new incoming emails. In order to catch up with those, we have to refresh
// the mails once after the initial load. (async)
this._loadMailSummariesAndEmitAsEvents()
// If the above trigger on new mails does not work reliable, we have to regularly check
// for new mails on the server. This is done only after all the mails have been loaded for the
// first time. (Note: set the refresh higher than the time it takes to download the mails).
if (this.config.imap.refreshIntervalSeconds) {
setInterval(
() => this._loadMailSummariesAndEmitAsEvents(),
this.config.imap.refreshIntervalSeconds * 1000
)
}
}
async _loadMailSummariesAndEmitAsEvents() {
// UID: Unique id of a message.
const uids = await this._getAllUids()
const newUids = uids.filter(uid => !this.loadedUids.has(uid))
// Optimize by fetching several messages (but not all) with one 'search' call.
// fetching all at once might be more efficient, but then it takes long until we see any messages
// in the frontend. With a small chunk size we ensure that we see the newest emails after a few seconds after
// restart.
const uidChunks = _.chunk(newUids, 20)
// Creates an array of functions. We do not start the search now, we just create the function.
const fetchFunctions = uidChunks.map(uidChunk => () =>
this._getMailHeadersAndEmitAsEvents(uidChunk)
)
await pSeries(fetchFunctions)
if (!this.initialLoadDone) {
this.initialLoadDone = true
this.emit(ImapService.EVENT_INITIAL_LOAD_DONE)
}
}
/**
*
* @param {Date} deleteMailsBefore delete mails before this date instance
*/
async deleteOldMails(deleteMailsBefore) {
debug(`deleting mails before ${deleteMailsBefore}`)
const uids = await this._searchWithoutFetch([
['!DELETED'],
['BEFORE', deleteMailsBefore]
])
if (uids.length === 0) {
return
}
debug(`deleting mails ${uids}`)
await this.connection.deleteMessage(uids)
console.log(`deleted ${uids.length} old messages.`)
uids.forEach(uid => this.emit(ImapService.EVENT_DELETED_MAIL, uid))
}
/**
* Helper method because ImapSimple#search also fetches each message. We just need the uids here.
*
* @param {Object} searchCriteria (see ImapSimple#search)
* @returns {Promise<Array<Int>>} Array of UIDs
* @private
*/
async _searchWithoutFetch(searchCriteria) {
const imapUnderlying = this.connection.imap
return new Promise((resolve, reject) => {
imapUnderlying.search(searchCriteria, (err, uids) => {
if (err) {
reject(err)
} else {
resolve(uids || [])
}
})
})
}
_createMailSummary(message) {
const headerPart = message.parts[0].body
const to = headerPart.to
.flatMap(to => addressparser(to))
// The address also contains the name, just keep the email
.map(addressObj => addressObj.address)
const from = headerPart.from.flatMap(from => addressparser(from))
const subject = headerPart.subject[0]
const date = headerPart.date[0]
const {uid} = message.attributes
return Mail.create(to, from, date, subject, uid)
}
async fetchOneFullMail(to, uid) {
if (!this.connection) {
// Here we 'fail fast' instead of waiting for the connection.
throw new Error('imap connection not ready')
}
debug(`fetching full message ${uid}`)
// For security we also filter TO, so it is harder to just enumerate all messages.
const searchCriteria = [['UID', uid], ['TO', to]]
const fetchOptions = {
bodies: ['HEADER', ''], // Empty string means full body
markSeen: false
}
const messages = await this.connection.search(searchCriteria, fetchOptions)
if (messages.length === 0) {
throw new Error('email not found')
}
const fullBody = _.find(messages[0].parts, {which: ''})
return simpleParser(fullBody.body)
}
async _getAllUids() {
// We ignore mails that are flagged as DELETED, but have not been removed (expunged) yet.
const uids = await this._searchWithoutFetch([['!DELETED']])
// Create copy to not mutate the original array. Sort with newest first (DESC).
return [...uids].sort().reverse()
}
async _getMailHeadersAndEmitAsEvents(uids) {
try {
const mails = await this._getMailHeaders(uids)
mails.forEach(mail => {
this.loadedUids.add(mail.attributes.uid)
// Some broadcast messages have no TO field. We have to ignore those messages.
if (mail.parts[0].body.to) {
this.emit(ImapService.EVENT_NEW_MAIL, this._createMailSummary(mail))
}
})
} catch (error) {
debug('can not fetch', error)
throw error
}
}
async _getMailHeaders(uids) {
const fetchOptions = {
bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)'],
struct: false
}
const searchCriteria = [['UID', ...uids]]
return this.connection.search(searchCriteria, fetchOptions)
}
}
// Consumers should use these constants:
ImapService.EVENT_NEW_MAIL = 'mail'
ImapService.EVENT_DELETED_MAIL = 'mailDeleted'
ImapService.EVENT_INITIAL_LOAD_DONE = 'initial load done'
ImapService.EVENT_ERROR = 'error'
module.exports = ImapService

View File

@ -0,0 +1,89 @@
const EventEmitter = require('events')
const debug = require('debug')('48hr-email:imap-manager')
const mem = require('mem')
const moment = require('moment')
const ImapService = require('./imap-service')
class MailProcessingService extends EventEmitter {
constructor(mailRepository, imapService, clientNotification, config) {
super()
this.mailRepository = mailRepository
this.clientNotification = clientNotification
this.imapService = imapService
this.config = config
// Cached methods:
this.cachedFetchFullMail = mem(
this.imapService.fetchOneFullMail.bind(this.imapService),
{maxAge: 10 * 60 * 1000}
)
this.initialLoadDone = false
// Delete old messages now and every few hours
this.imapService.once(ImapService.EVENT_INITIAL_LOAD_DONE, () =>
this._deleteOldMails()
)
setInterval(() => this._deleteOldMails(), 1000 * 3600 * 6)
}
getMailSummaries(address) {
return this.mailRepository.getForRecipient(address)
}
getOneFullMail(address, uid) {
return this.cachedFetchFullMail(address, uid)
}
getAllMailSummaries() {
return this.mailRepository.getAll()
}
onInitialLoadDone() {
this.initialLoadDone = true
console.log(
`initial load done, got ${this.mailRepository.mailCount()} mails`
)
}
onNewMail(mail) {
if (this.initialLoadDone) {
// For now, only log messages if they arrive after the initial load
debug('new mail for', mail.to[0])
}
mail.to.forEach(to => {
this.mailRepository.add(to, mail)
return this.clientNotification.emit(to)
})
}
onMailDeleted(uid) {
debug('mail deleted with uid', uid)
this.mailRepository.removeUid(uid)
// No client notification required, as nobody can cold a connection for 30+ days.
}
async _deleteOldMails() {
try {
await this.imapService.deleteOldMails(
moment()
.subtract(this.config.email.deleteMailsOlderThanDays, 'days')
.toDate()
)
} catch (error) {
console.log('can not delete old messages', error)
}
}
_saveToFile(mails, filename) {
const fs = require('fs')
fs.writeFile(filename, JSON.stringify(mails), err => {
if (err) {
console.error('can not save mails to file', err)
}
})
}
}
module.exports = MailProcessingService

42
domain/mail-repository.js Normal file
View File

@ -0,0 +1,42 @@
const debug = require('debug')('48hr-email:mail-summary-store')
const MultiMap = require('mnemonist/multi-map')
const _ = require('lodash')
class MailRepository {
constructor() {
// MultiMap docs: https://yomguithereal.github.io/mnemonist/multi-map
this.mailSummaries = new MultiMap()
}
getForRecipient(address) {
const mails = this.mailSummaries.get(address) || []
return _.orderBy(mails, mail => Date.parse(mail.date), ['desc'])
}
getAll() {
const mails = [...this.mailSummaries.values()]
return _.orderBy(mails, mail => Date.parse(mail.date), ['desc'])
}
add(to, mailSummary) {
this.mailSummaries.set(to.toLowerCase(), mailSummary)
}
removeUid(uid) {
// TODO: make this more efficient, looping through each email is not cool.
this.mailSummaries.forEachAssociation((mails, to) => {
mails
.filter(mail => mail.uid === uid)
.forEach(mail => {
this.mailSummaries.remove(to, mail)
debug('removed ', mail.date, to, mail.subject)
})
})
}
mailCount() {
return this.mailSummaries.size
}
}
module.exports = MailRepository

15
domain/mail.js Normal file
View File

@ -0,0 +1,15 @@
class Mail {
constructor(to, from, date, subject, uid) {
this.to = to
this.from = from
this.date = date
this.subject = subject
this.uid = uid
}
static create(to, from, date, subject, uid) {
return new Mail(to, from, date, subject, uid)
}
}
module.exports = Mail

View File

@ -0,0 +1,31 @@
const EventEmitter = require('events')
const debug = require('debug')('48hr-email:notification')
/**
* Receives sign-ins from users and notifies them when new mails are available.
*/
class ClientNotification extends EventEmitter {
use(io) {
io.on('connection', socket => {
socket.on('sign in', address => this._signIn(socket, address))
})
}
_signIn(socket, address) {
debug(`socketio signed in: ${address}`)
const newMailListener = () => {
debug(`${address} has new messages, sending notification`)
socket.emit('new emails')
}
this.on(address, newMailListener)
socket.on('disconnect', reason => {
debug(`client disconnect: ${address} (${reason})`)
this.removeListener(address, newMailListener)
})
}
}
module.exports = ClientNotification

View File

@ -0,0 +1,602 @@
/*!
* Milligram v1.3.0
* https://milligram.github.io
*
* Copyright (c) 2017 CJ Patoilo
* Licensed under the MIT license
*/
*,
*:after,
*:before {
box-sizing: inherit;
}
html {
box-sizing: border-box;
font-size: 62.5%;
}
body {
color: #606c76;
font-family: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
font-size: 1.6em;
font-weight: 300;
letter-spacing: .01em;
line-height: 1.6;
}
blockquote {
border-left: 0.3rem solid #d1d1d1;
margin-left: 0;
margin-right: 0;
padding: 1rem 1.5rem;
}
blockquote *:last-child {
margin-bottom: 0;
}
.button,
button,
input[type='button'],
input[type='reset'],
input[type='submit'] {
background-color: #9b4dca;
border: 0.1rem solid #9b4dca;
border-radius: .4rem;
color: #fff;
cursor: pointer;
display: inline-block;
font-size: 1.1rem;
font-weight: 700;
height: 3.8rem;
letter-spacing: .1rem;
line-height: 3.8rem;
padding: 0 3.0rem;
text-align: center;
text-decoration: none;
text-transform: uppercase;
white-space: nowrap;
}
.button:focus, .button:hover,
button:focus,
button:hover,
input[type='button']:focus,
input[type='button']:hover,
input[type='reset']:focus,
input[type='reset']:hover,
input[type='submit']:focus,
input[type='submit']:hover {
background-color: #606c76;
border-color: #606c76;
color: #fff;
outline: 0;
}
.button[disabled],
button[disabled],
input[type='button'][disabled],
input[type='reset'][disabled],
input[type='submit'][disabled] {
cursor: default;
opacity: .5;
}
.button[disabled]:focus, .button[disabled]:hover,
button[disabled]:focus,
button[disabled]:hover,
input[type='button'][disabled]:focus,
input[type='button'][disabled]:hover,
input[type='reset'][disabled]:focus,
input[type='reset'][disabled]:hover,
input[type='submit'][disabled]:focus,
input[type='submit'][disabled]:hover {
background-color: #9b4dca;
border-color: #9b4dca;
}
.button.button-outline,
button.button-outline,
input[type='button'].button-outline,
input[type='reset'].button-outline,
input[type='submit'].button-outline {
background-color: transparent;
color: #9b4dca;
}
.button.button-outline:focus, .button.button-outline:hover,
button.button-outline:focus,
button.button-outline:hover,
input[type='button'].button-outline:focus,
input[type='button'].button-outline:hover,
input[type='reset'].button-outline:focus,
input[type='reset'].button-outline:hover,
input[type='submit'].button-outline:focus,
input[type='submit'].button-outline:hover {
background-color: transparent;
border-color: #606c76;
color: #606c76;
}
.button.button-outline[disabled]:focus, .button.button-outline[disabled]:hover,
button.button-outline[disabled]:focus,
button.button-outline[disabled]:hover,
input[type='button'].button-outline[disabled]:focus,
input[type='button'].button-outline[disabled]:hover,
input[type='reset'].button-outline[disabled]:focus,
input[type='reset'].button-outline[disabled]:hover,
input[type='submit'].button-outline[disabled]:focus,
input[type='submit'].button-outline[disabled]:hover {
border-color: inherit;
color: #9b4dca;
}
.button.button-clear,
button.button-clear,
input[type='button'].button-clear,
input[type='reset'].button-clear,
input[type='submit'].button-clear {
background-color: transparent;
border-color: transparent;
color: #9b4dca;
}
.button.button-clear:focus, .button.button-clear:hover,
button.button-clear:focus,
button.button-clear:hover,
input[type='button'].button-clear:focus,
input[type='button'].button-clear:hover,
input[type='reset'].button-clear:focus,
input[type='reset'].button-clear:hover,
input[type='submit'].button-clear:focus,
input[type='submit'].button-clear:hover {
background-color: transparent;
border-color: transparent;
color: #606c76;
}
.button.button-clear[disabled]:focus, .button.button-clear[disabled]:hover,
button.button-clear[disabled]:focus,
button.button-clear[disabled]:hover,
input[type='button'].button-clear[disabled]:focus,
input[type='button'].button-clear[disabled]:hover,
input[type='reset'].button-clear[disabled]:focus,
input[type='reset'].button-clear[disabled]:hover,
input[type='submit'].button-clear[disabled]:focus,
input[type='submit'].button-clear[disabled]:hover {
color: #9b4dca;
}
code {
background: #f4f5f6;
border-radius: .4rem;
font-size: 86%;
margin: 0 .2rem;
padding: .2rem .5rem;
white-space: nowrap;
}
pre {
background: #f4f5f6;
border-left: 0.3rem solid #9b4dca;
overflow-y: hidden;
}
pre > code {
border-radius: 0;
display: block;
padding: 1rem 1.5rem;
white-space: pre;
}
hr {
border: 0;
border-top: 0.1rem solid #f4f5f6;
margin: 3.0rem 0;
}
input[type='email'],
input[type='number'],
input[type='password'],
input[type='search'],
input[type='tel'],
input[type='text'],
input[type='url'],
textarea,
select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-color: transparent;
border: 0.1rem solid #d1d1d1;
border-radius: .4rem;
box-shadow: none;
box-sizing: inherit;
height: 3.8rem;
padding: .6rem 1.0rem;
width: 100%;
}
input[type='email']:focus,
input[type='number']:focus,
input[type='password']:focus,
input[type='search']:focus,
input[type='tel']:focus,
input[type='text']:focus,
input[type='url']:focus,
textarea:focus,
select:focus {
border-color: #9b4dca;
outline: 0;
}
select {
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#d1d1d1" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>') center right no-repeat;
padding-right: 3.0rem;
}
select:focus {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#9b4dca" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>');
}
textarea {
min-height: 6.5rem;
}
label,
legend {
display: block;
font-size: 1.6rem;
font-weight: 700;
margin-bottom: .5rem;
}
fieldset {
border-width: 0;
padding: 0;
}
input[type='checkbox'],
input[type='radio'] {
display: inline;
}
.label-inline {
display: inline-block;
font-weight: normal;
margin-left: .5rem;
}
.container {
margin: 0 auto;
max-width: 112.0rem;
padding: 0 2.0rem;
position: relative;
width: 100%;
}
.row {
display: flex;
flex-direction: column;
padding: 0;
width: 100%;
}
.row.row-no-padding {
padding: 0;
}
.row.row-no-padding > .column {
padding: 0;
}
.row.row-wrap {
flex-wrap: wrap;
}
.row.row-top {
align-items: flex-start;
}
.row.row-bottom {
align-items: flex-end;
}
.row.row-center {
align-items: center;
}
.row.row-stretch {
align-items: stretch;
}
.row.row-baseline {
align-items: baseline;
}
.row .column {
display: block;
flex: 1 1 auto;
margin-left: 0;
max-width: 100%;
width: 100%;
}
.row .column.column-offset-10 {
margin-left: 10%;
}
.row .column.column-offset-20 {
margin-left: 20%;
}
.row .column.column-offset-25 {
margin-left: 25%;
}
.row .column.column-offset-33, .row .column.column-offset-34 {
margin-left: 33.3333%;
}
.row .column.column-offset-50 {
margin-left: 50%;
}
.row .column.column-offset-66, .row .column.column-offset-67 {
margin-left: 66.6666%;
}
.row .column.column-offset-75 {
margin-left: 75%;
}
.row .column.column-offset-80 {
margin-left: 80%;
}
.row .column.column-offset-90 {
margin-left: 90%;
}
.row .column.column-10 {
flex: 0 0 10%;
max-width: 10%;
}
.row .column.column-20 {
flex: 0 0 20%;
max-width: 20%;
}
.row .column.column-25 {
flex: 0 0 25%;
max-width: 25%;
}
.row .column.column-33, .row .column.column-34 {
flex: 0 0 33.3333%;
max-width: 33.3333%;
}
.row .column.column-40 {
flex: 0 0 40%;
max-width: 40%;
}
.row .column.column-50 {
flex: 0 0 50%;
max-width: 50%;
}
.row .column.column-60 {
flex: 0 0 60%;
max-width: 60%;
}
.row .column.column-66, .row .column.column-67 {
flex: 0 0 66.6666%;
max-width: 66.6666%;
}
.row .column.column-75 {
flex: 0 0 75%;
max-width: 75%;
}
.row .column.column-80 {
flex: 0 0 80%;
max-width: 80%;
}
.row .column.column-90 {
flex: 0 0 90%;
max-width: 90%;
}
.row .column .column-top {
align-self: flex-start;
}
.row .column .column-bottom {
align-self: flex-end;
}
.row .column .column-center {
-ms-grid-row-align: center;
align-self: center;
}
@media (min-width: 40rem) {
.row {
flex-direction: row;
margin-left: -1.0rem;
width: calc(100% + 2.0rem);
}
.row .column {
margin-bottom: inherit;
padding: 0 1.0rem;
}
}
a {
color: #9b4dca;
text-decoration: none;
}
a:focus, a:hover {
color: #606c76;
}
dl,
ol,
ul {
list-style: none;
margin-top: 0;
padding-left: 0;
}
dl dl,
dl ol,
dl ul,
ol dl,
ol ol,
ol ul,
ul dl,
ul ol,
ul ul {
font-size: 90%;
margin: 1.5rem 0 1.5rem 3.0rem;
}
ol {
list-style: decimal inside;
}
ul {
list-style: circle inside;
}
.button,
button,
dd,
dt,
li {
margin-bottom: 1.0rem;
}
fieldset,
input,
select,
textarea {
margin-bottom: 1.5rem;
}
blockquote,
dl,
figure,
form,
ol,
p,
pre,
table,
ul {
margin-bottom: 2.5rem;
}
table {
border-spacing: 0;
width: 100%;
}
td,
th {
border-bottom: 0.1rem solid #e1e1e1;
padding: 1.2rem 1.5rem;
text-align: left;
}
td:first-child,
th:first-child {
padding-left: 0;
}
td:last-child,
th:last-child {
padding-right: 0;
}
b,
strong {
font-weight: bold;
}
p {
margin-top: 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 300;
letter-spacing: -.1rem;
margin-bottom: 2.0rem;
margin-top: 0;
}
h1 {
font-size: 4.6rem;
line-height: 1.2;
}
h2 {
font-size: 3.6rem;
line-height: 1.25;
}
h3 {
font-size: 2.8rem;
line-height: 1.3;
}
h4 {
font-size: 2.2rem;
letter-spacing: -.08rem;
line-height: 1.35;
}
h5 {
font-size: 1.8rem;
letter-spacing: -.05rem;
line-height: 1.5;
}
h6 {
font-size: 1.6rem;
letter-spacing: 0;
line-height: 1.4;
}
img {
max-width: 100%;
}
.clearfix:after {
clear: both;
content: ' ';
display: table;
}
.float-left {
float: left;
}
.float-right {
float: right;
}
/*# sourceMappingURL=milligram.css.map */

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@ -0,0 +1,57 @@
/* eslint no-unused-vars: 0 */
/* eslint no-undef: 0 */
function showNewMailsNotification(address, reloadPage) {
// We want the page to be reloaded. But then when clicking the notification, it can not find the tab and will open a new one.
const notification = new Notification(address, {
body: 'You have new messages',
icon: '/images/logo.gif',
tag: 'voidmail-replace-notification',
renotify: true
})
notification.addEventListener('click', event => {
// TODO: does not work after reloading the page, see #1
event.preventDefault()
})
if (reloadPage) {
location.reload()
}
}
function enableNewMessageNotifications(address, reloadPage) {
enableNotifications()
const socket = io()
socket.emit('sign in', address)
socket.on('reconnect', () => {
socket.emit('sign in', address)
})
socket.on('new emails', () => {
showNewMailsNotification(address, reloadPage)
})
}
function enableNotifications() {
// Let's check if the browser supports notifications
if (!('Notification' in window)) {
return false
}
// Let's check whether notification permissions have already been granted
if (Notification.permission === 'granted') {
return true
}
// Otherwise, we need to ask the user for permission
if (Notification.permission !== 'denied') {
Notification.requestPermission(permission => {
// If the user accepts, let's create a notification
return permission === 'granted'
})
}
// Finally, if the user has denied notifications and you
// want to be respectful there is no need to bother them any more.
}

View File

@ -0,0 +1,134 @@
body {
margin: 0;
padding: 10px 10px 10px;
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: #131516;
color: #cccccc;
}
main {
flex: 1; /* keep footer at the bottom */
}
a {
color: #cccccc;
}
h1 {
font-size: 3rem;
}
h3 {
font-size: 2rem;
}
a.no-link-color {
color: #cccccc;
}
.email:hover {
color: black;
background-color: #9b4cda;
}
iframe {
width: 80%;
height: 60vh;
border: 1px dotted black;
margin-left: 10%;
}
blockquote {
border-left: .3rem solid #9b4dca;
}
blockquote.warning {
border-left: .3rem solid #ca5414;
}
text-muted {
color: gray;
}
.footer {
text-align: center
}
/* Reset apple form styles */
input, textarea, select, select:active, select:focus, select:hover {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none; border-radius: 0;
background-image: none;
}
#login {
padding-top: 15vh;
display: flex;
flex-direction: column;
max-width: 600px;
margin: auto;
}
#login h1, #login h4 {
text-align: center;
}
#login fieldset {
display: flex;
flex-direction: column;
}
#login fieldset label {
position: relative;
top: 12px;
left: 12px;
width: fit-content;
line-height: 1;
padding: 0 6px;
font-size: 1.4rem;
background-color: #131516;
z-index: 999;
}
#login input[type="text"], #login select {
border-radius: 0.4rem;
color: #cccccc;
font-size: 1.6rem;
height: 4.2rem;
padding: 0 1.4rem;
margin-bottom: 1rem;
}
#login select option {
background-color: #131516;
color: #cccccc;
}
#login .dropdown {
position: relative;
border-radius: 0.4rem;
}
#login .dropdown::before {
position: absolute;
content: "\2193";
top: 15%;
right: 12px;
}
#login .buttons {
margin-top: 1.5rem;
display: flex;
gap: 20px;
flex-wrap: wrap;
}
#login .buttons > * {
width: 100%;
flex: 1;
font-size: 1.3rem;
}

View File

@ -0,0 +1,51 @@
const express = require('express')
const router = new express.Router()
const {sanitizeParam} = require('express-validator/filter')
const sanitizeAddress = sanitizeParam('address').customSanitizer(
(value, {req}) => {
return req.params.address
.replace(/[^A-Za-z0-9_.+@-]/g, '') // Remove special characters
.toLowerCase()
}
)
router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, (req, res, _next) => {
const mailProcessingService = req.app.get('mailProcessingService')
res.render('inbox', {
title: "48hr.email | " + req.params.address,
address: req.params.address,
mailSummaries: mailProcessingService.getMailSummaries(req.params.address)
})
})
router.get(
'^/:address/:uid([0-9]+$)',
sanitizeAddress,
async (req, res, next) => {
try {
const mailProcessingService = req.app.get('mailProcessingService')
const mail = await mailProcessingService.getOneFullMail(
req.params.address,
req.params.uid
)
if (mail) {
// Emails are immutable, cache if found
res.set('Cache-Control', 'private, max-age=600')
res.render('mail', {
title: req.params.address,
address: req.params.address,
mail
})
} else {
next({message: 'email not found', status: 404})
}
} catch (error) {
console.error('error while fetching one email', error)
next(error)
}
}
)
module.exports = router

View File

@ -0,0 +1,41 @@
const express = require('express')
const router = new express.Router()
const randomWord = require('random-word')
const {check, validationResult} = require('express-validator/check')
const config = require('../../../application/config')
router.get('/', (req, res, _next) => {
res.render('login', {
title: '48hr.email | Your temporary Inbox',
username: randomWord(),
domains: config.email.domains
})
})
router.get('/random', (req, res, _next) => {
res.redirect(`/${randomWord()}@${config.email.domains[Math.floor(Math.random() * config.email.domains.length)]}`)
})
router.post(
'/',
[
check('username').isLength({min: 1}),
check('domain').isIn(config.email.domains)
],
(req, res) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.render('login', {
title: 'Login',
username: req.body.username,
domain: req.body.domain,
userInputError: true
})
}
res.redirect(`/${req.body.username}@${req.body.domain}`)
}
)
module.exports = router

View File

@ -0,0 +1,9 @@
{% extends 'layout.twig' %}
{% block body %}
<div style="float: right"><a href="/login"> Logout</a></div>
<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>
{% endblock %}

View File

@ -0,0 +1,35 @@
{% extends 'layout.twig' %}
{% block body %}
<script>
document.addEventListener("DOMContentLoaded", function () {
enableNewMessageNotifications('{{ address }}', true)
});
</script>
<div style="float: right"><a href="/login"> Logout</a></div>
<h1>{{ address }}</h1>
{% for mail in mailSummaries %}
<a href="{{ mail.to[0] }}/{{ mail.uid }}" class="no-link-color">
<blockquote class="email">
<h6 class="list-group-item-heading">
{{ mail.from[0].name }}
<span class="text-muted">{{ mail.from[0].address }}</span>
<small class="float-right">{{ mail.date |date }}</small>
</h6>
<p class="list-group-item-text text-truncate" style="width: 75%">
{{ mail.subject }} </p>
</div>
</blockquote>
</a>
{% endfor %}
{% if not mailSummaries %}
<blockquote>
There are no mails yet.
</blockquote>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
<meta name="darkreader" content="stfu">
<meta name="description" content="Dont give shady companies your real email. Use 48hr.email to protect your privacy!">
<meta property="og:image" content="images/logo.gif">
<link rel="shortcut icon" href="/images/icon.ico">
<link rel='stylesheet' href='/dependencies/milligram.css' />
<link rel='stylesheet' href='/stylesheets/custom.css' />
<script src="/socket.io/socket.io.js" defer="true"></script>
<script src="/javascripts/notifications.js" defer="true"></script>
</head>
<body>
<main>
<a href="https://48hr.email/">
<img src="/images/logo.gif" class="logo" style="max-width: 75px">
</a>
{% block body %}{% endblock %}
</main>
{% block footer %}
<section class="container footer">
<hr>
<h4>48hr.email offered by <a href="https://crazyco.xyz/" style="text-decoration:underline">CrazyCo</a> | All Emails will be deleted after 48hrs</h4>
</section>
{% endblock %}
</body>
</html>

View File

@ -0,0 +1 @@
2023/11/01 09:58:03 Micro started

View File

@ -0,0 +1,36 @@
{% extends 'layout.twig' %}
{% block body %}
<div id="login">
<h1>Welcome!</h1>
<h4>Here you can either create a new Inbox, or access your old one</h4>
<br>
<br>
{% if userInputError %}
<blockquote class="warning">
Your input was invalid. Please try other values.
</blockquote>
{% endif %}
<form method="POST" action="/login">
<fieldset>
<label for="nameField">Name</label>
<input type="text" id="nameField" name="username" value="{{ username }}">
<label for="commentField">Domain</label>
<div class="dropdown">
<select id="commentField" name="domain">
{% for domain in domains %}
<option value="{{ domain }}">{{ domain }}</option>
{% endfor %}
</select>
</div>
<div class="buttons">
<input class="button" type="submit" value="Access This Inbox">
<a class="button" href="/login/random">Create Random Inbox</a>
</div>
</fieldset>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,35 @@
{% extends 'layout.twig' %}
{% block body %}
<script>
enableNewMessageNotifications('{{ address }}', false)
</script>
<div style="float: right; text-align: end;">
<a href="/{{ address }}">
← Return to inbox</a>
<br>
<a href="/login">
Logout</a>
</div>
<hr>
<div style="text-align: center;">
<h1>{{ mail.subject }}</h1>
</div>
{% if mail.html %}
<div>
<iframe srcdoc='{{ mail.html }}'></iframe>
</div>
{% elseif mail.textAsHtml %}
<div class="mail_body">
{{ mail.textAsHtml|raw }}
</div>
{% else %}
<div class="mail_body"></div>
{% endif %}
<h3 style="text-align: center;display: block;">{{ mail.from.text }} | {{ mail.date| date }}</h3>
{% endblock %}

View File

@ -0,0 +1,34 @@
{% extends 'layout.twig' %}
{% block body %}
<script>
enableNewMessageNotifications('{{ address }}', false)
</script>
<a href="/{{ address }}">
← Return to inbox</a>
<div style="float: right">
<a href="/login">
Logout</a>
</div>
<h1>{{ mail.subject }}</h1>
<h3>{{ mail.from.text }}</h3>
<h3>{{ mail.date| date }}</h3>
<hr>
{% if mail.html %}
<iframe>
<div class="mail_body" style="overflow: auto;">
{{ mail.html|raw }}
</div>
</iframe>
{% elseif mail.textAsHtml %}
<div class="mail_body">
{{ mail.textAsHtml|raw }}
</div>
{% else %}
<div class="mail_body"></div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,27 @@
const sanitizeHtml = require('sanitize-html')
/**
* Transformes <a> tags to always use "noreferrer noopener" and open in a new window.
* @param {Object} value the dom before transformation
* @returns {*} dom after transformation
*/
exports.sanitizeHtmlTwigFilter = function(value) {
return sanitizeHtml(value, {
allowedAttributes: {
a: ['href', 'target', 'rel']
},
transformTags: {
a(tagName, attribs) {
return {
tagName,
attribs: {
rel: 'noreferrer noopener',
href: attribs.href,
target: '_blank'
}
}
}
}
})
}

83
infrastructure/web/web.js Normal file
View File

@ -0,0 +1,83 @@
const path = require('path')
const http = require('http')
const debug = require('debug')('voidmail:server')
const express = require('express')
const logger = require('morgan')
const Twig = require('twig')
const compression = require('compression')
const helmet = require('helmet')
const socketio = require('socket.io')
const config = require('../../application/config')
const inboxRouter = require('./routes/inbox')
const loginRouter = require('./routes/login')
const {sanitizeHtmlTwigFilter} = require('./views/twig-filters')
// Init express middleware
const app = express()
app.use(helmet())
app.use(compression())
app.set('config', config)
const server = http.createServer(app)
const io = socketio(server)
app.set('socketio', io)
app.use(logger('dev'))
app.use(express.json())
app.use(express.urlencoded({extended: false}))
// View engine setup
app.set('views', path.join(__dirname, 'views'))
app.set('view engine', 'twig')
app.set('twig options', {
autoescape: true
})
// Application code:
app.use(
express.static(path.join(__dirname, 'public'), {
immutable: true,
maxAge: '1h'
})
)
Twig.extendFilter('sanitizeHtml', sanitizeHtmlTwigFilter)
app.get('/', (req, res, _next) => {
res.redirect('/login')
})
app.use('/login', loginRouter)
app.use('/', inboxRouter)
// Catch 404 and forward to error handler
app.use((req, res, next) => {
next({message: 'page not found', status: 404})
})
// Error handler
app.use((err, req, res, _next) => {
// Set locals, only providing error in development
res.locals.message = err.message
res.locals.error = req.app.get('env') === 'development' ? err : {}
// Render the error page
res.status(err.status || 500)
res.render('error')
})
/**
* Get port from environment and store in Express.
*/
app.set('port', config.http.port)
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(config.http.port)
server.on('listening', () => {
const addr = server.address()
const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port
debug('Listening on ' + bind)
})
module.exports = {app, io, server}

63
package.json Normal file
View File

@ -0,0 +1,63 @@
{
"name": "48hr.email",
"version": "1.0",
"private": false,
"scripts": {
"start": "node ./app.js",
"test": "xo",
"debug": "node --nolazy --inspect-brk=9229 ./app.js"
},
"dependencies": {
"array.prototype.flatmap": "^1.2.1",
"async-retry": "^1.2.3",
"compression": "^1.7.3",
"debug": "^2.6.9",
"express": "~4.16.0",
"express-validator": "^5.3.1",
"helmet": "^3.16.0",
"http-errors": "~1.6.2",
"imap-simple": "^4.3.0",
"lodash": "^4.17.13",
"mailparser": "^2.4.3",
"mem": "^4.2.0",
"mnemonist": "^0.27.2",
"moment": "^2.24.0",
"morgan": "~1.9.0",
"nodemailer": "^5.1.1",
"p-series": "^2.0.0",
"random-word": "^2.0.0",
"sanitize-html": "^1.20.0",
"socket.io": "^2.2.0",
"twig": "~0.10.3"
},
"devDependencies": {
"xo": "^0.24.0"
},
"xo": {
"semicolon": false,
"prettier": true,
"rules": {
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
]
},
"overrides": [
{
"files": "public/javascripts/*.js",
"esnext": false,
"env": [
"browser"
],
"globals": [
"io"
]
}
]
},
"engines": {
"node": "10.x"
}
}