├── .gitattributes ├── .gitignore ├── .jshintrc ├── .pkgr.yml ├── app.js ├── bin ├── mail-null └── send.js ├── client ├── .jshintrc ├── app.jsx ├── details.jsx ├── list.jsx └── menu.jsx ├── index.js ├── lib └── storage.js ├── package.json ├── packager ├── Procfile └── postinst ├── public ├── index.html └── stylesheets │ └── style.styl ├── readme.md └── smtp.js /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .idea 4 | data.json 5 | public/stylesheets/style.css -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": [], 3 | "asi" : false, 4 | "bitwise" : true, 5 | "boss" : false, 6 | "curly" : true, 7 | "debug": false, 8 | "devel": false, 9 | "eqeqeq": true, 10 | "evil": false, 11 | "expr": true, 12 | "forin": false, 13 | "immed": true, 14 | "latedef" : false, 15 | "laxbreak": false, 16 | "multistr": true, 17 | "newcap": true, 18 | "noarg": true, 19 | "node" : true, 20 | "noempty": false, 21 | "nonew": true, 22 | "onevar": false, 23 | "plusplus": false, 24 | "regexp": false, 25 | "strict": false, 26 | "sub": false, 27 | "trailing" : true, 28 | "undef": true 29 | } 30 | -------------------------------------------------------------------------------- /.pkgr.yml: -------------------------------------------------------------------------------- 1 | default_dependencies: false 2 | targets: 3 | ubuntu-12.04: 4 | ubuntu-14.04: 5 | debian-8: 6 | debian-7: 7 | before: 8 | - mv packager/Procfile . 9 | after_install: ./packager/postinst 10 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var logger = require('morgan'); 3 | var errorhandler = require('errorhandler'); 4 | var http = require('http'); 5 | var path = require('path'); 6 | var storage = require('./lib/storage'); 7 | var browserify = require('connect-browserify'); 8 | var reactify = require('reactify'); 9 | var uglifyify = require('uglifyify'); 10 | var envify = require('envify'); 11 | 12 | var app = express(); 13 | 14 | var debug = process.env.NODE_ENV !== 'production'; 15 | 16 | // all environments 17 | app.set('port', process.env.PORT || 3000); 18 | app.use(logger('dev')); 19 | 20 | app.use(require('stylus').middleware({ 21 | src: path.join(__dirname, 'public'), 22 | compress: !debug 23 | })); 24 | 25 | var transforms = [reactify, envify]; 26 | if (!debug) { 27 | transforms.push(uglifyify); 28 | } 29 | app.get('/bundle.js', browserify.serve({ 30 | entry: path.join(__dirname, 'client/app.jsx'), 31 | debug: debug, 32 | watch: debug, 33 | transforms: transforms, 34 | extensions: ['.jsx'] 35 | })); 36 | 37 | app.use(express.static(path.join(__dirname, 'public'))); 38 | 39 | // development only 40 | if ('development' === app.get('env')) { 41 | app.use(errorhandler()); 42 | } 43 | 44 | var server = http.createServer(app).listen(app.get('port'), function () { 45 | console.log( 46 | 'Web server listening on port ' + app.get('port') + ' (' + app.get('env') + ')'); 47 | }); 48 | 49 | var io = require('socket.io').listen(server); 50 | 51 | io.sockets.on('connection', function (socket) { 52 | socket.emit('init', storage.mails); 53 | socket.on('clear_all_emails', function (data) { 54 | storage.clearAll(); 55 | // update all clients 56 | io.sockets.emit('init', storage.mails); 57 | }); 58 | storage.on('got_mail', function (mail) { 59 | socket.emit('got_mail', mail); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /bin/mail-null: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../index.js'); 4 | -------------------------------------------------------------------------------- /bin/send.js: -------------------------------------------------------------------------------- 1 | var nodemailer = require('nodemailer'); 2 | 3 | // Create a SMTP transport object 4 | var transport = nodemailer.createTransport({ 5 | port: 2525 6 | }); 7 | 8 | var message = { 9 | 10 | // sender info 11 | from: 'Sender Name ', 12 | 13 | // Comma separated list of recipients 14 | to: '"Receiver Name" ', 15 | 16 | // Subject of the message 17 | subject: 'My Test Subject ✔', // 18 | 19 | headers: { 20 | 'X-Laziness-level': 1000 21 | }, 22 | 23 | // plaintext body 24 | text: 'Hello to myself!\nFrom myself', 25 | 26 | // HTML body 27 | html:'

Hello to myself

', 28 | 29 | 30 | // Binary Buffer attachment 31 | attachments: [{ 32 | filename: 'image.png', 33 | content: new Buffer('iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEUAAAD/' + 34 | '//+l2Z/dAAAAM0lEQVR4nGP4/5/h/1+G/58ZDrAz3D/McH8yw83NDDeNGe4U' + 35 | 'g9C9zwz3gVLMDA/A6P9/AFGGFyjOXZtQAAAAAElFTkSuQmCC', 'base64'), 36 | cid: 'note@node' 37 | }] 38 | }; 39 | 40 | transport.sendMail(message, function(error, info){ 41 | if(error){ 42 | console.log('Error occured'); 43 | console.log(error.message); 44 | return; 45 | } 46 | console.log('Message sent: ' + info.response); 47 | 48 | // if you don't want to use this transport object anymore, uncomment following line 49 | transport.close(); // close the connection pool 50 | }); -------------------------------------------------------------------------------- /client/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": [ 3 | "io", 4 | "React" 5 | ], 6 | "asi" : false, 7 | "bitwise" : true, 8 | "boss" : false, 9 | "browser": true, 10 | "curly" : true, 11 | "debug": false, 12 | "devel": false, 13 | "eqeqeq": true, 14 | "evil": false, 15 | "expr": true, 16 | "forin": false, 17 | "immed": true, 18 | "latedef" : false, 19 | "laxbreak": false, 20 | "multistr": true, 21 | "newcap": true, 22 | "noarg": true, 23 | "noempty": false, 24 | "nonew": true, 25 | "onevar": false, 26 | "plusplus": false, 27 | "regexp": false, 28 | "strict": false, 29 | "sub": false, 30 | "trailing" : true, 31 | "undef": true 32 | } -------------------------------------------------------------------------------- /client/app.jsx: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | 3 | var React = require('react'); 4 | var ReactDOM = require('react-dom'); 5 | 6 | var List = require('./list.jsx'); 7 | var Details = require('./details.jsx'); 8 | var Menu = require('./menu.jsx'); 9 | 10 | var App = React.createClass({ 11 | getInitialState: function () { 12 | return {selected: null}; 13 | }, 14 | handleEmailSelected: function (email) { 15 | this.setState({selected: email}); 16 | }, 17 | removeAllEmails: function () { 18 | this.setState({selected: null}); 19 | clearAllMails(); 20 | }, 21 | render: function () { 22 | return ( 23 |
24 | 27 |
28 | 29 |
); 30 | } 31 | }); 32 | 33 | var socket = io.connect(); 34 | socket.on('init', init); 35 | socket.on('got_mail', gotMail); 36 | var emails = []; 37 | 38 | function init(mails) { 39 | emails = mails; 40 | ReactDOM.render(, document.getElementById('app')); 41 | } 42 | 43 | function clearAllMails() { 44 | socket.emit('clear_all_emails'); 45 | emails = []; 46 | init(emails) 47 | } 48 | 49 | function gotMail(mail) { 50 | emails.push(mail); 51 | init(emails); 52 | } 53 | -------------------------------------------------------------------------------- /client/details.jsx: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | 3 | var React = require('react'); 4 | var ReactDOM = require('react-dom'); 5 | 6 | var filesize = require('filesize'); 7 | 8 | var getAvailableView = function(email, view) { 9 | if(!email) { 10 | return view; 11 | } 12 | 13 | if(view === 'html') { 14 | if(email.html) { 15 | return view; 16 | } 17 | 18 | view = 'text'; 19 | } 20 | 21 | if(view === 'text' && email.text) { 22 | return view; 23 | } 24 | 25 | return 'headers'; 26 | }; 27 | 28 | module.exports = React.createClass({ 29 | handleViewSelected: function(view) { 30 | this.setState({ view: view }); 31 | }, 32 | getInitialState: function() { 33 | return { view: 'html' }; 34 | }, 35 | componentWillReceiveProps: function(newProps){ 36 | if(newProps.email === this.props.email) return; 37 | this.setState({ view: getAvailableView(newProps.email, 'html') }); 38 | }, 39 | render: function () { 40 | var email = this.props.email; 41 | 42 | return ( 43 |
44 | 45 | 46 | 47 |
48 | ); 49 | } 50 | }); 51 | 52 | var DetailsContent = React.createClass({ 53 | handleMessageEvent: function(e){ 54 | if (!this.refs || !this.refs.iframe) return; 55 | var iframe = ReactDOM.findDOMNode(this.refs.iframe); 56 | iframe.style.height = e.data.height + 'px'; 57 | }, 58 | componentDidMount: function(){ 59 | window.addEventListener('message', this.handleMessageEvent); 60 | }, 61 | componentWillUnmount: function(){ 62 | window.removeEventListener('message', this.handleMessageEvent); 63 | }, 64 | componentWillReceiveProps: function(newProps) { 65 | if(newProps.view === this.props.view && newProps.email === this.props.email) return; 66 | this.handleMessageEvent({data: {height: 0}}); 67 | }, 68 | renderIframeContent: function(content) { 69 | var email = this.props.email; 70 | var html = ''; 71 | html += content; 72 | html += ''; 73 | 74 | email.attachments.forEach(function(attachment){ 75 | var regex = new RegExp('cid:' + attachment.contentId, 'g'); 76 | html = html.replace(regex, "data:image/png;base64," + attachment.content); 77 | }); 78 | 79 | return ( 80 |
87 |
88 | ); 89 | }, 90 | renderHtml: function() { 91 | return this.renderIframeContent(this.props.email.html); 92 | }, 93 | renderText: function() { 94 | return this.renderIframeContent('
' + this.props.email.text + '
'); 95 | }, 96 | renderHeaders: function() { 97 | var headers = this.props.email.headers; 98 | var keys = Object.keys(headers); 99 | 100 | return ( 101 |
102 |
103 | {keys.map(function(key, i) { 104 | return ( 105 | 106 |
{key}:
107 |
{[].concat(headers[key]).join(', ')}
108 |
109 | ); 110 | })} 111 |
112 |
113 | ); 114 | }, 115 | render: function() { 116 | var email = this.props.email; 117 | if (!email) { return
; } 118 | 119 | var view = this.props.view; 120 | 121 | if(view === 'html') { 122 | return this.renderHtml(); 123 | } 124 | if(view === 'text') { 125 | return this.renderText(); 126 | } 127 | 128 | return this.renderHeaders(); 129 | } 130 | }); 131 | 132 | var DetailsHeader = React.createClass({ 133 | render: function(){ 134 | var email = this.props.email; 135 | if (!email) { return
; } 136 | var from = email.from[0]; 137 | var to = email.to; 138 | var cc = email.cc; 139 | var bcc = email.bcc; 140 | var attachments = email.attachments; 141 | 142 | var renderAddresses = function(addresses, label) { 143 | if (!addresses || addresses.length === 0) return; 144 | return ( 145 | 146 |
{label}
147 |
148 | {addresses.map(function (to, i) { 149 | return ( 150 | {to.name} <{to.address}> 151 | ); 152 | })} 153 |
154 |
155 | ); 156 | }; 157 | 158 | var renderAttachments = function(attachments) { 159 | if (!attachments) return; 160 | 161 | return ( 162 |
163 | {attachments.map(function(att, i) { 164 | return ( 165 | 166 | 170 | {att.fileName} 171 | 172 | {filesize(att.length)} 173 | 174 | ); 175 | })} 176 |
177 | ); 178 | }; 179 | 180 | return ( 181 |
182 |
From:
{from.name} <{from.address}>
183 | {renderAddresses(email.to, "To:")} 184 | {renderAddresses(email.cc, "Cc:")} 185 | {renderAddresses(email.bcc, "Bcc:")} 186 |
Date:
{email.headers.date}
187 |
Subject:
{email.subject}
188 |
Attachments:
{renderAttachments(attachments)} 189 |
190 | ); 191 | } 192 | }); 193 | 194 | var DetailsFooter = React.createClass({ 195 | onClick: function(view) { 196 | this.props.onViewSelected(view); 197 | return false; 198 | }, 199 | render: function() { 200 | var email = this.props.email; 201 | if (!email) { return
; } 202 | 203 | var hide = function(prop) { 204 | return prop ? {} : { display: 'none' }; 205 | }; 206 | 207 | return ( 208 |
209 | View HTML 210 | View text 211 | View headers 212 |
213 | ); 214 | } 215 | }); 216 | -------------------------------------------------------------------------------- /client/list.jsx: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | 3 | var React = require('react'); 4 | 5 | module.exports = React.createClass({ 6 | render: function () { 7 | return ( 8 |
9 |
    10 | {this.props.emails.map(function (email, i) { 11 | return ( 12 | 18 | ); 19 | }, this)} 20 |
21 |
22 | ); 23 | } 24 | }); 25 | 26 | var MailItem = React.createClass({ 27 | render: function () { 28 | var email = this.props.email; 29 | var classes = 'message'; 30 | var email; 31 | if (this.props.selected) { 32 | classes += ' selected'; 33 | } 34 | 35 | try { 36 | email =
  • 37 |
    38 | {this.props.email.from[0].name} <{email.from[0].address}> 39 |
    40 |
    {email.subject}
    41 |
    {email.headers.date}
    42 |
  • ; 43 | } catch (e) { 44 | setTimeout(function () { 45 | throw e; 46 | }); 47 | email =
    ; 48 | } 49 | return email; 50 | } 51 | }); 52 | -------------------------------------------------------------------------------- /client/menu.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var filesize = require('filesize'); 3 | 4 | 5 | var Menu = React.createClass({ 6 | getInitialState: function () { 7 | return {}; 8 | }, 9 | render: function () { 10 | return ( 11 |
    12 |
      13 | 18 |
    19 |
    20 | ); 21 | } 22 | }); 23 | 24 | 25 | module.exports = Menu; 26 | 27 | 28 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('./smtp'); 2 | require('./app'); -------------------------------------------------------------------------------- /lib/storage.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | var EventEmitter = require('events').EventEmitter; 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | // in-memory event emitting storage of emails 6 | 7 | var dataPath = path.join(__dirname, '../data.json'); 8 | 9 | var Storage = function Storage(){ 10 | this.mails = []; 11 | try { 12 | this.mails = require(dataPath); 13 | } catch(e){} 14 | }; 15 | 16 | util.inherits(Storage, EventEmitter); 17 | 18 | Storage.prototype.clearAll = function () { 19 | this.mails = []; 20 | if (process.env.NODE_ENV !== 'production') { 21 | fs.writeFile(dataPath, JSON.stringify(this.mails, null, '\t')); 22 | } 23 | }; 24 | 25 | Storage.prototype.push = function (mail) { 26 | this.mails.push(mail); 27 | 28 | var BUFFER_SIZE = 100; 29 | if (this.mails.length > BUFFER_SIZE) { 30 | this.mails = this.mails.slice(-1 * BUFFER_SIZE); 31 | } 32 | 33 | this.emit('got_mail', mail); 34 | if (process.env.NODE_ENV !== 'production') { 35 | fs.writeFile(dataPath, JSON.stringify(this.mails, null, '\t')); 36 | } 37 | }; 38 | 39 | var s = new Storage(); 40 | 41 | module.exports = s; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mail-null", 3 | "version": "0.0.12", 4 | "description": "Fake SMTP server that doesn't deliver the received mails. Useful for debugging and testing apps that send email.", 5 | "bin": "./bin/mail-null", 6 | "scripts": { 7 | "start": "node index.js", 8 | "watch": "nodemon ." 9 | }, 10 | "dependencies": { 11 | "connect-browserify": "^4.0.0", 12 | "envify": "^3.4.1", 13 | "errorhandler": "^1.0.1", 14 | "express": "^4.14.0", 15 | "filesize": "^3.3.0", 16 | "mailparser": "^0.6.1", 17 | "morgan": "^1.1.1", 18 | "react": "^15.3.2", 19 | "react-dom": "^15.3.2", 20 | "reactify": "^1.1.1", 21 | "simplesmtp": "^0.3.23", 22 | "socket.io": "^1.5.1", 23 | "stylus": "^0.54.5", 24 | "uglifyify": "^3.0.4" 25 | }, 26 | "devDependencies": { 27 | "nodemailer": "~2.6.4", 28 | "nodemon": "~1.11.0" 29 | }, 30 | "keywords": [ 31 | "smtp", 32 | "development", 33 | "test", 34 | "fake", 35 | "dummy", 36 | "mock", 37 | "email", 38 | "e-mail" 39 | ], 40 | "author": { 41 | "name": "Jonas Mosbech", 42 | "email": "jonas@mosbech.net", 43 | "url": "http://github.com/jmosbech" 44 | }, 45 | "repository": { 46 | "type": "git", 47 | "url": "git@github.com:jmosbech/mail-null.git" 48 | }, 49 | "license": "MIT" 50 | } 51 | -------------------------------------------------------------------------------- /packager/Procfile: -------------------------------------------------------------------------------- 1 | main: node bin/mail-null 2 | -------------------------------------------------------------------------------- /packager/postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | APP_NAME="mail-null" 6 | CLI="$APP_NAME" 7 | 8 | $CLI scale main=0 || true 9 | $CLI scale main=1 || true 10 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | /mail/null 6 | 7 | 8 | 9 |
    10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/stylesheets/style.styl: -------------------------------------------------------------------------------- 1 | sidebar-width = 340px 2 | line-height = 1.5 3 | 4 | /* ---- */ 5 | 6 | *, 7 | *:before, 8 | *:after 9 | box-sizing: border-box; 10 | 11 | html, body, #app 12 | margin: 0 13 | padding: 0 14 | height: 100% 15 | 16 | body 17 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif 18 | 19 | .app 20 | display: table 21 | 22 | 23 | .app, 24 | .details 25 | height: 100% 26 | min-height: 100% 27 | width: 100% 28 | 29 | button 30 | height: 30px 31 | 32 | button.warn 33 | background-color: #f44336; 34 | color:white; 35 | 36 | .menu 37 | display: table-row 38 | height: 50px 39 | 40 | .menu ul 41 | list-style-type: none 42 | 43 | .sidebar 44 | display: table-cell 45 | width: sidebar-width 46 | 47 | .sidebar ul 48 | list-style-type: none 49 | padding: 0 50 | margin: 0 51 | height: 100% 52 | overflow-y: auto 53 | 54 | .message 55 | border-top: 1px solid transparent 56 | border-bottom: 1px solid #e8e9ec 57 | padding: 10px 58 | overflow: hidden 59 | line-height: line-height 60 | cursor: pointer; 61 | 62 | .message .from 63 | font-weight: bold 64 | 65 | .message .from, 66 | .message .subject 67 | text-overflow: ellipsis; 68 | overflow: hidden; 69 | white-space: nowrap; 70 | 71 | .message .date 72 | font-size: 80% 73 | 74 | .message.selected 75 | background-color: #eee 76 | border-color: #b8babb 77 | box-shadow: inset 0px 5px 15px 0px rgba(0,0,0,0.10); 78 | 79 | .details 80 | border-left: 1px solid #a7a7a7 81 | width: auto 82 | overflow-y: auto 83 | display: block 84 | 85 | .details-header 86 | margin: 10px 87 | margin-top: 0 88 | background-color: #e6e6e6 89 | border-radius: 5px 90 | padding: 10px 91 | overflow: hidden 92 | line-height: line-height 93 | 94 | details-label-width = 100px 95 | 96 | .details-header dt 97 | float:left 98 | width: details-label-width 99 | text-align: right 100 | padding-right: 10px 101 | 102 | .details-header dd 103 | margin-left: details-label-width 104 | 105 | .details-header .address:after 106 | content: "; " 107 | 108 | .details-header .address:last-child:after 109 | content: "" 110 | 111 | .iframe-container 112 | margin: 10px 113 | border: 1px solid #a7a7a7 114 | 115 | .details-content 116 | width: 100% 117 | display: block 118 | 119 | .attachment 120 | padding-right: 15px 121 | 122 | .attachment-name 123 | padding-right: 5px 124 | 125 | .attachment-size 126 | color: gray 127 | 128 | .details-footer 129 | margin: 10px 130 | overflow: hidden 131 | line-height: 1.5 132 | font-size: 12px 133 | 134 | .details-footer a 135 | margin-right: 10px 136 | 137 | .headers-container 138 | margin: 10px 139 | overflow: hidden 140 | line-height: 1.5 141 | border: 1px solid #a7a7a7 142 | 143 | .headers-container dt 144 | float: left 145 | width: 250px 146 | text-align: right 147 | padding-right: 10px 148 | font-weight: bold 149 | 150 | .headers-container dd 151 | margin-left: 250px 152 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ``/mail/null`` 2 | Tired of accidentally sending test mails from your development environment to real addresses? 3 | 4 | Meet `/mail/null`, a dummy SMTP server for development. Instead of relaying messages it renders them in a simple web interface for easy browsing. 5 | 6 | [![Dependency Status](https://david-dm.org/jmosbech/mail-null.png)](https://david-dm.org/jmosbech/mail-null) 7 | 8 | ## Installation 9 | ``` 10 | npm install -g mail-null 11 | ``` 12 | ## Start 13 | ``` 14 | mail-null 15 | ``` 16 | 17 | ## Configuration 18 | The server listens for connections on port 2525 (SMTP) and 3000 (HTTP). 19 | This can be overridden by setting the environment variables `SMTP_PORT` and `PORT`. 20 | 21 | ## License 22 | MIT 23 | -------------------------------------------------------------------------------- /smtp.js: -------------------------------------------------------------------------------- 1 | var simplesmtp = require('simplesmtp'); 2 | var MailParser = require('mailparser').MailParser; 3 | var storage = require('./lib/storage'); 4 | 5 | var port = process.env.SMTP_PORT || 2525; 6 | 7 | simplesmtp.createSimpleServer( 8 | {disableSTARTTLS: true, ignoreTLS: true, SMTPBanner: '/mail/null - where your test emails go to die'}, 9 | function (req) { 10 | var mailparser = new MailParser(); 11 | mailparser.on('end', function (email) { 12 | email.attachments = (email.attachments||[]).map(function(attachment){ 13 | attachment.content = attachment.content.toString('base64'); 14 | return attachment; 15 | }); 16 | storage.push(email); 17 | }); 18 | req.pipe(mailparser); 19 | req.accept(); 20 | }).listen(port); 21 | 22 | console.log('SMTP server listening on port ' + port); 23 | --------------------------------------------------------------------------------