Compare commits
374 Commits
Author | SHA1 | Date |
---|---|---|
Travis Burtrum | 1c049fc071 | |
Travis Burtrum | 017e704791 | |
Travis Burtrum | 0aa927ab50 | |
Travis Burtrum | 93dd5d1758 | |
Travis Burtrum | d42f2bf11d | |
Travis Burtrum | fb91dd0902 | |
Samuel Cochran | 7fe655fdac | |
Samuel Cochran | fdbe5c4535 | |
Samuel Cochran | e8531da70d | |
Samuel Cochran | 132f1c0f42 | |
Samuel Cochran | 0021a2909c | |
Samuel Cochran | 2e27c830b1 | |
Samuel Cochran | c29336b78d | |
Samuel Cochran | d932eff261 | |
Samuel Cochran | e2d89b65db | |
Samuel Cochran | ba4ca7f8d6 | |
Samuel Cochran | 17054f80ad | |
Samuel Cochran | f5cbdec8b3 | |
Sasha Gerrand | 02abbcbeb4 | |
Samuel Cochran | bc86e995ef | |
Samuel Cochran | ed9174ab42 | |
Samuel Cochran | 4dcf7776aa | |
Odin Dutton | dc6ae47749 | |
Samuel Cochran | e3c23333bf | |
Samuel Cochran | 2411713bba | |
Samuel Cochran | f2a7385097 | |
Samuel Cochran | cff07b758c | |
Samuel Cochran | f80a80ea9c | |
Samuel Cochran | 2b53ab2735 | |
Samuel Cochran | 4d7429c127 | |
Samuel Cochran | e6283f590b | |
Samuel Cochran | 844c0a7d72 | |
Samuel Cochran | 420d24e914 | |
Samuel Cochran | 0c1a1e4148 | |
Samuel Cochran | 79e023ef58 | |
Samuel Cochran | 433fad4e4d | |
Grant McCarriagher | a6ef2147e0 | |
Samuel Cochran | efd7b4ff0e | |
Samuel Cochran | c9c015b930 | |
Samuel Cochran | ca0166ded4 | |
Samuel Cochran | 134a99c9c1 | |
Samuel Cochran | d32b8465ba | |
Samuel Cochran | 885f0d95d8 | |
Daniel O'Connor | 9eabbd331b | |
Csiszár Attila | 101c068ac2 | |
s | 681ef78caa | |
Samuel Cochran | d7a4737532 | |
Samuel Cochran | 0179a924a0 | |
Samuel Cochran | 8e77cde268 | |
Samuel Cochran | 5e2e3d378b | |
Samuel Cochran | 80cbb52448 | |
Samuel Cochran | 5b3781b44c | |
Samuel Cochran | 757eafe337 | |
Samuel Cochran | d3336535ae | |
Samuel Cochran | 23398d7042 | |
Samuel Cochran | 14a86ef8d4 | |
Samuel Cochran | 8e760d8a50 | |
Samuel Cochran | 442a3f5404 | |
Samuel Cochran | 7533eb4317 | |
Samuel Cochran | 3e1f362250 | |
Pavel | de0edff86b | |
Samuel Cochran | 1fee20497c | |
Samuel Cochran | e703dbcf13 | |
Samuel Cochran | a4d9735b9a | |
Samuel Cochran | aa9b8c925a | |
Samuel Cochran | d3f083f8df | |
Samuel Cochran | e76d367755 | |
Samuel Cochran | 927b8a1aae | |
Samuel Cochran | 021022c20f | |
Samuel Cochran | 841c3a2ef3 | |
Samuel Cochran | 640d3710ae | |
Samuel Cochran | 6ec409bfcb | |
Paul Bowsher | 18655f41f2 | |
Paul Bowsher | 9f2457aa4d | |
Paul Bowsher | 09e8349b6f | |
Jordan Eldredge | de8a4735a7 | |
Paul Bowsher | 1ab18c821d | |
Samuel Cochran | eff638f920 | |
Samuel Cochran | e3e7dec757 | |
Samuel Cochran | ed72712daa | |
Samuel Cochran | 18797b26ee | |
Samuel Cochran | d1f3a75929 | |
Samuel Cochran | f3b7befe94 | |
Samuel Cochran | 21ae84c059 | |
Dirk Kelly | c42fad387a | |
Samuel Cochran | d4c853db63 | |
Emil Sågfors | d661bd35bf | |
Samuel Cochran | 2ad4ea38a9 | |
Samuel Cochran | 2a8d42849c | |
David Landry | fdb5483560 | |
Samuel Cochran | 44ca32c3af | |
Samuel Cochran | 213119cb5f | |
Samuel Cochran | bb128422c4 | |
Samuel Cochran | aa5e5bfd31 | |
Samuel Cochran | dfaf307071 | |
Samuel Cochran | 184773b664 | |
Samuel Cochran | 65743c22fd | |
Samuel Cochran | 27277a6cbe | |
Samuel Cochran | 90b4cefb5a | |
Samuel Cochran | c7cf74b7ad | |
Samuel Cochran | c9c7ef840d | |
Samuel Cochran | c577f06ba0 | |
Jakub Pavlík jn | e1ee9eefc9 | |
Samuel Cochran | 8c5c8a51bb | |
Samuel Cochran | 5812eadddb | |
Samuel Cochran | 903e3ad2fb | |
Samuel Cochran | a4e62f2e92 | |
Samuel Cochran | 0de09cdf55 | |
Samuel Cochran | d496655cf9 | |
Samuel Cochran | c3f6979314 | |
Samuel Cochran | 77212c3e98 | |
Samuel Cochran | 8f5a5b59ce | |
Samuel Cochran | 20ffd4433e | |
Samuel Cochran | 70be04c478 | |
Samuel Cochran | 0398d2d6a3 | |
Samuel Cochran | 272b4fa855 | |
Samuel Cochran | 2056339bdb | |
Samuel Cochran | d2ba6d19f2 | |
Samuel Cochran | 5b9424c650 | |
Rick Cobb | 64e1ef41d8 | |
Rick Cobb | 295691d625 | |
Samuel Cochran | f575b849a4 | |
Jan Deelstra | a90e115c89 | |
Samuel Cochran | 7c31360b3a | |
Sylvain Rayé | f4061fa861 | |
Samuel Cochran | f93df88021 | |
Samuel Cochran | 264e912a02 | |
Samuel Cochran | a6c8f680c4 | |
Samuel Cochran | 8913812b92 | |
Samuel Cochran | 61f0fdede3 | |
Samuel Cochran | c1e4b5da86 | |
Samuel Cochran | cc17f2d765 | |
maxgalbu | e9f59ba608 | |
Ivan Kuchin | 3ffa6a3237 | |
Jakub Pavlík jn | 2f5c2ff01c | |
Samuel Cochran | 7cc20ce471 | |
Samuel Cochran | f8df981d96 | |
Jakub Pavlík jn | ffb4ec4e4c | |
Jakub Pavlík jn | ef09b0fde3 | |
Samuel Cochran | fb13a62589 | |
Samuel Cochran | dd180d96cb | |
Samuel Cochran | 0b6d041b93 | |
Samuel Cochran | 44262f9862 | |
gondo | 9d71c72c42 | |
Samuel Cochran | a06f51d4bb | |
Jacob Haslehurst | 8e5ef66f0a | |
Jacob Haslehurst | 091ae6d1fc | |
Samuel Cochran | 16264a89ba | |
Julien Kirch | 6ce4dda354 | |
Samuel Cochran | e19a2096b0 | |
Samuel Cochran | cca077af28 | |
Samuel Cochran | 808ada7a3b | |
Hubert Dabrowski | f10ac38e90 | |
Hubert Dabrowski | b6e0daf0ac | |
Samuel Cochran | 1241446fa0 | |
Samuel Cochran | 37f55300ff | |
Samuel Cochran | 6dfed04dd9 | |
Samuel Cochran | e8ca2a8fcf | |
Samuel Cochran | 2a931fdc6b | |
Samuel Cochran | 6605fc266f | |
Samuel Cochran | 2e7f9562df | |
Samuel Cochran | 6dca1a8e6b | |
Samuel Cochran | 327cd355b0 | |
Samuel Cochran | f25eef73ef | |
Samuel Cochran | c971f543d4 | |
Charlie Sanders | 2ae395a0ad | |
Samuel Cochran | fbd6b946f3 | |
Rémi Prévost | 7d242e9fc0 | |
Samuel Cochran | 3aa2815cea | |
Ivan Kuchin | b3a4e86a48 | |
Ivan Kuchin | 2e35d0cc9d | |
Ivan Kuchin | 0a4775092a | |
Ivan Kuchin | eb3e14a8a6 | |
Samuel Cochran | a1b8f8b3d3 | |
Samuel Cochran | c24e075d6f | |
Samuel Cochran | 74beed307c | |
Samuel Cochran | 76cef09b4e | |
Ivan Kuchin | 81cb465357 | |
Ivan Kuchin | 7bc086ff49 | |
Ivan Kuchin | 09312ffb6e | |
Ivan Kuchin | cb3974f1b2 | |
Ivan Kuchin | a161dedf0a | |
Alexey Chernenkov | e7b39e9234 | |
Samuel Cochran | 3f4abe1d32 | |
Samuel Cochran | 15ebba22a5 | |
Samuel Cochran | ae61a04033 | |
Samuel Cochran | 39aac3422b | |
Samuel Cochran | 85ce6f49d1 | |
Samuel Cochran | a0620de9d5 | |
Samuel Cochran | 502dad4493 | |
Samuel Cochran | 76ca9ac918 | |
Samuel Cochran | aa5f056ae6 | |
Samuel Cochran | 9d91e3f82c | |
Samuel Cochran | 19eb9ce540 | |
Samuel Cochran | a2f9808c75 | |
Samuel Cochran | d74b700216 | |
Samuel Cochran | 5f05ea0d5d | |
Samuel Cochran | c920bad92f | |
Samuel Cochran | 20cf6e98f1 | |
Samuel Cochran | cfdadc01fd | |
Samuel Cochran | 9d50d202a1 | |
Samuel Cochran | 1c68ff5662 | |
Samuel Cochran | 8a69d92fca | |
Samuel Cochran | 5416c0b860 | |
Samuel Cochran | 69b91a1fa8 | |
Samuel Cochran | 744ee93c57 | |
Samuel Cochran | faf18259ef | |
Alexey Kuleshov | 3b446217ad | |
Samuel Cochran | 20b5635aff | |
Samuel Cochran | 73562c77be | |
Samuel Cochran | 0e58ca6887 | |
Samuel Cochran | fb3e63e74c | |
Samuel Cochran | b0cce663f4 | |
Josh McArthur | cee3ed232f | |
Josh McArthur | 5233db0bb5 | |
Josh McArthur | 089ddaa579 | |
Josh McArthur | 7b3825c8dd | |
Josh McArthur | c9f6127b77 | |
Josh McArthur | aa4a51fb6d | |
Samuel Cochran | c8090609e5 | |
Samuel Cochran | e350050fa2 | |
Samuel Cochran | 0964de9fe4 | |
Samuel Cochran | 2ab9c6c0f0 | |
Samuel Cochran | 86bd77bcd1 | |
João Britto | 5ba5413a9a | |
Samuel Cochran | 8682ab266b | |
Samuel Cochran | fcb9a0e4bb | |
Samuel Cochran | 1e717a7d76 | |
Sachin Ranchod | 3d6f2a4b6a | |
Samuel Cochran | 1b4ab9fdac | |
Samuel Cochran | 0a083c0300 | |
Samuel Cochran | 4978e8c5e7 | |
Samuel Cochran | 1067c208cb | |
Samuel Cochran | 50ea84cebf | |
Ryan Montgomery | a9e46c1d55 | |
Chris Kimpton | e4eb0992f4 | |
Samuel Cochran | c1027deee6 | |
Samuel Cochran | 59f0ec8532 | |
Samuel Cochran | e5c8ef9b08 | |
Samuel Cochran | 0a29130d0f | |
Nando Vieira | d815809607 | |
Samuel Cochran | 428e7cdf1e | |
Gregor Schmidt | 3bbaa6f2de | |
Samuel Cochran | d9986bad86 | |
Samuel Cochran | bbcd792a03 | |
Samuel Cochran | 024186ebc3 | |
Samuel Cochran | 54eb83d7a2 | |
Samuel Cochran | 08ab0c5f69 | |
Samuel Cochran | 93a31fd62b | |
Samuel Cochran | 623f2b4514 | |
Samuel Cochran | 60160e1642 | |
Gregor Schmidt | 32ae22e4c7 | |
Gregor Schmidt | 2379603d16 | |
Samuel Cochran | 88711084e7 | |
Samuel Cochran | 63432c27c8 | |
Samuel Cochran | c3a3bb1880 | |
Samuel Cochran | 736f3ee821 | |
Michael Moen | 2290c0eb3e | |
Samuel Cochran | 56113d9c3f | |
Samuel Cochran | e9da024345 | |
Samuel Cochran | 6b5f8cbfca | |
Samuel Cochran | eb45a62413 | |
Samuel Cochran | eed61fe11e | |
Samuel Cochran | 6ba508c635 | |
Samuel Cochran | a6e9fafbfc | |
Samuel Cochran | 0de42cf049 | |
Samuel Cochran | 4ba18f7296 | |
Samuel Cochran | 51e74fdac2 | |
Samuel Cochran | d2bb7e8c66 | |
Samuel Cochran | cc517b051c | |
Samuel Cochran | c760c01b81 | |
Samuel Cochran | ae83b075c2 | |
Samuel Cochran | 515b5ce4bc | |
Samuel Cochran | 26eb609085 | |
Samuel Cochran | 628755ece8 | |
Samuel Cochran | 9a914ddfd0 | |
Samuel Cochran | ae9a324118 | |
Samuel Cochran | ad6b61edf3 | |
Samuel Cochran | f5efe23c6b | |
Samuel Cochran | 6571f84794 | |
Samuel Cochran | baba52adcb | |
Samuel Cochran | 8424fedaaf | |
Samuel Cochran | 55790a91a4 | |
Samuel Cochran | e22d6766e3 | |
Samuel Cochran | 559452ecc1 | |
Samuel Cochran | cbc49f7ba6 | |
Samuel Cochran | 0e8ac1c5a2 | |
Samuel Cochran | 5ba5624c13 | |
Samuel Cochran | d0d6b29606 | |
Samuel Cochran | 4b689159df | |
Samuel Cochran | 49d42da4e5 | |
Samuel Cochran | b59cffd784 | |
Samuel Cochran | e85036a994 | |
Samuel Cochran | 51042a28a8 | |
Samuel Cochran | 22d5fcaf33 | |
Samuel Cochran | ad556f2fdc | |
Samuel Cochran | a4269a895f | |
Samuel Cochran | 5b4d6ed26e | |
Samuel Cochran | 811a3ac47f | |
Samuel Cochran | 290d2db96a | |
Samuel Cochran | f0b8c2c63c | |
Samuel Cochran | 4e65f6aedc | |
Samuel Cochran | 6b1f3e343d | |
Samuel Cochran | f92a0684be | |
Samuel Cochran | f9c32e6b9f | |
Samuel Cochran | 4d0163ca0e | |
Samuel Cochran | 30a34984bd | |
Samuel Cochran | 406a7980ed | |
Samuel Cochran | 0ef6d42349 | |
Samuel Cochran | b8d72b0b53 | |
Samuel Cochran | 06f2238ef3 | |
Samuel Cochran | 803a569ac8 | |
Samuel Cochran | 17d2cb3de9 | |
Samuel Cochran | bd1bfc47cf | |
Samuel Cochran | 72a761c198 | |
Samuel Cochran | 56a82f5b46 | |
Samuel Cochran | f63c85629d | |
Samuel Cochran | bcdf16680a | |
Samuel Cochran | 63f7f85437 | |
Samuel Cochran | ac6a439796 | |
Samuel Cochran | df7cda92d2 | |
Samuel Cochran | d07a6dff46 | |
Samuel Cochran | 895f7f4f59 | |
Samuel Cochran | 0abd0037db | |
Samuel Cochran | e33448c965 | |
Samuel Cochran | 858d4c4290 | |
Samuel Cochran | 8137c06c52 | |
Samuel Cochran | ac1e200111 | |
Samuel Cochran | 08e3745a96 | |
Samuel Cochran | 42796bbc69 | |
Samuel Cochran | 4f7c72e532 | |
Samuel Cochran | 00a415f386 | |
Samuel Cochran | a9d884859b | |
Samuel Cochran | da6fcfff29 | |
Samuel Cochran | 531a5c07f6 | |
Samuel Cochran | 53ca9485e1 | |
Samuel Cochran | e41d40549d | |
Samuel Cochran | 46d1e2a6e8 | |
Samuel Cochran | 63dce8410e | |
Samuel Cochran | ef7c6828ab | |
Samuel Cochran | fbcdf4a1c2 | |
Samuel Cochran | da9f1d6b17 | |
Samuel Cochran | 1a0e2a1f64 | |
Samuel Cochran | 6703495637 | |
Samuel Cochran | 0cbaab1a56 | |
Samuel Cochran | afee4a2c4a | |
Samuel Cochran | ca89f36ab6 | |
Samuel Cochran | 3e954599ff | |
Samuel Cochran | 1ff11d6920 | |
Samuel Cochran | b4ad9041c3 | |
Samuel Cochran | 90b0aadeaf | |
Samuel Cochran | 6003e631fc | |
Samuel Cochran | f8897680ce | |
Samuel Cochran | 8d6c237d62 | |
Samuel Cochran | 36faf40f5f | |
Samuel Cochran | 9c6b80cb63 | |
Samuel Cochran | cbf6a77b5d | |
Samuel Cochran | 3ad06e148f | |
Samuel Cochran | 66d258a859 | |
Samuel Cochran | 412bbfa00b | |
Samuel Cochran | 675c0b369d | |
Samuel Cochran | bb2dcba797 | |
Samuel Cochran | f396958ba3 | |
Samuel Cochran | e482f33693 | |
Samuel Cochran | d6c13f3d57 | |
Samuel Cochran | 2fdce6900a | |
Samuel Cochran | d62d0e6d78 | |
Samuel Cochran | aeaba34034 | |
Samuel Cochran | 13405c9686 | |
Samuel Cochran | 02367c1443 | |
Samuel Cochran | 6fa12ee55b | |
Samuel Cochran | a89b4c2860 | |
Samuel Cochran | 76c22e3971 | |
Samuel Cochran | 7613141e9a |
|
@ -1 +1,13 @@
|
|||
pkg
|
||||
# Caches
|
||||
/.bundle
|
||||
/.sass-cache
|
||||
|
||||
# Gemfile locks ignored for gems
|
||||
/Gemfile.lock
|
||||
|
||||
# Generated documentation and assets
|
||||
/doc
|
||||
/public/assets
|
||||
|
||||
# Build gems
|
||||
*.gem
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
sudo: false
|
||||
language: ruby
|
||||
rvm:
|
||||
- 1.9.3
|
||||
- 2.0.0
|
||||
- 2.1.5
|
||||
- 2.2.0
|
|
@ -0,0 +1,10 @@
|
|||
FROM ruby:2.3
|
||||
MAINTAINER Samuel Cochran <sj26@sj26.com>
|
||||
|
||||
RUN gem install mailcatcher
|
||||
|
||||
EXPOSE 1025
|
||||
EXPOSE 1080
|
||||
|
||||
ENTRYPOINT ["mailcatcher", "-f"]
|
||||
CMD ["--ip", "0.0.0.0"]
|
|
@ -0,0 +1,10 @@
|
|||
source "https://rubygems.org"
|
||||
|
||||
gemspec
|
||||
|
||||
# mime-types 3+, required by mail, requires ruby 2.0+
|
||||
gem "mime-types", "< 3" if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("2")
|
||||
|
||||
#group :development do
|
||||
# gem "pry"
|
||||
#end
|
2
LICENSE
2
LICENSE
|
@ -1,4 +1,4 @@
|
|||
Copyright (c) 2010 Samuel Cochran
|
||||
Copyright (c) 2010-2011 Samuel Cochran
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
|
|
106
README.md
106
README.md
|
@ -4,42 +4,118 @@ Catches mail and serves it through a dream.
|
|||
|
||||
MailCatcher runs a super simple SMTP server which catches any message sent to it to display in a web interface. Run mailcatcher, set your favourite app to deliver to smtp://127.0.0.1:1025 instead of your default SMTP server, then check out http://127.0.0.1:1080 to see the mail that's arrived so far.
|
||||
|
||||
![MailCatcher screenshot](https://cloud.githubusercontent.com/assets/14028/14093249/4100f904-f598-11e5-936b-e6a396f18e39.png)
|
||||
|
||||
## Features
|
||||
|
||||
* Catches all mail and stores it for display.
|
||||
* Shows HTML, Plain Text and Source version of messages, as applicable.
|
||||
* Rewrites HTML enabling display of embedded, inline images/etc. (currently very basic)
|
||||
* Rewrites HTML enabling display of embedded, inline images/etc and open links in a new window.
|
||||
* Lists attachments and allows separate downloading of parts.
|
||||
* Written super-simply in EventMachine, easy to dig in and change.
|
||||
* Download original email to view in your native mail client(s).
|
||||
* Command line options to override the default SMTP/HTTP IP and port settings.
|
||||
* Mail appears instantly if your browser supports [WebSockets][websockets], otherwise updates every thirty seconds.
|
||||
* Runs as a daemon run in the background.
|
||||
* Sendmail-analogue command, `catchmail`, makes [using mailcatcher from PHP][withphp] a lot easier.
|
||||
* Written super-simply in EventMachine, easy to dig in and change.
|
||||
* Keyboard navigation between messages
|
||||
|
||||
## How
|
||||
|
||||
1. `gem install mailcatcher`
|
||||
2. `mailcatcher`
|
||||
3. Go to http://localhost:1080/
|
||||
4. Send mail through smtp://localhost:1025
|
||||
|
||||
Use `mailcatcher --help` to see the command line options. The brave can get the source from [the GitHub repository][mailcatcher-github].
|
||||
|
||||
### Bundler
|
||||
|
||||
Please don't put mailcatcher into your Gemfile. It will conflict with your applications gems at some point.
|
||||
|
||||
Instead, pop a note in your README stating you use mailcatcher. Simply run `gem install mailcatcher` then `mailcatcher` to get started.
|
||||
|
||||
### RVM
|
||||
|
||||
Under RVM your mailcatcher command may only be available under the ruby you install mailcatcher into. To prevent this, and to prevent gem conflicts, install mailcatcher into a dedicated gemset and create wrapper scripts:
|
||||
|
||||
rvm default@mailcatcher --create do gem install mailcatcher
|
||||
rvm wrapper default@mailcatcher --no-prefix mailcatcher catchmail
|
||||
|
||||
### Rails
|
||||
|
||||
To set up your rails app, I recommend adding this to your `environments/development.rb`:
|
||||
|
||||
config.action_mailer.delivery_method = :smtp
|
||||
config.action_mailer.smtp_settings = { :address => "localhost", :port => 1025 }
|
||||
config.action_mailer.raise_delivery_errors = false
|
||||
|
||||
### PHP
|
||||
|
||||
For projects using PHP, or PHP frameworks and application platforms like Drupal, you can set [PHP's mail configuration](http://www.php.net/manual/en/mail.configuration.php) in your [php.ini](http://www.php.net/manual/en/configuration.file.php) to send via MailCatcher with:
|
||||
|
||||
sendmail_path = /usr/bin/env catchmail -f some@from.address
|
||||
|
||||
You can do this in your [Apache configuration](http://php.net/manual/en/configuration.changes.php) like so:
|
||||
|
||||
php_admin_value sendmail_path "/usr/bin/env catchmail -f some@from.address"
|
||||
|
||||
If you've installed via RVM this probably won't work unless you've manually added your RVM bin paths to your system environment's PATH. In that case, run `which catchmail` and put that path into the `sendmail_path` directive above instead of `/usr/bin/env catchmail`.
|
||||
|
||||
If starting `mailcatcher` on alternative SMTP IP and/or port with parameters like `--smtp-ip 192.168.0.1 --smtp-port 10025`, add the same parameters to your `catchmail` command:
|
||||
|
||||
sendmail_path = /usr/bin/env catchmail --smtp-ip 192.160.0.1 --smtp-port 10025 -f some@from.address
|
||||
|
||||
### Django
|
||||
|
||||
For use in Django, simply add the following configuration to your projects' settings.py
|
||||
|
||||
```python
|
||||
if DEBUG:
|
||||
EMAIL_HOST = '127.0.0.1'
|
||||
EMAIL_HOST_USER = ''
|
||||
EMAIL_HOST_PASSWORD = ''
|
||||
EMAIL_PORT = 1025
|
||||
EMAIL_USE_TLS = False
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
A fairly RESTful URL schema means you can download a list of messages in JSON from `/messages`, each message's metadata with `/messages/:id.json`, and then the pertinent parts with `/messages/:id.html` and `/messages/:id.plain` for the default HTML and plain text version, `/messages/:id/:cid` for individual attachments by CID, or the whole message with `/messages/:id.source`.
|
||||
|
||||
## Caveats
|
||||
|
||||
* Mail requires activesupport which requires i18n, but it doesn't list it as a dependency. For now I've added i18n as a requirement for MailCatcher.
|
||||
* For now you need to refresh the page to see new mail. Websockets support is coming soon to show new mail immediately.
|
||||
* Mail proccessing is fairly basic but easily modified. If something doesn't work for you, fork and fix it or file an issue and let me know. Include the whole message you're having problems with.
|
||||
* Mail processing is fairly basic but easily modified. If something doesn't work for you, fork and fix it or [file an issue][mailcatcher-issues] and let me know. Include the whole message you're having problems with.
|
||||
* The interface is very basic and has not been tested on many browsers yet.
|
||||
|
||||
## TODO
|
||||
|
||||
* Websockets for immediate mail viewing.
|
||||
* Download link to view original message in mail client.
|
||||
* Growl support.
|
||||
* Test suite.
|
||||
* Better organisation.
|
||||
* Better interface. SproutCore?
|
||||
* Add mail delivery on request, optionally multiple times.
|
||||
* Compatibility testing against CampaignMonitor's [design guidelines](http://www.campaignmonitor.com/design-guidelines/) and [CSS support matrix](http://www.campaignmonitor.com/css/).
|
||||
* Forward mail to rendering service, maybe CampaignMonitor?
|
||||
* Package as an app? Native interfaces?
|
||||
|
||||
## Thanks
|
||||
|
||||
MailCatcher is just a mishmash of other people's hard work. Thank you so much to the people who have built the wonderful guts on which this project relies.
|
||||
|
||||
## Copyright
|
||||
Thanks also to [The Frontier Group][tfg] for giving me the idea, being great guinea pigs and letting me steal pieces of time to keep the project alive.
|
||||
|
||||
Copyright (c) 2010 Samuel Cochran. See LICENSE for details.
|
||||
## Donations
|
||||
|
||||
I work on MailCatcher mostly in my own spare time. If you've found Mailcatcher useful and would like to help feed me and fund continued development and new features, please [donate via PayPal][donate]. If you'd like a specific feature added to MailCatcher and are willing to pay for it, please [email me](mailto:sj26@sj26.com).
|
||||
|
||||
## License
|
||||
|
||||
Copyright © 2010-2011 Samuel Cochran (sj26@sj26.com). Released under the MIT License, see [LICENSE][license] for details.
|
||||
|
||||
## Dreams
|
||||
|
||||
For dream catching, try [this](http://goo.gl/kgbh).
|
||||
For dream catching, try [this](http://goo.gl/kgbh). OR [THIS](http://www.nyanicorn.com), OMG.
|
||||
|
||||
[donate]: https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=522WUPLRWUSKE
|
||||
[license]: https://github.com/sj26/mailcatcher/blob/master/LICENSE
|
||||
[mailcatcher-github]: https://github.com/sj26/mailcatcher
|
||||
[mailcatcher-issues]: https://github.com/sj26/mailcatcher/issues
|
||||
[tfg]: http://www.thefrontiergroup.com.au
|
||||
[websockets]: http://www.whatwg.org/specs/web-socket-protocol/
|
||||
[withphp]: http://webschuur.com/publications/blogs/2011-05-29-catchmail_for_drupal_and_other_phpapplications_the_simple_version
|
||||
|
|
92
Rakefile
92
Rakefile
|
@ -1,43 +1,61 @@
|
|||
require 'rubygems'
|
||||
require 'rake'
|
||||
require "fileutils"
|
||||
require "rubygems"
|
||||
|
||||
begin
|
||||
require 'jeweler'
|
||||
Jeweler::Tasks.new do |gem|
|
||||
gem.name = "mailcatcher"
|
||||
gem.summary = %Q{Runs an SMTP server, catches and displays email in a web interface.}
|
||||
gem.description = <<-EOD
|
||||
MailCatcher runs a super simple SMTP server which catches any
|
||||
message sent to it to display in a web interface. Run
|
||||
mailcatcher, set your favourite app to deliver to
|
||||
smtp://127.0.0.1:1025 instead of your default SMTP server,
|
||||
then check out http://127.0.0.1:1080 to see the mail.
|
||||
EOD
|
||||
gem.email = "sj26@sj26.com"
|
||||
gem.homepage = "http://github.com/sj26/mailcatcher"
|
||||
gem.authors = ["Samuel Cochran"]
|
||||
|
||||
gem.add_dependency 'eventmachine'
|
||||
gem.add_dependency 'mail'
|
||||
gem.add_dependency 'i18n'
|
||||
gem.add_dependency 'sqlite3-ruby'
|
||||
gem.add_dependency 'thin'
|
||||
gem.add_dependency 'skinny'
|
||||
gem.add_dependency 'haml'
|
||||
gem.add_dependency 'json'
|
||||
require "mail_catcher/version"
|
||||
|
||||
# XXX: Would prefer to use Rake::SprocketsTask but can't populate
|
||||
# non-digest assets, and we don't want sprockets at runtime so
|
||||
# can't use manifest directly. Perhaps index.html should be
|
||||
# precompiled with digest assets paths?
|
||||
|
||||
desc "Compile assets"
|
||||
task "assets" do
|
||||
compiled_path = File.expand_path("../public/assets", __FILE__)
|
||||
FileUtils.mkdir_p(compiled_path)
|
||||
|
||||
require "mail_catcher/web/assets"
|
||||
sprockets = MailCatcher::Web::Assets
|
||||
sprockets.css_compressor = :sass
|
||||
sprockets.js_compressor = :uglifier
|
||||
sprockets.each_logical_path(/(\Amailcatcher\.(js|css)|\.(xsl|png)\Z)/) do |logical_path|
|
||||
if asset = sprockets.find_asset(logical_path)
|
||||
target = File.join(compiled_path, logical_path)
|
||||
asset.write_to target
|
||||
end
|
||||
end
|
||||
Jeweler::GemcutterTasks.new
|
||||
rescue LoadError
|
||||
puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
|
||||
end
|
||||
|
||||
require 'rake/rdoctask'
|
||||
Rake::RDocTask.new do |rdoc|
|
||||
version = File.exist?('VERSION') ? File.read('VERSION') : ""
|
||||
desc "Package as Gem"
|
||||
task "package" => ["assets"] do
|
||||
require "rubygems/package"
|
||||
require "rubygems/specification"
|
||||
|
||||
rdoc.rdoc_dir = 'rdoc'
|
||||
rdoc.title = "MailCatcher #{version}"
|
||||
rdoc.rdoc_files.include('README*')
|
||||
rdoc.rdoc_files.include('lib/*.rb')
|
||||
rdoc.rdoc_files.include('lib/**/*.rb')
|
||||
spec_file = File.expand_path("../mailcatcher.gemspec", __FILE__)
|
||||
spec = Gem::Specification.load(spec_file)
|
||||
|
||||
Gem::Package.build spec
|
||||
end
|
||||
|
||||
desc "Release Gem to RubyGems"
|
||||
task "release" => ["package"] do
|
||||
%x[gem push mailcatcher-#{MailCatcher::VERSION}.gem]
|
||||
end
|
||||
|
||||
require "rdoc/task"
|
||||
|
||||
RDoc::Task.new(:rdoc => "doc",:clobber_rdoc => "doc:clean", :rerdoc => "doc:force") do |rdoc|
|
||||
rdoc.title = "MailCatcher #{MailCatcher::VERSION}"
|
||||
rdoc.rdoc_dir = "doc"
|
||||
rdoc.main = "README.md"
|
||||
rdoc.rdoc_files.include "lib/**/*.rb"
|
||||
end
|
||||
|
||||
require "rake/testtask"
|
||||
|
||||
Rake::TestTask.new do |task|
|
||||
task.pattern = "spec/*_spec.rb"
|
||||
end
|
||||
|
||||
task :test => :assets
|
||||
|
||||
task :default => :test
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
|
@ -0,0 +1,340 @@
|
|||
#= require modernizr
|
||||
#= require jquery
|
||||
#= require date
|
||||
#= require favcount
|
||||
#= require flexie
|
||||
#= require keymaster
|
||||
|
||||
# Add a new jQuery selector expression which does a case-insensitive :contains
|
||||
jQuery.expr[":"].icontains = (a, i, m) ->
|
||||
(a.textContent ? a.innerText ? "").toUpperCase().indexOf(m[3].toUpperCase()) >= 0
|
||||
|
||||
class MailCatcher
|
||||
constructor: ->
|
||||
$("#messages tr").live "click", (e) =>
|
||||
e.preventDefault()
|
||||
@loadMessage $(e.currentTarget).attr("data-message-id")
|
||||
|
||||
$("input[name=search]").keyup (e) =>
|
||||
query = $.trim $(e.currentTarget).val()
|
||||
if query
|
||||
@searchMessages query
|
||||
else
|
||||
@clearSearch()
|
||||
|
||||
$("#message .views .format.tab a").live "click", (e) =>
|
||||
e.preventDefault()
|
||||
@loadMessageBody @selectedMessage(), $($(e.currentTarget).parent("li")).data("message-format")
|
||||
|
||||
$("#message iframe").load =>
|
||||
@decorateMessageBody()
|
||||
|
||||
$("#resizer").live
|
||||
mousedown: (e) =>
|
||||
e.preventDefault()
|
||||
$(window).bind events =
|
||||
mouseup: (e) =>
|
||||
e.preventDefault()
|
||||
$(window).unbind events
|
||||
mousemove: (e) =>
|
||||
e.preventDefault()
|
||||
@resizeTo e.clientY
|
||||
|
||||
@resizeToSaved()
|
||||
|
||||
$("nav.app .clear a").live "click", (e) =>
|
||||
e.preventDefault()
|
||||
if confirm "You will lose all your received messages.\n\nAre you sure you want to clear all messages?"
|
||||
$.ajax
|
||||
url: "/messages"
|
||||
type: "DELETE"
|
||||
success: =>
|
||||
@unselectMessage()
|
||||
@updateMessagesCount()
|
||||
error: ->
|
||||
alert "Error while clearing all messages."
|
||||
|
||||
$("nav.app .quit a").live "click", (e) =>
|
||||
e.preventDefault()
|
||||
if confirm "You will lose all your received messages.\n\nAre you sure you want to quit?"
|
||||
$.ajax
|
||||
type: "DELETE"
|
||||
success: ->
|
||||
location.replace $("body > header h1 a").attr("href")
|
||||
error: ->
|
||||
alert "Error while quitting."
|
||||
|
||||
@favcount = new Favcount($("""link[rel="icon"]""").attr("href"))
|
||||
|
||||
key "up", =>
|
||||
if @selectedMessage()
|
||||
@loadMessage $("#messages tr.selected").prev().data("message-id")
|
||||
else
|
||||
@loadMessage $("#messages tbody tr[data-message-id]:first").data("message-id")
|
||||
false
|
||||
|
||||
key "down", =>
|
||||
if @selectedMessage()
|
||||
@loadMessage $("#messages tr.selected").next().data("message-id")
|
||||
else
|
||||
@loadMessage $("#messages tbody tr[data-message-id]:first").data("message-id")
|
||||
false
|
||||
|
||||
key "⌘+up, ctrl+up", =>
|
||||
@loadMessage $("#messages tbody tr[data-message-id]:first").data("message-id")
|
||||
false
|
||||
|
||||
key "⌘+down, ctrl+down", =>
|
||||
@loadMessage $("#messages tbody tr[data-message-id]:last").data("message-id")
|
||||
false
|
||||
|
||||
key "left", =>
|
||||
@openTab @previousTab()
|
||||
false
|
||||
|
||||
key "right", =>
|
||||
@openTab @nextTab()
|
||||
false
|
||||
|
||||
key "backspace, delete", =>
|
||||
id = @selectedMessage()
|
||||
if id?
|
||||
$.ajax
|
||||
url: "/messages/" + id
|
||||
type: "DELETE"
|
||||
success: =>
|
||||
# callback will delete, this gets us into race conditions...
|
||||
#@deleteMessage [id]
|
||||
|
||||
error: ->
|
||||
alert "Error while removing message."
|
||||
false
|
||||
|
||||
@refresh()
|
||||
@subscribe()
|
||||
|
||||
# Only here because Safari's Date parsing *sucks*
|
||||
# We throw away the timezone, but you could use it for something...
|
||||
parseDateRegexp: /^(\d{4})[-\/\\](\d{2})[-\/\\](\d{2})(?:\s+|T)(\d{2})[:-](\d{2})[:-](\d{2})(?:([ +-]\d{2}:\d{2}|\s*\S+|Z?))?$/
|
||||
parseDate: (date) ->
|
||||
if match = @parseDateRegexp.exec(date)
|
||||
new Date match[1], match[2] - 1, match[3], match[4], match[5], match[6], 0
|
||||
|
||||
offsetTimeZone: (date) ->
|
||||
offset = Date.now().getTimezoneOffset() * 60000 #convert timezone difference to milliseconds
|
||||
date.setTime(date.getTime() - offset)
|
||||
date
|
||||
|
||||
formatDate: (date) ->
|
||||
date &&= @parseDate(date) if typeof(date) == "string"
|
||||
date &&= @offsetTimeZone(date)
|
||||
date &&= date.toString("dddd, d MMM yyyy h:mm:ss tt")
|
||||
|
||||
messagesCount: ->
|
||||
$("#messages tr").length - 1
|
||||
|
||||
updateMessagesCount: ->
|
||||
@favcount.set(@messagesCount())
|
||||
document.title = 'MailCatcher (' + @messagesCount() + ')'
|
||||
|
||||
tabs: ->
|
||||
$("#message ul").children(".tab")
|
||||
|
||||
getTab: (i) =>
|
||||
$(@tabs()[i])
|
||||
|
||||
selectedTab: =>
|
||||
@tabs().index($("#message li.tab.selected"))
|
||||
|
||||
openTab: (i) =>
|
||||
@getTab(i).children("a").click()
|
||||
|
||||
previousTab: (tab)=>
|
||||
i = if tab || tab is 0 then tab else @selectedTab() - 1
|
||||
i = @tabs().length - 1 if i < 0
|
||||
if @getTab(i).is(":visible")
|
||||
i
|
||||
else
|
||||
@previousTab(i - 1)
|
||||
|
||||
nextTab: (tab) =>
|
||||
i = if tab then tab else @selectedTab() + 1
|
||||
i = 0 if i > @tabs().length - 1
|
||||
if @getTab(i).is(":visible")
|
||||
i
|
||||
else
|
||||
@nextTab(i + 1)
|
||||
|
||||
haveMessage: (id) ->
|
||||
$("""#messages tbody tr[data-message-id="#{id}"]""").length > 0
|
||||
|
||||
selectedMessage: ->
|
||||
$("#messages tr.selected").data "message-id"
|
||||
|
||||
searchMessages: (query) ->
|
||||
selector = (":icontains('#{token}')" for token in query.split /\s+/).join("")
|
||||
$rows = $("#messages tbody tr")
|
||||
$rows.not(selector).hide()
|
||||
$rows.filter(selector).show()
|
||||
|
||||
clearSearch: ->
|
||||
$("#messages tbody tr").show()
|
||||
|
||||
addMessage: (message) ->
|
||||
row = $("<tr />").attr("data-message-id", message.id.toString())
|
||||
.append($("<td/>").text(message.sender or "No sender").toggleClass("blank", !message.sender))
|
||||
.append($("<td/>").text((message.recipients || []).join(", ") or "No receipients").toggleClass("blank", !message.recipients.length))
|
||||
.append($("<td/>").text(message.subject or "No subject").toggleClass("blank", !message.subject))
|
||||
.append($("<td/>").text(@formatDate(message.created_at)))
|
||||
if 'from_server' of message
|
||||
row = row.append($("<td/>").text(message.from_server or "Unknown").toggleClass("blank", !message.from_server))
|
||||
row.prependTo($("#messages tbody"))
|
||||
@updateMessagesCount()
|
||||
|
||||
deleteMessage: (ids) ->
|
||||
selectedId = @selectedMessage()
|
||||
for id in ids
|
||||
if @haveMessage id
|
||||
messageRow = $("""#messages tbody tr[data-message-id="#{id}"]""")
|
||||
if messageRow
|
||||
if id == selectedId
|
||||
switchTo = messageRow.next().data("message-id") || messageRow.prev().data("message-id")
|
||||
messageRow.remove()
|
||||
if switchTo && @haveMessage switchTo
|
||||
@loadMessage switchTo
|
||||
#else if selectedId
|
||||
# @unselectMessage()
|
||||
@updateMessagesCount()
|
||||
|
||||
scrollToRow: (row) ->
|
||||
relativePosition = row.offset().top - $("#messages").offset().top
|
||||
if relativePosition < 0
|
||||
$("#messages").scrollTop($("#messages").scrollTop() + relativePosition - 20)
|
||||
else
|
||||
overflow = relativePosition + row.height() - $("#messages").height()
|
||||
if overflow > 0
|
||||
$("#messages").scrollTop($("#messages").scrollTop() + overflow + 20)
|
||||
|
||||
unselectMessage: ->
|
||||
$("#messages tbody, #message .metadata dd").empty()
|
||||
$("#message .metadata .attachments").hide()
|
||||
$("#message iframe").attr("src", "about:blank")
|
||||
true
|
||||
|
||||
loadMessage: (id) ->
|
||||
id = id.id if id?.id?
|
||||
id ||= $("#messages tr.selected").attr "data-message-id"
|
||||
|
||||
if id?
|
||||
$("#messages tbody tr:not([data-message-id='#{id}'])").removeClass("selected")
|
||||
messageRow = $("#messages tbody tr[data-message-id='#{id}']")
|
||||
messageRow.addClass("selected")
|
||||
@scrollToRow(messageRow)
|
||||
|
||||
$.getJSON "/messages/#{id}.json", (message) =>
|
||||
$("#message .metadata dd.created_at").text(@formatDate message.created_at)
|
||||
$("#message .metadata dd.from").text(message.sender)
|
||||
$("#message .metadata dd.to").text((message.recipients || []).join(", "))
|
||||
$("#message .metadata dd.subject").text(message.subject)
|
||||
$("#message .views .tab.format").each (i, el) ->
|
||||
$el = $(el)
|
||||
format = $el.attr("data-message-format")
|
||||
if $.inArray(format, message.formats) >= 0
|
||||
$el.find("a").attr("href", "/messages/#{id}.#{format}")
|
||||
$el.show()
|
||||
else
|
||||
$el.hide()
|
||||
|
||||
if $("#message .views .tab.selected:not(:visible)").length
|
||||
$("#message .views .tab.selected").removeClass("selected")
|
||||
$("#message .views .tab:visible:first").addClass("selected")
|
||||
|
||||
if message.attachments.length
|
||||
$ul = $("<ul/>").appendTo($("#message .metadata dd.attachments").empty())
|
||||
|
||||
$.each message.attachments, (i, attachment) ->
|
||||
$ul.append($("<li>").append($("<a>").attr("href", attachment["href"]).addClass(attachment["type"].split("/", 1)[0]).addClass(attachment["type"].replace("/", "-")).text(attachment["filename"])))
|
||||
$("#message .metadata .attachments").show()
|
||||
else
|
||||
$("#message .metadata .attachments").hide()
|
||||
|
||||
$("#message .views .download a").attr("href", "/messages/#{id}.eml")
|
||||
|
||||
@loadMessageBody()
|
||||
|
||||
# XXX: These should probably cache their iframes for the current message now we're using a remote service:
|
||||
|
||||
loadMessageBody: (id, format) ->
|
||||
id ||= @selectedMessage()
|
||||
format ||= $("#message .views .tab.format.selected").attr("data-message-format")
|
||||
format ||= "html"
|
||||
|
||||
$("""#message .views .tab[data-message-format="#{format}"]:not(.selected)""").addClass("selected")
|
||||
$("""#message .views .tab:not([data-message-format="#{format}"]).selected""").removeClass("selected")
|
||||
|
||||
if id?
|
||||
$("#message iframe").attr("src", "/messages/#{id}.#{format}")
|
||||
|
||||
decorateMessageBody: ->
|
||||
format = $("#message .views .tab.format.selected").attr("data-message-format")
|
||||
|
||||
switch format
|
||||
when "html"
|
||||
body = $("#message iframe").contents().find("body")
|
||||
$("a", body).attr("target", "_blank")
|
||||
when "plain"
|
||||
message_iframe = $("#message iframe").contents()
|
||||
text = message_iframe.text()
|
||||
text = text.replace(/((http|ftp|https):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-\.,@?^=%&:\/~\+#]*[\w\-\@?^=%&\/~\+#])?)/g, """<a href="$1" target="_blank">$1</a>""")
|
||||
text = text.replace(/\n/g, "<br/>")
|
||||
message_iframe.find("html").html("<html><body>#{text}</html></body>")
|
||||
|
||||
refresh: ->
|
||||
$.getJSON "/messages", (messages) =>
|
||||
all_msg_ids = []
|
||||
$.each messages, (i, message) =>
|
||||
id = message.id if message.id?
|
||||
all_msg_ids.push(id)
|
||||
unless @haveMessage id
|
||||
@addMessage message
|
||||
ids_to_remove = []
|
||||
$.each $("#messages tbody tr[data-message-id]"), (i, row) =>
|
||||
existing_id = $(row).data("message-id")
|
||||
unless existing_id in all_msg_ids
|
||||
ids_to_remove.push(existing_id)
|
||||
@deleteMessage ids_to_remove
|
||||
|
||||
subscribe: ->
|
||||
if WebSocket?
|
||||
@subscribeWebSocket()
|
||||
else
|
||||
@subscribePoll()
|
||||
|
||||
subscribeWebSocket: ->
|
||||
secure = window.location.protocol is "https:"
|
||||
protocol = if secure then "wss" else "ws"
|
||||
@websocket = new WebSocket("#{protocol}://#{window.location.host}/messages")
|
||||
@websocket.onmessage = (event) =>
|
||||
message = $.parseJSON event.data
|
||||
if 'id' of message
|
||||
@addMessage message
|
||||
else
|
||||
@deleteMessage message
|
||||
|
||||
subscribePoll: ->
|
||||
unless @refreshInterval?
|
||||
@refreshInterval = setInterval (=> @refresh()), 1000
|
||||
|
||||
resizeToSavedKey: "mailcatcherSeparatorHeight"
|
||||
|
||||
resizeTo: (height) ->
|
||||
$("#messages").css
|
||||
height: height - $("#messages").offset().top
|
||||
window.localStorage?.setItem(@resizeToSavedKey, height)
|
||||
|
||||
resizeToSaved: ->
|
||||
height = parseInt(window.localStorage?.getItem(@resizeToSavedKey))
|
||||
unless isNaN height
|
||||
@resizeTo height
|
||||
|
||||
$ -> window.MailCatcher = new MailCatcher
|
|
@ -0,0 +1,257 @@
|
|||
@import "compass"
|
||||
@import "compass/reset"
|
||||
|
||||
html, body
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
body
|
||||
+establish-baseline(12px)
|
||||
display: -moz-box
|
||||
display: -webkit-box
|
||||
display: box
|
||||
-moz-box-orient: vertical
|
||||
-webkit-box-orient: vertical
|
||||
box-orient: vertical
|
||||
background: #eee
|
||||
color: #000
|
||||
font-size: 12px
|
||||
font-family: Helvetica, sans-serif
|
||||
|
||||
&.iframe
|
||||
background: #fff
|
||||
|
||||
h1
|
||||
font-size: 1.3em
|
||||
margin: 12px
|
||||
p, form
|
||||
margin: 0 12px 12px 12px
|
||||
line-height: 1.25
|
||||
.loading
|
||||
color: #666
|
||||
margin-left: 0.5em
|
||||
|
||||
.button
|
||||
padding: .5em 1em
|
||||
border: 1px solid #ccc
|
||||
+border-radius(2px)
|
||||
+background(linear-gradient(color-stops(#f4f4f4, #ececec)), #ececec)
|
||||
color: #666
|
||||
+text-shadow(1px 1px 0 #fff)
|
||||
text-decoration: none
|
||||
&:hover, &:focus
|
||||
border-color: #999
|
||||
border-bottom-color: #666
|
||||
+background(linear-gradient(color-stops(#eee, #ddd)), #ddd)
|
||||
color: #333
|
||||
text-decoration: none
|
||||
&:active, &.active
|
||||
border-color: #666
|
||||
border-bottom-color: #999
|
||||
+background(linear-gradient(color-stops(#ddd, #eee)), #eee)
|
||||
color: #333
|
||||
text-decoration: none
|
||||
+text-shadow(-1px -1px 0 #eee)
|
||||
|
||||
body > header
|
||||
+clearfix
|
||||
border-bottom: 1px solid #ccc
|
||||
h1
|
||||
float: left
|
||||
margin-left: 6px
|
||||
padding: 6px
|
||||
padding-left: 30px
|
||||
background: url(/assets/logo.png) left no-repeat
|
||||
font-size: 18px
|
||||
font-weight: bold
|
||||
a
|
||||
color: black
|
||||
text-decoration: none
|
||||
+text-shadow(0 1px 0 white)
|
||||
+transition(0.1s ease)
|
||||
&:hover
|
||||
color: #4183C4
|
||||
nav
|
||||
&.project
|
||||
float: left
|
||||
&.app
|
||||
float: right
|
||||
border-left: 1px solid #ccc
|
||||
li
|
||||
display: block
|
||||
float: left
|
||||
border-left: 1px solid #fff
|
||||
border-right: 1px solid #ccc
|
||||
input
|
||||
margin: 6px
|
||||
a
|
||||
display: block
|
||||
padding: 10px
|
||||
text-decoration: none
|
||||
+text-shadow(0 1px 0 white)
|
||||
+background(linear-gradient(color-stops(#f4f4f4, #ececec)), #ececec)
|
||||
color: #666
|
||||
+text-shadow(1px 1px 0 #fff)
|
||||
text-decoration: none
|
||||
&:hover, &:focus
|
||||
+background(linear-gradient(color-stops(#eee, #ddd)), #ddd)
|
||||
color: #333
|
||||
text-decoration: none
|
||||
&:active, &.active
|
||||
+background(linear-gradient(color-stops(#ddd, #eee)), #eee)
|
||||
color: #333
|
||||
text-decoration: none
|
||||
+text-shadow(-1px -1px 0 #eee)
|
||||
|
||||
#messages
|
||||
width: 100%
|
||||
height: 10em
|
||||
// Two rows with padding:
|
||||
min-height: (2 * (1em + .5em))
|
||||
overflow: auto
|
||||
background: #fff
|
||||
border-top: 1px solid #fff
|
||||
table
|
||||
+clearfix
|
||||
width: 100%
|
||||
thead tr
|
||||
background: #eee
|
||||
color: #333
|
||||
th
|
||||
padding: .25em
|
||||
font-weight: bold
|
||||
color: #666
|
||||
+text-shadow(0 1px 0 white)
|
||||
tbody tr
|
||||
cursor: pointer
|
||||
+transition(0.1s ease)
|
||||
color: #333
|
||||
&:hover
|
||||
color: #000
|
||||
&:nth-child(even)
|
||||
background: #f0f0f0
|
||||
&.selected
|
||||
background: Highlight
|
||||
color: HighlightText
|
||||
td
|
||||
padding: .25em
|
||||
&.blank
|
||||
color: #666
|
||||
font-style: italic
|
||||
#resizer
|
||||
padding-bottom: 5px
|
||||
cursor: ns-resize
|
||||
.ruler
|
||||
border-top: 1px solid #ccc
|
||||
border-bottom: 1px solid #fff
|
||||
|
||||
#message
|
||||
display: -moz-box
|
||||
display: -webkit-box
|
||||
display: box
|
||||
-moz-box-orient: vertical
|
||||
-webkit-box-orient: vertical
|
||||
box-orient: vertical
|
||||
-moz-box-flex: 1
|
||||
-webkit-box-flex: 1
|
||||
box-flex: 1
|
||||
> header
|
||||
+clearfix
|
||||
.metadata
|
||||
+clearfix
|
||||
padding: .5em
|
||||
// This is already padded by resizer
|
||||
padding-top: 0
|
||||
dt, dd
|
||||
padding: .25em
|
||||
dt
|
||||
float: left
|
||||
clear: left
|
||||
width: 8em
|
||||
margin-right: .5em
|
||||
text-align: right
|
||||
font-weight: bold
|
||||
color: #666
|
||||
+text-shadow(0 1px 0 white)
|
||||
dd.subject
|
||||
font-weight: bold
|
||||
.attachments
|
||||
display: none
|
||||
ul
|
||||
display: inline
|
||||
li
|
||||
+inline-block
|
||||
margin-right: .5em
|
||||
.views
|
||||
ul
|
||||
padding: 0 .5em
|
||||
border-bottom: 1px solid #ccc
|
||||
.tab
|
||||
+inline-block
|
||||
a
|
||||
+inline-block
|
||||
padding: .5em
|
||||
border: 1px solid #ccc
|
||||
background: #ddd
|
||||
color: #333
|
||||
border-width: 1px 1px 0 1px
|
||||
cursor: pointer
|
||||
+text-shadow(0 1px 0 #eeeeee)
|
||||
text-decoration: none
|
||||
&:not(.selected):hover a
|
||||
background-color: #eee
|
||||
&.selected a
|
||||
background: #fff
|
||||
color: #000
|
||||
height: 13px
|
||||
+box-shadow(1px 1px 0 #ccc)
|
||||
margin-bottom: -2px
|
||||
cursor: default
|
||||
.action
|
||||
+inline-block
|
||||
float: right
|
||||
margin: 0 .25em
|
||||
|
||||
.fractal-analysis
|
||||
margin: 12px 0
|
||||
.report-intro
|
||||
font-weight: bold
|
||||
&.valid
|
||||
color: #090
|
||||
&.invalid
|
||||
color: #c33
|
||||
code
|
||||
font-family: Monaco, "Courier New", Courier, monospace
|
||||
background-color: #f8f8ff
|
||||
color: #444
|
||||
padding: 0 .2em
|
||||
border: 1px solid #dedede
|
||||
ul
|
||||
margin: 1em 0 1em 1em
|
||||
list-style-type: square
|
||||
ol
|
||||
margin: 1em 0 1em 2em
|
||||
list-style-type: decimal
|
||||
ul li, ol li
|
||||
display: list-item
|
||||
margin: .5em 0 .5em 1em
|
||||
.error-intro
|
||||
strong
|
||||
font-weight: bold
|
||||
.unsupported-clients
|
||||
dt
|
||||
padding-left: 1em
|
||||
dd
|
||||
padding-left: 2em
|
||||
ul
|
||||
li
|
||||
display: list-item
|
||||
|
||||
iframe
|
||||
display: -moz-box
|
||||
display: -webkit-box
|
||||
display: box
|
||||
-moz-box-flex: 1
|
||||
-webkit-box-flex: 1
|
||||
box-flex: 1
|
||||
background: #fff
|
|
@ -0,0 +1,71 @@
|
|||
#!/usr/bin/env ruby
|
||||
|
||||
begin
|
||||
require 'mail'
|
||||
rescue LoadError
|
||||
require 'rubygems'
|
||||
require 'mail'
|
||||
end
|
||||
|
||||
require 'optparse'
|
||||
|
||||
options = {:smtp_ip => '127.0.0.1', :smtp_port => 1025}
|
||||
|
||||
OptionParser.new do |parser|
|
||||
parser.banner = <<-BANNER.gsub /^ +/, ""
|
||||
Usage: catchmail [options] [recipient ...]
|
||||
sendmail-like interface to forward mail to MailCatcher.
|
||||
BANNER
|
||||
|
||||
parser.on('--ip IP') do |ip|
|
||||
options[:smtp_ip] = ip
|
||||
end
|
||||
|
||||
parser.on('--smtp-ip IP', 'Set the ip address of the smtp server') do |ip|
|
||||
options[:smtp_ip] = ip
|
||||
end
|
||||
|
||||
parser.on('--smtp-port PORT', Integer, 'Set the port of the smtp server') do |port|
|
||||
options[:smtp_port] = port
|
||||
end
|
||||
|
||||
parser.on('-f FROM', 'Set the sending address') do |from|
|
||||
options[:from] = from
|
||||
end
|
||||
|
||||
parser.on('-oi', 'Ignored option -oi') do |ignored|
|
||||
end
|
||||
parser.on('-t', 'Ignored option -t') do |ignored|
|
||||
end
|
||||
parser.on('-q', 'Ignored option -q') do |ignored|
|
||||
end
|
||||
|
||||
parser.on('-x', '--no-exit', 'Can\'t exit from the application') do
|
||||
options[:no_exit] = true
|
||||
end
|
||||
|
||||
parser.on('-h', '--help', 'Display this help information') do
|
||||
puts parser
|
||||
exit!
|
||||
end
|
||||
end.parse!
|
||||
|
||||
Mail.defaults do
|
||||
delivery_method :smtp,
|
||||
:address => options[:smtp_ip],
|
||||
:port => options[:smtp_port]
|
||||
end
|
||||
|
||||
message = Mail.new($stdin.read)
|
||||
|
||||
message.return_path = options[:from] if options[:from]
|
||||
|
||||
ARGV.each do |recipient|
|
||||
if message.to.nil?
|
||||
message.to = recipient
|
||||
else
|
||||
message.to << recipient
|
||||
end
|
||||
end
|
||||
|
||||
message.deliver
|
|
@ -1,44 +1,5 @@
|
|||
#!/usr/bin/env ruby
|
||||
|
||||
$: << File.expand_path(File.join(File.dirname(__FILE__), '../lib'))
|
||||
|
||||
require 'mail_catcher'
|
||||
require 'optparse'
|
||||
|
||||
options = {}
|
||||
|
||||
OptionParser.new do |opts|
|
||||
opts.banner = 'Usage: mailcatcher [options]'
|
||||
|
||||
options[:smtp_ip] = '127.0.0.1'
|
||||
opts.on('--smtp-ip IP', 'Set the ip address of the smtp server') do |ip|
|
||||
options[:smtp_ip] = ip
|
||||
end
|
||||
|
||||
options[:smtp_port] = 1025
|
||||
opts.on('--smtp-port PORT', Integer, 'Set the port of the smtp server') do |port|
|
||||
options[:smtp_port] = port
|
||||
end
|
||||
|
||||
options[:http_ip] = '127.0.0.1'
|
||||
opts.on('--http-ip IP', 'Set the ip address of the http server') do |ip|
|
||||
options[:http_ip] = ip
|
||||
end
|
||||
|
||||
options[:http_port] = 1080
|
||||
opts.on('--http-port PORT', Integer, 'Set the port address of the http server') do |port|
|
||||
options[:http_port] = port
|
||||
end
|
||||
|
||||
options[:verbose] = false
|
||||
opts.on('-v', '--verbose', 'Be more verbose') do
|
||||
options[:verbose] = true
|
||||
end
|
||||
|
||||
opts.on('-h', '--help', 'Display this help information') do
|
||||
puts opts
|
||||
exit!
|
||||
end
|
||||
end.parse!
|
||||
|
||||
MailCatcher.run(options)
|
||||
MailCatcher.run!
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
To: Blah <blah@blah.com>
|
||||
From: Me <me@sj26.com>
|
||||
Subject: Test Attachment Mail
|
||||
Mime-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary=BOUNDARY--198849662
|
||||
|
||||
Header
|
||||
|
||||
--BOUNDARY--198849662
|
||||
Content-Type: text/plain
|
||||
Content-Disposition: attachment; filename=blah.txt
|
||||
|
||||
Plain text mail
|
||||
|
||||
--BOUNDARY--198849662
|
||||
Content-Type: text/html
|
||||
|
||||
<html><body><em>HTML</em> mail</body></html>
|
||||
|
||||
--BOUNDARY--198849662--
|
|
@ -0,0 +1,39 @@
|
|||
Date: Wed, 08 Jan 2014 06:52:20 +0000
|
||||
From: survey@place.com
|
||||
Reply-To: Support support@someplace.com
|
||||
To: asdfasdf@asdfasdf.com
|
||||
Message-ID:
|
||||
Subject: Subject
|
||||
Mime-Version: 1.0
|
||||
Content-Type: text/html;
|
||||
charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
|
||||
|
||||
First Name:Asdf
|
||||
|
||||
|
||||
|
||||
Last Name:Asdf
|
||||
|
||||
|
||||
|
||||
Full Name:Asdf Asdf
|
||||
|
||||
|
||||
|
||||
Formal Name:Mr. Asdf Asdf
|
||||
|
||||
|
||||
|
||||
Company Name:Some Company
|
||||
|
||||
|
||||
|
||||
URL: http://localhost:3000/surveys/er2014/en
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
To: Blah <blah@blah.com>
|
||||
From: Me <me@sj26.com>
|
||||
Subject: Whatever
|
||||
Content-Type: text/plain
|
||||
|
||||
Plain text mail
|
||||
|
||||
With some dot lines:
|
||||
|
||||
.
|
||||
|
||||
...
|
||||
|
||||
Done.
|
|
@ -0,0 +1,10 @@
|
|||
To: Blah <blah@blah.com>
|
||||
From: Me <me@sj26.com>
|
||||
Subject: Test HTML Mail
|
||||
Content-Type: text/html
|
||||
|
||||
<html>
|
||||
<body>
|
||||
Yo, you <em>slimey scoundrel</em>.
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,5 @@
|
|||
To: Blah <blah@blah.com>
|
||||
From: Me <me@sj26.com>
|
||||
Subject: Test mail
|
||||
|
||||
Test mail.
|
|
@ -0,0 +1,19 @@
|
|||
To: Blah <blah@blah.com>
|
||||
From: Me <me@sj26.com>
|
||||
Subject: Test Multipart Mail
|
||||
Mime-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary=BOUNDARY--198849662
|
||||
|
||||
Header
|
||||
|
||||
--BOUNDARY--198849662
|
||||
Content-Type: text/plain
|
||||
|
||||
Plain text mail
|
||||
|
||||
--BOUNDARY--198849662
|
||||
Content-Type: text/html
|
||||
|
||||
<html><body><em>HTML</em> mail</body></html>
|
||||
|
||||
--BOUNDARY--198849662--
|
|
@ -0,0 +1,6 @@
|
|||
To: Blah <blah@blah.com>
|
||||
From: Me <me@sj26.com>
|
||||
Subject: Plain mail
|
||||
Content-Type: text/plain
|
||||
|
||||
Here's some text
|
|
@ -0,0 +1,18 @@
|
|||
To: Blah <blah@blah.com>
|
||||
From: Me <me@sj26.com>
|
||||
Subject: Test quoted-printable HTML mail
|
||||
Mime-Version: 1.0
|
||||
Content-Type: text/html;
|
||||
charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<html class=3D"slim"></html>
|
||||
<p>
|
||||
Thank you for allowing Grand Rounds to provide a test case that ma=
|
||||
y demonstrate a limitation in MailCatcher. Open source makes dev good=
|
||||
|
||||
</p>
|
||||
<p>
|
||||
You can access an error at <a href=3D"http://localhost:9876/big/long/d50=
|
||||
243b933ddd425">here</a>
|
||||
</p>=
|
|
@ -0,0 +1,6 @@
|
|||
To: Blah <blah@blah.com>
|
||||
From: Me <me@sj26.com>
|
||||
Subject: Test mail
|
||||
Content-Type: application/x-weird
|
||||
|
||||
Weird stuff~
|
|
@ -0,0 +1,10 @@
|
|||
To: Blah <blah@blah.com>
|
||||
From: Me <me@sj26.com>
|
||||
Subject: Test XHTML Mail
|
||||
Content-Type: application/xhtml+xml
|
||||
|
||||
<html>
|
||||
<body>
|
||||
Yo, you <em>slimey scoundrel</em>.
|
||||
</body>
|
||||
</html>
|
|
@ -1,26 +1,262 @@
|
|||
require 'eventmachine'
|
||||
require 'thin'
|
||||
# Apparently rubygems won't activate these on its own, so here we go. Let's
|
||||
# repeat the invention of Bundler all over again.
|
||||
gem "eventmachine", "1.0.9.1"
|
||||
gem "mail", "~> 2.3"
|
||||
gem "rack", "~> 1.5"
|
||||
gem "sinatra", "~> 1.2"
|
||||
gem "sqlite3", "~> 1.3"
|
||||
gem "thin", "~> 1.5.0"
|
||||
gem "skinny", "~> 0.2.3"
|
||||
|
||||
require "open3"
|
||||
require "optparse"
|
||||
require "rbconfig"
|
||||
|
||||
require "eventmachine"
|
||||
require "thin"
|
||||
|
||||
module EventMachine
|
||||
# Monkey patch fix for 10deb4
|
||||
# See https://github.com/eventmachine/eventmachine/issues/569
|
||||
def self.reactor_running?
|
||||
(@reactor_running || false)
|
||||
end
|
||||
end
|
||||
|
||||
require "mail_catcher/events"
|
||||
require "mail_catcher/mail"
|
||||
require "mail_catcher/smtp"
|
||||
require "mail_catcher/web"
|
||||
require "mail_catcher/version"
|
||||
|
||||
module MailCatcher extend self
|
||||
def which?(command)
|
||||
ENV["PATH"].split(File::PATH_SEPARATOR).any? do |directory|
|
||||
File.executable?(File.join(directory, command.to_s))
|
||||
end
|
||||
end
|
||||
|
||||
def mac?
|
||||
RbConfig::CONFIG['host_os'] =~ /darwin/
|
||||
end
|
||||
|
||||
def windows?
|
||||
RbConfig::CONFIG['host_os'] =~ /mswin|mingw/
|
||||
end
|
||||
|
||||
def macruby?
|
||||
mac? and const_defined? :MACRUBY_VERSION
|
||||
end
|
||||
|
||||
def browseable?
|
||||
windows? or which? "open"
|
||||
end
|
||||
|
||||
def browse url
|
||||
if windows?
|
||||
system "start", "/b", url
|
||||
elsif which? "open"
|
||||
system "open", url
|
||||
end
|
||||
end
|
||||
|
||||
def log_exception(message, context, exception)
|
||||
gems_paths = (Gem.path | [Gem.default_dir]).map { |path| Regexp.escape(path) }
|
||||
gems_regexp = %r{(?:#{gems_paths.join('|')})/gems/([^/]+)-([\w.]+)/(.*)}
|
||||
gems_replace = '\1 (\2) \3'
|
||||
|
||||
puts "*** #{message}: #{context.inspect}"
|
||||
puts " Exception: #{exception}"
|
||||
puts " Backtrace:", *exception.backtrace.map { |line| " #{line.sub(gems_regexp, gems_replace)}" }
|
||||
puts " Please submit this as an issue at http://github.com/sj26/mailcatcher/issues"
|
||||
end
|
||||
|
||||
@@defaults = {
|
||||
:smtp_ip => '127.0.0.1',
|
||||
:smtp_port => '1025',
|
||||
:http_ip => '127.0.0.1',
|
||||
:http_port => '1080',
|
||||
:verbose => false,
|
||||
:daemon => !windows?,
|
||||
:browse => false,
|
||||
:quit => true,
|
||||
:sqlite_db => ':memory:',
|
||||
:delete_older_than => nil,
|
||||
:keep_num_emails => nil,
|
||||
:show_from_server => false,
|
||||
}
|
||||
|
||||
def options
|
||||
@@options
|
||||
end
|
||||
|
||||
def quittable?
|
||||
options[:quit]
|
||||
end
|
||||
|
||||
def show_from_server?
|
||||
options[:show_from_server]
|
||||
end
|
||||
|
||||
def parse! arguments=ARGV, defaults=@defaults
|
||||
@@defaults.dup.tap do |options|
|
||||
OptionParser.new do |parser|
|
||||
parser.banner = "Usage: mailcatcher [options]"
|
||||
parser.version = VERSION
|
||||
|
||||
parser.on("--ip IP", "Set the ip address of both servers") do |ip|
|
||||
options[:smtp_ip] = options[:http_ip] = ip
|
||||
end
|
||||
|
||||
parser.on("--smtp-ip IP", "Set the ip address of the smtp server") do |ip|
|
||||
options[:smtp_ip] = ip
|
||||
end
|
||||
|
||||
parser.on("--smtp-port PORT", Integer, "Set the port of the smtp server") do |port|
|
||||
options[:smtp_port] = port
|
||||
end
|
||||
|
||||
parser.on("--http-ip IP", "Set the ip address of the http server") do |ip|
|
||||
options[:http_ip] = ip
|
||||
end
|
||||
|
||||
parser.on("--http-port PORT", Integer, "Set the port address of the http server") do |port|
|
||||
options[:http_port] = port
|
||||
end
|
||||
|
||||
parser.on("--no-quit", "Don't allow quitting the process") do
|
||||
options[:quit] = false
|
||||
end
|
||||
|
||||
parser.on("--show-from-server", "Show From Server column") do
|
||||
options[:show_from_server] = true
|
||||
end
|
||||
|
||||
parser.on("--sqlite-db PATH", "Set the path to the sqlite database, default in-memory only") do |db|
|
||||
options[:sqlite_db] = db
|
||||
end
|
||||
|
||||
parser.on("--delete-older-than TIME_MODIFIER", "On mail receipt, delete all mail older than this, examples: '-5 minutes', '-2 days'") do |db|
|
||||
options[:delete_older_than] = db
|
||||
end
|
||||
|
||||
parser.on("--keep-num-emails MAX_EMAILS", "Only keep this many emails, deletes oldest") do |db|
|
||||
options[:keep_num_emails] = db
|
||||
end
|
||||
|
||||
if mac?
|
||||
parser.on("--[no-]growl") do |growl|
|
||||
puts "Growl is no longer supported"
|
||||
exit -2
|
||||
end
|
||||
end
|
||||
|
||||
unless windows?
|
||||
parser.on('-f', '--foreground', 'Run in the foreground') do
|
||||
options[:daemon] = false
|
||||
end
|
||||
end
|
||||
|
||||
if browseable?
|
||||
parser.on('-b', '--browse', 'Open web browser') do
|
||||
options[:browse] = true
|
||||
end
|
||||
end
|
||||
|
||||
parser.on('-v', '--verbose', 'Be more verbose') do
|
||||
options[:verbose] = true
|
||||
end
|
||||
|
||||
parser.on('-h', '--help', 'Display this help information') do
|
||||
puts parser
|
||||
exit
|
||||
end
|
||||
end.parse!
|
||||
end
|
||||
end
|
||||
|
||||
def run! options=nil
|
||||
# If we are passed options, fill in the blanks
|
||||
options &&= options.reverse_merge @@defaults
|
||||
# Otherwise, parse them from ARGV
|
||||
options ||= parse!
|
||||
|
||||
# Stash them away for later
|
||||
@@options = options
|
||||
|
||||
# If we're running in the foreground sync the output.
|
||||
unless options[:daemon]
|
||||
$stdout.sync = $stderr.sync = true
|
||||
end
|
||||
|
||||
module MailCatcher
|
||||
autoload :Events, 'mail_catcher/events'
|
||||
autoload :Mail, 'mail_catcher/mail'
|
||||
autoload :Smtp, 'mail_catcher/smtp'
|
||||
autoload :Web, 'mail_catcher/web'
|
||||
|
||||
def self.run(options = {})
|
||||
options[:smtp_ip] ||= '127.0.0.1'
|
||||
options[:smtp_port] ||= 1025
|
||||
options[:http_ip] ||= '127.0.0.1'
|
||||
options[:http_port] ||= 1080
|
||||
|
||||
puts "Starting MailCatcher"
|
||||
puts "==> smtp://#{options[:smtp_ip]}:#{options[:smtp_port]}"
|
||||
puts "==> http://#{options[:http_ip]}:#{options[:http_port]}"
|
||||
|
||||
Thin::Logging.silent = true
|
||||
Thin::Logging.silent = (ENV["MAILCATCHER_ENV"] != "development")
|
||||
|
||||
# One EventMachine loop...
|
||||
EventMachine.run do
|
||||
EventMachine.start_server options[:smtp_ip], options[:smtp_port], Smtp
|
||||
Thin::Server.start options[:http_ip], options[:http_port], Web
|
||||
# Set up an SMTP server to run within EventMachine
|
||||
rescue_port options[:smtp_port] do
|
||||
EventMachine.start_server options[:smtp_ip], options[:smtp_port], Smtp
|
||||
puts "==> #{smtp_url}"
|
||||
end
|
||||
|
||||
# Let Thin set itself up inside our EventMachine loop
|
||||
# (Skinny/WebSockets just works on the inside)
|
||||
rescue_port options[:http_port] do
|
||||
Thin::Server.start(options[:http_ip], options[:http_port], Web)
|
||||
puts "==> #{http_url}"
|
||||
end
|
||||
|
||||
# Open the web browser before detatching console
|
||||
if options[:browse]
|
||||
EventMachine.next_tick do
|
||||
browse http_url
|
||||
end
|
||||
end
|
||||
|
||||
# Daemonize, if we should, but only after the servers have started.
|
||||
if options[:daemon]
|
||||
EventMachine.next_tick do
|
||||
if quittable?
|
||||
puts "*** MailCatcher runs as a daemon by default. Go to the web interface to quit."
|
||||
else
|
||||
puts "*** MailCatcher is now running as a daemon that cannot be quit."
|
||||
end
|
||||
Process.daemon
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def quit!
|
||||
EventMachine.next_tick { EventMachine.stop_event_loop }
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def smtp_url
|
||||
"smtp://#{@@options[:smtp_ip]}:#{@@options[:smtp_port]}"
|
||||
end
|
||||
|
||||
def http_url
|
||||
"http://#{@@options[:http_ip]}:#{@@options[:http_port]}"
|
||||
end
|
||||
|
||||
def rescue_port port
|
||||
begin
|
||||
yield
|
||||
|
||||
# XXX: EventMachine only spits out RuntimeError with a string description
|
||||
rescue RuntimeError
|
||||
if $!.to_s =~ /\bno acceptor\b/
|
||||
puts "~~> ERROR: Something's using port #{port}. Are you already running MailCatcher?"
|
||||
puts "==> #{smtp_url}"
|
||||
puts "==> #{http_url}"
|
||||
exit -1
|
||||
else
|
||||
raise
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
require 'eventmachine'
|
||||
require "eventmachine"
|
||||
|
||||
module MailCatcher
|
||||
module Events
|
||||
MessageAdded = EventMachine::Channel.new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,119 +1,200 @@
|
|||
require 'mail'
|
||||
require 'sqlite3'
|
||||
require 'eventmachine'
|
||||
require "eventmachine"
|
||||
require "json"
|
||||
require "mail"
|
||||
require "sqlite3"
|
||||
|
||||
module MailCatcher::Mail
|
||||
class << self
|
||||
def db
|
||||
@@__db ||= begin
|
||||
SQLite3::Database.new(':memory:', :results_as_hash => true, :type_translation => true).tap do |db|
|
||||
db.execute(<<-SQL)
|
||||
CREATE TABLE message (
|
||||
id INTEGER PRIMARY KEY ASC,
|
||||
sender TEXT,
|
||||
recipients TEXT,
|
||||
subject TEXT,
|
||||
source BLOB,
|
||||
size TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_DATETIME
|
||||
)
|
||||
SQL
|
||||
db.execute(<<-SQL)
|
||||
CREATE TABLE message_part (
|
||||
id INTEGER PRIMARY KEY ASC,
|
||||
message_id INTEGER NOT NULL,
|
||||
cid TEXT,
|
||||
type TEXT,
|
||||
is_attachment INTEGER,
|
||||
filename TEXT,
|
||||
charset TEXT,
|
||||
body BLOB,
|
||||
size INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_DATETIME
|
||||
)
|
||||
SQL
|
||||
end
|
||||
module MailCatcher::Mail extend self
|
||||
def db
|
||||
@__db ||= begin
|
||||
SQLite3::Database.new(MailCatcher.options[:sqlite_db], :type_translation => true).tap do |db|
|
||||
db.execute(<<-SQL)
|
||||
CREATE TABLE IF NOT EXISTS message (
|
||||
id INTEGER PRIMARY KEY ASC,
|
||||
sender TEXT,
|
||||
recipients TEXT,
|
||||
subject TEXT,
|
||||
from_server TEXT,
|
||||
source BLOB,
|
||||
size TEXT,
|
||||
type TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_DATETIME
|
||||
)
|
||||
SQL
|
||||
db.execute(<<-SQL)
|
||||
CREATE TABLE IF NOT EXISTS message_part (
|
||||
id INTEGER PRIMARY KEY ASC,
|
||||
message_id INTEGER NOT NULL,
|
||||
cid TEXT,
|
||||
type TEXT,
|
||||
is_attachment INTEGER,
|
||||
filename TEXT,
|
||||
charset TEXT,
|
||||
body BLOB,
|
||||
size INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_DATETIME
|
||||
)
|
||||
SQL
|
||||
end
|
||||
end
|
||||
|
||||
def add_message(message)
|
||||
@@add_message_query ||= db.prepare("INSERT INTO message (sender, recipients, subject, source, size, created_at) VALUES (?, ?, ?, ?, ?, datetime('now'))")
|
||||
|
||||
mail = Mail.new(message[:source])
|
||||
result = @@add_message_query.execute(message[:sender], message[:recipients].inspect, mail.subject, message[:source], message[:source].length)
|
||||
message_id = db.last_insert_row_id
|
||||
(mail.all_parts || [mail]).each do |part|
|
||||
body = part.body.to_s
|
||||
add_message_part(message_id, part.cid, part.mime_type || 'text/plain', part.attachment? ? 1 : 0, part.filename, part.charset, body, body.length)
|
||||
end
|
||||
|
||||
EventMachine.next_tick do
|
||||
message = MailCatcher::Mail.message message_id
|
||||
MailCatcher::Events::MessageAdded.push message
|
||||
end
|
||||
end
|
||||
|
||||
def add_message_part(*args)
|
||||
@@add_message_part_query ||= db.prepare "INSERT INTO message_part (message_id, cid, type, is_attachment, filename, charset, body, size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))"
|
||||
@@add_message_part_query.execute(*args)
|
||||
end
|
||||
|
||||
def latest_created_at
|
||||
@@latest_created_at_query ||= db.prepare "SELECT created_at FROM message ORDER BY created_at DESC LIMIT 1"
|
||||
@@latest_created_at_query.execute.next
|
||||
end
|
||||
|
||||
def messages
|
||||
@@messages_query ||= db.prepare "SELECT id, sender, recipients, subject, size, created_at FROM message ORDER BY created_at DESC"
|
||||
@@messages_query.execute.to_a
|
||||
end
|
||||
|
||||
def message(id)
|
||||
@@message_query ||= db.prepare "SELECT * FROM message WHERE id = ? LIMIT 1"
|
||||
@@message_query.execute(id).next
|
||||
end
|
||||
|
||||
def message_has_html?(id)
|
||||
@@message_has_html_query ||= db.prepare "SELECT 1 FROM message_part WHERE message_id = ? AND is_attachment = 0 AND type = 'text/html' LIMIT 1"
|
||||
!!@@message_has_html_query.execute(id).next
|
||||
end
|
||||
|
||||
def message_has_plain?(id)
|
||||
@@message_has_html_query ||= db.prepare "SELECT 1 FROM message_part WHERE message_id = ? AND is_attachment = 0 AND type = 'text/plain' LIMIT 1"
|
||||
!!@@message_has_html_query.execute(id).next
|
||||
end
|
||||
|
||||
def message_parts(id)
|
||||
@@message_parts_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? ORDER BY filename ASC"
|
||||
@@message_parts_query.execute(id).to_a
|
||||
end
|
||||
|
||||
def message_attachments(id)
|
||||
@@message_parts_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? AND is_attachment = 1 ORDER BY filename ASC"
|
||||
@@message_parts_query.execute(id).to_a
|
||||
end
|
||||
|
||||
def message_part(message_id, part_id)
|
||||
@@message_part_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND id = ? LIMIT 1"
|
||||
@@message_part_query.execute(message_id, part_id).next
|
||||
end
|
||||
|
||||
def message_part_type(message_id, part_type)
|
||||
@@message_part_type_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND type = ? AND is_attachment = 0 LIMIT 1"
|
||||
@@message_part_type_query.execute(message_id, part_type).next
|
||||
end
|
||||
|
||||
def message_part_html(message_id)
|
||||
message_part_type message_id, "text/html"
|
||||
end
|
||||
|
||||
def message_part_plain(message_id)
|
||||
message_part_type message_id, "text/plain"
|
||||
end
|
||||
|
||||
def message_part_cid(message_id, cid)
|
||||
@@message_part_cid_query ||= db.prepare 'SELECT * FROM message_part WHERE message_id = ?'
|
||||
@@message_part_cid_query.execute(message_id).find { |part| part["cid"] == cid }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def add_message(message)
|
||||
@add_message_query ||= db.prepare("INSERT INTO message (sender, recipients, subject, from_server, source, type, size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))")
|
||||
|
||||
mail = Mail.new(message[:source])
|
||||
from_server = mail.received ? mail.received.value.sub(/^from\s+/, '').sub(/\s+.*$/, '') : nil
|
||||
sender = (mail.from && !mail.from.empty?) ? mail.from : message[:sender]
|
||||
recipients = (mail.to && !mail.to.empty?) ? mail.to : message[:recipients]
|
||||
@add_message_query.execute(sender, JSON.generate(recipients), mail.subject, from_server, message[:source], mail.mime_type || "text/plain", message[:source].length)
|
||||
message_id = db.last_insert_row_id
|
||||
parts = mail.all_parts
|
||||
parts = [mail] if parts.empty?
|
||||
parts.each do |part|
|
||||
body = part.body.to_s
|
||||
# Only parts have CIDs, not mail
|
||||
cid = part.cid if part.respond_to? :cid
|
||||
add_message_part(message_id, cid, part.mime_type || "text/plain", part.attachment? ? 1 : 0, part.filename, part.charset, body, body.length)
|
||||
end
|
||||
|
||||
EventMachine.next_tick do
|
||||
message = MailCatcher::Mail.message message_id
|
||||
MailCatcher::Events::MessageAdded.push message
|
||||
if MailCatcher.options[:delete_older_than]
|
||||
MailCatcher::Mail.delete_messages_older_than!(MailCatcher.options[:delete_older_than])
|
||||
end
|
||||
if MailCatcher.options[:keep_num_emails]
|
||||
MailCatcher::Mail.delete_messages_keep!(MailCatcher.options[:keep_num_emails])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def add_message_part(*args)
|
||||
@add_message_part_query ||= db.prepare "INSERT INTO message_part (message_id, cid, type, is_attachment, filename, charset, body, size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))"
|
||||
@add_message_part_query.execute(*args)
|
||||
end
|
||||
|
||||
def latest_created_at
|
||||
@latest_created_at_query ||= db.prepare "SELECT created_at FROM message ORDER BY created_at DESC LIMIT 1"
|
||||
@latest_created_at_query.execute.next
|
||||
end
|
||||
|
||||
def messages
|
||||
@messages_query ||= db.prepare MailCatcher.show_from_server? ?
|
||||
"SELECT id, sender, recipients, subject, from_server, size, created_at FROM message ORDER BY created_at, id ASC" :
|
||||
"SELECT id, sender, recipients, subject, size, created_at FROM message ORDER BY created_at, id ASC"
|
||||
@messages_query.execute.map do |row|
|
||||
Hash[row.fields.zip(row)].tap do |message|
|
||||
message["recipients"] &&= JSON.parse(message["recipients"])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def message(id)
|
||||
@message_query ||= db.prepare "SELECT * FROM message WHERE id = ? LIMIT 1"
|
||||
row = @message_query.execute(id).next
|
||||
row && Hash[row.fields.zip(row)].tap do |message|
|
||||
unless MailCatcher.show_from_server?
|
||||
message.delete('from_server')
|
||||
end
|
||||
message["recipients"] &&= JSON.parse(message["recipients"])
|
||||
end
|
||||
end
|
||||
|
||||
def message_has_html?(id)
|
||||
@message_has_html_query ||= db.prepare "SELECT 1 FROM message_part WHERE message_id = ? AND is_attachment = 0 AND type IN ('application/xhtml+xml', 'text/html') LIMIT 1"
|
||||
(!!@message_has_html_query.execute(id).next) || ["text/html", "application/xhtml+xml"].include?(message(id)["type"])
|
||||
end
|
||||
|
||||
def message_has_plain?(id)
|
||||
@message_has_plain_query ||= db.prepare "SELECT 1 FROM message_part WHERE message_id = ? AND is_attachment = 0 AND type = 'text/plain' LIMIT 1"
|
||||
(!!@message_has_plain_query.execute(id).next) || message(id)["type"] == "text/plain"
|
||||
end
|
||||
|
||||
def message_parts(id)
|
||||
@message_parts_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? ORDER BY filename ASC"
|
||||
@message_parts_query.execute(id).map do |row|
|
||||
Hash[row.fields.zip(row)]
|
||||
end
|
||||
end
|
||||
|
||||
def message_attachments(id)
|
||||
@message_parts_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? AND is_attachment = 1 ORDER BY filename ASC"
|
||||
@message_parts_query.execute(id).map do |row|
|
||||
Hash[row.fields.zip(row)]
|
||||
end
|
||||
end
|
||||
|
||||
def message_part(message_id, part_id)
|
||||
@message_part_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND id = ? LIMIT 1"
|
||||
row = @message_part_query.execute(message_id, part_id).next
|
||||
row && Hash[row.fields.zip(row)]
|
||||
end
|
||||
|
||||
def message_part_type(message_id, part_type)
|
||||
@message_part_type_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND type = ? AND is_attachment = 0 LIMIT 1"
|
||||
row = @message_part_type_query.execute(message_id, part_type).next
|
||||
row && Hash[row.fields.zip(row)]
|
||||
end
|
||||
|
||||
def message_part_html(message_id)
|
||||
part = message_part_type(message_id, "text/html")
|
||||
part ||= message_part_type(message_id, "application/xhtml+xml")
|
||||
part ||= begin
|
||||
message = message(message_id)
|
||||
message if message and ["text/html", "application/xhtml+xml"].include? message["type"]
|
||||
end
|
||||
end
|
||||
|
||||
def message_part_plain(message_id)
|
||||
message_part_type message_id, "text/plain"
|
||||
end
|
||||
|
||||
def message_part_cid(message_id, cid)
|
||||
@message_part_cid_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ?"
|
||||
@message_part_cid_query.execute(message_id).map do |row|
|
||||
Hash[row.fields.zip(row)]
|
||||
end.find do |part|
|
||||
part["cid"] == cid
|
||||
end
|
||||
end
|
||||
|
||||
def delete_ids_query!(query, *bind_vars)
|
||||
ids ||= []
|
||||
query.execute(bind_vars).map do |row|
|
||||
ids << row[0]
|
||||
end
|
||||
delete_ids!(ids)
|
||||
end
|
||||
|
||||
def delete_ids!(ids)
|
||||
unless ids.empty?
|
||||
id_query = ids.join(',')
|
||||
|
||||
db.execute("DELETE FROM message WHERE id IN (#{id_query})")
|
||||
db.execute("DELETE FROM message_part WHERE id IN (#{id_query})")
|
||||
|
||||
EventMachine.next_tick do
|
||||
MailCatcher::Events::MessageAdded.push ids
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete!
|
||||
@delete_all_messages_query ||= db.prepare "SELECT id FROM message"
|
||||
delete_ids_query!(@delete_all_messages_query)
|
||||
end
|
||||
|
||||
def delete_message!(message_id)
|
||||
delete_ids!([message_id.to_i])
|
||||
end
|
||||
|
||||
def delete_messages_older_than!(modifier)
|
||||
@delete_messages_older_than_query ||= db.prepare "SELECT id FROM message WHERE created_at < datetime('now', ?)"
|
||||
delete_ids_query!(@delete_messages_older_than_query, modifier)
|
||||
end
|
||||
|
||||
def delete_messages_keep!(keep_num_emails)
|
||||
@delete_messages_older_than_query ||= db.prepare "SELECT id FROM message ORDER BY id DESC LIMIT -1 OFFSET ?"
|
||||
delete_ids_query!(@delete_messages_older_than_query, keep_num_emails)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,48 +1,55 @@
|
|||
require 'eventmachine'
|
||||
require "eventmachine"
|
||||
|
||||
module MailCatcher
|
||||
class Smtp < EventMachine::Protocols::SmtpServer
|
||||
def current_message
|
||||
@current_message ||= {}
|
||||
end
|
||||
|
||||
def receive_reset
|
||||
@current_message = nil
|
||||
true
|
||||
end
|
||||
|
||||
def receive_sender(sender)
|
||||
current_message[:sender] = sender
|
||||
true
|
||||
end
|
||||
|
||||
def receive_recipient(recipient)
|
||||
current_message[:recipients] ||= []
|
||||
current_message[:recipients] << recipient
|
||||
true
|
||||
end
|
||||
|
||||
def receive_data_chunk(lines)
|
||||
current_message[:source] ||= ""
|
||||
current_message[:source] += lines.join("\n")
|
||||
true
|
||||
require "mail_catcher/mail"
|
||||
|
||||
class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
|
||||
# We override EM's mail from processing to allow multiple mail-from commands
|
||||
# per [RFC 2821](http://tools.ietf.org/html/rfc2821#section-4.1.1.2)
|
||||
def process_mail_from sender
|
||||
if @state.include? :mail_from
|
||||
@state -= [:mail_from, :rcpt, :data]
|
||||
receive_reset
|
||||
end
|
||||
|
||||
def receive_message
|
||||
MailCatcher::Mail.add_message current_message
|
||||
puts "==> SMTP: Received message from '#{current_message[:sender]}' (#{current_message[:source].length} bytes)"
|
||||
true
|
||||
rescue
|
||||
puts "*** Error receiving message: #{current_message.inspect}"
|
||||
puts " Exception: #{$!}"
|
||||
puts " Backtrace:"
|
||||
$!.backtrace.each do |line|
|
||||
puts " #{line}"
|
||||
end
|
||||
puts " Please submit this as an issue at http://github.com/sj26/mailcatcher/issues"
|
||||
false
|
||||
ensure
|
||||
@current_message = nil
|
||||
end
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def current_message
|
||||
@current_message ||= {}
|
||||
end
|
||||
|
||||
def receive_reset
|
||||
@current_message = nil
|
||||
true
|
||||
end
|
||||
|
||||
def receive_sender(sender)
|
||||
current_message[:sender] = sender
|
||||
true
|
||||
end
|
||||
|
||||
def receive_recipient(recipient)
|
||||
current_message[:recipients] ||= []
|
||||
current_message[:recipients] << recipient
|
||||
true
|
||||
end
|
||||
|
||||
def receive_data_chunk(lines)
|
||||
current_message[:source] ||= ""
|
||||
lines.each do |line|
|
||||
current_message[:source] << line << "\r\n"
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def receive_message
|
||||
MailCatcher::Mail.add_message current_message
|
||||
puts "==> SMTP: Received message from '#{current_message[:sender]}' (#{current_message[:source].length} bytes)"
|
||||
true
|
||||
rescue => exception
|
||||
MailCatcher.log_exception("Error receiving message", @current_message, exception)
|
||||
false
|
||||
ensure
|
||||
@current_message = nil
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
module MailCatcher
|
||||
VERSION = "0.6.5"
|
||||
end
|
|
@ -1,98 +1,22 @@
|
|||
require 'sinatra'
|
||||
require 'json'
|
||||
require 'pathname'
|
||||
require "rack/builder"
|
||||
|
||||
require 'skinny'
|
||||
|
||||
class Sinatra::Request
|
||||
include Skinny::RequestHelpers
|
||||
end
|
||||
require "mail_catcher/web/application"
|
||||
|
||||
module MailCatcher
|
||||
class Web < Sinatra::Base
|
||||
set :root, Pathname.new(__FILE__).dirname.parent.parent
|
||||
set :haml, :format => :html5
|
||||
|
||||
get '/' do
|
||||
haml :index
|
||||
end
|
||||
|
||||
get '/messages' do
|
||||
if request.websocket?
|
||||
request.websocket!(
|
||||
:protocol => "MailCatcher 0.2 Message Push",
|
||||
:on_start => proc do |websocket|
|
||||
subscription = MailCatcher::Events::MessageAdded.subscribe { |message| websocket.send_message message.to_json }
|
||||
websocket.on_close do |websocket|
|
||||
MailCatcher::Events::MessageAdded.unsubscribe subscription
|
||||
end
|
||||
end)
|
||||
else
|
||||
MailCatcher::Mail.messages.to_json
|
||||
module Web extend self
|
||||
def app
|
||||
@@app ||= Rack::Builder.new do
|
||||
if ENV["MAILCATCHER_ENV"] == "development"
|
||||
require "mail_catcher/web/assets"
|
||||
map("/assets") { run Assets }
|
||||
end
|
||||
|
||||
map("/") { run Application }
|
||||
end
|
||||
end
|
||||
|
||||
get '/messages/:id.json' do
|
||||
id = params[:id].to_i
|
||||
if message = MailCatcher::Mail.message(id)
|
||||
message.merge({
|
||||
"formats" => [
|
||||
"source",
|
||||
("html" if MailCatcher::Mail.message_has_html? id),
|
||||
("plain" if MailCatcher::Mail.message_has_plain? id),
|
||||
].flatten,
|
||||
"attachments" => MailCatcher::Mail.message_attachments(id).map do |attachment|
|
||||
attachment.merge({"href" => "/messages/#{escape(id)}/#{escape(attachment['cid'])}"})
|
||||
end,
|
||||
}).to_json
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
get '/messages/:id.html' do
|
||||
id = params[:id].to_i
|
||||
if part = MailCatcher::Mail.message_part_html(id)
|
||||
content_type part["type"], :charset => (part["charset"] || "utf8")
|
||||
part["body"].gsub(/cid:([^'"> ]+)/, "#{id}/\\1")
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
get "/messages/:id.plain" do
|
||||
id = params[:id].to_i
|
||||
if part = MailCatcher::Mail.message_part_plain(id)
|
||||
content_type part["type"], :charset => (part["charset"] || "utf8")
|
||||
part["body"]
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
get "/messages/:id.source" do
|
||||
id = params[:id].to_i
|
||||
if message = MailCatcher::Mail.message(id)
|
||||
content_type "text/plain"
|
||||
message["source"]
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
get "/messages/:id/:cid" do
|
||||
id = params[:id].to_i
|
||||
if part = MailCatcher::Mail.message_part_cid(id, params[:cid])
|
||||
content_type part["type"], :charset => (part["charset"] || "utf8")
|
||||
attachment part["filename"] if part["is_attachment"] == 1
|
||||
body part["body"].to_s
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
not_found do
|
||||
"<html><body><h1>No Dice</h1><p>The message you were looking for does not exist, or doesn't have content of this type.</p></body></html>"
|
||||
|
||||
def call(env)
|
||||
app.call(env)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,181 @@
|
|||
require "pathname"
|
||||
require "net/http"
|
||||
require "uri"
|
||||
|
||||
require "sinatra"
|
||||
require "skinny"
|
||||
|
||||
require "mail_catcher/events"
|
||||
require "mail_catcher/mail"
|
||||
|
||||
class Sinatra::Request
|
||||
include Skinny::Helpers
|
||||
end
|
||||
|
||||
module MailCatcher
|
||||
module Web
|
||||
class Application < Sinatra::Base
|
||||
set :development, ENV["MAILCATCHER_ENV"] == "development"
|
||||
set :root, File.expand_path("#{__FILE__}/../../../..")
|
||||
|
||||
if development?
|
||||
require "sprockets-helpers"
|
||||
|
||||
configure do
|
||||
require "mail_catcher/web/assets"
|
||||
Sprockets::Helpers.configure do |config|
|
||||
config.environment = Assets
|
||||
config.prefix = "/assets"
|
||||
config.digest = false
|
||||
config.public_path = public_folder
|
||||
config.debug = true
|
||||
end
|
||||
end
|
||||
|
||||
helpers do
|
||||
include Sprockets::Helpers
|
||||
end
|
||||
else
|
||||
helpers do
|
||||
def javascript_tag(name)
|
||||
%{<script src="/assets/#{name}.js"></script>}
|
||||
end
|
||||
|
||||
def stylesheet_tag(name)
|
||||
%{<link rel="stylesheet" href="/assets/#{name}.css">}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
get "/" do
|
||||
erb :index
|
||||
end
|
||||
|
||||
delete "/" do
|
||||
if MailCatcher.quittable?
|
||||
MailCatcher.quit!
|
||||
status 204
|
||||
else
|
||||
status 403
|
||||
end
|
||||
end
|
||||
|
||||
get "/messages" do
|
||||
if request.websocket?
|
||||
request.websocket!(
|
||||
:on_start => proc do |websocket|
|
||||
subscription = Events::MessageAdded.subscribe do |message|
|
||||
begin
|
||||
websocket.send_message(JSON.generate(message))
|
||||
rescue => exception
|
||||
MailCatcher.log_exception("Error sending message through websocket", message, exception)
|
||||
end
|
||||
end
|
||||
|
||||
websocket.on_close do |*|
|
||||
Events::MessageAdded.unsubscribe subscription
|
||||
end
|
||||
end)
|
||||
else
|
||||
content_type :json
|
||||
JSON.generate(Mail.messages)
|
||||
end
|
||||
end
|
||||
|
||||
delete "/messages" do
|
||||
Mail.delete!
|
||||
status 204
|
||||
end
|
||||
|
||||
get "/messages/:id.json" do
|
||||
id = params[:id].to_i
|
||||
if message = Mail.message(id)
|
||||
content_type :json
|
||||
JSON.generate(message.merge({
|
||||
"formats" => [
|
||||
"source",
|
||||
("html" if Mail.message_has_html? id),
|
||||
("plain" if Mail.message_has_plain? id)
|
||||
].compact,
|
||||
"attachments" => Mail.message_attachments(id).map do |attachment|
|
||||
attachment.merge({"href" => "/messages/#{escape(id)}/parts/#{escape(attachment["cid"])}"})
|
||||
end,
|
||||
}))
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
get "/messages/:id.html" do
|
||||
id = params[:id].to_i
|
||||
if part = Mail.message_part_html(id)
|
||||
content_type :html, :charset => (part["charset"] || "utf8")
|
||||
|
||||
body = part["body"]
|
||||
|
||||
# Rewrite body to link to embedded attachments served by cid
|
||||
body.gsub! /cid:([^'"> ]+)/, "#{id}/parts/\\1"
|
||||
|
||||
body
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
get "/messages/:id.plain" do
|
||||
id = params[:id].to_i
|
||||
if part = Mail.message_part_plain(id)
|
||||
content_type part["type"], :charset => (part["charset"] || "utf8")
|
||||
part["body"]
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
get "/messages/:id.source" do
|
||||
id = params[:id].to_i
|
||||
if message = Mail.message(id)
|
||||
content_type "text/plain"
|
||||
message["source"]
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
get "/messages/:id.eml" do
|
||||
id = params[:id].to_i
|
||||
if message = Mail.message(id)
|
||||
content_type "message/rfc822"
|
||||
message["source"]
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
get "/messages/:id/parts/:cid" do
|
||||
id = params[:id].to_i
|
||||
if part = Mail.message_part_cid(id, params[:cid])
|
||||
content_type part["type"], :charset => (part["charset"] || "utf8")
|
||||
attachment part["filename"] if part["is_attachment"] == 1
|
||||
body part["body"].to_s
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
delete "/messages/:id" do
|
||||
id = params[:id].to_i
|
||||
if Mail.message(id)
|
||||
Mail.delete_message!(id)
|
||||
status 204
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
not_found do
|
||||
erb :"404"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,13 @@
|
|||
require "sprockets"
|
||||
require "sprockets-sass"
|
||||
require "compass"
|
||||
|
||||
module MailCatcher
|
||||
module Web
|
||||
Assets = Sprockets::Environment.new(File.expand_path("#{__FILE__}/../../../..")).tap do |sprockets|
|
||||
Dir["#{sprockets.root}/{,vendor}/assets/*"].each do |path|
|
||||
sprockets.append_path(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
require "mail_catcher"
|
||||
|
||||
Mailcatcher = MailCatcher
|
|
@ -0,0 +1,52 @@
|
|||
require File.expand_path("../lib/mail_catcher/version", __FILE__)
|
||||
|
||||
Gem::Specification.new do |s|
|
||||
s.name = "mailcatcher"
|
||||
s.version = MailCatcher::VERSION
|
||||
s.license = "MIT"
|
||||
s.summary = "Runs an SMTP server, catches and displays email in a web interface."
|
||||
s.description = <<-END
|
||||
MailCatcher runs a super simple SMTP server which catches any
|
||||
message sent to it to display in a web interface. Run
|
||||
mailcatcher, set your favourite app to deliver to
|
||||
smtp://127.0.0.1:1025 instead of your default SMTP server,
|
||||
then check out http://127.0.0.1:1080 to see the mail.
|
||||
END
|
||||
|
||||
s.author = "Samuel Cochran"
|
||||
s.email = "sj26@sj26.com"
|
||||
s.homepage = "http://mailcatcher.me"
|
||||
|
||||
s.files = Dir[
|
||||
"README.md", "LICENSE", "VERSION",
|
||||
"bin/*",
|
||||
"lib/**/*.rb",
|
||||
"public/**/*",
|
||||
"views/**/*",
|
||||
] - Dir["lib/mail_catcher/web/assets.rb"]
|
||||
s.require_paths = ["lib"]
|
||||
s.executables = ["mailcatcher", "catchmail"]
|
||||
s.extra_rdoc_files = ["README.md", "LICENSE"]
|
||||
|
||||
s.required_ruby_version = ">= 1.9.3"
|
||||
|
||||
s.add_dependency "eventmachine", "1.0.9.1"
|
||||
s.add_dependency "mail", "~> 2.3"
|
||||
s.add_dependency "rack", "~> 1.5"
|
||||
s.add_dependency "sinatra", "~> 1.2"
|
||||
s.add_dependency "sqlite3", "~> 1.3"
|
||||
s.add_dependency "thin", "~> 1.5.0"
|
||||
s.add_dependency "skinny", "~> 0.2.3"
|
||||
|
||||
s.add_development_dependency "coffee-script"
|
||||
s.add_development_dependency "compass"
|
||||
s.add_development_dependency "minitest", "~> 5.0"
|
||||
s.add_development_dependency "rake"
|
||||
s.add_development_dependency "rdoc"
|
||||
s.add_development_dependency "sass"
|
||||
s.add_development_dependency "selenium-webdriver"
|
||||
s.add_development_dependency "sprockets"
|
||||
s.add_development_dependency "sprockets-sass"
|
||||
s.add_development_dependency "sprockets-helpers"
|
||||
s.add_development_dependency "uglifier"
|
||||
end
|
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
|
@ -1,94 +0,0 @@
|
|||
var MailCatcher = {
|
||||
init: function() {
|
||||
$('#mail tr').live('click', function() {
|
||||
MailCatcher.load($(this).attr('data-message-id'));
|
||||
});
|
||||
|
||||
$('#message .formats ul li').live('click', function() {
|
||||
MailCatcher.loadBody($('#mail tr.selected').attr('data-message-id'), $(this).attr('data-message-format'));
|
||||
});
|
||||
|
||||
MailCatcher.refresh();
|
||||
MailCatcher.subscribe();
|
||||
},
|
||||
|
||||
addMessage: function(message) {
|
||||
var row = $('<tr />').attr('data-message-id', message.id.toString());
|
||||
$.each(['sender', 'recipients', 'subject', 'created_at'], function (i, property) {
|
||||
row.append($('<td />').text(message[property]));
|
||||
});
|
||||
$('#mail tbody').append(row);
|
||||
},
|
||||
|
||||
refresh: function() {
|
||||
console.log('Refreshing messages');
|
||||
$.getJSON('/messages', function(mail) {
|
||||
$.each(mail, function(i, message) {
|
||||
MailCatcher.addMessage(message);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
subscribe: function () {
|
||||
MailCatcher.websocket = new WebSocket("ws" + (window.location.scheme == 'https' ? 's' : '') + "://" + window.location.host + "/messages");
|
||||
MailCatcher.websocket.onmessage = function (event) {
|
||||
console.log('Message received:', event.data);
|
||||
MailCatcher.addMessage($.parseJSON(event.data));
|
||||
};
|
||||
},
|
||||
|
||||
load: function(id) {
|
||||
id = id || $('#mail tr.selected').attr('data-message-id');
|
||||
|
||||
if (id !== null) {
|
||||
console.log('Loading message', id);
|
||||
|
||||
$('#mail tbody tr:not([data-message-id="'+id+'"])').removeClass('selected');
|
||||
$('#mail tbody tr[data-message-id="'+id+'"]').addClass('selected');
|
||||
$.getJSON('/messages/' + id + '.json', function(message) {
|
||||
$('#message .received span').text(message.created_at);
|
||||
$('#message .from span').text(message.sender);
|
||||
$('#message .to span').text(message.recipients);
|
||||
$('#message .subject span').text(message.subject);
|
||||
$('#message .formats ul li').each(function(i, el) {
|
||||
var $el = $(el),
|
||||
format = $el.attr('data-message-format');
|
||||
if ($.inArray(format, message.formats) >= 0) {
|
||||
$el.show();
|
||||
} else {
|
||||
$el.hide();
|
||||
}
|
||||
});
|
||||
if ($("#message .formats ul li.selected:not(:visible)")) {
|
||||
$("#message .formats ul li.selected").removeClass("selected");
|
||||
$("#message .formats ul li:visible:first").addClass("selected");
|
||||
}
|
||||
if (message.attachments.length > 0) {
|
||||
console.log(message.attachments);
|
||||
$('#message .attachments ul').empty();
|
||||
$.each(message.attachments, function (i, attachment) {
|
||||
$('#message .attachments ul').append($('<li>').append($('<a>').attr('href', attachment['href']).addClass(attachment['type'].split('/', 1)[0]).addClass(attachment['type'].replace('/', '-')).text(attachment['filename'])));
|
||||
});
|
||||
$('#message .attachments').show();
|
||||
} else {
|
||||
$('#message .attachments').hide();
|
||||
}
|
||||
MailCatcher.loadBody();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loadBody: function(id, format) {
|
||||
id = id || $('#mail tr.selected').attr('data-message-id');
|
||||
format = format || $('#message .formats ul li.selected').first().attr('data-message-format') || 'html';
|
||||
|
||||
$('#message .formats ul li[data-message-format="'+format+'"]').addClass('selected');
|
||||
$('#message .formats ul li:not([data-message-format="'+format+'"])').removeClass('selected');
|
||||
|
||||
if (id !== undefined && id !== null) {
|
||||
console.log('Loading message', id, 'in format', format);
|
||||
|
||||
$('#message iframe').attr('src', '/messages/' + id + '.' + format);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -1,23 +0,0 @@
|
|||
html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, font, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td { margin:0px; padding:0px; border:0px; outline:0px; font-weight:inherit; font-style:inherit; font-size:100%; font-family:inherit; vertical-align:baseline; }
|
||||
body { line-height: 1; color: black; background: Window; color: WindowText; font-size: 12px; font-family: Helvetica, Arial, sans-serif; }
|
||||
ol, ul { list-style: none; }
|
||||
table { border-collapse: separate; border-spacing: 0px; }
|
||||
caption, th, td { text-align: left; font-weight: normal; }
|
||||
|
||||
#mail { height: 10em; overflow: auto; background: ThreeDHighlight; border-bottom: 1px solid ButtonFace; }
|
||||
#mail table { width: 100%; }
|
||||
#mail table thead tr { background: ButtonHighlight; color: ButtonText; }
|
||||
#mail table thead tr th { padding: .25em; font-weight: bold; }
|
||||
#mail table tbody tr:nth-child(even) { background: ButtonHighlight; color: ButtonText; }
|
||||
#mail table tbody tr.selected { background: Highlight; color: HighlightText; }
|
||||
#mail table tbody tr td { padding: .25em; }
|
||||
#message .metadata { padding: 1em; }
|
||||
#message .metadata div { padding: .25em;; }
|
||||
#message .metadata div label { display: inline-block; width: 8em; text-align: right; font-weight: bold; }
|
||||
#message .metadata .attachments { display: none; }
|
||||
#message .metadata .attachments ul { display: inline; }
|
||||
#message .metadata .attachments ul li { display: inline-block; margin-right: .5em; }
|
||||
#message .formats ul { border-bottom: 1px solid WindowFrame; padding: 0 .5em; }
|
||||
#message .formats ul li { display: inline-block; padding: .5em; border: solid WindowFrame; background: ButtonFace; color: ButtonText; border-width: 1px 1px 0 1px; }
|
||||
#message .formats ul li.selected { background: ThreeDHighlight; color: WindowText; height: 13px; margin-bottom: -1px; }
|
||||
#message iframe { width: 100%; height: 42em; background: ThreeDHighlight; }
|
|
@ -0,0 +1,227 @@
|
|||
ENV["MAILCATCHER_ENV"] = "test"
|
||||
|
||||
require "minitest/autorun"
|
||||
require "mail_catcher"
|
||||
require "socket"
|
||||
require "net/smtp"
|
||||
require "selenium-webdriver"
|
||||
|
||||
SMTP_PORT = 10025
|
||||
HTTP_PORT = 10080
|
||||
|
||||
# Start MailCatcher
|
||||
MAILCATCHER_PID = spawn "bundle", "exec", "mailcatcher", "--foreground", "--smtp-port", SMTP_PORT.to_s, "--http-port", HTTP_PORT.to_s
|
||||
|
||||
# Make sure it will be stopped
|
||||
MiniTest.after_run do
|
||||
Process.kill("TERM", MAILCATCHER_PID) and Process.wait
|
||||
end
|
||||
|
||||
# Wait for it to boot
|
||||
begin
|
||||
TCPSocket.new("127.0.0.1", SMTP_PORT).close
|
||||
TCPSocket.new("127.0.0.1", HTTP_PORT).close
|
||||
rescue Errno::ECONNREFUSED
|
||||
retry
|
||||
end
|
||||
|
||||
describe MailCatcher do
|
||||
DEFAULT_FROM = "from@example.com"
|
||||
DEFAULT_TO = "to@example.com"
|
||||
|
||||
def deliver(message, options={})
|
||||
options = {:from => DEFAULT_FROM, :to => DEFAULT_TO}.merge(options)
|
||||
Net::SMTP.start('127.0.0.1', SMTP_PORT) do |smtp|
|
||||
smtp.send_message message, options[:from], options[:to]
|
||||
end
|
||||
end
|
||||
|
||||
def read_example(name)
|
||||
File.read(File.expand_path("../../examples/#{name}", __FILE__))
|
||||
end
|
||||
|
||||
def deliver_example(name, options={})
|
||||
deliver(read_example(name), options)
|
||||
end
|
||||
|
||||
def selenium
|
||||
@selenium ||= Selenium::WebDriver.for(:phantomjs)
|
||||
end
|
||||
|
||||
before { selenium.navigate.to("http://127.0.0.1:#{HTTP_PORT}") }
|
||||
|
||||
def messages_element
|
||||
selenium.find_element(:id, "messages")
|
||||
end
|
||||
|
||||
def message_row_element
|
||||
messages_element.find_element(:xpath, ".//table/tbody/tr[1]")
|
||||
end
|
||||
|
||||
def message_from_element
|
||||
message_row_element.find_element(:xpath, ".//td[1]")
|
||||
end
|
||||
|
||||
def message_to_element
|
||||
message_row_element.find_element(:xpath, ".//td[2]")
|
||||
end
|
||||
|
||||
def message_subject_element
|
||||
message_row_element.find_element(:xpath, ".//td[3]")
|
||||
end
|
||||
|
||||
def message_received_element
|
||||
message_row_element.find_element(:xpath, ".//td[4]")
|
||||
end
|
||||
|
||||
def html_tab_element
|
||||
selenium.find_element(:css, "#message header .format.html a")
|
||||
end
|
||||
|
||||
def plain_tab_element
|
||||
selenium.find_element(:css, "#message header .format.plain a")
|
||||
end
|
||||
|
||||
def source_tab_element
|
||||
selenium.find_element(:css, "#message header .format.source a")
|
||||
end
|
||||
|
||||
def iframe_element
|
||||
selenium.find_element(:css, "#message iframe")
|
||||
end
|
||||
|
||||
def body_element
|
||||
selenium.find_element(:tag_name, "body")
|
||||
end
|
||||
|
||||
it "catches and displays a plain text message as plain text and source" do
|
||||
deliver_example("plainmail")
|
||||
|
||||
message_from_element.text.must_include DEFAULT_FROM
|
||||
message_to_element.text.must_include DEFAULT_TO
|
||||
message_subject_element.text.must_equal "Plain mail"
|
||||
Time.parse(message_received_element.text).must_be_close_to Time.now, 5
|
||||
|
||||
message_row_element.click
|
||||
|
||||
source_tab_element.displayed?.must_equal true
|
||||
plain_tab_element.displayed?.must_equal true
|
||||
html_tab_element.displayed?.must_equal false
|
||||
|
||||
plain_tab_element.click
|
||||
|
||||
iframe_element.displayed?.must_equal true
|
||||
iframe_element.attribute(:src).must_match(/\.plain\Z/)
|
||||
|
||||
selenium.switch_to.frame(iframe_element)
|
||||
|
||||
body_element.text.wont_include "Subject: Plain mail"
|
||||
body_element.text.must_include "Here's some text"
|
||||
|
||||
selenium.switch_to.default_content
|
||||
source_tab_element.click
|
||||
selenium.switch_to.frame(iframe_element)
|
||||
|
||||
body_element.text.must_include "Subject: Plain mail"
|
||||
body_element.text.must_include "Here's some text"
|
||||
end
|
||||
|
||||
it "catches and displays an html message as html and source" do
|
||||
deliver_example("htmlmail")
|
||||
|
||||
message_from_element.text.must_include DEFAULT_FROM
|
||||
message_to_element.text.must_include DEFAULT_TO
|
||||
message_subject_element.text.must_equal "Test HTML Mail"
|
||||
Time.parse(message_received_element.text).must_be_close_to Time.now, 5
|
||||
|
||||
message_row_element.click
|
||||
|
||||
source_tab_element.displayed?.must_equal true
|
||||
plain_tab_element.displayed?.must_equal false
|
||||
html_tab_element.displayed?.must_equal true
|
||||
|
||||
html_tab_element.click
|
||||
|
||||
iframe_element.displayed?.must_equal true
|
||||
iframe_element.attribute(:src).must_match /\.html\Z/
|
||||
|
||||
selenium.switch_to.frame(iframe_element)
|
||||
|
||||
body_element.text.must_include "Yo, you slimey scoundrel."
|
||||
body_element.text.wont_include "Content-Type: text/html"
|
||||
body_element.text.wont_include "Yo, you <em>slimey scoundrel</em>."
|
||||
|
||||
selenium.switch_to.default_content
|
||||
source_tab_element.click
|
||||
selenium.switch_to.frame(iframe_element)
|
||||
|
||||
body_element.text.must_include "Content-Type: text/html"
|
||||
body_element.text.must_include "Yo, you <em>slimey scoundrel</em>."
|
||||
body_element.text.wont_include "Yo, you slimey scoundrel."
|
||||
end
|
||||
|
||||
it "catches and displays a multipart message as text, html and source" do
|
||||
deliver_example("multipartmail")
|
||||
|
||||
message_from_element.text.must_include DEFAULT_FROM
|
||||
message_to_element.text.must_include DEFAULT_TO
|
||||
message_subject_element.text.must_equal "Test Multipart Mail"
|
||||
Time.parse(message_received_element.text).must_be_close_to Time.now, 5
|
||||
|
||||
message_row_element.click
|
||||
|
||||
source_tab_element.displayed?.must_equal true
|
||||
plain_tab_element.displayed?.must_equal true
|
||||
html_tab_element.displayed?.must_equal true
|
||||
|
||||
plain_tab_element.click
|
||||
|
||||
iframe_element.displayed?.must_equal true
|
||||
iframe_element.attribute(:src).must_match /\.plain\Z/
|
||||
|
||||
selenium.switch_to.frame(iframe_element)
|
||||
|
||||
body_element.text.must_include "Plain text mail"
|
||||
body_element.text.wont_include "HTML mail"
|
||||
body_element.text.wont_include "Content-Type: multipart/alternative; boundary=BOUNDARY--198849662"
|
||||
|
||||
selenium.switch_to.default_content
|
||||
html_tab_element.click
|
||||
selenium.switch_to.frame(iframe_element)
|
||||
|
||||
body_element.text.must_include "HTML mail"
|
||||
body_element.text.wont_include "Content-Type: multipart/alternative; boundary=BOUNDARY--198849662"
|
||||
|
||||
selenium.switch_to.default_content
|
||||
source_tab_element.click
|
||||
selenium.switch_to.frame(iframe_element)
|
||||
|
||||
body_element.text.must_include "Content-Type: multipart/alternative; boundary=BOUNDARY--198849662"
|
||||
body_element.text.must_include "Plain text mail"
|
||||
body_element.text.must_include "<em>HTML</em> mail"
|
||||
end
|
||||
|
||||
it "catches and displays an unknown message as source" do
|
||||
deliver_example("unknownmail")
|
||||
|
||||
skip
|
||||
end
|
||||
|
||||
it "catches and displays a message with multipart attachments" do
|
||||
deliver_example("attachmail")
|
||||
|
||||
skip
|
||||
end
|
||||
|
||||
it "doesn't choke on messages containing dots" do
|
||||
deliver_example("dotmail")
|
||||
|
||||
skip
|
||||
end
|
||||
|
||||
it "doesn't choke on messages containing quoted printables" do
|
||||
deliver_example("quoted_printable_htmlmail")
|
||||
|
||||
skip
|
||||
end
|
||||
end
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Version: 1.0 Alpha-1
|
||||
* Build Date: 13-Nov-2007
|
||||
* Copyright (c) 2006-2007, Coolite Inc. (http://www.coolite.com/). All rights reserved.
|
||||
* License: Licensed under The MIT License. See license.txt and http://www.datejs.com/license/.
|
||||
* Website: http://www.datejs.com/ or http://www.coolite.com/datejs/
|
||||
*/
|
||||
Date.CultureInfo={name:"en-US",englishName:"English (United States)",nativeName:"English (United States)",dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],abbreviatedDayNames:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],shortestDayNames:["Su","Mo","Tu","We","Th","Fr","Sa"],firstLetterDayNames:["S","M","T","W","T","F","S"],monthNames:["January","February","March","April","May","June","July","August","September","October","November","December"],abbreviatedMonthNames:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],amDesignator:"AM",pmDesignator:"PM",firstDayOfWeek:0,twoDigitYearMax:2029,dateElementOrder:"mdy",formatPatterns:{shortDate:"M/d/yyyy",longDate:"dddd, MMMM dd, yyyy",shortTime:"h:mm tt",longTime:"h:mm:ss tt",fullDateTime:"dddd, MMMM dd, yyyy h:mm:ss tt",sortableDateTime:"yyyy-MM-ddTHH:mm:ss",universalSortableDateTime:"yyyy-MM-dd HH:mm:ssZ",rfc1123:"ddd, dd MMM yyyy HH:mm:ss GMT",monthDay:"MMMM dd",yearMonth:"MMMM, yyyy"},regexPatterns:{jan:/^jan(uary)?/i,feb:/^feb(ruary)?/i,mar:/^mar(ch)?/i,apr:/^apr(il)?/i,may:/^may/i,jun:/^jun(e)?/i,jul:/^jul(y)?/i,aug:/^aug(ust)?/i,sep:/^sep(t(ember)?)?/i,oct:/^oct(ober)?/i,nov:/^nov(ember)?/i,dec:/^dec(ember)?/i,sun:/^su(n(day)?)?/i,mon:/^mo(n(day)?)?/i,tue:/^tu(e(s(day)?)?)?/i,wed:/^we(d(nesday)?)?/i,thu:/^th(u(r(s(day)?)?)?)?/i,fri:/^fr(i(day)?)?/i,sat:/^sa(t(urday)?)?/i,future:/^next/i,past:/^last|past|prev(ious)?/i,add:/^(\+|after|from)/i,subtract:/^(\-|before|ago)/i,yesterday:/^yesterday/i,today:/^t(oday)?/i,tomorrow:/^tomorrow/i,now:/^n(ow)?/i,millisecond:/^ms|milli(second)?s?/i,second:/^sec(ond)?s?/i,minute:/^min(ute)?s?/i,hour:/^h(ou)?rs?/i,week:/^w(ee)?k/i,month:/^m(o(nth)?s?)?/i,day:/^d(ays?)?/i,year:/^y((ea)?rs?)?/i,shortMeridian:/^(a|p)/i,longMeridian:/^(a\.?m?\.?|p\.?m?\.?)/i,timezone:/^((e(s|d)t|c(s|d)t|m(s|d)t|p(s|d)t)|((gmt)?\s*(\+|\-)\s*\d\d\d\d?)|gmt)/i,ordinalSuffix:/^\s*(st|nd|rd|th)/i,timeContext:/^\s*(\:|a|p)/i},abbreviatedTimeZoneStandard:{GMT:"-000",EST:"-0400",CST:"-0500",MST:"-0600",PST:"-0700"},abbreviatedTimeZoneDST:{GMT:"-000",EDT:"-0500",CDT:"-0600",MDT:"-0700",PDT:"-0800"}};
|
||||
Date.getMonthNumberFromName=function(name){var n=Date.CultureInfo.monthNames,m=Date.CultureInfo.abbreviatedMonthNames,s=name.toLowerCase();for(var i=0;i<n.length;i++){if(n[i].toLowerCase()==s||m[i].toLowerCase()==s){return i;}}
|
||||
return-1;};Date.getDayNumberFromName=function(name){var n=Date.CultureInfo.dayNames,m=Date.CultureInfo.abbreviatedDayNames,o=Date.CultureInfo.shortestDayNames,s=name.toLowerCase();for(var i=0;i<n.length;i++){if(n[i].toLowerCase()==s||m[i].toLowerCase()==s){return i;}}
|
||||
return-1;};Date.isLeapYear=function(year){return(((year%4===0)&&(year%100!==0))||(year%400===0));};Date.getDaysInMonth=function(year,month){return[31,(Date.isLeapYear(year)?29:28),31,30,31,30,31,31,30,31,30,31][month];};Date.getTimezoneOffset=function(s,dst){return(dst||false)?Date.CultureInfo.abbreviatedTimeZoneDST[s.toUpperCase()]:Date.CultureInfo.abbreviatedTimeZoneStandard[s.toUpperCase()];};Date.getTimezoneAbbreviation=function(offset,dst){var n=(dst||false)?Date.CultureInfo.abbreviatedTimeZoneDST:Date.CultureInfo.abbreviatedTimeZoneStandard,p;for(p in n){if(n[p]===offset){return p;}}
|
||||
return null;};Date.prototype.clone=function(){return new Date(this.getTime());};Date.prototype.compareTo=function(date){if(isNaN(this)){throw new Error(this);}
|
||||
if(date instanceof Date&&!isNaN(date)){return(this>date)?1:(this<date)?-1:0;}else{throw new TypeError(date);}};Date.prototype.equals=function(date){return(this.compareTo(date)===0);};Date.prototype.between=function(start,end){var t=this.getTime();return t>=start.getTime()&&t<=end.getTime();};Date.prototype.addMilliseconds=function(value){this.setMilliseconds(this.getMilliseconds()+value);return this;};Date.prototype.addSeconds=function(value){return this.addMilliseconds(value*1000);};Date.prototype.addMinutes=function(value){return this.addMilliseconds(value*60000);};Date.prototype.addHours=function(value){return this.addMilliseconds(value*3600000);};Date.prototype.addDays=function(value){return this.addMilliseconds(value*86400000);};Date.prototype.addWeeks=function(value){return this.addMilliseconds(value*604800000);};Date.prototype.addMonths=function(value){var n=this.getDate();this.setDate(1);this.setMonth(this.getMonth()+value);this.setDate(Math.min(n,this.getDaysInMonth()));return this;};Date.prototype.addYears=function(value){return this.addMonths(value*12);};Date.prototype.add=function(config){if(typeof config=="number"){this._orient=config;return this;}
|
||||
var x=config;if(x.millisecond||x.milliseconds){this.addMilliseconds(x.millisecond||x.milliseconds);}
|
||||
if(x.second||x.seconds){this.addSeconds(x.second||x.seconds);}
|
||||
if(x.minute||x.minutes){this.addMinutes(x.minute||x.minutes);}
|
||||
if(x.hour||x.hours){this.addHours(x.hour||x.hours);}
|
||||
if(x.month||x.months){this.addMonths(x.month||x.months);}
|
||||
if(x.year||x.years){this.addYears(x.year||x.years);}
|
||||
if(x.day||x.days){this.addDays(x.day||x.days);}
|
||||
return this;};Date._validate=function(value,min,max,name){if(typeof value!="number"){throw new TypeError(value+" is not a Number.");}else if(value<min||value>max){throw new RangeError(value+" is not a valid value for "+name+".");}
|
||||
return true;};Date.validateMillisecond=function(n){return Date._validate(n,0,999,"milliseconds");};Date.validateSecond=function(n){return Date._validate(n,0,59,"seconds");};Date.validateMinute=function(n){return Date._validate(n,0,59,"minutes");};Date.validateHour=function(n){return Date._validate(n,0,23,"hours");};Date.validateDay=function(n,year,month){return Date._validate(n,1,Date.getDaysInMonth(year,month),"days");};Date.validateMonth=function(n){return Date._validate(n,0,11,"months");};Date.validateYear=function(n){return Date._validate(n,1,9999,"seconds");};Date.prototype.set=function(config){var x=config;if(!x.millisecond&&x.millisecond!==0){x.millisecond=-1;}
|
||||
if(!x.second&&x.second!==0){x.second=-1;}
|
||||
if(!x.minute&&x.minute!==0){x.minute=-1;}
|
||||
if(!x.hour&&x.hour!==0){x.hour=-1;}
|
||||
if(!x.day&&x.day!==0){x.day=-1;}
|
||||
if(!x.month&&x.month!==0){x.month=-1;}
|
||||
if(!x.year&&x.year!==0){x.year=-1;}
|
||||
if(x.millisecond!=-1&&Date.validateMillisecond(x.millisecond)){this.addMilliseconds(x.millisecond-this.getMilliseconds());}
|
||||
if(x.second!=-1&&Date.validateSecond(x.second)){this.addSeconds(x.second-this.getSeconds());}
|
||||
if(x.minute!=-1&&Date.validateMinute(x.minute)){this.addMinutes(x.minute-this.getMinutes());}
|
||||
if(x.hour!=-1&&Date.validateHour(x.hour)){this.addHours(x.hour-this.getHours());}
|
||||
if(x.month!==-1&&Date.validateMonth(x.month)){this.addMonths(x.month-this.getMonth());}
|
||||
if(x.year!=-1&&Date.validateYear(x.year)){this.addYears(x.year-this.getFullYear());}
|
||||
if(x.day!=-1&&Date.validateDay(x.day,this.getFullYear(),this.getMonth())){this.addDays(x.day-this.getDate());}
|
||||
if(x.timezone){this.setTimezone(x.timezone);}
|
||||
if(x.timezoneOffset){this.setTimezoneOffset(x.timezoneOffset);}
|
||||
return this;};Date.prototype.clearTime=function(){this.setHours(0);this.setMinutes(0);this.setSeconds(0);this.setMilliseconds(0);return this;};Date.prototype.isLeapYear=function(){var y=this.getFullYear();return(((y%4===0)&&(y%100!==0))||(y%400===0));};Date.prototype.isWeekday=function(){return!(this.is().sat()||this.is().sun());};Date.prototype.getDaysInMonth=function(){return Date.getDaysInMonth(this.getFullYear(),this.getMonth());};Date.prototype.moveToFirstDayOfMonth=function(){return this.set({day:1});};Date.prototype.moveToLastDayOfMonth=function(){return this.set({day:this.getDaysInMonth()});};Date.prototype.moveToDayOfWeek=function(day,orient){var diff=(day-this.getDay()+7*(orient||+1))%7;return this.addDays((diff===0)?diff+=7*(orient||+1):diff);};Date.prototype.moveToMonth=function(month,orient){var diff=(month-this.getMonth()+12*(orient||+1))%12;return this.addMonths((diff===0)?diff+=12*(orient||+1):diff);};Date.prototype.getDayOfYear=function(){return Math.floor((this-new Date(this.getFullYear(),0,1))/86400000);};Date.prototype.getWeekOfYear=function(firstDayOfWeek){var y=this.getFullYear(),m=this.getMonth(),d=this.getDate();var dow=firstDayOfWeek||Date.CultureInfo.firstDayOfWeek;var offset=7+1-new Date(y,0,1).getDay();if(offset==8){offset=1;}
|
||||
var daynum=((Date.UTC(y,m,d,0,0,0)-Date.UTC(y,0,1,0,0,0))/86400000)+1;var w=Math.floor((daynum-offset+7)/7);if(w===dow){y--;var prevOffset=7+1-new Date(y,0,1).getDay();if(prevOffset==2||prevOffset==8){w=53;}else{w=52;}}
|
||||
return w;};Date.prototype.isDST=function(){console.log('isDST');return this.toString().match(/(E|C|M|P)(S|D)T/)[2]=="D";};Date.prototype.getTimezone=function(){return Date.getTimezoneAbbreviation(this.getUTCOffset,this.isDST());};Date.prototype.setTimezoneOffset=function(s){var here=this.getTimezoneOffset(),there=Number(s)*-6/10;this.addMinutes(there-here);return this;};Date.prototype.setTimezone=function(s){return this.setTimezoneOffset(Date.getTimezoneOffset(s));};Date.prototype.getUTCOffset=function(){var n=this.getTimezoneOffset()*-10/6,r;if(n<0){r=(n-10000).toString();return r[0]+r.substr(2);}else{r=(n+10000).toString();return"+"+r.substr(1);}};Date.prototype.getDayName=function(abbrev){return abbrev?Date.CultureInfo.abbreviatedDayNames[this.getDay()]:Date.CultureInfo.dayNames[this.getDay()];};Date.prototype.getMonthName=function(abbrev){return abbrev?Date.CultureInfo.abbreviatedMonthNames[this.getMonth()]:Date.CultureInfo.monthNames[this.getMonth()];};Date.prototype._toString=Date.prototype.toString;Date.prototype.toString=function(format){var self=this;var p=function p(s){return(s.toString().length==1)?"0"+s:s;};return format?format.replace(/dd?d?d?|MM?M?M?|yy?y?y?|hh?|HH?|mm?|ss?|tt?|zz?z?/g,function(format){switch(format){case"hh":return p(self.getHours()<13?self.getHours():(self.getHours()-12));case"h":return self.getHours()<13?self.getHours():(self.getHours()-12);case"HH":return p(self.getHours());case"H":return self.getHours();case"mm":return p(self.getMinutes());case"m":return self.getMinutes();case"ss":return p(self.getSeconds());case"s":return self.getSeconds();case"yyyy":return self.getFullYear();case"yy":return self.getFullYear().toString().substring(2,4);case"dddd":return self.getDayName();case"ddd":return self.getDayName(true);case"dd":return p(self.getDate());case"d":return self.getDate().toString();case"MMMM":return self.getMonthName();case"MMM":return self.getMonthName(true);case"MM":return p((self.getMonth()+1));case"M":return self.getMonth()+1;case"t":return self.getHours()<12?Date.CultureInfo.amDesignator.substring(0,1):Date.CultureInfo.pmDesignator.substring(0,1);case"tt":return self.getHours()<12?Date.CultureInfo.amDesignator:Date.CultureInfo.pmDesignator;case"zzz":case"zz":case"z":return"";}}):this._toString();};
|
||||
Date.now=function(){return new Date();};Date.today=function(){return Date.now().clearTime();};Date.prototype._orient=+1;Date.prototype.next=function(){this._orient=+1;return this;};Date.prototype.last=Date.prototype.prev=Date.prototype.previous=function(){this._orient=-1;return this;};Date.prototype._is=false;Date.prototype.is=function(){this._is=true;return this;};Number.prototype._dateElement="day";Number.prototype.fromNow=function(){var c={};c[this._dateElement]=this;return Date.now().add(c);};Number.prototype.ago=function(){var c={};c[this._dateElement]=this*-1;return Date.now().add(c);};(function(){var $D=Date.prototype,$N=Number.prototype;var dx=("sunday monday tuesday wednesday thursday friday saturday").split(/\s/),mx=("january february march april may june july august september october november december").split(/\s/),px=("Millisecond Second Minute Hour Day Week Month Year").split(/\s/),de;var df=function(n){return function(){if(this._is){this._is=false;return this.getDay()==n;}
|
||||
return this.moveToDayOfWeek(n,this._orient);};};for(var i=0;i<dx.length;i++){$D[dx[i]]=$D[dx[i].substring(0,3)]=df(i);}
|
||||
var mf=function(n){return function(){if(this._is){this._is=false;return this.getMonth()===n;}
|
||||
return this.moveToMonth(n,this._orient);};};for(var j=0;j<mx.length;j++){$D[mx[j]]=$D[mx[j].substring(0,3)]=mf(j);}
|
||||
var ef=function(j){return function(){if(j.substring(j.length-1)!="s"){j+="s";}
|
||||
return this["add"+j](this._orient);};};var nf=function(n){return function(){this._dateElement=n;return this;};};for(var k=0;k<px.length;k++){de=px[k].toLowerCase();$D[de]=$D[de+"s"]=ef(px[k]);$N[de]=$N[de+"s"]=nf(de);}}());Date.prototype.toJSONString=function(){return this.toString("yyyy-MM-ddThh:mm:ssZ");};Date.prototype.toShortDateString=function(){return this.toString(Date.CultureInfo.formatPatterns.shortDatePattern);};Date.prototype.toLongDateString=function(){return this.toString(Date.CultureInfo.formatPatterns.longDatePattern);};Date.prototype.toShortTimeString=function(){return this.toString(Date.CultureInfo.formatPatterns.shortTimePattern);};Date.prototype.toLongTimeString=function(){return this.toString(Date.CultureInfo.formatPatterns.longTimePattern);};Date.prototype.getOrdinal=function(){switch(this.getDate()){case 1:case 21:case 31:return"st";case 2:case 22:return"nd";case 3:case 23:return"rd";default:return"th";}};
|
||||
(function(){Date.Parsing={Exception:function(s){this.message="Parse error at '"+s.substring(0,10)+" ...'";}};var $P=Date.Parsing;var _=$P.Operators={rtoken:function(r){return function(s){var mx=s.match(r);if(mx){return([mx[0],s.substring(mx[0].length)]);}else{throw new $P.Exception(s);}};},token:function(s){return function(s){return _.rtoken(new RegExp("^\s*"+s+"\s*"))(s);};},stoken:function(s){return _.rtoken(new RegExp("^"+s));},until:function(p){return function(s){var qx=[],rx=null;while(s.length){try{rx=p.call(this,s);}catch(e){qx.push(rx[0]);s=rx[1];continue;}
|
||||
break;}
|
||||
return[qx,s];};},many:function(p){return function(s){var rx=[],r=null;while(s.length){try{r=p.call(this,s);}catch(e){return[rx,s];}
|
||||
rx.push(r[0]);s=r[1];}
|
||||
return[rx,s];};},optional:function(p){return function(s){var r=null;try{r=p.call(this,s);}catch(e){return[null,s];}
|
||||
return[r[0],r[1]];};},not:function(p){return function(s){try{p.call(this,s);}catch(e){return[null,s];}
|
||||
throw new $P.Exception(s);};},ignore:function(p){return p?function(s){var r=null;r=p.call(this,s);return[null,r[1]];}:null;},product:function(){var px=arguments[0],qx=Array.prototype.slice.call(arguments,1),rx=[];for(var i=0;i<px.length;i++){rx.push(_.each(px[i],qx));}
|
||||
return rx;},cache:function(rule){var cache={},r=null;return function(s){try{r=cache[s]=(cache[s]||rule.call(this,s));}catch(e){r=cache[s]=e;}
|
||||
if(r instanceof $P.Exception){throw r;}else{return r;}};},any:function(){var px=arguments;return function(s){var r=null;for(var i=0;i<px.length;i++){if(px[i]==null){continue;}
|
||||
try{r=(px[i].call(this,s));}catch(e){r=null;}
|
||||
if(r){return r;}}
|
||||
throw new $P.Exception(s);};},each:function(){var px=arguments;return function(s){var rx=[],r=null;for(var i=0;i<px.length;i++){if(px[i]==null){continue;}
|
||||
try{r=(px[i].call(this,s));}catch(e){throw new $P.Exception(s);}
|
||||
rx.push(r[0]);s=r[1];}
|
||||
return[rx,s];};},all:function(){var px=arguments,_=_;return _.each(_.optional(px));},sequence:function(px,d,c){d=d||_.rtoken(/^\s*/);c=c||null;if(px.length==1){return px[0];}
|
||||
return function(s){var r=null,q=null;var rx=[];for(var i=0;i<px.length;i++){try{r=px[i].call(this,s);}catch(e){break;}
|
||||
rx.push(r[0]);try{q=d.call(this,r[1]);}catch(ex){q=null;break;}
|
||||
s=q[1];}
|
||||
if(!r){throw new $P.Exception(s);}
|
||||
if(q){throw new $P.Exception(q[1]);}
|
||||
if(c){try{r=c.call(this,r[1]);}catch(ey){throw new $P.Exception(r[1]);}}
|
||||
return[rx,(r?r[1]:s)];};},between:function(d1,p,d2){d2=d2||d1;var _fn=_.each(_.ignore(d1),p,_.ignore(d2));return function(s){var rx=_fn.call(this,s);return[[rx[0][0],r[0][2]],rx[1]];};},list:function(p,d,c){d=d||_.rtoken(/^\s*/);c=c||null;return(p instanceof Array?_.each(_.product(p.slice(0,-1),_.ignore(d)),p.slice(-1),_.ignore(c)):_.each(_.many(_.each(p,_.ignore(d))),px,_.ignore(c)));},set:function(px,d,c){d=d||_.rtoken(/^\s*/);c=c||null;return function(s){var r=null,p=null,q=null,rx=null,best=[[],s],last=false;for(var i=0;i<px.length;i++){q=null;p=null;r=null;last=(px.length==1);try{r=px[i].call(this,s);}catch(e){continue;}
|
||||
rx=[[r[0]],r[1]];if(r[1].length>0&&!last){try{q=d.call(this,r[1]);}catch(ex){last=true;}}else{last=true;}
|
||||
if(!last&&q[1].length===0){last=true;}
|
||||
if(!last){var qx=[];for(var j=0;j<px.length;j++){if(i!=j){qx.push(px[j]);}}
|
||||
p=_.set(qx,d).call(this,q[1]);if(p[0].length>0){rx[0]=rx[0].concat(p[0]);rx[1]=p[1];}}
|
||||
if(rx[1].length<best[1].length){best=rx;}
|
||||
if(best[1].length===0){break;}}
|
||||
if(best[0].length===0){return best;}
|
||||
if(c){try{q=c.call(this,best[1]);}catch(ey){throw new $P.Exception(best[1]);}
|
||||
best[1]=q[1];}
|
||||
return best;};},forward:function(gr,fname){return function(s){return gr[fname].call(this,s);};},replace:function(rule,repl){return function(s){var r=rule.call(this,s);return[repl,r[1]];};},process:function(rule,fn){return function(s){var r=rule.call(this,s);return[fn.call(this,r[0]),r[1]];};},min:function(min,rule){return function(s){var rx=rule.call(this,s);if(rx[0].length<min){throw new $P.Exception(s);}
|
||||
return rx;};}};var _generator=function(op){return function(){var args=null,rx=[];if(arguments.length>1){args=Array.prototype.slice.call(arguments);}else if(arguments[0]instanceof Array){args=arguments[0];}
|
||||
if(args){for(var i=0,px=args.shift();i<px.length;i++){args.unshift(px[i]);rx.push(op.apply(null,args));args.shift();return rx;}}else{return op.apply(null,arguments);}};};var gx="optional not ignore cache".split(/\s/);for(var i=0;i<gx.length;i++){_[gx[i]]=_generator(_[gx[i]]);}
|
||||
var _vector=function(op){return function(){if(arguments[0]instanceof Array){return op.apply(null,arguments[0]);}else{return op.apply(null,arguments);}};};var vx="each any all".split(/\s/);for(var j=0;j<vx.length;j++){_[vx[j]]=_vector(_[vx[j]]);}}());(function(){var flattenAndCompact=function(ax){var rx=[];for(var i=0;i<ax.length;i++){if(ax[i]instanceof Array){rx=rx.concat(flattenAndCompact(ax[i]));}else{if(ax[i]){rx.push(ax[i]);}}}
|
||||
return rx;};Date.Grammar={};Date.Translator={hour:function(s){return function(){this.hour=Number(s);};},minute:function(s){return function(){this.minute=Number(s);};},second:function(s){return function(){this.second=Number(s);};},meridian:function(s){return function(){this.meridian=s.slice(0,1).toLowerCase();};},timezone:function(s){return function(){var n=s.replace(/[^\d\+\-]/g,"");if(n.length){this.timezoneOffset=Number(n);}else{this.timezone=s.toLowerCase();}};},day:function(x){var s=x[0];return function(){this.day=Number(s.match(/\d+/)[0]);};},month:function(s){return function(){this.month=((s.length==3)?Date.getMonthNumberFromName(s):(Number(s)-1));};},year:function(s){return function(){var n=Number(s);this.year=((s.length>2)?n:(n+(((n+2000)<Date.CultureInfo.twoDigitYearMax)?2000:1900)));};},rday:function(s){return function(){switch(s){case"yesterday":this.days=-1;break;case"tomorrow":this.days=1;break;case"today":this.days=0;break;case"now":this.days=0;this.now=true;break;}};},finishExact:function(x){x=(x instanceof Array)?x:[x];var now=new Date();this.year=now.getFullYear();this.month=now.getMonth();this.day=1;this.hour=0;this.minute=0;this.second=0;for(var i=0;i<x.length;i++){if(x[i]){x[i].call(this);}}
|
||||
this.hour=(this.meridian=="p"&&this.hour<13)?this.hour+12:this.hour;if(this.day>Date.getDaysInMonth(this.year,this.month)){throw new RangeError(this.day+" is not a valid value for days.");}
|
||||
var r=new Date(this.year,this.month,this.day,this.hour,this.minute,this.second);if(this.timezone){r.set({timezone:this.timezone});}else if(this.timezoneOffset){r.set({timezoneOffset:this.timezoneOffset});}
|
||||
return r;},finish:function(x){x=(x instanceof Array)?flattenAndCompact(x):[x];if(x.length===0){return null;}
|
||||
for(var i=0;i<x.length;i++){if(typeof x[i]=="function"){x[i].call(this);}}
|
||||
if(this.now){return new Date();}
|
||||
var today=Date.today();var method=null;var expression=!!(this.days!=null||this.orient||this.operator);if(expression){var gap,mod,orient;orient=((this.orient=="past"||this.operator=="subtract")?-1:1);if(this.weekday){this.unit="day";gap=(Date.getDayNumberFromName(this.weekday)-today.getDay());mod=7;this.days=gap?((gap+(orient*mod))%mod):(orient*mod);}
|
||||
if(this.month){this.unit="month";gap=(this.month-today.getMonth());mod=12;this.months=gap?((gap+(orient*mod))%mod):(orient*mod);this.month=null;}
|
||||
if(!this.unit){this.unit="day";}
|
||||
if(this[this.unit+"s"]==null||this.operator!=null){if(!this.value){this.value=1;}
|
||||
if(this.unit=="week"){this.unit="day";this.value=this.value*7;}
|
||||
this[this.unit+"s"]=this.value*orient;}
|
||||
return today.add(this);}else{if(this.meridian&&this.hour){this.hour=(this.hour<13&&this.meridian=="p")?this.hour+12:this.hour;}
|
||||
if(this.weekday&&!this.day){this.day=(today.addDays((Date.getDayNumberFromName(this.weekday)-today.getDay()))).getDate();}
|
||||
if(this.month&&!this.day){this.day=1;}
|
||||
return today.set(this);}}};var _=Date.Parsing.Operators,g=Date.Grammar,t=Date.Translator,_fn;g.datePartDelimiter=_.rtoken(/^([\s\-\.\,\/\x27]+)/);g.timePartDelimiter=_.stoken(":");g.whiteSpace=_.rtoken(/^\s*/);g.generalDelimiter=_.rtoken(/^(([\s\,]|at|on)+)/);var _C={};g.ctoken=function(keys){var fn=_C[keys];if(!fn){var c=Date.CultureInfo.regexPatterns;var kx=keys.split(/\s+/),px=[];for(var i=0;i<kx.length;i++){px.push(_.replace(_.rtoken(c[kx[i]]),kx[i]));}
|
||||
fn=_C[keys]=_.any.apply(null,px);}
|
||||
return fn;};g.ctoken2=function(key){return _.rtoken(Date.CultureInfo.regexPatterns[key]);};g.h=_.cache(_.process(_.rtoken(/^(0[0-9]|1[0-2]|[1-9])/),t.hour));g.hh=_.cache(_.process(_.rtoken(/^(0[0-9]|1[0-2])/),t.hour));g.H=_.cache(_.process(_.rtoken(/^([0-1][0-9]|2[0-3]|[0-9])/),t.hour));g.HH=_.cache(_.process(_.rtoken(/^([0-1][0-9]|2[0-3])/),t.hour));g.m=_.cache(_.process(_.rtoken(/^([0-5][0-9]|[0-9])/),t.minute));g.mm=_.cache(_.process(_.rtoken(/^[0-5][0-9]/),t.minute));g.s=_.cache(_.process(_.rtoken(/^([0-5][0-9]|[0-9])/),t.second));g.ss=_.cache(_.process(_.rtoken(/^[0-5][0-9]/),t.second));g.hms=_.cache(_.sequence([g.H,g.mm,g.ss],g.timePartDelimiter));g.t=_.cache(_.process(g.ctoken2("shortMeridian"),t.meridian));g.tt=_.cache(_.process(g.ctoken2("longMeridian"),t.meridian));g.z=_.cache(_.process(_.rtoken(/^(\+|\-)?\s*\d\d\d\d?/),t.timezone));g.zz=_.cache(_.process(_.rtoken(/^(\+|\-)\s*\d\d\d\d/),t.timezone));g.zzz=_.cache(_.process(g.ctoken2("timezone"),t.timezone));g.timeSuffix=_.each(_.ignore(g.whiteSpace),_.set([g.tt,g.zzz]));g.time=_.each(_.optional(_.ignore(_.stoken("T"))),g.hms,g.timeSuffix);g.d=_.cache(_.process(_.each(_.rtoken(/^([0-2]\d|3[0-1]|\d)/),_.optional(g.ctoken2("ordinalSuffix"))),t.day));g.dd=_.cache(_.process(_.each(_.rtoken(/^([0-2]\d|3[0-1])/),_.optional(g.ctoken2("ordinalSuffix"))),t.day));g.ddd=g.dddd=_.cache(_.process(g.ctoken("sun mon tue wed thu fri sat"),function(s){return function(){this.weekday=s;};}));g.M=_.cache(_.process(_.rtoken(/^(1[0-2]|0\d|\d)/),t.month));g.MM=_.cache(_.process(_.rtoken(/^(1[0-2]|0\d)/),t.month));g.MMM=g.MMMM=_.cache(_.process(g.ctoken("jan feb mar apr may jun jul aug sep oct nov dec"),t.month));g.y=_.cache(_.process(_.rtoken(/^(\d\d?)/),t.year));g.yy=_.cache(_.process(_.rtoken(/^(\d\d)/),t.year));g.yyy=_.cache(_.process(_.rtoken(/^(\d\d?\d?\d?)/),t.year));g.yyyy=_.cache(_.process(_.rtoken(/^(\d\d\d\d)/),t.year));_fn=function(){return _.each(_.any.apply(null,arguments),_.not(g.ctoken2("timeContext")));};g.day=_fn(g.d,g.dd);g.month=_fn(g.M,g.MMM);g.year=_fn(g.yyyy,g.yy);g.orientation=_.process(g.ctoken("past future"),function(s){return function(){this.orient=s;};});g.operator=_.process(g.ctoken("add subtract"),function(s){return function(){this.operator=s;};});g.rday=_.process(g.ctoken("yesterday tomorrow today now"),t.rday);g.unit=_.process(g.ctoken("minute hour day week month year"),function(s){return function(){this.unit=s;};});g.value=_.process(_.rtoken(/^\d\d?(st|nd|rd|th)?/),function(s){return function(){this.value=s.replace(/\D/g,"");};});g.expression=_.set([g.rday,g.operator,g.value,g.unit,g.orientation,g.ddd,g.MMM]);_fn=function(){return _.set(arguments,g.datePartDelimiter);};g.mdy=_fn(g.ddd,g.month,g.day,g.year);g.ymd=_fn(g.ddd,g.year,g.month,g.day);g.dmy=_fn(g.ddd,g.day,g.month,g.year);g.date=function(s){return((g[Date.CultureInfo.dateElementOrder]||g.mdy).call(this,s));};g.format=_.process(_.many(_.any(_.process(_.rtoken(/^(dd?d?d?|MM?M?M?|yy?y?y?|hh?|HH?|mm?|ss?|tt?|zz?z?)/),function(fmt){if(g[fmt]){return g[fmt];}else{throw Date.Parsing.Exception(fmt);}}),_.process(_.rtoken(/^[^dMyhHmstz]+/),function(s){return _.ignore(_.stoken(s));}))),function(rules){return _.process(_.each.apply(null,rules),t.finishExact);});var _F={};var _get=function(f){return _F[f]=(_F[f]||g.format(f)[0]);};g.formats=function(fx){if(fx instanceof Array){var rx=[];for(var i=0;i<fx.length;i++){rx.push(_get(fx[i]));}
|
||||
return _.any.apply(null,rx);}else{return _get(fx);}};g._formats=g.formats(["yyyy-MM-ddTHH:mm:ss","ddd, MMM dd, yyyy H:mm:ss tt","ddd MMM d yyyy HH:mm:ss zzz","d"]);g._start=_.process(_.set([g.date,g.time,g.expression],g.generalDelimiter,g.whiteSpace),t.finish);g.start=function(s){try{var r=g._formats.call({},s);if(r[1].length===0){return r;}}catch(e){}
|
||||
return g._start.call({},s);};}());Date._parse=Date.parse;Date.parse=function(s){var r=null;if(!s){return null;}
|
||||
try{r=Date.Grammar.start.call({},s);}catch(e){return null;}
|
||||
return((r[1].length===0)?r[0]:null);};Date.getParseFunction=function(fx){var fn=Date.Grammar.formats(fx);return function(s){var r=null;try{r=fn.call({},s);}catch(e){return null;}
|
||||
return((r[1].length===0)?r[0]:null);};};Date.parseExact=function(s,fx){return Date.getParseFunction(fx)(s);};
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* favcount.js v1.5.0
|
||||
* http://chrishunt.co/favcount
|
||||
* Dynamically updates the favicon with a number.
|
||||
*
|
||||
* Copyright 2013, Chris Hunt
|
||||
* Released under the MIT license
|
||||
*/
|
||||
|
||||
(function(){
|
||||
function Favcount(icon) {
|
||||
this.icon = icon;
|
||||
this.opacity = 0.4;
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.font = "Helvetica, Arial, sans-serif";
|
||||
}
|
||||
|
||||
Favcount.prototype.set = function(count) {
|
||||
var self = this,
|
||||
img = document.createElement('img');
|
||||
|
||||
if (self.canvas.getContext) {
|
||||
img.crossOrigin = "anonymous";
|
||||
|
||||
img.onload = function() {
|
||||
drawCanvas(self.canvas, self.opacity, self.font, img, normalize(count));
|
||||
};
|
||||
|
||||
img.src = this.icon;
|
||||
}
|
||||
};
|
||||
|
||||
function normalize(count) {
|
||||
count = Math.round(count);
|
||||
|
||||
if (isNaN(count) || count < 1) {
|
||||
return '';
|
||||
} else if (count < 10) {
|
||||
return ' ' + count;
|
||||
} else if (count > 99) {
|
||||
return '99';
|
||||
} else {
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
function drawCanvas(canvas, opacity, font, img, count) {
|
||||
var head = document.getElementsByTagName('head')[0],
|
||||
favicon = document.createElement('link'),
|
||||
multiplier, fontSize, context, xOffset, yOffset, border, shadow;
|
||||
|
||||
favicon.rel = 'icon';
|
||||
|
||||
// Scale canvas elements based on favicon size
|
||||
multiplier = img.width / 16;
|
||||
fontSize = multiplier * 11;
|
||||
xOffset = multiplier;
|
||||
yOffset = multiplier * 11;
|
||||
border = multiplier;
|
||||
shadow = multiplier * 2;
|
||||
|
||||
canvas.height = canvas.width = img.width;
|
||||
context = canvas.getContext('2d');
|
||||
context.font = 'bold ' + fontSize + 'px ' + font;
|
||||
|
||||
// Draw faded favicon background
|
||||
if (count) { context.globalAlpha = opacity; }
|
||||
context.drawImage(img, 0, 0);
|
||||
context.globalAlpha = 1.0;
|
||||
|
||||
// Draw white drop shadow
|
||||
context.shadowColor = '#FFF';
|
||||
context.shadowBlur = shadow;
|
||||
context.shadowOffsetX = 0;
|
||||
context.shadowOffsetY = 0;
|
||||
|
||||
// Draw white border
|
||||
context.fillStyle = '#FFF';
|
||||
context.fillText(count, xOffset, yOffset);
|
||||
context.fillText(count, xOffset + border, yOffset);
|
||||
context.fillText(count, xOffset, yOffset + border);
|
||||
context.fillText(count, xOffset + border, yOffset + border);
|
||||
|
||||
// Draw black count
|
||||
context.fillStyle = '#000';
|
||||
context.fillText(count,
|
||||
xOffset + (border / 2.0),
|
||||
yOffset + (border / 2.0)
|
||||
);
|
||||
|
||||
// Replace favicon with new favicon
|
||||
favicon.href = canvas.toDataURL('image/png');
|
||||
head.removeChild(document.querySelector('link[rel=icon]'));
|
||||
head.appendChild(favicon);
|
||||
}
|
||||
|
||||
this.Favcount = Favcount;
|
||||
}).call(this);
|
||||
|
||||
(function(){
|
||||
Favcount.VERSION = '1.5.0';
|
||||
}).call(this);
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,296 @@
|
|||
// keymaster.js
|
||||
// (c) 2011-2013 Thomas Fuchs
|
||||
// keymaster.js may be freely distributed under the MIT license.
|
||||
|
||||
;(function(global){
|
||||
var k,
|
||||
_handlers = {},
|
||||
_mods = { 16: false, 18: false, 17: false, 91: false },
|
||||
_scope = 'all',
|
||||
// modifier keys
|
||||
_MODIFIERS = {
|
||||
'⇧': 16, shift: 16,
|
||||
'⌥': 18, alt: 18, option: 18,
|
||||
'⌃': 17, ctrl: 17, control: 17,
|
||||
'⌘': 91, command: 91
|
||||
},
|
||||
// special keys
|
||||
_MAP = {
|
||||
backspace: 8, tab: 9, clear: 12,
|
||||
enter: 13, 'return': 13,
|
||||
esc: 27, escape: 27, space: 32,
|
||||
left: 37, up: 38,
|
||||
right: 39, down: 40,
|
||||
del: 46, 'delete': 46,
|
||||
home: 36, end: 35,
|
||||
pageup: 33, pagedown: 34,
|
||||
',': 188, '.': 190, '/': 191,
|
||||
'`': 192, '-': 189, '=': 187,
|
||||
';': 186, '\'': 222,
|
||||
'[': 219, ']': 221, '\\': 220
|
||||
},
|
||||
code = function(x){
|
||||
return _MAP[x] || x.toUpperCase().charCodeAt(0);
|
||||
},
|
||||
_downKeys = [];
|
||||
|
||||
for(k=1;k<20;k++) _MAP['f'+k] = 111+k;
|
||||
|
||||
// IE doesn't support Array#indexOf, so have a simple replacement
|
||||
function index(array, item){
|
||||
var i = array.length;
|
||||
while(i--) if(array[i]===item) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// for comparing mods before unassignment
|
||||
function compareArray(a1, a2) {
|
||||
if (a1.length != a2.length) return false;
|
||||
for (var i = 0; i < a1.length; i++) {
|
||||
if (a1[i] !== a2[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
var modifierMap = {
|
||||
16:'shiftKey',
|
||||
18:'altKey',
|
||||
17:'ctrlKey',
|
||||
91:'metaKey'
|
||||
};
|
||||
function updateModifierKey(event) {
|
||||
for(k in _mods) _mods[k] = event[modifierMap[k]];
|
||||
};
|
||||
|
||||
// handle keydown event
|
||||
function dispatch(event) {
|
||||
var key, handler, k, i, modifiersMatch, scope;
|
||||
key = event.keyCode;
|
||||
|
||||
if (index(_downKeys, key) == -1) {
|
||||
_downKeys.push(key);
|
||||
}
|
||||
|
||||
// if a modifier key, set the key.<modifierkeyname> property to true and return
|
||||
if(key == 93 || key == 224) key = 91; // right command on webkit, command on Gecko
|
||||
if(key in _mods) {
|
||||
_mods[key] = true;
|
||||
// 'assignKey' from inside this closure is exported to window.key
|
||||
for(k in _MODIFIERS) if(_MODIFIERS[k] == key) assignKey[k] = true;
|
||||
return;
|
||||
}
|
||||
updateModifierKey(event);
|
||||
|
||||
// see if we need to ignore the keypress (filter() can can be overridden)
|
||||
// by default ignore key presses if a select, textarea, or input is focused
|
||||
if(!assignKey.filter.call(this, event)) return;
|
||||
|
||||
// abort if no potentially matching shortcuts found
|
||||
if (!(key in _handlers)) return;
|
||||
|
||||
scope = getScope();
|
||||
|
||||
// for each potential shortcut
|
||||
for (i = 0; i < _handlers[key].length; i++) {
|
||||
handler = _handlers[key][i];
|
||||
|
||||
// see if it's in the current scope
|
||||
if(handler.scope == scope || handler.scope == 'all'){
|
||||
// check if modifiers match if any
|
||||
modifiersMatch = handler.mods.length > 0;
|
||||
for(k in _mods)
|
||||
if((!_mods[k] && index(handler.mods, +k) > -1) ||
|
||||
(_mods[k] && index(handler.mods, +k) == -1)) modifiersMatch = false;
|
||||
// call the handler and stop the event if neccessary
|
||||
if((handler.mods.length == 0 && !_mods[16] && !_mods[18] && !_mods[17] && !_mods[91]) || modifiersMatch){
|
||||
if(handler.method(event, handler)===false){
|
||||
if(event.preventDefault) event.preventDefault();
|
||||
else event.returnValue = false;
|
||||
if(event.stopPropagation) event.stopPropagation();
|
||||
if(event.cancelBubble) event.cancelBubble = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// unset modifier keys on keyup
|
||||
function clearModifier(event){
|
||||
var key = event.keyCode, k,
|
||||
i = index(_downKeys, key);
|
||||
|
||||
// remove key from _downKeys
|
||||
if (i >= 0) {
|
||||
_downKeys.splice(i, 1);
|
||||
}
|
||||
|
||||
if(key == 93 || key == 224) key = 91;
|
||||
if(key in _mods) {
|
||||
_mods[key] = false;
|
||||
for(k in _MODIFIERS) if(_MODIFIERS[k] == key) assignKey[k] = false;
|
||||
}
|
||||
};
|
||||
|
||||
function resetModifiers() {
|
||||
for(k in _mods) _mods[k] = false;
|
||||
for(k in _MODIFIERS) assignKey[k] = false;
|
||||
};
|
||||
|
||||
// parse and assign shortcut
|
||||
function assignKey(key, scope, method){
|
||||
var keys, mods;
|
||||
keys = getKeys(key);
|
||||
if (method === undefined) {
|
||||
method = scope;
|
||||
scope = 'all';
|
||||
}
|
||||
|
||||
// for each shortcut
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
// set modifier keys if any
|
||||
mods = [];
|
||||
key = keys[i].split('+');
|
||||
if (key.length > 1){
|
||||
mods = getMods(key);
|
||||
key = [key[key.length-1]];
|
||||
}
|
||||
// convert to keycode and...
|
||||
key = key[0]
|
||||
key = code(key);
|
||||
// ...store handler
|
||||
if (!(key in _handlers)) _handlers[key] = [];
|
||||
_handlers[key].push({ shortcut: keys[i], scope: scope, method: method, key: keys[i], mods: mods });
|
||||
}
|
||||
};
|
||||
|
||||
// unbind all handlers for given key in current scope
|
||||
function unbindKey(key, scope) {
|
||||
var multipleKeys, keys,
|
||||
mods = [],
|
||||
i, j, obj;
|
||||
|
||||
multipleKeys = getKeys(key);
|
||||
|
||||
for (j = 0; j < multipleKeys.length; j++) {
|
||||
keys = multipleKeys[j].split('+');
|
||||
|
||||
if (keys.length > 1) {
|
||||
mods = getMods(keys);
|
||||
key = keys[keys.length - 1];
|
||||
}
|
||||
|
||||
key = code(key);
|
||||
|
||||
if (scope === undefined) {
|
||||
scope = getScope();
|
||||
}
|
||||
if (!_handlers[key]) {
|
||||
return;
|
||||
}
|
||||
for (i = 0; i < _handlers[key].length; i++) {
|
||||
obj = _handlers[key][i];
|
||||
// only clear handlers if correct scope and mods match
|
||||
if (obj.scope === scope && compareArray(obj.mods, mods)) {
|
||||
_handlers[key][i] = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Returns true if the key with code 'keyCode' is currently down
|
||||
// Converts strings into key codes.
|
||||
function isPressed(keyCode) {
|
||||
if (typeof(keyCode)=='string') {
|
||||
keyCode = code(keyCode);
|
||||
}
|
||||
return index(_downKeys, keyCode) != -1;
|
||||
}
|
||||
|
||||
function getPressedKeyCodes() {
|
||||
return _downKeys.slice(0);
|
||||
}
|
||||
|
||||
function filter(event){
|
||||
var tagName = (event.target || event.srcElement).tagName;
|
||||
// ignore keypressed in any elements that support keyboard data input
|
||||
return !(tagName == 'INPUT' || tagName == 'SELECT' || tagName == 'TEXTAREA');
|
||||
}
|
||||
|
||||
// initialize key.<modifier> to false
|
||||
for(k in _MODIFIERS) assignKey[k] = false;
|
||||
|
||||
// set current scope (default 'all')
|
||||
function setScope(scope){ _scope = scope || 'all' };
|
||||
function getScope(){ return _scope || 'all' };
|
||||
|
||||
// delete all handlers for a given scope
|
||||
function deleteScope(scope){
|
||||
var key, handlers, i;
|
||||
|
||||
for (key in _handlers) {
|
||||
handlers = _handlers[key];
|
||||
for (i = 0; i < handlers.length; ) {
|
||||
if (handlers[i].scope === scope) handlers.splice(i, 1);
|
||||
else i++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// abstract key logic for assign and unassign
|
||||
function getKeys(key) {
|
||||
var keys;
|
||||
key = key.replace(/\s/g, '');
|
||||
keys = key.split(',');
|
||||
if ((keys[keys.length - 1]) == '') {
|
||||
keys[keys.length - 2] += ',';
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// abstract mods logic for assign and unassign
|
||||
function getMods(key) {
|
||||
var mods = key.slice(0, key.length - 1);
|
||||
for (var mi = 0; mi < mods.length; mi++)
|
||||
mods[mi] = _MODIFIERS[mods[mi]];
|
||||
return mods;
|
||||
}
|
||||
|
||||
// cross-browser events
|
||||
function addEvent(object, event, method) {
|
||||
if (object.addEventListener)
|
||||
object.addEventListener(event, method, false);
|
||||
else if(object.attachEvent)
|
||||
object.attachEvent('on'+event, function(){ method(window.event) });
|
||||
};
|
||||
|
||||
// set the handlers globally on document
|
||||
addEvent(document, 'keydown', function(event) { dispatch(event) }); // Passing _scope to a callback to ensure it remains the same by execution. Fixes #48
|
||||
addEvent(document, 'keyup', clearModifier);
|
||||
|
||||
// reset modifiers to false whenever the window is (re)focused.
|
||||
addEvent(window, 'focus', resetModifiers);
|
||||
|
||||
// store previously defined key
|
||||
var previousKey = global.key;
|
||||
|
||||
// restore previously defined key and return reference to our key object
|
||||
function noConflict() {
|
||||
var k = global.key;
|
||||
global.key = previousKey;
|
||||
return k;
|
||||
}
|
||||
|
||||
// set window.key and window.key.set/get/deleteScope, and the default filter
|
||||
global.key = assignKey;
|
||||
global.key.setScope = setScope;
|
||||
global.key.getScope = getScope;
|
||||
global.key.deleteScope = deleteScope;
|
||||
global.key.filter = filter;
|
||||
global.key.isPressed = isPressed;
|
||||
global.key.getPressedKeyCodes = getPressedKeyCodes;
|
||||
global.key.noConflict = noConflict;
|
||||
global.key.unbind = unbindKey;
|
||||
|
||||
if(typeof module !== 'undefined') module.exports = key;
|
||||
|
||||
})(this);
|
|
@ -0,0 +1,357 @@
|
|||
/* Modernizr 2.7.1 (Custom Build) | MIT & BSD
|
||||
* Build: http://modernizr.com/download/#-shiv-cssclasses
|
||||
*/
|
||||
;
|
||||
|
||||
|
||||
|
||||
window.Modernizr = (function( window, document, undefined ) {
|
||||
|
||||
var version = '2.7.1',
|
||||
|
||||
Modernizr = {},
|
||||
|
||||
enableClasses = true,
|
||||
|
||||
docElement = document.documentElement,
|
||||
|
||||
mod = 'modernizr',
|
||||
modElem = document.createElement(mod),
|
||||
mStyle = modElem.style,
|
||||
|
||||
inputElem ,
|
||||
|
||||
|
||||
toString = {}.toString, tests = {},
|
||||
inputs = {},
|
||||
attrs = {},
|
||||
|
||||
classes = [],
|
||||
|
||||
slice = classes.slice,
|
||||
|
||||
featureName,
|
||||
|
||||
|
||||
|
||||
_hasOwnProperty = ({}).hasOwnProperty, hasOwnProp;
|
||||
|
||||
if ( !is(_hasOwnProperty, 'undefined') && !is(_hasOwnProperty.call, 'undefined') ) {
|
||||
hasOwnProp = function (object, property) {
|
||||
return _hasOwnProperty.call(object, property);
|
||||
};
|
||||
}
|
||||
else {
|
||||
hasOwnProp = function (object, property) {
|
||||
return ((property in object) && is(object.constructor.prototype[property], 'undefined'));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (!Function.prototype.bind) {
|
||||
Function.prototype.bind = function bind(that) {
|
||||
|
||||
var target = this;
|
||||
|
||||
if (typeof target != "function") {
|
||||
throw new TypeError();
|
||||
}
|
||||
|
||||
var args = slice.call(arguments, 1),
|
||||
bound = function () {
|
||||
|
||||
if (this instanceof bound) {
|
||||
|
||||
var F = function(){};
|
||||
F.prototype = target.prototype;
|
||||
var self = new F();
|
||||
|
||||
var result = target.apply(
|
||||
self,
|
||||
args.concat(slice.call(arguments))
|
||||
);
|
||||
if (Object(result) === result) {
|
||||
return result;
|
||||
}
|
||||
return self;
|
||||
|
||||
} else {
|
||||
|
||||
return target.apply(
|
||||
that,
|
||||
args.concat(slice.call(arguments))
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
return bound;
|
||||
};
|
||||
}
|
||||
|
||||
function setCss( str ) {
|
||||
mStyle.cssText = str;
|
||||
}
|
||||
|
||||
function setCssAll( str1, str2 ) {
|
||||
return setCss(prefixes.join(str1 + ';') + ( str2 || '' ));
|
||||
}
|
||||
|
||||
function is( obj, type ) {
|
||||
return typeof obj === type;
|
||||
}
|
||||
|
||||
function contains( str, substr ) {
|
||||
return !!~('' + str).indexOf(substr);
|
||||
}
|
||||
|
||||
|
||||
function testDOMProps( props, obj, elem ) {
|
||||
for ( var i in props ) {
|
||||
var item = obj[props[i]];
|
||||
if ( item !== undefined) {
|
||||
|
||||
if (elem === false) return props[i];
|
||||
|
||||
if (is(item, 'function')){
|
||||
return item.bind(elem || obj);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
for ( var feature in tests ) {
|
||||
if ( hasOwnProp(tests, feature) ) {
|
||||
featureName = feature.toLowerCase();
|
||||
Modernizr[featureName] = tests[feature]();
|
||||
|
||||
classes.push((Modernizr[featureName] ? '' : 'no-') + featureName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Modernizr.addTest = function ( feature, test ) {
|
||||
if ( typeof feature == 'object' ) {
|
||||
for ( var key in feature ) {
|
||||
if ( hasOwnProp( feature, key ) ) {
|
||||
Modernizr.addTest( key, feature[ key ] );
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
feature = feature.toLowerCase();
|
||||
|
||||
if ( Modernizr[feature] !== undefined ) {
|
||||
return Modernizr;
|
||||
}
|
||||
|
||||
test = typeof test == 'function' ? test() : test;
|
||||
|
||||
if (typeof enableClasses !== "undefined" && enableClasses) {
|
||||
docElement.className += ' ' + (test ? '' : 'no-') + feature;
|
||||
}
|
||||
Modernizr[feature] = test;
|
||||
|
||||
}
|
||||
|
||||
return Modernizr;
|
||||
};
|
||||
|
||||
|
||||
setCss('');
|
||||
modElem = inputElem = null;
|
||||
|
||||
;(function(window, document) {
|
||||
var version = '3.7.0';
|
||||
|
||||
var options = window.html5 || {};
|
||||
|
||||
var reSkip = /^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i;
|
||||
|
||||
var saveClones = /^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i;
|
||||
|
||||
var supportsHtml5Styles;
|
||||
|
||||
var expando = '_html5shiv';
|
||||
|
||||
var expanID = 0;
|
||||
|
||||
var expandoData = {};
|
||||
|
||||
var supportsUnknownElements;
|
||||
|
||||
(function() {
|
||||
try {
|
||||
var a = document.createElement('a');
|
||||
a.innerHTML = '<xyz></xyz>';
|
||||
supportsHtml5Styles = ('hidden' in a);
|
||||
|
||||
supportsUnknownElements = a.childNodes.length == 1 || (function() {
|
||||
(document.createElement)('a');
|
||||
var frag = document.createDocumentFragment();
|
||||
return (
|
||||
typeof frag.cloneNode == 'undefined' ||
|
||||
typeof frag.createDocumentFragment == 'undefined' ||
|
||||
typeof frag.createElement == 'undefined'
|
||||
);
|
||||
}());
|
||||
} catch(e) {
|
||||
supportsHtml5Styles = true;
|
||||
supportsUnknownElements = true;
|
||||
}
|
||||
|
||||
}());
|
||||
|
||||
function addStyleSheet(ownerDocument, cssText) {
|
||||
var p = ownerDocument.createElement('p'),
|
||||
parent = ownerDocument.getElementsByTagName('head')[0] || ownerDocument.documentElement;
|
||||
|
||||
p.innerHTML = 'x<style>' + cssText + '</style>';
|
||||
return parent.insertBefore(p.lastChild, parent.firstChild);
|
||||
}
|
||||
|
||||
function getElements() {
|
||||
var elements = html5.elements;
|
||||
return typeof elements == 'string' ? elements.split(' ') : elements;
|
||||
}
|
||||
|
||||
function getExpandoData(ownerDocument) {
|
||||
var data = expandoData[ownerDocument[expando]];
|
||||
if (!data) {
|
||||
data = {};
|
||||
expanID++;
|
||||
ownerDocument[expando] = expanID;
|
||||
expandoData[expanID] = data;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function createElement(nodeName, ownerDocument, data){
|
||||
if (!ownerDocument) {
|
||||
ownerDocument = document;
|
||||
}
|
||||
if(supportsUnknownElements){
|
||||
return ownerDocument.createElement(nodeName);
|
||||
}
|
||||
if (!data) {
|
||||
data = getExpandoData(ownerDocument);
|
||||
}
|
||||
var node;
|
||||
|
||||
if (data.cache[nodeName]) {
|
||||
node = data.cache[nodeName].cloneNode();
|
||||
} else if (saveClones.test(nodeName)) {
|
||||
node = (data.cache[nodeName] = data.createElem(nodeName)).cloneNode();
|
||||
} else {
|
||||
node = data.createElem(nodeName);
|
||||
}
|
||||
|
||||
return node.canHaveChildren && !reSkip.test(nodeName) && !node.tagUrn ? data.frag.appendChild(node) : node;
|
||||
}
|
||||
|
||||
function createDocumentFragment(ownerDocument, data){
|
||||
if (!ownerDocument) {
|
||||
ownerDocument = document;
|
||||
}
|
||||
if(supportsUnknownElements){
|
||||
return ownerDocument.createDocumentFragment();
|
||||
}
|
||||
data = data || getExpandoData(ownerDocument);
|
||||
var clone = data.frag.cloneNode(),
|
||||
i = 0,
|
||||
elems = getElements(),
|
||||
l = elems.length;
|
||||
for(;i<l;i++){
|
||||
clone.createElement(elems[i]);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
function shivMethods(ownerDocument, data) {
|
||||
if (!data.cache) {
|
||||
data.cache = {};
|
||||
data.createElem = ownerDocument.createElement;
|
||||
data.createFrag = ownerDocument.createDocumentFragment;
|
||||
data.frag = data.createFrag();
|
||||
}
|
||||
|
||||
|
||||
ownerDocument.createElement = function(nodeName) {
|
||||
if (!html5.shivMethods) {
|
||||
return data.createElem(nodeName);
|
||||
}
|
||||
return createElement(nodeName, ownerDocument, data);
|
||||
};
|
||||
|
||||
ownerDocument.createDocumentFragment = Function('h,f', 'return function(){' +
|
||||
'var n=f.cloneNode(),c=n.createElement;' +
|
||||
'h.shivMethods&&(' +
|
||||
getElements().join().replace(/[\w\-]+/g, function(nodeName) {
|
||||
data.createElem(nodeName);
|
||||
data.frag.createElement(nodeName);
|
||||
return 'c("' + nodeName + '")';
|
||||
}) +
|
||||
');return n}'
|
||||
)(html5, data.frag);
|
||||
}
|
||||
|
||||
function shivDocument(ownerDocument) {
|
||||
if (!ownerDocument) {
|
||||
ownerDocument = document;
|
||||
}
|
||||
var data = getExpandoData(ownerDocument);
|
||||
|
||||
if (html5.shivCSS && !supportsHtml5Styles && !data.hasCSS) {
|
||||
data.hasCSS = !!addStyleSheet(ownerDocument,
|
||||
'article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}' +
|
||||
'mark{background:#FF0;color:#000}' +
|
||||
'template{display:none}'
|
||||
);
|
||||
}
|
||||
if (!supportsUnknownElements) {
|
||||
shivMethods(ownerDocument, data);
|
||||
}
|
||||
return ownerDocument;
|
||||
}
|
||||
|
||||
var html5 = {
|
||||
|
||||
'elements': options.elements || 'abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output progress section summary template time video',
|
||||
|
||||
'version': version,
|
||||
|
||||
'shivCSS': (options.shivCSS !== false),
|
||||
|
||||
'supportsUnknownElements': supportsUnknownElements,
|
||||
|
||||
'shivMethods': (options.shivMethods !== false),
|
||||
|
||||
'type': 'default',
|
||||
|
||||
'shivDocument': shivDocument,
|
||||
|
||||
createElement: createElement,
|
||||
|
||||
createDocumentFragment: createDocumentFragment
|
||||
};
|
||||
|
||||
window.html5 = html5;
|
||||
|
||||
shivDocument(document);
|
||||
|
||||
}(this, document));
|
||||
|
||||
Modernizr._version = version;
|
||||
|
||||
docElement.className = docElement.className.replace(/(^|\s)no-js(\s|$)/, '$1$2') +
|
||||
|
||||
(enableClasses ? ' js ' + classes.join(' ') : '');
|
||||
|
||||
return Modernizr;
|
||||
|
||||
})(this, this.document);
|
||||
;
|
|
@ -0,0 +1,6 @@
|
|||
<html>
|
||||
<body>
|
||||
<h1>No Dice</h1>
|
||||
<p>The message you were looking for does not exist, or doesn't have content of this type.</p>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,65 @@
|
|||
<!DOCTYPE html>
|
||||
<html class="mailcatcher">
|
||||
<head>
|
||||
<title>MailCatcher</title>
|
||||
<link href="/favicon.ico" rel="icon" />
|
||||
<%= stylesheet_tag "mailcatcher" %>
|
||||
<%= javascript_tag "mailcatcher" %>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="http://mailcatcher.me" target="_blank">MailCatcher</a></h1>
|
||||
<nav class="app">
|
||||
<ul>
|
||||
<li class="search"><input type="search" name="search" placeholder="Search messages..." incremental="true" /></li>
|
||||
<li class="clear"><a href="#" title="Clear all messages">Clear</a></li>
|
||||
<% if MailCatcher.quittable? %>
|
||||
<li class="quit"><a href="#" title="Quit MailCatcher">Quit</a></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<nav id="messages">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>Subject</th>
|
||||
<th>Received</th>
|
||||
<% if MailCatcher.show_from_server? %>
|
||||
<th>From Server</th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</nav>
|
||||
<div id="resizer"><div class="ruler"></div></div>
|
||||
<article id="message">
|
||||
<header>
|
||||
<dl class="metadata">
|
||||
<dt class="created_at">Received</dt>
|
||||
<dd class="created_at"></dd>
|
||||
<dt class="from">From</dt>
|
||||
<dd class="from"></dd>
|
||||
<dt class="to">To</dt>
|
||||
<dd class="to"></dd>
|
||||
<dt class="subject">Subject</dt>
|
||||
<dd class="subject"></dd>
|
||||
<dt class="attachments">Attachments</dt>
|
||||
<dd class="attachments"></dd>
|
||||
</dl>
|
||||
<nav class="views">
|
||||
<ul>
|
||||
<li class="format tab html selected" data-message-format="html"><a href="#">HTML</a></li>
|
||||
<li class="format tab plain" data-message-format="plain"><a href="#">Plain Text</a></li>
|
||||
<li class="format tab source" data-message-format="source"><a href="#">Source</a></li>
|
||||
<li class="action download" data-message-format="html"><a href="#" class="button"><span>Download</span></a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<iframe class="body"></iframe>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
|
@ -1,41 +0,0 @@
|
|||
!!!
|
||||
%html
|
||||
%head
|
||||
%title MailCatcher
|
||||
%link{:rel => "stylesheet", :href => "/stylesheets/application.css"}
|
||||
%script{:src => "/javascripts/jquery.js"}
|
||||
%script{:src => "/javascripts/application.js"}
|
||||
:javascript
|
||||
$(MailCatcher.init);
|
||||
%body
|
||||
#mail
|
||||
%table
|
||||
%thead
|
||||
%th From
|
||||
%th To
|
||||
%th Subject
|
||||
%th Received
|
||||
%tbody
|
||||
#message
|
||||
.metadata
|
||||
.received
|
||||
%label Received
|
||||
%span
|
||||
.from
|
||||
%label From
|
||||
%span
|
||||
.to
|
||||
%label To
|
||||
%span
|
||||
.subject
|
||||
%label Subject
|
||||
%span
|
||||
.attachments
|
||||
%label Attachments
|
||||
%ul
|
||||
.formats
|
||||
%ul
|
||||
%li.selected{'data-message-format' => 'html'} HTML
|
||||
%li{'data-message-format' => 'plain'} Plain Text
|
||||
%li{'data-message-format' => 'source'} Source
|
||||
%iframe
|
Loading…
Reference in New Issue